aboutsummaryrefslogtreecommitdiffstats
path: root/vendor/libghostty-vt/src/terminal/search/screen.zig
diff options
context:
space:
mode:
authorsl0p <sl0p@sl0p.foo>2026-08-13 22:00:03 +0200
committersl0p <sl0p@sl0p.foo>2026-08-13 22:00:03 +0200
commit2c1b855c3cf8cf8841bf25cd939e478a7f0bfac8 (patch)
tree7cd6274303ba1da1f8bd57371aec336dd687fb58 /vendor/libghostty-vt/src/terminal/search/screen.zig
downloadslosh-2c1b855c3cf8cf8841bf25cd939e478a7f0bfac8.tar.gz
slosh-2c1b855c3cf8cf8841bf25cd939e478a7f0bfac8.tar.xz
slosh-2c1b855c3cf8cf8841bf25cd939e478a7f0bfac8.zip
sl0ptty: M0 — libghostty-vt vendored, one pane, compositor, diff renderer
Design contract in DESIGN.md. libghostty-vt vendored fresh from ghostty main@bedb6f2 (zig 0.16), built to a 14MB static lib. M0: pty spawn (no libutil), a lib-vt terminal per pane, a screen cell buffer, a diff emitter that repaints only what changed, raw mode, SIGWINCH, and a ~120Hz frame cap that coalesces pty reads. Also the seed of the headless harness: --headless dumps the composited screen without a tty, so composition is testable in milliseconds. tests/live_m0.py covers what headless cannot: raw mode, the diff emitter, resize propagation, and the quit path. 6/6 green.
Diffstat (limited to 'vendor/libghostty-vt/src/terminal/search/screen.zig')
-rw-r--r--vendor/libghostty-vt/src/terminal/search/screen.zig1985
1 files changed, 1985 insertions, 0 deletions
diff --git a/vendor/libghostty-vt/src/terminal/search/screen.zig b/vendor/libghostty-vt/src/terminal/search/screen.zig
new file mode 100644
index 0000000..81e43ec
--- /dev/null
+++ b/vendor/libghostty-vt/src/terminal/search/screen.zig
@@ -0,0 +1,1985 @@
+const std = @import("std");
+const assert = @import("../../quirks.zig").inlineAssert;
+const testing = std.testing;
+const tripwire = @import("../../tripwire.zig");
+const Allocator = std.mem.Allocator;
+const point = @import("../point.zig");
+const highlight = @import("../highlight.zig");
+const size = @import("../size.zig");
+const FlattenedHighlight = highlight.Flattened;
+const TrackedHighlight = highlight.Tracked;
+const PageList = @import("../PageList.zig");
+const Pin = PageList.Pin;
+const Screen = @import("../Screen.zig");
+const Terminal = @import("../Terminal.zig");
+const ActiveSearch = @import("active.zig").ActiveSearch;
+const PageListSearch = @import("pagelist.zig").PageListSearch;
+const SlidingWindow = @import("sliding_window.zig").SlidingWindow;
+
+const log = std.log.scoped(.search_screen);
+
+const reloadActive_tw = tripwire.module(enum {
+ history_append_new,
+ history_append_existing,
+}, ScreenSearch.reloadActive);
+
+/// Searches for a needle within a Screen, handling active area updates,
+/// pages being pruned from the screen (e.g. scrollback limits), and more.
+///
+/// Unlike our lower-level searchers (like ActiveSearch and PageListSearch),
+/// this will cache and store all search results so the caller can re-access
+/// them as needed. This structure does this because it is intended to help
+/// the caller handle the case where the Screen is changing while the user
+/// is searching.
+///
+/// An inactive screen can continue to be searched in the background, and when
+/// screen state changes, the renderer/caller can access the existing search
+/// results without needing to re-search everything. This prevents a particularly
+/// nasty UX where going to alt screen (e.g. neovim) and then back would
+/// restart the full scrollback search.
+pub const ScreenSearch = struct {
+ /// The screen being searched.
+ screen: *Screen,
+
+ /// The active area search state
+ active: ActiveSearch,
+
+ /// The history (scrollback) search state. May be null if there is
+ /// no history yet.
+ history: ?HistorySearch,
+
+ /// Current state of the search, a state machine.
+ state: State,
+
+ /// The currently selected match, if any. As the screen contents
+ /// change or get pruned, the screen search will do its best to keep
+ /// this accurate.
+ selected: ?SelectedMatch = null,
+
+ /// The results found so far. These are stored separately because history
+ /// is mostly immutable once found, while active area results may
+ /// change. This lets us easily reset the active area results for a
+ /// re-search scenario.
+ history_results: std.ArrayList(FlattenedHighlight),
+ active_results: std.ArrayList(FlattenedHighlight),
+
+ /// The dimensions of the screen. When this changes we need to
+ /// restart the whole search, currently.
+ rows: size.CellCountInt,
+ cols: size.CellCountInt,
+
+ pub const SelectedMatch = struct {
+ /// Index from the end of the match list (0 = most recent match)
+ idx: usize,
+
+ /// Tracked highlight so we can detect movement.
+ highlight: TrackedHighlight,
+
+ pub fn deinit(self: *SelectedMatch, screen: *Screen) void {
+ self.highlight.deinit(screen);
+ }
+ };
+
+ /// History search state.
+ const HistorySearch = struct {
+ /// The actual searcher state.
+ searcher: PageListSearch,
+
+ /// The pin for the first node that this searcher is searching from.
+ /// We use this when the active area changes to find the diff between
+ /// the top of the new active area and the previous start point
+ /// to determine if we need to search more history.
+ start_pin: *Pin,
+
+ pub fn deinit(self: *HistorySearch, screen: *Screen) void {
+ self.searcher.deinit();
+ screen.pages.untrackPin(self.start_pin);
+ }
+ };
+
+ /// Search state machine
+ const State = enum {
+ /// Currently searching the active area
+ active,
+
+ /// Currently searching the history area
+ history,
+
+ /// History search is waiting for more data to be fed before
+ /// it can progress.
+ history_feed,
+
+ /// Search is complete given the current terminal state.
+ complete,
+
+ pub fn isComplete(self: State) bool {
+ return switch (self) {
+ .complete => true,
+ else => false,
+ };
+ }
+
+ pub fn needsFeed(self: State) bool {
+ return switch (self) {
+ .history_feed => true,
+
+ // Not obvious but complete search states will prune
+ // stale history results on feed.
+ .complete => true,
+
+ else => false,
+ };
+ }
+ };
+
+ // Initialize a screen search for the given screen and needle.
+ pub fn init(
+ alloc: Allocator,
+ screen: *Screen,
+ needle_unowned: []const u8,
+ ) Allocator.Error!ScreenSearch {
+ var result: ScreenSearch = .{
+ .screen = screen,
+ .rows = screen.pages.rows,
+ .cols = screen.pages.cols,
+ .active = try .init(alloc, needle_unowned),
+ .history = null,
+ .state = .active,
+ .active_results = .empty,
+ .history_results = .empty,
+ };
+ errdefer result.deinit();
+
+ // Update our initial active area state
+ try result.reloadActive();
+
+ return result;
+ }
+
+ pub fn deinit(self: *ScreenSearch) void {
+ self.deinitInternal(true);
+ }
+
+ /// Release owned search state after the underlying Screen has already
+ /// been destroyed. Tracked pins belonged to that Screen's PageList pool
+ /// and were freed with it, so only independently owned memory is freed.
+ pub fn deinitScreenInvalid(self: *ScreenSearch) void {
+ self.deinitInternal(false);
+ }
+
+ fn deinitInternal(self: *ScreenSearch, screen_valid: bool) void {
+ const alloc = self.allocator();
+ self.active.deinit();
+ if (self.history) |*h| {
+ if (screen_valid) {
+ h.deinit(self.screen);
+ } else {
+ h.searcher.deinitListInvalid();
+ }
+ }
+ if (screen_valid) {
+ if (self.selected) |*m| m.deinit(self.screen);
+ }
+ for (self.active_results.items) |*hl| hl.deinit(alloc);
+ self.active_results.deinit(alloc);
+ for (self.history_results.items) |*hl| hl.deinit(alloc);
+ self.history_results.deinit(alloc);
+ }
+
+ fn allocator(self: *ScreenSearch) Allocator {
+ return self.active.window.alloc;
+ }
+
+ /// The needle that this search is using.
+ pub fn needle(self: *const ScreenSearch) []const u8 {
+ assert(self.active.window.direction == .forward);
+ return self.active.window.needle;
+ }
+
+ /// Returns the total number of matches found so far.
+ pub fn matchesLen(self: *const ScreenSearch) usize {
+ return self.active_results.items.len + self.history_results.items.len;
+ }
+
+ /// Reinitialize cached search state after a screen resize. Reflow can
+ /// replace every PageList node, so even flattened highlights that weren't
+ /// selected contain invalid node pointers once either dimension changes.
+ ///
+ /// Returns true when the reset occurred. The replacement search has
+ /// already loaded its active area through `init` in that case.
+ fn resetIfDimensionsChanged(self: *ScreenSearch) Allocator.Error!bool {
+ if (self.screen.pages.rows == self.rows and
+ self.screen.pages.cols == self.cols)
+ {
+ return false;
+ }
+
+ const new: ScreenSearch = try .init(
+ self.allocator(),
+ self.screen,
+ self.needle(),
+ );
+
+ self.deinit();
+ self.* = new;
+
+ assert(self.screen.pages.rows == self.rows);
+ assert(self.screen.pages.cols == self.cols);
+ return true;
+ }
+
+ /// Returns all matches as an owned slice (caller must free).
+ /// The matches are ordered from most recent to oldest (e.g. bottom
+ /// of the screen to top of the screen).
+ pub fn matches(
+ self: *ScreenSearch,
+ alloc: Allocator,
+ ) Allocator.Error![]FlattenedHighlight {
+ const active_results = self.active_results.items;
+ const history_results = self.history_results.items;
+ const results = try alloc.alloc(
+ FlattenedHighlight,
+ active_results.len + history_results.len,
+ );
+ errdefer alloc.free(results);
+
+ // Active does a forward search, so we add the active results then
+ // reverse them. There are usually not many active results so this
+ // is fast enough compared to adding them in reverse order.
+ assert(self.active.window.direction == .forward);
+ @memcpy(
+ results[0..active_results.len],
+ active_results,
+ );
+ std.mem.reverse(FlattenedHighlight, results[0..active_results.len]);
+
+ // History does a backward search, so we can just append them
+ // after.
+ @memcpy(
+ results[active_results.len..],
+ history_results,
+ );
+
+ return results;
+ }
+
+ /// Search the full screen state. This will block until the search
+ /// is complete. For performance, it is recommended to use `tick` and
+ /// `feed` to incrementally make progress on the search instead.
+ pub fn searchAll(self: *ScreenSearch) Allocator.Error!void {
+ while (true) {
+ self.tick() catch |err| switch (err) {
+ error.OutOfMemory => return error.OutOfMemory,
+ error.FeedRequired => try self.feed(),
+ error.SearchComplete => return,
+ };
+ }
+ }
+
+ pub const TickError = Allocator.Error || error{
+ FeedRequired,
+ SearchComplete,
+ };
+
+ /// Make incremental progress on the search without accessing any
+ /// screen state (so no lock is required).
+ ///
+ /// This will return error.FeedRequired if the search cannot make progress
+ /// without being fed more data. In this case, the caller should call
+ /// the `feed` function to provide more data to the searcher.
+ ///
+ /// This will return error.SearchComplete if the search is fully complete.
+ /// This is to signal to the caller that it can move to a more efficient
+ /// sleep/wait state until there is more work to do (e.g. new data to feed).
+ pub fn tick(self: *ScreenSearch) TickError!void {
+ switch (self.state) {
+ .active => try self.tickActive(),
+ .history => try self.tickHistory(),
+ .history_feed => return error.FeedRequired,
+ .complete => return error.SearchComplete,
+ }
+ }
+
+ /// Feed more data to the searcher so it can continue searching. This
+ /// accesses the screen state, so the caller must hold the necessary locks.
+ ///
+ /// Feed on a complete screen search will perform some cleanup of
+ /// potentially stale history results (pruned) and reclaim some memory.
+ pub fn feed(self: *ScreenSearch) Allocator.Error!void {
+ // Resize/reflow invalidates every flattened result, not just search
+ // state that needs another history feed.
+ _ = try self.resetIfDimensionsChanged();
+
+ const history: *PageListSearch = if (self.history) |*h| &h.searcher else {
+ // No history to feed, search is complete.
+ self.state = .complete;
+ return;
+ };
+
+ // Future: we may want to feed multiple pages at once here to
+ // lower the frequency of lock acquisitions.
+ if (!try history.feed()) {
+ // No more data to feed, search is complete.
+ self.state = .complete;
+
+ // We use this opportunity to also clean up older history
+ // results that may be gone due to scrollback pruning, though.
+ self.pruneHistory();
+
+ return;
+ }
+
+ // Depending on our state handle where feed goes
+ switch (self.state) {
+ // If we're searching active or history, then feeding doesn't
+ // change the state.
+ .active, .history => {},
+
+ // Feed goes back to searching history.
+ .history_feed => self.state = .history,
+
+ // If we're complete then the feed call above should always
+ // return false and we can't reach this.
+ .complete => unreachable,
+ }
+ }
+
+ fn historyResultIsValid(
+ self: *const ScreenSearch,
+ hl: *const FlattenedHighlight,
+ ) bool {
+ const chunks = hl.chunks.slice();
+ const nodes = chunks.items(.node);
+ const serials = chunks.items(.serial);
+ for (nodes, serials) |node, serial| {
+ if (!self.screen.pages.nodeIsValid(node, serial)) return false;
+ }
+
+ return true;
+ }
+
+ /// Clear a selected history match if its flattened page coordinates are
+ /// stale. reloadActive only inspects the selected history result, so this
+ /// avoids validating every cached result on each active-area refresh.
+ fn validateSelectedHistory(self: *ScreenSearch) void {
+ const m = if (self.selected) |*m| m else return;
+ const active_len = self.active_results.items.len;
+ if (m.idx < active_len) return;
+
+ const history_idx = m.idx - active_len;
+ if (history_idx < self.history_results.items.len and
+ self.historyResultIsValid(&self.history_results.items[history_idx]))
+ {
+ return;
+ }
+
+ m.deinit(self.screen);
+ self.selected = null;
+ }
+
+ fn pruneHistory(self: *ScreenSearch) void {
+ // Go through our history results in order (newest to oldest) to find
+ // any result that contains an invalid serial.
+ var i: usize = 0;
+ while (i < self.history_results.items.len) {
+ const hl = &self.history_results.items[i];
+ const chunks = hl.chunks.slice();
+ const serials = chunks.items(.serial);
+ const first_serial = serials[0];
+ if (first_serial < self.screen.pages.page_serial_epoch) {
+ // Only a whole-list reset advances the epoch. Results are
+ // newest to oldest, so this result and the entire remaining
+ // suffix predate that reset. Drop them without scanning the
+ // live page list for every captured chunk.
+ const alloc = self.allocator();
+ if (self.selected) |*m| {
+ const first_pruned = self.active_results.items.len + i;
+ if (m.idx >= first_pruned) {
+ m.deinit(self.screen);
+ self.selected = null;
+ }
+ }
+ for (self.history_results.items[i..]) |*prune_hl| prune_hl.deinit(alloc);
+ self.history_results.shrinkAndFree(alloc, i);
+ return;
+ }
+
+ // Ordinary pruning, layout changes, and replacements do not
+ // advance the epoch. Validate their pointer-plus-generation pairs
+ // against the live list.
+ if (self.historyResultIsValid(hl)) {
+ i += 1;
+ continue;
+ }
+
+ // Only this result is known to be invalid. Older results may live
+ // on unrelated pages and remain usable, so remove it individually.
+ const result_idx = self.active_results.items.len + i;
+ if (self.selected) |*m| {
+ if (m.idx == result_idx) {
+ m.deinit(self.screen);
+ self.selected = null;
+ } else if (m.idx > result_idx) {
+ m.idx -= 1;
+ }
+ }
+
+ var removed = self.history_results.orderedRemove(i);
+ removed.deinit(self.allocator());
+ }
+ }
+
+ fn tickActive(self: *ScreenSearch) Allocator.Error!void {
+ // For the active area, we consume the entire search in one go
+ // because the active area is generally small.
+ const alloc = self.allocator();
+ while (self.active.next()) |hl| {
+ // If this fails, then we miss a result since `active.next()`
+ // moves forward and prunes data. In the future, we may want
+ // to have some more robust error handling but the only
+ // scenario this would fail is OOM and we're probably in
+ // deeper trouble at that point anyways.
+ var hl_cloned = try hl.clone(alloc);
+ errdefer hl_cloned.deinit(alloc);
+ try self.active_results.append(alloc, hl_cloned);
+ }
+
+ // We've consumed the entire active area, move to history.
+ self.state = .history;
+ }
+
+ fn tickHistory(self: *ScreenSearch) Allocator.Error!void {
+ const history: *HistorySearch = if (self.history) |*h| h else {
+ // No history to search, we're done.
+ self.state = .complete;
+ return;
+ };
+
+ // Try to consume all the loaded matches in one go, because
+ // the search is generally fast for loaded data.
+ const alloc = self.allocator();
+ while (history.searcher.next()) |hl| {
+ // Ignore selections that are found within the starting
+ // node since those are covered by the active area search.
+ if (hl.chunks.items(.node)[0] == history.start_pin.node) continue;
+
+ // Same note as tickActive for error handling.
+ var hl_cloned = try hl.clone(alloc);
+ errdefer hl_cloned.deinit(alloc);
+ try self.history_results.append(alloc, hl_cloned);
+
+ // Since history only appends to our results in reverse order,
+ // we don't need to update any selected match state. The index
+ // and prior results are unaffected.
+ }
+
+ // We need to be fed more data.
+ self.state = .history_feed;
+ }
+
+ /// Reload the active area because it has changed.
+ ///
+ /// Since it is very fast, this will also do the full active area
+ /// search again, too. This avoids any complexity around the search
+ /// state machine.
+ ///
+ /// The caller must hold the necessary locks to access the screen state.
+ pub fn reloadActive(self: *ScreenSearch) Allocator.Error!void {
+ // This check must precede all inspection of cached highlights and
+ // searchers: column reflow may have freed every node they reference.
+ if (try self.resetIfDimensionsChanged()) return;
+
+ // reloadActive only inspects the selected history result, so validate
+ // that result without scanning every cached match on this hot path.
+ self.validateSelectedHistory();
+
+ const tw = reloadActive_tw;
+
+ // If our selection pin became garbage it means we scrolled off
+ // the end. Clear our selection and on exit of this function,
+ // try to select the last match.
+ const select_prev: bool = select_prev: {
+ const m = if (self.selected) |*m| m else break :select_prev false;
+ if (!m.highlight.start.garbage and
+ !m.highlight.end.garbage) break :select_prev false;
+
+ m.deinit(self.screen);
+ self.selected = null;
+ break :select_prev true;
+ };
+ defer if (select_prev) {
+ _ = self.select(.prev) catch |err| {
+ log.info("reload failed to reset search selection err={}", .{err});
+ };
+ };
+
+ const alloc = self.allocator();
+ const list: *PageList = &self.screen.pages;
+ if (try self.active.update(list)) |history_node| history: {
+ // We need to account for any active area growth that would
+ // cause new pages to move into our history. If there are new
+ // pages then we need to re-search the pages and add it to
+ // our history results.
+
+ // If our screen has no scrollback then we have no history.
+ if (self.screen.no_scrollback) {
+ assert(self.history == null);
+ break :history;
+ }
+
+ const history_: ?*HistorySearch = if (self.history) |*h| state: {
+ // If our start pin became garbage, it means we pruned all
+ // the way up through it, so we have no history anymore.
+ // Reset our history state.
+ if (h.start_pin.garbage) {
+ h.deinit(self.screen);
+ self.history = null;
+ for (self.history_results.items) |*hl| hl.deinit(alloc);
+ self.history_results.clearRetainingCapacity();
+ break :state null;
+ }
+
+ break :state h;
+ } else null;
+
+ const history = history_ orelse {
+ // No history search yet, but we now have history. So let's
+ // initialize.
+
+ var search: PageListSearch = try .init(
+ alloc,
+ self.needle(),
+ list,
+ history_node,
+ );
+ errdefer search.deinit();
+
+ const pin = try list.trackPin(.{ .node = history_node });
+ errdefer list.untrackPin(pin);
+
+ self.history = .{
+ .searcher = search,
+ .start_pin = pin,
+ };
+
+ // We don't need to update any history since we had no history
+ // before, so we can break out of the whole conditional.
+ break :history;
+ };
+
+ if (history.start_pin.node == history_node) {
+ // No change in the starting node, we're done.
+ break :history;
+ }
+
+ // Do a forward search from our prior node to this one. We
+ // collect all the results into a new list. We ASSUME that
+ // reloadActive is being called frequently enough that there isn't
+ // a massive amount of history to search here.
+ var window: SlidingWindow = try .init(
+ alloc,
+ .forward,
+ self.needle(),
+ );
+ defer window.deinit();
+ while (true) {
+ _ = try window.append(history.start_pin.node);
+ if (history.start_pin.node == history_node) break;
+ const next = history.start_pin.node.next orelse break;
+ history.start_pin.node = next;
+ }
+ assert(history.start_pin.node == history_node);
+
+ var results: std.ArrayList(FlattenedHighlight) = try .initCapacity(
+ alloc,
+ self.history_results.items.len,
+ );
+ errdefer {
+ for (results.items) |*hl| hl.deinit(alloc);
+ results.deinit(alloc);
+ }
+ while (window.next()) |hl| {
+ if (hl.chunks.items(.node)[0] == history_node) continue;
+
+ var hl_cloned = try hl.clone(alloc);
+ errdefer hl_cloned.deinit(alloc);
+ try tw.check(.history_append_new);
+ try results.append(alloc, hl_cloned);
+ }
+
+ // If we have no matches then there is nothing to change
+ // in our history (fast path)
+ if (results.items.len == 0) break :history;
+
+ // The number added to our history. Needed for updating
+ // our selection if we have one.
+ const added_len = results.items.len;
+
+ // Matches! Reverse our list then append all the remaining
+ // history items that didn't start on our original node.
+ std.mem.reverse(FlattenedHighlight, results.items);
+ try tw.check(.history_append_existing);
+ try results.appendSlice(alloc, self.history_results.items);
+ self.history_results.deinit(alloc);
+ self.history_results = results;
+
+ // If our prior selection was in the history area, update
+ // the offset.
+ if (self.selected) |*m| selected: {
+ const active_len = self.active_results.items.len;
+ if (m.idx < active_len) break :selected;
+ m.idx += added_len;
+
+ // Moving the idx should not change our targeted result
+ // since the history is immutable.
+ if (comptime std.debug.runtime_safety) {
+ const hl = self.history_results.items[m.idx - active_len];
+ assert(m.highlight.start.eql(hl.startPin()));
+ }
+ }
+ } else {
+ // No history node means we have no history
+ if (self.history) |*h| {
+ h.deinit(self.screen);
+ self.history = null;
+ for (self.history_results.items) |*hl| hl.deinit(alloc);
+ self.history_results.clearRetainingCapacity();
+ }
+
+ // If we have a selection in the history area, we need to
+ // move it to the end of the active area.
+ if (self.selected) |*m| selected: {
+ const active_len = self.active_results.items.len;
+ if (m.idx < active_len) break :selected;
+ m.deinit(self.screen);
+ self.selected = null;
+ _ = self.select(.prev) catch |err| {
+ log.info("reload failed to reset search selection err={}", .{err});
+ };
+ }
+ }
+
+ // Figure out if we need to fixup our selection later because
+ // it was in the active area.
+ const old_active_len = self.active_results.items.len;
+ const old_selection_idx: ?usize = if (self.selected) |m| m.idx else null;
+ errdefer if (old_selection_idx != null and
+ old_selection_idx.? < old_active_len)
+ {
+ // This is the error scenario. If something fails below,
+ // our active area is probably gone, so we just go back
+ // to the first result because our selection can't be trusted.
+ if (self.selected) |*m| {
+ m.deinit(self.screen);
+ self.selected = null;
+ _ = self.select(.next) catch |err| {
+ log.info("reload failed to reset search selection err={}", .{err});
+ };
+ }
+ };
+
+ // Reset our active search results and search again.
+ for (self.active_results.items) |*hl| hl.deinit(alloc);
+ self.active_results.clearRetainingCapacity();
+ switch (self.state) {
+ // If we're in the active state we run a normal tick so
+ // we can move into a better state.
+ .active => try self.tickActive(),
+
+ // Otherwise, just tick it and move back to whatever state
+ // we were in.
+ else => {
+ const old_state = self.state;
+ defer self.state = old_state;
+ try self.tickActive();
+ },
+ }
+
+ // If we have no scrollback, we need to prune any active results
+ // that aren't in the actual active area. We only do this for the
+ // no scrollback scenario because with scrollback we actually
+ // rely on our active search searching by page to find history
+ // items as well. This is all related to the fact that PageList
+ // scrollback limits are discrete by page size except we special
+ // case zero.
+ if (self.screen.no_scrollback and
+ self.active_results.items.len > 0)
+ active_prune: {
+ const items = self.active_results.items;
+ const tl = self.screen.pages.getTopLeft(.active);
+ for (0.., items) |i, *hl| {
+ if (!tl.before(hl.endPin())) {
+ // Deinit because its going to be pruned no matter
+ // what at some point for not being in the active area.
+ hl.deinit(alloc);
+ continue;
+ }
+
+ // In the active area! Since our results are sorted
+ // that means everything after this is also in the active
+ // area, so we prune up to this i.
+ if (i > 0) self.active_results.replaceRangeAssumeCapacity(
+ 0,
+ i,
+ &.{},
+ );
+
+ break :active_prune;
+ }
+
+ // None are in the active area...
+ self.active_results.clearRetainingCapacity();
+ }
+
+ // Now we have to fixup our selection if we had one.
+ fixup: {
+ const old_idx = old_selection_idx orelse break :fixup;
+ const m = if (self.selected) |*m| m else break :fixup;
+
+ // If our old selection wasn't in the active area, then we
+ // need to fix up our offsets.
+ if (old_idx >= old_active_len) {
+ m.idx -= old_active_len;
+ m.idx += self.active_results.items.len;
+ break :fixup;
+ }
+
+ // We search for the matching highlight in the new active results.
+ for (0.., self.active_results.items) |i, hl| {
+ const untracked = hl.untracked();
+ if (m.highlight.start.eql(untracked.start) and
+ m.highlight.end.eql(untracked.end))
+ {
+ // Found it! Update our index.
+ m.idx = self.active_results.items.len - 1 - i;
+ break :fixup;
+ }
+ }
+
+ // No match, just go back to the first match.
+ m.deinit(self.screen);
+ self.selected = null;
+ _ = self.select(.next) catch |err| {
+ log.info("reload failed to reset search selection err={}", .{err});
+ };
+ }
+ }
+
+ /// 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;
+ }
+
+ pub const Select = enum {
+ /// Next selection, in reverse order (newest to oldest),
+ /// non-wrapping.
+ next,
+
+ /// Prev selection, in forward order (oldest to newest),
+ /// non-wrapping.
+ prev,
+ };
+
+ /// Select the next or previous search result. This requires read/write
+ /// access to the underlying screen, since we utilize tracked pins to
+ /// ensure our selection sticks with contents changing.
+ pub fn select(self: *ScreenSearch, to: Select) Allocator.Error!bool {
+ // A resize can replace any page node, including nodes retained by
+ // flattened history results. Reset before reloadActive or tracking a
+ // selected result so no pre-resize pointer is ever dereferenced.
+ _ = try self.resetIfDimensionsChanged();
+
+ // Reload validates the selected history result before inspecting its
+ // cached coordinates. Prune the remaining results afterward so every
+ // candidate is valid before selection tracks it.
+ try self.reloadActive();
+ self.pruneHistory();
+
+ return switch (to) {
+ .next => try self.selectNext(),
+ .prev => try self.selectPrev(),
+ };
+ }
+
+ fn selectNext(self: *ScreenSearch) Allocator.Error!bool {
+ // Get our previous match so we can change it. If we have no
+ // prior match, we have the easy task of getting the first.
+ var prev = if (self.selected) |*m| m else {
+ // Get our highlight
+ const hl: FlattenedHighlight = hl: {
+ if (self.active_results.items.len > 0) {
+ // Active is in forward order
+ const len = self.active_results.items.len;
+ break :hl self.active_results.items[len - 1];
+ } else if (self.history_results.items.len > 0) {
+ // History is in reverse order
+ break :hl self.history_results.items[0];
+ } else {
+ // No matches at all. Can't select anything.
+ return false;
+ }
+ };
+
+ // Pin it so we can track any movement
+ const tracked = try hl.untracked().track(self.screen);
+ errdefer tracked.deinit(self.screen);
+
+ // Our selection is index zero since we just started and
+ // we store our selection.
+ self.selected = .{
+ .idx = 0,
+ .highlight = tracked,
+ };
+ return true;
+ };
+
+ const active_len = self.active_results.items.len;
+ const history_len = self.history_results.items.len;
+ const next_idx = if (prev.idx + 1 >= active_len + history_len) 0 else prev.idx + 1;
+ const hl: FlattenedHighlight = if (next_idx < active_len)
+ self.active_results.items[active_len - 1 - next_idx]
+ else
+ self.history_results.items[next_idx - active_len];
+
+ // Pin it so we can track any movement
+ const tracked = try hl.untracked().track(self.screen);
+ errdefer tracked.deinit(self.screen);
+
+ // Free our previous match and setup our new selection
+ prev.deinit(self.screen);
+ self.selected = .{
+ .idx = next_idx,
+ .highlight = tracked,
+ };
+
+ return true;
+ }
+
+ fn selectPrev(self: *ScreenSearch) Allocator.Error!bool {
+ // Get our previous match so we can change it. If we have no
+ // prior match, we have the easy task of getting the last.
+ var prev = if (self.selected) |*m| m else {
+ // Get our highlight (oldest match)
+ const hl: FlattenedHighlight = hl: {
+ if (self.history_results.items.len > 0) {
+ // History is in reverse order, so last item is oldest
+ const len = self.history_results.items.len;
+ break :hl self.history_results.items[len - 1];
+ } else if (self.active_results.items.len > 0) {
+ // Active is in forward order, so first item is oldest
+ break :hl self.active_results.items[0];
+ } else {
+ // No matches at all. Can't select anything.
+ return false;
+ }
+ };
+
+ // Pin it so we can track any movement
+ const tracked = try hl.untracked().track(self.screen);
+ errdefer tracked.deinit(self.screen);
+
+ // Our selection is the last index since we just started
+ // and we store our selection.
+ const active_len = self.active_results.items.len;
+ const history_len = self.history_results.items.len;
+ self.selected = .{
+ .idx = active_len + history_len - 1,
+ .highlight = tracked,
+ };
+ return true;
+ };
+
+ const active_len = self.active_results.items.len;
+ const history_len = self.history_results.items.len;
+ const next_idx = if (prev.idx != 0) prev.idx - 1 else active_len + history_len - 1;
+
+ const hl: FlattenedHighlight = if (next_idx < active_len)
+ self.active_results.items[active_len - 1 - next_idx]
+ else
+ self.history_results.items[next_idx - active_len];
+
+ // Pin it so we can track any movement
+ const tracked = try hl.untracked().track(self.screen);
+ errdefer tracked.deinit(self.screen);
+
+ // Free our previous match and setup our new selection
+ prev.deinit(self.screen);
+ self.selected = .{
+ .idx = next_idx,
+ .highlight = tracked,
+ };
+
+ return true;
+ }
+};
+
+test "simple search" {
+ const alloc = testing.allocator;
+ const io = testing.io;
+ var t: Terminal = try .init(io, alloc, .{ .cols = 10, .rows = 2 });
+ defer t.deinit(alloc);
+
+ var s = t.vtStream();
+ defer s.deinit();
+ s.nextSlice("Fizz\r\nBuzz\r\nFizz\r\nBang");
+
+ var search: ScreenSearch = try .init(alloc, t.screens.active, "Fizz");
+ defer search.deinit();
+ try search.searchAll();
+ try testing.expectEqual(2, search.active_results.items.len);
+ // We don't test history results since there is overlap
+
+ // Get all matches
+ const matches = try search.matches(alloc);
+ defer alloc.free(matches);
+ try testing.expectEqual(2, matches.len);
+
+ {
+ const sel = matches[0].untracked();
+ try testing.expectEqual(point.Point{ .screen = .{
+ .x = 0,
+ .y = 2,
+ } }, t.screens.active.pages.pointFromPin(.screen, sel.start).?);
+ try testing.expectEqual(point.Point{ .screen = .{
+ .x = 3,
+ .y = 2,
+ } }, t.screens.active.pages.pointFromPin(.screen, sel.end).?);
+ }
+ {
+ const sel = matches[1].untracked();
+ try testing.expectEqual(point.Point{ .screen = .{
+ .x = 0,
+ .y = 0,
+ } }, t.screens.active.pages.pointFromPin(.screen, sel.start).?);
+ try testing.expectEqual(point.Point{ .screen = .{
+ .x = 3,
+ .y = 0,
+ } }, t.screens.active.pages.pointFromPin(.screen, sel.end).?);
+ }
+}
+
+test "simple search with history" {
+ 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);
+ const list: *PageList = &t.screens.active.pages;
+
+ var s = t.vtStream();
+ defer s.deinit();
+
+ s.nextSlice("Fizz\r\n");
+ while (list.totalPages() < 3) s.nextSlice("\r\n");
+ for (0..list.rows) |_| s.nextSlice("\r\n");
+ s.nextSlice("hello.");
+
+ var search: ScreenSearch = try .init(alloc, t.screens.active, "Fizz");
+ defer search.deinit();
+ try search.searchAll();
+ try testing.expectEqual(0, search.active_results.items.len);
+
+ // Get all matches
+ const matches = try search.matches(alloc);
+ defer alloc.free(matches);
+ try testing.expectEqual(1, matches.len);
+
+ {
+ const sel = matches[0].untracked();
+ try testing.expectEqual(point.Point{ .screen = .{
+ .x = 0,
+ .y = 0,
+ } }, t.screens.active.pages.pointFromPin(.screen, sel.start).?);
+ try testing.expectEqual(point.Point{ .screen = .{
+ .x = 3,
+ .y = 0,
+ } }, t.screens.active.pages.pointFromPin(.screen, sel.end).?);
+ }
+}
+
+test "reload active with history change" {
+ 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);
+ const list: *PageList = &t.screens.active.pages;
+
+ var s = t.vtStream();
+ defer s.deinit();
+ s.nextSlice("Fizz\r\n");
+
+ // Start up our search which will populate our initial active area.
+ var search: ScreenSearch = try .init(alloc, t.screens.active, "Fizz");
+ defer search.deinit();
+ try search.searchAll();
+ {
+ const matches = try search.matches(alloc);
+ defer alloc.free(matches);
+ try testing.expectEqual(1, matches.len);
+ }
+
+ // Grow into two pages so our history pin will move.
+ while (list.totalPages() < 2) s.nextSlice("\r\n");
+ for (0..list.rows) |_| s.nextSlice("\r\n");
+ s.nextSlice("2Fizz");
+
+ // Active area changed so reload
+ try search.reloadActive();
+ try search.searchAll();
+
+ // Get all matches
+ {
+ const matches = try search.matches(alloc);
+ defer alloc.free(matches);
+ try testing.expectEqual(2, matches.len);
+ {
+ const sel = matches[1].untracked();
+ try testing.expectEqual(point.Point{ .screen = .{
+ .x = 0,
+ .y = 0,
+ } }, t.screens.active.pages.pointFromPin(.screen, sel.start).?);
+ try testing.expectEqual(point.Point{ .screen = .{
+ .x = 3,
+ .y = 0,
+ } }, t.screens.active.pages.pointFromPin(.screen, sel.end).?);
+ }
+ {
+ const sel = matches[0].untracked();
+ try testing.expectEqual(point.Point{ .active = .{
+ .x = 1,
+ .y = 1,
+ } }, t.screens.active.pages.pointFromPin(.active, sel.start).?);
+ try testing.expectEqual(point.Point{ .active = .{
+ .x = 4,
+ .y = 1,
+ } }, t.screens.active.pages.pointFromPin(.active, sel.end).?);
+ }
+ }
+
+ // Reset the screen which will make our pin garbage.
+ t.fullReset();
+ s.nextSlice("WeFizzing");
+ try search.reloadActive();
+ try search.searchAll();
+
+ {
+ const matches = try search.matches(alloc);
+ defer alloc.free(matches);
+ try testing.expectEqual(1, matches.len);
+ {
+ const sel = matches[0].untracked();
+ try testing.expectEqual(point.Point{ .active = .{
+ .x = 2,
+ .y = 0,
+ } }, t.screens.active.pages.pointFromPin(.active, sel.start).?);
+ try testing.expectEqual(point.Point{ .active = .{
+ .x = 5,
+ .y = 0,
+ } }, t.screens.active.pages.pointFromPin(.active, sel.end).?);
+ }
+ }
+}
+
+test "active change contents" {
+ const alloc = testing.allocator;
+ const io = testing.io;
+ var t: Terminal = try .init(io, alloc, .{ .cols = 10, .rows = 5 });
+ defer t.deinit(alloc);
+
+ var s = t.vtStream();
+ defer s.deinit();
+ s.nextSlice("Fuzz\r\nBuzz\r\nFizz\r\nBang");
+
+ var search: ScreenSearch = try .init(alloc, t.screens.active, "Fizz");
+ defer search.deinit();
+ try search.searchAll();
+ try testing.expectEqual(1, search.active_results.items.len);
+
+ // Erase the screen, move our cursor to the top, and change contents.
+ s.nextSlice("\x1b[2J\x1b[H"); // Clear screen and move home
+ s.nextSlice("Bang\r\nFizz\r\nHello!");
+
+ try search.reloadActive();
+ try search.searchAll();
+ try testing.expectEqual(1, search.active_results.items.len);
+
+ // Get all matches
+ const matches = try search.matches(alloc);
+ defer alloc.free(matches);
+ try testing.expectEqual(1, matches.len);
+
+ {
+ const sel = matches[0].untracked();
+ try testing.expectEqual(point.Point{ .screen = .{
+ .x = 0,
+ .y = 1,
+ } }, t.screens.active.pages.pointFromPin(.screen, sel.start).?);
+ try testing.expectEqual(point.Point{ .screen = .{
+ .x = 3,
+ .y = 1,
+ } }, t.screens.active.pages.pointFromPin(.screen, sel.end).?);
+ }
+}
+
+test "select next" {
+ const alloc = testing.allocator;
+ const io = testing.io;
+ var t: Terminal = try .init(io, alloc, .{ .cols = 10, .rows = 2 });
+ defer t.deinit(alloc);
+
+ var s = t.vtStream();
+ defer s.deinit();
+ s.nextSlice("Fizz\r\nBuzz\r\nFizz\r\nBang");
+
+ var search: ScreenSearch = try .init(alloc, t.screens.active, "Fizz");
+ defer search.deinit();
+
+ // Initially no selection
+ try testing.expect(search.selectedMatch() == null);
+
+ // Select our next match (first)
+ try search.searchAll();
+ _ = try search.select(.next);
+ {
+ const sel = search.selectedMatch().?.untracked();
+ try testing.expectEqual(point.Point{ .screen = .{
+ .x = 0,
+ .y = 2,
+ } }, t.screens.active.pages.pointFromPin(.screen, sel.start).?);
+ try testing.expectEqual(point.Point{ .screen = .{
+ .x = 3,
+ .y = 2,
+ } }, t.screens.active.pages.pointFromPin(.screen, sel.end).?);
+ }
+
+ // Next match
+ _ = try search.select(.next);
+ {
+ const sel = search.selectedMatch().?.untracked();
+ try testing.expectEqual(point.Point{ .screen = .{
+ .x = 0,
+ .y = 0,
+ } }, t.screens.active.pages.pointFromPin(.screen, sel.start).?);
+ try testing.expectEqual(point.Point{ .screen = .{
+ .x = 3,
+ .y = 0,
+ } }, t.screens.active.pages.pointFromPin(.screen, sel.end).?);
+ }
+
+ // Next match (wrap)
+ _ = try search.select(.next);
+ {
+ const sel = search.selectedMatch().?.untracked();
+ try testing.expectEqual(point.Point{ .screen = .{
+ .x = 0,
+ .y = 2,
+ } }, t.screens.active.pages.pointFromPin(.screen, sel.start).?);
+ try testing.expectEqual(point.Point{ .screen = .{
+ .x = 3,
+ .y = 2,
+ } }, t.screens.active.pages.pointFromPin(.screen, sel.end).?);
+ }
+}
+
+test "select after resize resets stale flattened results" {
+ const alloc = testing.allocator;
+ const io = testing.io;
+ var t: Terminal = try .init(io, alloc, .{
+ .cols = 10,
+ .rows = 3,
+ .max_scrollback_bytes = std.math.maxInt(usize),
+ });
+ defer t.deinit(alloc);
+
+ var stream = t.vtStream();
+ defer stream.deinit();
+ stream.nextSlice("Fizz\r\nBuzz");
+
+ var search: ScreenSearch = try .init(alloc, t.screens.active, "Fizz");
+ defer search.deinit();
+ try search.searchAll();
+ try testing.expect(try search.select(.next));
+ try testing.expect(search.selectedMatch() != null);
+ try testing.expectEqual(@as(size.CellCountInt, 10), search.cols);
+
+ // Column reflow replaces the nodes referenced by the cached flattened
+ // active result. select() calls reloadActive(), so dimension invalidation
+ // must run before either path dereferences those old nodes.
+ try t.screens.active.pages.resize(.{ .cols = 5 });
+ try testing.expectEqual(@as(size.CellCountInt, 10), search.cols);
+
+ try testing.expect(try search.select(.next));
+ try testing.expectEqual(@as(size.CellCountInt, 5), search.cols);
+
+ const selected = search.selectedMatch().?.untracked();
+ try testing.expectEqual(@as(size.CellCountInt, 5), selected.start.node.cols());
+ try testing.expectEqual(@as(size.CellCountInt, 5), selected.end.node.cols());
+}
+
+test "select in active changes contents completely" {
+ const alloc = testing.allocator;
+ const io = testing.io;
+ var t: Terminal = try .init(io, alloc, .{ .cols = 10, .rows = 5 });
+ defer t.deinit(alloc);
+
+ var s = t.vtStream();
+ defer s.deinit();
+ s.nextSlice("Fizz\r\nBuzz\r\nFizz\r\nBang");
+
+ var search: ScreenSearch = try .init(alloc, t.screens.active, "Fizz");
+ defer search.deinit();
+ try search.searchAll();
+ _ = try search.select(.next);
+ _ = try search.select(.next);
+ {
+ // Initial selection is the first fizz
+ const sel = search.selectedMatch().?.untracked();
+ try testing.expectEqual(point.Point{ .screen = .{
+ .x = 0,
+ .y = 0,
+ } }, t.screens.active.pages.pointFromPin(.screen, sel.start).?);
+ try testing.expectEqual(point.Point{ .screen = .{
+ .x = 3,
+ .y = 0,
+ } }, t.screens.active.pages.pointFromPin(.screen, sel.end).?);
+ }
+
+ // Erase the screen, move our cursor to the top, and change contents.
+ s.nextSlice("\x1b[2J\x1b[H"); // Clear screen and move home
+ s.nextSlice("Fuzz\r\nFizz\r\nHello!");
+
+ try search.reloadActive();
+ {
+ // Our selection should move to the first
+ const sel = search.selectedMatch().?.untracked();
+ try testing.expectEqual(point.Point{ .screen = .{
+ .x = 0,
+ .y = 1,
+ } }, t.screens.active.pages.pointFromPin(.screen, sel.start).?);
+ try testing.expectEqual(point.Point{ .screen = .{
+ .x = 3,
+ .y = 1,
+ } }, t.screens.active.pages.pointFromPin(.screen, sel.end).?);
+ }
+
+ // Erase the screen, redraw with same contents.
+ s.nextSlice("\x1b[2J\x1b[H"); // Clear screen and move home
+ s.nextSlice("Fuzz\r\nFizz\r\nFizz");
+
+ try search.reloadActive();
+ {
+ // Our selection should not move to the first
+ const sel = search.selectedMatch().?.untracked();
+ try testing.expectEqual(point.Point{ .screen = .{
+ .x = 0,
+ .y = 1,
+ } }, t.screens.active.pages.pointFromPin(.screen, sel.start).?);
+ try testing.expectEqual(point.Point{ .screen = .{
+ .x = 3,
+ .y = 1,
+ } }, t.screens.active.pages.pointFromPin(.screen, sel.end).?);
+ }
+}
+
+test "select into history" {
+ 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);
+ const list: *PageList = &t.screens.active.pages;
+
+ var s = t.vtStream();
+ defer s.deinit();
+
+ s.nextSlice("Fizz\r\n");
+ while (list.totalPages() < 3) s.nextSlice("\r\n");
+ for (0..list.rows) |_| s.nextSlice("\r\n");
+ s.nextSlice("hello.");
+
+ var search: ScreenSearch = try .init(alloc, t.screens.active, "Fizz");
+ defer search.deinit();
+ try search.searchAll();
+
+ // Get all matches
+ _ = try search.select(.next);
+ {
+ const sel = search.selectedMatch().?.untracked();
+ try testing.expectEqual(point.Point{ .screen = .{
+ .x = 0,
+ .y = 0,
+ } }, t.screens.active.pages.pointFromPin(.screen, sel.start).?);
+ try testing.expectEqual(point.Point{ .screen = .{
+ .x = 3,
+ .y = 0,
+ } }, t.screens.active.pages.pointFromPin(.screen, sel.end).?);
+ }
+
+ // Erase the screen, redraw with same contents.
+ s.nextSlice("\x1b[2J\x1b[H"); // Clear screen and move home
+ s.nextSlice("yo yo");
+
+ try search.reloadActive();
+ {
+ // Our selection should not move since the history is still active.
+ const sel = search.selectedMatch().?.untracked();
+ try testing.expectEqual(point.Point{ .screen = .{
+ .x = 0,
+ .y = 0,
+ } }, t.screens.active.pages.pointFromPin(.screen, sel.start).?);
+ try testing.expectEqual(point.Point{ .screen = .{
+ .x = 3,
+ .y = 0,
+ } }, t.screens.active.pages.pointFromPin(.screen, sel.end).?);
+ }
+
+ // Create some new history by adding more lines.
+ s.nextSlice("\r\nfizz\r\nfizz\r\nfizz"); // Clear screen and move home
+ try search.reloadActive();
+ {
+ // Our selection should not move since the history is still not
+ // pruned.
+ const sel = search.selectedMatch().?.untracked();
+ try testing.expectEqual(point.Point{ .screen = .{
+ .x = 0,
+ .y = 0,
+ } }, t.screens.active.pages.pointFromPin(.screen, sel.start).?);
+ try testing.expectEqual(point.Point{ .screen = .{
+ .x = 3,
+ .y = 0,
+ } }, t.screens.active.pages.pointFromPin(.screen, sel.end).?);
+ }
+}
+
+test "select prev" {
+ const alloc = testing.allocator;
+ const io = testing.io;
+ var t: Terminal = try .init(io, alloc, .{ .cols = 10, .rows = 2 });
+ defer t.deinit(alloc);
+
+ var s = t.vtStream();
+ defer s.deinit();
+ s.nextSlice("Fizz\r\nBuzz\r\nFizz\r\nBang");
+
+ var search: ScreenSearch = try .init(alloc, t.screens.active, "Fizz");
+ defer search.deinit();
+
+ // Initially no selection
+ try testing.expect(search.selectedMatch() == null);
+
+ // Select prev (oldest first)
+ try search.searchAll();
+ _ = try search.select(.prev);
+ {
+ const sel = search.selectedMatch().?.untracked();
+ try testing.expectEqual(point.Point{ .screen = .{
+ .x = 0,
+ .y = 0,
+ } }, t.screens.active.pages.pointFromPin(.screen, sel.start).?);
+ try testing.expectEqual(point.Point{ .screen = .{
+ .x = 3,
+ .y = 0,
+ } }, t.screens.active.pages.pointFromPin(.screen, sel.end).?);
+ }
+
+ // Prev match (towards newest)
+ _ = try search.select(.prev);
+ {
+ const sel = search.selectedMatch().?.untracked();
+ try testing.expectEqual(point.Point{ .screen = .{
+ .x = 0,
+ .y = 2,
+ } }, t.screens.active.pages.pointFromPin(.screen, sel.start).?);
+ try testing.expectEqual(point.Point{ .screen = .{
+ .x = 3,
+ .y = 2,
+ } }, t.screens.active.pages.pointFromPin(.screen, sel.end).?);
+ }
+
+ // Prev match (wrap)
+ _ = try search.select(.prev);
+ {
+ const sel = search.selectedMatch().?.untracked();
+ try testing.expectEqual(point.Point{ .screen = .{
+ .x = 0,
+ .y = 0,
+ } }, t.screens.active.pages.pointFromPin(.screen, sel.start).?);
+ try testing.expectEqual(point.Point{ .screen = .{
+ .x = 3,
+ .y = 0,
+ } }, t.screens.active.pages.pointFromPin(.screen, sel.end).?);
+ }
+}
+
+test "select prev then next" {
+ const alloc = testing.allocator;
+ const io = testing.io;
+ var t: Terminal = try .init(io, alloc, .{ .cols = 10, .rows = 2 });
+ defer t.deinit(alloc);
+
+ var s = t.vtStream();
+ defer s.deinit();
+ s.nextSlice("Fizz\r\nBuzz\r\nFizz\r\nBang");
+
+ var search: ScreenSearch = try .init(alloc, t.screens.active, "Fizz");
+ defer search.deinit();
+ try search.searchAll();
+
+ // Select next (newest first)
+ _ = try search.select(.next);
+ {
+ const sel = search.selectedMatch().?.untracked();
+ try testing.expectEqual(point.Point{ .screen = .{
+ .x = 0,
+ .y = 2,
+ } }, t.screens.active.pages.pointFromPin(.screen, sel.start).?);
+ }
+
+ // Select next (older)
+ _ = try search.select(.next);
+ {
+ const sel = search.selectedMatch().?.untracked();
+ try testing.expectEqual(point.Point{ .screen = .{
+ .x = 0,
+ .y = 0,
+ } }, t.screens.active.pages.pointFromPin(.screen, sel.start).?);
+ }
+
+ // Select prev (back to newer)
+ _ = try search.select(.prev);
+ {
+ const sel = search.selectedMatch().?.untracked();
+ try testing.expectEqual(point.Point{ .screen = .{
+ .x = 0,
+ .y = 2,
+ } }, t.screens.active.pages.pointFromPin(.screen, sel.start).?);
+ }
+}
+
+test "select prev with history" {
+ 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);
+ const list: *PageList = &t.screens.active.pages;
+
+ var s = t.vtStream();
+ defer s.deinit();
+
+ s.nextSlice("Fizz\r\n");
+ while (list.totalPages() < 3) s.nextSlice("\r\n");
+ for (0..list.rows) |_| s.nextSlice("\r\n");
+ s.nextSlice("Fizz.");
+
+ var search: ScreenSearch = try .init(alloc, t.screens.active, "Fizz");
+ defer search.deinit();
+ try search.searchAll();
+
+ // Select prev (oldest first, should be in history)
+ _ = try search.select(.prev);
+ {
+ const sel = search.selectedMatch().?.untracked();
+ try testing.expectEqual(point.Point{ .screen = .{
+ .x = 0,
+ .y = 0,
+ } }, t.screens.active.pages.pointFromPin(.screen, sel.start).?);
+ try testing.expectEqual(point.Point{ .screen = .{
+ .x = 3,
+ .y = 0,
+ } }, t.screens.active.pages.pointFromPin(.screen, sel.end).?);
+ }
+
+ // Select prev (towards newer, should move to active area)
+ _ = try search.select(.prev);
+ {
+ const sel = search.selectedMatch().?.untracked();
+ try testing.expectEqual(point.Point{ .active = .{
+ .x = 0,
+ .y = 1,
+ } }, t.screens.active.pages.pointFromPin(.active, sel.start).?);
+ try testing.expectEqual(point.Point{ .active = .{
+ .x = 3,
+ .y = 1,
+ } }, t.screens.active.pages.pointFromPin(.active, sel.end).?);
+ }
+}
+
+test "select prev wraps when all matches are in history" {
+ // Regression test: when every match is in scrollback (the active area
+ // has none, so active_len == 0), selecting prev from index 0 must wrap
+ // to the last result without underflowing `active_len - 1`.
+ 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);
+ const list: *PageList = &t.screens.active.pages;
+
+ var s = t.vtStream();
+ defer s.deinit();
+
+ // Put the only match in scrollback, then scroll the active area to all
+ // blank lines so it contains no match (active_len == 0, history_len == 1).
+ s.nextSlice("Fizz\r\n");
+ while (list.totalPages() < 3) s.nextSlice("\r\n");
+ for (0..list.rows) |_| s.nextSlice("\r\n");
+
+ var search: ScreenSearch = try .init(alloc, t.screens.active, "Fizz");
+ defer search.deinit();
+ try search.searchAll();
+ try testing.expectEqual(0, search.active_results.items.len);
+
+ // Select the first match (idx 0), then wrap backwards. This must not
+ // panic and must keep a valid selection.
+ _ = try search.select(.next);
+ _ = try search.select(.prev);
+ try testing.expect(search.selectedMatch() != null);
+}
+
+test "select after all matches disappear drops the selection" {
+ // The wrap arithmetic in selectPrev (active_len + history_len - 1) would
+ // underflow if a selection were ever live while both result lists are
+ // empty. This guards the invariant that makes that unreachable: when a
+ // reload/prune empties the results, the selection is dropped, so the next
+ // select() hits the "no matches" guard instead of the wrap arithmetic.
+ const alloc = testing.allocator;
+ const io = testing.io;
+ var t: Terminal = try .init(io, alloc, .{ .cols = 10, .rows = 2 });
+ defer t.deinit(alloc);
+
+ var s = t.vtStream();
+ defer s.deinit();
+ s.nextSlice("Fizz");
+
+ var search: ScreenSearch = try .init(alloc, t.screens.active, "Fizz");
+ defer search.deinit();
+ try search.searchAll();
+ try testing.expectEqual(1, search.active_results.items.len);
+
+ // Take a selection, then overwrite the only match so a reload finds none
+ // (active and history both empty).
+ _ = try search.select(.next);
+ try testing.expect(search.selectedMatch() != null);
+ s.nextSlice("\x1b[1;1H ");
+
+ // Must not underflow; the selection is dropped and nothing is selected.
+ _ = try search.select(.prev);
+ try testing.expect(search.selectedMatch() == null);
+ try testing.expectEqual(0, search.active_results.items.len);
+ try testing.expectEqual(0, search.history_results.items.len);
+}
+
+test "select after partial history erase drops a pruned selection" {
+ 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);
+ const list: *PageList = &t.screens.active.pages;
+
+ var stream = t.vtStream();
+ defer stream.deinit();
+
+ stream.nextSlice("error\r\n");
+ const first = list.pages.first.?;
+ while (list.totalPages() < 3) stream.nextSlice("\r\n");
+ const first_rows = first.rows();
+
+ var search: ScreenSearch = try .init(alloc, t.screens.active, "error");
+ defer search.deinit();
+ try search.searchAll();
+ try testing.expectEqual(0, search.active_results.items.len);
+ try testing.expectEqual(1, search.history_results.items.len);
+ try testing.expect(try search.select(.next));
+
+ list.eraseHistory(.{ .history = .{ .y = first_rows - 1 } });
+ try testing.expect(!search.selected.?.highlight.start.garbage);
+
+ try testing.expect(!try search.select(.next));
+ try testing.expect(search.selected == null);
+}
+
+test "select after history compaction ignores replaced results" {
+ 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);
+ const list: *PageList = &t.screens.active.pages;
+
+ var stream = t.vtStream();
+ defer stream.deinit();
+
+ stream.nextSlice("error\r\n");
+ const first = list.pages.first.?;
+ while (list.totalPages() < 3) stream.nextSlice("\r\n");
+ try list.split(.{
+ .node = first,
+ .y = first.rows() / 2,
+ .x = 0,
+ });
+
+ var search: ScreenSearch = try .init(alloc, t.screens.active, "error");
+ defer search.deinit();
+ try search.searchAll();
+ try testing.expectEqual(1, search.history_results.items.len);
+
+ const replacement = (try list.compact(first)).?;
+ try testing.expect(replacement != first);
+
+ try testing.expect(!try search.select(.next));
+ try testing.expect(search.selected == null);
+}
+
+test "select after partial history page erase ignores shifted results" {
+ 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);
+ const list: *PageList = &t.screens.active.pages;
+
+ var stream = t.vtStream();
+ defer stream.deinit();
+
+ const first = list.pages.first.?;
+ while (first.rows() < first.capacity().rows) stream.nextSlice("\r\n");
+ stream.nextSlice("error");
+ for (0..list.rows + 1) |_| stream.nextSlice("\r\n");
+ try testing.expect(first != list.pages.last.?);
+
+ var search: ScreenSearch = try .init(alloc, t.screens.active, "error");
+ defer search.deinit();
+ try search.searchAll();
+ try testing.expectEqual(0, search.active_results.items.len);
+ try testing.expectEqual(1, search.history_results.items.len);
+
+ const old_rows = first.rows();
+ list.eraseHistory(.{ .history = .{ .y = 0 } });
+ try testing.expectEqual(old_rows - 1, first.rows());
+
+ try testing.expect(!try search.select(.next));
+ try testing.expect(search.selected == null);
+}
+
+test "reload defers pruning unselected history results" {
+ 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);
+ const list: *PageList = &t.screens.active.pages;
+
+ var stream = t.vtStream();
+ defer stream.deinit();
+
+ const first = list.pages.first.?;
+ while (first.rows() < first.capacity().rows) stream.nextSlice("\r\n");
+ stream.nextSlice("error");
+ for (0..list.rows + 1) |_| stream.nextSlice("\r\n");
+
+ var search: ScreenSearch = try .init(alloc, t.screens.active, "error");
+ defer search.deinit();
+ try search.searchAll();
+ try testing.expectEqual(1, search.history_results.items.len);
+
+ list.eraseHistory(.{ .history = .{ .y = 0 } });
+
+ // Routine active refreshes don't inspect unselected history results.
+ try search.reloadActive();
+ try testing.expectEqual(1, search.history_results.items.len);
+
+ // Selection prunes every candidate before attempting to track it.
+ try testing.expect(!try search.select(.next));
+ try testing.expectEqual(0, search.history_results.items.len);
+}
+
+test "reload after partial history page erase drops shifted selection first" {
+ 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);
+ const list: *PageList = &t.screens.active.pages;
+
+ var stream = t.vtStream();
+ defer stream.deinit();
+
+ const first = list.pages.first.?;
+ while (first.rows() < first.capacity().rows) stream.nextSlice("\r\n");
+ stream.nextSlice("error");
+ for (0..list.rows + 1) |_| stream.nextSlice("\r\n");
+
+ var search: ScreenSearch = try .init(alloc, t.screens.active, "error");
+ defer search.deinit();
+ try search.searchAll();
+ try testing.expect(try search.select(.next));
+ try testing.expect(search.selected != null);
+
+ list.eraseHistory(.{ .history = .{ .y = 0 } });
+
+ // Advance the active boundary into another node and add matches so
+ // reloadActive updates the history result offsets. It must discard the
+ // stale selected result before comparing any of its captured coordinates.
+ const active_node = list.getTopLeft(.active).node;
+ while (list.getTopLeft(.active).node == active_node) {
+ stream.nextSlice("error\r\n");
+ }
+
+ try search.reloadActive();
+ try testing.expect(search.selected == null);
+}
+
+test "select after history page split ignores moved results" {
+ 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);
+ const list: *PageList = &t.screens.active.pages;
+
+ var stream = t.vtStream();
+ defer stream.deinit();
+
+ const first = list.pages.first.?;
+ while (first.rows() < first.capacity().rows) stream.nextSlice("\r\n");
+ stream.nextSlice("error");
+ for (0..list.rows + 1) |_| stream.nextSlice("\r\n");
+
+ var search: ScreenSearch = try .init(alloc, t.screens.active, "error");
+ defer search.deinit();
+ try search.searchAll();
+ try testing.expectEqual(1, search.history_results.items.len);
+
+ try list.split(.{
+ .node = first,
+ .y = first.rows() / 2,
+ .x = 0,
+ });
+
+ try testing.expect(!try search.select(.next));
+ try testing.expect(search.selected == null);
+}
+
+test "screen search no scrollback has no history" {
+ const alloc = testing.allocator;
+ const io = testing.io;
+ var t: Terminal = try .init(io, alloc, .{
+ .cols = 10,
+ .rows = 2,
+ .max_scrollback_bytes = 0,
+ });
+ defer t.deinit(alloc);
+
+ // Alt screen has no scrollback
+ _ = try t.switchScreen(.alternate);
+
+ var s = t.vtStream();
+ defer s.deinit();
+
+ // This will probably stop working at some point and we'll have
+ // no way to test it using public APIs, but at the time of writing
+ // this test, CSI 22 J (scroll complete) pushes into scrollback
+ // with alt screen.
+ s.nextSlice("Fizz\r\n");
+ s.nextSlice("\x1b[22J");
+ s.nextSlice("hello.");
+
+ var search: ScreenSearch = try .init(alloc, t.screens.active, "Fizz");
+ defer search.deinit();
+ try search.searchAll();
+ try testing.expectEqual(0, search.active_results.items.len);
+
+ // Get all matches
+ const matches = try search.matches(alloc);
+ defer alloc.free(matches);
+ try testing.expectEqual(0, matches.len);
+}
+
+test "reloadActive partial history cleanup on appendSlice error" {
+ // This test verifies that when reloadActive fails at appendSlice (after
+ // the loop), all FlattenedHighlight items are properly cleaned up.
+ 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);
+ const list: *PageList = &t.screens.active.pages;
+
+ var s = t.vtStream();
+ defer s.deinit();
+
+ // Write multiple "Fizz" matches that will end up in history.
+ // We need enough content to push "Fizz" entries into scrollback.
+ s.nextSlice("Fizz\r\nFizz\r\n");
+ while (list.totalPages() < 3) s.nextSlice("\r\n");
+ for (0..list.rows) |_| s.nextSlice("\r\n");
+ s.nextSlice("Fizz.");
+
+ // Complete initial search
+ var search: ScreenSearch = try .init(alloc, t.screens.active, "Fizz");
+ defer search.deinit();
+ try search.searchAll();
+
+ // Now trigger reloadActive by adding more content that changes the
+ // active/history boundary. First add more "Fizz" entries to history.
+ s.nextSlice("\r\nFizz\r\nFizz\r\n");
+ while (list.totalPages() < 4) s.nextSlice("\r\n");
+ for (0..list.rows) |_| s.nextSlice("\r\n");
+
+ // Arm the tripwire to fail at appendSlice (after the loop completes).
+ // At this point, there are FlattenedHighlight items in the results list
+ // that need cleanup.
+ const tw = reloadActive_tw;
+ defer tw.end(.reset) catch unreachable;
+ tw.errorAlways(.history_append_existing, error.OutOfMemory);
+
+ // reloadActive is called by select(), which should trigger the error path.
+ // If the bug exists, testing.allocator will report a memory leak
+ // because FlattenedHighlight items weren't cleaned up.
+ try testing.expectError(error.OutOfMemory, search.select(.next));
+}
+
+test "reloadActive partial history cleanup on loop append error" {
+ // This test verifies that when reloadActive fails inside the loop
+ // (after some items have been appended), all FlattenedHighlight items
+ // are properly cleaned up.
+ 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);
+ const list: *PageList = &t.screens.active.pages;
+
+ var s = t.vtStream();
+ defer s.deinit();
+
+ // Write multiple "Fizz" matches that will end up in history.
+ // We need enough content to push "Fizz" entries into scrollback.
+ s.nextSlice("Fizz\r\nFizz\r\n");
+ while (list.totalPages() < 3) s.nextSlice("\r\n");
+ for (0..list.rows) |_| s.nextSlice("\r\n");
+ s.nextSlice("Fizz.");
+
+ // Complete initial search
+ var search: ScreenSearch = try .init(alloc, t.screens.active, "Fizz");
+ defer search.deinit();
+ try search.searchAll();
+
+ // Now trigger reloadActive by adding more content that changes the
+ // active/history boundary. First add more "Fizz" entries to history.
+ s.nextSlice("\r\nFizz\r\nFizz\r\n");
+ while (list.totalPages() < 4) s.nextSlice("\r\n");
+ for (0..list.rows) |_| s.nextSlice("\r\n");
+
+ // Arm the tripwire to fail after the first loop append succeeds.
+ // This leaves at least one FlattenedHighlight in the results list
+ // that needs cleanup.
+ const tw = reloadActive_tw;
+ defer tw.end(.reset) catch unreachable;
+ tw.errorAfter(.history_append_new, error.OutOfMemory, 1);
+
+ // reloadActive is called by select(), which should trigger the error path.
+ // If the bug exists, testing.allocator will report a memory leak
+ // because FlattenedHighlight items weren't cleaned up.
+ try testing.expectError(error.OutOfMemory, search.select(.next));
+}
+
+test "select after clearing scrollback" {
+ // Regression test for: https://github.com/ghostty-org/ghostty/issues/11957
+ // After clearing scrollback (CSI 3J), selecting next/prev should not crash.
+ 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);
+ const list: *PageList = &t.screens.active.pages;
+
+ var s = t.vtStream();
+ defer s.deinit();
+
+ // Write enough content to push matches into scrollback history.
+ s.nextSlice("error\r\n");
+ while (list.totalPages() < 3) s.nextSlice("error\r\n");
+ for (0..list.rows) |_| s.nextSlice("\r\n");
+ s.nextSlice("error.");
+
+ // Start search and find all matches.
+ var search: ScreenSearch = try .init(alloc, t.screens.active, "error");
+ defer search.deinit();
+ try search.searchAll();
+
+ // Should have matches in both history and active areas.
+ try testing.expect(search.history_results.items.len > 0);
+ try testing.expect(search.active_results.items.len > 0);
+
+ // Select a match first (so we have a selection).
+ _ = try search.select(.next);
+ try testing.expect(search.selected != null);
+
+ // Clear scrollback (equivalent to CSI 3J / Cmd+K erasing scrollback).
+ t.eraseDisplay(.scrollback, false);
+
+ // Selecting next/prev after clearing scrollback should not crash.
+ // Before the fix, this would hit an assertion in trackPin because
+ // the FlattenedHighlight contained dangling node pointers.
+ _ = try search.select(.next);
+ _ = try search.select(.prev);
+}