libghostty: set the search needle via ghostty_search_set, drop GhosttySearchOptions

This commit is contained in:
Mitchell Hashimoto
2026-08-31 14:08:07 -07:00
parent 674abd8a19
commit 76d9fcefef
4 changed files with 301 additions and 241 deletions

View File

@@ -23,15 +23,18 @@ int main() {
strlen(lines[i]));
}
// The user typed a query into the find bar, so create a search bound
// to the terminal. Matching is byte-exact except ASCII letters, which
// compare case-insensitively, so "error" also finds "ERROR". The
// needle can't be changed later. Retyping means free and create.
GhosttySearchOptions opts = GHOSTTY_INIT_SIZED(GhosttySearchOptions);
opts.needle = (GhosttyString){ (const uint8_t *)"error", 5 };
// The user opened the find bar, so create a search bound to the
// terminal. It starts idle until it has a needle.
GhosttySearch search;
result = ghostty_search_new(NULL, &search, terminal, &opts);
result = ghostty_search_new(NULL, &search, terminal);
assert(result == GHOSTTY_SUCCESS);
// The user typed a query. Matching is byte-exact except ASCII
// letters, which compare case-insensitively, so "error" also finds
// "ERROR". Retyping just sets the needle again: a changed needle
// restarts the search and an unchanged one keeps its results.
GhosttyString needle = { (const uint8_t *)"error", 5 };
result = ghostty_search_set(search, GHOSTTY_SEARCH_OPT_NEEDLE, &needle);
assert(result == GHOSTTY_SUCCESS);
// Drive the search. Interactive embedders interleave
@@ -105,8 +108,8 @@ int main() {
(unsigned)start.x, (unsigned)end.x);
}
// Closing the find bar (or retyping the needle). The search borrows
// the terminal, so it must always be freed before the terminal.
// Closing the find bar. The search borrows the terminal, but the
// two can be freed in either order.
ghostty_search_free(search);
ghostty_terminal_free(terminal);
return 0;

View File

