Search Thread (#9602)

Progress towards #189 

## Search Thread

This completes an initial implementation of the search thread. This is a
separate thread that manages a terminal search operation, triggering
event callbacks for various scenarios. The performance goal of the
search thread is to minimize time spent within the critical area of the
terminal lock while making forward progress on search outside of that.

The search thread sends two messages back to the caller right now:

- Total matches: sent continuously as new matches are found, just a
number
- Viewport matches: a list of `Selection` structures spanning the
current viewport of matches within the visible viewport. Sent whenever
the viewport changes (location or content).

I think that's enough to build a rudimentary search UI.

For this initial implementation, the search also relies on a "refresh
timer" which trigger every 24ms (40 FPS) to grab the lock and look for
any reconciliation that needs to happen: viewport moved, active screen
changed, active area changed, etc. This is a total guess and is
arbitrary currently. The value should be tuned to balance responsiveness
and IO throughput (lock-holding). I actually suspect this may be too
frequent right now.

A TODO is noted for the future to pause the refresh timer when the
terminal being search isn't focused. We'll have to do that before
shipping search because the way its built right now we will definitely
consume unnecessary CPU while unfocused. But only while search is
active.

## `ViewportSearch`

I also determined the need for what is called the `ViewportSearch`
layer. This is similar to `ActiveSearch` in that it throws away and
re-searches an area, but is tuned towards efficiently detecting viewport
changes. I found its more efficient to continuously research the visible
viewport than to hunt for those matches within the cached ScreenSearch
results, which can be very large.

## Future

Next up we need to hook up the search thread to some keybindings to
start and stop the search so we can then trigger those from our apprts
(GUIs).

I highly suspect this is going to expose some major performance issues
(overly active message sending being the likely culprit) that we can fix
up in the search thread thereafter. Up until this point we can only run
this stuff in isolation in unit tests which is good for testing
correctness but difficult for testing resource usage.

There are some written TODOs in the Thread.zig file in this PR. I may
address some of them before merging, since I think a couple are pretty
obvious performance gotchas.
pull/9605/head
Mitchell Hashimoto 2025-11-15 19:50:28 -08:00 committed by GitHub
commit 4c0d7379db
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
5 changed files with 993 additions and 15 deletions

View File

@ -73,7 +73,7 @@ pub fn BlockingQueue(
not_full_waiters: usize = 0,
/// Allocate the blocking queue on the heap.
pub fn create(alloc: Allocator) !*Self {
pub fn create(alloc: Allocator) Allocator.Error!*Self {
const ptr = try alloc.create(Self);
errdefer alloc.destroy(ptr);

View File

@ -3,6 +3,7 @@
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 Viewport = @import("search/viewport.zig").ViewportSearch;
pub const Thread = @import("search/Thread.zig");
test {

View File

@ -9,11 +9,38 @@
pub const Thread = @This();
const std = @import("std");
const builtin = @import("builtin");
const testing = std.testing;
const Allocator = std.mem.Allocator;
const Mutex = std.Thread.Mutex;
const xev = @import("../../global.zig").xev;
const internal_os = @import("../../os/main.zig");
const BlockingQueue = @import("../../datastruct/main.zig").BlockingQueue;
const point = @import("../point.zig");
const PageList = @import("../PageList.zig");
const Screen = @import("../Screen.zig");
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 log = std.log.scoped(.search_thread);
// TODO: Some stuff that could be improved:
// - pause the refresh timer when the terminal isn't focused
// - we probably want to know our progress through the search
// for viewport matches so we can show n/total UI.
// - notifications should be coalesced to avoid spamming a massive
// amount of events if the terminal is changing rapidly.
/// The interval at which we refresh the terminal state to check if
/// there are any changes that require us to re-search. This should be
/// balanced to be fast enough to be responsive but not so fast that
/// we hold the terminal lock too often.
const REFRESH_INTERVAL = 24; // 40 FPS
/// Allocator used for some state
alloc: std.mem.Allocator,
@ -21,25 +48,78 @@ alloc: std.mem.Allocator,
/// this is a blocking queue so if it is full you will get errors (or block).
mailbox: *Mailbox,
/// The event loop for the search thread.
loop: xev.Loop,
/// This can be used to wake up the renderer and force a render safely from
/// any thread.
wakeup: xev.Async,
wakeup_c: xev.Completion = .{},
/// This can be used to stop the thread on the next loop iteration.
stop: xev.Async,
stop_c: xev.Completion = .{},
/// The timer used for refreshing the terminal state to determine if
/// we have a stale active area, viewport, screen change, etc. This is
/// CPU intensive so we stop doing this under certain conditions.
refresh: xev.Timer,
refresh_c: xev.Completion = .{},
refresh_active: bool = false,
/// Search state. Starts as null and is populated when a search is
/// started (a needle is given).
search: ?Search = null,
/// The options used to initialize this thread.
opts: Options,
/// Initialize the thread. This does not START the thread. This only sets
/// up all the internal state necessary prior to starting the thread. It
/// is up to the caller to start the thread with the threadMain entrypoint.
pub fn init(alloc: Allocator) Thread {
pub fn init(alloc: Allocator, opts: Options) !Thread {
// The mailbox for messaging this thread
var mailbox = try Mailbox.create(alloc);
errdefer mailbox.destroy(alloc);
// Create our event loop.
var loop = try xev.Loop.init(.{});
errdefer loop.deinit();
// This async handle is used to "wake up" the renderer and force a render.
var wakeup_h = try xev.Async.init();
errdefer wakeup_h.deinit();
// This async handle is used to stop the loop and force the thread to end.
var stop_h = try xev.Async.init();
errdefer stop_h.deinit();
// Refresh timer, see comments.
var refresh_h = try xev.Timer.init();
errdefer refresh_h.deinit();
return .{
.alloc = alloc,
.mailbox = mailbox,
.loop = loop,
.wakeup = wakeup_h,
.stop = stop_h,
.refresh = refresh_h,
.opts = opts,
};
}
/// Clean up the thread. This is only safe to call once the thread
/// completes executing; the caller must join prior to this.
pub fn deinit(self: *Thread) void {
self.refresh.deinit();
self.wakeup.deinit();
self.stop.deinit();
self.loop.deinit();
// Nothing can possibly access the mailbox anymore, destroy it.
self.mailbox.destroy(self.alloc);
if (self.search) |*s| s.deinit();
}
/// The main entrypoint for the thread.
@ -53,11 +133,579 @@ pub fn threadMain(self: *Thread) void {
fn threadMain_(self: *Thread) !void {
defer log.debug("search thread exited", .{});
_ = self;
// Right now, on Darwin, `std.Thread.setName` can only name the current
// thread, and we have no way to get the current thread from within it,
// so instead we use this code to name the thread instead.
if (comptime builtin.os.tag.isDarwin()) {
internal_os.macos.pthread_setname_np(&"search".*);
// We can run with lower priority than other threads.
const class: internal_os.macos.QosClass = .utility;
if (internal_os.macos.setQosClass(class)) {
log.debug("thread QoS class set class={}", .{class});
} else |err| {
log.warn("error setting QoS class err={}", .{err});
}
}
// Start the async handlers
self.wakeup.wait(&self.loop, &self.wakeup_c, Thread, self, wakeupCallback);
self.stop.wait(&self.loop, &self.stop_c, Thread, self, stopCallback);
// Send an initial wakeup so we drain our mailbox immediately.
try self.wakeup.notify();
// Start the refresh timer
self.startRefreshTimer();
// Run
log.debug("starting search thread", .{});
defer log.debug("starting search thread shutdown", .{});
// Unlike some of our other threads, we interleave search work
// with our xev loop so that we can try to make forward search progress
// while also listening for messages.
while (true) {
// If our loop is canceled then we drain our messages and quit.
if (self.loop.stopped()) {
while (self.mailbox.pop()) |message| {
log.debug("mailbox message ignored during shutdown={}", .{message});
}
return;
}
const s: *Search = 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);
continue;
};
// If we have an active search, we always send any pending
// 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,
);
}
if (s.isComplete()) {
// If our search is complete, there's no more work to do, we
// can block until we have an xev action.
try self.loop.run(.once);
continue;
}
// Tick the search. This will trigger any event callbacks, lock
// for data loading, etc.
switch (s.tick()) {
// We're complete now when we were not before. Notify!
.complete => {},
// Forward progress was made.
.progress => {},
// All searches are blocked. Let's grab the lock and feed data.
.blocked => {
self.opts.mutex.lock();
defer self.opts.mutex.unlock();
s.feed(self.alloc, self.opts.terminal);
},
}
// We have an active search, so we only want to process messages
// we have but otherwise return immediately so we can continue the
// search. If the above completed the search, we still want to
// go around the loop as quickly as possible to send notifications,
// and then we'll block on the loop next time.
try self.loop.run(.no_wait);
}
}
/// Drain the mailbox.
fn drainMailbox(self: *Thread) !void {
while (self.mailbox.pop()) |message| {
log.debug("mailbox message={}", .{message});
switch (message) {
.change_needle => |v| try self.changeNeedle(v),
}
}
}
/// Change the search term to the given value.
fn changeNeedle(self: *Thread, needle: []const u8) !void {
log.debug("changing search needle to '{s}'", .{needle});
// Stop the previous search
if (self.search) |*s| {
s.deinit();
self.search = null;
}
// No needle means stop the search.
if (needle.len == 0) return;
// Setup our search state.
self.search = try .init(self.alloc, needle);
// We need to grab the terminal lock and do an initial feed.
self.opts.mutex.lock();
defer self.opts.mutex.unlock();
self.search.?.feed(self.alloc, self.opts.terminal);
}
fn startRefreshTimer(self: *Thread) void {
// Set our active state so it knows we're running. We set this before
// even checking the active state in case we have a pending shutdown.
self.refresh_active = true;
// If our timer is already active, then we don't have to do anything.
if (self.refresh_c.state() == .active) return;
// Start the timer which loops
self.refresh.run(
&self.loop,
&self.refresh_c,
REFRESH_INTERVAL,
Thread,
self,
refreshCallback,
);
}
fn stopRefreshTimer(self: *Thread) void {
// This will stop the refresh on the next iteration.
self.refresh_active = false;
}
fn wakeupCallback(
self_: ?*Thread,
_: *xev.Loop,
_: *xev.Completion,
r: xev.Async.WaitError!void,
) xev.CallbackAction {
_ = r catch |err| {
log.warn("error in wakeup err={}", .{err});
return .rearm;
};
const self = self_.?;
// When we wake up, we drain the mailbox. Mailbox producers should
// wake up our thread after publishing.
self.drainMailbox() catch |err|
log.warn("error draining mailbox err={}", .{err});
return .rearm;
}
fn stopCallback(
self_: ?*Thread,
_: *xev.Loop,
_: *xev.Completion,
r: xev.Async.WaitError!void,
) xev.CallbackAction {
_ = r catch unreachable;
self_.?.loop.stop();
return .disarm;
}
fn refreshCallback(
self_: ?*Thread,
_: *xev.Loop,
_: *xev.Completion,
r: xev.Timer.RunError!void,
) xev.CallbackAction {
_ = r catch unreachable;
const self: *Thread = self_ orelse {
// This shouldn't happen so we log it.
log.warn("refresh callback fired without data set", .{});
return .disarm;
};
// Run our feed if we have a search active.
if (self.search) |*s| {
self.opts.mutex.lock();
defer self.opts.mutex.unlock();
s.feed(self.alloc, self.opts.terminal);
}
// Only continue if we're still active
if (self.refresh_active) self.refresh.run(
&self.loop,
&self.refresh_c,
REFRESH_INTERVAL,
Thread,
self,
refreshCallback,
);
return .disarm;
}
pub const Options = struct {
/// Mutex that must be held while reading/writing the terminal.
mutex: *Mutex,
/// The terminal data to search.
terminal: *Terminal,
/// The callback for events from the search thread along with optional
/// userdata. This can be null if you don't want to receive events,
/// which could be useful for a one-time search (although, odd, you
/// should use our search structures directly then).
event_cb: ?EventCallback = null,
event_userdata: ?*anyopaque = null,
};
pub const EventCallback = *const fn (event: Event, userdata: ?*anyopaque) void;
/// The type used for sending messages to the thread.
pub const Mailbox = BlockingQueue(Message, 64);
/// The messages that can be sent to the thread.
pub const Message = union(enum) {};
pub const Message = union(enum) {
/// Change the search term. If no prior search term is given this
/// will start a search. If an existing search term is given this will
/// stop the prior search and start a new one.
change_needle: []const u8,
};
/// Events that can be emitted from the search thread. The caller
/// chooses to handle these as they see fit.
pub const Event = union(enum) {
/// Search is complete for the given needle on all screens.
complete,
/// Total matches on the current active screen have changed.
total_matches: usize,
/// Matches in the viewport have changed. The memory is owned by the
/// search thread and is only valid during the callback.
viewport_matches: []const Selection,
};
/// Search state.
const Search = struct {
/// Active viewport search for the active screen.
viewport: ViewportSearch,
/// The searchers for all the screens.
screens: std.EnumMap(ScreenSet.Key, ScreenSearch),
/// The last active screen
last_active_screen: ScreenSet.Key,
/// The last total matches reported.
last_total: ?usize,
/// True if we sent the complete notification yet.
last_complete: bool,
/// The last viewport matches we found.
stale_viewport_matches: bool,
pub fn init(
alloc: Allocator,
needle: []const u8,
) Allocator.Error!Search {
var vp: ViewportSearch = try .init(alloc, needle);
errdefer vp.deinit();
return .{
.viewport = vp,
.screens = .init(.{}),
.last_active_screen = .primary,
.last_total = null,
.last_complete = false,
.stale_viewport_matches = true,
};
}
pub fn deinit(self: *Search) void {
self.viewport.deinit();
var it = self.screens.iterator();
while (it.next()) |entry| entry.value.deinit();
}
/// 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_active_screen) {
self.last_active_screen = t.screens.active_key;
self.last_total = null; // force notification
}
// 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: bool = remove: {
// If the screen doesn't exist at all, remove it.
const actual = t.screens.all.get(entry.key) orelse break :remove true;
// If the screen pointer changed, remove it, the screen
// was totally reinitialized.
break :remove actual != entry.value.screen;
};
if (remove) {
entry.value.deinit();
_ = self.screens.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;
self.screens.put(entry.key, 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;
},
});
}
}
// 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_active_screen) orelse return;
// Check our total match data
const total = screen_search.matchesLen();
if (total != self.last_total) {
self.last_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 results: std.ArrayList(Selection) = .empty;
defer results.deinit(alloc);
while (self.viewport.next()) |sel| {
results.append(alloc, sel) 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;
},
};
}
cb(.{ .viewport_matches = results.items }, ud);
}
// Send our complete notification if we just completed.
if (!self.last_complete and self.isComplete()) {
self.last_complete = true;
cb(.complete, ud);
}
}
};
test {
const UserData = struct {
const Self = @This();
reset: std.Thread.ResetEvent = .{},
total: usize = 0,
viewport: []const Selection = &.{},
fn callback(event: Event, userdata: ?*anyopaque) void {
const ud: *Self = @ptrCast(@alignCast(userdata.?));
switch (event) {
.complete => ud.reset.set(),
.total_matches => |v| ud.total = v,
.viewport_matches => |v| {
testing.allocator.free(ud.viewport);
ud.viewport = testing.allocator.dupe(
Selection,
v,
) catch unreachable;
},
}
}
};
const alloc = testing.allocator;
var mutex: std.Thread.Mutex = .{};
var t: Terminal = try .init(alloc, .{ .cols = 20, .rows = 2 });
defer t.deinit(alloc);
var stream = t.vtStream();
defer stream.deinit();
try stream.nextSlice("Hello, world");
var ud: UserData = .{};
defer alloc.free(ud.viewport);
var thread: Thread = try .init(alloc, .{
.mutex = &mutex,
.terminal = &t,
.event_cb = &UserData.callback,
.event_userdata = &ud,
});
defer thread.deinit();
var os_thread = try std.Thread.spawn(
.{},
threadMain,
.{&thread},
);
// Start our search
_ = thread.mailbox.push(
.{ .change_needle = "world" },
.forever,
);
try thread.wakeup.notify();
// Wait for completion
try ud.reset.timedWait(100 * std.time.ns_per_ms);
// Stop the thread
try thread.stop.notify();
os_thread.join();
// 1 total matches
try testing.expectEqual(1, ud.total);
try testing.expectEqual(1, ud.viewport.len);
{
const sel = ud.viewport[0];
try testing.expectEqual(point.Point{ .screen = .{
.x = 7,
.y = 0,
} }, t.screens.active.pages.pointFromPin(.screen, sel.start()).?);
try testing.expectEqual(point.Point{ .screen = .{
.x = 11,
.y = 0,
} }, t.screens.active.pages.pointFromPin(.screen, sel.end()).?);
}
}

