mirror of
https://github.com/ghostty-org/ghostty.git
synced 2026-09-14 18:01:58 +00:00
libghostty: add terminal search API (#14097)
This exposes the terminal search API through libghostty C and Zig APIs.
This was previously available through the Zig APIs but forced our
threading model. I've now extracted the full terminal search state to a
new `terminal.search.TerminalSearch` structure so threading isn't
forced. The C API is completely new.
## Example (C)
```c
GhosttySearch search;
ghostty_search_new(NULL, &search, terminal);
GhosttyString needle = { (const uint8_t *)"error", 5 };
ghostty_search_set(search, GHOSTTY_SEARCH_OPT_NEEDLE, &needle);
ghostty_search_run(search);
// Find bar chrome: "k of n"
size_t total, idx;
ghostty_search_get(search, GHOSTTY_SEARCH_DATA_TOTAL_MATCHES, &total);
// Enter: select the next match (wraps, scrolls the viewport if needed)
ghostty_search_set(search, GHOSTTY_SEARCH_OPT_SELECT_NEXT, NULL);
ghostty_search_get(search, GHOSTTY_SEARCH_DATA_SELECTED_INDEX, &idx);
ghostty_search_free(search);
```
This commit is contained in:
19
example/c-vt-search/README.md
Normal file
19
example/c-vt-search/README.md
Normal file
@@ -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
|
||||
```
|
||||
42
example/c-vt-search/build.zig
Normal file
42
example/c-vt-search/build.zig
Normal file
@@ -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);
|
||||
}
|
||||
24
example/c-vt-search/build.zig.zon
Normal file
24
example/c-vt-search/build.zig.zon
Normal file
@@ -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",
|
||||
},
|
||||
}
|
||||
117
example/c-vt-search/src/main.c
Normal file
117
example/c-vt-search/src/main.c
Normal file
@@ -0,0 +1,117 @@
|
||||
#include <assert.h>
|
||||
#include <stdbool.h>
|
||||
#include <stdio.h>
|
||||
#include <string.h>
|
||||
#include <ghostty/vt.h>
|
||||
|
||||
//! [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]
|
||||
@@ -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 <ghostty/vt/paste.h>
|
||||
#include <ghostty/vt/point.h>
|
||||
#include <ghostty/vt/screen.h>
|
||||
#include <ghostty/vt/search.h>
|
||||
#include <ghostty/vt/selection.h>
|
||||
#include <ghostty/vt/size_report.h>
|
||||
#include <ghostty/vt/snapshot.h>
|
||||
|
||||
501
include/ghostty/vt/search.h
Normal file
501
include/ghostty/vt/search.h
Normal file
@@ -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 <stddef.h>
|
||||
#include <ghostty/vt/allocator.h>
|
||||
#include <ghostty/vt/selection.h>
|
||||
#include <ghostty/vt/types.h>
|
||||
|
||||
#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 */
|
||||
@@ -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.
|
||||
*
|
||||
|
||||
@@ -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.
|
||||
*
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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;
|
||||
|
||||
1009
src/terminal/c/search.zig
Normal file
1009
src/terminal/c/search.zig
Normal file
File diff suppressed because it is too large
Load Diff
@@ -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),
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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"),
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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));
|
||||
}
|
||||
|
||||
@@ -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 {
|
||||
|
||||
655
src/terminal/search/terminal.zig
Normal file
655
src/terminal/search/terminal.zig
Normal file
@@ -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));
|
||||
}
|
||||
Reference in New Issue
Block a user