From 5db002cb12e876381a653789bb27dfea128ca2f1 Mon Sep 17 00:00:00 2001 From: Mitchell Hashimoto Date: Sun, 26 Nov 2023 12:51:25 -0800 Subject: [PATCH] renderer/metal: underline urls --- pkg/oniguruma/main.zig | 1 + src/config.zig | 1 + src/config/url.zig | 26 +++++++++++ src/main.zig | 4 ++ src/renderer/Metal.zig | 102 ++++++++++++++++++++++++++++++++++++++++- 5 files changed, 133 insertions(+), 1 deletion(-) create mode 100644 src/config/url.zig 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,