summaryrefslogtreecommitdiff
diff options
context:
space:
mode:
authorMitchell Hashimoto <mitchell.hashimoto@gmail.com>2023-11-26 12:51:25 -0800
committerMitchell Hashimoto <mitchell.hashimoto@gmail.com>2023-11-29 15:30:21 -0800
commit5db002cb12e876381a653789bb27dfea128ca2f1 (patch)
tree2a679093ea36a918f32e837d46612f47a7e9dbcd
parentc7ccded35912e3d122cd52d7828f352b85c758da (diff)
renderer/metal: underline urls
-rw-r--r--pkg/oniguruma/main.zig1
-rw-r--r--src/config.zig1
-rw-r--r--src/config/url.zig26
-rw-r--r--src/main.zig4
-rw-r--r--src/renderer/Metal.zig102
5 files changed, 133 insertions, 1 deletions
diff --git a/pkg/oniguruma/main.zig b/pkg/oniguruma/main.zig
index 5e56c70be..b4eb3053b 100644
--- a/pkg/oniguruma/main.zig
+++ b/pkg/oniguruma/main.zig
@@ -4,6 +4,7 @@ pub usingnamespace @import("regex.zig");
pub usingnamespace @import("region.zig");
pub usingnamespace @import("types.zig");
pub const c = @import("c.zig");
+pub const testing = @import("testing.zig");
test {
@import("std").testing.refAllDecls(@This());
diff --git a/src/config.zig b/src/config.zig
index e639f9b84..cd449fb38 100644
--- a/src/config.zig
+++ b/src/config.zig
@@ -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;
diff --git a/src/config/url.zig b/src/config/url.zig
new file mode 100644
index 000000000..4cbfacdd4
--- /dev/null
+++ b/src/config/url.zig
@@ -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();
+ }
+}
diff --git a/src/main.zig b/src/main.zig
index 6d8ac9ad0..91167e721 100644
--- a/src/main.zig
+++ b/src/main.zig
@@ -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);
diff --git a/src/renderer/Metal.zig b/src/renderer/Metal.zig
index 5c11595c4..d5e7690c4 100644
--- a/src/renderer/Metal.zig
+++ b/src/renderer/Metal.zig
@@ -9,6 +9,7 @@ const builtin = @import("builtin");
const glfw = @import("glfw");
const objc = @import("objc");
const macos = @import("macos");
+const oni = @import("oniguruma");
const imgui = @import("imgui");
const glslang = @import("glslang");
const apprt = @import("../apprt.zig");
@@ -119,6 +120,11 @@ texture_color: objc.Object, // MTLTexture
/// Custom shader state. This is only set if we have custom shaders.
custom_shader_state: ?CustomShaderState = null,
+/// Our URL regex.
+/// One day, this will be part of DerivedConfig since all regex matchers
+/// will be part of the config.
+url_regex: oni.Regex,
+
pub const CustomShaderState = struct {
/// The screen texture that we render the terminal to. If we don't have
/// custom shaders, we render directly to the drawable.
@@ -342,6 +348,16 @@ pub fn init(alloc: Allocator, options: renderer.Options) !Metal {
const texture_greyscale = try initAtlasTexture(device, &options.font_group.atlas_greyscale);
const texture_color = try initAtlasTexture(device, &options.font_group.atlas_color);
+ // Create our URL regex
+ var url_re = try oni.Regex.init(
+ configpkg.url.regex,
+ .{},
+ oni.Encoding.utf8,
+ oni.Syntax.default,
+ null,
+ );
+ errdefer url_re.deinit();
+
return Metal{
.alloc = alloc,
.config = options.config,
@@ -382,6 +398,8 @@ pub fn init(alloc: Allocator, options: renderer.Options) !Metal {
.texture_greyscale = texture_greyscale,
.texture_color = texture_color,
.custom_shader_state = custom_shader_state,
+
+ .url_regex = url_re,
};
}
@@ -411,6 +429,8 @@ pub fn deinit(self: *Metal) void {
self.shaders.deinit(self.alloc);
+ self.url_regex.deinit();
+
self.* = undefined;
}
@@ -1371,6 +1391,49 @@ 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();
+
+ // All of our matching URLs. The selections in this list MUST be in
+ // order of left-to-right top-to-bottom (English reading order). This
+ // requirement is necessary for the URL highlighting to work properly
+ // and has nothing to do with the locale.
+ var urls = std.ArrayList(terminal.Selection).init(arena_alloc);
+
+ // Find all the cells that have URLs.
+ var lineIter = screen.lineIterator(.viewport);
+ while (lineIter.next()) |line| {
+ const strmap = line.stringMap(arena_alloc) catch continue;
+ defer strmap.deinit(arena_alloc);
+
+ var offset: usize = 0;
+ while (true) {
+ var match = self.url_regex.search(strmap.string[offset..], .{}) catch |err| {
+ switch (err) {
+ error.Mismatch => {},
+ else => log.warn("failed to search for URLs err={}", .{err}),
+ }
+
+ break;
+ };
+ defer match.deinit();
+
+ // Determine the screen point for the match
+ const start_idx: usize = @intCast(match.starts()[0]);
+ const end_idx: usize = @intCast(match.ends()[0] - 1);
+ const start_pt = strmap.map[offset + start_idx];
+ const end_pt = strmap.map[offset + end_idx];
+
+ // Move our offset so we can continue searching
+ offset += end_idx;
+
+ // Store our selection
+ try urls.append(.{ .start = start_pt, .end = end_pt });
+ }
+ }
+
// 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 {
@@ -1388,6 +1451,10 @@ fn rebuildCells(
// remains visible.
var cursor_cell: ?mtl_shaders.Cell = null;
+ // Keep track of our current hint that is being tracked.
+ var hint: ?terminal.Selection = if (urls.items.len > 0) urls.items[0] else null;
+ var hint_i: usize = 0;
+
// Build each cell
var rowIter = screen.rowIterator(.viewport);
var y: usize = 0;
@@ -1475,10 +1542,43 @@ 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 (hint) |sel| hint: {
+ const pt: terminal.point.ScreenPoint = .{
+ .x = shaper_cell.x,
+ .y = y,
+ };
+
+ // If the end is before the point then we try to
+ // move to the next hint.
+ var compare_sel: terminal.Selection = sel;
+ if (sel.end.before(pt)) {
+ hint_i += 1;
+ if (hint_i >= urls.items.len) {
+ hint = null;
+ break :hint;
+ }
+
+ compare_sel = urls.items[hint_i];
+ hint = compare_sel;
+ }
+
+ if (compare_sel.contains(pt)) {
+ cell.attrs.underline = .single;
+ break :hint;
+ }
+ }
+
+ break :cell cell;
+ };
+
if (self.updateCell(
term_selection,
screen,
- row.getCell(shaper_cell.x),
+ cell,
shaper_cell,
run,
shaper_cell.x,