Search binding, viewport rendering (#9687)
The march towards #189 continues. This hooks up the search thread to the main surface and all the state necessary for the renderer to show search results in the viewport! This also adds a `search` binding which takes a query to start/stop a search. **This still doesn't add any search GUI,** which will come later, the internals must happen first. A non-blank binding will start or change the search term. An empty binding will stop search: ``` keybind = cmd+f=search:Hello keybind = shift+cmd+f=search: ``` > [!NOTE] > > Obviously, search will eventually have a GUI. The point of this PR is primarily to connect all the various internal systems more than anything. GUI will come soon. ## Demo https://github.com/user-attachments/assets/06af5a3b-280e-4804-b506-419b92a95f99 ## Major Changes The only major changes required as part of this is the introduction of what I'm calling the terminal "highlight" system. This is a generic system for highlighting portions of the terminal contents. These will ultimately underpin what we currently call "selections" (selecting text with your mouse/keyboard) but that is far too large a change to make in one PR. Therefore, this PR introduces highlights and the only consumer is the entire search subsystem. ## Limitations Still plenty of limitations we need to keep marching towards resolving: - ~~Search matches are styled the same as selections. They should be different.~~ - There is no way to iterate search matches, yet. - There is no GUI for inputting a search query or viewing total matches, yet. - ~~I think searches are case-sensitive currently and they should probably not be.~~ Done, for ASCII. But hey, it's something!pull/9695/head
commit
6f0927c42a
124
src/Surface.zig
124
src/Surface.zig
|
|
@ -155,6 +155,9 @@ selection_scroll_active: bool = false,
|
|||
/// the wall clock time that has elapsed between timestamps.
|
||||
command_timer: ?std.time.Instant = null,
|
||||
|
||||
/// Search state
|
||||
search: ?Search = null,
|
||||
|
||||
/// The effect of an input event. This can be used by callers to take
|
||||
/// the appropriate action after an input event. For example, key
|
||||
/// input can be forwarded to the OS for further processing if it
|
||||
|
|
@ -174,6 +177,26 @@ pub const InputEffect = enum {
|
|||
closed,
|
||||
};
|
||||
|
||||
/// The search state for the surface.
|
||||
const Search = struct {
|
||||
state: terminal.search.Thread,
|
||||
thread: std.Thread,
|
||||
|
||||
pub fn deinit(self: *Search) void {
|
||||
// Notify the thread to stop
|
||||
self.state.stop.notify() catch |err| log.err(
|
||||
"error notifying search thread to stop, may stall err={}",
|
||||
.{err},
|
||||
);
|
||||
|
||||
// Wait for the OS thread to quit
|
||||
self.thread.join();
|
||||
|
||||
// Now it is safe to deinit the state
|
||||
self.state.deinit();
|
||||
}
|
||||
};
|
||||
|
||||
/// Mouse state for the surface.
|
||||
const Mouse = struct {
|
||||
/// The last tracked mouse button state by button.
|
||||
|
|
@ -728,6 +751,9 @@ pub fn init(
|
|||
}
|
||||
|
||||
pub fn deinit(self: *Surface) void {
|
||||
// Stop search thread
|
||||
if (self.search) |*s| s.deinit();
|
||||
|
||||
// Stop rendering thread
|
||||
{
|
||||
self.renderer_thread.stop.notify() catch |err|
|
||||
|
|
@ -1301,6 +1327,61 @@ fn reportColorScheme(self: *Surface, force: bool) void {
|
|||
self.io.queueMessage(.{ .write_stable = output }, .unlocked);
|
||||
}
|
||||
|
||||
fn searchCallback(event: terminal.search.Thread.Event, ud: ?*anyopaque) void {
|
||||
// IMPORTANT: This function is run on the SEARCH THREAD! It is NOT SAFE
|
||||
// to access anything other than values that never change on the surface.
|
||||
// The surface is guaranteed to be valid for the lifetime of the search
|
||||
// thread.
|
||||
const self: *Surface = @ptrCast(@alignCast(ud.?));
|
||||
self.searchCallback_(event) catch |err| {
|
||||
log.warn("error in search callback err={}", .{err});
|
||||
};
|
||||
}
|
||||
|
||||
fn searchCallback_(
|
||||
self: *Surface,
|
||||
event: terminal.search.Thread.Event,
|
||||
) !void {
|
||||
// NOTE: This runs on the search thread.
|
||||
|
||||
switch (event) {
|
||||
.viewport_matches => |matches_unowned| {
|
||||
var arena: ArenaAllocator = .init(self.alloc);
|
||||
errdefer arena.deinit();
|
||||
const alloc = arena.allocator();
|
||||
|
||||
const matches = try alloc.dupe(terminal.highlight.Flattened, matches_unowned);
|
||||
for (matches) |*m| m.* = try m.clone(alloc);
|
||||
|
||||
_ = self.renderer_thread.mailbox.push(
|
||||
.{ .search_viewport_matches = .{
|
||||
.arena = arena,
|
||||
.matches = matches,
|
||||
} },
|
||||
.forever,
|
||||
);
|
||||
try self.renderer_thread.wakeup.notify();
|
||||
},
|
||||
|
||||
// When we quit, tell our renderer to reset any search state.
|
||||
.quit => {
|
||||
_ = self.renderer_thread.mailbox.push(
|
||||
.{ .search_viewport_matches = .{
|
||||
.arena = .init(self.alloc),
|
||||
.matches = &.{},
|
||||
} },
|
||||
.forever,
|
||||
);
|
||||
try self.renderer_thread.wakeup.notify();
|
||||
},
|
||||
|
||||
// Unhandled, so far.
|
||||
.total_matches,
|
||||
.complete,
|
||||
=> {},
|
||||
}
|
||||
}
|
||||
|
||||
/// Call this when modifiers change. This is safe to call even if modifiers
|
||||
/// match the previous state.
|
||||
///
|
||||
|
|
@ -4770,6 +4851,49 @@ pub fn performBindingAction(self: *Surface, action: input.Binding.Action) !bool
|
|||
self.renderer_state.terminal.fullReset();
|
||||
},
|
||||
|
||||
.search => |text| search: {
|
||||
const s: *Search = if (self.search) |*s| s else init: {
|
||||
// If we're stopping the search and we had no prior search,
|
||||
// then there is nothing to do.
|
||||
if (text.len == 0) break :search;
|
||||
|
||||
// We need to assign directly to self.search because we need
|
||||
// a stable pointer back to the thread state.
|
||||
self.search = .{
|
||||
.state = try .init(self.alloc, .{
|
||||
.mutex = self.renderer_state.mutex,
|
||||
.terminal = self.renderer_state.terminal,
|
||||
.event_cb = &searchCallback,
|
||||
.event_userdata = self,
|
||||
}),
|
||||
.thread = undefined,
|
||||
};
|
||||
const s: *Search = &self.search.?;
|
||||
errdefer s.state.deinit();
|
||||
|
||||
s.thread = try .spawn(
|
||||
.{},
|
||||
terminal.search.Thread.threadMain,
|
||||
.{&s.state},
|
||||
);
|
||||
s.thread.setName("search") catch {};
|
||||
|
||||
break :init s;
|
||||
};
|
||||
|
||||
// Zero-length text means stop searching.
|
||||
if (text.len == 0) {
|
||||
s.deinit();
|
||||
self.search = null;
|
||||
break :search;
|
||||
}
|
||||
|
||||
_ = s.state.mailbox.push(
|
||||
.{ .change_needle = text },
|
||||
.forever,
|
||||
);
|
||||
},
|
||||
|
||||
.copy_to_clipboard => |format| {
|
||||
// We can read from the renderer state without holding
|
||||
// the lock because only we will write to this field.
|
||||
|
|
|
|||
|
|
@ -978,6 +978,20 @@ palette: Palette = .{},
|
|||
/// Available since: 1.1.0
|
||||
@"split-divider-color": ?Color = null,
|
||||
|
||||
/// The foreground and background color for search matches. This only applies
|
||||
/// to non-focused search matches, also known as candidate matches.
|
||||
///
|
||||
/// 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
|
||||
@"search-foreground": TerminalColor = .{ .color = .{ .r = 0, .g = 0, .b = 0 } },
|
||||
@"search-background": TerminalColor = .{ .color = .{ .r = 0xFF, .g = 0xE0, .b = 0x82 } },
|
||||
|
||||
/// 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:
|
||||
|
|
|
|||
|
|
@ -91,15 +91,24 @@ pub fn CircBuf(comptime T: type, comptime default: T) type {
|
|||
self.full = self.head == self.tail;
|
||||
}
|
||||
|
||||
/// Append a slice to the buffer. If the buffer cannot fit the
|
||||
/// entire slice then an error will be returned. It is up to the
|
||||
/// caller to rotate the circular buffer if they want to overwrite
|
||||
/// the oldest data.
|
||||
pub fn appendSlice(
|
||||
/// Append a single value to the buffer, assuming there is capacity.
|
||||
pub fn appendAssumeCapacity(self: *Self, v: T) void {
|
||||
assert(!self.full);
|
||||
self.storage[self.head] = v;
|
||||
self.head += 1;
|
||||
if (self.head >= self.storage.len) self.head = 0;
|
||||
self.full = self.head == self.tail;
|
||||
}
|
||||
|
||||
/// Append a slice to the buffer.
|
||||
pub fn appendSliceAssumeCapacity(
|
||||
self: *Self,
|
||||
slice: []const T,
|
||||
) Allocator.Error!void {
|
||||
const storage = self.getPtrSlice(self.len(), slice.len);
|
||||
) void {
|
||||
const storage = self.getPtrSlice(
|
||||
self.len(),
|
||||
slice.len,
|
||||
);
|
||||
fastmem.copy(T, storage[0], slice[0..storage[0].len]);
|
||||
fastmem.copy(T, storage[1], slice[storage[0].len..]);
|
||||
}
|
||||
|
|
@ -456,7 +465,7 @@ test "CircBuf append slice" {
|
|||
var buf = try Buf.init(alloc, 5);
|
||||
defer buf.deinit(alloc);
|
||||
|
||||
try buf.appendSlice("hello");
|
||||
buf.appendSliceAssumeCapacity("hello");
|
||||
{
|
||||
var it = buf.iterator(.forward);
|
||||
try testing.expect(it.next().?.* == 'h');
|
||||
|
|
@ -486,7 +495,7 @@ test "CircBuf append slice with wrap" {
|
|||
try testing.expect(!buf.full);
|
||||
try testing.expectEqual(@as(usize, 2), buf.len());
|
||||
|
||||
try buf.appendSlice("AB");
|
||||
buf.appendSliceAssumeCapacity("AB");
|
||||
{
|
||||
var it = buf.iterator(.forward);
|
||||
try testing.expect(it.next().?.* == 0);
|
||||
|
|
|
|||
|
|
@ -332,6 +332,10 @@ pub const Action = union(enum) {
|
|||
/// to 14.5 points.
|
||||
set_font_size: f32,
|
||||
|
||||
/// Start a search for the given text. If the text is empty, then
|
||||
/// the search is canceled. If a previous search is active, it is replaced.
|
||||
search: []const u8,
|
||||
|
||||
/// Clear the screen and all scrollback.
|
||||
clear_screen,
|
||||
|
||||
|
|
@ -1152,6 +1156,7 @@ pub const Action = union(enum) {
|
|||
.esc,
|
||||
.text,
|
||||
.cursor_key,
|
||||
.search,
|
||||
.reset,
|
||||
.copy_to_clipboard,
|
||||
.copy_url_to_clipboard,
|
||||
|
|
|
|||
|
|
@ -604,6 +604,7 @@ fn actionCommands(action: Action.Key) []const Command {
|
|||
.csi,
|
||||
.esc,
|
||||
.cursor_key,
|
||||
.search,
|
||||
.set_font_size,
|
||||
.scroll_to_row,
|
||||
.scroll_page_fractional,
|
||||
|
|
|
|||
|
|
@ -26,6 +26,7 @@ pub const point = terminal.point;
|
|||
pub const color = terminal.color;
|
||||
pub const device_status = terminal.device_status;
|
||||
pub const formatter = terminal.formatter;
|
||||
pub const highlight = terminal.highlight;
|
||||
pub const kitty = terminal.kitty;
|
||||
pub const modes = terminal.modes;
|
||||
pub const page = terminal.page;
|
||||
|
|
|
|||
|
|
@ -451,6 +451,14 @@ fn drainMailbox(self: *Thread) !void {
|
|||
self.startDrawTimer();
|
||||
},
|
||||
|
||||
.search_viewport_matches => |v| {
|
||||
// Note we don't free the new value because we expect our
|
||||
// allocators to match.
|
||||
if (self.renderer.search_matches) |*m| m.arena.deinit();
|
||||
self.renderer.search_matches = v;
|
||||
self.renderer.search_matches_dirty = true;
|
||||
},
|
||||
|
||||
.inspector => |v| self.flags.has_inspector = v,
|
||||
|
||||
.macos_display_id => |v| {
|
||||
|
|
|
|||
|
|
@ -122,6 +122,16 @@ pub fn Renderer(comptime GraphicsAPI: type) type {
|
|||
scrollbar: terminal.Scrollbar,
|
||||
scrollbar_dirty: bool,
|
||||
|
||||
/// The most recent viewport matches so that we can render search
|
||||
/// matches in the visible frame. This is provided asynchronously
|
||||
/// from the search thread so we have the dirty flag to also note
|
||||
/// if we need to rebuild our cells to include search highlights.
|
||||
///
|
||||
/// 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_matches_dirty: bool,
|
||||
|
||||
/// The current set of cells to render. This is rebuilt on every frame
|
||||
/// but we keep this around so that we don't reallocate. Each set of
|
||||
/// cells goes into a separate shader.
|
||||
|
|
@ -527,6 +537,8 @@ pub fn Renderer(comptime GraphicsAPI: type) type {
|
|||
foreground: terminal.color.RGB,
|
||||
selection_background: ?configpkg.Config.TerminalColor,
|
||||
selection_foreground: ?configpkg.Config.TerminalColor,
|
||||
search_background: configpkg.Config.TerminalColor,
|
||||
search_foreground: configpkg.Config.TerminalColor,
|
||||
bold_color: ?configpkg.BoldColor,
|
||||
faint_opacity: u8,
|
||||
min_contrast: f32,
|
||||
|
|
@ -598,6 +610,8 @@ pub fn Renderer(comptime GraphicsAPI: type) type {
|
|||
|
||||
.selection_background = config.@"selection-background",
|
||||
.selection_foreground = config.@"selection-foreground",
|
||||
.search_background = config.@"search-background",
|
||||
.search_foreground = config.@"search-foreground",
|
||||
|
||||
.custom_shaders = custom_shaders,
|
||||
.bg_image = bg_image,
|
||||
|
|
@ -672,6 +686,8 @@ pub fn Renderer(comptime GraphicsAPI: type) type {
|
|||
.focused = true,
|
||||
.scrollbar = .zero,
|
||||
.scrollbar_dirty = false,
|
||||
.search_matches = null,
|
||||
.search_matches_dirty = false,
|
||||
|
||||
// Render state
|
||||
.cells = .{},
|
||||
|
|
@ -744,7 +760,7 @@ pub fn Renderer(comptime GraphicsAPI: type) type {
|
|||
|
||||
pub fn deinit(self: *Self) void {
|
||||
self.terminal_state.deinit(self.alloc);
|
||||
|
||||
if (self.search_matches) |*m| m.arena.deinit();
|
||||
self.swap_chain.deinit();
|
||||
|
||||
if (DisplayLink != void) {
|
||||
|
|
@ -1114,6 +1130,12 @@ pub fn Renderer(comptime GraphicsAPI: type) type {
|
|||
// Update our terminal state
|
||||
try self.terminal_state.update(self.alloc, state.terminal);
|
||||
|
||||
// If our terminal state is dirty at all we need to redo
|
||||
// the viewport search.
|
||||
if (self.terminal_state.dirty != .false) {
|
||||
state.terminal.flags.search_viewport_dirty = true;
|
||||
}
|
||||
|
||||
// Get our scrollbar out of the terminal. We synchronize
|
||||
// the scrollbar read with frame data updates because this
|
||||
// naturally limits the number of calls to this method (it
|
||||
|
|
@ -1179,6 +1201,25 @@ pub fn Renderer(comptime GraphicsAPI: type) type {
|
|||
log.warn("error searching for regex links err={}", .{err});
|
||||
};
|
||||
|
||||
// Clear our highlight state and update.
|
||||
if (self.search_matches_dirty or self.terminal_state.dirty != .false) {
|
||||
self.search_matches_dirty = false;
|
||||
|
||||
for (self.terminal_state.row_data.items(.highlights)) |*highlights| {
|
||||
highlights.clearRetainingCapacity();
|
||||
}
|
||||
|
||||
if (self.search_matches) |m| {
|
||||
self.terminal_state.updateHighlightsFlattened(
|
||||
self.alloc,
|
||||
m.matches,
|
||||
) catch |err| {
|
||||
// Not a critical error, we just won't show highlights.
|
||||
log.warn("error updating search highlights err={}", .{err});
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
// Build our GPU cells
|
||||
try self.rebuildCells(
|
||||
critical.preedit,
|
||||
|
|
@ -2354,6 +2395,7 @@ pub fn Renderer(comptime GraphicsAPI: type) type {
|
|||
const row_cells = row_data.items(.cells);
|
||||
const row_dirty = row_data.items(.dirty);
|
||||
const row_selection = row_data.items(.selection);
|
||||
const row_highlights = row_data.items(.highlights);
|
||||
|
||||
// If our cell contents buffer is shorter than the screen viewport,
|
||||
// we render the rows that fit, starting from the bottom. If instead
|
||||
|
|
@ -2369,7 +2411,8 @@ pub fn Renderer(comptime GraphicsAPI: type) type {
|
|||
row_cells[0..row_len],
|
||||
row_dirty[0..row_len],
|
||||
row_selection[0..row_len],
|
||||
) |y_usize, row, *cells, *dirty, selection| {
|
||||
row_highlights[0..row_len],
|
||||
) |y_usize, row, *cells, *dirty, selection, highlights| {
|
||||
const y: terminal.size.CellCountInt = @intCast(y_usize);
|
||||
|
||||
if (!rebuild) {
|
||||
|
|
@ -2513,15 +2556,30 @@ pub fn Renderer(comptime GraphicsAPI: type) type {
|
|||
.{};
|
||||
|
||||
// True if this cell is selected
|
||||
const selected: bool = selected: {
|
||||
const sel = selection orelse break :selected false;
|
||||
const selected: enum {
|
||||
false,
|
||||
selection,
|
||||
search,
|
||||
} = 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;
|
||||
}
|
||||
}
|
||||
|
||||
const sel = selection orelse break :selected .false;
|
||||
const x_compare = if (wide == .spacer_tail)
|
||||
x -| 1
|
||||
else
|
||||
x;
|
||||
|
||||
break :selected x_compare >= sel[0] and
|
||||
x_compare <= sel[1];
|
||||
if (x_compare >= sel[0] and
|
||||
x_compare <= sel[1]) break :selected .selection;
|
||||
|
||||
break :selected .false;
|
||||
};
|
||||
|
||||
// The `_style` suffixed values are the colors based on
|
||||
|
|
@ -2538,25 +2596,26 @@ pub fn Renderer(comptime GraphicsAPI: type) type {
|
|||
});
|
||||
|
||||
// The final background color for the cell.
|
||||
const bg = bg: {
|
||||
if (selected) {
|
||||
// If we have an explicit selection background color
|
||||
// specified int he config, use that
|
||||
if (self.config.selection_background) |v| {
|
||||
break :bg switch (v) {
|
||||
.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,
|
||||
};
|
||||
}
|
||||
const bg = switch (selected) {
|
||||
// If we have an explicit selection background color
|
||||
// specified in the config, use that.
|
||||
//
|
||||
// If no configuration, then our selection background
|
||||
// is our foreground color.
|
||||
.selection => if (self.config.selection_background) |v| switch (v) {
|
||||
.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,
|
||||
} else state.colors.foreground,
|
||||
|
||||
// If no configuration, then our selection background
|
||||
// is our foreground color.
|
||||
break :bg state.colors.foreground;
|
||||
}
|
||||
.search => switch (self.config.search_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
|
||||
break :bg if (style.flags.inverse != isCovering(cell.codepoint()))
|
||||
.false => if (style.flags.inverse != isCovering(cell.codepoint()))
|
||||
// Two cases cause us to invert (use the fg color as the bg)
|
||||
// - The "inverse" style flag.
|
||||
// - A "covering" glyph; we use fg for bg in that
|
||||
|
|
@ -2568,7 +2627,7 @@ pub fn Renderer(comptime GraphicsAPI: type) type {
|
|||
fg_style
|
||||
else
|
||||
// Otherwise they cancel out.
|
||||
bg_style;
|
||||
bg_style,
|
||||
};
|
||||
|
||||
const fg = fg: {
|
||||
|
|
@ -2580,23 +2639,24 @@ pub fn Renderer(comptime GraphicsAPI: type) type {
|
|||
// - Cell is selected, inverted, and set to cell-foreground
|
||||
// - Cell is selected, not inverted, and set to cell-background
|
||||
// - Cell is inverted and not selected
|
||||
if (selected) {
|
||||
// Use the selection foreground if set
|
||||
if (self.config.selection_foreground) |v| {
|
||||
break :fg switch (v) {
|
||||
.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,
|
||||
};
|
||||
}
|
||||
break :fg switch (selected) {
|
||||
.selection => if (self.config.selection_foreground) |v| switch (v) {
|
||||
.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,
|
||||
} else state.colors.background,
|
||||
|
||||
break :fg state.colors.background;
|
||||
}
|
||||
.search => switch (self.config.search_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,
|
||||
},
|
||||
|
||||
break :fg if (style.flags.inverse)
|
||||
final_bg
|
||||
else
|
||||
fg_style;
|
||||
.false => if (style.flags.inverse)
|
||||
final_bg
|
||||
else
|
||||
fg_style,
|
||||
};
|
||||
};
|
||||
|
||||
// Foreground alpha for this cell.
|
||||
|
|
@ -2614,7 +2674,7 @@ pub fn Renderer(comptime GraphicsAPI: type) type {
|
|||
const default: u8 = 255;
|
||||
|
||||
// Cells that are selected should be fully opaque.
|
||||
if (selected) break :bg_alpha default;
|
||||
if (selected != .false) break :bg_alpha default;
|
||||
|
||||
// Cells that are reversed should be fully opaque.
|
||||
if (style.flags.inverse) break :bg_alpha default;
|
||||
|
|
|
|||
|
|
@ -1,6 +1,7 @@
|
|||
const std = @import("std");
|
||||
const assert = @import("../quirks.zig").inlineAssert;
|
||||
const Allocator = std.mem.Allocator;
|
||||
const ArenaAllocator = std.heap.ArenaAllocator;
|
||||
const configpkg = @import("../config.zig");
|
||||
const font = @import("../font/main.zig");
|
||||
const renderer = @import("../renderer.zig");
|
||||
|
|
@ -10,7 +11,7 @@ const terminal = @import("../terminal/main.zig");
|
|||
pub const Message = union(enum) {
|
||||
/// Purposely crash the renderer. This is used for testing and debugging.
|
||||
/// See the "crash" binding action.
|
||||
crash: void,
|
||||
crash,
|
||||
|
||||
/// A change in state in the window focus that this renderer is
|
||||
/// rendering within. This is only sent when a change is detected so
|
||||
|
|
@ -24,7 +25,7 @@ pub const Message = union(enum) {
|
|||
|
||||
/// Reset the cursor blink by immediately showing the cursor then
|
||||
/// restarting the timer.
|
||||
reset_cursor_blink: void,
|
||||
reset_cursor_blink,
|
||||
|
||||
/// Change the font grid. This can happen for any number of reasons
|
||||
/// including a font size change, family change, etc.
|
||||
|
|
@ -52,12 +53,22 @@ pub const Message = union(enum) {
|
|||
impl: *renderer.Renderer.DerivedConfig,
|
||||
},
|
||||
|
||||
/// Matches for the current viewport from the search thread. These happen
|
||||
/// async so they may be off for a frame or two from the actually rendered
|
||||
/// viewport. The renderer must handle this gracefully.
|
||||
search_viewport_matches: SearchMatches,
|
||||
|
||||
/// Activate or deactivate the inspector.
|
||||
inspector: bool,
|
||||
|
||||
/// The macOS display ID has changed for the window.
|
||||
macos_display_id: u32,
|
||||
|
||||
pub const SearchMatches = struct {
|
||||
arena: ArenaAllocator,
|
||||
matches: []const 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);
|
||||
|
|
|
|||
|
|
@ -3729,7 +3729,11 @@ pub const PageIterator = struct {
|
|||
|
||||
pub const Chunk = struct {
|
||||
node: *List.Node,
|
||||
|
||||
/// Start y index (inclusive) of this chunk in the page.
|
||||
start: size.CellCountInt,
|
||||
|
||||
/// End y index (exclusive) of this chunk in the page.
|
||||
end: size.CellCountInt,
|
||||
|
||||
pub fn rows(self: Chunk) []Row {
|
||||
|
|
|
|||
|
|
@ -112,6 +112,16 @@ flags: packed struct {
|
|||
/// True if the terminal should perform selection scrolling.
|
||||
selection_scroll: bool = false,
|
||||
|
||||
/// Dirty flag used only by the search thread. The renderer is expected
|
||||
/// to set this to true if the viewport was dirty as it was rendering.
|
||||
/// This is used by the search thread to more efficiently re-search the
|
||||
/// viewport and active area.
|
||||
///
|
||||
/// Since the renderer is going to inspect the viewport/active area ANYWAYS,
|
||||
/// this lets our search thread do less work and hold the lock less time,
|
||||
/// resulting in more throughput for everything.
|
||||
search_viewport_dirty: bool = false,
|
||||
|
||||
/// Dirty flags for the renderer.
|
||||
dirty: Dirty = .{},
|
||||
} = .{},
|
||||
|
|
|
|||
|
|
@ -0,0 +1,166 @@
|
|||
//! Highlights are any contiguous sequences of cells that should
|
||||
//! be called out in some way, most commonly for text selection but
|
||||
//! also search results or any other purpose.
|
||||
//!
|
||||
//! Within the terminal package, a highlight is a generic concept
|
||||
//! that represents a range of cells.
|
||||
|
||||
// NOTE: The plan is for highlights to ultimately replace Selection
|
||||
// completely. Selection is deeply tied to various parts of the Ghostty
|
||||
// internals so this may take some time.
|
||||
|
||||
const std = @import("std");
|
||||
const Allocator = std.mem.Allocator;
|
||||
const assert = @import("../quirks.zig").inlineAssert;
|
||||
const size = @import("size.zig");
|
||||
const PageList = @import("PageList.zig");
|
||||
const PageChunk = PageList.PageIterator.Chunk;
|
||||
const Pin = PageList.Pin;
|
||||
const Screen = @import("Screen.zig");
|
||||
|
||||
/// An untracked highlight is a highlight that stores its highlighted
|
||||
/// area as a top-left and bottom-right screen pin. Since it is untracked,
|
||||
/// the pins are only valid for the current terminal state and may not
|
||||
/// be safe to use after any terminal modifications.
|
||||
///
|
||||
/// For rectangle highlights/selections, the downstream consumer of this
|
||||
/// code is expected to interpret the pins in whatever shape they want.
|
||||
/// For example, a rectangular selection would interpret the pins as
|
||||
/// setting the x bounds for each row between start.y and end.y.
|
||||
///
|
||||
/// To simplify all operations, start MUST be before or equal to end.
|
||||
pub const Untracked = struct {
|
||||
start: Pin,
|
||||
end: Pin,
|
||||
};
|
||||
|
||||
/// A tracked highlight is a highlight that stores its highlighted
|
||||
/// area as tracked pins within a screen.
|
||||
///
|
||||
/// A tracked highlight ensures that the pins remain valid even as
|
||||
/// the terminal state changes. Because of this, tracked highlights
|
||||
/// have more operations available to them.
|
||||
///
|
||||
/// There is more overhead to creating and maintaining tracked highlights.
|
||||
/// If you're manipulating highlights that are untracked and you're sure
|
||||
/// that the terminal state won't change, you can use the `initAssume`
|
||||
/// function.
|
||||
pub const Tracked = struct {
|
||||
start: *Pin,
|
||||
end: *Pin,
|
||||
|
||||
pub fn init(
|
||||
screen: *Screen,
|
||||
start: Pin,
|
||||
end: Pin,
|
||||
) Allocator.Error!Tracked {
|
||||
const start_tracked = try screen.pages.trackPin(start);
|
||||
errdefer screen.pages.untrackPin(start_tracked);
|
||||
const end_tracked = try screen.pages.trackPin(end);
|
||||
errdefer screen.pages.untrackPin(end_tracked);
|
||||
return .{
|
||||
.start = start_tracked,
|
||||
.end = end_tracked,
|
||||
};
|
||||
}
|
||||
|
||||
/// Initializes a tracked highlight by assuming that the provided
|
||||
/// pins are already tracked. This allows callers to perform tracked
|
||||
/// operations without the overhead of tracking the pins, if the
|
||||
/// caller can guarantee that the pins are already tracked or that
|
||||
/// the terminal state will not change.
|
||||
///
|
||||
/// Do not call deinit on highlights created with this function.
|
||||
pub fn initAssume(
|
||||
start: *Pin,
|
||||
end: *Pin,
|
||||
) Tracked {
|
||||
return .{
|
||||
.start = start,
|
||||
.end = end,
|
||||
};
|
||||
}
|
||||
|
||||
pub fn deinit(
|
||||
self: Tracked,
|
||||
screen: *Screen,
|
||||
) void {
|
||||
screen.pages.untrackPin(self.start);
|
||||
screen.pages.untrackPin(self.end);
|
||||
}
|
||||
};
|
||||
|
||||
/// A flattened highlight is a highlight that stores its highlighted
|
||||
/// area as a list of page chunks. This representation allows for
|
||||
/// traversing the entire highlighted area without needing to read any
|
||||
/// terminal state or dereference any page nodes (which may have been
|
||||
/// pruned).
|
||||
pub const Flattened = struct {
|
||||
/// The page chunks that make up this highlight. This handles the
|
||||
/// y bounds since chunks[0].start is the first highlighted row
|
||||
/// and chunks[len - 1].end is the last highlighted row (exclsive).
|
||||
chunks: std.MultiArrayList(PageChunk),
|
||||
|
||||
/// The x bounds of the highlight. `bot_x` may be less than `top_x`
|
||||
/// for typical left-to-right highlights: can start the selection right
|
||||
/// of the end on a higher row.
|
||||
top_x: size.CellCountInt,
|
||||
bot_x: size.CellCountInt,
|
||||
|
||||
/// Exposed for easier type references.
|
||||
pub const Chunk = PageChunk;
|
||||
|
||||
pub const empty: Flattened = .{
|
||||
.chunks = .empty,
|
||||
.top_x = 0,
|
||||
.bot_x = 0,
|
||||
};
|
||||
|
||||
pub fn init(
|
||||
alloc: Allocator,
|
||||
start: Pin,
|
||||
end: Pin,
|
||||
) Allocator.Error!Flattened {
|
||||
var result: std.MultiArrayList(PageChunk) = .empty;
|
||||
errdefer result.deinit(alloc);
|
||||
var it = start.pageIterator(.right_down, end);
|
||||
while (it.next()) |chunk| try result.append(alloc, chunk);
|
||||
return .{
|
||||
.chunks = result,
|
||||
.top_x = start.x,
|
||||
.end_x = end.x,
|
||||
};
|
||||
}
|
||||
|
||||
pub fn deinit(self: *Flattened, alloc: Allocator) void {
|
||||
self.chunks.deinit(alloc);
|
||||
}
|
||||
|
||||
pub fn clone(self: *const Flattened, alloc: Allocator) Allocator.Error!Flattened {
|
||||
return .{
|
||||
.chunks = try self.chunks.clone(alloc),
|
||||
.top_x = self.top_x,
|
||||
.bot_x = self.bot_x,
|
||||
};
|
||||
}
|
||||
|
||||
/// Convert to an Untracked highlight.
|
||||
pub fn untracked(self: Flattened) Untracked {
|
||||
const slice = self.chunks.slice();
|
||||
const nodes = slice.items(.node);
|
||||
const starts = slice.items(.start);
|
||||
const ends = slice.items(.end);
|
||||
return .{
|
||||
.start = .{
|
||||
.node = nodes[0],
|
||||
.x = self.top_x,
|
||||
.y = starts[0],
|
||||
},
|
||||
.end = .{
|
||||
.node = nodes[nodes.len - 1],
|
||||
.x = self.bot_x,
|
||||
.y = ends[ends.len - 1] - 1,
|
||||
},
|
||||
};
|
||||
}
|
||||
};
|
||||
|
|
@ -15,6 +15,7 @@ pub const point = @import("point.zig");
|
|||
pub const color = @import("color.zig");
|
||||
pub const device_status = @import("device_status.zig");
|
||||
pub const formatter = @import("formatter.zig");
|
||||
pub const highlight = @import("highlight.zig");
|
||||
pub const kitty = @import("kitty.zig");
|
||||
pub const modes = @import("modes.zig");
|
||||
pub const page = @import("page.zig");
|
||||
|
|
|
|||
|
|
@ -5,6 +5,7 @@ const ArenaAllocator = std.heap.ArenaAllocator;
|
|||
const fastmem = @import("../fastmem.zig");
|
||||
const color = @import("color.zig");
|
||||
const cursor = @import("cursor.zig");
|
||||
const highlight = @import("highlight.zig");
|
||||
const point = @import("point.zig");
|
||||
const size = @import("size.zig");
|
||||
const page = @import("page.zig");
|
||||
|
|
@ -191,6 +192,10 @@ 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),
|
||||
};
|
||||
|
||||
pub const Cell = struct {
|
||||
|
|
@ -348,6 +353,7 @@ pub const RenderState = struct {
|
|||
.cells = .empty,
|
||||
.dirty = true,
|
||||
.selection = null,
|
||||
.highlights = .empty,
|
||||
});
|
||||
}
|
||||
} else {
|
||||
|
|
@ -630,6 +636,76 @@ pub const RenderState = struct {
|
|||
s.dirty = .{};
|
||||
}
|
||||
|
||||
/// Update the highlights in the render state from the given flattened
|
||||
/// highlights. Because this uses flattened highlights, it does not require
|
||||
/// reading from the terminal state so it should be done outside of
|
||||
/// any critical sections.
|
||||
///
|
||||
/// This will not clear any previous highlights, so the caller must
|
||||
/// manually clear them if desired.
|
||||
pub fn updateHighlightsFlattened(
|
||||
self: *RenderState,
|
||||
alloc: Allocator,
|
||||
hls: []const highlight.Flattened,
|
||||
) Allocator.Error!void {
|
||||
// Fast path, we have no highlights!
|
||||
if (hls.len == 0) return;
|
||||
|
||||
// This is, admittedly, horrendous. This is some low hanging fruit
|
||||
// to optimize. In my defense, screens are usually small, the number
|
||||
// of highlights is usually small, and this only happens on the
|
||||
// viewport outside of a locked area. Still, I'd love to see this
|
||||
// improved someday.
|
||||
|
||||
// We need to track whether any row had a match so we can mark
|
||||
// the dirty state.
|
||||
var any_dirty: bool = false;
|
||||
|
||||
const row_data = self.row_data.slice();
|
||||
const row_arenas = row_data.items(.arena);
|
||||
const row_dirties = row_data.items(.dirty);
|
||||
const row_pins = row_data.items(.pin);
|
||||
const row_highlights_slice = row_data.items(.highlights);
|
||||
for (
|
||||
row_arenas,
|
||||
row_pins,
|
||||
row_highlights_slice,
|
||||
row_dirties,
|
||||
) |*row_arena, row_pin, *row_highlights, *dirty| {
|
||||
for (hls) |hl| {
|
||||
const chunks_slice = hl.chunks.slice();
|
||||
const nodes = chunks_slice.items(.node);
|
||||
const starts = chunks_slice.items(.start);
|
||||
const ends = chunks_slice.items(.end);
|
||||
for (0.., nodes) |i, node| {
|
||||
// If this node doesn't match or we're not within
|
||||
// the row range, skip it.
|
||||
if (node != row_pin.node or
|
||||
row_pin.y < starts[i] or
|
||||
row_pin.y >= ends[i]) continue;
|
||||
|
||||
// We're a match!
|
||||
var arena = row_arena.promote(alloc);
|
||||
defer row_arena.* = arena.state;
|
||||
const arena_alloc = arena.allocator();
|
||||
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,
|
||||
},
|
||||
);
|
||||
|
||||
dirty.* = true;
|
||||
any_dirty = true;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Mark our dirty state.
|
||||
if (any_dirty and self.dirty == .false) self.dirty = .partial;
|
||||
}
|
||||
|
||||
pub const StringMap = std.ArrayListUnmanaged(point.Coordinate);
|
||||
|
||||
/// Convert the current render state contents to a UTF-8 encoded
|
||||
|
|
|
|||
|
|
@ -12,11 +12,13 @@ const std = @import("std");
|
|||
const builtin = @import("builtin");
|
||||
const testing = std.testing;
|
||||
const Allocator = std.mem.Allocator;
|
||||
const ArenaAllocator = std.heap.ArenaAllocator;
|
||||
const Mutex = std.Thread.Mutex;
|
||||
const xev = @import("../../global.zig").xev;
|
||||
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 PageList = @import("../PageList.zig");
|
||||
const Screen = @import("../Screen.zig");
|
||||
const ScreenSet = @import("../ScreenSet.zig");
|
||||
|
|
@ -161,7 +163,14 @@ fn threadMain_(self: *Thread) !void {
|
|||
|
||||
// Run
|
||||
log.debug("starting search thread", .{});
|
||||
defer log.debug("starting search thread shutdown", .{});
|
||||
defer {
|
||||
log.debug("starting search thread shutdown", .{});
|
||||
|
||||
// Send the quit message
|
||||
if (self.opts.event_cb) |cb| {
|
||||
cb(.quit, self.opts.event_userdata);
|
||||
}
|
||||
}
|
||||
|
||||
// Unlike some of our other threads, we interleave search work
|
||||
// with our xev loop so that we can try to make forward search progress
|
||||
|
|
@ -245,6 +254,18 @@ fn changeNeedle(self: *Thread, needle: []const u8) !void {
|
|||
if (self.search) |*s| {
|
||||
s.deinit();
|
||||
self.search = null;
|
||||
|
||||
// When the search changes then we need to emit that it stopped.
|
||||
if (self.opts.event_cb) |cb| {
|
||||
cb(
|
||||
.{ .total_matches = 0 },
|
||||
self.opts.event_userdata,
|
||||
);
|
||||
cb(
|
||||
.{ .viewport_matches = &.{} },
|
||||
self.opts.event_userdata,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// No needle means stop the search.
|
||||
|
|
@ -379,6 +400,9 @@ pub const Message = union(enum) {
|
|||
/// Events that can be emitted from the search thread. The caller
|
||||
/// chooses to handle these as they see fit.
|
||||
pub const Event = union(enum) {
|
||||
/// Search is quitting. The search thread is exiting.
|
||||
quit,
|
||||
|
||||
/// Search is complete for the given needle on all screens.
|
||||
complete,
|
||||
|
||||
|
|
@ -387,7 +411,7 @@ pub const Event = union(enum) {
|
|||
|
||||
/// Matches in the viewport have changed. The memory is owned by the
|
||||
/// search thread and is only valid during the callback.
|
||||
viewport_matches: []const Selection,
|
||||
viewport_matches: []const FlattenedHighlight,
|
||||
};
|
||||
|
||||
/// Search state.
|
||||
|
|
@ -417,6 +441,10 @@ const Search = struct {
|
|||
var vp: ViewportSearch = try .init(alloc, needle);
|
||||
errdefer vp.deinit();
|
||||
|
||||
// We use dirty tracking for active area changes. Start with it
|
||||
// dirty so the first change is re-searched.
|
||||
vp.active_dirty = true;
|
||||
|
||||
return .{
|
||||
.viewport = vp,
|
||||
.screens = .init(.{}),
|
||||
|
|
@ -551,6 +579,15 @@ const Search = struct {
|
|||
}
|
||||
}
|
||||
|
||||
// See the `search_viewport_dirty` flag on the terminal to know
|
||||
// what exactly this is for. But, if this is set, we know the renderer
|
||||
// found the viewport/active area dirty, so we should mark it as
|
||||
// dirty in our viewport searcher so it forces a re-search.
|
||||
if (t.flags.search_viewport_dirty) {
|
||||
self.viewport.active_dirty = true;
|
||||
t.flags.search_viewport_dirty = false;
|
||||
}
|
||||
|
||||
// Check our viewport for changes.
|
||||
if (self.viewport.update(&t.screens.active.pages)) |updated| {
|
||||
if (updated) self.stale_viewport_matches = true;
|
||||
|
|
@ -589,6 +626,7 @@ const Search = struct {
|
|||
// Check our total match data
|
||||
const total = screen_search.matchesLen();
|
||||
if (total != self.last_total) {
|
||||
log.debug("notifying total matches={}", .{total});
|
||||
self.last_total = total;
|
||||
cb(.{ .total_matches = total }, ud);
|
||||
}
|
||||
|
|
@ -603,10 +641,13 @@ const Search = struct {
|
|||
// process will make it stale again.
|
||||
self.stale_viewport_matches = false;
|
||||
|
||||
var results: std.ArrayList(Selection) = .empty;
|
||||
defer results.deinit(alloc);
|
||||
while (self.viewport.next()) |sel| {
|
||||
results.append(alloc, sel) catch |err| switch (err) {
|
||||
var arena: ArenaAllocator = .init(alloc);
|
||||
defer arena.deinit();
|
||||
const arena_alloc = arena.allocator();
|
||||
var results: std.ArrayList(FlattenedHighlight) = .empty;
|
||||
while (self.viewport.next()) |hl| {
|
||||
const hl_cloned = hl.clone(arena_alloc) catch continue;
|
||||
results.append(arena_alloc, hl_cloned) catch |err| switch (err) {
|
||||
error.OutOfMemory => {
|
||||
log.warn(
|
||||
"error collecting viewport matches err={}",
|
||||
|
|
@ -621,11 +662,13 @@ const Search = struct {
|
|||
};
|
||||
}
|
||||
|
||||
log.debug("notifying viewport matches len={}", .{results.items.len});
|
||||
cb(.{ .viewport_matches = results.items }, ud);
|
||||
}
|
||||
|
||||
// Send our complete notification if we just completed.
|
||||
if (!self.last_complete and self.isComplete()) {
|
||||
log.debug("notifying search complete", .{});
|
||||
self.last_complete = true;
|
||||
cb(.complete, ud);
|
||||
}
|
||||
|
|
@ -637,19 +680,30 @@ test {
|
|||
const Self = @This();
|
||||
reset: std.Thread.ResetEvent = .{},
|
||||
total: usize = 0,
|
||||
viewport: []const Selection = &.{},
|
||||
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.dupe(
|
||||
Selection,
|
||||
v,
|
||||
|
||||
ud.viewport = testing.allocator.alloc(
|
||||
FlattenedHighlight,
|
||||
v.len,
|
||||
) catch unreachable;
|
||||
for (ud.viewport, v) |*dst, src| {
|
||||
dst.* = src.clone(testing.allocator) catch unreachable;
|
||||
}
|
||||
},
|
||||
}
|
||||
}
|
||||
|
|
@ -665,7 +719,7 @@ test {
|
|||
try stream.nextSlice("Hello, world");
|
||||
|
||||
var ud: UserData = .{};
|
||||
defer alloc.free(ud.viewport);
|
||||
defer ud.deinit();
|
||||
var thread: Thread = try .init(alloc, .{
|
||||
.mutex = &mutex,
|
||||
.terminal = &t,
|
||||
|
|
@ -698,14 +752,14 @@ test {
|
|||
try testing.expectEqual(1, ud.total);
|
||||
try testing.expectEqual(1, ud.viewport.len);
|
||||
{
|
||||
const sel = ud.viewport[0];
|
||||
const sel = ud.viewport[0].untracked();
|
||||
try testing.expectEqual(point.Point{ .screen = .{
|
||||
.x = 7,
|
||||
.y = 0,
|
||||
} }, t.screens.active.pages.pointFromPin(.screen, sel.start()).?);
|
||||
} }, t.screens.active.pages.pointFromPin(.screen, sel.start).?);
|
||||
try testing.expectEqual(point.Point{ .screen = .{
|
||||
.x = 11,
|
||||
.y = 0,
|
||||
} }, t.screens.active.pages.pointFromPin(.screen, sel.end()).?);
|
||||
} }, t.screens.active.pages.pointFromPin(.screen, sel.end).?);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -3,6 +3,7 @@ const testing = std.testing;
|
|||
const Allocator = std.mem.Allocator;
|
||||
const point = @import("../point.zig");
|
||||
const size = @import("../size.zig");
|
||||
const FlattenedHighlight = @import("../highlight.zig").Flattened;
|
||||
const PageList = @import("../PageList.zig");
|
||||
const Selection = @import("../Selection.zig");
|
||||
const SlidingWindow = @import("sliding_window.zig").SlidingWindow;
|
||||
|
|
@ -96,7 +97,7 @@ pub const ActiveSearch = struct {
|
|||
|
||||
/// Find the next match for the needle in the active area. This returns
|
||||
/// null when there are no more matches.
|
||||
pub fn next(self: *ActiveSearch) ?Selection {
|
||||
pub fn next(self: *ActiveSearch) ?FlattenedHighlight {
|
||||
return self.window.next();
|
||||
}
|
||||
};
|
||||
|
|
@ -115,26 +116,28 @@ test "simple search" {
|
|||
_ = try search.update(&t.screens.active.pages);
|
||||
|
||||
{
|
||||
const sel = search.next().?;
|
||||
const h = search.next().?;
|
||||
const sel = h.untracked();
|
||||
try testing.expectEqual(point.Point{ .active = .{
|
||||
.x = 0,
|
||||
.y = 0,
|
||||
} }, t.screens.active.pages.pointFromPin(.active, sel.start()).?);
|
||||
} }, t.screens.active.pages.pointFromPin(.active, sel.start).?);
|
||||
try testing.expectEqual(point.Point{ .active = .{
|
||||
.x = 3,
|
||||
.y = 0,
|
||||
} }, t.screens.active.pages.pointFromPin(.active, sel.end()).?);
|
||||
} }, t.screens.active.pages.pointFromPin(.active, sel.end).?);
|
||||
}
|
||||
{
|
||||
const sel = search.next().?;
|
||||
const h = search.next().?;
|
||||
const sel = h.untracked();
|
||||
try testing.expectEqual(point.Point{ .active = .{
|
||||
.x = 0,
|
||||
.y = 2,
|
||||
} }, t.screens.active.pages.pointFromPin(.active, sel.start()).?);
|
||||
} }, t.screens.active.pages.pointFromPin(.active, sel.start).?);
|
||||
try testing.expectEqual(point.Point{ .active = .{
|
||||
.x = 3,
|
||||
.y = 2,
|
||||
} }, t.screens.active.pages.pointFromPin(.active, sel.end()).?);
|
||||
} }, t.screens.active.pages.pointFromPin(.active, sel.end).?);
|
||||
}
|
||||
try testing.expect(search.next() == null);
|
||||
}
|
||||
|
|
@ -158,15 +161,16 @@ test "clear screen and search" {
|
|||
_ = try search.update(&t.screens.active.pages);
|
||||
|
||||
{
|
||||
const sel = search.next().?;
|
||||
const h = search.next().?;
|
||||
const sel = h.untracked();
|
||||
try testing.expectEqual(point.Point{ .active = .{
|
||||
.x = 0,
|
||||
.y = 1,
|
||||
} }, t.screens.active.pages.pointFromPin(.active, sel.start()).?);
|
||||
} }, 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()).?);
|
||||
} }, t.screens.active.pages.pointFromPin(.active, sel.end).?);
|
||||
}
|
||||
try testing.expect(search.next() == null);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -5,6 +5,7 @@ const testing = std.testing;
|
|||
const CircBuf = @import("../../datastruct/main.zig").CircBuf;
|
||||
const terminal = @import("../main.zig");
|
||||
const point = terminal.point;
|
||||
const FlattenedHighlight = @import("../highlight.zig").Flattened;
|
||||
const Page = terminal.Page;
|
||||
const PageList = terminal.PageList;
|
||||
const Pin = PageList.Pin;
|
||||
|
|
@ -97,7 +98,7 @@ pub const PageListSearch = struct {
|
|||
///
|
||||
/// This does NOT access the PageList, so it can be called without
|
||||
/// a lock held.
|
||||
pub fn next(self: *PageListSearch) ?Selection {
|
||||
pub fn next(self: *PageListSearch) ?FlattenedHighlight {
|
||||
return self.window.next();
|
||||
}
|
||||
|
||||
|
|
@ -149,26 +150,28 @@ test "simple search" {
|
|||
defer search.deinit();
|
||||
|
||||
{
|
||||
const sel = search.next().?;
|
||||
const h = search.next().?;
|
||||
const sel = h.untracked();
|
||||
try testing.expectEqual(point.Point{ .active = .{
|
||||
.x = 0,
|
||||
.y = 2,
|
||||
} }, t.screens.active.pages.pointFromPin(.active, sel.start()).?);
|
||||
} }, t.screens.active.pages.pointFromPin(.active, sel.start).?);
|
||||
try testing.expectEqual(point.Point{ .active = .{
|
||||
.x = 3,
|
||||
.y = 2,
|
||||
} }, t.screens.active.pages.pointFromPin(.active, sel.end()).?);
|
||||
} }, t.screens.active.pages.pointFromPin(.active, sel.end).?);
|
||||
}
|
||||
{
|
||||
const sel = search.next().?;
|
||||
const h = search.next().?;
|
||||
const sel = h.untracked();
|
||||
try testing.expectEqual(point.Point{ .active = .{
|
||||
.x = 0,
|
||||
.y = 0,
|
||||
} }, t.screens.active.pages.pointFromPin(.active, sel.start()).?);
|
||||
} }, t.screens.active.pages.pointFromPin(.active, sel.start).?);
|
||||
try testing.expectEqual(point.Point{ .active = .{
|
||||
.x = 3,
|
||||
.y = 0,
|
||||
} }, t.screens.active.pages.pointFromPin(.active, sel.end()).?);
|
||||
} }, t.screens.active.pages.pointFromPin(.active, sel.end).?);
|
||||
}
|
||||
try testing.expect(search.next() == null);
|
||||
|
||||
|
|
@ -335,12 +338,13 @@ test "feed with match spanning page boundary" {
|
|||
try testing.expect(try search.feed());
|
||||
|
||||
// Should find the spanning match
|
||||
const sel = search.next().?;
|
||||
try testing.expect(sel.start().node != sel.end().node);
|
||||
const h = search.next().?;
|
||||
const sel = h.untracked();
|
||||
try testing.expect(sel.start.node != sel.end.node);
|
||||
{
|
||||
const str = try t.screens.active.selectionString(
|
||||
alloc,
|
||||
.{ .sel = sel },
|
||||
.{ .sel = .init(sel.start, sel.end, false) },
|
||||
);
|
||||
defer alloc.free(str);
|
||||
try testing.expectEqualStrings(str, "Test");
|
||||
|
|
|
|||
|
|
@ -3,6 +3,7 @@ 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 PageList = @import("../PageList.zig");
|
||||
const Pin = PageList.Pin;
|
||||
const Screen = @import("../Screen.zig");
|
||||
|
|
@ -44,8 +45,8 @@ pub const ScreenSearch = struct {
|
|||
/// is mostly immutable once found, while active area results may
|
||||
/// change. This lets us easily reset the active area results for a
|
||||
/// re-search scenario.
|
||||
history_results: std.ArrayList(Selection),
|
||||
active_results: std.ArrayList(Selection),
|
||||
history_results: std.ArrayList(FlattenedHighlight),
|
||||
active_results: std.ArrayList(FlattenedHighlight),
|
||||
|
||||
/// History search state.
|
||||
const HistorySearch = struct {
|
||||
|
|
@ -120,7 +121,9 @@ pub const ScreenSearch = struct {
|
|||
const alloc = self.allocator();
|
||||
self.active.deinit();
|
||||
if (self.history) |*h| h.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);
|
||||
self.history_results.deinit(alloc);
|
||||
}
|
||||
|
||||
|
|
@ -145,11 +148,11 @@ pub const ScreenSearch = struct {
|
|||
pub fn matches(
|
||||
self: *ScreenSearch,
|
||||
alloc: Allocator,
|
||||
) Allocator.Error![]Selection {
|
||||
) Allocator.Error![]FlattenedHighlight {
|
||||
const active_results = self.active_results.items;
|
||||
const history_results = self.history_results.items;
|
||||
const results = try alloc.alloc(
|
||||
Selection,
|
||||
FlattenedHighlight,
|
||||
active_results.len + history_results.len,
|
||||
);
|
||||
errdefer alloc.free(results);
|
||||
|
|
@ -162,7 +165,7 @@ pub const ScreenSearch = struct {
|
|||
results[0..active_results.len],
|
||||
active_results,
|
||||
);
|
||||
std.mem.reverse(Selection, results[0..active_results.len]);
|
||||
std.mem.reverse(FlattenedHighlight, results[0..active_results.len]);
|
||||
|
||||
// History does a backward search, so we can just append them
|
||||
// after.
|
||||
|
|
@ -247,13 +250,15 @@ pub const ScreenSearch = struct {
|
|||
// For the active area, we consume the entire search in one go
|
||||
// because the active area is generally small.
|
||||
const alloc = self.allocator();
|
||||
while (self.active.next()) |sel| {
|
||||
while (self.active.next()) |hl| {
|
||||
// If this fails, then we miss a result since `active.next()`
|
||||
// moves forward and prunes data. In the future, we may want
|
||||
// to have some more robust error handling but the only
|
||||
// scenario this would fail is OOM and we're probably in
|
||||
// deeper trouble at that point anyways.
|
||||
try self.active_results.append(alloc, sel);
|
||||
var hl_cloned = try hl.clone(alloc);
|
||||
errdefer hl_cloned.deinit(alloc);
|
||||
try self.active_results.append(alloc, hl_cloned);
|
||||
}
|
||||
|
||||
// We've consumed the entire active area, move to history.
|
||||
|
|
@ -270,13 +275,15 @@ pub const ScreenSearch = struct {
|
|||
// Try to consume all the loaded matches in one go, because
|
||||
// the search is generally fast for loaded data.
|
||||
const alloc = self.allocator();
|
||||
while (history.searcher.next()) |sel| {
|
||||
while (history.searcher.next()) |hl| {
|
||||
// Ignore selections that are found within the starting
|
||||
// node since those are covered by the active area search.
|
||||
if (sel.start().node == history.start_pin.node) continue;
|
||||
if (hl.chunks.items(.node)[0] == history.start_pin.node) continue;
|
||||
|
||||
// Same note as tickActive for error handling.
|
||||
try self.history_results.append(alloc, sel);
|
||||
var hl_cloned = try hl.clone(alloc);
|
||||
errdefer hl_cloned.deinit(alloc);
|
||||
try self.history_results.append(alloc, hl_cloned);
|
||||
}
|
||||
|
||||
// We need to be fed more data.
|
||||
|
|
@ -291,6 +298,7 @@ pub const ScreenSearch = struct {
|
|||
///
|
||||
/// The caller must hold the necessary locks to access the screen state.
|
||||
pub fn reloadActive(self: *ScreenSearch) Allocator.Error!void {
|
||||
const alloc = self.allocator();
|
||||
const list: *PageList = &self.screen.pages;
|
||||
if (try self.active.update(list)) |history_node| history: {
|
||||
// We need to account for any active area growth that would
|
||||
|
|
@ -305,6 +313,7 @@ pub const ScreenSearch = struct {
|
|||
if (h.start_pin.garbage) {
|
||||
h.deinit(self.screen);
|
||||
self.history = null;
|
||||
for (self.history_results.items) |*hl| hl.deinit(alloc);
|
||||
self.history_results.clearRetainingCapacity();
|
||||
break :state null;
|
||||
}
|
||||
|
|
@ -317,7 +326,7 @@ pub const ScreenSearch = struct {
|
|||
// initialize.
|
||||
|
||||
var search: PageListSearch = try .init(
|
||||
self.allocator(),
|
||||
alloc,
|
||||
self.needle(),
|
||||
list,
|
||||
history_node,
|
||||
|
|
@ -346,7 +355,6 @@ pub const ScreenSearch = struct {
|
|||
// collect all the results into a new list. We ASSUME that
|
||||
// reloadActive is being called frequently enough that there isn't
|
||||
// a massive amount of history to search here.
|
||||
const alloc = self.allocator();
|
||||
var window: SlidingWindow = try .init(
|
||||
alloc,
|
||||
.forward,
|
||||
|
|
@ -361,17 +369,17 @@ pub const ScreenSearch = struct {
|
|||
}
|
||||
assert(history.start_pin.node == history_node);
|
||||
|
||||
var results: std.ArrayList(Selection) = try .initCapacity(
|
||||
var results: std.ArrayList(FlattenedHighlight) = try .initCapacity(
|
||||
alloc,
|
||||
self.history_results.items.len,
|
||||
);
|
||||
errdefer results.deinit(alloc);
|
||||
while (window.next()) |sel| {
|
||||
if (sel.start().node == history_node) continue;
|
||||
try results.append(
|
||||
alloc,
|
||||
sel,
|
||||
);
|
||||
while (window.next()) |hl| {
|
||||
if (hl.chunks.items(.node)[0] == history_node) continue;
|
||||
|
||||
var hl_cloned = try hl.clone(alloc);
|
||||
errdefer hl_cloned.deinit(alloc);
|
||||
try results.append(alloc, hl_cloned);
|
||||
}
|
||||
|
||||
// If we have no matches then there is nothing to change
|
||||
|
|
@ -380,13 +388,14 @@ pub const ScreenSearch = struct {
|
|||
|
||||
// Matches! Reverse our list then append all the remaining
|
||||
// history items that didn't start on our original node.
|
||||
std.mem.reverse(Selection, results.items);
|
||||
std.mem.reverse(FlattenedHighlight, results.items);
|
||||
try results.appendSlice(alloc, self.history_results.items);
|
||||
self.history_results.deinit(alloc);
|
||||
self.history_results = results;
|
||||
}
|
||||
|
||||
// Reset our active search results and search again.
|
||||
for (self.active_results.items) |*hl| hl.deinit(alloc);
|
||||
self.active_results.clearRetainingCapacity();
|
||||
switch (self.state) {
|
||||
// If we're in the active state we run a normal tick so
|
||||
|
|
@ -425,26 +434,26 @@ test "simple search" {
|
|||
try testing.expectEqual(2, matches.len);
|
||||
|
||||
{
|
||||
const sel = matches[0];
|
||||
const sel = matches[0].untracked();
|
||||
try testing.expectEqual(point.Point{ .screen = .{
|
||||
.x = 0,
|
||||
.y = 2,
|
||||
} }, t.screens.active.pages.pointFromPin(.screen, sel.start()).?);
|
||||
} }, 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()).?);
|
||||
} }, t.screens.active.pages.pointFromPin(.screen, sel.end).?);
|
||||
}
|
||||
{
|
||||
const sel = matches[1];
|
||||
const sel = matches[1].untracked();
|
||||
try testing.expectEqual(point.Point{ .screen = .{
|
||||
.x = 0,
|
||||
.y = 0,
|
||||
} }, t.screens.active.pages.pointFromPin(.screen, sel.start()).?);
|
||||
} }, 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()).?);
|
||||
} }, t.screens.active.pages.pointFromPin(.screen, sel.end).?);
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -477,15 +486,15 @@ test "simple search with history" {
|
|||
try testing.expectEqual(1, matches.len);
|
||||
|
||||
{
|
||||
const sel = matches[0];
|
||||
const sel = matches[0].untracked();
|
||||
try testing.expectEqual(point.Point{ .screen = .{
|
||||
.x = 0,
|
||||
.y = 0,
|
||||
} }, t.screens.active.pages.pointFromPin(.screen, sel.start()).?);
|
||||
} }, 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()).?);
|
||||
} }, t.screens.active.pages.pointFromPin(.screen, sel.end).?);
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -528,26 +537,26 @@ test "reload active with history change" {
|
|||
defer alloc.free(matches);
|
||||
try testing.expectEqual(2, matches.len);
|
||||
{
|
||||
const sel = matches[1];
|
||||
const sel = matches[1].untracked();
|
||||
try testing.expectEqual(point.Point{ .screen = .{
|
||||
.x = 0,
|
||||
.y = 0,
|
||||
} }, t.screens.active.pages.pointFromPin(.screen, sel.start()).?);
|
||||
} }, 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()).?);
|
||||
} }, t.screens.active.pages.pointFromPin(.screen, sel.end).?);
|
||||
}
|
||||
{
|
||||
const sel = matches[0];
|
||||
const sel = matches[0].untracked();
|
||||
try testing.expectEqual(point.Point{ .active = .{
|
||||
.x = 1,
|
||||
.y = 1,
|
||||
} }, t.screens.active.pages.pointFromPin(.active, sel.start()).?);
|
||||
} }, t.screens.active.pages.pointFromPin(.active, sel.start).?);
|
||||
try testing.expectEqual(point.Point{ .active = .{
|
||||
.x = 4,
|
||||
.y = 1,
|
||||
} }, t.screens.active.pages.pointFromPin(.active, sel.end()).?);
|
||||
} }, t.screens.active.pages.pointFromPin(.active, sel.end).?);
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -562,15 +571,15 @@ test "reload active with history change" {
|
|||
defer alloc.free(matches);
|
||||
try testing.expectEqual(1, matches.len);
|
||||
{
|
||||
const sel = matches[0];
|
||||
const sel = matches[0].untracked();
|
||||
try testing.expectEqual(point.Point{ .active = .{
|
||||
.x = 2,
|
||||
.y = 0,
|
||||
} }, t.screens.active.pages.pointFromPin(.active, sel.start()).?);
|
||||
} }, t.screens.active.pages.pointFromPin(.active, sel.start).?);
|
||||
try testing.expectEqual(point.Point{ .active = .{
|
||||
.x = 5,
|
||||
.y = 0,
|
||||
} }, t.screens.active.pages.pointFromPin(.active, sel.end()).?);
|
||||
} }, t.screens.active.pages.pointFromPin(.active, sel.end).?);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -603,14 +612,14 @@ test "active change contents" {
|
|||
try testing.expectEqual(1, matches.len);
|
||||
|
||||
{
|
||||
const sel = matches[0];
|
||||
const sel = matches[0].untracked();
|
||||
try testing.expectEqual(point.Point{ .screen = .{
|
||||
.x = 0,
|
||||
.y = 1,
|
||||
} }, t.screens.active.pages.pointFromPin(.screen, sel.start()).?);
|
||||
} }, 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()).?);
|
||||
} }, t.screens.active.pages.pointFromPin(.screen, sel.end).?);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -4,11 +4,13 @@ const Allocator = std.mem.Allocator;
|
|||
const CircBuf = @import("../../datastruct/main.zig").CircBuf;
|
||||
const terminal = @import("../main.zig");
|
||||
const point = terminal.point;
|
||||
const size = terminal.size;
|
||||
const PageList = terminal.PageList;
|
||||
const Pin = PageList.Pin;
|
||||
const Selection = terminal.Selection;
|
||||
const Screen = terminal.Screen;
|
||||
const PageFormatter = @import("../formatter.zig").PageFormatter;
|
||||
const FlattenedHighlight = terminal.highlight.Flattened;
|
||||
|
||||
/// Searches page nodes via a sliding window. The sliding window maintains
|
||||
/// the invariant that data isn't pruned until (1) we've searched it and
|
||||
|
|
@ -51,6 +53,10 @@ pub const SlidingWindow = struct {
|
|||
/// data to meta.
|
||||
meta: MetaBuf,
|
||||
|
||||
/// Buffer that can fit any amount of chunks necessary for next
|
||||
/// to never fail allocation.
|
||||
chunk_buf: std.MultiArrayList(FlattenedHighlight.Chunk),
|
||||
|
||||
/// Offset into data for our current state. This handles the
|
||||
/// situation where our search moved through meta[0] but didn't
|
||||
/// do enough to prune it.
|
||||
|
|
@ -113,6 +119,7 @@ pub const SlidingWindow = struct {
|
|||
.alloc = alloc,
|
||||
.data = data,
|
||||
.meta = meta,
|
||||
.chunk_buf = .empty,
|
||||
.needle = needle,
|
||||
.direction = direction,
|
||||
.overlap_buf = overlap_buf,
|
||||
|
|
@ -122,6 +129,7 @@ pub const SlidingWindow = struct {
|
|||
pub fn deinit(self: *SlidingWindow) void {
|
||||
self.alloc.free(self.overlap_buf);
|
||||
self.alloc.free(self.needle);
|
||||
self.chunk_buf.deinit(self.alloc);
|
||||
self.data.deinit(self.alloc);
|
||||
|
||||
var meta_it = self.meta.iterator(.forward);
|
||||
|
|
@ -143,14 +151,17 @@ pub const SlidingWindow = struct {
|
|||
/// the invariant that the window is always big enough to contain
|
||||
/// the needle.
|
||||
///
|
||||
/// It may seem wasteful to return a full selection, since the needle
|
||||
/// length is known it seems like we can get away with just returning
|
||||
/// the start index. However, returning a full selection will give us
|
||||
/// more flexibility in the future (e.g. if we want to support regex
|
||||
/// searches or other more complex searches). It does cost us some memory,
|
||||
/// but searches are expected to be relatively rare compared to normal
|
||||
/// operations and can eat up some extra memory temporarily.
|
||||
pub fn next(self: *SlidingWindow) ?Selection {
|
||||
/// This returns a flattened highlight on a match. The
|
||||
/// flattened highlight requires allocation and is therefore more expensive
|
||||
/// than a normal selection, but it is more efficient to render since it
|
||||
/// has all the information without having to dereference pointers into
|
||||
/// the terminal state.
|
||||
///
|
||||
/// The flattened highlight chunks reference internal memory for this
|
||||
/// sliding window and are only valid until the next call to `next()`
|
||||
/// or `append()`. If the caller wants to retain the flattened highlight
|
||||
/// then they should clone it.
|
||||
pub fn next(self: *SlidingWindow) ?FlattenedHighlight {
|
||||
const slices = slices: {
|
||||
// If we have less data then the needle then we can't possibly match
|
||||
const data_len = self.data.len();
|
||||
|
|
@ -163,8 +174,8 @@ pub const SlidingWindow = struct {
|
|||
};
|
||||
|
||||
// Search the first slice for the needle.
|
||||
if (std.mem.indexOf(u8, slices[0], self.needle)) |idx| {
|
||||
return self.selection(
|
||||
if (std.ascii.indexOfIgnoreCase(slices[0], self.needle)) |idx| {
|
||||
return self.highlight(
|
||||
idx,
|
||||
self.needle.len,
|
||||
);
|
||||
|
|
@ -189,23 +200,22 @@ pub const SlidingWindow = struct {
|
|||
@memcpy(self.overlap_buf[prefix.len..overlap_len], suffix);
|
||||
|
||||
// Search the overlap
|
||||
const idx = std.mem.indexOf(
|
||||
u8,
|
||||
const idx = std.ascii.indexOfIgnoreCase(
|
||||
self.overlap_buf[0..overlap_len],
|
||||
self.needle,
|
||||
) orelse break :overlap;
|
||||
|
||||
// We found a match in the overlap buffer. We need to map the
|
||||
// index back to the data buffer in order to get our selection.
|
||||
return self.selection(
|
||||
return self.highlight(
|
||||
slices[0].len - prefix.len + idx,
|
||||
self.needle.len,
|
||||
);
|
||||
}
|
||||
|
||||
// Search the last slice for the needle.
|
||||
if (std.mem.indexOf(u8, slices[1], self.needle)) |idx| {
|
||||
return self.selection(
|
||||
if (std.ascii.indexOfIgnoreCase(slices[1], self.needle)) |idx| {
|
||||
return self.highlight(
|
||||
slices[0].len + idx,
|
||||
self.needle.len,
|
||||
);
|
||||
|
|
@ -263,114 +273,230 @@ pub const SlidingWindow = struct {
|
|||
return null;
|
||||
}
|
||||
|
||||
/// Return a selection for the given start and length into the data
|
||||
/// buffer and also prune the data/meta buffers if possible up to
|
||||
/// this start index.
|
||||
/// Return a flattened highlight for the given start and length.
|
||||
///
|
||||
/// The flattened highlight can be used to render the highlight
|
||||
/// in the most efficient way because it doesn't require a terminal
|
||||
/// lock to access terminal data to compare whether some viewport
|
||||
/// matches the highlight (because it doesn't need to traverse
|
||||
/// the page nodes).
|
||||
///
|
||||
/// The start index is assumed to be relative to the offset. i.e.
|
||||
/// index zero is actually at `self.data[self.data_offset]`. The
|
||||
/// selection will account for the offset.
|
||||
fn selection(
|
||||
fn highlight(
|
||||
self: *SlidingWindow,
|
||||
start_offset: usize,
|
||||
len: usize,
|
||||
) Selection {
|
||||
) terminal.highlight.Flattened {
|
||||
const start = start_offset + self.data_offset;
|
||||
assert(start < self.data.len());
|
||||
assert(start + len <= self.data.len());
|
||||
const end = start + len - 1;
|
||||
if (comptime std.debug.runtime_safety) {
|
||||
assert(start < self.data.len());
|
||||
assert(start + len <= self.data.len());
|
||||
}
|
||||
|
||||
// meta_consumed is the number of bytes we've consumed in the
|
||||
// data buffer up to and NOT including the meta where we've
|
||||
// found our pin. This is important because it tells us the
|
||||
// amount of data we can safely deleted from self.data since
|
||||
// we can't partially delete a meta block's data. (The partial
|
||||
// amount is represented by self.data_offset).
|
||||
var meta_it = self.meta.iterator(.forward);
|
||||
var meta_consumed: usize = 0;
|
||||
const tl: Pin = pin(&meta_it, &meta_consumed, start);
|
||||
// Clear our previous chunk buffer to store this result
|
||||
self.chunk_buf.clearRetainingCapacity();
|
||||
var result: terminal.highlight.Flattened = .empty;
|
||||
|
||||
// Store the information required to prune later. We store this
|
||||
// now because we only want to prune up to our START so we can
|
||||
// find overlapping matches.
|
||||
const tl_meta_idx = meta_it.idx - 1;
|
||||
const tl_meta_consumed = meta_consumed;
|
||||
// Go through the meta nodes to find our start.
|
||||
const tl: struct {
|
||||
/// If non-null, we need to continue searching for the bottom-right.
|
||||
br: ?struct {
|
||||
it: MetaBuf.Iterator,
|
||||
consumed: usize,
|
||||
},
|
||||
|
||||
// We have to seek back so that we reinspect our current
|
||||
// iterator value again in case the start and end are in the
|
||||
// same segment.
|
||||
meta_it.seekBy(-1);
|
||||
const br: Pin = pin(&meta_it, &meta_consumed, start + len - 1);
|
||||
assert(meta_it.idx >= 1);
|
||||
/// Data to prune, both are lengths.
|
||||
prune: struct {
|
||||
meta: usize,
|
||||
data: usize,
|
||||
},
|
||||
} = tl: {
|
||||
var meta_it = self.meta.iterator(.forward);
|
||||
var meta_consumed: usize = 0;
|
||||
while (meta_it.next()) |meta| {
|
||||
// Always increment our consumed count so that our index
|
||||
// is right for the end search if we do it.
|
||||
const prior_meta_consumed = meta_consumed;
|
||||
meta_consumed += meta.cell_map.items.len;
|
||||
|
||||
// meta_i is the index we expect to find the match in the
|
||||
// cell map within this meta if it contains it.
|
||||
const meta_i = start - prior_meta_consumed;
|
||||
|
||||
// This meta doesn't contain the match. This means we
|
||||
// can also prune this set of data because we only look
|
||||
// forward.
|
||||
if (meta_i >= meta.cell_map.items.len) continue;
|
||||
|
||||
// Now we look for the end. In MOST cases it is the same as
|
||||
// our starting chunk because highlights are usually small and
|
||||
// not on a boundary, so let's optimize for that.
|
||||
const end_i = end - prior_meta_consumed;
|
||||
if (end_i < meta.cell_map.items.len) {
|
||||
@branchHint(.likely);
|
||||
|
||||
// The entire highlight is within this meta.
|
||||
const start_map = meta.cell_map.items[meta_i];
|
||||
const end_map = meta.cell_map.items[end_i];
|
||||
result.top_x = start_map.x;
|
||||
result.bot_x = end_map.x;
|
||||
self.chunk_buf.appendAssumeCapacity(.{
|
||||
.node = meta.node,
|
||||
.start = @intCast(start_map.y),
|
||||
.end = @intCast(end_map.y + 1),
|
||||
});
|
||||
|
||||
break :tl .{
|
||||
.br = null,
|
||||
.prune = .{
|
||||
.meta = meta_it.idx - 1,
|
||||
.data = prior_meta_consumed,
|
||||
},
|
||||
};
|
||||
} else {
|
||||
// We found the meta that contains the start of the match
|
||||
// only. Consume this entire node from our start offset.
|
||||
const map = meta.cell_map.items[meta_i];
|
||||
result.top_x = map.x;
|
||||
self.chunk_buf.appendAssumeCapacity(.{
|
||||
.node = meta.node,
|
||||
.start = @intCast(map.y),
|
||||
.end = meta.node.data.size.rows,
|
||||
});
|
||||
|
||||
break :tl .{
|
||||
.br = .{
|
||||
.it = meta_it,
|
||||
.consumed = meta_consumed,
|
||||
},
|
||||
.prune = .{
|
||||
.meta = meta_it.idx - 1,
|
||||
.data = prior_meta_consumed,
|
||||
},
|
||||
};
|
||||
}
|
||||
} else {
|
||||
// Precondition that the start index is within the data buffer.
|
||||
unreachable;
|
||||
}
|
||||
};
|
||||
|
||||
// Search for our end.
|
||||
if (tl.br) |br| {
|
||||
var meta_it = br.it;
|
||||
var meta_consumed: usize = br.consumed;
|
||||
while (meta_it.next()) |meta| {
|
||||
// meta_i is the index we expect to find the match in the
|
||||
// cell map within this meta if it contains it.
|
||||
const meta_i = end - meta_consumed;
|
||||
if (meta_i >= meta.cell_map.items.len) {
|
||||
// This meta doesn't contain the match. We still add it
|
||||
// to our results because we want the full flattened list.
|
||||
self.chunk_buf.appendAssumeCapacity(.{
|
||||
.node = meta.node,
|
||||
.start = 0,
|
||||
.end = meta.node.data.size.rows,
|
||||
});
|
||||
|
||||
meta_consumed += meta.cell_map.items.len;
|
||||
continue;
|
||||
}
|
||||
|
||||
// We found it
|
||||
const map = meta.cell_map.items[meta_i];
|
||||
result.bot_x = map.x;
|
||||
self.chunk_buf.appendAssumeCapacity(.{
|
||||
.node = meta.node,
|
||||
.start = 0,
|
||||
.end = @intCast(map.y + 1),
|
||||
});
|
||||
break;
|
||||
} else {
|
||||
// Precondition that the end index is within the data buffer.
|
||||
unreachable;
|
||||
}
|
||||
}
|
||||
|
||||
// Our offset into the current meta block is the start index
|
||||
// minus the amount of data fully consumed. We then add one
|
||||
// to move one past the match so we don't repeat it.
|
||||
self.data_offset = start - tl_meta_consumed + 1;
|
||||
self.data_offset = start - tl.prune.data + 1;
|
||||
|
||||
// meta_it.idx is br's meta index plus one (because the iterator
|
||||
// moves one past the end; we call next() one last time). So
|
||||
// we compare against one to check that the meta that we matched
|
||||
// in has prior meta blocks we can prune.
|
||||
if (tl_meta_idx > 0) {
|
||||
// If we went beyond our initial meta node we can prune.
|
||||
if (tl.prune.meta > 0) {
|
||||
// Deinit all our memory in the meta blocks prior to our
|
||||
// match.
|
||||
const meta_count = tl_meta_idx;
|
||||
meta_it.reset();
|
||||
for (0..meta_count) |_| meta_it.next().?.deinit(self.alloc);
|
||||
if (comptime std.debug.runtime_safety) {
|
||||
assert(meta_it.idx == meta_count);
|
||||
assert(meta_it.next().?.node == tl.node);
|
||||
var meta_it = self.meta.iterator(.forward);
|
||||
var meta_consumed: usize = 0;
|
||||
for (0..tl.prune.meta) |_| {
|
||||
const meta: *Meta = meta_it.next().?;
|
||||
meta_consumed += meta.cell_map.items.len;
|
||||
meta.deinit(self.alloc);
|
||||
}
|
||||
self.meta.deleteOldest(meta_count);
|
||||
if (comptime std.debug.runtime_safety) {
|
||||
assert(meta_it.idx == tl.prune.meta);
|
||||
assert(meta_it.next().?.node == self.chunk_buf.items(.node)[0]);
|
||||
}
|
||||
self.meta.deleteOldest(tl.prune.meta);
|
||||
|
||||
// Delete all the data up to our current index.
|
||||
assert(tl_meta_consumed > 0);
|
||||
self.data.deleteOldest(tl_meta_consumed);
|
||||
assert(tl.prune.data > 0);
|
||||
self.data.deleteOldest(tl.prune.data);
|
||||
}
|
||||
|
||||
self.assertIntegrity();
|
||||
return switch (self.direction) {
|
||||
.forward => .init(tl, br, false),
|
||||
.reverse => .init(br, tl, false),
|
||||
};
|
||||
}
|
||||
switch (self.direction) {
|
||||
.forward => {},
|
||||
.reverse => {
|
||||
if (self.chunk_buf.len > 1) {
|
||||
// Reverse all our chunks. This should be pretty obvious why.
|
||||
const slice = self.chunk_buf.slice();
|
||||
const nodes = slice.items(.node);
|
||||
const starts = slice.items(.start);
|
||||
const ends = slice.items(.end);
|
||||
std.mem.reverse(*PageList.List.Node, nodes);
|
||||
std.mem.reverse(size.CellCountInt, starts);
|
||||
std.mem.reverse(size.CellCountInt, ends);
|
||||
|
||||
/// Convert a data index into a pin.
|
||||
///
|
||||
/// The iterator and offset are both expected to be passed by
|
||||
/// pointer so that the pin can be efficiently called for multiple
|
||||
/// indexes (in order). See selection() for an example.
|
||||
///
|
||||
/// Precondition: the index must be within the data buffer.
|
||||
fn pin(
|
||||
it: *MetaBuf.Iterator,
|
||||
offset: *usize,
|
||||
idx: usize,
|
||||
) Pin {
|
||||
while (it.next()) |meta| {
|
||||
// meta_i is the index we expect to find the match in the
|
||||
// cell map within this meta if it contains it.
|
||||
const meta_i = idx - offset.*;
|
||||
if (meta_i >= meta.cell_map.items.len) {
|
||||
// This meta doesn't contain the match. This means we
|
||||
// can also prune this set of data because we only look
|
||||
// forward.
|
||||
offset.* += meta.cell_map.items.len;
|
||||
continue;
|
||||
}
|
||||
// Now normally with forward traversal with multiple pages,
|
||||
// the suffix of the first page and the prefix of the last
|
||||
// page are used.
|
||||
//
|
||||
// For a reverse traversal, this is inverted (since the
|
||||
// pages are in reverse order we get the suffix of the last
|
||||
// page and the prefix of the first page). So we need to
|
||||
// invert this.
|
||||
//
|
||||
// We DON'T need to do this for any middle pages because
|
||||
// they always use the full page.
|
||||
//
|
||||
// We DON'T need to do this for chunks.len == 1 because
|
||||
// the pages themselves aren't reversed and we don't have
|
||||
// any prefix/suffix problems.
|
||||
//
|
||||
// This is a fixup that makes our start/end match the
|
||||
// same logic as the loops above if they were in forward
|
||||
// order.
|
||||
assert(nodes.len >= 2);
|
||||
starts[0] = ends[0] - 1;
|
||||
ends[0] = nodes[0].data.size.rows;
|
||||
ends[nodes.len - 1] = starts[nodes.len - 1] + 1;
|
||||
starts[nodes.len - 1] = 0;
|
||||
}
|
||||
|
||||
// We found the meta that contains the start of the match.
|
||||
const map = meta.cell_map.items[meta_i];
|
||||
return .{
|
||||
.node = meta.node,
|
||||
.y = @intCast(map.y),
|
||||
.x = map.x,
|
||||
};
|
||||
// X values also need to be reversed since the top/bottom
|
||||
// are swapped for the nodes.
|
||||
const top_x = result.top_x;
|
||||
result.top_x = result.bot_x;
|
||||
result.bot_x = top_x;
|
||||
},
|
||||
}
|
||||
|
||||
// Unreachable because it is a precondition that the index is
|
||||
// within the data buffer.
|
||||
unreachable;
|
||||
// Copy over our MultiArrayList so it points to the proper memory.
|
||||
result.chunks = self.chunk_buf;
|
||||
return result;
|
||||
}
|
||||
|
||||
/// Add a new node to the sliding window. This will always grow
|
||||
|
|
@ -442,10 +568,11 @@ pub const SlidingWindow = struct {
|
|||
// Ensure our buffers are big enough to store what we need.
|
||||
try self.data.ensureUnusedCapacity(self.alloc, written.len);
|
||||
try self.meta.ensureUnusedCapacity(self.alloc, 1);
|
||||
try self.chunk_buf.ensureTotalCapacity(self.alloc, self.meta.capacity());
|
||||
|
||||
// Append our new node to the circular buffer.
|
||||
try self.data.appendSlice(written);
|
||||
try self.meta.append(meta);
|
||||
self.data.appendSliceAssumeCapacity(written);
|
||||
self.meta.appendAssumeCapacity(meta);
|
||||
|
||||
self.assertIntegrity();
|
||||
return written.len;
|
||||
|
|
@ -505,31 +632,77 @@ test "SlidingWindow single append" {
|
|||
|
||||
// We should be able to find two matches.
|
||||
{
|
||||
const sel = w.next().?;
|
||||
const h = w.next().?;
|
||||
const sel = h.untracked();
|
||||
try testing.expectEqual(point.Point{ .active = .{
|
||||
.x = 7,
|
||||
.y = 0,
|
||||
} }, s.pages.pointFromPin(.active, sel.start()).?);
|
||||
} }, s.pages.pointFromPin(.active, sel.start));
|
||||
try testing.expectEqual(point.Point{ .active = .{
|
||||
.x = 10,
|
||||
.y = 0,
|
||||
} }, s.pages.pointFromPin(.active, sel.end()).?);
|
||||
} }, s.pages.pointFromPin(.active, sel.end));
|
||||
}
|
||||
{
|
||||
const sel = w.next().?;
|
||||
const h = w.next().?;
|
||||
const sel = h.untracked();
|
||||
try testing.expectEqual(point.Point{ .active = .{
|
||||
.x = 19,
|
||||
.y = 0,
|
||||
} }, s.pages.pointFromPin(.active, sel.start()).?);
|
||||
} }, s.pages.pointFromPin(.active, sel.start));
|
||||
try testing.expectEqual(point.Point{ .active = .{
|
||||
.x = 22,
|
||||
.y = 0,
|
||||
} }, s.pages.pointFromPin(.active, sel.end()).?);
|
||||
} }, s.pages.pointFromPin(.active, sel.end));
|
||||
}
|
||||
try testing.expect(w.next() == null);
|
||||
try testing.expect(w.next() == null);
|
||||
}
|
||||
|
||||
test "SlidingWindow single append case insensitive ASCII" {
|
||||
const testing = std.testing;
|
||||
const alloc = testing.allocator;
|
||||
|
||||
var w: SlidingWindow = try .init(alloc, .forward, "Boo!");
|
||||
defer w.deinit();
|
||||
|
||||
var s = try Screen.init(alloc, .{ .cols = 80, .rows = 24, .max_scrollback = 0 });
|
||||
defer s.deinit();
|
||||
try s.testWriteString("hello. boo! hello. boo!");
|
||||
|
||||
// We want to test single-page cases.
|
||||
try testing.expect(s.pages.pages.first == s.pages.pages.last);
|
||||
const node: *PageList.List.Node = s.pages.pages.first.?;
|
||||
_ = try w.append(node);
|
||||
|
||||
// We should be able to find two matches.
|
||||
{
|
||||
const h = w.next().?;
|
||||
const sel = h.untracked();
|
||||
try testing.expectEqual(point.Point{ .active = .{
|
||||
.x = 7,
|
||||
.y = 0,
|
||||
} }, s.pages.pointFromPin(.active, sel.start));
|
||||
try testing.expectEqual(point.Point{ .active = .{
|
||||
.x = 10,
|
||||
.y = 0,
|
||||
} }, s.pages.pointFromPin(.active, sel.end));
|
||||
}
|
||||
{
|
||||
const h = w.next().?;
|
||||
const sel = h.untracked();
|
||||
try testing.expectEqual(point.Point{ .active = .{
|
||||
.x = 19,
|
||||
.y = 0,
|
||||
} }, s.pages.pointFromPin(.active, sel.start));
|
||||
try testing.expectEqual(point.Point{ .active = .{
|
||||
.x = 22,
|
||||
.y = 0,
|
||||
} }, s.pages.pointFromPin(.active, sel.end));
|
||||
}
|
||||
try testing.expect(w.next() == null);
|
||||
try testing.expect(w.next() == null);
|
||||
}
|
||||
test "SlidingWindow single append no match" {
|
||||
const testing = std.testing;
|
||||
const alloc = testing.allocator;
|
||||
|
|
@ -582,26 +755,28 @@ test "SlidingWindow two pages" {
|
|||
|
||||
// Search should find two matches
|
||||
{
|
||||
const sel = w.next().?;
|
||||
const h = w.next().?;
|
||||
const sel = h.untracked();
|
||||
try testing.expectEqual(point.Point{ .active = .{
|
||||
.x = 76,
|
||||
.y = 22,
|
||||
} }, s.pages.pointFromPin(.active, sel.start()).?);
|
||||
} }, s.pages.pointFromPin(.active, sel.start).?);
|
||||
try testing.expectEqual(point.Point{ .active = .{
|
||||
.x = 79,
|
||||
.y = 22,
|
||||
} }, s.pages.pointFromPin(.active, sel.end()).?);
|
||||
} }, s.pages.pointFromPin(.active, sel.end).?);
|
||||
}
|
||||
{
|
||||
const sel = w.next().?;
|
||||
const h = w.next().?;
|
||||
const sel = h.untracked();
|
||||
try testing.expectEqual(point.Point{ .active = .{
|
||||
.x = 7,
|
||||
.y = 23,
|
||||
} }, s.pages.pointFromPin(.active, sel.start()).?);
|
||||
} }, s.pages.pointFromPin(.active, sel.start).?);
|
||||
try testing.expectEqual(point.Point{ .active = .{
|
||||
.x = 10,
|
||||
.y = 23,
|
||||
} }, s.pages.pointFromPin(.active, sel.end()).?);
|
||||
} }, s.pages.pointFromPin(.active, sel.end).?);
|
||||
}
|
||||
try testing.expect(w.next() == null);
|
||||
try testing.expect(w.next() == null);
|
||||
|
|
@ -634,15 +809,16 @@ test "SlidingWindow two pages match across boundary" {
|
|||
|
||||
// Search should find a match
|
||||
{
|
||||
const sel = w.next().?;
|
||||
const h = w.next().?;
|
||||
const sel = h.untracked();
|
||||
try testing.expectEqual(point.Point{ .active = .{
|
||||
.x = 76,
|
||||
.y = 22,
|
||||
} }, s.pages.pointFromPin(.active, sel.start()).?);
|
||||
} }, s.pages.pointFromPin(.active, sel.start).?);
|
||||
try testing.expectEqual(point.Point{ .active = .{
|
||||
.x = 7,
|
||||
.y = 23,
|
||||
} }, s.pages.pointFromPin(.active, sel.end()).?);
|
||||
} }, s.pages.pointFromPin(.active, sel.end).?);
|
||||
}
|
||||
try testing.expect(w.next() == null);
|
||||
try testing.expect(w.next() == null);
|
||||
|
|
@ -831,15 +1007,16 @@ test "SlidingWindow single append across circular buffer boundary" {
|
|||
try testing.expect(slices[1].len > 0);
|
||||
}
|
||||
{
|
||||
const sel = w.next().?;
|
||||
const h = w.next().?;
|
||||
const sel = h.untracked();
|
||||
try testing.expectEqual(point.Point{ .active = .{
|
||||
.x = 19,
|
||||
.y = 0,
|
||||
} }, s.pages.pointFromPin(.active, sel.start()).?);
|
||||
} }, s.pages.pointFromPin(.active, sel.start).?);
|
||||
try testing.expectEqual(point.Point{ .active = .{
|
||||
.x = 21,
|
||||
.y = 0,
|
||||
} }, s.pages.pointFromPin(.active, sel.end()).?);
|
||||
} }, s.pages.pointFromPin(.active, sel.end).?);
|
||||
}
|
||||
try testing.expect(w.next() == null);
|
||||
}
|
||||
|
|
@ -889,15 +1066,16 @@ test "SlidingWindow single append match on boundary" {
|
|||
try testing.expect(slices[1].len > 0);
|
||||
}
|
||||
{
|
||||
const sel = w.next().?;
|
||||
const h = w.next().?;
|
||||
const sel = h.untracked();
|
||||
try testing.expectEqual(point.Point{ .active = .{
|
||||
.x = 21,
|
||||
.y = 0,
|
||||
} }, s.pages.pointFromPin(.active, sel.start()).?);
|
||||
} }, s.pages.pointFromPin(.active, sel.start).?);
|
||||
try testing.expectEqual(point.Point{ .active = .{
|
||||
.x = 1,
|
||||
.y = 0,
|
||||
} }, s.pages.pointFromPin(.active, sel.end()).?);
|
||||
} }, s.pages.pointFromPin(.active, sel.end).?);
|
||||
}
|
||||
try testing.expect(w.next() == null);
|
||||
}
|
||||
|
|
@ -920,26 +1098,28 @@ test "SlidingWindow single append reversed" {
|
|||
|
||||
// We should be able to find two matches.
|
||||
{
|
||||
const sel = w.next().?;
|
||||
const h = w.next().?;
|
||||
const sel = h.untracked();
|
||||
try testing.expectEqual(point.Point{ .active = .{
|
||||
.x = 19,
|
||||
.y = 0,
|
||||
} }, s.pages.pointFromPin(.active, sel.start()).?);
|
||||
} }, s.pages.pointFromPin(.active, sel.start).?);
|
||||
try testing.expectEqual(point.Point{ .active = .{
|
||||
.x = 22,
|
||||
.y = 0,
|
||||
} }, s.pages.pointFromPin(.active, sel.end()).?);
|
||||
} }, s.pages.pointFromPin(.active, sel.end).?);
|
||||
}
|
||||
{
|
||||
const sel = w.next().?;
|
||||
const h = w.next().?;
|
||||
const sel = h.untracked();
|
||||
try testing.expectEqual(point.Point{ .active = .{
|
||||
.x = 7,
|
||||
.y = 0,
|
||||
} }, s.pages.pointFromPin(.active, sel.start()).?);
|
||||
} }, s.pages.pointFromPin(.active, sel.start).?);
|
||||
try testing.expectEqual(point.Point{ .active = .{
|
||||
.x = 10,
|
||||
.y = 0,
|
||||
} }, s.pages.pointFromPin(.active, sel.end()).?);
|
||||
} }, s.pages.pointFromPin(.active, sel.end).?);
|
||||
}
|
||||
try testing.expect(w.next() == null);
|
||||
try testing.expect(w.next() == null);
|
||||
|
|
@ -997,26 +1177,28 @@ test "SlidingWindow two pages reversed" {
|
|||
|
||||
// Search should find two matches (in reverse order)
|
||||
{
|
||||
const sel = w.next().?;
|
||||
const h = w.next().?;
|
||||
const sel = h.untracked();
|
||||
try testing.expectEqual(point.Point{ .active = .{
|
||||
.x = 7,
|
||||
.y = 23,
|
||||
} }, s.pages.pointFromPin(.active, sel.start()).?);
|
||||
} }, s.pages.pointFromPin(.active, sel.start).?);
|
||||
try testing.expectEqual(point.Point{ .active = .{
|
||||
.x = 10,
|
||||
.y = 23,
|
||||
} }, s.pages.pointFromPin(.active, sel.end()).?);
|
||||
} }, s.pages.pointFromPin(.active, sel.end).?);
|
||||
}
|
||||
{
|
||||
const sel = w.next().?;
|
||||
const h = w.next().?;
|
||||
const sel = h.untracked();
|
||||
try testing.expectEqual(point.Point{ .active = .{
|
||||
.x = 76,
|
||||
.y = 22,
|
||||
} }, s.pages.pointFromPin(.active, sel.start()).?);
|
||||
} }, s.pages.pointFromPin(.active, sel.start).?);
|
||||
try testing.expectEqual(point.Point{ .active = .{
|
||||
.x = 79,
|
||||
.y = 22,
|
||||
} }, s.pages.pointFromPin(.active, sel.end()).?);
|
||||
} }, s.pages.pointFromPin(.active, sel.end).?);
|
||||
}
|
||||
try testing.expect(w.next() == null);
|
||||
try testing.expect(w.next() == null);
|
||||
|
|
@ -1049,15 +1231,16 @@ test "SlidingWindow two pages match across boundary reversed" {
|
|||
|
||||
// Search should find a match
|
||||
{
|
||||
const sel = w.next().?;
|
||||
const h = w.next().?;
|
||||
const sel = h.untracked();
|
||||
try testing.expectEqual(point.Point{ .active = .{
|
||||
.x = 76,
|
||||
.y = 22,
|
||||
} }, s.pages.pointFromPin(.active, sel.start()).?);
|
||||
} }, s.pages.pointFromPin(.active, sel.start).?);
|
||||
try testing.expectEqual(point.Point{ .active = .{
|
||||
.x = 7,
|
||||
.y = 23,
|
||||
} }, s.pages.pointFromPin(.active, sel.end()).?);
|
||||
} }, s.pages.pointFromPin(.active, sel.end).?);
|
||||
}
|
||||
try testing.expect(w.next() == null);
|
||||
try testing.expect(w.next() == null);
|
||||
|
|
@ -1185,15 +1368,16 @@ test "SlidingWindow single append across circular buffer boundary reversed" {
|
|||
try testing.expect(slices[1].len > 0);
|
||||
}
|
||||
{
|
||||
const sel = w.next().?;
|
||||
const h = w.next().?;
|
||||
const sel = h.untracked();
|
||||
try testing.expectEqual(point.Point{ .active = .{
|
||||
.x = 19,
|
||||
.y = 0,
|
||||
} }, s.pages.pointFromPin(.active, sel.start()).?);
|
||||
} }, s.pages.pointFromPin(.active, sel.start).?);
|
||||
try testing.expectEqual(point.Point{ .active = .{
|
||||
.x = 21,
|
||||
.y = 0,
|
||||
} }, s.pages.pointFromPin(.active, sel.end()).?);
|
||||
} }, s.pages.pointFromPin(.active, sel.end).?);
|
||||
}
|
||||
try testing.expect(w.next() == null);
|
||||
}
|
||||
|
|
@ -1244,15 +1428,16 @@ test "SlidingWindow single append match on boundary reversed" {
|
|||
try testing.expect(slices[1].len > 0);
|
||||
}
|
||||
{
|
||||
const sel = w.next().?;
|
||||
const h = w.next().?;
|
||||
const sel = h.untracked();
|
||||
try testing.expectEqual(point.Point{ .active = .{
|
||||
.x = 21,
|
||||
.y = 0,
|
||||
} }, s.pages.pointFromPin(.active, sel.start()).?);
|
||||
} }, s.pages.pointFromPin(.active, sel.start).?);
|
||||
try testing.expectEqual(point.Point{ .active = .{
|
||||
.x = 1,
|
||||
.y = 0,
|
||||
} }, s.pages.pointFromPin(.active, sel.end()).?);
|
||||
} }, s.pages.pointFromPin(.active, sel.end).?);
|
||||
}
|
||||
try testing.expect(w.next() == null);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -4,6 +4,7 @@ const testing = std.testing;
|
|||
const Allocator = std.mem.Allocator;
|
||||
const point = @import("../point.zig");
|
||||
const size = @import("../size.zig");
|
||||
const FlattenedHighlight = @import("../highlight.zig").Flattened;
|
||||
const PageList = @import("../PageList.zig");
|
||||
const Selection = @import("../Selection.zig");
|
||||
const SlidingWindow = @import("sliding_window.zig").SlidingWindow;
|
||||
|
|
@ -26,6 +27,12 @@ pub const ViewportSearch = struct {
|
|||
window: SlidingWindow,
|
||||
fingerprint: ?Fingerprint,
|
||||
|
||||
/// If this is null, then active dirty tracking is disabled and if the
|
||||
/// viewport overlaps the active area we always re-search. If this is
|
||||
/// non-null, then we only re-search if the active area is dirty. Dirty
|
||||
/// marking is up to the caller.
|
||||
active_dirty: ?bool,
|
||||
|
||||
pub fn init(
|
||||
alloc: Allocator,
|
||||
needle_unowned: []const u8,
|
||||
|
|
@ -35,7 +42,11 @@ pub const ViewportSearch = struct {
|
|||
// a small amount of work to reverse things.
|
||||
var window: SlidingWindow = try .init(alloc, .forward, needle_unowned);
|
||||
errdefer window.deinit();
|
||||
return .{ .window = window, .fingerprint = null };
|
||||
return .{
|
||||
.window = window,
|
||||
.fingerprint = null,
|
||||
.active_dirty = null,
|
||||
};
|
||||
}
|
||||
|
||||
pub fn deinit(self: *ViewportSearch) void {
|
||||
|
|
@ -74,17 +85,29 @@ pub const ViewportSearch = struct {
|
|||
var fingerprint: Fingerprint = try .init(self.window.alloc, list);
|
||||
if (self.fingerprint) |*old| {
|
||||
if (old.eql(fingerprint)) match: {
|
||||
// If our fingerprint contains the active area, then we always
|
||||
// re-search since the active area is mutable.
|
||||
const active_tl = list.getTopLeft(.active);
|
||||
const active_br = list.getBottomRight(.active).?;
|
||||
// Determine if we need to check if we overlap the active
|
||||
// area. If we have dirty tracking on we also set it to
|
||||
// false here.
|
||||
const check_active: bool = active: {
|
||||
const dirty = self.active_dirty orelse break :active true;
|
||||
if (!dirty) break :active false;
|
||||
self.active_dirty = false;
|
||||
break :active true;
|
||||
};
|
||||
|
||||
// If our viewport contains the start or end of the active area,
|
||||
// we are in the active area. We purposely do this first
|
||||
// because our viewport is always larger than the active area.
|
||||
for (old.nodes) |node| {
|
||||
if (node == active_tl.node) break :match;
|
||||
if (node == active_br.node) break :match;
|
||||
if (check_active) {
|
||||
// If our fingerprint contains the active area, then we always
|
||||
// re-search since the active area is mutable.
|
||||
const active_tl = list.getTopLeft(.active);
|
||||
const active_br = list.getBottomRight(.active).?;
|
||||
|
||||
// If our viewport contains the start or end of the active area,
|
||||
// we are in the active area. We purposely do this first
|
||||
// because our viewport is always larger than the active area.
|
||||
for (old.nodes) |node| {
|
||||
if (node == active_tl.node) break :match;
|
||||
if (node == active_br.node) break :match;
|
||||
}
|
||||
}
|
||||
|
||||
// No change
|
||||
|
|
@ -102,6 +125,10 @@ pub const ViewportSearch = struct {
|
|||
self.fingerprint = null;
|
||||
}
|
||||
|
||||
// If our active area was set as dirty, we always unset it here
|
||||
// because we're re-searching now.
|
||||
if (self.active_dirty) |*v| v.* = false;
|
||||
|
||||
// Clear our previous sliding window
|
||||
self.window.clearAndRetainCapacity();
|
||||
|
||||
|
|
@ -150,7 +177,7 @@ pub const ViewportSearch = struct {
|
|||
|
||||
/// Find the next match for the needle in the active area. This returns
|
||||
/// null when there are no more matches.
|
||||
pub fn next(self: *ViewportSearch) ?Selection {
|
||||
pub fn next(self: *ViewportSearch) ?FlattenedHighlight {
|
||||
return self.window.next();
|
||||
}
|
||||
|
||||
|
|
@ -207,26 +234,28 @@ test "simple search" {
|
|||
try testing.expect(try search.update(&t.screens.active.pages));
|
||||
|
||||
{
|
||||
const sel = search.next().?;
|
||||
const h = search.next().?;
|
||||
const sel = h.untracked();
|
||||
try testing.expectEqual(point.Point{ .active = .{
|
||||
.x = 0,
|
||||
.y = 0,
|
||||
} }, t.screens.active.pages.pointFromPin(.active, sel.start()).?);
|
||||
} }, t.screens.active.pages.pointFromPin(.active, sel.start).?);
|
||||
try testing.expectEqual(point.Point{ .active = .{
|
||||
.x = 3,
|
||||
.y = 0,
|
||||
} }, t.screens.active.pages.pointFromPin(.active, sel.end()).?);
|
||||
} }, t.screens.active.pages.pointFromPin(.active, sel.end).?);
|
||||
}
|
||||
{
|
||||
const sel = search.next().?;
|
||||
const h = search.next().?;
|
||||
const sel = h.untracked();
|
||||
try testing.expectEqual(point.Point{ .active = .{
|
||||
.x = 0,
|
||||
.y = 2,
|
||||
} }, t.screens.active.pages.pointFromPin(.active, sel.start()).?);
|
||||
} }, t.screens.active.pages.pointFromPin(.active, sel.start).?);
|
||||
try testing.expectEqual(point.Point{ .active = .{
|
||||
.x = 3,
|
||||
.y = 2,
|
||||
} }, t.screens.active.pages.pointFromPin(.active, sel.end()).?);
|
||||
} }, t.screens.active.pages.pointFromPin(.active, sel.end).?);
|
||||
}
|
||||
try testing.expect(search.next() == null);
|
||||
}
|
||||
|
|
@ -250,15 +279,63 @@ test "clear screen and search" {
|
|||
try testing.expect(try search.update(&t.screens.active.pages));
|
||||
|
||||
{
|
||||
const sel = search.next().?;
|
||||
const h = search.next().?;
|
||||
const sel = h.untracked();
|
||||
try testing.expectEqual(point.Point{ .active = .{
|
||||
.x = 0,
|
||||
.y = 1,
|
||||
} }, t.screens.active.pages.pointFromPin(.active, sel.start()).?);
|
||||
} }, 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()).?);
|
||||
} }, t.screens.active.pages.pointFromPin(.active, sel.end).?);
|
||||
}
|
||||
try testing.expect(search.next() == null);
|
||||
}
|
||||
|
||||
test "clear screen and search dirty tracking" {
|
||||
const alloc = testing.allocator;
|
||||
var t: Terminal = try .init(alloc, .{ .cols = 10, .rows = 10 });
|
||||
defer t.deinit(alloc);
|
||||
|
||||
var s = t.vtStream();
|
||||
defer s.deinit();
|
||||
try s.nextSlice("Fizz\r\nBuzz\r\nFizz\r\nBang");
|
||||
|
||||
var search: ViewportSearch = try .init(alloc, "Fizz");
|
||||
defer search.deinit();
|
||||
|
||||
// Turn on dirty tracking
|
||||
search.active_dirty = false;
|
||||
|
||||
// Should update since we've never searched before
|
||||
try testing.expect(try search.update(&t.screens.active.pages));
|
||||
|
||||
// Should not update since nothing changed
|
||||
try testing.expect(!try search.update(&t.screens.active.pages));
|
||||
|
||||
try s.nextSlice("\x1b[2J"); // Clear screen
|
||||
try s.nextSlice("\x1b[H"); // Move cursor home
|
||||
try s.nextSlice("Buzz\r\nFizz\r\nBuzz");
|
||||
|
||||
// Should still not update since active area isn't dirty
|
||||
try testing.expect(!try search.update(&t.screens.active.pages));
|
||||
|
||||
// Mark
|
||||
search.active_dirty = true;
|
||||
try testing.expect(try search.update(&t.screens.active.pages));
|
||||
|
||||
{
|
||||
const h = search.next().?;
|
||||
const sel = h.untracked();
|
||||
try testing.expectEqual(point.Point{ .active = .{
|
||||
.x = 0,
|
||||
.y = 1,
|
||||
} }, t.screens.active.pages.pointFromPin(.active, sel.start).?);
|
||||
try testing.expectEqual(point.Point{ .active = .{
|
||||
.x = 3,
|
||||
.y = 1,
|
||||
} }, t.screens.active.pages.pointFromPin(.active, sel.end).?);
|
||||
}
|
||||
try testing.expect(search.next() == null);
|
||||
}
|
||||
|
|
@ -289,15 +366,16 @@ test "history search, no active area" {
|
|||
try testing.expect(try search.update(&t.screens.active.pages));
|
||||
|
||||
{
|
||||
const sel = search.next().?;
|
||||
const h = search.next().?;
|
||||
const sel = h.untracked();
|
||||
try testing.expectEqual(point.Point{ .screen = .{
|
||||
.x = 0,
|
||||
.y = 0,
|
||||
} }, t.screens.active.pages.pointFromPin(.screen, sel.start()).?);
|
||||
} }, 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()).?);
|
||||
} }, t.screens.active.pages.pointFromPin(.screen, sel.end).?);
|
||||
}
|
||||
try testing.expect(search.next() == null);
|
||||
|
||||
|
|
|
|||
Loading…
Reference in New Issue