mirror of
https://github.com/ghostty-org/ghostty.git
synced 2026-09-14 18:01:58 +00:00
terminal: extract whole-terminal search orchestration from the search thread
This commit is contained in:
@@ -5,6 +5,7 @@ pub const options = @import("terminal_options");
|
||||
pub const Active = @import("search/active.zig").ActiveSearch;
|
||||
pub const PageList = @import("search/pagelist.zig").PageListSearch;
|
||||
pub const Screen = @import("search/screen.zig").ScreenSearch;
|
||||
pub const Terminal = @import("search/terminal.zig").TerminalSearch;
|
||||
pub const Viewport = @import("search/viewport.zig").ViewportSearch;
|
||||
|
||||
// The search thread is not available in libghostty due to the xev dep
|
||||
|
||||
@@ -12,7 +12,6 @@ const std = @import("std");
|
||||
const builtin = @import("builtin");
|
||||
const testing = std.testing;
|
||||
const Allocator = std.mem.Allocator;
|
||||
const ArenaAllocator = std.heap.ArenaAllocator;
|
||||
const Mutex = std.Io.Mutex;
|
||||
const global = @import("../../global.zig");
|
||||
const xev = global.xev;
|
||||
@@ -23,11 +22,10 @@ const point = @import("../point.zig");
|
||||
const FlattenedHighlight = @import("../highlight.zig").Flattened;
|
||||
const UntrackedHighlight = @import("../highlight.zig").Untracked;
|
||||
const ScreenSet = @import("../ScreenSet.zig");
|
||||
const Selection = @import("../Selection.zig");
|
||||
const Terminal = @import("../Terminal.zig");
|
||||
|
||||
const ScreenSearch = @import("screen.zig").ScreenSearch;
|
||||
const ViewportSearch = @import("viewport.zig").ViewportSearch;
|
||||
const TerminalSearch = @import("terminal.zig").TerminalSearch;
|
||||
|
||||
const log = std.log.scoped(.search_thread);
|
||||
|
||||
@@ -72,7 +70,10 @@ refresh_active: bool = false,
|
||||
|
||||
/// Search state. Starts as null and is populated when a search is
|
||||
/// started (a needle is given).
|
||||
search: ?Search = null,
|
||||
search: ?TerminalSearch = null,
|
||||
|
||||
/// The last-notified values used to diff search state into events.
|
||||
notify_state: NotifyState = .{},
|
||||
|
||||
/// The options used to initialize this thread.
|
||||
opts: Options,
|
||||
@@ -190,7 +191,7 @@ fn threadMain_(self: *Thread) !void {
|
||||
return;
|
||||
}
|
||||
|
||||
const s: *Search = if (self.search) |*s| s else {
|
||||
const s: *TerminalSearch = if (self.search) |*s| s else {
|
||||
// If we're not actively searching, we can block the loop
|
||||
// until it does some work.
|
||||
try self.loop.run(.once);
|
||||
@@ -201,11 +202,7 @@ fn threadMain_(self: *Thread) !void {
|
||||
// notifications. Even if the search is complete, there may be
|
||||
// notifications to send.
|
||||
if (self.opts.event_cb) |cb| {
|
||||
s.notify(
|
||||
self.alloc,
|
||||
cb,
|
||||
self.opts.event_userdata,
|
||||
);
|
||||
self.notify(s, cb, self.opts.event_userdata);
|
||||
}
|
||||
|
||||
if (s.isComplete()) {
|
||||
@@ -228,7 +225,7 @@ fn threadMain_(self: *Thread) !void {
|
||||
.blocked => {
|
||||
self.opts.mutex.lockUncancelable(global.io());
|
||||
defer self.opts.mutex.unlock(global.io());
|
||||
s.feed(self.alloc, self.opts.terminal);
|
||||
self.feedLocked(s);
|
||||
},
|
||||
}
|
||||
|
||||
@@ -241,6 +238,21 @@ fn threadMain_(self: *Thread) !void {
|
||||
}
|
||||
}
|
||||
|
||||
/// Feed the search from the terminal state. The terminal mutex must
|
||||
/// be held by the caller.
|
||||
fn feedLocked(self: *Thread, s: *TerminalSearch) void {
|
||||
const t = self.opts.terminal;
|
||||
|
||||
// See the `search_viewport_dirty` flag on the terminal to know
|
||||
// what exactly this is for. But, if this is set, we know the renderer
|
||||
// found the viewport/active area dirty, so the active area must be
|
||||
// re-scanned.
|
||||
const active_dirty = t.flags.search_viewport_dirty;
|
||||
t.flags.search_viewport_dirty = false;
|
||||
|
||||
s.feed(t, active_dirty);
|
||||
}
|
||||
|
||||
/// Drain the mailbox.
|
||||
fn drainMailbox(self: *Thread) !void {
|
||||
while (self.mailbox.pop(global.io())) |message| {
|
||||
@@ -261,51 +273,12 @@ fn select(self: *Thread, sel: ScreenSearch.Select) !void {
|
||||
self.opts.mutex.lockUncancelable(global.io());
|
||||
defer self.opts.mutex.unlock(global.io());
|
||||
|
||||
// A screen can be removed or replaced between refresh ticks. Reconcile
|
||||
// while holding the terminal lock before touching any ScreenSearch pins.
|
||||
s.feed(self.alloc, self.opts.terminal);
|
||||
const screen_search = s.screens.getPtr(s.last_screen.key) orelse return;
|
||||
|
||||
// Make the selection. Ignore the result because we don't
|
||||
// care if the selection didn't change.
|
||||
_ = try screen_search.select(sel);
|
||||
|
||||
// Grab our match if we have one. If we don't have a selection
|
||||
// then we do nothing.
|
||||
const flattened = screen_search.selectedMatch() orelse return;
|
||||
|
||||
// No matter what we reset our selected match cache. This will
|
||||
// trigger a callback which will trigger the renderer to wake up
|
||||
// so it can be notified the screen scrolled.
|
||||
s.last_screen.selected = null;
|
||||
|
||||
// Grab the current screen and see if this match is visible within
|
||||
// the viewport already. If it is, we do nothing.
|
||||
const screen = self.opts.terminal.screens.get(
|
||||
s.last_screen.key,
|
||||
) orelse return;
|
||||
|
||||
// Grab the viewport. Viewports and selections are usually small
|
||||
// so this check isn't very expensive, despite appearing O(N^2),
|
||||
// both Ns are usually equal to 1.
|
||||
var it = screen.pages.pageIterator(
|
||||
.right_down,
|
||||
.{ .viewport = .{} },
|
||||
null,
|
||||
);
|
||||
const hl_chunks = flattened.chunks.slice();
|
||||
while (it.next()) |chunk| {
|
||||
for (0..hl_chunks.len) |i| {
|
||||
const hl_chunk = hl_chunks.get(i);
|
||||
if (chunk.overlaps(.{
|
||||
.node = hl_chunk.node,
|
||||
.start = hl_chunk.start,
|
||||
.end = hl_chunk.end,
|
||||
})) return;
|
||||
}
|
||||
if (try s.select(self.opts.terminal, sel, .if_needed)) {
|
||||
// No matter what we reset our selected match cache. This will
|
||||
// trigger a callback which will trigger the renderer to wake up
|
||||
// so it can be notified the screen scrolled.
|
||||
self.notify_state.selected = null;
|
||||
}
|
||||
|
||||
screen.scroll(.{ .pin = flattened.startPin() });
|
||||
}
|
||||
|
||||
/// Change the search term to the given value.
|
||||
@@ -315,7 +288,7 @@ fn changeNeedle(self: *Thread, needle: []const u8) !void {
|
||||
// Stop the previous search
|
||||
if (self.search) |*s| {
|
||||
// If our search is unchanged, do nothing.
|
||||
if (std.ascii.eqlIgnoreCase(s.viewport.needle(), needle)) return;
|
||||
if (std.ascii.eqlIgnoreCase(s.needle(), needle)) return;
|
||||
|
||||
{
|
||||
self.opts.mutex.lockUncancelable(global.io());
|
||||
@@ -323,6 +296,7 @@ fn changeNeedle(self: *Thread, needle: []const u8) !void {
|
||||
s.deinit(self.opts.terminal);
|
||||
}
|
||||
self.search = null;
|
||||
self.notify_state = .{};
|
||||
|
||||
// When the search changes then we need to emit that it stopped.
|
||||
if (self.opts.event_cb) |cb| {
|
||||
@@ -346,11 +320,98 @@ fn changeNeedle(self: *Thread, needle: []const u8) !void {
|
||||
|
||||
// Setup our search state.
|
||||
self.search = try .init(self.alloc, needle);
|
||||
self.notify_state = .{};
|
||||
|
||||
// We need to grab the terminal lock and do an initial feed.
|
||||
self.opts.mutex.lockUncancelable(global.io());
|
||||
defer self.opts.mutex.unlock(global.io());
|
||||
self.search.?.feed(self.alloc, self.opts.terminal);
|
||||
self.feedLocked(&self.search.?);
|
||||
}
|
||||
|
||||
/// Notify about any changes to the search state by diffing against
|
||||
/// the last-notified values.
|
||||
///
|
||||
/// This doesn't require any locking as it only reads search-owned state.
|
||||
fn notify(
|
||||
self: *Thread,
|
||||
s: *TerminalSearch,
|
||||
cb: EventCallback,
|
||||
ud: ?*anyopaque,
|
||||
) void {
|
||||
const state = &self.notify_state;
|
||||
|
||||
// A screen switch makes all previously notified per-screen state
|
||||
// stale, so reset it to force recalculations and notifications.
|
||||
if (state.key != s.active_key) {
|
||||
state.key = s.active_key;
|
||||
state.total = null;
|
||||
state.selected = null;
|
||||
}
|
||||
|
||||
const screen_search = s.activeScreenSearch() orelse return;
|
||||
|
||||
// Check our total match data
|
||||
const total = screen_search.matchesLen();
|
||||
if (total != state.total) {
|
||||
log.debug("notifying total matches={}", .{total});
|
||||
state.total = total;
|
||||
cb(.{ .total_matches = total }, ud);
|
||||
}
|
||||
|
||||
// Check our viewport matches. If they're stale, we collect them
|
||||
// now. We do this as part of notify and not tick because the
|
||||
// viewport search is very fast and doesn't require ticked progress
|
||||
// or feeds.
|
||||
if (s.stale_viewport_matches) viewport: {
|
||||
const matches = s.viewportMatches() catch |err| {
|
||||
log.warn("error collecting viewport matches err={}", .{err});
|
||||
break :viewport;
|
||||
};
|
||||
|
||||
log.debug("notifying viewport matches len={}", .{matches.len});
|
||||
cb(.{ .viewport_matches = matches }, ud);
|
||||
}
|
||||
|
||||
// Check our last selected match data.
|
||||
if (screen_search.selected) |m| match: {
|
||||
const flattened = screen_search.selectedMatch() orelse break :match;
|
||||
const untracked = flattened.untracked();
|
||||
if (state.selected) |prev| {
|
||||
if (prev.idx == m.idx and prev.highlight.eql(untracked)) {
|
||||
// Same selection, don't update it.
|
||||
break :match;
|
||||
}
|
||||
}
|
||||
|
||||
// New selection, notify!
|
||||
state.selected = .{
|
||||
.idx = m.idx,
|
||||
.highlight = untracked,
|
||||
};
|
||||
|
||||
log.debug("notifying selection updated idx={}", .{m.idx});
|
||||
cb(
|
||||
.{ .selected_match = .{
|
||||
.idx = m.idx,
|
||||
.highlight = flattened,
|
||||
} },
|
||||
ud,
|
||||
);
|
||||
} else if (state.selected != null) {
|
||||
log.debug("notifying selection cleared", .{});
|
||||
state.selected = null;
|
||||
cb(
|
||||
.{ .selected_match = null },
|
||||
ud,
|
||||
);
|
||||
}
|
||||
|
||||
// Send our complete notification if we just completed.
|
||||
if (!state.complete and s.isComplete()) {
|
||||
log.debug("notifying search complete", .{});
|
||||
state.complete = true;
|
||||
cb(.complete, ud);
|
||||
}
|
||||
}
|
||||
|
||||
fn startRefreshTimer(self: *Thread) void {
|
||||
@@ -426,7 +487,7 @@ fn refreshCallback(
|
||||
if (self.search) |*s| {
|
||||
self.opts.mutex.lockUncancelable(global.io());
|
||||
defer self.opts.mutex.unlock(global.io());
|
||||
s.feed(self.alloc, self.opts.terminal);
|
||||
self.feedLocked(s);
|
||||
}
|
||||
|
||||
// Only continue if we're still active
|
||||
@@ -502,352 +563,25 @@ pub const Event = union(enum) {
|
||||
};
|
||||
};
|
||||
|
||||
/// Search state.
|
||||
const Search = struct {
|
||||
/// Active viewport search for the active screen.
|
||||
viewport: ViewportSearch,
|
||||
/// The last-notified values used by `notify` to diff search state
|
||||
/// into events.
|
||||
const NotifyState = struct {
|
||||
/// The active screen key the state below was captured against.
|
||||
key: ScreenSet.Key = .primary,
|
||||
|
||||
/// The searchers for all the screens.
|
||||
screens: std.EnumMap(ScreenSet.Key, ScreenSearch),
|
||||
/// Last notified total matches count
|
||||
total: ?usize = null,
|
||||
|
||||
/// ScreenSet generations captured when each searcher was initialized.
|
||||
/// Allocators may reuse a destroyed Screen address, so pointer equality
|
||||
/// alone cannot distinguish replacement screens from stale handles.
|
||||
screen_generations: std.EnumMap(ScreenSet.Key, usize),
|
||||
|
||||
/// All state related to screen switches, collected so that when
|
||||
/// we switch screens it makes everything related stale, too.
|
||||
last_screen: ScreenState,
|
||||
/// Last notified selected match
|
||||
selected: ?Selected = null,
|
||||
|
||||
/// True if we sent the complete notification yet.
|
||||
last_complete: bool,
|
||||
complete: bool = false,
|
||||
|
||||
/// The last viewport matches we found.
|
||||
stale_viewport_matches: bool,
|
||||
|
||||
const ScreenState = struct {
|
||||
/// Last active screen key
|
||||
key: ScreenSet.Key,
|
||||
|
||||
/// Last notified total matches count
|
||||
total: ?usize = null,
|
||||
|
||||
/// Last notified selected match index
|
||||
selected: ?SelectedMatch = null,
|
||||
|
||||
const SelectedMatch = struct {
|
||||
idx: usize,
|
||||
highlight: UntrackedHighlight,
|
||||
};
|
||||
const Selected = struct {
|
||||
idx: usize,
|
||||
highlight: UntrackedHighlight,
|
||||
};
|
||||
|
||||
pub fn init(
|
||||
alloc: Allocator,
|
||||
needle: []const u8,
|
||||
) Allocator.Error!Search {
|
||||
var vp: ViewportSearch = try .init(alloc, needle);
|
||||
errdefer vp.deinit();
|
||||
|
||||
// We use dirty tracking for active area changes. Start with it
|
||||
// dirty so the first change is re-searched.
|
||||
vp.active_dirty = true;
|
||||
|
||||
return .{
|
||||
.viewport = vp,
|
||||
.screens = .init(.{}),
|
||||
.screen_generations = .init(.{}),
|
||||
.last_screen = .{ .key = .primary },
|
||||
.last_complete = false,
|
||||
.stale_viewport_matches = true,
|
||||
};
|
||||
}
|
||||
|
||||
pub fn deinit(self: *Search, t: *Terminal) void {
|
||||
self.viewport.deinit();
|
||||
var it = self.screens.iterator();
|
||||
while (it.next()) |entry| {
|
||||
if (self.screenIsValid(&t.screens, entry.key, entry.value)) {
|
||||
entry.value.deinit();
|
||||
} else {
|
||||
entry.value.deinitScreenInvalid();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn screenIsValid(
|
||||
self: *const Search,
|
||||
screens: *const ScreenSet,
|
||||
key: ScreenSet.Key,
|
||||
search: *const ScreenSearch,
|
||||
) bool {
|
||||
const generation = self.screen_generations.get(key) orelse return false;
|
||||
if (generation != screens.generation(key)) return false;
|
||||
const actual = screens.get(key) orelse return false;
|
||||
return actual == search.screen;
|
||||
}
|
||||
|
||||
/// Returns true if all searches on all screens are complete.
|
||||
pub fn isComplete(self: *Search) bool {
|
||||
var it = self.screens.iterator();
|
||||
while (it.next()) |entry| {
|
||||
if (!entry.value.state.isComplete()) return false;
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
pub const Tick = enum {
|
||||
/// All searches are complete.
|
||||
complete,
|
||||
|
||||
/// Progress was made on at least one screen.
|
||||
progress,
|
||||
|
||||
/// All incomplete searches are blocked on feed.
|
||||
blocked,
|
||||
};
|
||||
|
||||
/// Tick the search forward as much as possible without acquiring
|
||||
/// the big lock. Returns the overall tick progress.
|
||||
pub fn tick(self: *Search) Tick {
|
||||
var result: Tick = .complete;
|
||||
var it = self.screens.iterator();
|
||||
while (it.next()) |entry| {
|
||||
if (entry.value.tick()) {
|
||||
result = .progress;
|
||||
} else |err| switch (err) {
|
||||
// Ignore... nothing we can do.
|
||||
error.OutOfMemory => log.warn(
|
||||
"error ticking screen search key={} err={}",
|
||||
.{ entry.key, err },
|
||||
),
|
||||
|
||||
// Ignore, good for us. State remains whatever it is.
|
||||
error.SearchComplete => {},
|
||||
|
||||
// Ignore, too, progressed
|
||||
error.FeedRequired => switch (result) {
|
||||
// If we think we're complete, we're not because we're
|
||||
// blocked now (nothing made progress).
|
||||
.complete => result = .blocked,
|
||||
|
||||
// If we made some progress, we remain in progress
|
||||
// since blocked means no progress at all.
|
||||
.progress => {},
|
||||
|
||||
// If we're blocked already then we remain blocked.
|
||||
.blocked => {},
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
// log.debug("tick result={}", .{result});
|
||||
return result;
|
||||
}
|
||||
|
||||
/// Grab the mutex and update any state that requires it, such as
|
||||
/// feeding additional data to the searches or updating the active screen.
|
||||
pub fn feed(
|
||||
self: *Search,
|
||||
alloc: Allocator,
|
||||
t: *Terminal,
|
||||
) void {
|
||||
// Update our active screen
|
||||
if (t.screens.active_key != self.last_screen.key) {
|
||||
// The default values will force resets of a bunch of other
|
||||
// state too to force recalculations and notifications.
|
||||
self.last_screen = .{ .key = t.screens.active_key };
|
||||
}
|
||||
|
||||
// Reconcile our screens with the terminal screens. Remove
|
||||
// searchers for screens that no longer exist and add searchers
|
||||
// for screens that do exist but we don't have yet.
|
||||
{
|
||||
// Remove screens we have that no longer exist or changed.
|
||||
var it = self.screens.iterator();
|
||||
while (it.next()) |entry| {
|
||||
const remove = !self.screenIsValid(
|
||||
&t.screens,
|
||||
entry.key,
|
||||
entry.value,
|
||||
);
|
||||
|
||||
if (remove) {
|
||||
entry.value.deinitScreenInvalid();
|
||||
_ = self.screens.remove(entry.key);
|
||||
_ = self.screen_generations.remove(entry.key);
|
||||
}
|
||||
}
|
||||
}
|
||||
{
|
||||
// Add screens that exist but we don't have yet.
|
||||
var it = t.screens.all.iterator();
|
||||
while (it.next()) |entry| {
|
||||
if (self.screens.contains(entry.key)) continue;
|
||||
const screen_search = ScreenSearch.init(
|
||||
alloc,
|
||||
entry.value.*,
|
||||
self.viewport.needle(),
|
||||
) catch |err| switch (err) {
|
||||
error.OutOfMemory => {
|
||||
// OOM is probably going to sink the entire ship but
|
||||
// we can just ignore it and wait on the next
|
||||
// reconciliation to try again.
|
||||
log.warn(
|
||||
"error initializing screen search for key={} err={}",
|
||||
.{ entry.key, err },
|
||||
);
|
||||
continue;
|
||||
},
|
||||
};
|
||||
self.screens.put(entry.key, screen_search);
|
||||
self.screen_generations.put(
|
||||
entry.key,
|
||||
t.screens.generation(entry.key),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// See the `search_viewport_dirty` flag on the terminal to know
|
||||
// what exactly this is for. But, if this is set, we know the renderer
|
||||
// found the viewport/active area dirty, so we should mark it as
|
||||
// dirty in our viewport searcher so it forces a re-search.
|
||||
if (t.flags.search_viewport_dirty) {
|
||||
t.flags.search_viewport_dirty = false;
|
||||
|
||||
// Mark our viewport dirty so it researches the active
|
||||
self.viewport.active_dirty = true;
|
||||
|
||||
// Reload our active area for our active screen
|
||||
if (self.screens.getPtr(t.screens.active_key)) |screen_search| {
|
||||
screen_search.reloadActive() catch |err| switch (err) {
|
||||
error.OutOfMemory => log.warn(
|
||||
"error reloading active area for screen key={} err={}",
|
||||
.{ t.screens.active_key, err },
|
||||
),
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
// Check our viewport for changes.
|
||||
if (self.viewport.update(&t.screens.active.pages)) |updated| {
|
||||
if (updated) self.stale_viewport_matches = true;
|
||||
} else |err| switch (err) {
|
||||
error.OutOfMemory => log.warn(
|
||||
"error updating viewport search err={}",
|
||||
.{err},
|
||||
),
|
||||
}
|
||||
|
||||
// Feed data
|
||||
var it = self.screens.iterator();
|
||||
while (it.next()) |entry| {
|
||||
if (entry.value.state.needsFeed()) {
|
||||
entry.value.feed() catch |err| switch (err) {
|
||||
error.OutOfMemory => log.warn(
|
||||
"error feeding screen search key={} err={}",
|
||||
.{ entry.key, err },
|
||||
),
|
||||
};
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Notify about any changes to the search state.
|
||||
///
|
||||
/// This doesn't require any locking as it only reads internal state.
|
||||
pub fn notify(
|
||||
self: *Search,
|
||||
alloc: Allocator,
|
||||
cb: EventCallback,
|
||||
ud: ?*anyopaque,
|
||||
) void {
|
||||
const screen_search = self.screens.get(self.last_screen.key) orelse return;
|
||||
|
||||
// Check our total match data
|
||||
const total = screen_search.matchesLen();
|
||||
if (total != self.last_screen.total) {
|
||||
log.debug("notifying total matches={}", .{total});
|
||||
self.last_screen.total = total;
|
||||
cb(.{ .total_matches = total }, ud);
|
||||
}
|
||||
|
||||
// Check our viewport matches. If they're stale, we do the
|
||||
// viewport search now. We do this as part of notify and not
|
||||
// tick because the viewport search is very fast and doesn't
|
||||
// require ticked progress or feeds.
|
||||
if (self.stale_viewport_matches) viewport: {
|
||||
// We always make stale as false. Even if we fail below
|
||||
// we require a re-feed to re-search the viewport. The feed
|
||||
// process will make it stale again.
|
||||
self.stale_viewport_matches = false;
|
||||
|
||||
var arena: ArenaAllocator = .init(alloc);
|
||||
defer arena.deinit();
|
||||
const arena_alloc = arena.allocator();
|
||||
var results: std.ArrayList(FlattenedHighlight) = .empty;
|
||||
while (self.viewport.next()) |hl| {
|
||||
const hl_cloned = hl.clone(arena_alloc) catch continue;
|
||||
results.append(arena_alloc, hl_cloned) catch |err| switch (err) {
|
||||
error.OutOfMemory => {
|
||||
log.warn(
|
||||
"error collecting viewport matches err={}",
|
||||
.{err},
|
||||
);
|
||||
|
||||
// Reset the viewport so we force an update on the
|
||||
// next feed.
|
||||
self.viewport.reset();
|
||||
break :viewport;
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
log.debug("notifying viewport matches len={}", .{results.items.len});
|
||||
cb(.{ .viewport_matches = results.items }, ud);
|
||||
}
|
||||
|
||||
// Check our last selected match data.
|
||||
if (screen_search.selected) |m| match: {
|
||||
const flattened = screen_search.selectedMatch() orelse break :match;
|
||||
const untracked = flattened.untracked();
|
||||
if (self.last_screen.selected) |prev| {
|
||||
if (prev.idx == m.idx and prev.highlight.eql(untracked)) {
|
||||
// Same selection, don't update it.
|
||||
break :match;
|
||||
}
|
||||
}
|
||||
|
||||
// New selection, notify!
|
||||
self.last_screen.selected = .{
|
||||
.idx = m.idx,
|
||||
.highlight = untracked,
|
||||
};
|
||||
|
||||
log.debug("notifying selection updated idx={}", .{m.idx});
|
||||
cb(
|
||||
.{ .selected_match = .{
|
||||
.idx = m.idx,
|
||||
.highlight = flattened,
|
||||
} },
|
||||
ud,
|
||||
);
|
||||
} else if (self.last_screen.selected != null) {
|
||||
log.debug("notifying selection cleared", .{});
|
||||
self.last_screen.selected = null;
|
||||
cb(
|
||||
.{ .selected_match = null },
|
||||
ud,
|
||||
);
|
||||
}
|
||||
|
||||
// Send our complete notification if we just completed.
|
||||
if (!self.last_complete and self.isComplete()) {
|
||||
log.debug("notifying search complete", .{});
|
||||
self.last_complete = true;
|
||||
cb(.complete, ud);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
const TestUserData = struct {
|
||||
@@ -945,33 +679,3 @@ test {
|
||||
} }, t.screens.active.pages.pointFromPin(.screen, sel.end).?);
|
||||
}
|
||||
}
|
||||
|
||||
test "select after active screen removal" {
|
||||
const alloc = testing.allocator;
|
||||
const io = testing.io;
|
||||
var mutex: std.Io.Mutex = .init;
|
||||
var t: Terminal = try .init(io, alloc, .{ .cols = 20, .rows = 2 });
|
||||
defer t.deinit(alloc);
|
||||
|
||||
_ = try t.switchScreen(.alternate);
|
||||
|
||||
var search: Search = try .init(alloc, "needle");
|
||||
search.feed(alloc, &t);
|
||||
try testing.expectEqual(ScreenSet.Key.alternate, search.last_screen.key);
|
||||
try testing.expect(search.screens.contains(.alternate));
|
||||
|
||||
var thread: Thread = undefined;
|
||||
thread.search = search;
|
||||
thread.opts = .{
|
||||
.mutex = &mutex,
|
||||
.terminal = &t,
|
||||
};
|
||||
defer if (thread.search) |*active| active.deinit(&t);
|
||||
|
||||
_ = try t.switchScreen(.primary);
|
||||
t.screens.remove(alloc, .alternate);
|
||||
|
||||
try thread.select(.next);
|
||||
try testing.expectEqual(ScreenSet.Key.primary, thread.search.?.last_screen.key);
|
||||
try testing.expect(!thread.search.?.screens.contains(.alternate));
|
||||
}
|
||||
|
||||
@@ -765,22 +765,31 @@ pub const ScreenSearch = struct {
|
||||
}
|
||||
}
|
||||
|
||||
/// Return the match at the given index in newest-to-oldest order
|
||||
/// (0 = most recent match). Returns null if the index is out of
|
||||
/// range.
|
||||
///
|
||||
/// This does not require read/write access to the underlying screen.
|
||||
pub fn matchAt(self: *const ScreenSearch, idx: usize) ?FlattenedHighlight {
|
||||
const active_len = self.active_results.items.len;
|
||||
if (idx < active_len) {
|
||||
return self.active_results.items[active_len - 1 - idx];
|
||||
}
|
||||
|
||||
const history_len = self.history_results.items.len;
|
||||
if (idx < active_len + history_len) {
|
||||
return self.history_results.items[idx - active_len];
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
/// Return the selected match.
|
||||
///
|
||||
/// This does not require read/write access to the underlying screen.
|
||||
pub fn selectedMatch(self: *const ScreenSearch) ?FlattenedHighlight {
|
||||
const sel = self.selected orelse return null;
|
||||
const active_len = self.active_results.items.len;
|
||||
if (sel.idx < active_len) {
|
||||
return self.active_results.items[active_len - 1 - sel.idx];
|
||||
}
|
||||
|
||||
const history_len = self.history_results.items.len;
|
||||
if (sel.idx < active_len + history_len) {
|
||||
return self.history_results.items[sel.idx - active_len];
|
||||
}
|
||||
|
||||
return null;
|
||||
return self.matchAt(sel.idx);
|
||||
}
|
||||
|
||||
pub const Select = enum {
|
||||
|
||||
621
src/terminal/search/terminal.zig
Normal file
621
src/terminal/search/terminal.zig
Normal file
@@ -0,0 +1,621 @@
|
||||
const std = @import("std");
|
||||
const testing = std.testing;
|
||||
const Allocator = std.mem.Allocator;
|
||||
const point = @import("../point.zig");
|
||||
const FlattenedHighlight = @import("../highlight.zig").Flattened;
|
||||
const ScreenSet = @import("../ScreenSet.zig");
|
||||
const Terminal = @import("../Terminal.zig");
|
||||
|
||||
const ScreenSearch = @import("screen.zig").ScreenSearch;
|
||||
const ViewportSearch = @import("viewport.zig").ViewportSearch;
|
||||
|
||||
const log = std.log.scoped(.search_terminal);
|
||||
|
||||
/// Searches for a needle within an entire Terminal, orchestrating a
|
||||
/// ScreenSearch per live screen plus a viewport search for the active
|
||||
/// screen.
|
||||
///
|
||||
/// This reconciles per-screen searchers against the terminal's active
|
||||
/// ScreenSet, retains results across primary/alt screen switches, and
|
||||
/// recovers from resize/reflow, resets, scrollback pruning, etc.
|
||||
///
|
||||
/// The main ownership contract is that this is safe to call concurrently
|
||||
/// with Terminal IO as long as you're not calling a function that takes
|
||||
/// a terminal as an argument. Each function has more specific details about
|
||||
/// its behavior.
|
||||
pub const TerminalSearch = struct {
|
||||
/// Allocator used for all search state.
|
||||
alloc: Allocator,
|
||||
|
||||
/// Active viewport search for the active screen.
|
||||
viewport: ViewportSearch,
|
||||
|
||||
/// The searchers for all the screens.
|
||||
screens: std.EnumMap(ScreenSet.Key, ScreenSearch),
|
||||
|
||||
/// ScreenSet generations captured when each searcher was initialized.
|
||||
/// Allocators may reuse a destroyed Screen address, so pointer equality
|
||||
/// alone cannot distinguish replacement screens from stale handles.
|
||||
screen_generations: std.EnumMap(ScreenSet.Key, usize),
|
||||
|
||||
/// The active screen key as of the last feed. All single-screen
|
||||
/// reads (total matches, selection, etc.) resolve against this.
|
||||
active_key: ScreenSet.Key,
|
||||
|
||||
/// True when the cached viewport matches are stale and must be
|
||||
/// recollected from the viewport searcher on the next
|
||||
/// `viewportMatches` call.
|
||||
stale_viewport_matches: bool,
|
||||
|
||||
/// Cached viewport matches. The viewport search's sliding window
|
||||
/// drains on read, so results are collected once per viewport
|
||||
/// change and cached here.
|
||||
viewport_matches: std.ArrayList(FlattenedHighlight),
|
||||
|
||||
/// Overall status of the search. See `status`.
|
||||
pub const Status = enum {
|
||||
/// `tick` can make progress without terminal access.
|
||||
running,
|
||||
|
||||
/// Blocked until the next `feed`. This is also the initial
|
||||
/// state, since a search that has never been fed has never
|
||||
/// seen the terminal.
|
||||
feed_required,
|
||||
|
||||
/// Caught up with the terminal state as of the last feed.
|
||||
complete,
|
||||
};
|
||||
|
||||
/// Viewport scroll behavior applied by `select` when a match
|
||||
/// becomes selected.
|
||||
pub const SelectScroll = enum {
|
||||
/// Scroll so the match is visible, only if it is not already.
|
||||
if_needed,
|
||||
|
||||
/// Never scroll.
|
||||
none,
|
||||
};
|
||||
|
||||
/// Initialize a search for the given needle. The needle is copied.
|
||||
///
|
||||
/// This doesn't read any terminal state. The first `feed` does.
|
||||
pub fn init(
|
||||
alloc: Allocator,
|
||||
needle_unowned: []const u8,
|
||||
) Allocator.Error!TerminalSearch {
|
||||
var vp: ViewportSearch = try .init(alloc, needle_unowned);
|
||||
errdefer vp.deinit();
|
||||
|
||||
// We use dirty tracking for active area changes. Start with it
|
||||
// dirty so the first change is re-searched.
|
||||
vp.active_dirty = true;
|
||||
|
||||
return .{
|
||||
.alloc = alloc,
|
||||
.viewport = vp,
|
||||
.screens = .init(.{}),
|
||||
.screen_generations = .init(.{}),
|
||||
.active_key = .primary,
|
||||
.stale_viewport_matches = true,
|
||||
.viewport_matches = .empty,
|
||||
};
|
||||
}
|
||||
|
||||
/// Release all state, including tracked pins held within the
|
||||
/// terminal, so this must be called before the terminal is
|
||||
/// deinitialized. The terminal must be the same one given to
|
||||
/// every other call.
|
||||
pub fn deinit(self: *TerminalSearch, t: *Terminal) void {
|
||||
self.clearViewportMatches();
|
||||
self.viewport_matches.deinit(self.alloc);
|
||||
self.viewport.deinit();
|
||||
var it = self.screens.iterator();
|
||||
while (it.next()) |entry| {
|
||||
if (self.screenIsValid(
|
||||
&t.screens,
|
||||
entry.key,
|
||||
entry.value,
|
||||
)) {
|
||||
entry.value.deinit();
|
||||
} else {
|
||||
entry.value.deinitScreenInvalid();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn screenIsValid(
|
||||
self: *const TerminalSearch,
|
||||
screens: *const ScreenSet,
|
||||
key: ScreenSet.Key,
|
||||
search: *const ScreenSearch,
|
||||
) bool {
|
||||
const generation = self.screen_generations.get(key) orelse return false;
|
||||
if (generation != screens.generation(key)) return false;
|
||||
const actual = screens.get(key) orelse return false;
|
||||
return actual == search.screen;
|
||||
}
|
||||
|
||||
/// The needle that this search is using, borrowed.
|
||||
pub fn needle(self: *const TerminalSearch) []const u8 {
|
||||
return self.viewport.needle();
|
||||
}
|
||||
|
||||
/// The searcher for the active screen as of the last feed. Null if
|
||||
/// the search has never been fed (or the screen failed to
|
||||
/// initialize).
|
||||
pub fn activeScreenSearch(self: *TerminalSearch) ?*ScreenSearch {
|
||||
return self.screens.getPtr(self.active_key);
|
||||
}
|
||||
|
||||
/// Returns true if all searches on all screens are complete.
|
||||
pub fn isComplete(self: *TerminalSearch) bool {
|
||||
var it = self.screens.iterator();
|
||||
while (it.next()) |entry| {
|
||||
if (!entry.value.state.isComplete()) return false;
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
/// The overall search status derived from the per-screen search
|
||||
/// states.
|
||||
///
|
||||
/// Unlike `isComplete`, a search that has never been fed reports
|
||||
/// `feed_required` rather than `complete`. An empty screen map
|
||||
/// means the search has never seen the terminal, so reporting it
|
||||
/// as complete would be technically true but useless to a caller
|
||||
/// deciding what to do next.
|
||||
pub fn status(self: *TerminalSearch) Status {
|
||||
var saw_any = false;
|
||||
var result: Status = .complete;
|
||||
var it = self.screens.iterator();
|
||||
while (it.next()) |entry| {
|
||||
saw_any = true;
|
||||
switch (entry.value.state) {
|
||||
// Progress is possible without a feed.
|
||||
.active, .history => return .running,
|
||||
|
||||
// Blocked until fed.
|
||||
.history_feed => result = .feed_required,
|
||||
|
||||
.complete => {},
|
||||
}
|
||||
}
|
||||
|
||||
if (!saw_any) return .feed_required;
|
||||
return result;
|
||||
}
|
||||
|
||||
pub const Tick = enum {
|
||||
/// All searches are complete.
|
||||
complete,
|
||||
|
||||
/// Progress was made on at least one screen.
|
||||
progress,
|
||||
|
||||
/// All incomplete searches are blocked on feed.
|
||||
blocked,
|
||||
};
|
||||
|
||||
/// Tick the search forward as much as possible without reading
|
||||
/// any terminal state, so this is safe to call concurrently with
|
||||
/// terminal IO. Returns the overall tick progress.
|
||||
pub fn tick(self: *TerminalSearch) Tick {
|
||||
var result: Tick = .complete;
|
||||
var it = self.screens.iterator();
|
||||
while (it.next()) |entry| {
|
||||
if (entry.value.tick()) {
|
||||
result = .progress;
|
||||
} else |err| switch (err) {
|
||||
// Ignore... nothing we can do.
|
||||
error.OutOfMemory => log.warn(
|
||||
"error ticking screen search key={} err={}",
|
||||
.{ entry.key, err },
|
||||
),
|
||||
|
||||
// Ignore, good for us. State remains whatever it is.
|
||||
error.SearchComplete => {},
|
||||
|
||||
// Ignore, too, progressed
|
||||
error.FeedRequired => switch (result) {
|
||||
// If we think we're complete, we're not because we're
|
||||
// blocked now (nothing made progress).
|
||||
.complete => result = .blocked,
|
||||
|
||||
// If we made some progress, we remain in progress
|
||||
// since blocked means no progress at all.
|
||||
.progress => {},
|
||||
|
||||
// If we're blocked already then we remain blocked.
|
||||
.blocked => {},
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
/// Read the terminal to update any search state that requires it,
|
||||
/// such as reconciling screens, feeding more data to the
|
||||
/// searchers, and detecting viewport changes.
|
||||
///
|
||||
/// This reads the terminal, so the caller must ensure the terminal
|
||||
/// isn't modified for the duration of this call (e.g. by holding a
|
||||
/// lock). Feeding is also the only way the search learns about
|
||||
/// terminal changes, so callers should feed periodically while a
|
||||
/// search is in use, even after it reports complete.
|
||||
///
|
||||
/// `active_dirty` should be true when the active area may have
|
||||
/// changed since the last feed, which triggers a re-scan of the
|
||||
/// active area. Callers that know when the active area changes
|
||||
/// (e.g. Ghostty's renderer-maintained dirty flag) can pass false
|
||||
/// to skip the re-scan. Callers without that knowledge should
|
||||
/// always pass true. That is always correct, just slightly more
|
||||
/// work, and the active area search is cheap by design.
|
||||
pub fn feed(
|
||||
self: *TerminalSearch,
|
||||
t: *Terminal,
|
||||
active_dirty: bool,
|
||||
) void {
|
||||
const alloc = self.alloc;
|
||||
|
||||
// Update our active screen
|
||||
if (t.screens.active_key != self.active_key) {
|
||||
self.active_key = t.screens.active_key;
|
||||
}
|
||||
|
||||
// Reconcile our screens with the terminal screens. Remove
|
||||
// searchers for screens that no longer exist and add searchers
|
||||
// for screens that do exist but we don't have yet.
|
||||
{
|
||||
// Remove screens we have that no longer exist or changed.
|
||||
var it = self.screens.iterator();
|
||||
while (it.next()) |entry| {
|
||||
const remove = !self.screenIsValid(
|
||||
&t.screens,
|
||||
entry.key,
|
||||
entry.value,
|
||||
);
|
||||
|
||||
if (remove) {
|
||||
entry.value.deinitScreenInvalid();
|
||||
_ = self.screens.remove(entry.key);
|
||||
_ = self.screen_generations.remove(entry.key);
|
||||
}
|
||||
}
|
||||
}
|
||||
{
|
||||
// Add screens that exist but we don't have yet.
|
||||
var it = t.screens.all.iterator();
|
||||
while (it.next()) |entry| {
|
||||
if (self.screens.contains(entry.key)) continue;
|
||||
const screen_search = ScreenSearch.init(
|
||||
alloc,
|
||||
entry.value.*,
|
||||
self.viewport.needle(),
|
||||
) catch |err| switch (err) {
|
||||
error.OutOfMemory => {
|
||||
// OOM is probably going to sink the entire ship but
|
||||
// we can just ignore it and wait on the next
|
||||
// reconciliation to try again.
|
||||
log.warn(
|
||||
"error initializing screen search for key={} err={}",
|
||||
.{ entry.key, err },
|
||||
);
|
||||
continue;
|
||||
},
|
||||
};
|
||||
self.screens.put(entry.key, screen_search);
|
||||
self.screen_generations.put(
|
||||
entry.key,
|
||||
t.screens.generation(entry.key),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// The caller told us the active area may have changed, so mark
|
||||
// our viewport searcher dirty (forcing a re-search) and reload
|
||||
// the active area for the active screen.
|
||||
if (active_dirty) {
|
||||
// Mark our viewport dirty so it researches the active
|
||||
self.viewport.active_dirty = true;
|
||||
|
||||
// Reload our active area for our active screen
|
||||
if (self.screens.getPtr(t.screens.active_key)) |screen_search| {
|
||||
screen_search.reloadActive() catch |err| switch (err) {
|
||||
error.OutOfMemory => log.warn(
|
||||
"error reloading active area for screen key={} err={}",
|
||||
.{ t.screens.active_key, err },
|
||||
),
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
// Check our viewport for changes.
|
||||
if (self.viewport.update(&t.screens.active.pages)) |updated| {
|
||||
if (updated) self.stale_viewport_matches = true;
|
||||
} else |err| switch (err) {
|
||||
error.OutOfMemory => log.warn(
|
||||
"error updating viewport search err={}",
|
||||
.{err},
|
||||
),
|
||||
}
|
||||
|
||||
// Feed data
|
||||
var it = self.screens.iterator();
|
||||
while (it.next()) |entry| {
|
||||
if (entry.value.state.needsFeed()) {
|
||||
entry.value.feed() catch |err| switch (err) {
|
||||
error.OutOfMemory => log.warn(
|
||||
"error feeding screen search key={} err={}",
|
||||
.{ entry.key, err },
|
||||
),
|
||||
};
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Return the matches on the pages covering the viewport, as of
|
||||
/// the last feed. Note this can include matches slightly outside
|
||||
/// the visible viewport when they share a page with it.
|
||||
///
|
||||
/// The results are cached, since the underlying viewport search
|
||||
/// drains as it is read. Matches are collected once after a feed
|
||||
/// notices a viewport change and the cached results are returned
|
||||
/// otherwise. The returned slice is owned by the search and valid
|
||||
/// until the next call to this function, the next feed, or deinit.
|
||||
///
|
||||
/// This doesn't read any terminal state, so it is safe to call
|
||||
/// concurrently with terminal IO.
|
||||
pub fn viewportMatches(
|
||||
self: *TerminalSearch,
|
||||
) Allocator.Error![]const FlattenedHighlight {
|
||||
if (!self.stale_viewport_matches) return self.viewport_matches.items;
|
||||
|
||||
// We always mark the cache fresh, even if collection fails
|
||||
// below: a failed collection isn't retried until the next feed
|
||||
// marks the cache stale again.
|
||||
self.stale_viewport_matches = false;
|
||||
|
||||
self.clearViewportMatches();
|
||||
errdefer {
|
||||
self.clearViewportMatches();
|
||||
|
||||
// Reset the viewport so we force an update (and therefore a
|
||||
// re-collection) on the next feed.
|
||||
self.viewport.reset();
|
||||
}
|
||||
while (self.viewport.next()) |hl| {
|
||||
var hl_cloned = try hl.clone(self.alloc);
|
||||
errdefer hl_cloned.deinit(self.alloc);
|
||||
try self.viewport_matches.append(self.alloc, hl_cloned);
|
||||
}
|
||||
|
||||
return self.viewport_matches.items;
|
||||
}
|
||||
|
||||
fn clearViewportMatches(self: *TerminalSearch) void {
|
||||
for (self.viewport_matches.items) |*hl| hl.deinit(self.alloc);
|
||||
self.viewport_matches.clearRetainingCapacity();
|
||||
}
|
||||
|
||||
/// Select the next or previous search result on the active screen,
|
||||
/// wrapping around at the ends, and optionally scrolling the
|
||||
/// viewport so the newly selected match is visible.
|
||||
///
|
||||
/// This feeds first so the selection always works against current
|
||||
/// terminal state, making it safe to call at any time relative to
|
||||
/// feeds. Like `feed`, this reads (and possibly scrolls) the
|
||||
/// terminal, so the caller must ensure the terminal isn't
|
||||
/// otherwise being used for the duration of this call.
|
||||
///
|
||||
/// Returns true if a match is selected after the operation, or
|
||||
/// false if there are no matches to select.
|
||||
pub fn select(
|
||||
self: *TerminalSearch,
|
||||
t: *Terminal,
|
||||
to: ScreenSearch.Select,
|
||||
scroll: SelectScroll,
|
||||
) Allocator.Error!bool {
|
||||
// A screen can be removed or replaced between feeds. Reconcile
|
||||
// while holding the terminal lock before touching any
|
||||
// ScreenSearch pins.
|
||||
self.feed(t, false);
|
||||
const screen_search = self.screens.getPtr(self.active_key) orelse
|
||||
return false;
|
||||
|
||||
// Make the selection. Ignore the result because we don't
|
||||
// care if the selection didn't change.
|
||||
_ = try screen_search.select(to);
|
||||
|
||||
// Grab our match if we have one. If we don't have a selection
|
||||
// then there was nothing to select.
|
||||
const flattened = screen_search.selectedMatch() orelse return false;
|
||||
|
||||
switch (scroll) {
|
||||
.none => return true,
|
||||
.if_needed => {},
|
||||
}
|
||||
|
||||
// Grab the current screen and see if this match is visible within
|
||||
// the viewport already. If it is, we do nothing.
|
||||
const screen = t.screens.get(self.active_key) orelse return true;
|
||||
|
||||
// Grab the viewport. Viewports and selections are usually small
|
||||
// so this check isn't very expensive, despite appearing O(N^2),
|
||||
// both Ns are usually equal to 1.
|
||||
var it = screen.pages.pageIterator(
|
||||
.right_down,
|
||||
.{ .viewport = .{} },
|
||||
null,
|
||||
);
|
||||
const hl_chunks = flattened.chunks.slice();
|
||||
while (it.next()) |chunk| {
|
||||
for (0..hl_chunks.len) |i| {
|
||||
const hl_chunk = hl_chunks.get(i);
|
||||
if (chunk.overlaps(.{
|
||||
.node = hl_chunk.node,
|
||||
.start = hl_chunk.start,
|
||||
.end = hl_chunk.end,
|
||||
})) return true;
|
||||
}
|
||||
}
|
||||
|
||||
screen.scroll(.{ .pin = flattened.startPin() });
|
||||
return true;
|
||||
}
|
||||
};
|
||||
|
||||
test "starts feed required and runs to complete" {
|
||||
const alloc = testing.allocator;
|
||||
const io = testing.io;
|
||||
var t: Terminal = try .init(io, alloc, .{ .cols = 10, .rows = 2 });
|
||||
defer t.deinit(alloc);
|
||||
|
||||
var stream = t.vtStream();
|
||||
defer stream.deinit();
|
||||
stream.nextSlice("Fizz\r\nBuzz\r\nFizz\r\nBang");
|
||||
|
||||
var search: TerminalSearch = try .init(alloc, "Fizz");
|
||||
defer search.deinit(&t);
|
||||
|
||||
// A fresh search has never seen the terminal, so it must report
|
||||
// feed_required even though isComplete() is trivially true.
|
||||
try testing.expect(search.isComplete());
|
||||
try testing.expectEqual(TerminalSearch.Status.feed_required, search.status());
|
||||
|
||||
// Pump until complete.
|
||||
while (search.status() != .complete) {
|
||||
switch (search.status()) {
|
||||
.feed_required => search.feed(&t, true),
|
||||
.running => _ = search.tick(),
|
||||
.complete => unreachable,
|
||||
}
|
||||
}
|
||||
|
||||
const screen_search = search.activeScreenSearch().?;
|
||||
try testing.expectEqual(2, screen_search.matchesLen());
|
||||
}
|
||||
|
||||
test "viewport matches are cached until the next feed" {
|
||||
const alloc = testing.allocator;
|
||||
const io = testing.io;
|
||||
var t: Terminal = try .init(io, alloc, .{ .cols = 10, .rows = 10 });
|
||||
defer t.deinit(alloc);
|
||||
|
||||
var stream = t.vtStream();
|
||||
defer stream.deinit();
|
||||
stream.nextSlice("Fizz\r\nBuzz\r\nFizz\r\nBang");
|
||||
|
||||
var search: TerminalSearch = try .init(alloc, "Fizz");
|
||||
defer search.deinit(&t);
|
||||
search.feed(&t, true);
|
||||
|
||||
// The sliding window drains on read, so a second read must return
|
||||
// the cached results rather than an empty list.
|
||||
try testing.expectEqual(2, (try search.viewportMatches()).len);
|
||||
try testing.expectEqual(2, (try search.viewportMatches()).len);
|
||||
|
||||
// A feed that observes a change refreshes the cache.
|
||||
stream.nextSlice("\r\nFizz");
|
||||
search.feed(&t, true);
|
||||
try testing.expectEqual(3, (try search.viewportMatches()).len);
|
||||
}
|
||||
|
||||
test "select after active screen removal" {
|
||||
const alloc = testing.allocator;
|
||||
const io = testing.io;
|
||||
var t: Terminal = try .init(io, alloc, .{ .cols = 20, .rows = 2 });
|
||||
defer t.deinit(alloc);
|
||||
|
||||
_ = try t.switchScreen(.alternate);
|
||||
|
||||
var search: TerminalSearch = try .init(alloc, "needle");
|
||||
defer search.deinit(&t);
|
||||
search.feed(&t, false);
|
||||
try testing.expectEqual(ScreenSet.Key.alternate, search.active_key);
|
||||
try testing.expect(search.screens.contains(.alternate));
|
||||
|
||||
_ = try t.switchScreen(.primary);
|
||||
t.screens.remove(alloc, .alternate);
|
||||
|
||||
// The select must reconcile against the live ScreenSet before
|
||||
// touching any pins from the removed screen.
|
||||
_ = try search.select(&t, .next, .if_needed);
|
||||
try testing.expectEqual(ScreenSet.Key.primary, search.active_key);
|
||||
try testing.expect(!search.screens.contains(.alternate));
|
||||
}
|
||||
|
||||
test "select scrolls the viewport only when needed" {
|
||||
const alloc = testing.allocator;
|
||||
const io = testing.io;
|
||||
var t: Terminal = try .init(io, alloc, .{
|
||||
.cols = 10,
|
||||
.rows = 2,
|
||||
.max_scrollback_bytes = std.math.maxInt(usize),
|
||||
});
|
||||
defer t.deinit(alloc);
|
||||
|
||||
var stream = t.vtStream();
|
||||
defer stream.deinit();
|
||||
stream.nextSlice("Fizz\r\n");
|
||||
for (0..30) |_| stream.nextSlice("\r\n");
|
||||
stream.nextSlice("Fizz");
|
||||
|
||||
var search: TerminalSearch = try .init(alloc, "Fizz");
|
||||
defer search.deinit(&t);
|
||||
while (search.status() != .complete) {
|
||||
switch (search.status()) {
|
||||
.feed_required => search.feed(&t, true),
|
||||
.running => _ = search.tick(),
|
||||
.complete => unreachable,
|
||||
}
|
||||
}
|
||||
|
||||
// Whether the selected match is within the visible viewport rows.
|
||||
// pointFromPin only bounds-checks the top of the region, so rows
|
||||
// below the viewport must be rejected by the row count.
|
||||
const Visible = struct {
|
||||
fn check(term: *Terminal, s: *TerminalSearch) bool {
|
||||
const pin = s.activeScreenSearch().?.selectedMatch().?.startPin();
|
||||
const pages = &term.screens.active.pages;
|
||||
const pt = pages.pointFromPin(.viewport, pin) orelse return false;
|
||||
return pt.viewport.y < pages.rows;
|
||||
}
|
||||
};
|
||||
|
||||
// First match is at the bottom which is already visible: no scroll.
|
||||
try testing.expect(try search.select(&t, .next, .if_needed));
|
||||
try testing.expectEqual(
|
||||
point.Point{ .active = .{ .x = 0, .y = 1 } },
|
||||
t.screens.active.pages.pointFromPin(
|
||||
.active,
|
||||
search.activeScreenSearch().?.selectedMatch().?.startPin(),
|
||||
).?,
|
||||
);
|
||||
try testing.expect(Visible.check(&t, &search));
|
||||
|
||||
// Second match is in scrollback: selecting it must scroll the
|
||||
// viewport so it becomes visible.
|
||||
try testing.expect(try search.select(&t, .next, .if_needed));
|
||||
try testing.expect(Visible.check(&t, &search));
|
||||
|
||||
// With scrolling disabled the viewport must stay where it is even
|
||||
// though the next selection (wrap back to the bottom) is not
|
||||
// visible.
|
||||
try testing.expect(try search.select(&t, .next, .none));
|
||||
try testing.expect(!Visible.check(&t, &search));
|
||||
}
|
||||
|
||||
test "no matches selects nothing" {
|
||||
const alloc = testing.allocator;
|
||||
const io = testing.io;
|
||||
var t: Terminal = try .init(io, alloc, .{ .cols = 10, .rows = 2 });
|
||||
defer t.deinit(alloc);
|
||||
|
||||
var search: TerminalSearch = try .init(alloc, "Fizz");
|
||||
defer search.deinit(&t);
|
||||
search.feed(&t, true);
|
||||
try testing.expect(!try search.select(&t, .next, .if_needed));
|
||||
try testing.expect(!try search.select(&t, .prev, .if_needed));
|
||||
}
|
||||
Reference in New Issue
Block a user