libghostty: selection word/line/output/all helpers

This commit is contained in:
Mitchell Hashimoto
2026-05-24 12:53:41 -07:00
parent 847b8afc87
commit cc48312c08
9 changed files with 405 additions and 60 deletions

View File

@@ -49,6 +49,7 @@ const string = @import("string.zig");
const terminal = struct {
const CursorStyle = @import("../terminal/cursor.zig").Style;
const color = @import("../terminal/color.zig");
const selection_codepoints = @import("../terminal/selection_codepoints.zig");
const style = @import("../terminal/style.zig");
const x11_color = @import("../terminal/x11_color.zig");
};
@@ -6149,32 +6150,8 @@ pub const RepeatableString = struct {
pub const SelectionWordChars = struct {
const Self = @This();
/// Default boundary characters: ` \t'"│`|:;,()[]{}<>$`
const default_codepoints = [_]u21{
0, // null
' ', // space
'\t', // tab
'\'', // single quote
'"', // double quote
'│', // U+2502 box drawing
'`', // backtick
'|', // pipe
':', // colon
';', // semicolon
',', // comma
'(', // left paren
')', // right paren
'[', // left bracket
']', // right bracket
'{', // left brace
'}', // right brace
'<', // less than
'>', // greater than
'$', // dollar
};
/// The parsed codepoints. Always includes null (U+0000) at index 0.
codepoints: []const u21 = &default_codepoints,
codepoints: []const u21 = &terminal.selection_codepoints.default_word_boundaries,
pub fn parseCLI(self: *Self, alloc: Allocator, input: ?[]const u8) !void {
const value = input orelse return error.ValueRequired;

View File

@@ -239,6 +239,10 @@ comptime {
@export(&c.terminal_mode_set, .{ .name = "ghostty_terminal_mode_set" });
@export(&c.terminal_get, .{ .name = "ghostty_terminal_get" });
@export(&c.terminal_get_multi, .{ .name = "ghostty_terminal_get_multi" });
@export(&c.terminal_select_word, .{ .name = "ghostty_terminal_select_word" });
@export(&c.terminal_select_line, .{ .name = "ghostty_terminal_select_line" });
@export(&c.terminal_select_all, .{ .name = "ghostty_terminal_select_all" });
@export(&c.terminal_select_output, .{ .name = "ghostty_terminal_select_output" });
@export(&c.terminal_selection_adjust, .{ .name = "ghostty_terminal_selection_adjust" });
@export(&c.terminal_selection_order, .{ .name = "ghostty_terminal_selection_order" });
@export(&c.terminal_selection_ordered, .{ .name = "ghostty_terminal_selection_ordered" });

View File

@@ -13,6 +13,7 @@ const tripwire = @import("../tripwire.zig");
const unicode = @import("../unicode/main.zig");
const Selection = @import("Selection.zig");
const PageList = @import("PageList.zig");
const selection_codepoints = @import("selection_codepoints.zig");
const StringMap = @import("StringMap.zig");
const ScreenFormatter = @import("formatter.zig").ScreenFormatter;
const osc = @import("osc.zig");
@@ -2516,7 +2517,7 @@ pub const SelectLine = struct {
/// These are the codepoints to consider whitespace to trim
/// from the ends of the selection.
whitespace: ?[]const u21 = &.{ 0, ' ', '\t' },
whitespace: ?[]const u21 = &selection_codepoints.default_line_whitespace,
/// If true, line selection will consider semantic prompt
/// state changing a boundary. State changing is ANY state
@@ -2652,10 +2653,10 @@ pub fn selectLine(self: *const Screen, opts: SelectLine) ?Selection {
if (!cell.hasText()) continue;
// Non-empty means we found it.
const this_whitespace = std.mem.indexOfAny(
const this_whitespace = std.mem.indexOfScalar(
u21,
whitespace,
&[_]u21{cell.content.codepoint},
cell.content.codepoint,
) != null;
if (this_whitespace) continue;
@@ -2674,10 +2675,10 @@ pub fn selectLine(self: *const Screen, opts: SelectLine) ?Selection {
if (!cell.hasText()) continue;
// Non-empty means we found it.
const this_whitespace = std.mem.indexOfAny(
const this_whitespace = std.mem.indexOfScalar(
u21,
whitespace,
&[_]u21{cell.content.codepoint},
cell.content.codepoint,
) != null;
if (this_whitespace) continue;
@@ -2798,10 +2799,10 @@ pub fn selectWord(
if (!start_cell.hasText()) return null;
// Determine if we are a boundary or not to determine what our boundary is.
const expect_boundary = std.mem.indexOfAny(
const expect_boundary = std.mem.indexOfScalar(
u21,
boundary_codepoints,
&[_]u21{start_cell.content.codepoint},
start_cell.content.codepoint,
) != null;
// Go forwards to find our end boundary
@@ -2816,10 +2817,10 @@ pub fn selectWord(
if (!cell.hasText()) break :end prev;
// If we do not match our expected set, we hit a boundary
const this_boundary = std.mem.indexOfAny(
const this_boundary = std.mem.indexOfScalar(
u21,
boundary_codepoints,
&[_]u21{cell.content.codepoint},
cell.content.codepoint,
) != null;
if (this_boundary != expect_boundary) break :end prev;
@@ -2853,10 +2854,10 @@ pub fn selectWord(
if (!cell.hasText()) break :start prev;
// If we do not match our expected set, we hit a boundary
const this_boundary = std.mem.indexOfAny(
const this_boundary = std.mem.indexOfScalar(
u21,
boundary_codepoints,
&[_]u21{cell.content.codepoint},
cell.content.codepoint,
) != null;
if (this_boundary != expect_boundary) break :start prev;

View File

@@ -170,6 +170,10 @@ pub const terminal_mode_get = terminal.mode_get;
pub const terminal_mode_set = terminal.mode_set;
pub const terminal_get = terminal.get;
pub const terminal_get_multi = terminal.get_multi;
pub const terminal_select_word = selection.word;
pub const terminal_select_line = selection.line;
pub const terminal_select_all = selection.all;
pub const terminal_select_output = selection.output;
pub const terminal_selection_adjust = selection.adjust;
pub const terminal_selection_order = selection.order;
pub const terminal_selection_ordered = selection.ordered;

View File

@@ -3,6 +3,7 @@ const testing = std.testing;
const lib = @import("../lib.zig");
const grid_ref = @import("grid_ref.zig");
const point = @import("../point.zig");
const selection_codepoints = @import("../selection_codepoints.zig");
const Selection = @import("../Selection.zig");
const Result = @import("result.zig").Result;
const terminal_c = @import("terminal.zig");
@@ -34,6 +35,130 @@ pub const CSelection = extern struct {
}
};
/// C: GhosttyTerminalSelectWordOptions
pub const SelectWordOptions = extern struct {
size: usize = @sizeOf(SelectWordOptions),
ref: grid_ref.CGridRef,
boundary_codepoints: ?[*]const u32 = null,
boundary_codepoints_len: usize = 0,
};
/// C: GhosttyTerminalSelectLineOptions
pub const SelectLineOptions = extern struct {
size: usize = @sizeOf(SelectLineOptions),
ref: grid_ref.CGridRef,
whitespace: ?[*]const u32 = null,
whitespace_len: usize = 0,
semantic_prompt_boundary: bool = false,
};
pub fn word(
terminal: terminal_c.Terminal,
options: ?*const SelectWordOptions,
out_selection: ?*CSelection,
) callconv(lib.calling_conv) Result {
const t = terminal_c.zigTerminal(terminal) orelse return .invalid_value;
const opts = options orelse return .invalid_value;
if (opts.size < @sizeOf(SelectWordOptions)) return .invalid_value;
const out = out_selection orelse return .invalid_value;
const boundary_codepoints = codepointSlice(
opts.boundary_codepoints,
opts.boundary_codepoints_len,
) catch return .invalid_value;
const screen = t.screens.active;
const pin = opts.ref.toPin() orelse return .invalid_value;
out.* = .fromZig(screen.selectWord(
pin,
boundary_codepoints orelse &selection_codepoints.default_word_boundaries,
) orelse
return .no_value);
return .success;
}
pub fn line(
terminal: terminal_c.Terminal,
options: ?*const SelectLineOptions,
out_selection: ?*CSelection,
) callconv(lib.calling_conv) Result {
const t = terminal_c.zigTerminal(terminal) orelse return .invalid_value;
const opts = options orelse return .invalid_value;
if (opts.size < @sizeOf(SelectLineOptions)) return .invalid_value;
const out = out_selection orelse return .invalid_value;
const whitespace = codepointSlice(
opts.whitespace,
opts.whitespace_len,
) catch return .invalid_value;
const screen = t.screens.active;
const pin = opts.ref.toPin() orelse return .invalid_value;
out.* = .fromZig(screen.selectLine(.{
.pin = pin,
.whitespace = whitespace orelse &selection_codepoints.default_line_whitespace,
.semantic_prompt_boundary = opts.semantic_prompt_boundary,
}) orelse return .no_value);
return .success;
}
pub fn all(
terminal: terminal_c.Terminal,
out_selection: ?*CSelection,
) callconv(lib.calling_conv) Result {
const t = terminal_c.zigTerminal(terminal) orelse return .invalid_value;
const out = out_selection orelse return .invalid_value;
out.* = .fromZig(t.screens.active.selectAll() orelse return .no_value);
return .success;
}
pub fn output(
terminal: terminal_c.Terminal,
ref: grid_ref.CGridRef,
out_selection: ?*CSelection,
) callconv(lib.calling_conv) Result {
const t = terminal_c.zigTerminal(terminal) orelse return .invalid_value;
const out = out_selection orelse return .invalid_value;
const screen = t.screens.active;
const pin = ref.toPin() orelse return .invalid_value;
out.* = .fromZig(screen.selectOutput(pin) orelse return .no_value);
return .success;
}
/// Return the borrowed C array of `uint32_t` codepoints as a `[]const u21`.
///
/// `NULL + len 0` returns null, which callers treat as “use the API default
/// set.” A non-null pointer with `len 0` returns an empty slice, meaning “use an
/// explicitly empty set.” A non-zero length requires a non-null pointer.
///
/// This is intentionally zero-copy. In the C ABI, codepoints are `uint32_t`,
/// but selection internals use Zig's `u21` to represent valid Unicode scalar
/// values. Zig currently stores `u21` in the same size and alignment as `u32`,
/// so we assert that layout relationship and reinterpret the borrowed slice.
/// If Zig ever changes that representation, these comptime assertions fail
/// loudly rather than silently making this cast wrong.
fn codepointSlice(
ptr: ?[*]const u32,
len: usize,
) error{InvalidValue}!?[]const u21 {
comptime {
std.debug.assert(@sizeOf(u21) == @sizeOf(u32));
std.debug.assert(@alignOf(u21) == @alignOf(u32));
}
if (len == 0) {
const p = ptr orelse return null;
_ = p;
return &.{};
}
const p = ptr orelse return error.InvalidValue;
const cps: [*]const u21 = @ptrCast(p);
return cps[0..len];
}
pub fn adjust(
terminal: terminal_c.Terminal,
selection: ?*CSelection,

View File

@@ -1393,6 +1393,72 @@ test "set and get selection" {
try testing.expectEqual(Result.no_value, get(t, .selection, @ptrCast(&out)));
}
test "selection derivation helpers" {
var t: Terminal = null;
try testing.expectEqual(Result.success, new(
&lib.alloc.test_allocator,
&t,
.{
.cols = 80,
.rows = 24,
.max_scrollback = 0,
},
));
defer free(t);
vt_write(t, " Hello \r\nWorld", 16);
var out: selection_c.CSelection = undefined;
var word_ref: grid_ref_c.CGridRef = .{};
try testing.expectEqual(Result.success, grid_ref(t, .{
.tag = .active,
.value = .{ .active = .{ .x = 3, .y = 0 } },
}, &word_ref));
var empty_ref: grid_ref_c.CGridRef = .{};
try testing.expectEqual(Result.success, grid_ref(t, .{
.tag = .active,
.value = .{ .active = .{ .x = 20, .y = 0 } },
}, &empty_ref));
var line_ref: grid_ref_c.CGridRef = .{};
try testing.expectEqual(Result.success, grid_ref(t, .{
.tag = .active,
.value = .{ .active = .{ .x = 0, .y = 0 } },
}, &line_ref));
var word_opts: selection_c.SelectWordOptions = .{
.ref = word_ref,
};
try testing.expectEqual(Result.success, selection_c.word(t, &word_opts, &out));
try testing.expectEqual(@as(u16, 2), out.start.toPin().?.x);
try testing.expectEqual(@as(u16, 6), out.end.toPin().?.x);
word_opts.ref = empty_ref;
try testing.expectEqual(Result.no_value, selection_c.word(t, &word_opts, &out));
var line_opts: selection_c.SelectLineOptions = .{
.ref = line_ref,
};
try testing.expectEqual(Result.success, selection_c.line(t, &line_opts, &out));
try testing.expectEqual(@as(u16, 2), out.start.toPin().?.x);
try testing.expectEqual(@as(u16, 6), out.end.toPin().?.x);
try testing.expectEqual(Result.success, selection_c.all(t, &out));
try testing.expectEqual(@as(u16, 2), out.start.toPin().?.x);
try testing.expectEqual(@as(u16, 0), out.start.toPin().?.y);
try testing.expectEqual(@as(u16, 4), out.end.toPin().?.x);
try testing.expectEqual(@as(u16, 1), out.end.toPin().?.y);
try testing.expectEqual(Result.no_value, selection_c.output(t, line_ref, &out));
line_opts.size = @sizeOf(usize) - 1;
try testing.expectEqual(Result.invalid_value, selection_c.line(t, &line_opts, &out));
try testing.expectEqual(Result.invalid_value, selection_c.word(t, null, &out));
try testing.expectEqual(Result.invalid_value, selection_c.word(t, &word_opts, null));
}
test "selection_adjust mutates snapshot end" {
var t: Terminal = null;
try testing.expectEqual(Result.success, new(

View File

@@ -20,30 +20,35 @@ const mouse_encode = @import("mouse_encode.zig");
const grid_ref = @import("grid_ref.zig");
/// All C API structs and their Ghostty C names.
pub const structs: std.StaticStringMap(StructInfo) = .initComptime(.{
.{ "GhosttyColorRgb", StructInfo.init(color.RGB.C) },
.{ "GhosttyDeviceAttributes", StructInfo.init(terminal.DeviceAttributes) },
.{ "GhosttyDeviceAttributesPrimary", StructInfo.init(terminal.DeviceAttributes.Primary) },
.{ "GhosttyDeviceAttributesSecondary", StructInfo.init(terminal.DeviceAttributes.Secondary) },
.{ "GhosttyDeviceAttributesTertiary", StructInfo.init(terminal.DeviceAttributes.Tertiary) },
.{ "GhosttyFormatterTerminalOptions", StructInfo.init(formatter.TerminalOptions) },
.{ "GhosttySelection", StructInfo.init(selection.CSelection) },
.{ "GhosttyFormatterTerminalExtra", StructInfo.init(formatter.TerminalOptions.Extra) },
.{ "GhosttyFormatterScreenExtra", StructInfo.init(formatter.ScreenOptions.Extra) },
.{ "GhosttyGridRef", StructInfo.init(grid_ref.CGridRef) },
.{ "GhosttyMouseEncoderSize", StructInfo.init(mouse_encode.Size) },
.{ "GhosttyMousePosition", StructInfo.init(mouse_event.Position) },
.{ "GhosttyPoint", StructInfo.init(point.Point.C) },
.{ "GhosttyPointCoordinate", StructInfo.init(point.Coordinate) },
.{ "GhosttyRenderStateColors", StructInfo.init(render.Colors) },
.{ "GhosttySizeReportSize", StructInfo.init(size_report.Size) },
.{ "GhosttyString", StructInfo.init(lib.String) },
.{ "GhosttyStyle", StructInfo.init(style_c.Style) },
.{ "GhosttyStyleColor", StructInfo.init(style_c.Color) },
.{ "GhosttyTerminalOptions", StructInfo.init(terminal.Options) },
.{ "GhosttyTerminalScrollbar", StructInfo.init(terminal.TerminalScrollbar) },
.{ "GhosttyTerminalScrollViewport", StructInfo.init(terminal.ScrollViewport) },
});
pub const structs: std.StaticStringMap(StructInfo) = structs: {
@setEvalBranchQuota(10_000);
break :structs .initComptime(.{
.{ "GhosttyColorRgb", StructInfo.init(color.RGB.C) },
.{ "GhosttyDeviceAttributes", StructInfo.init(terminal.DeviceAttributes) },
.{ "GhosttyDeviceAttributesPrimary", StructInfo.init(terminal.DeviceAttributes.Primary) },
.{ "GhosttyDeviceAttributesSecondary", StructInfo.init(terminal.DeviceAttributes.Secondary) },
.{ "GhosttyDeviceAttributesTertiary", StructInfo.init(terminal.DeviceAttributes.Tertiary) },
.{ "GhosttyFormatterTerminalOptions", StructInfo.init(formatter.TerminalOptions) },
.{ "GhosttySelection", StructInfo.init(selection.CSelection) },
.{ "GhosttyTerminalSelectWordOptions", StructInfo.init(selection.SelectWordOptions) },
.{ "GhosttyTerminalSelectLineOptions", StructInfo.init(selection.SelectLineOptions) },
.{ "GhosttyFormatterTerminalExtra", StructInfo.init(formatter.TerminalOptions.Extra) },
.{ "GhosttyFormatterScreenExtra", StructInfo.init(formatter.ScreenOptions.Extra) },
.{ "GhosttyGridRef", StructInfo.init(grid_ref.CGridRef) },
.{ "GhosttyMouseEncoderSize", StructInfo.init(mouse_encode.Size) },
.{ "GhosttyMousePosition", StructInfo.init(mouse_event.Position) },
.{ "GhosttyPoint", StructInfo.init(point.Point.C) },
.{ "GhosttyPointCoordinate", StructInfo.init(point.Coordinate) },
.{ "GhosttyRenderStateColors", StructInfo.init(render.Colors) },
.{ "GhosttySizeReportSize", StructInfo.init(size_report.Size) },
.{ "GhosttyString", StructInfo.init(lib.String) },
.{ "GhosttyStyle", StructInfo.init(style_c.Style) },
.{ "GhosttyStyleColor", StructInfo.init(style_c.Color) },
.{ "GhosttyTerminalOptions", StructInfo.init(terminal.Options) },
.{ "GhosttyTerminalScrollbar", StructInfo.init(terminal.TerminalScrollbar) },
.{ "GhosttyTerminalScrollViewport", StructInfo.init(terminal.ScrollViewport) },
});
};
/// The comptime-generated JSON string of all structs.
pub const json: [:0]const u8 = json: {

View File

@@ -0,0 +1,31 @@
// This file contains various default word boundaries used for
// selection logic. We put it in a separate file so that different
// subsystems can import it without introducing a number of
// dependencies.
/// Default boundary characters for word selection: ` \t'"│`|:;,()[]{}<>$`
pub const default_word_boundaries = [_]u21{
0, // null
' ', // space
'\t', // tab
'\'', // single quote
'"', // double quote
'│', // U+2502 box drawing
'`', // backtick
'|', // pipe
':', // colon
';', // semicolon
',', // comma
'(', // left paren
')', // right paren
'[', // left bracket
']', // right bracket
'{', // left brace
'}', // right brace
'<', // less than
'>', // greater than
'$', // dollar
};
/// Default whitespace characters trimmed from line selections.
pub const default_line_whitespace = [_]u21{ 0, ' ', '\t' };