From 2f61ba036ed4d0013f34414728938bf7825219c8 Mon Sep 17 00:00:00 2001 From: Mitchell Hashimoto Date: Wed, 27 May 2026 08:00:37 -0700 Subject: [PATCH 1/9] libghostty: starting the SelectionGesture API, just init/get --- include/ghostty/vt/selection.h | 194 +++++++++++++++++++ src/lib_vt.zig | 5 + src/terminal/SelectionGesture.zig | 27 +-- src/terminal/c/main.zig | 7 + src/terminal/c/selection_gesture.zig | 272 +++++++++++++++++++++++++++ 5 files changed, 494 insertions(+), 11 deletions(-) create mode 100644 src/terminal/c/selection_gesture.zig diff --git a/include/ghostty/vt/selection.h b/include/ghostty/vt/selection.h index 142877a97..dcdf9108c 100644 --- a/include/ghostty/vt/selection.h +++ b/include/ghostty/vt/selection.h @@ -39,6 +39,17 @@ extern "C" { * @{ */ +/** + * Opaque handle to state for interpreting terminal selection gestures. + * + * The gesture owns only the state required to interpret pointer events. Calls + * that use a gesture are not concurrency-safe and must be serialized with + * terminal mutations. + * + * @ingroup selection + */ +typedef struct GhosttySelectionGestureImpl* GhosttySelectionGesture; + /** * A snapshot selection range defined by two grid references. * @@ -283,6 +294,189 @@ typedef enum GHOSTTY_ENUM_TYPED { GHOSTTY_SELECTION_ADJUST_MAX_VALUE = GHOSTTY_ENUM_MAX_VALUE, } GhosttySelectionAdjust; +/** + * Selection behavior chosen for a gesture's click sequence. + * + * @ingroup selection + */ +typedef enum GHOSTTY_ENUM_TYPED { + /** Cell-granular drag selection. */ + GHOSTTY_SELECTION_GESTURE_BEHAVIOR_CELL = 0, + + /** Word selection on press and word-granular drag selection. */ + GHOSTTY_SELECTION_GESTURE_BEHAVIOR_WORD = 1, + + /** Line selection on press and line-granular drag selection. */ + GHOSTTY_SELECTION_GESTURE_BEHAVIOR_LINE = 2, + + /** Semantic command output selection on press and drag. */ + GHOSTTY_SELECTION_GESTURE_BEHAVIOR_OUTPUT = 3, + + GHOSTTY_SELECTION_GESTURE_BEHAVIOR_MAX_VALUE = GHOSTTY_ENUM_MAX_VALUE, +} GhosttySelectionGestureBehavior; + +/** + * Current autoscroll direction for an active selection drag gesture. + * + * @ingroup selection + */ +typedef enum GHOSTTY_ENUM_TYPED { + /** No selection autoscroll is requested. */ + GHOSTTY_SELECTION_GESTURE_AUTOSCROLL_NONE = 0, + + /** Selection dragging should autoscroll the viewport upward. */ + GHOSTTY_SELECTION_GESTURE_AUTOSCROLL_UP = 1, + + /** Selection dragging should autoscroll the viewport downward. */ + GHOSTTY_SELECTION_GESTURE_AUTOSCROLL_DOWN = 2, + + GHOSTTY_SELECTION_GESTURE_AUTOSCROLL_MAX_VALUE = GHOSTTY_ENUM_MAX_VALUE, +} GhosttySelectionGestureAutoscroll; + +/** + * Data fields readable from a selection gesture with + * ghostty_selection_gesture_get(). + * + * @ingroup selection + */ +typedef enum GHOSTTY_ENUM_TYPED { + /** Current click count: uint8_t*. 0 means inactive. */ + GHOSTTY_SELECTION_GESTURE_DATA_CLICK_COUNT = 0, + + /** Whether the current/last left-click gesture has dragged: bool*. */ + GHOSTTY_SELECTION_GESTURE_DATA_DRAGGED = 1, + + /** Current autoscroll request: GhosttySelectionGestureAutoscroll*. */ + GHOSTTY_SELECTION_GESTURE_DATA_AUTOSCROLL = 2, + + /** Current gesture behavior: GhosttySelectionGestureBehavior*. */ + GHOSTTY_SELECTION_GESTURE_DATA_BEHAVIOR = 3, + + /** + * Current left-click anchor: GhosttyGridRef*. + * + * Returns GHOSTTY_NO_VALUE if there is no valid active anchor. On success, + * writes an untracked GhosttyGridRef snapshot with normal GhosttyGridRef + * lifetime rules. + */ + GHOSTTY_SELECTION_GESTURE_DATA_ANCHOR = 4, + + GHOSTTY_SELECTION_GESTURE_DATA_MAX_VALUE = GHOSTTY_ENUM_MAX_VALUE, +} GhosttySelectionGestureData; + +/** + * Create a selection gesture object. + * + * The gesture stores mutable state for terminal text selection gestures. The + * gesture is not bound to a terminal at creation time; terminal-dependent APIs + * take the terminal explicitly. + * + * @param allocator Allocator, or NULL for the default allocator + * @param out_gesture Receives the created gesture handle + * @return GHOSTTY_SUCCESS on success, GHOSTTY_INVALID_VALUE if out_gesture is + * NULL, or GHOSTTY_OUT_OF_MEMORY if allocation fails + * + * @ingroup selection + */ +GHOSTTY_API GhosttyResult ghostty_selection_gesture_new( + const GhosttyAllocator* allocator, + GhosttySelectionGesture* out_gesture); + +/** + * Free a selection gesture object. + * + * This releases any tracked terminal references owned by the gesture using the + * provided terminal, then frees the gesture object. Passing NULL for gesture is + * allowed and is a no-op. + * + * If the terminal is still alive, pass the terminal most recently used with the + * gesture so any tracked terminal references can be released correctly. If the + * terminal has already been freed, pass NULL for terminal; the terminal's page + * storage has already released the underlying tracked references, so the + * gesture wrapper can be safely discarded without touching the stale terminal + * state. + * + * @param gesture Selection gesture handle to free + * @param terminal Terminal used to release tracked gesture state, or NULL if + * the terminal has already been freed + * + * @ingroup selection + */ +GHOSTTY_API void ghostty_selection_gesture_free( + GhosttySelectionGesture gesture, + GhosttyTerminal terminal); + +/** + * Reset any active selection gesture state. + * + * This cancels the active click sequence and releases any tracked terminal + * references owned by the gesture without freeing the gesture object. + * Passing NULL is allowed and is a no-op. + * + * @param gesture Selection gesture handle to reset + * @param terminal Terminal used to release tracked gesture state + * + * @ingroup selection + */ +GHOSTTY_API void ghostty_selection_gesture_reset( + GhosttySelectionGesture gesture, + GhosttyTerminal terminal); + +/** + * Read data from a selection gesture. + * + * The type of value depends on data and is documented by + * GhosttySelectionGestureData. For GHOSTTY_SELECTION_GESTURE_DATA_ANCHOR, + * the returned GhosttyGridRef is an untracked snapshot with normal grid-ref + * lifetime rules. + * + * @param gesture Selection gesture handle (NULL returns GHOSTTY_INVALID_VALUE) + * @param terminal Terminal used to validate terminal-backed gesture state + * @param data Data field to read + * @param value Output pointer whose type depends on data + * @return GHOSTTY_SUCCESS on success, GHOSTTY_NO_VALUE if the requested data + * has no value, or GHOSTTY_INVALID_VALUE if gesture, terminal, data, or + * value is invalid + * + * @ingroup selection + */ +GHOSTTY_API GhosttyResult ghostty_selection_gesture_get( + GhosttySelectionGesture gesture, + GhosttyTerminal terminal, + GhosttySelectionGestureData data, + void* value); + +/** + * Read multiple data fields from a selection gesture in a single call. + * + * This is an optimization over calling ghostty_selection_gesture_get() multiple + * times. Each entry in values must point to storage of the type documented by + * the corresponding GhosttySelectionGestureData key. + * + * If any individual read fails, the function returns that error and writes the + * index of the failing key to out_written when out_written is non-NULL. On + * success, out_written receives count when non-NULL. + * + * @param gesture Selection gesture handle (NULL returns GHOSTTY_INVALID_VALUE) + * @param terminal Terminal used to validate terminal-backed gesture state + * @param count Number of data fields to read + * @param keys Data fields to read (must not be NULL) + * @param values Output pointers corresponding to keys (must not be NULL) + * @param out_written Optional number of fields read, or failing index on error + * @return GHOSTTY_SUCCESS on success, GHOSTTY_NO_VALUE if a requested data + * field has no value, or GHOSTTY_INVALID_VALUE if gesture, terminal, + * keys, values, or a value pointer is invalid + * + * @ingroup selection + */ +GHOSTTY_API GhosttyResult ghostty_selection_gesture_get_multi( + GhosttySelectionGesture gesture, + GhosttyTerminal terminal, + size_t count, + const GhosttySelectionGestureData* keys, + void** values, + size_t* out_written); + /** * Derive a word selection snapshot from a terminal grid reference. * diff --git a/src/lib_vt.zig b/src/lib_vt.zig index 71b709135..1806d74bc 100644 --- a/src/lib_vt.zig +++ b/src/lib_vt.zig @@ -251,6 +251,11 @@ comptime { @export(&c.terminal_selection_ordered, .{ .name = "ghostty_terminal_selection_ordered" }); @export(&c.terminal_selection_contains, .{ .name = "ghostty_terminal_selection_contains" }); @export(&c.terminal_selection_equal, .{ .name = "ghostty_terminal_selection_equal" }); + @export(&c.selection_gesture_new, .{ .name = "ghostty_selection_gesture_new" }); + @export(&c.selection_gesture_free, .{ .name = "ghostty_selection_gesture_free" }); + @export(&c.selection_gesture_reset, .{ .name = "ghostty_selection_gesture_reset" }); + @export(&c.selection_gesture_get, .{ .name = "ghostty_selection_gesture_get" }); + @export(&c.selection_gesture_get_multi, .{ .name = "ghostty_selection_gesture_get_multi" }); @export(&c.terminal_grid_ref, .{ .name = "ghostty_terminal_grid_ref" }); @export(&c.terminal_grid_ref_track, .{ .name = "ghostty_terminal_grid_ref_track" }); @export(&c.terminal_point_from_grid_ref, .{ .name = "ghostty_terminal_point_from_grid_ref" }); diff --git a/src/terminal/SelectionGesture.zig b/src/terminal/SelectionGesture.zig index 4b1edac88..22ba468b9 100644 --- a/src/terminal/SelectionGesture.zig +++ b/src/terminal/SelectionGesture.zig @@ -70,6 +70,7 @@ const std = @import("std"); 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"); @@ -118,22 +119,26 @@ left_drag_autoscroll: Autoscroll, /// /// This is used to implement selection above/below the viewport that /// wants to drag the viewport. -pub const Autoscroll = enum { none, up, down }; +pub const Autoscroll = lib.Enum(lib.target, &.{ + "none", + "up", + "down", +}); /// The selection behavior for a click and subsequent drag. -pub const Behavior = enum { - /// Cell-granular drag selection. Press returns null to clear selection. - cell, +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, + // Word selection on press and word-granular drag selection. + "word", - /// Line selection on press and line-granular drag selection. - line, + // Line selection on press and line-granular drag selection. + "line", - /// Semantic command output selection on press and drag. - output, -}; + // Semantic command output selection on press and drag. + "output", +}); /// Standard terminal selection behavior for single-, double-, and triple-clicks. /// diff --git a/src/terminal/c/main.zig b/src/terminal/c/main.zig index 1d78f06bb..3b853b467 100644 --- a/src/terminal/c/main.zig +++ b/src/terminal/c/main.zig @@ -31,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"); @@ -182,6 +183,11 @@ 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_get = selection_gesture.get; +pub const selection_gesture_get_multi = selection_gesture.get_multi; pub const terminal_grid_ref = terminal.grid_ref; pub const terminal_grid_ref_track = terminal.grid_ref_track; pub const terminal_point_from_grid_ref = terminal.point_from_grid_ref; @@ -214,6 +220,7 @@ test { _ = osc; _ = render; _ = selection; + _ = selection_gesture; _ = key_event; _ = key_encode; _ = mouse_event; diff --git a/src/terminal/c/selection_gesture.zig b/src/terminal/c/selection_gesture.zig new file mode 100644 index 000000000..70c3d76b5 --- /dev/null +++ b/src/terminal/c/selection_gesture.zig @@ -0,0 +1,272 @@ +const std = @import("std"); +const testing = std.testing; +const lib = @import("../lib.zig"); +const CAllocator = lib.alloc.Allocator; +const SelectionGesture = @import("../SelectionGesture.zig"); +const grid_ref = @import("grid_ref.zig"); +const terminal_c = @import("terminal.zig"); +const Result = @import("result.zig").Result; + +const log = std.log.scoped(.selection_gesture_c); + +/// C: GhosttySelectionGesture +pub const Gesture = ?*GestureWrapper; + +const GestureWrapper = struct { + alloc: std.mem.Allocator, + gesture: SelectionGesture = .init, +}; + +/// C: GhosttySelectionGestureBehavior +pub const Behavior = SelectionGesture.Behavior; + +/// C: GhosttySelectionGestureAutoscroll +pub const Autoscroll = SelectionGesture.Autoscroll; + +/// 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, + }; + } +}; + +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 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 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 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; +} + +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 free null" { + free(null, null); +} From bbfa984aec99c8d3e2e7dde1a10c7520f4f873cb Mon Sep 17 00:00:00 2001 From: Mitchell Hashimoto Date: Wed, 27 May 2026 09:10:30 -0700 Subject: [PATCH 2/9] libghostty: GhosttySelectionGestureEvent --- include/ghostty/vt/selection.h | 136 ++++++++++++ include/ghostty/vt/types.h | 32 +++ src/lib_vt.zig | 3 + src/terminal/c/main.zig | 3 + src/terminal/c/selection_gesture.zig | 312 +++++++++++++++++++++++++++ src/terminal/c/types.zig | 21 ++ 6 files changed, 507 insertions(+) diff --git a/include/ghostty/vt/selection.h b/include/ghostty/vt/selection.h index dcdf9108c..053c3bc44 100644 --- a/include/ghostty/vt/selection.h +++ b/include/ghostty/vt/selection.h @@ -50,6 +50,16 @@ extern "C" { */ typedef struct GhosttySelectionGestureImpl* GhosttySelectionGesture; +/** + * Opaque handle to reusable input data for selection gesture operations. + * + * Event options are set with ghostty_selection_gesture_event_set(). Individual + * gesture operations document which options are required or optional. + * + * @ingroup selection + */ +typedef struct GhosttySelectionGestureEventImpl* GhosttySelectionGestureEvent; + /** * A snapshot selection range defined by two grid references. * @@ -315,6 +325,22 @@ typedef enum GHOSTTY_ENUM_TYPED { GHOSTTY_SELECTION_GESTURE_BEHAVIOR_MAX_VALUE = GHOSTTY_ENUM_MAX_VALUE, } GhosttySelectionGestureBehavior; +/** + * Selection behaviors for single-, double-, and triple-click gestures. + * + * @ingroup selection + */ +typedef struct { + /** Behavior for single-click selection gestures. */ + GhosttySelectionGestureBehavior single_click; + + /** Behavior for double-click selection gestures. */ + GhosttySelectionGestureBehavior double_click; + + /** Behavior for triple-click selection gestures. */ + GhosttySelectionGestureBehavior triple_click; +} GhosttySelectionGestureBehaviors; + /** * Current autoscroll direction for an active selection drag gesture. * @@ -364,6 +390,116 @@ typedef enum GHOSTTY_ENUM_TYPED { GHOSTTY_SELECTION_GESTURE_DATA_MAX_VALUE = GHOSTTY_ENUM_MAX_VALUE, } GhosttySelectionGestureData; +/** + * Selection gesture event type. + * + * The event type is fixed when the event is created. Each event type documents + * which options are valid and which options are required by gesture operations. + * + * @ingroup selection + */ +typedef enum GHOSTTY_ENUM_TYPED { + /** Press event for ghostty_selection_gesture_press(). */ + GHOSTTY_SELECTION_GESTURE_EVENT_TYPE_PRESS = 0, + + GHOSTTY_SELECTION_GESTURE_EVENT_TYPE_MAX_VALUE = GHOSTTY_ENUM_MAX_VALUE, +} GhosttySelectionGestureEventType; + +/** + * Options stored on a reusable selection gesture event. + * + * Passing NULL as the value to ghostty_selection_gesture_event_set() clears the + * corresponding option. + * + * @ingroup selection + */ +typedef enum GHOSTTY_ENUM_TYPED { + /** Grid reference under the pointer: GhosttyGridRef*. */ + GHOSTTY_SELECTION_GESTURE_EVENT_OPT_REF = 0, + + /** Surface-space pointer position: GhosttySurfacePosition*. */ + GHOSTTY_SELECTION_GESTURE_EVENT_OPT_POSITION = 1, + + /** Maximum repeat-click distance in pixels: double*. */ + GHOSTTY_SELECTION_GESTURE_EVENT_OPT_REPEAT_DISTANCE = 2, + + /** + * Optional monotonic event time in nanoseconds: uint64_t*. + * + * If unset, press treats the event as untimed and only single-click behavior + * is available. + */ + GHOSTTY_SELECTION_GESTURE_EVENT_OPT_TIME_NS = 3, + + /** Maximum interval between repeat clicks in nanoseconds: uint64_t*. */ + GHOSTTY_SELECTION_GESTURE_EVENT_OPT_REPEAT_INTERVAL_NS = 4, + + /** + * Word-boundary codepoints: GhosttyCodepoints*. + * + * The codepoints are copied into event-owned storage when set. If unset, + * operations that need word boundaries use Ghostty's defaults. + */ + GHOSTTY_SELECTION_GESTURE_EVENT_OPT_WORD_BOUNDARY_CODEPOINTS = 5, + + /** + * Selection behavior table: GhosttySelectionGestureBehaviors*. + * + * If unset, press uses the default behavior table: cell, word, line. + */ + GHOSTTY_SELECTION_GESTURE_EVENT_OPT_BEHAVIORS = 6, + + GHOSTTY_SELECTION_GESTURE_EVENT_OPT_MAX_VALUE = GHOSTTY_ENUM_MAX_VALUE, +} GhosttySelectionGestureEventOption; + +/** + * Create a reusable selection gesture event object. + * + * @param allocator Allocator, or NULL for the default allocator + * @param out_event Receives the created event handle + * @param type Event type. This is fixed for the lifetime of the event. + * @return GHOSTTY_SUCCESS on success, GHOSTTY_INVALID_VALUE if out_event is + * NULL or type is invalid, or GHOSTTY_OUT_OF_MEMORY if allocation fails + * + * @ingroup selection + */ +GHOSTTY_API GhosttyResult ghostty_selection_gesture_event_new( + const GhosttyAllocator* allocator, + GhosttySelectionGestureEvent* out_event, + GhosttySelectionGestureEventType type); + +/** + * Free a selection gesture event object. + * + * Passing NULL is allowed and is a no-op. + * + * @param event Selection gesture event handle to free + * + * @ingroup selection + */ +GHOSTTY_API void ghostty_selection_gesture_event_free( + GhosttySelectionGestureEvent event); + +/** + * Set or clear an option on a selection gesture event. + * + * The value type depends on option and is documented by + * GhosttySelectionGestureEventOption. Passing NULL for value clears the option. + * + * @param event Selection gesture event handle (NULL returns GHOSTTY_INVALID_VALUE) + * @param option Event option to set or clear + * @param value Pointer to the input value for option, or NULL to clear + * @return GHOSTTY_SUCCESS on success, GHOSTTY_OUT_OF_MEMORY if copying + * event-owned data fails, or GHOSTTY_INVALID_VALUE if event, option, or + * value is invalid + * + * @ingroup selection + */ +GHOSTTY_API GhosttyResult ghostty_selection_gesture_event_set( + GhosttySelectionGestureEvent event, + GhosttySelectionGestureEventOption option, + const void* value); + /** * Create a selection gesture object. * diff --git a/include/ghostty/vt/types.h b/include/ghostty/vt/types.h index f3874153f..1bec223d6 100644 --- a/include/ghostty/vt/types.h +++ b/include/ghostty/vt/types.h @@ -227,6 +227,38 @@ typedef struct { size_t len; } GhosttyString; +/** + * A surface-space position in pixels. + * + * This is not a terminal grid coordinate. It represents an x/y position in the + * rendered surface coordinate space, with (0, 0) at the top-left of the + * surface. + */ +typedef struct { + /** X position in surface pixels. */ + double x; + + /** Y position in surface pixels. */ + double y; +} GhosttySurfacePosition; + +/** + * A borrowed list of Unicode scalar values. + * + * Values are encoded as uint32_t scalar values. The memory is not owned by this + * struct. The pointer is only valid for the lifetime documented by the API that + * consumes or produces it. + * + * APIs may document special handling for NULL + len 0, such as “use defaults”. + */ +typedef struct { + /** Pointer to Unicode scalar values. */ + const uint32_t* ptr; + + /** Number of entries in ptr. */ + size_t len; +} GhosttyCodepoints; + /** * Initialize a sized struct to zero and set its size field. * diff --git a/src/lib_vt.zig b/src/lib_vt.zig index 1806d74bc..a7139476f 100644 --- a/src/lib_vt.zig +++ b/src/lib_vt.zig @@ -256,6 +256,9 @@ comptime { @export(&c.selection_gesture_reset, .{ .name = "ghostty_selection_gesture_reset" }); @export(&c.selection_gesture_get, .{ .name = "ghostty_selection_gesture_get" }); @export(&c.selection_gesture_get_multi, .{ .name = "ghostty_selection_gesture_get_multi" }); + @export(&c.selection_gesture_event_new, .{ .name = "ghostty_selection_gesture_event_new" }); + @export(&c.selection_gesture_event_free, .{ .name = "ghostty_selection_gesture_event_free" }); + @export(&c.selection_gesture_event_set, .{ .name = "ghostty_selection_gesture_event_set" }); @export(&c.terminal_grid_ref, .{ .name = "ghostty_terminal_grid_ref" }); @export(&c.terminal_grid_ref_track, .{ .name = "ghostty_terminal_grid_ref_track" }); @export(&c.terminal_point_from_grid_ref, .{ .name = "ghostty_terminal_point_from_grid_ref" }); diff --git a/src/terminal/c/main.zig b/src/terminal/c/main.zig index 3b853b467..959edaabe 100644 --- a/src/terminal/c/main.zig +++ b/src/terminal/c/main.zig @@ -188,6 +188,9 @@ pub const selection_gesture_free = selection_gesture.free; pub const selection_gesture_reset = selection_gesture.reset; 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; diff --git a/src/terminal/c/selection_gesture.zig b/src/terminal/c/selection_gesture.zig index 70c3d76b5..8087a88a9 100644 --- a/src/terminal/c/selection_gesture.zig +++ b/src/terminal/c/selection_gesture.zig @@ -1,10 +1,13 @@ 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 terminal_c = @import("terminal.zig"); +const types = @import("types.zig"); const Result = @import("result.zig").Result; const log = std.log.scoped(.selection_gesture_c); @@ -12,17 +15,71 @@ 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, + }, + + // Backing storage for Press.word_boundary_codepoints. The C API receives + // codepoints as borrowed uint32_t values, but SelectionGesture.Press stores + // a []const u21 slice. We copy/convert into event-owned storage so the real + // Press 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() }, + }; + } + + 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 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, @@ -42,6 +99,34 @@ pub const Data = enum(c_int) { } }; +/// C: GhosttySelectionGestureEventType +pub const EventType = enum(c_int) { + press = 0, +}; + +/// 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, + + 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, + }; + } +}; + pub fn new( alloc_: ?*const CAllocator, out_gesture: ?*Gesture, @@ -60,6 +145,29 @@ pub fn new( 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, @@ -72,6 +180,13 @@ pub fn free( 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, @@ -81,6 +196,27 @@ pub fn reset( wrapper.gesture.reset(t); } +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, @@ -151,6 +287,102 @@ fn getTyped( 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), + }; +} + +fn pressSetTyped( + event: *EventWrapper, + press: *SelectionGesture.Press, + comptime option: EventOption, + value: ?*const option.Type(), +) Result { + const v = value orelse { + switch (option) { + .ref => {}, + .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 => clearPressCodepoints(event, press), + .behaviors => { + event.behaviors = SelectionGesture.default_behaviors; + press.behaviors = &event.behaviors; + }, + } + return .success; + }; + + switch (option) { + .ref => press.pin = v.toPin() orelse return .invalid_value, + .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 => { + if (v.len > 0 and v.ptr == null) return .invalid_value; + clearPressCodepoints(event, press); + const ptr = v.ptr orelse { + event.word_boundary_codepoints = &.{}; + press.word_boundary_codepoints = event.word_boundary_codepoints.?; + return .success; + }; + const copy = event.alloc.alloc(u21, v.len) catch return .out_of_memory; + errdefer event.alloc.free(copy); + for (copy, ptr[0..v.len]) |*dst, cp| { + dst.* = std.math.cast(u21, cp) orelse return .invalid_value; + } + event.word_boundary_codepoints = copy; + press.word_boundary_codepoints = copy; + }, + .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; + }, + } + + return .success; +} + +fn clearPressCodepoints(event: *EventWrapper, press: *SelectionGesture.Press) void { + if (event.word_boundary_codepoints) |cps| { + if (cps.len > 0) event.alloc.free(cps); + } + event.word_boundary_codepoints = null; + press.word_boundary_codepoints = &selection_codepoints.default_word_boundaries; +} + +fn instantFromNs(ns: u64) std.time.Instant { + return switch (builtin.os.tag) { + .windows, .uefi, .wasi => .{ .timestamp = ns }, + else => .{ .timestamp = .{ + .sec = @intCast(ns / std.time.ns_per_s), + .nsec = @intCast(ns % std.time.ns_per_s), + } }, + }; +} + +fn 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( @@ -267,6 +499,86 @@ test "selection gesture get_multi returns first failing index" { 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 free null" { free(null, null); } + +test "selection gesture event free null" { + event_free(null); +} diff --git a/src/terminal/c/types.zig b/src/terminal/c/types.zig index d9ece57ee..9a4ef3d56 100644 --- a/src/terminal/c/types.zig +++ b/src/terminal/c/types.zig @@ -14,15 +14,29 @@ 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) = structs: { @setEvalBranchQuota(10_000); break :structs .initComptime(.{ + .{ "GhosttyCodepoints", StructInfo.init(Codepoints) }, .{ "GhosttyColorRgb", StructInfo.init(color.RGB.C) }, .{ "GhosttyDeviceAttributes", StructInfo.init(terminal.DeviceAttributes) }, .{ "GhosttyDeviceAttributesPrimary", StructInfo.init(terminal.DeviceAttributes.Primary) }, @@ -41,8 +55,10 @@ pub const structs: std.StaticStringMap(StructInfo) = structs: { .{ "GhosttyPoint", StructInfo.init(point.Point.C) }, .{ "GhosttyPointCoordinate", StructInfo.init(point.Coordinate) }, .{ "GhosttyRenderStateColors", StructInfo.init(render.Colors) }, + .{ "GhosttySelectionGestureBehaviors", StructInfo.init(selection_gesture.Behaviors) }, .{ "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) }, @@ -150,6 +166,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", From 5ac8e6569a8d1d73f1bff9b4fc82ea703b9ca97e Mon Sep 17 00:00:00 2001 From: Mitchell Hashimoto Date: Wed, 27 May 2026 10:44:40 -0700 Subject: [PATCH 3/9] libghostty: add ghostty_selection_gesture_event --- include/ghostty/vt/selection.h | 29 ++++++ src/lib_vt.zig | 1 + src/terminal/c/main.zig | 1 + src/terminal/c/selection_gesture.zig | 145 ++++++++++++++++++++++++++- 4 files changed, 174 insertions(+), 2 deletions(-) diff --git a/include/ghostty/vt/selection.h b/include/ghostty/vt/selection.h index 053c3bc44..0270a3038 100644 --- a/include/ghostty/vt/selection.h +++ b/include/ghostty/vt/selection.h @@ -500,6 +500,35 @@ GHOSTTY_API GhosttyResult ghostty_selection_gesture_event_set( GhosttySelectionGestureEventOption option, const void* value); +/** + * Apply a selection gesture event and return the resulting selection snapshot. + * + * This dispatches to the gesture operation matching the event's fixed type. + * For GHOSTTY_SELECTION_GESTURE_EVENT_TYPE_PRESS, the event must have + * GHOSTTY_SELECTION_GESTURE_EVENT_OPT_REF set before calling this function. + * All other press options use their initialized defaults when unset or cleared. + * + * The returned selection is not installed as the terminal's current selection. + * It is a snapshot with the same lifetime rules as GhosttySelection. + * + * @param gesture Selection gesture handle (NULL returns GHOSTTY_INVALID_VALUE) + * @param terminal Terminal used to interpret and update gesture state + * @param event Selection gesture event handle (NULL returns GHOSTTY_INVALID_VALUE) + * @param[out] out_selection On success, receives the resulting selection. May + * be NULL to apply the event and discard the selection result. + * @return GHOSTTY_SUCCESS on success, GHOSTTY_NO_VALUE if the event does not + * currently produce a selection, GHOSTTY_OUT_OF_MEMORY if tracking + * gesture state fails, or GHOSTTY_INVALID_VALUE if gesture, terminal, + * event, or required event data is invalid + * + * @ingroup selection + */ +GHOSTTY_API GhosttyResult ghostty_selection_gesture_event( + GhosttySelectionGesture gesture, + GhosttyTerminal terminal, + GhosttySelectionGestureEvent event, + GhosttySelection* out_selection); + /** * Create a selection gesture object. * diff --git a/src/lib_vt.zig b/src/lib_vt.zig index a7139476f..6d4406e88 100644 --- a/src/lib_vt.zig +++ b/src/lib_vt.zig @@ -254,6 +254,7 @@ comptime { @export(&c.selection_gesture_new, .{ .name = "ghostty_selection_gesture_new" }); @export(&c.selection_gesture_free, .{ .name = "ghostty_selection_gesture_free" }); @export(&c.selection_gesture_reset, .{ .name = "ghostty_selection_gesture_reset" }); + @export(&c.selection_gesture_event, .{ .name = "ghostty_selection_gesture_event" }); @export(&c.selection_gesture_get, .{ .name = "ghostty_selection_gesture_get" }); @export(&c.selection_gesture_get_multi, .{ .name = "ghostty_selection_gesture_get_multi" }); @export(&c.selection_gesture_event_new, .{ .name = "ghostty_selection_gesture_event_new" }); diff --git a/src/terminal/c/main.zig b/src/terminal/c/main.zig index 959edaabe..648bdbe51 100644 --- a/src/terminal/c/main.zig +++ b/src/terminal/c/main.zig @@ -186,6 +186,7 @@ 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; diff --git a/src/terminal/c/selection_gesture.zig b/src/terminal/c/selection_gesture.zig index 8087a88a9..df823e62e 100644 --- a/src/terminal/c/selection_gesture.zig +++ b/src/terminal/c/selection_gesture.zig @@ -6,6 +6,7 @@ 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 selection_c = @import("selection.zig"); const terminal_c = @import("terminal.zig"); const types = @import("types.zig"); const Result = @import("result.zig").Result; @@ -29,6 +30,12 @@ const EventWrapper = struct { press: SelectionGesture.Press, }, + // Press.pin has no safe sentinel value: PageList.Pin contains a non-null + // node pointer and is undefined until the C caller provides a GhosttyGridRef. + // Track that separately so event execution can reject a press whose required + // ref option was never set, or was later cleared. + press_pin_set: bool = false, + // Backing storage for Press.word_boundary_codepoints. The C API receives // codepoints as borrowed uint32_t values, but SelectionGesture.Press stores // a []const u21 slice. We copy/convert into event-owned storage so the real @@ -196,6 +203,28 @@ pub fn reset( 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.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; + }, + }; +} + pub fn event_set( event_: Event, option: EventOption, @@ -306,7 +335,7 @@ fn pressSetTyped( ) Result { const v = value orelse { switch (option) { - .ref => {}, + .ref => event.press_pin_set = false, .position => { press.xpos = 0; press.ypos = 0; @@ -324,7 +353,10 @@ fn pressSetTyped( }; switch (option) { - .ref => press.pin = v.toPin() orelse return .invalid_value, + .ref => { + press.pin = v.toPin() orelse return .invalid_value; + event.press_pin_set = true; + }, .position => { press.xpos = v.x; press.ypos = v.y; @@ -575,6 +607,115 @@ test "selection gesture event behaviors" { 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 free null" { free(null, null); } From 3fd2c66a048ad12901ea30ef30da1a4dfc7395a4 Mon Sep 17 00:00:00 2001 From: Mitchell Hashimoto Date: Wed, 27 May 2026 10:48:30 -0700 Subject: [PATCH 4/9] libghostty: selection gesture release event --- include/ghostty/vt/selection.h | 16 +++- src/terminal/c/selection_gesture.zig | 137 +++++++++++++++++++++++++++ 2 files changed, 152 insertions(+), 1 deletion(-) diff --git a/include/ghostty/vt/selection.h b/include/ghostty/vt/selection.h index 0270a3038..e0476f7db 100644 --- a/include/ghostty/vt/selection.h +++ b/include/ghostty/vt/selection.h @@ -402,6 +402,9 @@ typedef enum GHOSTTY_ENUM_TYPED { /** Press event for ghostty_selection_gesture_press(). */ GHOSTTY_SELECTION_GESTURE_EVENT_TYPE_PRESS = 0, + /** Release event for ghostty_selection_gesture_release(). */ + GHOSTTY_SELECTION_GESTURE_EVENT_TYPE_RELEASE = 1, + GHOSTTY_SELECTION_GESTURE_EVENT_TYPE_MAX_VALUE = GHOSTTY_ENUM_MAX_VALUE, } GhosttySelectionGestureEventType; @@ -414,7 +417,12 @@ typedef enum GHOSTTY_ENUM_TYPED { * @ingroup selection */ typedef enum GHOSTTY_ENUM_TYPED { - /** Grid reference under the pointer: GhosttyGridRef*. */ + /** + * Grid reference under the pointer: GhosttyGridRef*. + * + * Required for PRESS events. Optional for RELEASE events; when unset or + * cleared, release records that the pointer did not map to a valid cell. + */ GHOSTTY_SELECTION_GESTURE_EVENT_OPT_REF = 0, /** Surface-space pointer position: GhosttySurfacePosition*. */ @@ -508,6 +516,12 @@ GHOSTTY_API GhosttyResult ghostty_selection_gesture_event_set( * GHOSTTY_SELECTION_GESTURE_EVENT_OPT_REF set before calling this function. * All other press options use their initialized defaults when unset or cleared. * + * For GHOSTTY_SELECTION_GESTURE_EVENT_TYPE_RELEASE, only + * GHOSTTY_SELECTION_GESTURE_EVENT_OPT_REF is valid. It is optional; if unset or + * cleared, release records that the pointer did not map to a valid cell. Release + * events update gesture state but do not produce a selection, so this function + * returns GHOSTTY_NO_VALUE after applying them. + * * The returned selection is not installed as the terminal's current selection. * It is a snapshot with the same lifetime rules as GhosttySelection. * diff --git a/src/terminal/c/selection_gesture.zig b/src/terminal/c/selection_gesture.zig index df823e62e..39ad8b489 100644 --- a/src/terminal/c/selection_gesture.zig +++ b/src/terminal/c/selection_gesture.zig @@ -28,6 +28,7 @@ const EventWrapper = struct { alloc: std.mem.Allocator, event: union(EventType) { press: SelectionGesture.Press, + release: SelectionGesture.Release, }, // Press.pin has no safe sentinel value: PageList.Pin contains a non-null @@ -51,6 +52,7 @@ const EventWrapper = struct { fn init(self: *EventWrapper, event_type: EventType) void { self.event = switch (event_type) { .press => .{ .press = self.defaultPress() }, + .release => .{ .release = self.defaultRelease() }, }; } @@ -67,6 +69,11 @@ const EventWrapper = struct { }; } + fn defaultRelease(self: *EventWrapper) SelectionGesture.Release { + _ = self; + return .{ .pin = null }; + } + fn deinit(self: *EventWrapper) void { if (self.word_boundary_codepoints) |cps| { if (cps.len > 0) self.alloc.free(cps); @@ -109,6 +116,7 @@ pub const Data = enum(c_int) { /// C: GhosttySelectionGestureEventType pub const EventType = enum(c_int) { press = 0, + release = 1, }; /// C: GhosttySelectionGestureEventOption @@ -222,6 +230,10 @@ pub fn handle_event( } else if (sel == null) return .no_value; return .success; }, + .release => |release| { + wrapper.gesture.release(t, release); + return .no_value; + }, }; } @@ -324,6 +336,7 @@ fn eventSetTyped( const event = event_ orelse return .invalid_value; return switch (event.event) { .press => |*press| pressSetTyped(event, press, option, value), + .release => |*release| releaseSetTyped(release, option, value), }; } @@ -392,6 +405,32 @@ fn pressSetTyped( 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, + => return .invalid_value, + } + + return .success; +} + fn clearPressCodepoints(event: *EventWrapper, press: *SelectionGesture.Press) void { if (event.word_boundary_codepoints) |cps| { if (cps.len > 0) event.alloc.free(cps); @@ -716,6 +755,104 @@ test "selection gesture event null output still reports no selection" { 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 free null" { free(null, null); } From 90fd1ec2e78d1c0b3f640c9eb1e73a6e7dd7232b Mon Sep 17 00:00:00 2001 From: Mitchell Hashimoto Date: Wed, 27 May 2026 10:51:12 -0700 Subject: [PATCH 5/9] libghostty: selection gesture drag events --- include/ghostty/vt/selection.h | 43 +++- src/terminal/c/selection_gesture.zig | 300 +++++++++++++++++++++++++-- src/terminal/c/types.zig | 1 + 3 files changed, 317 insertions(+), 27 deletions(-) diff --git a/include/ghostty/vt/selection.h b/include/ghostty/vt/selection.h index e0476f7db..1cb774071 100644 --- a/include/ghostty/vt/selection.h +++ b/include/ghostty/vt/selection.h @@ -341,6 +341,25 @@ typedef struct { GhosttySelectionGestureBehavior triple_click; } GhosttySelectionGestureBehaviors; +/** + * Display geometry used to interpret selection gesture drag events. + * + * @ingroup selection + */ +typedef struct { + /** Number of columns in the rendered terminal grid. Must be non-zero. */ + uint32_t columns; + + /** Width of one terminal cell in surface pixels. Must be non-zero. */ + uint32_t cell_width; + + /** Left padding before the terminal grid begins in surface pixels. */ + uint32_t padding_left; + + /** Height of the rendered terminal surface in surface pixels. Must be non-zero. */ + uint32_t screen_height; +} GhosttySelectionGestureGeometry; + /** * Current autoscroll direction for an active selection drag gesture. * @@ -405,6 +424,9 @@ typedef enum GHOSTTY_ENUM_TYPED { /** Release event for ghostty_selection_gesture_release(). */ GHOSTTY_SELECTION_GESTURE_EVENT_TYPE_RELEASE = 1, + /** Drag event for ghostty_selection_gesture_drag(). */ + GHOSTTY_SELECTION_GESTURE_EVENT_TYPE_DRAG = 2, + GHOSTTY_SELECTION_GESTURE_EVENT_TYPE_MAX_VALUE = GHOSTTY_ENUM_MAX_VALUE, } GhosttySelectionGestureEventType; @@ -420,12 +442,12 @@ typedef enum GHOSTTY_ENUM_TYPED { /** * Grid reference under the pointer: GhosttyGridRef*. * - * Required for PRESS events. Optional for RELEASE events; when unset or - * cleared, release records that the pointer did not map to a valid cell. + * Required for PRESS and DRAG events. Optional for RELEASE events; when unset + * or cleared, release records that the pointer did not map to a valid cell. */ GHOSTTY_SELECTION_GESTURE_EVENT_OPT_REF = 0, - /** Surface-space pointer position: GhosttySurfacePosition*. */ + /** Surface-space pointer position: GhosttySurfacePosition*. Valid for PRESS and DRAG. */ GHOSTTY_SELECTION_GESTURE_EVENT_OPT_POSITION = 1, /** Maximum repeat-click distance in pixels: double*. */ @@ -446,7 +468,8 @@ typedef enum GHOSTTY_ENUM_TYPED { * Word-boundary codepoints: GhosttyCodepoints*. * * The codepoints are copied into event-owned storage when set. If unset, - * operations that need word boundaries use Ghostty's defaults. + * operations that need word boundaries use Ghostty's defaults. Valid for + * PRESS and DRAG. */ GHOSTTY_SELECTION_GESTURE_EVENT_OPT_WORD_BOUNDARY_CODEPOINTS = 5, @@ -457,6 +480,12 @@ typedef enum GHOSTTY_ENUM_TYPED { */ GHOSTTY_SELECTION_GESTURE_EVENT_OPT_BEHAVIORS = 6, + /** Whether a drag event should produce a rectangular selection: bool*. */ + GHOSTTY_SELECTION_GESTURE_EVENT_OPT_RECTANGLE = 7, + + /** Drag display geometry: GhosttySelectionGestureGeometry*. Required for DRAG. */ + GHOSTTY_SELECTION_GESTURE_EVENT_OPT_GEOMETRY = 8, + GHOSTTY_SELECTION_GESTURE_EVENT_OPT_MAX_VALUE = GHOSTTY_ENUM_MAX_VALUE, } GhosttySelectionGestureEventOption; @@ -522,6 +551,12 @@ GHOSTTY_API GhosttyResult ghostty_selection_gesture_event_set( * events update gesture state but do not produce a selection, so this function * returns GHOSTTY_NO_VALUE after applying them. * + * For GHOSTTY_SELECTION_GESTURE_EVENT_TYPE_DRAG, + * GHOSTTY_SELECTION_GESTURE_EVENT_OPT_REF and + * GHOSTTY_SELECTION_GESTURE_EVENT_OPT_GEOMETRY are required. Position, + * rectangle, and word-boundary codepoints are optional and use initialized + * defaults when unset or cleared. + * * The returned selection is not installed as the terminal's current selection. * It is a snapshot with the same lifetime rules as GhosttySelection. * diff --git a/src/terminal/c/selection_gesture.zig b/src/terminal/c/selection_gesture.zig index 39ad8b489..580fbe723 100644 --- a/src/terminal/c/selection_gesture.zig +++ b/src/terminal/c/selection_gesture.zig @@ -29,6 +29,7 @@ const EventWrapper = struct { event: union(EventType) { press: SelectionGesture.Press, release: SelectionGesture.Release, + drag: SelectionGesture.Drag, }, // Press.pin has no safe sentinel value: PageList.Pin contains a non-null @@ -37,10 +38,18 @@ const EventWrapper = struct { // ref option was never set, or was later cleared. press_pin_set: bool = false, - // Backing storage for Press.word_boundary_codepoints. The C API receives - // codepoints as borrowed uint32_t values, but SelectionGesture.Press stores - // a []const u21 slice. We copy/convert into event-owned storage so the real - // Press payload can safely point at it until the event is changed or freed. + // Drag.pin and Drag.geometry are required by SelectionGesture.drag but have + // no meaningful zero/sentinel value. Track whether the C caller set them so + // dispatch can reject incomplete drag events instead of using placeholder + // data. + drag_pin_set: bool = false, + drag_geometry_set: bool = false, + + // Backing storage for Press/Drag.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 @@ -53,6 +62,7 @@ const EventWrapper = struct { self.event = switch (event_type) { .press => .{ .press = self.defaultPress() }, .release => .{ .release = self.defaultRelease() }, + .drag => .{ .drag = self.defaultDrag() }, }; } @@ -74,6 +84,18 @@ const EventWrapper = struct { 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 deinit(self: *EventWrapper) void { if (self.word_boundary_codepoints) |cps| { if (cps.len > 0) self.alloc.free(cps); @@ -117,6 +139,7 @@ pub const Data = enum(c_int) { pub const EventType = enum(c_int) { press = 0, release = 1, + drag = 2, }; /// C: GhosttySelectionGestureEventOption @@ -128,6 +151,8 @@ pub const EventOption = enum(c_int) { repeat_interval_ns = 4, word_boundary_codepoints = 5, behaviors = 6, + rectangle = 7, + geometry = 8, pub fn Type(comptime self: EventOption) type { return switch (self) { @@ -138,6 +163,28 @@ pub const EventOption = enum(c_int) { .repeat_interval_ns => u64, .word_boundary_codepoints => types.Codepoints, .behaviors => Behaviors, + .rectangle => bool, + .geometry => Geometry, + }; + } +}; + +/// 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, }; } }; @@ -234,6 +281,15 @@ pub fn handle_event( wrapper.gesture.release(t, release); return .no_value; }, + .drag => |drag| { + if (!event_wrapper.drag_pin_set) return .invalid_value; + if (!event_wrapper.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; + }, }; } @@ -337,6 +393,7 @@ fn eventSetTyped( return switch (event.event) { .press => |*press| pressSetTyped(event, press, option, value), .release => |*release| releaseSetTyped(release, option, value), + .drag => |*drag| dragSetTyped(event, drag, option, value), }; } @@ -356,11 +413,17 @@ fn pressSetTyped( .repeat_distance => press.max_distance = 0, .time_ns => press.time = null, .repeat_interval_ns => press.repeat_interval = 0, - .word_boundary_codepoints => clearPressCodepoints(event, press), + .word_boundary_codepoints => clearWordBoundaryCodepoints( + event, + &press.word_boundary_codepoints, + ), .behaviors => { event.behaviors = SelectionGesture.default_behaviors; press.behaviors = &event.behaviors; }, + .rectangle, + .geometry, + => return .invalid_value, } return .success; }; @@ -377,22 +440,11 @@ fn pressSetTyped( .repeat_distance => press.max_distance = v.*, .time_ns => press.time = instantFromNs(v.*), .repeat_interval_ns => press.repeat_interval = v.*, - .word_boundary_codepoints => { - if (v.len > 0 and v.ptr == null) return .invalid_value; - clearPressCodepoints(event, press); - const ptr = v.ptr orelse { - event.word_boundary_codepoints = &.{}; - press.word_boundary_codepoints = event.word_boundary_codepoints.?; - return .success; - }; - const copy = event.alloc.alloc(u21, v.len) catch return .out_of_memory; - errdefer event.alloc.free(copy); - for (copy, ptr[0..v.len]) |*dst, cp| { - dst.* = std.math.cast(u21, cp) orelse return .invalid_value; - } - event.word_boundary_codepoints = copy; - press.word_boundary_codepoints = copy; - }, + .word_boundary_codepoints => return trySetWordBoundaryCodepoints( + event, + &press.word_boundary_codepoints, + v, + ), .behaviors => { if (!validBehavior(v.single_click) or !validBehavior(v.double_click) or @@ -400,6 +452,9 @@ fn pressSetTyped( event.behaviors = .{ v.single_click, v.double_click, v.triple_click }; press.behaviors = &event.behaviors; }, + .rectangle, + .geometry, + => return .invalid_value, } return .success; @@ -425,18 +480,101 @@ fn releaseSetTyped( .repeat_interval_ns, .word_boundary_codepoints, .behaviors, + .rectangle, + .geometry, => return .invalid_value, } return .success; } -fn clearPressCodepoints(event: *EventWrapper, press: *SelectionGesture.Press) void { +fn dragSetTyped( + event: *EventWrapper, + drag: *SelectionGesture.Drag, + comptime option: EventOption, + value: ?*const option.Type(), +) Result { + const v = value orelse { + switch (option) { + .ref => event.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.drag_geometry_set = false, + + .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.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.drag_geometry_set = true; + }, + + .repeat_distance, + .time_ns, + .repeat_interval_ns, + .behaviors, + => 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; - press.word_boundary_codepoints = &selection_codepoints.default_word_boundaries; + target.* = &selection_codepoints.default_word_boundaries; } fn instantFromNs(ns: u64) std.time.Instant { @@ -853,6 +991,122 @@ test "selection gesture release without ref marks 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 free null" { free(null, null); } diff --git a/src/terminal/c/types.zig b/src/terminal/c/types.zig index 9a4ef3d56..a44dd1ff5 100644 --- a/src/terminal/c/types.zig +++ b/src/terminal/c/types.zig @@ -56,6 +56,7 @@ pub const structs: std.StaticStringMap(StructInfo) = structs: { .{ "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) }, From 603684ba11092b9430c336b3378ba22ef9615cc0 Mon Sep 17 00:00:00 2001 From: Mitchell Hashimoto Date: Wed, 27 May 2026 10:54:54 -0700 Subject: [PATCH 6/9] libghostty: selection gesture autotick --- include/ghostty/vt/selection.h | 27 +++- src/terminal/c/selection_gesture.zig | 233 ++++++++++++++++++++++++++- 2 files changed, 250 insertions(+), 10 deletions(-) diff --git a/include/ghostty/vt/selection.h b/include/ghostty/vt/selection.h index 1cb774071..bcba934b2 100644 --- a/include/ghostty/vt/selection.h +++ b/include/ghostty/vt/selection.h @@ -427,6 +427,9 @@ typedef enum GHOSTTY_ENUM_TYPED { /** Drag event for ghostty_selection_gesture_drag(). */ GHOSTTY_SELECTION_GESTURE_EVENT_TYPE_DRAG = 2, + /** Autoscroll tick event for ghostty_selection_gesture_autoscroll_tick(). */ + GHOSTTY_SELECTION_GESTURE_EVENT_TYPE_AUTOSCROLL_TICK = 3, + GHOSTTY_SELECTION_GESTURE_EVENT_TYPE_MAX_VALUE = GHOSTTY_ENUM_MAX_VALUE, } GhosttySelectionGestureEventType; @@ -447,7 +450,11 @@ typedef enum GHOSTTY_ENUM_TYPED { */ GHOSTTY_SELECTION_GESTURE_EVENT_OPT_REF = 0, - /** Surface-space pointer position: GhosttySurfacePosition*. Valid for PRESS and DRAG. */ + /** + * Surface-space pointer position: GhosttySurfacePosition*. + * + * Valid for PRESS, DRAG, and AUTOSCROLL_TICK. + */ GHOSTTY_SELECTION_GESTURE_EVENT_OPT_POSITION = 1, /** Maximum repeat-click distance in pixels: double*. */ @@ -468,8 +475,9 @@ typedef enum GHOSTTY_ENUM_TYPED { * Word-boundary codepoints: GhosttyCodepoints*. * * The codepoints are copied into event-owned storage when set. If unset, - * operations that need word boundaries use Ghostty's defaults. Valid for - * PRESS and DRAG. + * operations that need word boundaries use Ghostty's defaults. + * + * Valid for PRESS, DRAG, and AUTOSCROLL_TICK. */ GHOSTTY_SELECTION_GESTURE_EVENT_OPT_WORD_BOUNDARY_CODEPOINTS = 5, @@ -480,12 +488,15 @@ typedef enum GHOSTTY_ENUM_TYPED { */ GHOSTTY_SELECTION_GESTURE_EVENT_OPT_BEHAVIORS = 6, - /** Whether a drag event should produce a rectangular selection: bool*. */ + /** Whether a drag or autoscroll tick should produce a rectangular selection: bool*. */ GHOSTTY_SELECTION_GESTURE_EVENT_OPT_RECTANGLE = 7, - /** Drag display geometry: GhosttySelectionGestureGeometry*. Required for DRAG. */ + /** Drag display geometry: GhosttySelectionGestureGeometry*. Required for DRAG and AUTOSCROLL_TICK. */ GHOSTTY_SELECTION_GESTURE_EVENT_OPT_GEOMETRY = 8, + /** Viewport coordinate for an autoscroll tick: GhosttyPointCoordinate*. Required for AUTOSCROLL_TICK. */ + GHOSTTY_SELECTION_GESTURE_EVENT_OPT_VIEWPORT = 9, + GHOSTTY_SELECTION_GESTURE_EVENT_OPT_MAX_VALUE = GHOSTTY_ENUM_MAX_VALUE, } GhosttySelectionGestureEventOption; @@ -557,6 +568,12 @@ GHOSTTY_API GhosttyResult ghostty_selection_gesture_event_set( * rectangle, and word-boundary codepoints are optional and use initialized * defaults when unset or cleared. * + * For GHOSTTY_SELECTION_GESTURE_EVENT_TYPE_AUTOSCROLL_TICK, + * GHOSTTY_SELECTION_GESTURE_EVENT_OPT_VIEWPORT and + * GHOSTTY_SELECTION_GESTURE_EVENT_OPT_GEOMETRY are required. Position, + * rectangle, and word-boundary codepoints are optional and use initialized + * defaults when unset or cleared. + * * The returned selection is not installed as the terminal's current selection. * It is a snapshot with the same lifetime rules as GhosttySelection. * diff --git a/src/terminal/c/selection_gesture.zig b/src/terminal/c/selection_gesture.zig index 580fbe723..5fd16f33f 100644 --- a/src/terminal/c/selection_gesture.zig +++ b/src/terminal/c/selection_gesture.zig @@ -6,6 +6,7 @@ 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"); @@ -30,6 +31,7 @@ const EventWrapper = struct { press: SelectionGesture.Press, release: SelectionGesture.Release, drag: SelectionGesture.Drag, + autoscroll_tick: SelectionGesture.AutoscrollTick, }, // Press.pin has no safe sentinel value: PageList.Pin contains a non-null @@ -45,11 +47,18 @@ const EventWrapper = struct { drag_pin_set: bool = false, drag_geometry_set: bool = false, - // Backing storage for Press/Drag.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. + // AutoscrollTick.viewport and AutoscrollTick.geometry are required by + // SelectionGesture.autoscrollTick but have no meaningful zero/sentinel + // value. Track whether the C caller set them so dispatch can reject + // incomplete tick events instead of using placeholder data. + 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 @@ -63,6 +72,7 @@ const EventWrapper = struct { .press => .{ .press = self.defaultPress() }, .release => .{ .release = self.defaultRelease() }, .drag => .{ .drag = self.defaultDrag() }, + .autoscroll_tick => .{ .autoscroll_tick = self.defaultAutoscrollTick() }, }; } @@ -96,6 +106,18 @@ const EventWrapper = struct { }; } + 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 deinit(self: *EventWrapper) void { if (self.word_boundary_codepoints) |cps| { if (cps.len > 0) self.alloc.free(cps); @@ -140,6 +162,7 @@ pub const EventType = enum(c_int) { press = 0, release = 1, drag = 2, + autoscroll_tick = 3, }; /// C: GhosttySelectionGestureEventOption @@ -153,6 +176,7 @@ pub const EventOption = enum(c_int) { behaviors = 6, rectangle = 7, geometry = 8, + viewport = 9, pub fn Type(comptime self: EventOption) type { return switch (self) { @@ -165,6 +189,7 @@ pub const EventOption = enum(c_int) { .behaviors => Behaviors, .rectangle => bool, .geometry => Geometry, + .viewport => point.Coordinate, }; } }; @@ -290,6 +315,15 @@ pub fn handle_event( } else if (sel == null) return .no_value; return .success; }, + .autoscroll_tick => |tick| { + if (!event_wrapper.autoscroll_tick_viewport_set) return .invalid_value; + if (!event_wrapper.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; + }, }; } @@ -394,6 +428,7 @@ fn eventSetTyped( .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), }; } @@ -423,6 +458,7 @@ fn pressSetTyped( }, .rectangle, .geometry, + .viewport, => return .invalid_value, } return .success; @@ -454,6 +490,7 @@ fn pressSetTyped( }, .rectangle, .geometry, + .viewport, => return .invalid_value, } @@ -482,6 +519,7 @@ fn releaseSetTyped( .behaviors, .rectangle, .geometry, + .viewport, => return .invalid_value, } @@ -507,6 +545,7 @@ fn dragSetTyped( ), .rectangle => drag.rectangle = false, .geometry => event.drag_geometry_set = false, + .viewport => return .invalid_value, .repeat_distance, .time_ns, @@ -536,6 +575,7 @@ fn dragSetTyped( drag.geometry = v.toZig() orelse return .invalid_value; event.drag_geometry_set = true; }, + .viewport => return .invalid_value, .repeat_distance, .time_ns, @@ -547,6 +587,67 @@ fn dragSetTyped( 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.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.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.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.autoscroll_tick_geometry_set = true; + }, + + .ref, + .repeat_distance, + .time_ns, + .repeat_interval_ns, + .behaviors, + => return .invalid_value, + } + + return .success; +} + fn trySetWordBoundaryCodepoints( event: *EventWrapper, target: *[]const u21, @@ -1107,6 +1208,128 @@ test "selection gesture drag requires ref and geometry" { 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 free null" { free(null, null); } From f0fcb104069647051b2e612c23c90e2414a0db58 Mon Sep 17 00:00:00 2001 From: Mitchell Hashimoto Date: Wed, 27 May 2026 10:56:55 -0700 Subject: [PATCH 7/9] libghostty: selection gesture deep press --- include/ghostty/vt/selection.h | 17 ++- src/terminal/c/selection_gesture.zig | 150 +++++++++++++++++++++++++++ 2 files changed, 162 insertions(+), 5 deletions(-) diff --git a/include/ghostty/vt/selection.h b/include/ghostty/vt/selection.h index bcba934b2..d42fa3c0e 100644 --- a/include/ghostty/vt/selection.h +++ b/include/ghostty/vt/selection.h @@ -418,18 +418,21 @@ typedef enum GHOSTTY_ENUM_TYPED { * @ingroup selection */ typedef enum GHOSTTY_ENUM_TYPED { - /** Press event for ghostty_selection_gesture_press(). */ + /** Press event for ghostty_selection_gesture_event(). */ GHOSTTY_SELECTION_GESTURE_EVENT_TYPE_PRESS = 0, - /** Release event for ghostty_selection_gesture_release(). */ + /** Release event for ghostty_selection_gesture_event(). */ GHOSTTY_SELECTION_GESTURE_EVENT_TYPE_RELEASE = 1, - /** Drag event for ghostty_selection_gesture_drag(). */ + /** Drag event for ghostty_selection_gesture_event(). */ GHOSTTY_SELECTION_GESTURE_EVENT_TYPE_DRAG = 2, - /** Autoscroll tick event for ghostty_selection_gesture_autoscroll_tick(). */ + /** Autoscroll tick event for ghostty_selection_gesture_event(). */ GHOSTTY_SELECTION_GESTURE_EVENT_TYPE_AUTOSCROLL_TICK = 3, + /** Deep press event for ghostty_selection_gesture_event(). */ + GHOSTTY_SELECTION_GESTURE_EVENT_TYPE_DEEP_PRESS = 4, + GHOSTTY_SELECTION_GESTURE_EVENT_TYPE_MAX_VALUE = GHOSTTY_ENUM_MAX_VALUE, } GhosttySelectionGestureEventType; @@ -477,7 +480,7 @@ typedef enum GHOSTTY_ENUM_TYPED { * The codepoints are copied into event-owned storage when set. If unset, * operations that need word boundaries use Ghostty's defaults. * - * Valid for PRESS, DRAG, and AUTOSCROLL_TICK. + * Valid for PRESS, DRAG, AUTOSCROLL_TICK, and DEEP_PRESS. */ GHOSTTY_SELECTION_GESTURE_EVENT_OPT_WORD_BOUNDARY_CODEPOINTS = 5, @@ -574,6 +577,10 @@ GHOSTTY_API GhosttyResult ghostty_selection_gesture_event_set( * rectangle, and word-boundary codepoints are optional and use initialized * defaults when unset or cleared. * + * For GHOSTTY_SELECTION_GESTURE_EVENT_TYPE_DEEP_PRESS, only + * GHOSTTY_SELECTION_GESTURE_EVENT_OPT_WORD_BOUNDARY_CODEPOINTS is valid. It is + * optional and uses initialized defaults when unset or cleared. + * * The returned selection is not installed as the terminal's current selection. * It is a snapshot with the same lifetime rules as GhosttySelection. * diff --git a/src/terminal/c/selection_gesture.zig b/src/terminal/c/selection_gesture.zig index 5fd16f33f..981bb0022 100644 --- a/src/terminal/c/selection_gesture.zig +++ b/src/terminal/c/selection_gesture.zig @@ -32,6 +32,7 @@ const EventWrapper = struct { release: SelectionGesture.Release, drag: SelectionGesture.Drag, autoscroll_tick: SelectionGesture.AutoscrollTick, + deep_press: SelectionGesture.DeepPress, }, // Press.pin has no safe sentinel value: PageList.Pin contains a non-null @@ -73,6 +74,7 @@ const EventWrapper = struct { .release => .{ .release = self.defaultRelease() }, .drag => .{ .drag = self.defaultDrag() }, .autoscroll_tick => .{ .autoscroll_tick = self.defaultAutoscrollTick() }, + .deep_press => .{ .deep_press = self.defaultDeepPress() }, }; } @@ -118,6 +120,13 @@ const EventWrapper = struct { }; } + 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); @@ -163,6 +172,7 @@ pub const EventType = enum(c_int) { release = 1, drag = 2, autoscroll_tick = 3, + deep_press = 4, }; /// C: GhosttySelectionGestureEventOption @@ -324,6 +334,13 @@ pub fn handle_event( } 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; + }, }; } @@ -429,6 +446,7 @@ fn eventSetTyped( .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), }; } @@ -648,6 +666,55 @@ fn autoscrollTickSetTyped( 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, @@ -1330,6 +1397,89 @@ test "selection gesture autoscroll tick requires viewport and geometry" { 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); } From 3e0477a14a1a6a0a8f4a5256b95528aa8145351a Mon Sep 17 00:00:00 2001 From: Mitchell Hashimoto Date: Wed, 27 May 2026 11:00:51 -0700 Subject: [PATCH 8/9] example/c-vt-selection-gesture --- example/c-vt-selection-gesture/README.md | 18 +++ example/c-vt-selection-gesture/build.zig | 42 +++++ example/c-vt-selection-gesture/build.zig.zon | 24 +++ example/c-vt-selection-gesture/src/main.c | 162 +++++++++++++++++++ include/ghostty/vt.h | 5 + include/ghostty/vt/selection.h | 10 ++ 6 files changed, 261 insertions(+) create mode 100644 example/c-vt-selection-gesture/README.md create mode 100644 example/c-vt-selection-gesture/build.zig create mode 100644 example/c-vt-selection-gesture/build.zig.zon create mode 100644 example/c-vt-selection-gesture/src/main.c diff --git a/example/c-vt-selection-gesture/README.md b/example/c-vt-selection-gesture/README.md new file mode 100644 index 000000000..a64df0e53 --- /dev/null +++ b/example/c-vt-selection-gesture/README.md @@ -0,0 +1,18 @@ +# Example: `ghostty-vt` Selection Gestures + +This contains a simple example of how to use the `ghostty-vt` selection +gesture API from C. It creates synthetic press, drag, release, and deep-press +events and formats the resulting selection snapshots. + +This uses a `build.zig` and `Zig` to build the C program so that we +can reuse a lot of our build logic and depend directly on our source +tree, but Ghostty emits a standard C library that can be used with any +C tooling. + +## Usage + +Run the program: + +```shell-session +zig build run +``` diff --git a/example/c-vt-selection-gesture/build.zig b/example/c-vt-selection-gesture/build.zig new file mode 100644 index 000000000..05f8d1bbc --- /dev/null +++ b/example/c-vt-selection-gesture/build.zig @@ -0,0 +1,42 @@ +const std = @import("std"); + +pub fn build(b: *std.Build) void { + const target = b.standardTargetOptions(.{}); + const optimize = b.standardOptimizeOption(.{}); + + const run_step = b.step("run", "Run the app"); + + const exe_mod = b.createModule(.{ + .target = target, + .optimize = optimize, + }); + exe_mod.addCSourceFiles(.{ + .root = b.path("src"), + .files = &.{"main.c"}, + }); + + // You'll want to use a lazy dependency here so that ghostty is only + // downloaded if you actually need it. + if (b.lazyDependency("ghostty", .{ + // Setting simd to false will force a pure static build that + // doesn't even require libc, but it has a significant performance + // penalty. If your embedding app requires libc anyway, you should + // always keep simd enabled. + // .simd = false, + })) |dep| { + exe_mod.linkLibrary(dep.artifact("ghostty-vt")); + } + + // Exe + const exe = b.addExecutable(.{ + .name = "c_vt_selection_gesture", + .root_module = exe_mod, + }); + b.installArtifact(exe); + + // Run + const run_cmd = b.addRunArtifact(exe); + run_cmd.step.dependOn(b.getInstallStep()); + if (b.args) |args| run_cmd.addArgs(args); + run_step.dependOn(&run_cmd.step); +} diff --git a/example/c-vt-selection-gesture/build.zig.zon b/example/c-vt-selection-gesture/build.zig.zon new file mode 100644 index 000000000..08db85223 --- /dev/null +++ b/example/c-vt-selection-gesture/build.zig.zon @@ -0,0 +1,24 @@ +.{ + .name = .c_vt_selection_gesture, + .version = "0.0.0", + .fingerprint = 0x5a4e72d27b582404, + .minimum_zig_version = "0.15.1", + .dependencies = .{ + // Ghostty dependency. In reality, you'd probably use a URL-based + // dependency like the one showed (and commented out) below this one. + // We use a path dependency here for simplicity and to ensure our + // examples always test against the source they're bundled with. + .ghostty = .{ .path = "../../" }, + + // Example of what a URL-based dependency looks like: + // .ghostty = .{ + // .url = "https://github.com/ghostty-org/ghostty/archive/COMMIT.tar.gz", + // .hash = "N-V-__8AAMVLTABmYkLqhZPLXnMl-KyN38R8UVYqGrxqO36s", + // }, + }, + .paths = .{ + "build.zig", + "build.zig.zon", + "src", + }, +} diff --git a/example/c-vt-selection-gesture/src/main.c b/example/c-vt-selection-gesture/src/main.c new file mode 100644 index 000000000..050e9a3b1 --- /dev/null +++ b/example/c-vt-selection-gesture/src/main.c @@ -0,0 +1,162 @@ +#include +#include +#include +#include +#include + +//! [selection-gesture-main] +static void vt_write(GhosttyTerminal terminal, const char *s) { + ghostty_terminal_vt_write(terminal, (const uint8_t *)s, strlen(s)); +} + +static GhosttyGridRef ref_at(GhosttyTerminal terminal, uint16_t x, uint16_t y) { + GhosttyGridRef ref = GHOSTTY_INIT_SIZED(GhosttyGridRef); + GhosttyPoint point = { + .tag = GHOSTTY_POINT_TAG_ACTIVE, + .value = { .coordinate = { .x = x, .y = y } }, + }; + + GhosttyResult result = ghostty_terminal_grid_ref(terminal, point, &ref); + assert(result == GHOSTTY_SUCCESS); + return ref; +} + +static void print_selection( + GhosttyTerminal terminal, + const char *label, + const GhosttySelection *selection) { + GhosttyTerminalSelectionFormatOptions opts = + GHOSTTY_INIT_SIZED(GhosttyTerminalSelectionFormatOptions); + opts.emit = GHOSTTY_FORMATTER_FORMAT_PLAIN; + opts.trim = true; + opts.selection = selection; + + uint8_t *buf = NULL; + size_t len = 0; + GhosttyResult result = ghostty_terminal_selection_format_alloc( + terminal, NULL, opts, &buf, &len); + assert(result == GHOSTTY_SUCCESS); + + printf("%s: ", label); + fwrite(buf, 1, len, stdout); + printf("\n"); + + ghostty_free(NULL, buf, len); +} + +static GhosttySelectionGestureEvent new_event( + GhosttySelectionGestureEventType type) { + GhosttySelectionGestureEvent event = NULL; + GhosttyResult result = ghostty_selection_gesture_event_new(NULL, &event, type); + assert(result == GHOSTTY_SUCCESS); + return event; +} + +int main() { + GhosttyTerminal terminal; + GhosttyTerminalOptions opts = { + .cols = 20, + .rows = 4, + .max_scrollback = 100, + }; + GhosttyResult result = ghostty_terminal_new(NULL, &terminal, opts); + assert(result == GHOSTTY_SUCCESS); + + vt_write(terminal, "hello world\r\nsecond line"); + + GhosttySelectionGesture gesture = NULL; + result = ghostty_selection_gesture_new(NULL, &gesture); + assert(result == GHOSTTY_SUCCESS); + + GhosttySelectionGestureEvent press = + new_event(GHOSTTY_SELECTION_GESTURE_EVENT_TYPE_PRESS); + GhosttySelectionGestureEvent drag = + new_event(GHOSTTY_SELECTION_GESTURE_EVENT_TYPE_DRAG); + GhosttySelectionGestureEvent release = + new_event(GHOSTTY_SELECTION_GESTURE_EVENT_TYPE_RELEASE); + GhosttySelectionGestureEvent deep_press = + new_event(GHOSTTY_SELECTION_GESTURE_EVENT_TYPE_DEEP_PRESS); + + GhosttySelectionGestureGeometry geometry = { + .columns = 20, + .cell_width = 10, + .padding_left = 0, + .screen_height = 40, + }; + + // Press in the first cell. A normal single press records the click anchor but + // doesn't produce a selection yet, so we discard the optional output. + GhosttyGridRef press_ref = ref_at(terminal, 0, 0); + result = ghostty_selection_gesture_event_set( + press, GHOSTTY_SELECTION_GESTURE_EVENT_OPT_REF, &press_ref); + assert(result == GHOSTTY_SUCCESS); + + GhosttySurfacePosition press_pos = { .x = 2, .y = 8 }; + result = ghostty_selection_gesture_event_set( + press, GHOSTTY_SELECTION_GESTURE_EVENT_OPT_POSITION, &press_pos); + assert(result == GHOSTTY_SUCCESS); + + result = ghostty_selection_gesture_event( + gesture, terminal, press, NULL); + assert(result == GHOSTTY_NO_VALUE); + + // Drag across "hello". The drag event returns a selection snapshot that the + // embedder can apply to its UI, copy, or format immediately. + GhosttyGridRef drag_ref = ref_at(terminal, 4, 0); + result = ghostty_selection_gesture_event_set( + drag, GHOSTTY_SELECTION_GESTURE_EVENT_OPT_REF, &drag_ref); + assert(result == GHOSTTY_SUCCESS); + + GhosttySurfacePosition drag_pos = { .x = 46, .y = 8 }; + result = ghostty_selection_gesture_event_set( + drag, GHOSTTY_SELECTION_GESTURE_EVENT_OPT_POSITION, &drag_pos); + assert(result == GHOSTTY_SUCCESS); + + result = ghostty_selection_gesture_event_set( + drag, GHOSTTY_SELECTION_GESTURE_EVENT_OPT_GEOMETRY, &geometry); + assert(result == GHOSTTY_SUCCESS); + + GhosttySelection selection = GHOSTTY_INIT_SIZED(GhosttySelection); + result = ghostty_selection_gesture_event( + gesture, terminal, drag, &selection); + assert(result == GHOSTTY_SUCCESS); + print_selection(terminal, "drag", &selection); + + // Release updates gesture state but never produces a selection. + result = ghostty_selection_gesture_event_set( + release, GHOSTTY_SELECTION_GESTURE_EVENT_OPT_REF, &drag_ref); + assert(result == GHOSTTY_SUCCESS); + result = ghostty_selection_gesture_event( + gesture, terminal, release, NULL); + assert(result == GHOSTTY_NO_VALUE); + + bool dragged = false; + result = ghostty_selection_gesture_get( + gesture, terminal, GHOSTTY_SELECTION_GESTURE_DATA_DRAGGED, &dragged); + assert(result == GHOSTTY_SUCCESS); + printf("dragged: %s\n", dragged ? "true" : "false"); + + // Deep press uses the active click anchor to select the surrounding word. + ghostty_selection_gesture_reset(gesture, terminal); + GhosttyGridRef world_ref = ref_at(terminal, 6, 0); + result = ghostty_selection_gesture_event_set( + press, GHOSTTY_SELECTION_GESTURE_EVENT_OPT_REF, &world_ref); + assert(result == GHOSTTY_SUCCESS); + result = ghostty_selection_gesture_event( + gesture, terminal, press, NULL); + assert(result == GHOSTTY_NO_VALUE); + + result = ghostty_selection_gesture_event( + gesture, terminal, deep_press, &selection); + assert(result == GHOSTTY_SUCCESS); + print_selection(terminal, "deep press", &selection); + + ghostty_selection_gesture_event_free(deep_press); + ghostty_selection_gesture_event_free(release); + ghostty_selection_gesture_event_free(drag); + ghostty_selection_gesture_event_free(press); + ghostty_selection_gesture_free(gesture, terminal); + ghostty_terminal_free(terminal); + return 0; +} +//! [selection-gesture-main] diff --git a/include/ghostty/vt.h b/include/ghostty/vt.h index 7a6a9758a..94a850334 100644 --- a/include/ghostty/vt.h +++ b/include/ghostty/vt.h @@ -104,6 +104,11 @@ * detect when it loses its value, and move it to a new point. */ +/** @example c-vt-selection-gesture/src/main.c + * This example demonstrates how to use synthetic selection gesture events to + * derive drag and deep-press selection snapshots. + */ + /** @example c-vt-kitty-graphics/src/main.c * This example demonstrates how to use the system interface to install a * PNG decoder callback and send a Kitty Graphics Protocol image. diff --git a/include/ghostty/vt/selection.h b/include/ghostty/vt/selection.h index d42fa3c0e..3b926aab6 100644 --- a/include/ghostty/vt/selection.h +++ b/include/ghostty/vt/selection.h @@ -32,9 +32,19 @@ extern "C" { * for the endpoints and reconstruct a GhosttySelection from fresh snapshots * when needed. * + * Selection gestures provide a reusable state machine for turning UI pointer + * interactions into selection snapshots. A caller creates one + * GhosttySelectionGesture per active gesture stream, reuses typed + * GhosttySelectionGestureEvent objects for synthetic press, drag, release, + * autoscroll tick, and deep-press events, and applies each event with + * ghostty_selection_gesture_event(). The returned GhosttySelection is a + * snapshot; the embedder decides whether to render it, format/copy it, or + * install it as the terminal's active selection. + * * ## Examples * * @snippet c-vt-selection/src/main.c selection-main + * @snippet c-vt-selection-gesture/src/main.c selection-gesture-main * * @{ */ From 4e2d7c314b2e3645924c50eba309a431d28c4bb2 Mon Sep 17 00:00:00 2001 From: Mitchell Hashimoto Date: Wed, 27 May 2026 11:05:20 -0700 Subject: [PATCH 9/9] libghostty: optimize bits for selection gesture validation fields --- src/terminal/c/selection_gesture.zig | 61 ++++++++++++---------------- 1 file changed, 27 insertions(+), 34 deletions(-) diff --git a/src/terminal/c/selection_gesture.zig b/src/terminal/c/selection_gesture.zig index 981bb0022..3562447d9 100644 --- a/src/terminal/c/selection_gesture.zig +++ b/src/terminal/c/selection_gesture.zig @@ -35,25 +35,18 @@ const EventWrapper = struct { deep_press: SelectionGesture.DeepPress, }, - // Press.pin has no safe sentinel value: PageList.Pin contains a non-null - // node pointer and is undefined until the C caller provides a GhosttyGridRef. - // Track that separately so event execution can reject a press whose required - // ref option was never set, or was later cleared. - press_pin_set: bool = false, - - // Drag.pin and Drag.geometry are required by SelectionGesture.drag but have - // no meaningful zero/sentinel value. Track whether the C caller set them so - // dispatch can reject incomplete drag events instead of using placeholder - // data. - drag_pin_set: bool = false, - drag_geometry_set: bool = false, - - // AutoscrollTick.viewport and AutoscrollTick.geometry are required by - // SelectionGesture.autoscrollTick but have no meaningful zero/sentinel - // value. Track whether the C caller set them so dispatch can reject - // incomplete tick events instead of using placeholder data. - autoscroll_tick_viewport_set: bool = false, - autoscroll_tick_geometry_set: bool = false, + // 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 @@ -305,7 +298,7 @@ pub fn handle_event( return switch (event_wrapper.event) { .press => |press| { - if (!event_wrapper.press_pin_set) return .invalid_value; + 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); @@ -317,8 +310,8 @@ pub fn handle_event( return .no_value; }, .drag => |drag| { - if (!event_wrapper.drag_pin_set) return .invalid_value; - if (!event_wrapper.drag_geometry_set) return .invalid_value; + 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); @@ -326,8 +319,8 @@ pub fn handle_event( return .success; }, .autoscroll_tick => |tick| { - if (!event_wrapper.autoscroll_tick_viewport_set) return .invalid_value; - if (!event_wrapper.autoscroll_tick_geometry_set) return .invalid_value; + 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); @@ -458,7 +451,7 @@ fn pressSetTyped( ) Result { const v = value orelse { switch (option) { - .ref => event.press_pin_set = false, + .ref => event.event_validation.press_pin_set = false, .position => { press.xpos = 0; press.ypos = 0; @@ -485,7 +478,7 @@ fn pressSetTyped( switch (option) { .ref => { press.pin = v.toPin() orelse return .invalid_value; - event.press_pin_set = true; + event.event_validation.press_pin_set = true; }, .position => { press.xpos = v.x; @@ -552,7 +545,7 @@ fn dragSetTyped( ) Result { const v = value orelse { switch (option) { - .ref => event.drag_pin_set = false, + .ref => event.event_validation.drag_pin_set = false, .position => { drag.xpos = 0; drag.ypos = 0; @@ -562,7 +555,7 @@ fn dragSetTyped( &drag.word_boundary_codepoints, ), .rectangle => drag.rectangle = false, - .geometry => event.drag_geometry_set = false, + .geometry => event.event_validation.drag_geometry_set = false, .viewport => return .invalid_value, .repeat_distance, @@ -577,7 +570,7 @@ fn dragSetTyped( switch (option) { .ref => { drag.pin = v.toPin() orelse return .invalid_value; - event.drag_pin_set = true; + event.event_validation.drag_pin_set = true; }, .position => { drag.xpos = v.x; @@ -591,7 +584,7 @@ fn dragSetTyped( .rectangle => drag.rectangle = v.*, .geometry => { drag.geometry = v.toZig() orelse return .invalid_value; - event.drag_geometry_set = true; + event.event_validation.drag_geometry_set = true; }, .viewport => return .invalid_value, @@ -613,7 +606,7 @@ fn autoscrollTickSetTyped( ) Result { const v = value orelse { switch (option) { - .viewport => event.autoscroll_tick_viewport_set = false, + .viewport => event.event_validation.autoscroll_tick_viewport_set = false, .position => { tick.xpos = 0; tick.ypos = 0; @@ -623,7 +616,7 @@ fn autoscrollTickSetTyped( &tick.word_boundary_codepoints, ), .rectangle => tick.rectangle = false, - .geometry => event.autoscroll_tick_geometry_set = false, + .geometry => event.event_validation.autoscroll_tick_geometry_set = false, .ref, .repeat_distance, @@ -638,7 +631,7 @@ fn autoscrollTickSetTyped( switch (option) { .viewport => { tick.viewport = v.*; - event.autoscroll_tick_viewport_set = true; + event.event_validation.autoscroll_tick_viewport_set = true; }, .position => { tick.xpos = v.x; @@ -652,7 +645,7 @@ fn autoscrollTickSetTyped( .rectangle => tick.rectangle = v.*, .geometry => { tick.geometry = v.toZig() orelse return .invalid_value; - event.autoscroll_tick_geometry_set = true; + event.event_validation.autoscroll_tick_geometry_set = true; }, .ref,