mirror of
https://github.com/ghostty-org/ghostty.git
synced 2025-07-14 15:56:13 +03:00
renderer/metal: underline urls
This commit is contained in:
@ -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());
|
||||||
|
@ -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
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();
|
||||||
|
}
|
||||||
|
}
|
@ -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);
|
||||||
|
@ -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,
|
||||||
|
Reference in New Issue
Block a user