search: handle soft-wrapped lines in sliding window properly (#9753)

Fixes #9752
pull/9755/head
Mitchell Hashimoto 2025-11-29 10:56:11 -08:00 committed by GitHub
commit 7f950cc892
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
3 changed files with 173 additions and 11 deletions

View File

@ -825,6 +825,8 @@ pub const PageFormatter = struct {
/// byte written to the writer offset by the byte index. It is the
/// caller's responsibility to free the map.
///
/// The x/y coordinate will be the coordinates within the page.
///
/// Warning: there is a significant performance hit to track this
point_map: ?struct {
alloc: Allocator,
@ -1450,6 +1452,76 @@ test "Page plain single line" {
);
}
test "Page plain single line soft-wrapped unwrapped" {
const testing = std.testing;
const alloc = testing.allocator;
var builder: std.Io.Writer.Allocating = .init(alloc);
defer builder.deinit();
var t = try Terminal.init(alloc, .{
.cols = 3,
.rows = 5,
});
defer t.deinit(alloc);
var s = t.vtStream();
defer s.deinit();
try s.nextSlice("hello!");
// Verify we have only a single page
const pages = &t.screens.active.pages;
try testing.expect(pages.pages.first != null);
try testing.expect(pages.pages.first == pages.pages.last);
// Create the formatter
const page = &pages.pages.last.?.data;
var formatter: PageFormatter = .init(page, .{
.emit = .plain,
.unwrap = true,
});
// Test our point map.
var point_map: std.ArrayList(Coordinate) = .empty;
defer point_map.deinit(alloc);
formatter.point_map = .{ .alloc = alloc, .map = &point_map };
// Verify output
// Note: we don't test the trailing state, which may have bugs
// with unwrap...
_ = try formatter.formatWithState(&builder.writer);
const output = builder.writer.buffered();
try testing.expectEqualStrings("hello!", output);
// Verify our point map
try testing.expectEqual(output.len, point_map.items.len);
try testing.expectEqual(
Coordinate{ .x = 0, .y = 0 },
point_map.items[0],
);
try testing.expectEqual(
Coordinate{ .x = 1, .y = 0 },
point_map.items[1],
);
try testing.expectEqual(
Coordinate{ .x = 2, .y = 0 },
point_map.items[2],
);
try testing.expectEqual(
Coordinate{ .x = 0, .y = 1 },
point_map.items[3],
);
try testing.expectEqual(
Coordinate{ .x = 1, .y = 1 },
point_map.items[4],
);
try testing.expectEqual(
Coordinate{ .x = 2, .y = 1 },
point_map.items[5],
);
}
test "Page plain single wide char" {
const testing = std.testing;
const alloc = testing.allocator;

View File

@ -703,8 +703,16 @@ pub const RenderState = struct {
.{
.tag = tag,
.range = .{
if (i == 0) hl.top_x else 0,
if (i == nodes.len - 1) hl.bot_x else self.cols - 1,
if (i == 0 and
row_pin.y == starts[0])
hl.top_x
else
0,
if (i == nodes.len - 1 and
row_pin.y == ends[nodes.len - 1] - 1)
hl.bot_x
else
self.cols - 1,
},
},
);

View File

@ -9,6 +9,7 @@ const PageList = terminal.PageList;
const Pin = PageList.Pin;
const Selection = terminal.Selection;
const Screen = terminal.Screen;
const Terminal = terminal.Terminal;
const PageFormatter = @import("../formatter.zig").PageFormatter;
const FlattenedHighlight = terminal.highlight.Flattened;
@ -462,12 +463,13 @@ pub const SlidingWindow = struct {
switch (self.direction) {
.forward => {},
.reverse => {
const slice = self.chunk_buf.slice();
const nodes = slice.items(.node);
const starts = slice.items(.start);
const ends = slice.items(.end);
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);
@ -484,10 +486,6 @@ pub const SlidingWindow = struct {
// 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.
@ -496,6 +494,13 @@ pub const SlidingWindow = struct {
ends[0] = nodes[0].data.size.rows;
ends[nodes.len - 1] = starts[nodes.len - 1] + 1;
starts[nodes.len - 1] = 0;
} else {
// For a single chunk, the y values are in reverse order
// (start is the screen-end, end is the screen-start).
// Swap them to get proper top-to-bottom order.
const start_y = starts[0];
starts[0] = ends[0] - 1;
ends[0] = start_y + 1;
}
// X values also need to be reversed since the top/bottom
@ -539,7 +544,10 @@ pub const SlidingWindow = struct {
// Encode the page into the buffer.
const formatter: PageFormatter = formatter: {
var formatter: PageFormatter = .init(&meta.node.data, .plain);
var formatter: PageFormatter = .init(&meta.node.data, .{
.emit = .plain,
.unwrap = true,
});
formatter.point_map = .{
.alloc = self.alloc,
.map = &meta.cell_map,
@ -1555,3 +1563,77 @@ test "SlidingWindow single append match on boundary reversed" {
}
try testing.expect(w.next() == null);
}
test "SlidingWindow single append soft wrapped" {
const testing = std.testing;
const alloc = testing.allocator;
var w: SlidingWindow = try .init(alloc, .forward, "boo!");
defer w.deinit();
var t: Terminal = try .init(alloc, .{ .cols = 4, .rows = 5 });
defer t.deinit(alloc);
var s = t.vtStream();
defer s.deinit();
try s.nextSlice("A\r\nxxboo!\r\nC");
// We want to test single-page cases.
const screen = t.screens.active;
try testing.expect(screen.pages.pages.first == screen.pages.pages.last);
const node: *PageList.List.Node = screen.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 = 2,
.y = 1,
} }, screen.pages.pointFromPin(.active, sel.start));
try testing.expectEqual(point.Point{ .active = .{
.x = 1,
.y = 2,
} }, screen.pages.pointFromPin(.active, sel.end));
}
try testing.expect(w.next() == null);
try testing.expect(w.next() == null);
}
test "SlidingWindow single append reversed soft wrapped" {
const testing = std.testing;
const alloc = testing.allocator;
var w: SlidingWindow = try .init(alloc, .reverse, "boo!");
defer w.deinit();
var t: Terminal = try .init(alloc, .{ .cols = 4, .rows = 5 });
defer t.deinit(alloc);
var s = t.vtStream();
defer s.deinit();
try s.nextSlice("A\r\nxxboo!\r\nC");
// We want to test single-page cases.
const screen = t.screens.active;
try testing.expect(screen.pages.pages.first == screen.pages.pages.last);
const node: *PageList.List.Node = screen.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 = 2,
.y = 1,
} }, screen.pages.pointFromPin(.active, sel.start));
try testing.expectEqual(point.Point{ .active = .{
.x = 1,
.y = 2,
} }, screen.pages.pointFromPin(.active, sel.end));
}
try testing.expect(w.next() == null);
try testing.expect(w.next() == null);
}