const std = @import("std"); const assert = @import("../../quirks.zig").inlineAssert; 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 SlidingWindow = @import("sliding_window.zig").SlidingWindow; const Terminal = @import("../Terminal.zig"); /// Searches for a substring within the viewport of a PageList. /// /// This contains logic to efficiently detect when the viewport changes /// and only re-search when necessary. /// /// The specialization for "viewport" is because the viewport is the /// only part of the search where the user can actively see the results, /// usually. In that case, it is more efficient to re-search only the /// viewport rather than store all the results for the entire screen. /// /// Note that this searches all the pages that viewport covers, so /// this can include extra matches outside the viewport if the data /// lives in the same page. pub const ViewportSearch = struct { window: SlidingWindow, fingerprint: Fingerprint = .{}, /// 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, ) Allocator.Error!ViewportSearch { // We just do a forward search since the viewport is usually // pretty small so search results are instant anyways. This avoids // a small amount of work to reverse things. var window: SlidingWindow = try .init(alloc, .forward, needle_unowned); errdefer window.deinit(); return .{ .window = window, .active_dirty = null, }; } pub fn deinit(self: *ViewportSearch) void { self.fingerprint.deinit(self.window.alloc); self.window.deinit(); } /// Reset our fingerprint and results so that the next update will /// always re-search. pub fn reset(self: *ViewportSearch) void { self.fingerprint.clearAndRetainCapacity(); self.window.clearAndRetainCapacity(); } /// The needle that this search is using. pub fn needle(self: *const ViewportSearch) []const u8 { assert(self.window.direction == .forward); return self.window.needle; } /// Update the sliding window to reflect the current viewport. This /// will do nothing if the viewport hasn't changed since the last /// search. /// /// The PageList must be safe to read throughout the lifetime of this /// function. /// /// Returns true if the viewport changed and a re-search is needed. /// Returns false if the viewport is unchanged. pub fn update( self: *ViewportSearch, list: *PageList, ) Allocator.Error!bool { // See if our viewport changed. We retain the fingerprint buffer so // unchanged viewports don't allocate and changed viewports usually // reuse the previous capacity. const fingerprint = &self.fingerprint; if (!try fingerprint.update(self.window.alloc, list)) match: { // 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 (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 (fingerprint.entries.items) |entry| { if (entry.node == active_tl.node) break :match; if (entry.node == active_br.node) break :match; } } // No change return false; } // 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(); // Add enough overlap to cover needle.len - 1 bytes (if it // exists) so we can cover the overlap. We only do this for the // soft-wrapped prior pages. const overlap_len = self.window.needle.len -| 1; const entries = fingerprint.entries.items; var node_ = entries[0].node.prev; var added: usize = 0; while (node_) |node| : (node_ = node.prev) { // We could be more accurate here and count bytes since the // last wrap but its complicated and unlikely multiple pages // wrap so this should be fine. const appended = try self.window.appendIfWrapped(node) orelse break; added += appended.content_len; if (added >= overlap_len) break; } // We can use our fingerprint nodes to initialize our sliding // window, since we already traversed the viewport once. var end_append: SlidingWindow.AppendResult = undefined; for (entries) |entry| { end_append = try self.window.append(entry.node); } // Add any trailing overlap as well. trailing: { const end: *PageList.List.Node = entries[entries.len - 1].node; if (!end_append.last_row_wrapped) break :trailing; node_ = end.next; added = 0; while (node_) |node| : (node_ = node.next) { const appended = try self.window.append(node); added += appended.content_len; if (added >= overlap_len) break; // If this row doesn't wrap, then we can quit if (!appended.last_row_wrapped) break; } } return true; } /// Find the next match for the needle in the active area. This returns /// null when there are no more matches. pub fn next(self: *ViewportSearch) ?FlattenedHighlight { return self.window.next(); } /// Viewport fingerprint so we can detect when the viewport moves. const Fingerprint = struct { /// The node addresses and generations that make up the viewport. We /// can't safely dereference cached node pointers because the nodes may /// be invalid, so equality only compares these captured values. entries: std.ArrayListUnmanaged(Entry) = .empty, const Entry = struct { node: *PageList.List.Node, serial: u64, }; pub fn deinit(self: *Fingerprint, alloc: Allocator) void { self.entries.deinit(alloc); } pub fn clearAndRetainCapacity(self: *Fingerprint) void { self.entries.clearRetainingCapacity(); } /// Update this fingerprint to match the current viewport. /// /// Returns true if the viewport changed. The viewport can have at /// most one entry per visible row, so we reserve that upper bound /// before mutating the retained buffer. This lets us rebuild in one /// pass while still preserving the old fingerprint if allocation /// fails. pub fn update(self: *Fingerprint, alloc: Allocator, pages: *PageList) Allocator.Error!bool { try self.entries.ensureTotalCapacity(alloc, pages.rows); const old_len = self.entries.items.len; var changed = false; var i: usize = 0; var it = pages.pageIterator(.right_down, .{ .viewport = .{} }, null); while (it.next()) |chunk| : (i += 1) { const entry: Entry = .{ .node = chunk.node, .serial = chunk.node.serial, }; if (!changed) { changed = i >= old_len or self.entries.items[i].node != entry.node or self.entries.items[i].serial != entry.serial; } if (i < self.entries.items.len) { self.entries.items[i] = entry; } else { self.entries.appendAssumeCapacity(entry); } } self.entries.shrinkRetainingCapacity(i); return changed or i != old_len; } }; }; test "simple search" { const alloc = testing.allocator; const io = testing.io; var t: Terminal = try .init(io, alloc, .{ .cols = 10, .rows = 10 }); defer t.deinit(alloc); var s = t.vtStream(); defer s.deinit(); s.nextSlice("Fizz\r\nBuzz\r\nFizz\r\nBang"); var search: ViewportSearch = try .init(alloc, "Fizz"); defer search.deinit(); try testing.expect(try search.update(&t.screens.active.pages)); // Viewport contains active so update should always re-search. try testing.expect(try search.update(&t.screens.active.pages)); { const 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).?); try testing.expectEqual(point.Point{ .active = .{ .x = 3, .y = 0, } }, t.screens.active.pages.pointFromPin(.active, sel.end).?); } { 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).?); try testing.expectEqual(point.Point{ .active = .{ .x = 3, .y = 2, } }, t.screens.active.pages.pointFromPin(.active, sel.end).?); } try testing.expect(search.next() == null); } test "fingerprint detects node generation changes" { const alloc = testing.allocator; const io = testing.io; var t: Terminal = try .init(io, alloc, .{ .cols = 10, .rows = 10 }); defer t.deinit(alloc); var search: ViewportSearch = try .init(alloc, "needle"); defer search.deinit(); search.active_dirty = false; try testing.expect(try search.update(&t.screens.active.pages)); try testing.expect(!try search.update(&t.screens.active.pages)); const old_entry = search.fingerprint.entries.items[0]; const node = t.screens.active.pages.pages.first.?; try testing.expectEqual(node, old_entry.node); const original_serial = node.serial; defer node.serial = original_serial; node.serial += 1; // The viewport still contains the same pointer, but its generation has // changed, so the cached window must be rebuilt. try testing.expect(try search.update(&t.screens.active.pages)); const new_entry = search.fingerprint.entries.items[0]; try testing.expectEqual(old_entry.node, new_entry.node); try testing.expect(old_entry.serial != new_entry.serial); } test "fingerprint only allocates on changes" { const alloc = testing.allocator; const io = testing.io; var t: Terminal = try .init(io, alloc, .{ .cols = 10, .rows = 10 }); defer t.deinit(alloc); var failing = testing.FailingAllocator.init(alloc, .{}); const failing_alloc = failing.allocator(); var search: ViewportSearch = try .init(failing_alloc, "needle"); defer search.deinit(); search.active_dirty = false; try testing.expect(try search.update(&t.screens.active.pages)); // Fail the next allocation. An unchanged viewport with active dirty // tracking disabled should reuse the existing fingerprint and return // unchanged before rebuilding the search window, so this update should // succeed without reporting a viewport change. failing.fail_index = failing.alloc_index; try testing.expect(!try search.update(&t.screens.active.pages)); } test "clear screen and search" { const alloc = testing.allocator; const io = testing.io; var t: Terminal = try .init(io, alloc, .{ .cols = 10, .rows = 10 }); defer t.deinit(alloc); var s = t.vtStream(); defer s.deinit(); s.nextSlice("Fizz\r\nBuzz\r\nFizz\r\nBang"); var search: ViewportSearch = try .init(alloc, "Fizz"); defer search.deinit(); try testing.expect(try search.update(&t.screens.active.pages)); s.nextSlice("\x1b[2J"); // Clear screen s.nextSlice("\x1b[H"); // Move cursor home s.nextSlice("Buzz\r\nFizz\r\nBuzz"); try testing.expect(try search.update(&t.screens.active.pages)); { 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).?); 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 "clear screen and search dirty tracking" { const alloc = testing.allocator; const io = testing.io; var t: Terminal = try .init(io, alloc, .{ .cols = 10, .rows = 10 }); defer t.deinit(alloc); var s = t.vtStream(); defer s.deinit(); 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)); s.nextSlice("\x1b[2J"); // Clear screen s.nextSlice("\x1b[H"); // Move cursor home 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 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).?); 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; const io = testing.io; var t: Terminal = try .init(io, alloc, .{ .cols = 10, .rows = 2 }); defer t.deinit(alloc); var s = t.vtStream(); defer s.deinit(); // Fill up first page const first_page_rows = t.screens.active.pages.pages.first.?.capacity().rows; s.nextSlice("Fizz\r\n"); for (1..first_page_rows - 1) |_| s.nextSlice("\r\n"); try testing.expect(t.screens.active.pages.pages.first == t.screens.active.pages.pages.last); // Create second page s.nextSlice("\r\n"); try testing.expect(t.screens.active.pages.pages.first != t.screens.active.pages.pages.last); s.nextSlice("Buzz\r\nFizz"); t.scrollViewport(.top); var search: ViewportSearch = try .init(alloc, "Fizz"); defer search.deinit(); try testing.expect(try search.update(&t.screens.active.pages)); { 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).?); try testing.expectEqual(point.Point{ .screen = .{ .x = 3, .y = 0, } }, t.screens.active.pages.pointFromPin(.screen, sel.end).?); } try testing.expect(search.next() == null); // Viewport doesn't contain active try testing.expect(!try search.update(&t.screens.active.pages)); try testing.expect(search.next() == null); }