From 56b69ff0fd966188331a841c93a97522af7a3891 Mon Sep 17 00:00:00 2001 From: Mitchell Hashimoto Date: Sat, 22 Nov 2025 21:06:31 -0800 Subject: [PATCH 01/14] datastruct: make CircBuf use the assumeCapacity pattern --- src/datastruct/circ_buf.zig | 27 +++++++++++++++++--------- src/terminal/search/sliding_window.zig | 4 ++-- 2 files changed, 20 insertions(+), 11 deletions(-) diff --git a/src/datastruct/circ_buf.zig b/src/datastruct/circ_buf.zig index baef6f9cf..0caa9e85d 100644 --- a/src/datastruct/circ_buf.zig +++ b/src/datastruct/circ_buf.zig @@ -91,15 +91,24 @@ pub fn CircBuf(comptime T: type, comptime default: T) type { self.full = self.head == self.tail; } - /// Append a slice to the buffer. If the buffer cannot fit the - /// entire slice then an error will be returned. It is up to the - /// caller to rotate the circular buffer if they want to overwrite - /// the oldest data. - pub fn appendSlice( + /// Append a single value to the buffer, assuming there is capacity. + pub fn appendAssumeCapacity(self: *Self, v: T) void { + assert(!self.full); + self.storage[self.head] = v; + self.head += 1; + if (self.head >= self.storage.len) self.head = 0; + self.full = self.head == self.tail; + } + + /// Append a slice to the buffer. + pub fn appendSliceAssumeCapacity( self: *Self, slice: []const T, - ) Allocator.Error!void { - const storage = self.getPtrSlice(self.len(), slice.len); + ) void { + const storage = self.getPtrSlice( + self.len(), + slice.len, + ); fastmem.copy(T, storage[0], slice[0..storage[0].len]); fastmem.copy(T, storage[1], slice[storage[0].len..]); } @@ -456,7 +465,7 @@ test "CircBuf append slice" { var buf = try Buf.init(alloc, 5); defer buf.deinit(alloc); - try buf.appendSlice("hello"); + buf.appendSliceAssumeCapacity("hello"); { var it = buf.iterator(.forward); try testing.expect(it.next().?.* == 'h'); @@ -486,7 +495,7 @@ test "CircBuf append slice with wrap" { try testing.expect(!buf.full); try testing.expectEqual(@as(usize, 2), buf.len()); - try buf.appendSlice("AB"); + buf.appendSliceAssumeCapacity("AB"); { var it = buf.iterator(.forward); try testing.expect(it.next().?.* == 0); diff --git a/src/terminal/search/sliding_window.zig b/src/terminal/search/sliding_window.zig index 2d09c781a..b0df3c13b 100644 --- a/src/terminal/search/sliding_window.zig +++ b/src/terminal/search/sliding_window.zig @@ -444,8 +444,8 @@ pub const SlidingWindow = struct { try self.meta.ensureUnusedCapacity(self.alloc, 1); // Append our new node to the circular buffer. - try self.data.appendSlice(written); - try self.meta.append(meta); + self.data.appendSliceAssumeCapacity(written); + self.meta.appendAssumeCapacity(meta); self.assertIntegrity(); return written.len; From ec5bdf1a5a7ac3172e5103d6eb92b109c78980d5 Mon Sep 17 00:00:00 2001 From: Mitchell Hashimoto Date: Sat, 22 Nov 2025 21:06:31 -0800 Subject: [PATCH 02/14] terminal: highlights --- src/lib_vt.zig | 1 + src/terminal/PageList.zig | 4 + src/terminal/highlight.zig | 154 +++++++++++++++++++++++++++++++++++++ src/terminal/main.zig | 1 + 4 files changed, 160 insertions(+) create mode 100644 src/terminal/highlight.zig diff --git a/src/lib_vt.zig b/src/lib_vt.zig index 95b308aab..03a883e20 100644 --- a/src/lib_vt.zig +++ b/src/lib_vt.zig @@ -26,6 +26,7 @@ pub const point = terminal.point; pub const color = terminal.color; pub const device_status = terminal.device_status; pub const formatter = terminal.formatter; +pub const highlight = terminal.highlight; pub const kitty = terminal.kitty; pub const modes = terminal.modes; pub const page = terminal.page; diff --git a/src/terminal/PageList.zig b/src/terminal/PageList.zig index 0e793a254..53c0c346b 100644 --- a/src/terminal/PageList.zig +++ b/src/terminal/PageList.zig @@ -3729,7 +3729,11 @@ pub const PageIterator = struct { pub const Chunk = struct { node: *List.Node, + + /// Start y index (inclusive) of this chunk in the page. start: size.CellCountInt, + + /// End y index (exclusive) of this chunk in the page. end: size.CellCountInt, pub fn rows(self: Chunk) []Row { diff --git a/src/terminal/highlight.zig b/src/terminal/highlight.zig new file mode 100644 index 000000000..626d6e471 --- /dev/null +++ b/src/terminal/highlight.zig @@ -0,0 +1,154 @@ +//! Highlights are any contiguous sequences of cells that should +//! be called out in some way, most commonly for text selection but +//! also search results or any other purpose. +//! +//! Within the terminal package, a highlight is a generic concept +//! that represents a range of cells. + +// NOTE: The plan is for highlights to ultimately replace Selection +// completely. Selection is deeply tied to various parts of the Ghostty +// internals so this may take some time. + +const std = @import("std"); +const Allocator = std.mem.Allocator; +const assert = @import("../quirks.zig").inlineAssert; +const size = @import("size.zig"); +const PageList = @import("PageList.zig"); +const PageChunk = PageList.PageIterator.Chunk; +const Pin = PageList.Pin; +const Screen = @import("Screen.zig"); + +/// An untracked highlight is a highlight that stores its highlighted +/// area as a top-left and bottom-right screen pin. Since it is untracked, +/// the pins are only valid for the current terminal state and may not +/// be safe to use after any terminal modifications. +/// +/// For rectangle highlights/selections, the downstream consumer of this +/// code is expected to interpret the pins in whatever shape they want. +/// For example, a rectangular selection would interpret the pins as +/// setting the x bounds for each row between start.y and end.y. +/// +/// To simplify all operations, start MUST be before or equal to end. +pub const Untracked = struct { + start: Pin, + end: Pin, +}; + +/// A tracked highlight is a highlight that stores its highlighted +/// area as tracked pins within a screen. +/// +/// A tracked highlight ensures that the pins remain valid even as +/// the terminal state changes. Because of this, tracked highlights +/// have more operations available to them. +/// +/// There is more overhead to creating and maintaining tracked highlights. +/// If you're manipulating highlights that are untracked and you're sure +/// that the terminal state won't change, you can use the `initAssume` +/// function. +pub const Tracked = struct { + start: *Pin, + end: *Pin, + + pub fn init( + screen: *Screen, + start: Pin, + end: Pin, + ) Allocator.Error!Tracked { + const start_tracked = try screen.pages.trackPin(start); + errdefer screen.pages.untrackPin(start_tracked); + const end_tracked = try screen.pages.trackPin(end); + errdefer screen.pages.untrackPin(end_tracked); + return .{ + .start = start_tracked, + .end = end_tracked, + }; + } + + /// Initializes a tracked highlight by assuming that the provided + /// pins are already tracked. This allows callers to perform tracked + /// operations without the overhead of tracking the pins, if the + /// caller can guarantee that the pins are already tracked or that + /// the terminal state will not change. + /// + /// Do not call deinit on highlights created with this function. + pub fn initAssume( + start: *Pin, + end: *Pin, + ) Tracked { + return .{ + .start = start, + .end = end, + }; + } + + pub fn deinit( + self: Tracked, + screen: *Screen, + ) void { + screen.pages.untrackPin(self.start); + screen.pages.untrackPin(self.end); + } +}; + +/// A flattened highlight is a highlight that stores its highlighted +/// area as a list of page chunks. This representation allows for +/// traversing the entire highlighted area without needing to read any +/// terminal state or dereference any page nodes (which may have been +/// pruned). +pub const Flattened = struct { + /// The page chunks that make up this highlight. This handles the + /// y bounds since chunks[0].start is the first highlighted row + /// and chunks[len - 1].end is the last highlighted row (exclsive). + chunks: std.MultiArrayList(PageChunk), + + /// The x bounds of the highlight. `bot_x` may be less than `top_x` + /// for typical left-to-right highlights: can start the selection right + /// of the end on a higher row. + top_x: size.CellCountInt, + bot_x: size.CellCountInt, + + /// Exposed for easier type references. + pub const Chunk = PageChunk; + + pub const empty: Flattened = .{ + .chunks = .empty, + .top_x = 0, + .bot_x = 0, + }; + + pub fn init( + alloc: Allocator, + start: Pin, + end: Pin, + ) Allocator.Error!Flattened { + var result: std.MultiArrayList(PageChunk) = .empty; + errdefer result.deinit(alloc); + var it = start.pageIterator(.right_down, end); + while (it.next()) |chunk| try result.append(alloc, chunk); + return .{ + .chunks = result, + .top_x = start.x, + .end_x = end.x, + }; + } + + /// Convert to an Untracked highlight. + pub fn untracked(self: Flattened) Untracked { + const slice = self.chunks.slice(); + const nodes = slice.items(.node); + const starts = slice.items(.start); + const ends = slice.items(.end); + return .{ + .start = .{ + .node = nodes[0], + .x = self.top_x, + .y = starts[0], + }, + .end = .{ + .node = nodes[nodes.len - 1], + .x = self.bot_x, + .y = ends[ends.len - 1] - 1, + }, + }; + } +}; diff --git a/src/terminal/main.zig b/src/terminal/main.zig index 77a96bfee..fc7584c1a 100644 --- a/src/terminal/main.zig +++ b/src/terminal/main.zig @@ -15,6 +15,7 @@ pub const point = @import("point.zig"); pub const color = @import("color.zig"); pub const device_status = @import("device_status.zig"); pub const formatter = @import("formatter.zig"); +pub const highlight = @import("highlight.zig"); pub const kitty = @import("kitty.zig"); pub const modes = @import("modes.zig"); pub const page = @import("page.zig"); From 05d6315e822f6574c6586348540d8626cbbd1cb7 Mon Sep 17 00:00:00 2001 From: Mitchell Hashimoto Date: Sat, 22 Nov 2025 21:06:31 -0800 Subject: [PATCH 03/14] terminal: add a SlidingWindow2 that uses highlights --- src/terminal/search.zig | 1 + src/terminal/search/sliding_window2.zig | 1400 +++++++++++++++++++++++ 2 files changed, 1401 insertions(+) create mode 100644 src/terminal/search/sliding_window2.zig diff --git a/src/terminal/search.zig b/src/terminal/search.zig index e69603c25..1ac18515c 100644 --- a/src/terminal/search.zig +++ b/src/terminal/search.zig @@ -19,4 +19,5 @@ test { // Non-public APIs _ = @import("search/sliding_window.zig"); + _ = @import("search/sliding_window2.zig"); } diff --git a/src/terminal/search/sliding_window2.zig b/src/terminal/search/sliding_window2.zig new file mode 100644 index 000000000..6aad0bff9 --- /dev/null +++ b/src/terminal/search/sliding_window2.zig @@ -0,0 +1,1400 @@ +const std = @import("std"); +const assert = @import("../../quirks.zig").inlineAssert; +const Allocator = std.mem.Allocator; +const CircBuf = @import("../../datastruct/main.zig").CircBuf; +const terminal = @import("../main.zig"); +const point = terminal.point; +const size = terminal.size; +const PageList = terminal.PageList; +const Pin = PageList.Pin; +const Selection = terminal.Selection; +const Screen = terminal.Screen; +const PageFormatter = @import("../formatter.zig").PageFormatter; +const FlattenedHighlight = terminal.highlight.Flattened; + +/// Searches page nodes via a sliding window. The sliding window maintains +/// the invariant that data isn't pruned until (1) we've searched it and +/// (2) we've accounted for overlaps across pages to fit the needle. +/// +/// The sliding window is first initialized empty. Pages are then appended +/// in the order to search them. The sliding window supports both a forward +/// and reverse order specified via `init`. The pages should be appended +/// in the correct order matching the search direction. +/// +/// All appends grow the window. The window is only pruned when a search +/// is done (positive or negative match) via `next()`. +/// +/// To avoid unnecessary memory growth, the recommended usage is to +/// call `next()` until it returns null and then `append` the next page +/// and repeat the process. This will always maintain the minimum +/// required memory to search for the needle. +/// +/// The caller is responsible for providing the pages and ensuring they're +/// in the proper order. The SlidingWindow itself doesn't own the pages, but +/// it will contain pointers to them in order to return selections. If any +/// pages become invalid, the caller should clear the sliding window and +/// start over. +pub const SlidingWindow = struct { + /// The allocator to use for all the data within this window. We + /// store this rather than passing it around because its already + /// part of multiple elements (eg. Meta's CellMap) and we want to + /// ensure we always use a consistent allocator. Additionally, only + /// a small amount of sliding windows are expected to be in use + /// at any one time so the memory overhead isn't that large. + alloc: Allocator, + + /// The data buffer is a circular buffer of u8 that contains the + /// encoded page text that we can use to search for the needle. + data: DataBuf, + + /// The meta buffer is a circular buffer that contains the metadata + /// about the pages we're searching. This usually isn't that large + /// so callers must iterate through it to find the offset to map + /// data to meta. + meta: MetaBuf, + + /// Buffer that can fit any amount of chunks necessary for next + /// to never fail allocation. + chunk_buf: std.MultiArrayList(FlattenedHighlight.Chunk), + + /// Offset into data for our current state. This handles the + /// situation where our search moved through meta[0] but didn't + /// do enough to prune it. + data_offset: usize = 0, + + /// The needle we're searching for. Does own the memory. + needle: []const u8, + + /// The search direction. If the direction is forward then pages should + /// be appended in forward linked list order from the PageList. If the + /// direction is reverse then pages should be appended in reverse order. + /// + /// This is important because in most cases, a reverse search is going + /// to be more desirable to search from the end of the active area + /// backwards so more recent data is found first. Supporting both is + /// trivial though and will let us do more complex optimizations in the + /// future (e.g. starting from the viewport and doing a forward/reverse + /// concurrently from that point). + direction: Direction, + + /// A buffer to store the overlap search data. This is used to search + /// overlaps between pages where the match starts on one page and + /// ends on another. The length is always `needle.len * 2`. + overlap_buf: []u8, + + const Direction = enum { forward, reverse }; + const DataBuf = CircBuf(u8, 0); + const MetaBuf = CircBuf(Meta, undefined); + const Meta = struct { + node: *PageList.List.Node, + cell_map: std.ArrayList(point.Coordinate), + + pub fn deinit(self: *Meta, alloc: Allocator) void { + self.cell_map.deinit(alloc); + } + }; + + pub fn init( + alloc: Allocator, + direction: Direction, + needle_unowned: []const u8, + ) Allocator.Error!SlidingWindow { + var data = try DataBuf.init(alloc, 0); + errdefer data.deinit(alloc); + + var meta = try MetaBuf.init(alloc, 0); + errdefer meta.deinit(alloc); + + const needle = try alloc.dupe(u8, needle_unowned); + errdefer alloc.free(needle); + switch (direction) { + .forward => {}, + .reverse => std.mem.reverse(u8, needle), + } + + const overlap_buf = try alloc.alloc(u8, needle.len * 2); + errdefer alloc.free(overlap_buf); + + return .{ + .alloc = alloc, + .data = data, + .meta = meta, + .chunk_buf = .empty, + .needle = needle, + .direction = direction, + .overlap_buf = overlap_buf, + }; + } + + pub fn deinit(self: *SlidingWindow) void { + self.alloc.free(self.overlap_buf); + self.alloc.free(self.needle); + self.chunk_buf.deinit(self.alloc); + self.data.deinit(self.alloc); + + var meta_it = self.meta.iterator(.forward); + while (meta_it.next()) |meta| meta.deinit(self.alloc); + self.meta.deinit(self.alloc); + } + + /// Clear all data but retain allocated capacity. + pub fn clearAndRetainCapacity(self: *SlidingWindow) void { + var meta_it = self.meta.iterator(.forward); + while (meta_it.next()) |meta| meta.deinit(self.alloc); + self.meta.clear(); + self.data.clear(); + self.data_offset = 0; + } + + /// Search the window for the next occurrence of the needle. As + /// the window moves, the window will prune itself while maintaining + /// the invariant that the window is always big enough to contain + /// the needle. + /// + /// This returns a flattened highlight on a match. The + /// flattened highlight requires allocation and is therefore more expensive + /// than a normal selection, but it is more efficient to render since it + /// has all the information without having to dereference pointers into + /// the terminal state. + /// + /// The flattened highlight chunks reference internal memory for this + /// sliding window and are only valid until the next call to `next()` + /// or `append()`. If the caller wants to retain the flattened highlight + /// then they should clone it. + pub fn next(self: *SlidingWindow) ?FlattenedHighlight { + const slices = slices: { + // If we have less data then the needle then we can't possibly match + const data_len = self.data.len(); + if (data_len < self.needle.len) return null; + + break :slices self.data.getPtrSlice( + self.data_offset, + data_len - self.data_offset, + ); + }; + + // Search the first slice for the needle. + if (std.mem.indexOf(u8, slices[0], self.needle)) |idx| { + return self.highlight( + idx, + self.needle.len, + ); + } + + // Search the overlap buffer for the needle. + if (slices[0].len > 0 and slices[1].len > 0) overlap: { + // Get up to needle.len - 1 bytes from each side (as much as + // we can) and store it in the overlap buffer. + const prefix: []const u8 = prefix: { + const len = @min(slices[0].len, self.needle.len - 1); + const idx = slices[0].len - len; + break :prefix slices[0][idx..]; + }; + const suffix: []const u8 = suffix: { + const len = @min(slices[1].len, self.needle.len - 1); + break :suffix slices[1][0..len]; + }; + const overlap_len = prefix.len + suffix.len; + assert(overlap_len <= self.overlap_buf.len); + @memcpy(self.overlap_buf[0..prefix.len], prefix); + @memcpy(self.overlap_buf[prefix.len..overlap_len], suffix); + + // Search the overlap + const idx = std.mem.indexOf( + u8, + self.overlap_buf[0..overlap_len], + self.needle, + ) orelse break :overlap; + + // We found a match in the overlap buffer. We need to map the + // index back to the data buffer in order to get our selection. + return self.highlight( + slices[0].len - prefix.len + idx, + self.needle.len, + ); + } + + // Search the last slice for the needle. + if (std.mem.indexOf(u8, slices[1], self.needle)) |idx| { + return self.highlight( + slices[0].len + idx, + self.needle.len, + ); + } + + // No match. We keep `needle.len - 1` bytes available to + // handle the future overlap case. + var meta_it = self.meta.iterator(.reverse); + prune: { + var saved: usize = 0; + while (meta_it.next()) |meta| { + const needed = self.needle.len - 1 - saved; + if (meta.cell_map.items.len >= needed) { + // We save up to this meta. We set our data offset + // to exactly where it needs to be to continue + // searching. + self.data_offset = meta.cell_map.items.len - needed; + break; + } + + saved += meta.cell_map.items.len; + } else { + // If we exited the while loop naturally then we + // never got the amount we needed and so there is + // nothing to prune. + assert(saved < self.needle.len - 1); + break :prune; + } + + const prune_count = self.meta.len() - meta_it.idx; + if (prune_count == 0) { + // This can happen if we need to save up to the first + // meta value to retain our window. + break :prune; + } + + // We can now delete all the metas up to but NOT including + // the meta we found through meta_it. + meta_it = self.meta.iterator(.forward); + var prune_data_len: usize = 0; + for (0..prune_count) |_| { + const meta = meta_it.next().?; + prune_data_len += meta.cell_map.items.len; + meta.deinit(self.alloc); + } + self.meta.deleteOldest(prune_count); + self.data.deleteOldest(prune_data_len); + } + + // Our data offset now moves to needle.len - 1 from the end so + // that we can handle the overlap case. + self.data_offset = self.data.len() - self.needle.len + 1; + + self.assertIntegrity(); + return null; + } + + /// Return a flattened highlight for the given start and length. + /// + /// The flattened highlight can be used to render the highlight + /// in the most efficent way because it doesn't require a terminal + /// lock to access terminal data to compare whether some viewport + /// matches the highlight (because it doesn't need to traverse + /// the page nodes). + /// + /// The start index is assumed to be relative to the offset. i.e. + /// index zero is actually at `self.data[self.data_offset]`. The + /// selection will account for the offset. + fn highlight( + self: *SlidingWindow, + start_offset: usize, + len: usize, + ) terminal.highlight.Flattened { + const start = start_offset + self.data_offset; + const end = start + len - 1; + if (comptime std.debug.runtime_safety) { + assert(start < self.data.len()); + assert(start + len <= self.data.len()); + } + + // Clear our previous chunk buffer to store this result + self.chunk_buf.clearRetainingCapacity(); + var result: terminal.highlight.Flattened = .empty; + + // Go through the meta nodes to find our start. + const tl: struct { + /// If non-null, we need to continue searching for the bottom-right. + br: ?struct { + it: MetaBuf.Iterator, + consumed: usize, + }, + + /// Data to prune, both are lengths. + prune: struct { + meta: usize, + data: usize, + }, + } = tl: { + var meta_it = self.meta.iterator(.forward); + var meta_consumed: usize = 0; + while (meta_it.next()) |meta| { + // Always increment our consumed count so that our index + // is right for the end search if we do it. + const prior_meta_consumed = meta_consumed; + meta_consumed += meta.cell_map.items.len; + + // meta_i is the index we expect to find the match in the + // cell map within this meta if it contains it. + const meta_i = start - prior_meta_consumed; + + // This meta doesn't contain the match. This means we + // can also prune this set of data because we only look + // forward. + if (meta_i >= meta.cell_map.items.len) continue; + + // Now we look for the end. In MOST cases it is the same as + // our starting chunk because highlights are usually small and + // not on a boundary, so let's optimize for that. + const end_i = end - prior_meta_consumed; + if (end_i < meta.cell_map.items.len) { + @branchHint(.likely); + + // The entire highlight is within this meta. + const start_map = meta.cell_map.items[meta_i]; + const end_map = meta.cell_map.items[end_i]; + result.top_x = start_map.x; + result.bot_x = end_map.x; + self.chunk_buf.appendAssumeCapacity(.{ + .node = meta.node, + .start = @intCast(start_map.y), + .end = @intCast(end_map.y + 1), + }); + + break :tl .{ + .br = null, + .prune = .{ + .meta = meta_it.idx - 1, + .data = prior_meta_consumed, + }, + }; + } else { + // We found the meta that contains the start of the match + // only. Consume this entire node from our start offset. + const map = meta.cell_map.items[meta_i]; + result.top_x = map.x; + self.chunk_buf.appendAssumeCapacity(.{ + .node = meta.node, + .start = @intCast(map.y), + .end = meta.node.data.size.rows, + }); + + break :tl .{ + .br = .{ + .it = meta_it, + .consumed = meta_consumed, + }, + .prune = .{ + .meta = meta_it.idx - 1, + .data = prior_meta_consumed, + }, + }; + } + } else { + // Precondition that the start index is within the data buffer. + unreachable; + } + }; + + // Search for our end. + if (tl.br) |br| { + var meta_it = br.it; + var meta_consumed: usize = br.consumed; + while (meta_it.next()) |meta| { + // meta_i is the index we expect to find the match in the + // cell map within this meta if it contains it. + const meta_i = end - meta_consumed; + if (meta_i >= meta.cell_map.items.len) { + // This meta doesn't contain the match. We still add it + // to our results because we want the full flattened list. + self.chunk_buf.appendAssumeCapacity(.{ + .node = meta.node, + .start = 0, + .end = meta.node.data.size.rows, + }); + + meta_consumed += meta.cell_map.items.len; + continue; + } + + // We found it + const map = meta.cell_map.items[meta_i]; + result.bot_x = map.x; + self.chunk_buf.appendAssumeCapacity(.{ + .node = meta.node, + .start = 0, + .end = @intCast(map.y + 1), + }); + break; + } else { + // Precondition that the end index is within the data buffer. + unreachable; + } + } + + // Our offset into the current meta block is the start index + // minus the amount of data fully consumed. We then add one + // to move one past the match so we don't repeat it. + self.data_offset = start - tl.prune.data + 1; + + // If we went beyond our initial meta node we can prune. + if (tl.prune.meta > 0) { + // Deinit all our memory in the meta blocks prior to our + // match. + var meta_it = self.meta.iterator(.forward); + var meta_consumed: usize = 0; + for (0..tl.prune.meta) |_| { + const meta: *Meta = meta_it.next().?; + meta_consumed += meta.cell_map.items.len; + meta.deinit(self.alloc); + } + if (comptime std.debug.runtime_safety) { + assert(meta_it.idx == tl.prune.meta); + assert(meta_it.next().?.node == self.chunk_buf.items(.node)[0]); + } + self.meta.deleteOldest(tl.prune.meta); + + // Delete all the data up to our current index. + assert(tl.prune.data > 0); + self.data.deleteOldest(tl.prune.data); + } + + switch (self.direction) { + .forward => {}, + .reverse => { + if (self.chunk_buf.len > 1) { + // Reverse all our chunks. This should be pretty obvious why. + const slice = self.chunk_buf.slice(); + const nodes = slice.items(.node); + const starts = slice.items(.start); + const ends = slice.items(.end); + std.mem.reverse(*PageList.List.Node, nodes); + std.mem.reverse(size.CellCountInt, starts); + std.mem.reverse(size.CellCountInt, ends); + + // Now normally with forward traversal with multiple pages, + // the suffix of the first page and the prefix of the last + // page are used. + // + // For a reverse traversal, this is inverted (since the + // pages are in reverse order we get the suffix of the last + // page and the prefix of the first page). So we need to + // invert this. + // + // We DON'T need to do this for any middle pages because + // they always use the full page. + // + // We DON'T need to do this for chunks.len == 1 because + // the pages themselves aren't reversed and we don't have + // any prefix/suffix problems. + // + // This is a fixup that makes our start/end match the + // same logic as the loops above if they were in forward + // order. + assert(nodes.len >= 2); + starts[0] = ends[0] - 1; + ends[0] = nodes[0].data.size.rows; + ends[nodes.len - 1] = starts[nodes.len - 1] + 1; + starts[nodes.len - 1] = 0; + } + + // X values also need to be reversed since the top/bottom + // are swapped for the nodes. + const top_x = result.top_x; + result.top_x = result.bot_x; + result.bot_x = top_x; + }, + } + + // Copy over our MultiArrayList so it points to the proper memory. + result.chunks = self.chunk_buf; + return result; + } + + /// 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!usize { + // Initialize our metadata for the node. + var meta: Meta = .{ + .node = node, + .cell_map = .empty, + }; + errdefer meta.deinit(self.alloc); + + // This is suboptimal but we need to encode the page once to + // temporary memory, and then copy it into our circular buffer. + // In the future, we should benchmark and see if we can encode + // directly into the circular buffer. + var encoded: std.Io.Writer.Allocating = .init(self.alloc); + defer encoded.deinit(); + + // Encode the page into the buffer. + const formatter: PageFormatter = formatter: { + var formatter: PageFormatter = .init(&meta.node.data, .plain); + formatter.point_map = .{ + .alloc = self.alloc, + .map = &meta.cell_map, + }; + break :formatter formatter; + }; + formatter.format(&encoded.writer) catch { + // writer uses anyerror but the only realistic error on + // an ArrayList is out of memory. + return error.OutOfMemory; + }; + assert(meta.cell_map.items.len == encoded.written().len); + + // If the node we're adding isn't soft-wrapped, we add the + // trailing newline. + const row = node.data.getRow(node.data.size.rows - 1); + if (!row.wrap) { + encoded.writer.writeByte('\n') catch return error.OutOfMemory; + try meta.cell_map.append( + self.alloc, + meta.cell_map.getLastOrNull() orelse .{ + .x = 0, + .y = 0, + }, + ); + } + + // Get our written data. If we're doing a reverse search then we + // need to reverse all our encodings. + const written = encoded.written(); + switch (self.direction) { + .forward => {}, + .reverse => { + std.mem.reverse(u8, written); + std.mem.reverse(point.Coordinate, meta.cell_map.items); + }, + } + + // Ensure our buffers are big enough to store what we need. + try self.data.ensureUnusedCapacity(self.alloc, written.len); + try self.meta.ensureUnusedCapacity(self.alloc, 1); + try self.chunk_buf.ensureTotalCapacity(self.alloc, self.meta.capacity()); + + // Append our new node to the circular buffer. + self.data.appendSliceAssumeCapacity(written); + self.meta.appendAssumeCapacity(meta); + + self.assertIntegrity(); + return written.len; + } + + /// Only for tests! + fn testChangeNeedle(self: *SlidingWindow, new: []const u8) void { + assert(new.len == self.needle.len); + self.alloc.free(self.needle); + self.needle = self.alloc.dupe(u8, new) catch unreachable; + } + + fn assertIntegrity(self: *const SlidingWindow) void { + if (comptime !std.debug.runtime_safety) return; + + // We don't run integrity checks on Valgrind because its soooooo slow, + // Valgrind is our integrity checker, and we run these during unit + // tests (non-Valgrind) anyways so we're verifying anyways. + if (std.valgrind.runningOnValgrind() > 0) return; + + // Integrity check: verify our data matches our metadata exactly. + var meta_it = self.meta.iterator(.forward); + var data_len: usize = 0; + while (meta_it.next()) |m| data_len += m.cell_map.items.len; + assert(data_len == self.data.len()); + + // Integrity check: verify our data offset is within bounds. + assert(self.data_offset < self.data.len()); + } +}; + +test "SlidingWindow empty on init" { + const testing = std.testing; + const alloc = testing.allocator; + + var w: SlidingWindow = try .init(alloc, .forward, "boo!"); + defer w.deinit(); + try testing.expectEqual(0, w.data.len()); + try testing.expectEqual(0, w.meta.len()); +} + +test "SlidingWindow single append" { + const testing = std.testing; + const alloc = testing.allocator; + + var w: SlidingWindow = try .init(alloc, .forward, "boo!"); + defer w.deinit(); + + var s = try Screen.init(alloc, .{ .cols = 80, .rows = 24, .max_scrollback = 0 }); + defer s.deinit(); + try s.testWriteString("hello. boo! hello. boo!"); + + // 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); + + // We should be able to find two matches. + { + const h = w.next().?; + const sel = h.untracked(); + try testing.expectEqual(point.Point{ .active = .{ + .x = 7, + .y = 0, + } }, s.pages.pointFromPin(.active, sel.start)); + try testing.expectEqual(point.Point{ .active = .{ + .x = 10, + .y = 0, + } }, s.pages.pointFromPin(.active, sel.end)); + } + { + const h = w.next().?; + const sel = h.untracked(); + try testing.expectEqual(point.Point{ .active = .{ + .x = 19, + .y = 0, + } }, s.pages.pointFromPin(.active, sel.start)); + try testing.expectEqual(point.Point{ .active = .{ + .x = 22, + .y = 0, + } }, s.pages.pointFromPin(.active, sel.end)); + } + try testing.expect(w.next() == null); + try testing.expect(w.next() == null); +} + +test "SlidingWindow single append no match" { + const testing = std.testing; + const alloc = testing.allocator; + + var w: SlidingWindow = try .init(alloc, .forward, "nope!"); + defer w.deinit(); + + var s = try Screen.init(alloc, .{ .cols = 80, .rows = 24, .max_scrollback = 0 }); + defer s.deinit(); + try s.testWriteString("hello. boo! hello. boo!"); + + // 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); + + // No matches + try testing.expect(w.next() == null); + try testing.expect(w.next() == null); + + // Should still keep the page + try testing.expectEqual(1, w.meta.len()); +} + +test "SlidingWindow two pages" { + const testing = std.testing; + const alloc = testing.allocator; + + var w: SlidingWindow = try .init(alloc, .forward, "boo!"); + defer w.deinit(); + + var s = try Screen.init(alloc, .{ .cols = 80, .rows = 24, .max_scrollback = 1000 }); + defer s.deinit(); + + // Fill up the first page. The final bytes in the first page + // are "boo!" + const first_page_rows = s.pages.pages.first.?.data.capacity.rows; + for (0..first_page_rows - 1) |_| try s.testWriteString("\n"); + for (0..s.pages.cols - 4) |_| try s.testWriteString("x"); + try s.testWriteString("boo!"); + try testing.expect(s.pages.pages.first == s.pages.pages.last); + try s.testWriteString("\n"); + try testing.expect(s.pages.pages.first != s.pages.pages.last); + try s.testWriteString("hello. boo!"); + + // Add both pages + const node: *PageList.List.Node = s.pages.pages.first.?; + _ = try w.append(node); + _ = try w.append(node.next.?); + + // Search should find two matches + { + const h = w.next().?; + const sel = h.untracked(); + try testing.expectEqual(point.Point{ .active = .{ + .x = 76, + .y = 22, + } }, s.pages.pointFromPin(.active, sel.start).?); + try testing.expectEqual(point.Point{ .active = .{ + .x = 79, + .y = 22, + } }, s.pages.pointFromPin(.active, sel.end).?); + } + { + const h = w.next().?; + const sel = h.untracked(); + try testing.expectEqual(point.Point{ .active = .{ + .x = 7, + .y = 23, + } }, s.pages.pointFromPin(.active, sel.start).?); + try testing.expectEqual(point.Point{ .active = .{ + .x = 10, + .y = 23, + } }, s.pages.pointFromPin(.active, sel.end).?); + } + try testing.expect(w.next() == null); + try testing.expect(w.next() == null); +} + +test "SlidingWindow two pages match across boundary" { + const testing = std.testing; + const alloc = testing.allocator; + + var w: SlidingWindow = try .init(alloc, .forward, "hello, world"); + defer w.deinit(); + + var s = try Screen.init(alloc, .{ .cols = 80, .rows = 24, .max_scrollback = 1000 }); + defer s.deinit(); + + // Fill up the first page. The final bytes in the first page + // are "boo!" + const first_page_rows = s.pages.pages.first.?.data.capacity.rows; + for (0..first_page_rows - 1) |_| try s.testWriteString("\n"); + for (0..s.pages.cols - 4) |_| try s.testWriteString("x"); + try s.testWriteString("hell"); + try testing.expect(s.pages.pages.first == s.pages.pages.last); + try s.testWriteString("o, world!"); + try testing.expect(s.pages.pages.first != s.pages.pages.last); + + // Add both pages + const node: *PageList.List.Node = s.pages.pages.first.?; + _ = try w.append(node); + _ = try w.append(node.next.?); + + // Search should find a match + { + const h = w.next().?; + const sel = h.untracked(); + try testing.expectEqual(point.Point{ .active = .{ + .x = 76, + .y = 22, + } }, s.pages.pointFromPin(.active, sel.start).?); + try testing.expectEqual(point.Point{ .active = .{ + .x = 7, + .y = 23, + } }, s.pages.pointFromPin(.active, sel.end).?); + } + try testing.expect(w.next() == null); + try testing.expect(w.next() == null); + + // We shouldn't prune because we don't have enough space + try testing.expectEqual(2, w.meta.len()); +} + +test "SlidingWindow two pages no match across boundary with newline" { + const testing = std.testing; + const alloc = testing.allocator; + + var w: SlidingWindow = try .init(alloc, .forward, "hello, world"); + defer w.deinit(); + + var s = try Screen.init(alloc, .{ .cols = 80, .rows = 24, .max_scrollback = 1000 }); + defer s.deinit(); + + // Fill up the first page. The final bytes in the first page + // are "boo!" + const first_page_rows = s.pages.pages.first.?.data.capacity.rows; + for (0..first_page_rows - 1) |_| try s.testWriteString("\n"); + for (0..s.pages.cols - 4) |_| try s.testWriteString("x"); + try s.testWriteString("hell"); + try testing.expect(s.pages.pages.first == s.pages.pages.last); + try s.testWriteString("\no, world!"); + try testing.expect(s.pages.pages.first != s.pages.pages.last); + + // Add both pages + const node: *PageList.List.Node = s.pages.pages.first.?; + _ = try w.append(node); + _ = try w.append(node.next.?); + + // Search should NOT find a match + try testing.expect(w.next() == null); + try testing.expect(w.next() == null); + + // We shouldn't prune because we don't have enough space + try testing.expectEqual(2, w.meta.len()); +} + +test "SlidingWindow two pages no match across boundary with newline reverse" { + const testing = std.testing; + const alloc = testing.allocator; + + var w: SlidingWindow = try .init(alloc, .reverse, "hello, world"); + defer w.deinit(); + + var s = try Screen.init(alloc, .{ .cols = 80, .rows = 24, .max_scrollback = 1000 }); + defer s.deinit(); + + // Fill up the first page. The final bytes in the first page + // are "boo!" + const first_page_rows = s.pages.pages.first.?.data.capacity.rows; + for (0..first_page_rows - 1) |_| try s.testWriteString("\n"); + for (0..s.pages.cols - 4) |_| try s.testWriteString("x"); + try s.testWriteString("hell"); + try testing.expect(s.pages.pages.first == s.pages.pages.last); + try s.testWriteString("\no, world!"); + try testing.expect(s.pages.pages.first != s.pages.pages.last); + + // Add both pages in reverse order + const node: *PageList.List.Node = s.pages.pages.first.?; + _ = try w.append(node.next.?); + _ = try w.append(node); + + // Search should NOT find a match + try testing.expect(w.next() == null); + try testing.expect(w.next() == null); +} + +test "SlidingWindow two pages no match prunes first page" { + const testing = std.testing; + const alloc = testing.allocator; + + var w: SlidingWindow = try .init(alloc, .forward, "nope!"); + defer w.deinit(); + + var s = try Screen.init(alloc, .{ .cols = 80, .rows = 24, .max_scrollback = 1000 }); + defer s.deinit(); + + // Fill up the first page. The final bytes in the first page + // are "boo!" + const first_page_rows = s.pages.pages.first.?.data.capacity.rows; + for (0..first_page_rows - 1) |_| try s.testWriteString("\n"); + for (0..s.pages.cols - 4) |_| try s.testWriteString("x"); + try s.testWriteString("boo!"); + try testing.expect(s.pages.pages.first == s.pages.pages.last); + try s.testWriteString("\n"); + try testing.expect(s.pages.pages.first != s.pages.pages.last); + try s.testWriteString("hello. boo!"); + + // Add both pages + const node: *PageList.List.Node = s.pages.pages.first.?; + _ = try w.append(node); + _ = try w.append(node.next.?); + + // Search should find nothing + try testing.expect(w.next() == null); + try testing.expect(w.next() == null); + + // We should've pruned our page because the second page + // has enough text to contain our needle. + try testing.expectEqual(1, w.meta.len()); +} + +test "SlidingWindow two pages no match keeps both pages" { + const testing = std.testing; + const alloc = testing.allocator; + + var s = try Screen.init(alloc, .{ .cols = 80, .rows = 24, .max_scrollback = 1000 }); + defer s.deinit(); + + // Fill up the first page. The final bytes in the first page + // are "boo!" + const first_page_rows = s.pages.pages.first.?.data.capacity.rows; + for (0..first_page_rows - 1) |_| try s.testWriteString("\n"); + for (0..s.pages.cols - 4) |_| try s.testWriteString("x"); + try s.testWriteString("boo!"); + try testing.expect(s.pages.pages.first == s.pages.pages.last); + try s.testWriteString("\n"); + try testing.expect(s.pages.pages.first != s.pages.pages.last); + try s.testWriteString("hello. boo!"); + + // Imaginary needle for search. Doesn't match! + var needle_list: std.ArrayList(u8) = .empty; + defer needle_list.deinit(alloc); + try needle_list.appendNTimes(alloc, 'x', first_page_rows * s.pages.cols); + const needle: []const u8 = needle_list.items; + + var w: SlidingWindow = try .init(alloc, .forward, needle); + defer w.deinit(); + + // Add both pages + const node: *PageList.List.Node = s.pages.pages.first.?; + _ = try w.append(node); + _ = try w.append(node.next.?); + + // Search should find nothing + try testing.expect(w.next() == null); + try testing.expect(w.next() == null); + + // No pruning because both pages are needed to fit needle. + try testing.expectEqual(2, w.meta.len()); +} + +test "SlidingWindow single append across circular buffer boundary" { + const testing = std.testing; + const alloc = testing.allocator; + + var w: SlidingWindow = try .init(alloc, .forward, "abc"); + defer w.deinit(); + + var s = try Screen.init(alloc, .{ .cols = 80, .rows = 24, .max_scrollback = 0 }); + defer s.deinit(); + try s.testWriteString("XXXXXXXXXXXXXXXXXXXboo!XXXXX"); + + // We are trying to break a circular buffer boundary so the way we + // do this is to duplicate the data then do a failing search. This + // will cause the first page to be pruned. The next time we append we'll + // put it in the middle of the circ buffer. We assert this so that if + // 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); + { + // No wrap around yet + const slices = w.data.getPtrSlice(0, w.data.len()); + try testing.expect(slices[0].len > 0); + try testing.expect(slices[1].len == 0); + } + + // Search non-match, prunes page + try testing.expect(w.next() == null); + try testing.expectEqual(1, w.meta.len()); + + // Change the needle, just needs to be the same length (not a real API) + w.testChangeNeedle("boo"); + + // Add new page, now wraps + _ = try w.append(node); + { + const slices = w.data.getPtrSlice(0, w.data.len()); + try testing.expect(slices[0].len > 0); + try testing.expect(slices[1].len > 0); + } + { + const h = w.next().?; + const sel = h.untracked(); + try testing.expectEqual(point.Point{ .active = .{ + .x = 19, + .y = 0, + } }, s.pages.pointFromPin(.active, sel.start).?); + try testing.expectEqual(point.Point{ .active = .{ + .x = 21, + .y = 0, + } }, s.pages.pointFromPin(.active, sel.end).?); + } + try testing.expect(w.next() == null); +} + +test "SlidingWindow single append match on boundary" { + const testing = std.testing; + const alloc = testing.allocator; + + var w: SlidingWindow = try .init(alloc, .forward, "abcd"); + defer w.deinit(); + + var s = try Screen.init(alloc, .{ .cols = 80, .rows = 24, .max_scrollback = 0 }); + defer s.deinit(); + try s.testWriteString("o!XXXXXXXXXXXXXXXXXXXbo"); + + // We need to surgically modify the last row to be soft-wrapped + try testing.expect(s.pages.pages.first == s.pages.pages.last); + const node: *PageList.List.Node = s.pages.pages.first.?; + node.data.getRow(node.data.size.rows - 1).wrap = true; + + // We are trying to break a circular buffer boundary so the way we + // do this is to duplicate the data then do a failing search. This + // will cause the first page to be pruned. The next time we append we'll + // put it in the middle of the circ buffer. We assert this so that if + // our implementation changes our test will fail. + _ = try w.append(node); + _ = try w.append(node); + { + // No wrap around yet + const slices = w.data.getPtrSlice(0, w.data.len()); + try testing.expect(slices[0].len > 0); + try testing.expect(slices[1].len == 0); + } + + // Search non-match, prunes page + try testing.expect(w.next() == null); + try testing.expectEqual(1, w.meta.len()); + + // Change the needle, just needs to be the same length (not a real API) + w.testChangeNeedle("boo!"); + + // Add new page, now wraps + _ = try w.append(node); + { + const slices = w.data.getPtrSlice(0, w.data.len()); + try testing.expect(slices[0].len > 0); + try testing.expect(slices[1].len > 0); + } + { + const h = w.next().?; + const sel = h.untracked(); + try testing.expectEqual(point.Point{ .active = .{ + .x = 21, + .y = 0, + } }, s.pages.pointFromPin(.active, sel.start).?); + try testing.expectEqual(point.Point{ .active = .{ + .x = 1, + .y = 0, + } }, s.pages.pointFromPin(.active, sel.end).?); + } + try testing.expect(w.next() == null); +} + +test "SlidingWindow single append reversed" { + const testing = std.testing; + const alloc = testing.allocator; + + var w: SlidingWindow = try .init(alloc, .reverse, "boo!"); + defer w.deinit(); + + var s = try Screen.init(alloc, .{ .cols = 80, .rows = 24, .max_scrollback = 0 }); + defer s.deinit(); + try s.testWriteString("hello. boo! hello. boo!"); + + // 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); + + // We should be able to find two matches. + { + const h = w.next().?; + const sel = h.untracked(); + try testing.expectEqual(point.Point{ .active = .{ + .x = 19, + .y = 0, + } }, s.pages.pointFromPin(.active, sel.start).?); + try testing.expectEqual(point.Point{ .active = .{ + .x = 22, + .y = 0, + } }, s.pages.pointFromPin(.active, sel.end).?); + } + { + const h = w.next().?; + const sel = h.untracked(); + try testing.expectEqual(point.Point{ .active = .{ + .x = 7, + .y = 0, + } }, s.pages.pointFromPin(.active, sel.start).?); + try testing.expectEqual(point.Point{ .active = .{ + .x = 10, + .y = 0, + } }, s.pages.pointFromPin(.active, sel.end).?); + } + try testing.expect(w.next() == null); + try testing.expect(w.next() == null); +} + +test "SlidingWindow single append no match reversed" { + const testing = std.testing; + const alloc = testing.allocator; + + var w: SlidingWindow = try .init(alloc, .reverse, "nope!"); + defer w.deinit(); + + var s = try Screen.init(alloc, .{ .cols = 80, .rows = 24, .max_scrollback = 0 }); + defer s.deinit(); + try s.testWriteString("hello. boo! hello. boo!"); + + // 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); + + // No matches + try testing.expect(w.next() == null); + try testing.expect(w.next() == null); + + // Should still keep the page + try testing.expectEqual(1, w.meta.len()); +} + +test "SlidingWindow two pages reversed" { + const testing = std.testing; + const alloc = testing.allocator; + + var w: SlidingWindow = try .init(alloc, .reverse, "boo!"); + defer w.deinit(); + + var s = try Screen.init(alloc, .{ .cols = 80, .rows = 24, .max_scrollback = 1000 }); + defer s.deinit(); + + // Fill up the first page. The final bytes in the first page + // are "boo!" + const first_page_rows = s.pages.pages.first.?.data.capacity.rows; + for (0..first_page_rows - 1) |_| try s.testWriteString("\n"); + for (0..s.pages.cols - 4) |_| try s.testWriteString("x"); + try s.testWriteString("boo!"); + try testing.expect(s.pages.pages.first == s.pages.pages.last); + try s.testWriteString("\n"); + try testing.expect(s.pages.pages.first != s.pages.pages.last); + try s.testWriteString("hello. boo!"); + + // Add both pages in reverse order + const node: *PageList.List.Node = s.pages.pages.first.?; + _ = try w.append(node.next.?); + _ = try w.append(node); + + // Search should find two matches (in reverse order) + { + const h = w.next().?; + const sel = h.untracked(); + try testing.expectEqual(point.Point{ .active = .{ + .x = 7, + .y = 23, + } }, s.pages.pointFromPin(.active, sel.start).?); + try testing.expectEqual(point.Point{ .active = .{ + .x = 10, + .y = 23, + } }, s.pages.pointFromPin(.active, sel.end).?); + } + { + const h = w.next().?; + const sel = h.untracked(); + try testing.expectEqual(point.Point{ .active = .{ + .x = 76, + .y = 22, + } }, s.pages.pointFromPin(.active, sel.start).?); + try testing.expectEqual(point.Point{ .active = .{ + .x = 79, + .y = 22, + } }, s.pages.pointFromPin(.active, sel.end).?); + } + try testing.expect(w.next() == null); + try testing.expect(w.next() == null); +} + +test "SlidingWindow two pages match across boundary reversed" { + const testing = std.testing; + const alloc = testing.allocator; + + var w: SlidingWindow = try .init(alloc, .reverse, "hello, world"); + defer w.deinit(); + + var s = try Screen.init(alloc, .{ .cols = 80, .rows = 24, .max_scrollback = 1000 }); + defer s.deinit(); + + // Fill up the first page. The final bytes in the first page + // are "hell" + const first_page_rows = s.pages.pages.first.?.data.capacity.rows; + for (0..first_page_rows - 1) |_| try s.testWriteString("\n"); + for (0..s.pages.cols - 4) |_| try s.testWriteString("x"); + try s.testWriteString("hell"); + try testing.expect(s.pages.pages.first == s.pages.pages.last); + try s.testWriteString("o, world!"); + try testing.expect(s.pages.pages.first != s.pages.pages.last); + + // Add both pages in reverse order + const node: *PageList.List.Node = s.pages.pages.first.?; + _ = try w.append(node.next.?); + _ = try w.append(node); + + // Search should find a match + { + const h = w.next().?; + const sel = h.untracked(); + try testing.expectEqual(point.Point{ .active = .{ + .x = 76, + .y = 22, + } }, s.pages.pointFromPin(.active, sel.start).?); + try testing.expectEqual(point.Point{ .active = .{ + .x = 7, + .y = 23, + } }, s.pages.pointFromPin(.active, sel.end).?); + } + try testing.expect(w.next() == null); + try testing.expect(w.next() == null); + + // In reverse mode, the last appended meta (first original page) is large + // enough to contain needle.len - 1 bytes, so pruning occurs + try testing.expectEqual(1, w.meta.len()); +} + +test "SlidingWindow two pages no match prunes first page reversed" { + const testing = std.testing; + const alloc = testing.allocator; + + var w: SlidingWindow = try .init(alloc, .reverse, "nope!"); + defer w.deinit(); + + var s = try Screen.init(alloc, .{ .cols = 80, .rows = 24, .max_scrollback = 1000 }); + defer s.deinit(); + + // Fill up the first page. The final bytes in the first page + // are "boo!" + const first_page_rows = s.pages.pages.first.?.data.capacity.rows; + for (0..first_page_rows - 1) |_| try s.testWriteString("\n"); + for (0..s.pages.cols - 4) |_| try s.testWriteString("x"); + try s.testWriteString("boo!"); + try testing.expect(s.pages.pages.first == s.pages.pages.last); + try s.testWriteString("\n"); + try testing.expect(s.pages.pages.first != s.pages.pages.last); + try s.testWriteString("hello. boo!"); + + // Add both pages in reverse order + const node: *PageList.List.Node = s.pages.pages.first.?; + _ = try w.append(node.next.?); + _ = try w.append(node); + + // Search should find nothing + try testing.expect(w.next() == null); + try testing.expect(w.next() == null); + + // We should've pruned our page because the second page + // has enough text to contain our needle. + try testing.expectEqual(1, w.meta.len()); +} + +test "SlidingWindow two pages no match keeps both pages reversed" { + const testing = std.testing; + const alloc = testing.allocator; + + var s = try Screen.init(alloc, .{ .cols = 80, .rows = 24, .max_scrollback = 1000 }); + defer s.deinit(); + + // Fill up the first page. The final bytes in the first page + // are "boo!" + const first_page_rows = s.pages.pages.first.?.data.capacity.rows; + for (0..first_page_rows - 1) |_| try s.testWriteString("\n"); + for (0..s.pages.cols - 4) |_| try s.testWriteString("x"); + try s.testWriteString("boo!"); + try testing.expect(s.pages.pages.first == s.pages.pages.last); + try s.testWriteString("\n"); + try testing.expect(s.pages.pages.first != s.pages.pages.last); + try s.testWriteString("hello. boo!"); + + // Imaginary needle for search. Doesn't match! + var needle_list: std.ArrayList(u8) = .empty; + defer needle_list.deinit(alloc); + try needle_list.appendNTimes(alloc, 'x', first_page_rows * s.pages.cols); + const needle: []const u8 = needle_list.items; + + var w: SlidingWindow = try .init(alloc, .reverse, needle); + defer w.deinit(); + + // Add both pages in reverse order + const node: *PageList.List.Node = s.pages.pages.first.?; + _ = try w.append(node.next.?); + _ = try w.append(node); + + // Search should find nothing + try testing.expect(w.next() == null); + try testing.expect(w.next() == null); + + // No pruning because both pages are needed to fit needle. + try testing.expectEqual(2, w.meta.len()); +} + +test "SlidingWindow single append across circular buffer boundary reversed" { + const testing = std.testing; + const alloc = testing.allocator; + + var w: SlidingWindow = try .init(alloc, .reverse, "abc"); + defer w.deinit(); + + var s = try Screen.init(alloc, .{ .cols = 80, .rows = 24, .max_scrollback = 0 }); + defer s.deinit(); + try s.testWriteString("XXXXXXXXXXXXXXXXXXXboo!XXXXX"); + + // We are trying to break a circular buffer boundary so the way we + // do this is to duplicate the data then do a failing search. This + // will cause the first page to be pruned. The next time we append we'll + // put it in the middle of the circ buffer. We assert this so that if + // 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); + { + // No wrap around yet + const slices = w.data.getPtrSlice(0, w.data.len()); + try testing.expect(slices[0].len > 0); + try testing.expect(slices[1].len == 0); + } + + // Search non-match, prunes page + try testing.expect(w.next() == null); + try testing.expectEqual(1, w.meta.len()); + + // Change the needle, just needs to be the same length (not a real API) + // testChangeNeedle doesn't reverse, so pass reversed needle for reverse mode + w.testChangeNeedle("oob"); + + // Add new page, now wraps + _ = try w.append(node); + { + const slices = w.data.getPtrSlice(0, w.data.len()); + try testing.expect(slices[0].len > 0); + try testing.expect(slices[1].len > 0); + } + { + const h = w.next().?; + const sel = h.untracked(); + try testing.expectEqual(point.Point{ .active = .{ + .x = 19, + .y = 0, + } }, s.pages.pointFromPin(.active, sel.start).?); + try testing.expectEqual(point.Point{ .active = .{ + .x = 21, + .y = 0, + } }, s.pages.pointFromPin(.active, sel.end).?); + } + try testing.expect(w.next() == null); +} + +test "SlidingWindow single append match on boundary reversed" { + const testing = std.testing; + const alloc = testing.allocator; + + var w: SlidingWindow = try .init(alloc, .reverse, "abcd"); + defer w.deinit(); + + var s = try Screen.init(alloc, .{ .cols = 80, .rows = 24, .max_scrollback = 0 }); + defer s.deinit(); + try s.testWriteString("o!XXXXXXXXXXXXXXXXXXXbo"); + + // We need to surgically modify the last row to be soft-wrapped + try testing.expect(s.pages.pages.first == s.pages.pages.last); + const node: *PageList.List.Node = s.pages.pages.first.?; + node.data.getRow(node.data.size.rows - 1).wrap = true; + + // We are trying to break a circular buffer boundary so the way we + // do this is to duplicate the data then do a failing search. This + // will cause the first page to be pruned. The next time we append we'll + // put it in the middle of the circ buffer. We assert this so that if + // our implementation changes our test will fail. + _ = try w.append(node); + _ = try w.append(node); + { + // No wrap around yet + const slices = w.data.getPtrSlice(0, w.data.len()); + try testing.expect(slices[0].len > 0); + try testing.expect(slices[1].len == 0); + } + + // Search non-match, prunes page + try testing.expect(w.next() == null); + try testing.expectEqual(1, w.meta.len()); + + // Change the needle, just needs to be the same length (not a real API) + // testChangeNeedle doesn't reverse, so pass reversed needle for reverse mode + w.testChangeNeedle("!oob"); + + // Add new page, now wraps + _ = try w.append(node); + { + const slices = w.data.getPtrSlice(0, w.data.len()); + try testing.expect(slices[0].len > 0); + try testing.expect(slices[1].len > 0); + } + { + const h = w.next().?; + const sel = h.untracked(); + try testing.expectEqual(point.Point{ .active = .{ + .x = 21, + .y = 0, + } }, s.pages.pointFromPin(.active, sel.start).?); + try testing.expectEqual(point.Point{ .active = .{ + .x = 1, + .y = 0, + } }, s.pages.pointFromPin(.active, sel.end).?); + } + try testing.expect(w.next() == null); +} From 6623c20c2dafd4320048e49b6d5a2ee802be24f9 Mon Sep 17 00:00:00 2001 From: Mitchell Hashimoto Date: Mon, 24 Nov 2025 10:19:10 -0800 Subject: [PATCH 04/14] terminal: switch search to use flattened highlights --- src/terminal/highlight.zig | 12 + src/terminal/search.zig | 1 - src/terminal/search/Thread.zig | 41 +- src/terminal/search/active.zig | 24 +- src/terminal/search/pagelist.zig | 24 +- src/terminal/search/screen.zig | 91 +- src/terminal/search/sliding_window.zig | 410 ++++--- src/terminal/search/sliding_window2.zig | 1400 ----------------------- src/terminal/search/viewport.zig | 31 +- 9 files changed, 412 insertions(+), 1622 deletions(-) delete mode 100644 src/terminal/search/sliding_window2.zig diff --git a/src/terminal/highlight.zig b/src/terminal/highlight.zig index 626d6e471..772d4d54b 100644 --- a/src/terminal/highlight.zig +++ b/src/terminal/highlight.zig @@ -132,6 +132,18 @@ pub const Flattened = struct { }; } + pub fn deinit(self: *Flattened, alloc: Allocator) void { + self.chunks.deinit(alloc); + } + + pub fn clone(self: *const Flattened, alloc: Allocator) Allocator.Error!Flattened { + return .{ + .chunks = try self.chunks.clone(alloc), + .top_x = self.top_x, + .bot_x = self.bot_x, + }; + } + /// Convert to an Untracked highlight. pub fn untracked(self: Flattened) Untracked { const slice = self.chunks.slice(); diff --git a/src/terminal/search.zig b/src/terminal/search.zig index 1ac18515c..e69603c25 100644 --- a/src/terminal/search.zig +++ b/src/terminal/search.zig @@ -19,5 +19,4 @@ test { // Non-public APIs _ = @import("search/sliding_window.zig"); - _ = @import("search/sliding_window2.zig"); } diff --git a/src/terminal/search/Thread.zig b/src/terminal/search/Thread.zig index 776dfc84a..fdd5f81bc 100644 --- a/src/terminal/search/Thread.zig +++ b/src/terminal/search/Thread.zig @@ -12,11 +12,13 @@ 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.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 FlattenedHighlight = @import("../highlight.zig").Flattened; const PageList = @import("../PageList.zig"); const Screen = @import("../Screen.zig"); const ScreenSet = @import("../ScreenSet.zig"); @@ -387,7 +389,7 @@ pub const Event = union(enum) { /// 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, + viewport_matches: []const FlattenedHighlight, }; /// Search state. @@ -603,10 +605,13 @@ const Search = struct { // 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) { + 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={}", @@ -637,7 +642,12 @@ test { const Self = @This(); reset: std.Thread.ResetEvent = .{}, total: usize = 0, - viewport: []const Selection = &.{}, + viewport: []FlattenedHighlight = &.{}, + + fn deinit(self: *Self) void { + for (self.viewport) |*hl| hl.deinit(testing.allocator); + testing.allocator.free(self.viewport); + } fn callback(event: Event, userdata: ?*anyopaque) void { const ud: *Self = @ptrCast(@alignCast(userdata.?)); @@ -645,11 +655,16 @@ test { .complete => ud.reset.set(), .total_matches => |v| ud.total = v, .viewport_matches => |v| { + for (ud.viewport) |*hl| hl.deinit(testing.allocator); testing.allocator.free(ud.viewport); - ud.viewport = testing.allocator.dupe( - Selection, - v, + + ud.viewport = testing.allocator.alloc( + FlattenedHighlight, + v.len, ) catch unreachable; + for (ud.viewport, v) |*dst, src| { + dst.* = src.clone(testing.allocator) catch unreachable; + } }, } } @@ -665,7 +680,7 @@ test { try stream.nextSlice("Hello, world"); var ud: UserData = .{}; - defer alloc.free(ud.viewport); + defer ud.deinit(); var thread: Thread = try .init(alloc, .{ .mutex = &mutex, .terminal = &t, @@ -698,14 +713,14 @@ test { try testing.expectEqual(1, ud.total); try testing.expectEqual(1, ud.viewport.len); { - const sel = ud.viewport[0]; + const sel = ud.viewport[0].untracked(); try testing.expectEqual(point.Point{ .screen = .{ .x = 7, .y = 0, - } }, t.screens.active.pages.pointFromPin(.screen, sel.start()).?); + } }, 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()).?); + } }, t.screens.active.pages.pointFromPin(.screen, sel.end).?); } } diff --git a/src/terminal/search/active.zig b/src/terminal/search/active.zig index 2ace939e7..2329c40b0 100644 --- a/src/terminal/search/active.zig +++ b/src/terminal/search/active.zig @@ -3,6 +3,7 @@ const testing = std.testing; const Allocator = std.mem.Allocator; const point = @import("../point.zig"); const size = @import("../size.zig"); +const FlattenedHighlight = @import("../highlight.zig").Flattened; const PageList = @import("../PageList.zig"); const Selection = @import("../Selection.zig"); const SlidingWindow = @import("sliding_window.zig").SlidingWindow; @@ -96,7 +97,7 @@ pub const ActiveSearch = struct { /// 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 { + pub fn next(self: *ActiveSearch) ?FlattenedHighlight { return self.window.next(); } }; @@ -115,26 +116,28 @@ test "simple search" { _ = try search.update(&t.screens.active.pages); { - const sel = search.next().?; + const h = search.next().?; + const sel = h.untracked(); try testing.expectEqual(point.Point{ .active = .{ .x = 0, .y = 0, - } }, t.screens.active.pages.pointFromPin(.active, sel.start()).?); + } }, 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()).?); + } }, t.screens.active.pages.pointFromPin(.active, sel.end).?); } { - const sel = search.next().?; + const h = search.next().?; + const sel = h.untracked(); try testing.expectEqual(point.Point{ .active = .{ .x = 0, .y = 2, - } }, t.screens.active.pages.pointFromPin(.active, sel.start()).?); + } }, 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()).?); + } }, t.screens.active.pages.pointFromPin(.active, sel.end).?); } try testing.expect(search.next() == null); } @@ -158,15 +161,16 @@ test "clear screen and search" { _ = try search.update(&t.screens.active.pages); { - const sel = search.next().?; + const h = search.next().?; + const sel = h.untracked(); try testing.expectEqual(point.Point{ .active = .{ .x = 0, .y = 1, - } }, t.screens.active.pages.pointFromPin(.active, sel.start()).?); + } }, 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()).?); + } }, t.screens.active.pages.pointFromPin(.active, sel.end).?); } try testing.expect(search.next() == null); } diff --git a/src/terminal/search/pagelist.zig b/src/terminal/search/pagelist.zig index 8a01a61fb..bd1ce9ef7 100644 --- a/src/terminal/search/pagelist.zig +++ b/src/terminal/search/pagelist.zig @@ -5,6 +5,7 @@ const testing = std.testing; const CircBuf = @import("../../datastruct/main.zig").CircBuf; const terminal = @import("../main.zig"); const point = terminal.point; +const FlattenedHighlight = @import("../highlight.zig").Flattened; const Page = terminal.Page; const PageList = terminal.PageList; const Pin = PageList.Pin; @@ -97,7 +98,7 @@ pub const PageListSearch = struct { /// /// This does NOT access the PageList, so it can be called without /// a lock held. - pub fn next(self: *PageListSearch) ?Selection { + pub fn next(self: *PageListSearch) ?FlattenedHighlight { return self.window.next(); } @@ -149,26 +150,28 @@ test "simple search" { defer search.deinit(); { - const sel = search.next().?; + const h = search.next().?; + const sel = h.untracked(); try testing.expectEqual(point.Point{ .active = .{ .x = 0, .y = 2, - } }, t.screens.active.pages.pointFromPin(.active, sel.start()).?); + } }, 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()).?); + } }, t.screens.active.pages.pointFromPin(.active, sel.end).?); } { - const sel = search.next().?; + const h = search.next().?; + const sel = h.untracked(); try testing.expectEqual(point.Point{ .active = .{ .x = 0, .y = 0, - } }, t.screens.active.pages.pointFromPin(.active, sel.start()).?); + } }, 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()).?); + } }, t.screens.active.pages.pointFromPin(.active, sel.end).?); } try testing.expect(search.next() == null); @@ -335,12 +338,13 @@ test "feed with match spanning page boundary" { try testing.expect(try search.feed()); // Should find the spanning match - const sel = search.next().?; - try testing.expect(sel.start().node != sel.end().node); + const h = search.next().?; + const sel = h.untracked(); + try testing.expect(sel.start.node != sel.end.node); { const str = try t.screens.active.selectionString( alloc, - .{ .sel = sel }, + .{ .sel = .init(sel.start, sel.end, false) }, ); defer alloc.free(str); try testing.expectEqualStrings(str, "Test"); diff --git a/src/terminal/search/screen.zig b/src/terminal/search/screen.zig index d2d138442..071ccd090 100644 --- a/src/terminal/search/screen.zig +++ b/src/terminal/search/screen.zig @@ -3,6 +3,7 @@ const assert = @import("../../quirks.zig").inlineAssert; const testing = std.testing; const Allocator = std.mem.Allocator; const point = @import("../point.zig"); +const FlattenedHighlight = @import("../highlight.zig").Flattened; const PageList = @import("../PageList.zig"); const Pin = PageList.Pin; const Screen = @import("../Screen.zig"); @@ -44,8 +45,8 @@ pub const ScreenSearch = struct { /// is mostly immutable once found, while active area results may /// change. This lets us easily reset the active area results for a /// re-search scenario. - history_results: std.ArrayList(Selection), - active_results: std.ArrayList(Selection), + history_results: std.ArrayList(FlattenedHighlight), + active_results: std.ArrayList(FlattenedHighlight), /// History search state. const HistorySearch = struct { @@ -120,7 +121,9 @@ pub const ScreenSearch = struct { const alloc = self.allocator(); self.active.deinit(); if (self.history) |*h| h.deinit(self.screen); + for (self.active_results.items) |*hl| hl.deinit(alloc); self.active_results.deinit(alloc); + for (self.history_results.items) |*hl| hl.deinit(alloc); self.history_results.deinit(alloc); } @@ -145,11 +148,11 @@ pub const ScreenSearch = struct { pub fn matches( self: *ScreenSearch, alloc: Allocator, - ) Allocator.Error![]Selection { + ) Allocator.Error![]FlattenedHighlight { const active_results = self.active_results.items; const history_results = self.history_results.items; const results = try alloc.alloc( - Selection, + FlattenedHighlight, active_results.len + history_results.len, ); errdefer alloc.free(results); @@ -162,7 +165,7 @@ pub const ScreenSearch = struct { results[0..active_results.len], active_results, ); - std.mem.reverse(Selection, results[0..active_results.len]); + std.mem.reverse(FlattenedHighlight, results[0..active_results.len]); // History does a backward search, so we can just append them // after. @@ -247,13 +250,15 @@ pub const ScreenSearch = struct { // For the active area, we consume the entire search in one go // because the active area is generally small. const alloc = self.allocator(); - while (self.active.next()) |sel| { + while (self.active.next()) |hl| { // If this fails, then we miss a result since `active.next()` // moves forward and prunes data. In the future, we may want // to have some more robust error handling but the only // scenario this would fail is OOM and we're probably in // deeper trouble at that point anyways. - try self.active_results.append(alloc, sel); + var hl_cloned = try hl.clone(alloc); + errdefer hl_cloned.deinit(alloc); + try self.active_results.append(alloc, hl_cloned); } // We've consumed the entire active area, move to history. @@ -270,13 +275,15 @@ pub const ScreenSearch = struct { // Try to consume all the loaded matches in one go, because // the search is generally fast for loaded data. const alloc = self.allocator(); - while (history.searcher.next()) |sel| { + while (history.searcher.next()) |hl| { // Ignore selections that are found within the starting // node since those are covered by the active area search. - if (sel.start().node == history.start_pin.node) continue; + if (hl.chunks.items(.node)[0] == history.start_pin.node) continue; // Same note as tickActive for error handling. - try self.history_results.append(alloc, sel); + var hl_cloned = try hl.clone(alloc); + errdefer hl_cloned.deinit(alloc); + try self.history_results.append(alloc, hl_cloned); } // We need to be fed more data. @@ -291,6 +298,7 @@ pub const ScreenSearch = struct { /// /// The caller must hold the necessary locks to access the screen state. pub fn reloadActive(self: *ScreenSearch) Allocator.Error!void { + const alloc = self.allocator(); const list: *PageList = &self.screen.pages; if (try self.active.update(list)) |history_node| history: { // We need to account for any active area growth that would @@ -305,6 +313,7 @@ pub const ScreenSearch = struct { if (h.start_pin.garbage) { h.deinit(self.screen); self.history = null; + for (self.history_results.items) |*hl| hl.deinit(alloc); self.history_results.clearRetainingCapacity(); break :state null; } @@ -317,7 +326,7 @@ pub const ScreenSearch = struct { // initialize. var search: PageListSearch = try .init( - self.allocator(), + alloc, self.needle(), list, history_node, @@ -346,7 +355,6 @@ pub const ScreenSearch = struct { // collect all the results into a new list. We ASSUME that // reloadActive is being called frequently enough that there isn't // a massive amount of history to search here. - const alloc = self.allocator(); var window: SlidingWindow = try .init( alloc, .forward, @@ -361,17 +369,17 @@ pub const ScreenSearch = struct { } assert(history.start_pin.node == history_node); - var results: std.ArrayList(Selection) = try .initCapacity( + var results: std.ArrayList(FlattenedHighlight) = try .initCapacity( alloc, self.history_results.items.len, ); errdefer results.deinit(alloc); - while (window.next()) |sel| { - if (sel.start().node == history_node) continue; - try results.append( - alloc, - sel, - ); + while (window.next()) |hl| { + if (hl.chunks.items(.node)[0] == history_node) continue; + + var hl_cloned = try hl.clone(alloc); + errdefer hl_cloned.deinit(alloc); + try results.append(alloc, hl_cloned); } // If we have no matches then there is nothing to change @@ -380,13 +388,14 @@ pub const ScreenSearch = struct { // Matches! Reverse our list then append all the remaining // history items that didn't start on our original node. - std.mem.reverse(Selection, results.items); + std.mem.reverse(FlattenedHighlight, results.items); try results.appendSlice(alloc, self.history_results.items); self.history_results.deinit(alloc); self.history_results = results; } // Reset our active search results and search again. + for (self.active_results.items) |*hl| hl.deinit(alloc); self.active_results.clearRetainingCapacity(); switch (self.state) { // If we're in the active state we run a normal tick so @@ -425,26 +434,26 @@ test "simple search" { try testing.expectEqual(2, matches.len); { - const sel = matches[0]; + const sel = matches[0].untracked(); try testing.expectEqual(point.Point{ .screen = .{ .x = 0, .y = 2, - } }, t.screens.active.pages.pointFromPin(.screen, sel.start()).?); + } }, t.screens.active.pages.pointFromPin(.screen, sel.start).?); try testing.expectEqual(point.Point{ .screen = .{ .x = 3, .y = 2, - } }, t.screens.active.pages.pointFromPin(.screen, sel.end()).?); + } }, t.screens.active.pages.pointFromPin(.screen, sel.end).?); } { - const sel = matches[1]; + const sel = matches[1].untracked(); try testing.expectEqual(point.Point{ .screen = .{ .x = 0, .y = 0, - } }, t.screens.active.pages.pointFromPin(.screen, sel.start()).?); + } }, 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()).?); + } }, t.screens.active.pages.pointFromPin(.screen, sel.end).?); } } @@ -477,15 +486,15 @@ test "simple search with history" { try testing.expectEqual(1, matches.len); { - const sel = matches[0]; + const sel = matches[0].untracked(); try testing.expectEqual(point.Point{ .screen = .{ .x = 0, .y = 0, - } }, t.screens.active.pages.pointFromPin(.screen, sel.start()).?); + } }, 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()).?); + } }, t.screens.active.pages.pointFromPin(.screen, sel.end).?); } } @@ -528,26 +537,26 @@ test "reload active with history change" { defer alloc.free(matches); try testing.expectEqual(2, matches.len); { - const sel = matches[1]; + const sel = matches[1].untracked(); try testing.expectEqual(point.Point{ .screen = .{ .x = 0, .y = 0, - } }, t.screens.active.pages.pointFromPin(.screen, sel.start()).?); + } }, 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()).?); + } }, t.screens.active.pages.pointFromPin(.screen, sel.end).?); } { - const sel = matches[0]; + const sel = matches[0].untracked(); try testing.expectEqual(point.Point{ .active = .{ .x = 1, .y = 1, - } }, t.screens.active.pages.pointFromPin(.active, sel.start()).?); + } }, t.screens.active.pages.pointFromPin(.active, sel.start).?); try testing.expectEqual(point.Point{ .active = .{ .x = 4, .y = 1, - } }, t.screens.active.pages.pointFromPin(.active, sel.end()).?); + } }, t.screens.active.pages.pointFromPin(.active, sel.end).?); } } @@ -562,15 +571,15 @@ test "reload active with history change" { defer alloc.free(matches); try testing.expectEqual(1, matches.len); { - const sel = matches[0]; + const sel = matches[0].untracked(); try testing.expectEqual(point.Point{ .active = .{ .x = 2, .y = 0, - } }, t.screens.active.pages.pointFromPin(.active, sel.start()).?); + } }, t.screens.active.pages.pointFromPin(.active, sel.start).?); try testing.expectEqual(point.Point{ .active = .{ .x = 5, .y = 0, - } }, t.screens.active.pages.pointFromPin(.active, sel.end()).?); + } }, t.screens.active.pages.pointFromPin(.active, sel.end).?); } } } @@ -603,14 +612,14 @@ test "active change contents" { try testing.expectEqual(1, matches.len); { - const sel = matches[0]; + const sel = matches[0].untracked(); try testing.expectEqual(point.Point{ .screen = .{ .x = 0, .y = 1, - } }, t.screens.active.pages.pointFromPin(.screen, sel.start()).?); + } }, t.screens.active.pages.pointFromPin(.screen, sel.start).?); try testing.expectEqual(point.Point{ .screen = .{ .x = 3, .y = 1, - } }, t.screens.active.pages.pointFromPin(.screen, sel.end()).?); + } }, t.screens.active.pages.pointFromPin(.screen, sel.end).?); } } diff --git a/src/terminal/search/sliding_window.zig b/src/terminal/search/sliding_window.zig index b0df3c13b..c1428e35c 100644 --- a/src/terminal/search/sliding_window.zig +++ b/src/terminal/search/sliding_window.zig @@ -4,11 +4,13 @@ const Allocator = std.mem.Allocator; const CircBuf = @import("../../datastruct/main.zig").CircBuf; const terminal = @import("../main.zig"); const point = terminal.point; +const size = terminal.size; const PageList = terminal.PageList; const Pin = PageList.Pin; const Selection = terminal.Selection; const Screen = terminal.Screen; const PageFormatter = @import("../formatter.zig").PageFormatter; +const FlattenedHighlight = terminal.highlight.Flattened; /// Searches page nodes via a sliding window. The sliding window maintains /// the invariant that data isn't pruned until (1) we've searched it and @@ -51,6 +53,10 @@ pub const SlidingWindow = struct { /// data to meta. meta: MetaBuf, + /// Buffer that can fit any amount of chunks necessary for next + /// to never fail allocation. + chunk_buf: std.MultiArrayList(FlattenedHighlight.Chunk), + /// Offset into data for our current state. This handles the /// situation where our search moved through meta[0] but didn't /// do enough to prune it. @@ -113,6 +119,7 @@ pub const SlidingWindow = struct { .alloc = alloc, .data = data, .meta = meta, + .chunk_buf = .empty, .needle = needle, .direction = direction, .overlap_buf = overlap_buf, @@ -122,6 +129,7 @@ pub const SlidingWindow = struct { pub fn deinit(self: *SlidingWindow) void { self.alloc.free(self.overlap_buf); self.alloc.free(self.needle); + self.chunk_buf.deinit(self.alloc); self.data.deinit(self.alloc); var meta_it = self.meta.iterator(.forward); @@ -143,14 +151,17 @@ pub const SlidingWindow = struct { /// 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 { + /// This returns a flattened highlight on a match. The + /// flattened highlight requires allocation and is therefore more expensive + /// than a normal selection, but it is more efficient to render since it + /// has all the information without having to dereference pointers into + /// the terminal state. + /// + /// The flattened highlight chunks reference internal memory for this + /// sliding window and are only valid until the next call to `next()` + /// or `append()`. If the caller wants to retain the flattened highlight + /// then they should clone it. + pub fn next(self: *SlidingWindow) ?FlattenedHighlight { const slices = slices: { // If we have less data then the needle then we can't possibly match const data_len = self.data.len(); @@ -164,7 +175,7 @@ pub const SlidingWindow = struct { // Search the first slice for the needle. if (std.mem.indexOf(u8, slices[0], self.needle)) |idx| { - return self.selection( + return self.highlight( idx, self.needle.len, ); @@ -197,7 +208,7 @@ pub const SlidingWindow = struct { // We found a match in the overlap buffer. We need to map the // index back to the data buffer in order to get our selection. - return self.selection( + return self.highlight( slices[0].len - prefix.len + idx, self.needle.len, ); @@ -205,7 +216,7 @@ pub const SlidingWindow = struct { // Search the last slice for the needle. if (std.mem.indexOf(u8, slices[1], self.needle)) |idx| { - return self.selection( + return self.highlight( slices[0].len + idx, self.needle.len, ); @@ -263,114 +274,230 @@ pub const SlidingWindow = struct { return null; } - /// Return a selection for the given start and length into the data - /// buffer and also prune the data/meta buffers if possible up to - /// this start index. + /// Return a flattened highlight for the given start and length. + /// + /// The flattened highlight can be used to render the highlight + /// in the most efficient way because it doesn't require a terminal + /// lock to access terminal data to compare whether some viewport + /// matches the highlight (because it doesn't need to traverse + /// the page nodes). /// /// The start index is assumed to be relative to the offset. i.e. /// index zero is actually at `self.data[self.data_offset]`. The /// selection will account for the offset. - fn selection( + fn highlight( self: *SlidingWindow, start_offset: usize, len: usize, - ) Selection { + ) terminal.highlight.Flattened { const start = start_offset + self.data_offset; - assert(start < self.data.len()); - assert(start + len <= self.data.len()); + const end = start + len - 1; + if (comptime std.debug.runtime_safety) { + assert(start < self.data.len()); + assert(start + len <= self.data.len()); + } - // meta_consumed is the number of bytes we've consumed in the - // data buffer up to and NOT including the meta where we've - // found our pin. This is important because it tells us the - // amount of data we can safely deleted from self.data since - // we can't partially delete a meta block's data. (The partial - // amount is represented by self.data_offset). - var meta_it = self.meta.iterator(.forward); - var meta_consumed: usize = 0; - const tl: Pin = pin(&meta_it, &meta_consumed, start); + // Clear our previous chunk buffer to store this result + self.chunk_buf.clearRetainingCapacity(); + var result: terminal.highlight.Flattened = .empty; - // Store the information required to prune later. We store this - // now because we only want to prune up to our START so we can - // find overlapping matches. - const tl_meta_idx = meta_it.idx - 1; - const tl_meta_consumed = meta_consumed; + // Go through the meta nodes to find our start. + const tl: struct { + /// If non-null, we need to continue searching for the bottom-right. + br: ?struct { + it: MetaBuf.Iterator, + consumed: usize, + }, - // We have to seek back so that we reinspect our current - // iterator value again in case the start and end are in the - // same segment. - meta_it.seekBy(-1); - const br: Pin = pin(&meta_it, &meta_consumed, start + len - 1); - assert(meta_it.idx >= 1); + /// Data to prune, both are lengths. + prune: struct { + meta: usize, + data: usize, + }, + } = tl: { + var meta_it = self.meta.iterator(.forward); + var meta_consumed: usize = 0; + while (meta_it.next()) |meta| { + // Always increment our consumed count so that our index + // is right for the end search if we do it. + const prior_meta_consumed = meta_consumed; + meta_consumed += meta.cell_map.items.len; + + // meta_i is the index we expect to find the match in the + // cell map within this meta if it contains it. + const meta_i = start - prior_meta_consumed; + + // This meta doesn't contain the match. This means we + // can also prune this set of data because we only look + // forward. + if (meta_i >= meta.cell_map.items.len) continue; + + // Now we look for the end. In MOST cases it is the same as + // our starting chunk because highlights are usually small and + // not on a boundary, so let's optimize for that. + const end_i = end - prior_meta_consumed; + if (end_i < meta.cell_map.items.len) { + @branchHint(.likely); + + // The entire highlight is within this meta. + const start_map = meta.cell_map.items[meta_i]; + const end_map = meta.cell_map.items[end_i]; + result.top_x = start_map.x; + result.bot_x = end_map.x; + self.chunk_buf.appendAssumeCapacity(.{ + .node = meta.node, + .start = @intCast(start_map.y), + .end = @intCast(end_map.y + 1), + }); + + break :tl .{ + .br = null, + .prune = .{ + .meta = meta_it.idx - 1, + .data = prior_meta_consumed, + }, + }; + } else { + // We found the meta that contains the start of the match + // only. Consume this entire node from our start offset. + const map = meta.cell_map.items[meta_i]; + result.top_x = map.x; + self.chunk_buf.appendAssumeCapacity(.{ + .node = meta.node, + .start = @intCast(map.y), + .end = meta.node.data.size.rows, + }); + + break :tl .{ + .br = .{ + .it = meta_it, + .consumed = meta_consumed, + }, + .prune = .{ + .meta = meta_it.idx - 1, + .data = prior_meta_consumed, + }, + }; + } + } else { + // Precondition that the start index is within the data buffer. + unreachable; + } + }; + + // Search for our end. + if (tl.br) |br| { + var meta_it = br.it; + var meta_consumed: usize = br.consumed; + while (meta_it.next()) |meta| { + // meta_i is the index we expect to find the match in the + // cell map within this meta if it contains it. + const meta_i = end - meta_consumed; + if (meta_i >= meta.cell_map.items.len) { + // This meta doesn't contain the match. We still add it + // to our results because we want the full flattened list. + self.chunk_buf.appendAssumeCapacity(.{ + .node = meta.node, + .start = 0, + .end = meta.node.data.size.rows, + }); + + meta_consumed += meta.cell_map.items.len; + continue; + } + + // We found it + const map = meta.cell_map.items[meta_i]; + result.bot_x = map.x; + self.chunk_buf.appendAssumeCapacity(.{ + .node = meta.node, + .start = 0, + .end = @intCast(map.y + 1), + }); + break; + } else { + // Precondition that the end index is within the data buffer. + unreachable; + } + } // Our offset into the current meta block is the start index // minus the amount of data fully consumed. We then add one // to move one past the match so we don't repeat it. - self.data_offset = start - tl_meta_consumed + 1; + self.data_offset = start - tl.prune.data + 1; - // meta_it.idx is br's meta index plus one (because the iterator - // moves one past the end; we call next() one last time). So - // we compare against one to check that the meta that we matched - // in has prior meta blocks we can prune. - if (tl_meta_idx > 0) { + // If we went beyond our initial meta node we can prune. + if (tl.prune.meta > 0) { // Deinit all our memory in the meta blocks prior to our // match. - const meta_count = tl_meta_idx; - meta_it.reset(); - for (0..meta_count) |_| meta_it.next().?.deinit(self.alloc); - if (comptime std.debug.runtime_safety) { - assert(meta_it.idx == meta_count); - assert(meta_it.next().?.node == tl.node); + var meta_it = self.meta.iterator(.forward); + var meta_consumed: usize = 0; + for (0..tl.prune.meta) |_| { + const meta: *Meta = meta_it.next().?; + meta_consumed += meta.cell_map.items.len; + meta.deinit(self.alloc); } - self.meta.deleteOldest(meta_count); + if (comptime std.debug.runtime_safety) { + assert(meta_it.idx == tl.prune.meta); + assert(meta_it.next().?.node == self.chunk_buf.items(.node)[0]); + } + self.meta.deleteOldest(tl.prune.meta); // Delete all the data up to our current index. - assert(tl_meta_consumed > 0); - self.data.deleteOldest(tl_meta_consumed); + assert(tl.prune.data > 0); + self.data.deleteOldest(tl.prune.data); } - self.assertIntegrity(); - return switch (self.direction) { - .forward => .init(tl, br, false), - .reverse => .init(br, tl, false), - }; - } + switch (self.direction) { + .forward => {}, + .reverse => { + if (self.chunk_buf.len > 1) { + // Reverse all our chunks. This should be pretty obvious why. + const slice = self.chunk_buf.slice(); + const nodes = slice.items(.node); + const starts = slice.items(.start); + const ends = slice.items(.end); + std.mem.reverse(*PageList.List.Node, nodes); + std.mem.reverse(size.CellCountInt, starts); + std.mem.reverse(size.CellCountInt, ends); - /// Convert a data index into a pin. - /// - /// The iterator and offset are both expected to be passed by - /// pointer so that the pin can be efficiently called for multiple - /// indexes (in order). See selection() for an example. - /// - /// Precondition: the index must be within the data buffer. - fn pin( - it: *MetaBuf.Iterator, - offset: *usize, - idx: usize, - ) Pin { - while (it.next()) |meta| { - // meta_i is the index we expect to find the match in the - // cell map within this meta if it contains it. - const meta_i = idx - offset.*; - if (meta_i >= meta.cell_map.items.len) { - // This meta doesn't contain the match. This means we - // can also prune this set of data because we only look - // forward. - offset.* += meta.cell_map.items.len; - continue; - } + // Now normally with forward traversal with multiple pages, + // the suffix of the first page and the prefix of the last + // page are used. + // + // For a reverse traversal, this is inverted (since the + // pages are in reverse order we get the suffix of the last + // page and the prefix of the first page). So we need to + // invert this. + // + // We DON'T need to do this for any middle pages because + // they always use the full page. + // + // We DON'T need to do this for chunks.len == 1 because + // the pages themselves aren't reversed and we don't have + // any prefix/suffix problems. + // + // This is a fixup that makes our start/end match the + // same logic as the loops above if they were in forward + // order. + assert(nodes.len >= 2); + starts[0] = ends[0] - 1; + ends[0] = nodes[0].data.size.rows; + ends[nodes.len - 1] = starts[nodes.len - 1] + 1; + starts[nodes.len - 1] = 0; + } - // We found the meta that contains the start of the match. - const map = meta.cell_map.items[meta_i]; - return .{ - .node = meta.node, - .y = @intCast(map.y), - .x = map.x, - }; + // X values also need to be reversed since the top/bottom + // are swapped for the nodes. + const top_x = result.top_x; + result.top_x = result.bot_x; + result.bot_x = top_x; + }, } - // Unreachable because it is a precondition that the index is - // within the data buffer. - unreachable; + // Copy over our MultiArrayList so it points to the proper memory. + result.chunks = self.chunk_buf; + return result; } /// Add a new node to the sliding window. This will always grow @@ -442,6 +569,7 @@ pub const SlidingWindow = struct { // Ensure our buffers are big enough to store what we need. try self.data.ensureUnusedCapacity(self.alloc, written.len); try self.meta.ensureUnusedCapacity(self.alloc, 1); + try self.chunk_buf.ensureTotalCapacity(self.alloc, self.meta.capacity()); // Append our new node to the circular buffer. self.data.appendSliceAssumeCapacity(written); @@ -505,26 +633,28 @@ test "SlidingWindow single append" { // We should be able to find two matches. { - const sel = w.next().?; + const h = w.next().?; + const sel = h.untracked(); try testing.expectEqual(point.Point{ .active = .{ .x = 7, .y = 0, - } }, s.pages.pointFromPin(.active, sel.start()).?); + } }, s.pages.pointFromPin(.active, sel.start)); try testing.expectEqual(point.Point{ .active = .{ .x = 10, .y = 0, - } }, s.pages.pointFromPin(.active, sel.end()).?); + } }, s.pages.pointFromPin(.active, sel.end)); } { - const sel = w.next().?; + const h = w.next().?; + const sel = h.untracked(); try testing.expectEqual(point.Point{ .active = .{ .x = 19, .y = 0, - } }, s.pages.pointFromPin(.active, sel.start()).?); + } }, s.pages.pointFromPin(.active, sel.start)); try testing.expectEqual(point.Point{ .active = .{ .x = 22, .y = 0, - } }, s.pages.pointFromPin(.active, sel.end()).?); + } }, s.pages.pointFromPin(.active, sel.end)); } try testing.expect(w.next() == null); try testing.expect(w.next() == null); @@ -582,26 +712,28 @@ test "SlidingWindow two pages" { // Search should find two matches { - const sel = w.next().?; + const h = w.next().?; + const sel = h.untracked(); try testing.expectEqual(point.Point{ .active = .{ .x = 76, .y = 22, - } }, s.pages.pointFromPin(.active, sel.start()).?); + } }, s.pages.pointFromPin(.active, sel.start).?); try testing.expectEqual(point.Point{ .active = .{ .x = 79, .y = 22, - } }, s.pages.pointFromPin(.active, sel.end()).?); + } }, s.pages.pointFromPin(.active, sel.end).?); } { - const sel = w.next().?; + const h = w.next().?; + const sel = h.untracked(); try testing.expectEqual(point.Point{ .active = .{ .x = 7, .y = 23, - } }, s.pages.pointFromPin(.active, sel.start()).?); + } }, s.pages.pointFromPin(.active, sel.start).?); try testing.expectEqual(point.Point{ .active = .{ .x = 10, .y = 23, - } }, s.pages.pointFromPin(.active, sel.end()).?); + } }, s.pages.pointFromPin(.active, sel.end).?); } try testing.expect(w.next() == null); try testing.expect(w.next() == null); @@ -634,15 +766,16 @@ test "SlidingWindow two pages match across boundary" { // Search should find a match { - const sel = w.next().?; + const h = w.next().?; + const sel = h.untracked(); try testing.expectEqual(point.Point{ .active = .{ .x = 76, .y = 22, - } }, s.pages.pointFromPin(.active, sel.start()).?); + } }, s.pages.pointFromPin(.active, sel.start).?); try testing.expectEqual(point.Point{ .active = .{ .x = 7, .y = 23, - } }, s.pages.pointFromPin(.active, sel.end()).?); + } }, s.pages.pointFromPin(.active, sel.end).?); } try testing.expect(w.next() == null); try testing.expect(w.next() == null); @@ -831,15 +964,16 @@ test "SlidingWindow single append across circular buffer boundary" { try testing.expect(slices[1].len > 0); } { - const sel = w.next().?; + const h = w.next().?; + const sel = h.untracked(); try testing.expectEqual(point.Point{ .active = .{ .x = 19, .y = 0, - } }, s.pages.pointFromPin(.active, sel.start()).?); + } }, s.pages.pointFromPin(.active, sel.start).?); try testing.expectEqual(point.Point{ .active = .{ .x = 21, .y = 0, - } }, s.pages.pointFromPin(.active, sel.end()).?); + } }, s.pages.pointFromPin(.active, sel.end).?); } try testing.expect(w.next() == null); } @@ -889,15 +1023,16 @@ test "SlidingWindow single append match on boundary" { try testing.expect(slices[1].len > 0); } { - const sel = w.next().?; + const h = w.next().?; + const sel = h.untracked(); try testing.expectEqual(point.Point{ .active = .{ .x = 21, .y = 0, - } }, s.pages.pointFromPin(.active, sel.start()).?); + } }, s.pages.pointFromPin(.active, sel.start).?); try testing.expectEqual(point.Point{ .active = .{ .x = 1, .y = 0, - } }, s.pages.pointFromPin(.active, sel.end()).?); + } }, s.pages.pointFromPin(.active, sel.end).?); } try testing.expect(w.next() == null); } @@ -920,26 +1055,28 @@ test "SlidingWindow single append reversed" { // We should be able to find two matches. { - const sel = w.next().?; + const h = w.next().?; + const sel = h.untracked(); try testing.expectEqual(point.Point{ .active = .{ .x = 19, .y = 0, - } }, s.pages.pointFromPin(.active, sel.start()).?); + } }, s.pages.pointFromPin(.active, sel.start).?); try testing.expectEqual(point.Point{ .active = .{ .x = 22, .y = 0, - } }, s.pages.pointFromPin(.active, sel.end()).?); + } }, s.pages.pointFromPin(.active, sel.end).?); } { - const sel = w.next().?; + const h = w.next().?; + const sel = h.untracked(); try testing.expectEqual(point.Point{ .active = .{ .x = 7, .y = 0, - } }, s.pages.pointFromPin(.active, sel.start()).?); + } }, s.pages.pointFromPin(.active, sel.start).?); try testing.expectEqual(point.Point{ .active = .{ .x = 10, .y = 0, - } }, s.pages.pointFromPin(.active, sel.end()).?); + } }, s.pages.pointFromPin(.active, sel.end).?); } try testing.expect(w.next() == null); try testing.expect(w.next() == null); @@ -997,26 +1134,28 @@ test "SlidingWindow two pages reversed" { // Search should find two matches (in reverse order) { - const sel = w.next().?; + const h = w.next().?; + const sel = h.untracked(); try testing.expectEqual(point.Point{ .active = .{ .x = 7, .y = 23, - } }, s.pages.pointFromPin(.active, sel.start()).?); + } }, s.pages.pointFromPin(.active, sel.start).?); try testing.expectEqual(point.Point{ .active = .{ .x = 10, .y = 23, - } }, s.pages.pointFromPin(.active, sel.end()).?); + } }, s.pages.pointFromPin(.active, sel.end).?); } { - const sel = w.next().?; + const h = w.next().?; + const sel = h.untracked(); try testing.expectEqual(point.Point{ .active = .{ .x = 76, .y = 22, - } }, s.pages.pointFromPin(.active, sel.start()).?); + } }, s.pages.pointFromPin(.active, sel.start).?); try testing.expectEqual(point.Point{ .active = .{ .x = 79, .y = 22, - } }, s.pages.pointFromPin(.active, sel.end()).?); + } }, s.pages.pointFromPin(.active, sel.end).?); } try testing.expect(w.next() == null); try testing.expect(w.next() == null); @@ -1049,15 +1188,16 @@ test "SlidingWindow two pages match across boundary reversed" { // Search should find a match { - const sel = w.next().?; + const h = w.next().?; + const sel = h.untracked(); try testing.expectEqual(point.Point{ .active = .{ .x = 76, .y = 22, - } }, s.pages.pointFromPin(.active, sel.start()).?); + } }, s.pages.pointFromPin(.active, sel.start).?); try testing.expectEqual(point.Point{ .active = .{ .x = 7, .y = 23, - } }, s.pages.pointFromPin(.active, sel.end()).?); + } }, s.pages.pointFromPin(.active, sel.end).?); } try testing.expect(w.next() == null); try testing.expect(w.next() == null); @@ -1185,15 +1325,16 @@ test "SlidingWindow single append across circular buffer boundary reversed" { try testing.expect(slices[1].len > 0); } { - const sel = w.next().?; + const h = w.next().?; + const sel = h.untracked(); try testing.expectEqual(point.Point{ .active = .{ .x = 19, .y = 0, - } }, s.pages.pointFromPin(.active, sel.start()).?); + } }, s.pages.pointFromPin(.active, sel.start).?); try testing.expectEqual(point.Point{ .active = .{ .x = 21, .y = 0, - } }, s.pages.pointFromPin(.active, sel.end()).?); + } }, s.pages.pointFromPin(.active, sel.end).?); } try testing.expect(w.next() == null); } @@ -1244,15 +1385,16 @@ test "SlidingWindow single append match on boundary reversed" { try testing.expect(slices[1].len > 0); } { - const sel = w.next().?; + const h = w.next().?; + const sel = h.untracked(); try testing.expectEqual(point.Point{ .active = .{ .x = 21, .y = 0, - } }, s.pages.pointFromPin(.active, sel.start()).?); + } }, s.pages.pointFromPin(.active, sel.start).?); try testing.expectEqual(point.Point{ .active = .{ .x = 1, .y = 0, - } }, s.pages.pointFromPin(.active, sel.end()).?); + } }, s.pages.pointFromPin(.active, sel.end).?); } try testing.expect(w.next() == null); } diff --git a/src/terminal/search/sliding_window2.zig b/src/terminal/search/sliding_window2.zig deleted file mode 100644 index 6aad0bff9..000000000 --- a/src/terminal/search/sliding_window2.zig +++ /dev/null @@ -1,1400 +0,0 @@ -const std = @import("std"); -const assert = @import("../../quirks.zig").inlineAssert; -const Allocator = std.mem.Allocator; -const CircBuf = @import("../../datastruct/main.zig").CircBuf; -const terminal = @import("../main.zig"); -const point = terminal.point; -const size = terminal.size; -const PageList = terminal.PageList; -const Pin = PageList.Pin; -const Selection = terminal.Selection; -const Screen = terminal.Screen; -const PageFormatter = @import("../formatter.zig").PageFormatter; -const FlattenedHighlight = terminal.highlight.Flattened; - -/// Searches page nodes via a sliding window. The sliding window maintains -/// the invariant that data isn't pruned until (1) we've searched it and -/// (2) we've accounted for overlaps across pages to fit the needle. -/// -/// The sliding window is first initialized empty. Pages are then appended -/// in the order to search them. The sliding window supports both a forward -/// and reverse order specified via `init`. The pages should be appended -/// in the correct order matching the search direction. -/// -/// All appends grow the window. The window is only pruned when a search -/// is done (positive or negative match) via `next()`. -/// -/// To avoid unnecessary memory growth, the recommended usage is to -/// call `next()` until it returns null and then `append` the next page -/// and repeat the process. This will always maintain the minimum -/// required memory to search for the needle. -/// -/// The caller is responsible for providing the pages and ensuring they're -/// in the proper order. The SlidingWindow itself doesn't own the pages, but -/// it will contain pointers to them in order to return selections. If any -/// pages become invalid, the caller should clear the sliding window and -/// start over. -pub const SlidingWindow = struct { - /// The allocator to use for all the data within this window. We - /// store this rather than passing it around because its already - /// part of multiple elements (eg. Meta's CellMap) and we want to - /// ensure we always use a consistent allocator. Additionally, only - /// a small amount of sliding windows are expected to be in use - /// at any one time so the memory overhead isn't that large. - alloc: Allocator, - - /// The data buffer is a circular buffer of u8 that contains the - /// encoded page text that we can use to search for the needle. - data: DataBuf, - - /// The meta buffer is a circular buffer that contains the metadata - /// about the pages we're searching. This usually isn't that large - /// so callers must iterate through it to find the offset to map - /// data to meta. - meta: MetaBuf, - - /// Buffer that can fit any amount of chunks necessary for next - /// to never fail allocation. - chunk_buf: std.MultiArrayList(FlattenedHighlight.Chunk), - - /// Offset into data for our current state. This handles the - /// situation where our search moved through meta[0] but didn't - /// do enough to prune it. - data_offset: usize = 0, - - /// The needle we're searching for. Does own the memory. - needle: []const u8, - - /// The search direction. If the direction is forward then pages should - /// be appended in forward linked list order from the PageList. If the - /// direction is reverse then pages should be appended in reverse order. - /// - /// This is important because in most cases, a reverse search is going - /// to be more desirable to search from the end of the active area - /// backwards so more recent data is found first. Supporting both is - /// trivial though and will let us do more complex optimizations in the - /// future (e.g. starting from the viewport and doing a forward/reverse - /// concurrently from that point). - direction: Direction, - - /// A buffer to store the overlap search data. This is used to search - /// overlaps between pages where the match starts on one page and - /// ends on another. The length is always `needle.len * 2`. - overlap_buf: []u8, - - const Direction = enum { forward, reverse }; - const DataBuf = CircBuf(u8, 0); - const MetaBuf = CircBuf(Meta, undefined); - const Meta = struct { - node: *PageList.List.Node, - cell_map: std.ArrayList(point.Coordinate), - - pub fn deinit(self: *Meta, alloc: Allocator) void { - self.cell_map.deinit(alloc); - } - }; - - pub fn init( - alloc: Allocator, - direction: Direction, - needle_unowned: []const u8, - ) Allocator.Error!SlidingWindow { - var data = try DataBuf.init(alloc, 0); - errdefer data.deinit(alloc); - - var meta = try MetaBuf.init(alloc, 0); - errdefer meta.deinit(alloc); - - const needle = try alloc.dupe(u8, needle_unowned); - errdefer alloc.free(needle); - switch (direction) { - .forward => {}, - .reverse => std.mem.reverse(u8, needle), - } - - const overlap_buf = try alloc.alloc(u8, needle.len * 2); - errdefer alloc.free(overlap_buf); - - return .{ - .alloc = alloc, - .data = data, - .meta = meta, - .chunk_buf = .empty, - .needle = needle, - .direction = direction, - .overlap_buf = overlap_buf, - }; - } - - pub fn deinit(self: *SlidingWindow) void { - self.alloc.free(self.overlap_buf); - self.alloc.free(self.needle); - self.chunk_buf.deinit(self.alloc); - self.data.deinit(self.alloc); - - var meta_it = self.meta.iterator(.forward); - while (meta_it.next()) |meta| meta.deinit(self.alloc); - self.meta.deinit(self.alloc); - } - - /// Clear all data but retain allocated capacity. - pub fn clearAndRetainCapacity(self: *SlidingWindow) void { - var meta_it = self.meta.iterator(.forward); - while (meta_it.next()) |meta| meta.deinit(self.alloc); - self.meta.clear(); - self.data.clear(); - self.data_offset = 0; - } - - /// Search the window for the next occurrence of the needle. As - /// the window moves, the window will prune itself while maintaining - /// the invariant that the window is always big enough to contain - /// the needle. - /// - /// This returns a flattened highlight on a match. The - /// flattened highlight requires allocation and is therefore more expensive - /// than a normal selection, but it is more efficient to render since it - /// has all the information without having to dereference pointers into - /// the terminal state. - /// - /// The flattened highlight chunks reference internal memory for this - /// sliding window and are only valid until the next call to `next()` - /// or `append()`. If the caller wants to retain the flattened highlight - /// then they should clone it. - pub fn next(self: *SlidingWindow) ?FlattenedHighlight { - const slices = slices: { - // If we have less data then the needle then we can't possibly match - const data_len = self.data.len(); - if (data_len < self.needle.len) return null; - - break :slices self.data.getPtrSlice( - self.data_offset, - data_len - self.data_offset, - ); - }; - - // Search the first slice for the needle. - if (std.mem.indexOf(u8, slices[0], self.needle)) |idx| { - return self.highlight( - idx, - self.needle.len, - ); - } - - // Search the overlap buffer for the needle. - if (slices[0].len > 0 and slices[1].len > 0) overlap: { - // Get up to needle.len - 1 bytes from each side (as much as - // we can) and store it in the overlap buffer. - const prefix: []const u8 = prefix: { - const len = @min(slices[0].len, self.needle.len - 1); - const idx = slices[0].len - len; - break :prefix slices[0][idx..]; - }; - const suffix: []const u8 = suffix: { - const len = @min(slices[1].len, self.needle.len - 1); - break :suffix slices[1][0..len]; - }; - const overlap_len = prefix.len + suffix.len; - assert(overlap_len <= self.overlap_buf.len); - @memcpy(self.overlap_buf[0..prefix.len], prefix); - @memcpy(self.overlap_buf[prefix.len..overlap_len], suffix); - - // Search the overlap - const idx = std.mem.indexOf( - u8, - self.overlap_buf[0..overlap_len], - self.needle, - ) orelse break :overlap; - - // We found a match in the overlap buffer. We need to map the - // index back to the data buffer in order to get our selection. - return self.highlight( - slices[0].len - prefix.len + idx, - self.needle.len, - ); - } - - // Search the last slice for the needle. - if (std.mem.indexOf(u8, slices[1], self.needle)) |idx| { - return self.highlight( - slices[0].len + idx, - self.needle.len, - ); - } - - // No match. We keep `needle.len - 1` bytes available to - // handle the future overlap case. - var meta_it = self.meta.iterator(.reverse); - prune: { - var saved: usize = 0; - while (meta_it.next()) |meta| { - const needed = self.needle.len - 1 - saved; - if (meta.cell_map.items.len >= needed) { - // We save up to this meta. We set our data offset - // to exactly where it needs to be to continue - // searching. - self.data_offset = meta.cell_map.items.len - needed; - break; - } - - saved += meta.cell_map.items.len; - } else { - // If we exited the while loop naturally then we - // never got the amount we needed and so there is - // nothing to prune. - assert(saved < self.needle.len - 1); - break :prune; - } - - const prune_count = self.meta.len() - meta_it.idx; - if (prune_count == 0) { - // This can happen if we need to save up to the first - // meta value to retain our window. - break :prune; - } - - // We can now delete all the metas up to but NOT including - // the meta we found through meta_it. - meta_it = self.meta.iterator(.forward); - var prune_data_len: usize = 0; - for (0..prune_count) |_| { - const meta = meta_it.next().?; - prune_data_len += meta.cell_map.items.len; - meta.deinit(self.alloc); - } - self.meta.deleteOldest(prune_count); - self.data.deleteOldest(prune_data_len); - } - - // Our data offset now moves to needle.len - 1 from the end so - // that we can handle the overlap case. - self.data_offset = self.data.len() - self.needle.len + 1; - - self.assertIntegrity(); - return null; - } - - /// Return a flattened highlight for the given start and length. - /// - /// The flattened highlight can be used to render the highlight - /// in the most efficent way because it doesn't require a terminal - /// lock to access terminal data to compare whether some viewport - /// matches the highlight (because it doesn't need to traverse - /// the page nodes). - /// - /// The start index is assumed to be relative to the offset. i.e. - /// index zero is actually at `self.data[self.data_offset]`. The - /// selection will account for the offset. - fn highlight( - self: *SlidingWindow, - start_offset: usize, - len: usize, - ) terminal.highlight.Flattened { - const start = start_offset + self.data_offset; - const end = start + len - 1; - if (comptime std.debug.runtime_safety) { - assert(start < self.data.len()); - assert(start + len <= self.data.len()); - } - - // Clear our previous chunk buffer to store this result - self.chunk_buf.clearRetainingCapacity(); - var result: terminal.highlight.Flattened = .empty; - - // Go through the meta nodes to find our start. - const tl: struct { - /// If non-null, we need to continue searching for the bottom-right. - br: ?struct { - it: MetaBuf.Iterator, - consumed: usize, - }, - - /// Data to prune, both are lengths. - prune: struct { - meta: usize, - data: usize, - }, - } = tl: { - var meta_it = self.meta.iterator(.forward); - var meta_consumed: usize = 0; - while (meta_it.next()) |meta| { - // Always increment our consumed count so that our index - // is right for the end search if we do it. - const prior_meta_consumed = meta_consumed; - meta_consumed += meta.cell_map.items.len; - - // meta_i is the index we expect to find the match in the - // cell map within this meta if it contains it. - const meta_i = start - prior_meta_consumed; - - // This meta doesn't contain the match. This means we - // can also prune this set of data because we only look - // forward. - if (meta_i >= meta.cell_map.items.len) continue; - - // Now we look for the end. In MOST cases it is the same as - // our starting chunk because highlights are usually small and - // not on a boundary, so let's optimize for that. - const end_i = end - prior_meta_consumed; - if (end_i < meta.cell_map.items.len) { - @branchHint(.likely); - - // The entire highlight is within this meta. - const start_map = meta.cell_map.items[meta_i]; - const end_map = meta.cell_map.items[end_i]; - result.top_x = start_map.x; - result.bot_x = end_map.x; - self.chunk_buf.appendAssumeCapacity(.{ - .node = meta.node, - .start = @intCast(start_map.y), - .end = @intCast(end_map.y + 1), - }); - - break :tl .{ - .br = null, - .prune = .{ - .meta = meta_it.idx - 1, - .data = prior_meta_consumed, - }, - }; - } else { - // We found the meta that contains the start of the match - // only. Consume this entire node from our start offset. - const map = meta.cell_map.items[meta_i]; - result.top_x = map.x; - self.chunk_buf.appendAssumeCapacity(.{ - .node = meta.node, - .start = @intCast(map.y), - .end = meta.node.data.size.rows, - }); - - break :tl .{ - .br = .{ - .it = meta_it, - .consumed = meta_consumed, - }, - .prune = .{ - .meta = meta_it.idx - 1, - .data = prior_meta_consumed, - }, - }; - } - } else { - // Precondition that the start index is within the data buffer. - unreachable; - } - }; - - // Search for our end. - if (tl.br) |br| { - var meta_it = br.it; - var meta_consumed: usize = br.consumed; - while (meta_it.next()) |meta| { - // meta_i is the index we expect to find the match in the - // cell map within this meta if it contains it. - const meta_i = end - meta_consumed; - if (meta_i >= meta.cell_map.items.len) { - // This meta doesn't contain the match. We still add it - // to our results because we want the full flattened list. - self.chunk_buf.appendAssumeCapacity(.{ - .node = meta.node, - .start = 0, - .end = meta.node.data.size.rows, - }); - - meta_consumed += meta.cell_map.items.len; - continue; - } - - // We found it - const map = meta.cell_map.items[meta_i]; - result.bot_x = map.x; - self.chunk_buf.appendAssumeCapacity(.{ - .node = meta.node, - .start = 0, - .end = @intCast(map.y + 1), - }); - break; - } else { - // Precondition that the end index is within the data buffer. - unreachable; - } - } - - // Our offset into the current meta block is the start index - // minus the amount of data fully consumed. We then add one - // to move one past the match so we don't repeat it. - self.data_offset = start - tl.prune.data + 1; - - // If we went beyond our initial meta node we can prune. - if (tl.prune.meta > 0) { - // Deinit all our memory in the meta blocks prior to our - // match. - var meta_it = self.meta.iterator(.forward); - var meta_consumed: usize = 0; - for (0..tl.prune.meta) |_| { - const meta: *Meta = meta_it.next().?; - meta_consumed += meta.cell_map.items.len; - meta.deinit(self.alloc); - } - if (comptime std.debug.runtime_safety) { - assert(meta_it.idx == tl.prune.meta); - assert(meta_it.next().?.node == self.chunk_buf.items(.node)[0]); - } - self.meta.deleteOldest(tl.prune.meta); - - // Delete all the data up to our current index. - assert(tl.prune.data > 0); - self.data.deleteOldest(tl.prune.data); - } - - switch (self.direction) { - .forward => {}, - .reverse => { - if (self.chunk_buf.len > 1) { - // Reverse all our chunks. This should be pretty obvious why. - const slice = self.chunk_buf.slice(); - const nodes = slice.items(.node); - const starts = slice.items(.start); - const ends = slice.items(.end); - std.mem.reverse(*PageList.List.Node, nodes); - std.mem.reverse(size.CellCountInt, starts); - std.mem.reverse(size.CellCountInt, ends); - - // Now normally with forward traversal with multiple pages, - // the suffix of the first page and the prefix of the last - // page are used. - // - // For a reverse traversal, this is inverted (since the - // pages are in reverse order we get the suffix of the last - // page and the prefix of the first page). So we need to - // invert this. - // - // We DON'T need to do this for any middle pages because - // they always use the full page. - // - // We DON'T need to do this for chunks.len == 1 because - // the pages themselves aren't reversed and we don't have - // any prefix/suffix problems. - // - // This is a fixup that makes our start/end match the - // same logic as the loops above if they were in forward - // order. - assert(nodes.len >= 2); - starts[0] = ends[0] - 1; - ends[0] = nodes[0].data.size.rows; - ends[nodes.len - 1] = starts[nodes.len - 1] + 1; - starts[nodes.len - 1] = 0; - } - - // X values also need to be reversed since the top/bottom - // are swapped for the nodes. - const top_x = result.top_x; - result.top_x = result.bot_x; - result.bot_x = top_x; - }, - } - - // Copy over our MultiArrayList so it points to the proper memory. - result.chunks = self.chunk_buf; - return result; - } - - /// 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!usize { - // Initialize our metadata for the node. - var meta: Meta = .{ - .node = node, - .cell_map = .empty, - }; - errdefer meta.deinit(self.alloc); - - // This is suboptimal but we need to encode the page once to - // temporary memory, and then copy it into our circular buffer. - // In the future, we should benchmark and see if we can encode - // directly into the circular buffer. - var encoded: std.Io.Writer.Allocating = .init(self.alloc); - defer encoded.deinit(); - - // Encode the page into the buffer. - const formatter: PageFormatter = formatter: { - var formatter: PageFormatter = .init(&meta.node.data, .plain); - formatter.point_map = .{ - .alloc = self.alloc, - .map = &meta.cell_map, - }; - break :formatter formatter; - }; - formatter.format(&encoded.writer) catch { - // writer uses anyerror but the only realistic error on - // an ArrayList is out of memory. - return error.OutOfMemory; - }; - assert(meta.cell_map.items.len == encoded.written().len); - - // If the node we're adding isn't soft-wrapped, we add the - // trailing newline. - const row = node.data.getRow(node.data.size.rows - 1); - if (!row.wrap) { - encoded.writer.writeByte('\n') catch return error.OutOfMemory; - try meta.cell_map.append( - self.alloc, - meta.cell_map.getLastOrNull() orelse .{ - .x = 0, - .y = 0, - }, - ); - } - - // Get our written data. If we're doing a reverse search then we - // need to reverse all our encodings. - const written = encoded.written(); - switch (self.direction) { - .forward => {}, - .reverse => { - std.mem.reverse(u8, written); - std.mem.reverse(point.Coordinate, meta.cell_map.items); - }, - } - - // Ensure our buffers are big enough to store what we need. - try self.data.ensureUnusedCapacity(self.alloc, written.len); - try self.meta.ensureUnusedCapacity(self.alloc, 1); - try self.chunk_buf.ensureTotalCapacity(self.alloc, self.meta.capacity()); - - // Append our new node to the circular buffer. - self.data.appendSliceAssumeCapacity(written); - self.meta.appendAssumeCapacity(meta); - - self.assertIntegrity(); - return written.len; - } - - /// Only for tests! - fn testChangeNeedle(self: *SlidingWindow, new: []const u8) void { - assert(new.len == self.needle.len); - self.alloc.free(self.needle); - self.needle = self.alloc.dupe(u8, new) catch unreachable; - } - - fn assertIntegrity(self: *const SlidingWindow) void { - if (comptime !std.debug.runtime_safety) return; - - // We don't run integrity checks on Valgrind because its soooooo slow, - // Valgrind is our integrity checker, and we run these during unit - // tests (non-Valgrind) anyways so we're verifying anyways. - if (std.valgrind.runningOnValgrind() > 0) return; - - // Integrity check: verify our data matches our metadata exactly. - var meta_it = self.meta.iterator(.forward); - var data_len: usize = 0; - while (meta_it.next()) |m| data_len += m.cell_map.items.len; - assert(data_len == self.data.len()); - - // Integrity check: verify our data offset is within bounds. - assert(self.data_offset < self.data.len()); - } -}; - -test "SlidingWindow empty on init" { - const testing = std.testing; - const alloc = testing.allocator; - - var w: SlidingWindow = try .init(alloc, .forward, "boo!"); - defer w.deinit(); - try testing.expectEqual(0, w.data.len()); - try testing.expectEqual(0, w.meta.len()); -} - -test "SlidingWindow single append" { - const testing = std.testing; - const alloc = testing.allocator; - - var w: SlidingWindow = try .init(alloc, .forward, "boo!"); - defer w.deinit(); - - var s = try Screen.init(alloc, .{ .cols = 80, .rows = 24, .max_scrollback = 0 }); - defer s.deinit(); - try s.testWriteString("hello. boo! hello. boo!"); - - // 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); - - // We should be able to find two matches. - { - const h = w.next().?; - const sel = h.untracked(); - try testing.expectEqual(point.Point{ .active = .{ - .x = 7, - .y = 0, - } }, s.pages.pointFromPin(.active, sel.start)); - try testing.expectEqual(point.Point{ .active = .{ - .x = 10, - .y = 0, - } }, s.pages.pointFromPin(.active, sel.end)); - } - { - const h = w.next().?; - const sel = h.untracked(); - try testing.expectEqual(point.Point{ .active = .{ - .x = 19, - .y = 0, - } }, s.pages.pointFromPin(.active, sel.start)); - try testing.expectEqual(point.Point{ .active = .{ - .x = 22, - .y = 0, - } }, s.pages.pointFromPin(.active, sel.end)); - } - try testing.expect(w.next() == null); - try testing.expect(w.next() == null); -} - -test "SlidingWindow single append no match" { - const testing = std.testing; - const alloc = testing.allocator; - - var w: SlidingWindow = try .init(alloc, .forward, "nope!"); - defer w.deinit(); - - var s = try Screen.init(alloc, .{ .cols = 80, .rows = 24, .max_scrollback = 0 }); - defer s.deinit(); - try s.testWriteString("hello. boo! hello. boo!"); - - // 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); - - // No matches - try testing.expect(w.next() == null); - try testing.expect(w.next() == null); - - // Should still keep the page - try testing.expectEqual(1, w.meta.len()); -} - -test "SlidingWindow two pages" { - const testing = std.testing; - const alloc = testing.allocator; - - var w: SlidingWindow = try .init(alloc, .forward, "boo!"); - defer w.deinit(); - - var s = try Screen.init(alloc, .{ .cols = 80, .rows = 24, .max_scrollback = 1000 }); - defer s.deinit(); - - // Fill up the first page. The final bytes in the first page - // are "boo!" - const first_page_rows = s.pages.pages.first.?.data.capacity.rows; - for (0..first_page_rows - 1) |_| try s.testWriteString("\n"); - for (0..s.pages.cols - 4) |_| try s.testWriteString("x"); - try s.testWriteString("boo!"); - try testing.expect(s.pages.pages.first == s.pages.pages.last); - try s.testWriteString("\n"); - try testing.expect(s.pages.pages.first != s.pages.pages.last); - try s.testWriteString("hello. boo!"); - - // Add both pages - const node: *PageList.List.Node = s.pages.pages.first.?; - _ = try w.append(node); - _ = try w.append(node.next.?); - - // Search should find two matches - { - const h = w.next().?; - const sel = h.untracked(); - try testing.expectEqual(point.Point{ .active = .{ - .x = 76, - .y = 22, - } }, s.pages.pointFromPin(.active, sel.start).?); - try testing.expectEqual(point.Point{ .active = .{ - .x = 79, - .y = 22, - } }, s.pages.pointFromPin(.active, sel.end).?); - } - { - const h = w.next().?; - const sel = h.untracked(); - try testing.expectEqual(point.Point{ .active = .{ - .x = 7, - .y = 23, - } }, s.pages.pointFromPin(.active, sel.start).?); - try testing.expectEqual(point.Point{ .active = .{ - .x = 10, - .y = 23, - } }, s.pages.pointFromPin(.active, sel.end).?); - } - try testing.expect(w.next() == null); - try testing.expect(w.next() == null); -} - -test "SlidingWindow two pages match across boundary" { - const testing = std.testing; - const alloc = testing.allocator; - - var w: SlidingWindow = try .init(alloc, .forward, "hello, world"); - defer w.deinit(); - - var s = try Screen.init(alloc, .{ .cols = 80, .rows = 24, .max_scrollback = 1000 }); - defer s.deinit(); - - // Fill up the first page. The final bytes in the first page - // are "boo!" - const first_page_rows = s.pages.pages.first.?.data.capacity.rows; - for (0..first_page_rows - 1) |_| try s.testWriteString("\n"); - for (0..s.pages.cols - 4) |_| try s.testWriteString("x"); - try s.testWriteString("hell"); - try testing.expect(s.pages.pages.first == s.pages.pages.last); - try s.testWriteString("o, world!"); - try testing.expect(s.pages.pages.first != s.pages.pages.last); - - // Add both pages - const node: *PageList.List.Node = s.pages.pages.first.?; - _ = try w.append(node); - _ = try w.append(node.next.?); - - // Search should find a match - { - const h = w.next().?; - const sel = h.untracked(); - try testing.expectEqual(point.Point{ .active = .{ - .x = 76, - .y = 22, - } }, s.pages.pointFromPin(.active, sel.start).?); - try testing.expectEqual(point.Point{ .active = .{ - .x = 7, - .y = 23, - } }, s.pages.pointFromPin(.active, sel.end).?); - } - try testing.expect(w.next() == null); - try testing.expect(w.next() == null); - - // We shouldn't prune because we don't have enough space - try testing.expectEqual(2, w.meta.len()); -} - -test "SlidingWindow two pages no match across boundary with newline" { - const testing = std.testing; - const alloc = testing.allocator; - - var w: SlidingWindow = try .init(alloc, .forward, "hello, world"); - defer w.deinit(); - - var s = try Screen.init(alloc, .{ .cols = 80, .rows = 24, .max_scrollback = 1000 }); - defer s.deinit(); - - // Fill up the first page. The final bytes in the first page - // are "boo!" - const first_page_rows = s.pages.pages.first.?.data.capacity.rows; - for (0..first_page_rows - 1) |_| try s.testWriteString("\n"); - for (0..s.pages.cols - 4) |_| try s.testWriteString("x"); - try s.testWriteString("hell"); - try testing.expect(s.pages.pages.first == s.pages.pages.last); - try s.testWriteString("\no, world!"); - try testing.expect(s.pages.pages.first != s.pages.pages.last); - - // Add both pages - const node: *PageList.List.Node = s.pages.pages.first.?; - _ = try w.append(node); - _ = try w.append(node.next.?); - - // Search should NOT find a match - try testing.expect(w.next() == null); - try testing.expect(w.next() == null); - - // We shouldn't prune because we don't have enough space - try testing.expectEqual(2, w.meta.len()); -} - -test "SlidingWindow two pages no match across boundary with newline reverse" { - const testing = std.testing; - const alloc = testing.allocator; - - var w: SlidingWindow = try .init(alloc, .reverse, "hello, world"); - defer w.deinit(); - - var s = try Screen.init(alloc, .{ .cols = 80, .rows = 24, .max_scrollback = 1000 }); - defer s.deinit(); - - // Fill up the first page. The final bytes in the first page - // are "boo!" - const first_page_rows = s.pages.pages.first.?.data.capacity.rows; - for (0..first_page_rows - 1) |_| try s.testWriteString("\n"); - for (0..s.pages.cols - 4) |_| try s.testWriteString("x"); - try s.testWriteString("hell"); - try testing.expect(s.pages.pages.first == s.pages.pages.last); - try s.testWriteString("\no, world!"); - try testing.expect(s.pages.pages.first != s.pages.pages.last); - - // Add both pages in reverse order - const node: *PageList.List.Node = s.pages.pages.first.?; - _ = try w.append(node.next.?); - _ = try w.append(node); - - // Search should NOT find a match - try testing.expect(w.next() == null); - try testing.expect(w.next() == null); -} - -test "SlidingWindow two pages no match prunes first page" { - const testing = std.testing; - const alloc = testing.allocator; - - var w: SlidingWindow = try .init(alloc, .forward, "nope!"); - defer w.deinit(); - - var s = try Screen.init(alloc, .{ .cols = 80, .rows = 24, .max_scrollback = 1000 }); - defer s.deinit(); - - // Fill up the first page. The final bytes in the first page - // are "boo!" - const first_page_rows = s.pages.pages.first.?.data.capacity.rows; - for (0..first_page_rows - 1) |_| try s.testWriteString("\n"); - for (0..s.pages.cols - 4) |_| try s.testWriteString("x"); - try s.testWriteString("boo!"); - try testing.expect(s.pages.pages.first == s.pages.pages.last); - try s.testWriteString("\n"); - try testing.expect(s.pages.pages.first != s.pages.pages.last); - try s.testWriteString("hello. boo!"); - - // Add both pages - const node: *PageList.List.Node = s.pages.pages.first.?; - _ = try w.append(node); - _ = try w.append(node.next.?); - - // Search should find nothing - try testing.expect(w.next() == null); - try testing.expect(w.next() == null); - - // We should've pruned our page because the second page - // has enough text to contain our needle. - try testing.expectEqual(1, w.meta.len()); -} - -test "SlidingWindow two pages no match keeps both pages" { - const testing = std.testing; - const alloc = testing.allocator; - - var s = try Screen.init(alloc, .{ .cols = 80, .rows = 24, .max_scrollback = 1000 }); - defer s.deinit(); - - // Fill up the first page. The final bytes in the first page - // are "boo!" - const first_page_rows = s.pages.pages.first.?.data.capacity.rows; - for (0..first_page_rows - 1) |_| try s.testWriteString("\n"); - for (0..s.pages.cols - 4) |_| try s.testWriteString("x"); - try s.testWriteString("boo!"); - try testing.expect(s.pages.pages.first == s.pages.pages.last); - try s.testWriteString("\n"); - try testing.expect(s.pages.pages.first != s.pages.pages.last); - try s.testWriteString("hello. boo!"); - - // Imaginary needle for search. Doesn't match! - var needle_list: std.ArrayList(u8) = .empty; - defer needle_list.deinit(alloc); - try needle_list.appendNTimes(alloc, 'x', first_page_rows * s.pages.cols); - const needle: []const u8 = needle_list.items; - - var w: SlidingWindow = try .init(alloc, .forward, needle); - defer w.deinit(); - - // Add both pages - const node: *PageList.List.Node = s.pages.pages.first.?; - _ = try w.append(node); - _ = try w.append(node.next.?); - - // Search should find nothing - try testing.expect(w.next() == null); - try testing.expect(w.next() == null); - - // No pruning because both pages are needed to fit needle. - try testing.expectEqual(2, w.meta.len()); -} - -test "SlidingWindow single append across circular buffer boundary" { - const testing = std.testing; - const alloc = testing.allocator; - - var w: SlidingWindow = try .init(alloc, .forward, "abc"); - defer w.deinit(); - - var s = try Screen.init(alloc, .{ .cols = 80, .rows = 24, .max_scrollback = 0 }); - defer s.deinit(); - try s.testWriteString("XXXXXXXXXXXXXXXXXXXboo!XXXXX"); - - // We are trying to break a circular buffer boundary so the way we - // do this is to duplicate the data then do a failing search. This - // will cause the first page to be pruned. The next time we append we'll - // put it in the middle of the circ buffer. We assert this so that if - // 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); - { - // No wrap around yet - const slices = w.data.getPtrSlice(0, w.data.len()); - try testing.expect(slices[0].len > 0); - try testing.expect(slices[1].len == 0); - } - - // Search non-match, prunes page - try testing.expect(w.next() == null); - try testing.expectEqual(1, w.meta.len()); - - // Change the needle, just needs to be the same length (not a real API) - w.testChangeNeedle("boo"); - - // Add new page, now wraps - _ = try w.append(node); - { - const slices = w.data.getPtrSlice(0, w.data.len()); - try testing.expect(slices[0].len > 0); - try testing.expect(slices[1].len > 0); - } - { - const h = w.next().?; - const sel = h.untracked(); - try testing.expectEqual(point.Point{ .active = .{ - .x = 19, - .y = 0, - } }, s.pages.pointFromPin(.active, sel.start).?); - try testing.expectEqual(point.Point{ .active = .{ - .x = 21, - .y = 0, - } }, s.pages.pointFromPin(.active, sel.end).?); - } - try testing.expect(w.next() == null); -} - -test "SlidingWindow single append match on boundary" { - const testing = std.testing; - const alloc = testing.allocator; - - var w: SlidingWindow = try .init(alloc, .forward, "abcd"); - defer w.deinit(); - - var s = try Screen.init(alloc, .{ .cols = 80, .rows = 24, .max_scrollback = 0 }); - defer s.deinit(); - try s.testWriteString("o!XXXXXXXXXXXXXXXXXXXbo"); - - // We need to surgically modify the last row to be soft-wrapped - try testing.expect(s.pages.pages.first == s.pages.pages.last); - const node: *PageList.List.Node = s.pages.pages.first.?; - node.data.getRow(node.data.size.rows - 1).wrap = true; - - // We are trying to break a circular buffer boundary so the way we - // do this is to duplicate the data then do a failing search. This - // will cause the first page to be pruned. The next time we append we'll - // put it in the middle of the circ buffer. We assert this so that if - // our implementation changes our test will fail. - _ = try w.append(node); - _ = try w.append(node); - { - // No wrap around yet - const slices = w.data.getPtrSlice(0, w.data.len()); - try testing.expect(slices[0].len > 0); - try testing.expect(slices[1].len == 0); - } - - // Search non-match, prunes page - try testing.expect(w.next() == null); - try testing.expectEqual(1, w.meta.len()); - - // Change the needle, just needs to be the same length (not a real API) - w.testChangeNeedle("boo!"); - - // Add new page, now wraps - _ = try w.append(node); - { - const slices = w.data.getPtrSlice(0, w.data.len()); - try testing.expect(slices[0].len > 0); - try testing.expect(slices[1].len > 0); - } - { - const h = w.next().?; - const sel = h.untracked(); - try testing.expectEqual(point.Point{ .active = .{ - .x = 21, - .y = 0, - } }, s.pages.pointFromPin(.active, sel.start).?); - try testing.expectEqual(point.Point{ .active = .{ - .x = 1, - .y = 0, - } }, s.pages.pointFromPin(.active, sel.end).?); - } - try testing.expect(w.next() == null); -} - -test "SlidingWindow single append reversed" { - const testing = std.testing; - const alloc = testing.allocator; - - var w: SlidingWindow = try .init(alloc, .reverse, "boo!"); - defer w.deinit(); - - var s = try Screen.init(alloc, .{ .cols = 80, .rows = 24, .max_scrollback = 0 }); - defer s.deinit(); - try s.testWriteString("hello. boo! hello. boo!"); - - // 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); - - // We should be able to find two matches. - { - const h = w.next().?; - const sel = h.untracked(); - try testing.expectEqual(point.Point{ .active = .{ - .x = 19, - .y = 0, - } }, s.pages.pointFromPin(.active, sel.start).?); - try testing.expectEqual(point.Point{ .active = .{ - .x = 22, - .y = 0, - } }, s.pages.pointFromPin(.active, sel.end).?); - } - { - const h = w.next().?; - const sel = h.untracked(); - try testing.expectEqual(point.Point{ .active = .{ - .x = 7, - .y = 0, - } }, s.pages.pointFromPin(.active, sel.start).?); - try testing.expectEqual(point.Point{ .active = .{ - .x = 10, - .y = 0, - } }, s.pages.pointFromPin(.active, sel.end).?); - } - try testing.expect(w.next() == null); - try testing.expect(w.next() == null); -} - -test "SlidingWindow single append no match reversed" { - const testing = std.testing; - const alloc = testing.allocator; - - var w: SlidingWindow = try .init(alloc, .reverse, "nope!"); - defer w.deinit(); - - var s = try Screen.init(alloc, .{ .cols = 80, .rows = 24, .max_scrollback = 0 }); - defer s.deinit(); - try s.testWriteString("hello. boo! hello. boo!"); - - // 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); - - // No matches - try testing.expect(w.next() == null); - try testing.expect(w.next() == null); - - // Should still keep the page - try testing.expectEqual(1, w.meta.len()); -} - -test "SlidingWindow two pages reversed" { - const testing = std.testing; - const alloc = testing.allocator; - - var w: SlidingWindow = try .init(alloc, .reverse, "boo!"); - defer w.deinit(); - - var s = try Screen.init(alloc, .{ .cols = 80, .rows = 24, .max_scrollback = 1000 }); - defer s.deinit(); - - // Fill up the first page. The final bytes in the first page - // are "boo!" - const first_page_rows = s.pages.pages.first.?.data.capacity.rows; - for (0..first_page_rows - 1) |_| try s.testWriteString("\n"); - for (0..s.pages.cols - 4) |_| try s.testWriteString("x"); - try s.testWriteString("boo!"); - try testing.expect(s.pages.pages.first == s.pages.pages.last); - try s.testWriteString("\n"); - try testing.expect(s.pages.pages.first != s.pages.pages.last); - try s.testWriteString("hello. boo!"); - - // Add both pages in reverse order - const node: *PageList.List.Node = s.pages.pages.first.?; - _ = try w.append(node.next.?); - _ = try w.append(node); - - // Search should find two matches (in reverse order) - { - const h = w.next().?; - const sel = h.untracked(); - try testing.expectEqual(point.Point{ .active = .{ - .x = 7, - .y = 23, - } }, s.pages.pointFromPin(.active, sel.start).?); - try testing.expectEqual(point.Point{ .active = .{ - .x = 10, - .y = 23, - } }, s.pages.pointFromPin(.active, sel.end).?); - } - { - const h = w.next().?; - const sel = h.untracked(); - try testing.expectEqual(point.Point{ .active = .{ - .x = 76, - .y = 22, - } }, s.pages.pointFromPin(.active, sel.start).?); - try testing.expectEqual(point.Point{ .active = .{ - .x = 79, - .y = 22, - } }, s.pages.pointFromPin(.active, sel.end).?); - } - try testing.expect(w.next() == null); - try testing.expect(w.next() == null); -} - -test "SlidingWindow two pages match across boundary reversed" { - const testing = std.testing; - const alloc = testing.allocator; - - var w: SlidingWindow = try .init(alloc, .reverse, "hello, world"); - defer w.deinit(); - - var s = try Screen.init(alloc, .{ .cols = 80, .rows = 24, .max_scrollback = 1000 }); - defer s.deinit(); - - // Fill up the first page. The final bytes in the first page - // are "hell" - const first_page_rows = s.pages.pages.first.?.data.capacity.rows; - for (0..first_page_rows - 1) |_| try s.testWriteString("\n"); - for (0..s.pages.cols - 4) |_| try s.testWriteString("x"); - try s.testWriteString("hell"); - try testing.expect(s.pages.pages.first == s.pages.pages.last); - try s.testWriteString("o, world!"); - try testing.expect(s.pages.pages.first != s.pages.pages.last); - - // Add both pages in reverse order - const node: *PageList.List.Node = s.pages.pages.first.?; - _ = try w.append(node.next.?); - _ = try w.append(node); - - // Search should find a match - { - const h = w.next().?; - const sel = h.untracked(); - try testing.expectEqual(point.Point{ .active = .{ - .x = 76, - .y = 22, - } }, s.pages.pointFromPin(.active, sel.start).?); - try testing.expectEqual(point.Point{ .active = .{ - .x = 7, - .y = 23, - } }, s.pages.pointFromPin(.active, sel.end).?); - } - try testing.expect(w.next() == null); - try testing.expect(w.next() == null); - - // In reverse mode, the last appended meta (first original page) is large - // enough to contain needle.len - 1 bytes, so pruning occurs - try testing.expectEqual(1, w.meta.len()); -} - -test "SlidingWindow two pages no match prunes first page reversed" { - const testing = std.testing; - const alloc = testing.allocator; - - var w: SlidingWindow = try .init(alloc, .reverse, "nope!"); - defer w.deinit(); - - var s = try Screen.init(alloc, .{ .cols = 80, .rows = 24, .max_scrollback = 1000 }); - defer s.deinit(); - - // Fill up the first page. The final bytes in the first page - // are "boo!" - const first_page_rows = s.pages.pages.first.?.data.capacity.rows; - for (0..first_page_rows - 1) |_| try s.testWriteString("\n"); - for (0..s.pages.cols - 4) |_| try s.testWriteString("x"); - try s.testWriteString("boo!"); - try testing.expect(s.pages.pages.first == s.pages.pages.last); - try s.testWriteString("\n"); - try testing.expect(s.pages.pages.first != s.pages.pages.last); - try s.testWriteString("hello. boo!"); - - // Add both pages in reverse order - const node: *PageList.List.Node = s.pages.pages.first.?; - _ = try w.append(node.next.?); - _ = try w.append(node); - - // Search should find nothing - try testing.expect(w.next() == null); - try testing.expect(w.next() == null); - - // We should've pruned our page because the second page - // has enough text to contain our needle. - try testing.expectEqual(1, w.meta.len()); -} - -test "SlidingWindow two pages no match keeps both pages reversed" { - const testing = std.testing; - const alloc = testing.allocator; - - var s = try Screen.init(alloc, .{ .cols = 80, .rows = 24, .max_scrollback = 1000 }); - defer s.deinit(); - - // Fill up the first page. The final bytes in the first page - // are "boo!" - const first_page_rows = s.pages.pages.first.?.data.capacity.rows; - for (0..first_page_rows - 1) |_| try s.testWriteString("\n"); - for (0..s.pages.cols - 4) |_| try s.testWriteString("x"); - try s.testWriteString("boo!"); - try testing.expect(s.pages.pages.first == s.pages.pages.last); - try s.testWriteString("\n"); - try testing.expect(s.pages.pages.first != s.pages.pages.last); - try s.testWriteString("hello. boo!"); - - // Imaginary needle for search. Doesn't match! - var needle_list: std.ArrayList(u8) = .empty; - defer needle_list.deinit(alloc); - try needle_list.appendNTimes(alloc, 'x', first_page_rows * s.pages.cols); - const needle: []const u8 = needle_list.items; - - var w: SlidingWindow = try .init(alloc, .reverse, needle); - defer w.deinit(); - - // Add both pages in reverse order - const node: *PageList.List.Node = s.pages.pages.first.?; - _ = try w.append(node.next.?); - _ = try w.append(node); - - // Search should find nothing - try testing.expect(w.next() == null); - try testing.expect(w.next() == null); - - // No pruning because both pages are needed to fit needle. - try testing.expectEqual(2, w.meta.len()); -} - -test "SlidingWindow single append across circular buffer boundary reversed" { - const testing = std.testing; - const alloc = testing.allocator; - - var w: SlidingWindow = try .init(alloc, .reverse, "abc"); - defer w.deinit(); - - var s = try Screen.init(alloc, .{ .cols = 80, .rows = 24, .max_scrollback = 0 }); - defer s.deinit(); - try s.testWriteString("XXXXXXXXXXXXXXXXXXXboo!XXXXX"); - - // We are trying to break a circular buffer boundary so the way we - // do this is to duplicate the data then do a failing search. This - // will cause the first page to be pruned. The next time we append we'll - // put it in the middle of the circ buffer. We assert this so that if - // 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); - { - // No wrap around yet - const slices = w.data.getPtrSlice(0, w.data.len()); - try testing.expect(slices[0].len > 0); - try testing.expect(slices[1].len == 0); - } - - // Search non-match, prunes page - try testing.expect(w.next() == null); - try testing.expectEqual(1, w.meta.len()); - - // Change the needle, just needs to be the same length (not a real API) - // testChangeNeedle doesn't reverse, so pass reversed needle for reverse mode - w.testChangeNeedle("oob"); - - // Add new page, now wraps - _ = try w.append(node); - { - const slices = w.data.getPtrSlice(0, w.data.len()); - try testing.expect(slices[0].len > 0); - try testing.expect(slices[1].len > 0); - } - { - const h = w.next().?; - const sel = h.untracked(); - try testing.expectEqual(point.Point{ .active = .{ - .x = 19, - .y = 0, - } }, s.pages.pointFromPin(.active, sel.start).?); - try testing.expectEqual(point.Point{ .active = .{ - .x = 21, - .y = 0, - } }, s.pages.pointFromPin(.active, sel.end).?); - } - try testing.expect(w.next() == null); -} - -test "SlidingWindow single append match on boundary reversed" { - const testing = std.testing; - const alloc = testing.allocator; - - var w: SlidingWindow = try .init(alloc, .reverse, "abcd"); - defer w.deinit(); - - var s = try Screen.init(alloc, .{ .cols = 80, .rows = 24, .max_scrollback = 0 }); - defer s.deinit(); - try s.testWriteString("o!XXXXXXXXXXXXXXXXXXXbo"); - - // We need to surgically modify the last row to be soft-wrapped - try testing.expect(s.pages.pages.first == s.pages.pages.last); - const node: *PageList.List.Node = s.pages.pages.first.?; - node.data.getRow(node.data.size.rows - 1).wrap = true; - - // We are trying to break a circular buffer boundary so the way we - // do this is to duplicate the data then do a failing search. This - // will cause the first page to be pruned. The next time we append we'll - // put it in the middle of the circ buffer. We assert this so that if - // our implementation changes our test will fail. - _ = try w.append(node); - _ = try w.append(node); - { - // No wrap around yet - const slices = w.data.getPtrSlice(0, w.data.len()); - try testing.expect(slices[0].len > 0); - try testing.expect(slices[1].len == 0); - } - - // Search non-match, prunes page - try testing.expect(w.next() == null); - try testing.expectEqual(1, w.meta.len()); - - // Change the needle, just needs to be the same length (not a real API) - // testChangeNeedle doesn't reverse, so pass reversed needle for reverse mode - w.testChangeNeedle("!oob"); - - // Add new page, now wraps - _ = try w.append(node); - { - const slices = w.data.getPtrSlice(0, w.data.len()); - try testing.expect(slices[0].len > 0); - try testing.expect(slices[1].len > 0); - } - { - const h = w.next().?; - const sel = h.untracked(); - try testing.expectEqual(point.Point{ .active = .{ - .x = 21, - .y = 0, - } }, s.pages.pointFromPin(.active, sel.start).?); - try testing.expectEqual(point.Point{ .active = .{ - .x = 1, - .y = 0, - } }, s.pages.pointFromPin(.active, sel.end).?); - } - try testing.expect(w.next() == null); -} diff --git a/src/terminal/search/viewport.zig b/src/terminal/search/viewport.zig index 70fc3088f..9d9cb754b 100644 --- a/src/terminal/search/viewport.zig +++ b/src/terminal/search/viewport.zig @@ -4,6 +4,7 @@ const testing = std.testing; const Allocator = std.mem.Allocator; const point = @import("../point.zig"); const size = @import("../size.zig"); +const FlattenedHighlight = @import("../highlight.zig").Flattened; const PageList = @import("../PageList.zig"); const Selection = @import("../Selection.zig"); const SlidingWindow = @import("sliding_window.zig").SlidingWindow; @@ -150,7 +151,7 @@ pub const ViewportSearch = struct { /// 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 { + pub fn next(self: *ViewportSearch) ?FlattenedHighlight { return self.window.next(); } @@ -207,26 +208,28 @@ test "simple search" { try testing.expect(try search.update(&t.screens.active.pages)); { - const sel = search.next().?; + const h = search.next().?; + const sel = h.untracked(); try testing.expectEqual(point.Point{ .active = .{ .x = 0, .y = 0, - } }, t.screens.active.pages.pointFromPin(.active, sel.start()).?); + } }, 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()).?); + } }, t.screens.active.pages.pointFromPin(.active, sel.end).?); } { - const sel = search.next().?; + const h = search.next().?; + const sel = h.untracked(); try testing.expectEqual(point.Point{ .active = .{ .x = 0, .y = 2, - } }, t.screens.active.pages.pointFromPin(.active, sel.start()).?); + } }, 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()).?); + } }, t.screens.active.pages.pointFromPin(.active, sel.end).?); } try testing.expect(search.next() == null); } @@ -250,15 +253,16 @@ test "clear screen and search" { try testing.expect(try search.update(&t.screens.active.pages)); { - const sel = search.next().?; + const h = search.next().?; + const sel = h.untracked(); try testing.expectEqual(point.Point{ .active = .{ .x = 0, .y = 1, - } }, t.screens.active.pages.pointFromPin(.active, sel.start()).?); + } }, 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()).?); + } }, t.screens.active.pages.pointFromPin(.active, sel.end).?); } try testing.expect(search.next() == null); } @@ -289,15 +293,16 @@ test "history search, no active area" { try testing.expect(try search.update(&t.screens.active.pages)); { - const sel = search.next().?; + const h = search.next().?; + const sel = h.untracked(); try testing.expectEqual(point.Point{ .screen = .{ .x = 0, .y = 0, - } }, t.screens.active.pages.pointFromPin(.screen, sel.start()).?); + } }, 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()).?); + } }, t.screens.active.pages.pointFromPin(.screen, sel.end).?); } try testing.expect(search.next() == null); From e49f4a6dbcc410331f5d0783e2981cfc7c4fab94 Mon Sep 17 00:00:00 2001 From: Mitchell Hashimoto Date: Sat, 15 Nov 2025 20:02:35 -0800 Subject: [PATCH 05/14] `search` binding action starts a search thread on surface --- src/Surface.zig | 75 ++++++++++++++++++++++++++++++++++ src/input/Binding.zig | 5 +++ src/input/command.zig | 1 + src/terminal/search/Thread.zig | 3 ++ 4 files changed, 84 insertions(+) diff --git a/src/Surface.zig b/src/Surface.zig index 63af42680..6189aae8e 100644 --- a/src/Surface.zig +++ b/src/Surface.zig @@ -155,6 +155,9 @@ selection_scroll_active: bool = false, /// the wall clock time that has elapsed between timestamps. command_timer: ?std.time.Instant = null, +/// Search state +search: ?Search = null, + /// The effect of an input event. This can be used by callers to take /// the appropriate action after an input event. For example, key /// input can be forwarded to the OS for further processing if it @@ -174,6 +177,26 @@ pub const InputEffect = enum { closed, }; +/// The search state for the surface. +const Search = struct { + state: terminal.search.Thread, + thread: std.Thread, + + pub fn deinit(self: *Search) void { + // Notify the thread to stop + self.state.stop.notify() catch |err| log.err( + "error notifying search thread to stop, may stall err={}", + .{err}, + ); + + // Wait for the OS thread to quit + self.thread.join(); + + // Now it is safe to deinit the state + self.state.deinit(); + } +}; + /// Mouse state for the surface. const Mouse = struct { /// The last tracked mouse button state by button. @@ -728,6 +751,9 @@ pub fn init( } pub fn deinit(self: *Surface) void { + // Stop search thread + if (self.search) |*s| s.deinit(); + // Stop rendering thread { self.renderer_thread.stop.notify() catch |err| @@ -1301,6 +1327,12 @@ fn reportColorScheme(self: *Surface, force: bool) void { self.io.queueMessage(.{ .write_stable = output }, .unlocked); } +fn searchCallback(event: terminal.search.Thread.Event, ud: ?*anyopaque) void { + const self: *Surface = @ptrCast(@alignCast(ud.?)); + _ = self; + _ = event; +} + /// Call this when modifiers change. This is safe to call even if modifiers /// match the previous state. /// @@ -4770,6 +4802,49 @@ pub fn performBindingAction(self: *Surface, action: input.Binding.Action) !bool self.renderer_state.terminal.fullReset(); }, + .search => |text| search: { + const s: *Search = if (self.search) |*s| s else init: { + // If we're stopping the search and we had no prior search, + // then there is nothing to do. + if (text.len == 0) break :search; + + // We need to assign directly to self.search because we need + // a stable pointer back to the thread state. + self.search = .{ + .state = try .init(self.alloc, .{ + .mutex = self.renderer_state.mutex, + .terminal = self.renderer_state.terminal, + .event_cb = &searchCallback, + .event_userdata = self, + }), + .thread = undefined, + }; + const s: *Search = &self.search.?; + errdefer s.state.deinit(); + + s.thread = try .spawn( + .{}, + terminal.search.Thread.threadMain, + .{&s.state}, + ); + s.thread.setName("search") catch {}; + + break :init s; + }; + + // Zero-length text means stop searching. + if (text.len == 0) { + s.deinit(); + self.search = null; + break :search; + } + + _ = s.state.mailbox.push( + .{ .change_needle = text }, + .forever, + ); + }, + .copy_to_clipboard => |format| { // We can read from the renderer state without holding // the lock because only we will write to this field. diff --git a/src/input/Binding.zig b/src/input/Binding.zig index c9f3a7343..1b681e725 100644 --- a/src/input/Binding.zig +++ b/src/input/Binding.zig @@ -332,6 +332,10 @@ pub const Action = union(enum) { /// to 14.5 points. set_font_size: f32, + /// Start a search for the given text. If the text is empty, then + /// the search is canceled. If a previous search is active, it is replaced. + search: []const u8, + /// Clear the screen and all scrollback. clear_screen, @@ -1152,6 +1156,7 @@ pub const Action = union(enum) { .esc, .text, .cursor_key, + .search, .reset, .copy_to_clipboard, .copy_url_to_clipboard, diff --git a/src/input/command.zig b/src/input/command.zig index b6f75080d..11f65cea3 100644 --- a/src/input/command.zig +++ b/src/input/command.zig @@ -604,6 +604,7 @@ fn actionCommands(action: Action.Key) []const Command { .csi, .esc, .cursor_key, + .search, .set_font_size, .scroll_to_row, .scroll_page_fractional, diff --git a/src/terminal/search/Thread.zig b/src/terminal/search/Thread.zig index fdd5f81bc..a35d658b3 100644 --- a/src/terminal/search/Thread.zig +++ b/src/terminal/search/Thread.zig @@ -591,6 +591,7 @@ const Search = struct { // Check our total match data const total = screen_search.matchesLen(); if (total != self.last_total) { + log.debug("notifying total matches={}", .{total}); self.last_total = total; cb(.{ .total_matches = total }, ud); } @@ -626,11 +627,13 @@ const Search = struct { }; } + log.debug("notifying viewport matches len={}", .{results.items.len}); cb(.{ .viewport_matches = results.items }, 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); } From 72921741e8805415130fbe8cf664e2c16a20b5ec Mon Sep 17 00:00:00 2001 From: Mitchell Hashimoto Date: Sat, 15 Nov 2025 20:28:45 -0800 Subject: [PATCH 06/14] terminal: search.viewport supports dirty tracking for more efficient --- src/terminal/search/viewport.zig | 90 ++++++++++++++++++++++++++++---- 1 file changed, 79 insertions(+), 11 deletions(-) diff --git a/src/terminal/search/viewport.zig b/src/terminal/search/viewport.zig index 9d9cb754b..0f479b811 100644 --- a/src/terminal/search/viewport.zig +++ b/src/terminal/search/viewport.zig @@ -27,6 +27,12 @@ pub const ViewportSearch = struct { window: SlidingWindow, fingerprint: ?Fingerprint, + /// If this is null, then active dirty tracking is disabled and if the + /// viewport overlaps the active area we always re-search. If this is + /// non-null, then we only re-search if the active area is dirty. Dirty + /// marking is up to the caller. + active_dirty: ?bool, + pub fn init( alloc: Allocator, needle_unowned: []const u8, @@ -36,7 +42,11 @@ pub const ViewportSearch = struct { // 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 }; + return .{ + .window = window, + .fingerprint = null, + .active_dirty = null, + }; } pub fn deinit(self: *ViewportSearch) void { @@ -75,17 +85,29 @@ pub const ViewportSearch = struct { 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).?; + // Determine if we need to check if we overlap the active + // area. If we have dirty tracking on we also set it to + // false here. + const check_active: bool = active: { + const dirty = self.active_dirty orelse break :active true; + if (!dirty) break :active false; + self.active_dirty = false; + break :active true; + }; - // 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; + if (check_active) { + // 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 @@ -267,6 +289,52 @@ test "clear screen and search" { try testing.expect(search.next() == null); } +test "clear screen and search dirty tracking" { + 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(); + + // Turn on dirty tracking + search.active_dirty = false; + + // Should update since we've never searched before + try testing.expect(try search.update(&t.screens.active.pages)); + + // Should not update since nothing changed + 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"); + + // Should still not update since active area isn't dirty + try testing.expect(!try search.update(&t.screens.active.pages)); + + // Mark + search.active_dirty = true; + 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 }); From 061d157b503115eda4df9b1e3886de6fb8471f20 Mon Sep 17 00:00:00 2001 From: Mitchell Hashimoto Date: Sat, 15 Nov 2025 20:47:42 -0800 Subject: [PATCH 07/14] terminal: search should use active area dirty tracking --- src/terminal/Terminal.zig | 10 ++++++++++ src/terminal/search/Thread.zig | 13 +++++++++++++ src/terminal/search/viewport.zig | 4 ++++ 3 files changed, 27 insertions(+) diff --git a/src/terminal/Terminal.zig b/src/terminal/Terminal.zig index e75fd731a..68919107b 100644 --- a/src/terminal/Terminal.zig +++ b/src/terminal/Terminal.zig @@ -112,6 +112,16 @@ flags: packed struct { /// True if the terminal should perform selection scrolling. selection_scroll: bool = false, + /// Dirty flag used only by the search thread. The renderer is expected + /// to set this to true if the viewport was dirty as it was rendering. + /// This is used by the search thread to more efficiently re-search the + /// viewport and active area. + /// + /// Since the renderer is going to inspect the viewport/active area ANYWAYS, + /// this lets our search thread do less work and hold the lock less time, + /// resulting in more throughput for everything. + search_viewport_dirty: bool = false, + /// Dirty flags for the renderer. dirty: Dirty = .{}, } = .{}, diff --git a/src/terminal/search/Thread.zig b/src/terminal/search/Thread.zig index a35d658b3..7ca9df0b7 100644 --- a/src/terminal/search/Thread.zig +++ b/src/terminal/search/Thread.zig @@ -419,6 +419,10 @@ const Search = struct { 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(.{}), @@ -553,6 +557,15 @@ const Search = struct { } } + // 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) { + self.viewport.active_dirty = true; + t.flags.search_viewport_dirty = false; + } + // Check our viewport for changes. if (self.viewport.update(&t.screens.active.pages)) |updated| { if (updated) self.stale_viewport_matches = true; diff --git a/src/terminal/search/viewport.zig b/src/terminal/search/viewport.zig index 0f479b811..6a266f47a 100644 --- a/src/terminal/search/viewport.zig +++ b/src/terminal/search/viewport.zig @@ -125,6 +125,10 @@ pub const ViewportSearch = struct { self.fingerprint = null; } + // If our active area was set as dirty, we always unset it here + // because we're re-searching now. + if (self.active_dirty) |*v| v.* = false; + // Clear our previous sliding window self.window.clearAndRetainCapacity(); From 6c8ffb5fc1e741b427fa7acc854570e208af7c67 Mon Sep 17 00:00:00 2001 From: Mitchell Hashimoto Date: Sun, 16 Nov 2025 07:05:32 -0800 Subject: [PATCH 08/14] renderer: receive message with viewport match selections Doesn't draw yet --- src/Surface.zig | 39 +++++++++++++++++++++++++++++++++++++-- src/renderer/Thread.zig | 8 ++++++++ src/renderer/generic.zig | 14 +++++++++++++- src/renderer/message.zig | 15 +++++++++++++-- 4 files changed, 71 insertions(+), 5 deletions(-) diff --git a/src/Surface.zig b/src/Surface.zig index 6189aae8e..cfc0b14aa 100644 --- a/src/Surface.zig +++ b/src/Surface.zig @@ -1328,9 +1328,44 @@ fn reportColorScheme(self: *Surface, force: bool) void { } fn searchCallback(event: terminal.search.Thread.Event, ud: ?*anyopaque) void { + // IMPORTANT: This function is run on the SEARCH THREAD! It is NOT SAFE + // to access anything other than values that never change on the surface. + // The surface is guaranteed to be valid for the lifetime of the search + // thread. const self: *Surface = @ptrCast(@alignCast(ud.?)); - _ = self; - _ = event; + self.searchCallback_(event) catch |err| { + log.warn("error in search callback err={}", .{err}); + }; +} + +fn searchCallback_( + self: *Surface, + event: terminal.search.Thread.Event, +) !void { + switch (event) { + .viewport_matches => |matches_unowned| { + var arena: ArenaAllocator = .init(self.alloc); + errdefer arena.deinit(); + const alloc = arena.allocator(); + + const matches = try alloc.dupe(terminal.highlight.Flattened, matches_unowned); + for (matches) |*m| m.* = try m.clone(alloc); + + _ = self.renderer_thread.mailbox.push( + .{ .search_viewport_matches = .{ + .arena = arena, + .matches = matches, + } }, + .forever, + ); + try self.renderer_thread.wakeup.notify(); + }, + + // Unhandled, so far. + .total_matches, + .complete, + => {}, + } } /// Call this when modifiers change. This is safe to call even if modifiers diff --git a/src/renderer/Thread.zig b/src/renderer/Thread.zig index 004cfd5fa..738dce61c 100644 --- a/src/renderer/Thread.zig +++ b/src/renderer/Thread.zig @@ -451,6 +451,14 @@ fn drainMailbox(self: *Thread) !void { self.startDrawTimer(); }, + .search_viewport_matches => |v| { + // Note we don't free the new value because we expect our + // allocators to match. + if (self.renderer.search_matches) |*m| m.arena.deinit(); + self.renderer.search_matches = v; + self.renderer.search_matches_dirty = true; + }, + .inspector => |v| self.flags.has_inspector = v, .macos_display_id => |v| { diff --git a/src/renderer/generic.zig b/src/renderer/generic.zig index 025578c81..1a816e751 100644 --- a/src/renderer/generic.zig +++ b/src/renderer/generic.zig @@ -122,6 +122,16 @@ pub fn Renderer(comptime GraphicsAPI: type) type { scrollbar: terminal.Scrollbar, scrollbar_dirty: bool, + /// The most recent viewport matches so that we can render search + /// matches in the visible frame. This is provided asynchronously + /// from the search thread so we have the dirty flag to also note + /// if we need to rebuild our cells to include search highlights. + /// + /// Note that the selections MAY BE INVALID (point to PageList nodes + /// that do not exist anymore). These must be validated prior to use. + search_matches: ?renderer.Message.SearchMatches, + search_matches_dirty: bool, + /// The current set of cells to render. This is rebuilt on every frame /// but we keep this around so that we don't reallocate. Each set of /// cells goes into a separate shader. @@ -672,6 +682,8 @@ pub fn Renderer(comptime GraphicsAPI: type) type { .focused = true, .scrollbar = .zero, .scrollbar_dirty = false, + .search_matches = null, + .search_matches_dirty = false, // Render state .cells = .{}, @@ -744,7 +756,7 @@ pub fn Renderer(comptime GraphicsAPI: type) type { pub fn deinit(self: *Self) void { self.terminal_state.deinit(self.alloc); - + if (self.search_matches) |*m| m.arena.deinit(); self.swap_chain.deinit(); if (DisplayLink != void) { diff --git a/src/renderer/message.zig b/src/renderer/message.zig index b36a99d5c..8a319166b 100644 --- a/src/renderer/message.zig +++ b/src/renderer/message.zig @@ -1,6 +1,7 @@ const std = @import("std"); const assert = @import("../quirks.zig").inlineAssert; const Allocator = std.mem.Allocator; +const ArenaAllocator = std.heap.ArenaAllocator; const configpkg = @import("../config.zig"); const font = @import("../font/main.zig"); const renderer = @import("../renderer.zig"); @@ -10,7 +11,7 @@ const terminal = @import("../terminal/main.zig"); pub const Message = union(enum) { /// Purposely crash the renderer. This is used for testing and debugging. /// See the "crash" binding action. - crash: void, + crash, /// A change in state in the window focus that this renderer is /// rendering within. This is only sent when a change is detected so @@ -24,7 +25,7 @@ pub const Message = union(enum) { /// Reset the cursor blink by immediately showing the cursor then /// restarting the timer. - reset_cursor_blink: void, + reset_cursor_blink, /// Change the font grid. This can happen for any number of reasons /// including a font size change, family change, etc. @@ -52,12 +53,22 @@ pub const Message = union(enum) { impl: *renderer.Renderer.DerivedConfig, }, + /// Matches for the current viewport from the search thread. These happen + /// async so they may be off for a frame or two from the actually rendered + /// viewport. The renderer must handle this gracefully. + search_viewport_matches: SearchMatches, + /// Activate or deactivate the inspector. inspector: bool, /// The macOS display ID has changed for the window. macos_display_id: u32, + pub const SearchMatches = struct { + arena: ArenaAllocator, + matches: []const terminal.highlight.Flattened, + }; + /// Initialize a change_config message. pub fn initChangeConfig(alloc: Allocator, config: *const configpkg.Config) !Message { const thread_ptr = try alloc.create(renderer.Thread.DerivedConfig); From dd9ed531ad16a1fe9d06e088c1f26dbe922ed321 Mon Sep 17 00:00:00 2001 From: Mitchell Hashimoto Date: Mon, 24 Nov 2025 12:26:59 -0800 Subject: [PATCH 09/14] render viewport matches --- src/renderer/generic.zig | 30 ++++++++++++++++++- src/terminal/render.zig | 63 ++++++++++++++++++++++++++++++++++++++++ 2 files changed, 92 insertions(+), 1 deletion(-) diff --git a/src/renderer/generic.zig b/src/renderer/generic.zig index 1a816e751..691831e8a 100644 --- a/src/renderer/generic.zig +++ b/src/renderer/generic.zig @@ -1191,6 +1191,23 @@ pub fn Renderer(comptime GraphicsAPI: type) type { log.warn("error searching for regex links err={}", .{err}); }; + // Clear our highlight state and update. + if (self.search_matches_dirty or self.terminal_state.dirty != .false) { + for (self.terminal_state.row_data.items(.highlights)) |*highlights| { + highlights.clearRetainingCapacity(); + } + + if (self.search_matches) |m| { + self.terminal_state.updateHighlightsFlattened( + self.alloc, + m.matches, + ) catch |err| { + // Not a critical error, we just won't show highlights. + log.warn("error updating search highlights err={}", .{err}); + }; + } + } + // Build our GPU cells try self.rebuildCells( critical.preedit, @@ -2366,6 +2383,7 @@ pub fn Renderer(comptime GraphicsAPI: type) type { const row_cells = row_data.items(.cells); const row_dirty = row_data.items(.dirty); const row_selection = row_data.items(.selection); + const row_highlights = row_data.items(.highlights); // If our cell contents buffer is shorter than the screen viewport, // we render the rows that fit, starting from the bottom. If instead @@ -2381,7 +2399,8 @@ pub fn Renderer(comptime GraphicsAPI: type) type { row_cells[0..row_len], row_dirty[0..row_len], row_selection[0..row_len], - ) |y_usize, row, *cells, *dirty, selection| { + row_highlights[0..row_len], + ) |y_usize, row, *cells, *dirty, selection, highlights| { const y: terminal.size.CellCountInt = @intCast(y_usize); if (!rebuild) { @@ -2526,6 +2545,15 @@ pub fn Renderer(comptime GraphicsAPI: type) type { // True if this cell is selected const selected: bool = selected: { + // If we're highlighted, then we're selected. In the + // future we want to use a different style for this + // but this to get started. + for (highlights.items) |hl| { + if (x >= hl[0] and x <= hl[1]) { + break :selected true; + } + } + const sel = selection orelse break :selected false; const x_compare = if (wide == .spacer_tail) x -| 1 diff --git a/src/terminal/render.zig b/src/terminal/render.zig index 86b299d72..49fc5af71 100644 --- a/src/terminal/render.zig +++ b/src/terminal/render.zig @@ -5,6 +5,7 @@ const ArenaAllocator = std.heap.ArenaAllocator; const fastmem = @import("../fastmem.zig"); const color = @import("color.zig"); const cursor = @import("cursor.zig"); +const highlight = @import("highlight.zig"); const point = @import("point.zig"); const size = @import("size.zig"); const page = @import("page.zig"); @@ -191,6 +192,10 @@ pub const RenderState = struct { /// The x range of the selection within this row. selection: ?[2]size.CellCountInt, + + /// The x ranges of highlights within this row. Highlights are + /// applied after the update by calling `updateHighlights`. + highlights: std.ArrayList([2]size.CellCountInt), }; pub const Cell = struct { @@ -348,6 +353,7 @@ pub const RenderState = struct { .cells = .empty, .dirty = true, .selection = null, + .highlights = .empty, }); } } else { @@ -630,6 +636,63 @@ pub const RenderState = struct { s.dirty = .{}; } + /// Update the highlights in the render state from the given flattened + /// highlights. Because this uses flattened highlights, it does not require + /// reading from the terminal state so it should be done outside of + /// any critical sections. + /// + /// This will not clear any previous highlights, so the caller must + /// manually clear them if desired. + pub fn updateHighlightsFlattened( + self: *RenderState, + alloc: Allocator, + hls: []const highlight.Flattened, + ) Allocator.Error!void { + // Fast path, we have no highlights! + if (hls.len == 0) return; + + // This is, admittedly, horrendous. This is some low hanging fruit + // to optimize. In my defense, screens are usually small, the number + // of highlights is usually small, and this only happens on the + // viewport outside of a locked area. Still, I'd love to see this + // improved someday. + const row_data = self.row_data.slice(); + const row_arenas = row_data.items(.arena); + const row_pins = row_data.items(.pin); + const row_highlights_slice = row_data.items(.highlights); + for ( + row_arenas, + row_pins, + row_highlights_slice, + ) |*row_arena, row_pin, *row_highlights| { + for (hls) |hl| { + const chunks_slice = hl.chunks.slice(); + const nodes = chunks_slice.items(.node); + const starts = chunks_slice.items(.start); + const ends = chunks_slice.items(.end); + for (0.., nodes) |i, node| { + // If this node doesn't match or we're not within + // the row range, skip it. + if (node != row_pin.node or + row_pin.y < starts[i] or + row_pin.y >= ends[i]) continue; + + // We're a match! + var arena = row_arena.promote(alloc); + defer row_arena.* = arena.state; + const arena_alloc = arena.allocator(); + try row_highlights.append( + arena_alloc, + .{ + if (i == 0) hl.top_x else 0, + if (i == nodes.len - 1) hl.bot_x else self.cols - 1, + }, + ); + } + } + } + } + pub const StringMap = std.ArrayListUnmanaged(point.Coordinate); /// Convert the current render state contents to a UTF-8 encoded From d0e3a79a74ac088be0a2db658abd8d634f043cb0 Mon Sep 17 00:00:00 2001 From: Mitchell Hashimoto Date: Mon, 24 Nov 2025 12:35:57 -0800 Subject: [PATCH 10/14] reset search on needle change or quit --- src/Surface.zig | 14 ++++++++++++++ src/terminal/search/Thread.zig | 25 ++++++++++++++++++++++++- src/terminal/search/viewport.zig | 7 ++++--- 3 files changed, 42 insertions(+), 4 deletions(-) diff --git a/src/Surface.zig b/src/Surface.zig index cfc0b14aa..989495309 100644 --- a/src/Surface.zig +++ b/src/Surface.zig @@ -1342,6 +1342,8 @@ fn searchCallback_( self: *Surface, event: terminal.search.Thread.Event, ) !void { + // NOTE: This runs on the search thread. + switch (event) { .viewport_matches => |matches_unowned| { var arena: ArenaAllocator = .init(self.alloc); @@ -1361,6 +1363,18 @@ fn searchCallback_( try self.renderer_thread.wakeup.notify(); }, + // When we quit, tell our renderer to reset any search state. + .quit => { + _ = self.renderer_thread.mailbox.push( + .{ .search_viewport_matches = .{ + .arena = .init(self.alloc), + .matches = &.{}, + } }, + .forever, + ); + try self.renderer_thread.wakeup.notify(); + }, + // Unhandled, so far. .total_matches, .complete, diff --git a/src/terminal/search/Thread.zig b/src/terminal/search/Thread.zig index 7ca9df0b7..2c5607809 100644 --- a/src/terminal/search/Thread.zig +++ b/src/terminal/search/Thread.zig @@ -163,7 +163,14 @@ fn threadMain_(self: *Thread) !void { // Run log.debug("starting search thread", .{}); - defer log.debug("starting search thread shutdown", .{}); + defer { + log.debug("starting search thread shutdown", .{}); + + // Send the quit message + if (self.opts.event_cb) |cb| { + cb(.quit, self.opts.event_userdata); + } + } // Unlike some of our other threads, we interleave search work // with our xev loop so that we can try to make forward search progress @@ -247,6 +254,18 @@ fn changeNeedle(self: *Thread, needle: []const u8) !void { if (self.search) |*s| { s.deinit(); self.search = null; + + // When the search changes then we need to emit that it stopped. + if (self.opts.event_cb) |cb| { + cb( + .{ .total_matches = 0 }, + self.opts.event_userdata, + ); + cb( + .{ .viewport_matches = &.{} }, + self.opts.event_userdata, + ); + } } // No needle means stop the search. @@ -381,6 +400,9 @@ pub const Message = union(enum) { /// 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 quitting. The search thread is exiting. + quit, + /// Search is complete for the given needle on all screens. complete, @@ -668,6 +690,7 @@ test { fn callback(event: Event, userdata: ?*anyopaque) void { const ud: *Self = @ptrCast(@alignCast(userdata.?)); switch (event) { + .quit => {}, .complete => ud.reset.set(), .total_matches => |v| ud.total = v, .viewport_matches => |v| { diff --git a/src/terminal/search/viewport.zig b/src/terminal/search/viewport.zig index 6a266f47a..55eedb724 100644 --- a/src/terminal/search/viewport.zig +++ b/src/terminal/search/viewport.zig @@ -326,15 +326,16 @@ test "clear screen and search dirty tracking" { try testing.expect(try search.update(&t.screens.active.pages)); { - const sel = search.next().?; + const h = search.next().?; + const sel = h.untracked(); try testing.expectEqual(point.Point{ .active = .{ .x = 0, .y = 1, - } }, t.screens.active.pages.pointFromPin(.active, sel.start()).?); + } }, 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()).?); + } }, t.screens.active.pages.pointFromPin(.active, sel.end).?); } try testing.expect(search.next() == null); } From 06981175afec87c23c6aec569ccb0f2b9770343c Mon Sep 17 00:00:00 2001 From: Mitchell Hashimoto Date: Mon, 24 Nov 2025 13:36:10 -0800 Subject: [PATCH 11/14] renderer: reset search dirty state after processing --- src/renderer/generic.zig | 2 ++ 1 file changed, 2 insertions(+) diff --git a/src/renderer/generic.zig b/src/renderer/generic.zig index 691831e8a..b1a0151a4 100644 --- a/src/renderer/generic.zig +++ b/src/renderer/generic.zig @@ -1193,6 +1193,8 @@ pub fn Renderer(comptime GraphicsAPI: type) type { // Clear our highlight state and update. if (self.search_matches_dirty or self.terminal_state.dirty != .false) { + self.search_matches_dirty = false; + for (self.terminal_state.row_data.items(.highlights)) |*highlights| { highlights.clearRetainingCapacity(); } From a4e40c75671400e11eaaeecb83a0c01bbeb818ed Mon Sep 17 00:00:00 2001 From: Mitchell Hashimoto Date: Mon, 24 Nov 2025 13:59:50 -0800 Subject: [PATCH 12/14] set proper dirty state to redo viewport search --- src/renderer/generic.zig | 6 ++++++ src/terminal/render.zig | 15 ++++++++++++++- 2 files changed, 20 insertions(+), 1 deletion(-) diff --git a/src/renderer/generic.zig b/src/renderer/generic.zig index b1a0151a4..fb82efd8d 100644 --- a/src/renderer/generic.zig +++ b/src/renderer/generic.zig @@ -1126,6 +1126,12 @@ pub fn Renderer(comptime GraphicsAPI: type) type { // Update our terminal state try self.terminal_state.update(self.alloc, state.terminal); + // If our terminal state is dirty at all we need to redo + // the viewport search. + if (self.terminal_state.dirty != .false) { + state.terminal.flags.search_viewport_dirty = true; + } + // Get our scrollbar out of the terminal. We synchronize // the scrollbar read with frame data updates because this // naturally limits the number of calls to this method (it diff --git a/src/terminal/render.zig b/src/terminal/render.zig index 49fc5af71..8f4da12eb 100644 --- a/src/terminal/render.zig +++ b/src/terminal/render.zig @@ -656,15 +656,22 @@ pub const RenderState = struct { // of highlights is usually small, and this only happens on the // viewport outside of a locked area. Still, I'd love to see this // improved someday. + + // We need to track whether any row had a match so we can mark + // the dirty state. + var any_dirty: bool = false; + const row_data = self.row_data.slice(); const row_arenas = row_data.items(.arena); + const row_dirties = row_data.items(.dirty); const row_pins = row_data.items(.pin); const row_highlights_slice = row_data.items(.highlights); for ( row_arenas, row_pins, row_highlights_slice, - ) |*row_arena, row_pin, *row_highlights| { + row_dirties, + ) |*row_arena, row_pin, *row_highlights, *dirty| { for (hls) |hl| { const chunks_slice = hl.chunks.slice(); const nodes = chunks_slice.items(.node); @@ -688,9 +695,15 @@ pub const RenderState = struct { if (i == nodes.len - 1) hl.bot_x else self.cols - 1, }, ); + + dirty.* = true; + any_dirty = true; } } } + + // Mark our dirty state. + if (any_dirty and self.dirty == .false) self.dirty = .partial; } pub const StringMap = std.ArrayListUnmanaged(point.Coordinate); From de16e4a92b2bffb4c4cb277742552fe6b5044c75 Mon Sep 17 00:00:00 2001 From: Mitchell Hashimoto Date: Mon, 24 Nov 2025 20:02:06 -0800 Subject: [PATCH 13/14] config: add selection-foreground/background --- src/config/Config.zig | 14 +++++++ src/renderer/generic.zig | 88 +++++++++++++++++++++++----------------- 2 files changed, 64 insertions(+), 38 deletions(-) diff --git a/src/config/Config.zig b/src/config/Config.zig index 6355b6c26..89254b93f 100644 --- a/src/config/Config.zig +++ b/src/config/Config.zig @@ -978,6 +978,20 @@ palette: Palette = .{}, /// Available since: 1.1.0 @"split-divider-color": ?Color = null, +/// The foreground and background color for search matches. This only applies +/// to non-focused search matches, also known as candidate matches. +/// +/// Valid values: +/// +/// - Hex (`#RRGGBB` or `RRGGBB`) +/// - Named X11 color +/// - "cell-foreground" to match the cell foreground color +/// - "cell-background" to match the cell background color +/// +/// The default value is +@"search-foreground": TerminalColor = .{ .color = .{ .r = 0, .g = 0, .b = 0 } }, +@"search-background": TerminalColor = .{ .color = .{ .r = 0xFF, .g = 0xE0, .b = 0x82 } }, + /// The command to run, usually a shell. If this is not an absolute path, it'll /// be looked up in the `PATH`. If this is not set, a default will be looked up /// from your system. The rules for the default lookup are: diff --git a/src/renderer/generic.zig b/src/renderer/generic.zig index fb82efd8d..7701a5418 100644 --- a/src/renderer/generic.zig +++ b/src/renderer/generic.zig @@ -537,6 +537,8 @@ pub fn Renderer(comptime GraphicsAPI: type) type { foreground: terminal.color.RGB, selection_background: ?configpkg.Config.TerminalColor, selection_foreground: ?configpkg.Config.TerminalColor, + search_background: configpkg.Config.TerminalColor, + search_foreground: configpkg.Config.TerminalColor, bold_color: ?configpkg.BoldColor, faint_opacity: u8, min_contrast: f32, @@ -608,6 +610,8 @@ pub fn Renderer(comptime GraphicsAPI: type) type { .selection_background = config.@"selection-background", .selection_foreground = config.@"selection-foreground", + .search_background = config.@"search-background", + .search_foreground = config.@"search-foreground", .custom_shaders = custom_shaders, .bg_image = bg_image, @@ -2552,24 +2556,30 @@ pub fn Renderer(comptime GraphicsAPI: type) type { .{}; // True if this cell is selected - const selected: bool = selected: { + const selected: enum { + false, + selection, + search, + } = selected: { // If we're highlighted, then we're selected. In the // future we want to use a different style for this // but this to get started. for (highlights.items) |hl| { if (x >= hl[0] and x <= hl[1]) { - break :selected true; + break :selected .search; } } - const sel = selection orelse break :selected false; + const sel = selection orelse break :selected .false; const x_compare = if (wide == .spacer_tail) x -| 1 else x; - break :selected x_compare >= sel[0] and - x_compare <= sel[1]; + if (x_compare >= sel[0] and + x_compare <= sel[1]) break :selected .selection; + + break :selected .false; }; // The `_style` suffixed values are the colors based on @@ -2586,25 +2596,26 @@ pub fn Renderer(comptime GraphicsAPI: type) type { }); // The final background color for the cell. - const bg = bg: { - if (selected) { - // If we have an explicit selection background color - // specified int he config, use that - if (self.config.selection_background) |v| { - break :bg switch (v) { - .color => |color| color.toTerminalRGB(), - .@"cell-foreground" => if (style.flags.inverse) bg_style else fg_style, - .@"cell-background" => if (style.flags.inverse) fg_style else bg_style, - }; - } + const bg = switch (selected) { + // If we have an explicit selection background color + // specified in the config, use that. + // + // If no configuration, then our selection background + // is our foreground color. + .selection => if (self.config.selection_background) |v| switch (v) { + .color => |color| color.toTerminalRGB(), + .@"cell-foreground" => if (style.flags.inverse) bg_style else fg_style, + .@"cell-background" => if (style.flags.inverse) fg_style else bg_style, + } else state.colors.foreground, - // If no configuration, then our selection background - // is our foreground color. - break :bg state.colors.foreground; - } + .search => switch (self.config.search_background) { + .color => |color| color.toTerminalRGB(), + .@"cell-foreground" => if (style.flags.inverse) bg_style else fg_style, + .@"cell-background" => if (style.flags.inverse) fg_style else bg_style, + }, // Not selected - break :bg if (style.flags.inverse != isCovering(cell.codepoint())) + .false => if (style.flags.inverse != isCovering(cell.codepoint())) // Two cases cause us to invert (use the fg color as the bg) // - The "inverse" style flag. // - A "covering" glyph; we use fg for bg in that @@ -2616,7 +2627,7 @@ pub fn Renderer(comptime GraphicsAPI: type) type { fg_style else // Otherwise they cancel out. - bg_style; + bg_style, }; const fg = fg: { @@ -2628,23 +2639,24 @@ pub fn Renderer(comptime GraphicsAPI: type) type { // - Cell is selected, inverted, and set to cell-foreground // - Cell is selected, not inverted, and set to cell-background // - Cell is inverted and not selected - if (selected) { - // Use the selection foreground if set - if (self.config.selection_foreground) |v| { - break :fg switch (v) { - .color => |color| color.toTerminalRGB(), - .@"cell-foreground" => if (style.flags.inverse) final_bg else fg_style, - .@"cell-background" => if (style.flags.inverse) fg_style else final_bg, - }; - } + break :fg switch (selected) { + .selection => if (self.config.selection_foreground) |v| switch (v) { + .color => |color| color.toTerminalRGB(), + .@"cell-foreground" => if (style.flags.inverse) final_bg else fg_style, + .@"cell-background" => if (style.flags.inverse) fg_style else final_bg, + } else state.colors.background, - break :fg state.colors.background; - } + .search => switch (self.config.search_foreground) { + .color => |color| color.toTerminalRGB(), + .@"cell-foreground" => if (style.flags.inverse) final_bg else fg_style, + .@"cell-background" => if (style.flags.inverse) fg_style else final_bg, + }, - break :fg if (style.flags.inverse) - final_bg - else - fg_style; + .false => if (style.flags.inverse) + final_bg + else + fg_style, + }; }; // Foreground alpha for this cell. @@ -2662,7 +2674,7 @@ pub fn Renderer(comptime GraphicsAPI: type) type { const default: u8 = 255; // Cells that are selected should be fully opaque. - if (selected) break :bg_alpha default; + if (selected != .false) break :bg_alpha default; // Cells that are reversed should be fully opaque. if (style.flags.inverse) break :bg_alpha default; From bb21c3d6b3efc8543f1f6ca155e1c8506544f4c6 Mon Sep 17 00:00:00 2001 From: Mitchell Hashimoto Date: Mon, 24 Nov 2025 20:25:25 -0800 Subject: [PATCH 14/14] search: case-insesitive (ascii) search --- src/terminal/search/sliding_window.zig | 51 ++++++++++++++++++++++++-- 1 file changed, 47 insertions(+), 4 deletions(-) diff --git a/src/terminal/search/sliding_window.zig b/src/terminal/search/sliding_window.zig index c1428e35c..ff0fa0277 100644 --- a/src/terminal/search/sliding_window.zig +++ b/src/terminal/search/sliding_window.zig @@ -174,7 +174,7 @@ pub const SlidingWindow = struct { }; // Search the first slice for the needle. - if (std.mem.indexOf(u8, slices[0], self.needle)) |idx| { + if (std.ascii.indexOfIgnoreCase(slices[0], self.needle)) |idx| { return self.highlight( idx, self.needle.len, @@ -200,8 +200,7 @@ pub const SlidingWindow = struct { @memcpy(self.overlap_buf[prefix.len..overlap_len], suffix); // Search the overlap - const idx = std.mem.indexOf( - u8, + const idx = std.ascii.indexOfIgnoreCase( self.overlap_buf[0..overlap_len], self.needle, ) orelse break :overlap; @@ -215,7 +214,7 @@ pub const SlidingWindow = struct { } // Search the last slice for the needle. - if (std.mem.indexOf(u8, slices[1], self.needle)) |idx| { + if (std.ascii.indexOfIgnoreCase(slices[1], self.needle)) |idx| { return self.highlight( slices[0].len + idx, self.needle.len, @@ -660,6 +659,50 @@ test "SlidingWindow single append" { try testing.expect(w.next() == null); } +test "SlidingWindow single append case insensitive ASCII" { + const testing = std.testing; + const alloc = testing.allocator; + + var w: SlidingWindow = try .init(alloc, .forward, "Boo!"); + defer w.deinit(); + + var s = try Screen.init(alloc, .{ .cols = 80, .rows = 24, .max_scrollback = 0 }); + defer s.deinit(); + try s.testWriteString("hello. boo! hello. boo!"); + + // 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); + + // We should be able to find two matches. + { + const h = w.next().?; + const sel = h.untracked(); + try testing.expectEqual(point.Point{ .active = .{ + .x = 7, + .y = 0, + } }, s.pages.pointFromPin(.active, sel.start)); + try testing.expectEqual(point.Point{ .active = .{ + .x = 10, + .y = 0, + } }, s.pages.pointFromPin(.active, sel.end)); + } + { + const h = w.next().?; + const sel = h.untracked(); + try testing.expectEqual(point.Point{ .active = .{ + .x = 19, + .y = 0, + } }, s.pages.pointFromPin(.active, sel.start)); + try testing.expectEqual(point.Point{ .active = .{ + .x = 22, + .y = 0, + } }, s.pages.pointFromPin(.active, sel.end)); + } + try testing.expect(w.next() == null); + try testing.expect(w.next() == null); +} test "SlidingWindow single append no match" { const testing = std.testing; const alloc = testing.allocator;