mirror of
https://github.com/ghostty-org/ghostty.git
synced 2026-05-20 12:01:34 +00:00
218
src/Surface.zig
218
src/Surface.zig
@@ -20,6 +20,7 @@ const builtin = @import("builtin");
|
||||
const assert = std.debug.assert;
|
||||
const Allocator = std.mem.Allocator;
|
||||
const ArenaAllocator = std.heap.ArenaAllocator;
|
||||
const oni = @import("oniguruma");
|
||||
const ziglyph = @import("ziglyph");
|
||||
const main = @import("main.zig");
|
||||
const renderer = @import("renderer.zig");
|
||||
@@ -137,6 +138,13 @@ const Mouse = struct {
|
||||
|
||||
/// True if the mouse is hidden
|
||||
hidden: bool = false,
|
||||
|
||||
/// True if the mouse position is currently over a link.
|
||||
over_link: bool = false,
|
||||
|
||||
/// The last x/y in the cursor position for links. We use this to
|
||||
/// only process link hover events when the mouse actually moves cells.
|
||||
link_point: ?terminal.point.Viewport = null,
|
||||
};
|
||||
|
||||
/// The configuration that a surface has, this is copied from the main
|
||||
@@ -165,12 +173,38 @@ const DerivedConfig = struct {
|
||||
window_padding_y: u32,
|
||||
window_padding_balance: bool,
|
||||
title: ?[:0]const u8,
|
||||
links: []const Link,
|
||||
|
||||
const Link = struct {
|
||||
regex: oni.Regex,
|
||||
action: input.Link.Action,
|
||||
};
|
||||
|
||||
pub fn init(alloc_gpa: Allocator, config: *const configpkg.Config) !DerivedConfig {
|
||||
var arena = ArenaAllocator.init(alloc_gpa);
|
||||
errdefer arena.deinit();
|
||||
const alloc = arena.allocator();
|
||||
|
||||
// Build all of our links
|
||||
const links = links: {
|
||||
var links = std.ArrayList(Link).init(alloc);
|
||||
defer links.deinit();
|
||||
for (config.link.links.items) |link| {
|
||||
var regex = try link.oniRegex();
|
||||
errdefer regex.deinit();
|
||||
try links.append(.{
|
||||
.regex = regex,
|
||||
.action = link.action,
|
||||
});
|
||||
}
|
||||
|
||||
break :links try links.toOwnedSlice();
|
||||
};
|
||||
errdefer {
|
||||
for (links) |*link| link.regex.deinit();
|
||||
alloc.free(links);
|
||||
}
|
||||
|
||||
return .{
|
||||
.original_font_size = config.@"font-size",
|
||||
.keybind = try config.keybind.clone(alloc),
|
||||
@@ -192,6 +226,7 @@ const DerivedConfig = struct {
|
||||
.window_padding_y = config.@"window-padding-y",
|
||||
.window_padding_balance = config.@"window-padding-balance",
|
||||
.title = config.title,
|
||||
.links = links,
|
||||
|
||||
// Assignments happen sequentially so we have to do this last
|
||||
// so that the memory is captured from allocs above.
|
||||
@@ -1197,6 +1232,18 @@ pub fn keyCallback(
|
||||
self.hideMouse();
|
||||
}
|
||||
|
||||
// If our mouse modifiers change, we run a cursor position event.
|
||||
// This handles the scenario where URL highlighting should be
|
||||
// toggled for example.
|
||||
if (!self.mouse.mods.equal(event.mods)) mouse_mods: {
|
||||
// We set this to null to force link reprocessing since
|
||||
// mod changes can affect link highlighting.
|
||||
self.mouse.link_point = null;
|
||||
self.mouse.mods = event.mods;
|
||||
const pos = self.rt_surface.getCursorPos() catch break :mouse_mods;
|
||||
self.cursorPosCallback(pos) catch {};
|
||||
}
|
||||
|
||||
// When we are in the middle of a mouse event and we press shift,
|
||||
// we change the mouse to a text shape so that selection appears
|
||||
// possible.
|
||||
@@ -1842,6 +1889,18 @@ pub fn mouseButtonCallback(
|
||||
}
|
||||
}
|
||||
|
||||
// Handle link clicking. We want to do this before we do mouse
|
||||
// reporting or any other mouse handling because a successfully
|
||||
// clicked link will swallow the event.
|
||||
if (button == .left and action == .release and self.mouse.over_link) {
|
||||
const pos = try self.rt_surface.getCursorPos();
|
||||
if (self.processLinks(pos)) |processed| {
|
||||
if (processed) return;
|
||||
} else |err| {
|
||||
log.warn("error processing links err={}", .{err});
|
||||
}
|
||||
}
|
||||
|
||||
// Report mouse events if enabled
|
||||
{
|
||||
self.renderer_state.mutex.lock();
|
||||
@@ -1970,6 +2029,65 @@ pub fn mouseButtonCallback(
|
||||
}
|
||||
}
|
||||
|
||||
/// Returns the link at the given cursor position, if any.
|
||||
fn linkAtPos(
|
||||
self: *Surface,
|
||||
pos: apprt.CursorPos,
|
||||
) !?struct {
|
||||
DerivedConfig.Link,
|
||||
terminal.Selection,
|
||||
} {
|
||||
// If we have no configured links we can save a lot of work
|
||||
if (self.config.links.len == 0) return null;
|
||||
|
||||
// Convert our cursor position to a screen point.
|
||||
const mouse_pt = mouse_pt: {
|
||||
const viewport_point = self.posToViewport(pos.x, pos.y);
|
||||
break :mouse_pt viewport_point.toScreen(&self.io.terminal.screen);
|
||||
};
|
||||
|
||||
// Get the line we're hovering over.
|
||||
const line = self.io.terminal.screen.getLine(mouse_pt) orelse
|
||||
return null;
|
||||
const strmap = try line.stringMap(self.alloc);
|
||||
defer strmap.deinit(self.alloc);
|
||||
|
||||
// Go through each link and see if we clicked it
|
||||
for (self.config.links) |link| {
|
||||
var it = strmap.searchIterator(link.regex);
|
||||
while (true) {
|
||||
var match = (try it.next()) orelse break;
|
||||
defer match.deinit();
|
||||
const sel = match.selection();
|
||||
if (!sel.contains(mouse_pt)) continue;
|
||||
return .{ link, sel };
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
/// Attempt to invoke the action of any link that is under the
|
||||
/// given position.
|
||||
///
|
||||
/// Requires the renderer state mutex is held.
|
||||
fn processLinks(self: *Surface, pos: apprt.CursorPos) !bool {
|
||||
const link, const sel = try self.linkAtPos(pos) orelse return false;
|
||||
switch (link.action) {
|
||||
.open => {
|
||||
const str = try self.io.terminal.screen.selectionString(
|
||||
self.alloc,
|
||||
sel,
|
||||
false,
|
||||
);
|
||||
defer self.alloc.free(str);
|
||||
try internal_os.open(self.alloc, str);
|
||||
},
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
pub fn cursorPosCallback(
|
||||
self: *Surface,
|
||||
pos: apprt.CursorPos,
|
||||
@@ -1980,18 +2098,29 @@ pub fn cursorPosCallback(
|
||||
// Always show the mouse again if it is hidden
|
||||
if (self.mouse.hidden) self.showMouse();
|
||||
|
||||
// The mouse position in the viewport
|
||||
const pos_vp = self.posToViewport(pos.x, pos.y);
|
||||
|
||||
// We always reset the over link status because it will be reprocessed
|
||||
// below. But we need the old value to know if we need to undo mouse
|
||||
// shape changes.
|
||||
const over_link = self.mouse.over_link;
|
||||
self.mouse.over_link = false;
|
||||
|
||||
// We are reading/writing state for the remainder
|
||||
self.renderer_state.mutex.lock();
|
||||
defer self.renderer_state.mutex.unlock();
|
||||
|
||||
// Update our mouse state. We set this to null initially because we only
|
||||
// want to set it when we're not selecting or doing any other mouse
|
||||
// event.
|
||||
self.renderer_state.mouse.point = null;
|
||||
|
||||
// If we have an inspector, we need to always record position information
|
||||
if (self.inspector) |insp| {
|
||||
insp.mouse.last_xpos = pos.x;
|
||||
insp.mouse.last_ypos = pos.y;
|
||||
|
||||
const point = self.posToViewport(pos.x, pos.y);
|
||||
insp.mouse.last_point = point.toScreen(&self.io.terminal.screen);
|
||||
|
||||
insp.mouse.last_point = pos_vp.toScreen(&self.io.terminal.screen);
|
||||
try self.queueRender();
|
||||
}
|
||||
|
||||
@@ -1999,7 +2128,6 @@ pub fn cursorPosCallback(
|
||||
if (self.io.terminal.flags.mouse_event != .none) report: {
|
||||
// Shift overrides mouse "grabbing" in the window, taken from Kitty.
|
||||
if (self.mouse.mods.shift and
|
||||
self.mouse.click_state[@intFromEnum(input.MouseButton.left)] == .press and
|
||||
!self.mouseShiftCapture(false)) break :report;
|
||||
|
||||
// We use the first mouse button we find pressed in order to report
|
||||
@@ -2011,41 +2139,73 @@ pub fn cursorPosCallback(
|
||||
|
||||
try self.mouseReport(button, .motion, self.mouse.mods, pos);
|
||||
|
||||
// If we were previously over a link, we need to queue a
|
||||
// render to undo the link state.
|
||||
if (over_link) try self.queueRender();
|
||||
|
||||
// If we're doing mouse motion tracking, we do not support text
|
||||
// selection.
|
||||
return;
|
||||
}
|
||||
|
||||
// If the cursor isn't clicked currently, it doesn't matter
|
||||
if (self.mouse.click_state[@intFromEnum(input.MouseButton.left)] != .press) return;
|
||||
// Handle cursor position for text selection
|
||||
if (self.mouse.click_state[@intFromEnum(input.MouseButton.left)] == .press) {
|
||||
// All roads lead to requiring a re-render at this point.
|
||||
try self.queueRender();
|
||||
|
||||
// All roads lead to requiring a re-render at this point.
|
||||
try self.queueRender();
|
||||
// If our y is negative, we're above the window. In this case, we scroll
|
||||
// up. The amount we scroll up is dependent on how negative we are.
|
||||
// Note: one day, we can change this from distance to time based if we want.
|
||||
//log.warn("CURSOR POS: {} {}", .{ pos, self.screen_size });
|
||||
const max_y: f32 = @floatFromInt(self.screen_size.height);
|
||||
if (pos.y < 0 or pos.y > max_y) {
|
||||
const delta: isize = if (pos.y < 0) -1 else 1;
|
||||
try self.io.terminal.scrollViewport(.{ .delta = delta });
|
||||
|
||||
// If our y is negative, we're above the window. In this case, we scroll
|
||||
// up. The amount we scroll up is dependent on how negative we are.
|
||||
// Note: one day, we can change this from distance to time based if we want.
|
||||
//log.warn("CURSOR POS: {} {}", .{ pos, self.screen_size });
|
||||
const max_y: f32 = @floatFromInt(self.screen_size.height);
|
||||
if (pos.y < 0 or pos.y > max_y) {
|
||||
const delta: isize = if (pos.y < 0) -1 else 1;
|
||||
try self.io.terminal.scrollViewport(.{ .delta = delta });
|
||||
// TODO: We want a timer or something to repeat while we're still
|
||||
// at this cursor position. Right now, the user has to jiggle their
|
||||
// mouse in order to scroll.
|
||||
}
|
||||
|
||||
// TODO: We want a timer or something to repeat while we're still
|
||||
// at this cursor position. Right now, the user has to jiggle their
|
||||
// mouse in order to scroll.
|
||||
// Convert to points
|
||||
const screen_point = pos_vp.toScreen(&self.io.terminal.screen);
|
||||
|
||||
// Handle dragging depending on click count
|
||||
switch (self.mouse.left_click_count) {
|
||||
1 => self.dragLeftClickSingle(screen_point, pos.x),
|
||||
2 => self.dragLeftClickDouble(screen_point),
|
||||
3 => self.dragLeftClickTriple(screen_point),
|
||||
else => unreachable,
|
||||
}
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
// Convert to points
|
||||
const viewport_point = self.posToViewport(pos.x, pos.y);
|
||||
const screen_point = viewport_point.toScreen(&self.io.terminal.screen);
|
||||
// Handle link hovering
|
||||
if (self.mouse.link_point) |last_vp| {
|
||||
// If our last link viewport point is unchanged, then don't process
|
||||
// links. This avoids constantly reprocessing regular expressions
|
||||
// for every pixel change.
|
||||
if (last_vp.eql(pos_vp)) {
|
||||
// We have to restore old values that are always cleared
|
||||
if (over_link) {
|
||||
self.mouse.over_link = over_link;
|
||||
self.renderer_state.mouse.point = pos_vp;
|
||||
}
|
||||
|
||||
// Handle dragging depending on click count
|
||||
switch (self.mouse.left_click_count) {
|
||||
1 => self.dragLeftClickSingle(screen_point, pos.x),
|
||||
2 => self.dragLeftClickDouble(screen_point),
|
||||
3 => self.dragLeftClickTriple(screen_point),
|
||||
else => unreachable,
|
||||
return;
|
||||
}
|
||||
}
|
||||
self.mouse.link_point = pos_vp;
|
||||
|
||||
if (try self.linkAtPos(pos)) |_| {
|
||||
self.renderer_state.mouse.point = pos_vp;
|
||||
self.mouse.over_link = true;
|
||||
try self.rt_surface.setMouseShape(.pointer);
|
||||
try self.queueRender();
|
||||
} else if (over_link) {
|
||||
try self.rt_surface.setMouseShape(self.io.terminal.mouse_shape);
|
||||
try self.queueRender();
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -912,7 +912,6 @@ fn keyEvent(
|
||||
ud: ?*anyopaque,
|
||||
) bool {
|
||||
const self = userdataSelf(ud.?);
|
||||
const mods = translateMods(gtk_mods);
|
||||
const keyval_unicode = c.gdk_keyval_to_unicode(keyval);
|
||||
const event = c.gtk_event_controller_get_current_event(@ptrCast(ec_key));
|
||||
|
||||
@@ -986,6 +985,57 @@ fn keyEvent(
|
||||
if (entry.native == keycode) break :keycode entry.key;
|
||||
} else .invalid;
|
||||
|
||||
// Get our modifiers. We have to translate modifier-only presses here
|
||||
// to state in the mods manually because GTK only does it AFTER the press
|
||||
// event.
|
||||
const mods = mods: {
|
||||
var mods = translateMods(gtk_mods);
|
||||
switch (physical_key) {
|
||||
.left_shift => {
|
||||
mods.shift = action == .press;
|
||||
if (mods.shift) mods.sides.shift = .left;
|
||||
},
|
||||
|
||||
.right_shift => {
|
||||
mods.shift = action == .press;
|
||||
if (mods.shift) mods.sides.shift = .right;
|
||||
},
|
||||
|
||||
.left_control => {
|
||||
mods.ctrl = action == .press;
|
||||
if (mods.ctrl) mods.sides.ctrl = .left;
|
||||
},
|
||||
|
||||
.right_control => {
|
||||
mods.ctrl = action == .press;
|
||||
if (mods.ctrl) mods.sides.ctrl = .right;
|
||||
},
|
||||
|
||||
.left_alt => {
|
||||
mods.alt = action == .press;
|
||||
if (mods.alt) mods.sides.alt = .left;
|
||||
},
|
||||
|
||||
.right_alt => {
|
||||
mods.alt = action == .press;
|
||||
if (mods.alt) mods.sides.alt = .right;
|
||||
},
|
||||
|
||||
.left_super => {
|
||||
mods.super = action == .press;
|
||||
if (mods.super) mods.sides.super = .left;
|
||||
},
|
||||
|
||||
.right_super => {
|
||||
mods.super = action == .press;
|
||||
if (mods.super) mods.sides.super = .right;
|
||||
},
|
||||
|
||||
else => {},
|
||||
}
|
||||
break :mods mods;
|
||||
};
|
||||
|
||||
// Get our consumed modifiers
|
||||
const consumed_mods: input.Mods = consumed: {
|
||||
const raw = c.gdk_key_event_get_consumed_modifiers(event);
|
||||
|
||||
@@ -3,6 +3,7 @@ const builtin = @import("builtin");
|
||||
pub usingnamespace @import("config/key.zig");
|
||||
pub const Config = @import("config/Config.zig");
|
||||
pub const string = @import("config/string.zig");
|
||||
pub const url = @import("config/url.zig");
|
||||
|
||||
// Field types
|
||||
pub const CopyOnSelect = Config.CopyOnSelect;
|
||||
|
||||
@@ -14,6 +14,7 @@ const terminal = @import("../terminal/main.zig");
|
||||
const internal_os = @import("../os/main.zig");
|
||||
const cli = @import("../cli.zig");
|
||||
|
||||
const url = @import("url.zig");
|
||||
const Key = @import("key.zig").Key;
|
||||
const KeyValue = @import("key.zig").Value;
|
||||
const ErrorList = @import("ErrorList.zig");
|
||||
@@ -329,6 +330,28 @@ command: ?[]const u8 = null,
|
||||
/// indicate that it is a login shell, depending on the OS).
|
||||
@"command-arg": RepeatableString = .{},
|
||||
|
||||
/// Match a regular expression against the terminal text and associate
|
||||
/// clicking it with an action. This can be used to match URLs, file paths,
|
||||
/// etc. Actions can be opening using the system opener (i.e. "open" or
|
||||
/// "xdg-open") or executing any arbitrary binding action.
|
||||
///
|
||||
/// Links that are configured earlier take precedence over links that
|
||||
/// are configured later.
|
||||
///
|
||||
/// A default link that matches a URL and opens it in the system opener
|
||||
/// always exists. This can be disabled using "link-url".
|
||||
///
|
||||
/// TODO: This can't currently be set!
|
||||
link: RepeatableLink = .{},
|
||||
|
||||
/// Enable URL matching. URLs are matched on hover and open using the
|
||||
/// default system application for the linked URL.
|
||||
///
|
||||
/// The URL matcher is always lowest priority of any configured links
|
||||
/// (see "link"). If you want to customize URL matching, use "link"
|
||||
/// and disable this.
|
||||
@"link-url": bool = true,
|
||||
|
||||
/// Start new windows in fullscreen. This setting applies to new
|
||||
/// windows and does not apply to tabs, splits, etc. However, this
|
||||
/// setting will apply to all new windows, not just the first one.
|
||||
@@ -1189,6 +1212,13 @@ pub fn default(alloc_gpa: Allocator) Allocator.Error!Config {
|
||||
);
|
||||
}
|
||||
|
||||
// Add our default link for URL detection
|
||||
try result.link.links.append(alloc, .{
|
||||
.regex = url.regex,
|
||||
.action = .{ .open = {} },
|
||||
.highlight = .{ .hover = {} },
|
||||
});
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
@@ -1527,6 +1557,10 @@ pub fn finalize(self: *Config) !void {
|
||||
// Minimmum window size
|
||||
if (self.@"window-width" > 0) self.@"window-width" = @max(10, self.@"window-width");
|
||||
if (self.@"window-height" > 0) self.@"window-height" = @max(4, self.@"window-height");
|
||||
|
||||
// If URLs are disabled, cut off the first link. The first link is
|
||||
// always the URL matcher.
|
||||
if (!self.@"link-url") self.link.links.items = self.link.links.items[1..];
|
||||
}
|
||||
|
||||
/// Callback for src/cli/args.zig to allow us to handle special cases
|
||||
@@ -2508,6 +2542,34 @@ pub const FontStyle = union(enum) {
|
||||
}
|
||||
};
|
||||
|
||||
/// See "link" for documentation.
|
||||
pub const RepeatableLink = struct {
|
||||
const Self = @This();
|
||||
|
||||
links: std.ArrayListUnmanaged(inputpkg.Link) = .{},
|
||||
|
||||
pub fn parseCLI(self: *Self, alloc: Allocator, input_: ?[]const u8) !void {
|
||||
_ = self;
|
||||
_ = alloc;
|
||||
_ = input_;
|
||||
return error.NotImplemented;
|
||||
}
|
||||
|
||||
/// Deep copy of the struct. Required by Config.
|
||||
pub fn clone(self: *const Self, alloc: Allocator) !Self {
|
||||
_ = self;
|
||||
_ = alloc;
|
||||
return .{};
|
||||
}
|
||||
|
||||
/// Compare if two of our value are requal. Required by Config.
|
||||
pub fn equal(self: Self, other: Self) bool {
|
||||
_ = self;
|
||||
_ = other;
|
||||
return true;
|
||||
}
|
||||
};
|
||||
|
||||
/// Options for copy on select behavior.
|
||||
pub const CopyOnSelect = enum {
|
||||
/// Disables copy on select entirely.
|
||||
|
||||
26
src/config/url.zig
Normal file
26
src/config/url.zig
Normal file
@@ -0,0 +1,26 @@
|
||||
const std = @import("std");
|
||||
const oni = @import("oniguruma");
|
||||
|
||||
/// Default URL regex. This is used to detect URLs in terminal output.
|
||||
/// This is here in the config package because one day the matchers will be
|
||||
/// configurable and this will be a default.
|
||||
///
|
||||
/// This is taken from the Alacritty project.
|
||||
pub const regex = "(ipfs:|ipns:|magnet:|mailto:|gemini://|gopher://|https://|http://|news:|file:|git://|ssh:|ftp://)[^\u{0000}-\u{001F}\u{007F}-\u{009F}<>\x22\\s{-}\\^⟨⟩\x60]+";
|
||||
|
||||
test "url regex" {
|
||||
try oni.testing.ensureInit();
|
||||
var re = try oni.Regex.init(regex, .{}, oni.Encoding.utf8, oni.Syntax.default, null);
|
||||
defer re.deinit();
|
||||
|
||||
// The URL cases to test that our regex matches. Feel free to add to this
|
||||
// as we find bugs or just want more coverage.
|
||||
const cases: []const []const u8 = &.{
|
||||
"https://example.com",
|
||||
};
|
||||
|
||||
for (cases) |case| {
|
||||
var reg = try re.search(case, .{});
|
||||
defer reg.deinit();
|
||||
}
|
||||
}
|
||||
@@ -7,6 +7,7 @@ pub const function_keys = @import("input/function_keys.zig");
|
||||
pub const keycodes = @import("input/keycodes.zig");
|
||||
pub const kitty = @import("input/kitty.zig");
|
||||
pub const Binding = @import("input/Binding.zig");
|
||||
pub const Link = @import("input/Link.zig");
|
||||
pub const KeyEncoder = @import("input/KeyEncoder.zig");
|
||||
pub const InspectorMode = Binding.Action.InspectorMode;
|
||||
pub const SplitDirection = Binding.Action.SplitDirection;
|
||||
|
||||
44
src/input/Link.zig
Normal file
44
src/input/Link.zig
Normal file
@@ -0,0 +1,44 @@
|
||||
//! A link is a clickable element that can be used to trigger some action.
|
||||
//! A link is NOT just a URL that opens in a browser. A link is any generic
|
||||
//! regular expression match over terminal text that can trigger various
|
||||
//! action types.
|
||||
const Link = @This();
|
||||
|
||||
const oni = @import("oniguruma");
|
||||
|
||||
/// The regular expression that will be used to match the link. Ownership
|
||||
/// of this memory is up to the caller. The link will never free this memory.
|
||||
regex: []const u8,
|
||||
|
||||
/// The action that will be triggered when the link is clicked.
|
||||
action: Action,
|
||||
|
||||
/// The situations in which the link will be highlighted. A link is only
|
||||
/// clickable by the mouse when it is highlighted, so this also controls
|
||||
/// when the link is clickable.
|
||||
highlight: Highlight,
|
||||
|
||||
pub const Action = union(enum) {
|
||||
/// Open the full matched value using the default open program.
|
||||
/// For example, on macOS this is "open" and on Linux this is "xdg-open".
|
||||
open: void,
|
||||
};
|
||||
|
||||
pub const Highlight = union(enum) {
|
||||
/// Always highlight the link.
|
||||
always: void,
|
||||
|
||||
/// Only highlight the link when the mouse is hovering over it.
|
||||
hover: void,
|
||||
};
|
||||
|
||||
/// Returns a new oni.Regex that can be used to match the link.
|
||||
pub fn oniRegex(self: *const Link) !oni.Regex {
|
||||
return try oni.Regex.init(
|
||||
self.regex,
|
||||
.{},
|
||||
oni.Encoding.utf8,
|
||||
oni.Syntax.default,
|
||||
null,
|
||||
);
|
||||
}
|
||||
@@ -6,6 +6,7 @@ const options = @import("build_options");
|
||||
const glfw = @import("glfw");
|
||||
const glslang = @import("glslang");
|
||||
const macos = @import("macos");
|
||||
const oni = @import("oniguruma");
|
||||
const tracy = @import("tracy");
|
||||
const cli = @import("cli.zig");
|
||||
const internal_os = @import("os/main.zig");
|
||||
@@ -277,6 +278,9 @@ pub const GlobalState = struct {
|
||||
// Initialize glslang for shader compilation
|
||||
try glslang.init();
|
||||
|
||||
// Initialize oniguruma for regex
|
||||
try oni.init(&.{oni.Encoding.utf8});
|
||||
|
||||
// Find our resources directory once for the app so every launch
|
||||
// hereafter can use this cached value.
|
||||
self.resources_dir = try internal_os.resourcesDir(self.alloc);
|
||||
|
||||
@@ -9,6 +9,7 @@ pub usingnamespace @import("homedir.zig");
|
||||
pub usingnamespace @import("locale.zig");
|
||||
pub usingnamespace @import("macos_version.zig");
|
||||
pub usingnamespace @import("mouse.zig");
|
||||
pub usingnamespace @import("open.zig");
|
||||
pub usingnamespace @import("pipe.zig");
|
||||
pub usingnamespace @import("resourcesdir.zig");
|
||||
pub const TempDir = @import("TempDir.zig");
|
||||
|
||||
16
src/os/open.zig
Normal file
16
src/os/open.zig
Normal file
@@ -0,0 +1,16 @@
|
||||
const std = @import("std");
|
||||
const builtin = @import("builtin");
|
||||
const Allocator = std.mem.Allocator;
|
||||
|
||||
/// Open a URL in the default handling application.
|
||||
pub fn open(alloc: Allocator, url: []const u8) !void {
|
||||
const argv = switch (builtin.os.tag) {
|
||||
.linux => &.{ "xdg-open", url },
|
||||
.macos => &.{ "open", url },
|
||||
.windows => &.{ "rundll32", "url.dll,FileProtocolHandler", url },
|
||||
else => @compileError("unsupported OS"),
|
||||
};
|
||||
|
||||
var exe = std.process.Child.init(argv, alloc);
|
||||
try exe.spawn();
|
||||
}
|
||||
@@ -18,6 +18,7 @@ const terminal = @import("../terminal/main.zig");
|
||||
const renderer = @import("../renderer.zig");
|
||||
const math = @import("../math.zig");
|
||||
const Surface = @import("../Surface.zig");
|
||||
const link = @import("link.zig");
|
||||
const shadertoy = @import("shadertoy.zig");
|
||||
const assert = std.debug.assert;
|
||||
const Allocator = std.mem.Allocator;
|
||||
@@ -153,6 +154,7 @@ pub const DerivedConfig = struct {
|
||||
invert_selection_fg_bg: bool,
|
||||
custom_shaders: std.ArrayListUnmanaged([]const u8),
|
||||
custom_shader_animation: bool,
|
||||
links: link.Set,
|
||||
|
||||
pub fn init(
|
||||
alloc_gpa: Allocator,
|
||||
@@ -174,6 +176,12 @@ pub const DerivedConfig = struct {
|
||||
font_styles.set(.italic, config.@"font-style-italic" != .false);
|
||||
font_styles.set(.bold_italic, config.@"font-style-bold-italic" != .false);
|
||||
|
||||
// Our link configs
|
||||
const links = try link.Set.fromConfig(
|
||||
alloc,
|
||||
config.link.links.items,
|
||||
);
|
||||
|
||||
return .{
|
||||
.background_opacity = @max(0, @min(1, config.@"background-opacity")),
|
||||
.font_thicken = config.@"font-thicken",
|
||||
@@ -208,12 +216,15 @@ pub const DerivedConfig = struct {
|
||||
|
||||
.custom_shaders = custom_shaders,
|
||||
.custom_shader_animation = config.@"custom-shader-animation",
|
||||
.links = links,
|
||||
|
||||
.arena = arena,
|
||||
};
|
||||
}
|
||||
|
||||
pub fn deinit(self: *DerivedConfig) void {
|
||||
const alloc = self.arena.allocator();
|
||||
self.links.deinit(alloc);
|
||||
self.arena.deinit();
|
||||
}
|
||||
};
|
||||
@@ -555,6 +566,7 @@ pub fn updateFrame(
|
||||
bg: terminal.color.RGB,
|
||||
selection: ?terminal.Selection,
|
||||
screen: terminal.Screen,
|
||||
mouse: renderer.State.Mouse,
|
||||
preedit: ?renderer.State.Preedit,
|
||||
cursor_style: ?renderer.CursorStyle,
|
||||
};
|
||||
@@ -622,6 +634,7 @@ pub fn updateFrame(
|
||||
.bg = self.background_color,
|
||||
.selection = selection,
|
||||
.screen = screen_copy,
|
||||
.mouse = state.mouse,
|
||||
.preedit = if (cursor_style != null) state.preedit else null,
|
||||
.cursor_style = cursor_style,
|
||||
};
|
||||
@@ -632,6 +645,7 @@ pub fn updateFrame(
|
||||
try self.rebuildCells(
|
||||
critical.selection,
|
||||
&critical.screen,
|
||||
critical.mouse,
|
||||
critical.preedit,
|
||||
critical.cursor_style,
|
||||
);
|
||||
@@ -1354,6 +1368,7 @@ fn rebuildCells(
|
||||
self: *Metal,
|
||||
term_selection: ?terminal.Selection,
|
||||
screen: *terminal.Screen,
|
||||
mouse: renderer.State.Mouse,
|
||||
preedit: ?renderer.State.Preedit,
|
||||
cursor_style_: ?renderer.CursorStyle,
|
||||
) !void {
|
||||
@@ -1371,6 +1386,18 @@ fn rebuildCells(
|
||||
(screen.rows * screen.cols * 2) + 1,
|
||||
);
|
||||
|
||||
// Create an arena for all our temporary allocations while rebuilding
|
||||
var arena = ArenaAllocator.init(self.alloc);
|
||||
defer arena.deinit();
|
||||
const arena_alloc = arena.allocator();
|
||||
|
||||
// Create our match set for the links.
|
||||
var link_match_set = try self.config.links.matchSet(
|
||||
arena_alloc,
|
||||
screen,
|
||||
mouse.point orelse .{},
|
||||
);
|
||||
|
||||
// Determine our x/y range for preedit. We don't want to render anything
|
||||
// here because we will render the preedit separately.
|
||||
const preedit_range: ?struct {
|
||||
@@ -1475,10 +1502,27 @@ fn rebuildCells(
|
||||
}
|
||||
}
|
||||
|
||||
// It this cell is within our hint range then we need to
|
||||
// underline it.
|
||||
const cell: terminal.Screen.Cell = cell: {
|
||||
var cell = row.getCell(shaper_cell.x);
|
||||
|
||||
// If our links contain this cell then we want to
|
||||
// underline it.
|
||||
if (link_match_set.orderedContains(.{
|
||||
.x = shaper_cell.x,
|
||||
.y = y,
|
||||
})) {
|
||||
cell.attrs.underline = .single;
|
||||
}
|
||||
|
||||
break :cell cell;
|
||||
};
|
||||
|
||||
if (self.updateCell(
|
||||
term_selection,
|
||||
screen,
|
||||
row.getCell(shaper_cell.x),
|
||||
cell,
|
||||
shaper_cell,
|
||||
run,
|
||||
shaper_cell.x,
|
||||
|
||||
@@ -8,6 +8,7 @@ const assert = std.debug.assert;
|
||||
const testing = std.testing;
|
||||
const Allocator = std.mem.Allocator;
|
||||
const ArenaAllocator = std.heap.ArenaAllocator;
|
||||
const link = @import("link.zig");
|
||||
const shadertoy = @import("shadertoy.zig");
|
||||
const apprt = @import("../apprt.zig");
|
||||
const configpkg = @import("../config.zig");
|
||||
@@ -225,6 +226,7 @@ pub const DerivedConfig = struct {
|
||||
invert_selection_fg_bg: bool,
|
||||
custom_shaders: std.ArrayListUnmanaged([]const u8),
|
||||
custom_shader_animation: bool,
|
||||
links: link.Set,
|
||||
|
||||
pub fn init(
|
||||
alloc_gpa: Allocator,
|
||||
@@ -246,6 +248,12 @@ pub const DerivedConfig = struct {
|
||||
font_styles.set(.italic, config.@"font-style-italic" != .false);
|
||||
font_styles.set(.bold_italic, config.@"font-style-bold-italic" != .false);
|
||||
|
||||
// Our link configs
|
||||
const links = try link.Set.fromConfig(
|
||||
alloc,
|
||||
config.link.links.items,
|
||||
);
|
||||
|
||||
return .{
|
||||
.background_opacity = @max(0, @min(1, config.@"background-opacity")),
|
||||
.font_thicken = config.@"font-thicken",
|
||||
@@ -280,12 +288,15 @@ pub const DerivedConfig = struct {
|
||||
|
||||
.custom_shaders = custom_shaders,
|
||||
.custom_shader_animation = config.@"custom-shader-animation",
|
||||
.links = links,
|
||||
|
||||
.arena = arena,
|
||||
};
|
||||
}
|
||||
|
||||
pub fn deinit(self: *DerivedConfig) void {
|
||||
const alloc = self.arena.allocator();
|
||||
self.links.deinit(alloc);
|
||||
self.arena.deinit();
|
||||
}
|
||||
};
|
||||
@@ -598,6 +609,7 @@ pub fn updateFrame(
|
||||
gl_bg: terminal.color.RGB,
|
||||
selection: ?terminal.Selection,
|
||||
screen: terminal.Screen,
|
||||
mouse: renderer.State.Mouse,
|
||||
preedit: ?renderer.State.Preedit,
|
||||
cursor_style: ?renderer.CursorStyle,
|
||||
};
|
||||
@@ -665,6 +677,7 @@ pub fn updateFrame(
|
||||
.gl_bg = self.background_color,
|
||||
.selection = selection,
|
||||
.screen = screen_copy,
|
||||
.mouse = state.mouse,
|
||||
.preedit = if (cursor_style != null) state.preedit else null,
|
||||
.cursor_style = cursor_style,
|
||||
};
|
||||
@@ -683,6 +696,7 @@ pub fn updateFrame(
|
||||
try self.rebuildCells(
|
||||
critical.selection,
|
||||
&critical.screen,
|
||||
critical.mouse,
|
||||
critical.preedit,
|
||||
critical.cursor_style,
|
||||
);
|
||||
@@ -855,6 +869,7 @@ pub fn rebuildCells(
|
||||
self: *OpenGL,
|
||||
term_selection: ?terminal.Selection,
|
||||
screen: *terminal.Screen,
|
||||
mouse: renderer.State.Mouse,
|
||||
preedit: ?renderer.State.Preedit,
|
||||
cursor_style_: ?renderer.CursorStyle,
|
||||
) !void {
|
||||
@@ -877,9 +892,21 @@ pub fn rebuildCells(
|
||||
(screen.rows * screen.cols * 2) + 1,
|
||||
);
|
||||
|
||||
// Create an arena for all our temporary allocations while rebuilding
|
||||
var arena = ArenaAllocator.init(self.alloc);
|
||||
defer arena.deinit();
|
||||
const arena_alloc = arena.allocator();
|
||||
|
||||
// We've written no data to the GPU, refresh it all
|
||||
self.gl_cells_written = 0;
|
||||
|
||||
// Create our match set for the links.
|
||||
var link_match_set = try self.config.links.matchSet(
|
||||
arena_alloc,
|
||||
screen,
|
||||
mouse.point orelse .{},
|
||||
);
|
||||
|
||||
// Determine our x/y range for preedit. We don't want to render anything
|
||||
// here because we will render the preedit separately.
|
||||
const preedit_range: ?struct {
|
||||
@@ -975,10 +1002,27 @@ pub fn rebuildCells(
|
||||
}
|
||||
}
|
||||
|
||||
// It this cell is within our hint range then we need to
|
||||
// underline it.
|
||||
const cell: terminal.Screen.Cell = cell: {
|
||||
var cell = row.getCell(shaper_cell.x);
|
||||
|
||||
// If our links contain this cell then we want to
|
||||
// underline it.
|
||||
if (link_match_set.orderedContains(.{
|
||||
.x = shaper_cell.x,
|
||||
.y = y,
|
||||
})) {
|
||||
cell.attrs.underline = .single;
|
||||
}
|
||||
|
||||
break :cell cell;
|
||||
};
|
||||
|
||||
if (self.updateCell(
|
||||
term_selection,
|
||||
screen,
|
||||
row.getCell(shaper_cell.x),
|
||||
cell,
|
||||
shaper_cell,
|
||||
run,
|
||||
shaper_cell.x,
|
||||
|
||||
@@ -25,6 +25,17 @@ inspector: ?*Inspector = null,
|
||||
/// a future exercise.
|
||||
preedit: ?Preedit = null,
|
||||
|
||||
/// Mouse state. This only contains state relevant to what renderers
|
||||
/// need about the mouse.
|
||||
mouse: Mouse = .{},
|
||||
|
||||
pub const Mouse = struct {
|
||||
/// The point on the viewport where the mouse currently is. We use
|
||||
/// viewport points to avoid the complexity of mapping the mouse to
|
||||
/// the renderer state.
|
||||
point: ?terminal.point.Viewport = null,
|
||||
};
|
||||
|
||||
/// The pre-edit state. See Surface.preeditCallback for more information.
|
||||
pub const Preedit = struct {
|
||||
/// The codepoints to render as preedit text. We allow up to 16 codepoints
|
||||
|
||||
257
src/renderer/link.zig
Normal file
257
src/renderer/link.zig
Normal file
@@ -0,0 +1,257 @@
|
||||
const std = @import("std");
|
||||
const Allocator = std.mem.Allocator;
|
||||
const oni = @import("oniguruma");
|
||||
const configpkg = @import("../config.zig");
|
||||
const inputpkg = @import("../input.zig");
|
||||
const terminal = @import("../terminal/main.zig");
|
||||
const point = terminal.point;
|
||||
const Screen = terminal.Screen;
|
||||
|
||||
const log = std.log.scoped(.renderer_link);
|
||||
|
||||
/// The link configuration needed for renderers.
|
||||
pub const Link = struct {
|
||||
/// The regular expression to match the link against.
|
||||
regex: oni.Regex,
|
||||
|
||||
/// The situations in which the link should be highlighted.
|
||||
highlight: inputpkg.Link.Highlight,
|
||||
|
||||
pub fn deinit(self: *Link) void {
|
||||
self.regex.deinit();
|
||||
}
|
||||
};
|
||||
|
||||
/// A set of links. This provides a higher level API for renderers
|
||||
/// to match against a viewport and determine if cells are part of
|
||||
/// a link.
|
||||
pub const Set = struct {
|
||||
links: []Link,
|
||||
|
||||
/// Returns the slice of links from the configuration.
|
||||
pub fn fromConfig(
|
||||
alloc: Allocator,
|
||||
config: []const inputpkg.Link,
|
||||
) !Set {
|
||||
var links = std.ArrayList(Link).init(alloc);
|
||||
defer links.deinit();
|
||||
|
||||
for (config) |link| {
|
||||
var regex = try link.oniRegex();
|
||||
errdefer regex.deinit();
|
||||
try links.append(.{
|
||||
.regex = regex,
|
||||
.highlight = link.highlight,
|
||||
});
|
||||
}
|
||||
|
||||
return .{ .links = try links.toOwnedSlice() };
|
||||
}
|
||||
|
||||
pub fn deinit(self: *Set, alloc: Allocator) void {
|
||||
for (self.links) |*link| link.deinit();
|
||||
alloc.free(self.links);
|
||||
}
|
||||
|
||||
/// Returns the matchset for the viewport state. The matchset is the
|
||||
/// full set of matching links for the visible viewport. A link
|
||||
/// only matches if it is also in the correct state (i.e. hovered
|
||||
/// if necessary).
|
||||
///
|
||||
/// This is not a particularly efficient operation. This should be
|
||||
/// called sparingly.
|
||||
pub fn matchSet(
|
||||
self: *const Set,
|
||||
alloc: Allocator,
|
||||
screen: *Screen,
|
||||
mouse_vp_pt: point.Viewport,
|
||||
) !MatchSet {
|
||||
// Convert the viewport point to a screen point.
|
||||
const mouse_pt = mouse_vp_pt.toScreen(screen);
|
||||
|
||||
// This contains our list of matches. The matches are stored
|
||||
// as selections which contain the start and end points of
|
||||
// the match. There is no way to map these back to the link
|
||||
// configuration right now because we don't need to.
|
||||
var matches = std.ArrayList(terminal.Selection).init(alloc);
|
||||
defer matches.deinit();
|
||||
|
||||
// Iterate over all the visible lines.
|
||||
var lineIter = screen.lineIterator(.viewport);
|
||||
while (lineIter.next()) |line| {
|
||||
const strmap = line.stringMap(alloc) catch |err| {
|
||||
log.warn(
|
||||
"failed to build string map for link checking err={}",
|
||||
.{err},
|
||||
);
|
||||
continue;
|
||||
};
|
||||
defer strmap.deinit(alloc);
|
||||
|
||||
// Go through each link and see if we have any matches.
|
||||
for (self.links) |link| {
|
||||
// If this is a hover link and our mouse point isn't within
|
||||
// this line at all, we can skip it.
|
||||
if (link.highlight == .hover) {
|
||||
if (!line.selection().contains(mouse_pt)) continue;
|
||||
}
|
||||
|
||||
var it = strmap.searchIterator(link.regex);
|
||||
while (true) {
|
||||
const match_ = it.next() catch |err| {
|
||||
log.warn("failed to search for link err={}", .{err});
|
||||
break;
|
||||
};
|
||||
var match = match_ orelse break;
|
||||
defer match.deinit();
|
||||
const sel = match.selection();
|
||||
|
||||
// If this is a highlight link then we only want to
|
||||
// include matches that include our hover point.
|
||||
if (link.highlight == .hover and
|
||||
!sel.contains(mouse_pt))
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
try matches.append(sel);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return .{ .matches = try matches.toOwnedSlice() };
|
||||
}
|
||||
};
|
||||
|
||||
/// MatchSet is the result of matching links against a screen. This contains
|
||||
/// all the matching links and operations on them such as whether a specific
|
||||
/// cell is part of a matched link.
|
||||
pub const MatchSet = struct {
|
||||
/// The matches.
|
||||
///
|
||||
/// Important: this must be in left-to-right top-to-bottom order.
|
||||
matches: []const terminal.Selection,
|
||||
i: usize = 0,
|
||||
|
||||
pub fn deinit(self: *MatchSet, alloc: Allocator) void {
|
||||
alloc.free(self.matches);
|
||||
}
|
||||
|
||||
/// Checks if the matchset contains the given pt. The points must be
|
||||
/// given in left-to-right top-to-bottom order. This is a stateful
|
||||
/// operation and giving a point out of order can cause invalid
|
||||
/// results.
|
||||
pub fn orderedContains(
|
||||
self: *MatchSet,
|
||||
pt: point.ScreenPoint,
|
||||
) bool {
|
||||
// If we're beyond the end of our possible matches, we're done.
|
||||
if (self.i >= self.matches.len) return false;
|
||||
|
||||
// If our selection ends before the point, then no point will ever
|
||||
// again match this selection so we move on to the next one.
|
||||
while (self.matches[self.i].end.before(pt)) {
|
||||
self.i += 1;
|
||||
if (self.i >= self.matches.len) return false;
|
||||
}
|
||||
|
||||
return self.matches[self.i].contains(pt);
|
||||
}
|
||||
};
|
||||
|
||||
test "matchset" {
|
||||
const testing = std.testing;
|
||||
const alloc = testing.allocator;
|
||||
|
||||
// Initialize our screen
|
||||
var s = try Screen.init(alloc, 5, 5, 0);
|
||||
defer s.deinit();
|
||||
const str = "1ABCD2EFGH\n3IJKL";
|
||||
try s.testWriteString(str);
|
||||
|
||||
// Get a set
|
||||
var set = try Set.fromConfig(alloc, &.{
|
||||
.{
|
||||
.regex = "AB",
|
||||
.action = .{ .open = {} },
|
||||
.highlight = .{ .always = {} },
|
||||
},
|
||||
|
||||
.{
|
||||
.regex = "EF",
|
||||
.action = .{ .open = {} },
|
||||
.highlight = .{ .always = {} },
|
||||
},
|
||||
});
|
||||
defer set.deinit(alloc);
|
||||
|
||||
// Get our matches
|
||||
var match = try set.matchSet(alloc, &s, .{});
|
||||
defer match.deinit(alloc);
|
||||
try testing.expectEqual(@as(usize, 2), match.matches.len);
|
||||
|
||||
// Test our matches
|
||||
try testing.expect(!match.orderedContains(.{ .x = 0, .y = 0 }));
|
||||
try testing.expect(match.orderedContains(.{ .x = 1, .y = 0 }));
|
||||
try testing.expect(match.orderedContains(.{ .x = 2, .y = 0 }));
|
||||
try testing.expect(!match.orderedContains(.{ .x = 3, .y = 0 }));
|
||||
try testing.expect(match.orderedContains(.{ .x = 1, .y = 1 }));
|
||||
try testing.expect(!match.orderedContains(.{ .x = 1, .y = 2 }));
|
||||
}
|
||||
|
||||
test "matchset hover links" {
|
||||
const testing = std.testing;
|
||||
const alloc = testing.allocator;
|
||||
|
||||
// Initialize our screen
|
||||
var s = try Screen.init(alloc, 5, 5, 0);
|
||||
defer s.deinit();
|
||||
const str = "1ABCD2EFGH\n3IJKL";
|
||||
try s.testWriteString(str);
|
||||
|
||||
// Get a set
|
||||
var set = try Set.fromConfig(alloc, &.{
|
||||
.{
|
||||
.regex = "AB",
|
||||
.action = .{ .open = {} },
|
||||
.highlight = .{ .hover = {} },
|
||||
},
|
||||
|
||||
.{
|
||||
.regex = "EF",
|
||||
.action = .{ .open = {} },
|
||||
.highlight = .{ .always = {} },
|
||||
},
|
||||
});
|
||||
defer set.deinit(alloc);
|
||||
|
||||
// Not hovering over the first link
|
||||
{
|
||||
var match = try set.matchSet(alloc, &s, .{});
|
||||
defer match.deinit(alloc);
|
||||
try testing.expectEqual(@as(usize, 1), match.matches.len);
|
||||
|
||||
// Test our matches
|
||||
try testing.expect(!match.orderedContains(.{ .x = 0, .y = 0 }));
|
||||
try testing.expect(!match.orderedContains(.{ .x = 1, .y = 0 }));
|
||||
try testing.expect(!match.orderedContains(.{ .x = 2, .y = 0 }));
|
||||
try testing.expect(!match.orderedContains(.{ .x = 3, .y = 0 }));
|
||||
try testing.expect(match.orderedContains(.{ .x = 1, .y = 1 }));
|
||||
try testing.expect(!match.orderedContains(.{ .x = 1, .y = 2 }));
|
||||
}
|
||||
|
||||
// Hovering over the first link
|
||||
{
|
||||
var match = try set.matchSet(alloc, &s, .{ .x = 1, .y = 0 });
|
||||
defer match.deinit(alloc);
|
||||
try testing.expectEqual(@as(usize, 2), match.matches.len);
|
||||
|
||||
// Test our matches
|
||||
try testing.expect(!match.orderedContains(.{ .x = 0, .y = 0 }));
|
||||
try testing.expect(match.orderedContains(.{ .x = 1, .y = 0 }));
|
||||
try testing.expect(match.orderedContains(.{ .x = 2, .y = 0 }));
|
||||
try testing.expect(!match.orderedContains(.{ .x = 3, .y = 0 }));
|
||||
try testing.expect(match.orderedContains(.{ .x = 1, .y = 1 }));
|
||||
try testing.expect(!match.orderedContains(.{ .x = 1, .y = 2 }));
|
||||
}
|
||||
}
|
||||
@@ -13,6 +13,9 @@
|
||||
//! affect this area.
|
||||
//! * Viewport - The area that is currently visible to the user. This
|
||||
//! can be thought of as the current window into the screen.
|
||||
//! * Row - A single visible row in the screen.
|
||||
//! * Line - A single line of text. This may map to multiple rows if
|
||||
//! the row is soft-wrapped.
|
||||
//!
|
||||
//! The internal storage of the screen is stored in a circular buffer
|
||||
//! with roughly the following format:
|
||||
@@ -64,6 +67,7 @@ const kitty = @import("kitty.zig");
|
||||
const point = @import("point.zig");
|
||||
const CircBuf = @import("../circ_buf.zig").CircBuf;
|
||||
const Selection = @import("Selection.zig");
|
||||
const StringMap = @import("StringMap.zig");
|
||||
const fastmem = @import("../fastmem.zig");
|
||||
const charsets = @import("charsets.zig");
|
||||
|
||||
@@ -900,6 +904,72 @@ pub const GraphemeData = union(enum) {
|
||||
}
|
||||
};
|
||||
|
||||
/// A line represents a line of text, potentially across soft-wrapped
|
||||
/// boundaries. This differs from row, which is a single physical row within
|
||||
/// the terminal screen.
|
||||
pub const Line = struct {
|
||||
screen: *Screen,
|
||||
tag: RowIndexTag,
|
||||
start: usize,
|
||||
len: usize,
|
||||
|
||||
/// Return the string for this line.
|
||||
pub fn string(self: *const Line, alloc: Allocator) ![:0]const u8 {
|
||||
return try self.screen.selectionString(alloc, self.selection(), true);
|
||||
}
|
||||
|
||||
/// Receive the string for this line along with the byte-to-point mapping.
|
||||
pub fn stringMap(self: *const Line, alloc: Allocator) !StringMap {
|
||||
return try self.screen.selectionStringMap(alloc, self.selection());
|
||||
}
|
||||
|
||||
/// Return a selection that covers the entire line.
|
||||
pub fn selection(self: *const Line) Selection {
|
||||
// Get the start and end screen point.
|
||||
const start_idx = self.tag.index(self.start).toScreen(self.screen).screen;
|
||||
const end_idx = self.tag.index(self.start + (self.len - 1)).toScreen(self.screen).screen;
|
||||
|
||||
// Convert the start and end screen points into a selection across
|
||||
// the entire rows. We then use selectionString because it handles
|
||||
// unwrapping, graphemes, etc.
|
||||
return .{
|
||||
.start = .{ .y = start_idx, .x = 0 },
|
||||
.end = .{ .y = end_idx, .x = self.screen.cols - 1 },
|
||||
};
|
||||
}
|
||||
};
|
||||
|
||||
/// Iterator over textual lines within the terminal. This will unwrap
|
||||
/// wrapped lines and consider them a single line.
|
||||
pub const LineIterator = struct {
|
||||
row_it: RowIterator,
|
||||
|
||||
pub fn next(self: *LineIterator) ?Line {
|
||||
const start = self.row_it.value;
|
||||
|
||||
// Get our current row
|
||||
var row = self.row_it.next() orelse return null;
|
||||
var len: usize = 1;
|
||||
|
||||
// While the row is wrapped we keep iterating over the rows
|
||||
// and incrementing the length.
|
||||
while (row.isWrapped()) {
|
||||
// Note: this orelse shouldn't happen. A wrapped row should
|
||||
// always have a next row. However, this isn't the place where
|
||||
// we want to assert that.
|
||||
row = self.row_it.next() orelse break;
|
||||
len += 1;
|
||||
}
|
||||
|
||||
return .{
|
||||
.screen = self.row_it.screen,
|
||||
.tag = self.row_it.tag,
|
||||
.start = start,
|
||||
.len = len,
|
||||
};
|
||||
}
|
||||
};
|
||||
|
||||
// Initialize to header and not a cell so that we can check header.init
|
||||
// to know if the remainder of the row has been initialized or not.
|
||||
const StorageBuf = CircBuf(StorageCell, .{ .header = .{} });
|
||||
@@ -1097,6 +1167,50 @@ pub fn rowIterator(self: *Screen, tag: RowIndexTag) RowIterator {
|
||||
};
|
||||
}
|
||||
|
||||
/// Returns an iterator that iterates over the lines of the screen. A line
|
||||
/// is a single line of text which may wrap across multiple rows. A row
|
||||
/// is a single physical row of the terminal.
|
||||
pub fn lineIterator(self: *Screen, tag: RowIndexTag) LineIterator {
|
||||
return .{ .row_it = self.rowIterator(tag) };
|
||||
}
|
||||
|
||||
/// Returns the line that contains the given point. This may be null if the
|
||||
/// point is outside the screen.
|
||||
pub fn getLine(self: *Screen, pt: point.ScreenPoint) ?Line {
|
||||
// If our y is outside of our written area, we have no line.
|
||||
if (pt.y >= RowIndexTag.screen.maxLen(self)) return null;
|
||||
if (pt.x >= self.cols) return null;
|
||||
|
||||
// Find the starting y. We go back and as soon as we find a row that
|
||||
// isn't wrapped, we know the NEXT line is the one we want.
|
||||
const start_y: usize = if (pt.y == 0) 0 else start_y: {
|
||||
for (1..pt.y) |y| {
|
||||
const bot_y = pt.y - y;
|
||||
const row = self.getRow(.{ .screen = bot_y });
|
||||
if (!row.isWrapped()) break :start_y bot_y + 1;
|
||||
}
|
||||
|
||||
break :start_y 0;
|
||||
};
|
||||
|
||||
// Find the end y, which is the first row that isn't wrapped.
|
||||
const end_y = end_y: {
|
||||
for (pt.y..self.rowsWritten()) |y| {
|
||||
const row = self.getRow(.{ .screen = y });
|
||||
if (!row.isWrapped()) break :end_y y;
|
||||
}
|
||||
|
||||
break :end_y self.rowsWritten() - 1;
|
||||
};
|
||||
|
||||
return .{
|
||||
.screen = self,
|
||||
.tag = .screen,
|
||||
.start = start_y,
|
||||
.len = (end_y - start_y) + 1,
|
||||
};
|
||||
}
|
||||
|
||||
/// Returns the row at the given index. This row is writable, although
|
||||
/// only the active area should probably be written to.
|
||||
pub fn getRow(self: *Screen, index: RowIndex) Row {
|
||||
@@ -2076,62 +2190,83 @@ pub fn selectionString(
|
||||
// Get the slices for the string
|
||||
const slices = self.selectionSlices(sel);
|
||||
|
||||
// We can now know how much space we'll need to store the string. We loop
|
||||
// over and UTF8-encode and calculate the exact size required. We will be
|
||||
// off here by at most "newlines" values in the worst case that every
|
||||
// single line is soft-wrapped.
|
||||
const chars = chars: {
|
||||
var count: usize = 0;
|
||||
// Use an ArrayList so that we can grow the array as we go. We
|
||||
// build an initial capacity of just our rows in our selection times
|
||||
// columns. It can be more or less based on graphemes, newlines, etc.
|
||||
var strbuilder = try std.ArrayList(u8).initCapacity(alloc, slices.rows * self.cols);
|
||||
defer strbuilder.deinit();
|
||||
|
||||
// We need to keep track of our x/y so that we can get graphemes.
|
||||
var y: usize = slices.sel.start.y;
|
||||
var x: usize = 0;
|
||||
var row: Row = undefined;
|
||||
// Get our string result.
|
||||
try self.selectionSliceString(slices, &strbuilder, null);
|
||||
|
||||
const arr = [_][]StorageCell{ slices.top, slices.bot };
|
||||
for (arr) |slice| {
|
||||
for (slice, 0..) |cell, i| {
|
||||
// detect row headers
|
||||
if (@mod(i, self.cols + 1) == 0) {
|
||||
// We use each row header as an opportunity to "count"
|
||||
// a new row, and therefore count a possible newline.
|
||||
count += 1;
|
||||
// Remove any trailing spaces on lines. We could do optimize this by
|
||||
// doing this in the loop above but this isn't very hot path code and
|
||||
// this is simple.
|
||||
if (trim) {
|
||||
var it = std.mem.tokenize(u8, strbuilder.items, "\n");
|
||||
|
||||
// Increase our row count and get our next row
|
||||
y += 1;
|
||||
x = 0;
|
||||
row = self.getRow(.{ .screen = y - 1 });
|
||||
continue;
|
||||
}
|
||||
|
||||
var buf: [4]u8 = undefined;
|
||||
const char = if (cell.cell.char > 0) cell.cell.char else ' ';
|
||||
count += try std.unicode.utf8Encode(@intCast(char), &buf);
|
||||
|
||||
// We need to also count any grapheme chars
|
||||
var it = row.codepointIterator(x);
|
||||
while (it.next()) |cp| {
|
||||
count += try std.unicode.utf8Encode(cp, &buf);
|
||||
}
|
||||
|
||||
x += 1;
|
||||
}
|
||||
// Reset our items. We retain our capacity. Because we're only
|
||||
// removing bytes, we know that the trimmed string must be no longer
|
||||
// than the original string so we copy directly back into our
|
||||
// allocated memory.
|
||||
strbuilder.clearRetainingCapacity();
|
||||
while (it.next()) |line| {
|
||||
const trimmed = std.mem.trimRight(u8, line, " \t");
|
||||
const i = strbuilder.items.len;
|
||||
strbuilder.items.len += trimmed.len;
|
||||
std.mem.copyForwards(u8, strbuilder.items[i..], trimmed);
|
||||
strbuilder.appendAssumeCapacity('\n');
|
||||
}
|
||||
|
||||
break :chars count;
|
||||
};
|
||||
const buf = try alloc.alloc(u8, chars + 1);
|
||||
errdefer alloc.free(buf);
|
||||
|
||||
// Special case the empty case
|
||||
if (chars == 0) {
|
||||
buf[0] = 0;
|
||||
return buf[0..0 :0];
|
||||
// Remove our trailing newline again
|
||||
if (strbuilder.items.len > 0) strbuilder.items.len -= 1;
|
||||
}
|
||||
|
||||
// Get our final string
|
||||
const string = try strbuilder.toOwnedSliceSentinel(0);
|
||||
errdefer alloc.free(string);
|
||||
|
||||
return string;
|
||||
}
|
||||
|
||||
/// Returns the row text associated with a selection along with the
|
||||
/// mapping of each individual byte in the string to the point in the screen.
|
||||
fn selectionStringMap(
|
||||
self: *Screen,
|
||||
alloc: Allocator,
|
||||
sel: Selection,
|
||||
) !StringMap {
|
||||
// Get the slices for the string
|
||||
const slices = self.selectionSlices(sel);
|
||||
|
||||
// Use an ArrayList so that we can grow the array as we go. We
|
||||
// build an initial capacity of just our rows in our selection times
|
||||
// columns. It can be more or less based on graphemes, newlines, etc.
|
||||
var strbuilder = try std.ArrayList(u8).initCapacity(alloc, slices.rows * self.cols);
|
||||
defer strbuilder.deinit();
|
||||
var mapbuilder = try std.ArrayList(point.ScreenPoint).initCapacity(alloc, strbuilder.capacity);
|
||||
defer mapbuilder.deinit();
|
||||
|
||||
// Get our results
|
||||
try self.selectionSliceString(slices, &strbuilder, &mapbuilder);
|
||||
|
||||
// Get our final string
|
||||
const string = try strbuilder.toOwnedSliceSentinel(0);
|
||||
errdefer alloc.free(string);
|
||||
const map = try mapbuilder.toOwnedSlice();
|
||||
errdefer alloc.free(map);
|
||||
return .{ .string = string, .map = map };
|
||||
}
|
||||
|
||||
/// Takes a SelectionSlices value and builds the string and mapping for it.
|
||||
fn selectionSliceString(
|
||||
self: *Screen,
|
||||
slices: SelectionSlices,
|
||||
strbuilder: *std.ArrayList(u8),
|
||||
mapbuilder: ?*std.ArrayList(point.ScreenPoint),
|
||||
) !void {
|
||||
// Connect the text from the two slices
|
||||
const arr = [_][]StorageCell{ slices.top, slices.bot };
|
||||
var buf_i: usize = 0;
|
||||
var row_count: usize = 0;
|
||||
for (arr) |slice| {
|
||||
const row_start: usize = row_count;
|
||||
@@ -2151,6 +2286,13 @@ pub fn selectionString(
|
||||
// the first row.
|
||||
var skip: usize = if (row_count == 0) slices.top_offset else 0;
|
||||
|
||||
// If we have runtime safety we need to initialize the row
|
||||
// so that the proper union tag is set. In release modes we
|
||||
// don't need to do this because we zero the memory.
|
||||
if (std.debug.runtime_safety) {
|
||||
_ = self.getRow(.{ .screen = slices.sel.start.y + row_i });
|
||||
}
|
||||
|
||||
const row: Row = .{ .screen = self, .storage = slice[start_idx..end_idx] };
|
||||
var it = row.cellIterator();
|
||||
var x: usize = 0;
|
||||
@@ -2166,56 +2308,61 @@ pub fn selectionString(
|
||||
if (cell.attrs.wide_spacer_head or
|
||||
cell.attrs.wide_spacer_tail) continue;
|
||||
|
||||
var buf: [4]u8 = undefined;
|
||||
const char = if (cell.char > 0) cell.char else ' ';
|
||||
buf_i += try std.unicode.utf8Encode(@intCast(char), buf[buf_i..]);
|
||||
{
|
||||
const encode_len = try std.unicode.utf8Encode(@intCast(char), &buf);
|
||||
try strbuilder.appendSlice(buf[0..encode_len]);
|
||||
if (mapbuilder) |b| {
|
||||
for (0..encode_len) |_| try b.append(.{
|
||||
.x = x,
|
||||
.y = slices.sel.start.y + row_i,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
var cp_it = row.codepointIterator(x);
|
||||
while (cp_it.next()) |cp| {
|
||||
buf_i += try std.unicode.utf8Encode(cp, buf[buf_i..]);
|
||||
const encode_len = try std.unicode.utf8Encode(cp, &buf);
|
||||
try strbuilder.appendSlice(buf[0..encode_len]);
|
||||
if (mapbuilder) |b| {
|
||||
for (0..encode_len) |_| try b.append(.{
|
||||
.x = x,
|
||||
.y = slices.sel.start.y + row_i,
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// If this row is not soft-wrapped, add a newline
|
||||
if (!row.header().flags.wrap) {
|
||||
buf[buf_i] = '\n';
|
||||
buf_i += 1;
|
||||
try strbuilder.append('\n');
|
||||
if (mapbuilder) |b| {
|
||||
try b.append(.{
|
||||
.x = self.cols - 1,
|
||||
.y = slices.sel.start.y + row_i,
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Remove our trailing newline, its never correct.
|
||||
if (buf_i > 0 and buf[buf_i - 1] == '\n') buf_i -= 1;
|
||||
|
||||
// Remove any trailing spaces on lines. We could do optimize this by
|
||||
// doing this in the loop above but this isn't very hot path code and
|
||||
// this is simple.
|
||||
if (trim) {
|
||||
var it = std.mem.tokenize(u8, buf[0..buf_i], "\n");
|
||||
buf_i = 0;
|
||||
while (it.next()) |line| {
|
||||
const trimmed = std.mem.trimRight(u8, line, " \t");
|
||||
std.mem.copy(u8, buf[buf_i..], trimmed);
|
||||
buf_i += trimmed.len;
|
||||
buf[buf_i] = '\n';
|
||||
buf_i += 1;
|
||||
}
|
||||
|
||||
// Remove our trailing newline again
|
||||
if (buf_i > 0) buf_i -= 1;
|
||||
if (strbuilder.items.len > 0 and
|
||||
strbuilder.items[strbuilder.items.len - 1] == '\n')
|
||||
{
|
||||
strbuilder.items.len -= 1;
|
||||
if (mapbuilder) |b| b.items.len -= 1;
|
||||
}
|
||||
|
||||
// Add null termination
|
||||
buf[buf_i] = 0;
|
||||
|
||||
// Realloc so our free length is exactly correct
|
||||
const result = try alloc.realloc(buf, buf_i + 1);
|
||||
return result[0..buf_i :0];
|
||||
if (std.debug.runtime_safety) {
|
||||
if (mapbuilder) |b| {
|
||||
assert(strbuilder.items.len == b.items.len);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Returns the slices that make up the selection, in order. There are at most
|
||||
/// two parts to handle the ring buffer. If the selection fits in one contiguous
|
||||
/// slice, then the second slice will have a length of zero.
|
||||
fn selectionSlices(self: *Screen, sel_raw: Selection) struct {
|
||||
const SelectionSlices = struct {
|
||||
rows: usize,
|
||||
|
||||
// The selection that the slices below represent. This may not
|
||||
@@ -2228,7 +2375,12 @@ fn selectionSlices(self: *Screen, sel_raw: Selection) struct {
|
||||
top_offset: usize,
|
||||
top: []StorageCell,
|
||||
bot: []StorageCell,
|
||||
} {
|
||||
};
|
||||
|
||||
/// Returns the slices that make up the selection, in order. There are at most
|
||||
/// two parts to handle the ring buffer. If the selection fits in one contiguous
|
||||
/// slice, then the second slice will have a length of zero.
|
||||
fn selectionSlices(self: *Screen, sel_raw: Selection) SelectionSlices {
|
||||
// Note: this function is tested via selectionString
|
||||
|
||||
// If the selection starts beyond the end of the screen, then we return empty
|
||||
@@ -3404,6 +3556,91 @@ test "Screen: write long emoji" {
|
||||
try testing.expectEqual(@as(usize, 5), s.cursor.x);
|
||||
}
|
||||
|
||||
test "Screen: lineIterator" {
|
||||
const testing = std.testing;
|
||||
const alloc = testing.allocator;
|
||||
|
||||
var s = try init(alloc, 5, 5, 0);
|
||||
defer s.deinit();
|
||||
|
||||
// Sanity check that our test helpers work
|
||||
const str = "1ABCD\n2EFGH";
|
||||
try s.testWriteString(str);
|
||||
|
||||
// Test the line iterator
|
||||
var iter = s.lineIterator(.viewport);
|
||||
{
|
||||
const line = iter.next().?;
|
||||
const actual = try line.string(alloc);
|
||||
defer alloc.free(actual);
|
||||
try testing.expectEqualStrings("1ABCD", actual);
|
||||
}
|
||||
{
|
||||
const line = iter.next().?;
|
||||
const actual = try line.string(alloc);
|
||||
defer alloc.free(actual);
|
||||
try testing.expectEqualStrings("2EFGH", actual);
|
||||
}
|
||||
try testing.expect(iter.next() == null);
|
||||
}
|
||||
|
||||
test "Screen: lineIterator soft wrap" {
|
||||
const testing = std.testing;
|
||||
const alloc = testing.allocator;
|
||||
|
||||
var s = try init(alloc, 5, 5, 0);
|
||||
defer s.deinit();
|
||||
|
||||
// Sanity check that our test helpers work
|
||||
const str = "1ABCD2EFGH\n3ABCD";
|
||||
try s.testWriteString(str);
|
||||
|
||||
// Test the line iterator
|
||||
var iter = s.lineIterator(.viewport);
|
||||
{
|
||||
const line = iter.next().?;
|
||||
const actual = try line.string(alloc);
|
||||
defer alloc.free(actual);
|
||||
try testing.expectEqualStrings("1ABCD2EFGH", actual);
|
||||
}
|
||||
{
|
||||
const line = iter.next().?;
|
||||
const actual = try line.string(alloc);
|
||||
defer alloc.free(actual);
|
||||
try testing.expectEqualStrings("3ABCD", actual);
|
||||
}
|
||||
try testing.expect(iter.next() == null);
|
||||
}
|
||||
|
||||
test "Screen: getLine soft wrap" {
|
||||
const testing = std.testing;
|
||||
const alloc = testing.allocator;
|
||||
|
||||
var s = try init(alloc, 5, 5, 0);
|
||||
defer s.deinit();
|
||||
|
||||
// Sanity check that our test helpers work
|
||||
const str = "1ABCD2EFGH\n3ABCD";
|
||||
try s.testWriteString(str);
|
||||
|
||||
// Test the line iterator
|
||||
{
|
||||
const line = s.getLine(.{ .x = 2, .y = 1 }).?;
|
||||
const actual = try line.string(alloc);
|
||||
defer alloc.free(actual);
|
||||
try testing.expectEqualStrings("1ABCD2EFGH", actual);
|
||||
}
|
||||
{
|
||||
const line = s.getLine(.{ .x = 2, .y = 2 }).?;
|
||||
const actual = try line.string(alloc);
|
||||
defer alloc.free(actual);
|
||||
try testing.expectEqualStrings("3ABCD", actual);
|
||||
}
|
||||
|
||||
try testing.expect(s.getLine(.{ .x = 2, .y = 3 }) == null);
|
||||
try testing.expect(s.getLine(.{ .x = 7, .y = 1 }) == null);
|
||||
}
|
||||
|
||||
test "Screen: scrolling" {
|
||||
const testing = std.testing;
|
||||
const alloc = testing.allocator;
|
||||
|
||||
124
src/terminal/StringMap.zig
Normal file
124
src/terminal/StringMap.zig
Normal file
@@ -0,0 +1,124 @@
|
||||
/// A string along with the mapping of each individual byte in the string
|
||||
/// to the point in the screen.
|
||||
const StringMap = @This();
|
||||
|
||||
const std = @import("std");
|
||||
const oni = @import("oniguruma");
|
||||
const point = @import("point.zig");
|
||||
const Selection = @import("Selection.zig");
|
||||
const Screen = @import("Screen.zig");
|
||||
const Allocator = std.mem.Allocator;
|
||||
|
||||
string: [:0]const u8,
|
||||
map: []point.ScreenPoint,
|
||||
|
||||
pub fn deinit(self: StringMap, alloc: Allocator) void {
|
||||
alloc.free(self.string);
|
||||
alloc.free(self.map);
|
||||
}
|
||||
|
||||
/// Returns an iterator that yields the next match of the given regex.
|
||||
pub fn searchIterator(
|
||||
self: StringMap,
|
||||
regex: oni.Regex,
|
||||
) SearchIterator {
|
||||
return .{ .map = self, .regex = regex };
|
||||
}
|
||||
|
||||
/// Iterates over the regular expression matches of the string.
|
||||
pub const SearchIterator = struct {
|
||||
map: StringMap,
|
||||
regex: oni.Regex,
|
||||
offset: usize = 0,
|
||||
|
||||
/// Returns the next regular expression match or null if there are
|
||||
/// no more matches.
|
||||
pub fn next(self: *SearchIterator) !?Match {
|
||||
if (self.offset >= self.map.string.len) return null;
|
||||
|
||||
var region = self.regex.search(
|
||||
self.map.string[self.offset..],
|
||||
.{},
|
||||
) catch |err| switch (err) {
|
||||
error.Mismatch => {
|
||||
self.offset = self.map.string.len;
|
||||
return null;
|
||||
},
|
||||
|
||||
else => return err,
|
||||
};
|
||||
errdefer region.deinit();
|
||||
|
||||
// Increment our offset by the number of bytes in the match.
|
||||
// We defer this so that we can return the match before
|
||||
// modifying the offset.
|
||||
const end_idx: usize = @intCast(region.ends()[0]);
|
||||
defer self.offset += end_idx;
|
||||
|
||||
return .{
|
||||
.map = self.map,
|
||||
.offset = self.offset,
|
||||
.region = region,
|
||||
};
|
||||
}
|
||||
};
|
||||
|
||||
/// A single regular expression match.
|
||||
pub const Match = struct {
|
||||
map: StringMap,
|
||||
offset: usize,
|
||||
region: oni.Region,
|
||||
|
||||
pub fn deinit(self: *Match) void {
|
||||
self.region.deinit();
|
||||
}
|
||||
|
||||
/// Returns the selection containing the full match.
|
||||
pub fn selection(self: Match) Selection {
|
||||
const start_idx: usize = @intCast(self.region.starts()[0]);
|
||||
const end_idx: usize = @intCast(self.region.ends()[0] - 1);
|
||||
const start_pt = self.map.map[self.offset + start_idx];
|
||||
const end_pt = self.map.map[self.offset + end_idx];
|
||||
return .{ .start = start_pt, .end = end_pt };
|
||||
}
|
||||
};
|
||||
|
||||
test "searchIterator" {
|
||||
const testing = std.testing;
|
||||
const alloc = testing.allocator;
|
||||
|
||||
// Initialize our regex
|
||||
try oni.testing.ensureInit();
|
||||
var re = try oni.Regex.init(
|
||||
"[A-B]{2}",
|
||||
.{},
|
||||
oni.Encoding.utf8,
|
||||
oni.Syntax.default,
|
||||
null,
|
||||
);
|
||||
defer re.deinit();
|
||||
|
||||
// Initialize our screen
|
||||
var s = try Screen.init(alloc, 5, 5, 0);
|
||||
defer s.deinit();
|
||||
const str = "1ABCD2EFGH\n3IJKL";
|
||||
try s.testWriteString(str);
|
||||
const line = s.getLine(.{ .x = 2, .y = 1 }).?;
|
||||
const map = try line.stringMap(alloc);
|
||||
defer map.deinit(alloc);
|
||||
|
||||
// Get our iterator
|
||||
var it = map.searchIterator(re);
|
||||
{
|
||||
var match = (try it.next()).?;
|
||||
defer match.deinit();
|
||||
|
||||
const sel = match.selection();
|
||||
try testing.expectEqual(Selection{
|
||||
.start = .{ .x = 1, .y = 0 },
|
||||
.end = .{ .x = 2, .y = 0 },
|
||||
}, sel);
|
||||
}
|
||||
|
||||
try testing.expect(try it.next() == null);
|
||||
}
|
||||
@@ -26,6 +26,7 @@ pub const Terminal = @import("Terminal.zig");
|
||||
pub const Parser = @import("Parser.zig");
|
||||
pub const Selection = @import("Selection.zig");
|
||||
pub const Screen = @import("Screen.zig");
|
||||
pub const StringMap = @import("StringMap.zig");
|
||||
pub const Stream = stream.Stream;
|
||||
pub const Cursor = Screen.Cursor;
|
||||
pub const CursorStyleReq = ansi.CursorStyle;
|
||||
|
||||
Reference in New Issue
Block a user