terminal/c: add search wrapper implementing the whole-terminal search API

This commit is contained in:
Mitchell Hashimoto
2026-08-31 13:20:51 -07:00
parent 149c9f562a
commit 32601cd79a
3 changed files with 866 additions and 0 deletions

View File

@@ -40,6 +40,7 @@ pub const mouse_event = @import("mouse_event.zig");
pub const mouse_encode = @import("mouse_encode.zig");
pub const paste = @import("paste.zig");
pub const row = @import("row.zig");
pub const search = @import("search.zig");
pub const sgr = @import("sgr.zig");
pub const size_report = @import("size_report.zig");
pub const snapshot = @import("snapshot.zig");
@@ -215,6 +216,14 @@ 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 search_new = search.new;
pub const search_free = search.free;
pub const search_tick = search.tick;
pub const search_feed = search.feed;
pub const search_run = search.run;
pub const search_set = search.set;
pub const search_get = search.get;
pub const search_get_multi = search.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;
@@ -271,6 +280,7 @@ test {
_ = mouse_event;
_ = mouse_encode;
_ = paste;
_ = search;
_ = sgr;
_ = size_report;
_ = snapshot;

844
src/terminal/c/search.zig Normal file
View File

@@ -0,0 +1,844 @@
const std = @import("std");
const testing = std.testing;
const lib = @import("../lib.zig");
const CAllocator = lib.alloc.Allocator;
const searchpkg = @import("../search.zig");
const TerminalSearch = searchpkg.Terminal;
const FlattenedHighlight = @import("../highlight.zig").Flattened;
const selection_c = @import("selection.zig");
const terminal_c = @import("terminal.zig");
const Result = @import("result.zig").Result;
const log = std.log.scoped(.search_c);
/// C: GhosttySearch
pub const Search = ?*SearchWrapper;
const SearchWrapper = struct {
alloc: std.mem.Allocator,
/// The terminal this search is bound to. This is borrowed, so the
/// search never frees it and the terminal must outlive the search.
terminal: *terminal_c.ZigTerminal,
search: TerminalSearch,
/// The scroll policy applied by the select options. Persistent
/// across selects, set via the select_scroll option.
select_scroll: TerminalSearch.SelectScroll = .if_needed,
};
/// C: GhosttySearchStatus
pub const Status = enum(c_int) {
running = 0,
feed_required = 1,
complete = 2,
fn fromZig(status: TerminalSearch.Status) Status {
return switch (status) {
.running => .running,
.feed_required => .feed_required,
.complete => .complete,
};
}
};
/// C: GhosttySearchScroll
pub const Scroll = enum(c_int) {
if_needed = 0,
none = 1,
fn toZig(self: Scroll) TerminalSearch.SelectScroll {
return switch (self) {
.if_needed => .if_needed,
.none => .none,
};
}
fn fromZig(scroll: TerminalSearch.SelectScroll) Scroll {
return switch (scroll) {
.if_needed => .if_needed,
.none => .none,
};
}
};
/// C: GhosttySearchData
pub const Data = enum(c_int) {
status = 0,
needle = 1,
total_matches = 2,
selected_index = 3,
selected_match = 4,
matches = 5,
viewport_matches = 6,
select_scroll = 7,
pub fn OutType(comptime self: Data) type {
return switch (self) {
.status => Status,
.needle => lib.String,
.total_matches => usize,
.selected_index => usize,
.selected_match => selection_c.CSelection,
.matches, .viewport_matches => selection_c.CSelectionBuffer,
.select_scroll => Scroll,
};
}
};
/// C: GhosttySearchOption
pub const Option = enum(c_int) {
select_next = 0,
select_prev = 1,
select_scroll = 2,
pub fn Type(comptime self: Option) type {
return switch (self) {
// The value must be NULL. Reserved for future use.
.select_next, .select_prev => void,
.select_scroll => Scroll,
};
}
};
/// C: GhosttySearchOptions
pub const Options = extern struct {
size: usize = @sizeOf(Options),
needle: lib.String,
};
pub fn new(
alloc_: ?*const CAllocator,
out_search: ?*Search,
terminal: terminal_c.Terminal,
options: ?*const Options,
) callconv(lib.calling_conv) Result {
const out = out_search orelse return .invalid_value;
out.* = null;
const t = terminal_c.zigTerminal(terminal) orelse return .invalid_value;
const opts = options orelse return .invalid_value;
if (opts.size < @sizeOf(Options)) return .invalid_value;
if (opts.needle.len == 0) return .invalid_value;
if (@intFromPtr(opts.needle.ptr) == 0) return .invalid_value;
const needle = opts.needle.ptr[0..opts.needle.len];
const alloc = lib.alloc.default(alloc_);
const wrapper = alloc.create(SearchWrapper) catch return .out_of_memory;
const search = TerminalSearch.init(alloc, needle) catch {
alloc.destroy(wrapper);
return .out_of_memory;
};
wrapper.* = .{
.alloc = alloc,
.terminal = t,
.search = search,
};
out.* = wrapper;
return .success;
}
pub fn free(search_: Search) callconv(lib.calling_conv) void {
const wrapper = search_ orelse return;
wrapper.search.deinit(wrapper.terminal);
const alloc = wrapper.alloc;
alloc.destroy(wrapper);
}
pub fn tick(
search_: Search,
out_status: ?*Status,
) callconv(lib.calling_conv) Result {
const wrapper = search_ orelse return .invalid_value;
_ = wrapper.search.tick();
if (out_status) |out| out.* = .fromZig(wrapper.search.status());
return .success;
}
pub fn feed(search_: Search) callconv(lib.calling_conv) Result {
const wrapper = search_ orelse return .invalid_value;
// The C API has no renderer cooperation to know whether the active
// area changed, so it is always re-scanned. This is correct without
// any dirty tracking and cheap because the active area search was
// built for exactly this.
wrapper.search.feed(wrapper.terminal, true);
return .success;
}
pub fn run(search_: Search) callconv(lib.calling_conv) Result {
const wrapper = search_ orelse return .invalid_value;
// Always start with a feed: complete only means caught up as of
// the last feed, so run doubles as the "terminal changed, catch
// up" convenience for one-shot embedders.
wrapper.search.feed(wrapper.terminal, true);
while (true) {
switch (wrapper.search.status()) {
.complete => return .success,
.feed_required => wrapper.search.feed(wrapper.terminal, true),
.running => _ = wrapper.search.tick(),
}
}
}
pub fn set(
search_: Search,
option: Option,
value: ?*const anyopaque,
) callconv(lib.calling_conv) Result {
if (comptime std.debug.runtime_safety) {
_ = std.enums.fromInt(Option, @intFromEnum(option)) orelse {
log.warn("search_set invalid option value={d}", .{@intFromEnum(option)});
return .invalid_value;
};
}
return switch (option) {
inline else => |comptime_option| setTyped(
search_,
comptime_option,
if (value) |ptr| @ptrCast(@alignCast(ptr)) else null,
),
};
}
fn setTyped(
search_: Search,
comptime option: Option,
value: ?*const option.Type(),
) Result {
const wrapper = search_ orelse return .invalid_value;
switch (option) {
.select_next, .select_prev => {
// The value is reserved for future use and must be NULL.
if (value != null) return .invalid_value;
const selected = wrapper.search.select(
wrapper.terminal,
switch (option) {
.select_next => .next,
.select_prev => .prev,
else => comptime unreachable,
},
wrapper.select_scroll,
) catch return .out_of_memory;
if (!selected) return .no_value;
},
.select_scroll => {
const v = value orelse {
wrapper.select_scroll = .if_needed;
return .success;
};
const scroll = std.enums.fromInt(Scroll, @intFromEnum(v.*)) orelse
return .invalid_value;
wrapper.select_scroll = scroll.toZig();
},
}
return .success;
}
pub fn get(
search_: Search,
data: Data,
value: ?*anyopaque,
) callconv(lib.calling_conv) Result {
if (comptime std.debug.runtime_safety) {
_ = std.enums.fromInt(Data, @intFromEnum(data)) orelse {
log.warn("search_get invalid data value={d}", .{@intFromEnum(data)});
return .invalid_value;
};
}
const out_ptr = value orelse return .invalid_value;
return switch (data) {
inline else => |comptime_data| getTyped(
search_,
comptime_data,
@ptrCast(@alignCast(out_ptr)),
),
};
}
pub fn get_multi(
search_: Search,
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(search_, 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(
search_: Search,
comptime data: Data,
out: *data.OutType(),
) Result {
const wrapper = search_ orelse return .invalid_value;
const search = &wrapper.search;
switch (data) {
.status => out.* = .fromZig(search.status()),
.needle => out.* = .init(search.needle()),
.total_matches => out.* = if (search.activeScreenSearch()) |ss|
ss.matchesLen()
else
0,
.selected_index => {
const ss = search.activeScreenSearch() orelse return .no_value;
const selected = ss.selected orelse return .no_value;
out.* = selected.idx;
},
.selected_match => {
const ss = search.activeScreenSearch() orelse return .no_value;
const hl = ss.selectedMatch() orelse return .no_value;
out.* = selectionFromHighlight(hl);
},
.matches => {
const ss = search.activeScreenSearch();
const total = if (ss) |s| s.matchesLen() else 0;
if (out.cap < total) {
out.len = total;
return .out_of_space;
}
if (total > 0) {
const dst = (out.ptr orelse return .invalid_value)[0..total];
for (dst, 0..) |*d, i| {
d.* = selectionFromHighlight(ss.?.matchAt(i).?);
}
}
out.len = total;
},
.viewport_matches => {
const matches = search.viewportMatches() catch
return .out_of_memory;
if (out.cap < matches.len) {
out.len = matches.len;
return .out_of_space;
}
if (matches.len > 0) {
const dst = (out.ptr orelse return .invalid_value)[0..matches.len];
for (dst, matches) |*d, hl| d.* = selectionFromHighlight(hl);
}
out.len = matches.len;
},
.select_scroll => out.* = .fromZig(wrapper.select_scroll),
}
return .success;
}
fn selectionFromHighlight(hl: FlattenedHighlight) selection_c.CSelection {
const untracked = hl.untracked();
return .{
.start = .fromPin(untracked.start),
.end = .fromPin(untracked.end),
.rectangle = false,
};
}
fn testString(str: []const u8) lib.String {
return .init(str);
}
test "search lifecycle and run to complete" {
var terminal: terminal_c.Terminal = null;
try testing.expectEqual(Result.success, terminal_c.new(
&lib.alloc.test_allocator,
&terminal,
10,
4,
));
defer terminal_c.free(terminal);
terminal_c.vt_write(terminal, "Fizz\r\nBuzz\r\nFizz\r\nBang", 22);
var search: Search = null;
const opts: Options = .{ .needle = testString("Fizz") };
try testing.expectEqual(Result.success, new(
&lib.alloc.test_allocator,
&search,
terminal,
&opts,
));
defer free(search);
// A fresh search must report feed_required, not complete. The
// internal completion check is trivially true before the search
// has seen the terminal.
var status: Status = .complete;
try testing.expectEqual(Result.success, get(search, .status, &status));
try testing.expectEqual(Status.feed_required, status);
try testing.expectEqual(Result.success, run(search));
try testing.expectEqual(Result.success, get(search, .status, &status));
try testing.expectEqual(Status.complete, status);
var total: usize = 0;
try testing.expectEqual(Result.success, get(search, .total_matches, &total));
try testing.expectEqual(@as(usize, 2), total);
// The needle is borrowed and matches what we searched for.
var needle: lib.String = undefined;
try testing.expectEqual(Result.success, get(search, .needle, &needle));
try testing.expectEqualStrings("Fizz", needle.ptr[0..needle.len]);
}
test "search tick reports feed required before first feed" {
var terminal: terminal_c.Terminal = null;
try testing.expectEqual(Result.success, terminal_c.new(
&lib.alloc.test_allocator,
&terminal,
10,
4,
));
defer terminal_c.free(terminal);
var search: Search = null;
const opts: Options = .{ .needle = testString("Fizz") };
try testing.expectEqual(Result.success, new(
&lib.alloc.test_allocator,
&search,
terminal,
&opts,
));
defer free(search);
var status: Status = .complete;
try testing.expectEqual(Result.success, tick(search, &status));
try testing.expectEqual(Status.feed_required, status);
// NULL status out is allowed.
try testing.expectEqual(Result.success, tick(search, null));
}
test "search feed after write updates results" {
var terminal: terminal_c.Terminal = null;
try testing.expectEqual(Result.success, terminal_c.new(
&lib.alloc.test_allocator,
&terminal,
10,
4,
));
defer terminal_c.free(terminal);
terminal_c.vt_write(terminal, "Fizz", 4);
var search: Search = null;
const opts: Options = .{ .needle = testString("Fizz") };
try testing.expectEqual(Result.success, new(
&lib.alloc.test_allocator,
&search,
terminal,
&opts,
));
defer free(search);
try testing.expectEqual(Result.success, run(search));
var total: usize = 0;
try testing.expectEqual(Result.success, get(search, .total_matches, &total));
try testing.expectEqual(@as(usize, 1), total);
// Write another match: the caller-driven feed is the "terminal
// changed" signal.
terminal_c.vt_write(terminal, "\r\nFizz", 6);
try testing.expectEqual(Result.success, run(search));
try testing.expectEqual(Result.success, get(search, .total_matches, &total));
try testing.expectEqual(@as(usize, 2), total);
}
test "search alt screen flip and return retains results" {
var terminal: terminal_c.Terminal = null;
try testing.expectEqual(Result.success, terminal_c.new(
&lib.alloc.test_allocator,
&terminal,
10,
4,
));
defer terminal_c.free(terminal);
terminal_c.vt_write(terminal, "Fizz\r\nFizz", 10);
var search: Search = null;
const opts: Options = .{ .needle = testString("Fizz") };
try testing.expectEqual(Result.success, new(
&lib.alloc.test_allocator,
&search,
terminal,
&opts,
));
defer free(search);
try testing.expectEqual(Result.success, run(search));
var total: usize = 0;
try testing.expectEqual(Result.success, get(search, .total_matches, &total));
try testing.expectEqual(@as(usize, 2), total);
// Flip to the alternate screen: reads now reflect that screen.
terminal_c.vt_write(terminal, "\x1b[?1049h", 8);
try testing.expectEqual(Result.success, run(search));
try testing.expectEqual(Result.success, get(search, .total_matches, &total));
try testing.expectEqual(@as(usize, 0), total);
terminal_c.vt_write(terminal, "Fizz", 4);
try testing.expectEqual(Result.success, run(search));
try testing.expectEqual(Result.success, get(search, .total_matches, &total));
try testing.expectEqual(@as(usize, 1), total);
// Return to the primary screen: results are retained and restored.
terminal_c.vt_write(terminal, "\x1b[?1049l", 8);
try testing.expectEqual(Result.success, run(search));
try testing.expectEqual(Result.success, get(search, .total_matches, &total));
try testing.expectEqual(@as(usize, 2), total);
}
test "search select wraps in both directions" {
var terminal: terminal_c.Terminal = null;
try testing.expectEqual(Result.success, terminal_c.new(
&lib.alloc.test_allocator,
&terminal,
10,
4,
));
defer terminal_c.free(terminal);
terminal_c.vt_write(terminal, "Fizz\r\nBuzz\r\nFizz", 16);
var search: Search = null;
const opts: Options = .{ .needle = testString("Fizz") };
try testing.expectEqual(Result.success, new(
&lib.alloc.test_allocator,
&search,
terminal,
&opts,
));
defer free(search);
try testing.expectEqual(Result.success, run(search));
// No selection yet.
var idx: usize = 999;
try testing.expectEqual(Result.no_value, get(search, .selected_index, &idx));
// Next: newest match first (index 0), then older (1), then wrap
// back to 0.
try testing.expectEqual(Result.success, set(search, .select_next, null));
try testing.expectEqual(Result.success, get(search, .selected_index, &idx));
try testing.expectEqual(@as(usize, 0), idx);
var match: selection_c.CSelection = undefined;
try testing.expectEqual(Result.success, get(search, .selected_match, &match));
try testing.expect(match.start.toPin() != null);
try testing.expectEqual(Result.success, set(search, .select_next, null));
try testing.expectEqual(Result.success, get(search, .selected_index, &idx));
try testing.expectEqual(@as(usize, 1), idx);
try testing.expectEqual(Result.success, set(search, .select_next, null));
try testing.expectEqual(Result.success, get(search, .selected_index, &idx));
try testing.expectEqual(@as(usize, 0), idx);
// Prev: wrap backward to the oldest match.
try testing.expectEqual(Result.success, set(search, .select_prev, null));
try testing.expectEqual(Result.success, get(search, .selected_index, &idx));
try testing.expectEqual(@as(usize, 1), idx);
}
test "search select with no matches returns no value" {
var terminal: terminal_c.Terminal = null;
try testing.expectEqual(Result.success, terminal_c.new(
&lib.alloc.test_allocator,
&terminal,
10,
4,
));
defer terminal_c.free(terminal);
var search: Search = null;
const opts: Options = .{ .needle = testString("Fizz") };
try testing.expectEqual(Result.success, new(
&lib.alloc.test_allocator,
&search,
terminal,
&opts,
));
defer free(search);
try testing.expectEqual(Result.no_value, set(search, .select_next, null));
try testing.expectEqual(Result.no_value, set(search, .select_prev, null));
}
test "search select rejects a non-null reserved value" {
var terminal: terminal_c.Terminal = null;
try testing.expectEqual(Result.success, terminal_c.new(
&lib.alloc.test_allocator,
&terminal,
10,
4,
));
defer terminal_c.free(terminal);
var search: Search = null;
const opts: Options = .{ .needle = testString("Fizz") };
try testing.expectEqual(Result.success, new(
&lib.alloc.test_allocator,
&search,
terminal,
&opts,
));
defer free(search);
const bogus: c_int = 0;
try testing.expectEqual(Result.invalid_value, set(search, .select_next, &bogus));
try testing.expectEqual(Result.invalid_value, set(search, .select_prev, &bogus));
}
test "search select scroll option" {
var terminal: terminal_c.Terminal = null;
try testing.expectEqual(Result.success, terminal_c.new(
&lib.alloc.test_allocator,
&terminal,
10,
4,
));
defer terminal_c.free(terminal);
var search: Search = null;
const opts: Options = .{ .needle = testString("Fizz") };
try testing.expectEqual(Result.success, new(
&lib.alloc.test_allocator,
&search,
terminal,
&opts,
));
defer free(search);
var scroll: Scroll = .none;
try testing.expectEqual(Result.success, get(search, .select_scroll, &scroll));
try testing.expectEqual(Scroll.if_needed, scroll);
const none: Scroll = .none;
try testing.expectEqual(Result.success, set(search, .select_scroll, &none));
try testing.expectEqual(Result.success, get(search, .select_scroll, &scroll));
try testing.expectEqual(Scroll.none, scroll);
// NULL resets to the default.
try testing.expectEqual(Result.success, set(search, .select_scroll, null));
try testing.expectEqual(Result.success, get(search, .select_scroll, &scroll));
try testing.expectEqual(Scroll.if_needed, scroll);
// Invalid enum values are rejected.
const bogus: c_int = 42;
try testing.expectEqual(Result.invalid_value, set(search, .select_scroll, @ptrCast(&bogus)));
}
test "search selection dropped when reset prunes results" {
var terminal: terminal_c.Terminal = null;
try testing.expectEqual(Result.success, terminal_c.new(
&lib.alloc.test_allocator,
&terminal,
10,
4,
));
defer terminal_c.free(terminal);
terminal_c.vt_write(terminal, "Fizz\r\nBuzz", 10);
var search: Search = null;
const opts: Options = .{ .needle = testString("Fizz") };
try testing.expectEqual(Result.success, new(
&lib.alloc.test_allocator,
&search,
terminal,
&opts,
));
defer free(search);
try testing.expectEqual(Result.success, run(search));
try testing.expectEqual(Result.success, set(search, .select_next, null));
// A selection survives unrelated writes...
terminal_c.vt_write(terminal, "\r\nBang", 6);
try testing.expectEqual(Result.success, run(search));
var match: selection_c.CSelection = undefined;
try testing.expectEqual(Result.success, get(search, .selected_match, &match));
// ...but a full reset prunes every result, dropping the selection.
terminal_c.vt_write(terminal, "\x1bc", 2);
try testing.expectEqual(Result.success, run(search));
var total: usize = 999;
try testing.expectEqual(Result.success, get(search, .total_matches, &total));
try testing.expectEqual(@as(usize, 0), total);
try testing.expectEqual(Result.no_value, get(search, .selected_match, &match));
}
test "search matches buffer semantics" {
var terminal: terminal_c.Terminal = null;
try testing.expectEqual(Result.success, terminal_c.new(
&lib.alloc.test_allocator,
&terminal,
10,
4,
));
defer terminal_c.free(terminal);
terminal_c.vt_write(terminal, "Fizz\r\nBuzz\r\nFizz", 16);
var search: Search = null;
const opts: Options = .{ .needle = testString("Fizz") };
try testing.expectEqual(Result.success, new(
&lib.alloc.test_allocator,
&search,
terminal,
&opts,
));
defer free(search);
try testing.expectEqual(Result.success, run(search));
// NULL ptr with cap 0 queries the required capacity.
var buf: selection_c.CSelectionBuffer = .{};
try testing.expectEqual(Result.out_of_space, get(search, .matches, &buf));
try testing.expectEqual(@as(usize, 2), buf.len);
// Too-small buffers report the required capacity.
var one: [1]selection_c.CSelection = undefined;
buf = .{ .ptr = &one, .cap = one.len };
try testing.expectEqual(Result.out_of_space, get(search, .matches, &buf));
try testing.expectEqual(@as(usize, 2), buf.len);
// A large-enough buffer is filled newest to oldest.
var storage: [4]selection_c.CSelection = undefined;
buf = .{ .ptr = &storage, .cap = storage.len };
try testing.expectEqual(Result.success, get(search, .matches, &buf));
try testing.expectEqual(@as(usize, 2), buf.len);
const first = storage[0].start.toPin().?;
const second = storage[1].start.toPin().?;
try testing.expect(first.y != second.y or first.node != second.node);
// Viewport matches use the same conventions and are cached across
// reads.
buf = .{};
try testing.expectEqual(Result.out_of_space, get(search, .viewport_matches, &buf));
try testing.expectEqual(@as(usize, 2), buf.len);
buf = .{ .ptr = &storage, .cap = storage.len };
try testing.expectEqual(Result.success, get(search, .viewport_matches, &buf));
try testing.expectEqual(@as(usize, 2), buf.len);
buf = .{ .ptr = &storage, .cap = storage.len };
try testing.expectEqual(Result.success, get(search, .viewport_matches, &buf));
try testing.expectEqual(@as(usize, 2), buf.len);
}
test "search 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,
10,
4,
));
defer terminal_c.free(terminal);
terminal_c.vt_write(terminal, "Fizz", 4);
var search: Search = null;
const opts: Options = .{ .needle = testString("Fizz") };
try testing.expectEqual(Result.success, new(
&lib.alloc.test_allocator,
&search,
terminal,
&opts,
));
defer free(search);
try testing.expectEqual(Result.success, run(search));
// No selection: selected_index fails at index 1.
const keys = [_]Data{ .total_matches, .selected_index, .status };
var total: usize = 0;
var idx: usize = 0;
var status: Status = .running;
var values = [_]?*anyopaque{ &total, &idx, &status };
var written: usize = 999;
try testing.expectEqual(Result.no_value, get_multi(
search,
keys.len,
&keys,
&values,
&written,
));
try testing.expectEqual(@as(usize, 1), written);
try testing.expectEqual(@as(usize, 1), total);
// After selecting, the whole batch succeeds.
try testing.expectEqual(Result.success, set(search, .select_next, null));
try testing.expectEqual(Result.success, get_multi(
search,
keys.len,
&keys,
&values,
&written,
));
try testing.expectEqual(keys.len, written);
try testing.expectEqual(@as(usize, 0), idx);
}
test "search new validates options" {
var terminal: terminal_c.Terminal = null;
try testing.expectEqual(Result.success, terminal_c.new(
&lib.alloc.test_allocator,
&terminal,
10,
4,
));
defer terminal_c.free(terminal);
var search: Search = null;
// Empty needle is invalid.
const empty: Options = .{ .needle = testString("") };
try testing.expectEqual(Result.invalid_value, new(
&lib.alloc.test_allocator,
&search,
terminal,
&empty,
));
try testing.expect(search == null);
// NULL options and NULL terminal are invalid.
const opts: Options = .{ .needle = testString("Fizz") };
try testing.expectEqual(Result.invalid_value, new(
&lib.alloc.test_allocator,
&search,
terminal,
null,
));
try testing.expectEqual(Result.invalid_value, new(
&lib.alloc.test_allocator,
&search,
null,
&opts,
));
}
test "search free null" {
free(null);
}

View File

@@ -38,6 +38,18 @@ pub const CSelection = extern struct {
}
};
/// C: GhosttySelectionBuffer
///
/// A caller-provided buffer of selections. This follows the same
/// conventions as GhosttyBuffer: ptr may be NULL with cap 0 to query
/// the required capacity, and len is set to the entries written on
/// success or to the required capacity on GHOSTTY_OUT_OF_SPACE.
pub const CSelectionBuffer = extern struct {
ptr: ?[*]CSelection = null,
cap: usize = 0,
len: usize = 0,
};
/// C: GhosttyTerminalSelectWordOptions
pub const SelectWordOptions = extern struct {
size: usize = @sizeOf(SelectWordOptions),