aboutsummaryrefslogtreecommitdiff
path: root/vendor/libghostty-vt/src/terminal
diff options
context:
space:
mode:
authorOgulcan Celik <ogulcancelik@gmail.com>2026-06-01 23:05:23 +0300
committerOgulcan Celik <ogulcancelik@gmail.com>2026-06-01 23:05:51 +0300
commit5bd21b7ed468b5ff4d1a5b2ed7e211f401eb138a (patch)
tree672fa4625cca0ecb3e9376fc7f8e33d7b0e9deef /vendor/libghostty-vt/src/terminal
parent32abb3776668ee768200019c170956f67fe33a86 (diff)
chore: update libghostty vendor
refs #276
Diffstat (limited to 'vendor/libghostty-vt/src/terminal')
-rw-r--r--vendor/libghostty-vt/src/terminal/PageList.zig70
-rw-r--r--vendor/libghostty-vt/src/terminal/Screen.zig64
-rw-r--r--vendor/libghostty-vt/src/terminal/ScreenSet.zig43
-rw-r--r--vendor/libghostty-vt/src/terminal/Selection.zig32
-rw-r--r--vendor/libghostty-vt/src/terminal/SelectionGesture.zig2024
-rw-r--r--vendor/libghostty-vt/src/terminal/Terminal.zig37
-rw-r--r--vendor/libghostty-vt/src/terminal/c/grid_ref_tracked.zig225
-rw-r--r--vendor/libghostty-vt/src/terminal/c/main.zig31
-rw-r--r--vendor/libghostty-vt/src/terminal/c/render.zig329
-rw-r--r--vendor/libghostty-vt/src/terminal/c/selection.zig515
-rw-r--r--vendor/libghostty-vt/src/terminal/c/selection_gesture.zig1488
-rw-r--r--vendor/libghostty-vt/src/terminal/c/terminal.zig501
-rw-r--r--vendor/libghostty-vt/src/terminal/c/types.zig77
-rw-r--r--vendor/libghostty-vt/src/terminal/lib.zig1
-rw-r--r--vendor/libghostty-vt/src/terminal/main.zig1
-rw-r--r--vendor/libghostty-vt/src/terminal/point.zig10
-rw-r--r--vendor/libghostty-vt/src/terminal/selection_codepoints.zig31
17 files changed, 5405 insertions, 74 deletions
diff --git a/vendor/libghostty-vt/src/terminal/PageList.zig b/vendor/libghostty-vt/src/terminal/PageList.zig
index 17eea73d..8e5cd193 100644
--- a/vendor/libghostty-vt/src/terminal/PageList.zig
+++ b/vendor/libghostty-vt/src/terminal/PageList.zig
@@ -658,6 +658,11 @@ 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;
+
// We need enough pages/nodes to keep our active area. This should
// never fail since we by definition have allocated a page already
// that fits our size but I'm not confident to make that assertion.
@@ -935,6 +940,10 @@ pub const Resize = struct {
pub const Cursor = struct {
x: size.CellCountInt,
y: size.CellCountInt,
+
+ /// When set, this pin preserves right-side blank cells up to the cursor
+ /// during reflow.
+ pin: ?*Pin = null,
};
};
@@ -1013,10 +1022,6 @@ fn resizeCols(
) Allocator.Error!void {
assert(cols != self.cols);
- // Update our cols. We have to do this early because grow() that we
- // may call below relies on this to calculate the proper page size.
- self.cols = cols;
-
// If we have a cursor position (x,y), then we try under any col resizing
// to keep the same number remaining active rows beneath it. This is a
// very special case if you can imagine clearing the screen (i.e.
@@ -1025,10 +1030,11 @@ fn resizeCols(
// pull down scrollback.
const preserved_cursor: ?struct {
tracked_pin: *Pin,
+ untrack: bool,
remaining_rows: usize,
wrapped_rows: usize,
} = if (cursor) |c| cursor: {
- const p = self.pin(.{ .active = .{
+ const p = if (c.pin) |cursor_pin| cursor_pin.* else self.pin(.{ .active = .{
.x = c.x,
.y = c.y,
} }) orelse break :cursor null;
@@ -1051,12 +1057,21 @@ fn resizeCols(
};
break :cursor .{
- .tracked_pin = try self.trackPin(p),
+ .tracked_pin = c.pin orelse try self.trackPin(p),
+ .untrack = c.pin == null,
.remaining_rows = self.rows - c.y - 1,
.wrapped_rows = wrapped,
};
} else null;
- defer if (preserved_cursor) |c| self.untrackPin(c.tracked_pin);
+ defer if (preserved_cursor) |c| {
+ if (c.untrack) self.untrackPin(c.tracked_pin);
+ };
+
+ // Update our cols. We have to do this early because grow() that we
+ // may call below relies on this to calculate the proper page size, but
+ // after preserved_cursor so that the cursor pin can resolve coordinates in
+ // the old active coordinate space.
+ self.cols = cols;
// Create the first node that contains our reflow.
const first_rewritten_node = node: {
@@ -1110,7 +1125,11 @@ fn resizeCols(
{
var reflow_cursor: ReflowCursor = .init(first_rewritten_node);
while (it.next()) |row| {
- try reflow_cursor.reflowRow(self, row);
+ try reflow_cursor.reflowRow(
+ self,
+ row,
+ if (preserved_cursor) |c| c.tracked_pin else null,
+ );
// Once we're done reflowing a page, destroy it immediately.
// This frees memory and makes it more likely in memory
@@ -1226,6 +1245,7 @@ const ReflowCursor = struct {
self: *ReflowCursor,
list: *PageList,
row: Pin,
+ cursor_pin: ?*Pin,
) Allocator.Error!void {
const src_page: *Page = &row.node.data;
const src_row = row.rowAndCell().row;
@@ -1253,6 +1273,8 @@ const ReflowCursor = struct {
if (&p.node.data != src_page or
p.y != src_y) continue;
+ if (cursor_pin != null and p == cursor_pin.?) continue;
+
// If this pin is in the blanks on the right and past the end
// of the dst col width then we move it to the end of the dst
// col width instead.
@@ -1268,6 +1290,14 @@ 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) {
+ cols_len = @max(cols_len, p.x + 1);
+ }
+ }
+
// Defer processing of blank rows so that blank rows
// at the end of the page list are never written.
if (cols_len == 0) {
@@ -13518,6 +13548,30 @@ test "PageList reset" {
}, s.getTopLeft(.active));
}
+test "PageList reset invalidates stale untracked refs even if node memory is reused" {
+ const testing = std.testing;
+ const alloc = testing.allocator;
+
+ 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();
+
+ // 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);
+
+ const new_serial = s.pages.first.?.serial;
+ try testing.expect(new_serial >= s.page_serial_min);
+ try testing.expect(new_serial < s.page_serial);
+}
+
test "PageList reset across two pages" {
const testing = std.testing;
const alloc = testing.allocator;
diff --git a/vendor/libghostty-vt/src/terminal/Screen.zig b/vendor/libghostty-vt/src/terminal/Screen.zig
index 39fdd610..becda78b 100644
--- a/vendor/libghostty-vt/src/terminal/Screen.zig
+++ b/vendor/libghostty-vt/src/terminal/Screen.zig
@@ -13,6 +13,7 @@ const tripwire = @import("../tripwire.zig");
const unicode = @import("../unicode/main.zig");
const Selection = @import("Selection.zig");
const PageList = @import("PageList.zig");
+const selection_codepoints = @import("selection_codepoints.zig");
const StringMap = @import("StringMap.zig");
const ScreenFormatter = @import("formatter.zig").ScreenFormatter;
const osc = @import("osc.zig");
@@ -1766,7 +1767,11 @@ pub inline fn resize(
.rows = opts.rows,
.cols = opts.cols,
.reflow = opts.reflow,
- .cursor = .{ .x = self.cursor.x, .y = self.cursor.y },
+ .cursor = .{
+ .x = self.cursor.x,
+ .y = self.cursor.y,
+ .pin = self.cursor.page_pin,
+ },
});
// If we have no scrollback and we shrunk our rows, we must explicitly
@@ -2512,7 +2517,7 @@ pub const SelectLine = struct {
/// These are the codepoints to consider whitespace to trim
/// from the ends of the selection.
- whitespace: ?[]const u21 = &.{ 0, ' ', '\t' },
+ whitespace: ?[]const u21 = &selection_codepoints.default_line_whitespace,
/// If true, line selection will consider semantic prompt
/// state changing a boundary. State changing is ANY state
@@ -2648,10 +2653,10 @@ pub fn selectLine(self: *const Screen, opts: SelectLine) ?Selection {
if (!cell.hasText()) continue;
// Non-empty means we found it.
- const this_whitespace = std.mem.indexOfAny(
+ const this_whitespace = std.mem.indexOfScalar(
u21,
whitespace,
- &[_]u21{cell.content.codepoint},
+ cell.content.codepoint,
) != null;
if (this_whitespace) continue;
@@ -2670,10 +2675,10 @@ pub fn selectLine(self: *const Screen, opts: SelectLine) ?Selection {
if (!cell.hasText()) continue;
// Non-empty means we found it.
- const this_whitespace = std.mem.indexOfAny(
+ const this_whitespace = std.mem.indexOfScalar(
u21,
whitespace,
- &[_]u21{cell.content.codepoint},
+ cell.content.codepoint,
) != null;
if (this_whitespace) continue;
@@ -2794,10 +2799,10 @@ pub fn selectWord(
if (!start_cell.hasText()) return null;
// Determine if we are a boundary or not to determine what our boundary is.
- const expect_boundary = std.mem.indexOfAny(
+ const expect_boundary = std.mem.indexOfScalar(
u21,
boundary_codepoints,
- &[_]u21{start_cell.content.codepoint},
+ start_cell.content.codepoint,
) != null;
// Go forwards to find our end boundary
@@ -2812,10 +2817,10 @@ pub fn selectWord(
if (!cell.hasText()) break :end prev;
// If we do not match our expected set, we hit a boundary
- const this_boundary = std.mem.indexOfAny(
+ const this_boundary = std.mem.indexOfScalar(
u21,
boundary_codepoints,
- &[_]u21{cell.content.codepoint},
+ cell.content.codepoint,
) != null;
if (this_boundary != expect_boundary) break :end prev;
@@ -2849,10 +2854,10 @@ pub fn selectWord(
if (!cell.hasText()) break :start prev;
// If we do not match our expected set, we hit a boundary
- const this_boundary = std.mem.indexOfAny(
+ const this_boundary = std.mem.indexOfScalar(
u21,
boundary_codepoints,
- &[_]u21{cell.content.codepoint},
+ cell.content.codepoint,
) != null;
if (this_boundary != expect_boundary) break :start prev;
@@ -7277,6 +7282,41 @@ test "Screen: resize less cols to eliminate wide char with row space" {
}
}
+test "Screen: resize less cols reflows cursor after wrapped text" {
+ const testing = std.testing;
+ const alloc = testing.allocator;
+ var s = try Screen.init(alloc, .{ .cols = 50, .rows = 7, .max_scrollback = 0 });
+ defer s.deinit();
+
+ for (0..30) |_| try s.testWriteString("a");
+
+ try testing.expectEqual(@as(usize, 0), s.cursor.y);
+ try testing.expectEqual(@as(usize, 30), s.cursor.x);
+
+ try s.resize(.{ .cols = 25, .rows = 7 });
+
+ try testing.expectEqual(@as(usize, 1), s.cursor.y);
+ try testing.expectEqual(@as(usize, 5), s.cursor.x);
+}
+
+test "Screen: resize less cols reflows cursor after empty cells" {
+ const testing = std.testing;
+ const alloc = testing.allocator;
+ var s = try Screen.init(alloc, .{ .cols = 10, .rows = 3, .max_scrollback = 0 });
+ defer s.deinit();
+
+ try s.testWriteString("abc");
+ s.cursorRight(6);
+
+ try testing.expectEqual(@as(usize, 0), s.cursor.y);
+ try testing.expectEqual(@as(usize, 9), s.cursor.x);
+
+ try s.resize(.{ .cols = 5, .rows = 3 });
+
+ try testing.expectEqual(@as(usize, 1), s.cursor.y);
+ try testing.expectEqual(@as(usize, 4), s.cursor.x);
+}
+
test "Screen: resize more cols with wide spacer head" {
const testing = std.testing;
const alloc = testing.allocator;
diff --git a/vendor/libghostty-vt/src/terminal/ScreenSet.zig b/vendor/libghostty-vt/src/terminal/ScreenSet.zig
index d0856da5..4d3689e3 100644
--- a/vendor/libghostty-vt/src/terminal/ScreenSet.zig
+++ b/vendor/libghostty-vt/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/vendor/libghostty-vt/src/terminal/Selection.zig b/vendor/libghostty-vt/src/terminal/Selection.zig
index 8cb52816..5258210c 100644
--- a/vendor/libghostty-vt/src/terminal/Selection.zig
+++ b/vendor/libghostty-vt/src/terminal/Selection.zig
@@ -4,6 +4,7 @@ const Selection = @This();
const std = @import("std");
const assert = @import("../quirks.zig").inlineAssert;
const Allocator = std.mem.Allocator;
+const lib = @import("lib.zig");
const page = @import("page.zig");
const point = @import("point.zig");
const PageList = @import("PageList.zig");
@@ -195,7 +196,12 @@ pub fn bottomRight(self: Selection, s: *const Screen) Pin {
/// operations only flip the x or y axis, not both. Depending on the y axis
/// direction, this is either mirrored_forward or mirrored_reverse.
///
-pub const Order = enum { forward, reverse, mirrored_forward, mirrored_reverse };
+pub const Order = lib.Enum(lib.target, &.{
+ "forward",
+ "reverse",
+ "mirrored_forward",
+ "mirrored_reverse",
+});
pub fn order(self: Selection, s: *const Screen) Order {
const start_pt = s.pages.pointFromPin(.screen, self.start()).?.screen;
@@ -389,18 +395,18 @@ pub fn containedRowCached(
}
/// Possible adjustments to the selection.
-pub const Adjustment = enum {
- left,
- right,
- up,
- down,
- home,
- end,
- page_up,
- page_down,
- beginning_of_line,
- end_of_line,
-};
+pub const Adjustment = lib.Enum(lib.target, &.{
+ "left",
+ "right",
+ "up",
+ "down",
+ "home",
+ "end",
+ "page_up",
+ "page_down",
+ "beginning_of_line",
+ "end_of_line",
+});
/// Adjust the selection by some given adjustment. An adjustment allows
/// a selection to be expanded slightly left, right, up, down, etc.
diff --git a/vendor/libghostty-vt/src/terminal/SelectionGesture.zig b/vendor/libghostty-vt/src/terminal/SelectionGesture.zig
new file mode 100644
index 00000000..f0e11f63
--- /dev/null
+++ b/vendor/libghostty-vt/src/terminal/SelectionGesture.zig
@@ -0,0 +1,2024 @@
+/// SelectionGesture manages gesture-based terminal text selection for one
+/// pointer stream: press, drag, release, autoscroll, and pressure/deep-press
+/// selection.
+///
+/// This type owns only the state required to interpret a gesture. It does not
+/// modify the terminal selection directly, except for scrolling the viewport
+/// during `autoscrollTick`. The caller feeds platform events into this type and
+/// applies the returned `Selection` to the active screen when appropriate.
+///
+/// A typical single-click drag flow looks like this:
+///
+/// ```zig
+/// const selection = try gesture.press(terminal, .{ ... });
+/// try terminal.screens.active.select(selection);
+/// if (gesture.drag(terminal, .{ ... })) |selection| {
+/// try terminal.screens.active.select(selection);
+/// }
+/// gesture.release(terminal, .{ ... });
+/// ```
+///
+/// Double- and triple-click gestures use the same event flow. Repeated presses
+/// inside `Press.repeat_interval` and within `Press.max_distance` increment the
+/// internal click count up to three. `Press.behaviors` maps single-, double-,
+/// and triple-clicks to behavior. By default, a single press returns null to
+/// clear any existing selection, a double-click returns a word selection, and a
+/// triple-click returns a line selection. Drags use the behavior selected by the
+/// corresponding press. A new press that is too late, too far away, or on
+/// another active screen starts a new single-click gesture.
+///
+/// # Resetting and lifetime
+///
+/// `release` ends the active drag/autoscroll phase but intentionally preserves
+/// enough state for a subsequent press to become a double- or triple-click.
+/// Call `reset` when the gesture is cancelled rather than released normally, or
+/// when another subsystem takes ownership of pointer input. Examples include
+/// enabling mouse reporting for an application, losing pointer/button state,
+/// destroying the surface, switching to a mode that must not continue text
+/// selection, or otherwise abandoning the current click sequence. Call `deinit`
+/// once before discarding the gesture object so any tracked click pin is
+/// released.
+///
+/// # Terminal and screen changes
+///
+/// The initial press pin is tracked in the active screen's `PageList`, so normal
+/// terminal output and viewport scrolling can move rows without making the
+/// gesture immediately stale. Selection results are computed against the current
+/// terminal contents at the time of each call. For example, a double-click drag
+/// selects word boundaries from the screen as it exists during `drag`, not from a
+/// snapshot captured at `press`.
+///
+/// The tracked pin is tied to both a `ScreenSet.Key` and that screen's
+/// generation. If the active screen changes, or a screen is removed/recycled,
+/// `validatedLeftClickPin` returns null and drag-style operations stop producing
+/// selections. `autoscrollTick` treats this as cancellation and calls `reset` so
+/// callers can stop their timers. This avoids exposing pins from inactive or
+/// freed screens, but it does not make a historical snapshot of terminal data.
+///
+/// # Concurrency
+///
+/// SelectionGesture is not concurrency safe. It has mutable gesture state and
+/// mutates/tracks pins inside the terminal page list without taking locks. The
+/// caller must serialize all calls that touch the same gesture and terminal,
+/// typically by holding the same terminal/renderer mutex used for other screen
+/// mutations. Do not call `press`, `drag`, `release`, `reset`, `deinit`, or
+/// `autoscrollTick` concurrently with each other or with unrelated terminal
+/// mutations unless the caller provides that synchronization.
+const SelectionGesture = @This();
+
+const std = @import("std");
+const builtin = @import("builtin");
+const assert = std.debug.assert;
+const testing = std.testing;
+const Allocator = std.mem.Allocator;
+const lib = @import("lib.zig");
+const PageList = @import("PageList.zig");
+const Pin = PageList.Pin;
+const Screen = @import("Screen.zig");
+const ScreenSet = @import("ScreenSet.zig");
+const Selection = @import("Selection.zig");
+const Terminal = @import("Terminal.zig");
+const point = @import("point.zig");
+
+const freestanding_wasm = builtin.target.cpu.arch == .wasm32 and
+ builtin.target.os.tag == .freestanding;
+
+/// Monotonic timestamp type for click-repeat detection.
+///
+/// Freestanding wasm cannot reference std.time.Instant because Zig's stdlib
+/// Instant type depends on POSIX timespec for that target, so represent the C
+/// API nanosecond timestamp directly as a u64 there.
+pub const Time = if (freestanding_wasm) u64 else std.time.Instant;
+
+/// The tracked pin of the initial left click along with the screen
+/// that the pin is part of.
+left_click_pin: ?*Pin,
+left_click_screen: ScreenSet.Key,
+left_click_screen_generation: usize,
+
+/// The count of clicks to count double and triple clicks and so on.
+/// The left click time was the last time the left click was done, if the
+/// caller could provide one. If this is null then we only support single clicks.
+left_click_count: u3,
+left_click_time: ?Time,
+
+/// The selection behavior chosen for the active left-click gesture.
+left_click_behavior: Behavior,
+
+/// 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
+/// stable during scrolling relative to the surface.
+left_click_xpos: f64,
+left_click_ypos: f64,
+
+/// True once the active left-click gesture has moved away from the initially
+/// pressed cell. This is reset on every press that starts or continues a
+/// multi-click sequence, and is left available for callers to inspect while
+/// handling the corresponding release.
+left_click_dragged: bool,
+
+/// The current autoscroll state for the active left-click drag gesture.
+left_drag_autoscroll: Autoscroll,
+
+/// The direction that selection dragging should autoscroll the viewport.
+/// This is derived from the most recent drag position relative to the
+/// surface bounds and reset whenever there is no active drag gesture.
+///
+/// When autoscroll is non-none, the caller should setup a timer
+/// to periodically call autoscrollTick. The timer interval is up to the
+/// caller but reasonable defaults are approximately every 15 milliseconds.
+///
+/// This is used to implement selection above/below the viewport that
+/// wants to drag the viewport.
+pub const Autoscroll = lib.Enum(lib.target, &.{
+ "none",
+ "up",
+ "down",
+});
+
+/// The selection behavior for a click and subsequent drag.
+pub const Behavior = lib.Enum(lib.target, &.{
+ // Cell-granular drag selection. Press returns null to clear selection.
+ "cell",
+
+ // Word selection on press and word-granular drag selection.
+ "word",
+
+ // Line selection on press and line-granular drag selection.
+ "line",
+
+ // Semantic command output selection on press and drag.
+ "output",
+});
+
+/// Standard terminal selection behavior for single-, double-, and triple-clicks.
+///
+/// A single click uses cell behavior, which returns null on press so callers can
+/// clear any existing selection and then drag by cell. A double-click selects and
+/// drags by word. A triple-click selects and drags by line.
+pub const default_behaviors: [3]Behavior = .{ .cell, .word, .line };
+
+/// Distance from the top or bottom surface edge, in pixels, where dragging
+/// should request autoscroll. This preserves the historical 1px buffer used
+/// so fullscreen-edge drags can still trigger autoscroll.
+const autoscroll_buffer: f64 = 1;
+
+pub const init: SelectionGesture = .{
+ .left_click_pin = null,
+ .left_click_count = 0,
+ .left_click_time = null,
+ .left_click_behavior = .cell,
+ .left_click_screen = .primary,
+ .left_click_screen_generation = 0,
+ .left_click_xpos = 0,
+ .left_click_ypos = 0,
+ .left_click_dragged = false,
+ .left_drag_autoscroll = .none,
+};
+
+pub fn deinit(self: *SelectionGesture, t: *Terminal) void {
+ // Grab our pagelist that is associated with the pin. If it doesn't
+ // exist anymore then our tracked pin is already free.
+ const pin = self.left_click_pin orelse return;
+ if (t.screens.generation(self.left_click_screen) != self.left_click_screen_generation) return;
+ const screen = t.screens.get(self.left_click_screen) orelse return;
+ screen.pages.untrackPin(pin);
+}
+
+/// Reset any active gesture state and untrack the tracked click pin.
+///
+/// Use this for cancellation/abandonment, not for the ordinary left-button
+/// release path. `release` deliberately keeps the last press time/count so a
+/// following press can become a double- or triple-click; `reset` clears that
+/// sequence and makes the next press a fresh single click.
+///
+/// Examples of reset-worthy events are: mouse reporting taking over, pointer
+/// capture being lost, a surface/window being torn down, or another interaction
+/// mode deciding that text selection must stop immediately. If the active screen
+/// was already removed or recycled, this safely drops the stale reference without
+/// trying to untrack a pin from the wrong screen generation.
+pub fn reset(self: *SelectionGesture, t: *Terminal) void {
+ self.left_click_count = 0;
+ self.left_click_time = null;
+ self.left_click_behavior = .cell;
+ self.left_click_dragged = false;
+ self.left_drag_autoscroll = .none;
+ self.untrackPin(t);
+}
+
+/// Return the tracked left-click pin only if it still belongs to the current
+/// active screen instance.
+///
+/// This validates both the screen key and generation so a pin from a removed,
+/// recycled, or inactive screen is never exposed to callers. A null result means
+/// callers should treat the in-progress gesture as temporarily or permanently
+/// unable to produce a selection. For a normal drag this usually means "do
+/// nothing for this event"; for autoscroll it is treated as cancellation because
+/// a timer should not continue firing for a gesture that no longer has a valid
+/// anchor.
+pub fn validatedLeftClickPin(
+ self: *const SelectionGesture,
+ screens: *const ScreenSet,
+) ?*Pin {
+ const pin = self.left_click_pin orelse return null;
+ if (self.left_click_screen != screens.active_key) return null;
+ if (screens.generation(self.left_click_screen) != self.left_click_screen_generation) return null;
+ _ = screens.get(self.left_click_screen) orelse return null;
+ return pin;
+}
+
+pub const Press = struct {
+ /// The time when the press event occurred. Use a monotonic timer.
+ /// 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,
+
+ /// The cell where the click was.
+ ///
+ /// `press` stores a tracked copy of this pin. The caller does not need to
+ /// keep `p.pin` alive after the call returns, but the pin must belong to the
+ /// terminal's active screen when passed in.
+ pin: Pin,
+
+ /// The x/y value of the click relative to the surface with (0,0) being
+ /// top-left. This is used for distance detection for multi-clicks so
+ /// double/triple clicks too far away from each other will reset the click
+ /// count as well more accurate drag behaviors.
+ xpos: f64,
+ ypos: f64,
+
+ /// Maximum distance a click can be from the original click to register
+ /// as a repeat. If uncertain, set this to cell width.
+ max_distance: f64,
+
+ /// The maximum interval in nanoseconds that a press is considered
+ /// a repeat e.g. to record double/triple clicks.
+ repeat_interval: u64,
+
+ /// The codepoints that delimit words for double-click selection.
+ word_boundary_codepoints: []const u21,
+
+ /// Selection behaviors for single-, double-, and triple-clicks.
+ behaviors: *const [3]Behavior = &default_behaviors,
+};
+
+/// Record a press event and return the standard selection for this click.
+///
+/// If this press continues the existing click sequence, the click count is
+/// incremented up to three and the original anchor pin is kept. Otherwise, the
+/// previous gesture state is cleared and this press becomes the new anchor.
+/// The returned selection is untracked and represents the standard terminal
+/// click behavior for the resulting click count. The caller is responsible for
+/// applying it to the screen, usually with `Screen.select`, and for arranging
+/// any copy-on-select behavior.
+///
+/// Examples:
+///
+/// * first press: `left_click_count == 1`, defaults to cell behavior;
+/// * second nearby press within the repeat interval: `left_click_count == 2`,
+/// defaults to word behavior;
+/// * third nearby press within the repeat interval: `left_click_count == 3`,
+/// defaults to line behavior;
+/// * press after the interval, too far away, or after a screen generation
+/// change: starts over at `left_click_count == 1` and returns null.
+pub fn press(
+ self: *SelectionGesture,
+ t: *Terminal,
+ p: Press,
+) Allocator.Error!?Selection {
+ if (self.left_click_count > 0) {
+ if (self.pressRepeat(t, p)) {
+ // Successful repeat.
+ return self.pressSelection(t.screens.active, p);
+ } else |err| switch (err) {
+ error.PressRequiresReset => {},
+ }
+ }
+
+ // Initial click or the repeat failed for some reason such as
+ // the subsequent click being too far away.
+ try self.pressInitial(t, p);
+ return self.pressSelection(t.screens.active, p);
+}
+
+pub const Drag = struct {
+ /// The cell where the current drag position is. This is used
+ /// synchronously to calculate the selection and is not tracked.
+ pin: Pin,
+
+ /// The x/y value of the drag relative to the surface with (0,0) being
+ /// top-left.
+ xpos: f64,
+ ypos: f64,
+
+ /// True if the current drag should produce a rectangular selection.
+ rectangle: bool,
+
+ /// The codepoints that delimit words for double-click drag selection.
+ word_boundary_codepoints: []const u21,
+
+ /// Geometry required for selection threshold and autoscroll calculations.
+ geometry: Geometry,
+
+ /// Display geometry needed to translate surface-relative pointer positions
+ /// into selection behavior.
+ pub const Geometry = struct {
+ /// The number of columns in the rendered terminal grid.
+ columns: u32,
+
+ /// The width of one terminal cell in surface pixels.
+ cell_width: u32,
+
+ /// The left padding before the terminal grid begins, in surface pixels.
+ padding_left: u32,
+
+ /// The height of the rendered terminal surface in surface pixels.
+ screen_height: u32,
+ };
+};
+
+/// Record a drag event and return the current untracked drag selection.
+///
+/// The returned selection is untracked and represents the best selection for the
+/// terminal contents at the time of this call. The caller is responsible for
+/// applying it to the screen, usually with `Screen.select`, and for arranging any
+/// copy-on-select behavior. A null result means either there is no active
+/// selection gesture, the original press is no longer valid for the active
+/// screen, or the drag has not crossed the threshold required to select a cell.
+///
+/// This method also updates `left_click_dragged` and `left_drag_autoscroll`.
+/// If `left_drag_autoscroll` becomes `.up` or `.down`, the caller should start or
+/// keep a timer that calls `autoscrollTick` while the button remains pressed. If
+/// it becomes `.none`, the caller should stop that timer.
+///
+/// Normal terminal output and viewport movement between drag events are allowed:
+/// the tracked press pin follows the page list, and the drag pin is used only
+/// synchronously. Content-sensitive selections such as word and line selection
+/// are recalculated from the current active screen every time.
+pub fn drag(
+ self: *SelectionGesture,
+ t: *Terminal,
+ d: Drag,
+) ?Selection {
+ // If we aren't currently clicked then we don't do any dragging
+ // behavior.
+ if (self.left_click_count == 0) {
+ assert(self.left_drag_autoscroll == .none);
+ return null;
+ }
+
+ // Get our click pin. We get a validated pin because if our
+ // screen changed out from under us then we aren't actually
+ // clicking anymore.
+ const click_pin = self.validatedLeftClickPin(&t.screens) orelse return null;
+ if (!d.pin.eql(click_pin.*)) self.left_click_dragged = true;
+
+ // Determine if we should autoscroll. If our drag position is above
+ // the top, we go up. If its below the bottom we go down. Easy.
+ const max_y: f64 = @floatFromInt(d.geometry.screen_height);
+ self.left_drag_autoscroll = if (d.ypos <= autoscroll_buffer)
+ .up
+ else if (d.ypos > max_y - autoscroll_buffer)
+ .down
+ else
+ .none;
+
+ const selection = switch (self.left_click_behavior) {
+ .cell => dragSelection(
+ click_pin.*,
+ d.pin,
+ @intFromFloat(@max(0, self.left_click_xpos)),
+ @intFromFloat(@max(0, d.xpos)),
+ d.rectangle,
+ d.geometry,
+ ),
+
+ .word => dragSelectionWord(
+ t.screens.active,
+ click_pin.*,
+ d.pin,
+ d.word_boundary_codepoints,
+ ),
+
+ .line => dragSelectionLine(
+ t.screens.active,
+ click_pin.*,
+ d.pin,
+ ),
+
+ .output => dragSelectionOutput(
+ t.screens.active,
+ click_pin.*,
+ d.pin,
+ ),
+ };
+
+ // Same-cell cell selections can still become real selections when the drag
+ // crosses the within-cell threshold. Treat those as drags so callers don't
+ // also process click-only actions such as opening links.
+ if (self.left_click_behavior == .cell and selection != null) {
+ self.left_click_dragged = true;
+ }
+
+ return selection;
+}
+
+pub const AutoscrollTick = struct {
+ /// The viewport cell where the current drag position is. This is resolved
+ /// after the viewport is scrolled so the selection tracks the newly visible
+ /// row under the pointer.
+ viewport: point.Coordinate,
+
+ /// The x/y value of the drag relative to the surface with (0,0) being
+ /// top-left.
+ xpos: f64,
+ ypos: f64,
+
+ /// True if the current drag should produce a rectangular selection.
+ rectangle: bool,
+
+ /// The codepoints that delimit words for double-click drag selection.
+ word_boundary_codepoints: []const u21,
+
+ /// Geometry required for selection threshold and autoscroll calculations.
+ geometry: Drag.Geometry,
+};
+
+/// Record a selection autoscroll tick for the active left-click drag gesture.
+///
+/// This scrolls the viewport in the active autoscroll direction and then
+/// continues the drag at the provided viewport position. The viewport position
+/// is resolved to a pin after scrolling so the drag applies to the row now under
+/// the pointer.
+///
+/// This always scrolls the viewport by exactly one row in the current
+/// autoscroll direction. If you want to scroll by more, increase your
+/// tick rate.
+///
+/// If the original press pin no longer belongs to the active screen, this calls
+/// `reset` and returns null. That is a signal for the caller to stop its
+/// autoscroll timer and leave any existing terminal selection alone unless some
+/// other event says otherwise.
+pub fn autoscrollTick(
+ self: *SelectionGesture,
+ t: *Terminal,
+ tick: AutoscrollTick,
+) ?Selection {
+ if (self.left_click_count == 0) {
+ assert(self.left_drag_autoscroll == .none);
+ return null;
+ }
+
+ const delta: isize = switch (self.left_drag_autoscroll) {
+ .none => return null,
+ .up => -1,
+ .down => 1,
+ };
+
+ // If our click pin no longer belongs to the active screen, the gesture is
+ // no longer valid. Stop it so callers can stop their autoscroll timer
+ // without clearing the current selection as if this were a real drag.
+ _ = self.validatedLeftClickPin(&t.screens) orelse {
+ self.reset(t);
+ return null;
+ };
+
+ t.scrollViewport(.{ .delta = delta });
+
+ const pin = t.screens.active.pages.pin(.{ .viewport = tick.viewport }) orelse return null;
+ return self.drag(t, .{
+ .pin = pin,
+ .xpos = tick.xpos,
+ .ypos = tick.ypos,
+ .rectangle = tick.rectangle,
+ .word_boundary_codepoints = tick.word_boundary_codepoints,
+ .geometry = tick.geometry,
+ });
+}
+
+/// A pressure-based activation during an existing left-click gesture.
+///
+/// This is the terminal gesture model for platform features such as macOS
+/// force click / deep click on pressure-sensitive trackpads. It is not a
+/// distinct mouse button and it is not part of the normal single/double/triple
+/// click count sequence; it can only occur after a left press is already
+/// active.
+pub const DeepPress = struct {
+ /// The codepoints that delimit words for the word selection produced by
+ /// the deep press.
+ word_boundary_codepoints: []const u21,
+};
+
+/// Record a deep press event for the active left-click gesture.
+///
+/// A deep press is a force/pressure activation while the primary pointer is
+/// already down. Ghostty treats it like the platform text-selection affordance:
+/// select the word under the original press, then consume the gesture so
+/// further cursor movement while the button remains pressed does not drag or
+/// autoscroll the selection.
+///
+/// After a successful deep press, the click sequence is cleared and the tracked
+/// pin is untracked. The returned selection should be applied by the caller. A
+/// null result means there was no valid active left-click anchor, commonly
+/// because the screen changed or the gesture had already been cancelled.
+pub fn deepPress(
+ self: *SelectionGesture,
+ t: *Terminal,
+ p: DeepPress,
+) ?Selection {
+ const click_pin = self.validatedLeftClickPin(&t.screens) orelse return null;
+ const sel = t.screens.active.selectWord(
+ click_pin.*,
+ p.word_boundary_codepoints,
+ );
+
+ self.left_click_count = 0;
+ self.left_click_time = null;
+ self.left_click_behavior = .cell;
+ self.left_click_dragged = true;
+ self.left_drag_autoscroll = .none;
+ self.untrackPin(t);
+
+ return sel;
+}
+
+pub const Release = struct {
+ /// The cell where the release occurred, if the release position mapped to
+ /// a valid cell. This is used synchronously to update gesture state and is
+ /// not tracked.
+ pin: ?Pin,
+};
+
+/// Record a release event for the active left-click gesture.
+///
+/// This stops autoscroll and updates `left_click_dragged`, but it does not clear
+/// the click count or time. Keeping that state is what lets the next nearby press
+/// become a double- or triple-click. Call `reset` instead if the release should
+/// cancel the click sequence entirely.
+///
+/// Pass the release pin when the pointer position maps to a valid terminal cell.
+/// If it does not, pass null; the gesture then conservatively records that the
+/// pointer moved away from the original pressed cell. This is useful for callers
+/// that use `left_click_dragged` after release to decide whether a click should
+/// activate links or other hit targets.
+pub fn release(
+ self: *SelectionGesture,
+ t: *Terminal,
+ r: Release,
+) void {
+ if (self.left_click_count == 0) {
+ assert(self.left_drag_autoscroll == .none);
+ return;
+ }
+
+ if (r.pin) |release_pin| {
+ if (self.validatedLeftClickPin(&t.screens)) |click_pin| {
+ if (!release_pin.eql(click_pin.*)) self.left_click_dragged = true;
+ } else {
+ // If the original anchor is no longer valid, conservatively treat
+ // this as a drag/cancelled click so callers don't perform click-only
+ // actions on a different or recycled screen.
+ self.left_click_dragged = true;
+ }
+ } else {
+ self.left_click_dragged = true;
+ }
+ self.left_drag_autoscroll = .none;
+}
+
+fn pressInitial(
+ self: *SelectionGesture,
+ t: *Terminal,
+ p: Press,
+) Allocator.Error!void {
+ // Setup our pin first, reusing our existing pin if we can.
+ if (self.left_click_pin) |pin| {
+ if (comptime std.debug.runtime_safety) {
+ assert(self.left_click_screen == t.screens.active_key);
+ assert(self.left_click_screen_generation == t.screens.generation(t.screens.active_key));
+ }
+ pin.* = p.pin;
+ } else {
+ const screens: *const ScreenSet = &t.screens;
+ self.left_click_pin = try screens.active.pages.trackPin(p.pin);
+ errdefer comptime unreachable;
+ self.left_click_screen = screens.active_key;
+ self.left_click_screen_generation = screens.generation(screens.active_key);
+ }
+ errdefer comptime unreachable;
+ self.left_click_count = 1;
+ self.left_click_behavior = p.behaviors[0];
+ self.left_click_xpos = p.xpos;
+ self.left_click_ypos = p.ypos;
+ self.left_click_time = p.time;
+ self.left_click_dragged = false;
+ self.left_drag_autoscroll = .none;
+}
+
+fn pressRepeat(
+ self: *SelectionGesture,
+ t: *Terminal,
+ p: Press,
+) error{PressRequiresReset}!void {
+ errdefer {
+ self.left_click_count = 0;
+ self.left_click_behavior = .cell;
+ self.untrackPin(t);
+ }
+
+ // If too much time has passed then we always reset.
+ 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);
+ if (since > p.repeat_interval) return error.PressRequiresReset;
+ }
+
+ // If the click is too far away from the initial click we can't continue.
+ const distance = @sqrt(
+ std.math.pow(f64, p.xpos - self.left_click_xpos, 2) +
+ std.math.pow(f64, p.ypos - self.left_click_ypos, 2),
+ );
+ if (distance > p.max_distance) return error.PressRequiresReset;
+
+ // If our prior click was on another screen then free and reset. "Another screen"
+ // doesn't just mean alt vs primary, it could mean an alt screen that was
+ // recycled since we free tracked pins on recycle.
+ const screens: *const ScreenSet = &t.screens;
+ if (self.left_click_screen != screens.active_key or
+ screens.generation(self.left_click_screen) !=
+ self.left_click_screen_generation)
+ {
+ // The error return will trigger the top-level errdefer which
+ // will reset our pin.
+ return error.PressRequiresReset;
+ }
+
+ self.left_click_time = time;
+ self.left_click_dragged = false;
+ self.left_drag_autoscroll = .none;
+ self.left_click_count = @min(
+ self.left_click_count + 1,
+ 3, // We only support triple clicks max
+ );
+ 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;
+ return time.since(prev_time);
+}
+
+fn pressSelection(
+ self: *const SelectionGesture,
+ screen: *Screen,
+ p: Press,
+) ?Selection {
+ return switch (self.left_click_behavior) {
+ .cell => null,
+ .word => screen.selectWord(p.pin, p.word_boundary_codepoints),
+ .line => screen.selectLine(.{ .pin = p.pin }),
+ .output => screen.selectOutput(p.pin),
+ };
+}
+
+/// Calculates the appropriate selection given pins and pixel x positions for
+/// the click point and the drag point, as well as selection mode and geometry.
+fn dragSelection(
+ click_pin: Pin,
+ drag_pin: Pin,
+ click_x: u32,
+ drag_x: u32,
+ rectangle_selection: bool,
+ geometry: Drag.Geometry,
+) ?Selection {
+ // Explanation:
+ //
+ // # Normal selections
+ //
+ // ## Left-to-right selections
+ // - The clicked cell is included if it was clicked to the left of its
+ // threshold point and the drag location is right of the threshold point.
+ // - The cell under the cursor (the "drag cell") is included if the drag
+ // location is right of its threshold point.
+ //
+ // ## Right-to-left selections
+ // - The clicked cell is included if it was clicked to the right of its
+ // threshold point and the drag location is left of the threshold point.
+ // - The cell under the cursor (the "drag cell") is included if the drag
+ // location is left of its threshold point.
+ //
+ // # Rectangular selections
+ //
+ // Rectangular selections are handled similarly, except that
+ // entire columns are considered rather than individual cells.
+
+ // 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.
+ const threshold_point: u32 = @intFromFloat(@round(
+ @as(f64, @floatFromInt(geometry.cell_width)) * 0.6,
+ ));
+
+ // We use this to clamp the pixel positions below.
+ const max_x = geometry.columns * geometry.cell_width - 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.
+ const drag_x_frac = @min(max_x, drag_x -| geometry.padding_left) % geometry.cell_width;
+
+ // We figure out the fractional part of the click x position similarly.
+ const click_x_frac = @min(max_x, click_x -| geometry.padding_left) % geometry.cell_width;
+
+ // Whether the click pin and drag pin are equal.
+ const same_pin = drag_pin.eql(click_pin);
+
+ // Whether or not the end point of our selection is before the start point.
+ const end_before_start = ebs: {
+ if (same_pin) {
+ break :ebs drag_x_frac < click_x_frac;
+ }
+
+ // Special handling for rectangular selections, we only use x position.
+ if (rectangle_selection) {
+ break :ebs switch (std.math.order(drag_pin.x, click_pin.x)) {
+ .eq => drag_x_frac < click_x_frac,
+ .lt => true,
+ .gt => false,
+ };
+ }
+
+ break :ebs drag_pin.before(click_pin);
+ };
+
+ // Whether or not the click pin cell
+ // should be included in the selection.
+ const include_click_cell = if (end_before_start)
+ click_x_frac >= threshold_point
+ else
+ click_x_frac < threshold_point;
+
+ // Whether or not the drag pin cell
+ // should be included in the selection.
+ const include_drag_cell = if (end_before_start)
+ drag_x_frac < threshold_point
+ else
+ drag_x_frac >= threshold_point;
+
+ // If the click cell should be included in the selection then it's the
+ // start, otherwise we get the previous or next cell to it depending on
+ // the type and direction of the selection.
+ const start_pin =
+ if (include_click_cell)
+ click_pin
+ else if (end_before_start)
+ if (rectangle_selection)
+ click_pin.leftClamp(1)
+ else
+ click_pin.leftWrap(1) orelse click_pin
+ else if (rectangle_selection)
+ click_pin.rightClamp(1)
+ else
+ click_pin.rightWrap(1) orelse click_pin;
+
+ // Likewise for the end pin with the drag cell.
+ const end_pin =
+ if (include_drag_cell)
+ drag_pin
+ else if (end_before_start)
+ if (rectangle_selection)
+ drag_pin.rightClamp(1)
+ else
+ drag_pin.rightWrap(1) orelse drag_pin
+ else if (rectangle_selection)
+ drag_pin.leftClamp(1)
+ else
+ drag_pin.leftWrap(1) orelse drag_pin;
+
+ // If the click cell is the same as the drag cell and the click cell
+ // shouldn't be included, or if the cells are adjacent such that the
+ // start or end pin becomes the other cell, and that cell should not
+ // be included, then we have no selection, so we set it to null.
+ //
+ // If in rectangular selection mode, we compare columns as well.
+ //
+ // TODO(qwerasd): this can/should probably be refactored, it's a bit
+ // repetitive and does excess work in rectangle mode.
+ if ((!include_click_cell and same_pin) or
+ (!include_click_cell and rectangle_selection and click_pin.x == drag_pin.x) or
+ (!include_click_cell and end_pin.eql(click_pin)) or
+ (!include_click_cell and rectangle_selection and end_pin.x == click_pin.x) or
+ (!include_drag_cell and start_pin.eql(drag_pin)) or
+ (!include_drag_cell and rectangle_selection and start_pin.x == drag_pin.x))
+ {
+ return null;
+ }
+
+ // TODO: Clamp selection to the screen area, don't
+ // let it extend past the last written row.
+
+ return .init(
+ start_pin,
+ end_pin,
+ rectangle_selection,
+ );
+}
+
+/// Calculates the appropriate word-wise selection for a double-click drag.
+fn dragSelectionWord(
+ screen: *Screen,
+ click_pin: Pin,
+ drag_pin: Pin,
+ boundary_codepoints: []const u21,
+) ?Selection {
+ // Get the word closest to our starting click.
+ const word_start = screen.selectWordBetween(
+ click_pin,
+ drag_pin,
+ boundary_codepoints,
+ ) orelse return null;
+
+ // Get the word closest to our current point.
+ const word_current = screen.selectWordBetween(
+ drag_pin,
+ click_pin,
+ boundary_codepoints,
+ ) orelse return null;
+
+ // If our current mouse position is before the starting position,
+ // then the selection start is the word nearest our current position.
+ return if (drag_pin.before(click_pin))
+ .init(
+ word_current.start(),
+ word_start.end(),
+ false,
+ )
+ else
+ .init(
+ word_start.start(),
+ word_current.end(),
+ false,
+ );
+}
+
+/// Calculates the appropriate line-wise selection for a triple-click drag.
+fn dragSelectionLine(
+ screen: *Screen,
+ click_pin: Pin,
+ drag_pin: Pin,
+) ?Selection {
+ // Get the line selection under our current drag point. If there isn't a
+ // line, do nothing.
+ const line = screen.selectLine(.{ .pin = drag_pin }) orelse return null;
+
+ // Get the selection under our click point. We first try to trim
+ // whitespace if we've selected a word. But if no word exists then
+ // we select the blank line.
+ const sel_ = screen.selectLine(.{ .pin = click_pin }) orelse
+ screen.selectLine(.{ .pin = click_pin, .whitespace = null });
+
+ var sel = sel_ orelse return null;
+ if (drag_pin.before(click_pin)) {
+ sel.startPtr().* = line.start();
+ } else {
+ sel.endPtr().* = line.end();
+ }
+ return sel;
+}
+
+/// Calculates the appropriate semantic-output-wise selection for an output
+/// drag. This expands from the output block under the click point to the output
+/// block under the current drag point. If the drag point is not output, keep the
+/// original output selection.
+fn dragSelectionOutput(
+ screen: *Screen,
+ click_pin: Pin,
+ drag_pin: Pin,
+) ?Selection {
+ var sel = screen.selectOutput(click_pin) orelse return null;
+ const current = screen.selectOutput(drag_pin) orelse return sel;
+
+ if (drag_pin.before(click_pin)) {
+ sel.startPtr().* = current.start();
+ } else {
+ sel.endPtr().* = current.end();
+ }
+ return sel;
+}
+
+fn untrackPin(self: *SelectionGesture, t: *Terminal) void {
+ // Can't untrack unless we have a pin.
+ const pin = self.left_click_pin orelse return;
+ self.left_click_pin = null;
+
+ // If the generation changed our pin is already invalid.
+ const screens: *const ScreenSet = &t.screens;
+ if (screens.generation(self.left_click_screen) != self.left_click_screen_generation) return;
+
+ // If we can't get a screen then its already freed.
+ const screen = screens.get(self.left_click_screen) orelse return;
+ screen.pages.untrackPin(pin);
+}
+
+fn testPress(t: *Terminal, x: u16, y: u32, time: ?std.time.Instant) Press {
+ return .{
+ .time = time,
+ .pin = t.screens.active.pages.pin(.{ .active = .{
+ .x = x,
+ .y = y,
+ } }).?,
+ .xpos = @floatFromInt(x),
+ .ypos = @floatFromInt(y),
+ .max_distance = 1,
+ .repeat_interval = std.math.maxInt(u64),
+ .word_boundary_codepoints = &.{},
+ };
+}
+
+fn testDrag(t: *Terminal, x: u16, y: u32, xpos: f64, ypos: f64) Drag {
+ return .{
+ .pin = t.screens.active.pages.pin(.{ .active = .{
+ .x = x,
+ .y = y,
+ } }).?,
+ .xpos = xpos,
+ .ypos = ypos,
+ .rectangle = false,
+ .word_boundary_codepoints = &.{},
+ .geometry = .{
+ .columns = 5,
+ .cell_width = 10,
+ .padding_left = 0,
+ .screen_height = 100,
+ },
+ };
+}
+
+fn testAutoscrollTick(
+ viewport: point.Coordinate,
+ xpos: f64,
+ ypos: f64,
+) AutoscrollTick {
+ return .{
+ .viewport = viewport,
+ .xpos = xpos,
+ .ypos = ypos,
+ .rectangle = false,
+ .word_boundary_codepoints = &.{},
+ .geometry = .{
+ .columns = 5,
+ .cell_width = 10,
+ .padding_left = 0,
+ .screen_height = 100,
+ },
+ };
+}
+
+fn testPin(t: *Terminal, x: u16, y: u32) Pin {
+ return t.screens.active.pages.pin(.{ .active = .{
+ .x = x,
+ .y = y,
+ } }).?;
+}
+
+/// Utility function for the unit tests for drag selection logic.
+///
+/// Tests a click and drag on a 10x5 cell grid, x positions are given in
+/// fractional cells, e.g. 3.1 would be 10% through the cell at x = 3.
+///
+/// NOTE: The geometry tested with has 10px wide cells, meaning only one digit
+/// after the decimal place has any meaning, e.g. 3.14 is equal to 3.1.
+///
+/// The provided start_x/y and end_x/y are the expected start and end points
+/// of the resulting selection.
+fn testDragSelection(
+ click_x: f64,
+ click_y: u32,
+ drag_x: f64,
+ drag_y: u32,
+ start_x: u16,
+ start_y: u32,
+ end_x: u16,
+ end_y: u32,
+ rect: bool,
+) !void {
+ assert(@import("builtin").is_test);
+
+ // Our screen size is 10x5 cells that are
+ // 10x20 px, with 5px padding on all sides.
+ const geometry: Drag.Geometry = .{
+ .columns = 10,
+ .cell_width = 10,
+ .padding_left = 5,
+ .screen_height = 110,
+ };
+ var screen = try Screen.init(testing.allocator, .{ .cols = 10, .rows = 5, .max_scrollback = 0 });
+ defer screen.deinit();
+
+ const click_pin = screen.pages.pin(.{
+ .viewport = .{ .x = @intFromFloat(@floor(click_x)), .y = click_y },
+ }) orelse unreachable;
+ const drag_pin = screen.pages.pin(.{
+ .viewport = .{ .x = @intFromFloat(@floor(drag_x)), .y = drag_y },
+ }) orelse unreachable;
+
+ const cell_width_f64: f64 = @floatFromInt(geometry.cell_width);
+ const click_x_pos: u32 =
+ @as(u32, @intFromFloat(@floor(click_x * cell_width_f64))) +
+ geometry.padding_left;
+ const drag_x_pos: u32 =
+ @as(u32, @intFromFloat(@floor(drag_x * cell_width_f64))) +
+ geometry.padding_left;
+
+ const start_pin = screen.pages.pin(.{
+ .viewport = .{ .x = start_x, .y = start_y },
+ }) orelse unreachable;
+ const end_pin = screen.pages.pin(.{
+ .viewport = .{ .x = end_x, .y = end_y },
+ }) orelse unreachable;
+
+ try testing.expectEqualDeep(Selection{
+ .bounds = .{ .untracked = .{
+ .start = start_pin,
+ .end = end_pin,
+ } },
+ .rectangle = rect,
+ }, dragSelection(
+ click_pin,
+ drag_pin,
+ click_x_pos,
+ drag_x_pos,
+ rect,
+ geometry,
+ ));
+}
+
+/// Like `testDragSelection` but checks that the resulting selection is null.
+///
+/// See `testDragSelection` for more details.
+fn testDragSelectionIsNull(
+ click_x: f64,
+ click_y: u32,
+ drag_x: f64,
+ drag_y: u32,
+ rect: bool,
+) !void {
+ assert(@import("builtin").is_test);
+
+ // Our screen size is 10x5 cells that are
+ // 10x20 px, with 5px padding on all sides.
+ const geometry: Drag.Geometry = .{
+ .columns = 10,
+ .cell_width = 10,
+ .padding_left = 5,
+ .screen_height = 110,
+ };
+ var screen = try Screen.init(testing.allocator, .{ .cols = 10, .rows = 5, .max_scrollback = 0 });
+ defer screen.deinit();
+
+ const click_pin = screen.pages.pin(.{
+ .viewport = .{ .x = @intFromFloat(@floor(click_x)), .y = click_y },
+ }) orelse unreachable;
+ const drag_pin = screen.pages.pin(.{
+ .viewport = .{ .x = @intFromFloat(@floor(drag_x)), .y = drag_y },
+ }) orelse unreachable;
+
+ const cell_width_f64: f64 = @floatFromInt(geometry.cell_width);
+ const click_x_pos: u32 =
+ @as(u32, @intFromFloat(@floor(click_x * cell_width_f64))) +
+ geometry.padding_left;
+ const drag_x_pos: u32 =
+ @as(u32, @intFromFloat(@floor(drag_x * cell_width_f64))) +
+ geometry.padding_left;
+
+ try testing.expectEqual(
+ null,
+ dragSelection(
+ click_pin,
+ drag_pin,
+ click_x_pos,
+ drag_x_pos,
+ rect,
+ geometry,
+ ),
+ );
+}
+
+test "SelectionGesture drag selection logic" {
+ // We disable format to make these easier to
+ // read by pairing sets of coordinates per line.
+ // zig fmt: off
+
+ // -- LTR
+ // single cell selection
+ try testDragSelection(
+ 3.0, 3, // click
+ 3.9, 3, // drag
+ 3, 3, // expected start
+ 3, 3, // expected end
+ false, // regular selection
+ );
+ // including click and drag pin cells
+ try testDragSelection(
+ 3.0, 3, // click
+ 5.9, 3, // drag
+ 3, 3, // expected start
+ 5, 3, // expected end
+ false, // regular selection
+ );
+ // including click pin cell but not drag pin cell
+ try testDragSelection(
+ 3.0, 3, // click
+ 5.0, 3, // drag
+ 3, 3, // expected start
+ 4, 3, // expected end
+ false, // regular selection
+ );
+ // including drag pin cell but not click pin cell
+ try testDragSelection(
+ 3.9, 3, // click
+ 5.9, 3, // drag
+ 4, 3, // expected start
+ 5, 3, // expected end
+ false, // regular selection
+ );
+ // including neither click nor drag pin cells
+ try testDragSelection(
+ 3.9, 3, // click
+ 5.0, 3, // drag
+ 4, 3, // expected start
+ 4, 3, // expected end
+ false, // regular selection
+ );
+ // empty selection (single cell on only left half)
+ try testDragSelectionIsNull(
+ 3.0, 3, // click
+ 3.1, 3, // drag
+ false, // regular selection
+ );
+ // empty selection (single cell on only right half)
+ try testDragSelectionIsNull(
+ 3.8, 3, // click
+ 3.9, 3, // drag
+ false, // regular selection
+ );
+ // empty selection (between two cells, not crossing threshold)
+ try testDragSelectionIsNull(
+ 3.9, 3, // click
+ 4.0, 3, // drag
+ false, // regular selection
+ );
+
+ // -- RTL
+ // single cell selection
+ try testDragSelection(
+ 3.9, 3, // click
+ 3.0, 3, // drag
+ 3, 3, // expected start
+ 3, 3, // expected end
+ false, // regular selection
+ );
+ // including click and drag pin cells
+ try testDragSelection(
+ 5.9, 3, // click
+ 3.0, 3, // drag
+ 5, 3, // expected start
+ 3, 3, // expected end
+ false, // regular selection
+ );
+ // including click pin cell but not drag pin cell
+ try testDragSelection(
+ 5.9, 3, // click
+ 3.9, 3, // drag
+ 5, 3, // expected start
+ 4, 3, // expected end
+ false, // regular selection
+ );
+ // including drag pin cell but not click pin cell
+ try testDragSelection(
+ 5.0, 3, // click
+ 3.0, 3, // drag
+ 4, 3, // expected start
+ 3, 3, // expected end
+ false, // regular selection
+ );
+ // including neither click nor drag pin cells
+ try testDragSelection(
+ 5.0, 3, // click
+ 3.9, 3, // drag
+ 4, 3, // expected start
+ 4, 3, // expected end
+ false, // regular selection
+ );
+ // empty selection (single cell on only left half)
+ try testDragSelectionIsNull(
+ 3.1, 3, // click
+ 3.0, 3, // drag
+ false, // regular selection
+ );
+ // empty selection (single cell on only right half)
+ try testDragSelectionIsNull(
+ 3.9, 3, // click
+ 3.8, 3, // drag
+ false, // regular selection
+ );
+ // empty selection (between two cells, not crossing threshold)
+ try testDragSelectionIsNull(
+ 4.0, 3, // click
+ 3.9, 3, // drag
+ false, // regular selection
+ );
+
+ // -- Wrapping
+ // LTR, wrap excluded cells
+ try testDragSelection(
+ 9.9, 2, // click
+ 0.0, 4, // drag
+ 0, 3, // expected start
+ 9, 3, // expected end
+ false, // regular selection
+ );
+ // RTL, wrap excluded cells
+ try testDragSelection(
+ 0.0, 4, // click
+ 9.9, 2, // drag
+ 9, 3, // expected start
+ 0, 3, // expected end
+ false, // regular selection
+ );
+}
+
+test "SelectionGesture rectangle drag selection logic" {
+ // We disable format to make these easier to
+ // read by pairing sets of coordinates per line.
+ // zig fmt: off
+
+ // -- LTR
+ // single column selection
+ try testDragSelection(
+ 3.0, 2, // click
+ 3.9, 4, // drag
+ 3, 2, // expected start
+ 3, 4, // expected end
+ true, //rectangle selection
+ );
+ // including click and drag pin columns
+ try testDragSelection(
+ 3.0, 2, // click
+ 5.9, 4, // drag
+ 3, 2, // expected start
+ 5, 4, // expected end
+ true, //rectangle selection
+ );
+ // including click pin column but not drag pin column
+ try testDragSelection(
+ 3.0, 2, // click
+ 5.0, 4, // drag
+ 3, 2, // expected start
+ 4, 4, // expected end
+ true, //rectangle selection
+ );
+ // including drag pin column but not click pin column
+ try testDragSelection(
+ 3.9, 2, // click
+ 5.9, 4, // drag
+ 4, 2, // expected start
+ 5, 4, // expected end
+ true, //rectangle selection
+ );
+ // including neither click nor drag pin columns
+ try testDragSelection(
+ 3.9, 2, // click
+ 5.0, 4, // drag
+ 4, 2, // expected start
+ 4, 4, // expected end
+ true, //rectangle selection
+ );
+ // empty selection (single column on only left half)
+ try testDragSelectionIsNull(
+ 3.0, 2, // click
+ 3.1, 4, // drag
+ true, //rectangle selection
+ );
+ // empty selection (single column on only right half)
+ try testDragSelectionIsNull(
+ 3.8, 2, // click
+ 3.9, 4, // drag
+ true, //rectangle selection
+ );
+ // empty selection (between two columns, not crossing threshold)
+ try testDragSelectionIsNull(
+ 3.9, 2, // click
+ 4.0, 4, // drag
+ true, //rectangle selection
+ );
+
+ // -- RTL
+ // single column selection
+ try testDragSelection(
+ 3.9, 2, // click
+ 3.0, 4, // drag
+ 3, 2, // expected start
+ 3, 4, // expected end
+ true, //rectangle selection
+ );
+ // including click and drag pin columns
+ try testDragSelection(
+ 5.9, 2, // click
+ 3.0, 4, // drag
+ 5, 2, // expected start
+ 3, 4, // expected end
+ true, //rectangle selection
+ );
+ // including click pin column but not drag pin column
+ try testDragSelection(
+ 5.9, 2, // click
+ 3.9, 4, // drag
+ 5, 2, // expected start
+ 4, 4, // expected end
+ true, //rectangle selection
+ );
+ // including drag pin column but not click pin column
+ try testDragSelection(
+ 5.0, 2, // click
+ 3.0, 4, // drag
+ 4, 2, // expected start
+ 3, 4, // expected end
+ true, //rectangle selection
+ );
+ // including neither click nor drag pin columns
+ try testDragSelection(
+ 5.0, 2, // click
+ 3.9, 4, // drag
+ 4, 2, // expected start
+ 4, 4, // expected end
+ true, //rectangle selection
+ );
+ // empty selection (single column on only left half)
+ try testDragSelectionIsNull(
+ 3.1, 2, // click
+ 3.0, 4, // drag
+ true, //rectangle selection
+ );
+ // empty selection (single column on only right half)
+ try testDragSelectionIsNull(
+ 3.9, 2, // click
+ 3.8, 4, // drag
+ true, //rectangle selection
+ );
+ // empty selection (between two columns, not crossing threshold)
+ try testDragSelectionIsNull(
+ 4.0, 2, // click
+ 3.9, 4, // drag
+ true, //rectangle selection
+ );
+
+ // -- Wrapping
+ // LTR, do not wrap
+ try testDragSelection(
+ 9.9, 2, // click
+ 0.0, 4, // drag
+ 9, 2, // expected start
+ 0, 4, // expected end
+ true, //rectangle selection
+ );
+ // RTL, do not wrap
+ try testDragSelection(
+ 0.0, 4, // click
+ 9.9, 2, // drag
+ 0, 4, // expected start
+ 9, 2, // expected end
+ true, //rectangle selection
+ );
+}
+
+test "SelectionGesture press records initial click" {
+ 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 time = try std.time.Instant.now();
+ _ = try gesture.press(&t, testPress(&t, 1, 2, time));
+
+ try testing.expectEqual(@as(u3, 1), gesture.left_click_count);
+ try testing.expectEqual(time, gesture.left_click_time.?);
+ try testing.expectEqual(@as(f64, 1), gesture.left_click_xpos);
+ try testing.expectEqual(@as(f64, 2), gesture.left_click_ypos);
+ try testing.expectEqual(false, gesture.left_click_dragged);
+}
+
+test "SelectionGesture press returns standard click selections" {
+ var t = try Terminal.init(testing.allocator, .{ .cols = 20, .rows = 5 });
+ defer t.deinit(testing.allocator);
+ try t.printString("alpha beta\none two");
+
+ var gesture: SelectionGesture = .init;
+ defer gesture.deinit(&t);
+
+ const time = try std.time.Instant.now();
+ var event = testPress(&t, 1, 0, time);
+ event.word_boundary_codepoints = &.{ ' ' };
+
+ try testing.expectEqual(null, try gesture.press(&t, event));
+
+ try testing.expectEqualDeep(Selection.init(
+ testPin(&t, 0, 0),
+ testPin(&t, 4, 0),
+ false,
+ ), (try gesture.press(&t, event)).?);
+
+ try testing.expectEqualDeep(Selection.init(
+ testPin(&t, 0, 0),
+ testPin(&t, 9, 0),
+ false,
+ ), (try gesture.press(&t, event)).?);
+}
+
+test "SelectionGesture press behaviors choose press and drag behavior" {
+ var t = try Terminal.init(testing.allocator, .{ .cols = 20, .rows = 5 });
+ defer t.deinit(testing.allocator);
+ try t.printString("alpha beta\none two\nthree four");
+
+ var gesture: SelectionGesture = .init;
+ defer gesture.deinit(&t);
+
+ const time = try std.time.Instant.now();
+ var event = testPress(&t, 1, 0, time);
+ event.behaviors = &.{ .cell, .line, .word };
+ event.word_boundary_codepoints = &.{ ' ' };
+
+ _ = try gesture.press(&t, event);
+ try testing.expectEqual(.cell, gesture.left_click_behavior);
+
+ const double_click = (try gesture.press(&t, event)).?;
+ try testing.expectEqual(.line, gesture.left_click_behavior);
+ try testing.expectEqualDeep(Selection.init(
+ testPin(&t, 0, 0),
+ testPin(&t, 9, 0),
+ false,
+ ), double_click);
+
+ const line_drag = gesture.drag(&t, testDrag(&t, 2, 2, 20, 50)).?;
+ try testing.expectEqualDeep(Selection.init(
+ testPin(&t, 0, 0),
+ testPin(&t, 9, 2),
+ false,
+ ), line_drag);
+}
+
+test "SelectionGesture output behavior selects and drags semantic output" {
+ var t = try Terminal.init(testing.allocator, .{ .cols = 10, .rows = 6 });
+ defer t.deinit(testing.allocator);
+
+ const screen = t.screens.active;
+ screen.cursorSetSemanticContent(.output);
+ try screen.testWriteString("out1\n");
+ screen.cursorSetSemanticContent(.{ .prompt = .initial });
+ try screen.testWriteString("$ ");
+ screen.cursorSetSemanticContent(.{ .input = .clear_explicit });
+ try screen.testWriteString("cmd\n");
+ screen.cursorSetSemanticContent(.output);
+ try screen.testWriteString("out2");
+
+ var gesture: SelectionGesture = .init;
+ defer gesture.deinit(&t);
+
+ var event = testPress(&t, 1, 0, try std.time.Instant.now());
+ event.behaviors = &.{ .output, .word, .line };
+
+ const press_selection = (try gesture.press(&t, event)).?;
+ try testing.expectEqual(.output, gesture.left_click_behavior);
+ try testing.expectEqualDeep(Selection.init(
+ testPin(&t, 0, 0),
+ testPin(&t, 3, 0),
+ false,
+ ), press_selection);
+
+ const output_drag = gesture.drag(&t, testDrag(&t, 1, 2, 10, 50)).?;
+ try testing.expectEqualDeep(Selection.init(
+ testPin(&t, 0, 0),
+ testPin(&t, 3, 2),
+ false,
+ ), output_drag);
+}
+
+test "SelectionGesture drag returns selection and records autoscroll" {
+ 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 sel = gesture.drag(&t, testDrag(&t, 3, 1, 39, 50)).?;
+ try testing.expectEqual(.none, gesture.left_drag_autoscroll);
+ try testing.expectEqual(true, gesture.left_click_dragged);
+
+ try testing.expectEqualDeep(Selection.init(
+ t.screens.active.pages.pin(.{ .active = .{ .x = 1, .y = 1 } }).?,
+ t.screens.active.pages.pin(.{ .active = .{ .x = 3, .y = 1 } }).?,
+ false,
+ ), sel);
+
+ _ = gesture.drag(&t, testDrag(&t, 3, 1, 39, 1));
+ try testing.expectEqual(.up, gesture.left_drag_autoscroll);
+
+ _ = gesture.drag(&t, testDrag(&t, 3, 1, 39, 100));
+ try testing.expectEqual(.down, gesture.left_drag_autoscroll);
+}
+
+test "SelectionGesture release clears autoscroll and records drag" {
+ 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()));
+ try testing.expectEqual(false, gesture.left_click_dragged);
+
+ _ = gesture.drag(&t, testDrag(&t, 1, 1, 10, 1));
+ try testing.expectEqual(.up, gesture.left_drag_autoscroll);
+ try testing.expectEqual(false, gesture.left_click_dragged);
+
+ gesture.release(&t, .{
+ .pin = testPin(&t, 2, 1),
+ });
+ try testing.expectEqual(.none, gesture.left_drag_autoscroll);
+ try testing.expectEqual(true, gesture.left_click_dragged);
+}
+
+test "SelectionGesture release with invalidated click records drag" {
+ 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()));
+ try testing.expectEqual(false, gesture.left_click_dragged);
+
+ _ = try t.screens.getInit(testing.allocator, .alternate, .{
+ .cols = t.cols,
+ .rows = t.rows,
+ });
+ t.screens.switchTo(.alternate);
+
+ gesture.release(&t, .{ .pin = testPin(&t, 1, 1) });
+ try testing.expectEqual(true, gesture.left_click_dragged);
+ try testing.expectEqual(.none, gesture.left_drag_autoscroll);
+}
+
+test "SelectionGesture same-cell threshold selection records drag" {
+ 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);
+ try testing.expectEqual(false, gesture.left_click_dragged);
+
+ const sel = gesture.drag(&t, testDrag(&t, 1, 1, 19, 50)).?;
+ try testing.expectEqual(true, gesture.left_click_dragged);
+ try testing.expectEqualDeep(Selection.init(
+ testPin(&t, 1, 1),
+ testPin(&t, 1, 1),
+ false,
+ ), sel);
+}
+
+test "SelectionGesture drag without press returns null" {
+ 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 testing.expectEqual(null, gesture.drag(&t, testDrag(&t, 1, 1, 10, 50)));
+ try testing.expectEqual(.none, gesture.left_drag_autoscroll);
+}
+
+test "SelectionGesture drag autoscroll edge boundaries" {
+ 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);
+
+ _ = gesture.drag(&t, testDrag(&t, 2, 1, 20, 1));
+ try testing.expectEqual(.up, gesture.left_drag_autoscroll);
+
+ _ = gesture.drag(&t, testDrag(&t, 2, 1, 20, 1.1));
+ try testing.expectEqual(.none, gesture.left_drag_autoscroll);
+
+ _ = gesture.drag(&t, testDrag(&t, 2, 1, 20, 99));
+ try testing.expectEqual(.none, gesture.left_drag_autoscroll);
+
+ _ = gesture.drag(&t, testDrag(&t, 2, 1, 20, 99.1));
+ try testing.expectEqual(.down, gesture.left_drag_autoscroll);
+}
+
+test "SelectionGesture autoscroll tick scrolls and continues drag" {
+ 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);
+
+ _ = gesture.drag(&t, testDrag(&t, 3, 1, 39, 100));
+ try testing.expectEqual(.down, gesture.left_drag_autoscroll);
+
+ const sel = gesture.autoscrollTick(&t, testAutoscrollTick(.{ .x = 3, .y = 2 }, 39, 100)).?;
+ try testing.expectEqual(.down, gesture.left_drag_autoscroll);
+ try testing.expectEqual(true, gesture.left_click_dragged);
+ try testing.expectEqualDeep(Selection.init(
+ testPin(&t, 1, 1),
+ testPin(&t, 3, 2),
+ false,
+ ), sel);
+}
+
+test "SelectionGesture autoscroll tick resolves drag pin after scrolling" {
+ var t = try Terminal.init(testing.allocator, .{ .cols = 5, .rows = 3, .max_scrollback = 10 });
+ defer t.deinit(testing.allocator);
+ try t.printString("1111\n2222\n3333\n4444\n5555");
+ t.scrollViewport(.{ .delta = -2 });
+
+ 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);
+
+ _ = gesture.drag(&t, testDrag(&t, 3, 2, 39, 100));
+ try testing.expectEqual(.down, gesture.left_drag_autoscroll);
+
+ const viewport: point.Coordinate = .{ .x = 3, .y = 2 };
+ const pre_scroll_pin = t.screens.active.pages.pin(.{ .viewport = viewport }).?;
+ const sel = gesture.autoscrollTick(&t, testAutoscrollTick(viewport, 39, 100)).?;
+ const post_scroll_pin = t.screens.active.pages.pin(.{ .viewport = viewport }).?;
+
+ try testing.expect(!pre_scroll_pin.eql(post_scroll_pin));
+ try testing.expectEqualDeep(Selection.init(
+ testPin(&t, 1, 1),
+ post_scroll_pin,
+ false,
+ ), sel);
+}
+
+test "SelectionGesture autoscroll tick stops with invalidated click" {
+ 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);
+
+ _ = gesture.drag(&t, testDrag(&t, 2, 1, 20, 1));
+ try testing.expectEqual(.up, gesture.left_drag_autoscroll);
+
+ _ = try t.screens.getInit(testing.allocator, .alternate, .{
+ .cols = t.cols,
+ .rows = t.rows,
+ });
+ t.screens.switchTo(.alternate);
+
+ try testing.expectEqual(null, gesture.autoscrollTick(&t, testAutoscrollTick(.{ .x = 2, .y = 1 }, 20, 1)));
+ try testing.expectEqual(.none, gesture.left_drag_autoscroll);
+ try testing.expectEqual(@as(u3, 0), gesture.left_click_count);
+}
+
+test "SelectionGesture deep press selects word and consumes drag" {
+ var t = try Terminal.init(testing.allocator, .{ .cols = 20, .rows = 5 });
+ defer t.deinit(testing.allocator);
+ try t.printString("alpha beta");
+
+ var gesture: SelectionGesture = .init;
+ defer gesture.deinit(&t);
+
+ _ = try gesture.press(&t, testPress(&t, 1, 0, try std.time.Instant.now()));
+ _ = gesture.drag(&t, testDrag(&t, 1, 0, 10, 1));
+ try testing.expectEqual(.up, gesture.left_drag_autoscroll);
+
+ const sel = gesture.deepPress(&t, .{
+ .word_boundary_codepoints = &.{ ' ' },
+ }).?;
+
+ try testing.expectEqualDeep(Selection.init(
+ testPin(&t, 0, 0),
+ testPin(&t, 4, 0),
+ false,
+ ), sel);
+ try testing.expectEqual(@as(u3, 0), gesture.left_click_count);
+ try testing.expectEqual(@as(?std.time.Instant, null), gesture.left_click_time);
+ try testing.expectEqual(true, gesture.left_click_dragged);
+ try testing.expectEqual(.none, gesture.left_drag_autoscroll);
+ try testing.expect(gesture.left_click_pin == null);
+
+ try testing.expectEqual(null, gesture.drag(&t, testDrag(&t, 7, 0, 70, 50)));
+ gesture.release(&t, .{ .pin = testPin(&t, 7, 0) });
+ try testing.expectEqual(true, gesture.left_click_dragged);
+}
+
+test "SelectionGesture drag with invalidated click returns null" {
+ 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);
+
+ _ = gesture.drag(&t, testDrag(&t, 2, 1, 20, 1));
+ try testing.expectEqual(.up, gesture.left_drag_autoscroll);
+
+ _ = try t.screens.getInit(testing.allocator, .alternate, .{
+ .cols = t.cols,
+ .rows = t.rows,
+ });
+ t.screens.switchTo(.alternate);
+
+ try testing.expectEqual(null, gesture.drag(&t, testDrag(&t, 2, 1, 20, 50)));
+ try testing.expectEqual(.up, gesture.left_drag_autoscroll);
+}
+
+test "SelectionGesture double-click drag selects by word" {
+ var t = try Terminal.init(testing.allocator, .{ .cols = 20, .rows = 5 });
+ defer t.deinit(testing.allocator);
+ try t.printString("alpha beta gamma");
+
+ var gesture: SelectionGesture = .init;
+ defer gesture.deinit(&t);
+
+ const time = try std.time.Instant.now();
+ _ = try gesture.press(&t, testPress(&t, 1, 0, time));
+ _ = try gesture.press(&t, testPress(&t, 1, 0, time));
+
+ var drag_event = testDrag(&t, 7, 0, 70, 50);
+ drag_event.word_boundary_codepoints = &.{ ' ' };
+ const sel = gesture.drag(&t, drag_event).?;
+
+ try testing.expectEqualDeep(Selection.init(
+ testPin(&t, 0, 0),
+ testPin(&t, 9, 0),
+ false,
+ ), sel);
+}
+
+test "SelectionGesture double-click drag selects by word backwards" {
+ var t = try Terminal.init(testing.allocator, .{ .cols = 20, .rows = 5 });
+ defer t.deinit(testing.allocator);
+ try t.printString("alpha beta gamma");
+
+ var gesture: SelectionGesture = .init;
+ defer gesture.deinit(&t);
+
+ const time = try std.time.Instant.now();
+ _ = try gesture.press(&t, testPress(&t, 7, 0, time));
+ _ = try gesture.press(&t, testPress(&t, 7, 0, time));
+
+ var drag_event = testDrag(&t, 1, 0, 10, 50);
+ drag_event.word_boundary_codepoints = &.{ ' ' };
+ const sel = gesture.drag(&t, drag_event).?;
+
+ try testing.expectEqualDeep(Selection.init(
+ testPin(&t, 0, 0),
+ testPin(&t, 9, 0),
+ false,
+ ), sel);
+}
+
+test "SelectionGesture double-click drag on empty cell selects nearest word" {
+ var t = try Terminal.init(testing.allocator, .{ .cols = 20, .rows = 5 });
+ defer t.deinit(testing.allocator);
+ try t.printString("alpha beta");
+
+ var gesture: SelectionGesture = .init;
+ defer gesture.deinit(&t);
+
+ const time = try std.time.Instant.now();
+ _ = try gesture.press(&t, testPress(&t, 1, 0, time));
+ _ = try gesture.press(&t, testPress(&t, 1, 0, time));
+
+ var drag_event = testDrag(&t, 15, 0, 150, 50);
+ drag_event.word_boundary_codepoints = &.{ ' ' };
+ const sel = gesture.drag(&t, drag_event).?;
+
+ try testing.expectEqualDeep(Selection.init(
+ testPin(&t, 0, 0),
+ testPin(&t, 9, 0),
+ false,
+ ), sel);
+}
+
+test "SelectionGesture triple-click drag selects by line" {
+ var t = try Terminal.init(testing.allocator, .{ .cols = 20, .rows = 5 });
+ defer t.deinit(testing.allocator);
+ try t.printString("alpha beta\none two\nthree four");
+
+ var gesture: SelectionGesture = .init;
+ defer gesture.deinit(&t);
+
+ const time = try std.time.Instant.now();
+ _ = try gesture.press(&t, testPress(&t, 1, 0, time));
+ _ = try gesture.press(&t, testPress(&t, 1, 0, time));
+ _ = try gesture.press(&t, testPress(&t, 1, 0, time));
+
+ const sel = gesture.drag(&t, testDrag(&t, 2, 2, 20, 50)).?;
+
+ try testing.expectEqualDeep(Selection.init(
+ testPin(&t, 0, 0),
+ testPin(&t, 9, 2),
+ false,
+ ), sel);
+}
+
+test "SelectionGesture triple-click drag selects by line backwards" {
+ var t = try Terminal.init(testing.allocator, .{ .cols = 20, .rows = 5 });
+ defer t.deinit(testing.allocator);
+ try t.printString("alpha beta\none two\nthree four");
+
+ var gesture: SelectionGesture = .init;
+ defer gesture.deinit(&t);
+
+ const time = try std.time.Instant.now();
+ _ = try gesture.press(&t, testPress(&t, 2, 2, time));
+ _ = try gesture.press(&t, testPress(&t, 2, 2, time));
+ _ = try gesture.press(&t, testPress(&t, 2, 2, time));
+
+ const sel = gesture.drag(&t, testDrag(&t, 1, 0, 10, 50)).?;
+
+ try testing.expectEqualDeep(Selection.init(
+ testPin(&t, 0, 0),
+ testPin(&t, 9, 2),
+ false,
+ ), sel);
+}
+
+test "SelectionGesture repeat increments 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 time = try std.time.Instant.now();
+ _ = try gesture.press(&t, testPress(&t, 1, 1, time));
+ _ = try gesture.press(&t, testPress(&t, 1, 1, time));
+
+ try testing.expectEqual(@as(u3, 2), gesture.left_click_count);
+}
+
+test "SelectionGesture repeat clamps at triple click" {
+ 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 time = try std.time.Instant.now();
+ for (0..4) |_| _ = try gesture.press(&t, testPress(&t, 1, 1, time));
+
+ try testing.expectEqual(@as(u3, 3), gesture.left_click_count);
+}
+
+test "SelectionGesture null initial time stays single click" {
+ 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, null));
+ _ = try gesture.press(&t, testPress(&t, 1, 1, try std.time.Instant.now()));
+
+ try testing.expectEqual(@as(u3, 1), gesture.left_click_count);
+ try testing.expect(gesture.left_click_time != null);
+}
+
+test "SelectionGesture null repeat time stays single click" {
+ 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()));
+ _ = try gesture.press(&t, testPress(&t, 1, 1, null));
+
+ try testing.expectEqual(@as(u3, 1), gesture.left_click_count);
+ try testing.expectEqual(@as(?std.time.Instant, null), gesture.left_click_time);
+}
+
+test "SelectionGesture distant press 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 time = try std.time.Instant.now();
+ _ = try gesture.press(&t, testPress(&t, 1, 1, time));
+ _ = try gesture.press(&t, testPress(&t, 4, 1, time));
+
+ try testing.expectEqual(@as(u3, 1), gesture.left_click_count);
+ try testing.expectEqual(@as(f64, 4), gesture.left_click_xpos);
+}
+
+test "SelectionGesture expired repeat 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);
+
+ var event = testPress(&t, 1, 1, try std.time.Instant.now());
+ event.repeat_interval = 0;
+ _ = try gesture.press(&t, event);
+
+ std.Thread.sleep(std.time.ns_per_ms);
+ event.time = try std.time.Instant.now();
+ _ = try gesture.press(&t, event);
+
+ try testing.expectEqual(@as(u3, 1), gesture.left_click_count);
+}
+
+test "SelectionGesture screen switch 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 time = try std.time.Instant.now();
+ const primary_tracked = t.screens.active.pages.countTrackedPins();
+ _ = try gesture.press(&t, testPress(&t, 1, 1, time));
+
+ _ = try t.screens.getInit(testing.allocator, .alternate, .{
+ .cols = t.cols,
+ .rows = t.rows,
+ });
+ t.screens.switchTo(.alternate);
+ _ = try gesture.press(&t, testPress(&t, 1, 1, time));
+
+ try testing.expectEqual(@as(u3, 1), gesture.left_click_count);
+ try testing.expectEqual(.alternate, gesture.left_click_screen);
+ try testing.expectEqual(primary_tracked, t.screens.get(.primary).?.pages.countTrackedPins());
+}
+
+test "SelectionGesture removed screen resets without untracking stale pin" {
+ 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 t.screens.getInit(testing.allocator, .alternate, .{
+ .cols = t.cols,
+ .rows = t.rows,
+ });
+ t.screens.switchTo(.alternate);
+ _ = try gesture.press(&t, testPress(&t, 1, 1, try std.time.Instant.now()));
+
+ t.screens.switchTo(.primary);
+ t.screens.remove(testing.allocator, .alternate);
+ _ = try gesture.press(&t, testPress(&t, 1, 1, try std.time.Instant.now()));
+
+ try testing.expectEqual(@as(u3, 1), gesture.left_click_count);
+ try testing.expectEqual(.primary, gesture.left_click_screen);
+}
+
+test "SelectionGesture deinit untracks pin" {
+ var t = try Terminal.init(testing.allocator, .{ .cols = 5, .rows = 5 });
+ defer t.deinit(testing.allocator);
+
+ var gesture: SelectionGesture = .init;
+ const tracked = t.screens.active.pages.countTrackedPins();
+ _ = try gesture.press(&t, testPress(&t, 1, 1, try std.time.Instant.now()));
+ try testing.expectEqual(tracked + 1, t.screens.active.pages.countTrackedPins());
+
+ gesture.deinit(&t);
+ try testing.expectEqual(tracked, t.screens.active.pages.countTrackedPins());
+}
diff --git a/vendor/libghostty-vt/src/terminal/Terminal.zig b/vendor/libghostty-vt/src/terminal/Terminal.zig
index 6740a93e..d41e4750 100644
--- a/vendor/libghostty-vt/src/terminal/Terminal.zig
+++ b/vendor/libghostty-vt/src/terminal/Terminal.zig
@@ -377,20 +377,20 @@ pub fn print(self: *Terminal, c: u21) !void {
// necessarily a grapheme break.
if (prev.cell.codepoint() == 0) break :grapheme;
+ var previous_codepoint: u21 = prev.cell.content.codepoint;
const grapheme_break = brk: {
var state: uucode.grapheme.BreakState = .default;
- var cp1: u21 = prev.cell.content.codepoint;
if (prev.cell.hasGrapheme()) {
const cps = self.screens.active.cursor.page_pin.node.data.lookupGrapheme(prev.cell).?;
for (cps) |cp2| {
- // log.debug("cp1={x} cp2={x}", .{ cp1, cp2 });
- assert(!unicode.graphemeBreak(cp1, cp2, &state));
- cp1 = cp2;
+ // log.debug("cp1={x} cp2={x}", .{ previous_codepoint, cp2 });
+ assert(!unicode.graphemeBreak(previous_codepoint, cp2, &state));
+ previous_codepoint = cp2;
}
}
- // log.debug("cp1={x} cp2={x} end", .{ cp1, c });
- break :brk unicode.graphemeBreak(cp1, c, &state);
+ // log.debug("cp1={x} cp2={x} end", .{ previous_codepoint, c });
+ break :brk unicode.graphemeBreak(previous_codepoint, c, &state);
};
// If we can NOT break, this means that "c" is part of a grapheme
@@ -402,7 +402,7 @@ pub fn print(self: *Terminal, c: u21) !void {
// 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(prev.cell.content.codepoint);
+ 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;
@@ -3318,7 +3318,7 @@ test "Terminal: zero-width character at start" {
try testing.expect(!t.isDirty(.{ .screen = .{ .x = 0, .y = 0 } }));
}
-// https://github.com/ghostty-org/ghostty/issues/12581
+// https://github.com/ghostty-org/ghostty/pull/12581
test "Terminal: zero-width character attaches to pending wrap cell" {
var t = try init(testing.allocator, .{ .cols = 2, .rows = 2 });
defer t.deinit(testing.allocator);
@@ -3741,6 +3741,27 @@ test "Terminal: invalid VS16 doesn't mark dirty" {
try testing.expect(!t.isDirty(.{ .screen = .{ .x = 0, .y = 0 } }));
}
+// https://github.com/ghostty-org/ghostty/pull/12596
+test "Terminal: variation selectors apply to preceding codepoint" {
+ var t = try init(testing.allocator, .{ .cols = 5, .rows = 5 });
+ defer t.deinit(testing.allocator);
+
+ // Enable grapheme clustering
+ t.modes.set(.grapheme_cluster, true);
+
+ // Pirate flag: black flag + ZWJ + skull and crossbones + VS16.
+ try t.print(0x1F3F4);
+ try t.print(0x200D);
+ try t.print(0x2620);
+ try t.print(0xFE0F);
+
+ const list_cell = t.screens.active.pages.getCell(.{ .screen = .{ .x = 0, .y = 0 } }).?;
+ 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).?);
+}
+
test "Terminal: print multicodepoint grapheme, mode 2027" {
var t = try init(testing.allocator, .{ .cols = 80, .rows = 80 });
defer t.deinit(testing.allocator);
diff --git a/vendor/libghostty-vt/src/terminal/c/grid_ref_tracked.zig b/vendor/libghostty-vt/src/terminal/c/grid_ref_tracked.zig
new file mode 100644
index 00000000..60091715
--- /dev/null
+++ b/vendor/libghostty-vt/src/terminal/c/grid_ref_tracked.zig
@@ -0,0 +1,225 @@
+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.terminal) |wrapper| {
+ _ = wrapper.tracked_grid_refs.swapRemove(ref);
+ }
+ 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));
+}
+
+test "tracked_grid_ref reports no value after terminal free" {
+ 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 },
+ ));
+
+ 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,
+ ));
+
+ terminal_c.free(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));
+
+ var coord: point.Coordinate = undefined;
+ try testing.expectEqual(Result.no_value, tracked_grid_ref_point(ref, .active, &coord));
+
+ try testing.expectEqual(Result.invalid_value, tracked_grid_ref_set(
+ ref,
+ terminal,
+ point.Point.cval(.{ .active = .{ .x = 0, .y = 0 } }),
+ ));
+
+ tracked_grid_ref_free(ref);
+}
diff --git a/vendor/libghostty-vt/src/terminal/c/main.zig b/vendor/libghostty-vt/src/terminal/c/main.zig
index 126b6640..648bdbe5 100644
--- a/vendor/libghostty-vt/src/terminal/c/main.zig
+++ b/vendor/libghostty-vt/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;
@@ -30,6 +31,7 @@ pub const modes = @import("modes.zig");
pub const osc = @import("osc.zig");
pub const render = @import("render.zig");
pub const selection = @import("selection.zig");
+pub const selection_gesture = @import("selection_gesture.zig");
pub const key_event = @import("key_event.zig");
pub const key_encode = @import("key_encode.zig");
pub const mouse_event = @import("mouse_event.zig");
@@ -169,7 +171,29 @@ pub const terminal_mode_get = terminal.mode_get;
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_select_word = selection.word;
+pub const terminal_select_word_between = selection.word_between;
+pub const terminal_select_line = selection.line;
+pub const terminal_select_all = selection.all;
+pub const terminal_select_output = selection.output;
+pub const terminal_selection_format_buf = selection.format_buf;
+pub const terminal_selection_format_alloc = selection.format_alloc;
+pub const terminal_selection_adjust = selection.adjust;
+pub const terminal_selection_order = selection.order;
+pub const terminal_selection_ordered = selection.ordered;
+pub const terminal_selection_contains = selection.contains;
+pub const terminal_selection_equal = selection.equal;
+pub const selection_gesture_new = selection_gesture.new;
+pub const selection_gesture_free = selection_gesture.free;
+pub const selection_gesture_reset = selection_gesture.reset;
+pub const selection_gesture_event = selection_gesture.handle_event;
+pub const selection_gesture_get = selection_gesture.get;
+pub const selection_gesture_get_multi = selection_gesture.get_multi;
+pub const selection_gesture_event_new = selection_gesture.event_new;
+pub const selection_gesture_event_free = selection_gesture.event_free;
+pub const selection_gesture_event_set = selection_gesture.event_set;
pub const 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 +203,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 +215,7 @@ test {
_ = cell;
_ = color;
_ = grid_ref;
+ _ = grid_ref_tracked;
_ = kitty_graphics;
_ = row;
_ = focus;
@@ -194,6 +224,7 @@ test {
_ = osc;
_ = render;
_ = selection;
+ _ = selection_gesture;
_ = key_event;
_ = key_encode;
_ = mouse_event;
diff --git a/vendor/libghostty-vt/src/terminal/c/render.zig b/vendor/libghostty-vt/src/terminal/c/render.zig
index af82ddfa..0f3b2e78 100644
--- a/vendor/libghostty-vt/src/terminal/c/render.zig
+++ b/vendor/libghostty-vt/src/terminal/c/render.zig
@@ -31,6 +31,7 @@ const RowIteratorWrapper = struct {
/// These are the raw pointers into the render state data.
raws: []const page.Row,
cells: []const std.MultiArrayList(renderpkg.RenderState.Cell),
+ selection: []const ?[2]size.CellCountInt,
dirty: []bool,
/// The color palette from the render state, needed to resolve
@@ -44,6 +45,7 @@ const RowCellsWrapper = struct {
raws: []const page.Cell,
graphemes: []const []const u21,
styles: []const Style,
+ selection: ?[2]size.CellCountInt,
/// The color palette, needed to resolve palette-indexed background colors.
palette: *const colorpkg.Palette,
@@ -61,6 +63,13 @@ pub const RowCells = ?*RowCellsWrapper;
/// C: GhosttyRenderStateDirty
pub const Dirty = renderpkg.RenderState.Dirty;
+/// C: GhosttyRenderStateRowSelection
+pub const RowSelection = extern struct {
+ size: usize = @sizeOf(RowSelection),
+ start_x: u16 = 0,
+ end_x: u16 = 0,
+};
+
/// C: GhosttyRenderStateCursorVisualStyle
pub const CursorVisualStyle = enum(c_int) {
bar = 0,
@@ -241,6 +250,7 @@ fn getTyped(
.y = null,
.raws = row_data.items(.raw),
.cells = row_data.items(.cells),
+ .selection = row_data.items(.selection),
.dirty = row_data.items(.dirty),
.palette = &state.state.colors.palette,
};
@@ -381,6 +391,7 @@ pub fn row_iterator_new(
.y = undefined,
.raws = undefined,
.cells = undefined,
+ .selection = undefined,
.dirty = undefined,
.palette = undefined,
};
@@ -417,6 +428,7 @@ pub fn row_cells_new(
.raws = undefined,
.graphemes = undefined,
.styles = undefined,
+ .selection = undefined,
.palette = undefined,
};
result.* = ptr;
@@ -453,6 +465,9 @@ pub const RowCellsData = enum(c_int) {
graphemes_buf = 4,
bg_color = 5,
fg_color = 6,
+ selected = 7,
+ has_styling = 8,
+ graphemes_utf8 = 9,
/// Output type expected for querying the data of the given kind.
pub fn OutType(comptime self: RowCellsData) type {
@@ -463,6 +478,8 @@ pub const RowCellsData = enum(c_int) {
.graphemes_len => u32,
.graphemes_buf => u32,
.bg_color, .fg_color => colorpkg.RGB.C,
+ .selected, .has_styling => bool,
+ .graphemes_utf8 => lib.Buffer,
};
}
};
@@ -478,6 +495,7 @@ pub fn row_cells_get(
return .invalid_value;
};
}
+ if (out == null) return .invalid_value;
return switch (data) {
.invalid => .invalid_value,
@@ -553,17 +571,56 @@ fn rowCellsGetTyped(
const fg = s.fg(.{ .default = .{}, .palette = cells.palette });
out.* = fg.cval();
},
+ .selected => out.* = if (cells.selection) |sel|
+ x >= sel[0] and x <= sel[1]
+ else
+ false,
+ .has_styling => out.* = cell.hasStyling(),
+ .graphemes_utf8 => return rowCellsGetGraphemesUtf8(cell, if (cell.hasGrapheme()) cells.graphemes[x] else &.{}, out),
}
return .success;
}
+fn rowCellsGetGraphemesUtf8(
+ cell: page.Cell,
+ extra: []const u21,
+ out: *lib.Buffer,
+) Result {
+ out.len = 0;
+
+ if (!cell.hasText()) return .success;
+
+ var needed = std.unicode.utf8CodepointSequenceLength(cell.codepoint()) catch
+ return .invalid_value;
+ for (extra) |cp| {
+ needed += std.unicode.utf8CodepointSequenceLength(cp) catch
+ return .invalid_value;
+ }
+ out.len = needed;
+
+ if (out.ptr == null or out.cap < needed) return .out_of_space;
+
+ const buf = out.ptr.?[0..out.cap];
+ var i: usize = 0;
+ i += std.unicode.utf8Encode(cell.codepoint(), buf[i..]) catch
+ return .invalid_value;
+ for (extra) |cp| {
+ i += std.unicode.utf8Encode(cp, buf[i..]) catch
+ return .invalid_value;
+ }
+
+ out.len = i;
+ return .success;
+}
+
/// C: GhosttyRenderStateRowData
pub const RowData = enum(c_int) {
invalid = 0,
dirty = 1,
raw = 2,
cells = 3,
+ selection = 4,
/// Output type expected for querying the data of the given kind.
pub fn OutType(comptime self: RowData) type {
@@ -572,6 +629,7 @@ pub const RowData = enum(c_int) {
.dirty => bool,
.raw => row.CRow,
.cells => RowCells,
+ .selection => RowSelection,
};
}
};
@@ -651,9 +709,18 @@ fn rowGetTyped(
.raws = cell_data.items(.raw),
.graphemes = cell_data.items(.grapheme),
.styles = cell_data.items(.style),
+ .selection = it.selection[y],
.palette = it.palette,
};
},
+ .selection => {
+ const out_size = out.size;
+ if (out_size < @sizeOf(RowSelection)) return .invalid_value;
+
+ const sel = it.selection[y] orelse return .no_value;
+ out.start_x = sel[0];
+ out.end_x = sel[1];
+ },
}
return .success;
@@ -845,6 +912,7 @@ test "render: row iterator new/free" {
try testing.expectEqual(@as(?size.CellCountInt, null), iterator_ptr.y);
try testing.expectEqual(row_data.items(.raw).len, iterator_ptr.raws.len);
try testing.expectEqual(row_data.items(.cells).len, iterator_ptr.cells.len);
+ try testing.expectEqual(row_data.items(.selection).len, iterator_ptr.selection.len);
try testing.expectEqual(row_data.items(.dirty).len, iterator_ptr.dirty.len);
}
@@ -1026,6 +1094,267 @@ test "render: row get/set dirty" {
try testing.expect(!dirty);
}
+test "render: row get selection" {
+ 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);
+
+ const t = terminal.?.terminal;
+ const screen = t.screens.active;
+ try screen.select(.init(
+ screen.pages.pin(.{ .active = .{ .x = 2, .y = 1 } }).?,
+ screen.pages.pin(.{ .active = .{ .x = 4, .y = 1 } }).?,
+ false,
+ ));
+
+ var state: RenderState = null;
+ try testing.expectEqual(Result.success, new(
+ &lib.alloc.test_allocator,
+ &state,
+ ));
+ defer free(state);
+
+ try testing.expectEqual(Result.success, update(state, terminal));
+
+ var it: RowIterator = null;
+ try testing.expectEqual(Result.success, row_iterator_new(
+ &lib.alloc.test_allocator,
+ &it,
+ ));
+ defer row_iterator_free(it);
+
+ try testing.expectEqual(Result.success, get(state, .row_iterator, @ptrCast(&it)));
+
+ var sel: RowSelection = .{};
+ try testing.expect(row_iterator_next(it));
+ try testing.expectEqual(Result.no_value, row_get(it, .selection, @ptrCast(&sel)));
+
+ try testing.expect(row_iterator_next(it));
+ sel = .{};
+ try testing.expectEqual(Result.success, row_get(it, .selection, @ptrCast(&sel)));
+ try testing.expectEqual(@as(u16, 2), sel.start_x);
+ try testing.expectEqual(@as(u16, 4), sel.end_x);
+
+ try testing.expect(row_iterator_next(it));
+ sel = .{};
+ try testing.expectEqual(Result.no_value, row_get(it, .selection, @ptrCast(&sel)));
+}
+
+test "render: row cells get selected" {
+ 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);
+
+ const t = terminal.?.terminal;
+ const screen = t.screens.active;
+ try screen.select(.init(
+ screen.pages.pin(.{ .active = .{ .x = 2, .y = 1 } }).?,
+ screen.pages.pin(.{ .active = .{ .x = 4, .y = 1 } }).?,
+ false,
+ ));
+
+ 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)));
+
+ var selected: bool = true;
+ try testing.expectEqual(Result.success, row_cells_select(cells, 0));
+ try testing.expectEqual(Result.success, row_cells_get(cells, .selected, @ptrCast(&selected)));
+ try testing.expect(!selected);
+
+ 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, 1));
+ try testing.expectEqual(Result.success, row_cells_get(cells, .selected, @ptrCast(&selected)));
+ try testing.expect(!selected);
+
+ try testing.expectEqual(Result.success, row_cells_select(cells, 2));
+ try testing.expectEqual(Result.success, row_cells_get(cells, .selected, @ptrCast(&selected)));
+ try testing.expect(selected);
+
+ try testing.expectEqual(Result.success, row_cells_select(cells, 4));
+ try testing.expectEqual(Result.success, row_cells_get(cells, .selected, @ptrCast(&selected)));
+ try testing.expect(selected);
+
+ try testing.expectEqual(Result.success, row_cells_select(cells, 5));
+ try testing.expectEqual(Result.success, row_cells_get(cells, .selected, @ptrCast(&selected)));
+ try testing.expect(!selected);
+
+ try testing.expectEqual(Result.success, row_cells_select(cells, 3));
+ selected = false;
+ var written: usize = 0;
+ const keys = [_]RowCellsData{.selected};
+ var values = [_]?*anyopaque{@ptrCast(&selected)};
+ try testing.expectEqual(Result.success, row_cells_get_multi(cells, keys.len, &keys, &values, &written));
+ try testing.expectEqual(keys.len, written);
+ try testing.expect(selected);
+}
+
+test "render: row cells get has_styling" {
+ 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);
+
+ const input = "A\x1b[31mB";
+ terminal_c.vt_write(terminal, input, 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)));
+
+ var has_styling = true;
+ try testing.expectEqual(Result.success, row_cells_select(cells, 0));
+ try testing.expectEqual(Result.success, row_cells_get(cells, .has_styling, @ptrCast(&has_styling)));
+ try testing.expect(!has_styling);
+
+ try testing.expectEqual(Result.success, row_cells_select(cells, 1));
+ try testing.expectEqual(Result.success, row_cells_get(cells, .has_styling, @ptrCast(&has_styling)));
+ try testing.expect(has_styling);
+
+ has_styling = false;
+ var written: usize = 0;
+ const keys = [_]RowCellsData{.has_styling};
+ var values = [_]?*anyopaque{@ptrCast(&has_styling)};
+ try testing.expectEqual(Result.success, row_cells_get_multi(cells, keys.len, &keys, &values, &written));
+ try testing.expectEqual(keys.len, written);
+ try testing.expect(has_styling);
+}
+
+test "render: row cells get graphemes utf8" {
+ 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);
+
+ const input = "e\u{301}";
+ terminal_c.vt_write(terminal, input, 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(@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 };
+ try testing.expectEqual(Result.success, row_cells_get(cells, .graphemes_utf8, @ptrCast(&text)));
+ try testing.expectEqual(@as(usize, 0), text.len);
+}
+
test "render: row iterator next" {
var terminal: terminal_c.Terminal = null;
try testing.expectEqual(Result.success, terminal_c.new(
diff --git a/vendor/libghostty-vt/src/terminal/c/selection.zig b/vendor/libghostty-vt/src/terminal/c/selection.zig
index 74e96598..cb574ecc 100644
--- a/vendor/libghostty-vt/src/terminal/c/selection.zig
+++ b/vendor/libghostty-vt/src/terminal/c/selection.zig
@@ -1,5 +1,20 @@
+const std = @import("std");
+const testing = std.testing;
+const lib = @import("../lib.zig");
+const CAllocator = lib.alloc.Allocator;
+const formatterpkg = @import("../formatter.zig");
const grid_ref = @import("grid_ref.zig");
+const point = @import("../point.zig");
+const selection_codepoints = @import("../selection_codepoints.zig");
const Selection = @import("../Selection.zig");
+const Result = @import("result.zig").Result;
+const terminal_c = @import("terminal.zig");
+
+const log = std.log.scoped(.selection_c);
+
+pub const Adjustment = Selection.Adjustment;
+pub const Order = Selection.Order;
+pub const Format = formatterpkg.Format;
/// C: GhosttySelection
pub const CSelection = extern struct {
@@ -13,4 +28,504 @@ pub const CSelection = extern struct {
const end_pin = self.end.toPin() orelse return null;
return Selection.init(start_pin, end_pin, self.rectangle);
}
+
+ pub fn fromZig(sel: Selection) CSelection {
+ return .{
+ .start = .fromPin(sel.start()),
+ .end = .fromPin(sel.end()),
+ .rectangle = sel.rectangle,
+ };
+ }
+};
+
+/// C: GhosttyTerminalSelectWordOptions
+pub const SelectWordOptions = extern struct {
+ size: usize = @sizeOf(SelectWordOptions),
+ ref: grid_ref.CGridRef,
+ boundary_codepoints: ?[*]const u32 = null,
+ boundary_codepoints_len: usize = 0,
+};
+
+/// C: GhosttyTerminalSelectWordBetweenOptions
+pub const SelectWordBetweenOptions = extern struct {
+ size: usize = @sizeOf(SelectWordBetweenOptions),
+ start: grid_ref.CGridRef,
+ end: grid_ref.CGridRef,
+ boundary_codepoints: ?[*]const u32 = null,
+ boundary_codepoints_len: usize = 0,
};
+
+/// C: GhosttyTerminalSelectLineOptions
+pub const SelectLineOptions = extern struct {
+ size: usize = @sizeOf(SelectLineOptions),
+ ref: grid_ref.CGridRef,
+ whitespace: ?[*]const u32 = null,
+ whitespace_len: usize = 0,
+ semantic_prompt_boundary: bool = false,
+};
+
+/// C: GhosttyTerminalSelectionFormatOptions
+pub const FormatOptions = extern struct {
+ size: usize = @sizeOf(FormatOptions),
+ emit: Format,
+ unwrap: bool,
+ trim: bool,
+ selection: ?*const CSelection = null,
+};
+
+pub fn word(
+ terminal: terminal_c.Terminal,
+ options: ?*const SelectWordOptions,
+ out_selection: ?*CSelection,
+) callconv(lib.calling_conv) Result {
+ const t = terminal_c.zigTerminal(terminal) orelse return .invalid_value;
+ const opts = options orelse return .invalid_value;
+ if (opts.size < @sizeOf(SelectWordOptions)) return .invalid_value;
+ const out = out_selection orelse return .invalid_value;
+
+ const boundary_codepoints = codepointSlice(
+ opts.boundary_codepoints,
+ opts.boundary_codepoints_len,
+ ) catch return .invalid_value;
+
+ const screen = t.screens.active;
+ const pin = opts.ref.toPin() orelse return .invalid_value;
+ out.* = .fromZig(screen.selectWord(
+ pin,
+ boundary_codepoints orelse &selection_codepoints.default_word_boundaries,
+ ) orelse
+ return .no_value);
+ return .success;
+}
+
+pub fn word_between(
+ terminal: terminal_c.Terminal,
+ options: ?*const SelectWordBetweenOptions,
+ out_selection: ?*CSelection,
+) callconv(lib.calling_conv) Result {
+ const t = terminal_c.zigTerminal(terminal) orelse return .invalid_value;
+ const opts = options orelse return .invalid_value;
+ if (opts.size < @sizeOf(SelectWordBetweenOptions)) return .invalid_value;
+ const out = out_selection orelse return .invalid_value;
+
+ const boundary_codepoints = codepointSlice(
+ opts.boundary_codepoints,
+ opts.boundary_codepoints_len,
+ ) catch return .invalid_value;
+
+ const screen = t.screens.active;
+ const start = opts.start.toPin() orelse return .invalid_value;
+ const end = opts.end.toPin() orelse return .invalid_value;
+ out.* = .fromZig(screen.selectWordBetween(
+ start,
+ end,
+ boundary_codepoints orelse &selection_codepoints.default_word_boundaries,
+ ) orelse
+ return .no_value);
+ return .success;
+}
+
+pub fn line(
+ terminal: terminal_c.Terminal,
+ options: ?*const SelectLineOptions,
+ out_selection: ?*CSelection,
+) callconv(lib.calling_conv) Result {
+ const t = terminal_c.zigTerminal(terminal) orelse return .invalid_value;
+ const opts = options orelse return .invalid_value;
+ if (opts.size < @sizeOf(SelectLineOptions)) return .invalid_value;
+ const out = out_selection orelse return .invalid_value;
+
+ const whitespace = codepointSlice(
+ opts.whitespace,
+ opts.whitespace_len,
+ ) catch return .invalid_value;
+
+ const screen = t.screens.active;
+ const pin = opts.ref.toPin() orelse return .invalid_value;
+ out.* = .fromZig(screen.selectLine(.{
+ .pin = pin,
+ .whitespace = whitespace orelse &selection_codepoints.default_line_whitespace,
+ .semantic_prompt_boundary = opts.semantic_prompt_boundary,
+ }) orelse return .no_value);
+ return .success;
+}
+
+pub fn all(
+ terminal: terminal_c.Terminal,
+ out_selection: ?*CSelection,
+) callconv(lib.calling_conv) Result {
+ const t = terminal_c.zigTerminal(terminal) orelse return .invalid_value;
+ const out = out_selection orelse return .invalid_value;
+
+ out.* = .fromZig(t.screens.active.selectAll() orelse return .no_value);
+ return .success;
+}
+
+pub fn output(
+ terminal: terminal_c.Terminal,
+ ref: grid_ref.CGridRef,
+ out_selection: ?*CSelection,
+) callconv(lib.calling_conv) Result {
+ const t = terminal_c.zigTerminal(terminal) orelse return .invalid_value;
+ const out = out_selection orelse return .invalid_value;
+
+ const screen = t.screens.active;
+ const pin = ref.toPin() orelse return .invalid_value;
+ out.* = .fromZig(screen.selectOutput(pin) orelse return .no_value);
+ return .success;
+}
+
+pub fn format_buf(
+ terminal: terminal_c.Terminal,
+ opts: FormatOptions,
+ out_: ?[*]u8,
+ out_len: usize,
+ out_written: *usize,
+) callconv(lib.calling_conv) Result {
+ const t = terminal_c.zigTerminal(terminal) orelse return .invalid_value;
+
+ if (out_ == null) {
+ var discarding: std.Io.Writer.Discarding = .init(&.{});
+ formatSelection(t, opts, &discarding.writer) catch |err| return switch (err) {
+ error.InvalidValue => .invalid_value,
+ error.NoValue => .no_value,
+ error.WriteFailed => unreachable,
+ };
+ out_written.* = @intCast(discarding.count);
+ return .out_of_space;
+ }
+
+ var writer: std.Io.Writer = .fixed(out_.?[0..out_len]);
+ formatSelection(t, opts, &writer) catch |err| switch (err) {
+ error.InvalidValue => return .invalid_value,
+ error.NoValue => return .no_value,
+ error.WriteFailed => {
+ var discarding: std.Io.Writer.Discarding = .init(&.{});
+ formatSelection(t, opts, &discarding.writer) catch unreachable;
+ out_written.* = @intCast(discarding.count);
+ return .out_of_space;
+ },
+ };
+
+ out_written.* = writer.end;
+ return .success;
+}
+
+pub fn format_alloc(
+ terminal: terminal_c.Terminal,
+ alloc_: ?*const CAllocator,
+ opts: FormatOptions,
+ out_ptr: *?[*]u8,
+ out_len: *usize,
+) callconv(lib.calling_conv) Result {
+ out_ptr.* = null;
+ out_len.* = 0;
+
+ const t = terminal_c.zigTerminal(terminal) orelse return .invalid_value;
+ const alloc = lib.alloc.default(alloc_);
+
+ var aw: std.Io.Writer.Allocating = .init(alloc);
+ defer aw.deinit();
+
+ formatSelection(t, opts, &aw.writer) catch |err| return switch (err) {
+ error.InvalidValue => .invalid_value,
+ error.NoValue => .no_value,
+ error.WriteFailed => .out_of_memory,
+ };
+
+ const buf = aw.toOwnedSlice() catch return .out_of_memory;
+ out_ptr.* = buf.ptr;
+ out_len.* = buf.len;
+ return .success;
+}
+
+fn formatSelection(
+ t: *terminal_c.ZigTerminal,
+ opts: FormatOptions,
+ writer: *std.Io.Writer,
+) error{ InvalidValue, NoValue, WriteFailed }!void {
+ var formatter = selectionFormatter(t, opts) catch |err| return err;
+ try formatter.format(writer);
+}
+
+fn selectionFormatter(
+ t: *terminal_c.ZigTerminal,
+ opts: FormatOptions,
+) error{ InvalidValue, NoValue }!formatterpkg.TerminalFormatter {
+ if (opts.size < @sizeOf(FormatOptions)) return error.InvalidValue;
+ _ = std.meta.intToEnum(Format, @intFromEnum(opts.emit)) catch
+ return error.InvalidValue;
+
+ const sel = if (opts.selection) |sel|
+ sel.toZig() orelse return error.InvalidValue
+ else
+ t.screens.active.selection orelse return error.NoValue;
+
+ var formatter: formatterpkg.TerminalFormatter = .init(t, .{
+ .emit = opts.emit,
+ .unwrap = opts.unwrap,
+ .trim = opts.trim,
+ });
+ formatter.content = .{ .selection = sel };
+ return formatter;
+}
+
+/// Return the borrowed C array of `uint32_t` codepoints as a `[]const u21`.
+///
+/// `NULL + len 0` returns null, which callers treat as “use the API default
+/// set.” A non-null pointer with `len 0` returns an empty slice, meaning “use an
+/// explicitly empty set.” A non-zero length requires a non-null pointer.
+///
+/// This is intentionally zero-copy. In the C ABI, codepoints are `uint32_t`,
+/// but selection internals use Zig's `u21` to represent valid Unicode scalar
+/// values. Zig currently stores `u21` in the same size and alignment as `u32`,
+/// so we assert that layout relationship and reinterpret the borrowed slice.
+/// If Zig ever changes that representation, these comptime assertions fail
+/// loudly rather than silently making this cast wrong.
+fn codepointSlice(
+ ptr: ?[*]const u32,
+ len: usize,
+) error{InvalidValue}!?[]const u21 {
+ comptime {
+ std.debug.assert(@sizeOf(u21) == @sizeOf(u32));
+ std.debug.assert(@alignOf(u21) == @alignOf(u32));
+ }
+
+ if (len == 0) {
+ const p = ptr orelse return null;
+ _ = p;
+ return &.{};
+ }
+
+ const p = ptr orelse return error.InvalidValue;
+ const cps: [*]const u21 = @ptrCast(p);
+ return cps[0..len];
+}
+
+pub fn adjust(
+ terminal: terminal_c.Terminal,
+ selection: ?*CSelection,
+ adjustment: Selection.Adjustment,
+) callconv(lib.calling_conv) Result {
+ if (comptime std.debug.runtime_safety) {
+ _ = std.meta.intToEnum(Selection.Adjustment, @intFromEnum(adjustment)) catch {
+ log.warn("terminal_selection_adjust invalid adjustment value={d}", .{@intFromEnum(adjustment)});
+ return .invalid_value;
+ };
+ }
+
+ const t = terminal_c.zigTerminal(terminal) orelse return .invalid_value;
+ const sel_ptr = selection orelse return .invalid_value;
+ var sel = sel_ptr.toZig() orelse return .invalid_value;
+ sel.adjust(t.screens.active, adjustment);
+ sel_ptr.* = .fromZig(sel);
+ return .success;
+}
+
+pub fn order(
+ terminal: terminal_c.Terminal,
+ selection: ?*const CSelection,
+ out_order: ?*Selection.Order,
+) callconv(lib.calling_conv) Result {
+ const t = terminal_c.zigTerminal(terminal) orelse return .invalid_value;
+ const sel = (selection orelse return .invalid_value).toZig() orelse
+ return .invalid_value;
+ const out = out_order orelse return .invalid_value;
+
+ out.* = sel.order(t.screens.active);
+ return .success;
+}
+
+pub fn ordered(
+ terminal: terminal_c.Terminal,
+ selection: ?*const CSelection,
+ desired: Selection.Order,
+ out_selection: ?*CSelection,
+) callconv(lib.calling_conv) Result {
+ if (comptime std.debug.runtime_safety) {
+ _ = std.meta.intToEnum(Selection.Order, @intFromEnum(desired)) catch {
+ log.warn("terminal_selection_ordered invalid desired value={d}", .{@intFromEnum(desired)});
+ return .invalid_value;
+ };
+ }
+
+ const t = terminal_c.zigTerminal(terminal) orelse return .invalid_value;
+ const sel = (selection orelse return .invalid_value).toZig() orelse
+ return .invalid_value;
+ const out = out_selection orelse return .invalid_value;
+
+ out.* = .fromZig(sel.ordered(t.screens.active, desired));
+ return .success;
+}
+
+pub fn contains(
+ terminal: terminal_c.Terminal,
+ selection: ?*const CSelection,
+ pt: point.Point.C,
+ out_contains: ?*bool,
+) callconv(lib.calling_conv) Result {
+ const t = terminal_c.zigTerminal(terminal) orelse return .invalid_value;
+ const sel = (selection orelse return .invalid_value).toZig() orelse
+ return .invalid_value;
+ const out = out_contains orelse return .invalid_value;
+
+ const screen = t.screens.active;
+ const pin = screen.pages.pin(.fromC(pt)) orelse return .invalid_value;
+ out.* = sel.contains(screen, pin);
+ return .success;
+}
+
+pub fn equal(
+ terminal: terminal_c.Terminal,
+ a: ?*const CSelection,
+ b: ?*const CSelection,
+ out_equal: ?*bool,
+) callconv(lib.calling_conv) Result {
+ _ = terminal_c.zigTerminal(terminal) orelse return .invalid_value;
+ const sel_a = (a orelse return .invalid_value).toZig() orelse
+ return .invalid_value;
+ const sel_b = (b orelse return .invalid_value).toZig() orelse
+ return .invalid_value;
+ const out = out_equal orelse return .invalid_value;
+
+ out.* = sel_a.eql(sel_b);
+ return .success;
+}
+
+test "selection_format_alloc uses active selection" {
+ 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 = 10_000 },
+ ));
+ defer terminal_c.free(t);
+
+ terminal_c.vt_write(t, "Hello World", 11);
+
+ var start_ref: grid_ref.CGridRef = .{};
+ try testing.expectEqual(Result.success, terminal_c.grid_ref(t, .{
+ .tag = .active,
+ .value = .{ .active = .{ .x = 6, .y = 0 } },
+ }, &start_ref));
+
+ var end_ref: grid_ref.CGridRef = .{};
+ try testing.expectEqual(Result.success, terminal_c.grid_ref(t, .{
+ .tag = .active,
+ .value = .{ .active = .{ .x = 10, .y = 0 } },
+ }, &end_ref));
+
+ const sel: CSelection = .{
+ .start = start_ref,
+ .end = end_ref,
+ };
+ try testing.expectEqual(Result.success, terminal_c.set(t, .selection, @ptrCast(&sel)));
+
+ const opts: FormatOptions = .{
+ .emit = .plain,
+ .unwrap = true,
+ .trim = true,
+ };
+
+ var required: usize = 0;
+ try testing.expectEqual(Result.out_of_space, format_buf(
+ t,
+ opts,
+ null,
+ 0,
+ &required,
+ ));
+ try testing.expectEqual(@as(usize, 5), required);
+
+ var out_ptr: ?[*]u8 = null;
+ var out_len: usize = 0;
+ try testing.expectEqual(Result.success, format_alloc(
+ t,
+ &lib.alloc.test_allocator,
+ opts,
+ &out_ptr,
+ &out_len,
+ ));
+ const ptr = out_ptr orelse return error.TestExpectedEqual;
+ defer lib.alloc.default(&lib.alloc.test_allocator).free(ptr[0..out_len]);
+
+ try testing.expectEqualStrings("World", ptr[0..out_len]);
+}
+
+test "selection_format_buf uses provided selection" {
+ 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 = 10_000 },
+ ));
+ defer terminal_c.free(t);
+
+ terminal_c.vt_write(t, "Hello World", 11);
+
+ var start_ref: grid_ref.CGridRef = .{};
+ try testing.expectEqual(Result.success, terminal_c.grid_ref(t, .{
+ .tag = .active,
+ .value = .{ .active = .{ .x = 0, .y = 0 } },
+ }, &start_ref));
+
+ var end_ref: grid_ref.CGridRef = .{};
+ try testing.expectEqual(Result.success, terminal_c.grid_ref(t, .{
+ .tag = .active,
+ .value = .{ .active = .{ .x = 4, .y = 0 } },
+ }, &end_ref));
+
+ const sel: CSelection = .{
+ .start = start_ref,
+ .end = end_ref,
+ };
+ const opts: FormatOptions = .{
+ .emit = .plain,
+ .unwrap = true,
+ .trim = true,
+ .selection = &sel,
+ };
+
+ var small: [2]u8 = undefined;
+ var written: usize = 0;
+ try testing.expectEqual(Result.out_of_space, format_buf(
+ t,
+ opts,
+ &small,
+ small.len,
+ &written,
+ ));
+ try testing.expectEqual(@as(usize, 5), written);
+
+ var buf: [32]u8 = undefined;
+ try testing.expectEqual(Result.success, format_buf(
+ t,
+ opts,
+ &buf,
+ buf.len,
+ &written,
+ ));
+ try testing.expectEqualStrings("Hello", buf[0..written]);
+}
+
+test "selection_format_alloc returns no_value without active selection" {
+ 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 = 10_000 },
+ ));
+ defer terminal_c.free(t);
+
+ var out_ptr: ?[*]u8 = @ptrFromInt(1);
+ var out_len: usize = 123;
+ try testing.expectEqual(Result.no_value, format_alloc(
+ t,
+ &lib.alloc.test_allocator,
+ .{ .emit = .plain, .unwrap = true, .trim = true },
+ &out_ptr,
+ &out_len,
+ ));
+ try testing.expect(out_ptr == null);
+ try testing.expectEqual(@as(usize, 0), out_len);
+}
diff --git a/vendor/libghostty-vt/src/terminal/c/selection_gesture.zig b/vendor/libghostty-vt/src/terminal/c/selection_gesture.zig
new file mode 100644
index 00000000..cc56da3c
--- /dev/null
+++ b/vendor/libghostty-vt/src/terminal/c/selection_gesture.zig
@@ -0,0 +1,1488 @@
+const std = @import("std");
+const testing = std.testing;
+const builtin = @import("builtin");
+const lib = @import("../lib.zig");
+const CAllocator = lib.alloc.Allocator;
+const SelectionGesture = @import("../SelectionGesture.zig");
+const selection_codepoints = @import("../selection_codepoints.zig");
+const grid_ref = @import("grid_ref.zig");
+const point = @import("../point.zig");
+const selection_c = @import("selection.zig");
+const terminal_c = @import("terminal.zig");
+const types = @import("types.zig");
+const Result = @import("result.zig").Result;
+
+const log = std.log.scoped(.selection_gesture_c);
+
+/// C: GhosttySelectionGesture
+pub const Gesture = ?*GestureWrapper;
+
+/// C: GhosttySelectionGestureEvent
+pub const Event = ?*EventWrapper;
+
+const GestureWrapper = struct {
+ alloc: std.mem.Allocator,
+ gesture: SelectionGesture = .init,
+};
+
+const EventWrapper = struct {
+ alloc: std.mem.Allocator,
+ event: union(EventType) {
+ press: SelectionGesture.Press,
+ release: SelectionGesture.Release,
+ drag: SelectionGesture.Drag,
+ autoscroll_tick: SelectionGesture.AutoscrollTick,
+ deep_press: SelectionGesture.DeepPress,
+ },
+
+ // Validation sidecar for required event fields that don't have safe
+ // sentinels in the real SelectionGesture payloads. For example, PageList.Pin
+ // contains a non-null node pointer and Geometry has no meaningful zero
+ // value. Keep these as one-bit flags so dispatch can reject incomplete C
+ // events instead of using undefined placeholder data.
+ event_validation: packed struct {
+ press_pin_set: bool = false,
+ drag_pin_set: bool = false,
+ drag_geometry_set: bool = false,
+ autoscroll_tick_viewport_set: bool = false,
+ autoscroll_tick_geometry_set: bool = false,
+ } = .{},
+
+ // Backing storage for Press/Drag/AutoscrollTick.word_boundary_codepoints.
+ // The C API receives codepoints as borrowed uint32_t values, but
+ // SelectionGesture stores a []const u21 slice. We copy/convert into
+ // event-owned storage so the real payload can safely point at it until the
+ // event is changed or freed.
+ word_boundary_codepoints: ?[]u21 = null,
+
+ // Backing storage for Press.behaviors. The C API sets behaviors as a value
+ // struct, but SelectionGesture.Press stores a pointer to a [3]Behavior.
+ // Keep the array on the event wrapper so the Press payload can point at a
+ // stable location for the lifetime of the event.
+ behaviors: [3]Behavior = SelectionGesture.default_behaviors,
+
+ fn init(self: *EventWrapper, event_type: EventType) void {
+ self.event = switch (event_type) {
+ .press => .{ .press = self.defaultPress() },
+ .release => .{ .release = self.defaultRelease() },
+ .drag => .{ .drag = self.defaultDrag() },
+ .autoscroll_tick => .{ .autoscroll_tick = self.defaultAutoscrollTick() },
+ .deep_press => .{ .deep_press = self.defaultDeepPress() },
+ };
+ }
+
+ fn defaultPress(self: *EventWrapper) SelectionGesture.Press {
+ return .{
+ .time = null,
+ .pin = undefined,
+ .xpos = 0,
+ .ypos = 0,
+ .max_distance = 0,
+ .repeat_interval = 0,
+ .word_boundary_codepoints = &selection_codepoints.default_word_boundaries,
+ .behaviors = &self.behaviors,
+ };
+ }
+
+ fn defaultRelease(self: *EventWrapper) SelectionGesture.Release {
+ _ = self;
+ return .{ .pin = null };
+ }
+
+ fn defaultDrag(self: *EventWrapper) SelectionGesture.Drag {
+ _ = self;
+ return .{
+ .pin = undefined,
+ .xpos = 0,
+ .ypos = 0,
+ .rectangle = false,
+ .word_boundary_codepoints = &selection_codepoints.default_word_boundaries,
+ .geometry = undefined,
+ };
+ }
+
+ fn defaultAutoscrollTick(self: *EventWrapper) SelectionGesture.AutoscrollTick {
+ _ = self;
+ return .{
+ .viewport = undefined,
+ .xpos = 0,
+ .ypos = 0,
+ .rectangle = false,
+ .word_boundary_codepoints = &selection_codepoints.default_word_boundaries,
+ .geometry = undefined,
+ };
+ }
+
+ fn defaultDeepPress(self: *EventWrapper) SelectionGesture.DeepPress {
+ _ = self;
+ return .{
+ .word_boundary_codepoints = &selection_codepoints.default_word_boundaries,
+ };
+ }
+
+ fn deinit(self: *EventWrapper) void {
+ if (self.word_boundary_codepoints) |cps| {
+ if (cps.len > 0) self.alloc.free(cps);
+ }
+ }
+};
+
+/// C: GhosttySelectionGestureBehavior
+pub const Behavior = SelectionGesture.Behavior;
+
+/// C: GhosttySelectionGestureAutoscroll
+pub const Autoscroll = SelectionGesture.Autoscroll;
+
+/// C: GhosttySelectionGestureBehaviors
+pub const Behaviors = extern struct {
+ single_click: Behavior,
+ double_click: Behavior,
+ triple_click: Behavior,
+};
+
+/// C: GhosttySelectionGestureData
+pub const Data = enum(c_int) {
+ click_count = 0,
+ dragged = 1,
+ autoscroll = 2,
+ behavior = 3,
+ anchor = 4,
+
+ pub fn OutType(comptime self: Data) type {
+ return switch (self) {
+ .click_count => u8,
+ .dragged => bool,
+ .autoscroll => Autoscroll,
+ .behavior => Behavior,
+ .anchor => grid_ref.CGridRef,
+ };
+ }
+};
+
+/// C: GhosttySelectionGestureEventType
+pub const EventType = enum(c_int) {
+ press = 0,
+ release = 1,
+ drag = 2,
+ autoscroll_tick = 3,
+ deep_press = 4,
+};
+
+/// C: GhosttySelectionGestureEventOption
+pub const EventOption = enum(c_int) {
+ ref = 0,
+ position = 1,
+ repeat_distance = 2,
+ time_ns = 3,
+ repeat_interval_ns = 4,
+ word_boundary_codepoints = 5,
+ behaviors = 6,
+ rectangle = 7,
+ geometry = 8,
+ viewport = 9,
+
+ pub fn Type(comptime self: EventOption) type {
+ return switch (self) {
+ .ref => grid_ref.CGridRef,
+ .position => types.SurfacePosition,
+ .repeat_distance => f64,
+ .time_ns => u64,
+ .repeat_interval_ns => u64,
+ .word_boundary_codepoints => types.Codepoints,
+ .behaviors => Behaviors,
+ .rectangle => bool,
+ .geometry => Geometry,
+ .viewport => point.Coordinate,
+ };
+ }
+};
+
+/// C: GhosttySelectionGestureGeometry
+pub const Geometry = extern struct {
+ columns: u32,
+ cell_width: u32,
+ padding_left: u32,
+ screen_height: u32,
+
+ fn toZig(self: Geometry) ?SelectionGesture.Drag.Geometry {
+ if (self.columns == 0) return null;
+ if (self.cell_width == 0) return null;
+ if (self.screen_height == 0) return null;
+ return .{
+ .columns = self.columns,
+ .cell_width = self.cell_width,
+ .padding_left = self.padding_left,
+ .screen_height = self.screen_height,
+ };
+ }
+};
+
+pub fn new(
+ alloc_: ?*const CAllocator,
+ out_gesture: ?*Gesture,
+) callconv(lib.calling_conv) Result {
+ const out = out_gesture orelse return .invalid_value;
+
+ const alloc = lib.alloc.default(alloc_);
+ const gesture = alloc.create(GestureWrapper) catch {
+ out.* = null;
+ return .out_of_memory;
+ };
+ gesture.* = .{
+ .alloc = alloc,
+ };
+ out.* = gesture;
+ return .success;
+}
+
+pub fn event_new(
+ alloc_: ?*const CAllocator,
+ out_event: ?*Event,
+ event_type: EventType,
+) callconv(lib.calling_conv) Result {
+ const out = out_event orelse return .invalid_value;
+ _ = std.meta.intToEnum(EventType, @intFromEnum(event_type)) catch
+ return .invalid_value;
+
+ const alloc = lib.alloc.default(alloc_);
+ const event = alloc.create(EventWrapper) catch {
+ out.* = null;
+ return .out_of_memory;
+ };
+ event.* = .{
+ .alloc = alloc,
+ .event = undefined,
+ };
+ event.init(event_type);
+ out.* = event;
+ return .success;
+}
+
+pub fn free(
+ gesture_: Gesture,
+ terminal: terminal_c.Terminal,
+) callconv(lib.calling_conv) void {
+ const wrapper = gesture_ orelse return;
+ if (terminal_c.zigTerminal(terminal)) |t| {
+ wrapper.gesture.deinit(t);
+ }
+ const alloc = wrapper.alloc;
+ alloc.destroy(wrapper);
+}
+
+pub fn event_free(event_: Event) callconv(lib.calling_conv) void {
+ const event = event_ orelse return;
+ event.deinit();
+ const alloc = event.alloc;
+ alloc.destroy(event);
+}
+
+pub fn reset(
+ gesture_: Gesture,
+ terminal: terminal_c.Terminal,
+) callconv(lib.calling_conv) void {
+ const wrapper = gesture_ orelse return;
+ const t = terminal_c.zigTerminal(terminal) orelse return;
+ wrapper.gesture.reset(t);
+}
+
+pub fn handle_event(
+ gesture_: Gesture,
+ terminal: terminal_c.Terminal,
+ event_: Event,
+ out_selection: ?*selection_c.CSelection,
+) callconv(lib.calling_conv) Result {
+ const wrapper = gesture_ orelse return .invalid_value;
+ const t = terminal_c.zigTerminal(terminal) orelse return .invalid_value;
+ const event_wrapper = event_ orelse return .invalid_value;
+
+ return switch (event_wrapper.event) {
+ .press => |press| {
+ if (!event_wrapper.event_validation.press_pin_set) return .invalid_value;
+ const sel = wrapper.gesture.press(t, press) catch return .out_of_memory;
+ if (out_selection) |out| {
+ out.* = selection_c.CSelection.fromZig(sel orelse return .no_value);
+ } else if (sel == null) return .no_value;
+ return .success;
+ },
+ .release => |release| {
+ wrapper.gesture.release(t, release);
+ return .no_value;
+ },
+ .drag => |drag| {
+ if (!event_wrapper.event_validation.drag_pin_set) return .invalid_value;
+ if (!event_wrapper.event_validation.drag_geometry_set) return .invalid_value;
+ const sel = wrapper.gesture.drag(t, drag);
+ if (out_selection) |out| {
+ out.* = selection_c.CSelection.fromZig(sel orelse return .no_value);
+ } else if (sel == null) return .no_value;
+ return .success;
+ },
+ .autoscroll_tick => |tick| {
+ if (!event_wrapper.event_validation.autoscroll_tick_viewport_set) return .invalid_value;
+ if (!event_wrapper.event_validation.autoscroll_tick_geometry_set) return .invalid_value;
+ const sel = wrapper.gesture.autoscrollTick(t, tick);
+ if (out_selection) |out| {
+ out.* = selection_c.CSelection.fromZig(sel orelse return .no_value);
+ } else if (sel == null) return .no_value;
+ return .success;
+ },
+ .deep_press => |deep_press| {
+ const sel = wrapper.gesture.deepPress(t, deep_press);
+ if (out_selection) |out| {
+ out.* = selection_c.CSelection.fromZig(sel orelse return .no_value);
+ } else if (sel == null) return .no_value;
+ return .success;
+ },
+ };
+}
+
+pub fn event_set(
+ event_: Event,
+ option: EventOption,
+ value: ?*const anyopaque,
+) callconv(lib.calling_conv) Result {
+ if (comptime std.debug.runtime_safety) {
+ _ = std.meta.intToEnum(EventOption, @intFromEnum(option)) catch {
+ log.warn("selection_gesture_event_set invalid option value={d}", .{@intFromEnum(option)});
+ return .invalid_value;
+ };
+ }
+
+ return switch (option) {
+ inline else => |comptime_option| eventSetTyped(
+ event_,
+ comptime_option,
+ if (value) |ptr| @ptrCast(@alignCast(ptr)) else null,
+ ),
+ };
+}
+
+pub fn get(
+ gesture_: Gesture,
+ terminal: terminal_c.Terminal,
+ data: Data,
+ out: ?*anyopaque,
+) callconv(lib.calling_conv) Result {
+ if (comptime std.debug.runtime_safety) {
+ _ = std.meta.intToEnum(Data, @intFromEnum(data)) catch {
+ log.warn("selection_gesture_get invalid data value={d}", .{@intFromEnum(data)});
+ return .invalid_value;
+ };
+ }
+
+ const out_ptr = out orelse return .invalid_value;
+ return switch (data) {
+ inline else => |comptime_data| getTyped(
+ gesture_,
+ terminal,
+ comptime_data,
+ @ptrCast(@alignCast(out_ptr)),
+ ),
+ };
+}
+
+pub fn get_multi(
+ gesture_: Gesture,
+ terminal: terminal_c.Terminal,
+ count: usize,
+ keys: ?[*]const Data,
+ values: ?[*]?*anyopaque,
+ out_written: ?*usize,
+) callconv(lib.calling_conv) Result {
+ const k = keys orelse return .invalid_value;
+ const v = values orelse return .invalid_value;
+
+ for (0..count) |i| {
+ const result = get(gesture_, terminal, k[i], v[i]);
+ if (result != .success) {
+ if (out_written) |w| w.* = i;
+ return result;
+ }
+ }
+ if (out_written) |w| w.* = count;
+ return .success;
+}
+
+fn getTyped(
+ gesture_: Gesture,
+ terminal: terminal_c.Terminal,
+ comptime data: Data,
+ out: *data.OutType(),
+) Result {
+ const wrapper = gesture_ orelse return .invalid_value;
+ const t = terminal_c.zigTerminal(terminal) orelse return .invalid_value;
+
+ switch (data) {
+ .click_count => out.* = wrapper.gesture.left_click_count,
+ .dragged => out.* = wrapper.gesture.left_click_dragged,
+ .autoscroll => out.* = wrapper.gesture.left_drag_autoscroll,
+ .behavior => out.* = wrapper.gesture.left_click_behavior,
+ .anchor => {
+ const pin = wrapper.gesture.validatedLeftClickPin(&t.screens) orelse
+ return .no_value;
+ out.* = .fromPin(pin.*);
+ },
+ }
+
+ return .success;
+}
+
+fn eventSetTyped(
+ event_: Event,
+ comptime option: EventOption,
+ value: ?*const option.Type(),
+) Result {
+ const event = event_ orelse return .invalid_value;
+ return switch (event.event) {
+ .press => |*press| pressSetTyped(event, press, option, value),
+ .release => |*release| releaseSetTyped(release, option, value),
+ .drag => |*drag| dragSetTyped(event, drag, option, value),
+ .autoscroll_tick => |*tick| autoscrollTickSetTyped(event, tick, option, value),
+ .deep_press => |*deep_press| deepPressSetTyped(event, deep_press, option, value),
+ };
+}
+
+fn pressSetTyped(
+ event: *EventWrapper,
+ press: *SelectionGesture.Press,
+ comptime option: EventOption,
+ value: ?*const option.Type(),
+) Result {
+ const v = value orelse {
+ switch (option) {
+ .ref => event.event_validation.press_pin_set = false,
+ .position => {
+ press.xpos = 0;
+ press.ypos = 0;
+ },
+ .repeat_distance => press.max_distance = 0,
+ .time_ns => press.time = null,
+ .repeat_interval_ns => press.repeat_interval = 0,
+ .word_boundary_codepoints => clearWordBoundaryCodepoints(
+ event,
+ &press.word_boundary_codepoints,
+ ),
+ .behaviors => {
+ event.behaviors = SelectionGesture.default_behaviors;
+ press.behaviors = &event.behaviors;
+ },
+ .rectangle,
+ .geometry,
+ .viewport,
+ => return .invalid_value,
+ }
+ return .success;
+ };
+
+ switch (option) {
+ .ref => {
+ press.pin = v.toPin() orelse return .invalid_value;
+ event.event_validation.press_pin_set = true;
+ },
+ .position => {
+ press.xpos = v.x;
+ press.ypos = v.y;
+ },
+ .repeat_distance => press.max_distance = v.*,
+ .time_ns => press.time = instantFromNs(v.*),
+ .repeat_interval_ns => press.repeat_interval = v.*,
+ .word_boundary_codepoints => return trySetWordBoundaryCodepoints(
+ event,
+ &press.word_boundary_codepoints,
+ v,
+ ),
+ .behaviors => {
+ if (!validBehavior(v.single_click) or
+ !validBehavior(v.double_click) or
+ !validBehavior(v.triple_click)) return .invalid_value;
+ event.behaviors = .{ v.single_click, v.double_click, v.triple_click };
+ press.behaviors = &event.behaviors;
+ },
+ .rectangle,
+ .geometry,
+ .viewport,
+ => return .invalid_value,
+ }
+
+ return .success;
+}
+
+fn releaseSetTyped(
+ release: *SelectionGesture.Release,
+ comptime option: EventOption,
+ value: ?*const option.Type(),
+) Result {
+ switch (option) {
+ .ref => {
+ const v = value orelse {
+ release.pin = null;
+ return .success;
+ };
+ release.pin = v.toPin() orelse return .invalid_value;
+ },
+
+ .position,
+ .repeat_distance,
+ .time_ns,
+ .repeat_interval_ns,
+ .word_boundary_codepoints,
+ .behaviors,
+ .rectangle,
+ .geometry,
+ .viewport,
+ => return .invalid_value,
+ }
+
+ return .success;
+}
+
+fn dragSetTyped(
+ event: *EventWrapper,
+ drag: *SelectionGesture.Drag,
+ comptime option: EventOption,
+ value: ?*const option.Type(),
+) Result {
+ const v = value orelse {
+ switch (option) {
+ .ref => event.event_validation.drag_pin_set = false,
+ .position => {
+ drag.xpos = 0;
+ drag.ypos = 0;
+ },
+ .word_boundary_codepoints => clearWordBoundaryCodepoints(
+ event,
+ &drag.word_boundary_codepoints,
+ ),
+ .rectangle => drag.rectangle = false,
+ .geometry => event.event_validation.drag_geometry_set = false,
+ .viewport => return .invalid_value,
+
+ .repeat_distance,
+ .time_ns,
+ .repeat_interval_ns,
+ .behaviors,
+ => return .invalid_value,
+ }
+ return .success;
+ };
+
+ switch (option) {
+ .ref => {
+ drag.pin = v.toPin() orelse return .invalid_value;
+ event.event_validation.drag_pin_set = true;
+ },
+ .position => {
+ drag.xpos = v.x;
+ drag.ypos = v.y;
+ },
+ .word_boundary_codepoints => return trySetWordBoundaryCodepoints(
+ event,
+ &drag.word_boundary_codepoints,
+ v,
+ ),
+ .rectangle => drag.rectangle = v.*,
+ .geometry => {
+ drag.geometry = v.toZig() orelse return .invalid_value;
+ event.event_validation.drag_geometry_set = true;
+ },
+ .viewport => return .invalid_value,
+
+ .repeat_distance,
+ .time_ns,
+ .repeat_interval_ns,
+ .behaviors,
+ => return .invalid_value,
+ }
+
+ return .success;
+}
+
+fn autoscrollTickSetTyped(
+ event: *EventWrapper,
+ tick: *SelectionGesture.AutoscrollTick,
+ comptime option: EventOption,
+ value: ?*const option.Type(),
+) Result {
+ const v = value orelse {
+ switch (option) {
+ .viewport => event.event_validation.autoscroll_tick_viewport_set = false,
+ .position => {
+ tick.xpos = 0;
+ tick.ypos = 0;
+ },
+ .word_boundary_codepoints => clearWordBoundaryCodepoints(
+ event,
+ &tick.word_boundary_codepoints,
+ ),
+ .rectangle => tick.rectangle = false,
+ .geometry => event.event_validation.autoscroll_tick_geometry_set = false,
+
+ .ref,
+ .repeat_distance,
+ .time_ns,
+ .repeat_interval_ns,
+ .behaviors,
+ => return .invalid_value,
+ }
+ return .success;
+ };
+
+ switch (option) {
+ .viewport => {
+ tick.viewport = v.*;
+ event.event_validation.autoscroll_tick_viewport_set = true;
+ },
+ .position => {
+ tick.xpos = v.x;
+ tick.ypos = v.y;
+ },
+ .word_boundary_codepoints => return trySetWordBoundaryCodepoints(
+ event,
+ &tick.word_boundary_codepoints,
+ v,
+ ),
+ .rectangle => tick.rectangle = v.*,
+ .geometry => {
+ tick.geometry = v.toZig() orelse return .invalid_value;
+ event.event_validation.autoscroll_tick_geometry_set = true;
+ },
+
+ .ref,
+ .repeat_distance,
+ .time_ns,
+ .repeat_interval_ns,
+ .behaviors,
+ => return .invalid_value,
+ }
+
+ return .success;
+}
+
+fn deepPressSetTyped(
+ event: *EventWrapper,
+ deep_press: *SelectionGesture.DeepPress,
+ comptime option: EventOption,
+ value: ?*const option.Type(),
+) Result {
+ const v = value orelse {
+ switch (option) {
+ .word_boundary_codepoints => clearWordBoundaryCodepoints(
+ event,
+ &deep_press.word_boundary_codepoints,
+ ),
+
+ .ref,
+ .position,
+ .repeat_distance,
+ .time_ns,
+ .repeat_interval_ns,
+ .behaviors,
+ .rectangle,
+ .geometry,
+ .viewport,
+ => return .invalid_value,
+ }
+ return .success;
+ };
+
+ switch (option) {
+ .word_boundary_codepoints => return trySetWordBoundaryCodepoints(
+ event,
+ &deep_press.word_boundary_codepoints,
+ v,
+ ),
+
+ .ref,
+ .position,
+ .repeat_distance,
+ .time_ns,
+ .repeat_interval_ns,
+ .behaviors,
+ .rectangle,
+ .geometry,
+ .viewport,
+ => return .invalid_value,
+ }
+
+ return .success;
+}
+
+fn trySetWordBoundaryCodepoints(
+ event: *EventWrapper,
+ target: *[]const u21,
+ value: *const types.Codepoints,
+) Result {
+ if (value.len > 0 and value.ptr == null) return .invalid_value;
+ clearWordBoundaryCodepoints(event, target);
+ const ptr = value.ptr orelse {
+ event.word_boundary_codepoints = &.{};
+ target.* = event.word_boundary_codepoints.?;
+ return .success;
+ };
+ const copy = event.alloc.alloc(u21, value.len) catch return .out_of_memory;
+ errdefer event.alloc.free(copy);
+ for (copy, ptr[0..value.len]) |*dst, cp| {
+ dst.* = std.math.cast(u21, cp) orelse return .invalid_value;
+ }
+ event.word_boundary_codepoints = copy;
+ target.* = copy;
+ return .success;
+}
+
+fn clearWordBoundaryCodepoints(event: *EventWrapper, target: *[]const u21) void {
+ if (event.word_boundary_codepoints) |cps| {
+ if (cps.len > 0) event.alloc.free(cps);
+ }
+ event.word_boundary_codepoints = null;
+ target.* = &selection_codepoints.default_word_boundaries;
+}
+
+fn instantFromNs(ns: u64) SelectionGesture.Time {
+ if (comptime builtin.target.cpu.arch == .wasm32 and
+ builtin.target.os.tag == .freestanding)
+ {
+ return ns;
+ }
+
+ 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 validBehavior(behavior: Behavior) bool {
+ _ = std.meta.intToEnum(Behavior, @intFromEnum(behavior)) catch return false;
+ return true;
+}
+
+test "selection gesture lifecycle and get" {
+ 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);
+
+ var gesture: Gesture = null;
+ try testing.expectEqual(Result.success, new(
+ &lib.alloc.test_allocator,
+ &gesture,
+ ));
+ defer free(gesture, terminal);
+
+ var click_count: u8 = 255;
+ try testing.expectEqual(Result.success, get(gesture, terminal, .click_count, &click_count));
+ try testing.expectEqual(@as(u8, 0), click_count);
+
+ var dragged = true;
+ try testing.expectEqual(Result.success, get(gesture, terminal, .dragged, &dragged));
+ try testing.expect(!dragged);
+
+ var autoscroll: Autoscroll = .up;
+ try testing.expectEqual(Result.success, get(gesture, terminal, .autoscroll, &autoscroll));
+ try testing.expectEqual(Autoscroll.none, autoscroll);
+
+ var behavior: Behavior = .word;
+ try testing.expectEqual(Result.success, get(gesture, terminal, .behavior, &behavior));
+ try testing.expectEqual(Behavior.cell, behavior);
+
+ var anchor: grid_ref.CGridRef = undefined;
+ try testing.expectEqual(Result.no_value, get(gesture, terminal, .anchor, &anchor));
+}
+
+test "selection gesture get_multi" {
+ 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);
+
+ var gesture: Gesture = null;
+ try testing.expectEqual(Result.success, new(
+ &lib.alloc.test_allocator,
+ &gesture,
+ ));
+ defer free(gesture, terminal);
+
+ const keys = [_]Data{ .click_count, .dragged, .autoscroll, .behavior };
+ var click_count: u8 = 255;
+ var dragged = true;
+ var autoscroll: Autoscroll = .up;
+ var behavior: Behavior = .word;
+ var values = [_]?*anyopaque{
+ &click_count,
+ &dragged,
+ &autoscroll,
+ &behavior,
+ };
+ var written: usize = 0;
+
+ try testing.expectEqual(Result.success, get_multi(
+ gesture,
+ terminal,
+ keys.len,
+ &keys,
+ &values,
+ &written,
+ ));
+ try testing.expectEqual(keys.len, written);
+ try testing.expectEqual(@as(u8, 0), click_count);
+ try testing.expect(!dragged);
+ try testing.expectEqual(Autoscroll.none, autoscroll);
+ try testing.expectEqual(Behavior.cell, behavior);
+}
+
+test "selection gesture get_multi returns first failing index" {
+ var terminal: terminal_c.Terminal = null;
+ try testing.expectEqual(Result.success, terminal_c.new(
+ &lib.alloc.test_allocator,
+ &terminal,
+ .{ .cols = 5, .rows = 2, .max_scrollback = 10_000 },
+ ));
+ defer terminal_c.free(terminal);
+
+ var gesture: Gesture = null;
+ try testing.expectEqual(Result.success, new(
+ &lib.alloc.test_allocator,
+ &gesture,
+ ));
+ defer free(gesture, terminal);
+
+ const keys = [_]Data{ .click_count, .anchor, .dragged };
+ var click_count: u8 = 255;
+ var anchor: grid_ref.CGridRef = undefined;
+ var dragged = true;
+ var values = [_]?*anyopaque{ &click_count, &anchor, &dragged };
+ var written: usize = 0;
+
+ try testing.expectEqual(Result.no_value, get_multi(
+ gesture,
+ terminal,
+ keys.len,
+ &keys,
+ &values,
+ &written,
+ ));
+ try testing.expectEqual(@as(usize, 1), written);
+ try testing.expectEqual(@as(u8, 0), click_count);
+ try testing.expect(dragged);
+}
+
+test "selection gesture event set clear and free" {
+ var event: Event = null;
+ try testing.expectEqual(Result.success, event_new(
+ &lib.alloc.test_allocator,
+ &event,
+ .press,
+ ));
+ defer event_free(event);
+
+ const in_pos: types.SurfacePosition = .{ .x = 12.5, .y = -3.25 };
+ try testing.expectEqual(Result.success, event_set(event, .position, &in_pos));
+ try testing.expectEqual(@as(f64, 12.5), event.?.event.press.xpos);
+ try testing.expectEqual(@as(f64, -3.25), event.?.event.press.ypos);
+
+ try testing.expectEqual(Result.success, event_set(event, .position, null));
+ try testing.expectEqual(@as(f64, 0), event.?.event.press.xpos);
+ try testing.expectEqual(@as(f64, 0), event.?.event.press.ypos);
+
+ const repeat_distance: f64 = 4.0;
+ try testing.expectEqual(Result.success, event_set(event, .repeat_distance, &repeat_distance));
+ try testing.expectEqual(repeat_distance, event.?.event.press.max_distance);
+}
+
+test "selection gesture event copies clears and frees codepoints" {
+ var event: Event = null;
+ try testing.expectEqual(Result.success, event_new(
+ &lib.alloc.test_allocator,
+ &event,
+ .press,
+ ));
+ defer event_free(event);
+
+ var values = [_]u32{ ' ', '\t' };
+ const in: types.Codepoints = .{ .ptr = &values, .len = values.len };
+ try testing.expectEqual(Result.success, event_set(event, .word_boundary_codepoints, &in));
+
+ values[0] = 'x';
+
+ try testing.expectEqual(@as(usize, 2), event.?.event.press.word_boundary_codepoints.len);
+ try testing.expectEqual(@as(u21, ' '), event.?.event.press.word_boundary_codepoints[0]);
+ try testing.expectEqual(@as(u21, '\t'), event.?.event.press.word_boundary_codepoints[1]);
+
+ const invalid: types.Codepoints = .{ .ptr = null, .len = 1 };
+ try testing.expectEqual(Result.invalid_value, event_set(event, .word_boundary_codepoints, &invalid));
+
+ try testing.expectEqual(Result.success, event_set(event, .word_boundary_codepoints, null));
+ try testing.expectEqual(
+ selection_codepoints.default_word_boundaries.len,
+ event.?.event.press.word_boundary_codepoints.len,
+ );
+
+ const empty: types.Codepoints = .{ .ptr = null, .len = 0 };
+ try testing.expectEqual(Result.success, event_set(event, .word_boundary_codepoints, &empty));
+ try testing.expectEqual(@as(usize, 0), event.?.event.press.word_boundary_codepoints.len);
+}
+
+test "selection gesture event behaviors" {
+ var event: Event = null;
+ try testing.expectEqual(Result.success, event_new(
+ &lib.alloc.test_allocator,
+ &event,
+ .press,
+ ));
+ defer event_free(event);
+
+ const in: Behaviors = .{
+ .single_click = .cell,
+ .double_click = .word,
+ .triple_click = .line,
+ };
+ try testing.expectEqual(Result.success, event_set(event, .behaviors, &in));
+ try testing.expectEqual(Behavior.cell, event.?.event.press.behaviors[0]);
+ try testing.expectEqual(Behavior.word, event.?.event.press.behaviors[1]);
+ try testing.expectEqual(Behavior.line, event.?.event.press.behaviors[2]);
+}
+
+test "selection gesture event applies press" {
+ 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);
+
+ var gesture: Gesture = null;
+ try testing.expectEqual(Result.success, new(
+ &lib.alloc.test_allocator,
+ &gesture,
+ ));
+ defer free(gesture, terminal);
+
+ var press_event: Event = null;
+ try testing.expectEqual(Result.success, event_new(
+ &lib.alloc.test_allocator,
+ &press_event,
+ .press,
+ ));
+ defer event_free(press_event);
+
+ terminal_c.vt_write(terminal, "abc", 3);
+
+ var ref: grid_ref.CGridRef = undefined;
+ try testing.expectEqual(Result.success, terminal_c.grid_ref(terminal, .{
+ .tag = .active,
+ .value = .{ .active = .{ .x = 1, .y = 0 } },
+ }, &ref));
+ try testing.expectEqual(Result.success, event_set(press_event, .ref, &ref));
+ const behaviors: Behaviors = .{
+ .single_click = .word,
+ .double_click = .word,
+ .triple_click = .line,
+ };
+ try testing.expectEqual(Result.success, event_set(press_event, .behaviors, &behaviors));
+
+ var sel: selection_c.CSelection = undefined;
+ try testing.expectEqual(Result.success, handle_event(gesture, terminal, press_event, &sel));
+ try testing.expectEqual(@as(u16, 0), sel.start.toPin().?.x);
+ try testing.expectEqual(@as(u16, 2), sel.end.toPin().?.x);
+
+ try testing.expectEqual(Result.success, handle_event(gesture, terminal, press_event, null));
+}
+
+test "selection gesture event press requires ref" {
+ 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);
+
+ var gesture: Gesture = null;
+ try testing.expectEqual(Result.success, new(
+ &lib.alloc.test_allocator,
+ &gesture,
+ ));
+ defer free(gesture, terminal);
+
+ var press_event: Event = null;
+ try testing.expectEqual(Result.success, event_new(
+ &lib.alloc.test_allocator,
+ &press_event,
+ .press,
+ ));
+ defer event_free(press_event);
+
+ var sel: selection_c.CSelection = undefined;
+ try testing.expectEqual(Result.invalid_value, handle_event(gesture, terminal, press_event, &sel));
+}
+
+test "selection gesture event null output still reports no selection" {
+ 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);
+
+ var gesture: Gesture = null;
+ try testing.expectEqual(Result.success, new(
+ &lib.alloc.test_allocator,
+ &gesture,
+ ));
+ defer free(gesture, terminal);
+
+ var press_event: Event = null;
+ try testing.expectEqual(Result.success, event_new(
+ &lib.alloc.test_allocator,
+ &press_event,
+ .press,
+ ));
+ defer event_free(press_event);
+
+ var ref: grid_ref.CGridRef = undefined;
+ try testing.expectEqual(Result.success, terminal_c.grid_ref(terminal, .{
+ .tag = .active,
+ .value = .{ .active = .{ .x = 1, .y = 0 } },
+ }, &ref));
+ try testing.expectEqual(Result.success, event_set(press_event, .ref, &ref));
+
+ try testing.expectEqual(Result.no_value, handle_event(gesture, terminal, press_event, null));
+}
+
+test "selection gesture event applies release" {
+ 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);
+
+ var gesture: Gesture = null;
+ try testing.expectEqual(Result.success, new(
+ &lib.alloc.test_allocator,
+ &gesture,
+ ));
+ defer free(gesture, terminal);
+
+ var press_event: Event = null;
+ try testing.expectEqual(Result.success, event_new(
+ &lib.alloc.test_allocator,
+ &press_event,
+ .press,
+ ));
+ defer event_free(press_event);
+
+ var release_event: Event = null;
+ try testing.expectEqual(Result.success, event_new(
+ &lib.alloc.test_allocator,
+ &release_event,
+ .release,
+ ));
+ defer event_free(release_event);
+
+ var ref: grid_ref.CGridRef = undefined;
+ try testing.expectEqual(Result.success, terminal_c.grid_ref(terminal, .{
+ .tag = .active,
+ .value = .{ .active = .{ .x = 1, .y = 0 } },
+ }, &ref));
+ try testing.expectEqual(Result.success, event_set(press_event, .ref, &ref));
+ try testing.expectEqual(Result.success, event_set(release_event, .ref, &ref));
+
+ try testing.expectEqual(Result.no_value, handle_event(gesture, terminal, press_event, null));
+ try testing.expectEqual(Result.no_value, handle_event(gesture, terminal, release_event, null));
+
+ var dragged = true;
+ try testing.expectEqual(Result.success, get(gesture, terminal, .dragged, &dragged));
+ try testing.expect(!dragged);
+
+ const pos: types.SurfacePosition = .{ .x = 0, .y = 0 };
+ try testing.expectEqual(Result.invalid_value, event_set(release_event, .position, &pos));
+}
+
+test "selection gesture release without ref marks dragged" {
+ 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);
+
+ var gesture: Gesture = null;
+ try testing.expectEqual(Result.success, new(
+ &lib.alloc.test_allocator,
+ &gesture,
+ ));
+ defer free(gesture, terminal);
+
+ var press_event: Event = null;
+ try testing.expectEqual(Result.success, event_new(
+ &lib.alloc.test_allocator,
+ &press_event,
+ .press,
+ ));
+ defer event_free(press_event);
+
+ var release_event: Event = null;
+ try testing.expectEqual(Result.success, event_new(
+ &lib.alloc.test_allocator,
+ &release_event,
+ .release,
+ ));
+ defer event_free(release_event);
+
+ var ref: grid_ref.CGridRef = undefined;
+ try testing.expectEqual(Result.success, terminal_c.grid_ref(terminal, .{
+ .tag = .active,
+ .value = .{ .active = .{ .x = 1, .y = 0 } },
+ }, &ref));
+ try testing.expectEqual(Result.success, event_set(press_event, .ref, &ref));
+
+ try testing.expectEqual(Result.no_value, handle_event(gesture, terminal, press_event, null));
+ try testing.expectEqual(Result.no_value, handle_event(gesture, terminal, release_event, null));
+
+ var dragged = false;
+ try testing.expectEqual(Result.success, get(gesture, terminal, .dragged, &dragged));
+ try testing.expect(dragged);
+}
+
+test "selection gesture event applies drag" {
+ 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, "abcde", 5);
+
+ var gesture: Gesture = null;
+ try testing.expectEqual(Result.success, new(
+ &lib.alloc.test_allocator,
+ &gesture,
+ ));
+ defer free(gesture, terminal);
+
+ var press_event: Event = null;
+ try testing.expectEqual(Result.success, event_new(
+ &lib.alloc.test_allocator,
+ &press_event,
+ .press,
+ ));
+ defer event_free(press_event);
+
+ var drag_event: Event = null;
+ try testing.expectEqual(Result.success, event_new(
+ &lib.alloc.test_allocator,
+ &drag_event,
+ .drag,
+ ));
+ defer event_free(drag_event);
+
+ var press_ref: grid_ref.CGridRef = undefined;
+ try testing.expectEqual(Result.success, terminal_c.grid_ref(terminal, .{
+ .tag = .active,
+ .value = .{ .active = .{ .x = 1, .y = 0 } },
+ }, &press_ref));
+ try testing.expectEqual(Result.success, event_set(press_event, .ref, &press_ref));
+
+ const press_pos: types.SurfacePosition = .{ .x = 10, .y = 10 };
+ try testing.expectEqual(Result.success, event_set(press_event, .position, &press_pos));
+ try testing.expectEqual(Result.no_value, handle_event(gesture, terminal, press_event, null));
+
+ var drag_ref: grid_ref.CGridRef = undefined;
+ try testing.expectEqual(Result.success, terminal_c.grid_ref(terminal, .{
+ .tag = .active,
+ .value = .{ .active = .{ .x = 3, .y = 0 } },
+ }, &drag_ref));
+ try testing.expectEqual(Result.success, event_set(drag_event, .ref, &drag_ref));
+
+ const drag_pos: types.SurfacePosition = .{ .x = 36, .y = 10 };
+ try testing.expectEqual(Result.success, event_set(drag_event, .position, &drag_pos));
+ const geometry: Geometry = .{
+ .columns = 5,
+ .cell_width = 10,
+ .padding_left = 0,
+ .screen_height = 20,
+ };
+ try testing.expectEqual(Result.success, event_set(drag_event, .geometry, &geometry));
+
+ var sel: selection_c.CSelection = undefined;
+ try testing.expectEqual(Result.success, handle_event(gesture, terminal, drag_event, &sel));
+ try testing.expectEqual(@as(u16, 1), sel.start.toPin().?.x);
+ try testing.expectEqual(@as(u16, 3), sel.end.toPin().?.x);
+
+ var dragged = false;
+ try testing.expectEqual(Result.success, get(gesture, terminal, .dragged, &dragged));
+ try testing.expect(dragged);
+}
+
+test "selection gesture drag requires ref and geometry" {
+ 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);
+
+ var gesture: Gesture = null;
+ try testing.expectEqual(Result.success, new(
+ &lib.alloc.test_allocator,
+ &gesture,
+ ));
+ defer free(gesture, terminal);
+
+ var drag_event: Event = null;
+ try testing.expectEqual(Result.success, event_new(
+ &lib.alloc.test_allocator,
+ &drag_event,
+ .drag,
+ ));
+ defer event_free(drag_event);
+
+ var sel: selection_c.CSelection = undefined;
+ try testing.expectEqual(Result.invalid_value, handle_event(gesture, terminal, drag_event, &sel));
+
+ var ref: grid_ref.CGridRef = undefined;
+ try testing.expectEqual(Result.success, terminal_c.grid_ref(terminal, .{
+ .tag = .active,
+ .value = .{ .active = .{ .x = 1, .y = 0 } },
+ }, &ref));
+ try testing.expectEqual(Result.success, event_set(drag_event, .ref, &ref));
+ try testing.expectEqual(Result.invalid_value, handle_event(gesture, terminal, drag_event, &sel));
+
+ const invalid_geometry: Geometry = .{
+ .columns = 5,
+ .cell_width = 0,
+ .padding_left = 0,
+ .screen_height = 20,
+ };
+ try testing.expectEqual(Result.invalid_value, event_set(drag_event, .geometry, &invalid_geometry));
+}
+
+test "selection gesture event applies autoscroll tick" {
+ 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, "abcde\r\nfghij", 12);
+
+ var gesture: Gesture = null;
+ try testing.expectEqual(Result.success, new(
+ &lib.alloc.test_allocator,
+ &gesture,
+ ));
+ defer free(gesture, terminal);
+
+ var press_event: Event = null;
+ try testing.expectEqual(Result.success, event_new(
+ &lib.alloc.test_allocator,
+ &press_event,
+ .press,
+ ));
+ defer event_free(press_event);
+
+ var drag_event: Event = null;
+ try testing.expectEqual(Result.success, event_new(
+ &lib.alloc.test_allocator,
+ &drag_event,
+ .drag,
+ ));
+ defer event_free(drag_event);
+
+ var tick_event: Event = null;
+ try testing.expectEqual(Result.success, event_new(
+ &lib.alloc.test_allocator,
+ &tick_event,
+ .autoscroll_tick,
+ ));
+ defer event_free(tick_event);
+
+ const geometry: Geometry = .{
+ .columns = 5,
+ .cell_width = 10,
+ .padding_left = 0,
+ .screen_height = 20,
+ };
+
+ var press_ref: grid_ref.CGridRef = undefined;
+ try testing.expectEqual(Result.success, terminal_c.grid_ref(terminal, .{
+ .tag = .active,
+ .value = .{ .active = .{ .x = 1, .y = 0 } },
+ }, &press_ref));
+ try testing.expectEqual(Result.success, event_set(press_event, .ref, &press_ref));
+ const press_pos: types.SurfacePosition = .{ .x = 10, .y = 10 };
+ try testing.expectEqual(Result.success, event_set(press_event, .position, &press_pos));
+ try testing.expectEqual(Result.no_value, handle_event(gesture, terminal, press_event, null));
+
+ var drag_ref: grid_ref.CGridRef = undefined;
+ try testing.expectEqual(Result.success, terminal_c.grid_ref(terminal, .{
+ .tag = .active,
+ .value = .{ .active = .{ .x = 3, .y = 1 } },
+ }, &drag_ref));
+ try testing.expectEqual(Result.success, event_set(drag_event, .ref, &drag_ref));
+ const drag_pos: types.SurfacePosition = .{ .x = 36, .y = 20 };
+ try testing.expectEqual(Result.success, event_set(drag_event, .position, &drag_pos));
+ try testing.expectEqual(Result.success, event_set(drag_event, .geometry, &geometry));
+ var sel: selection_c.CSelection = undefined;
+ try testing.expectEqual(Result.success, handle_event(gesture, terminal, drag_event, &sel));
+
+ var autoscroll: Autoscroll = .none;
+ try testing.expectEqual(Result.success, get(gesture, terminal, .autoscroll, &autoscroll));
+ try testing.expectEqual(Autoscroll.down, autoscroll);
+
+ const viewport: point.Coordinate = .{ .x = 3, .y = 1 };
+ try testing.expectEqual(Result.success, event_set(tick_event, .viewport, &viewport));
+ try testing.expectEqual(Result.success, event_set(tick_event, .position, &drag_pos));
+ try testing.expectEqual(Result.success, event_set(tick_event, .geometry, &geometry));
+
+ try testing.expectEqual(Result.success, handle_event(gesture, terminal, tick_event, &sel));
+}
+
+test "selection gesture autoscroll tick requires viewport and geometry" {
+ 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);
+
+ var gesture: Gesture = null;
+ try testing.expectEqual(Result.success, new(
+ &lib.alloc.test_allocator,
+ &gesture,
+ ));
+ defer free(gesture, terminal);
+
+ var tick_event: Event = null;
+ try testing.expectEqual(Result.success, event_new(
+ &lib.alloc.test_allocator,
+ &tick_event,
+ .autoscroll_tick,
+ ));
+ defer event_free(tick_event);
+
+ var sel: selection_c.CSelection = undefined;
+ try testing.expectEqual(Result.invalid_value, handle_event(gesture, terminal, tick_event, &sel));
+
+ const viewport: point.Coordinate = .{ .x = 1, .y = 0 };
+ try testing.expectEqual(Result.success, event_set(tick_event, .viewport, &viewport));
+ try testing.expectEqual(Result.invalid_value, handle_event(gesture, terminal, tick_event, &sel));
+
+ var ref: grid_ref.CGridRef = undefined;
+ try testing.expectEqual(Result.success, terminal_c.grid_ref(terminal, .{
+ .tag = .active,
+ .value = .{ .active = .{ .x = 1, .y = 0 } },
+ }, &ref));
+ try testing.expectEqual(Result.invalid_value, event_set(tick_event, .ref, &ref));
+}
+
+test "selection gesture event applies deep press" {
+ 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, "abcde", 5);
+
+ var gesture: Gesture = null;
+ try testing.expectEqual(Result.success, new(
+ &lib.alloc.test_allocator,
+ &gesture,
+ ));
+ defer free(gesture, terminal);
+
+ var press_event: Event = null;
+ try testing.expectEqual(Result.success, event_new(
+ &lib.alloc.test_allocator,
+ &press_event,
+ .press,
+ ));
+ defer event_free(press_event);
+
+ var deep_press_event: Event = null;
+ try testing.expectEqual(Result.success, event_new(
+ &lib.alloc.test_allocator,
+ &deep_press_event,
+ .deep_press,
+ ));
+ defer event_free(deep_press_event);
+
+ var ref: grid_ref.CGridRef = undefined;
+ try testing.expectEqual(Result.success, terminal_c.grid_ref(terminal, .{
+ .tag = .active,
+ .value = .{ .active = .{ .x = 2, .y = 0 } },
+ }, &ref));
+ try testing.expectEqual(Result.success, event_set(press_event, .ref, &ref));
+ try testing.expectEqual(Result.no_value, handle_event(gesture, terminal, press_event, null));
+
+ var sel: selection_c.CSelection = undefined;
+ try testing.expectEqual(Result.success, handle_event(gesture, terminal, deep_press_event, &sel));
+ try testing.expectEqual(@as(u16, 0), sel.start.toPin().?.x);
+ try testing.expectEqual(@as(u16, 4), sel.end.toPin().?.x);
+
+ var dragged = false;
+ try testing.expectEqual(Result.success, get(gesture, terminal, .dragged, &dragged));
+ try testing.expect(dragged);
+
+ const pos: types.SurfacePosition = .{ .x = 0, .y = 0 };
+ try testing.expectEqual(Result.invalid_value, event_set(deep_press_event, .position, &pos));
+}
+
+test "selection gesture deep press without active anchor returns no value" {
+ 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);
+
+ var gesture: Gesture = null;
+ try testing.expectEqual(Result.success, new(
+ &lib.alloc.test_allocator,
+ &gesture,
+ ));
+ defer free(gesture, terminal);
+
+ var deep_press_event: Event = null;
+ try testing.expectEqual(Result.success, event_new(
+ &lib.alloc.test_allocator,
+ &deep_press_event,
+ .deep_press,
+ ));
+ defer event_free(deep_press_event);
+
+ var sel: selection_c.CSelection = undefined;
+ try testing.expectEqual(Result.no_value, handle_event(gesture, terminal, deep_press_event, &sel));
+}
+
+test "selection gesture free null" {
+ free(null, null);
+}
+
+test "selection gesture event free null" {
+ event_free(null);
+}
diff --git a/vendor/libghostty-vt/src/terminal/c/terminal.zig b/vendor/libghostty-vt/src/terminal/c/terminal.zig
index d3293f3b..5a5db2d6 100644
--- a/vendor/libghostty-vt/src/terminal/c/terminal.zig
+++ b/vendor/libghostty-vt/src/terminal/c/terminal.zig
@@ -3,7 +3,7 @@ const testing = std.testing;
const build_options = @import("terminal_options");
const lib = @import("../lib.zig");
const CAllocator = lib.alloc.Allocator;
-const ZigTerminal = @import("../Terminal.zig");
+pub const ZigTerminal = @import("../Terminal.zig");
const Stream = @import("../stream_terminal.zig").Stream;
const ScreenSet = @import("../ScreenSet.zig");
const PageList = @import("../PageList.zig");
@@ -19,6 +19,8 @@ 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 selection_c = @import("selection.zig");
const style_c = @import("style.zig");
const color = @import("../color.zig");
const Result = @import("result.zig").Result;
@@ -34,6 +36,7 @@ const TerminalWrapper = struct {
terminal: *ZigTerminal,
stream: Stream,
effects: Effects = .{},
+ tracked_grid_refs: std.AutoArrayHashMapUnmanaged(*grid_ref_tracked_c.TrackedGridRef, void) = .{},
};
/// C callback state for terminal effects. Trampolines are always
@@ -208,6 +211,10 @@ const Effects = struct {
/// C: GhosttyTerminal
pub const Terminal = ?*TerminalWrapper;
+pub fn zigTerminal(terminal_: Terminal) ?*ZigTerminal {
+ return (terminal_ orelse return null).terminal;
+}
+
/// C: GhosttyTerminalOptions
pub const Options = extern struct {
cols: size.CellCountInt,
@@ -259,6 +266,11 @@ fn new_(
});
errdefer t.deinit(alloc);
+ // libghostty-vt embedders don't necessarily install Ghostty's shell
+ // integration, so don't assume OSC 133 prompts can be redrawn on resize.
+ // Shells can still opt in with OSC 133;A;redraw=1.
+ t.flags.shell_redraws_prompt = .false;
+
// Setup our stream with trampolines always installed so that
// setting C callbacks at any time takes effect immediately.
var handler: Stream.Handler = t.vtHandler();
@@ -313,6 +325,7 @@ pub const Option = enum(c_int) {
kitty_image_medium_shared_mem = 18,
apc_max_bytes = 19,
apc_max_bytes_kitty = 20,
+ selection = 21,
/// Input type expected for setting the option.
pub fn InType(comptime self: Option) type {
@@ -335,6 +348,7 @@ pub const Option = enum(c_int) {
.kitty_image_medium_shared_mem,
=> ?*const bool,
.apc_max_bytes, .apc_max_bytes_kitty => ?*const usize,
+ .selection => ?*const selection_c.CSelection,
};
}
};
@@ -442,6 +456,14 @@ fn setTyped(
wrapper.stream.handler.apc_handler.max_bytes.remove(.kitty);
}
},
+ .selection => {
+ if (value) |ptr| {
+ const sel = ptr.toZig() orelse return .invalid_value;
+ wrapper.terminal.screens.active.select(sel) catch return .out_of_memory;
+ } else {
+ wrapper.terminal.screens.active.clearSelection();
+ }
+ },
}
return .success;
}
@@ -575,13 +597,15 @@ pub const TerminalData = enum(c_int) {
kitty_image_medium_temp_file = 28,
kitty_image_medium_shared_mem = 29,
kitty_graphics = 30,
+ selection = 31,
+ viewport_active = 32,
/// Output type expected for querying the data of the given kind.
pub fn OutType(comptime self: TerminalData) type {
return switch (self) {
.invalid => void,
.cols, .rows, .cursor_x, .cursor_y => size.CellCountInt,
- .cursor_pending_wrap, .cursor_visible, .mouse_tracking => bool,
+ .cursor_pending_wrap, .cursor_visible, .mouse_tracking, .viewport_active => bool,
.active_screen => TerminalScreen,
.kitty_keyboard_flags => u8,
.scrollbar => TerminalScrollbar,
@@ -603,6 +627,7 @@ pub const TerminalData = enum(c_int) {
.kitty_image_medium_shared_mem,
=> bool,
.kitty_graphics => KittyGraphics,
+ .selection => selection_c.CSelection,
};
}
};
@@ -712,6 +737,10 @@ fn getTyped(
if (comptime !build_options.kitty_graphics) return .no_value;
out.* = &t.screens.active.kitty_images;
},
+ .selection => out.* = selection_c.CSelection.fromZig(
+ t.screens.active.selection orelse return .no_value,
+ ),
+ .viewport_active => out.* = t.screens.active.pages.viewport == .active,
}
return .success;
@@ -723,18 +752,56 @@ 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,
+ };
+
+ // Store the tracked ref in the terminal so that when we free
+ // the terminal the tracked ref can be detached safely.
+ wrapper.tracked_grid_refs.putNoClobber(
+ alloc,
+ ref,
+ {},
+ ) catch {
+ list.untrackPin(tracked_pin);
+ alloc.destroy(ref);
+ return .out_of_memory;
+ };
+
+ out.* = ref;
+ return .success;
+}
+
pub fn point_from_grid_ref(
terminal_: Terminal,
ref: *const grid_ref_c.CGridRef,
@@ -752,9 +819,11 @@ pub fn point_from_grid_ref(
pub fn free(terminal_: Terminal) callconv(lib.calling_conv) void {
const wrapper = terminal_ orelse return;
const t = wrapper.terminal;
+ const alloc = t.gpa();
+ for (wrapper.tracked_grid_refs.keys()) |ref| ref.terminal = null;
+ wrapper.tracked_grid_refs.deinit(alloc);
wrapper.stream.deinit();
- const alloc = t.gpa();
t.deinit(alloc);
alloc.destroy(t);
alloc.destroy(wrapper);
@@ -821,6 +890,10 @@ test "scroll_viewport" {
const zt = t.?.terminal;
+ var viewport_active: bool = false;
+ try testing.expectEqual(Result.success, get(t, .viewport_active, @ptrCast(&viewport_active)));
+ try testing.expect(viewport_active);
+
// Write "hello" on the first line
vt_write(t, "hello", 5);
@@ -835,6 +908,8 @@ test "scroll_viewport" {
// Scroll to top: "hello" should be visible again
scroll_viewport(t, .{ .tag = .top, .value = undefined });
+ 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);
@@ -843,6 +918,8 @@ test "scroll_viewport" {
// Scroll to bottom: viewport should be empty again
scroll_viewport(t, .{ .tag = .bottom, .value = undefined });
+ 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);
@@ -851,6 +928,8 @@ test "scroll_viewport" {
// Scroll up by delta to bring "hello" back into view
scroll_viewport(t, .{ .tag = .delta, .value = .{ .delta = -3 } });
+ 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);
@@ -1298,6 +1377,410 @@ test "get invalid" {
try testing.expectEqual(Result.invalid_value, get(t, .invalid, null));
}
+test "set and get selection" {
+ 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);
+
+ vt_write(t, "Hello", 5);
+
+ var start_ref: grid_ref_c.CGridRef = .{};
+ try testing.expectEqual(Result.success, grid_ref(t, .{
+ .tag = .active,
+ .value = .{ .active = .{ .x = 0, .y = 0 } },
+ }, &start_ref));
+
+ var end_ref: grid_ref_c.CGridRef = .{};
+ try testing.expectEqual(Result.success, grid_ref(t, .{
+ .tag = .active,
+ .value = .{ .active = .{ .x = 4, .y = 0 } },
+ }, &end_ref));
+
+ var out: selection_c.CSelection = undefined;
+ try testing.expectEqual(Result.no_value, get(t, .selection, @ptrCast(&out)));
+
+ const sel: selection_c.CSelection = .{
+ .start = start_ref,
+ .end = end_ref,
+ .rectangle = true,
+ };
+ try testing.expectEqual(Result.success, set(t, .selection, @ptrCast(&sel)));
+ try testing.expect(t.?.terminal.screens.active.selection.?.tracked());
+
+ try testing.expectEqual(Result.success, get(t, .selection, @ptrCast(&out)));
+ try testing.expect(out.start.toPin().?.eql(start_ref.toPin().?));
+ try testing.expect(out.end.toPin().?.eql(end_ref.toPin().?));
+ try testing.expect(out.rectangle);
+
+ try testing.expectEqual(Result.success, set(t, .selection, null));
+ try testing.expect(t.?.terminal.screens.active.selection == null);
+ try testing.expectEqual(Result.no_value, get(t, .selection, @ptrCast(&out)));
+}
+
+test "selection derivation helpers" {
+ 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);
+
+ vt_write(t, " Hello \r\nWorld", 16);
+
+ var out: selection_c.CSelection = undefined;
+
+ var word_ref: grid_ref_c.CGridRef = .{};
+ try testing.expectEqual(Result.success, grid_ref(t, .{
+ .tag = .active,
+ .value = .{ .active = .{ .x = 3, .y = 0 } },
+ }, &word_ref));
+
+ var empty_ref: grid_ref_c.CGridRef = .{};
+ try testing.expectEqual(Result.success, grid_ref(t, .{
+ .tag = .active,
+ .value = .{ .active = .{ .x = 20, .y = 0 } },
+ }, &empty_ref));
+
+ var line_ref: grid_ref_c.CGridRef = .{};
+ try testing.expectEqual(Result.success, grid_ref(t, .{
+ .tag = .active,
+ .value = .{ .active = .{ .x = 0, .y = 0 } },
+ }, &line_ref));
+
+ var word_opts: selection_c.SelectWordOptions = .{
+ .ref = word_ref,
+ };
+ try testing.expectEqual(Result.success, selection_c.word(t, &word_opts, &out));
+ try testing.expectEqual(@as(u16, 2), out.start.toPin().?.x);
+ try testing.expectEqual(@as(u16, 6), out.end.toPin().?.x);
+
+ word_opts.ref = empty_ref;
+ try testing.expectEqual(Result.no_value, selection_c.word(t, &word_opts, &out));
+
+ var between_start_ref: grid_ref_c.CGridRef = .{};
+ try testing.expectEqual(Result.success, grid_ref(t, .{
+ .tag = .active,
+ .value = .{ .active = .{ .x = 20, .y = 1 } },
+ }, &between_start_ref));
+
+ var between_end_ref: grid_ref_c.CGridRef = .{};
+ try testing.expectEqual(Result.success, grid_ref(t, .{
+ .tag = .active,
+ .value = .{ .active = .{ .x = 0, .y = 1 } },
+ }, &between_end_ref));
+
+ var word_between_opts: selection_c.SelectWordBetweenOptions = .{
+ .start = between_start_ref,
+ .end = between_end_ref,
+ };
+ try testing.expectEqual(Result.success, selection_c.word_between(t, &word_between_opts, &out));
+ try testing.expectEqual(@as(u16, 0), out.start.toPin().?.x);
+ try testing.expectEqual(@as(u16, 1), out.start.toPin().?.y);
+ try testing.expectEqual(@as(u16, 4), out.end.toPin().?.x);
+ try testing.expectEqual(@as(u16, 1), out.end.toPin().?.y);
+
+ var line_opts: selection_c.SelectLineOptions = .{
+ .ref = line_ref,
+ };
+ try testing.expectEqual(Result.success, selection_c.line(t, &line_opts, &out));
+ try testing.expectEqual(@as(u16, 2), out.start.toPin().?.x);
+ try testing.expectEqual(@as(u16, 6), out.end.toPin().?.x);
+
+ try testing.expectEqual(Result.success, selection_c.all(t, &out));
+ try testing.expectEqual(@as(u16, 2), out.start.toPin().?.x);
+ try testing.expectEqual(@as(u16, 0), out.start.toPin().?.y);
+ try testing.expectEqual(@as(u16, 4), out.end.toPin().?.x);
+ try testing.expectEqual(@as(u16, 1), out.end.toPin().?.y);
+
+ try testing.expectEqual(Result.no_value, selection_c.output(t, line_ref, &out));
+
+ line_opts.size = @sizeOf(usize) - 1;
+ try testing.expectEqual(Result.invalid_value, selection_c.line(t, &line_opts, &out));
+ try testing.expectEqual(Result.invalid_value, selection_c.word(t, null, &out));
+ try testing.expectEqual(Result.invalid_value, selection_c.word(t, &word_opts, null));
+ try testing.expectEqual(Result.invalid_value, selection_c.word_between(t, null, &out));
+ try testing.expectEqual(Result.invalid_value, selection_c.word_between(t, &word_between_opts, null));
+}
+
+test "selection_adjust mutates snapshot end" {
+ 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);
+
+ vt_write(t, "Hello", 5);
+
+ var start_ref: grid_ref_c.CGridRef = .{};
+ try testing.expectEqual(Result.success, grid_ref(t, .{
+ .tag = .active,
+ .value = .{ .active = .{ .x = 0, .y = 0 } },
+ }, &start_ref));
+
+ var end_ref: grid_ref_c.CGridRef = .{};
+ try testing.expectEqual(Result.success, grid_ref(t, .{
+ .tag = .active,
+ .value = .{ .active = .{ .x = 1, .y = 0 } },
+ }, &end_ref));
+
+ var sel: selection_c.CSelection = .{
+ .start = start_ref,
+ .end = end_ref,
+ };
+ try testing.expectEqual(Result.success, selection_c.adjust(t, &sel, .right));
+ try testing.expectEqual(@as(u16, 0), sel.start.toPin().?.x);
+ try testing.expectEqual(@as(u16, 2), sel.end.toPin().?.x);
+
+ try testing.expectEqual(Result.success, selection_c.adjust(t, &sel, .left));
+ try testing.expectEqual(@as(u16, 0), sel.start.toPin().?.x);
+ try testing.expectEqual(@as(u16, 1), sel.end.toPin().?.x);
+
+ sel = .{
+ .start = end_ref,
+ .end = start_ref,
+ };
+ try testing.expectEqual(Result.success, selection_c.adjust(t, &sel, .right));
+ try testing.expectEqual(@as(u16, 1), sel.start.toPin().?.x);
+ try testing.expectEqual(@as(u16, 1), sel.end.toPin().?.x);
+}
+
+test "selection_order and selection_ordered" {
+ 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);
+
+ vt_write(t, "Hello\r\nWorld", 12);
+
+ var start_ref: grid_ref_c.CGridRef = .{};
+ try testing.expectEqual(Result.success, grid_ref(t, .{
+ .tag = .active,
+ .value = .{ .active = .{ .x = 3, .y = 0 } },
+ }, &start_ref));
+
+ var end_ref: grid_ref_c.CGridRef = .{};
+ try testing.expectEqual(Result.success, grid_ref(t, .{
+ .tag = .active,
+ .value = .{ .active = .{ .x = 1, .y = 1 } },
+ }, &end_ref));
+
+ const sel: selection_c.CSelection = .{
+ .start = start_ref,
+ .end = end_ref,
+ .rectangle = true,
+ };
+
+ var order: selection_c.Order = undefined;
+ try testing.expectEqual(Result.success, selection_c.order(t, &sel, &order));
+ try testing.expectEqual(selection_c.Order.mirrored_forward, order);
+
+ var out: selection_c.CSelection = undefined;
+ try testing.expectEqual(Result.success, selection_c.ordered(t, &sel, .forward, &out));
+ try testing.expectEqual(@as(u16, 1), out.start.toPin().?.x);
+ try testing.expectEqual(@as(u16, 0), out.start.toPin().?.y);
+ try testing.expectEqual(@as(u16, 3), out.end.toPin().?.x);
+ try testing.expectEqual(@as(u16, 1), out.end.toPin().?.y);
+ try testing.expect(out.rectangle);
+
+ try testing.expectEqual(Result.success, selection_c.ordered(t, &sel, .reverse, &out));
+ try testing.expectEqual(@as(u16, 3), out.start.toPin().?.x);
+ try testing.expectEqual(@as(u16, 1), out.start.toPin().?.y);
+ try testing.expectEqual(@as(u16, 1), out.end.toPin().?.x);
+ try testing.expectEqual(@as(u16, 0), out.end.toPin().?.y);
+ try testing.expect(out.rectangle);
+}
+
+test "selection_contains" {
+ 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);
+
+ vt_write(t, "Hello\r\nWorld", 12);
+
+ var start_ref: grid_ref_c.CGridRef = .{};
+ try testing.expectEqual(Result.success, grid_ref(t, .{
+ .tag = .active,
+ .value = .{ .active = .{ .x = 3, .y = 0 } },
+ }, &start_ref));
+
+ var end_ref: grid_ref_c.CGridRef = .{};
+ try testing.expectEqual(Result.success, grid_ref(t, .{
+ .tag = .active,
+ .value = .{ .active = .{ .x = 1, .y = 1 } },
+ }, &end_ref));
+
+ const linear: selection_c.CSelection = .{
+ .start = start_ref,
+ .end = end_ref,
+ };
+
+ var contains: bool = undefined;
+ try testing.expectEqual(Result.success, selection_c.contains(t, &linear, .{
+ .tag = .active,
+ .value = .{ .active = .{ .x = 4, .y = 0 } },
+ }, &contains));
+ try testing.expect(contains);
+
+ try testing.expectEqual(Result.success, selection_c.contains(t, &linear, .{
+ .tag = .active,
+ .value = .{ .active = .{ .x = 2, .y = 0 } },
+ }, &contains));
+ try testing.expect(!contains);
+
+ const rectangle: selection_c.CSelection = .{
+ .start = start_ref,
+ .end = end_ref,
+ .rectangle = true,
+ };
+
+ try testing.expectEqual(Result.success, selection_c.contains(t, &rectangle, .{
+ .tag = .active,
+ .value = .{ .active = .{ .x = 2, .y = 0 } },
+ }, &contains));
+ try testing.expect(contains);
+}
+
+test "selection_equal" {
+ 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 other_t: Terminal = null;
+ try testing.expectEqual(Result.success, new(
+ &lib.alloc.test_allocator,
+ &other_t,
+ .{
+ .cols = 80,
+ .rows = 24,
+ .max_scrollback = 0,
+ },
+ ));
+ defer free(other_t);
+
+ vt_write(t, "Hello", 5);
+ vt_write(other_t, "Hello", 5);
+
+ var start_ref: grid_ref_c.CGridRef = .{};
+ try testing.expectEqual(Result.success, grid_ref(t, .{
+ .tag = .active,
+ .value = .{ .active = .{ .x = 0, .y = 0 } },
+ }, &start_ref));
+
+ var end_ref: grid_ref_c.CGridRef = .{};
+ try testing.expectEqual(Result.success, grid_ref(t, .{
+ .tag = .active,
+ .value = .{ .active = .{ .x = 1, .y = 0 } },
+ }, &end_ref));
+
+ var other_end_ref: grid_ref_c.CGridRef = .{};
+ try testing.expectEqual(Result.success, grid_ref(t, .{
+ .tag = .active,
+ .value = .{ .active = .{ .x = 2, .y = 0 } },
+ }, &other_end_ref));
+
+ var cross_terminal_ref: grid_ref_c.CGridRef = .{};
+ try testing.expectEqual(Result.success, grid_ref(other_t, .{
+ .tag = .active,
+ .value = .{ .active = .{ .x = 1, .y = 0 } },
+ }, &cross_terminal_ref));
+
+ const sel: selection_c.CSelection = .{
+ .start = start_ref,
+ .end = end_ref,
+ };
+ const equal_sel: selection_c.CSelection = .{
+ .start = start_ref,
+ .end = end_ref,
+ };
+ const different_endpoint: selection_c.CSelection = .{
+ .start = start_ref,
+ .end = other_end_ref,
+ };
+ const different_rectangle: selection_c.CSelection = .{
+ .start = start_ref,
+ .end = end_ref,
+ .rectangle = true,
+ };
+ const cross_terminal: selection_c.CSelection = .{
+ .start = start_ref,
+ .end = cross_terminal_ref,
+ };
+
+ var equal: bool = undefined;
+ try testing.expectEqual(Result.success, selection_c.equal(t, &sel, &equal_sel, &equal));
+ try testing.expect(equal);
+
+ try testing.expectEqual(Result.success, selection_c.equal(t, &sel, &different_endpoint, &equal));
+ try testing.expect(!equal);
+
+ try testing.expectEqual(Result.success, selection_c.equal(t, &sel, &different_rectangle, &equal));
+ try testing.expect(!equal);
+
+ try testing.expectEqual(Result.success, selection_c.equal(t, &sel, &cross_terminal, &equal));
+ try testing.expect(!equal);
+ try testing.expectEqual(Result.invalid_value, selection_c.equal(t, &sel, &equal_sel, null));
+}
+
+test "selection_order invalid values" {
+ 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 order: selection_c.Order = undefined;
+ try testing.expectEqual(Result.invalid_value, selection_c.order(null, null, &order));
+ try testing.expectEqual(Result.invalid_value, selection_c.order(t, null, &order));
+}
+
test "grid_ref" {
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 500809d9..de043a03 100644
--- a/vendor/libghostty-vt/src/terminal/c/types.zig
+++ b/vendor/libghostty-vt/src/terminal/c/types.zig
@@ -14,36 +14,60 @@ const size_report = @import("size_report.zig");
const terminal = @import("terminal.zig");
const formatter = @import("formatter.zig");
const selection = @import("selection.zig");
+const selection_gesture = @import("selection_gesture.zig");
const render = @import("render.zig");
const style_c = @import("style.zig");
const mouse_encode = @import("mouse_encode.zig");
const grid_ref = @import("grid_ref.zig");
+/// C: GhosttySurfacePosition
+pub const SurfacePosition = extern struct {
+ x: f64,
+ y: f64,
+};
+
+/// C: GhosttyCodepoints
+pub const Codepoints = extern struct {
+ ptr: ?[*]const u32 = null,
+ len: usize = 0,
+};
+
/// All C API structs and their Ghostty C names.
-pub const structs: std.StaticStringMap(StructInfo) = .initComptime(.{
- .{ "GhosttyColorRgb", StructInfo.init(color.RGB.C) },
- .{ "GhosttyDeviceAttributes", StructInfo.init(terminal.DeviceAttributes) },
- .{ "GhosttyDeviceAttributesPrimary", StructInfo.init(terminal.DeviceAttributes.Primary) },
- .{ "GhosttyDeviceAttributesSecondary", StructInfo.init(terminal.DeviceAttributes.Secondary) },
- .{ "GhosttyDeviceAttributesTertiary", StructInfo.init(terminal.DeviceAttributes.Tertiary) },
- .{ "GhosttyFormatterTerminalOptions", StructInfo.init(formatter.TerminalOptions) },
- .{ "GhosttySelection", StructInfo.init(selection.CSelection) },
- .{ "GhosttyFormatterTerminalExtra", StructInfo.init(formatter.TerminalOptions.Extra) },
- .{ "GhosttyFormatterScreenExtra", StructInfo.init(formatter.ScreenOptions.Extra) },
- .{ "GhosttyGridRef", StructInfo.init(grid_ref.CGridRef) },
- .{ "GhosttyMouseEncoderSize", StructInfo.init(mouse_encode.Size) },
- .{ "GhosttyMousePosition", StructInfo.init(mouse_event.Position) },
- .{ "GhosttyPoint", StructInfo.init(point.Point.C) },
- .{ "GhosttyPointCoordinate", StructInfo.init(point.Coordinate) },
- .{ "GhosttyRenderStateColors", StructInfo.init(render.Colors) },
- .{ "GhosttySizeReportSize", StructInfo.init(size_report.Size) },
- .{ "GhosttyString", StructInfo.init(lib.String) },
- .{ "GhosttyStyle", StructInfo.init(style_c.Style) },
- .{ "GhosttyStyleColor", StructInfo.init(style_c.Color) },
- .{ "GhosttyTerminalOptions", StructInfo.init(terminal.Options) },
- .{ "GhosttyTerminalScrollbar", StructInfo.init(terminal.TerminalScrollbar) },
- .{ "GhosttyTerminalScrollViewport", StructInfo.init(terminal.ScrollViewport) },
-});
+pub const structs: std.StaticStringMap(StructInfo) = structs: {
+ @setEvalBranchQuota(10_000);
+ break :structs .initComptime(.{
+ .{ "GhosttyBuffer", StructInfo.init(lib.Buffer) },
+ .{ "GhosttyCodepoints", StructInfo.init(Codepoints) },
+ .{ "GhosttyColorRgb", StructInfo.init(color.RGB.C) },
+ .{ "GhosttyDeviceAttributes", StructInfo.init(terminal.DeviceAttributes) },
+ .{ "GhosttyDeviceAttributesPrimary", StructInfo.init(terminal.DeviceAttributes.Primary) },
+ .{ "GhosttyDeviceAttributesSecondary", StructInfo.init(terminal.DeviceAttributes.Secondary) },
+ .{ "GhosttyDeviceAttributesTertiary", StructInfo.init(terminal.DeviceAttributes.Tertiary) },
+ .{ "GhosttyFormatterTerminalOptions", StructInfo.init(formatter.TerminalOptions) },
+ .{ "GhosttySelection", StructInfo.init(selection.CSelection) },
+ .{ "GhosttyTerminalSelectWordOptions", StructInfo.init(selection.SelectWordOptions) },
+ .{ "GhosttyTerminalSelectWordBetweenOptions", StructInfo.init(selection.SelectWordBetweenOptions) },
+ .{ "GhosttyTerminalSelectLineOptions", StructInfo.init(selection.SelectLineOptions) },
+ .{ "GhosttyFormatterTerminalExtra", StructInfo.init(formatter.TerminalOptions.Extra) },
+ .{ "GhosttyFormatterScreenExtra", StructInfo.init(formatter.ScreenOptions.Extra) },
+ .{ "GhosttyGridRef", StructInfo.init(grid_ref.CGridRef) },
+ .{ "GhosttyMouseEncoderSize", StructInfo.init(mouse_encode.Size) },
+ .{ "GhosttyMousePosition", StructInfo.init(mouse_event.Position) },
+ .{ "GhosttyPoint", StructInfo.init(point.Point.C) },
+ .{ "GhosttyPointCoordinate", StructInfo.init(point.Coordinate) },
+ .{ "GhosttyRenderStateColors", StructInfo.init(render.Colors) },
+ .{ "GhosttySelectionGestureBehaviors", StructInfo.init(selection_gesture.Behaviors) },
+ .{ "GhosttySelectionGestureGeometry", StructInfo.init(selection_gesture.Geometry) },
+ .{ "GhosttySizeReportSize", StructInfo.init(size_report.Size) },
+ .{ "GhosttyString", StructInfo.init(lib.String) },
+ .{ "GhosttySurfacePosition", StructInfo.init(SurfacePosition) },
+ .{ "GhosttyStyle", StructInfo.init(style_c.Style) },
+ .{ "GhosttyStyleColor", StructInfo.init(style_c.Color) },
+ .{ "GhosttyTerminalOptions", StructInfo.init(terminal.Options) },
+ .{ "GhosttyTerminalScrollbar", StructInfo.init(terminal.TerminalScrollbar) },
+ .{ "GhosttyTerminalScrollViewport", StructInfo.init(terminal.ScrollViewport) },
+ });
+};
/// The comptime-generated JSON string of all structs.
pub const json: [:0]const u8 = json: {
@@ -144,6 +168,11 @@ fn jsonWriteAll(writer: *std.Io.Writer) std.Io.Writer.Error!void {
fn typeName(comptime T: type) []const u8 {
return switch (@typeInfo(T)) {
.bool => "bool",
+ .float => |info| switch (info.bits) {
+ 32 => "f32",
+ 64 => "f64",
+ else => @compileError("unsupported float size"),
+ },
.int => |info| switch (info.signedness) {
.signed => switch (info.bits) {
8 => "i8",
diff --git a/vendor/libghostty-vt/src/terminal/lib.zig b/vendor/libghostty-vt/src/terminal/lib.zig
index 3cd657b4..921d831b 100644
--- a/vendor/libghostty-vt/src/terminal/lib.zig
+++ b/vendor/libghostty-vt/src/terminal/lib.zig
@@ -14,6 +14,7 @@ pub const calling_conv: std.builtin.CallingConvention = .c;
/// Forwarded decls from lib that are used.
pub const alloc = lib.allocator;
+pub const Buffer = lib.Buffer;
pub const Enum = lib.Enum;
pub const TaggedUnion = lib.TaggedUnion;
pub const Struct = lib.Struct;
diff --git a/vendor/libghostty-vt/src/terminal/main.zig b/vendor/libghostty-vt/src/terminal/main.zig
index 87a9aded..53491a00 100644
--- a/vendor/libghostty-vt/src/terminal/main.zig
+++ b/vendor/libghostty-vt/src/terminal/main.zig
@@ -49,6 +49,7 @@ pub const Screen = @import("Screen.zig");
pub const ScreenSet = @import("ScreenSet.zig");
pub const Scrollbar = PageList.Scrollbar;
pub const Selection = @import("Selection.zig");
+pub const SelectionGesture = @import("SelectionGesture.zig");
pub const SizeReportStyle = csi.SizeReportStyle;
pub const StringMap = @import("StringMap.zig");
pub const Style = style.Style;
diff --git a/vendor/libghostty-vt/src/terminal/point.zig b/vendor/libghostty-vt/src/terminal/point.zig
index 4297bf5b..f6bf25c3 100644
--- a/vendor/libghostty-vt/src/terminal/point.zig
+++ b/vendor/libghostty-vt/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 {
diff --git a/vendor/libghostty-vt/src/terminal/selection_codepoints.zig b/vendor/libghostty-vt/src/terminal/selection_codepoints.zig
new file mode 100644
index 00000000..4c318403
--- /dev/null
+++ b/vendor/libghostty-vt/src/terminal/selection_codepoints.zig
@@ -0,0 +1,31 @@
+// This file contains various default word boundaries used for
+// selection logic. We put it in a separate file so that different
+// subsystems can import it without introducing a number of
+// dependencies.
+
+/// Default boundary characters for word selection: ` \t'"│`|:;,()[]{}<>$`
+pub const default_word_boundaries = [_]u21{
+ 0, // null
+ ' ', // space
+ '\t', // tab
+ '\'', // single quote
+ '"', // double quote
+ '│', // U+2502 box drawing
+ '`', // backtick
+ '|', // pipe
+ ':', // colon
+ ';', // semicolon
+ ',', // comma
+ '(', // left paren
+ ')', // right paren
+ '[', // left bracket
+ ']', // right bracket
+ '{', // left brace
+ '}', // right brace
+ '<', // less than
+ '>', // greater than
+ '$', // dollar
+};
+
+/// Default whitespace characters trimmed from line selections.
+pub const default_line_whitespace = [_]u21{ 0, ' ', '\t' };