From 5a7596e1b19af2ec58ef9ebfd6f475e870a4052f Mon Sep 17 00:00:00 2001 From: Mitchell Hashimoto Date: Wed, 29 Nov 2023 11:14:56 -0800 Subject: [PATCH] renderer: link set for more efficient matching --- src/renderer/Metal.zig | 96 +++++++-------------------- src/renderer/State.zig | 6 ++ src/renderer/link.zig | 143 +++++++++++++++++++++++++++++++++++++++++ 3 files changed, 173 insertions(+), 72 deletions(-) create mode 100644 src/renderer/link.zig diff --git a/src/renderer/Metal.zig b/src/renderer/Metal.zig index ed4bd5f17..76afaf459 100644 --- a/src/renderer/Metal.zig +++ b/src/renderer/Metal.zig @@ -9,7 +9,6 @@ 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"); @@ -19,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; @@ -120,11 +120,6 @@ 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. @@ -159,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, @@ -180,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", @@ -214,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(); } }; @@ -348,16 +353,6 @@ 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, @@ -398,8 +393,6 @@ 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, }; } @@ -429,8 +422,6 @@ pub fn deinit(self: *Metal) void { self.shaders.deinit(self.alloc); - self.url_regex.deinit(); - self.* = undefined; } @@ -1396,31 +1387,12 @@ fn rebuildCells( 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 it = strmap.searchIterator(self.url_regex); - while (true) { - const match_ = it.next() catch |err| { - log.warn("failed to search for URLs err={}", .{err}); - break; - }; - var match = match_ orelse break; - defer match.deinit(); - - // Store our selection - try urls.append(match.selection()); - } - } + // Create our match set for the links. + var link_match_set = try self.config.links.matchSet( + arena_alloc, + screen, + .{}, // TODO: mouse hover point + ); // Determine our x/y range for preedit. We don't want to render anything // here because we will render the preedit separately. @@ -1439,10 +1411,6 @@ 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; @@ -1534,30 +1502,14 @@ fn rebuildCells( // 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; - } + // 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; diff --git a/src/renderer/State.zig b/src/renderer/State.zig index 57b54365d..55332c2d4 100644 --- a/src/renderer/State.zig +++ b/src/renderer/State.zig @@ -25,6 +25,12 @@ 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 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 diff --git a/src/renderer/link.zig b/src/renderer/link.zig new file mode 100644 index 000000000..973ba92e8 --- /dev/null +++ b/src/renderer/link.zig @@ -0,0 +1,143 @@ +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_pt: point.Viewport, + ) !MatchSet { + _ = mouse_pt; + + // 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| { + 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(); + try matches.append(match.selection()); + } + } + } + + 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); + } +};