libghostty: starting the SelectionGesture API, just init/get

This commit is contained in:
Mitchell Hashimoto
2026-05-27 08:00:37 -07:00
parent 3103ae8838
commit 2f61ba036e
5 changed files with 494 additions and 11 deletions

View File

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

View File

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

View File

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

View File

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

View File

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