search: navigable search results (previous/next) (#9702)

Continuing #189

This adds the `navigate_search:previous` and `next` key bindings which
allow search matches to be navigated. The currently selected search
match is highlighted using a new `search-selected-foreground/background`
configuration with a reasonable default.

As search results are navigated, the viewport moves to keep them
visible.

## Demo



https://github.com/user-attachments/assets/facc9f3e-e327-4c65-b5f7-0279480ac357
pull/9713/head
Mitchell Hashimoto 2025-11-25 11:13:37 -08:00 committed by GitHub
commit 14abc6a49d
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
11 changed files with 1000 additions and 57 deletions

View File

@ -1363,6 +1363,32 @@ fn searchCallback_(
try self.renderer_thread.wakeup.notify();
},
.selected_match => |selected_| {
if (selected_) |sel| {
// Copy the flattened match.
var arena: ArenaAllocator = .init(self.alloc);
errdefer arena.deinit();
const alloc = arena.allocator();
const match = try sel.highlight.clone(alloc);
_ = self.renderer_thread.mailbox.push(
.{ .search_selected_match = .{
.arena = arena,
.match = match,
} },
.forever,
);
} else {
// Reset our selected match
_ = self.renderer_thread.mailbox.push(
.{ .search_selected_match = null },
.forever,
);
}
try self.renderer_thread.wakeup.notify();
},
// When we quit, tell our renderer to reset any search state.
.quit => {
_ = self.renderer_thread.mailbox.push(
@ -4892,6 +4918,19 @@ pub fn performBindingAction(self: *Surface, action: input.Binding.Action) !bool
.{ .change_needle = text },
.forever,
);
s.state.wakeup.notify() catch {};
},
.navigate_search => |nav| {
const s: *Search = if (self.search) |*s| s else return false;
_ = s.state.mailbox.push(
.{ .select = switch (nav) {
.next => .next,
.previous => .prev,
} },
.forever,
);
s.state.wakeup.notify() catch {};
},
.copy_to_clipboard => |format| {

View File

@ -988,10 +988,25 @@ palette: Palette = .{},
/// - "cell-foreground" to match the cell foreground color
/// - "cell-background" to match the cell background color
///
/// The default value is
/// The default value is black text on a golden yellow background.
@"search-foreground": TerminalColor = .{ .color = .{ .r = 0, .g = 0, .b = 0 } },
@"search-background": TerminalColor = .{ .color = .{ .r = 0xFF, .g = 0xE0, .b = 0x82 } },
/// The foreground and background color for the currently selected search match.
/// This is the focused match that will be jumped to when using next/previous
/// search navigation.
///
/// Valid values:
///
/// - Hex (`#RRGGBB` or `RRGGBB`)
/// - Named X11 color
/// - "cell-foreground" to match the cell foreground color
/// - "cell-background" to match the cell background color
///
/// The default value is black text on a soft peach background.
@"search-selected-foreground": TerminalColor = .{ .color = .{ .r = 0, .g = 0, .b = 0 } },
@"search-selected-background": TerminalColor = .{ .color = .{ .r = 0xF2, .g = 0xA5, .b = 0x7E } },
/// The command to run, usually a shell. If this is not an absolute path, it'll
/// be looked up in the `PATH`. If this is not set, a default will be looked up
/// from your system. The rules for the default lookup are:

View File

@ -336,6 +336,10 @@ pub const Action = union(enum) {
/// the search is canceled. If a previous search is active, it is replaced.
search: []const u8,
/// Navigate the search results. If there is no active search, this
/// is not performed.
navigate_search: NavigateSearch,
/// Clear the screen and all scrollback.
clear_screen,
@ -826,6 +830,11 @@ pub const Action = union(enum) {
}
};
pub const NavigateSearch = enum {
previous,
next,
};
pub const AdjustSelection = enum {
left,
right,
@ -1157,6 +1166,7 @@ pub const Action = union(enum) {
.text,
.cursor_key,
.search,
.navigate_search,
.reset,
.copy_to_clipboard,
.copy_url_to_clipboard,

View File

@ -163,6 +163,16 @@ fn actionCommands(action: Action.Key) []const Command {
.description = "Paste the contents of the selection clipboard.",
}},
.navigate_search => comptime &.{ .{
.action = .{ .navigate_search = .next },
.title = "Next Search Result",
.description = "Navigate to the next search result, if any.",
}, .{
.action = .{ .navigate_search = .previous },
.title = "Previous Search Result",
.description = "Navigate to the previous search result, if any.",
} },
.increase_font_size => comptime &.{.{
.action = .{ .increase_font_size = 1 },
.title = "Increase Font Size",

View File

@ -459,6 +459,14 @@ fn drainMailbox(self: *Thread) !void {
self.renderer.search_matches_dirty = true;
},
.search_selected_match => |v| {
// Note we don't free the new value because we expect our
// allocators to match.
if (self.renderer.search_selected_match) |*m| m.arena.deinit();
self.renderer.search_selected_match = v;
self.renderer.search_matches_dirty = true;
},
.inspector => |v| self.flags.has_inspector = v,
.macos_display_id => |v| {

View File

@ -130,6 +130,7 @@ pub fn Renderer(comptime GraphicsAPI: type) type {
/// Note that the selections MAY BE INVALID (point to PageList nodes
/// that do not exist anymore). These must be validated prior to use.
search_matches: ?renderer.Message.SearchMatches,
search_selected_match: ?renderer.Message.SearchMatch,
search_matches_dirty: bool,
/// The current set of cells to render. This is rebuilt on every frame
@ -222,6 +223,11 @@ pub fn Renderer(comptime GraphicsAPI: type) type {
/// a large screen.
terminal_state_frame_count: usize = 0,
const HighlightTag = enum(u8) {
search_match,
search_match_selected,
};
/// Swap chain which maintains multiple copies of the state needed to
/// render a frame, so that we can start building the next frame while
/// the previous frame is still being processed on the GPU.
@ -539,6 +545,8 @@ pub fn Renderer(comptime GraphicsAPI: type) type {
selection_foreground: ?configpkg.Config.TerminalColor,
search_background: configpkg.Config.TerminalColor,
search_foreground: configpkg.Config.TerminalColor,
search_selected_background: configpkg.Config.TerminalColor,
search_selected_foreground: configpkg.Config.TerminalColor,
bold_color: ?configpkg.BoldColor,
faint_opacity: u8,
min_contrast: f32,
@ -612,6 +620,8 @@ pub fn Renderer(comptime GraphicsAPI: type) type {
.selection_foreground = config.@"selection-foreground",
.search_background = config.@"search-background",
.search_foreground = config.@"search-foreground",
.search_selected_background = config.@"search-selected-background",
.search_selected_foreground = config.@"search-selected-foreground",
.custom_shaders = custom_shaders,
.bg_image = bg_image,
@ -687,6 +697,7 @@ pub fn Renderer(comptime GraphicsAPI: type) type {
.scrollbar = .zero,
.scrollbar_dirty = false,
.search_matches = null,
.search_selected_match = null,
.search_matches_dirty = false,
// Render state
@ -760,6 +771,7 @@ pub fn Renderer(comptime GraphicsAPI: type) type {
pub fn deinit(self: *Self) void {
self.terminal_state.deinit(self.alloc);
if (self.search_selected_match) |*m| m.arena.deinit();
if (self.search_matches) |*m| m.arena.deinit();
self.swap_chain.deinit();
@ -1209,9 +1221,24 @@ pub fn Renderer(comptime GraphicsAPI: type) type {
highlights.clearRetainingCapacity();
}
// NOTE: The order below matters. Highlights added earlier
// will take priority.
if (self.search_selected_match) |m| {
self.terminal_state.updateHighlightsFlattened(
self.alloc,
@intFromEnum(HighlightTag.search_match_selected),
(&m.match)[0..1],
) catch |err| {
// Not a critical error, we just won't show highlights.
log.warn("error updating search selected highlight err={}", .{err});
};
}
if (self.search_matches) |m| {
self.terminal_state.updateHighlightsFlattened(
self.alloc,
@intFromEnum(HighlightTag.search_match),
m.matches,
) catch |err| {
// Not a critical error, we just won't show highlights.
@ -2560,13 +2587,18 @@ pub fn Renderer(comptime GraphicsAPI: type) type {
false,
selection,
search,
search_selected,
} = selected: {
// If we're highlighted, then we're selected. In the
// future we want to use a different style for this
// but this to get started.
for (highlights.items) |hl| {
if (x >= hl[0] and x <= hl[1]) {
break :selected .search;
if (x >= hl.range[0] and x <= hl.range[1]) {
const tag: HighlightTag = @enumFromInt(hl.tag);
break :selected switch (tag) {
.search_match => .search,
.search_match_selected => .search_selected,
};
}
}
@ -2614,6 +2646,12 @@ pub fn Renderer(comptime GraphicsAPI: type) type {
.@"cell-background" => if (style.flags.inverse) fg_style else bg_style,
},
.search_selected => switch (self.config.search_selected_background) {
.color => |color| color.toTerminalRGB(),
.@"cell-foreground" => if (style.flags.inverse) bg_style else fg_style,
.@"cell-background" => if (style.flags.inverse) fg_style else bg_style,
},
// Not selected
.false => if (style.flags.inverse != isCovering(cell.codepoint()))
// Two cases cause us to invert (use the fg color as the bg)
@ -2652,6 +2690,12 @@ pub fn Renderer(comptime GraphicsAPI: type) type {
.@"cell-background" => if (style.flags.inverse) fg_style else final_bg,
},
.search_selected => switch (self.config.search_selected_foreground) {
.color => |color| color.toTerminalRGB(),
.@"cell-foreground" => if (style.flags.inverse) final_bg else fg_style,
.@"cell-background" => if (style.flags.inverse) fg_style else final_bg,
},
.false => if (style.flags.inverse)
final_bg
else

View File

@ -58,6 +58,10 @@ pub const Message = union(enum) {
/// viewport. The renderer must handle this gracefully.
search_viewport_matches: SearchMatches,
/// The selected match from the search thread. May be null to indicate
/// no match currently.
search_selected_match: ?SearchMatch,
/// Activate or deactivate the inspector.
inspector: bool,
@ -69,6 +73,11 @@ pub const Message = union(enum) {
matches: []const terminal.highlight.Flattened,
};
pub const SearchMatch = struct {
arena: ArenaAllocator,
match: terminal.highlight.Flattened,
};
/// Initialize a change_config message.
pub fn initChangeConfig(alloc: Allocator, config: *const configpkg.Config) !Message {
const thread_ptr = try alloc.create(renderer.Thread.DerivedConfig);

View File

@ -32,6 +32,21 @@ const Screen = @import("Screen.zig");
pub const Untracked = struct {
start: Pin,
end: Pin,
pub fn track(
self: *const Untracked,
screen: *Screen,
) Allocator.Error!Tracked {
return try .init(
screen,
self.start,
self.end,
);
}
pub fn eql(self: Untracked, other: Untracked) bool {
return self.start.eql(other.start) and self.end.eql(other.end);
}
};
/// A tracked highlight is a highlight that stores its highlighted
@ -144,8 +159,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

@ -193,9 +193,17 @@ pub const RenderState = struct {
/// The x range of the selection within this row.
selection: ?[2]size.CellCountInt,
/// The x ranges of highlights within this row. Highlights are
/// applied after the update by calling `updateHighlights`.
highlights: std.ArrayList([2]size.CellCountInt),
/// The highlights within this row.
highlights: std.ArrayList(Highlight),
};
pub const Highlight = struct {
/// A special tag that can be used by the caller to differentiate
/// different highlight types. The value is opaque to the RenderState.
tag: u8,
/// The x ranges of highlights within this row.
range: [2]size.CellCountInt,
};
pub const Cell = struct {
@ -646,6 +654,7 @@ pub const RenderState = struct {
pub fn updateHighlightsFlattened(
self: *RenderState,
alloc: Allocator,
tag: u8,
hls: []const highlight.Flattened,
) Allocator.Error!void {
// Fast path, we have no highlights!
@ -691,8 +700,11 @@ pub const RenderState = struct {
try row_highlights.append(
arena_alloc,
.{
if (i == 0) hl.top_x else 0,
if (i == nodes.len - 1) hl.bot_x else self.cols - 1,
.tag = tag,
.range = .{
if (i == 0) hl.top_x else 0,
if (i == nodes.len - 1) hl.bot_x else self.cols - 1,
},
},
);

View File

@ -19,6 +19,7 @@ const internal_os = @import("../../os/main.zig");
const BlockingQueue = @import("../../datastruct/main.zig").BlockingQueue;
const point = @import("../point.zig");
const FlattenedHighlight = @import("../highlight.zig").Flattened;
const UntrackedHighlight = @import("../highlight.zig").Untracked;
const PageList = @import("../PageList.zig");
const Screen = @import("../Screen.zig");
const ScreenSet = @import("../ScreenSet.zig");
@ -242,6 +243,28 @@ fn drainMailbox(self: *Thread) !void {
log.debug("mailbox message={}", .{message});
switch (message) {
.change_needle => |v| try self.changeNeedle(v),
.select => |v| try self.select(v),
}
}
}
fn select(self: *Thread, sel: ScreenSearch.Select) !void {
const s = if (self.search) |*s| s else return;
const screen_search = s.screens.getPtr(s.last_screen.key) orelse return;
self.opts.mutex.lock();
defer self.opts.mutex.unlock();
// The selection will trigger a selection change notification
// if it did change.
if (try screen_search.select(sel)) scroll: {
if (screen_search.selected) |m| {
// Selection changed, let's scroll the viewport to see it
// since we have the lock anyways.
const screen = self.opts.terminal.screens.get(
s.last_screen.key,
) orelse break :scroll;
screen.scroll(.{ .pin = m.highlight.start.* });
}
}
}
@ -395,6 +418,9 @@ pub const Message = union(enum) {
/// will start a search. If an existing search term is given this will
/// stop the prior search and start a new one.
change_needle: []const u8,
/// Select a search result.
select: ScreenSearch.Select,
};
/// Events that can be emitted from the search thread. The caller
@ -409,9 +435,17 @@ pub const Event = union(enum) {
/// Total matches on the current active screen have changed.
total_matches: usize,
/// Selected match changed.
selected_match: ?SelectedMatch,
/// Matches in the viewport have changed. The memory is owned by the
/// search thread and is only valid during the callback.
viewport_matches: []const FlattenedHighlight,
pub const SelectedMatch = struct {
idx: usize,
highlight: FlattenedHighlight,
};
};
/// Search state.
@ -422,11 +456,9 @@ const Search = struct {
/// The searchers for all the screens.
screens: std.EnumMap(ScreenSet.Key, ScreenSearch),
/// The last active screen
last_active_screen: ScreenSet.Key,
/// The last total matches reported.
last_total: ?usize,
/// All state related to screen switches, collected so that when
/// we switch screens it makes everything related stale, too.
last_screen: ScreenState,
/// True if we sent the complete notification yet.
last_complete: bool,
@ -434,6 +466,22 @@ const Search = struct {
/// The last viewport matches we found.
stale_viewport_matches: bool,
const ScreenState = struct {
/// Last active screen key
key: ScreenSet.Key,
/// Last notified total matches count
total: ?usize = null,
/// Last notified selected match index
selected: ?SelectedMatch = null,
const SelectedMatch = struct {
idx: usize,
highlight: UntrackedHighlight,
};
};
pub fn init(
alloc: Allocator,
needle: []const u8,
@ -448,8 +496,7 @@ const Search = struct {
return .{
.viewport = vp,
.screens = .init(.{}),
.last_active_screen = .primary,
.last_total = null,
.last_screen = .{ .key = .primary },
.last_complete = false,
.stale_viewport_matches = true,
};
@ -528,9 +575,10 @@ const Search = struct {
t: *Terminal,
) void {
// Update our active screen
if (t.screens.active_key != self.last_active_screen) {
self.last_active_screen = t.screens.active_key;
self.last_total = null; // force notification
if (t.screens.active_key != self.last_screen.key) {
// The default values will force resets of a bunch of other
// state too to force recalculations and notifications.
self.last_screen = .{ .key = t.screens.active_key };
}
// Reconcile our screens with the terminal screens. Remove
@ -621,13 +669,13 @@ const Search = struct {
cb: EventCallback,
ud: ?*anyopaque,
) void {
const screen_search = self.screens.get(self.last_active_screen) orelse return;
const screen_search = self.screens.get(self.last_screen.key) orelse return;
// Check our total match data
const total = screen_search.matchesLen();
if (total != self.last_total) {
if (total != self.last_screen.total) {
log.debug("notifying total matches={}", .{total});
self.last_total = total;
self.last_screen.total = total;
cb(.{ .total_matches = total }, ud);
}
@ -666,6 +714,40 @@ const Search = struct {
cb(.{ .viewport_matches = results.items }, ud);
}
// Check our last selected match data.
if (screen_search.selected) |m| match: {
const flattened = screen_search.selectedMatch() orelse break :match;
const untracked = flattened.untracked();
if (self.last_screen.selected) |prev| {
if (prev.idx == m.idx and prev.highlight.eql(untracked)) {
// Same selection, don't update it.
break :match;
}
}
// New selection, notify!
self.last_screen.selected = .{
.idx = m.idx,
.highlight = untracked,
};
log.debug("notifying selection updated idx={}", .{m.idx});
cb(
.{ .selected_match = .{
.idx = m.idx,
.highlight = flattened,
} },
ud,
);
} else if (self.last_screen.selected != null) {
log.debug("notifying selection cleared", .{});
self.last_screen.selected = null;
cb(
.{ .selected_match = null },
ud,
);
}
// Send our complete notification if we just completed.
if (!self.last_complete and self.isComplete()) {
log.debug("notifying search complete", .{});
@ -675,40 +757,42 @@ const Search = struct {
}
};
const TestUserData = struct {
const Self = @This();
reset: std.Thread.ResetEvent = .{},
total: usize = 0,
selected: ?Event.SelectedMatch = null,
viewport: []FlattenedHighlight = &.{},
fn deinit(self: *Self) void {
for (self.viewport) |*hl| hl.deinit(testing.allocator);
testing.allocator.free(self.viewport);
}
fn callback(event: Event, userdata: ?*anyopaque) void {
const ud: *Self = @ptrCast(@alignCast(userdata.?));
switch (event) {
.quit => {},
.complete => ud.reset.set(),
.total_matches => |v| ud.total = v,
.selected_match => |v| ud.selected = v,
.viewport_matches => |v| {
for (ud.viewport) |*hl| hl.deinit(testing.allocator);
testing.allocator.free(ud.viewport);
ud.viewport = testing.allocator.alloc(
FlattenedHighlight,
v.len,
) catch unreachable;
for (ud.viewport, v) |*dst, src| {
dst.* = src.clone(testing.allocator) catch unreachable;
}
},
}
}
};
test {
const UserData = struct {
const Self = @This();
reset: std.Thread.ResetEvent = .{},
total: usize = 0,
viewport: []FlattenedHighlight = &.{},
fn deinit(self: *Self) void {
for (self.viewport) |*hl| hl.deinit(testing.allocator);
testing.allocator.free(self.viewport);
}
fn callback(event: Event, userdata: ?*anyopaque) void {
const ud: *Self = @ptrCast(@alignCast(userdata.?));
switch (event) {
.quit => {},
.complete => ud.reset.set(),
.total_matches => |v| ud.total = v,
.viewport_matches => |v| {
for (ud.viewport) |*hl| hl.deinit(testing.allocator);
testing.allocator.free(ud.viewport);
ud.viewport = testing.allocator.alloc(
FlattenedHighlight,
v.len,
) catch unreachable;
for (ud.viewport, v) |*dst, src| {
dst.* = src.clone(testing.allocator) catch unreachable;
}
},
}
}
};
const alloc = testing.allocator;
var mutex: std.Thread.Mutex = .{};
var t: Terminal = try .init(alloc, .{ .cols = 20, .rows = 2 });
@ -718,12 +802,12 @@ test {
defer stream.deinit();
try stream.nextSlice("Hello, world");
var ud: UserData = .{};
var ud: TestUserData = .{};
defer ud.deinit();
var thread: Thread = try .init(alloc, .{
.mutex = &mutex,
.terminal = &t,
.event_cb = &UserData.callback,
.event_cb = &TestUserData.callback,
.event_userdata = &ud,
});
defer thread.deinit();

View File

@ -3,7 +3,9 @@ const assert = @import("../../quirks.zig").inlineAssert;
const testing = std.testing;
const Allocator = std.mem.Allocator;
const point = @import("../point.zig");
const FlattenedHighlight = @import("../highlight.zig").Flattened;
const highlight = @import("../highlight.zig");
const FlattenedHighlight = highlight.Flattened;
const TrackedHighlight = highlight.Tracked;
const PageList = @import("../PageList.zig");
const Pin = PageList.Pin;
const Screen = @import("../Screen.zig");
@ -13,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.
///
@ -41,6 +45,11 @@ pub const ScreenSearch = struct {
/// Current state of the search, a state machine.
state: State,
/// The currently selected match, if any. As the screen contents
/// change or get pruned, the screen search will do its best to keep
/// this accurate.
selected: ?SelectedMatch = null,
/// The results found so far. These are stored separately because history
/// is mostly immutable once found, while active area results may
/// change. This lets us easily reset the active area results for a
@ -48,6 +57,18 @@ pub const ScreenSearch = struct {
history_results: std.ArrayList(FlattenedHighlight),
active_results: std.ArrayList(FlattenedHighlight),
pub const SelectedMatch = struct {
/// Index from the end of the match list (0 = most recent match)
idx: usize,
/// Tracked highlight so we can detect movement.
highlight: TrackedHighlight,
pub fn deinit(self: *SelectedMatch, screen: *Screen) void {
self.highlight.deinit(screen);
}
};
/// History search state.
const HistorySearch = struct {
/// The actual searcher state.
@ -90,6 +111,11 @@ pub const ScreenSearch = struct {
pub fn needsFeed(self: State) bool {
return switch (self) {
.history_feed => true,
// Not obvious but complete search states will prune
// stale history results on feed.
.complete => true,
else => false,
};
}
@ -121,6 +147,7 @@ pub const ScreenSearch = struct {
const alloc = self.allocator();
self.active.deinit();
if (self.history) |*h| h.deinit(self.screen);
if (self.selected) |*m| m.deinit(self.screen);
for (self.active_results.items) |*hl| hl.deinit(alloc);
self.active_results.deinit(alloc);
for (self.history_results.items) |*hl| hl.deinit(alloc);
@ -216,6 +243,9 @@ pub const ScreenSearch = struct {
/// Feed more data to the searcher so it can continue searching. This
/// accesses the screen state, so the caller must hold the necessary locks.
///
/// Feed on a complete screen search will perform some cleanup of
/// potentially stale history results (pruned) and reclaim some memory.
pub fn feed(self: *ScreenSearch) Allocator.Error!void {
const history: *PageListSearch = if (self.history) |*h| &h.searcher else {
// No history to feed, search is complete.
@ -228,6 +258,11 @@ pub const ScreenSearch = struct {
if (!try history.feed()) {
// No more data to feed, search is complete.
self.state = .complete;
// We use this opportunity to also clean up older history
// results that may be gone due to scrollback pruning, though.
self.pruneHistory();
return;
}
@ -246,6 +281,55 @@ pub const ScreenSearch = struct {
}
}
fn pruneHistory(self: *ScreenSearch) void {
const history: *PageListSearch = if (self.history) |*h| &h.searcher else return;
// Keep track of the last checked node to avoid redundant work.
var last_checked: ?*PageList.List.Node = null;
// Go through our history results in reverse order to find
// the oldest matches first (since oldest nodes are pruned first).
for (0..self.history_results.items.len) |rev_i| {
const i = self.history_results.items.len - 1 - rev_i;
const node = node: {
const hl = &self.history_results.items[i];
break :node hl.chunks.items(.node)[0];
};
// If this is the same node as what we last checked and
// found to prune, then continue until we find the first
// non-matching, non-pruned node so we can prune the older
// ones.
if (last_checked == node) continue;
last_checked = node;
// Try to find this node in the PageList using a standard
// O(N) traversal. This isn't as bad as it seems because our
// oldest matches are likely to be near the start of the
// list and as soon as we find one we're done.
var it = history.list.pages.first;
while (it) |valid_node| : (it = valid_node.next) {
if (valid_node != node) continue;
// This is a valid node. If we're not at rev_i 0 then
// it means we have some data to prune! If we are
// at rev_i 0 then we can break out because there
// is nothing to prune.
if (rev_i == 0) return;
// Prune the last rev_i items.
const alloc = self.allocator();
for (self.history_results.items[i + 1 ..]) |*prune_hl| {
prune_hl.deinit(alloc);
}
self.history_results.shrinkAndFree(alloc, i);
// Once we've pruned, future results can't be invalid.
return;
}
}
}
fn tickActive(self: *ScreenSearch) Allocator.Error!void {
// For the active area, we consume the entire search in one go
// because the active area is generally small.
@ -284,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.
@ -298,6 +386,24 @@ 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(.prev) catch |err| {
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: {
@ -392,8 +498,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();
@ -410,6 +550,203 @@ 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});
};
}
}
/// Return the selected match.
///
/// This does not require read/write access to the underlying screen.
pub fn selectedMatch(self: *const ScreenSearch) ?FlattenedHighlight {
const sel = self.selected orelse return null;
const active_len = self.active_results.items.len;
if (sel.idx < active_len) {
return self.active_results.items[active_len - 1 - sel.idx];
}
const history_len = self.history_results.items.len;
if (sel.idx < active_len + history_len) {
return self.history_results.items[sel.idx - active_len];
}
return null;
}
pub const Select = enum {
/// Next selection, in reverse order (newest to oldest),
/// non-wrapping.
next,
/// Prev selection, in forward order (oldest to newest),
/// non-wrapping.
prev,
};
/// Select the next or previous search result. This requires read/write
/// access to the underlying screen, since we utilize tracked pins to
/// ensure our selection sticks with contents changing.
pub fn select(self: *ScreenSearch, to: Select) Allocator.Error!bool {
// All selection requires valid pins so we prune history and
// reload our active area immediately. This ensures all search
// results point to valid nodes.
try self.reloadActive();
self.pruneHistory();
return switch (to) {
.next => try self.selectNext(),
.prev => try self.selectPrev(),
};
}
fn selectNext(self: *ScreenSearch) Allocator.Error!bool {
// Get our previous match so we can change it. If we have no
// prior match, we have the easy task of getting the first.
var prev = if (self.selected) |*m| m else {
// Get our highlight
const hl: FlattenedHighlight = hl: {
if (self.active_results.items.len > 0) {
// Active is in forward order
const len = self.active_results.items.len;
break :hl self.active_results.items[len - 1];
} else if (self.history_results.items.len > 0) {
// History is in reverse order
break :hl self.history_results.items[0];
} else {
// No matches at all. Can't select anything.
return false;
}
};
// Pin it so we can track any movement
const tracked = try hl.untracked().track(self.screen);
errdefer tracked.deinit(self.screen);
// Our selection is index zero since we just started and
// we store our selection.
self.selected = .{
.idx = 0,
.highlight = tracked,
};
return true;
};
const next_idx = prev.idx + 1;
const active_len = self.active_results.items.len;
const history_len = self.history_results.items.len;
if (next_idx >= active_len + history_len) {
// No more matches. We don't wrap or reset the match currently.
return false;
}
const hl: FlattenedHighlight = if (next_idx < active_len)
self.active_results.items[active_len - 1 - next_idx]
else
self.history_results.items[next_idx - active_len];
// Pin it so we can track any movement
const tracked = try hl.untracked().track(self.screen);
errdefer tracked.deinit(self.screen);
// Free our previous match and setup our new selection
prev.deinit(self.screen);
self.selected = .{
.idx = next_idx,
.highlight = tracked,
};
return true;
}
fn selectPrev(self: *ScreenSearch) Allocator.Error!bool {
// Get our previous match so we can change it. If we have no
// prior match, we have the easy task of getting the last.
var prev = if (self.selected) |*m| m else {
// Get our highlight (oldest match)
const hl: FlattenedHighlight = hl: {
if (self.history_results.items.len > 0) {
// History is in reverse order, so last item is oldest
const len = self.history_results.items.len;
break :hl self.history_results.items[len - 1];
} else if (self.active_results.items.len > 0) {
// Active is in forward order, so first item is oldest
break :hl self.active_results.items[0];
} else {
// No matches at all. Can't select anything.
return false;
}
};
// Pin it so we can track any movement
const tracked = try hl.untracked().track(self.screen);
errdefer tracked.deinit(self.screen);
// Our selection is the last index since we just started
// and we store our selection.
const active_len = self.active_results.items.len;
const history_len = self.history_results.items.len;
self.selected = .{
.idx = active_len + history_len - 1,
.highlight = tracked,
};
return true;
};
// Can't go below zero
if (prev.idx == 0) {
// No more matches. We don't wrap or reset the match currently.
return false;
}
const next_idx = prev.idx - 1;
const active_len = self.active_results.items.len;
const hl: FlattenedHighlight = if (next_idx < active_len)
self.active_results.items[active_len - 1 - next_idx]
else
self.history_results.items[next_idx - active_len];
// Pin it so we can track any movement
const tracked = try hl.untracked().track(self.screen);
errdefer tracked.deinit(self.screen);
// Free our previous match and setup our new selection
prev.deinit(self.screen);
self.selected = .{
.idx = next_idx,
.highlight = tracked,
};
return true;
}
};
@ -623,3 +960,352 @@ test "active change contents" {
} }, t.screens.active.pages.pointFromPin(.screen, sel.end).?);
}
}
test "select next" {
const alloc = testing.allocator;
var t: Terminal = try .init(alloc, .{ .cols = 10, .rows = 2 });
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();
// Initially no selection
try testing.expect(search.selectedMatch() == null);
// Select our next match (first)
try search.searchAll();
_ = try search.select(.next);
{
const sel = search.selectedMatch().?.untracked();
try testing.expectEqual(point.Point{ .screen = .{
.x = 0,
.y = 2,
} }, t.screens.active.pages.pointFromPin(.screen, sel.start).?);
try testing.expectEqual(point.Point{ .screen = .{
.x = 3,
.y = 2,
} }, t.screens.active.pages.pointFromPin(.screen, sel.end).?);
}
// Next match
_ = 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).?);
}
// Next match (no wrap)
_ = 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).?);
}
}
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).?);
}
}
test "select prev" {
const alloc = testing.allocator;
var t: Terminal = try .init(alloc, .{ .cols = 10, .rows = 2 });
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();
// Initially no selection
try testing.expect(search.selectedMatch() == null);
// Select prev (oldest first)
try search.searchAll();
_ = try search.select(.prev);
{
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).?);
}
// Prev match (towards newest)
_ = try search.select(.prev);
{
const sel = search.selectedMatch().?.untracked();
try testing.expectEqual(point.Point{ .screen = .{
.x = 0,
.y = 2,
} }, t.screens.active.pages.pointFromPin(.screen, sel.start).?);
try testing.expectEqual(point.Point{ .screen = .{
.x = 3,
.y = 2,
} }, t.screens.active.pages.pointFromPin(.screen, sel.end).?);
}
// Prev match (no wrap, stays at newest)
_ = try search.select(.prev);
{
const sel = search.selectedMatch().?.untracked();
try testing.expectEqual(point.Point{ .screen = .{
.x = 0,
.y = 2,
} }, t.screens.active.pages.pointFromPin(.screen, sel.start).?);
try testing.expectEqual(point.Point{ .screen = .{
.x = 3,
.y = 2,
} }, t.screens.active.pages.pointFromPin(.screen, sel.end).?);
}
}
test "select prev then next" {
const alloc = testing.allocator;
var t: Terminal = try .init(alloc, .{ .cols = 10, .rows = 2 });
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();
// Select next (newest first)
_ = try search.select(.next);
{
const sel = search.selectedMatch().?.untracked();
try testing.expectEqual(point.Point{ .screen = .{
.x = 0,
.y = 2,
} }, t.screens.active.pages.pointFromPin(.screen, sel.start).?);
}
// Select next (older)
_ = 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).?);
}
// Select prev (back to newer)
_ = try search.select(.prev);
{
const sel = search.selectedMatch().?.untracked();
try testing.expectEqual(point.Point{ .screen = .{
.x = 0,
.y = 2,
} }, t.screens.active.pages.pointFromPin(.screen, sel.start).?);
}
}
test "select prev with 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("Fizz.");
var search: ScreenSearch = try .init(alloc, t.screens.active, "Fizz");
defer search.deinit();
try search.searchAll();
// Select prev (oldest first, should be in history)
_ = try search.select(.prev);
{
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).?);
}
// Select prev (towards newer, should move to active area)
_ = try search.select(.prev);
{
const sel = search.selectedMatch().?.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).?);
}
}