terminal: extract whole-terminal search orchestration from the search thread

This commit is contained in:
Mitchell Hashimoto
2026-08-31 13:19:14 -07:00
parent c2906398be
commit 149c9f562a
4 changed files with 774 additions and 439 deletions

View File

@@ -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

View File

@@ -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));
}

View File

@@ -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 {

View 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));
}