From cd00a8a2ab661da2c38c16c5b3a616296a9da415 Mon Sep 17 00:00:00 2001 From: Mitchell Hashimoto Date: Thu, 20 Nov 2025 05:44:18 -1000 Subject: [PATCH] renderer: handle normal non-osc8 links with new render state --- src/renderer/generic.zig | 39 +-- src/renderer/link.zig | 677 ++++++++++----------------------------- src/terminal/render.zig | 64 +++- 3 files changed, 250 insertions(+), 530 deletions(-) diff --git a/src/renderer/generic.zig b/src/renderer/generic.zig index 02f3a7357..591b0643b 100644 --- a/src/renderer/generic.zig +++ b/src/renderer/generic.zig @@ -1069,14 +1069,15 @@ pub fn Renderer(comptime GraphicsAPI: type) type { // Data we extract out of the critical area. const Critical = struct { - osc8_links: terminal.RenderState.CellSet, + links: terminal.RenderState.CellSet, + mouse: renderer.State.Mouse, preedit: ?renderer.State.Preedit, cursor_style: ?renderer.CursorStyle, scrollbar: terminal.Scrollbar, }; // Update all our data as tightly as possible within the mutex. - const critical: Critical = critical: { + var critical: Critical = critical: { // const start = try std.time.Instant.now(); // const start_micro = std.time.microTimestamp(); // defer { @@ -1135,7 +1136,7 @@ pub fn Renderer(comptime GraphicsAPI: type) type { // Get our OSC8 links we're hovering if we have a mouse. // This requires terminal state because of URLs. - const osc8_links: terminal.RenderState.CellSet = osc8: { + const links: terminal.RenderState.CellSet = osc8: { // If our mouse isn't hovering, we have no links. const vp = state.mouse.point orelse break :osc8 .empty; @@ -1153,18 +1154,31 @@ pub fn Renderer(comptime GraphicsAPI: type) type { }; break :critical .{ - .osc8_links = osc8_links, + .links = links, + .mouse = state.mouse, .preedit = preedit, .cursor_style = cursor_style, .scrollbar = scrollbar, }; }; + // Outside the critical area we can update our links to contain + // our regex results. + self.config.links.renderCellMap( + arena_alloc, + &critical.links, + &self.terminal_state, + state.mouse.point, + state.mouse.mods, + ) catch |err| { + log.warn("error searching for regex links err={}", .{err}); + }; + // Build our GPU cells try self.rebuildCells( critical.preedit, critical.cursor_style, - &critical.osc8_links, + &critical.links, ); // Notify our shaper we're done for the frame. For some shapers, @@ -2248,7 +2262,7 @@ pub fn Renderer(comptime GraphicsAPI: type) type { self: *Self, preedit: ?renderer.State.Preedit, cursor_style_: ?renderer.CursorStyle, - osc8_links: *const terminal.RenderState.CellSet, + links: *const terminal.RenderState.CellSet, ) !void { const state: *terminal.RenderState = &self.terminal_state; defer state.redraw = false; @@ -2264,15 +2278,6 @@ pub fn Renderer(comptime GraphicsAPI: type) type { // std.log.warn("[rebuildCells time] {}\t{}", .{start_micro, end.since(start) / std.time.ns_per_us}); // } - // TODO: renderstate - // Create our match set for the links. - // var link_match_set: link.MatchSet = if (mouse.point) |mouse_pt| try self.config.links.matchSet( - // arena_alloc, - // screen, - // mouse_pt, - // mouse.mods, - // ) else .{}; - // 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 { @@ -2647,9 +2652,7 @@ pub fn Renderer(comptime GraphicsAPI: type) type { // an underline, in which case use a double underline to // distinguish them. const underline: terminal.Attribute.Underline = underline: { - // TODO: renderstate regex links - - if (osc8_links.contains(.{ + if (links.contains(.{ .x = @intCast(x), .y = @intCast(y), })) { diff --git a/src/renderer/link.zig b/src/renderer/link.zig index e16a85a68..8c09a3195 100644 --- a/src/renderer/link.zig +++ b/src/renderer/link.zig @@ -1,4 +1,5 @@ const std = @import("std"); +const assert = std.debug.assert; const Allocator = std.mem.Allocator; const oni = @import("oniguruma"); const configpkg = @import("../config.zig"); @@ -54,354 +55,105 @@ pub const Set = struct { 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_vp_pt: point.Coordinate, - mouse_mods: inputpkg.Mods, - ) !MatchSet { - // Convert the viewport point to a screen point. - const mouse_pin = screen.pages.pin(.{ - .viewport = mouse_vp_pt, - }) orelse return .{}; - - // 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) = .empty; - defer matches.deinit(alloc); - - // If our mouse is over an OSC8 link, then we can skip the regex - // matches below since OSC8 takes priority. - try self.matchSetFromOSC8( - alloc, - &matches, - screen, - mouse_pin, - mouse_mods, - ); - - // If we have no matches then we can try the regex matches. - if (matches.items.len == 0) { - try self.matchSetFromLinks( - alloc, - &matches, - screen, - mouse_pin, - mouse_mods, - ); - } - - return .{ .matches = try matches.toOwnedSlice(alloc) }; - } - - fn matchSetFromOSC8( - self: *const Set, - alloc: Allocator, - matches: *std.ArrayList(terminal.Selection), - screen: *Screen, - mouse_pin: terminal.Pin, - mouse_mods: inputpkg.Mods, - ) !void { - // If the right mods aren't pressed, then we can't match. - if (!mouse_mods.equal(inputpkg.ctrlOrSuper(.{}))) return; - - // Check if the cell the mouse is over is an OSC8 hyperlink - const mouse_cell = mouse_pin.rowAndCell().cell; - if (!mouse_cell.hyperlink) return; - - // Get our hyperlink entry - const page: *terminal.Page = &mouse_pin.node.data; - const link_id = page.lookupHyperlink(mouse_cell) orelse { - log.warn("failed to find hyperlink for cell", .{}); - return; - }; - const link = page.hyperlink_set.get(page.memory, link_id); - - // If our link has an implicit ID (no ID set explicitly via OSC8) - // then we use an alternate matching technique that iterates forward - // and backward until it finds boundaries. - if (link.id == .implicit) { - const uri = link.uri.slice(page.memory); - return try self.matchSetFromOSC8Implicit( - alloc, - matches, - mouse_pin, - uri, - ); - } - - // Go through every row and find matching hyperlinks for the given ID. - // Note the link ID is not the same as the OSC8 ID parameter. But - // we hash hyperlinks by their contents which should achieve the same - // thing so we can use the ID as a key. - var current: ?terminal.Selection = null; - var row_it = screen.pages.getTopLeft(.viewport).rowIterator(.right_down, null); - while (row_it.next()) |row_pin| { - const row = row_pin.rowAndCell().row; - - // If the row doesn't have any hyperlinks then we're done - // building our matching selection. - if (!row.hyperlink) { - if (current) |sel| { - try matches.append(alloc, sel); - current = null; - } - - continue; - } - - // We have hyperlinks, look for our own matching hyperlink. - for (row_pin.cells(.right), 0..) |*cell, x| { - const match = match: { - if (cell.hyperlink) { - if (row_pin.node.data.lookupHyperlink(cell)) |cell_link_id| { - break :match cell_link_id == link_id; - } - } - break :match false; - }; - - // If we have a match, extend our selection or start a new - // selection. - if (match) { - const cell_pin = row_pin.right(x); - if (current) |*sel| { - sel.endPtr().* = cell_pin; - } else { - current = .init( - cell_pin, - cell_pin, - false, - ); - } - - continue; - } - - // No match, if we have a current selection then complete it. - if (current) |sel| { - try matches.append(alloc, sel); - current = null; - } - } - } - } - - /// Match OSC8 links around the mouse pin for an OSC8 link with an - /// implicit ID. This only matches cells with the same URI directly - /// around the mouse pin. - fn matchSetFromOSC8Implicit( - self: *const Set, - alloc: Allocator, - matches: *std.ArrayList(terminal.Selection), - mouse_pin: terminal.Pin, - uri: []const u8, - ) !void { - _ = self; - - // Our selection starts with just our pin. - var sel = terminal.Selection.init(mouse_pin, mouse_pin, false); - - // Expand it to the left. - var it = mouse_pin.cellIterator(.left_up, null); - while (it.next()) |cell_pin| { - const page: *terminal.Page = &cell_pin.node.data; - const rac = cell_pin.rowAndCell(); - const cell = rac.cell; - - // If this cell isn't a hyperlink then we've found a boundary - if (!cell.hyperlink) break; - - const link_id = page.lookupHyperlink(cell) orelse { - log.warn("failed to find hyperlink for cell", .{}); - break; - }; - const link = page.hyperlink_set.get(page.memory, link_id); - - // If this link has an explicit ID then we found a boundary - if (link.id != .implicit) break; - - // If this link has a different URI then we found a boundary - const cell_uri = link.uri.slice(page.memory); - if (!std.mem.eql(u8, uri, cell_uri)) break; - - sel.startPtr().* = cell_pin; - } - - // Expand it to the right - it = mouse_pin.cellIterator(.right_down, null); - while (it.next()) |cell_pin| { - const page: *terminal.Page = &cell_pin.node.data; - const rac = cell_pin.rowAndCell(); - const cell = rac.cell; - - // If this cell isn't a hyperlink then we've found a boundary - if (!cell.hyperlink) break; - - const link_id = page.lookupHyperlink(cell) orelse { - log.warn("failed to find hyperlink for cell", .{}); - break; - }; - const link = page.hyperlink_set.get(page.memory, link_id); - - // If this link has an explicit ID then we found a boundary - if (link.id != .implicit) break; - - // If this link has a different URI then we found a boundary - const cell_uri = link.uri.slice(page.memory); - if (!std.mem.eql(u8, uri, cell_uri)) break; - - sel.endPtr().* = cell_pin; - } - - try matches.append(alloc, sel); - } - /// Fills matches with the matches from regex link matches. - fn matchSetFromLinks( + pub fn renderCellMap( self: *const Set, alloc: Allocator, - matches: *std.ArrayList(terminal.Selection), - screen: *Screen, - mouse_pin: terminal.Pin, + result: *terminal.RenderState.CellSet, + render_state: *const terminal.RenderState, + mouse_viewport: ?point.Coordinate, mouse_mods: inputpkg.Mods, ) !void { - // Iterate over all the visible lines. - var lineIter = screen.lineIterator(screen.pages.pin(.{ - .viewport = .{}, - }) orelse return); - while (lineIter.next()) |line_sel| { - const strmap: terminal.StringMap = strmap: { - var strmap: terminal.StringMap = undefined; - const str = screen.selectionString(alloc, .{ - .sel = line_sel, - .trim = false, - .map = &strmap, - }) catch |err| { - log.warn( - "failed to build string map for link checking err={}", - .{err}, - ); - continue; - }; - alloc.free(str); - break :strmap strmap; - }; - defer strmap.deinit(alloc); + // Fast path, not very likely since we have default links. + if (self.links.len == 0) return; + + // Convert our render state to a string + byte map. + var builder: std.Io.Writer.Allocating = .init(alloc); + defer builder.deinit(); + var map: terminal.RenderState.StringMap = .empty; + defer map.deinit(alloc); + try render_state.string(&builder.writer, .{ + .alloc = alloc, + .map = &map, + }); + + const str = builder.writer.buffered(); + + // Go through each link and see if we have any matches. + for (self.links) |*link| { + // Determine if our highlight conditions are met. We use a + // switch here instead of an if so that we can get a compile + // error if any other conditions are added. + switch (link.highlight) { + .always => {}, + .always_mods => |v| if (!mouse_mods.equal(v)) continue, + + // We check the hover points later. + .hover => if (mouse_viewport == null) continue, + .hover_mods => |v| { + if (mouse_viewport == null) continue; + if (!mouse_mods.equal(v)) continue; + }, + } + + var offset: usize = 0; + while (offset < str.len) { + var region = link.regex.search( + str[offset..], + .{}, + ) catch |err| switch (err) { + error.Mismatch => break, + else => return err, + }; + defer region.deinit(); + + // We have a match! + const offset_start: usize = @intCast(region.starts()[0]); + const offset_end: usize = @intCast(region.ends()[0]); + const start = offset + offset_start; + const end = offset + offset_end; + + // 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. + defer offset = end; - // Go through each link and see if we have any matches. - for (self.links) |link| { - // Determine if our highlight conditions are met. We use a - // switch here instead of an if so that we can get a compile - // error if any other conditions are added. switch (link.highlight) { - .always => {}, - .always_mods => |v| if (!mouse_mods.equal(v)) continue, - inline .hover, .hover_mods => |v, tag| { - if (!line_sel.contains(screen, mouse_pin)) continue; - if (comptime tag == .hover_mods) { - if (!mouse_mods.equal(v)) continue; - } - }, + .always, .always_mods => {}, + .hover, .hover_mods => if (mouse_viewport) |vp| { + for (map.items[start..end]) |pt| { + if (pt.eql(vp)) break; + } else continue; + } else continue, } - 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(); - const sel = match.selection(); - - // If this is a highlight link then we only want to - // include matches that include our hover point. - switch (link.highlight) { - .always, .always_mods => {}, - .hover, - .hover_mods, - => if (!sel.contains(screen, mouse_pin)) continue, - } - - try matches.append(alloc, sel); + // Record the match + for (map.items[start..end]) |pt| { + try result.put(alloc, pt, {}); } } } } }; -/// 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 pin. This is slower than - /// orderedContains but is stateless and more flexible since it doesn't - /// require the points to be in order. - pub fn contains( - self: *MatchSet, - screen: *const Screen, - pin: terminal.Pin, - ) bool { - for (self.matches) |sel| { - if (sel.contains(screen, pin)) return true; - } - - return false; - } - - /// 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, - screen: *const Screen, - pin: terminal.Pin, - ) 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(pin)) { - self.i += 1; - if (self.i >= self.matches.len) return false; - } - - return self.matches[self.i].contains(screen, pin); - } -}; - -test "matchset" { +test "renderCellMap" { const testing = std.testing; const alloc = testing.allocator; - // Initialize our screen - var s = try Screen.init(alloc, .{ .cols = 5, .rows = 5, .max_scrollback = 0 }); + var t: terminal.Terminal = try .init(alloc, .{ + .cols = 5, + .rows = 3, + }); + defer t.deinit(alloc); + + var s = t.vtStream(); defer s.deinit(); - const str = "1ABCD2EFGH\n3IJKL"; - try s.testWriteString(str); + const str = "1ABCD2EFGH\r\n3IJKL"; + try s.nextSlice(str); + + var state: terminal.RenderState = .empty; + defer state.deinit(alloc); + try state.update(alloc, &t); // Get a set var set = try Set.fromConfig(alloc, &.{ @@ -420,46 +172,41 @@ test "matchset" { defer set.deinit(alloc); // Get our matches - var match = try set.matchSet(alloc, &s, .{}, .{}); - defer match.deinit(alloc); - try testing.expectEqual(@as(usize, 2), match.matches.len); - - // Test our matches - try testing.expect(!match.orderedContains(&s, s.pages.pin(.{ .screen = .{ - .x = 0, - .y = 0, - } }).?)); - try testing.expect(match.orderedContains(&s, s.pages.pin(.{ .screen = .{ - .x = 1, - .y = 0, - } }).?)); - try testing.expect(match.orderedContains(&s, s.pages.pin(.{ .screen = .{ - .x = 2, - .y = 0, - } }).?)); - try testing.expect(!match.orderedContains(&s, s.pages.pin(.{ .screen = .{ - .x = 3, - .y = 0, - } }).?)); - try testing.expect(match.orderedContains(&s, s.pages.pin(.{ .screen = .{ - .x = 1, - .y = 1, - } }).?)); - try testing.expect(!match.orderedContains(&s, s.pages.pin(.{ .screen = .{ - .x = 1, - .y = 2, - } }).?)); + var result: terminal.RenderState.CellSet = .empty; + defer result.deinit(alloc); + try set.renderCellMap( + alloc, + &result, + &state, + null, + .{}, + ); + try testing.expect(!result.contains(.{ .x = 0, .y = 0 })); + try testing.expect(result.contains(.{ .x = 1, .y = 0 })); + try testing.expect(result.contains(.{ .x = 2, .y = 0 })); + try testing.expect(!result.contains(.{ .x = 3, .y = 0 })); + try testing.expect(result.contains(.{ .x = 1, .y = 1 })); + try testing.expect(!result.contains(.{ .x = 1, .y = 2 })); } -test "matchset hover links" { +test "renderCellMap hover links" { const testing = std.testing; const alloc = testing.allocator; - // Initialize our screen - var s = try Screen.init(alloc, .{ .cols = 5, .rows = 5, .max_scrollback = 0 }); + var t: terminal.Terminal = try .init(alloc, .{ + .cols = 5, + .rows = 3, + }); + defer t.deinit(alloc); + + var s = t.vtStream(); defer s.deinit(); - const str = "1ABCD2EFGH\n3IJKL"; - try s.testWriteString(str); + const str = "1ABCD2EFGH\r\n3IJKL"; + try s.nextSlice(str); + + var state: terminal.RenderState = .empty; + defer state.deinit(alloc); + try state.update(alloc, &t); // Get a set var set = try Set.fromConfig(alloc, &.{ @@ -479,80 +226,65 @@ test "matchset hover links" { // Not hovering over the first link { - var match = try set.matchSet(alloc, &s, .{}, .{}); - defer match.deinit(alloc); - try testing.expectEqual(@as(usize, 1), match.matches.len); + var result: terminal.RenderState.CellSet = .empty; + defer result.deinit(alloc); + try set.renderCellMap( + alloc, + &result, + &state, + null, + .{}, + ); // Test our matches - try testing.expect(!match.orderedContains(&s, s.pages.pin(.{ .screen = .{ - .x = 0, - .y = 0, - } }).?)); - try testing.expect(!match.orderedContains(&s, s.pages.pin(.{ .screen = .{ - .x = 1, - .y = 0, - } }).?)); - try testing.expect(!match.orderedContains(&s, s.pages.pin(.{ .screen = .{ - .x = 2, - .y = 0, - } }).?)); - try testing.expect(!match.orderedContains(&s, s.pages.pin(.{ .screen = .{ - .x = 3, - .y = 0, - } }).?)); - try testing.expect(match.orderedContains(&s, s.pages.pin(.{ .screen = .{ - .x = 1, - .y = 1, - } }).?)); - try testing.expect(!match.orderedContains(&s, s.pages.pin(.{ .screen = .{ - .x = 1, - .y = 2, - } }).?)); + try testing.expect(!result.contains(.{ .x = 0, .y = 0 })); + try testing.expect(!result.contains(.{ .x = 1, .y = 0 })); + try testing.expect(!result.contains(.{ .x = 2, .y = 0 })); + try testing.expect(!result.contains(.{ .x = 3, .y = 0 })); + try testing.expect(result.contains(.{ .x = 1, .y = 1 })); + try testing.expect(!result.contains(.{ .x = 1, .y = 2 })); } // Hovering over the first link { - var match = try set.matchSet(alloc, &s, .{ .x = 1, .y = 0 }, .{}); - defer match.deinit(alloc); - try testing.expectEqual(@as(usize, 2), match.matches.len); + var result: terminal.RenderState.CellSet = .empty; + defer result.deinit(alloc); + try set.renderCellMap( + alloc, + &result, + &state, + .{ .x = 1, .y = 0 }, + .{}, + ); // Test our matches - try testing.expect(!match.orderedContains(&s, s.pages.pin(.{ .screen = .{ - .x = 0, - .y = 0, - } }).?)); - try testing.expect(match.orderedContains(&s, s.pages.pin(.{ .screen = .{ - .x = 1, - .y = 0, - } }).?)); - try testing.expect(match.orderedContains(&s, s.pages.pin(.{ .screen = .{ - .x = 2, - .y = 0, - } }).?)); - try testing.expect(!match.orderedContains(&s, s.pages.pin(.{ .screen = .{ - .x = 3, - .y = 0, - } }).?)); - try testing.expect(match.orderedContains(&s, s.pages.pin(.{ .screen = .{ - .x = 1, - .y = 1, - } }).?)); - try testing.expect(!match.orderedContains(&s, s.pages.pin(.{ .screen = .{ - .x = 1, - .y = 2, - } }).?)); + try testing.expect(!result.contains(.{ .x = 0, .y = 0 })); + try testing.expect(result.contains(.{ .x = 1, .y = 0 })); + try testing.expect(result.contains(.{ .x = 2, .y = 0 })); + try testing.expect(!result.contains(.{ .x = 3, .y = 0 })); + try testing.expect(result.contains(.{ .x = 1, .y = 1 })); + try testing.expect(!result.contains(.{ .x = 1, .y = 2 })); } } -test "matchset mods no match" { +test "renderCellMap mods no match" { const testing = std.testing; const alloc = testing.allocator; - // Initialize our screen - var s = try Screen.init(alloc, .{ .cols = 5, .rows = 5, .max_scrollback = 0 }); + var t: terminal.Terminal = try .init(alloc, .{ + .cols = 5, + .rows = 3, + }); + defer t.deinit(alloc); + + var s = t.vtStream(); defer s.deinit(); - const str = "1ABCD2EFGH\n3IJKL"; - try s.testWriteString(str); + const str = "1ABCD2EFGH\r\n3IJKL"; + try s.nextSlice(str); + + var state: terminal.RenderState = .empty; + defer state.deinit(alloc); + try state.update(alloc, &t); // Get a set var set = try Set.fromConfig(alloc, &.{ @@ -571,96 +303,21 @@ test "matchset mods no match" { defer set.deinit(alloc); // Get our matches - var match = try set.matchSet(alloc, &s, .{}, .{}); - defer match.deinit(alloc); - try testing.expectEqual(@as(usize, 1), match.matches.len); - - // Test our matches - try testing.expect(!match.orderedContains(&s, s.pages.pin(.{ .screen = .{ - .x = 0, - .y = 0, - } }).?)); - try testing.expect(match.orderedContains(&s, s.pages.pin(.{ .screen = .{ - .x = 1, - .y = 0, - } }).?)); - try testing.expect(match.orderedContains(&s, s.pages.pin(.{ .screen = .{ - .x = 2, - .y = 0, - } }).?)); - try testing.expect(!match.orderedContains(&s, s.pages.pin(.{ .screen = .{ - .x = 3, - .y = 0, - } }).?)); - try testing.expect(!match.orderedContains(&s, s.pages.pin(.{ .screen = .{ - .x = 1, - .y = 1, - } }).?)); - try testing.expect(!match.orderedContains(&s, s.pages.pin(.{ .screen = .{ - .x = 1, - .y = 2, - } }).?)); -} - -test "matchset osc8" { - const testing = std.testing; - const alloc = testing.allocator; - - // Initialize our terminal - var t = try Terminal.init(alloc, .{ .cols = 10, .rows = 10 }); - defer t.deinit(alloc); - const s: *terminal.Screen = t.screens.active; - - try t.printString("ABC"); - try t.screens.active.startHyperlink("http://example.com", null); - try t.printString("123"); - t.screens.active.endHyperlink(); - - // Get a set - var set = try Set.fromConfig(alloc, &.{}); - defer set.deinit(alloc); - - // No matches over the non-link - { - var match = try set.matchSet( - alloc, - t.screens.active, - .{ .x = 2, .y = 0 }, - inputpkg.ctrlOrSuper(.{}), - ); - defer match.deinit(alloc); - try testing.expectEqual(@as(usize, 0), match.matches.len); - } - - // Match over link - var match = try set.matchSet( + var result: terminal.RenderState.CellSet = .empty; + defer result.deinit(alloc); + try set.renderCellMap( alloc, - t.screens.active, - .{ .x = 3, .y = 0 }, - inputpkg.ctrlOrSuper(.{}), + &result, + &state, + null, + .{}, ); - defer match.deinit(alloc); - try testing.expectEqual(@as(usize, 1), match.matches.len); // Test our matches - try testing.expect(!match.orderedContains(s, s.pages.pin(.{ .screen = .{ - .x = 2, - .y = 0, - } }).?)); - try testing.expect(match.orderedContains(s, s.pages.pin(.{ .screen = .{ - .x = 3, - .y = 0, - } }).?)); - try testing.expect(match.orderedContains(s, s.pages.pin(.{ .screen = .{ - .x = 4, - .y = 0, - } }).?)); - try testing.expect(match.orderedContains(s, s.pages.pin(.{ .screen = .{ - .x = 5, - .y = 0, - } }).?)); - try testing.expect(!match.orderedContains(s, s.pages.pin(.{ .screen = .{ - .x = 6, - .y = 0, - } }).?)); + try testing.expect(!result.contains(.{ .x = 0, .y = 0 })); + try testing.expect(result.contains(.{ .x = 1, .y = 0 })); + try testing.expect(result.contains(.{ .x = 2, .y = 0 })); + try testing.expect(!result.contains(.{ .x = 3, .y = 0 })); + try testing.expect(!result.contains(.{ .x = 1, .y = 1 })); + try testing.expect(!result.contains(.{ .x = 1, .y = 2 })); } diff --git a/src/terminal/render.zig b/src/terminal/render.zig index 381fbf12f..bdc4693b1 100644 --- a/src/terminal/render.zig +++ b/src/terminal/render.zig @@ -18,6 +18,7 @@ const Terminal = @import("Terminal.zig"); // - tests for dirty state // - tests for colors // - tests for linkCells +// - tests for string // Developer note: this is in src/terminal and not src/renderer because // the goal is that this remains generic to multiple renderers. This can @@ -329,7 +330,7 @@ pub const RenderState = struct { const row_data = self.row_data.slice(); const row_arenas = row_data.items(.arena); const row_pins = row_data.items(.pin); - const row_raws = row_data.items(.raw); + const row_rows = row_data.items(.raw); const row_cells = row_data.items(.cells); const row_dirties = row_data.items(.dirty); @@ -416,7 +417,7 @@ pub const RenderState = struct { assert(page_cells.len == self.cols); // Copy our raw row data - row_raws[y] = page_rac.row.*; + row_rows[y] = page_rac.row.*; // Note: our cells MultiArrayList uses our general allocator. // We do this on purpose because as rows become dirty, we do @@ -516,6 +517,65 @@ pub const RenderState = struct { s.dirty = .{}; } + pub const StringMap = std.ArrayListUnmanaged(point.Coordinate); + + /// Convert the current render state contents to a UTF-8 encoded + /// string written to the given writer. This will unwrap all the wrapped + /// rows. This is useful for a minimal viewport search. + /// + /// NOTE: There is a limitation in that wrapped lines before/after + /// the the top/bottom line of the viewport are not inluded, since + /// the render state cuts them off. + pub fn string( + self: *const RenderState, + writer: *std.Io.Writer, + map: ?struct { + alloc: Allocator, + map: *StringMap, + }, + ) (Allocator.Error || std.Io.Writer.Error)!void { + const row_slice = self.row_data.slice(); + const row_rows = row_slice.items(.raw); + const row_cells = row_slice.items(.cells); + + for ( + 0.., + row_rows, + row_cells, + ) |y, row, cells| { + const cells_slice = cells.slice(); + for ( + 0.., + cells_slice.items(.raw), + cells_slice.items(.grapheme), + ) |x, cell, graphemes| { + var len: usize = std.unicode.utf8CodepointSequenceLength(cell.codepoint()) catch + return error.WriteFailed; + try writer.print("{u}", .{cell.codepoint()}); + if (cell.hasGrapheme()) { + for (graphemes) |cp| { + len += std.unicode.utf8CodepointSequenceLength(cp) catch + return error.WriteFailed; + try writer.print("{u}", .{cp}); + } + } + + if (map) |m| try m.map.appendNTimes(m.alloc, .{ + .x = @intCast(x), + .y = @intCast(y), + }, len); + } + + if (!row.wrap) { + try writer.writeAll("\n"); + if (map) |m| try m.map.append(m.alloc, .{ + .x = @intCast(cells_slice.len), + .y = @intCast(y), + }); + } + } + } + /// A set of coordinates representing cells. pub const CellSet = std.AutoArrayHashMapUnmanaged(point.Coordinate, void);