Merge branch 'grapheme-break' into grapheme-width-changes

This commit is contained in:
Jacob Sandlund
2025-11-26 17:46:17 -05:00
42 changed files with 2287 additions and 309 deletions

View File

@@ -804,6 +804,14 @@ pub fn close(self: *Surface) void {
self.rt_surface.close(self.needsConfirmQuit());
}
/// Returns a mailbox that can be used to send messages to this surface.
inline fn surfaceMailbox(self: *Surface) Mailbox {
return .{
.surface = self,
.app = .{ .rt_app = self.rt_app, .mailbox = &self.app.mailbox },
};
}
/// Forces the surface to render. This is useful for when the surface
/// is in the middle of animation (such as a resize, etc.) or when
/// the render timer is managed manually by the apprt.
@@ -1069,6 +1077,22 @@ pub fn handleMessage(self: *Surface, msg: Message) !void {
log.warn("apprt failed to notify command finish={}", .{err});
};
},
.search_total => |v| {
_ = try self.rt_app.performAction(
.{ .surface = self },
.search_total,
.{ .total = v },
);
},
.search_selected => |v| {
_ = try self.rt_app.performAction(
.{ .surface = self },
.search_selected,
.{ .selected = v },
);
},
}
}
@@ -1363,8 +1387,57 @@ fn searchCallback_(
try self.renderer_thread.wakeup.notify();
},
.selected_match => |selected_| {
if (selected_) |sel| {
// Copy the flattened match.
var arena: ArenaAllocator = .init(self.alloc);
errdefer arena.deinit();
const alloc = arena.allocator();
const match = try sel.highlight.clone(alloc);
_ = self.renderer_thread.mailbox.push(
.{ .search_selected_match = .{
.arena = arena,
.match = match,
} },
.forever,
);
// Send the selected index to the surface mailbox
_ = self.surfaceMailbox().push(
.{ .search_selected = sel.idx },
.forever,
);
} else {
// Reset our selected match
_ = self.renderer_thread.mailbox.push(
.{ .search_selected_match = null },
.forever,
);
// Reset the selected index
_ = self.surfaceMailbox().push(
.{ .search_selected = null },
.forever,
);
}
try self.renderer_thread.wakeup.notify();
},
.total_matches => |total| {
_ = self.surfaceMailbox().push(
.{ .search_total = total },
.forever,
);
},
// When we quit, tell our renderer to reset any search state.
.quit => {
_ = self.renderer_thread.mailbox.push(
.{ .search_selected_match = null },
.forever,
);
_ = self.renderer_thread.mailbox.push(
.{ .search_viewport_matches = .{
.arena = .init(self.alloc),
@@ -1373,12 +1446,20 @@ fn searchCallback_(
.forever,
);
try self.renderer_thread.wakeup.notify();
// Reset search totals in the surface
_ = self.surfaceMailbox().push(
.{ .search_total = null },
.forever,
);
_ = self.surfaceMailbox().push(
.{ .search_selected = null },
.forever,
);
},
// Unhandled, so far.
.total_matches,
.complete,
=> {},
.complete => {},
}
}
@@ -4851,11 +4932,42 @@ pub fn performBindingAction(self: *Surface, action: input.Binding.Action) !bool
self.renderer_state.terminal.fullReset();
},
.start_search => {
// To save resources, we don't actually start a search here,
// we just notify the apprt. The real thread will start when
// the first needles are set.
return try self.rt_app.performAction(
.{ .surface = self },
.start_search,
.{ .needle = "" },
);
},
.end_search => {
// We only return that this was performed if we actually
// stopped a search, but we also send the apprt end_search so
// that GUIs can clean up stale stuff.
const performed = self.search != null;
if (self.search) |*s| {
s.deinit();
self.search = null;
}
_ = try self.rt_app.performAction(
.{ .surface = self },
.end_search,
{},
);
return performed;
},
.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;
if (text.len == 0) return false;
// We need to assign directly to self.search because we need
// a stable pointer back to the thread state.
@@ -4889,9 +5001,25 @@ pub fn performBindingAction(self: *Surface, action: input.Binding.Action) !bool
}
_ = s.state.mailbox.push(
.{ .change_needle = text },
.{ .change_needle = try .init(
self.alloc,
text,
) },
.forever,
);
s.state.wakeup.notify() catch {};
},
.navigate_search => |nav| {
const s: *Search = if (self.search) |*s| s else return false;
_ = s.state.mailbox.push(
.{ .select = switch (nav) {
.next => .next,
.previous => .prev,
} },
.forever,
);
s.state.wakeup.notify() catch {};
},
.copy_to_clipboard => |format| {

View File

@@ -301,6 +301,18 @@ pub const Action = union(Key) {
/// A command has finished,
command_finished: CommandFinished,
/// Start the search overlay with an optional initial needle.
start_search: StartSearch,
/// End the search overlay, clearing the search state and hiding it.
end_search,
/// The total number of matches found by the search.
search_total: SearchTotal,
/// The currently selected search match index (1-based).
search_selected: SearchSelected,
/// Sync with: ghostty_action_tag_e
pub const Key = enum(c_int) {
quit,
@@ -358,6 +370,10 @@ pub const Action = union(Key) {
progress_report,
show_on_screen_keyboard,
command_finished,
start_search,
end_search,
search_total,
search_selected,
};
/// Sync with: ghostty_action_u
@@ -770,3 +786,48 @@ pub const CommandFinished = struct {
};
}
};
pub const StartSearch = struct {
needle: [:0]const u8,
// Sync with: ghostty_action_start_search_s
pub const C = extern struct {
needle: [*:0]const u8,
};
pub fn cval(self: StartSearch) C {
return .{
.needle = self.needle.ptr,
};
}
};
pub const SearchTotal = struct {
total: ?usize,
// Sync with: ghostty_action_search_total_s
pub const C = extern struct {
total: isize,
};
pub fn cval(self: SearchTotal) C {
return .{
.total = if (self.total) |t| @intCast(t) else -1,
};
}
};
pub const SearchSelected = struct {
selected: ?usize,
// Sync with: ghostty_action_search_selected_s
pub const C = extern struct {
selected: isize,
};
pub fn cval(self: SearchSelected) C {
return .{
.selected = if (self.selected) |s| @intCast(s) else -1,
};
}
};

View File

@@ -743,6 +743,10 @@ pub const Application = extern struct {
.check_for_updates,
.undo,
.redo,
.start_search,
.end_search,
.search_total,
.search_selected,
=> {
log.warn("unimplemented action={}", .{action});
return false;

View File

@@ -3373,12 +3373,16 @@ const Clipboard = struct {
// text/plain type. The default charset when there is
// none is ASCII, and lots of things look for UTF-8
// specifically.
// The specs are not clear about the order here, but
// some clients apparently pick the first match in the
// order we set here then garble up bare 'text/plain'
// with non-ASCII UTF-8 content, so offer UTF-8 first.
//
// Note that under X11, GTK automatically adds the
// UTF8_STRING atom when this is present.
const text_provider_atoms = [_][:0]const u8{
"text/plain",
"text/plain;charset=utf-8",
"text/plain",
};
var text_providers: [text_provider_atoms.len]*gdk.ContentProvider = undefined;
for (text_provider_atoms, 0..) |atom, j| {

View File

@@ -6,15 +6,15 @@ const build_config = @import("../build_config.zig");
const App = @import("../App.zig");
const Surface = @import("../Surface.zig");
const renderer = @import("../renderer.zig");
const termio = @import("../termio.zig");
const terminal = @import("../terminal/main.zig");
const Config = @import("../config.zig").Config;
const MessageData = @import("../datastruct/main.zig").MessageData;
/// The message types that can be sent to a single surface.
pub const Message = union(enum) {
/// Represents a write request. Magic number comes from the max size
/// we want this union to be.
pub const WriteReq = termio.MessageData(u8, 255);
pub const WriteReq = MessageData(u8, 255);
/// Set the title of the surface.
/// TODO: we should change this to a "WriteReq" style structure in
@@ -107,6 +107,12 @@ pub const Message = union(enum) {
/// The scrollbar state changed for the surface.
scrollbar: terminal.Scrollbar,
/// Search progress update
search_total: ?usize,
/// Selected search index change
search_selected: ?usize,
pub const ReportTitleStyle = enum {
csi_21_t,

View File

@@ -107,7 +107,7 @@ fn stepWcwidth(ptr: *anyopaque) Benchmark.Error!void {
const self: *CodepointWidth = @ptrCast(@alignCast(ptr));
const f = self.data_f orelse return;
var read_buf: [4096]u8 = undefined;
var read_buf: [4096]u8 align(std.atomic.cache_line) = undefined;
var f_reader = f.reader(&read_buf);
var r = &f_reader.interface;
@@ -134,7 +134,7 @@ fn stepTable(ptr: *anyopaque) Benchmark.Error!void {
const self: *CodepointWidth = @ptrCast(@alignCast(ptr));
const f = self.data_f orelse return;
var read_buf: [4096]u8 = undefined;
var read_buf: [4096]u8 align(std.atomic.cache_line) = undefined;
var f_reader = f.reader(&read_buf);
var r = &f_reader.interface;
@@ -166,7 +166,7 @@ fn stepSimd(ptr: *anyopaque) Benchmark.Error!void {
const self: *CodepointWidth = @ptrCast(@alignCast(ptr));
const f = self.data_f orelse return;
var read_buf: [4096]u8 = undefined;
var read_buf: [4096]u8 align(std.atomic.cache_line) = undefined;
var f_reader = f.reader(&read_buf);
var r = &f_reader.interface;

View File

@@ -91,7 +91,7 @@ fn stepNoop(ptr: *anyopaque) Benchmark.Error!void {
const self: *GraphemeBreak = @ptrCast(@alignCast(ptr));
const f = self.data_f orelse return;
var read_buf: [4096]u8 = undefined;
var read_buf: [4096]u8 align(std.atomic.cache_line) = undefined;
var f_reader = f.reader(&read_buf);
var r = &f_reader.interface;
@@ -114,7 +114,7 @@ fn stepTable(ptr: *anyopaque) Benchmark.Error!void {
const self: *GraphemeBreak = @ptrCast(@alignCast(ptr));
const f = self.data_f orelse return;
var read_buf: [4096]u8 = undefined;
var read_buf: [4096]u8 align(std.atomic.cache_line) = undefined;
var f_reader = f.reader(&read_buf);
var r = &f_reader.interface;

View File

@@ -90,7 +90,7 @@ fn stepUucode(ptr: *anyopaque) Benchmark.Error!void {
const self: *IsSymbol = @ptrCast(@alignCast(ptr));
const f = self.data_f orelse return;
var read_buf: [4096]u8 = undefined;
var read_buf: [4096]u8 align(std.atomic.cache_line) = undefined;
var f_reader = f.reader(&read_buf);
var r = &f_reader.interface;
@@ -117,7 +117,7 @@ fn stepTable(ptr: *anyopaque) Benchmark.Error!void {
const self: *IsSymbol = @ptrCast(@alignCast(ptr));
const f = self.data_f orelse return;
var read_buf: [4096]u8 = undefined;
var read_buf: [4096]u8 align(std.atomic.cache_line) = undefined;
var f_reader = f.reader(&read_buf);
var r = &f_reader.interface;

View File

@@ -109,7 +109,7 @@ fn setup(ptr: *anyopaque) Benchmark.Error!void {
var stream = self.terminal.vtStream();
defer stream.deinit();
var read_buf: [4096]u8 = undefined;
var read_buf: [4096]u8 align(std.atomic.cache_line) = undefined;
var f_reader = data_f.reader(&read_buf);
const r = &f_reader.interface;

View File

@@ -75,7 +75,7 @@ fn step(ptr: *anyopaque) Benchmark.Error!void {
// the benchmark results and... I know writing this that we
// aren't currently IO bound.
const f = self.data_f orelse return;
var read_buf: [4096]u8 = undefined;
var read_buf: [4096]u8 align(std.atomic.cache_line) = undefined;
var f_reader = f.reader(&read_buf);
var r = &f_reader.interface;

View File

@@ -114,7 +114,7 @@ fn step(ptr: *anyopaque) Benchmark.Error!void {
// aren't currently IO bound.
const f = self.data_f orelse return;
var read_buf: [4096]u8 = undefined;
var read_buf: [4096]u8 align(std.atomic.cache_line) = undefined;
var f_reader = f.reader(&read_buf);
const r = &f_reader.interface;

View File

@@ -988,10 +988,25 @@ palette: Palette = .{},
/// - "cell-foreground" to match the cell foreground color
/// - "cell-background" to match the cell background color
///
/// The default value is
/// The default value is black text on a golden yellow background.
@"search-foreground": TerminalColor = .{ .color = .{ .r = 0, .g = 0, .b = 0 } },
@"search-background": TerminalColor = .{ .color = .{ .r = 0xFF, .g = 0xE0, .b = 0x82 } },
/// The foreground and background color for the currently selected search match.
/// This is the focused match that will be jumped to when using next/previous
/// search navigation.
///
/// Valid values:
///
/// - Hex (`#RRGGBB` or `RRGGBB`)
/// - Named X11 color
/// - "cell-foreground" to match the cell foreground color
/// - "cell-background" to match the cell background color
///
/// The default value is black text on a soft peach background.
@"search-selected-foreground": TerminalColor = .{ .color = .{ .r = 0, .g = 0, .b = 0 } },
@"search-selected-background": TerminalColor = .{ .color = .{ .r = 0xF2, .g = 0xA5, .b = 0x7E } },
/// The command to run, usually a shell. If this is not an absolute path, it'll
/// be looked up in the `PATH`. If this is not set, a default will be looked up
/// from your system. The rules for the default lookup are:
@@ -6388,6 +6403,38 @@ pub const Keybinds = struct {
.{ .jump_to_prompt = 1 },
);
// Search
try self.set.putFlags(
alloc,
.{ .key = .{ .unicode = 'f' }, .mods = .{ .super = true } },
.start_search,
.{ .performable = true },
);
try self.set.putFlags(
alloc,
.{ .key = .{ .unicode = 'f' }, .mods = .{ .super = true, .shift = true } },
.end_search,
.{ .performable = true },
);
try self.set.putFlags(
alloc,
.{ .key = .{ .physical = .escape } },
.end_search,
.{ .performable = true },
);
try self.set.putFlags(
alloc,
.{ .key = .{ .unicode = 'g' }, .mods = .{ .super = true } },
.{ .navigate_search = .next },
.{ .performable = true },
);
try self.set.putFlags(
alloc,
.{ .key = .{ .unicode = 'g' }, .mods = .{ .super = true, .shift = true } },
.{ .navigate_search = .previous },
.{ .performable = true },
);
// Inspector, matching Chromium
try self.set.put(
alloc,

View File

@@ -13,6 +13,7 @@ pub const BlockingQueue = blocking_queue.BlockingQueue;
pub const CacheTable = cache_table.CacheTable;
pub const CircBuf = circ_buf.CircBuf;
pub const IntrusiveDoublyLinkedList = intrusive_linked_list.DoublyLinkedList;
pub const MessageData = @import("message_data.zig").MessageData;
pub const SegmentedPool = segmented_pool.SegmentedPool;
pub const SplitTree = split_tree.SplitTree;

View File

@@ -0,0 +1,124 @@
const std = @import("std");
const assert = @import("../quirks.zig").inlineAssert;
const Allocator = std.mem.Allocator;
/// Creates a union that can be used to accommodate data that fit within an array,
/// are a stable pointer, or require deallocation. This is helpful for thread
/// messaging utilities.
pub fn MessageData(comptime Elem: type, comptime small_size: comptime_int) type {
return union(enum) {
pub const Self = @This();
pub const Small = struct {
pub const Max = small_size;
pub const Array = [Max]Elem;
pub const Len = std.math.IntFittingRange(0, small_size);
data: Array = undefined,
len: Len = 0,
};
pub const Alloc = struct {
alloc: Allocator,
data: []Elem,
};
pub const Stable = []const Elem;
/// A small write where the data fits into this union size.
small: Small,
/// A stable pointer so we can just pass the slice directly through.
/// This is useful i.e. for const data.
stable: Stable,
/// Allocated and must be freed with the provided allocator. This
/// should be rarely used.
alloc: Alloc,
/// Initializes the union for a given data type. This will
/// attempt to fit into a small value if possible, otherwise
/// will allocate and put into alloc.
///
/// This can't and will never detect stable pointers.
pub fn init(alloc: Allocator, data: anytype) !Self {
switch (@typeInfo(@TypeOf(data))) {
.pointer => |info| {
assert(info.size == .slice);
assert(info.child == Elem);
// If it fits in our small request, do that.
if (data.len <= Small.Max) {
var buf: Small.Array = undefined;
@memcpy(buf[0..data.len], data);
return Self{
.small = .{
.data = buf,
.len = @intCast(data.len),
},
};
}
// Otherwise, allocate
const buf = try alloc.dupe(Elem, data);
errdefer alloc.free(buf);
return Self{
.alloc = .{
.alloc = alloc,
.data = buf,
},
};
},
else => unreachable,
}
}
pub fn deinit(self: Self) void {
switch (self) {
.small, .stable => {},
.alloc => |v| v.alloc.free(v.data),
}
}
/// Returns a const slice of the data pointed to by this request.
pub fn slice(self: *const Self) []const Elem {
return switch (self.*) {
.small => |*v| v.data[0..v.len],
.stable => |v| v,
.alloc => |v| v.data,
};
}
};
}
test "MessageData init small" {
const testing = std.testing;
const alloc = testing.allocator;
const Data = MessageData(u8, 10);
const input = "hello!";
const io = try Data.init(alloc, @as([]const u8, input));
try testing.expect(io == .small);
}
test "MessageData init alloc" {
const testing = std.testing;
const alloc = testing.allocator;
const Data = MessageData(u8, 10);
const input = "hello! " ** 100;
const io = try Data.init(alloc, @as([]const u8, input));
try testing.expect(io == .alloc);
io.alloc.alloc.free(io.alloc.data);
}
test "MessageData small fits non-u8 sized data" {
const testing = std.testing;
const alloc = testing.allocator;
const len = 500;
const Data = MessageData(u8, len);
const input: []const u8 = "X" ** len;
const io = try Data.init(alloc, input);
try testing.expect(io == .small);
}

View File

@@ -333,9 +333,24 @@ pub const Action = union(enum) {
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.
/// the search is canceled. A canceled search will not disable any GUI
/// elements showing search. For that, the explicit end_search binding
/// should be used.
///
/// If a previous search is active, it is replaced.
search: []const u8,
/// Navigate the search results. If there is no active search, this
/// is not performed.
navigate_search: NavigateSearch,
/// Start a search if it isn't started already. This doesn't set any
/// search terms, but opens the UI for searching.
start_search,
/// End the current search if any and hide any GUI elements.
end_search,
/// Clear the screen and all scrollback.
clear_screen,
@@ -826,6 +841,11 @@ pub const Action = union(enum) {
}
};
pub const NavigateSearch = enum {
previous,
next,
};
pub const AdjustSelection = enum {
left,
right,
@@ -1157,6 +1177,9 @@ pub const Action = union(enum) {
.text,
.cursor_key,
.search,
.navigate_search,
.start_search,
.end_search,
.reset,
.copy_to_clipboard,
.copy_url_to_clipboard,

View File

@@ -163,6 +163,34 @@ fn actionCommands(action: Action.Key) []const Command {
.description = "Paste the contents of the selection clipboard.",
}},
.start_search => comptime &.{.{
.action = .start_search,
.title = "Start Search",
.description = "Start a search if one isn't already active.",
}},
.end_search => comptime &.{.{
.action = .end_search,
.title = "End Search",
.description = "End the current search if any and hide any GUI elements.",
}},
.navigate_search => comptime &.{ .{
.action = .{ .navigate_search = .next },
.title = "Next Search Result",
.description = "Navigate to the next search result, if any.",
}, .{
.action = .{ .navigate_search = .previous },
.title = "Previous Search Result",
.description = "Navigate to the previous search result, if any.",
} },
.search => comptime &.{.{
.action = .{ .search = "" },
.title = "End Search",
.description = "End a search if one is active.",
}},
.increase_font_size => comptime &.{.{
.action = .{ .increase_font_size = 1 },
.title = "Increase Font Size",
@@ -604,7 +632,6 @@ fn actionCommands(action: Action.Key) []const Command {
.csi,
.esc,
.cursor_key,
.search,
.set_font_size,
.scroll_to_row,
.scroll_page_fractional,

View File

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

View File

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

View File

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

View File

@@ -43,6 +43,7 @@ const Node = struct {
prev: ?*Node = null,
next: ?*Node = null,
data: Page,
serial: u64,
};
/// The memory pool we get page nodes from.
@@ -113,6 +114,24 @@ pool_owned: bool,
/// The list of pages in the screen.
pages: List,
/// A monotonically increasing serial number that is incremented each
/// time a page is allocated or reused as new. The serial is assigned to
/// the Node.
///
/// The serial number can be used to detect whether the page is identical
/// to the page that was originally referenced by a pointer. Since we reuse
/// and pool memory, pointer stability is not guaranteed, but the serial
/// will always be different for different allocations.
///
/// Developer note: we never do overflow checking on this. If we created
/// a new page every second it'd take 584 billion years to overflow. We're
/// going to risk it.
page_serial: u64,
/// The lowest still valid serial number that could exist. This allows
/// for quick comparisons to find invalid pages in references.
page_serial_min: u64,
/// Byte size of the total amount of allocated pages. Note this does
/// not include the total allocated amount in the pool which may be more
/// than this due to preheating.
@@ -264,7 +283,13 @@ pub fn init(
// necessary.
var pool = try MemoryPool.init(alloc, std.heap.page_allocator, page_preheat);
errdefer pool.deinit();
const page_list, const page_size = try initPages(&pool, cols, rows);
var page_serial: u64 = 0;
const page_list, const page_size = try initPages(
&pool,
&page_serial,
cols,
rows,
);
// Get our minimum max size, see doc comments for more details.
const min_max_size = try minMaxSize(cols, rows);
@@ -282,6 +307,8 @@ pub fn init(
.pool = pool,
.pool_owned = true,
.pages = page_list,
.page_serial = page_serial,
.page_serial_min = 0,
.page_size = page_size,
.explicit_max_size = max_size orelse std.math.maxInt(usize),
.min_max_size = min_max_size,
@@ -297,6 +324,7 @@ pub fn init(
fn initPages(
pool: *MemoryPool,
serial: *u64,
cols: size.CellCountInt,
rows: size.CellCountInt,
) !struct { List, usize } {
@@ -323,6 +351,7 @@ fn initPages(
.init(page_buf),
Page.layout(cap),
),
.serial = serial.*,
};
node.data.size.rows = @min(rem, node.data.capacity.rows);
rem -= node.data.size.rows;
@@ -330,6 +359,9 @@ fn initPages(
// Add the page to the list
page_list.append(node);
page_size += page_buf.len;
// Increment our serial
serial.* += 1;
}
assert(page_list.first != null);
@@ -363,6 +395,7 @@ pub inline fn pauseIntegrityChecks(self: *PageList, pause: bool) void {
const IntegrityError = error{
TotalRowsMismatch,
ViewportPinOffsetMismatch,
PageSerialInvalid,
};
/// Verify the integrity of the PageList. This is expensive and should
@@ -374,8 +407,27 @@ fn verifyIntegrity(self: *const PageList) IntegrityError!void {
// Our viewport pin should never be garbage
assert(!self.viewport_pin.garbage);
// Grab our total rows
var actual_total: usize = 0;
{
var node_ = self.pages.first;
while (node_) |node| {
actual_total += node.data.size.rows;
node_ = node.next;
// While doing this traversal, verify no node has a serial
// number lower than our min.
if (node.serial < self.page_serial_min) {
log.warn(
"PageList integrity violation: page serial too low serial={} min={}",
.{ node.serial, self.page_serial_min },
);
return IntegrityError.PageSerialInvalid;
}
}
}
// Verify that our cached total_rows matches the actual row count
const actual_total = self.totalRows();
if (actual_total != self.total_rows) {
log.warn(
"PageList integrity violation: total_rows mismatch cached={} actual={}",
@@ -523,6 +575,7 @@ pub fn reset(self: *PageList) void {
// we retained the capacity for the minimum number of pages we need.
self.pages, self.page_size = initPages(
&self.pool,
&self.page_serial,
self.cols,
self.rows,
) catch @panic("initPages failed");
@@ -638,6 +691,7 @@ pub fn clone(
}
// Copy our pages
var page_serial: u64 = 0;
var total_rows: usize = 0;
var page_size: usize = 0;
while (it.next()) |chunk| {
@@ -646,6 +700,7 @@ pub fn clone(
const node = try createPageExt(
pool,
chunk.node.data.capacity,
&page_serial,
&page_size,
);
assert(node.data.capacity.rows >= chunk.end - chunk.start);
@@ -690,6 +745,8 @@ pub fn clone(
.alloc => true,
},
.pages = page_list,
.page_serial = page_serial,
.page_serial_min = 0,
.page_size = page_size,
.explicit_max_size = self.explicit_max_size,
.min_max_size = self.min_max_size,
@@ -2431,6 +2488,14 @@ pub fn grow(self: *PageList) !?*List.Node {
first.data.size.rows = 1;
self.pages.insertAfter(last, first);
// We also need to reset the serial number. Since this is the only
// place we ever reuse a serial number, we also can safely set
// page_serial_min to be one more than the old serial because we
// only ever prune the oldest pages.
self.page_serial_min = first.serial + 1;
first.serial = self.page_serial;
self.page_serial += 1;
// Update any tracked pins that point to this page to point to the
// new first page to the top-left.
const pin_keys = self.tracked_pins.keys();
@@ -2570,12 +2635,18 @@ inline fn createPage(
cap: Capacity,
) Allocator.Error!*List.Node {
// log.debug("create page cap={}", .{cap});
return try createPageExt(&self.pool, cap, &self.page_size);
return try createPageExt(
&self.pool,
cap,
&self.page_serial,
&self.page_size,
);
}
inline fn createPageExt(
pool: *MemoryPool,
cap: Capacity,
serial: *u64,
total_size: ?*usize,
) Allocator.Error!*List.Node {
var page = try pool.nodes.create();
@@ -2605,8 +2676,12 @@ inline fn createPageExt(
// to undefined, 0xAA.
if (comptime std.debug.runtime_safety) @memset(page_buf, 0);
page.* = .{ .data = .initBuf(.init(page_buf), layout) };
page.* = .{
.data = .initBuf(.init(page_buf), layout),
.serial = serial.*,
};
page.data.size.rows = 0;
serial.* += 1;
if (total_size) |v| {
// Accumulate page size now. We don't assert or check max size

View File

@@ -32,6 +32,21 @@ const Screen = @import("Screen.zig");
pub const Untracked = struct {
start: Pin,
end: Pin,
pub fn track(
self: *const Untracked,
screen: *Screen,
) Allocator.Error!Tracked {
return try .init(
screen,
self.start,
self.end,
);
}
pub fn eql(self: Untracked, other: Untracked) bool {
return self.start.eql(other.start) and self.end.eql(other.end);
}
};
/// A tracked highlight is a highlight that stores its highlighted
@@ -99,7 +114,7 @@ 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),
chunks: std.MultiArrayList(Chunk),
/// 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
@@ -107,8 +122,16 @@ pub const Flattened = struct {
top_x: size.CellCountInt,
bot_x: size.CellCountInt,
/// Exposed for easier type references.
pub const Chunk = PageChunk;
/// A flattened chunk is almost identical to a PageList.Chunk but
/// we also flatten the serial number. This lets the flattened
/// highlight more robust for comparisons and validity checks with
/// the PageList.
pub const Chunk = struct {
node: *PageList.List.Node,
serial: u64,
start: size.CellCountInt,
end: size.CellCountInt,
};
pub const empty: Flattened = .{
.chunks = .empty,
@@ -124,7 +147,12 @@ pub const Flattened = struct {
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);
while (it.next()) |chunk| try result.append(alloc, .{
.node = chunk.node,
.serial = chunk.node.serial,
.start = chunk.start,
.end = chunk.end,
});
return .{
.chunks = result,
.top_x = start.x,
@@ -144,8 +172,19 @@ pub const Flattened = struct {
};
}
pub fn startPin(self: Flattened) Pin {
const slice = self.chunks.slice();
return .{
.node = slice.items(.node)[0],
.x = self.top_x,
.y = slice.items(.start)[0],
};
}
/// Convert to an Untracked highlight.
pub fn untracked(self: Flattened) Untracked {
// Note: we don't use startPin/endPin here because it is slightly
// faster to reuse the slices.
const slice = self.chunks.slice();
const nodes = slice.items(.node);
const starts = slice.items(.start);

View File

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

View File

@@ -17,8 +17,10 @@ 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 MessageData = @import("../../datastruct/main.zig").MessageData;
const point = @import("../point.zig");
const FlattenedHighlight = @import("../highlight.zig").Flattened;
const UntrackedHighlight = @import("../highlight.zig").Untracked;
const PageList = @import("../PageList.zig");
const Screen = @import("../Screen.zig");
const ScreenSet = @import("../ScreenSet.zig");
@@ -241,7 +243,32 @@ fn drainMailbox(self: *Thread) !void {
while (self.mailbox.pop()) |message| {
log.debug("mailbox message={}", .{message});
switch (message) {
.change_needle => |v| try self.changeNeedle(v),
.change_needle => |v| {
defer v.deinit();
try self.changeNeedle(v.slice());
},
.select => |v| try self.select(v),
}
}
}
fn select(self: *Thread, sel: ScreenSearch.Select) !void {
const s = if (self.search) |*s| s else return;
const screen_search = s.screens.getPtr(s.last_screen.key) orelse return;
self.opts.mutex.lock();
defer self.opts.mutex.unlock();
// The selection will trigger a selection change notification
// if it did change.
if (try screen_search.select(sel)) scroll: {
if (screen_search.selected) |m| {
// Selection changed, let's scroll the viewport to see it
// since we have the lock anyways.
const screen = self.opts.terminal.screens.get(
s.last_screen.key,
) orelse break :scroll;
screen.scroll(.{ .pin = m.highlight.start.* });
}
}
}
@@ -252,6 +279,9 @@ fn changeNeedle(self: *Thread, needle: []const u8) !void {
// Stop the previous search
if (self.search) |*s| {
// If our search is unchanged, do nothing.
if (std.ascii.eqlIgnoreCase(s.viewport.needle(), needle)) return;
s.deinit();
self.search = null;
@@ -261,6 +291,10 @@ fn changeNeedle(self: *Thread, needle: []const u8) !void {
.{ .total_matches = 0 },
self.opts.event_userdata,
);
cb(
.{ .selected_match = null },
self.opts.event_userdata,
);
cb(
.{ .viewport_matches = &.{} },
self.opts.event_userdata,
@@ -391,10 +425,17 @@ pub const Mailbox = BlockingQueue(Message, 64);
/// The messages that can be sent to the thread.
pub const Message = union(enum) {
/// Represents a write request. Magic number comes from the max size
/// we want this union to be.
pub const WriteReq = MessageData(u8, 255);
/// Change the search term. If no prior search term is given this
/// will start a search. If an existing search term is given this will
/// stop the prior search and start a new one.
change_needle: []const u8,
change_needle: WriteReq,
/// Select a search result.
select: ScreenSearch.Select,
};
/// Events that can be emitted from the search thread. The caller
@@ -409,9 +450,17 @@ pub const Event = union(enum) {
/// Total matches on the current active screen have changed.
total_matches: usize,
/// Selected match changed.
selected_match: ?SelectedMatch,
/// Matches in the viewport have changed. The memory is owned by the
/// search thread and is only valid during the callback.
viewport_matches: []const FlattenedHighlight,
pub const SelectedMatch = struct {
idx: usize,
highlight: FlattenedHighlight,
};
};
/// Search state.
@@ -422,11 +471,9 @@ const Search = struct {
/// The searchers for all the screens.
screens: std.EnumMap(ScreenSet.Key, ScreenSearch),
/// The last active screen
last_active_screen: ScreenSet.Key,
/// The last total matches reported.
last_total: ?usize,
/// All state related to screen switches, collected so that when
/// we switch screens it makes everything related stale, too.
last_screen: ScreenState,
/// True if we sent the complete notification yet.
last_complete: bool,
@@ -434,6 +481,22 @@ const Search = struct {
/// The last viewport matches we found.
stale_viewport_matches: bool,
const ScreenState = struct {
/// Last active screen key
key: ScreenSet.Key,
/// Last notified total matches count
total: ?usize = null,
/// Last notified selected match index
selected: ?SelectedMatch = null,
const SelectedMatch = struct {
idx: usize,
highlight: UntrackedHighlight,
};
};
pub fn init(
alloc: Allocator,
needle: []const u8,
@@ -448,8 +511,7 @@ const Search = struct {
return .{
.viewport = vp,
.screens = .init(.{}),
.last_active_screen = .primary,
.last_total = null,
.last_screen = .{ .key = .primary },
.last_complete = false,
.stale_viewport_matches = true,
};
@@ -528,9 +590,10 @@ const Search = struct {
t: *Terminal,
) void {
// Update our active screen
if (t.screens.active_key != self.last_active_screen) {
self.last_active_screen = t.screens.active_key;
self.last_total = null; // force notification
if (t.screens.active_key != self.last_screen.key) {
// The default values will force resets of a bunch of other
// state too to force recalculations and notifications.
self.last_screen = .{ .key = t.screens.active_key };
}
// Reconcile our screens with the terminal screens. Remove
@@ -584,8 +647,20 @@ const Search = struct {
// 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;
// Mark our viewport dirty so it researches the active
self.viewport.active_dirty = true;
// Reload our active area for our active screen
if (self.screens.getPtr(t.screens.active_key)) |screen_search| {
screen_search.reloadActive() catch |err| switch (err) {
error.OutOfMemory => log.warn(
"error reloading active area for screen key={} err={}",
.{ t.screens.active_key, err },
),
};
}
}
// Check our viewport for changes.
@@ -621,13 +696,13 @@ const Search = struct {
cb: EventCallback,
ud: ?*anyopaque,
) void {
const screen_search = self.screens.get(self.last_active_screen) orelse return;
const screen_search = self.screens.get(self.last_screen.key) orelse return;
// Check our total match data
const total = screen_search.matchesLen();
if (total != self.last_total) {
if (total != self.last_screen.total) {
log.debug("notifying total matches={}", .{total});
self.last_total = total;
self.last_screen.total = total;
cb(.{ .total_matches = total }, ud);
}
@@ -666,6 +741,40 @@ const Search = struct {
cb(.{ .viewport_matches = results.items }, ud);
}
// Check our last selected match data.
if (screen_search.selected) |m| match: {
const flattened = screen_search.selectedMatch() orelse break :match;
const untracked = flattened.untracked();
if (self.last_screen.selected) |prev| {
if (prev.idx == m.idx and prev.highlight.eql(untracked)) {
// Same selection, don't update it.
break :match;
}
}
// New selection, notify!
self.last_screen.selected = .{
.idx = m.idx,
.highlight = untracked,
};
log.debug("notifying selection updated idx={}", .{m.idx});
cb(
.{ .selected_match = .{
.idx = m.idx,
.highlight = flattened,
} },
ud,
);
} else if (self.last_screen.selected != null) {
log.debug("notifying selection cleared", .{});
self.last_screen.selected = null;
cb(
.{ .selected_match = null },
ud,
);
}
// Send our complete notification if we just completed.
if (!self.last_complete and self.isComplete()) {
log.debug("notifying search complete", .{});
@@ -675,40 +784,42 @@ const Search = struct {
}
};
const TestUserData = struct {
const Self = @This();
reset: std.Thread.ResetEvent = .{},
total: usize = 0,
selected: ?Event.SelectedMatch = null,
viewport: []FlattenedHighlight = &.{},
fn deinit(self: *Self) void {
for (self.viewport) |*hl| hl.deinit(testing.allocator);
testing.allocator.free(self.viewport);
}
fn callback(event: Event, userdata: ?*anyopaque) void {
const ud: *Self = @ptrCast(@alignCast(userdata.?));
switch (event) {
.quit => {},
.complete => ud.reset.set(),
.total_matches => |v| ud.total = v,
.selected_match => |v| ud.selected = v,
.viewport_matches => |v| {
for (ud.viewport) |*hl| hl.deinit(testing.allocator);
testing.allocator.free(ud.viewport);
ud.viewport = testing.allocator.alloc(
FlattenedHighlight,
v.len,
) catch unreachable;
for (ud.viewport, v) |*dst, src| {
dst.* = src.clone(testing.allocator) catch unreachable;
}
},
}
}
};
test {
const UserData = struct {
const Self = @This();
reset: std.Thread.ResetEvent = .{},
total: usize = 0,
viewport: []FlattenedHighlight = &.{},
fn deinit(self: *Self) void {
for (self.viewport) |*hl| hl.deinit(testing.allocator);
testing.allocator.free(self.viewport);
}
fn callback(event: Event, userdata: ?*anyopaque) void {
const ud: *Self = @ptrCast(@alignCast(userdata.?));
switch (event) {
.quit => {},
.complete => ud.reset.set(),
.total_matches => |v| ud.total = v,
.viewport_matches => |v| {
for (ud.viewport) |*hl| hl.deinit(testing.allocator);
testing.allocator.free(ud.viewport);
ud.viewport = testing.allocator.alloc(
FlattenedHighlight,
v.len,
) catch unreachable;
for (ud.viewport, v) |*dst, src| {
dst.* = src.clone(testing.allocator) catch unreachable;
}
},
}
}
};
const alloc = testing.allocator;
var mutex: std.Thread.Mutex = .{};
var t: Terminal = try .init(alloc, .{ .cols = 20, .rows = 2 });
@@ -718,12 +829,12 @@ test {
defer stream.deinit();
try stream.nextSlice("Hello, world");
var ud: UserData = .{};
var ud: TestUserData = .{};
defer ud.deinit();
var thread: Thread = try .init(alloc, .{
.mutex = &mutex,
.terminal = &t,
.event_cb = &UserData.callback,
.event_cb = &TestUserData.callback,
.event_userdata = &ud,
});
defer thread.deinit();
@@ -736,7 +847,10 @@ test {
// Start our search
_ = thread.mailbox.push(
.{ .change_needle = "world" },
.{ .change_needle = try .init(
alloc,
@as([]const u8, "world"),
) },
.forever,
);
try thread.wakeup.notify();

View File

@@ -3,7 +3,10 @@ const assert = @import("../../quirks.zig").inlineAssert;
const testing = std.testing;
const Allocator = std.mem.Allocator;
const point = @import("../point.zig");
const FlattenedHighlight = @import("../highlight.zig").Flattened;
const highlight = @import("../highlight.zig");
const size = @import("../size.zig");
const FlattenedHighlight = highlight.Flattened;
const TrackedHighlight = highlight.Tracked;
const PageList = @import("../PageList.zig");
const Pin = PageList.Pin;
const Screen = @import("../Screen.zig");
@@ -13,6 +16,8 @@ const ActiveSearch = @import("active.zig").ActiveSearch;
const PageListSearch = @import("pagelist.zig").PageListSearch;
const SlidingWindow = @import("sliding_window.zig").SlidingWindow;
const log = std.log.scoped(.search_screen);
/// Searches for a needle within a Screen, handling active area updates,
/// pages being pruned from the screen (e.g. scrollback limits), and more.
///
@@ -41,6 +46,11 @@ pub const ScreenSearch = struct {
/// Current state of the search, a state machine.
state: State,
/// The currently selected match, if any. As the screen contents
/// change or get pruned, the screen search will do its best to keep
/// this accurate.
selected: ?SelectedMatch = null,
/// The results found so far. These are stored separately because history
/// is mostly immutable once found, while active area results may
/// change. This lets us easily reset the active area results for a
@@ -48,6 +58,23 @@ pub const ScreenSearch = struct {
history_results: std.ArrayList(FlattenedHighlight),
active_results: std.ArrayList(FlattenedHighlight),
/// The dimensions of the screen. When this changes we need to
/// restart the whole search, currently.
rows: size.CellCountInt,
cols: size.CellCountInt,
pub const SelectedMatch = struct {
/// Index from the end of the match list (0 = most recent match)
idx: usize,
/// Tracked highlight so we can detect movement.
highlight: TrackedHighlight,
pub fn deinit(self: *SelectedMatch, screen: *Screen) void {
self.highlight.deinit(screen);
}
};
/// History search state.
const HistorySearch = struct {
/// The actual searcher state.
@@ -90,6 +117,11 @@ pub const ScreenSearch = struct {
pub fn needsFeed(self: State) bool {
return switch (self) {
.history_feed => true,
// Not obvious but complete search states will prune
// stale history results on feed.
.complete => true,
else => false,
};
}
@@ -103,6 +135,8 @@ pub const ScreenSearch = struct {
) Allocator.Error!ScreenSearch {
var result: ScreenSearch = .{
.screen = screen,
.rows = screen.pages.rows,
.cols = screen.pages.cols,
.active = try .init(alloc, needle_unowned),
.history = null,
.state = .active,
@@ -121,6 +155,7 @@ pub const ScreenSearch = struct {
const alloc = self.allocator();
self.active.deinit();
if (self.history) |*h| h.deinit(self.screen);
if (self.selected) |*m| m.deinit(self.screen);
for (self.active_results.items) |*hl| hl.deinit(alloc);
self.active_results.deinit(alloc);
for (self.history_results.items) |*hl| hl.deinit(alloc);
@@ -216,7 +251,33 @@ pub const ScreenSearch = struct {
/// Feed more data to the searcher so it can continue searching. This
/// accesses the screen state, so the caller must hold the necessary locks.
///
/// Feed on a complete screen search will perform some cleanup of
/// potentially stale history results (pruned) and reclaim some memory.
pub fn feed(self: *ScreenSearch) Allocator.Error!void {
// If the screen resizes, we have to reset our entire search. That
// isn't ideal but we don't have a better way right now to handle
// reflowing the search results beyond putting a tracked pin for
// every single result.
if (self.screen.pages.rows != self.rows or
self.screen.pages.cols != self.cols)
{
// Reinit
const new: ScreenSearch = try .init(
self.allocator(),
self.screen,
self.needle(),
);
// Deinit/reinit
self.deinit();
self.* = new;
// New result should have matching dimensions
assert(self.screen.pages.rows == self.rows);
assert(self.screen.pages.cols == self.cols);
}
const history: *PageListSearch = if (self.history) |*h| &h.searcher else {
// No history to feed, search is complete.
self.state = .complete;
@@ -228,6 +289,11 @@ pub const ScreenSearch = struct {
if (!try history.feed()) {
// No more data to feed, search is complete.
self.state = .complete;
// We use this opportunity to also clean up older history
// results that may be gone due to scrollback pruning, though.
self.pruneHistory();
return;
}
@@ -246,6 +312,25 @@ pub const ScreenSearch = struct {
}
}
fn pruneHistory(self: *ScreenSearch) void {
// Go through our history results in order (newest to oldest) to find
// any result that contains an invalid serial. Prune up to that
// point.
for (0..self.history_results.items.len) |i| {
const hl = &self.history_results.items[i];
const serials = hl.chunks.items(.serial);
const lowest = serials[0];
if (lowest < self.screen.pages.page_serial_min) {
// Everything from here forward we assume is invalid because
// our history results only get older.
const alloc = self.allocator();
for (self.history_results.items[i..]) |*prune_hl| prune_hl.deinit(alloc);
self.history_results.shrinkAndFree(alloc, i);
return;
}
}
}
fn tickActive(self: *ScreenSearch) Allocator.Error!void {
// For the active area, we consume the entire search in one go
// because the active area is generally small.
@@ -284,6 +369,10 @@ pub const ScreenSearch = struct {
var hl_cloned = try hl.clone(alloc);
errdefer hl_cloned.deinit(alloc);
try self.history_results.append(alloc, hl_cloned);
// Since history only appends to our results in reverse order,
// we don't need to update any selected match state. The index
// and prior results are unaffected.
}
// We need to be fed more data.
@@ -298,6 +387,24 @@ pub const ScreenSearch = struct {
///
/// The caller must hold the necessary locks to access the screen state.
pub fn reloadActive(self: *ScreenSearch) Allocator.Error!void {
// If our selection pin became garbage it means we scrolled off
// the end. Clear our selection and on exit of this function,
// try to select the last match.
const select_prev: bool = select_prev: {
const m = if (self.selected) |*m| m else break :select_prev false;
if (!m.highlight.start.garbage and
!m.highlight.end.garbage) break :select_prev false;
m.deinit(self.screen);
self.selected = null;
break :select_prev true;
};
defer if (select_prev) {
_ = self.select(.prev) catch |err| {
log.info("reload failed to reset search selection err={}", .{err});
};
};
const alloc = self.allocator();
const list: *PageList = &self.screen.pages;
if (try self.active.update(list)) |history_node| history: {
@@ -386,14 +493,72 @@ pub const ScreenSearch = struct {
// in our history (fast path)
if (results.items.len == 0) break :history;
// The number added to our history. Needed for updating
// our selection if we have one.
const added_len = results.items.len;
// Matches! Reverse our list then append all the remaining
// history items that didn't start on our original node.
std.mem.reverse(FlattenedHighlight, results.items);
try results.appendSlice(alloc, self.history_results.items);
self.history_results.deinit(alloc);
self.history_results = results;
// If our prior selection was in the history area, update
// the offset.
if (self.selected) |*m| selected: {
const active_len = self.active_results.items.len;
if (m.idx < active_len) break :selected;
m.idx += added_len;
// Moving the idx should not change our targeted result
// since the history is immutable.
if (comptime std.debug.runtime_safety) {
const hl = self.history_results.items[m.idx - active_len];
assert(m.highlight.start.eql(hl.startPin()));
}
}
} else {
// No history node means we have no history
if (self.history) |*h| {
h.deinit(self.screen);
self.history = null;
for (self.history_results.items) |*hl| hl.deinit(alloc);
self.history_results.clearRetainingCapacity();
}
// If we have a selection in the history area, we need to
// move it to the end of the active area.
if (self.selected) |*m| selected: {
const active_len = self.active_results.items.len;
if (m.idx < active_len) break :selected;
m.deinit(self.screen);
self.selected = null;
_ = self.select(.prev) catch |err| {
log.info("reload failed to reset search selection err={}", .{err});
};
}
}
// Figure out if we need to fixup our selection later because
// it was in the active area.
const old_active_len = self.active_results.items.len;
const old_selection_idx: ?usize = if (self.selected) |m| m.idx else null;
errdefer if (old_selection_idx != null and
old_selection_idx.? < old_active_len)
{
// This is the error scenario. If something fails below,
// our active area is probably gone, so we just go back
// to the first result because our selection can't be trusted.
if (self.selected) |*m| {
m.deinit(self.screen);
self.selected = null;
_ = self.select(.next) catch |err| {
log.info("reload failed to reset search selection err={}", .{err});
};
}
};
// Reset our active search results and search again.
for (self.active_results.items) |*hl| hl.deinit(alloc);
self.active_results.clearRetainingCapacity();
@@ -410,6 +575,203 @@ pub const ScreenSearch = struct {
try self.tickActive();
},
}
// Active area search was successful. Now we have to fixup our
// selection if we had one.
fixup: {
const old_idx = old_selection_idx orelse break :fixup;
const m = if (self.selected) |*m| m else break :fixup;
// If our old selection wasn't in the active area, then we
// need to fix up our offsets.
if (old_idx >= old_active_len) {
m.idx -= old_active_len;
m.idx += self.active_results.items.len;
break :fixup;
}
// We search for the matching highlight in the new active results.
for (0.., self.active_results.items) |i, hl| {
const untracked = hl.untracked();
if (m.highlight.start.eql(untracked.start) and
m.highlight.end.eql(untracked.end))
{
// Found it! Update our index.
m.idx = self.active_results.items.len - 1 - i;
break :fixup;
}
}
// No match, just go back to the first match.
m.deinit(self.screen);
self.selected = null;
_ = self.select(.next) catch |err| {
log.info("reload failed to reset search selection err={}", .{err});
};
}
}
/// Return the selected match.
///
/// This does not require read/write access to the underlying screen.
pub fn selectedMatch(self: *const ScreenSearch) ?FlattenedHighlight {
const sel = self.selected orelse return null;
const active_len = self.active_results.items.len;
if (sel.idx < active_len) {
return self.active_results.items[active_len - 1 - sel.idx];
}
const history_len = self.history_results.items.len;
if (sel.idx < active_len + history_len) {
return self.history_results.items[sel.idx - active_len];
}
return null;
}
pub const Select = enum {
/// Next selection, in reverse order (newest to oldest),
/// non-wrapping.
next,
/// Prev selection, in forward order (oldest to newest),
/// non-wrapping.
prev,
};
/// Select the next or previous search result. This requires read/write
/// access to the underlying screen, since we utilize tracked pins to
/// ensure our selection sticks with contents changing.
pub fn select(self: *ScreenSearch, to: Select) Allocator.Error!bool {
// All selection requires valid pins so we prune history and
// reload our active area immediately. This ensures all search
// results point to valid nodes.
try self.reloadActive();
self.pruneHistory();
return switch (to) {
.next => try self.selectNext(),
.prev => try self.selectPrev(),
};
}
fn selectNext(self: *ScreenSearch) Allocator.Error!bool {
// Get our previous match so we can change it. If we have no
// prior match, we have the easy task of getting the first.
var prev = if (self.selected) |*m| m else {
// Get our highlight
const hl: FlattenedHighlight = hl: {
if (self.active_results.items.len > 0) {
// Active is in forward order
const len = self.active_results.items.len;
break :hl self.active_results.items[len - 1];
} else if (self.history_results.items.len > 0) {
// History is in reverse order
break :hl self.history_results.items[0];
} else {
// No matches at all. Can't select anything.
return false;
}
};
// Pin it so we can track any movement
const tracked = try hl.untracked().track(self.screen);
errdefer tracked.deinit(self.screen);
// Our selection is index zero since we just started and
// we store our selection.
self.selected = .{
.idx = 0,
.highlight = tracked,
};
return true;
};
const next_idx = prev.idx + 1;
const active_len = self.active_results.items.len;
const history_len = self.history_results.items.len;
if (next_idx >= active_len + history_len) {
// No more matches. We don't wrap or reset the match currently.
return false;
}
const hl: FlattenedHighlight = if (next_idx < active_len)
self.active_results.items[active_len - 1 - next_idx]
else
self.history_results.items[next_idx - active_len];
// Pin it so we can track any movement
const tracked = try hl.untracked().track(self.screen);
errdefer tracked.deinit(self.screen);
// Free our previous match and setup our new selection
prev.deinit(self.screen);
self.selected = .{
.idx = next_idx,
.highlight = tracked,
};
return true;
}
fn selectPrev(self: *ScreenSearch) Allocator.Error!bool {
// Get our previous match so we can change it. If we have no
// prior match, we have the easy task of getting the last.
var prev = if (self.selected) |*m| m else {
// Get our highlight (oldest match)
const hl: FlattenedHighlight = hl: {
if (self.history_results.items.len > 0) {
// History is in reverse order, so last item is oldest
const len = self.history_results.items.len;
break :hl self.history_results.items[len - 1];
} else if (self.active_results.items.len > 0) {
// Active is in forward order, so first item is oldest
break :hl self.active_results.items[0];
} else {
// No matches at all. Can't select anything.
return false;
}
};
// Pin it so we can track any movement
const tracked = try hl.untracked().track(self.screen);
errdefer tracked.deinit(self.screen);
// Our selection is the last index since we just started
// and we store our selection.
const active_len = self.active_results.items.len;
const history_len = self.history_results.items.len;
self.selected = .{
.idx = active_len + history_len - 1,
.highlight = tracked,
};
return true;
};
// Can't go below zero
if (prev.idx == 0) {
// No more matches. We don't wrap or reset the match currently.
return false;
}
const next_idx = prev.idx - 1;
const active_len = self.active_results.items.len;
const hl: FlattenedHighlight = if (next_idx < active_len)
self.active_results.items[active_len - 1 - next_idx]
else
self.history_results.items[next_idx - active_len];
// Pin it so we can track any movement
const tracked = try hl.untracked().track(self.screen);
errdefer tracked.deinit(self.screen);
// Free our previous match and setup our new selection
prev.deinit(self.screen);
self.selected = .{
.idx = next_idx,
.highlight = tracked,
};
return true;
}
};
@@ -623,3 +985,352 @@ test "active change contents" {
} }, t.screens.active.pages.pointFromPin(.screen, sel.end).?);
}
}
test "select next" {
const alloc = testing.allocator;
var t: Terminal = try .init(alloc, .{ .cols = 10, .rows = 2 });
defer t.deinit(alloc);
var s = t.vtStream();
defer s.deinit();
try s.nextSlice("Fizz\r\nBuzz\r\nFizz\r\nBang");
var search: ScreenSearch = try .init(alloc, t.screens.active, "Fizz");
defer search.deinit();
// Initially no selection
try testing.expect(search.selectedMatch() == null);
// Select our next match (first)
try search.searchAll();
_ = try search.select(.next);
{
const sel = search.selectedMatch().?.untracked();
try testing.expectEqual(point.Point{ .screen = .{
.x = 0,
.y = 2,
} }, t.screens.active.pages.pointFromPin(.screen, sel.start).?);
try testing.expectEqual(point.Point{ .screen = .{
.x = 3,
.y = 2,
} }, t.screens.active.pages.pointFromPin(.screen, sel.end).?);
}
// Next match
_ = try search.select(.next);
{
const sel = search.selectedMatch().?.untracked();
try testing.expectEqual(point.Point{ .screen = .{
.x = 0,
.y = 0,
} }, t.screens.active.pages.pointFromPin(.screen, sel.start).?);
try testing.expectEqual(point.Point{ .screen = .{
.x = 3,
.y = 0,
} }, t.screens.active.pages.pointFromPin(.screen, sel.end).?);
}
// Next match (no wrap)
_ = try search.select(.next);
{
const sel = search.selectedMatch().?.untracked();
try testing.expectEqual(point.Point{ .screen = .{
.x = 0,
.y = 0,
} }, t.screens.active.pages.pointFromPin(.screen, sel.start).?);
try testing.expectEqual(point.Point{ .screen = .{
.x = 3,
.y = 0,
} }, t.screens.active.pages.pointFromPin(.screen, sel.end).?);
}
}
test "select in active changes contents completely" {
const alloc = testing.allocator;
var t: Terminal = try .init(alloc, .{ .cols = 10, .rows = 5 });
defer t.deinit(alloc);
var s = t.vtStream();
defer s.deinit();
try s.nextSlice("Fizz\r\nBuzz\r\nFizz\r\nBang");
var search: ScreenSearch = try .init(alloc, t.screens.active, "Fizz");
defer search.deinit();
try search.searchAll();
_ = try search.select(.next);
_ = try search.select(.next);
{
// Initial selection is the first fizz
const sel = search.selectedMatch().?.untracked();
try testing.expectEqual(point.Point{ .screen = .{
.x = 0,
.y = 0,
} }, t.screens.active.pages.pointFromPin(.screen, sel.start).?);
try testing.expectEqual(point.Point{ .screen = .{
.x = 3,
.y = 0,
} }, t.screens.active.pages.pointFromPin(.screen, sel.end).?);
}
// Erase the screen, move our cursor to the top, and change contents.
try s.nextSlice("\x1b[2J\x1b[H"); // Clear screen and move home
try s.nextSlice("Fuzz\r\nFizz\r\nHello!");
try search.reloadActive();
{
// Our selection should move to the first
const sel = search.selectedMatch().?.untracked();
try testing.expectEqual(point.Point{ .screen = .{
.x = 0,
.y = 1,
} }, t.screens.active.pages.pointFromPin(.screen, sel.start).?);
try testing.expectEqual(point.Point{ .screen = .{
.x = 3,
.y = 1,
} }, t.screens.active.pages.pointFromPin(.screen, sel.end).?);
}
// Erase the screen, redraw with same contents.
try s.nextSlice("\x1b[2J\x1b[H"); // Clear screen and move home
try s.nextSlice("Fuzz\r\nFizz\r\nFizz");
try search.reloadActive();
{
// Our selection should not move to the first
const sel = search.selectedMatch().?.untracked();
try testing.expectEqual(point.Point{ .screen = .{
.x = 0,
.y = 1,
} }, t.screens.active.pages.pointFromPin(.screen, sel.start).?);
try testing.expectEqual(point.Point{ .screen = .{
.x = 3,
.y = 1,
} }, t.screens.active.pages.pointFromPin(.screen, sel.end).?);
}
}
test "select into history" {
const alloc = testing.allocator;
var t: Terminal = try .init(alloc, .{
.cols = 10,
.rows = 2,
.max_scrollback = std.math.maxInt(usize),
});
defer t.deinit(alloc);
const list: *PageList = &t.screens.active.pages;
var s = t.vtStream();
defer s.deinit();
try s.nextSlice("Fizz\r\n");
while (list.totalPages() < 3) try s.nextSlice("\r\n");
for (0..list.rows) |_| try s.nextSlice("\r\n");
try s.nextSlice("hello.");
var search: ScreenSearch = try .init(alloc, t.screens.active, "Fizz");
defer search.deinit();
try search.searchAll();
// Get all matches
_ = try search.select(.next);
{
const sel = search.selectedMatch().?.untracked();
try testing.expectEqual(point.Point{ .screen = .{
.x = 0,
.y = 0,
} }, t.screens.active.pages.pointFromPin(.screen, sel.start).?);
try testing.expectEqual(point.Point{ .screen = .{
.x = 3,
.y = 0,
} }, t.screens.active.pages.pointFromPin(.screen, sel.end).?);
}
// Erase the screen, redraw with same contents.
try s.nextSlice("\x1b[2J\x1b[H"); // Clear screen and move home
try s.nextSlice("yo yo");
try search.reloadActive();
{
// Our selection should not move since the history is still active.
const sel = search.selectedMatch().?.untracked();
try testing.expectEqual(point.Point{ .screen = .{
.x = 0,
.y = 0,
} }, t.screens.active.pages.pointFromPin(.screen, sel.start).?);
try testing.expectEqual(point.Point{ .screen = .{
.x = 3,
.y = 0,
} }, t.screens.active.pages.pointFromPin(.screen, sel.end).?);
}
// Create some new history by adding more lines.
try s.nextSlice("\r\nfizz\r\nfizz\r\nfizz"); // Clear screen and move home
try search.reloadActive();
{
// Our selection should not move since the history is still not
// pruned.
const sel = search.selectedMatch().?.untracked();
try testing.expectEqual(point.Point{ .screen = .{
.x = 0,
.y = 0,
} }, t.screens.active.pages.pointFromPin(.screen, sel.start).?);
try testing.expectEqual(point.Point{ .screen = .{
.x = 3,
.y = 0,
} }, t.screens.active.pages.pointFromPin(.screen, sel.end).?);
}
}
test "select prev" {
const alloc = testing.allocator;
var t: Terminal = try .init(alloc, .{ .cols = 10, .rows = 2 });
defer t.deinit(alloc);
var s = t.vtStream();
defer s.deinit();
try s.nextSlice("Fizz\r\nBuzz\r\nFizz\r\nBang");
var search: ScreenSearch = try .init(alloc, t.screens.active, "Fizz");
defer search.deinit();
// Initially no selection
try testing.expect(search.selectedMatch() == null);
// Select prev (oldest first)
try search.searchAll();
_ = try search.select(.prev);
{
const sel = search.selectedMatch().?.untracked();
try testing.expectEqual(point.Point{ .screen = .{
.x = 0,
.y = 0,
} }, t.screens.active.pages.pointFromPin(.screen, sel.start).?);
try testing.expectEqual(point.Point{ .screen = .{
.x = 3,
.y = 0,
} }, t.screens.active.pages.pointFromPin(.screen, sel.end).?);
}
// Prev match (towards newest)
_ = try search.select(.prev);
{
const sel = search.selectedMatch().?.untracked();
try testing.expectEqual(point.Point{ .screen = .{
.x = 0,
.y = 2,
} }, t.screens.active.pages.pointFromPin(.screen, sel.start).?);
try testing.expectEqual(point.Point{ .screen = .{
.x = 3,
.y = 2,
} }, t.screens.active.pages.pointFromPin(.screen, sel.end).?);
}
// Prev match (no wrap, stays at newest)
_ = try search.select(.prev);
{
const sel = search.selectedMatch().?.untracked();
try testing.expectEqual(point.Point{ .screen = .{
.x = 0,
.y = 2,
} }, t.screens.active.pages.pointFromPin(.screen, sel.start).?);
try testing.expectEqual(point.Point{ .screen = .{
.x = 3,
.y = 2,
} }, t.screens.active.pages.pointFromPin(.screen, sel.end).?);
}
}
test "select prev then next" {
const alloc = testing.allocator;
var t: Terminal = try .init(alloc, .{ .cols = 10, .rows = 2 });
defer t.deinit(alloc);
var s = t.vtStream();
defer s.deinit();
try s.nextSlice("Fizz\r\nBuzz\r\nFizz\r\nBang");
var search: ScreenSearch = try .init(alloc, t.screens.active, "Fizz");
defer search.deinit();
try search.searchAll();
// Select next (newest first)
_ = try search.select(.next);
{
const sel = search.selectedMatch().?.untracked();
try testing.expectEqual(point.Point{ .screen = .{
.x = 0,
.y = 2,
} }, t.screens.active.pages.pointFromPin(.screen, sel.start).?);
}
// Select next (older)
_ = try search.select(.next);
{
const sel = search.selectedMatch().?.untracked();
try testing.expectEqual(point.Point{ .screen = .{
.x = 0,
.y = 0,
} }, t.screens.active.pages.pointFromPin(.screen, sel.start).?);
}
// Select prev (back to newer)
_ = try search.select(.prev);
{
const sel = search.selectedMatch().?.untracked();
try testing.expectEqual(point.Point{ .screen = .{
.x = 0,
.y = 2,
} }, t.screens.active.pages.pointFromPin(.screen, sel.start).?);
}
}
test "select prev with history" {
const alloc = testing.allocator;
var t: Terminal = try .init(alloc, .{
.cols = 10,
.rows = 2,
.max_scrollback = std.math.maxInt(usize),
});
defer t.deinit(alloc);
const list: *PageList = &t.screens.active.pages;
var s = t.vtStream();
defer s.deinit();
try s.nextSlice("Fizz\r\n");
while (list.totalPages() < 3) try s.nextSlice("\r\n");
for (0..list.rows) |_| try s.nextSlice("\r\n");
try s.nextSlice("Fizz.");
var search: ScreenSearch = try .init(alloc, t.screens.active, "Fizz");
defer search.deinit();
try search.searchAll();
// Select prev (oldest first, should be in history)
_ = try search.select(.prev);
{
const sel = search.selectedMatch().?.untracked();
try testing.expectEqual(point.Point{ .screen = .{
.x = 0,
.y = 0,
} }, t.screens.active.pages.pointFromPin(.screen, sel.start).?);
try testing.expectEqual(point.Point{ .screen = .{
.x = 3,
.y = 0,
} }, t.screens.active.pages.pointFromPin(.screen, sel.end).?);
}
// Select prev (towards newer, should move to active area)
_ = try search.select(.prev);
{
const sel = search.selectedMatch().?.untracked();
try testing.expectEqual(point.Point{ .active = .{
.x = 0,
.y = 1,
} }, t.screens.active.pages.pointFromPin(.active, sel.start).?);
try testing.expectEqual(point.Point{ .active = .{
.x = 3,
.y = 1,
} }, t.screens.active.pages.pointFromPin(.active, sel.end).?);
}
}

View File

@@ -87,6 +87,7 @@ pub const SlidingWindow = struct {
const MetaBuf = CircBuf(Meta, undefined);
const Meta = struct {
node: *PageList.List.Node,
serial: u64,
cell_map: std.ArrayList(point.Coordinate),
pub fn deinit(self: *Meta, alloc: Allocator) void {
@@ -221,10 +222,17 @@ pub const SlidingWindow = struct {
);
}
// Special case 1-lengthed needles to delete the entire buffer.
if (self.needle.len == 1) {
self.clearAndRetainCapacity();
self.assertIntegrity();
return null;
}
// No match. We keep `needle.len - 1` bytes available to
// handle the future overlap case.
var meta_it = self.meta.iterator(.reverse);
prune: {
var meta_it = self.meta.iterator(.reverse);
var saved: usize = 0;
while (meta_it.next()) |meta| {
const needed = self.needle.len - 1 - saved;
@@ -345,6 +353,7 @@ pub const SlidingWindow = struct {
result.bot_x = end_map.x;
self.chunk_buf.appendAssumeCapacity(.{
.node = meta.node,
.serial = meta.serial,
.start = @intCast(start_map.y),
.end = @intCast(end_map.y + 1),
});
@@ -363,6 +372,7 @@ pub const SlidingWindow = struct {
result.top_x = map.x;
self.chunk_buf.appendAssumeCapacity(.{
.node = meta.node,
.serial = meta.serial,
.start = @intCast(map.y),
.end = meta.node.data.size.rows,
});
@@ -397,6 +407,7 @@ pub const SlidingWindow = struct {
// to our results because we want the full flattened list.
self.chunk_buf.appendAssumeCapacity(.{
.node = meta.node,
.serial = meta.serial,
.start = 0,
.end = meta.node.data.size.rows,
});
@@ -410,6 +421,7 @@ pub const SlidingWindow = struct {
result.bot_x = map.x;
self.chunk_buf.appendAssumeCapacity(.{
.node = meta.node,
.serial = meta.serial,
.start = 0,
.end = @intCast(map.y + 1),
});
@@ -513,6 +525,7 @@ pub const SlidingWindow = struct {
// Initialize our metadata for the node.
var meta: Meta = .{
.node = node,
.serial = node.serial,
.cell_map = .empty,
};
errdefer meta.deinit(self.alloc);
@@ -600,7 +613,7 @@ pub const SlidingWindow = struct {
assert(data_len == self.data.len());
// Integrity check: verify our data offset is within bounds.
assert(self.data_offset < self.data.len());
assert(self.data.len() == 0 or self.data_offset < self.data.len());
}
};
@@ -703,6 +716,52 @@ test "SlidingWindow single append case insensitive ASCII" {
try testing.expect(w.next() == null);
try testing.expect(w.next() == null);
}
test "SlidingWindow single append single char" {
const testing = std.testing;
const alloc = testing.allocator;
var w: SlidingWindow = try .init(alloc, .forward, "b");
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 = 7,
.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 = 19,
.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;
@@ -782,6 +841,61 @@ test "SlidingWindow two pages" {
try testing.expect(w.next() == null);
}
test "SlidingWindow two pages single char" {
const testing = std.testing;
const alloc = testing.allocator;
var w: SlidingWindow = try .init(alloc, .forward, "b");
defer w.deinit();
var s = try Screen.init(alloc, .{ .cols = 80, .rows = 24, .max_scrollback = 1000 });
defer s.deinit();
// Fill up the first page. The final bytes in the first page
// are "boo!"
const first_page_rows = s.pages.pages.first.?.data.capacity.rows;
for (0..first_page_rows - 1) |_| try s.testWriteString("\n");
for (0..s.pages.cols - 4) |_| try s.testWriteString("x");
try s.testWriteString("boo!");
try testing.expect(s.pages.pages.first == s.pages.pages.last);
try s.testWriteString("\n");
try testing.expect(s.pages.pages.first != s.pages.pages.last);
try s.testWriteString("hello. boo!");
// Add both pages
const node: *PageList.List.Node = s.pages.pages.first.?;
_ = try w.append(node);
_ = try w.append(node.next.?);
// Search should find two matches
{
const h = w.next().?;
const sel = h.untracked();
try testing.expectEqual(point.Point{ .active = .{
.x = 76,
.y = 22,
} }, s.pages.pointFromPin(.active, sel.start).?);
try testing.expectEqual(point.Point{ .active = .{
.x = 76,
.y = 22,
} }, s.pages.pointFromPin(.active, sel.end).?);
}
{
const h = w.next().?;
const sel = h.untracked();
try testing.expectEqual(point.Point{ .active = .{
.x = 7,
.y = 23,
} }, s.pages.pointFromPin(.active, sel.start).?);
try testing.expectEqual(point.Point{ .active = .{
.x = 7,
.y = 23,
} }, s.pages.pointFromPin(.active, sel.end).?);
}
try testing.expect(w.next() == null);
try testing.expect(w.next() == null);
}
test "SlidingWindow two pages match across boundary" {
const testing = std.testing;
const alloc = testing.allocator;

View File

@@ -30,7 +30,6 @@ pub const Backend = backend.Backend;
pub const DerivedConfig = Termio.DerivedConfig;
pub const Mailbox = mailbox.Mailbox;
pub const Message = message.Message;
pub const MessageData = message.MessageData;
pub const StreamHandler = stream_handler.StreamHandler;
test {

View File

@@ -5,6 +5,7 @@ const apprt = @import("../apprt.zig");
const renderer = @import("../renderer.zig");
const terminal = @import("../terminal/main.zig");
const termio = @import("../termio.zig");
const MessageData = @import("../datastruct/main.zig").MessageData;
/// The messages that can be sent to an IO thread.
///
@@ -97,95 +98,6 @@ pub const Message = union(enum) {
};
};
/// Creates a union that can be used to accommodate data that fit within an array,
/// are a stable pointer, or require deallocation. This is helpful for thread
/// messaging utilities.
pub fn MessageData(comptime Elem: type, comptime small_size: comptime_int) type {
return union(enum) {
pub const Self = @This();
pub const Small = struct {
pub const Max = small_size;
pub const Array = [Max]Elem;
pub const Len = std.math.IntFittingRange(0, small_size);
data: Array = undefined,
len: Len = 0,
};
pub const Alloc = struct {
alloc: Allocator,
data: []Elem,
};
pub const Stable = []const Elem;
/// A small write where the data fits into this union size.
small: Small,
/// A stable pointer so we can just pass the slice directly through.
/// This is useful i.e. for const data.
stable: Stable,
/// Allocated and must be freed with the provided allocator. This
/// should be rarely used.
alloc: Alloc,
/// Initializes the union for a given data type. This will
/// attempt to fit into a small value if possible, otherwise
/// will allocate and put into alloc.
///
/// This can't and will never detect stable pointers.
pub fn init(alloc: Allocator, data: anytype) !Self {
switch (@typeInfo(@TypeOf(data))) {
.pointer => |info| {
assert(info.size == .slice);
assert(info.child == Elem);
// If it fits in our small request, do that.
if (data.len <= Small.Max) {
var buf: Small.Array = undefined;
@memcpy(buf[0..data.len], data);
return Self{
.small = .{
.data = buf,
.len = @intCast(data.len),
},
};
}
// Otherwise, allocate
const buf = try alloc.dupe(Elem, data);
errdefer alloc.free(buf);
return Self{
.alloc = .{
.alloc = alloc,
.data = buf,
},
};
},
else => unreachable,
}
}
pub fn deinit(self: Self) void {
switch (self) {
.small, .stable => {},
.alloc => |v| v.alloc.free(v.data),
}
}
/// Returns a const slice of the data pointed to by this request.
pub fn slice(self: *const Self) []const Elem {
return switch (self.*) {
.small => |*v| v.data[0..v.len],
.stable => |v| v,
.alloc => |v| v.data,
};
}
};
}
test {
std.testing.refAllDecls(@This());
}
@@ -195,35 +107,3 @@ test {
const testing = std.testing;
try testing.expectEqual(@as(usize, 40), @sizeOf(Message));
}
test "MessageData init small" {
const testing = std.testing;
const alloc = testing.allocator;
const Data = MessageData(u8, 10);
const input = "hello!";
const io = try Data.init(alloc, @as([]const u8, input));
try testing.expect(io == .small);
}
test "MessageData init alloc" {
const testing = std.testing;
const alloc = testing.allocator;
const Data = MessageData(u8, 10);
const input = "hello! " ** 100;
const io = try Data.init(alloc, @as([]const u8, input));
try testing.expect(io == .alloc);
io.alloc.alloc.free(io.alloc.data);
}
test "MessageData small fits non-u8 sized data" {
const testing = std.testing;
const alloc = testing.allocator;
const len = 500;
const Data = MessageData(u8, len);
const input: []const u8 = "X" ** len;
const io = try Data.init(alloc, input);
try testing.expect(io == .small);
}