From 0ea350a8f28eea5567ef8d3191599f549cc5d7c0 Mon Sep 17 00:00:00 2001 From: Mitchell Hashimoto Date: Wed, 12 Nov 2025 10:27:52 -0800 Subject: [PATCH] terminal: ActiveSearch for searching the active area --- src/terminal/search.zig | 1 + src/terminal/search/active.zig | 168 +++++++++++++++++++++++++ src/terminal/search/sliding_window.zig | 79 +++++++----- 3 files changed, 215 insertions(+), 33 deletions(-) create mode 100644 src/terminal/search/active.zig diff --git a/src/terminal/search.zig b/src/terminal/search.zig index a375c6ece..724b5c171 100644 --- a/src/terminal/search.zig +++ b/src/terminal/search.zig @@ -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"); diff --git a/src/terminal/search/active.zig b/src/terminal/search/active.zig new file mode 100644 index 000000000..b682c6df3 --- /dev/null +++ b/src/terminal/search/active.zig @@ -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); +} diff --git a/src/terminal/search/sliding_window.zig b/src/terminal/search/sliding_window.zig index f27299db2..29c612691 100644 --- a/src/terminal/search/sliding_window.zig +++ b/src/terminal/search/sliding_window.zig @@ -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);