From d1f41e2035bee4b22b4663edd49bd71e5fabfa1f Mon Sep 17 00:00:00 2001 From: Mitchell Hashimoto Date: Wed, 3 Jul 2024 18:59:50 -0700 Subject: [PATCH] terminal: hyperlink start/end on screen --- src/terminal/Screen.zig | 179 +++++++++++++++++++++++++++++++ src/terminal/hyperlink.zig | 66 ++++++++++-- src/terminal/main.zig | 2 + src/terminal/ref_counted_set.zig | 2 + 4 files changed, 239 insertions(+), 10 deletions(-) diff --git a/src/terminal/Screen.zig b/src/terminal/Screen.zig index 02bb9c9b6..53a4fa47a 100644 --- a/src/terminal/Screen.zig +++ b/src/terminal/Screen.zig @@ -15,6 +15,8 @@ const pagepkg = @import("page.zig"); const point = @import("point.zig"); const size = @import("size.zig"); const style = @import("style.zig"); +const hyperlink = @import("hyperlink.zig"); +const Offset = size.Offset; const Page = pagepkg.Page; const Row = pagepkg.Row; const Cell = pagepkg.Cell; @@ -101,11 +103,33 @@ pub const Cursor = struct { /// our style when used. style_id: style.Id = style.default_id, + /// The hyperlink ID that is currently active for the cursor. A value + /// of zero means no hyperlink is active. (Implements OSC8, saying that + /// so code search can find it.). + hyperlink_id: hyperlink.Id = 0, + + /// This is the implicit ID to use for hyperlinks that don't specify + /// an ID. We do an overflowing add to this so repeats can technically + /// happen with carefully crafted inputs but for real workloads its + /// highly unlikely -- and the fix is for the TUI program to use explicit + /// IDs. + hyperlink_implicit_id: size.OffsetInt = 0, + + /// Heap-allocated hyperlink state so that we can recreate it when + /// the cursor page pin changes. We can't get it from the old screen + /// state because the page may be cleared. This is heap allocated + /// because its most likely null. + hyperlink: ?*Hyperlink = null, + /// The pointers into the page list where the cursor is currently /// located. This makes it faster to move the cursor. page_pin: *PageList.Pin, page_row: *pagepkg.Row, page_cell: *pagepkg.Cell, + + pub fn deinit(self: *Cursor, alloc: Allocator) void { + if (self.hyperlink) |link| link.destroy(alloc); + } }; /// The visual style of the cursor. Whether or not it blinks @@ -141,6 +165,31 @@ pub const CharsetState = struct { const CharsetArray = std.EnumArray(charsets.Slots, charsets.Charset); }; +pub const Hyperlink = struct { + id: ?[]const u8, + uri: []const u8, + + pub fn create( + alloc: Allocator, + uri: []const u8, + id: ?[]const u8, + ) !*Hyperlink { + const self = try alloc.create(Hyperlink); + errdefer alloc.destroy(self); + self.id = if (id) |v| try alloc.dupe(u8, v) else null; + errdefer if (self.id) |v| alloc.free(v); + self.uri = try alloc.dupe(u8, uri); + errdefer alloc.free(self.uri); + return self; + } + + pub fn destroy(self: *Hyperlink, alloc: Allocator) void { + if (self.id) |id| alloc.free(id); + alloc.free(self.uri); + alloc.destroy(self); + } +}; + /// Initialize a new screen. /// /// max_scrollback is the amount of scrollback to keep in bytes. This @@ -179,6 +228,7 @@ pub fn init( pub fn deinit(self: *Screen) void { self.kitty_images.deinit(self.alloc, self); + self.cursor.deinit(self.alloc); self.pages.deinit(); } @@ -220,6 +270,9 @@ pub fn assertIntegrity(self: *const Screen) void { /// - Cursor location can be expensive to calculate with respect to the /// specified region. It is faster to grab the cursor from the old /// screen and then move it to the new screen. +/// - Current hyperlink cursor state has heap allocations. Since clone +/// is only for read-only operations, it is better to not have any +/// hyperlink state. Note that already-written hyperlinks are cloned. /// /// If not mentioned above, then there isn't a specific reason right now /// to not copy some data other than we probably didn't need it and it @@ -1313,6 +1366,104 @@ pub fn appendGrapheme(self: *Screen, cell: *Cell, cp: u21) !void { }; } +/// Start the hyperlink state. Future cells will be marked as hyperlinks with +/// this state. Note that various terminal operations may clear the hyperlink +/// state, such as switching screens (alt screen). +pub fn startHyperlink( + self: *Screen, + uri: []const u8, + id_: ?[]const u8, +) !void { + // End any prior hyperlink + self.endHyperlink(); + + // Create our hyperlink state. + const link = try Hyperlink.create(self.alloc, uri, id_); + errdefer link.destroy(self.alloc); + + // TODO: look for previous hyperlink that matches this. + + // Copy our URI into the page memory. + var page = &self.cursor.page_pin.page.data; + const string_alloc = &page.string_alloc; + const page_uri: Offset(u8).Slice = uri: { + const buf = try string_alloc.alloc(u8, page.memory, uri.len); + errdefer string_alloc.free(page.memory, buf); + @memcpy(buf, uri); + + break :uri .{ + .offset = size.getOffset(u8, page.memory, &buf[0]), + .len = uri.len, + }; + }; + errdefer string_alloc.free( + page.memory, + page_uri.offset.ptr(page.memory)[0..page_uri.len], + ); + + // Copy our ID into page memory or create an implicit ID via the counter + const page_id: hyperlink.Hyperlink.Id = if (id_) |id| explicit: { + const buf = try string_alloc.alloc(u8, page.memory, id.len); + errdefer string_alloc.free(page.memory, buf); + @memcpy(buf, id); + + break :explicit .{ + .explicit = .{ + .offset = size.getOffset(u8, page.memory, &buf[0]), + .len = id.len, + }, + }; + } else implicit: { + defer self.cursor.hyperlink_implicit_id += 1; + break :implicit .{ .implicit = self.cursor.hyperlink_implicit_id }; + }; + errdefer switch (page_id) { + .implicit => self.cursor.hyperlink_implicit_id -= 1, + .explicit => |slice| string_alloc.free( + page.memory, + slice.offset.ptr(page.memory)[0..slice.len], + ), + }; + + // Put our hyperlink into the hyperlink set to get an ID + const id = try page.hyperlink_set.addContext( + page.memory, + .{ .id = page_id, .uri = page_uri }, + .{ .page = page }, + ); + errdefer page.hyperlink_set.release(page.memory, id); + + // Save it all + self.cursor.hyperlink = link; + self.cursor.hyperlink_id = id; +} + +/// End the hyperlink state so that future cells aren't part of the +/// current hyperlink (if any). This is safe to call multiple times. +pub fn endHyperlink(self: *Screen) void { + // If we have no hyperlink state then do nothing + if (self.cursor.hyperlink_id == 0) { + assert(self.cursor.hyperlink == null); + return; + } + + // Release the old hyperlink state. If there are cells using the + // hyperlink this will work because the creation creates a reference + // and all additional cells create a new reference. This release will + // just release our initial reference. + // + // If the ref count reaches zero the set will not delete the item + // immediately; it is kept around in case it is used again (this is + // how RefCountedSet works). This causes some memory fragmentation but + // is fine because if it is ever pruned the context deleted callback + // will be called. + var page = &self.cursor.page_pin.page.data; + page.hyperlink_set.release(page.memory, self.cursor.hyperlink_id); + self.cursor.hyperlink.?.destroy(self.alloc); + self.cursor.hyperlink_id = 0; + self.cursor.hyperlink = null; +} + /// Set the selection to the given selection. If this is a tracked selection /// then the screen will take overnship of the selection. If this is untracked /// then the screen will convert it to tracked internally. This will automatically @@ -7261,12 +7412,40 @@ test "Screen: lineIterator soft wrap" { // try testing.expect(iter.next() == null); } +test "Screen: hyperlink start/end" { + const testing = std.testing; + const alloc = testing.allocator; + + var s = try init(alloc, 5, 5, 0); + defer s.deinit(); + try testing.expect(s.cursor.hyperlink_id == 0); + { + const page = &s.cursor.page_pin.page.data; + try testing.expectEqual(0, page.hyperlink_set.count()); + } + + try s.startHyperlink("http://example.com", null); + try testing.expect(s.cursor.hyperlink_id != 0); + { + const page = &s.cursor.page_pin.page.data; + try testing.expectEqual(1, page.hyperlink_set.count()); + } + + s.endHyperlink(); + try testing.expect(s.cursor.hyperlink_id == 0); + { + const page = &s.cursor.page_pin.page.data; + try testing.expectEqual(0, page.hyperlink_set.count()); + } +} + test "Screen: adjustCapacity cursor style ref count" { const testing = std.testing; const alloc = testing.allocator; var s = try init(alloc, 5, 5, 0); defer s.deinit(); + try s.setAttribute(.{ .bold = {} }); try s.testWriteString("1ABCD"); diff --git a/src/terminal/hyperlink.zig b/src/terminal/hyperlink.zig index d7e59914a..210c1a5da 100644 --- a/src/terminal/hyperlink.zig +++ b/src/terminal/hyperlink.zig @@ -2,10 +2,15 @@ const std = @import("std"); const assert = std.debug.assert; const hash_map = @import("hash_map.zig"); const AutoOffsetHashMap = hash_map.AutoOffsetHashMap; +const pagepkg = @import("page.zig"); const size = @import("size.zig"); const Offset = size.Offset; -const Cell = @import("page.zig").Cell; +const Cell = pagepkg.Cell; +const Page = pagepkg.Page; const RefCountedSet = @import("ref_counted_set.zig").RefCountedSet; +const Wyhash = std.hash.Wyhash; +const autoHash = std.hash.autoHash; +const autoHashStrat = std.hash.autoHashStrat; /// The unique identifier for a hyperlink. This is at most the number of cells /// that can fit in a single terminal page. @@ -18,17 +23,58 @@ pub const Map = AutoOffsetHashMap(Offset(Cell), Id); /// The main entry for hyperlinks. pub const Hyperlink = struct { - id: union(enum) { + id: Hyperlink.Id, + uri: Offset(u8).Slice, + + pub const Id = union(enum) { /// An explicitly provided ID via the OSC8 sequence. explicit: Offset(u8).Slice, /// No ID was provided so we auto-generate the ID based on an - /// incrementing counter. TODO: implement the counter + /// incrementing counter attached to the screen. implicit: size.OffsetInt, - }, + }; - /// The URI for the actual link. - uri: Offset(u8).Slice, + pub fn hash(self: *const Hyperlink, base: anytype) u64 { + var hasher = Wyhash.init(0); + autoHash(&hasher, std.meta.activeTag(self.id)); + switch (self.id) { + .implicit => |v| autoHash(&hasher, v), + .explicit => |slice| autoHashStrat( + &hasher, + slice.offset.ptr(base)[0..slice.len], + .Deep, + ), + } + autoHashStrat( + &hasher, + self.uri.offset.ptr(base)[0..self.uri.len], + .Deep, + ); + return hasher.final(); + } + + pub fn eql(self: *const Hyperlink, base: anytype, other: *const Hyperlink) bool { + if (std.meta.activeTag(self.id) != std.meta.activeTag(other.id)) return false; + switch (self.id) { + .implicit => if (self.id.implicit != other.id.implicit) return false, + .explicit => { + const self_ptr = self.id.explicit.offset.ptr(base); + const other_ptr = other.id.explicit.offset.ptr(base); + if (!std.mem.eql( + u8, + self_ptr[0..self.id.explicit.len], + other_ptr[0..other.id.explicit.len], + )) return false; + }, + } + + return std.mem.eql( + u8, + self.uri.offset.ptr(base)[0..self.uri.len], + other.uri.offset.ptr(base)[0..other.uri.len], + ); + } }; /// The set of hyperlinks. This is ref-counted so that a set of cells @@ -38,14 +84,14 @@ pub const Set = RefCountedSet( Id, size.CellCountInt, struct { + page: ?*Page = null, + pub fn hash(self: *const @This(), link: Hyperlink) u64 { - _ = self; - return link.hash(); + return link.hash(self.page.?.memory); } pub fn eql(self: *const @This(), a: Hyperlink, b: Hyperlink) bool { - _ = self; - return a.eql(b); + return a.eql(self.page.?.memory, &b); } }, ); diff --git a/src/terminal/main.zig b/src/terminal/main.zig index 857dd79f3..8807921ff 100644 --- a/src/terminal/main.zig +++ b/src/terminal/main.zig @@ -6,6 +6,7 @@ const charsets = @import("charsets.zig"); const stream = @import("stream.zig"); const ansi = @import("ansi.zig"); const csi = @import("csi.zig"); +const hyperlink = @import("hyperlink.zig"); const sgr = @import("sgr.zig"); const style = @import("style.zig"); pub const apc = @import("apc.zig"); @@ -60,5 +61,6 @@ test { // Internals _ = @import("bitmap_allocator.zig"); _ = @import("hash_map.zig"); + _ = @import("ref_counted_set.zig"); _ = @import("size.zig"); } diff --git a/src/terminal/ref_counted_set.zig b/src/terminal/ref_counted_set.zig index aaaab890a..ba1e0afd1 100644 --- a/src/terminal/ref_counted_set.zig +++ b/src/terminal/ref_counted_set.zig @@ -476,6 +476,8 @@ pub fn RefCountedSet( /// is ignored and the existing item's ID is returned. fn upsert(self: *Self, base: anytype, value: T, new_id: Id, ctx: Context) Id { // If the item already exists, return it. + // TODO: we should probably call deleted here on value since + // we're using the value already in the map if (self.lookup(base, value, ctx)) |id| return id; const table = self.table.ptr(base);