diff --git a/example/c-vt-search/README.md b/example/c-vt-search/README.md new file mode 100644 index 000000000..3d11a3153 --- /dev/null +++ b/example/c-vt-search/README.md @@ -0,0 +1,19 @@ +# Example: `ghostty-vt` Terminal Search + +This contains a simple example of how to use the `ghostty-vt` search +API from C. It writes content into a terminal, searches it for a +string, navigates between the matches like a find bar, and reads the +viewport matches an embedder would use to draw highlights. + +This uses a `build.zig` and `Zig` to build the C program so that we +can reuse a lot of our build logic and depend directly on our source +tree, but Ghostty emits a standard C library that can be used with any +C tooling. + +## Usage + +Run the program: + +```shell-session +zig build run +``` diff --git a/example/c-vt-search/build.zig b/example/c-vt-search/build.zig new file mode 100644 index 000000000..51ddac608 --- /dev/null +++ b/example/c-vt-search/build.zig @@ -0,0 +1,42 @@ +const std = @import("std"); + +pub fn build(b: *std.Build) void { + const target = b.standardTargetOptions(.{}); + const optimize = b.standardOptimizeOption(.{}); + + const run_step = b.step("run", "Run the app"); + + const exe_mod = b.createModule(.{ + .target = target, + .optimize = optimize, + }); + exe_mod.addCSourceFiles(.{ + .root = b.path("src"), + .files = &.{"main.c"}, + }); + + // You'll want to use a lazy dependency here so that ghostty is only + // downloaded if you actually need it. + if (b.lazyDependency("ghostty", .{ + // Setting simd to false will force a pure static build that + // doesn't even require libc, but it has a significant performance + // penalty. If your embedding app requires libc anyway, you should + // always keep simd enabled. + // .simd = false, + })) |dep| { + exe_mod.linkLibrary(dep.artifact("ghostty-vt")); + } + + // Exe + const exe = b.addExecutable(.{ + .name = "c_vt_search", + .root_module = exe_mod, + }); + b.installArtifact(exe); + + // Run + const run_cmd = b.addRunArtifact(exe); + run_cmd.step.dependOn(b.getInstallStep()); + if (b.args) |args| run_cmd.addArgs(args); + run_step.dependOn(&run_cmd.step); +} diff --git a/example/c-vt-search/build.zig.zon b/example/c-vt-search/build.zig.zon new file mode 100644 index 000000000..2882f49f3 --- /dev/null +++ b/example/c-vt-search/build.zig.zon @@ -0,0 +1,24 @@ +.{ + .name = .c_vt_search, + .version = "0.0.0", + .fingerprint = 0x91a25a7f6cd3ff58, + .minimum_zig_version = "0.15.1", + .dependencies = .{ + // Ghostty dependency. In reality, you'd probably use a URL-based + // dependency like the one showed (and commented out) below this one. + // We use a path dependency here for simplicity and to ensure our + // examples always test against the source they're bundled with. + .ghostty = .{ .path = "../../" }, + + // Example of what a URL-based dependency looks like: + // .ghostty = .{ + // .url = "https://github.com/ghostty-org/ghostty/archive/COMMIT.tar.gz", + // .hash = "N-V-__8AAMVLTABmYkLqhZPLXnMl-KyN38R8UVYqGrxqO36s", + // }, + }, + .paths = .{ + "build.zig", + "build.zig.zon", + "src", + }, +} diff --git a/example/c-vt-search/src/main.c b/example/c-vt-search/src/main.c new file mode 100644 index 000000000..769946807 --- /dev/null +++ b/example/c-vt-search/src/main.c @@ -0,0 +1,117 @@ +#include +#include +#include +#include +#include + +//! [search-main] +int main() { + // Create a terminal and fill it with some content to search. + GhosttyTerminal terminal; + GhosttyResult result = ghostty_terminal_new(NULL, &terminal, 80, 24); + assert(result == GHOSTTY_SUCCESS); + + const char *lines[] = { + "$ make test\r\n", + "compiling module A... ok\r\n", + "compiling module B... error: missing semicolon\r\n", + "linking... error: undefined symbol\r\n", + "$ grep -n ERROR build.log\r\n", + }; + for (size_t i = 0; i < sizeof(lines) / sizeof(lines[0]); i++) { + ghostty_terminal_vt_write(terminal, (const uint8_t *)lines[i], + strlen(lines[i])); + } + + // 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); + 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 + // ghostty_search_tick() and ghostty_search_feed() with their event + // loop, but for a one-shot search we can just run it to completion. + result = ghostty_search_run(search); + assert(result == GHOSTTY_SUCCESS); + + // The total match count, for find bar text like "1 of 3". + size_t total = 0; + result = ghostty_search_get(search, GHOSTTY_SEARCH_DATA_TOTAL_MATCHES, + &total); + assert(result == GHOSTTY_SUCCESS); + printf("%zu matches for \"error\"\n", total); + + // The user pressed Enter, so select the next match. Selection starts + // at the newest match, moves toward older content, and wraps around. + // This scrolls the viewport to the match if it isn't visible, per + // the GHOSTTY_SEARCH_OPT_SELECT_SCROLL policy. + while (true) { + result = ghostty_search_set(search, GHOSTTY_SEARCH_OPT_SELECT_NEXT, NULL); + if (result != GHOSTTY_SUCCESS) break; + + // Read the selection state in one call. Index 0 is the newest + // match, so a "k of n" find bar renders index + 1. + size_t idx = 0; + GhosttySelection match = GHOSTTY_INIT_SIZED(GhosttySelection); + const GhosttySearchData keys[] = { + GHOSTTY_SEARCH_DATA_SELECTED_INDEX, + GHOSTTY_SEARCH_DATA_SELECTED_MATCH, + }; + void *values[] = { &idx, &match }; + result = ghostty_search_get_multi( + search, sizeof(keys) / sizeof(keys[0]), keys, values, NULL); + assert(result == GHOSTTY_SUCCESS); + printf("selected %zu of %zu\n", idx + 1, total); + + // Wrapped back around to the first match: stop. + if (idx + 1 == total) break; + } + + // Each frame while the find bar is open, feed to catch up with any + // terminal changes and then read the viewport matches to draw + // highlights. The list can include matches just past the viewport + // when they share a page with it, so convert each endpoint to + // viewport coordinates and skip matches outside the visible rows. + result = ghostty_search_feed(search); + assert(result == GHOSTTY_SUCCESS); + + GhosttySelection viewport_storage[64]; + GhosttySelectionBuffer viewport = { + .ptr = viewport_storage, + .cap = sizeof(viewport_storage) / sizeof(viewport_storage[0]), + }; + result = ghostty_search_get(search, GHOSTTY_SEARCH_DATA_VIEWPORT_MATCHES, + &viewport); + assert(result == GHOSTTY_SUCCESS); + for (size_t i = 0; i < viewport.len; i++) { + GhosttyPointCoordinate start, end; + if (ghostty_terminal_point_from_grid_ref( + terminal, &viewport_storage[i].start, GHOSTTY_POINT_TAG_VIEWPORT, + &start) != GHOSTTY_SUCCESS) continue; + if (ghostty_terminal_point_from_grid_ref( + terminal, &viewport_storage[i].end, GHOSTTY_POINT_TAG_VIEWPORT, + &end) != GHOSTTY_SUCCESS) continue; + if (start.y >= 24 || end.y >= 24) continue; + + // A real embedder draws a highlight rect from start to end here. + printf("highlight rows %u-%u, cols %u-%u\n", + (unsigned)start.y, (unsigned)end.y, + (unsigned)start.x, (unsigned)end.x); + } + + // 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; +} +//! [search-main] diff --git a/include/ghostty/vt.h b/include/ghostty/vt.h index 57f0a0e65..cfa1017e7 100644 --- a/include/ghostty/vt.h +++ b/include/ghostty/vt.h @@ -32,6 +32,7 @@ * - @ref render "Render State" - Incremental render state updates for custom renderers * - @ref formatter "Formatter" - Format terminal content as plain text, VT sequences, or HTML * - @ref snapshot "Terminal Snapshot" - Encode and incrementally restore terminal state + * - @ref search "Search" - Search terminal contents, including scrollback * - @ref osc "OSC Parser" - Parse OSC (Operating System Command) sequences * - @ref sgr "SGR Parser" - Parse SGR (Select Graphic Rendition) sequences * - @ref paste "Paste" - Paste into a terminal, validate and encode paste data @@ -59,6 +60,7 @@ * - @ref c-vt-grid-traverse/src/main.c - Grid traversal example using grid refs * - @ref c-vt-grid-ref-tracked/src/main.c - Tracked grid ref example * - @ref c-vt-compression/src/main.c - Idle scrollback compression example + * - @ref c-vt-search/src/main.c - Terminal search example * */ @@ -125,6 +127,12 @@ * PNG decoder callback and send a Kitty Graphics Protocol image. */ +/** @example c-vt-search/src/main.c + * This example demonstrates how to search terminal contents for a + * string, navigate between the matches like a find bar, and read the + * viewport matches used to draw highlights. + */ + #ifndef GHOSTTY_VT_H #define GHOSTTY_VT_H @@ -156,6 +164,7 @@ extern "C" { #include #include #include +#include #include #include #include diff --git a/include/ghostty/vt/search.h b/include/ghostty/vt/search.h new file mode 100644 index 000000000..68aebcc4f --- /dev/null +++ b/include/ghostty/vt/search.h @@ -0,0 +1,501 @@ +/** + * @file search.h + * + * Search terminal contents, including scrollback, for a string. + */ + +#ifndef GHOSTTY_VT_SEARCH_H +#define GHOSTTY_VT_SEARCH_H + +#include +#include +#include +#include + +#ifdef __cplusplus +extern "C" { +#endif + +/** @defgroup search Search + * + * Search a terminal for a string, covering the active area and + * scrollback of both the primary and alternate screens. + * + * 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 + * + * Searching a large scrollback takes time, so the work is split into + * small steps the caller drives so that the caller can control + * performance more directly: + * + * - ghostty_search_tick() makes a bounded amount of progress on data + * the search has already copied. It never touches the terminal, + * meaning it can be safely called from a thread. + * - ghostty_search_feed() reads the terminal to copy in more data and + * pick up terminal changes. Feeding is the only way the search + * learns that the terminal changed, so keep feeding periodically + * while the search is in use. This requires exclusive terminal access. + * - ghostty_search_run() is a blocking convenience that feeds and + * ticks until the search is caught up. + * + * GHOSTTY_SEARCH_STATUS_COMPLETE means the search is caught up with + * the terminal as of the last feed. It never means finished forever, + * since later terminal writes require another feed to be seen. + * + * ## Matches are selections + * + * Every match is returned as a GhosttySelection snapshot with + * rectangle set to false, so the existing selection APIs all work on + * matches: ghostty_terminal_selection_format_buf() to copy the + * matched text, ghostty_terminal_point_from_grid_ref() with + * GHOSTTY_POINT_TAG_VIEWPORT to position highlight rectangles, + * ghostty_terminal_selection_contains() for hit testing, and + * ghostty_terminal_set() with GHOSTTY_TERMINAL_OPT_SELECTION to make + * a match the terminal's selection. + * + * Returned matches follow the usual snapshot lifetime rules: they are + * only valid until the next operation that modifies the terminal, + * including ghostty_terminal_vt_write(), resize, reset, and free. + * Read matches after a feed, use them before the terminal changes + * again, and re-read them rather than caching them. The selected + * match is kept accurate internally across terminal changes, so the + * safe way to follow a match is to re-read + * GHOSTTY_SEARCH_DATA_SELECTED_MATCH after each feed. + * + * ## Lifetime + * + * The search borrows the terminal it was created with and never frees + * it. Any number of searches, alongside other terminal readers such + * as formatters and render states, may share one terminal. + * + * The search and its terminal can be freed in either order. Freeing + * the search first releases tracked state it holds within the + * terminal. If the terminal is freed first, the search detects this: + * calls that need the terminal return GHOSTTY_INVALID_VALUE, reads + * return whatever the search last saw, and ghostty_search_free() + * releases only search-owned memory. A search cannot be rebound, so + * searching another terminal means creating a new search. + * + * ## Threading + * + * The library creates no threads. Calls on one GhosttySearch are not + * safe to make concurrently with each other, so the caller must + * serialize them. + * + * Functions that touch the terminal (ghostty_search_new(), + * ghostty_search_feed(), ghostty_search_run(), ghostty_search_set() + * 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 + * and is safe to call while another thread modifies the terminal. + * This split is how Ghostty runs search on a background thread: tick + * freely, and take the terminal lock only to feed. Reading returned + * match values is always safe, but passing them to APIs that take the + * terminal follows the terminal serialization rule above. + * + * ## Example + * + * @snippet c-vt-search/src/main.c search-main + * + * @{ + */ + +/** + * Progress state of a search. + * + * @ingroup search + */ +typedef enum GHOSTTY_ENUM_TYPED { + /** + * ghostty_search_tick() can make progress without terminal access. + */ + GHOSTTY_SEARCH_STATUS_RUNNING = 0, + + /** + * 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, + + /** + * 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. A search with no needle set also reports + * complete, since there is nothing to look for. + */ + GHOSTTY_SEARCH_STATUS_COMPLETE = 2, + + GHOSTTY_SEARCH_STATUS_MAX_VALUE = GHOSTTY_ENUM_MAX_VALUE, +} GhosttySearchStatus; + +/** + * Scroll policy applied when a match becomes selected via + * GHOSTTY_SEARCH_OPT_SELECT_NEXT or GHOSTTY_SEARCH_OPT_SELECT_PREV. + * + * @ingroup search + */ +typedef enum GHOSTTY_ENUM_TYPED { + /** Scroll the viewport so the match is visible, only if it is not + * already visible. This is the default. */ + GHOSTTY_SEARCH_SCROLL_IF_NEEDED = 0, + + /** Never scroll the viewport. */ + GHOSTTY_SEARCH_SCROLL_NONE = 1, + + GHOSTTY_SEARCH_SCROLL_MAX_VALUE = GHOSTTY_ENUM_MAX_VALUE, +} GhosttySearchScroll; + +/** + * Data fields readable with ghostty_search_get(). The output value + * type is documented per field. + * + * All reads reflect the terminal's active screen as of the last feed. + * When the running application switches to the alternate screen, the + * next feed switches counts, matches, and selection to that screen's + * results. Primary screen results, including completed scrollback + * searches, are retained and restored on the way back. + * + * @ingroup search + */ +typedef enum GHOSTTY_ENUM_TYPED { + /** Current search status: GhosttySearchStatus*. */ + GHOSTTY_SEARCH_DATA_STATUS = 0, + + /** + * The needle this search is looking for: GhosttyString*. The bytes + * 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, + + /** + * Total matches found so far on the active screen: size_t*. Zero + * until the first feed. + */ + GHOSTTY_SEARCH_DATA_TOTAL_MATCHES = 2, + + /** + * Index of the selected match: size_t*. This indexes the newest to + * oldest ordering of GHOSTTY_SEARCH_DATA_MATCHES, where 0 is the + * newest match, so a "k of n" find bar renders index + 1 of + * GHOSTTY_SEARCH_DATA_TOTAL_MATCHES. Returns GHOSTTY_NO_VALUE when + * nothing is selected. + */ + GHOSTTY_SEARCH_DATA_SELECTED_INDEX = 3, + + /** + * The selected match: GhosttySelection*. This is an untracked + * snapshot with standard GhosttySelection lifetime rules. Returns + * GHOSTTY_NO_VALUE when nothing is selected. + */ + GHOSTTY_SEARCH_DATA_SELECTED_MATCH = 4, + + /** + * All matches on the active screen, ordered newest to oldest, from + * the bottom of the active area up through scrollback: + * GhosttySelectionBuffer*. Set ptr to NULL with cap 0 to query the + * required capacity. An undersized buffer returns + * GHOSTTY_OUT_OF_SPACE with the required capacity in len. + */ + GHOSTTY_SEARCH_DATA_MATCHES = 5, + + /** + * Matches on the pages covering the viewport, for drawing highlight + * rectangles: GhosttySelectionBuffer*. The list is computed during + * feeds and cached, so it reflects the viewport as of the last + * feed. + * + * Matches are found a page at a time, so the list can include + * matches slightly outside the visible viewport when they share a + * page with it. Ghostty's own renderer behaves the same way. + * Converting each match to viewport coordinates with + * ghostty_terminal_point_from_grid_ref() clips this naturally: skip + * matches that fail the conversion or whose row is beyond the + * visible row count. + */ + GHOSTTY_SEARCH_DATA_VIEWPORT_MATCHES = 6, + + /** Current scroll policy: GhosttySearchScroll*. */ + GHOSTTY_SEARCH_DATA_SELECT_SCROLL = 7, + + GHOSTTY_SEARCH_DATA_MAX_VALUE = GHOSTTY_ENUM_MAX_VALUE, +} GhosttySearchData; + +/** + * Options writable with ghostty_search_set(). The value type, and + * what a NULL value means, is documented per option. + * + * @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 + * from the prompt usually wants. Wraps around past the oldest + * match. + * + * The value must be NULL. It is reserved for future use. + * + * This catches up with the terminal first, so it is safe to call at + * any time relative to feeds. The viewport scrolls to the newly + * selected match according to GHOSTTY_SEARCH_OPT_SELECT_SCROLL. + * This reads the terminal, so the caller must serialize it with all + * other access to the same terminal. Returns GHOSTTY_NO_VALUE when + * there are no matches. + */ + 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 = 2, + + /** + * Set the scroll policy applied by the select options: const + * GhosttySearchScroll*. The policy persists until changed. A NULL + * 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 = 3, + + GHOSTTY_SEARCH_OPT_MAX_VALUE = GHOSTTY_ENUM_MAX_VALUE, +} GhosttySearchOption; + +/** + * 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(). + * + * 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 + * @return GHOSTTY_SUCCESS on success, GHOSTTY_INVALID_VALUE if + * 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); + +/** + * Free a search. + * + * If the bound terminal is still alive, this releases tracked state + * the search holds within it, so the caller must serialize this call + * with all other access to the same terminal. If the terminal was + * already freed, the search has been detached and this releases only + * search-owned memory. Passing NULL is allowed and is a no-op. + * + * @param search Search handle to free + * + * @ingroup search + */ +GHOSTTY_API void ghostty_search_free(GhosttySearch search); + +/** + * Make a bounded amount of search progress. + * + * This only works on data the search has already copied and never + * reads the terminal, so it is safe to call while another thread + * modifies the terminal. Call it in a loop while the status is + * GHOSTTY_SEARCH_STATUS_RUNNING. When the status becomes + * GHOSTTY_SEARCH_STATUS_FEED_REQUIRED, call ghostty_search_feed() to + * unblock it. + * + * @param search Search handle (NULL returns GHOSTTY_INVALID_VALUE) + * @param[out] out_status Receives the status after the tick (may be NULL) + * @return GHOSTTY_SUCCESS on success, or GHOSTTY_INVALID_VALUE if + * search is NULL + * + * @ingroup search + */ +GHOSTTY_API GhosttyResult ghostty_search_tick( + GhosttySearch search, + GhosttySearchStatus* out_status); + +/** + * Read the terminal to update the search. + * + * Each feed catches the search up with the terminal: it reconciles + * the tracked screens against the live ones, re-scans the active + * area, refreshes the viewport match list, gives the scrollback + * searcher its next chunk of data, and prunes results that scrollback + * eviction invalidated. Feeding is also the only way the search + * learns about terminal changes, so keep feeding periodically while + * the search is in use, even after it reports complete. + * + * This reads the terminal, so the caller must serialize it with all + * other access to the same terminal. Each call does a bounded amount + * of work so that any caller-held terminal lock is held only briefly. + * + * @param search Search handle (NULL returns GHOSTTY_INVALID_VALUE) + * @return GHOSTTY_SUCCESS on success, or GHOSTTY_INVALID_VALUE if + * search is NULL or the terminal was freed + * + * @ingroup search + */ +GHOSTTY_API GhosttyResult ghostty_search_feed(GhosttySearch search); + +/** + * Feed and tick until the search is caught up with the terminal. + * + * This is a blocking convenience for one-shot and single-threaded + * embedders. It always performs at least one feed, so it also picks + * up any terminal changes since the last feed, then loops until the + * status is GHOSTTY_SEARCH_STATUS_COMPLETE. Searching a large + * scrollback can take a while, so interactive embedders should drive + * ghostty_search_tick() and ghostty_search_feed() themselves. + * + * This reads the terminal for the entire call, so the caller must + * serialize it with all other access to the same terminal. + * + * @param search Search handle (NULL returns GHOSTTY_INVALID_VALUE) + * @return GHOSTTY_SUCCESS on success, or GHOSTTY_INVALID_VALUE if + * search is NULL or the terminal was freed + * + * @ingroup search + */ +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 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) + * @param option Option to write + * @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 + * 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 + */ +GHOSTTY_API GhosttyResult ghostty_search_set( + GhosttySearch search, + GhosttySearchOption option, + const void* value); + +/** + * Read a data field from a search. + * + * The output value type depends on data and is documented by + * GhosttySearchData. This never reads the terminal, so it is safe to + * call while another thread modifies the terminal. Returned + * selections are untracked snapshots with standard GhosttySelection + * lifetime rules. + * + * @param search Search handle (NULL returns GHOSTTY_INVALID_VALUE) + * @param data Data field to read + * @param value Output pointer whose type depends on data + * @return GHOSTTY_SUCCESS on success, GHOSTTY_NO_VALUE if the + * requested data has no value, GHOSTTY_OUT_OF_SPACE if a + * provided GhosttySelectionBuffer is too small (required + * capacity in its len), GHOSTTY_OUT_OF_MEMORY if collecting + * viewport matches fails, or GHOSTTY_INVALID_VALUE if search, + * data, or value is invalid + * + * @ingroup search + */ +GHOSTTY_API GhosttyResult ghostty_search_get( + GhosttySearch search, + GhosttySearchData data, + void* value); + +/** + * Read multiple data fields from a search in a single call. + * + * This is an optimization over calling ghostty_search_get() multiple + * times. Each entry in values must point to storage of the type + * documented by the corresponding GhosttySearchData key. + * + * If any individual read fails, the function returns that error and + * writes the index of the failing key to out_written when out_written + * is non-NULL. Earlier keys have already been written. On success, + * out_written receives count when non-NULL. A too-small + * GhosttySelectionBuffer stops the batch with GHOSTTY_OUT_OF_SPACE at + * that key's index with the required capacity in its len, so order + * buffer-valued keys after scalar keys. + * + * @param search Search handle (NULL returns GHOSTTY_INVALID_VALUE) + * @param count Number of data fields to read + * @param keys Data fields to read (must not be NULL) + * @param values Output pointers corresponding to keys (must not be NULL) + * @param out_written Optional number of fields read, or failing index + * on error + * @return GHOSTTY_SUCCESS on success, or the first failing read's + * result + * + * @ingroup search + */ +GHOSTTY_API GhosttyResult ghostty_search_get_multi( + GhosttySearch search, + size_t count, + const GhosttySearchData* keys, + void** values, + size_t* out_written); + +/** @} */ + +#ifdef __cplusplus +} +#endif + +#endif /* GHOSTTY_VT_SEARCH_H */ diff --git a/include/ghostty/vt/selection.h b/include/ghostty/vt/selection.h index 3b926aab6..27d3c47b2 100644 --- a/include/ghostty/vt/selection.h +++ b/include/ghostty/vt/selection.h @@ -116,6 +116,29 @@ typedef struct { bool rectangle; } GhosttySelection; +/** + * 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. APIs that fill this type set len to + * the number of entries written on GHOSTTY_SUCCESS, or to the required entry + * capacity on GHOSTTY_OUT_OF_SPACE. + * + * @ingroup selection + */ +typedef struct { + /** Destination buffer for selections. May be NULL when cap is 0 to query + * the required capacity. */ + GhosttySelection* ptr; + + /** Capacity of ptr in entries. */ + size_t cap; + + /** Entries written on success, or required entry capacity on + * GHOSTTY_OUT_OF_SPACE. */ + size_t len; +} GhosttySelectionBuffer; + /** * Options for deriving a word selection from a terminal grid reference. * diff --git a/include/ghostty/vt/types.h b/include/ghostty/vt/types.h index 2184ec95c..8c5f35f21 100644 --- a/include/ghostty/vt/types.h +++ b/include/ghostty/vt/types.h @@ -188,6 +188,19 @@ typedef struct GhosttyRenderStateRowIteratorImpl* GhosttyRenderStateRowIterator; */ typedef struct GhosttyRenderStateRowCellsImpl* GhosttyRenderStateRowCells; +/** + * Opaque handle to a terminal search. + * + * A search is bound to the terminal it was created with. It borrows the + * terminal, so it never frees it, and the search must be freed with + * ghostty_search_free(). If the terminal is freed first, the search + * detects this: calls that need the terminal fail cleanly and the + * search can still be freed. + * + * @ingroup search + */ +typedef struct GhosttySearchImpl* GhosttySearch; + /** * Opaque handle to an SGR parser instance. * diff --git a/src/lib_vt.zig b/src/lib_vt.zig index 2a45d1cd4..162798894 100644 --- a/src/lib_vt.zig +++ b/src/lib_vt.zig @@ -349,6 +349,16 @@ comptime { @export(&c.selection_gesture_event_free, .{ .name = "ghostty_selection_gesture_event_free" }); @export(&c.selection_gesture_event_set, .{ .name = "ghostty_selection_gesture_event_set" }); } + if (features.search) { + @export(&c.search_new, .{ .name = "ghostty_search_new" }); + @export(&c.search_free, .{ .name = "ghostty_search_free" }); + @export(&c.search_tick, .{ .name = "ghostty_search_tick" }); + @export(&c.search_feed, .{ .name = "ghostty_search_feed" }); + @export(&c.search_run, .{ .name = "ghostty_search_run" }); + @export(&c.search_set, .{ .name = "ghostty_search_set" }); + @export(&c.search_get, .{ .name = "ghostty_search_get" }); + @export(&c.search_get_multi, .{ .name = "ghostty_search_get_multi" }); + } // Selections are expressed in grid references, so the untracked // reference constructors are required by both features. if (features.grid_introspection or features.selection) { diff --git a/src/terminal/build_options.zig b/src/terminal/build_options.zig index 8fc2c3e90..f16cbf42f 100644 --- a/src/terminal/build_options.zig +++ b/src/terminal/build_options.zig @@ -88,6 +88,15 @@ pub const Options = struct { /// `ghostty_terminal_selection_*`, `ghostty_selection_gesture_*`. selection: bool = true, + /// Terminal search: find matches for a string in the active + /// area and scrollback (ASCII case-insensitive), with results + /// that survive primary/alternate screen switches, resize, and + /// scrollback pruning, plus match selection with wrap-around + /// and viewport scrolling. Used to implement find bars. + /// + /// C API: `ghostty_search_*`. + search: bool = true, + /// The render state API: a coherent, update-in-place view of /// the visible screen (rows, cells, styles, cursor, colors, /// palette) designed to drive a renderer at frame rates. This diff --git a/src/terminal/c/main.zig b/src/terminal/c/main.zig index 562fe40e6..45b4dc47f 100644 --- a/src/terminal/c/main.zig +++ b/src/terminal/c/main.zig @@ -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; diff --git a/src/terminal/c/search.zig b/src/terminal/c/search.zig new file mode 100644 index 000000000..c51378914 --- /dev/null +++ b/src/terminal/c/search.zig @@ -0,0 +1,1009 @@ +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; + +pub const SearchWrapper = struct { + alloc: std.mem.Allocator, + + /// The terminal this search is bound to. This is borrowed, so the + /// search never frees it. If the terminal is freed first, it sets + /// this to null to detach the search: calls that need the terminal + /// then fail cleanly and free releases only search-owned memory. + terminal: terminal_c.Terminal, + + /// 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. + select_scroll: TerminalSearch.SelectScroll = .if_needed, + + /// The Zig terminal this search is bound to, or null if the + /// terminal was freed before the search. + fn zigTerminal(self: *const SearchWrapper) ?*terminal_c.ZigTerminal { + 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 +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) { + 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, + }; + } +}; + +pub fn new( + alloc_: ?*const CAllocator, + out_search: ?*Search, + terminal: terminal_c.Terminal, +) callconv(lib.calling_conv) Result { + const out = out_search orelse return .invalid_value; + out.* = null; + + const terminal_wrapper = terminal orelse return .invalid_value; + const alloc = lib.alloc.default(alloc_); + const wrapper = alloc.create(SearchWrapper) catch return .out_of_memory; + wrapper.* = .{ + .alloc = alloc, + .terminal = terminal_wrapper, + }; + + // Store the search in the terminal so that when the terminal is + // freed the search can be detached safely. + terminal_wrapper.searches.putNoClobber( + terminal_wrapper.terminal.gpa(), + wrapper, + {}, + ) catch { + alloc.destroy(wrapper); + return .out_of_memory; + }; + + out.* = wrapper; + return .success; +} + +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); + 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. + if (wrapper.search) |*s| s.deinit(null); + } + 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; + 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. + 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. + s.feed(t, true); + while (true) { + switch (s.status()) { + .complete => return .success, + .feed_required => s.feed(t, true), + .running => _ = s.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) { + .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 s = if (wrapper.search) |*s| s else return .no_value; + const selected = s.select( + t, + 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; +} + +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, + 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; + + switch (data) { + .status => out.* = wrapper.status(), + + .needle => { + const s = if (wrapper.search) |*s| s else return .no_value; + out.* = .init(s.needle()); + }, + + .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 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 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 = 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; + } + 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: []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; + } + 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); +} + +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( + &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); + + const search: Search = try testNewSearch(terminal, "Fizz"); + defer free(search); + + // 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); + + 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); + + const search: Search = try testNewSearch(terminal, "Fizz"); + 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); + + const search: Search = try testNewSearch(terminal, "Fizz"); + 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); + + const search: Search = try testNewSearch(terminal, "Fizz"); + 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); + + const search: Search = try testNewSearch(terminal, "Fizz"); + 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); + + const search: Search = try testNewSearch(terminal, "Fizz"); + 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); + + const search: Search = try testNewSearch(terminal, "Fizz"); + 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); + + const search: Search = try testNewSearch(terminal, "Fizz"); + 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); + + 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)); + + // 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); + + const search: Search = try testNewSearch(terminal, "Fizz"); + 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); + + const search: Search = try testNewSearch(terminal, "Fizz"); + 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; + + // 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, + null, + )); + try testing.expect(search == null); +} + +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, + )); + 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, + &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" { + var terminal: terminal_c.Terminal = null; + try testing.expectEqual(Result.success, terminal_c.new( + &lib.alloc.test_allocator, + &terminal, + 10, + 4, + )); + + terminal_c.vt_write(terminal, "Fizz\r\nFizz", 10); + + const search: Search = try testNewSearch(terminal, "Fizz"); + + // Run and select so the search holds tracked pins within the + // terminal's page storage. + try testing.expectEqual(Result.success, run(search)); + try testing.expectEqual(Result.success, set(search, .select_next, null)); + + // Free the terminal first. The search detaches: calls that need + // the terminal fail cleanly instead of touching freed memory. + terminal_c.free(terminal); + 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; + try testing.expectEqual(Result.success, get(search, .needle, &needle_out)); + try testing.expectEqualStrings("Fizz", needle_out.ptr[0..needle_out.len]); + const scroll: Scroll = .none; + try testing.expectEqual(Result.success, set(search, .select_scroll, &scroll)); + + // The search can still be freed, releasing only its own memory. + free(search); +} + +test "search freed before terminal detaches from the registry" { + 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); + + // 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 a: Search = try testNewSearch(terminal, "Fizz"); + const b: Search = try testNewSearch(terminal, "Fizz"); + defer free(b); + + try testing.expectEqual(Result.success, run(a)); + free(a); + try testing.expectEqual(Result.success, run(b)); +} + +test "search free null" { + free(null); +} diff --git a/src/terminal/c/selection.zig b/src/terminal/c/selection.zig index 6dcfcc070..77d518d1f 100644 --- a/src/terminal/c/selection.zig +++ b/src/terminal/c/selection.zig @@ -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), diff --git a/src/terminal/c/terminal.zig b/src/terminal/c/terminal.zig index 70d7ab849..5bf2c750c 100644 --- a/src/terminal/c/terminal.zig +++ b/src/terminal/c/terminal.zig @@ -24,6 +24,7 @@ const cell_c = @import("cell.zig"); const row_c = @import("row.zig"); const grid_ref_c = @import("grid_ref.zig"); const grid_ref_tracked_c = @import("grid_ref_tracked.zig"); +const search_c = @import("search.zig"); const selection_c = @import("selection.zig"); const style_c = @import("style.zig"); const color = @import("../color.zig"); @@ -113,6 +114,7 @@ const TerminalWrapper = struct { stream: Stream, effects: Effects = .{}, tracked_grid_refs: std.AutoArrayHashMapUnmanaged(*grid_ref_tracked_c.TrackedGridRef, void) = .{}, + searches: std.AutoArrayHashMapUnmanaged(*search_c.SearchWrapper, void) = .{}, /// Fetches a `TerminalWrapper` reference from a `Handler`. fn fromHandler(handler: *Handler) *TerminalWrapper { @@ -1855,6 +1857,8 @@ pub fn free(terminal_: Terminal) callconv(lib.calling_conv) void { for (wrapper.tracked_grid_refs.keys()) |ref| ref.terminal = null; wrapper.tracked_grid_refs.deinit(alloc); + for (wrapper.searches.keys()) |search| search.terminal = null; + wrapper.searches.deinit(alloc); wrapper.stream.deinit(); t.deinit(alloc); wrapper.io.deinit(alloc); diff --git a/src/terminal/c/types.zig b/src/terminal/c/types.zig index c1f524a86..4184de822 100644 --- a/src/terminal/c/types.zig +++ b/src/terminal/c/types.zig @@ -41,6 +41,7 @@ const paste = @import("paste.zig"); const render = @import("render.zig"); const result = @import("result.zig"); const row = @import("row.zig"); +const search = @import("search.zig"); const selection = @import("selection.zig"); const selection_gesture = @import("selection_gesture.zig"); const size_report = @import("size_report.zig"); @@ -204,6 +205,7 @@ const type_decls = [_]TypeDecl{ .initStruct("GhosttyRenderStateCursor", render.Cursor), .initStruct("GhosttyRenderStateRowSelection", render.RowSelection), .initStruct("GhosttySelection", selection.CSelection), + .initStruct("GhosttySelectionBuffer", selection.CSelectionBuffer), .initStruct("GhosttySelectionGestureBehaviors", selection_gesture.Behaviors), .initStruct("GhosttySelectionGestureGeometry", selection_gesture.Geometry), .initTaggedStruct("GhosttySgrAttribute", sgr.Attribute.C, "tag", "value", .generated), @@ -287,6 +289,10 @@ const type_decls = [_]TypeDecl{ .initEnum("GhosttyRenderStateRowOption", render.RowOption, "GHOSTTY_RENDER_STATE_ROW_OPTION_"), .initEnum("GhosttyRowData", row.RowData, "GHOSTTY_ROW_DATA_"), .initEnum("GhosttyRowSemanticPrompt", row.SemanticPrompt, "GHOSTTY_ROW_SEMANTIC_"), + .initEnum("GhosttySearchData", search.Data, "GHOSTTY_SEARCH_DATA_"), + .initEnum("GhosttySearchOption", search.Option, "GHOSTTY_SEARCH_OPT_"), + .initEnum("GhosttySearchScroll", search.Scroll, "GHOSTTY_SEARCH_SCROLL_"), + .initEnum("GhosttySearchStatus", search.Status, "GHOSTTY_SEARCH_STATUS_"), .initEnum("GhosttySelectionAdjust", Selection.Adjustment, "GHOSTTY_SELECTION_ADJUST_"), .initEnum("GhosttySelectionGestureAutoscroll", selection_gesture.Autoscroll, "GHOSTTY_SELECTION_GESTURE_AUTOSCROLL_"), .initEnum("GhosttySelectionGestureBehavior", selection_gesture.Behavior, "GHOSTTY_SELECTION_GESTURE_BEHAVIOR_"), @@ -333,6 +339,7 @@ const type_decls = [_]TypeDecl{ .initOpaque("GhosttyRenderState"), .initOpaque("GhosttyRenderStateRowCells"), .initOpaque("GhosttyRenderStateRowIterator"), + .initOpaque("GhosttySearch"), .initOpaque("GhosttySelectionGesture"), .initOpaque("GhosttySelectionGestureEvent"), .initOpaque("GhosttySgrParser"), diff --git a/src/terminal/search.zig b/src/terminal/search.zig index e69603c25..6913df2e2 100644 --- a/src/terminal/search.zig +++ b/src/terminal/search.zig @@ -5,6 +5,7 @@ pub const options = @import("terminal_options"); pub const Active = @import("search/active.zig").ActiveSearch; pub const PageList = @import("search/pagelist.zig").PageListSearch; pub const Screen = @import("search/screen.zig").ScreenSearch; +pub const Terminal = @import("search/terminal.zig").TerminalSearch; pub const Viewport = @import("search/viewport.zig").ViewportSearch; // The search thread is not available in libghostty due to the xev dep diff --git a/src/terminal/search/Thread.zig b/src/terminal/search/Thread.zig index ecc727399..0b7813219 100644 --- a/src/terminal/search/Thread.zig +++ b/src/terminal/search/Thread.zig @@ -12,7 +12,6 @@ const std = @import("std"); const builtin = @import("builtin"); const testing = std.testing; const Allocator = std.mem.Allocator; -const ArenaAllocator = std.heap.ArenaAllocator; const Mutex = std.Io.Mutex; const global = @import("../../global.zig"); const xev = global.xev; @@ -23,11 +22,10 @@ const point = @import("../point.zig"); const FlattenedHighlight = @import("../highlight.zig").Flattened; const UntrackedHighlight = @import("../highlight.zig").Untracked; const ScreenSet = @import("../ScreenSet.zig"); -const Selection = @import("../Selection.zig"); const Terminal = @import("../Terminal.zig"); const ScreenSearch = @import("screen.zig").ScreenSearch; -const ViewportSearch = @import("viewport.zig").ViewportSearch; +const TerminalSearch = @import("terminal.zig").TerminalSearch; const log = std.log.scoped(.search_thread); @@ -72,7 +70,10 @@ refresh_active: bool = false, /// Search state. Starts as null and is populated when a search is /// started (a needle is given). -search: ?Search = null, +search: ?TerminalSearch = null, + +/// The last-notified values used to diff search state into events. +notify_state: NotifyState = .{}, /// The options used to initialize this thread. opts: Options, @@ -190,7 +191,7 @@ fn threadMain_(self: *Thread) !void { return; } - const s: *Search = if (self.search) |*s| s else { + const s: *TerminalSearch = if (self.search) |*s| s else { // If we're not actively searching, we can block the loop // until it does some work. try self.loop.run(.once); @@ -201,11 +202,7 @@ fn threadMain_(self: *Thread) !void { // notifications. Even if the search is complete, there may be // notifications to send. if (self.opts.event_cb) |cb| { - s.notify( - self.alloc, - cb, - self.opts.event_userdata, - ); + self.notify(s, cb, self.opts.event_userdata); } if (s.isComplete()) { @@ -228,7 +225,7 @@ fn threadMain_(self: *Thread) !void { .blocked => { self.opts.mutex.lockUncancelable(global.io()); defer self.opts.mutex.unlock(global.io()); - s.feed(self.alloc, self.opts.terminal); + self.feedLocked(s); }, } @@ -241,6 +238,21 @@ fn threadMain_(self: *Thread) !void { } } +/// Feed the search from the terminal state. The terminal mutex must +/// be held by the caller. +fn feedLocked(self: *Thread, s: *TerminalSearch) void { + const t = self.opts.terminal; + + // See the `search_viewport_dirty` flag on the terminal to know + // what exactly this is for. But, if this is set, we know the renderer + // found the viewport/active area dirty, so the active area must be + // re-scanned. + const active_dirty = t.flags.search_viewport_dirty; + t.flags.search_viewport_dirty = false; + + s.feed(t, active_dirty); +} + /// Drain the mailbox. fn drainMailbox(self: *Thread) !void { while (self.mailbox.pop(global.io())) |message| { @@ -261,51 +273,12 @@ fn select(self: *Thread, sel: ScreenSearch.Select) !void { self.opts.mutex.lockUncancelable(global.io()); defer self.opts.mutex.unlock(global.io()); - // A screen can be removed or replaced between refresh ticks. Reconcile - // while holding the terminal lock before touching any ScreenSearch pins. - s.feed(self.alloc, self.opts.terminal); - const screen_search = s.screens.getPtr(s.last_screen.key) orelse return; - - // Make the selection. Ignore the result because we don't - // care if the selection didn't change. - _ = try screen_search.select(sel); - - // Grab our match if we have one. If we don't have a selection - // then we do nothing. - const flattened = screen_search.selectedMatch() orelse return; - - // No matter what we reset our selected match cache. This will - // trigger a callback which will trigger the renderer to wake up - // so it can be notified the screen scrolled. - s.last_screen.selected = null; - - // Grab the current screen and see if this match is visible within - // the viewport already. If it is, we do nothing. - const screen = self.opts.terminal.screens.get( - s.last_screen.key, - ) orelse return; - - // Grab the viewport. Viewports and selections are usually small - // so this check isn't very expensive, despite appearing O(N^2), - // both Ns are usually equal to 1. - var it = screen.pages.pageIterator( - .right_down, - .{ .viewport = .{} }, - null, - ); - const hl_chunks = flattened.chunks.slice(); - while (it.next()) |chunk| { - for (0..hl_chunks.len) |i| { - const hl_chunk = hl_chunks.get(i); - if (chunk.overlaps(.{ - .node = hl_chunk.node, - .start = hl_chunk.start, - .end = hl_chunk.end, - })) return; - } + if (try s.select(self.opts.terminal, sel, .if_needed)) { + // No matter what we reset our selected match cache. This will + // trigger a callback which will trigger the renderer to wake up + // so it can be notified the screen scrolled. + self.notify_state.selected = null; } - - screen.scroll(.{ .pin = flattened.startPin() }); } /// Change the search term to the given value. @@ -315,7 +288,7 @@ fn changeNeedle(self: *Thread, needle: []const u8) !void { // Stop the previous search if (self.search) |*s| { // If our search is unchanged, do nothing. - if (std.ascii.eqlIgnoreCase(s.viewport.needle(), needle)) return; + if (std.ascii.eqlIgnoreCase(s.needle(), needle)) return; { self.opts.mutex.lockUncancelable(global.io()); @@ -323,6 +296,7 @@ fn changeNeedle(self: *Thread, needle: []const u8) !void { s.deinit(self.opts.terminal); } self.search = null; + self.notify_state = .{}; // When the search changes then we need to emit that it stopped. if (self.opts.event_cb) |cb| { @@ -346,11 +320,98 @@ fn changeNeedle(self: *Thread, needle: []const u8) !void { // Setup our search state. self.search = try .init(self.alloc, needle); + self.notify_state = .{}; // We need to grab the terminal lock and do an initial feed. self.opts.mutex.lockUncancelable(global.io()); defer self.opts.mutex.unlock(global.io()); - self.search.?.feed(self.alloc, self.opts.terminal); + self.feedLocked(&self.search.?); +} + +/// Notify about any changes to the search state by diffing against +/// the last-notified values. +/// +/// This doesn't require any locking as it only reads search-owned state. +fn notify( + self: *Thread, + s: *TerminalSearch, + cb: EventCallback, + ud: ?*anyopaque, +) void { + const state = &self.notify_state; + + // A screen switch makes all previously notified per-screen state + // stale, so reset it to force recalculations and notifications. + if (state.key != s.active_key) { + state.key = s.active_key; + state.total = null; + state.selected = null; + } + + const screen_search = s.activeScreenSearch() orelse return; + + // Check our total match data + const total = screen_search.matchesLen(); + if (total != state.total) { + log.debug("notifying total matches={}", .{total}); + state.total = total; + cb(.{ .total_matches = total }, ud); + } + + // Check our viewport matches. If they're stale, we collect them + // now. We do this as part of notify and not tick because the + // viewport search is very fast and doesn't require ticked progress + // or feeds. + if (s.stale_viewport_matches) viewport: { + const matches = s.viewportMatches() catch |err| { + log.warn("error collecting viewport matches err={}", .{err}); + break :viewport; + }; + + log.debug("notifying viewport matches len={}", .{matches.len}); + cb(.{ .viewport_matches = matches }, ud); + } + + // Check our last selected match data. + if (screen_search.selected) |m| match: { + const flattened = screen_search.selectedMatch() orelse break :match; + const untracked = flattened.untracked(); + if (state.selected) |prev| { + if (prev.idx == m.idx and prev.highlight.eql(untracked)) { + // Same selection, don't update it. + break :match; + } + } + + // New selection, notify! + state.selected = .{ + .idx = m.idx, + .highlight = untracked, + }; + + log.debug("notifying selection updated idx={}", .{m.idx}); + cb( + .{ .selected_match = .{ + .idx = m.idx, + .highlight = flattened, + } }, + ud, + ); + } else if (state.selected != null) { + log.debug("notifying selection cleared", .{}); + state.selected = null; + cb( + .{ .selected_match = null }, + ud, + ); + } + + // Send our complete notification if we just completed. + if (!state.complete and s.isComplete()) { + log.debug("notifying search complete", .{}); + state.complete = true; + cb(.complete, ud); + } } fn startRefreshTimer(self: *Thread) void { @@ -426,7 +487,7 @@ fn refreshCallback( if (self.search) |*s| { self.opts.mutex.lockUncancelable(global.io()); defer self.opts.mutex.unlock(global.io()); - s.feed(self.alloc, self.opts.terminal); + self.feedLocked(s); } // Only continue if we're still active @@ -502,352 +563,25 @@ pub const Event = union(enum) { }; }; -/// Search state. -const Search = struct { - /// Active viewport search for the active screen. - viewport: ViewportSearch, +/// The last-notified values used by `notify` to diff search state +/// into events. +const NotifyState = struct { + /// The active screen key the state below was captured against. + key: ScreenSet.Key = .primary, - /// The searchers for all the screens. - screens: std.EnumMap(ScreenSet.Key, ScreenSearch), + /// Last notified total matches count + total: ?usize = null, - /// ScreenSet generations captured when each searcher was initialized. - /// Allocators may reuse a destroyed Screen address, so pointer equality - /// alone cannot distinguish replacement screens from stale handles. - screen_generations: std.EnumMap(ScreenSet.Key, usize), - - /// All state related to screen switches, collected so that when - /// we switch screens it makes everything related stale, too. - last_screen: ScreenState, + /// Last notified selected match + selected: ?Selected = null, /// True if we sent the complete notification yet. - last_complete: bool, + complete: bool = false, - /// The last viewport matches we found. - stale_viewport_matches: bool, - - const ScreenState = struct { - /// Last active screen key - key: ScreenSet.Key, - - /// Last notified total matches count - total: ?usize = null, - - /// Last notified selected match index - selected: ?SelectedMatch = null, - - const SelectedMatch = struct { - idx: usize, - highlight: UntrackedHighlight, - }; + const Selected = struct { + idx: usize, + highlight: UntrackedHighlight, }; - - pub fn init( - alloc: Allocator, - needle: []const u8, - ) Allocator.Error!Search { - var vp: ViewportSearch = try .init(alloc, needle); - errdefer vp.deinit(); - - // We use dirty tracking for active area changes. Start with it - // dirty so the first change is re-searched. - vp.active_dirty = true; - - return .{ - .viewport = vp, - .screens = .init(.{}), - .screen_generations = .init(.{}), - .last_screen = .{ .key = .primary }, - .last_complete = false, - .stale_viewport_matches = true, - }; - } - - pub fn deinit(self: *Search, t: *Terminal) void { - self.viewport.deinit(); - var it = self.screens.iterator(); - while (it.next()) |entry| { - if (self.screenIsValid(&t.screens, entry.key, entry.value)) { - entry.value.deinit(); - } else { - entry.value.deinitScreenInvalid(); - } - } - } - - fn screenIsValid( - self: *const Search, - screens: *const ScreenSet, - key: ScreenSet.Key, - search: *const ScreenSearch, - ) bool { - const generation = self.screen_generations.get(key) orelse return false; - if (generation != screens.generation(key)) return false; - const actual = screens.get(key) orelse return false; - return actual == search.screen; - } - - /// Returns true if all searches on all screens are complete. - pub fn isComplete(self: *Search) bool { - var it = self.screens.iterator(); - while (it.next()) |entry| { - if (!entry.value.state.isComplete()) return false; - } - - return true; - } - - pub const Tick = enum { - /// All searches are complete. - complete, - - /// Progress was made on at least one screen. - progress, - - /// All incomplete searches are blocked on feed. - blocked, - }; - - /// Tick the search forward as much as possible without acquiring - /// the big lock. Returns the overall tick progress. - pub fn tick(self: *Search) Tick { - var result: Tick = .complete; - var it = self.screens.iterator(); - while (it.next()) |entry| { - if (entry.value.tick()) { - result = .progress; - } else |err| switch (err) { - // Ignore... nothing we can do. - error.OutOfMemory => log.warn( - "error ticking screen search key={} err={}", - .{ entry.key, err }, - ), - - // Ignore, good for us. State remains whatever it is. - error.SearchComplete => {}, - - // Ignore, too, progressed - error.FeedRequired => switch (result) { - // If we think we're complete, we're not because we're - // blocked now (nothing made progress). - .complete => result = .blocked, - - // If we made some progress, we remain in progress - // since blocked means no progress at all. - .progress => {}, - - // If we're blocked already then we remain blocked. - .blocked => {}, - }, - } - } - - // log.debug("tick result={}", .{result}); - return result; - } - - /// Grab the mutex and update any state that requires it, such as - /// feeding additional data to the searches or updating the active screen. - pub fn feed( - self: *Search, - alloc: Allocator, - t: *Terminal, - ) void { - // Update our active screen - if (t.screens.active_key != self.last_screen.key) { - // The default values will force resets of a bunch of other - // state too to force recalculations and notifications. - self.last_screen = .{ .key = t.screens.active_key }; - } - - // Reconcile our screens with the terminal screens. Remove - // searchers for screens that no longer exist and add searchers - // for screens that do exist but we don't have yet. - { - // Remove screens we have that no longer exist or changed. - var it = self.screens.iterator(); - while (it.next()) |entry| { - const remove = !self.screenIsValid( - &t.screens, - entry.key, - entry.value, - ); - - if (remove) { - entry.value.deinitScreenInvalid(); - _ = self.screens.remove(entry.key); - _ = self.screen_generations.remove(entry.key); - } - } - } - { - // Add screens that exist but we don't have yet. - var it = t.screens.all.iterator(); - while (it.next()) |entry| { - if (self.screens.contains(entry.key)) continue; - const screen_search = ScreenSearch.init( - alloc, - entry.value.*, - self.viewport.needle(), - ) catch |err| switch (err) { - error.OutOfMemory => { - // OOM is probably going to sink the entire ship but - // we can just ignore it and wait on the next - // reconciliation to try again. - log.warn( - "error initializing screen search for key={} err={}", - .{ entry.key, err }, - ); - continue; - }, - }; - self.screens.put(entry.key, screen_search); - self.screen_generations.put( - entry.key, - t.screens.generation(entry.key), - ); - } - } - - // See the `search_viewport_dirty` flag on the terminal to know - // what exactly this is for. But, if this is set, we know the renderer - // found the viewport/active area dirty, so we should mark it as - // dirty in our viewport searcher so it forces a re-search. - if (t.flags.search_viewport_dirty) { - t.flags.search_viewport_dirty = false; - - // Mark our viewport dirty so it researches the active - self.viewport.active_dirty = true; - - // Reload our active area for our active screen - if (self.screens.getPtr(t.screens.active_key)) |screen_search| { - screen_search.reloadActive() catch |err| switch (err) { - error.OutOfMemory => log.warn( - "error reloading active area for screen key={} err={}", - .{ t.screens.active_key, err }, - ), - }; - } - } - - // Check our viewport for changes. - if (self.viewport.update(&t.screens.active.pages)) |updated| { - if (updated) self.stale_viewport_matches = true; - } else |err| switch (err) { - error.OutOfMemory => log.warn( - "error updating viewport search err={}", - .{err}, - ), - } - - // Feed data - var it = self.screens.iterator(); - while (it.next()) |entry| { - if (entry.value.state.needsFeed()) { - entry.value.feed() catch |err| switch (err) { - error.OutOfMemory => log.warn( - "error feeding screen search key={} err={}", - .{ entry.key, err }, - ), - }; - } - } - } - - /// Notify about any changes to the search state. - /// - /// This doesn't require any locking as it only reads internal state. - pub fn notify( - self: *Search, - alloc: Allocator, - cb: EventCallback, - ud: ?*anyopaque, - ) void { - const screen_search = self.screens.get(self.last_screen.key) orelse return; - - // Check our total match data - const total = screen_search.matchesLen(); - if (total != self.last_screen.total) { - log.debug("notifying total matches={}", .{total}); - self.last_screen.total = total; - cb(.{ .total_matches = total }, ud); - } - - // Check our viewport matches. If they're stale, we do the - // viewport search now. We do this as part of notify and not - // tick because the viewport search is very fast and doesn't - // require ticked progress or feeds. - if (self.stale_viewport_matches) viewport: { - // We always make stale as false. Even if we fail below - // we require a re-feed to re-search the viewport. The feed - // process will make it stale again. - self.stale_viewport_matches = false; - - var arena: ArenaAllocator = .init(alloc); - defer arena.deinit(); - const arena_alloc = arena.allocator(); - var results: std.ArrayList(FlattenedHighlight) = .empty; - while (self.viewport.next()) |hl| { - const hl_cloned = hl.clone(arena_alloc) catch continue; - results.append(arena_alloc, hl_cloned) catch |err| switch (err) { - error.OutOfMemory => { - log.warn( - "error collecting viewport matches err={}", - .{err}, - ); - - // Reset the viewport so we force an update on the - // next feed. - self.viewport.reset(); - break :viewport; - }, - }; - } - - log.debug("notifying viewport matches len={}", .{results.items.len}); - cb(.{ .viewport_matches = results.items }, ud); - } - - // Check our last selected match data. - if (screen_search.selected) |m| match: { - const flattened = screen_search.selectedMatch() orelse break :match; - const untracked = flattened.untracked(); - if (self.last_screen.selected) |prev| { - if (prev.idx == m.idx and prev.highlight.eql(untracked)) { - // Same selection, don't update it. - break :match; - } - } - - // New selection, notify! - self.last_screen.selected = .{ - .idx = m.idx, - .highlight = untracked, - }; - - log.debug("notifying selection updated idx={}", .{m.idx}); - cb( - .{ .selected_match = .{ - .idx = m.idx, - .highlight = flattened, - } }, - ud, - ); - } else if (self.last_screen.selected != null) { - log.debug("notifying selection cleared", .{}); - self.last_screen.selected = null; - cb( - .{ .selected_match = null }, - ud, - ); - } - - // Send our complete notification if we just completed. - if (!self.last_complete and self.isComplete()) { - log.debug("notifying search complete", .{}); - self.last_complete = true; - cb(.complete, ud); - } - } }; const TestUserData = struct { @@ -945,33 +679,3 @@ test { } }, t.screens.active.pages.pointFromPin(.screen, sel.end).?); } } - -test "select after active screen removal" { - const alloc = testing.allocator; - const io = testing.io; - var mutex: std.Io.Mutex = .init; - var t: Terminal = try .init(io, alloc, .{ .cols = 20, .rows = 2 }); - defer t.deinit(alloc); - - _ = try t.switchScreen(.alternate); - - var search: Search = try .init(alloc, "needle"); - search.feed(alloc, &t); - try testing.expectEqual(ScreenSet.Key.alternate, search.last_screen.key); - try testing.expect(search.screens.contains(.alternate)); - - var thread: Thread = undefined; - thread.search = search; - thread.opts = .{ - .mutex = &mutex, - .terminal = &t, - }; - defer if (thread.search) |*active| active.deinit(&t); - - _ = try t.switchScreen(.primary); - t.screens.remove(alloc, .alternate); - - try thread.select(.next); - try testing.expectEqual(ScreenSet.Key.primary, thread.search.?.last_screen.key); - try testing.expect(!thread.search.?.screens.contains(.alternate)); -} diff --git a/src/terminal/search/screen.zig b/src/terminal/search/screen.zig index 81e43ecaf..cbda5a388 100644 --- a/src/terminal/search/screen.zig +++ b/src/terminal/search/screen.zig @@ -765,22 +765,31 @@ pub const ScreenSearch = struct { } } + /// Return the match at the given index in newest-to-oldest order + /// (0 = most recent match). Returns null if the index is out of + /// range. + /// + /// This does not require read/write access to the underlying screen. + pub fn matchAt(self: *const ScreenSearch, idx: usize) ?FlattenedHighlight { + const active_len = self.active_results.items.len; + if (idx < active_len) { + return self.active_results.items[active_len - 1 - idx]; + } + + const history_len = self.history_results.items.len; + if (idx < active_len + history_len) { + return self.history_results.items[idx - active_len]; + } + + return null; + } + /// Return the selected match. /// /// This does not require read/write access to the underlying screen. pub fn selectedMatch(self: *const ScreenSearch) ?FlattenedHighlight { const sel = self.selected orelse return null; - const active_len = self.active_results.items.len; - if (sel.idx < active_len) { - return self.active_results.items[active_len - 1 - sel.idx]; - } - - const history_len = self.history_results.items.len; - if (sel.idx < active_len + history_len) { - return self.history_results.items[sel.idx - active_len]; - } - - return null; + return self.matchAt(sel.idx); } pub const Select = enum { diff --git a/src/terminal/search/terminal.zig b/src/terminal/search/terminal.zig new file mode 100644 index 000000000..062cc0049 --- /dev/null +++ b/src/terminal/search/terminal.zig @@ -0,0 +1,655 @@ +const std = @import("std"); +const testing = std.testing; +const Allocator = std.mem.Allocator; +const point = @import("../point.zig"); +const FlattenedHighlight = @import("../highlight.zig").Flattened; +const ScreenSet = @import("../ScreenSet.zig"); +const Terminal = @import("../Terminal.zig"); + +const ScreenSearch = @import("screen.zig").ScreenSearch; +const ViewportSearch = @import("viewport.zig").ViewportSearch; + +const log = std.log.scoped(.search_terminal); + +/// Searches for a needle within an entire Terminal, orchestrating a +/// ScreenSearch per live screen plus a viewport search for the active +/// screen. +/// +/// This reconciles per-screen searchers against the terminal's active +/// ScreenSet, retains results across primary/alt screen switches, and +/// recovers from resize/reflow, resets, scrollback pruning, etc. +/// +/// The main ownership contract is that this is safe to call concurrently +/// with Terminal IO as long as you're not calling a function that takes +/// a terminal as an argument. Each function has more specific details about +/// its behavior. +pub const TerminalSearch = struct { + /// Allocator used for all search state. + alloc: Allocator, + + /// Active viewport search for the active screen. + viewport: ViewportSearch, + + /// The searchers for all the screens. + screens: std.EnumMap(ScreenSet.Key, ScreenSearch), + + /// ScreenSet generations captured when each searcher was initialized. + /// Allocators may reuse a destroyed Screen address, so pointer equality + /// alone cannot distinguish replacement screens from stale handles. + screen_generations: std.EnumMap(ScreenSet.Key, usize), + + /// The active screen key as of the last feed. All single-screen + /// reads (total matches, selection, etc.) resolve against this. + active_key: ScreenSet.Key, + + /// True when the cached viewport matches are stale and must be + /// recollected from the viewport searcher on the next + /// `viewportMatches` call. + stale_viewport_matches: bool, + + /// Cached viewport matches. The viewport search's sliding window + /// drains on read, so results are collected once per viewport + /// change and cached here. + viewport_matches: std.ArrayList(FlattenedHighlight), + + /// Overall status of the search. See `status`. + pub const Status = enum { + /// `tick` can make progress without terminal access. + running, + + /// Blocked until the next `feed`. This is also the initial + /// state, since a search that has never been fed has never + /// seen the terminal. + feed_required, + + /// Caught up with the terminal state as of the last feed. + complete, + }; + + /// Viewport scroll behavior applied by `select` when a match + /// becomes selected. + pub const SelectScroll = enum { + /// Scroll so the match is visible, only if it is not already. + if_needed, + + /// Never scroll. + none, + }; + + /// Initialize a search for the given needle. The needle is copied. + /// + /// This doesn't read any terminal state. The first `feed` does. + pub fn init( + alloc: Allocator, + needle_unowned: []const u8, + ) Allocator.Error!TerminalSearch { + var vp: ViewportSearch = try .init(alloc, needle_unowned); + errdefer vp.deinit(); + + // We use dirty tracking for active area changes. Start with it + // dirty so the first change is re-searched. + vp.active_dirty = true; + + return .{ + .alloc = alloc, + .viewport = vp, + .screens = .init(.{}), + .screen_generations = .init(.{}), + .active_key = .primary, + .stale_viewport_matches = true, + .viewport_matches = .empty, + }; + } + + /// Release all state. The terminal must be the same one given to + /// every other call, or null if the terminal has already been + /// deinitialized. When the terminal is alive this releases tracked + /// pins held within it. When it is null, those pins died with the + /// terminal's page storage, so only search-owned memory is freed. + pub fn deinit(self: *TerminalSearch, t_: ?*Terminal) void { + self.clearViewportMatches(); + self.viewport_matches.deinit(self.alloc); + self.viewport.deinit(); + var it = self.screens.iterator(); + while (it.next()) |entry| { + const valid = if (t_) |t| self.screenIsValid( + &t.screens, + entry.key, + entry.value, + ) else false; + if (valid) { + entry.value.deinit(); + } else { + entry.value.deinitScreenInvalid(); + } + } + } + + fn screenIsValid( + self: *const TerminalSearch, + screens: *const ScreenSet, + key: ScreenSet.Key, + search: *const ScreenSearch, + ) bool { + const generation = self.screen_generations.get(key) orelse return false; + if (generation != screens.generation(key)) return false; + const actual = screens.get(key) orelse return false; + return actual == search.screen; + } + + /// The needle that this search is using, borrowed. + pub fn needle(self: *const TerminalSearch) []const u8 { + return self.viewport.needle(); + } + + /// The searcher for the active screen as of the last feed. Null if + /// the search has never been fed (or the screen failed to + /// initialize). + pub fn activeScreenSearch(self: *TerminalSearch) ?*ScreenSearch { + return self.screens.getPtr(self.active_key); + } + + /// Returns true if all searches on all screens are complete. + pub fn isComplete(self: *TerminalSearch) bool { + var it = self.screens.iterator(); + while (it.next()) |entry| { + if (!entry.value.state.isComplete()) return false; + } + + return true; + } + + /// The overall search status derived from the per-screen search + /// states. + /// + /// Unlike `isComplete`, a search that has never been fed reports + /// `feed_required` rather than `complete`. An empty screen map + /// means the search has never seen the terminal, so reporting it + /// as complete would be technically true but useless to a caller + /// deciding what to do next. + pub fn status(self: *TerminalSearch) Status { + var saw_any = false; + var result: Status = .complete; + var it = self.screens.iterator(); + while (it.next()) |entry| { + saw_any = true; + switch (entry.value.state) { + // Progress is possible without a feed. + .active, .history => return .running, + + // Blocked until fed. + .history_feed => result = .feed_required, + + .complete => {}, + } + } + + if (!saw_any) return .feed_required; + return result; + } + + pub const Tick = enum { + /// All searches are complete. + complete, + + /// Progress was made on at least one screen. + progress, + + /// All incomplete searches are blocked on feed. + blocked, + }; + + /// Tick the search forward as much as possible without reading + /// any terminal state, so this is safe to call concurrently with + /// terminal IO. Returns the overall tick progress. + pub fn tick(self: *TerminalSearch) Tick { + var result: Tick = .complete; + var it = self.screens.iterator(); + while (it.next()) |entry| { + if (entry.value.tick()) { + result = .progress; + } else |err| switch (err) { + // Ignore... nothing we can do. + error.OutOfMemory => log.warn( + "error ticking screen search key={} err={}", + .{ entry.key, err }, + ), + + // Ignore, good for us. State remains whatever it is. + error.SearchComplete => {}, + + // Ignore, too, progressed + error.FeedRequired => switch (result) { + // If we think we're complete, we're not because we're + // blocked now (nothing made progress). + .complete => result = .blocked, + + // If we made some progress, we remain in progress + // since blocked means no progress at all. + .progress => {}, + + // If we're blocked already then we remain blocked. + .blocked => {}, + }, + } + } + + return result; + } + + /// Read the terminal to update any search state that requires it, + /// such as reconciling screens, feeding more data to the + /// searchers, and detecting viewport changes. + /// + /// This reads the terminal, so the caller must ensure the terminal + /// isn't modified for the duration of this call (e.g. by holding a + /// lock). Feeding is also the only way the search learns about + /// terminal changes, so callers should feed periodically while a + /// search is in use, even after it reports complete. + /// + /// `active_dirty` should be true when the active area may have + /// changed since the last feed, which triggers a re-scan of the + /// active area. Callers that know when the active area changes + /// (e.g. Ghostty's renderer-maintained dirty flag) can pass false + /// to skip the re-scan. Callers without that knowledge should + /// always pass true. That is always correct, just slightly more + /// work, and the active area search is cheap by design. + pub fn feed( + self: *TerminalSearch, + t: *Terminal, + active_dirty: bool, + ) void { + const alloc = self.alloc; + + // Update our active screen + if (t.screens.active_key != self.active_key) { + self.active_key = t.screens.active_key; + } + + // Reconcile our screens with the terminal screens. Remove + // searchers for screens that no longer exist and add searchers + // for screens that do exist but we don't have yet. + { + // Remove screens we have that no longer exist or changed. + var it = self.screens.iterator(); + while (it.next()) |entry| { + const remove = !self.screenIsValid( + &t.screens, + entry.key, + entry.value, + ); + + if (remove) { + entry.value.deinitScreenInvalid(); + _ = self.screens.remove(entry.key); + _ = self.screen_generations.remove(entry.key); + } + } + } + { + // Add screens that exist but we don't have yet. + var it = t.screens.all.iterator(); + while (it.next()) |entry| { + if (self.screens.contains(entry.key)) continue; + const screen_search = ScreenSearch.init( + alloc, + entry.value.*, + self.viewport.needle(), + ) catch |err| switch (err) { + error.OutOfMemory => { + // OOM is probably going to sink the entire ship but + // we can just ignore it and wait on the next + // reconciliation to try again. + log.warn( + "error initializing screen search for key={} err={}", + .{ entry.key, err }, + ); + continue; + }, + }; + self.screens.put(entry.key, screen_search); + self.screen_generations.put( + entry.key, + t.screens.generation(entry.key), + ); + } + } + + // The caller told us the active area may have changed, so mark + // our viewport searcher dirty (forcing a re-search) and reload + // the active area for the active screen. + if (active_dirty) { + // Mark our viewport dirty so it researches the active + self.viewport.active_dirty = true; + + // Reload our active area for our active screen + if (self.screens.getPtr(t.screens.active_key)) |screen_search| { + screen_search.reloadActive() catch |err| switch (err) { + error.OutOfMemory => log.warn( + "error reloading active area for screen key={} err={}", + .{ t.screens.active_key, err }, + ), + }; + } + } + + // Check our viewport for changes. + if (self.viewport.update(&t.screens.active.pages)) |updated| { + if (updated) self.stale_viewport_matches = true; + } else |err| switch (err) { + error.OutOfMemory => log.warn( + "error updating viewport search err={}", + .{err}, + ), + } + + // Feed data + var it = self.screens.iterator(); + while (it.next()) |entry| { + if (entry.value.state.needsFeed()) { + entry.value.feed() catch |err| switch (err) { + error.OutOfMemory => log.warn( + "error feeding screen search key={} err={}", + .{ entry.key, err }, + ), + }; + } + } + } + + /// Return the matches on the pages covering the viewport, as of + /// the last feed. Note this can include matches slightly outside + /// the visible viewport when they share a page with it. + /// + /// The results are cached, since the underlying viewport search + /// drains as it is read. Matches are collected once after a feed + /// notices a viewport change and the cached results are returned + /// otherwise. The returned slice is owned by the search and valid + /// until the next call to this function, the next feed, or deinit. + /// + /// This doesn't read any terminal state, so it is safe to call + /// concurrently with terminal IO. + pub fn viewportMatches( + self: *TerminalSearch, + ) Allocator.Error![]const FlattenedHighlight { + if (!self.stale_viewport_matches) return self.viewport_matches.items; + + // We always mark the cache fresh, even if collection fails + // below: a failed collection isn't retried until the next feed + // marks the cache stale again. + self.stale_viewport_matches = false; + + self.clearViewportMatches(); + errdefer { + self.clearViewportMatches(); + + // Reset the viewport so we force an update (and therefore a + // re-collection) on the next feed. + self.viewport.reset(); + } + while (self.viewport.next()) |hl| { + var hl_cloned = try hl.clone(self.alloc); + errdefer hl_cloned.deinit(self.alloc); + try self.viewport_matches.append(self.alloc, hl_cloned); + } + + return self.viewport_matches.items; + } + + fn clearViewportMatches(self: *TerminalSearch) void { + for (self.viewport_matches.items) |*hl| hl.deinit(self.alloc); + self.viewport_matches.clearRetainingCapacity(); + } + + /// Select the next or previous search result on the active screen, + /// wrapping around at the ends, and optionally scrolling the + /// viewport so the newly selected match is visible. + /// + /// This feeds first so the selection always works against current + /// terminal state, making it safe to call at any time relative to + /// feeds. Like `feed`, this reads (and possibly scrolls) the + /// terminal, so the caller must ensure the terminal isn't + /// otherwise being used for the duration of this call. + /// + /// Returns true if a match is selected after the operation, or + /// false if there are no matches to select. + pub fn select( + self: *TerminalSearch, + t: *Terminal, + to: ScreenSearch.Select, + scroll: SelectScroll, + ) Allocator.Error!bool { + // A screen can be removed or replaced between feeds. Reconcile + // while holding the terminal lock before touching any + // ScreenSearch pins. + self.feed(t, false); + const screen_search = self.screens.getPtr(self.active_key) orelse + return false; + + // Make the selection. Ignore the result because we don't + // care if the selection didn't change. + _ = try screen_search.select(to); + + // Grab our match if we have one. If we don't have a selection + // then there was nothing to select. + const flattened = screen_search.selectedMatch() orelse return false; + + switch (scroll) { + .none => return true, + .if_needed => {}, + } + + // Grab the current screen and see if this match is visible within + // the viewport already. If it is, we do nothing. + const screen = t.screens.get(self.active_key) orelse return true; + + // Grab the viewport. Viewports and selections are usually small + // so this check isn't very expensive, despite appearing O(N^2), + // both Ns are usually equal to 1. + var it = screen.pages.pageIterator( + .right_down, + .{ .viewport = .{} }, + null, + ); + const hl_chunks = flattened.chunks.slice(); + while (it.next()) |chunk| { + for (0..hl_chunks.len) |i| { + const hl_chunk = hl_chunks.get(i); + if (chunk.overlaps(.{ + .node = hl_chunk.node, + .start = hl_chunk.start, + .end = hl_chunk.end, + })) return true; + } + } + + screen.scroll(.{ .pin = flattened.startPin() }); + return true; + } +}; + +test "starts feed required and runs to complete" { + const alloc = testing.allocator; + const io = testing.io; + var t: Terminal = try .init(io, alloc, .{ .cols = 10, .rows = 2 }); + defer t.deinit(alloc); + + var stream = t.vtStream(); + defer stream.deinit(); + stream.nextSlice("Fizz\r\nBuzz\r\nFizz\r\nBang"); + + var search: TerminalSearch = try .init(alloc, "Fizz"); + defer search.deinit(&t); + + // A fresh search has never seen the terminal, so it must report + // feed_required even though isComplete() is trivially true. + try testing.expect(search.isComplete()); + try testing.expectEqual(TerminalSearch.Status.feed_required, search.status()); + + // Pump until complete. + while (search.status() != .complete) { + switch (search.status()) { + .feed_required => search.feed(&t, true), + .running => _ = search.tick(), + .complete => unreachable, + } + } + + const screen_search = search.activeScreenSearch().?; + try testing.expectEqual(2, screen_search.matchesLen()); +} + +test "viewport matches are cached until the next feed" { + const alloc = testing.allocator; + const io = testing.io; + var t: Terminal = try .init(io, alloc, .{ .cols = 10, .rows = 10 }); + defer t.deinit(alloc); + + var stream = t.vtStream(); + defer stream.deinit(); + stream.nextSlice("Fizz\r\nBuzz\r\nFizz\r\nBang"); + + var search: TerminalSearch = try .init(alloc, "Fizz"); + defer search.deinit(&t); + search.feed(&t, true); + + // The sliding window drains on read, so a second read must return + // the cached results rather than an empty list. + try testing.expectEqual(2, (try search.viewportMatches()).len); + try testing.expectEqual(2, (try search.viewportMatches()).len); + + // A feed that observes a change refreshes the cache. + stream.nextSlice("\r\nFizz"); + search.feed(&t, true); + try testing.expectEqual(3, (try search.viewportMatches()).len); +} + +test "select after active screen removal" { + const alloc = testing.allocator; + const io = testing.io; + var t: Terminal = try .init(io, alloc, .{ .cols = 20, .rows = 2 }); + defer t.deinit(alloc); + + _ = try t.switchScreen(.alternate); + + var search: TerminalSearch = try .init(alloc, "needle"); + defer search.deinit(&t); + search.feed(&t, false); + try testing.expectEqual(ScreenSet.Key.alternate, search.active_key); + try testing.expect(search.screens.contains(.alternate)); + + _ = try t.switchScreen(.primary); + t.screens.remove(alloc, .alternate); + + // The select must reconcile against the live ScreenSet before + // touching any pins from the removed screen. + _ = try search.select(&t, .next, .if_needed); + try testing.expectEqual(ScreenSet.Key.primary, search.active_key); + try testing.expect(!search.screens.contains(.alternate)); +} + +test "select scrolls the viewport only when needed" { + const alloc = testing.allocator; + const io = testing.io; + var t: Terminal = try .init(io, alloc, .{ + .cols = 10, + .rows = 2, + .max_scrollback_bytes = std.math.maxInt(usize), + }); + defer t.deinit(alloc); + + var stream = t.vtStream(); + defer stream.deinit(); + stream.nextSlice("Fizz\r\n"); + for (0..30) |_| stream.nextSlice("\r\n"); + stream.nextSlice("Fizz"); + + var search: TerminalSearch = try .init(alloc, "Fizz"); + defer search.deinit(&t); + while (search.status() != .complete) { + switch (search.status()) { + .feed_required => search.feed(&t, true), + .running => _ = search.tick(), + .complete => unreachable, + } + } + + // Whether the selected match is within the visible viewport rows. + // pointFromPin only bounds-checks the top of the region, so rows + // below the viewport must be rejected by the row count. + const Visible = struct { + fn check(term: *Terminal, s: *TerminalSearch) bool { + const pin = s.activeScreenSearch().?.selectedMatch().?.startPin(); + const pages = &term.screens.active.pages; + const pt = pages.pointFromPin(.viewport, pin) orelse return false; + return pt.viewport.y < pages.rows; + } + }; + + // First match is at the bottom which is already visible: no scroll. + try testing.expect(try search.select(&t, .next, .if_needed)); + try testing.expectEqual( + point.Point{ .active = .{ .x = 0, .y = 1 } }, + t.screens.active.pages.pointFromPin( + .active, + search.activeScreenSearch().?.selectedMatch().?.startPin(), + ).?, + ); + try testing.expect(Visible.check(&t, &search)); + + // Second match is in scrollback: selecting it must scroll the + // viewport so it becomes visible. + try testing.expect(try search.select(&t, .next, .if_needed)); + try testing.expect(Visible.check(&t, &search)); + + // With scrolling disabled the viewport must stay where it is even + // though the next selection (wrap back to the bottom) is not + // visible. + try testing.expect(try search.select(&t, .next, .none)); + try testing.expect(!Visible.check(&t, &search)); +} + +test "deinit after the terminal is gone" { + const alloc = testing.allocator; + const io = testing.io; + var t: Terminal = try .init(io, alloc, .{ + .cols = 10, + .rows = 2, + .max_scrollback_bytes = std.math.maxInt(usize), + }); + + var stream = t.vtStream(); + stream.nextSlice("Fizz\r\nBuzz\r\nFizz"); + + // Run to complete and select a match so the search holds tracked + // pins within the terminal's page storage. + var search: TerminalSearch = try .init(alloc, "Fizz"); + while (search.status() != .complete) { + switch (search.status()) { + .feed_required => search.feed(&t, true), + .running => _ = search.tick(), + .complete => unreachable, + } + } + try testing.expect(try search.select(&t, .next, .none)); + + // Deinitialize the terminal first. The pins died with the page + // storage, so deinit with a null terminal must free only + // search-owned memory without touching the terminal. + stream.deinit(); + t.deinit(alloc); + search.deinit(null); +} + +test "no matches selects nothing" { + const alloc = testing.allocator; + const io = testing.io; + var t: Terminal = try .init(io, alloc, .{ .cols = 10, .rows = 2 }); + defer t.deinit(alloc); + + var search: TerminalSearch = try .init(alloc, "Fizz"); + defer search.deinit(&t); + search.feed(&t, true); + try testing.expect(!try search.select(&t, .next, .if_needed)); + try testing.expect(!try search.select(&t, .prev, .if_needed)); +}