mirror-ghostty/src/terminal/search/screen.zig

1337 lines
48 KiB
Zig

const std = @import("std");
const assert = @import("../../quirks.zig").inlineAssert;
const testing = std.testing;
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 Selection = @import("../Selection.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);
/// 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 {
const alloc = self.allocator();
self.active.deinit();
if (self.history) |*h| h.deinit(self.screen);
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;
}
/// 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 {
// If the screen resizes, we have to reset our entire search. That
// isn't ideal but we don't have a better way right now to handle
// reflowing the search results beyond putting a tracked pin for
// every single result.
if (self.screen.pages.rows != self.rows or
self.screen.pages.cols != self.cols)
{
// Reinit
const new: ScreenSearch = try .init(
self.allocator(),
self.screen,
self.needle(),
);
// Deinit/reinit
self.deinit();
self.* = new;
// New result should have matching dimensions
assert(self.screen.pages.rows == self.rows);
assert(self.screen.pages.cols == self.cols);
}
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 pruneHistory(self: *ScreenSearch) void {
// Go through our history results in order (newest to oldest) to find
// any result that contains an invalid serial. Prune up to that
// point.
for (0..self.history_results.items.len) |i| {
const hl = &self.history_results.items[i];
const serials = hl.chunks.items(.serial);
const lowest = serials[0];
if (lowest < self.screen.pages.page_serial_min) {
// Everything from here forward we assume is invalid because
// our history results only get older.
const alloc = self.allocator();
for (self.history_results.items[i..]) |*prune_hl| prune_hl.deinit(alloc);
self.history_results.shrinkAndFree(alloc, i);
return;
}
}
}
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 {
// 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.
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 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 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 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();
},
}
// Active area search was successful. 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 {
// All selection requires valid pins so we prune history and
// reload our active area immediately. This ensures all search
// results point to valid nodes.
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 next_idx = prev.idx + 1;
const active_len = self.active_results.items.len;
const history_len = self.history_results.items.len;
if (next_idx >= active_len + history_len) {
// No more matches. We don't wrap or reset the match currently.
return false;
}
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;
};
// Can't go below zero
if (prev.idx == 0) {
// No more matches. We don't wrap or reset the match currently.
return false;
}
const next_idx = prev.idx - 1;
const active_len = self.active_results.items.len;
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;
var t: Terminal = try .init(alloc, .{ .cols = 10, .rows = 2 });
defer t.deinit(alloc);
var s = t.vtStream();
defer s.deinit();
try 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;
var t: Terminal = try .init(alloc, .{
.cols = 10,
.rows = 2,
.max_scrollback = std.math.maxInt(usize),
});
defer t.deinit(alloc);
const list: *PageList = &t.screens.active.pages;
var s = t.vtStream();
defer s.deinit();
try s.nextSlice("Fizz\r\n");
while (list.totalPages() < 3) try s.nextSlice("\r\n");
for (0..list.rows) |_| try s.nextSlice("\r\n");
try 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;
var t: Terminal = try .init(alloc, .{
.cols = 10,
.rows = 2,
.max_scrollback = std.math.maxInt(usize),
});
defer t.deinit(alloc);
const list: *PageList = &t.screens.active.pages;
var s = t.vtStream();
defer s.deinit();
try 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) try s.nextSlice("\r\n");
for (0..list.rows) |_| try s.nextSlice("\r\n");
try 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();
try 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;
var t: Terminal = try .init(alloc, .{ .cols = 10, .rows = 5 });
defer t.deinit(alloc);
var s = t.vtStream();
defer s.deinit();
try 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.
try s.nextSlice("\x1b[2J\x1b[H"); // Clear screen and move home
try 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;
var t: Terminal = try .init(alloc, .{ .cols = 10, .rows = 2 });
defer t.deinit(alloc);
var s = t.vtStream();
defer s.deinit();
try 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 (no wrap)
_ = 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).?);
}
}
test "select in active changes contents completely" {
const alloc = testing.allocator;
var t: Terminal = try .init(alloc, .{ .cols = 10, .rows = 5 });
defer t.deinit(alloc);
var s = t.vtStream();
defer s.deinit();
try 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.
try s.nextSlice("\x1b[2J\x1b[H"); // Clear screen and move home
try 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.
try s.nextSlice("\x1b[2J\x1b[H"); // Clear screen and move home
try 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;
var t: Terminal = try .init(alloc, .{
.cols = 10,
.rows = 2,
.max_scrollback = std.math.maxInt(usize),
});
defer t.deinit(alloc);
const list: *PageList = &t.screens.active.pages;
var s = t.vtStream();
defer s.deinit();
try s.nextSlice("Fizz\r\n");
while (list.totalPages() < 3) try s.nextSlice("\r\n");
for (0..list.rows) |_| try s.nextSlice("\r\n");
try 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.
try s.nextSlice("\x1b[2J\x1b[H"); // Clear screen and move home
try 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.
try 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;
var t: Terminal = try .init(alloc, .{ .cols = 10, .rows = 2 });
defer t.deinit(alloc);
var s = t.vtStream();
defer s.deinit();
try 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 (no wrap, stays at 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).?);
}
}
test "select prev then next" {
const alloc = testing.allocator;
var t: Terminal = try .init(alloc, .{ .cols = 10, .rows = 2 });
defer t.deinit(alloc);
var s = t.vtStream();
defer s.deinit();
try 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;
var t: Terminal = try .init(alloc, .{
.cols = 10,
.rows = 2,
.max_scrollback = std.math.maxInt(usize),
});
defer t.deinit(alloc);
const list: *PageList = &t.screens.active.pages;
var s = t.vtStream();
defer s.deinit();
try s.nextSlice("Fizz\r\n");
while (list.totalPages() < 3) try s.nextSlice("\r\n");
for (0..list.rows) |_| try s.nextSlice("\r\n");
try 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).?);
}
}