libghostty: selection gesture deep press

This commit is contained in:
Mitchell Hashimoto
2026-05-27 10:56:55 -07:00
parent 603684ba11
commit f0fcb10406
2 changed files with 162 additions and 5 deletions

View File

@@ -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.
*

View File

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