mirror of
https://github.com/ghostty-org/ghostty.git
synced 2025-07-15 16:26:08 +03:00
terminal: hyperlink start/end on screen
This commit is contained in:
@ -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");
|
||||
|
||||
|
@ -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);
|
||||
}
|
||||
},
|
||||
);
|
||||
|
@ -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");
|
||||
}
|
||||
|
@ -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);
|
||||
|
Reference in New Issue
Block a user