From 149c9f562af3933493eb7dd275259eee9ce26f79 Mon Sep 17 00:00:00 2001 From: Mitchell Hashimoto Date: Mon, 31 Aug 2026 13:19:14 -0700 Subject: [PATCH] terminal: extract whole-terminal search orchestration from the search thread --- src/terminal/search.zig | 1 + src/terminal/search/Thread.zig | 560 +++++++--------------------- src/terminal/search/screen.zig | 31 +- src/terminal/search/terminal.zig | 621 +++++++++++++++++++++++++++++++ 4 files changed, 774 insertions(+), 439 deletions(-) create mode 100644 src/terminal/search/terminal.zig diff --git a/src/terminal/search.zig b/src/terminal/search.zig index e69603c25..6913df2e2 100644 --- a/src/terminal/search.zig +++ b/src/terminal/search.zig @@ -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 diff --git a/src/terminal/search/Thread.zig b/src/terminal/search/Thread.zig index ecc727399..0b7813219 100644 --- a/src/terminal/search/Thread.zig +++ b/src/terminal/search/Thread.zig @@ -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)); -} diff --git a/src/terminal/search/screen.zig b/src/terminal/search/screen.zig index 81e43ecaf..cbda5a388 100644 --- a/src/terminal/search/screen.zig +++ b/src/terminal/search/screen.zig @@ -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 { diff --git a/src/terminal/search/terminal.zig b/src/terminal/search/terminal.zig new file mode 100644 index 000000000..eca292778 --- /dev/null +++ b/src/terminal/search/terminal.zig @@ -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)); +}