Merge pull request #968 from mitchellh/urls

Clickable URLs
This commit is contained in:
Mitchell Hashimoto
2023-11-30 12:15:55 -08:00
committed by GitHub
31 changed files with 1830 additions and 113 deletions

View File

@@ -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();
}
}

View File

@@ -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);

View File

@@ -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;

View File

@@ -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
View 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();
}
}

View File

@@ -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
View 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,
);
}

View File

@@ -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);

View File

@@ -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
View 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();
}

View File

@@ -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,

View File

@@ -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,

View File

@@ -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
View 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 }));
}
}

View File

@@ -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
View 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);
}

View File

@@ -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;