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); +}