mirror of
https://github.com/ghostty-org/ghostty.git
synced 2025-07-16 00:36:07 +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 point = @import("point.zig");
|
||||||
const size = @import("size.zig");
|
const size = @import("size.zig");
|
||||||
const style = @import("style.zig");
|
const style = @import("style.zig");
|
||||||
|
const hyperlink = @import("hyperlink.zig");
|
||||||
|
const Offset = size.Offset;
|
||||||
const Page = pagepkg.Page;
|
const Page = pagepkg.Page;
|
||||||
const Row = pagepkg.Row;
|
const Row = pagepkg.Row;
|
||||||
const Cell = pagepkg.Cell;
|
const Cell = pagepkg.Cell;
|
||||||
@ -101,11 +103,33 @@ pub const Cursor = struct {
|
|||||||
/// our style when used.
|
/// our style when used.
|
||||||
style_id: style.Id = style.default_id,
|
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
|
/// The pointers into the page list where the cursor is currently
|
||||||
/// located. This makes it faster to move the cursor.
|
/// located. This makes it faster to move the cursor.
|
||||||
page_pin: *PageList.Pin,
|
page_pin: *PageList.Pin,
|
||||||
page_row: *pagepkg.Row,
|
page_row: *pagepkg.Row,
|
||||||
page_cell: *pagepkg.Cell,
|
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
|
/// 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);
|
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.
|
/// Initialize a new screen.
|
||||||
///
|
///
|
||||||
/// max_scrollback is the amount of scrollback to keep in bytes. This
|
/// 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 {
|
pub fn deinit(self: *Screen) void {
|
||||||
self.kitty_images.deinit(self.alloc, self);
|
self.kitty_images.deinit(self.alloc, self);
|
||||||
|
self.cursor.deinit(self.alloc);
|
||||||
self.pages.deinit();
|
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
|
/// - Cursor location can be expensive to calculate with respect to the
|
||||||
/// specified region. It is faster to grab the cursor from the old
|
/// specified region. It is faster to grab the cursor from the old
|
||||||
/// screen and then move it to the new screen.
|
/// 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
|
/// 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
|
/// 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
|
/// 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 take overnship of the selection. If this is untracked
|
||||||
/// then the screen will convert it to tracked internally. This will automatically
|
/// 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);
|
// 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" {
|
test "Screen: adjustCapacity cursor style ref count" {
|
||||||
const testing = std.testing;
|
const testing = std.testing;
|
||||||
const alloc = testing.allocator;
|
const alloc = testing.allocator;
|
||||||
|
|
||||||
var s = try init(alloc, 5, 5, 0);
|
var s = try init(alloc, 5, 5, 0);
|
||||||
defer s.deinit();
|
defer s.deinit();
|
||||||
|
|
||||||
try s.setAttribute(.{ .bold = {} });
|
try s.setAttribute(.{ .bold = {} });
|
||||||
try s.testWriteString("1ABCD");
|
try s.testWriteString("1ABCD");
|
||||||
|
|
||||||
|
@ -2,10 +2,15 @@ const std = @import("std");
|
|||||||
const assert = std.debug.assert;
|
const assert = std.debug.assert;
|
||||||
const hash_map = @import("hash_map.zig");
|
const hash_map = @import("hash_map.zig");
|
||||||
const AutoOffsetHashMap = hash_map.AutoOffsetHashMap;
|
const AutoOffsetHashMap = hash_map.AutoOffsetHashMap;
|
||||||
|
const pagepkg = @import("page.zig");
|
||||||
const size = @import("size.zig");
|
const size = @import("size.zig");
|
||||||
const Offset = size.Offset;
|
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 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
|
/// The unique identifier for a hyperlink. This is at most the number of cells
|
||||||
/// that can fit in a single terminal page.
|
/// that can fit in a single terminal page.
|
||||||
@ -18,17 +23,58 @@ pub const Map = AutoOffsetHashMap(Offset(Cell), Id);
|
|||||||
|
|
||||||
/// The main entry for hyperlinks.
|
/// The main entry for hyperlinks.
|
||||||
pub const Hyperlink = struct {
|
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.
|
/// An explicitly provided ID via the OSC8 sequence.
|
||||||
explicit: Offset(u8).Slice,
|
explicit: Offset(u8).Slice,
|
||||||
|
|
||||||
/// No ID was provided so we auto-generate the ID based on an
|
/// 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,
|
implicit: size.OffsetInt,
|
||||||
},
|
};
|
||||||
|
|
||||||
/// The URI for the actual link.
|
pub fn hash(self: *const Hyperlink, base: anytype) u64 {
|
||||||
uri: Offset(u8).Slice,
|
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
|
/// The set of hyperlinks. This is ref-counted so that a set of cells
|
||||||
@ -38,14 +84,14 @@ pub const Set = RefCountedSet(
|
|||||||
Id,
|
Id,
|
||||||
size.CellCountInt,
|
size.CellCountInt,
|
||||||
struct {
|
struct {
|
||||||
|
page: ?*Page = null,
|
||||||
|
|
||||||
pub fn hash(self: *const @This(), link: Hyperlink) u64 {
|
pub fn hash(self: *const @This(), link: Hyperlink) u64 {
|
||||||
_ = self;
|
return link.hash(self.page.?.memory);
|
||||||
return link.hash();
|
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn eql(self: *const @This(), a: Hyperlink, b: Hyperlink) bool {
|
pub fn eql(self: *const @This(), a: Hyperlink, b: Hyperlink) bool {
|
||||||
_ = self;
|
return a.eql(self.page.?.memory, &b);
|
||||||
return a.eql(b);
|
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
|
@ -6,6 +6,7 @@ const charsets = @import("charsets.zig");
|
|||||||
const stream = @import("stream.zig");
|
const stream = @import("stream.zig");
|
||||||
const ansi = @import("ansi.zig");
|
const ansi = @import("ansi.zig");
|
||||||
const csi = @import("csi.zig");
|
const csi = @import("csi.zig");
|
||||||
|
const hyperlink = @import("hyperlink.zig");
|
||||||
const sgr = @import("sgr.zig");
|
const sgr = @import("sgr.zig");
|
||||||
const style = @import("style.zig");
|
const style = @import("style.zig");
|
||||||
pub const apc = @import("apc.zig");
|
pub const apc = @import("apc.zig");
|
||||||
@ -60,5 +61,6 @@ test {
|
|||||||
// Internals
|
// Internals
|
||||||
_ = @import("bitmap_allocator.zig");
|
_ = @import("bitmap_allocator.zig");
|
||||||
_ = @import("hash_map.zig");
|
_ = @import("hash_map.zig");
|
||||||
|
_ = @import("ref_counted_set.zig");
|
||||||
_ = @import("size.zig");
|
_ = @import("size.zig");
|
||||||
}
|
}
|
||||||
|
@ -476,6 +476,8 @@ pub fn RefCountedSet(
|
|||||||
/// is ignored and the existing item's ID is returned.
|
/// is ignored and the existing item's ID is returned.
|
||||||
fn upsert(self: *Self, base: anytype, value: T, new_id: Id, ctx: Context) Id {
|
fn upsert(self: *Self, base: anytype, value: T, new_id: Id, ctx: Context) Id {
|
||||||
// If the item already exists, return it.
|
// 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;
|
if (self.lookup(base, value, ctx)) |id| return id;
|
||||||
|
|
||||||
const table = self.table.ptr(base);
|
const table = self.table.ptr(base);
|
||||||
|
Reference in New Issue
Block a user