mirror of
https://github.com/ghostty-org/ghostty.git
synced 2026-09-14 18:01:58 +00:00
terminal/c: add search wrapper implementing the whole-terminal search API
This commit is contained in:
@@ -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
844
src/terminal/c/search.zig
Normal 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);
|
||||
}
|
||||
@@ -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),
|
||||
|
||||
Reference in New Issue
Block a user