From 90fd1ec2e78d1c0b3f640c9eb1e73a6e7dd7232b Mon Sep 17 00:00:00 2001 From: Mitchell Hashimoto Date: Wed, 27 May 2026 10:51:12 -0700 Subject: [PATCH] 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) },