@@ -21,13 +21,18 @@ extern "C" {
* Search a terminal for a string, covering the active area and
* scrollback of both the primary and alternate screens.
*
* A GhosttySearch searches one terminal for one needle. It is bound to
* the terminal it was created with and handles the hard parts of
* terminal search internally: results stay in sync with the live
* screens, survive primary/alternate screen switches (entering and
* leaving a fullscreen app such as vim does not restart a scrollback
* search), and recover from resize, reflow, resets, and scrollback
* pruning.
* A GhosttySearch searches the terminal it was created with for a
* needle set with GHOSTTY_SEARCH_OPT_NEEDLE. It handles the hard
* parts of terminal search internally: results stay in sync with the
* live screens, survive primary/alternate screen switches (entering
* and leaving a fullscreen app such as vim does not restart a
* scrollback search), and recover from resize, reflow, resets, and
* scrollback pruning.
*
* A search starts idle. Setting the needle starts the search,
* changing it restarts the search from scratch, and clearing it
* returns the search to idle. Matching is byte-exact except ASCII
* letters, which compare case-insensitively.
*
* ## Driving a search
*
@@ -91,8 +96,8 @@ extern "C" {
*
* Functions that touch the terminal (ghostty_search_new(),
* ghostty_search_feed(), ghostty_search_run(), ghostty_search_set()
* with the select options, and ghostty_search_free()) must also be
* serialized with all other access to the same terminal.
* with the needle and select options, and ghostty_search_free()) must
* also be serialized with all other access to the same terminal.
*
* Everything else (ghostty_search_tick(), ghostty_search_get(), and
* ghostty_search_get_multi()) only touches memory owned by the search
@@ -121,8 +126,8 @@ typedef enum GHOSTTY_ENUM_TYPED {
GHOSTTY_SEARCH_STATUS_RUNNING = 0,
/**
* Blocked until ghostty_search_feed(). This is also the initial
* state, since a search that has never been fed has never seen the
* Blocked until ghostty_search_feed(). This is also the state right
* after a needle is set, since the search has not yet seen the
* terminal.
*/
GHOSTTY_SEARCH_STATUS_FEED_REQUIRED = 1,
@@ -130,7 +135,8 @@ typedef enum GHOSTTY_ENUM_TYPED {
/**
* Caught up with the terminal state as of the last feed. This never
* means finished forever, since later terminal writes require
* another feed to be seen.
* another feed to be seen. A search with no needle set also reports
* complete, since there is nothing to look for.
*/
GHOSTTY_SEARCH_STATUS_COMPLETE = 2,
@@ -172,8 +178,9 @@ typedef enum GHOSTTY_ENUM_TYPED {
/**
* The needle this search is looking for: GhosttyString*. The bytes
* are borrowed from the search and remain valid until
* ghostty_search_free().
* are borrowed from the search and remain valid until the needle is
* changed or the search is freed. Returns GHOSTTY_NO_VALUE when no
* needle is set.
*/
GHOSTTY_SEARCH_DATA_NEEDLE = 1,
@@ -237,6 +244,25 @@ typedef enum GHOSTTY_ENUM_TYPED {
* @ingroup search
*/
typedef enum GHOSTTY_ENUM_TYPED {
/**
* Set the needle to search for: const GhosttyString*. The bytes are
* copied, so the caller's memory does not need to outlive the call.
* Matching is byte-exact except ASCII letters, which compare
* case-insensitively.
*
* Changing the needle restarts the search from scratch and drops
* all results. As an exception, setting a needle equal to the current
* one (compared the same way as matching) keeps existing results,
* so find bars can resubmit freely. A NULL or empty value clears
* the needle and returns the search to idle.
*
* Replacing or clearing a needle releases tracked state held
* within the terminal, so the caller must serialize this with all
* other access to the same terminal. Returns GHOSTTY_INVALID_VALUE
* after the terminal was freed.
*/
GHOSTTY_SEARCH_OPT_NEEDLE = 0,
/**
* Select the next match, moving toward older content: from the
* bottom of the screen upward into history, the direction a search
@@ -252,14 +278,14 @@ typedef enum GHOSTTY_ENUM_TYPED {
* other access to the same terminal. Returns GHOSTTY_NO_VALUE when
* there are no matches.
*/
GHOSTTY_SEARCH_OPT_SELECT_NEXT = 0,
GHOSTTY_SEARCH_OPT_SELECT_NEXT = 1,
/**
* Select the previous match, moving toward newer content, wrapping
* around past the newest match. Otherwise identical to
* GHOSTTY_SEARCH_OPT_SELECT_NEXT.
*/
GHOSTTY_SEARCH_OPT_SELECT_PREV = 1,
GHOSTTY_SEARCH_OPT_SELECT_PREV = 2,
/**
* Set the scroll policy applied by the select options: const
@@ -267,62 +293,39 @@ typedef enum GHOSTTY_ENUM_TYPED {
* value resets it to GHOSTTY_SEARCH_SCROLL_IF_NEEDED. This only
* modifies search-owned state and never reads the terminal.
*/
GHOSTTY_SEARCH_OPT_SELECT_SCROLL = 2,
GHOSTTY_SEARCH_OPT_SELECT_SCROLL = 3,
GHOSTTY_SEARCH_OPT_MAX_VALUE = GHOSTTY_ENUM_MAX_VALUE,
} GhosttySearchOption;
/**
* Options for creating a search.
*
* This is a sized struct. Use GHOSTTY_INIT_SIZED() to initialize it.
*
* @ingroup search
*/
typedef struct {
/** Size of this struct in bytes. Must be set to sizeof(GhosttySearchOptions). */
size_t size;
/**
* The bytes to search for. This is copied, so the caller's memory
* does not need to outlive the create call. Must not be empty.
*
* Matching is byte-exact except ASCII letters, which compare
* case-insensitively.
*
* The needle cannot be changed after the search is created. To
* change the query, free the search and create a new one. This is
* also how Ghostty's own find bar handles retyping.
*/
GhosttyString needle;
} GhosttySearchOptions;
/**
* Create a search bound to a terminal.
*
* The search borrows the terminal and never frees it. The search and
* the terminal can be freed in either order; see ghostty_search_free().
*
* Creation is cheap and does not read terminal contents (the first
* feed does), but it registers the search with the terminal so the
* two can be freed in any order. The caller must serialize this call
* with all other access to the same terminal.
* The search starts idle with no needle: it reports
* GHOSTTY_SEARCH_STATUS_COMPLETE and finds nothing. Set
* GHOSTTY_SEARCH_OPT_NEEDLE to start searching.
*
* Creation is cheap and does not read terminal contents, but it
* registers the search with the terminal so the two can be freed in
* any order. The caller must serialize this call with all other
* access to the same terminal.
*
* @param allocator Allocator, or NULL for the default allocator
* @param out_search Receives the created search handle
* @param terminal Terminal to bind the search to
* @param options Search options (the needle is copied)
* @return GHOSTTY_SUCCESS on success, GHOSTTY_INVALID_VALUE if
* out_search, terminal, or options are invalid or the needle
* is empty, or GHOSTTY_OUT_OF_MEMORY if allocation fails
* out_search or terminal is invalid, or GHOSTTY_OUT_OF_MEMORY
* if allocation fails
*
* @ingroup search
*/
GHOSTTY_API GhosttyResult ghostty_search_new(
const GhosttyAllocator* allocator,
GhosttySearch* out_search,
GhosttyTerminal terminal,
const GhosttySearchOptions* options);
GhosttyTerminal terminal);
/**
* Free a search.
@@ -408,9 +411,9 @@ GHOSTTY_API GhosttyResult ghostty_search_run(GhosttySearch search);
* Write an option to a search.
*
* The value type, and what a NULL value means, depends on the option
* and is documented by GhosttySearchOption. The select options read
* the terminal, so the caller must serialize those calls with all
* other access to the same terminal.
* and is documented by GhosttySearchOption. The needle and select
* options touch the terminal, so the caller must serialize those
* calls with all other access to the same terminal.
* GHOSTTY_SEARCH_OPT_SELECT_SCROLL only modifies search-owned state.
*
* @param search Search handle (NULL returns GHOSTTY_INVALID_VALUE)
@@ -418,10 +421,10 @@ GHOSTTY_API GhosttyResult ghostty_search_run(GhosttySearch search);
* @param value Pointer to the input value for the option. The meaning
* of NULL is documented per option.
* @return GHOSTTY_SUCCESS on success, GHOSTTY_NO_VALUE if a select
* option found no matches, GHOSTTY_OUT_OF_MEMORY if selection
* tracking fails, or GHOSTTY_INVALID_VALUE if search, option,
* or value is invalid or a select option is used after the
* terminal was freed
* option found no matches, GHOSTTY_OUT_OF_MEMORY if
* allocation fails, or GHOSTTY_INVALID_VALUE if search,
* option, or value is invalid or the option needs a terminal
* that was already freed
*
* @ingroup search
*/

View File

@@ -23,7 +23,9 @@ pub const SearchWrapper = struct {
/// then fail cleanly and free releases only search-owned memory.
terminal: terminal_c.Terminal,
search: TerminalSearch,
/// The search state. Null when no needle is set: the search
/// starts idle and returns to idle when the needle is cleared.
search: ?TerminalSearch = null,
/// The scroll policy applied by the select options. Persistent
/// across selects, set via the select_scroll option.
@@ -35,6 +37,13 @@ pub const SearchWrapper = struct {
const terminal_wrapper = self.terminal orelse return null;
return terminal_wrapper.terminal;
}
/// The status of the search. A search with no needle reports
/// complete since there is nothing to look for.
fn status(self: *SearchWrapper) Status {
const s = if (self.search) |*s| s else return .complete;
return .fromZig(s.status());
}
};
/// C: GhosttySearchStatus
@@ -98,12 +107,14 @@ pub const Data = enum(c_int) {
/// C: GhosttySearchOption
pub const Option = enum(c_int) {
select_next = 0,
select_prev = 1,
select_scroll = 2,
needle = 0,
select_next = 1,
select_prev = 2,
select_scroll = 3,
pub fn Type(comptime self: Option) type {
return switch (self) {
.needle => lib.String,
// The value must be NULL. Reserved for future use.
.select_next, .select_prev => void,
.select_scroll => Scroll,
@@ -111,38 +122,20 @@ pub const Option = enum(c_int) {
}
};
/// 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 terminal_wrapper = 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 = terminal_wrapper,
.search = search,
};
// Store the search in the terminal so that when the terminal is
@@ -152,7 +145,6 @@ pub fn new(
wrapper,
{},
) catch {
wrapper.search.deinit(terminal_wrapper.terminal);
alloc.destroy(wrapper);
return .out_of_memory;
};
@@ -165,11 +157,11 @@ pub fn free(search_: Search) callconv(lib.calling_conv) void {
const wrapper = search_ orelse return;
if (wrapper.terminal) |terminal_wrapper| {
_ = terminal_wrapper.searches.swapRemove(wrapper);
wrapper.search.deinit(terminal_wrapper.terminal);
if (wrapper.search) |*s| s.deinit(terminal_wrapper.terminal);
} else {
// The terminal was freed first. Tracked state died with it,
// so only search-owned memory is freed.
wrapper.search.deinit(null);
if (wrapper.search) |*s| s.deinit(null);
}
const alloc = wrapper.alloc;
alloc.destroy(wrapper);
@@ -180,36 +172,38 @@ pub fn tick(
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());
if (wrapper.search) |*s| _ = s.tick();
if (out_status) |out| out.* = wrapper.status();
return .success;
}
pub fn feed(search_: Search) callconv(lib.calling_conv) Result {
const wrapper = search_ orelse return .invalid_value;
const t = wrapper.zigTerminal() orelse return .invalid_value;
const s = if (wrapper.search) |*s| s else return .success;
// 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(t, true);
s.feed(t, true);
return .success;
}
pub fn run(search_: Search) callconv(lib.calling_conv) Result {
const wrapper = search_ orelse return .invalid_value;
const t = wrapper.zigTerminal() orelse return .invalid_value;
const s = if (wrapper.search) |*s| s else return .success;
// 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(t, true);
s.feed(t, true);
while (true) {
switch (wrapper.search.status()) {
switch (s.status()) {
.complete => return .success,
.feed_required => wrapper.search.feed(t, true),
.running => _ = wrapper.search.tick(),
.feed_required => s.feed(t, true),
.running => _ = s.tick(),
}
}
}
@@ -242,12 +236,46 @@ fn setTyped(
) Result {
const wrapper = search_ orelse return .invalid_value;
switch (option) {
.needle => {
// The needle touches the terminal (replacing or clearing
// an active search releases tracked pins through it), so
// it requires the terminal to still be alive.
const t = wrapper.zigTerminal() orelse return .invalid_value;
// NULL and empty both clear the needle, returning the
// search to idle. This matches the internal engine, where
// an empty needle stops the search.
const v = value orelse return clearNeedle(wrapper, t);
if (v.len == 0) return clearNeedle(wrapper, t);
if (@intFromPtr(v.ptr) == 0) return .invalid_value;
const bytes = v.ptr[0..v.len];
// Setting the current needle again keeps existing results,
// using the same ASCII case-insensitive comparison as
// matching. This is what a find bar wants on resubmit.
if (wrapper.search) |*s| {
if (std.ascii.eqlIgnoreCase(s.needle(), bytes)) {
return .success;
}
}
// Create the replacement before dropping the current
// search so an allocation failure keeps existing state.
const replacement = TerminalSearch.init(
wrapper.alloc,
bytes,
) catch return .out_of_memory;
if (wrapper.search) |*s| s.deinit(t);
wrapper.search = replacement;
},
.select_next, .select_prev => {
// The value is reserved for future use and must be NULL.
if (value != null) return .invalid_value;
const t = wrapper.zigTerminal() orelse return .invalid_value;
const selected = wrapper.search.select(
const s = if (wrapper.search) |*s| s else return .no_value;
const selected = s.select(
t,
switch (option) {
.select_next => .next,
@@ -273,6 +301,14 @@ fn setTyped(
return .success;
}
fn clearNeedle(wrapper: *SearchWrapper, t: *terminal_c.ZigTerminal) Result {
if (wrapper.search) |*s| {
s.deinit(t);
wrapper.search = null;
}
return .success;
}
pub fn get(
search_: Search,
data: Data,
@@ -322,33 +358,41 @@ fn getTyped(
out: *data.OutType(),
) Result {
const wrapper = search_ orelse return .invalid_value;
const search = &wrapper.search;
switch (data) {
.status => out.* = .fromZig(search.status()),
.status => out.* = wrapper.status(),
.needle => out.* = .init(search.needle()),
.needle => {
const s = if (wrapper.search) |*s| s else return .no_value;
out.* = .init(s.needle());
},
.total_matches => out.* = if (search.activeScreenSearch()) |ss|
ss.matchesLen()
else
0,
.total_matches => out.* = total: {
const s = if (wrapper.search) |*s| s else break :total 0;
const ss = s.activeScreenSearch() orelse break :total 0;
break :total ss.matchesLen();
},
.selected_index => {
const ss = search.activeScreenSearch() orelse return .no_value;
const s = if (wrapper.search) |*s| s else return .no_value;
const ss = s.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 s = if (wrapper.search) |*s| s else return .no_value;
const ss = s.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;
const ss = if (wrapper.search) |*s|
s.activeScreenSearch()
else
null;
const total = if (ss) |v| v.matchesLen() else 0;
if (out.cap < total) {
out.len = total;
return .out_of_space;
@@ -363,8 +407,10 @@ fn getTyped(
},
.viewport_matches => {
const matches = search.viewportMatches() catch
return .out_of_memory;
const matches: []const FlattenedHighlight = if (wrapper.search) |*s|
s.viewportMatches() catch return .out_of_memory
else
&.{};
if (out.cap < matches.len) {
out.len = matches.len;
return .out_of_space;
@@ -395,6 +441,19 @@ fn testString(str: []const u8) lib.String {
return .init(str);
}
fn testNewSearch(terminal: terminal_c.Terminal, needle_str: []const u8) !Search {
var search: Search = null;
try testing.expectEqual(Result.success, new(
&lib.alloc.test_allocator,
&search,
terminal,
));
errdefer free(search);
const needle_value = testString(needle_str);
try testing.expectEqual(Result.success, set(search, .needle, &needle_value));
return search;
}
test "search lifecycle and run to complete" {
var terminal: terminal_c.Terminal = null;
try testing.expectEqual(Result.success, terminal_c.new(
@@ -407,19 +466,12 @@ test "search lifecycle and run to complete" {
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,
));
const search: Search = try testNewSearch(terminal, "Fizz");
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.
// Right after the needle is set the 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);
@@ -448,14 +500,7 @@ test "search tick reports feed required before first feed" {
));
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,
));
const search: Search = try testNewSearch(terminal, "Fizz");
defer free(search);
var status: Status = .complete;
@@ -478,14 +523,7 @@ test "search feed after write updates results" {
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,
));
const search: Search = try testNewSearch(terminal, "Fizz");
defer free(search);
try testing.expectEqual(Result.success, run(search));
@@ -513,14 +551,7 @@ test "search alt screen flip and return retains results" {
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,
));
const search: Search = try testNewSearch(terminal, "Fizz");
defer free(search);
try testing.expectEqual(Result.success, run(search));
@@ -558,14 +589,7 @@ test "search select wraps in both directions" {
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,
));
const search: Search = try testNewSearch(terminal, "Fizz");
defer free(search);
try testing.expectEqual(Result.success, run(search));
@@ -607,14 +631,7 @@ test "search select with no matches returns no value" {
));
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,
));
const search: Search = try testNewSearch(terminal, "Fizz");
defer free(search);
try testing.expectEqual(Result.no_value, set(search, .select_next, null));
@@ -631,14 +648,7 @@ test "search select rejects a non-null reserved value" {
));
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,
));
const search: Search = try testNewSearch(terminal, "Fizz");
defer free(search);
const bogus: c_int = 0;
@@ -656,14 +666,7 @@ test "search select scroll option" {
));
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,
));
const search: Search = try testNewSearch(terminal, "Fizz");
defer free(search);
var scroll: Scroll = .none;
@@ -697,14 +700,7 @@ test "search selection dropped when reset prunes results" {
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,
));
const search: Search = try testNewSearch(terminal, "Fizz");
defer free(search);
try testing.expectEqual(Result.success, run(search));
try testing.expectEqual(Result.success, set(search, .select_next, null));
@@ -736,14 +732,7 @@ test "search matches buffer semantics" {
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,
));
const search: Search = try testNewSearch(terminal, "Fizz");
defer free(search);
try testing.expectEqual(Result.success, run(search));
@@ -792,14 +781,7 @@ test "search get_multi returns first failing index" {
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,
));
const search: Search = try testNewSearch(terminal, "Fizz");
defer free(search);
try testing.expectEqual(Result.success, run(search));
@@ -845,30 +827,121 @@ test "search new validates options" {
var search: Search = null;
// Empty needle is invalid.
const empty: Options = .{ .needle = testString("") };
// NULL out and NULL terminal are invalid.
try testing.expectEqual(Result.invalid_value, new(
&lib.alloc.test_allocator,
null,
terminal,
));
try testing.expectEqual(Result.invalid_value, new(
&lib.alloc.test_allocator,
&search,
terminal,
&empty,
null,
));
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(
test "search without a needle is idle" {
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;
try testing.expectEqual(Result.success, new(
&lib.alloc.test_allocator,
&search,
terminal,
null,
));
try testing.expectEqual(Result.invalid_value, new(
defer free(search);
// With nothing to look for, the search is complete and empty, and
// driving it is a harmless no-op.
var status: Status = .running;
try testing.expectEqual(Result.success, get(search, .status, &status));
try testing.expectEqual(Status.complete, status);
try testing.expectEqual(Result.success, feed(search));
try testing.expectEqual(Result.success, run(search));
try testing.expectEqual(Result.success, tick(search, &status));
try testing.expectEqual(Status.complete, status);
var needle_out: lib.String = undefined;
try testing.expectEqual(Result.no_value, get(search, .needle, &needle_out));
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, set(search, .select_next, null));
var buf: selection_c.CSelectionBuffer = .{};
try testing.expectEqual(Result.success, get(search, .matches, &buf));
try testing.expectEqual(@as(usize, 0), buf.len);
buf = .{};
try testing.expectEqual(Result.success, get(search, .viewport_matches, &buf));
try testing.expectEqual(@as(usize, 0), buf.len);
// Clearing an already-clear needle is fine.
try testing.expectEqual(Result.success, set(search, .needle, null));
}
test "search needle change and clear" {
var terminal: terminal_c.Terminal = null;
try testing.expectEqual(Result.success, terminal_c.new(
&lib.alloc.test_allocator,
&search,
null,
&opts,
&terminal,
10,
4,
));
defer terminal_c.free(terminal);
terminal_c.vt_write(terminal, "Fizz\r\nBuzz", 10);
const search: Search = try testNewSearch(terminal, "Fizz");
defer free(search);
try testing.expectEqual(Result.success, run(search));
try testing.expectEqual(Result.success, set(search, .select_next, null));
var total: usize = 0;
try testing.expectEqual(Result.success, get(search, .total_matches, &total));
try testing.expectEqual(@as(usize, 1), total);
// Setting the same needle again (any ASCII case) keeps existing
// results and the selection.
const same = testString("fIZZ");
try testing.expectEqual(Result.success, set(search, .needle, &same));
var needle_out: lib.String = undefined;
try testing.expectEqual(Result.success, get(search, .needle, &needle_out));
try testing.expectEqualStrings("Fizz", needle_out.ptr[0..needle_out.len]);
var idx: usize = 999;
try testing.expectEqual(Result.success, get(search, .selected_index, &idx));
try testing.expectEqual(@as(usize, 0), idx);
// Changing the needle restarts the search from scratch.
const changed = testString("Buzz");
try testing.expectEqual(Result.success, set(search, .needle, &changed));
var status: Status = .complete;
try testing.expectEqual(Result.success, get(search, .status, &status));
try testing.expectEqual(Status.feed_required, status);
try testing.expectEqual(Result.no_value, get(search, .selected_index, &idx));
try testing.expectEqual(Result.success, run(search));
try testing.expectEqual(Result.success, get(search, .total_matches, &total));
try testing.expectEqual(@as(usize, 1), total);
try testing.expectEqual(Result.success, get(search, .needle, &needle_out));
try testing.expectEqualStrings("Buzz", needle_out.ptr[0..needle_out.len]);
// An empty needle clears the search, same as NULL.
const empty = testString("");
try testing.expectEqual(Result.success, set(search, .needle, &empty));
try testing.expectEqual(Result.no_value, get(search, .needle, &needle_out));
try testing.expectEqual(Result.success, get(search, .status, &status));
try testing.expectEqual(Status.complete, status);
try testing.expectEqual(Result.success, get(search, .total_matches, &total));
try testing.expectEqual(@as(usize, 0), total);
}
test "search free after terminal free" {
@@ -882,14 +955,7 @@ test "search free after terminal free" {
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,
));
const search: Search = try testNewSearch(terminal, "Fizz");
// Run and select so the search holds tracked pins within the
// terminal's page storage.
@@ -902,6 +968,8 @@ test "search free after terminal free" {
try testing.expectEqual(Result.invalid_value, feed(search));
try testing.expectEqual(Result.invalid_value, run(search));
try testing.expectEqual(Result.invalid_value, set(search, .select_next, null));
const needle_value = testString("Buzz");
try testing.expectEqual(Result.invalid_value, set(search, .needle, &needle_value));
// Reads that only touch search-owned state still answer.
var needle_out: lib.String = undefined;
@@ -927,21 +995,8 @@ test "search freed before terminal detaches from the registry" {
// Create two searches and free one while the terminal is alive.
// The freed search must be unregistered so the later terminal free
// only detaches the survivor.
const opts: Options = .{ .needle = testString("Fizz") };
var a: Search = null;
try testing.expectEqual(Result.success, new(
&lib.alloc.test_allocator,
&a,
terminal,
&opts,
));
var b: Search = null;
try testing.expectEqual(Result.success, new(
&lib.alloc.test_allocator,
&b,
terminal,
&opts,
));
const a: Search = try testNewSearch(terminal, "Fizz");
const b: Search = try testNewSearch(terminal, "Fizz");
defer free(b);
try testing.expectEqual(Result.success, run(a));

View File

@@ -204,7 +204,6 @@ const type_decls = [_]TypeDecl{
.initStruct("GhosttyRenderStateColors", render.Colors),
.initStruct("GhosttyRenderStateCursor", render.Cursor),
.initStruct("GhosttyRenderStateRowSelection", render.RowSelection),
.initStruct("GhosttySearchOptions", search.Options),
.initStruct("GhosttySelection", selection.CSelection),
.initStruct("GhosttySelectionBuffer", selection.CSelectionBuffer),
.initStruct("GhosttySelectionGestureBehaviors", selection_gesture.Behaviors),