From 603684ba11092b9430c336b3378ba22ef9615cc0 Mon Sep 17 00:00:00 2001 From: Mitchell Hashimoto Date: Wed, 27 May 2026 10:54:54 -0700 Subject: [PATCH] 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); }