From aa86031ff6ec2ba93c50cfb76a1c001a87a53123 Mon Sep 17 00:00:00 2001 From: Mitchell Hashimoto Date: Tue, 28 Nov 2023 13:55:57 -0800 Subject: [PATCH] terminal: move line searching here, unit test --- src/Surface.zig | 110 ++++++++++++++++++++++++-------- src/renderer/Metal.zig | 22 ++----- src/terminal/Screen.zig | 13 +--- src/terminal/StringMap.zig | 124 +++++++++++++++++++++++++++++++++++++ src/terminal/main.zig | 1 + 5 files changed, 216 insertions(+), 54 deletions(-) create mode 100644 src/terminal/StringMap.zig diff --git a/src/Surface.zig b/src/Surface.zig index 698f218f5..20148a8cf 100644 --- a/src/Surface.zig +++ b/src/Surface.zig @@ -20,6 +20,7 @@ const builtin = @import("builtin"); const assert = std.debug.assert; const Allocator = std.mem.Allocator; const ArenaAllocator = std.heap.ArenaAllocator; +const oni = @import("oniguruma"); const ziglyph = @import("ziglyph"); const main = @import("main.zig"); const renderer = @import("renderer.zig"); @@ -165,12 +166,38 @@ const DerivedConfig = struct { window_padding_y: u32, window_padding_balance: bool, title: ?[:0]const u8, + links: []const Link, + + const Link = struct { + regex: oni.Regex, + action: input.Link.Action, + }; pub fn init(alloc_gpa: Allocator, config: *const configpkg.Config) !DerivedConfig { var arena = ArenaAllocator.init(alloc_gpa); errdefer arena.deinit(); const alloc = arena.allocator(); + // Build all of our links + const links = links: { + var links = std.ArrayList(Link).init(alloc); + defer links.deinit(); + for (config.link.links.items) |link| { + var regex = try link.oniRegex(); + errdefer regex.deinit(); + try links.append(.{ + .regex = regex, + .action = link.action, + }); + } + + break :links try links.toOwnedSlice(); + }; + errdefer { + for (links) |*link| link.regex.deinit(); + alloc.free(links); + } + return .{ .original_font_size = config.@"font-size", .keybind = try config.keybind.clone(alloc), @@ -192,6 +219,7 @@ const DerivedConfig = struct { .window_padding_y = config.@"window-padding-y", .window_padding_balance = config.@"window-padding-balance", .title = config.title, + .links = links, // Assignments happen sequentially so we have to do this last // so that the memory is captured from allocs above. @@ -1842,6 +1870,14 @@ pub fn mouseButtonCallback( } } + // Handle link clicking. We want to do this before we do mouse + // reporting or any other mouse handling because a successfully + // clicked link will swallow the event. + if (button == .left and action == .release) { + const pos = try self.rt_surface.getCursorPos(); + if (try self.processLinks(pos)) return; + } + // Report mouse events if enabled { self.renderer_state.mutex.lock(); @@ -1970,6 +2006,28 @@ pub fn mouseButtonCallback( } } +/// Attempt to invoke the action of any link that is under the +/// given position. +fn processLinks(self: *Surface, pos: apprt.CursorPos) !bool { + // If we have no configured links we can save a lot of work + if (self.config.links.len == 0) return false; + + // Convert our cursor position to a screen point. + const screen_point = screen_point: { + const viewport_point = self.posToViewport(pos.x, pos.y); + break :screen_point viewport_point.toScreen(&self.io.terminal.screen); + }; + + // Get the line we're hovering over. + const line = self.io.terminal.screen.getLine(screen_point) orelse + return false; + const strmap = try line.stringMap(self.alloc); + defer strmap.deinit(self.alloc); + + // TODO + return false; +} + pub fn cursorPosCallback( self: *Surface, pos: apprt.CursorPos, @@ -2016,36 +2074,38 @@ pub fn cursorPosCallback( return; } - // If the cursor isn't clicked currently, it doesn't matter - if (self.mouse.click_state[@intFromEnum(input.MouseButton.left)] != .press) return; + // Handle cursor position for text selection + if (self.mouse.click_state[@intFromEnum(input.MouseButton.left)] == .press) { + // All roads lead to requiring a re-render at this point. + try self.queueRender(); - // All roads lead to requiring a re-render at this point. - try self.queueRender(); + // If our y is negative, we're above the window. In this case, we scroll + // up. The amount we scroll up is dependent on how negative we are. + // Note: one day, we can change this from distance to time based if we want. + //log.warn("CURSOR POS: {} {}", .{ pos, self.screen_size }); + const max_y: f32 = @floatFromInt(self.screen_size.height); + if (pos.y < 0 or pos.y > max_y) { + const delta: isize = if (pos.y < 0) -1 else 1; + try self.io.terminal.scrollViewport(.{ .delta = delta }); - // If our y is negative, we're above the window. In this case, we scroll - // up. The amount we scroll up is dependent on how negative we are. - // Note: one day, we can change this from distance to time based if we want. - //log.warn("CURSOR POS: {} {}", .{ pos, self.screen_size }); - const max_y: f32 = @floatFromInt(self.screen_size.height); - if (pos.y < 0 or pos.y > max_y) { - const delta: isize = if (pos.y < 0) -1 else 1; - try self.io.terminal.scrollViewport(.{ .delta = delta }); + // TODO: We want a timer or something to repeat while we're still + // at this cursor position. Right now, the user has to jiggle their + // mouse in order to scroll. + } - // TODO: We want a timer or something to repeat while we're still - // at this cursor position. Right now, the user has to jiggle their - // mouse in order to scroll. - } + // Convert to points + const viewport_point = self.posToViewport(pos.x, pos.y); + const screen_point = viewport_point.toScreen(&self.io.terminal.screen); - // Convert to points - const viewport_point = self.posToViewport(pos.x, pos.y); - const screen_point = viewport_point.toScreen(&self.io.terminal.screen); + // Handle dragging depending on click count + switch (self.mouse.left_click_count) { + 1 => self.dragLeftClickSingle(screen_point, pos.x), + 2 => self.dragLeftClickDouble(screen_point), + 3 => self.dragLeftClickTriple(screen_point), + else => unreachable, + } - // Handle dragging depending on click count - switch (self.mouse.left_click_count) { - 1 => self.dragLeftClickSingle(screen_point, pos.x), - 2 => self.dragLeftClickDouble(screen_point), - 3 => self.dragLeftClickTriple(screen_point), - else => unreachable, + return; } } diff --git a/src/renderer/Metal.zig b/src/renderer/Metal.zig index d5e7690c4..ed4bd5f17 100644 --- a/src/renderer/Metal.zig +++ b/src/renderer/Metal.zig @@ -1408,29 +1408,17 @@ fn rebuildCells( const strmap = line.stringMap(arena_alloc) catch continue; defer strmap.deinit(arena_alloc); - var offset: usize = 0; + var it = strmap.searchIterator(self.url_regex); 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}), - } - + const match_ = it.next() catch |err| { + log.warn("failed to search for URLs err={}", .{err}); break; }; + var match = match_ orelse 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 }); + try urls.append(match.selection()); } } diff --git a/src/terminal/Screen.zig b/src/terminal/Screen.zig index 9d9345c9a..08af2b9f2 100644 --- a/src/terminal/Screen.zig +++ b/src/terminal/Screen.zig @@ -67,6 +67,7 @@ const kitty = @import("kitty.zig"); const point = @import("point.zig"); const CircBuf = @import("../circ_buf.zig").CircBuf; const Selection = @import("Selection.zig"); +const StringMap = @import("StringMap.zig"); const fastmem = @import("../fastmem.zig"); const charsets = @import("charsets.zig"); @@ -2228,18 +2229,6 @@ pub fn selectionString( return string; } -/// A string along with the mapping of each individual byte in the string -/// to the point in the screen. -pub const StringMap = struct { - string: [:0]const u8, - map: []point.ScreenPoint, - - pub fn deinit(self: StringMap, alloc: Allocator) void { - alloc.free(self.string); - alloc.free(self.map); - } -}; - /// Returns the row text associated with a selection along with the /// mapping of each individual byte in the string to the point in the screen. fn selectionStringMap( diff --git a/src/terminal/StringMap.zig b/src/terminal/StringMap.zig new file mode 100644 index 000000000..588013d9d --- /dev/null +++ b/src/terminal/StringMap.zig @@ -0,0 +1,124 @@ +/// A string along with the mapping of each individual byte in the string +/// to the point in the screen. +const StringMap = @This(); + +const std = @import("std"); +const oni = @import("oniguruma"); +const point = @import("point.zig"); +const Selection = @import("Selection.zig"); +const Screen = @import("Screen.zig"); +const Allocator = std.mem.Allocator; + +string: [:0]const u8, +map: []point.ScreenPoint, + +pub fn deinit(self: StringMap, alloc: Allocator) void { + alloc.free(self.string); + alloc.free(self.map); +} + +/// Returns an iterator that yields the next match of the given regex. +pub fn searchIterator( + self: StringMap, + regex: oni.Regex, +) SearchIterator { + return .{ .map = self, .regex = regex }; +} + +/// Iterates over the regular expression matches of the string. +pub const SearchIterator = struct { + map: StringMap, + regex: oni.Regex, + offset: usize = 0, + + /// Returns the next regular expression match or null if there are + /// no more matches. + pub fn next(self: *SearchIterator) !?Match { + if (self.offset >= self.map.string.len) return null; + + var region = self.regex.search( + self.map.string[self.offset..], + .{}, + ) catch |err| switch (err) { + error.Mismatch => { + self.offset = self.map.string.len; + return null; + }, + + else => return err, + }; + errdefer region.deinit(); + + // Increment our offset by the number of bytes in the match. + // We defer this so that we can return the match before + // modifying the offset. + const end_idx: usize = @intCast(region.ends()[0]); + defer self.offset += end_idx; + + return .{ + .map = self.map, + .offset = self.offset, + .region = region, + }; + } +}; + +/// A single regular expression match. +pub const Match = struct { + map: StringMap, + offset: usize, + region: oni.Region, + + pub fn deinit(self: *Match) void { + self.region.deinit(); + } + + /// Returns the selection containing the full match. + pub fn selection(self: Match) Selection { + const start_idx: usize = @intCast(self.region.starts()[0]); + const end_idx: usize = @intCast(self.region.ends()[0] - 1); + const start_pt = self.map.map[self.offset + start_idx]; + const end_pt = self.map.map[self.offset + end_idx]; + return .{ .start = start_pt, .end = end_pt }; + } +}; + +test "searchIterator" { + const testing = std.testing; + const alloc = testing.allocator; + + // Initialize our regex + try oni.testing.ensureInit(); + var re = try oni.Regex.init( + "[A-B]{2}", + .{}, + oni.Encoding.utf8, + oni.Syntax.default, + null, + ); + defer re.deinit(); + + // Initialize our screen + var s = try Screen.init(alloc, 5, 5, 0); + defer s.deinit(); + const str = "1ABCD2EFGH\n3IJKL"; + try s.testWriteString(str); + const line = s.getLine(.{ .x = 2, .y = 1 }).?; + const map = try line.stringMap(alloc); + defer map.deinit(alloc); + + // Get our iterator + var it = map.searchIterator(re); + { + var match = (try it.next()).?; + defer match.deinit(); + + const sel = match.selection(); + try testing.expectEqual(Selection{ + .start = .{ .x = 1, .y = 0 }, + .end = .{ .x = 2, .y = 0 }, + }, sel); + } + + try testing.expect(try it.next() == null); +} diff --git a/src/terminal/main.zig b/src/terminal/main.zig index a752d64eb..486e3526e 100644 --- a/src/terminal/main.zig +++ b/src/terminal/main.zig @@ -26,6 +26,7 @@ pub const Terminal = @import("Terminal.zig"); pub const Parser = @import("Parser.zig"); pub const Selection = @import("Selection.zig"); pub const Screen = @import("Screen.zig"); +pub const StringMap = @import("StringMap.zig"); pub const Stream = stream.Stream; pub const Cursor = Screen.Cursor; pub const CursorStyleReq = ansi.CursorStyle;