mirror of
https://github.com/ghostty-org/ghostty.git
synced 2026-07-27 11:06:31 +00:00
Merge branch 'grapheme-break' into grapheme-width-changes
This commit is contained in:
138
src/Surface.zig
138
src/Surface.zig
@@ -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| {
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
};
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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| {
|
||||
|
||||
@@ -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,
|
||||
|
||||
|
||||
@@ -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;
|
||||
|
||||
|
||||
@@ -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;
|
||||
|
||||
|
||||
@@ -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;
|
||||
|
||||
|
||||
@@ -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;
|
||||
|
||||
|
||||
@@ -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;
|
||||
|
||||
|
||||
@@ -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;
|
||||
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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;
|
||||
|
||||
|
||||
124
src/datastruct/message_data.zig
Normal file
124
src/datastruct/message_data.zig
Normal 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);
|
||||
}
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -459,6 +459,14 @@ fn drainMailbox(self: *Thread) !void {
|
||||
self.renderer.search_matches_dirty = true;
|
||||
},
|
||||
|
||||
.search_selected_match => |v| {
|
||||
// Note we don't free the new value because we expect our
|
||||
// allocators to match.
|
||||
if (self.renderer.search_selected_match) |*m| m.arena.deinit();
|
||||
self.renderer.search_selected_match = v;
|
||||
self.renderer.search_matches_dirty = true;
|
||||
},
|
||||
|
||||
.inspector => |v| self.flags.has_inspector = v,
|
||||
|
||||
.macos_display_id => |v| {
|
||||
|
||||
@@ -130,6 +130,7 @@ pub fn Renderer(comptime GraphicsAPI: type) type {
|
||||
/// Note that the selections MAY BE INVALID (point to PageList nodes
|
||||
/// that do not exist anymore). These must be validated prior to use.
|
||||
search_matches: ?renderer.Message.SearchMatches,
|
||||
search_selected_match: ?renderer.Message.SearchMatch,
|
||||
search_matches_dirty: bool,
|
||||
|
||||
/// The current set of cells to render. This is rebuilt on every frame
|
||||
@@ -222,6 +223,11 @@ pub fn Renderer(comptime GraphicsAPI: type) type {
|
||||
/// a large screen.
|
||||
terminal_state_frame_count: usize = 0,
|
||||
|
||||
const HighlightTag = enum(u8) {
|
||||
search_match,
|
||||
search_match_selected,
|
||||
};
|
||||
|
||||
/// Swap chain which maintains multiple copies of the state needed to
|
||||
/// render a frame, so that we can start building the next frame while
|
||||
/// the previous frame is still being processed on the GPU.
|
||||
@@ -539,6 +545,8 @@ pub fn Renderer(comptime GraphicsAPI: type) type {
|
||||
selection_foreground: ?configpkg.Config.TerminalColor,
|
||||
search_background: configpkg.Config.TerminalColor,
|
||||
search_foreground: configpkg.Config.TerminalColor,
|
||||
search_selected_background: configpkg.Config.TerminalColor,
|
||||
search_selected_foreground: configpkg.Config.TerminalColor,
|
||||
bold_color: ?configpkg.BoldColor,
|
||||
faint_opacity: u8,
|
||||
min_contrast: f32,
|
||||
@@ -612,6 +620,8 @@ pub fn Renderer(comptime GraphicsAPI: type) type {
|
||||
.selection_foreground = config.@"selection-foreground",
|
||||
.search_background = config.@"search-background",
|
||||
.search_foreground = config.@"search-foreground",
|
||||
.search_selected_background = config.@"search-selected-background",
|
||||
.search_selected_foreground = config.@"search-selected-foreground",
|
||||
|
||||
.custom_shaders = custom_shaders,
|
||||
.bg_image = bg_image,
|
||||
@@ -687,6 +697,7 @@ pub fn Renderer(comptime GraphicsAPI: type) type {
|
||||
.scrollbar = .zero,
|
||||
.scrollbar_dirty = false,
|
||||
.search_matches = null,
|
||||
.search_selected_match = null,
|
||||
.search_matches_dirty = false,
|
||||
|
||||
// Render state
|
||||
@@ -760,6 +771,7 @@ pub fn Renderer(comptime GraphicsAPI: type) type {
|
||||
|
||||
pub fn deinit(self: *Self) void {
|
||||
self.terminal_state.deinit(self.alloc);
|
||||
if (self.search_selected_match) |*m| m.arena.deinit();
|
||||
if (self.search_matches) |*m| m.arena.deinit();
|
||||
self.swap_chain.deinit();
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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,
|
||||
},
|
||||
},
|
||||
);
|
||||
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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).?);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user