diff --git a/src/terminal/search/active.zig b/src/terminal/search/active.zig index 345c842fa..aee21b3e3 100644 --- a/src/terminal/search/active.zig +++ b/src/terminal/search/active.zig @@ -79,15 +79,11 @@ pub const ActiveSearch = struct { // Next, add enough overlap to cover needle.len - 1 bytes (if it // exists) so we can cover the overlap. while (node_) |node| : (node_ = node.prev) { - // If the last row of this node isn't wrapped we can't overlap. - const row = node.page().getRow(node.rows() - 1); - if (!row.wrap) break; - // We could be more accurate here and count bytes since the // last wrap but its complicated and unlikely multiple pages // wrap so this should be fine. - const added = try self.window.append(node); - if (added >= self.window.needle.len - 1) break; + const appended = try self.window.appendIfWrapped(node) orelse break; + if (appended.content_len >= self.window.needle.len - 1) break; } // Return the last node we added to our window. diff --git a/src/terminal/search/pagelist.zig b/src/terminal/search/pagelist.zig index 9d98e5eaf..e8354d799 100644 --- a/src/terminal/search/pagelist.zig +++ b/src/terminal/search/pagelist.zig @@ -3,6 +3,7 @@ const Allocator = std.mem.Allocator; const testing = std.testing; const terminal = @import("../main.zig"); const point = terminal.point; +const size = terminal.size; const FlattenedHighlight = @import("../highlight.zig").Flattened; const Page = terminal.Page; const PageList = terminal.PageList; @@ -121,7 +122,7 @@ pub const PageListSearch = struct { // get our desired amount of data. var node_: ?*PageList.List.Node = self.pin.node.prev; while (node_) |node| : (node_ = node.prev) { - rem -|= try self.window.append(node); + rem -|= (try self.window.append(node)).content_len; // Move our tracked pin to the new node. self.pin.node = node; @@ -359,6 +360,76 @@ test "feed with match spanning page boundary" { try testing.expect(!try search.feed()); } +test "compressed history match spanning page boundary remains compressed" { + const alloc = testing.allocator; + var t: Terminal = try .init(alloc, .{ + .cols = 80, + .rows = 24, + .max_scrollback = 10 * 1024 * 1024, + }); + defer t.deinit(alloc); + + var stream = t.vtStream(); + defer stream.deinit(); + + const pages = &t.screens.active.pages; + const first = pages.pages.first.?; + const first_page_rows = first.capacity().rows; + + // Put half the needle on either side of a soft-wrapped page boundary. + for (0..first_page_rows - 1) |_| stream.nextSlice("\r\n"); + for (0..pages.cols - 2) |_| stream.nextSlice("x"); + stream.nextSlice("Test"); + const second = first.next.?; + try testing.expectEqual(second, pages.pages.last.?); + + // Move both matching pages completely into history. The third page holds + // the active area, making the first two eligible for compression. + while (pages.pages.last.? == second) stream.nextSlice("\r\n"); + for (0..pages.rows) |_| stream.nextSlice("\r\n"); + try testing.expect(pages.getTopLeft(.active).node != second); + + _ = pages.compress(.full); + try testing.expectEqual(PageList.List.Node.Storage.compressed, first.storage()); + try testing.expectEqual(PageList.List.Node.Storage.compressed, second.storage()); + const compressed = pages.memoryStats(); + try testing.expect(compressed.compressed_pages >= 2); + + var search: PageListSearch = try .init( + alloc, + "Test", + pages, + pages.pages.last.?, + ); + defer search.deinit(); + + // Drain and feed incrementally until the boundary match is available. The + // exact number of feeds depends on how much blank-page text the formatter + // trims, so bound progress by the number of pages rather than encoding + // that implementation detail in the test. + var found: ?FlattenedHighlight = null; + for (0..pages.totalPages() + 1) |_| { + if (search.next()) |match| { + found = match; + break; + } + if (!try search.feed()) break; + } + try testing.expect(found != null); + + const match = found.?.untracked(); + try testing.expectEqual(first, match.start.node); + try testing.expectEqual(second, match.end.node); + try testing.expectEqual(@as(size.CellCountInt, pages.cols - 2), match.start.x); + try testing.expectEqual(@as(size.CellCountInt, 1), match.end.x); + + // Search owns formatted text and coordinate maps, not page snapshots. The + // original raw mappings therefore remain discarded after matching. + try testing.expectEqual(compressed, pages.memoryStats()); + try testing.expectEqual(PageList.List.Node.Storage.compressed, first.storage()); + try testing.expectEqual(PageList.List.Node.Storage.compressed, second.storage()); +} + test "feed with match spanning page boundary with newline" { const alloc = testing.allocator; var t: Terminal = try .init(alloc, .{ .cols = 80, .rows = 24 }); diff --git a/src/terminal/search/sliding_window.zig b/src/terminal/search/sliding_window.zig index 77b4d0ad0..ea039bf3a 100644 --- a/src/terminal/search/sliding_window.zig +++ b/src/terminal/search/sliding_window.zig @@ -89,6 +89,7 @@ pub const SlidingWindow = struct { const Meta = struct { node: *PageList.List.Node, serial: u64, + rows: size.CellCountInt, cell_map: std.ArrayList(point.Coordinate), pub fn deinit(self: *Meta, alloc: Allocator) void { @@ -96,6 +97,17 @@ pub const SlidingWindow = struct { } }; + /// Information copied from a page while appending it to the window. + /// + /// Both values remain safe after the node's preserved page is released. + /// In particular, callers use `last_row_wrapped` to decide whether an + /// adjacent page can contribute to a cross-page match without reading the + /// node again. + pub const AppendResult = struct { + content_len: usize, + last_row_wrapped: bool, + }; + pub fn init( alloc: Allocator, direction: Direction, @@ -309,6 +321,12 @@ pub const SlidingWindow = struct { self.chunk_buf.clearRetainingCapacity(); var result: terminal.highlight.Flattened = .empty; + // A reverse cross-page match needs the row count of the last meta in + // search order after the chunks themselves are reversed. Snapshot it + // while traversing Meta rather than dereferencing a PageList node after + // the terminal lock has been released. + var cross_page_end_rows: ?size.CellCountInt = null; + // Go through the meta nodes to find our start. const tl: struct { /// If non-null, we need to continue searching for the bottom-right. @@ -375,7 +393,7 @@ pub const SlidingWindow = struct { .node = meta.node, .serial = meta.serial, .start = @intCast(map.y), - .end = meta.node.rows(), + .end = meta.rows, }); break :tl .{ @@ -410,7 +428,7 @@ pub const SlidingWindow = struct { .node = meta.node, .serial = meta.serial, .start = 0, - .end = meta.node.rows(), + .end = meta.rows, }); meta_consumed += meta.cell_map.items.len; @@ -420,6 +438,7 @@ pub const SlidingWindow = struct { // We found it const map = meta.cell_map.items[meta_i]; result.bot_x = map.x; + cross_page_end_rows = meta.rows; self.chunk_buf.appendAssumeCapacity(.{ .node = meta.node, .serial = meta.serial, @@ -491,7 +510,7 @@ pub const SlidingWindow = struct { // order. assert(nodes.len >= 2); starts[0] = ends[0] - 1; - ends[0] = nodes[0].rows(); + ends[0] = cross_page_end_rows.?; ends[nodes.len - 1] = starts[nodes.len - 1] + 1; starts[nodes.len - 1] = 0; } else { @@ -526,11 +545,49 @@ pub const SlidingWindow = struct { pub fn append( self: *SlidingWindow, node: *PageList.List.Node, - ) Allocator.Error!usize { + ) Allocator.Error!AppendResult { + var preserved = try node.pagePreservingState(self.alloc); + defer preserved.deinit(); + + const page = preserved.page(); + const last_row_wrapped = page.getRow(page.size.rows - 1).wrap; + return self.appendPage(node, page, last_row_wrapped); + } + + /// Append a node only when its last row is soft wrapped. + /// + /// This acquires one preserved page for both the wrap check and formatting + /// so a compressed node is decoded at most once. It is used when loading + /// the overlap around an otherwise complete search region. + pub fn appendIfWrapped( + self: *SlidingWindow, + node: *PageList.List.Node, + ) Allocator.Error!?AppendResult { + var preserved = try node.pagePreservingState(self.alloc); + defer preserved.deinit(); + + const page = preserved.page(); + const last_row_wrapped = page.getRow(page.size.rows - 1).wrap; + if (!last_row_wrapped) return null; + return try self.appendPage(node, page, last_row_wrapped); + } + + /// Copy one preserved page into the window's owned search buffers. + /// + /// No pointer into `page` may escape this function: compressed preserved + /// pages own temporary decode storage, while resident values borrow node + /// memory. + fn appendPage( + self: *SlidingWindow, + node: *PageList.List.Node, + page: *const terminal.Page, + last_row_wrapped: bool, + ) Allocator.Error!AppendResult { // Initialize our metadata for the node. var meta: Meta = .{ .node = node, .serial = node.serial, + .rows = page.size.rows, .cell_map = .empty, }; errdefer meta.deinit(self.alloc); @@ -544,7 +601,7 @@ pub const SlidingWindow = struct { // Encode the page into the buffer. const formatter: PageFormatter = formatter: { - var formatter: PageFormatter = .init(meta.node.page(), .{ + var formatter: PageFormatter = .init(page, .{ .emit = .plain, .unwrap = true, }); @@ -563,8 +620,7 @@ pub const SlidingWindow = struct { // If the node we're adding isn't soft-wrapped, we add the // trailing newline. - const row = node.page().getRow(node.rows() - 1); - if (!row.wrap) { + if (!last_row_wrapped) { encoded.writer.writeByte('\n') catch return error.OutOfMemory; try meta.cell_map.append( self.alloc, @@ -580,7 +636,10 @@ pub const SlidingWindow = struct { const written = encoded.written(); if (written.len == 0) { self.assertIntegrity(); - return 0; + return .{ + .content_len = 0, + .last_row_wrapped = last_row_wrapped, + }; } // Get our written data. If we're doing a reverse search then we @@ -603,7 +662,10 @@ pub const SlidingWindow = struct { self.meta.appendAssumeCapacity(meta); self.assertIntegrity(); - return written.len; + return .{ + .content_len = written.len, + .last_row_wrapped = last_row_wrapped, + }; } /// Only for tests! diff --git a/src/terminal/search/viewport.zig b/src/terminal/search/viewport.zig index 67e8cabf2..a1458bbed 100644 --- a/src/terminal/search/viewport.zig +++ b/src/terminal/search/viewport.zig @@ -137,37 +137,35 @@ pub const ViewportSearch = struct { var node_ = fingerprint.nodes[0].prev; var added: usize = 0; while (node_) |node| : (node_ = node.prev) { - // If the last row of this node isn't wrapped we can't overlap. - const row = node.page().getRow(node.rows() - 1); - if (!row.wrap) break; - // We could be more accurate here and count bytes since the // last wrap but its complicated and unlikely multiple pages // wrap so this should be fine. - added += try self.window.append(node); + const appended = try self.window.appendIfWrapped(node) orelse break; + added += appended.content_len; if (added >= self.window.needle.len - 1) break; } // We can use our fingerprint nodes to initialize our sliding // window, since we already traversed the viewport once. + var end_append: SlidingWindow.AppendResult = undefined; for (fingerprint.nodes) |node| { - _ = try self.window.append(node); + end_append = try self.window.append(node); } // Add any trailing overlap as well. trailing: { const end: *PageList.List.Node = fingerprint.nodes[fingerprint.nodes.len - 1]; - if (!end.page().getRow(end.rows() - 1).wrap) break :trailing; + if (!end_append.last_row_wrapped) break :trailing; node_ = end.next; added = 0; while (node_) |node| : (node_ = node.next) { - added += try self.window.append(node); + const appended = try self.window.append(node); + added += appended.content_len; if (added >= self.window.needle.len - 1) break; // If this row doesn't wrap, then we can quit - const row = node.page().getRow(node.rows() - 1); - if (!row.wrap) break; + if (!appended.last_row_wrapped) break; } }