libghostty: GhosttySelectionGestureEvent

This commit is contained in:
Mitchell Hashimoto
2026-05-27 09:10:30 -07:00
parent 2f61ba036e
commit bbfa984aec
6 changed files with 507 additions and 0 deletions

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -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",