mirror of
https://github.com/ghostty-org/ghostty.git
synced 2026-08-02 13:49:10 +00:00
Search previously used the normal page access boundary while formatting history and checking soft-wrap boundaries. Inspecting compressed history therefore restored its retained mapping and undid the memory reclamation. Format through preserved page snapshots and copy row counts and wrap state into the sliding window's owned metadata. Overlap decisions reuse the same snapshot, so compressed pages are decoded at most once per append and remain compressed after matching. Add a cross-page regression which searches compressed history and verifies both source nodes retain their compressed representation.
172 lines
6.1 KiB
Zig
172 lines
6.1 KiB
Zig
const std = @import("std");
|
|
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 active area of a PageList.
|
|
///
|
|
/// The distinction for "active area" is important because it is the
|
|
/// only part of a PageList that is mutable. Therefore, its the only part
|
|
/// of the terminal that needs to be repeatedly searched as the contents
|
|
/// change.
|
|
///
|
|
/// This struct specializes in searching only within that active area,
|
|
/// and handling the active area moving as new lines are added to the bottom.
|
|
pub const ActiveSearch = struct {
|
|
window: SlidingWindow,
|
|
|
|
pub fn init(
|
|
alloc: Allocator,
|
|
needle: []const u8,
|
|
) Allocator.Error!ActiveSearch {
|
|
// We just do a forward search since the active area is usually
|
|
// pretty small so search results are instant anyways. This avoids
|
|
// a small amount of work to reverse things.
|
|
var window: SlidingWindow = try .init(alloc, .forward, needle);
|
|
errdefer window.deinit();
|
|
return .{ .window = window };
|
|
}
|
|
|
|
pub fn deinit(self: *ActiveSearch) void {
|
|
self.window.deinit();
|
|
}
|
|
|
|
/// Update the active area to reflect the current state of the PageList.
|
|
///
|
|
/// This doesn't do the search, it only copies the necessary data
|
|
/// to perform the search later. This lets the caller hold the lock
|
|
/// on the PageList for a minimal amount of time.
|
|
///
|
|
/// This returns the first page (in reverse order) covered by this
|
|
/// search. This allows the history search to overlap and search history.
|
|
/// There CAN BE duplicates, and this page CAN BE mutable, so the history
|
|
/// search results should prune anything that's in the active area.
|
|
///
|
|
/// If the return value is null it means the active area covers the entire
|
|
/// PageList, currently.
|
|
pub fn update(
|
|
self: *ActiveSearch,
|
|
list: *const PageList,
|
|
) Allocator.Error!?*PageList.List.Node {
|
|
// Clear our previous sliding window
|
|
self.window.clearAndRetainCapacity();
|
|
|
|
// First up, add enough pages to cover the active area.
|
|
var rem: usize = list.rows;
|
|
var node_ = list.pages.last;
|
|
var last_node: ?*PageList.List.Node = null;
|
|
while (node_) |node| : (node_ = node.prev) {
|
|
_ = try self.window.append(node);
|
|
last_node = node;
|
|
|
|
// If we reached our target amount, then this is the last
|
|
// page that contains the active area. We go to the previous
|
|
// page once more since its the first page of our required
|
|
// overlap.
|
|
if (rem <= node.rows()) {
|
|
node_ = node.prev;
|
|
break;
|
|
}
|
|
|
|
rem -= node.rows();
|
|
}
|
|
|
|
// 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) {
|
|
// 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;
|
|
if (appended.content_len >= self.window.needle.len - 1) break;
|
|
}
|
|
|
|
// Return the last node we added to our window.
|
|
return last_node;
|
|
}
|
|
|
|
/// Find the next match for the needle in the active area. This returns
|
|
/// null when there are no more matches.
|
|
pub fn next(self: *ActiveSearch) ?FlattenedHighlight {
|
|
return self.window.next();
|
|
}
|
|
};
|
|
|
|
test "simple search" {
|
|
const alloc = testing.allocator;
|
|
var t: Terminal = try .init(alloc, .{ .cols = 10, .rows = 10 });
|
|
defer t.deinit(alloc);
|
|
|
|
var s = t.vtStream();
|
|
defer s.deinit();
|
|
s.nextSlice("Fizz\r\nBuzz\r\nFizz\r\nBang");
|
|
|
|
var search: ActiveSearch = try .init(alloc, "Fizz");
|
|
defer search.deinit();
|
|
_ = 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 "clear screen and search" {
|
|
const alloc = testing.allocator;
|
|
var t: Terminal = try .init(alloc, .{ .cols = 10, .rows = 10 });
|
|
defer t.deinit(alloc);
|
|
|
|
var s = t.vtStream();
|
|
defer s.deinit();
|
|
s.nextSlice("Fizz\r\nBuzz\r\nFizz\r\nBang");
|
|
|
|
var search: ActiveSearch = try .init(alloc, "Fizz");
|
|
defer search.deinit();
|
|
_ = 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 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);
|
|
}
|