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-0279480ac357pull/9713/head
commit
14abc6a49d
|
|
@ -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| {
|
||||
|
|
|
|||
|
|
@ -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:
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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| {
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
},
|
||||
},
|
||||
);
|
||||
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
|
|
|
|||
|
|
@ -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).?);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
Loading…
Reference in New Issue