View File

@ -78,17 +78,31 @@ pub const ScreenSearch = struct {
/// Search is complete given the current terminal state.
complete,
pub fn isComplete(self: State) bool {
return switch (self) {
.complete => true,
else => false,
};
}
pub fn needsFeed(self: State) bool {
return switch (self) {
.history_feed => true,
else => false,
};
}
};
// Initialize a screen search for the given screen and needle.
pub fn init(
alloc: Allocator,
screen: *Screen,
needle: []const u8,
needle_unowned: []const u8,
) Allocator.Error!ScreenSearch {
var result: ScreenSearch = .{
.screen = screen,
.active = try .init(alloc, needle),
.active = try .init(alloc, needle_unowned),
.history = null,
.state = .active,
.active_results = .empty,
@ -114,10 +128,16 @@ pub const ScreenSearch = struct {
return self.active.window.alloc;
}
pub const TickError = Allocator.Error || error{
FeedRequired,
SearchComplete,
};
/// The needle that this search is using.
pub fn needle(self: *const ScreenSearch) []const u8 {
assert(self.active.window.direction == .forward);
return self.active.window.needle;
}
/// Returns the total number of matches found so far.
pub fn matchesLen(self: *const ScreenSearch) usize {
return self.active_results.items.len + self.history_results.items.len;
}
/// Returns all matches as an owned slice (caller must free).
/// The matches are ordered from most recent to oldest (e.g. bottom
@ -167,6 +187,11 @@ pub const ScreenSearch = struct {
}
}
pub const TickError = Allocator.Error || error{
FeedRequired,
SearchComplete,
};
/// Make incremental progress on the search without accessing any
/// screen state (so no lock is required).
///
@ -291,12 +316,9 @@ pub const ScreenSearch = struct {
// No history search yet, but we now have history. So let's
// initialize.
// Our usage of needle below depends on this
assert(self.active.window.direction == .forward);
var search: PageListSearch = try .init(
self.allocator(),
self.active.window.needle,
self.needle(),
list,
history_node,
);
@ -328,7 +350,7 @@ pub const ScreenSearch = struct {
var window: SlidingWindow = try .init(
alloc,
.forward,
self.active.window.needle,
self.needle(),
);
defer window.deinit();
while (true) {

View File

@ -0,0 +1,307 @@
const std = @import("std");
const assert = std.debug.assert;
const testing = std.testing;
const Allocator = std.mem.Allocator;
const point = @import("../point.zig");
const size = @import("../size.zig");
const PageList = @import("../PageList.zig");
const Selection = @import("../Selection.zig");
const SlidingWindow = @import("sliding_window.zig").SlidingWindow;
const Terminal = @import("../Terminal.zig");
/// Searches for a substring within the viewport of a PageList.
///
/// This contains logic to efficiently detect when the viewport changes
/// and only re-search when necessary.
///
/// The specialization for "viewport" is because the viewport is the
/// only part of the search where the user can actively see the results,
/// usually. In that case, it is more efficient to re-search only the
/// viewport rather than store all the results for the entire screen.
///
/// Note that this searches all the pages that viewport covers, so
/// this can include extra matches outside the viewport if the data
/// lives in the same page.
pub const ViewportSearch = struct {
window: SlidingWindow,
fingerprint: ?Fingerprint,
pub fn init(
alloc: Allocator,
needle_unowned: []const u8,
) Allocator.Error!ViewportSearch {
// We just do a forward search since the viewport is usually
// pretty small so search results are instant anyways. This avoids
// a small amount of work to reverse things.
var window: SlidingWindow = try .init(alloc, .forward, needle_unowned);
errdefer window.deinit();
return .{ .window = window, .fingerprint = null };
}
pub fn deinit(self: *ViewportSearch) void {
if (self.fingerprint) |*fp| fp.deinit(self.window.alloc);
self.window.deinit();
}
/// Reset our fingerprint and results so that the next update will
/// always re-search.
pub fn reset(self: *ViewportSearch) void {
if (self.fingerprint) |*fp| fp.deinit(self.window.alloc);
self.fingerprint = null;
self.window.clearAndRetainCapacity();
}
/// The needle that this search is using.
pub fn needle(self: *const ViewportSearch) []const u8 {
assert(self.window.direction == .forward);
return self.window.needle;
}
/// Update the sliding window to reflect the current viewport. This
/// will do nothing if the viewport hasn't changed since the last
/// search.
///
/// The PageList must be safe to read throughout the lifetime of this
/// function.
///
/// Returns true if the viewport changed and a re-search is needed.
/// Returns false if the viewport is unchanged.
pub fn update(
self: *ViewportSearch,
list: *PageList,
) Allocator.Error!bool {
// See if our viewport changed
var fingerprint: Fingerprint = try .init(self.window.alloc, list);
if (self.fingerprint) |*old| {
if (old.eql(fingerprint)) match: {
// If our fingerprint contains the active area, then we always
// re-search since the active area is mutable.
const active_tl = list.getTopLeft(.active);
const active_br = list.getBottomRight(.active).?;
// If our viewport contains the start or end of the active area,
// we are in the active area. We purposely do this first
// because our viewport is always larger than the active area.
for (old.nodes) |node| {
if (node == active_tl.node) break :match;
if (node == active_br.node) break :match;
}
// No change
fingerprint.deinit(self.window.alloc);
return false;
}
old.deinit(self.window.alloc);
self.fingerprint = null;
}
assert(self.fingerprint == null);
self.fingerprint = fingerprint;
errdefer {
fingerprint.deinit(self.window.alloc);
self.fingerprint = null;
}
// Clear our previous sliding window
self.window.clearAndRetainCapacity();
// Add enough overlap to cover needle.len - 1 bytes (if it
// exists) so we can cover the overlap. We only do this for the
// soft-wrapped prior pages.
var node_ = fingerprint.nodes[0].prev;
var added: usize = 0;
while (node_) |node| : (node_ = node.prev) {
// If the last row of this node isn't wrapped we can't overlap.
const row = node.data.getRow(node.data.size.rows - 1);
if (!row.wrap) break;
// We could be more accurate here and count bytes since the
// last wrap but its complicated and unlikely multiple pages
// wrap so this should be fine.
added += try self.window.append(node);
if (added >= self.window.needle.len - 1) break;
}
// We can use our fingerprint nodes to initialize our sliding
// window, since we already traversed the viewport once.
for (fingerprint.nodes) |node| {
_ = try self.window.append(node);
}
// Add any trailing overlap as well.
trailing: {
const end: *PageList.List.Node = fingerprint.nodes[fingerprint.nodes.len - 1];
if (!end.data.getRow(end.data.size.rows - 1).wrap) break :trailing;
node_ = end.next;
added = 0;
while (node_) |node| : (node_ = node.next) {
added += try self.window.append(node);
if (added >= self.window.needle.len - 1) break;
// If this row doesn't wrap, then we can quit
const row = node.data.getRow(node.data.size.rows - 1);
if (!row.wrap) break;
}
}
return true;
}
/// Find the next match for the needle in the active area. This returns
/// null when there are no more matches.
pub fn next(self: *ViewportSearch) ?Selection {
return self.window.next();
}
/// Viewport fingerprint so we can detect when the viewport moves.
const Fingerprint = struct {
/// The nodes that make up the viewport. We need to flatten this
/// to a single list because we can't safely traverse the cached values
/// because the page nodes may be invalid. All that is safe is comparing
/// the actual pointer values.
nodes: []const *PageList.List.Node,
pub fn init(alloc: Allocator, pages: *PageList) Allocator.Error!Fingerprint {
var list: std.ArrayList(*PageList.List.Node) = .empty;
defer list.deinit(alloc);
// Get our viewport area. Bottom right of a viewport can never
// fail.
const tl = pages.getTopLeft(.viewport);
const br = pages.getBottomRight(.viewport).?;
var it = tl.pageIterator(.right_down, br);
while (it.next()) |chunk| try list.append(alloc, chunk.node);
return .{ .nodes = try list.toOwnedSlice(alloc) };
}
pub fn deinit(self: *Fingerprint, alloc: Allocator) void {
alloc.free(self.nodes);
}
pub fn eql(self: Fingerprint, other: Fingerprint) bool {
if (self.nodes.len != other.nodes.len) return false;
for (self.nodes, other.nodes) |a, b| {
if (a != b) return false;
}
return true;
}
};
};
test "simple search" {
const alloc = testing.allocator;
var t: Terminal = try .init(alloc, .{ .cols = 10, .rows = 10 });
defer t.deinit(alloc);
var s = t.vtStream();
defer s.deinit();
try s.nextSlice("Fizz\r\nBuzz\r\nFizz\r\nBang");
var search: ViewportSearch = try .init(alloc, "Fizz");
defer search.deinit();
try testing.expect(try search.update(&t.screens.active.pages));
// Viewport contains active so update should always re-search.
try testing.expect(try search.update(&t.screens.active.pages));
{
const sel = search.next().?;
try testing.expectEqual(point.Point{ .active = .{
.x = 0,
.y = 0,
} }, t.screens.active.pages.pointFromPin(.active, sel.start()).?);
try testing.expectEqual(point.Point{ .active = .{
.x = 3,
.y = 0,
} }, t.screens.active.pages.pointFromPin(.active, sel.end()).?);
}
{
const sel = search.next().?;
try testing.expectEqual(point.Point{ .active = .{
.x = 0,
.y = 2,
} }, t.screens.active.pages.pointFromPin(.active, sel.start()).?);
try testing.expectEqual(point.Point{ .active = .{
.x = 3,
.y = 2,
} }, t.screens.active.pages.pointFromPin(.active, sel.end()).?);
}
try testing.expect(search.next() == null);
}
test "clear screen and search" {
const alloc = testing.allocator;
var t: Terminal = try .init(alloc, .{ .cols = 10, .rows = 10 });
defer t.deinit(alloc);
var s = t.vtStream();
defer s.deinit();
try s.nextSlice("Fizz\r\nBuzz\r\nFizz\r\nBang");
var search: ViewportSearch = try .init(alloc, "Fizz");
defer search.deinit();
try testing.expect(try search.update(&t.screens.active.pages));
try s.nextSlice("\x1b[2J"); // Clear screen
try s.nextSlice("\x1b[H"); // Move cursor home
try s.nextSlice("Buzz\r\nFizz\r\nBuzz");
try testing.expect(try search.update(&t.screens.active.pages));
{
const sel = search.next().?;
try testing.expectEqual(point.Point{ .active = .{
.x = 0,
.y = 1,
} }, t.screens.active.pages.pointFromPin(.active, sel.start()).?);
try testing.expectEqual(point.Point{ .active = .{
.x = 3,
.y = 1,
} }, t.screens.active.pages.pointFromPin(.active, sel.end()).?);
}
try testing.expect(search.next() == null);
}
test "history search, no active area" {
const alloc = testing.allocator;
var t: Terminal = try .init(alloc, .{ .cols = 10, .rows = 2 });
defer t.deinit(alloc);
var s = t.vtStream();
defer s.deinit();
// Fill up first page
const first_page_rows = t.screens.active.pages.pages.first.?.data.capacity.rows;
try s.nextSlice("Fizz\r\n");
for (1..first_page_rows - 1) |_| try s.nextSlice("\r\n");
try testing.expect(t.screens.active.pages.pages.first == t.screens.active.pages.pages.last);
// Create second page
try s.nextSlice("\r\n");
try testing.expect(t.screens.active.pages.pages.first != t.screens.active.pages.pages.last);
try s.nextSlice("Buzz\r\nFizz");
try t.scrollViewport(.top);
var search: ViewportSearch = try .init(alloc, "Fizz");
defer search.deinit();
try testing.expect(try search.update(&t.screens.active.pages));
{
const sel = search.next().?;
try testing.expectEqual(point.Point{ .screen = .{
.x = 0,
.y = 0,
} }, t.screens.active.pages.pointFromPin(.screen, sel.start()).?);
try testing.expectEqual(point.Point{ .screen = .{
.x = 3,
.y = 0,
} }, t.screens.active.pages.pointFromPin(.screen, sel.end()).?);
}
try testing.expect(search.next() == null);
// Viewport doesn't contain active
try testing.expect(!try search.update(&t.screens.active.pages));
try testing.expect(search.next() == null);
}