From 76d9fcefef592617360577c9208490dc44d04a65 Mon Sep 17 00:00:00 2001 From: Mitchell Hashimoto Date: Mon, 31 Aug 2026 14:08:07 -0700 Subject: [PATCH] libghostty: set the search needle via ghostty_search_set, drop GhosttySearchOptions --- example/c-vt-search/src/main.c | 23 +- include/ghostty/vt/search.h | 119 +++++----- src/terminal/c/search.zig | 399 +++++++++++++++++++-------------- src/terminal/c/types.zig | 1 - 4 files changed, 301 insertions(+), 241 deletions(-) diff --git a/example/c-vt-search/src/main.c b/example/c-vt-search/src/main.c index 9d15c9f8d..769946807 100644 --- a/example/c-vt-search/src/main.c +++ b/example/c-vt-search/src/main.c @@ -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; diff --git a/include/ghostty/vt/search.h b/include/ghostty/vt/search.h index c3b22c9d6..68aebcc4f 100644 --- a/include/ghostty/vt/search.h +++ b/include/ghostty/vt/search.h @@ -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 */ diff --git a/src/terminal/c/search.zig b/src/terminal/c/search.zig index e46c66509..c51378914 100644 --- a/src/terminal/c/search.zig +++ b/src/terminal/c/search.zig @@ -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)); diff --git a/src/terminal/c/types.zig b/src/terminal/c/types.zig index 690b70293..4184de822 100644 --- a/src/terminal/c/types.zig +++ b/src/terminal/c/types.zig @@ -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),