search: fixup selected search when reloading active area

pull/9702/head
Mitchell Hashimoto 2025-11-25 09:20:28 -08:00
parent 23479fe409
commit c38e098c4c
No known key found for this signature in database
GPG Key ID: 523D5DC389D273BC
2 changed files with 238 additions and 0 deletions

View File

@ -155,8 +155,19 @@ pub const Flattened = struct {
};
}
pub fn startPin(self: Flattened) Pin {
const slice = self.chunks.slice();
return .{
.node = slice.items(.node)[0],
.x = self.top_x,
.y = slice.items(.start)[0],
};
}
/// Convert to an Untracked highlight.
pub fn untracked(self: Flattened) Untracked {
// Note: we don't use startPin/endPin here because it is slightly
// faster to reuse the slices.
const slice = self.chunks.slice();
const nodes = slice.items(.node);
const starts = slice.items(.start);

View File

@ -15,6 +15,8 @@ const ActiveSearch = @import("active.zig").ActiveSearch;
const PageListSearch = @import("pagelist.zig").PageListSearch;
const SlidingWindow = @import("sliding_window.zig").SlidingWindow;
const log = std.log.scoped(.search_screen);
/// Searches for a needle within a Screen, handling active area updates,
/// pages being pruned from the screen (e.g. scrollback limits), and more.
///
@ -366,6 +368,10 @@ pub const ScreenSearch = struct {
var hl_cloned = try hl.clone(alloc);
errdefer hl_cloned.deinit(alloc);
try self.history_results.append(alloc, hl_cloned);
// Since history only appends to our results in reverse order,
// we don't need to update any selected match state. The index
// and prior results are unaffected.
}
// We need to be fed more data.
@ -380,6 +386,23 @@ pub const ScreenSearch = struct {
///
/// The caller must hold the necessary locks to access the screen state.
pub fn reloadActive(self: *ScreenSearch) Allocator.Error!void {
// If our selection pin became garbage it means we scrolled off
// the end. Clear our selection and on exit of this function,
// try to select the last match.
const select_prev: bool = select_prev: {
const m = if (self.selected) |*m| m else break :select_prev false;
if (!m.highlight.start.garbage and
!m.highlight.end.garbage) break :select_prev false;
m.deinit(self.screen);
self.selected = null;
break :select_prev true;
};
defer if (select_prev) self.select(.next) catch |err| {
// TODO: Change the above next to prev
log.info("reload failed to reset search selection err={}", .{err});
};
const alloc = self.allocator();
const list: *PageList = &self.screen.pages;
if (try self.active.update(list)) |history_node| history: {
@ -474,8 +497,42 @@ pub const ScreenSearch = struct {
try results.appendSlice(alloc, self.history_results.items);
self.history_results.deinit(alloc);
self.history_results = results;
// If our prior selection was in the history area, update
// the offset.
if (self.selected) |*m| selected: {
const active_len = self.active_results.items.len;
if (m.idx < active_len) break :selected;
m.idx += results.items.len;
// Moving the idx should not change our targeted result
// since the history is immutable.
if (comptime std.debug.runtime_safety) {
const hl = self.history_results.items[m.idx - active_len];
assert(m.highlight.start.eql(hl.startPin()));
}
}
}
// Figure out if we need to fixup our selection later because
// it was in the active area.
const old_active_len = self.active_results.items.len;
const old_selection_idx: ?usize = if (self.selected) |m| m.idx else null;
errdefer if (old_selection_idx != null and
old_selection_idx.? < old_active_len)
{
// This is the error scenario. If something fails below,
// our active area is probably gone, so we just go back
// to the first result because our selection can't be trusted.
if (self.selected) |*m| {
m.deinit(self.screen);
self.selected = null;
self.select(.next) catch |err| {
log.info("reload failed to reset search selection err={}", .{err});
};
}
};
// Reset our active search results and search again.
for (self.active_results.items) |*hl| hl.deinit(alloc);
self.active_results.clearRetainingCapacity();
@ -492,6 +549,40 @@ pub const ScreenSearch = struct {
try self.tickActive();
},
}
// Active area search was successful. Now we have to fixup our
// selection if we had one.
fixup: {
const old_idx = old_selection_idx orelse break :fixup;
const m = if (self.selected) |*m| m else break :fixup;
// If our old selection wasn't in the active area, then we
// need to fix up our offsets.
if (old_idx >= old_active_len) {
m.idx -= old_active_len;
m.idx += self.active_results.items.len;
break :fixup;
}
// We search for the matching highlight in the new active results.
for (0.., self.active_results.items) |i, hl| {
const untracked = hl.untracked();
if (m.highlight.start.eql(untracked.start) and
m.highlight.end.eql(untracked.end))
{
// Found it! Update our index.
m.idx = self.active_results.items.len - 1 - i;
break :fixup;
}
}
// No match, just go back to the first match.
m.deinit(self.screen);
self.selected = null;
self.select(.next) catch |err| {
log.info("reload failed to reset search selection err={}", .{err});
};
}
}
pub const Select = enum {
@ -858,3 +949,139 @@ test "select next" {
} }, t.screens.active.pages.pointFromPin(.screen, sel.end).?);
}
}
test "select in active changes contents completely" {
const alloc = testing.allocator;
var t: Terminal = try .init(alloc, .{ .cols = 10, .rows = 5 });
defer t.deinit(alloc);
var s = t.vtStream();
defer s.deinit();
try s.nextSlice("Fizz\r\nBuzz\r\nFizz\r\nBang");
var search: ScreenSearch = try .init(alloc, t.screens.active, "Fizz");
defer search.deinit();
try search.searchAll();
try search.select(.next);
try search.select(.next);
{
// Initial selection is the first fizz
const sel = search.selectedMatch().?.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).?);
}
// Erase the screen, move our cursor to the top, and change contents.
try s.nextSlice("\x1b[2J\x1b[H"); // Clear screen and move home
try s.nextSlice("Fuzz\r\nFizz\r\nHello!");
try search.reloadActive();
{
// Our selection should move to the first
const sel = search.selectedMatch().?.untracked();
try testing.expectEqual(point.Point{ .screen = .{
.x = 0,
.y = 1,
} }, 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).?);
}
// Erase the screen, redraw with same contents.
try s.nextSlice("\x1b[2J\x1b[H"); // Clear screen and move home
try s.nextSlice("Fuzz\r\nFizz\r\nFizz");
try search.reloadActive();
{
// Our selection should not move to the first
const sel = search.selectedMatch().?.untracked();
try testing.expectEqual(point.Point{ .screen = .{
.x = 0,
.y = 1,
} }, 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).?);
}
}
test "select into history" {
const alloc = testing.allocator;
var t: Terminal = try .init(alloc, .{
.cols = 10,
.rows = 2,
.max_scrollback = std.math.maxInt(usize),
});
defer t.deinit(alloc);
const list: *PageList = &t.screens.active.pages;
var s = t.vtStream();
defer s.deinit();
try s.nextSlice("Fizz\r\n");
while (list.totalPages() < 3) try s.nextSlice("\r\n");
for (0..list.rows) |_| try s.nextSlice("\r\n");
try s.nextSlice("hello.");
var search: ScreenSearch = try .init(alloc, t.screens.active, "Fizz");
defer search.deinit();
try search.searchAll();
// Get all matches
try search.select(.next);
{
const sel = search.selectedMatch().?.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).?);
}
// Erase the screen, redraw with same contents.
try s.nextSlice("\x1b[2J\x1b[H"); // Clear screen and move home
try s.nextSlice("yo yo");
try search.reloadActive();
{
// Our selection should not move since the history is still active.
const sel = search.selectedMatch().?.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).?);
}
// Create some new history by adding more lines.
try s.nextSlice("\r\nfizz\r\nfizz\r\nfizz"); // Clear screen and move home
try search.reloadActive();
{
// Our selection should not move since the history is still not
// pruned.
const sel = search.selectedMatch().?.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).?);
}
}