terminal: hyperlink start/end on screen

This commit is contained in:
Mitchell Hashimoto
2024-07-03 18:59:50 -07:00
parent 51c05aeb99
commit d1f41e2035
4 changed files with 239 additions and 10 deletions

View File

@ -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");

View File

@ -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);
}
},
);

View File

@ -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");
}

View File

@ -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);