renderer/metal: underline urls

This commit is contained in:
Mitchell Hashimoto
2023-11-26 12:51:25 -08:00
parent c7ccded359
commit 5db002cb12
5 changed files with 133 additions and 1 deletions

View File

@ -4,6 +4,7 @@ pub usingnamespace @import("regex.zig");
pub usingnamespace @import("region.zig"); pub usingnamespace @import("region.zig");
pub usingnamespace @import("types.zig"); pub usingnamespace @import("types.zig");
pub const c = @import("c.zig"); pub const c = @import("c.zig");
pub const testing = @import("testing.zig");
test { test {
@import("std").testing.refAllDecls(@This()); @import("std").testing.refAllDecls(@This());

View File

@ -3,6 +3,7 @@ const builtin = @import("builtin");
pub usingnamespace @import("config/key.zig"); pub usingnamespace @import("config/key.zig");
pub const Config = @import("config/Config.zig"); pub const Config = @import("config/Config.zig");
pub const string = @import("config/string.zig"); pub const string = @import("config/string.zig");
pub const url = @import("config/url.zig");
// Field types // Field types
pub const CopyOnSelect = Config.CopyOnSelect; pub const CopyOnSelect = Config.CopyOnSelect;

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

@ -6,6 +6,7 @@ const options = @import("build_options");
const glfw = @import("glfw"); const glfw = @import("glfw");
const glslang = @import("glslang"); const glslang = @import("glslang");
const macos = @import("macos"); const macos = @import("macos");
const oni = @import("oniguruma");
const tracy = @import("tracy"); const tracy = @import("tracy");
const cli = @import("cli.zig"); const cli = @import("cli.zig");
const internal_os = @import("os/main.zig"); const internal_os = @import("os/main.zig");
@ -277,6 +278,9 @@ pub const GlobalState = struct {
// Initialize glslang for shader compilation // Initialize glslang for shader compilation
try glslang.init(); try glslang.init();
// Initialize oniguruma for regex
try oni.init(&.{oni.Encoding.utf8});
// Find our resources directory once for the app so every launch // Find our resources directory once for the app so every launch
// hereafter can use this cached value. // hereafter can use this cached value.
self.resources_dir = try internal_os.resourcesDir(self.alloc); self.resources_dir = try internal_os.resourcesDir(self.alloc);

View File

@ -9,6 +9,7 @@ const builtin = @import("builtin");
const glfw = @import("glfw"); const glfw = @import("glfw");
const objc = @import("objc"); const objc = @import("objc");
const macos = @import("macos"); const macos = @import("macos");
const oni = @import("oniguruma");
const imgui = @import("imgui"); const imgui = @import("imgui");
const glslang = @import("glslang"); const glslang = @import("glslang");
const apprt = @import("../apprt.zig"); 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. This is only set if we have custom shaders.
custom_shader_state: ?CustomShaderState = null, 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 { pub const CustomShaderState = struct {
/// The screen texture that we render the terminal to. If we don't have /// The screen texture that we render the terminal to. If we don't have
/// custom shaders, we render directly to the drawable. /// 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_greyscale = try initAtlasTexture(device, &options.font_group.atlas_greyscale);
const texture_color = try initAtlasTexture(device, &options.font_group.atlas_color); 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{ return Metal{
.alloc = alloc, .alloc = alloc,
.config = options.config, .config = options.config,
@ -382,6 +398,8 @@ pub fn init(alloc: Allocator, options: renderer.Options) !Metal {
.texture_greyscale = texture_greyscale, .texture_greyscale = texture_greyscale,
.texture_color = texture_color, .texture_color = texture_color,
.custom_shader_state = custom_shader_state, .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.shaders.deinit(self.alloc);
self.url_regex.deinit();
self.* = undefined; self.* = undefined;
} }
@ -1371,6 +1391,49 @@ fn rebuildCells(
(screen.rows * screen.cols * 2) + 1, (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 // Determine our x/y range for preedit. We don't want to render anything
// here because we will render the preedit separately. // here because we will render the preedit separately.
const preedit_range: ?struct { const preedit_range: ?struct {
@ -1388,6 +1451,10 @@ fn rebuildCells(
// remains visible. // remains visible.
var cursor_cell: ?mtl_shaders.Cell = null; 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 // Build each cell
var rowIter = screen.rowIterator(.viewport); var rowIter = screen.rowIterator(.viewport);
var y: usize = 0; 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( if (self.updateCell(
term_selection, term_selection,
screen, screen,
row.getCell(shaper_cell.x), cell,
shaper_cell, shaper_cell,
run, run,
shaper_cell.x, shaper_cell.x,