terminal: ActiveSearch for searching the active area

pull/9585/head
Mitchell Hashimoto 2025-11-12 10:27:52 -08:00
parent 43835d1468
commit 0ea350a8f2
No known key found for this signature in database
GPG Key ID: 523D5DC389D273BC
3 changed files with 215 additions and 33 deletions

View File

@ -1,5 +1,6 @@
//! Search functionality for the terminal.
pub const Active = @import("search/active.zig").ActiveSearch;
pub const PageList = @import("search/pagelist.zig").PageListSearch;
pub const Thread = @import("search/Thread.zig");

View File

@ -0,0 +1,168 @@
const std = @import("std");
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 active area of a PageList.
///
/// The distinction for "active area" is important because it is the
/// only part of a PageList that is mutable. Therefore, its the only part
/// of the terminal that needs to be repeatedly searched as the contents
/// change.
///
/// This struct specializes in searching only within that active area,
/// and handling the active area moving as new lines are added to the bottom.
pub const ActiveSearch = struct {
window: SlidingWindow,
pub fn init(
alloc: Allocator,
needle: []const u8,
) Allocator.Error!ActiveSearch {
// We just do a forward search since the active area 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);
errdefer window.deinit();
return .{ .window = window };
}
pub fn deinit(self: *ActiveSearch) void {
self.window.deinit();
}
/// Update the active area to reflect the current state of the PageList.
///
/// This doesn't do the search, it only copies the necessary data
/// to perform the search later. This lets the caller hold the lock
/// on the PageList for a minimal amount of time.
///
/// This returns the first page (in reverse order) NOT searched by
/// this active area. This is useful for callers that want to follow up
/// with populating the scrollback searcher. The scrollback searcher
/// should start searching from the returned page backwards.
///
/// If the return value is null it means the active area covers the entire
/// PageList, currently.
pub fn update(
self: *ActiveSearch,
list: *const PageList,
) Allocator.Error!?*PageList.List.Node {
// Clear our previous sliding window
self.window.clearAndRetainCapacity();
// First up, add enough pages to cover the active area.
var rem: usize = list.rows;
var node_ = list.pages.last;
while (node_) |node| : (node_ = node.prev) {
_ = try self.window.append(node);
// If we reached our target amount, then this is the last
// page that contains the active area. We go to the previous
// page once more since its the first page of our required
// overlap.
if (rem <= node.data.size.rows) {
node_ = node.prev;
break;
}
rem -= node.data.size.rows;
}
// Next, add enough overlap to cover needle.len - 1 bytes (if it
// exists) so we can cover the overlap.
rem = self.window.needle.len - 1;
while (node_) |node| : (node_ = node.prev) {
const added = try self.window.append(node);
if (added >= rem) {
node_ = node.prev;
break;
}
rem -= added;
}
// Return the first page NOT covered by the active area.
return node_;
}
/// Find the next match for the needle in the active area. This returns
/// null when there are no more matches.
pub fn next(self: *ActiveSearch) ?Selection {
return self.window.next();
}
};
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: ActiveSearch = try .init(alloc, "Fizz");
defer search.deinit();
_ = try search.update(&t.screen.pages);
{
const sel = search.next().?;
try testing.expectEqual(point.Point{ .active = .{
.x = 0,
.y = 0,
} }, t.screen.pages.pointFromPin(.active, sel.start()).?);
try testing.expectEqual(point.Point{ .active = .{
.x = 3,
.y = 0,
} }, t.screen.pages.pointFromPin(.active, sel.end()).?);
}
{
const sel = search.next().?;
try testing.expectEqual(point.Point{ .active = .{
.x = 0,
.y = 2,
} }, t.screen.pages.pointFromPin(.active, sel.start()).?);
try testing.expectEqual(point.Point{ .active = .{
.x = 3,
.y = 2,
} }, t.screen.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: ActiveSearch = try .init(alloc, "Fizz");
defer search.deinit();
_ = try search.update(&t.screen.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 search.update(&t.screen.pages);
{
const sel = search.next().?;
try testing.expectEqual(point.Point{ .active = .{
.x = 0,
.y = 1,
} }, t.screen.pages.pointFromPin(.active, sel.start()).?);
try testing.expectEqual(point.Point{ .active = .{
.x = 3,
.y = 1,
} }, t.screen.pages.pointFromPin(.active, sel.end()).?);
}
try testing.expect(search.next() == null);
}

View File

@ -142,6 +142,14 @@ pub const SlidingWindow = struct {
/// the window moves, the window will prune itself while maintaining
/// the invariant that the window is always big enough to contain
/// the needle.
///
/// It may seem wasteful to return a full selection, since the needle
/// length is known it seems like we can get away with just returning
/// the start index. However, returning a full selection will give us
/// more flexibility in the future (e.g. if we want to support regex
/// searches or other more complex searches). It does cost us some memory,
/// but searches are expected to be relatively rare compared to normal
/// operations and can eat up some extra memory temporarily.
pub fn next(self: *SlidingWindow) ?Selection {
const slices = slices: {
// If we have less data then the needle then we can't possibly match
@ -368,10 +376,14 @@ pub const SlidingWindow = struct {
/// Add a new node to the sliding window. This will always grow
/// the sliding window; data isn't pruned until it is consumed
/// via a search (via next()).
///
/// Returns the number of bytes of content added to the sliding window.
/// The total bytes will be larger since this omits metadata, but it is
/// an accurate measure of the text content size added.
pub fn append(
self: *SlidingWindow,
node: *PageList.List.Node,
) Allocator.Error!void {
) Allocator.Error!usize {
// Initialize our metadata for the node.
var meta: Meta = .{
.node = node,
@ -422,6 +434,7 @@ pub const SlidingWindow = struct {
try self.meta.append(meta);
self.assertIntegrity();
return written.len;
}
/// Only for tests!
@ -474,7 +487,7 @@ test "SlidingWindow single append" {
// We want to test single-page cases.
try testing.expect(s.pages.pages.first == s.pages.pages.last);
const node: *PageList.List.Node = s.pages.pages.first.?;
try w.append(node);
_ = try w.append(node);
// We should be able to find two matches.
{
@ -517,7 +530,7 @@ test "SlidingWindow single append no match" {
// We want to test single-page cases.
try testing.expect(s.pages.pages.first == s.pages.pages.last);
const node: *PageList.List.Node = s.pages.pages.first.?;
try w.append(node);
_ = try w.append(node);
// No matches
try testing.expect(w.next() == null);
@ -550,8 +563,8 @@ test "SlidingWindow two pages" {
// Add both pages
const node: *PageList.List.Node = s.pages.pages.first.?;
try w.append(node);
try w.append(node.next.?);
_ = try w.append(node);
_ = try w.append(node.next.?);
// Search should find two matches
{
@ -602,8 +615,8 @@ test "SlidingWindow two pages match across boundary" {
// Add both pages
const node: *PageList.List.Node = s.pages.pages.first.?;
try w.append(node);
try w.append(node.next.?);
_ = try w.append(node);
_ = try w.append(node.next.?);
// Search should find a match
{
@ -647,8 +660,8 @@ test "SlidingWindow two pages no match prunes first page" {
// Add both pages
const node: *PageList.List.Node = s.pages.pages.first.?;
try w.append(node);
try w.append(node.next.?);
_ = try w.append(node);
_ = try w.append(node.next.?);
// Search should find nothing
try testing.expect(w.next() == null);
@ -688,8 +701,8 @@ test "SlidingWindow two pages no match keeps both pages" {
// Add both pages
const node: *PageList.List.Node = s.pages.pages.first.?;
try w.append(node);
try w.append(node.next.?);
_ = try w.append(node);
_ = try w.append(node.next.?);
// Search should find nothing
try testing.expect(w.next() == null);
@ -717,8 +730,8 @@ test "SlidingWindow single append across circular buffer boundary" {
// our implementation changes our test will fail.
try testing.expect(s.pages.pages.first == s.pages.pages.last);
const node: *PageList.List.Node = s.pages.pages.first.?;
try w.append(node);
try w.append(node);
_ = try w.append(node);
_ = try w.append(node);
{
// No wrap around yet
const slices = w.data.getPtrSlice(0, w.data.len());
@ -734,7 +747,7 @@ test "SlidingWindow single append across circular buffer boundary" {
w.testChangeNeedle("boo");
// Add new page, now wraps
try w.append(node);
_ = try w.append(node);
{
const slices = w.data.getPtrSlice(0, w.data.len());
try testing.expect(slices[0].len > 0);
@ -772,8 +785,8 @@ test "SlidingWindow single append match on boundary" {
// our implementation changes our test will fail.
try testing.expect(s.pages.pages.first == s.pages.pages.last);
const node: *PageList.List.Node = s.pages.pages.first.?;
try w.append(node);
try w.append(node);
_ = try w.append(node);
_ = try w.append(node);
{
// No wrap around yet
const slices = w.data.getPtrSlice(0, w.data.len());
@ -789,7 +802,7 @@ test "SlidingWindow single append match on boundary" {
w.testChangeNeedle("boo!");
// Add new page, now wraps
try w.append(node);
_ = try w.append(node);
{
const slices = w.data.getPtrSlice(0, w.data.len());
try testing.expect(slices[0].len > 0);
@ -823,7 +836,7 @@ test "SlidingWindow single append reversed" {
// We want to test single-page cases.
try testing.expect(s.pages.pages.first == s.pages.pages.last);
const node: *PageList.List.Node = s.pages.pages.first.?;
try w.append(node);
_ = try w.append(node);
// We should be able to find two matches.
{
@ -866,7 +879,7 @@ test "SlidingWindow single append no match reversed" {
// We want to test single-page cases.
try testing.expect(s.pages.pages.first == s.pages.pages.last);
const node: *PageList.List.Node = s.pages.pages.first.?;
try w.append(node);
_ = try w.append(node);
// No matches
try testing.expect(w.next() == null);
@ -899,8 +912,8 @@ test "SlidingWindow two pages reversed" {
// Add both pages in reverse order
const node: *PageList.List.Node = s.pages.pages.first.?;
try w.append(node.next.?);
try w.append(node);
_ = try w.append(node.next.?);
_ = try w.append(node);
// Search should find two matches (in reverse order)
{
@ -951,8 +964,8 @@ test "SlidingWindow two pages match across boundary reversed" {
// Add both pages in reverse order
const node: *PageList.List.Node = s.pages.pages.first.?;
try w.append(node.next.?);
try w.append(node);
_ = try w.append(node.next.?);
_ = try w.append(node);
// Search should find a match
{
@ -997,8 +1010,8 @@ test "SlidingWindow two pages no match prunes first page reversed" {
// Add both pages in reverse order
const node: *PageList.List.Node = s.pages.pages.first.?;
try w.append(node.next.?);
try w.append(node);
_ = try w.append(node.next.?);
_ = try w.append(node);
// Search should find nothing
try testing.expect(w.next() == null);
@ -1038,8 +1051,8 @@ test "SlidingWindow two pages no match keeps both pages reversed" {
// Add both pages in reverse order
const node: *PageList.List.Node = s.pages.pages.first.?;
try w.append(node.next.?);
try w.append(node);
_ = try w.append(node.next.?);
_ = try w.append(node);
// Search should find nothing
try testing.expect(w.next() == null);
@ -1067,8 +1080,8 @@ test "SlidingWindow single append across circular buffer boundary reversed" {
// our implementation changes our test will fail.
try testing.expect(s.pages.pages.first == s.pages.pages.last);
const node: *PageList.List.Node = s.pages.pages.first.?;
try w.append(node);
try w.append(node);
_ = try w.append(node);
_ = try w.append(node);
{
// No wrap around yet
const slices = w.data.getPtrSlice(0, w.data.len());
@ -1085,7 +1098,7 @@ test "SlidingWindow single append across circular buffer boundary reversed" {
w.testChangeNeedle("oob");
// Add new page, now wraps
try w.append(node);
_ = try w.append(node);
{
const slices = w.data.getPtrSlice(0, w.data.len());
try testing.expect(slices[0].len > 0);
@ -1123,8 +1136,8 @@ test "SlidingWindow single append match on boundary reversed" {
// our implementation changes our test will fail.
try testing.expect(s.pages.pages.first == s.pages.pages.last);
const node: *PageList.List.Node = s.pages.pages.first.?;
try w.append(node);
try w.append(node);
_ = try w.append(node);
_ = try w.append(node);
{
// No wrap around yet
const slices = w.data.getPtrSlice(0, w.data.len());
@ -1141,7 +1154,7 @@ test "SlidingWindow single append match on boundary reversed" {
w.testChangeNeedle("!oob");
// Add new page, now wraps
try w.append(node);
_ = try w.append(node);
{
const slices = w.data.getPtrSlice(0, w.data.len());
try testing.expect(slices[0].len > 0);