From 58173c9df5e58b40dd0572607623f199bc9e08d0 Mon Sep 17 00:00:00 2001 From: Mitchell Hashimoto Date: Sun, 9 Jun 2024 10:20:18 -0700 Subject: [PATCH 01/58] terminal: parse osc 8 hyperlink_start --- src/terminal/osc.zig | 154 ++++++++++++++++++++++++++++++++++++++++ src/terminal/stream.zig | 5 ++ 2 files changed, 159 insertions(+) diff --git a/src/terminal/osc.zig b/src/terminal/osc.zig index a220ea031..a6edc9c65 100644 --- a/src/terminal/osc.zig +++ b/src/terminal/osc.zig @@ -133,6 +133,12 @@ pub const Command = union(enum) { body: []const u8, }, + /// Start a hyperlink (OSC 8) + hyperlink_start: struct { + id: ?[]const u8 = null, + uri: []const u8, + }, + pub const ColorKind = union(enum) { palette: u8, foreground, @@ -239,6 +245,7 @@ pub const Parser = struct { @"7", @"77", @"777", + @"8", @"9", // OSC 10 is used to query or set the current foreground color. @@ -267,6 +274,10 @@ pub const Parser = struct { color_palette_index, color_palette_index_end, + // Hyperlinks + hyperlink_param_key, + hyperlink_param_value, + // Reset color palette index reset_color_palette_index, @@ -333,6 +344,7 @@ pub const Parser = struct { '4' => self.state = .@"4", '5' => self.state = .@"5", '7' => self.state = .@"7", + '8' => self.state = .@"8", '9' => self.state = .@"9", else => self.state = .invalid, }, @@ -556,6 +568,47 @@ pub const Parser = struct { else => self.state = .invalid, }, + .@"8" => switch (c) { + ';' => { + self.command = .{ .hyperlink_start = .{ + .uri = "", + } }; + + self.state = .hyperlink_param_key; + self.buf_start = self.buf_idx; + }, + else => self.state = .invalid, + }, + + .hyperlink_param_key => switch (c) { + ';' => { + self.state = .string; + self.temp_state = .{ .str = &self.command.hyperlink_start.uri }; + self.buf_start = self.buf_idx; + }, + '=' => { + self.temp_state = .{ .key = self.buf[self.buf_start .. self.buf_idx - 1] }; + self.state = .hyperlink_param_value; + self.buf_start = self.buf_idx; + }, + else => {}, + }, + + .hyperlink_param_value => switch (c) { + ':' => { + self.endHyperlinkOptionValue(); + self.state = .hyperlink_param_key; + self.buf_start = self.buf_idx; + }, + ';' => { + self.endHyperlinkOptionValue(); + self.state = .string; + self.temp_state = .{ .str = &self.command.hyperlink_start.uri }; + self.buf_start = self.buf_idx; + }, + else => {}, + }, + .rxvt_extension => switch (c) { 'a'...'z' => {}, ';' => { @@ -772,6 +825,24 @@ pub const Parser = struct { self.state = .allocable_string; } + fn endHyperlinkOptionValue(self: *Parser) void { + const value = if (self.buf_start == self.buf_idx) + "" + else + self.buf[self.buf_start .. self.buf_idx - 1]; + + if (mem.eql(u8, self.temp_state.key, "id")) { + switch (self.command) { + .hyperlink_start => |*v| { + // We treat empty IDs as null ids so that we can + // auto-assign. + if (value.len > 0) v.id = value; + }, + else => {}, + } + } else log.info("unknown hyperlink option: {s}", .{self.temp_state.key}); + } + fn endSemanticOptionValue(self: *Parser) void { const value = self.buf[self.buf_start..self.buf_idx]; @@ -1272,3 +1343,86 @@ test "OSC: empty param" { const cmd = p.end('\x1b'); try testing.expect(cmd == null); } + +test "OSC: hyperlink" { + const testing = std.testing; + + var p: Parser = .{}; + + const input = "8;;http://example.com"; + for (input) |ch| p.next(ch); + + const cmd = p.end('\x1b').?; + try testing.expect(cmd == .hyperlink_start); + try testing.expectEqualStrings(cmd.hyperlink_start.uri, "http://example.com"); +} + +test "OSC: hyperlink with id set" { + const testing = std.testing; + + var p: Parser = .{}; + + const input = "8;id=foo;http://example.com"; + for (input) |ch| p.next(ch); + + const cmd = p.end('\x1b').?; + try testing.expect(cmd == .hyperlink_start); + try testing.expectEqualStrings(cmd.hyperlink_start.id.?, "foo"); + try testing.expectEqualStrings(cmd.hyperlink_start.uri, "http://example.com"); +} + +test "OSC: hyperlink with empty id" { + const testing = std.testing; + + var p: Parser = .{}; + + const input = "8;id=;http://example.com"; + for (input) |ch| p.next(ch); + + const cmd = p.end('\x1b').?; + try testing.expect(cmd == .hyperlink_start); + try testing.expectEqual(null, cmd.hyperlink_start.id); + try testing.expectEqualStrings(cmd.hyperlink_start.uri, "http://example.com"); +} + +test "OSC: hyperlink with incomplete key" { + const testing = std.testing; + + var p: Parser = .{}; + + const input = "8;id;http://example.com"; + for (input) |ch| p.next(ch); + + const cmd = p.end('\x1b').?; + try testing.expect(cmd == .hyperlink_start); + try testing.expectEqual(null, cmd.hyperlink_start.id); + try testing.expectEqualStrings(cmd.hyperlink_start.uri, "http://example.com"); +} + +test "OSC: hyperlink with empty key" { + const testing = std.testing; + + var p: Parser = .{}; + + const input = "8;=value;http://example.com"; + for (input) |ch| p.next(ch); + + const cmd = p.end('\x1b').?; + try testing.expect(cmd == .hyperlink_start); + try testing.expectEqual(null, cmd.hyperlink_start.id); + try testing.expectEqualStrings(cmd.hyperlink_start.uri, "http://example.com"); +} + +test "OSC: hyperlink with empty key and id" { + const testing = std.testing; + + var p: Parser = .{}; + + const input = "8;=value:id=foo;http://example.com"; + for (input) |ch| p.next(ch); + + const cmd = p.end('\x1b').?; + try testing.expect(cmd == .hyperlink_start); + try testing.expectEqualStrings(cmd.hyperlink_start.id.?, "foo"); + try testing.expectEqualStrings(cmd.hyperlink_start.uri, "http://example.com"); +} diff --git a/src/terminal/stream.zig b/src/terminal/stream.zig index 01e027ec2..d5ba3d53d 100644 --- a/src/terminal/stream.zig +++ b/src/terminal/stream.zig @@ -1333,6 +1333,11 @@ pub fn Stream(comptime Handler: type) type { return; } else log.warn("unimplemented OSC callback: {}", .{cmd}); }, + + .hyperlink_start => |v| { + _ = v; + @panic("TODO(osc8)"); + }, } // Fall through for when we don't have a handler. From f8e74a563a6ec39cb529036bd566dce28944c881 Mon Sep 17 00:00:00 2001 From: Mitchell Hashimoto Date: Sun, 9 Jun 2024 10:29:42 -0700 Subject: [PATCH 02/58] terminal: parse osc8 end --- src/terminal/osc.zig | 40 ++++++++++++++++++++++++++++++++++++++-- src/terminal/stream.zig | 4 ++++ 2 files changed, 42 insertions(+), 2 deletions(-) diff --git a/src/terminal/osc.zig b/src/terminal/osc.zig index a6edc9c65..a8d801ef0 100644 --- a/src/terminal/osc.zig +++ b/src/terminal/osc.zig @@ -1,4 +1,5 @@ //! OSC (Operating System Command) related functions and types. OSC is +//! //! another set of control sequences for terminal programs that start with //! "ESC ]". Unlike CSI or standard ESC sequences, they may contain strings //! and other irregular formatting so a dedicated parser is created to handle it. @@ -139,6 +140,9 @@ pub const Command = union(enum) { uri: []const u8, }, + /// End a hyperlink (OSC 8) + hyperlink_end: void, + pub const ColorKind = union(enum) { palette: u8, foreground, @@ -277,6 +281,7 @@ pub const Parser = struct { // Hyperlinks hyperlink_param_key, hyperlink_param_value, + hyperlink_uri, // Reset color palette index reset_color_palette_index, @@ -582,8 +587,8 @@ pub const Parser = struct { .hyperlink_param_key => switch (c) { ';' => { - self.state = .string; - self.temp_state = .{ .str = &self.command.hyperlink_start.uri }; + self.complete = true; + self.state = .hyperlink_uri; self.buf_start = self.buf_idx; }, '=' => { @@ -609,6 +614,8 @@ pub const Parser = struct { else => {}, }, + .hyperlink_uri => {}, + .rxvt_extension => switch (c) { 'a'...'z' => {}, ';' => { @@ -825,6 +832,22 @@ pub const Parser = struct { self.state = .allocable_string; } + fn endHyperlink(self: *Parser) void { + switch (self.command) { + .hyperlink_start => |*v| { + const value = self.buf[self.buf_start..self.buf_idx]; + if (v.id == null and value.len == 0) { + self.command = .{ .hyperlink_end = {} }; + return; + } + + v.uri = value; + }, + + else => unreachable, + } + } + fn endHyperlinkOptionValue(self: *Parser) void { const value = if (self.buf_start == self.buf_idx) "" @@ -922,6 +945,7 @@ pub const Parser = struct { switch (self.state) { .semantic_exit_code => self.endSemanticExitCode(), .semantic_option_value => self.endSemanticOptionValue(), + .hyperlink_uri => self.endHyperlink(), .string => self.endString(), .allocable_string => self.endAllocableString(), else => {}, @@ -1426,3 +1450,15 @@ test "OSC: hyperlink with empty key and id" { try testing.expectEqualStrings(cmd.hyperlink_start.id.?, "foo"); try testing.expectEqualStrings(cmd.hyperlink_start.uri, "http://example.com"); } + +test "OSC: hyperlink end" { + const testing = std.testing; + + var p: Parser = .{}; + + const input = "8;;"; + for (input) |ch| p.next(ch); + + const cmd = p.end('\x1b').?; + try testing.expect(cmd == .hyperlink_end); +} diff --git a/src/terminal/stream.zig b/src/terminal/stream.zig index d5ba3d53d..05d6734ac 100644 --- a/src/terminal/stream.zig +++ b/src/terminal/stream.zig @@ -1338,6 +1338,10 @@ pub fn Stream(comptime Handler: type) type { _ = v; @panic("TODO(osc8)"); }, + + .hyperlink_end => { + @panic("TODO(osc8)"); + }, } // Fall through for when we don't have a handler. From 6c7b7843e97e0843a4870ae95aac2ce8bc3a5b08 Mon Sep 17 00:00:00 2001 From: Mitchell Hashimoto Date: Sun, 9 Jun 2024 10:32:34 -0700 Subject: [PATCH 03/58] terminal: additional parse test cases --- src/terminal/osc.zig | 12 ++++++++++++ 1 file changed, 12 insertions(+) diff --git a/src/terminal/osc.zig b/src/terminal/osc.zig index a8d801ef0..c069e0d4b 100644 --- a/src/terminal/osc.zig +++ b/src/terminal/osc.zig @@ -1451,6 +1451,18 @@ test "OSC: hyperlink with empty key and id" { try testing.expectEqualStrings(cmd.hyperlink_start.uri, "http://example.com"); } +test "OSC: hyperlink with empty uri" { + const testing = std.testing; + + var p: Parser = .{}; + + const input = "8;id=foo;"; + for (input) |ch| p.next(ch); + + const cmd = p.end('\x1b'); + try testing.expect(cmd == null); +} + test "OSC: hyperlink end" { const testing = std.testing; From 25d1e861ec20eb0370a976395a9b1baf72ea564d Mon Sep 17 00:00:00 2001 From: Mitchell Hashimoto Date: Sun, 9 Jun 2024 12:01:55 -0700 Subject: [PATCH 04/58] terminal: page memory layout for uri/hyperlink data --- src/terminal/page.zig | 74 +++++++++++++++++++++++++++++++++++++++++-- 1 file changed, 72 insertions(+), 2 deletions(-) diff --git a/src/terminal/page.zig b/src/terminal/page.zig index 65334460f..deff6709e 100644 --- a/src/terminal/page.zig +++ b/src/terminal/page.zig @@ -34,6 +34,37 @@ const grapheme_count_default = GraphemeAlloc.bitmap_bit_size; const grapheme_bytes_default = grapheme_count_default * grapheme_chunk; const GraphemeMap = AutoOffsetHashMap(Offset(Cell), Offset(u21).Slice); +/// The allocator for URIs (OSC8). We use a chunk size of 32 because +/// URIs are usually pretty long and that let's us represent most +/// bare domains in one chunk. This allocator is ALSO used for OSC8 +/// IDs. These are usually short but the chunk length should be good. +const uri_chunk_len = 32; +const uri_chunk = uri_chunk_len * @sizeOf(u8); +const UriAlloc = BitmapAllocator(uri_chunk); + +/// The hyperlinks counts are shared between URI and hyperlink entries +/// so it is a worst possible byte size. You probably don't need a large +/// value here to accomodate many cells for typical (rare) hyperlink +/// usage. +const hyperlink_count_default = 1; +const hyperlink_bytes_default = hyperlink_count_default * @max( + (uri_chunk * 2), // ID + URI + @sizeOf(HyperlinkEntry), // Entry +); + +/// The hyperlink map and entry. +const HyperlinkMap = AutoOffsetHashMap(Offset(Cell), HyperlinkEntry); +const HyperlinkEntry = struct { + /// The ID for the hyperlink. This is always non-empty. We + /// auto-generate an ID if one is not provided by the user. + /// This is in UriAlloc. + id: Offset(u8).Slice, + + /// The URI for the actual link. + /// This is in UriAlloc. + uri: Offset(u8).Slice, +}; + /// A page represents a specific section of terminal screen. The primary /// idea of a page is that it is a fully self-contained unit that can be /// serialized, copied, etc. as a convenient way to represent a section @@ -88,6 +119,14 @@ pub const Page = struct { /// path. grapheme_map: GraphemeMap, + /// The URI data for hyperlinks (URI + ID). This is relatively rare + /// so this defaults to a very small allocation size. + uri_alloc: UriAlloc, + + /// The mapping of cells to the hyperlink associated with them. This + /// only has a value when a cell has the "hyperlink" flag set. + hyperlink_map: HyperlinkMap, + /// The available set of styles in use on this page. styles: style.Set, @@ -207,6 +246,14 @@ pub const Page = struct { buf.add(l.grapheme_map_start), l.grapheme_map_layout, ), + .uri_alloc = UriAlloc.init( + buf.add(l.uri_alloc_start), + l.uri_alloc_layout, + ), + .hyperlink_map = HyperlinkMap.init( + buf.add(l.hyperlink_map_start), + l.hyperlink_map_layout, + ), .size = .{ .cols = cap.cols, .rows = cap.rows }, .capacity = cap, }; @@ -977,6 +1024,10 @@ pub const Page = struct { grapheme_alloc_layout: GraphemeAlloc.Layout, grapheme_map_start: usize, grapheme_map_layout: GraphemeMap.Layout, + uri_alloc_start: usize, + uri_alloc_layout: UriAlloc.Layout, + hyperlink_map_start: usize, + hyperlink_map_layout: HyperlinkMap.Layout, capacity: Capacity, }; @@ -1015,7 +1066,16 @@ pub const Page = struct { const grapheme_map_start = alignForward(usize, grapheme_alloc_end, GraphemeMap.base_align); const grapheme_map_end = grapheme_map_start + grapheme_map_layout.total_size; - const total_size = alignForward(usize, grapheme_map_end, std.mem.page_size); + const uri_alloc_layout = UriAlloc.layout(cap.hyperlink_bytes); + const uri_alloc_start = alignForward(usize, grapheme_map_end, UriAlloc.base_align); + const uri_alloc_end = uri_alloc_start + uri_alloc_layout.total_size; + + const hyperlink_count = @divFloor(cap.hyperlink_bytes, @sizeOf(HyperlinkEntry)); + const hyperlink_map_layout = HyperlinkMap.layout(@intCast(hyperlink_count)); + const hyperlink_map_start = alignForward(usize, uri_alloc_end, HyperlinkMap.base_align); + const hyperlink_map_end = hyperlink_map_start + hyperlink_map_layout.total_size; + + const total_size = alignForward(usize, hyperlink_map_end, std.mem.page_size); return .{ .total_size = total_size, @@ -1031,6 +1091,10 @@ pub const Page = struct { .grapheme_alloc_layout = grapheme_alloc_layout, .grapheme_map_start = grapheme_map_start, .grapheme_map_layout = grapheme_map_layout, + .uri_alloc_start = uri_alloc_start, + .uri_alloc_layout = uri_alloc_layout, + .hyperlink_map_start = hyperlink_map_start, + .hyperlink_map_layout = hyperlink_map_layout, .capacity = cap, }; } @@ -1064,6 +1128,10 @@ pub const Capacity = struct { /// Number of bytes to allocate for grapheme data. grapheme_bytes: usize = grapheme_bytes_default, + /// Number of bytes to allocate for hyperlink data. The bytes + /// are shared use for IDs, URIs, and hyperlink entries. + hyperlink_bytes: usize = hyperlink_bytes_default, + pub const Adjustment = struct { cols: ?size.CellCountInt = null, }; @@ -1089,7 +1157,9 @@ pub const Capacity = struct { // for rows & cells (which will allow us to calculate the number of // rows we can fit at a certain column width) we need to layout the // "meta" members of the page (i.e. everything else) from the end. - const grapheme_map_start = alignBackward(usize, layout.total_size - layout.grapheme_map_layout.total_size, GraphemeMap.base_align); + const hyperlink_map_start = alignBackward(usize, layout.total_size - layout.hyperlink_map_layout.total_size, HyperlinkMap.base_align); + const uri_alloc_start = alignBackward(usize, hyperlink_map_start - layout.uri_alloc_layout.total_size, UriAlloc.base_align); + const grapheme_map_start = alignBackward(usize, uri_alloc_start - layout.grapheme_map_layout.total_size, GraphemeMap.base_align); const grapheme_alloc_start = alignBackward(usize, grapheme_map_start - layout.grapheme_alloc_layout.total_size, GraphemeAlloc.base_align); const styles_start = alignBackward(usize, grapheme_alloc_start - layout.styles_layout.total_size, style.Set.base_align); From 75e1655228a6fcfc3b9b518122af5390bd5a2c8f Mon Sep 17 00:00:00 2001 From: Mitchell Hashimoto Date: Sun, 9 Jun 2024 12:04:50 -0700 Subject: [PATCH 05/58] terminal: change default hyperlink count to zero --- src/terminal/hash_map.zig | 2 +- src/terminal/page.zig | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/src/terminal/hash_map.zig b/src/terminal/hash_map.zig index c9c650784..9e47da561 100644 --- a/src/terminal/hash_map.zig +++ b/src/terminal/hash_map.zig @@ -857,7 +857,7 @@ fn HashMapUnmanaged( /// because capacity is rounded up to the next power of two. This is /// a design requirement for this hash map implementation. pub fn layoutForCapacity(new_capacity: Size) Layout { - assert(std.math.isPowerOfTwo(new_capacity)); + assert(new_capacity == 0 or std.math.isPowerOfTwo(new_capacity)); // Pack our metadata, keys, and values. const meta_start = @sizeOf(Header); diff --git a/src/terminal/page.zig b/src/terminal/page.zig index deff6709e..701303d45 100644 --- a/src/terminal/page.zig +++ b/src/terminal/page.zig @@ -46,7 +46,7 @@ const UriAlloc = BitmapAllocator(uri_chunk); /// so it is a worst possible byte size. You probably don't need a large /// value here to accomodate many cells for typical (rare) hyperlink /// usage. -const hyperlink_count_default = 1; +const hyperlink_count_default = 0; const hyperlink_bytes_default = hyperlink_count_default * @max( (uri_chunk * 2), // ID + URI @sizeOf(HyperlinkEntry), // Entry From 69705cbcedac16ef41d65bd4eb826e18b0207e3c Mon Sep 17 00:00:00 2001 From: Mitchell Hashimoto Date: Wed, 3 Jul 2024 09:51:12 -0700 Subject: [PATCH 06/58] terminal: remove the hyperlink stuff i'm starting over --- src/terminal/page.zig | 74 ++----------------------------------------- 1 file changed, 2 insertions(+), 72 deletions(-) diff --git a/src/terminal/page.zig b/src/terminal/page.zig index 701303d45..65334460f 100644 --- a/src/terminal/page.zig +++ b/src/terminal/page.zig @@ -34,37 +34,6 @@ const grapheme_count_default = GraphemeAlloc.bitmap_bit_size; const grapheme_bytes_default = grapheme_count_default * grapheme_chunk; const GraphemeMap = AutoOffsetHashMap(Offset(Cell), Offset(u21).Slice); -/// The allocator for URIs (OSC8). We use a chunk size of 32 because -/// URIs are usually pretty long and that let's us represent most -/// bare domains in one chunk. This allocator is ALSO used for OSC8 -/// IDs. These are usually short but the chunk length should be good. -const uri_chunk_len = 32; -const uri_chunk = uri_chunk_len * @sizeOf(u8); -const UriAlloc = BitmapAllocator(uri_chunk); - -/// The hyperlinks counts are shared between URI and hyperlink entries -/// so it is a worst possible byte size. You probably don't need a large -/// value here to accomodate many cells for typical (rare) hyperlink -/// usage. -const hyperlink_count_default = 0; -const hyperlink_bytes_default = hyperlink_count_default * @max( - (uri_chunk * 2), // ID + URI - @sizeOf(HyperlinkEntry), // Entry -); - -/// The hyperlink map and entry. -const HyperlinkMap = AutoOffsetHashMap(Offset(Cell), HyperlinkEntry); -const HyperlinkEntry = struct { - /// The ID for the hyperlink. This is always non-empty. We - /// auto-generate an ID if one is not provided by the user. - /// This is in UriAlloc. - id: Offset(u8).Slice, - - /// The URI for the actual link. - /// This is in UriAlloc. - uri: Offset(u8).Slice, -}; - /// A page represents a specific section of terminal screen. The primary /// idea of a page is that it is a fully self-contained unit that can be /// serialized, copied, etc. as a convenient way to represent a section @@ -119,14 +88,6 @@ pub const Page = struct { /// path. grapheme_map: GraphemeMap, - /// The URI data for hyperlinks (URI + ID). This is relatively rare - /// so this defaults to a very small allocation size. - uri_alloc: UriAlloc, - - /// The mapping of cells to the hyperlink associated with them. This - /// only has a value when a cell has the "hyperlink" flag set. - hyperlink_map: HyperlinkMap, - /// The available set of styles in use on this page. styles: style.Set, @@ -246,14 +207,6 @@ pub const Page = struct { buf.add(l.grapheme_map_start), l.grapheme_map_layout, ), - .uri_alloc = UriAlloc.init( - buf.add(l.uri_alloc_start), - l.uri_alloc_layout, - ), - .hyperlink_map = HyperlinkMap.init( - buf.add(l.hyperlink_map_start), - l.hyperlink_map_layout, - ), .size = .{ .cols = cap.cols, .rows = cap.rows }, .capacity = cap, }; @@ -1024,10 +977,6 @@ pub const Page = struct { grapheme_alloc_layout: GraphemeAlloc.Layout, grapheme_map_start: usize, grapheme_map_layout: GraphemeMap.Layout, - uri_alloc_start: usize, - uri_alloc_layout: UriAlloc.Layout, - hyperlink_map_start: usize, - hyperlink_map_layout: HyperlinkMap.Layout, capacity: Capacity, }; @@ -1066,16 +1015,7 @@ pub const Page = struct { const grapheme_map_start = alignForward(usize, grapheme_alloc_end, GraphemeMap.base_align); const grapheme_map_end = grapheme_map_start + grapheme_map_layout.total_size; - const uri_alloc_layout = UriAlloc.layout(cap.hyperlink_bytes); - const uri_alloc_start = alignForward(usize, grapheme_map_end, UriAlloc.base_align); - const uri_alloc_end = uri_alloc_start + uri_alloc_layout.total_size; - - const hyperlink_count = @divFloor(cap.hyperlink_bytes, @sizeOf(HyperlinkEntry)); - const hyperlink_map_layout = HyperlinkMap.layout(@intCast(hyperlink_count)); - const hyperlink_map_start = alignForward(usize, uri_alloc_end, HyperlinkMap.base_align); - const hyperlink_map_end = hyperlink_map_start + hyperlink_map_layout.total_size; - - const total_size = alignForward(usize, hyperlink_map_end, std.mem.page_size); + const total_size = alignForward(usize, grapheme_map_end, std.mem.page_size); return .{ .total_size = total_size, @@ -1091,10 +1031,6 @@ pub const Page = struct { .grapheme_alloc_layout = grapheme_alloc_layout, .grapheme_map_start = grapheme_map_start, .grapheme_map_layout = grapheme_map_layout, - .uri_alloc_start = uri_alloc_start, - .uri_alloc_layout = uri_alloc_layout, - .hyperlink_map_start = hyperlink_map_start, - .hyperlink_map_layout = hyperlink_map_layout, .capacity = cap, }; } @@ -1128,10 +1064,6 @@ pub const Capacity = struct { /// Number of bytes to allocate for grapheme data. grapheme_bytes: usize = grapheme_bytes_default, - /// Number of bytes to allocate for hyperlink data. The bytes - /// are shared use for IDs, URIs, and hyperlink entries. - hyperlink_bytes: usize = hyperlink_bytes_default, - pub const Adjustment = struct { cols: ?size.CellCountInt = null, }; @@ -1157,9 +1089,7 @@ pub const Capacity = struct { // for rows & cells (which will allow us to calculate the number of // rows we can fit at a certain column width) we need to layout the // "meta" members of the page (i.e. everything else) from the end. - const hyperlink_map_start = alignBackward(usize, layout.total_size - layout.hyperlink_map_layout.total_size, HyperlinkMap.base_align); - const uri_alloc_start = alignBackward(usize, hyperlink_map_start - layout.uri_alloc_layout.total_size, UriAlloc.base_align); - const grapheme_map_start = alignBackward(usize, uri_alloc_start - layout.grapheme_map_layout.total_size, GraphemeMap.base_align); + const grapheme_map_start = alignBackward(usize, layout.total_size - layout.grapheme_map_layout.total_size, GraphemeMap.base_align); const grapheme_alloc_start = alignBackward(usize, grapheme_map_start - layout.grapheme_alloc_layout.total_size, GraphemeAlloc.base_align); const styles_start = alignBackward(usize, grapheme_alloc_start - layout.styles_layout.total_size, style.Set.base_align); From a71b487d5851a3f3b9534e9d913e908a3b06e9be Mon Sep 17 00:00:00 2001 From: Mitchell Hashimoto Date: Wed, 3 Jul 2024 10:13:21 -0700 Subject: [PATCH 07/58] terminal: add strings table to page --- src/terminal/Screen.zig | 7 ----- src/terminal/bitmap_allocator.zig | 3 ++ src/terminal/page.zig | 48 +++++++++++++++++++++++++++++-- 3 files changed, 48 insertions(+), 10 deletions(-) diff --git a/src/terminal/Screen.zig b/src/terminal/Screen.zig index 347a06ebf..02bb9c9b6 100644 --- a/src/terminal/Screen.zig +++ b/src/terminal/Screen.zig @@ -3306,13 +3306,6 @@ test "Screen: scrolling when viewport is pruned" { for (0..1000) |_| try s.testWriteString("1ABCD\n2EFGH\n3IJKL\n"); try s.testWriteString("1ABCD\n2EFGH\n3IJKL"); - { - // Test our contents rotated - const contents = try s.dumpStringAlloc(alloc, .{ .viewport = .{} }); - defer alloc.free(contents); - try testing.expectEqualStrings("1ABCD\n2EFGH\n3IJKL", contents); - } - { try testing.expectEqual(point.Point{ .screen = .{ .x = 0, diff --git a/src/terminal/bitmap_allocator.zig b/src/terminal/bitmap_allocator.zig index 550a50416..a13236217 100644 --- a/src/terminal/bitmap_allocator.zig +++ b/src/terminal/bitmap_allocator.zig @@ -65,6 +65,9 @@ pub fn BitmapAllocator(comptime chunk_size: comptime_int) type { /// Allocate n elements of type T. This will return error.OutOfMemory /// if there isn't enough space in the backing buffer. + /// + /// Use (size.zig).getOffset to get the base offset from the backing + /// memory for portable storage. pub fn alloc( self: *Self, comptime T: type, diff --git a/src/terminal/page.zig b/src/terminal/page.zig index 65334460f..8f895a82c 100644 --- a/src/terminal/page.zig +++ b/src/terminal/page.zig @@ -34,6 +34,24 @@ const grapheme_count_default = GraphemeAlloc.bitmap_bit_size; const grapheme_bytes_default = grapheme_count_default * grapheme_chunk; const GraphemeMap = AutoOffsetHashMap(Offset(Cell), Offset(u21).Slice); +/// The allocator used for shared utf8-encoded strings within a page. +/// Note the chunk size below is the minimum size of a single allocation +/// and requires a single bit of metadata in our bitmap allocator. Therefore +/// it should be tuned carefully (too small and we waste metadata, too large +/// and we have fragmentation). We can probably use a better allocation +/// strategy in the future. +/// +/// At the time of writing this, the strings table is only used for OSC8 +/// IDs and URIs. IDs are usually short and URIs are usually longer. I chose +/// 32 bytes as a compromise between these two since it represents single +/// domain links quite well and is not too wasteful for short IDs. We can +/// continue to tune this as we see how it's used. +const string_chunk_len = 32; +const string_chunk = string_chunk_len * @sizeOf(u8); +const StringAlloc = BitmapAllocator(string_chunk); +const string_count_default = StringAlloc.bitmap_bit_size; +const string_bytes_default = string_count_default * string_chunk; + /// A page represents a specific section of terminal screen. The primary /// idea of a page is that it is a fully self-contained unit that can be /// serialized, copied, etc. as a convenient way to represent a section @@ -75,6 +93,11 @@ pub const Page = struct { /// first column, all cells in that row are laid out in column order. cells: Offset(Cell), + /// The string allocator for this page used for shared utf-8 encoded + /// strings. Liveness of strings and memory management is deferred to + /// the individual use case. + string_alloc: StringAlloc, + /// The multi-codepoint grapheme data for this page. This is where /// any cell that has more than one codepoint will be stored. This is /// relatively rare (typically only emoji) so this defaults to a very small @@ -199,6 +222,10 @@ pub const Page = struct { l.styles_layout, .{}, ), + .string_alloc = StringAlloc.init( + buf.add(l.string_alloc_start), + l.string_alloc_layout, + ), .grapheme_alloc = GraphemeAlloc.init( buf.add(l.grapheme_alloc_start), l.grapheme_alloc_layout, @@ -977,6 +1004,8 @@ pub const Page = struct { grapheme_alloc_layout: GraphemeAlloc.Layout, grapheme_map_start: usize, grapheme_map_layout: GraphemeMap.Layout, + string_alloc_start: usize, + string_alloc_layout: StringAlloc.Layout, capacity: Capacity, }; @@ -1015,7 +1044,11 @@ pub const Page = struct { const grapheme_map_start = alignForward(usize, grapheme_alloc_end, GraphemeMap.base_align); const grapheme_map_end = grapheme_map_start + grapheme_map_layout.total_size; - const total_size = alignForward(usize, grapheme_map_end, std.mem.page_size); + const string_layout = StringAlloc.layout(cap.string_bytes); + const string_start = alignForward(usize, grapheme_map_end, StringAlloc.base_align); + const string_end = string_start + string_layout.total_size; + + const total_size = alignForward(usize, string_end, std.mem.page_size); return .{ .total_size = total_size, @@ -1031,6 +1064,8 @@ pub const Page = struct { .grapheme_alloc_layout = grapheme_alloc_layout, .grapheme_map_start = grapheme_map_start, .grapheme_map_layout = grapheme_map_layout, + .string_alloc_start = string_start, + .string_alloc_layout = string_layout, .capacity = cap, }; } @@ -1038,12 +1073,15 @@ pub const Page = struct { /// The standard capacity for a page that doesn't have special /// requirements. This is enough to support a very large number of cells. -/// The standard capacity is chosen as the fast-path for allocation. +/// The standard capacity is chosen as the fast-path for allocation since +/// pages of standard capacity use a pooled allocator instead of single-use +/// mmaps. pub const std_capacity: Capacity = .{ .cols = 215, .rows = 215, .styles = 128, .grapheme_bytes = 8192, + .string_bytes = 2048, }; /// The size of this page. @@ -1064,6 +1102,9 @@ pub const Capacity = struct { /// Number of bytes to allocate for grapheme data. grapheme_bytes: usize = grapheme_bytes_default, + /// Number of bytes to allocate for strings. + string_bytes: usize = string_bytes_default, + pub const Adjustment = struct { cols: ?size.CellCountInt = null, }; @@ -1089,7 +1130,8 @@ pub const Capacity = struct { // for rows & cells (which will allow us to calculate the number of // rows we can fit at a certain column width) we need to layout the // "meta" members of the page (i.e. everything else) from the end. - const grapheme_map_start = alignBackward(usize, layout.total_size - layout.grapheme_map_layout.total_size, GraphemeMap.base_align); + const string_alloc_start = alignBackward(usize, layout.total_size, StringAlloc.base_align); + const grapheme_map_start = alignBackward(usize, string_alloc_start - layout.grapheme_map_layout.total_size, GraphemeMap.base_align); const grapheme_alloc_start = alignBackward(usize, grapheme_map_start - layout.grapheme_alloc_layout.total_size, GraphemeAlloc.base_align); const styles_start = alignBackward(usize, grapheme_alloc_start - layout.styles_layout.total_size, style.Set.base_align); From 2a7755c515e2547924f11bdee5cb08bad6e9c3fc Mon Sep 17 00:00:00 2001 From: Mitchell Hashimoto Date: Wed, 3 Jul 2024 10:52:37 -0700 Subject: [PATCH 08/58] terminal: hyperlink data structures beginning, alloc into page --- src/terminal/hyperlink.zig | 51 ++++++++++++++++++++++++++++++++++++++ src/terminal/page.zig | 47 +++++++++++++++++++++++++++++++++-- src/terminal/style.zig | 3 +-- 3 files changed, 97 insertions(+), 4 deletions(-) create mode 100644 src/terminal/hyperlink.zig diff --git a/src/terminal/hyperlink.zig b/src/terminal/hyperlink.zig new file mode 100644 index 000000000..d7e59914a --- /dev/null +++ b/src/terminal/hyperlink.zig @@ -0,0 +1,51 @@ +const std = @import("std"); +const assert = std.debug.assert; +const hash_map = @import("hash_map.zig"); +const AutoOffsetHashMap = hash_map.AutoOffsetHashMap; +const size = @import("size.zig"); +const Offset = size.Offset; +const Cell = @import("page.zig").Cell; +const RefCountedSet = @import("ref_counted_set.zig").RefCountedSet; + +/// The unique identifier for a hyperlink. This is at most the number of cells +/// that can fit in a single terminal page. +pub const Id = size.CellCountInt; + +// The mapping of cell to hyperlink. We use an offset hash map to save space +// since its very unlikely a cell is a hyperlink, so its a waste to store +// the hyperlink ID in the cell itself. +pub const Map = AutoOffsetHashMap(Offset(Cell), Id); + +/// The main entry for hyperlinks. +pub const Hyperlink = struct { + 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 + implicit: size.OffsetInt, + }, + + /// The URI for the actual link. + uri: Offset(u8).Slice, +}; + +/// The set of hyperlinks. This is ref-counted so that a set of cells +/// can share the same hyperlink without duplicating the data. +pub const Set = RefCountedSet( + Hyperlink, + Id, + size.CellCountInt, + struct { + pub fn hash(self: *const @This(), link: Hyperlink) u64 { + _ = self; + return link.hash(); + } + + pub fn eql(self: *const @This(), a: Hyperlink, b: Hyperlink) bool { + _ = self; + return a.eql(b); + } + }, +); diff --git a/src/terminal/page.zig b/src/terminal/page.zig index 8f895a82c..da3b0d2f5 100644 --- a/src/terminal/page.zig +++ b/src/terminal/page.zig @@ -7,6 +7,7 @@ const testing = std.testing; const posix = std.posix; const fastmem = @import("../fastmem.zig"); const color = @import("color.zig"); +const hyperlink = @import("hyperlink.zig"); const sgr = @import("sgr.zig"); const style = @import("style.zig"); const size = @import("size.zig"); @@ -114,6 +115,13 @@ pub const Page = struct { /// The available set of styles in use on this page. styles: style.Set, + /// The structures used for tracking hyperlinks within the page. + /// The map maps cell offsets to hyperlink IDs and the IDs are in + /// the ref counted set. The strings within the hyperlink structures + /// are allocated in the string allocator. + hyperlink_map: hyperlink.Map, + hyperlink_set: hyperlink.Set, + /// The offset to the first mask of dirty bits in the page. /// /// The dirty bits is a contiguous array of usize where each bit represents @@ -234,6 +242,15 @@ pub const Page = struct { buf.add(l.grapheme_map_start), l.grapheme_map_layout, ), + .hyperlink_map = hyperlink.Map.init( + buf.add(l.hyperlink_map_start), + l.hyperlink_map_layout, + ), + .hyperlink_set = hyperlink.Set.init( + buf.add(l.hyperlink_set_start), + l.hyperlink_set_layout, + .{}, + ), .size = .{ .cols = cap.cols, .rows = cap.rows }, .capacity = cap, }; @@ -1006,6 +1023,10 @@ pub const Page = struct { grapheme_map_layout: GraphemeMap.Layout, string_alloc_start: usize, string_alloc_layout: StringAlloc.Layout, + hyperlink_map_start: usize, + hyperlink_map_layout: hyperlink.Map.Layout, + hyperlink_set_start: usize, + hyperlink_set_layout: hyperlink.Set.Layout, capacity: Capacity, }; @@ -1048,7 +1069,15 @@ pub const Page = struct { const string_start = alignForward(usize, grapheme_map_end, StringAlloc.base_align); const string_end = string_start + string_layout.total_size; - const total_size = alignForward(usize, string_end, std.mem.page_size); + const hyperlink_map_layout = hyperlink.Map.layout(@intCast(cap.hyperlink_cells)); + const hyperlink_map_start = alignForward(usize, string_end, hyperlink.Map.base_align); + const hyperlink_map_end = hyperlink_map_start + hyperlink_map_layout.total_size; + + const hyperlink_set_layout = hyperlink.Set.layout(@intCast(cap.hyperlink_entries)); + const hyperlink_set_start = alignForward(usize, hyperlink_map_end, hyperlink.Set.base_align); + const hyperlink_set_end = hyperlink_set_start + hyperlink_set_layout.total_size; + + const total_size = alignForward(usize, hyperlink_set_end, std.mem.page_size); return .{ .total_size = total_size, @@ -1066,6 +1095,10 @@ pub const Page = struct { .grapheme_map_layout = grapheme_map_layout, .string_alloc_start = string_start, .string_alloc_layout = string_layout, + .hyperlink_map_start = hyperlink_map_start, + .hyperlink_map_layout = hyperlink_map_layout, + .hyperlink_set_start = hyperlink_set_start, + .hyperlink_set_layout = hyperlink_set_layout, .capacity = cap, }; } @@ -1080,6 +1113,8 @@ pub const std_capacity: Capacity = .{ .cols = 215, .rows = 215, .styles = 128, + .hyperlink_cells = 32, // TODO: think about these numbers + .hyperlink_entries = 4, .grapheme_bytes = 8192, .string_bytes = 2048, }; @@ -1099,6 +1134,12 @@ pub const Capacity = struct { /// Number of unique styles that can be used on this page. styles: usize = 16, + /// The cells is the number of cells in the terminal that can have + /// a hyperlink and the entries is the number of unique hyperlinks + /// itself. + hyperlink_cells: usize = 32, + hyperlink_entries: usize = 4, + /// Number of bytes to allocate for grapheme data. grapheme_bytes: usize = grapheme_bytes_default, @@ -1130,7 +1171,9 @@ pub const Capacity = struct { // for rows & cells (which will allow us to calculate the number of // rows we can fit at a certain column width) we need to layout the // "meta" members of the page (i.e. everything else) from the end. - const string_alloc_start = alignBackward(usize, layout.total_size, StringAlloc.base_align); + const hyperlink_set_start = alignBackward(usize, layout.total_size - layout.hyperlink_set_layout.total_size, hyperlink.Set.base_align); + const hyperlink_map_start = alignBackward(usize, hyperlink_set_start - layout.hyperlink_map_layout.total_size, hyperlink.Map.base_align); + const string_alloc_start = alignBackward(usize, hyperlink_map_start - layout.string_alloc_layout.total_size, StringAlloc.base_align); const grapheme_map_start = alignBackward(usize, string_alloc_start - layout.grapheme_map_layout.total_size, GraphemeMap.base_align); const grapheme_alloc_start = alignBackward(usize, grapheme_map_start - layout.grapheme_alloc_layout.total_size, GraphemeAlloc.base_align); const styles_start = alignBackward(usize, grapheme_alloc_start - layout.styles_layout.total_size, style.Set.base_align); diff --git a/src/terminal/style.zig b/src/terminal/style.zig index 813411a73..430fca214 100644 --- a/src/terminal/style.zig +++ b/src/terminal/style.zig @@ -6,12 +6,11 @@ const page = @import("page.zig"); const size = @import("size.zig"); const Offset = size.Offset; const OffsetBuf = size.OffsetBuf; +const RefCountedSet = @import("ref_counted_set.zig").RefCountedSet; const Wyhash = std.hash.Wyhash; const autoHash = std.hash.autoHash; -const RefCountedSet = @import("ref_counted_set.zig").RefCountedSet; - /// The unique identifier for a style. This is at most the number of cells /// that can fit into a terminal page. pub const Id = size.CellCountInt; From cb1caff0181d5e07349b41d273b854967023e1ec Mon Sep 17 00:00:00 2001 From: Mitchell Hashimoto Date: Wed, 3 Jul 2024 16:18:00 -0700 Subject: [PATCH 09/58] terminal: refcountedset passes base memory to all context funcs This enables these funcs to access memory offsets that may be present in set items, which is possible since the set itself is in an offset-based structure. --- src/terminal/ref_counted_set.zig | 12 ++++++------ src/terminal/style.zig | 6 ++++-- 2 files changed, 10 insertions(+), 8 deletions(-) diff --git a/src/terminal/ref_counted_set.zig b/src/terminal/ref_counted_set.zig index c6cf12db5..0ea4c5709 100644 --- a/src/terminal/ref_counted_set.zig +++ b/src/terminal/ref_counted_set.zig @@ -264,7 +264,7 @@ pub fn RefCountedSet( self.living += 1; return if (added_id == id) null else added_id; - } else if (self.context.eql(value, items[id].value)) { + } else if (self.context.eql(base, value, items[id].value)) { items[id].meta.ref += 1; return null; @@ -390,7 +390,7 @@ pub fn RefCountedSet( if (comptime @hasDecl(Context, "deleted")) { // Inform the context struct that we're // deleting the dead item's value for good. - self.context.deleted(item.value); + self.context.deleted(base, item.value); } self.psl_stats[item.meta.psl] -= 1; @@ -423,7 +423,7 @@ pub fn RefCountedSet( const table = self.table.ptr(base); const items = self.items.ptr(base); - const hash: u64 = self.context.hash(value); + const hash: u64 = self.context.hash(base, value); for (0..self.max_psl + 1) |i| { const p: usize = @intCast((hash + i) & self.layout.table_mask); @@ -455,7 +455,7 @@ pub fn RefCountedSet( // If the item is a part of the same probe sequence, // we check if it matches the value we're looking for. if (item.meta.psl == i and - self.context.eql(value, item.value)) + self.context.eql(base, value, item.value)) { return id; } @@ -481,7 +481,7 @@ pub fn RefCountedSet( .meta = .{ .psl = 0, .ref = 0 }, }; - const hash: u64 = self.context.hash(value); + const hash: u64 = self.context.hash(base, value); var held_id: Id = new_id; var held_item: *Item = &new_item; @@ -510,7 +510,7 @@ pub fn RefCountedSet( if (comptime @hasDecl(Context, "deleted")) { // Inform the context struct that we're // deleting the dead item's value for good. - self.context.deleted(item.value); + self.context.deleted(base, item.value); } chosen_id = id; diff --git a/src/terminal/style.zig b/src/terminal/style.zig index 430fca214..cce20e711 100644 --- a/src/terminal/style.zig +++ b/src/terminal/style.zig @@ -247,13 +247,15 @@ pub const Set = RefCountedSet( Id, size.CellCountInt, struct { - pub fn hash(self: *const @This(), style: Style) u64 { + pub fn hash(self: *const @This(), base: anytype, style: Style) u64 { _ = self; + _ = base; return style.hash(); } - pub fn eql(self: *const @This(), a: Style, b: Style) bool { + pub fn eql(self: *const @This(), base: anytype, a: Style, b: Style) bool { _ = self; + _ = base; return a.eql(b); } }, From 2e41afc7873277aa73267d7277e27d93b8bd241b Mon Sep 17 00:00:00 2001 From: Mitchell Hashimoto Date: Wed, 3 Jul 2024 18:23:46 -0700 Subject: [PATCH 10/58] terminal: RefCountedSet has Context variant methods --- src/terminal/ref_counted_set.zig | 36 +++++++++++++++++++------------- 1 file changed, 21 insertions(+), 15 deletions(-) diff --git a/src/terminal/ref_counted_set.zig b/src/terminal/ref_counted_set.zig index 0ea4c5709..4b470400d 100644 --- a/src/terminal/ref_counted_set.zig +++ b/src/terminal/ref_counted_set.zig @@ -205,12 +205,15 @@ pub fn RefCountedSet( /// /// If the set has no more room, then an OutOfMemory error is returned. pub fn add(self: *Self, base: anytype, value: T) AddError!Id { + return try self.addContext(base, value, self.context); + } + pub fn addContext(self: *Self, base: anytype, value: T, ctx: Context) AddError!Id { const items = self.items.ptr(base); // Trim dead items from the end of the list. while (self.next_id > 1 and items[self.next_id - 1].meta.ref == 0) { self.next_id -= 1; - self.deleteItem(base, self.next_id); + self.deleteItem(base, self.next_id, ctx); } // If we still don't have an available ID, we can't continue. @@ -232,7 +235,7 @@ pub fn RefCountedSet( return AddError.OutOfMemory; } - const id = self.upsert(base, value, self.next_id); + const id = self.upsert(base, value, self.next_id, ctx); items[id].meta.ref += 1; if (id == self.next_id) self.next_id += 1; @@ -251,27 +254,30 @@ pub fn RefCountedSet( /// /// If the set has no more room, then an OutOfMemory error is returned. pub fn addWithId(self: *Self, base: anytype, value: T, id: Id) AddError!?Id { + return try self.addWithIdContext(base, value, id, self.context); + } + pub fn addWithIdContext(self: *Self, base: anytype, value: T, id: Id, ctx: Context) AddError!?Id { const items = self.items.ptr(base); if (id < self.next_id) { if (items[id].meta.ref == 0) { - self.deleteItem(base, id); + self.deleteItem(base, id, ctx); - const added_id = self.upsert(base, value, id); + const added_id = self.upsert(base, value, id, ctx); items[added_id].meta.ref += 1; self.living += 1; return if (added_id == id) null else added_id; - } else if (self.context.eql(base, value, items[id].value)) { + } else if (ctx.eql(base, value, items[id].value)) { items[id].meta.ref += 1; return null; } } - return try self.add(base, value); + return try self.addContext(base, value, ctx); } /// Increment an item's reference count by 1. @@ -377,7 +383,7 @@ pub fn RefCountedSet( /// Delete an item, removing any references from /// the table, and freeing its ID to be re-used. - fn deleteItem(self: *Self, base: anytype, id: Id) void { + fn deleteItem(self: *Self, base: anytype, id: Id, ctx: Context) void { const table = self.table.ptr(base); const items = self.items.ptr(base); @@ -390,7 +396,7 @@ pub fn RefCountedSet( if (comptime @hasDecl(Context, "deleted")) { // Inform the context struct that we're // deleting the dead item's value for good. - self.context.deleted(base, item.value); + ctx.deleted(base, item.value); } self.psl_stats[item.meta.psl] -= 1; @@ -419,11 +425,11 @@ pub fn RefCountedSet( /// Find an item in the table and return its ID. /// If the item does not exist in the table, null is returned. - fn lookup(self: *Self, base: anytype, value: T) ?Id { + fn lookup(self: *Self, base: anytype, value: T, ctx: Context) ?Id { const table = self.table.ptr(base); const items = self.items.ptr(base); - const hash: u64 = self.context.hash(base, value); + const hash: u64 = ctx.hash(base, value); for (0..self.max_psl + 1) |i| { const p: usize = @intCast((hash + i) & self.layout.table_mask); @@ -455,7 +461,7 @@ pub fn RefCountedSet( // If the item is a part of the same probe sequence, // we check if it matches the value we're looking for. if (item.meta.psl == i and - self.context.eql(base, value, item.value)) + ctx.eql(base, value, item.value)) { return id; } @@ -468,9 +474,9 @@ pub fn RefCountedSet( /// for it if not present. If a new item is added, `new_id` will /// be used as the ID. If an existing item is found, the `new_id` /// is ignored and the existing item's ID is returned. - fn upsert(self: *Self, base: anytype, value: T, new_id: Id) Id { + fn upsert(self: *Self, base: anytype, value: T, new_id: Id, ctx: Context) Id { // If the item already exists, return it. - if (self.lookup(base, value)) |id| return id; + if (self.lookup(base, value, ctx)) |id| return id; const table = self.table.ptr(base); const items = self.items.ptr(base); @@ -481,7 +487,7 @@ pub fn RefCountedSet( .meta = .{ .psl = 0, .ref = 0 }, }; - const hash: u64 = self.context.hash(base, value); + const hash: u64 = ctx.hash(base, value); var held_id: Id = new_id; var held_item: *Item = &new_item; @@ -510,7 +516,7 @@ pub fn RefCountedSet( if (comptime @hasDecl(Context, "deleted")) { // Inform the context struct that we're // deleting the dead item's value for good. - self.context.deleted(base, item.value); + ctx.deleted(base, item.value); } chosen_id = id; From 51c05aeb99357106b1723a8ec1b72c6c6dc94c6e Mon Sep 17 00:00:00 2001 From: Mitchell Hashimoto Date: Wed, 3 Jul 2024 18:26:35 -0700 Subject: [PATCH 11/58] terminal: RefCountedSet doesn't need to pass base anymore --- src/terminal/ref_counted_set.zig | 12 ++++++------ src/terminal/style.zig | 6 ++---- 2 files changed, 8 insertions(+), 10 deletions(-) diff --git a/src/terminal/ref_counted_set.zig b/src/terminal/ref_counted_set.zig index 4b470400d..aaaab890a 100644 --- a/src/terminal/ref_counted_set.zig +++ b/src/terminal/ref_counted_set.zig @@ -270,7 +270,7 @@ pub fn RefCountedSet( self.living += 1; return if (added_id == id) null else added_id; - } else if (ctx.eql(base, value, items[id].value)) { + } else if (ctx.eql(value, items[id].value)) { items[id].meta.ref += 1; return null; @@ -396,7 +396,7 @@ pub fn RefCountedSet( if (comptime @hasDecl(Context, "deleted")) { // Inform the context struct that we're // deleting the dead item's value for good. - ctx.deleted(base, item.value); + ctx.deleted(item.value); } self.psl_stats[item.meta.psl] -= 1; @@ -429,7 +429,7 @@ pub fn RefCountedSet( const table = self.table.ptr(base); const items = self.items.ptr(base); - const hash: u64 = ctx.hash(base, value); + const hash: u64 = ctx.hash(value); for (0..self.max_psl + 1) |i| { const p: usize = @intCast((hash + i) & self.layout.table_mask); @@ -461,7 +461,7 @@ pub fn RefCountedSet( // If the item is a part of the same probe sequence, // we check if it matches the value we're looking for. if (item.meta.psl == i and - ctx.eql(base, value, item.value)) + ctx.eql(value, item.value)) { return id; } @@ -487,7 +487,7 @@ pub fn RefCountedSet( .meta = .{ .psl = 0, .ref = 0 }, }; - const hash: u64 = ctx.hash(base, value); + const hash: u64 = ctx.hash(value); var held_id: Id = new_id; var held_item: *Item = &new_item; @@ -516,7 +516,7 @@ pub fn RefCountedSet( if (comptime @hasDecl(Context, "deleted")) { // Inform the context struct that we're // deleting the dead item's value for good. - ctx.deleted(base, item.value); + ctx.deleted(item.value); } chosen_id = id; diff --git a/src/terminal/style.zig b/src/terminal/style.zig index cce20e711..430fca214 100644 --- a/src/terminal/style.zig +++ b/src/terminal/style.zig @@ -247,15 +247,13 @@ pub const Set = RefCountedSet( Id, size.CellCountInt, struct { - pub fn hash(self: *const @This(), base: anytype, style: Style) u64 { + pub fn hash(self: *const @This(), style: Style) u64 { _ = self; - _ = base; return style.hash(); } - pub fn eql(self: *const @This(), base: anytype, a: Style, b: Style) bool { + pub fn eql(self: *const @This(), a: Style, b: Style) bool { _ = self; - _ = base; return a.eql(b); } }, From d1f41e2035bee4b22b4663edd49bd71e5fabfa1f Mon Sep 17 00:00:00 2001 From: Mitchell Hashimoto Date: Wed, 3 Jul 2024 18:59:50 -0700 Subject: [PATCH 12/58] 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); From 548850e453c7f8000c5bc5d38b66576139542462 Mon Sep 17 00:00:00 2001 From: Mitchell Hashimoto Date: Wed, 3 Jul 2024 19:03:25 -0700 Subject: [PATCH 13/58] terminal: RefCountedSet should call deleted on upsert --- src/terminal/ref_counted_set.zig | 10 +++++++--- 1 file changed, 7 insertions(+), 3 deletions(-) diff --git a/src/terminal/ref_counted_set.zig b/src/terminal/ref_counted_set.zig index ba1e0afd1..0fcb84b57 100644 --- a/src/terminal/ref_counted_set.zig +++ b/src/terminal/ref_counted_set.zig @@ -476,9 +476,13 @@ 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; + if (self.lookup(base, value, ctx)) |id| { + // Notify the context that the value is "deleted" because + // we're reusing the existing value in the set. This allows + // callers to clean up any resources associated with the value. + if (comptime @hasDecl(Context, "deleted")) ctx.deleted(value); + return id; + } const table = self.table.ptr(base); const items = self.items.ptr(base); From c880bb6f45d9328f5c965181c8ad23f9a2cde6ff Mon Sep 17 00:00:00 2001 From: Mitchell Hashimoto Date: Wed, 3 Jul 2024 20:46:50 -0700 Subject: [PATCH 14/58] terminal: test hyperlink reuse shares ID --- src/terminal/Screen.zig | 36 ++++++++++++++++++++++++++++++++++-- 1 file changed, 34 insertions(+), 2 deletions(-) diff --git a/src/terminal/Screen.zig b/src/terminal/Screen.zig index 53a4fa47a..5d138250c 100644 --- a/src/terminal/Screen.zig +++ b/src/terminal/Screen.zig @@ -1381,8 +1381,6 @@ pub fn startHyperlink( 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; @@ -7439,6 +7437,40 @@ test "Screen: hyperlink start/end" { } } +test "Screen: hyperlink reuse" { + 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()); + } + + // Use it for the first time + try s.startHyperlink("http://example.com", null); + try testing.expect(s.cursor.hyperlink_id != 0); + const id = s.cursor.hyperlink_id; + + // Reuse the same hyperlink, expect we have the same ID + try s.startHyperlink("http://example.com", null); + try testing.expectEqual(id, s.cursor.hyperlink_id); + { + 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; From 6fc9e92dba6ec0fbefb0d2120640cca8d6d08d66 Mon Sep 17 00:00:00 2001 From: Mitchell Hashimoto Date: Wed, 3 Jul 2024 20:46:59 -0700 Subject: [PATCH 15/58] terminal: hyperlink deleted callback frees string memory --- src/terminal/hyperlink.zig | 16 ++++++++++++++++ 1 file changed, 16 insertions(+) diff --git a/src/terminal/hyperlink.zig b/src/terminal/hyperlink.zig index 210c1a5da..49bf93faf 100644 --- a/src/terminal/hyperlink.zig +++ b/src/terminal/hyperlink.zig @@ -93,5 +93,21 @@ pub const Set = RefCountedSet( pub fn eql(self: *const @This(), a: Hyperlink, b: Hyperlink) bool { return a.eql(self.page.?.memory, &b); } + + pub fn deleted(self: *const @This(), link: Hyperlink) void { + const page = self.page.?; + const alloc = &page.string_alloc; + switch (link.id) { + .implicit => {}, + .explicit => |v| alloc.free( + page.memory, + v.offset.ptr(page.memory)[0..v.len], + ), + } + alloc.free( + page.memory, + link.uri.offset.ptr(page.memory)[0..link.uri.len], + ); + } }, ); From a3a445a0666338a63c29cfcf1717db38f1653f26 Mon Sep 17 00:00:00 2001 From: Mitchell Hashimoto Date: Wed, 3 Jul 2024 21:17:12 -0700 Subject: [PATCH 16/58] terminal: print sets hyperlink state, tests --- src/terminal/Terminal.zig | 111 +++++++++++++++++++++++++++++++++++++- src/terminal/page.zig | 54 ++++++++++++++++++- 2 files changed, 163 insertions(+), 2 deletions(-) diff --git a/src/terminal/Terminal.zig b/src/terminal/Terminal.zig index 8dcb46133..4b9899da9 100644 --- a/src/terminal/Terminal.zig +++ b/src/terminal/Terminal.zig @@ -14,6 +14,7 @@ const ansi = @import("ansi.zig"); const modes = @import("modes.zig"); const charsets = @import("charsets.zig"); const csi = @import("csi.zig"); +const hyperlink = @import("hyperlink.zig"); const kitty = @import("kitty.zig"); const point = @import("point.zig"); const sgr = @import("sgr.zig"); @@ -600,10 +601,26 @@ fn printCell( ); } + // We check for an active hyperlink first because setHyperlink + // handles clearing the old hyperlink and an optimization if we're + // overwriting the same hyperlink. + if (self.screen.cursor.hyperlink_id > 0) { + // If we have a hyperlink configured, apply it to this cell + var page = &self.screen.cursor.page_pin.page.data; + page.setHyperlink(cell, self.screen.cursor.hyperlink_id) catch |err| { + // TODO: an error can only happen if our page is out of space + // so realloc the page here. + log.err("failed to set hyperlink, ignoring err={}", .{err}); + }; + } else if (cell.hyperlink) { + // If the previous cell had a hyperlink then we need to clear it. + var page = &self.screen.cursor.page_pin.page.data; + page.clearHyperlink(cell); + } + // We don't need to update the style refs unless the // cell's new style will be different after writing. const style_changed = cell.style_id != self.screen.cursor.style_id; - if (style_changed) { var page = &self.screen.cursor.page_pin.page.data; @@ -621,6 +638,7 @@ fn printCell( .style_id = self.screen.cursor.style_id, .wide = wide, .protected = self.screen.cursor.protected, + .hyperlink = self.screen.cursor.hyperlink_id > 0, }; if (style_changed) { @@ -3775,6 +3793,97 @@ test "Terminal: print wide char at right margin does not create spacer head" { } } +test "Terminal: print with hyperlink" { + var t = try init(testing.allocator, .{ .cols = 80, .rows = 80 }); + defer t.deinit(testing.allocator); + + // Setup our hyperlink and print + try t.screen.startHyperlink("http://example.com", null); + try t.printString("123456"); + + // Verify all our cells have a hyperlink + for (0..6) |x| { + const list_cell = t.screen.pages.getCell(.{ .screen = .{ + .x = @intCast(x), + .y = 0, + } }).?; + const cell = list_cell.cell; + try testing.expect(cell.hyperlink); + const id = list_cell.page.data.lookupHyperlink(cell).?; + try testing.expectEqual(@as(hyperlink.Id, 1), id); + } + + try testing.expect(t.isDirty(.{ .screen = .{ .x = 0, .y = 0 } })); +} + +test "Terminal: print and end hyperlink" { + var t = try init(testing.allocator, .{ .cols = 80, .rows = 80 }); + defer t.deinit(testing.allocator); + + // Setup our hyperlink and print + try t.screen.startHyperlink("http://example.com", null); + try t.printString("123"); + t.screen.endHyperlink(); + try t.printString("456"); + + // Verify all our cells have a hyperlink + for (0..3) |x| { + const list_cell = t.screen.pages.getCell(.{ .screen = .{ + .x = @intCast(x), + .y = 0, + } }).?; + const cell = list_cell.cell; + try testing.expect(cell.hyperlink); + const id = list_cell.page.data.lookupHyperlink(cell).?; + try testing.expectEqual(@as(hyperlink.Id, 1), id); + } + for (3..6) |x| { + const list_cell = t.screen.pages.getCell(.{ .screen = .{ + .x = @intCast(x), + .y = 0, + } }).?; + const cell = list_cell.cell; + try testing.expect(!cell.hyperlink); + } + + try testing.expect(t.isDirty(.{ .screen = .{ .x = 0, .y = 0 } })); +} + +test "Terminal: print and change hyperlink" { + var t = try init(testing.allocator, .{ .cols = 80, .rows = 80 }); + defer t.deinit(testing.allocator); + + // Setup our hyperlink and print + try t.screen.startHyperlink("http://one.example.com", null); + try t.printString("123"); + try t.screen.startHyperlink("http://two.example.com", null); + try t.printString("456"); + + // Verify all our cells have a hyperlink + for (0..3) |x| { + const list_cell = t.screen.pages.getCell(.{ .screen = .{ + .x = @intCast(x), + .y = 0, + } }).?; + const cell = list_cell.cell; + try testing.expect(cell.hyperlink); + const id = list_cell.page.data.lookupHyperlink(cell).?; + try testing.expectEqual(@as(hyperlink.Id, 1), id); + } + for (3..6) |x| { + const list_cell = t.screen.pages.getCell(.{ .screen = .{ + .x = @intCast(x), + .y = 0, + } }).?; + const cell = list_cell.cell; + try testing.expect(cell.hyperlink); + const id = list_cell.page.data.lookupHyperlink(cell).?; + try testing.expectEqual(@as(hyperlink.Id, 2), id); + } + + try testing.expect(t.isDirty(.{ .screen = .{ .x = 0, .y = 0 } })); +} + test "Terminal: linefeed and carriage return" { var t = try init(testing.allocator, .{ .cols = 80, .rows = 80 }); defer t.deinit(testing.allocator); diff --git a/src/terminal/page.zig b/src/terminal/page.zig index da3b0d2f5..86d3fefb4 100644 --- a/src/terminal/page.zig +++ b/src/terminal/page.zig @@ -859,6 +859,53 @@ pub const Page = struct { @memset(@as([]u64, @ptrCast(cells)), 0); } + /// Returns the hyperlink ID for the given cell. + pub fn lookupHyperlink(self: *const Page, cell: *Cell) ?hyperlink.Id { + const cell_offset = getOffset(Cell, self.memory, cell); + const map = self.hyperlink_map.map(self.memory); + return map.get(cell_offset); + } + + /// Clear the hyperlink from the given cell. + pub fn clearHyperlink(self: *Page, cell: *Cell) void { + defer self.assertIntegrity(); + + // Get our ID + const cell_offset = getOffset(Cell, self.memory, cell); + var map = self.hyperlink_map.map(self.memory); + const entry = map.getEntry(cell_offset) orelse return; + + // Release our usage of this + self.hyperlink_set.release(self.memory, entry.value_ptr.*); + + // Free the memory + map.removeByPtr(entry.key_ptr); + } + + /// Set the hyperlink for the given cell. If the cell already has a + /// hyperlink, then this will handle memory management for the prior + /// hyperlink. + pub fn setHyperlink(self: *Page, cell: *Cell, id: hyperlink.Id) !void { + defer self.assertIntegrity(); + + const cell_offset = getOffset(Cell, self.memory, cell); + var map = self.hyperlink_map.map(self.memory); + const gop = try map.getOrPut(cell_offset); + + if (gop.found_existing) { + // If the hyperlink matches then we don't need to do anything. + if (gop.value_ptr.* == id) return; + + // Different hyperlink, we need to release the old one + self.hyperlink_set.release(self.memory, gop.value_ptr.*); + } + + // Increase ref count for our new hyperlink and set it + self.hyperlink_set.use(self.memory, id); + gop.value_ptr.* = id; + cell.hyperlink = true; + } + /// Append a codepoint to the given cell as a grapheme. pub fn appendGrapheme(self: *Page, row: *Row, cell: *Cell, cp: u21) Allocator.Error!void { defer self.assertIntegrity(); @@ -1297,7 +1344,12 @@ pub const Cell = packed struct(u64) { /// Whether this was written with the protection flag set. protected: bool = false, - _padding: u19 = 0, + /// Whether this cell is a hyperlink. If this is true then you must + /// look up the hyperlink ID in the page hyperlink_map and the ID in + /// the hyperlink_set to get the actual hyperlink data. + hyperlink: bool = false, + + _padding: u18 = 0, pub const ContentTag = enum(u2) { /// A single codepoint, could be zero to be empty cell. From e2133cbd92f5f8e8189820148ee987e554e881a3 Mon Sep 17 00:00:00 2001 From: Mitchell Hashimoto Date: Thu, 4 Jul 2024 09:42:46 -0700 Subject: [PATCH 17/58] terminal: row needs hyperlink state, test clearing hyperlink --- src/terminal/Terminal.zig | 43 +++++++++++++++++++++++++++++++++++++-- src/terminal/page.zig | 31 ++++++++++++++++++++++------ 2 files changed, 66 insertions(+), 8 deletions(-) diff --git a/src/terminal/Terminal.zig b/src/terminal/Terminal.zig index 4b9899da9..51d7a7fb1 100644 --- a/src/terminal/Terminal.zig +++ b/src/terminal/Terminal.zig @@ -607,7 +607,11 @@ fn printCell( if (self.screen.cursor.hyperlink_id > 0) { // If we have a hyperlink configured, apply it to this cell var page = &self.screen.cursor.page_pin.page.data; - page.setHyperlink(cell, self.screen.cursor.hyperlink_id) catch |err| { + page.setHyperlink( + self.screen.cursor.page_row, + cell, + self.screen.cursor.hyperlink_id, + ) catch |err| { // TODO: an error can only happen if our page is out of space // so realloc the page here. log.err("failed to set hyperlink, ignoring err={}", .{err}); @@ -615,7 +619,7 @@ fn printCell( } else if (cell.hyperlink) { // If the previous cell had a hyperlink then we need to clear it. var page = &self.screen.cursor.page_pin.page.data; - page.clearHyperlink(cell); + page.clearHyperlink(self.screen.cursor.page_row, cell); } // We don't need to update the style refs unless the @@ -3807,6 +3811,8 @@ test "Terminal: print with hyperlink" { .x = @intCast(x), .y = 0, } }).?; + const row = list_cell.row; + try testing.expect(row.hyperlink); const cell = list_cell.cell; try testing.expect(cell.hyperlink); const id = list_cell.page.data.lookupHyperlink(cell).?; @@ -3832,6 +3838,8 @@ test "Terminal: print and end hyperlink" { .x = @intCast(x), .y = 0, } }).?; + const row = list_cell.row; + try testing.expect(row.hyperlink); const cell = list_cell.cell; try testing.expect(cell.hyperlink); const id = list_cell.page.data.lookupHyperlink(cell).?; @@ -3842,6 +3850,8 @@ test "Terminal: print and end hyperlink" { .x = @intCast(x), .y = 0, } }).?; + const row = list_cell.row; + try testing.expect(row.hyperlink); const cell = list_cell.cell; try testing.expect(!cell.hyperlink); } @@ -3884,6 +3894,35 @@ test "Terminal: print and change hyperlink" { try testing.expect(t.isDirty(.{ .screen = .{ .x = 0, .y = 0 } })); } +test "Terminal: overwrite hyperlink" { + var t = try init(testing.allocator, .{ .cols = 80, .rows = 80 }); + defer t.deinit(testing.allocator); + + // Setup our hyperlink and print + try t.screen.startHyperlink("http://one.example.com", null); + try t.printString("123"); + t.setCursorPos(1, 1); + t.screen.endHyperlink(); + try t.printString("456"); + + // Verify all our cells have a hyperlink + for (0..3) |x| { + const list_cell = t.screen.pages.getCell(.{ .screen = .{ + .x = @intCast(x), + .y = 0, + } }).?; + const page = &list_cell.page.data; + const row = list_cell.row; + try testing.expect(!row.hyperlink); + const cell = list_cell.cell; + try testing.expect(!cell.hyperlink); + try testing.expect(page.lookupHyperlink(cell) == null); + try testing.expectEqual(0, page.hyperlink_set.count()); + } + + try testing.expect(t.isDirty(.{ .screen = .{ .x = 0, .y = 0 } })); +} + test "Terminal: linefeed and carriage return" { var t = try init(testing.allocator, .{ .cols = 80, .rows = 80 }); defer t.deinit(testing.allocator); diff --git a/src/terminal/page.zig b/src/terminal/page.zig index 86d3fefb4..2aa6f78e3 100644 --- a/src/terminal/page.zig +++ b/src/terminal/page.zig @@ -838,12 +838,20 @@ pub const Page = struct { defer self.assertIntegrity(); const cells = row.cells.ptr(self.memory)[left..end]; + if (row.grapheme) { for (cells) |*cell| { if (cell.hasGrapheme()) self.clearGrapheme(row, cell); } } + if (row.hyperlink) { + row.hyperlink = false; + for (cells) |*cell| { + if (cell.hyperlink) self.clearHyperlink(row, cell); + } + } + if (row.styled) { for (cells) |*cell| { if (cell.style_id == style.default_id) continue; @@ -867,7 +875,7 @@ pub const Page = struct { } /// Clear the hyperlink from the given cell. - pub fn clearHyperlink(self: *Page, cell: *Cell) void { + pub fn clearHyperlink(self: *Page, row: *Row, cell: *Cell) void { defer self.assertIntegrity(); // Get our ID @@ -875,17 +883,22 @@ pub const Page = struct { var map = self.hyperlink_map.map(self.memory); const entry = map.getEntry(cell_offset) orelse return; - // Release our usage of this + // Release our usage of this, free memory, unset flag self.hyperlink_set.release(self.memory, entry.value_ptr.*); - - // Free the memory map.removeByPtr(entry.key_ptr); + cell.hyperlink = false; + + // Mark that we no longer have graphemes, also search the row + // to make sure its state is correct. + const cells = row.cells.ptr(self.memory)[0..self.size.cols]; + for (cells) |c| if (c.hyperlink) return; + row.hyperlink = false; } /// Set the hyperlink for the given cell. If the cell already has a /// hyperlink, then this will handle memory management for the prior /// hyperlink. - pub fn setHyperlink(self: *Page, cell: *Cell, id: hyperlink.Id) !void { + pub fn setHyperlink(self: *Page, row: *Row, cell: *Cell, id: hyperlink.Id) !void { defer self.assertIntegrity(); const cell_offset = getOffset(Cell, self.memory, cell); @@ -904,6 +917,7 @@ pub const Page = struct { self.hyperlink_set.use(self.memory, id); gop.value_ptr.* = id; cell.hyperlink = true; + row.hyperlink = true; } /// Append a codepoint to the given cell as a grapheme. @@ -1280,11 +1294,16 @@ pub const Row = packed struct(u64) { /// At the time of writing this, the speed difference is around 4x. styled: bool = false, + /// True if any of the cells in this row are part of a hyperlink. + /// This is similar to styled: it can have false positives but never + /// false negatives. This is used to optimize hyperlink operations. + hyperlink: bool = false, + /// The semantic prompt type for this row as specified by the /// running program, or "unknown" if it was never set. semantic_prompt: SemanticPrompt = .unknown, - _padding: u25 = 0, + _padding: u24 = 0, /// Semantic prompt type. pub const SemanticPrompt = enum(u3) { From 57c5522a6ba24555180aea1231cd2397a24d906c Mon Sep 17 00:00:00 2001 From: Mitchell Hashimoto Date: Thu, 4 Jul 2024 10:00:12 -0700 Subject: [PATCH 18/58] terminal: handle moving/swapping/clearing cells with hyperlinks --- src/terminal/Screen.zig | 7 ++++ src/terminal/Terminal.zig | 79 +++++++++++++++++++++++++++++++++++++++ src/terminal/page.zig | 38 +++++++++++++++++++ 3 files changed, 124 insertions(+) diff --git a/src/terminal/Screen.zig b/src/terminal/Screen.zig index 5d138250c..2c4797841 100644 --- a/src/terminal/Screen.zig +++ b/src/terminal/Screen.zig @@ -949,6 +949,13 @@ pub fn clearCells( } } + // If we have hyperlinks, we need to clear those. + if (row.hyperlink) { + for (cells) |*cell| { + if (cell.hyperlink) page.clearHyperlink(row, cell); + } + } + if (row.styled) { for (cells) |*cell| { if (cell.style_id == style.default_id) continue; diff --git a/src/terminal/Terminal.zig b/src/terminal/Terminal.zig index 51d7a7fb1..2c39db76a 100644 --- a/src/terminal/Terminal.zig +++ b/src/terminal/Terminal.zig @@ -7643,6 +7643,85 @@ test "Terminal: insertBlanks split multi-cell character from tail" { } } +test "Terminal: insertBlanks shifts hyperlinks" { + // osc "8;;http://example.com" + // printf "link" + // printf "\r" + // csi "3@" + // echo + // + // link should be preserved, blanks should not be linked + + const alloc = testing.allocator; + var t = try init(alloc, .{ .cols = 10, .rows = 2 }); + defer t.deinit(alloc); + + try t.screen.startHyperlink("http://example.com", null); + try t.printString("ABC"); + t.setCursorPos(1, 1); + t.insertBlanks(2); + + { + const str = try t.plainString(testing.allocator); + defer testing.allocator.free(str); + try testing.expectEqualStrings(" ABC", str); + } + + // Verify all our cells have a hyperlink + for (2..5) |x| { + const list_cell = t.screen.pages.getCell(.{ .screen = .{ + .x = @intCast(x), + .y = 0, + } }).?; + const row = list_cell.row; + try testing.expect(row.hyperlink); + const cell = list_cell.cell; + try testing.expect(cell.hyperlink); + const id = list_cell.page.data.lookupHyperlink(cell).?; + try testing.expectEqual(@as(hyperlink.Id, 1), id); + } + for (0..2) |x| { + const list_cell = t.screen.pages.getCell(.{ .screen = .{ + .x = @intCast(x), + .y = 0, + } }).?; + const cell = list_cell.cell; + try testing.expect(!cell.hyperlink); + const id = list_cell.page.data.lookupHyperlink(cell); + try testing.expect(id == null); + } +} + +test "Terminal: insertBlanks pushes hyperlink off end completely" { + const alloc = testing.allocator; + var t = try init(alloc, .{ .cols = 3, .rows = 2 }); + defer t.deinit(alloc); + + try t.screen.startHyperlink("http://example.com", null); + try t.printString("ABC"); + t.setCursorPos(1, 1); + t.insertBlanks(3); + + { + const str = try t.plainString(testing.allocator); + defer testing.allocator.free(str); + try testing.expectEqualStrings("", str); + } + + for (0..3) |x| { + const list_cell = t.screen.pages.getCell(.{ .screen = .{ + .x = @intCast(x), + .y = 0, + } }).?; + const row = list_cell.row; + try testing.expect(!row.hyperlink); + const cell = list_cell.cell; + try testing.expect(!cell.hyperlink); + const id = list_cell.page.data.lookupHyperlink(cell); + try testing.expect(id == null); + } +} + test "Terminal: insert mode with space" { const alloc = testing.allocator; var t = try init(alloc, .{ .cols = 10, .rows = 2 }); diff --git a/src/terminal/page.zig b/src/terminal/page.zig index 2aa6f78e3..70b9dc582 100644 --- a/src/terminal/page.zig +++ b/src/terminal/page.zig @@ -816,6 +816,26 @@ pub const Page = struct { } } + // Hyperlinks are keyed by cell offset. + if (src.hyperlink or dst.hyperlink) { + if (src.hyperlink and !dst.hyperlink) { + self.moveHyperlink(src, dst); + } else if (!src.hyperlink and dst.hyperlink) { + self.moveHyperlink(dst, src); + } else { + // Both had hyperlinks, so we have to manually swap + const src_offset = getOffset(Cell, self.memory, src); + const dst_offset = getOffset(Cell, self.memory, dst); + var map = self.hyperlink_map.map(self.memory); + const src_entry = map.getEntry(src_offset).?; + const dst_entry = map.getEntry(dst_offset).?; + const src_value = src_entry.value_ptr.*; + const dst_value = dst_entry.value_ptr.*; + src_entry.value_ptr.* = dst_value; + dst_entry.value_ptr.* = src_value; + } + } + // Copy the metadata. Note that we do NOT have to worry about // styles because styles are keyed by ID and we're preserving the // exact ref count and row state here. @@ -920,6 +940,24 @@ pub const Page = struct { row.hyperlink = true; } + /// Move the hyperlink from one cell to another. This can't fail + /// because we avoid any allocations since we're just moving data. + /// Destination must NOT have a hyperlink. + fn moveHyperlink(self: *Page, src: *Cell, dst: *Cell) void { + if (comptime std.debug.runtime_safety) { + assert(src.hyperlink); + assert(!dst.hyperlink); + } + + const src_offset = getOffset(Cell, self.memory, src); + const dst_offset = getOffset(Cell, self.memory, dst); + var map = self.hyperlink_map.map(self.memory); + const entry = map.getEntry(src_offset).?; + const value = entry.value_ptr.*; + map.removeByPtr(entry.key_ptr); + map.putAssumeCapacity(dst_offset, value); + } + /// Append a codepoint to the given cell as a grapheme. pub fn appendGrapheme(self: *Page, row: *Row, cell: *Cell, cp: u21) Allocator.Error!void { defer self.assertIntegrity(); From 96ff17a9b4cff74a2861ae2fb4e4389052e7ea15 Mon Sep 17 00:00:00 2001 From: Mitchell Hashimoto Date: Thu, 4 Jul 2024 10:02:00 -0700 Subject: [PATCH 19/58] terminal: save/restore cursor doesn't modify hyperlink state --- src/terminal/Terminal.zig | 13 +++++++++++++ 1 file changed, 13 insertions(+) diff --git a/src/terminal/Terminal.zig b/src/terminal/Terminal.zig index 2c39db76a..7a7e4d5bc 100644 --- a/src/terminal/Terminal.zig +++ b/src/terminal/Terminal.zig @@ -8306,6 +8306,19 @@ test "Terminal: saveCursor protected pen" { try testing.expect(t.screen.cursor.protected); } +test "Terminal: saveCursor doesn't modify hyperlink state" { + const alloc = testing.allocator; + var t = try init(alloc, .{ .cols = 3, .rows = 3 }); + defer t.deinit(alloc); + + try t.screen.startHyperlink("http://example.com", null); + const id = t.screen.cursor.hyperlink_id; + t.saveCursor(); + try testing.expectEqual(id, t.screen.cursor.hyperlink_id); + try t.restoreCursor(); + try testing.expectEqual(id, t.screen.cursor.hyperlink_id); +} + test "Terminal: setProtectedMode" { const alloc = testing.allocator; var t = try init(alloc, .{ .cols = 3, .rows = 3 }); From bac1307c4b647aa4761f7fc30db8f99934a721f6 Mon Sep 17 00:00:00 2001 From: Mitchell Hashimoto Date: Thu, 4 Jul 2024 10:23:21 -0700 Subject: [PATCH 20/58] terminal: index hyperlink tests --- src/terminal/Terminal.zig | 131 ++++++++++++++++++++++++++++++++++++++ src/terminal/page.zig | 40 ++++++++++-- 2 files changed, 165 insertions(+), 6 deletions(-) diff --git a/src/terminal/Terminal.zig b/src/terminal/Terminal.zig index 7a7e4d5bc..5a74f8f66 100644 --- a/src/terminal/Terminal.zig +++ b/src/terminal/Terminal.zig @@ -5809,6 +5809,51 @@ test "Terminal: index from the bottom" { } } +test "Terminal: index scrolling with hyperlink" { + const alloc = testing.allocator; + var t = try init(alloc, .{ .cols = 2, .rows = 5 }); + defer t.deinit(alloc); + + t.setCursorPos(5, 1); + try t.screen.startHyperlink("http://example.com", null); + try t.print('A'); + t.screen.endHyperlink(); + t.cursorLeft(1); // undo moving right from 'A' + try t.index(); + try t.print('B'); + + { + const str = try t.plainString(testing.allocator); + defer testing.allocator.free(str); + try testing.expectEqualStrings("\n\n\nA\nB", str); + } + + { + const list_cell = t.screen.pages.getCell(.{ .viewport = .{ + .x = 0, + .y = 3, + } }).?; + const row = list_cell.row; + try testing.expect(row.hyperlink); + const cell = list_cell.cell; + try testing.expect(cell.hyperlink); + const id = list_cell.page.data.lookupHyperlink(cell).?; + try testing.expectEqual(@as(hyperlink.Id, 1), id); + } + { + const list_cell = t.screen.pages.getCell(.{ .viewport = .{ + .x = 0, + .y = 4, + } }).?; + const row = list_cell.row; + try testing.expect(!row.hyperlink); + const cell = list_cell.cell; + try testing.expect(!cell.hyperlink); + const id = list_cell.page.data.lookupHyperlink(cell); + try testing.expect(id == null); + } +} + test "Terminal: index outside of scrolling region" { const alloc = testing.allocator; var t = try init(alloc, .{ .cols = 2, .rows = 5 }); @@ -5935,6 +5980,92 @@ test "Terminal: index inside scroll region" { } } +test "Terminal: index bottom of scroll region with hyperlinks" { + const alloc = testing.allocator; + var t = try init(alloc, .{ .rows = 5, .cols = 5 }); + defer t.deinit(alloc); + + t.setTopAndBottomMargin(1, 2); + try t.print('A'); + try t.index(); + t.carriageReturn(); + try t.screen.startHyperlink("http://example.com", null); + try t.print('B'); + t.screen.endHyperlink(); + try t.index(); + t.carriageReturn(); + try t.print('C'); + + { + const str = try t.plainString(testing.allocator); + defer testing.allocator.free(str); + try testing.expectEqualStrings("B\nC", str); + } + + { + const list_cell = t.screen.pages.getCell(.{ .viewport = .{ + .x = 0, + .y = 0, + } }).?; + const row = list_cell.row; + try testing.expect(row.hyperlink); + const cell = list_cell.cell; + try testing.expect(cell.hyperlink); + const id = list_cell.page.data.lookupHyperlink(cell).?; + try testing.expectEqual(@as(hyperlink.Id, 1), id); + } + { + const list_cell = t.screen.pages.getCell(.{ .viewport = .{ + .x = 0, + .y = 1, + } }).?; + const row = list_cell.row; + try testing.expect(!row.hyperlink); + const cell = list_cell.cell; + try testing.expect(!cell.hyperlink); + const id = list_cell.page.data.lookupHyperlink(cell); + try testing.expect(id == null); + } +} + +test "Terminal: index bottom of scroll region clear hyperlinks" { + const alloc = testing.allocator; + var t = try init(alloc, .{ .rows = 5, .cols = 5 }); + defer t.deinit(alloc); + + t.setTopAndBottomMargin(1, 2); + try t.screen.startHyperlink("http://example.com", null); + try t.print('A'); + t.screen.endHyperlink(); + try t.index(); + t.carriageReturn(); + try t.print('B'); + try t.index(); + t.carriageReturn(); + try t.print('C'); + + { + const str = try t.plainString(testing.allocator); + defer testing.allocator.free(str); + try testing.expectEqualStrings("B\nC", str); + } + + for (0..2) |y| { + const list_cell = t.screen.pages.getCell(.{ .viewport = .{ + .x = 0, + .y = @intCast(y), + } }).?; + const row = list_cell.row; + try testing.expect(!row.hyperlink); + const cell = list_cell.cell; + try testing.expect(!cell.hyperlink); + const id = list_cell.page.data.lookupHyperlink(cell); + try testing.expect(id == null); + const page = &list_cell.page.data; + try testing.expectEqual(0, page.hyperlink_set.count()); + } +} + test "Terminal: index bottom of scroll region with background SGR" { const alloc = testing.allocator; var t = try init(alloc, .{ .rows = 5, .cols = 5 }); diff --git a/src/terminal/page.zig b/src/terminal/page.zig index 70b9dc582..c2c099013 100644 --- a/src/terminal/page.zig +++ b/src/terminal/page.zig @@ -615,9 +615,7 @@ pub const Page = struct { // If our destination has styles or graphemes then we need to // clear some state. - if (dst_row.grapheme or dst_row.styled) { - self.clearCells(dst_row, x_start, x_end); - } + if (dst_row.managedMemory()) self.clearCells(dst_row, x_start, x_end); // Copy all the row metadata but keep our cells offset dst_row.* = copy: { @@ -640,7 +638,7 @@ pub const Page = struct { // If we have no managed memory in the source, then we can just // copy it directly. - if (!src_row.grapheme and !src_row.styled) { + if (!src_row.managedMemory()) { fastmem.copy(Cell, cells, other_cells); } else { // We have managed memory, so we have to do a slower copy to @@ -655,6 +653,26 @@ pub const Page = struct { const cps = other.lookupGrapheme(src_cell).?; for (cps) |cp| try self.appendGrapheme(dst_row, dst_cell, cp); } + if (src_cell.hyperlink) hyperlink: { + dst_row.hyperlink = true; + + // Fast-path: same page we can move it directly + if (other == self) { + self.moveHyperlink(src_cell, dst_cell); + break :hyperlink; + } + + // Slow-path: get the hyperlink from the other page, + // add it, and migrate. + const id = other.lookupHyperlink(src_cell).?; + const other_link = other.hyperlink_set.get(other.memory, id); + const dst_id = try self.hyperlink_set.addContext( + self.memory, + other_link.*, + .{ .page = self }, + ); + try self.setHyperlink(dst_row, dst_cell, dst_id); + } if (src_cell.style_id != style.default_id) { dst_row.styled = true; @@ -668,8 +686,12 @@ pub const Page = struct { // Slow path: Get the style from the other // page and add it to this page's style set. - const other_style = other.styles.get(other.memory, src_cell.style_id).*; - if (try self.styles.addWithId(self.memory, other_style, src_cell.style_id)) |id| { + const other_style = other.styles.get(other.memory, src_cell.style_id); + if (try self.styles.addWithId( + self.memory, + other_style.*, + src_cell.style_id, + )) |id| { dst_cell.style_id = id; } } @@ -1365,6 +1387,12 @@ pub const Row = packed struct(u64) { return self == .prompt or self == .prompt_continuation or self == .input; } }; + + /// Returns true if this row has any managed memory outside of the + /// row structure (graphemes, styles, etc.) + fn managedMemory(self: Row) bool { + return self.grapheme or self.styled or self.hyperlink; + } }; /// A cell represents a single terminal grid cell. From 84edaed690d120fa4a091b0c7731af2eee6476e9 Mon Sep 17 00:00:00 2001 From: Mitchell Hashimoto Date: Thu, 4 Jul 2024 10:36:41 -0700 Subject: [PATCH 21/58] terminal: scrollDown with hyperlinks --- src/terminal/Terminal.zig | 157 ++++++++++++++++++++++++++++++++++++++ src/terminal/page.zig | 39 +++++----- 2 files changed, 175 insertions(+), 21 deletions(-) diff --git a/src/terminal/Terminal.zig b/src/terminal/Terminal.zig index 5a74f8f66..c7b514d1a 100644 --- a/src/terminal/Terminal.zig +++ b/src/terminal/Terminal.zig @@ -5187,6 +5187,57 @@ test "Terminal: scrollDown simple" { } } +test "Terminal: scrollDown hyperlink moves" { + const alloc = testing.allocator; + var t = try init(alloc, .{ .rows = 5, .cols = 5 }); + defer t.deinit(alloc); + + try t.screen.startHyperlink("http://example.com", null); + try t.printString("ABC"); + t.screen.endHyperlink(); + t.carriageReturn(); + try t.linefeed(); + try t.printString("DEF"); + t.carriageReturn(); + try t.linefeed(); + try t.printString("GHI"); + t.setCursorPos(2, 2); + t.scrollDown(1); + + { + const str = try t.plainString(testing.allocator); + defer testing.allocator.free(str); + try testing.expectEqualStrings("\nABC\nDEF\nGHI", str); + } + + for (0..3) |x| { + const list_cell = t.screen.pages.getCell(.{ .viewport = .{ + .x = @intCast(x), + .y = 1, + } }).?; + const row = list_cell.row; + try testing.expect(row.hyperlink); + const cell = list_cell.cell; + try testing.expect(cell.hyperlink); + const id = list_cell.page.data.lookupHyperlink(cell).?; + try testing.expectEqual(@as(hyperlink.Id, 1), id); + const page = &list_cell.page.data; + try testing.expectEqual(1, page.hyperlink_set.count()); + } + for (0..3) |x| { + const list_cell = t.screen.pages.getCell(.{ .viewport = .{ + .x = @intCast(x), + .y = 0, + } }).?; + const row = list_cell.row; + try testing.expect(!row.hyperlink); + const cell = list_cell.cell; + try testing.expect(!cell.hyperlink); + const id = list_cell.page.data.lookupHyperlink(cell); + try testing.expect(id == null); + } +} + test "Terminal: scrollDown outside of scroll region" { const alloc = testing.allocator; var t = try init(alloc, .{ .rows = 5, .cols = 5 }); @@ -5256,6 +5307,112 @@ test "Terminal: scrollDown left/right scroll region" { } } +test "Terminal: scrollDown left/right scroll region hyperlink" { + const alloc = testing.allocator; + var t = try init(alloc, .{ .cols = 10, .rows = 10 }); + defer t.deinit(alloc); + + try t.screen.startHyperlink("http://example.com", null); + try t.printString("ABC123"); + t.screen.endHyperlink(); + t.carriageReturn(); + try t.linefeed(); + try t.printString("DEF456"); + t.carriageReturn(); + try t.linefeed(); + try t.printString("GHI789"); + t.scrolling_region.left = 1; + t.scrolling_region.right = 3; + t.setCursorPos(2, 2); + t.scrollDown(1); + + { + const str = try t.plainString(testing.allocator); + defer testing.allocator.free(str); + try testing.expectEqualStrings("A 23\nDBC156\nGEF489\n HI7", str); + } + + // First row preserves hyperlink where we didn't scroll + { + for (0..1) |x| { + const list_cell = t.screen.pages.getCell(.{ .viewport = .{ + .x = @intCast(x), + .y = 0, + } }).?; + const row = list_cell.row; + try testing.expect(row.hyperlink); + const cell = list_cell.cell; + try testing.expect(cell.hyperlink); + const id = list_cell.page.data.lookupHyperlink(cell).?; + try testing.expectEqual(@as(hyperlink.Id, 1), id); + const page = &list_cell.page.data; + try testing.expectEqual(1, page.hyperlink_set.count()); + } + for (1..4) |x| { + const list_cell = t.screen.pages.getCell(.{ .viewport = .{ + .x = @intCast(x), + .y = 0, + } }).?; + const cell = list_cell.cell; + try testing.expect(!cell.hyperlink); + const id = list_cell.page.data.lookupHyperlink(cell); + try testing.expect(id == null); + } + for (4..6) |x| { + const list_cell = t.screen.pages.getCell(.{ .viewport = .{ + .x = @intCast(x), + .y = 0, + } }).?; + const row = list_cell.row; + try testing.expect(row.hyperlink); + const cell = list_cell.cell; + try testing.expect(cell.hyperlink); + const id = list_cell.page.data.lookupHyperlink(cell).?; + try testing.expectEqual(@as(hyperlink.Id, 1), id); + const page = &list_cell.page.data; + try testing.expectEqual(1, page.hyperlink_set.count()); + } + } + + // Second row gets some hyperlinks + { + for (0..1) |x| { + const list_cell = t.screen.pages.getCell(.{ .viewport = .{ + .x = @intCast(x), + .y = 1, + } }).?; + const cell = list_cell.cell; + try testing.expect(!cell.hyperlink); + const id = list_cell.page.data.lookupHyperlink(cell); + try testing.expect(id == null); + } + for (1..4) |x| { + const list_cell = t.screen.pages.getCell(.{ .viewport = .{ + .x = @intCast(x), + .y = 1, + } }).?; + const row = list_cell.row; + try testing.expect(row.hyperlink); + const cell = list_cell.cell; + try testing.expect(cell.hyperlink); + const id = list_cell.page.data.lookupHyperlink(cell).?; + try testing.expectEqual(@as(hyperlink.Id, 1), id); + const page = &list_cell.page.data; + try testing.expectEqual(1, page.hyperlink_set.count()); + } + for (4..6) |x| { + const list_cell = t.screen.pages.getCell(.{ .viewport = .{ + .x = @intCast(x), + .y = 1, + } }).?; + const cell = list_cell.cell; + try testing.expect(!cell.hyperlink); + const id = list_cell.page.data.lookupHyperlink(cell); + try testing.expect(id == null); + } + } +} + test "Terminal: scrollDown outside of left/right scroll region" { const alloc = testing.allocator; var t = try init(alloc, .{ .cols = 10, .rows = 10 }); diff --git a/src/terminal/page.zig b/src/terminal/page.zig index c2c099013..7a6a76a98 100644 --- a/src/terminal/page.zig +++ b/src/terminal/page.zig @@ -764,31 +764,28 @@ pub const Page = struct { // Clear our destination now matter what self.clearCells(dst_row, dst_left, dst_left + len); - // If src has no graphemes, this is very fast because we can - // just copy the cells directly because every other attribute - // is position-independent. - const src_grapheme = src_row.grapheme or grapheme: { - for (src_cells) |c| if (c.hasGrapheme()) break :grapheme true; - break :grapheme false; - }; - if (!src_grapheme) { + // If src has no managed memory, this is very fast. + if (!src_row.managedMemory()) { fastmem.copy(Cell, dst_cells, src_cells); } else { - // Source has graphemes, meaning we have to do a slower - // cell by cell copy. + // Source has graphemes or hyperlinks... for (src_cells, dst_cells) |*src, *dst| { dst.* = src.*; - if (!src.hasGrapheme()) continue; - - // Required for moveGrapheme assertions - dst.content_tag = .codepoint; - self.moveGrapheme(src, dst); - src.content_tag = .codepoint; - dst.content_tag = .codepoint_grapheme; + if (src.hasGrapheme()) { + // Required for moveGrapheme assertions + dst.content_tag = .codepoint; + self.moveGrapheme(src, dst); + src.content_tag = .codepoint; + dst.content_tag = .codepoint_grapheme; + dst_row.grapheme = true; + } + if (src.hyperlink) { + dst.hyperlink = false; + self.moveHyperlink(src, dst); + dst.hyperlink = true; + dst_row.hyperlink = true; + } } - - // The destination row must be marked - dst_row.grapheme = true; } // The destination row has styles if any of the cells are styled @@ -805,6 +802,7 @@ pub const Page = struct { @memset(@as([]u64, @ptrCast(src_cells)), 0); if (src_cells.len == self.size.cols) { src_row.grapheme = false; + src_row.hyperlink = false; src_row.styled = false; } } @@ -888,7 +886,6 @@ pub const Page = struct { } if (row.hyperlink) { - row.hyperlink = false; for (cells) |*cell| { if (cell.hyperlink) self.clearHyperlink(row, cell); } From d9e654da4a9e55a0a2900d430cd65b308dfca628 Mon Sep 17 00:00:00 2001 From: Mitchell Hashimoto Date: Thu, 4 Jul 2024 10:41:16 -0700 Subject: [PATCH 22/58] terminal: scrollUp hyperlink tests --- src/terminal/Terminal.zig | 194 ++++++++++++++++++++++++++++++++++++++ 1 file changed, 194 insertions(+) diff --git a/src/terminal/Terminal.zig b/src/terminal/Terminal.zig index c7b514d1a..55e361f09 100644 --- a/src/terminal/Terminal.zig +++ b/src/terminal/Terminal.zig @@ -5018,6 +5018,94 @@ test "Terminal: scrollUp simple" { } } +test "Terminal: scrollUp moves hyperlink" { + const alloc = testing.allocator; + var t = try init(alloc, .{ .rows = 5, .cols = 5 }); + defer t.deinit(alloc); + + try t.printString("ABC"); + t.carriageReturn(); + try t.linefeed(); + try t.screen.startHyperlink("http://example.com", null); + try t.printString("DEF"); + t.screen.endHyperlink(); + t.carriageReturn(); + try t.linefeed(); + try t.printString("GHI"); + t.setCursorPos(2, 2); + t.scrollUp(1); + + { + const str = try t.plainString(testing.allocator); + defer testing.allocator.free(str); + try testing.expectEqualStrings("DEF\nGHI", str); + } + + for (0..3) |x| { + const list_cell = t.screen.pages.getCell(.{ .viewport = .{ + .x = @intCast(x), + .y = 0, + } }).?; + const row = list_cell.row; + try testing.expect(row.hyperlink); + const cell = list_cell.cell; + try testing.expect(cell.hyperlink); + const id = list_cell.page.data.lookupHyperlink(cell).?; + try testing.expectEqual(@as(hyperlink.Id, 1), id); + const page = &list_cell.page.data; + try testing.expectEqual(1, page.hyperlink_set.count()); + } + for (0..3) |x| { + const list_cell = t.screen.pages.getCell(.{ .viewport = .{ + .x = @intCast(x), + .y = 1, + } }).?; + const row = list_cell.row; + try testing.expect(!row.hyperlink); + const cell = list_cell.cell; + try testing.expect(!cell.hyperlink); + const id = list_cell.page.data.lookupHyperlink(cell); + try testing.expect(id == null); + } +} + +test "Terminal: scrollUp clears hyperlink" { + const alloc = testing.allocator; + var t = try init(alloc, .{ .rows = 5, .cols = 5 }); + defer t.deinit(alloc); + + try t.screen.startHyperlink("http://example.com", null); + try t.printString("ABC"); + t.screen.endHyperlink(); + t.carriageReturn(); + try t.linefeed(); + try t.printString("DEF"); + t.carriageReturn(); + try t.linefeed(); + try t.printString("GHI"); + t.setCursorPos(2, 2); + t.scrollUp(1); + + { + const str = try t.plainString(testing.allocator); + defer testing.allocator.free(str); + try testing.expectEqualStrings("DEF\nGHI", str); + } + + for (0..3) |x| { + const list_cell = t.screen.pages.getCell(.{ .viewport = .{ + .x = @intCast(x), + .y = 0, + } }).?; + const row = list_cell.row; + try testing.expect(!row.hyperlink); + const cell = list_cell.cell; + try testing.expect(!cell.hyperlink); + const id = list_cell.page.data.lookupHyperlink(cell); + try testing.expect(id == null); + } +} + test "Terminal: scrollUp top/bottom scroll region" { const alloc = testing.allocator; var t = try init(alloc, .{ .rows = 5, .cols = 5 }); @@ -5081,6 +5169,112 @@ test "Terminal: scrollUp left/right scroll region" { } } +test "Terminal: scrollUp left/right scroll region hyperlink" { + const alloc = testing.allocator; + var t = try init(alloc, .{ .cols = 10, .rows = 10 }); + defer t.deinit(alloc); + + try t.printString("ABC123"); + t.carriageReturn(); + try t.linefeed(); + try t.screen.startHyperlink("http://example.com", null); + try t.printString("DEF456"); + t.screen.endHyperlink(); + t.carriageReturn(); + try t.linefeed(); + try t.printString("GHI789"); + t.scrolling_region.left = 1; + t.scrolling_region.right = 3; + t.setCursorPos(2, 2); + t.scrollUp(1); + + { + const str = try t.plainString(testing.allocator); + defer testing.allocator.free(str); + try testing.expectEqualStrings("AEF423\nDHI756\nG 89", str); + } + + // First row gets some hyperlinks + { + for (0..1) |x| { + const list_cell = t.screen.pages.getCell(.{ .viewport = .{ + .x = @intCast(x), + .y = 0, + } }).?; + const cell = list_cell.cell; + try testing.expect(!cell.hyperlink); + const id = list_cell.page.data.lookupHyperlink(cell); + try testing.expect(id == null); + } + for (1..4) |x| { + const list_cell = t.screen.pages.getCell(.{ .viewport = .{ + .x = @intCast(x), + .y = 0, + } }).?; + const row = list_cell.row; + try testing.expect(row.hyperlink); + const cell = list_cell.cell; + try testing.expect(cell.hyperlink); + const id = list_cell.page.data.lookupHyperlink(cell).?; + try testing.expectEqual(@as(hyperlink.Id, 1), id); + const page = &list_cell.page.data; + try testing.expectEqual(1, page.hyperlink_set.count()); + } + for (4..6) |x| { + const list_cell = t.screen.pages.getCell(.{ .viewport = .{ + .x = @intCast(x), + .y = 0, + } }).?; + const cell = list_cell.cell; + try testing.expect(!cell.hyperlink); + const id = list_cell.page.data.lookupHyperlink(cell); + try testing.expect(id == null); + } + } + + // Second row preserves hyperlink where we didn't scroll + { + for (0..1) |x| { + const list_cell = t.screen.pages.getCell(.{ .viewport = .{ + .x = @intCast(x), + .y = 1, + } }).?; + const row = list_cell.row; + try testing.expect(row.hyperlink); + const cell = list_cell.cell; + try testing.expect(cell.hyperlink); + const id = list_cell.page.data.lookupHyperlink(cell).?; + try testing.expectEqual(@as(hyperlink.Id, 1), id); + const page = &list_cell.page.data; + try testing.expectEqual(1, page.hyperlink_set.count()); + } + for (1..4) |x| { + const list_cell = t.screen.pages.getCell(.{ .viewport = .{ + .x = @intCast(x), + .y = 1, + } }).?; + const cell = list_cell.cell; + try testing.expect(!cell.hyperlink); + const id = list_cell.page.data.lookupHyperlink(cell); + try testing.expect(id == null); + } + for (4..6) |x| { + const list_cell = t.screen.pages.getCell(.{ .viewport = .{ + .x = @intCast(x), + .y = 1, + } }).?; + const row = list_cell.row; + try testing.expect(row.hyperlink); + const cell = list_cell.cell; + try testing.expect(cell.hyperlink); + const id = list_cell.page.data.lookupHyperlink(cell).?; + try testing.expectEqual(@as(hyperlink.Id, 1), id); + const page = &list_cell.page.data; + try testing.expectEqual(1, page.hyperlink_set.count()); + } + } +} + test "Terminal: scrollUp preserves pending wrap" { const alloc = testing.allocator; var t = try init(alloc, .{ .rows = 5, .cols = 5 }); From f920068ce60694fecc39a9b3574df48bc70f8693 Mon Sep 17 00:00:00 2001 From: Mitchell Hashimoto Date: Thu, 4 Jul 2024 10:47:11 -0700 Subject: [PATCH 23/58] terminal: full reset clears OSC8 state --- src/terminal/Terminal.zig | 10 ++++++++++ 1 file changed, 10 insertions(+) diff --git a/src/terminal/Terminal.zig b/src/terminal/Terminal.zig index 55e361f09..d11b10788 100644 --- a/src/terminal/Terminal.zig +++ b/src/terminal/Terminal.zig @@ -2541,6 +2541,7 @@ pub fn fullReset(self: *Terminal) void { log.warn("restore cursor on primary screen failed err={}", .{err}); }; + self.screen.endHyperlink(); self.screen.charset = .{}; self.modes = .{}; self.flags = .{}; @@ -9959,6 +9960,15 @@ test "Terminal: fullReset with a non-empty pen" { try testing.expectEqual(@as(style.Id, 0), t.screen.cursor.style_id); } +test "Terminal: fullReset hyperlink" { + var t = try init(testing.allocator, .{ .cols = 80, .rows = 80 }); + defer t.deinit(testing.allocator); + + try t.screen.startHyperlink("http://example.com", null); + t.fullReset(); + try testing.expectEqual(0, t.screen.cursor.hyperlink_id); +} + test "Terminal: fullReset with a non-empty saved cursor" { var t = try init(testing.allocator, .{ .cols = 80, .rows = 80 }); defer t.deinit(testing.allocator); From 245314b14ef2bd32162fa30c23675a0250b6fa81 Mon Sep 17 00:00:00 2001 From: Mitchell Hashimoto Date: Thu, 4 Jul 2024 10:49:53 -0700 Subject: [PATCH 24/58] termio: hook up OSC8 --- src/terminal/stream.zig | 11 ++++++++--- src/termio/Exec.zig | 8 ++++++++ 2 files changed, 16 insertions(+), 3 deletions(-) diff --git a/src/terminal/stream.zig b/src/terminal/stream.zig index 05d6734ac..6706ce1d1 100644 --- a/src/terminal/stream.zig +++ b/src/terminal/stream.zig @@ -1335,12 +1335,17 @@ pub fn Stream(comptime Handler: type) type { }, .hyperlink_start => |v| { - _ = v; - @panic("TODO(osc8)"); + if (@hasDecl(T, "startHyperlink")) { + try self.handler.startHyperlink(v.uri, v.id); + return; + } else log.warn("unimplemented OSC callback: {}", .{cmd}); }, .hyperlink_end => { - @panic("TODO(osc8)"); + if (@hasDecl(T, "endHyperlink")) { + try self.handler.endHyperlink(); + return; + } else log.warn("unimplemented OSC callback: {}", .{cmd}); }, } diff --git a/src/termio/Exec.zig b/src/termio/Exec.zig index aeff1f0dd..8c6212554 100644 --- a/src/termio/Exec.zig +++ b/src/termio/Exec.zig @@ -2358,6 +2358,14 @@ const StreamHandler = struct { } } + pub fn startHyperlink(self: *StreamHandler, uri: []const u8, id: ?[]const u8) !void { + try self.terminal.screen.startHyperlink(uri, id); + } + + pub fn endHyperlink(self: *StreamHandler) !void { + self.terminal.screen.endHyperlink(); + } + pub fn deviceAttributes( self: *StreamHandler, req: terminal.DeviceAttributeReq, From 365567b3c6d0ec5c451f4705c3d8ea8ee4f99264 Mon Sep 17 00:00:00 2001 From: Mitchell Hashimoto Date: Thu, 4 Jul 2024 10:54:45 -0700 Subject: [PATCH 25/58] terminal: increase std cap for now until we implement resize --- src/terminal/page.zig | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/terminal/page.zig b/src/terminal/page.zig index 7a6a76a98..55924e73f 100644 --- a/src/terminal/page.zig +++ b/src/terminal/page.zig @@ -1231,8 +1231,8 @@ pub const std_capacity: Capacity = .{ .cols = 215, .rows = 215, .styles = 128, - .hyperlink_cells = 32, // TODO: think about these numbers - .hyperlink_entries = 4, + .hyperlink_cells = 64, // TODO: think about these numbers + .hyperlink_entries = 32, .grapheme_bytes = 8192, .string_bytes = 2048, }; From d7e089e2aafab5a09f12bfe6e4db27786be0d480 Mon Sep 17 00:00:00 2001 From: Mitchell Hashimoto Date: Thu, 4 Jul 2024 14:29:09 -0700 Subject: [PATCH 26/58] terminal: simplify hyperlink capacity --- src/terminal/page.zig | 51 +++++++++++++++++++++----------- src/terminal/ref_counted_set.zig | 10 +++++++ 2 files changed, 43 insertions(+), 18 deletions(-) diff --git a/src/terminal/page.zig b/src/terminal/page.zig index 55924e73f..37b7c6458 100644 --- a/src/terminal/page.zig +++ b/src/terminal/page.zig @@ -53,6 +53,15 @@ const StringAlloc = BitmapAllocator(string_chunk); const string_count_default = StringAlloc.bitmap_bit_size; const string_bytes_default = string_count_default * string_chunk; +/// Default number of hyperlinks we support. +/// +/// The cell multiplier is the number of cells per hyperlink entry that +/// we support. A hyperlink can be longer than this multiplier; the multiplier +/// just sets the total capacity to simplify adjustable size metrics. +const hyperlink_count_default = 32; +const hyperlink_bytes_default = hyperlink_count_default * @sizeOf(hyperlink.Set.Item); +const hyperlink_cell_multiplier = 16; + /// A page represents a specific section of terminal screen. The primary /// idea of a page is that it is a fully self-contained unit that can be /// serialized, copied, etc. as a convenient way to represent a section @@ -1187,15 +1196,24 @@ pub const Page = struct { const string_start = alignForward(usize, grapheme_map_end, StringAlloc.base_align); const string_end = string_start + string_layout.total_size; - const hyperlink_map_layout = hyperlink.Map.layout(@intCast(cap.hyperlink_cells)); - const hyperlink_map_start = alignForward(usize, string_end, hyperlink.Map.base_align); - const hyperlink_map_end = hyperlink_map_start + hyperlink_map_layout.total_size; - - const hyperlink_set_layout = hyperlink.Set.layout(@intCast(cap.hyperlink_entries)); - const hyperlink_set_start = alignForward(usize, hyperlink_map_end, hyperlink.Set.base_align); + const hyperlink_count = @divFloor(cap.hyperlink_bytes, @sizeOf(hyperlink.Set.Item)); + const hyperlink_set_layout = hyperlink.Set.layout(@intCast(hyperlink_count)); + const hyperlink_set_start = alignForward(usize, string_end, hyperlink.Set.base_align); const hyperlink_set_end = hyperlink_set_start + hyperlink_set_layout.total_size; - const total_size = alignForward(usize, hyperlink_set_end, std.mem.page_size); + const hyperlink_map_count: u32 = count: { + if (hyperlink_count == 0) break :count 0; + const mult = std.math.cast( + u32, + hyperlink_count * hyperlink_cell_multiplier, + ) orelse break :count std.math.maxInt(u32); + break :count std.math.ceilPowerOfTwoAssert(u32, mult); + }; + const hyperlink_map_layout = hyperlink.Map.layout(hyperlink_map_count); + const hyperlink_map_start = alignForward(usize, hyperlink_set_end, hyperlink.Map.base_align); + const hyperlink_map_end = hyperlink_map_start + hyperlink_map_layout.total_size; + + const total_size = alignForward(usize, hyperlink_map_end, std.mem.page_size); return .{ .total_size = total_size, @@ -1231,10 +1249,7 @@ pub const std_capacity: Capacity = .{ .cols = 215, .rows = 215, .styles = 128, - .hyperlink_cells = 64, // TODO: think about these numbers - .hyperlink_entries = 32, .grapheme_bytes = 8192, - .string_bytes = 2048, }; /// The size of this page. @@ -1252,11 +1267,11 @@ pub const Capacity = struct { /// Number of unique styles that can be used on this page. styles: usize = 16, - /// The cells is the number of cells in the terminal that can have - /// a hyperlink and the entries is the number of unique hyperlinks - /// itself. - hyperlink_cells: usize = 32, - hyperlink_entries: usize = 4, + /// Number of bytes to allocate for hyperlink data. Note that the + /// amount of data used for hyperlinks in total is more than this because + /// hyperlinks use string data as well as a small amount of lookup metadata. + /// This number is a rough approximation. + hyperlink_bytes: usize = hyperlink_bytes_default, /// Number of bytes to allocate for grapheme data. grapheme_bytes: usize = grapheme_bytes_default, @@ -1289,9 +1304,9 @@ pub const Capacity = struct { // for rows & cells (which will allow us to calculate the number of // rows we can fit at a certain column width) we need to layout the // "meta" members of the page (i.e. everything else) from the end. - const hyperlink_set_start = alignBackward(usize, layout.total_size - layout.hyperlink_set_layout.total_size, hyperlink.Set.base_align); - const hyperlink_map_start = alignBackward(usize, hyperlink_set_start - layout.hyperlink_map_layout.total_size, hyperlink.Map.base_align); - const string_alloc_start = alignBackward(usize, hyperlink_map_start - layout.string_alloc_layout.total_size, StringAlloc.base_align); + const hyperlink_map_start = alignBackward(usize, layout.total_size - layout.hyperlink_map_layout.total_size, hyperlink.Map.base_align); + const hyperlink_set_start = alignBackward(usize, hyperlink_map_start - layout.hyperlink_set_layout.total_size, hyperlink.Set.base_align); + const string_alloc_start = alignBackward(usize, hyperlink_set_start - layout.string_alloc_layout.total_size, StringAlloc.base_align); const grapheme_map_start = alignBackward(usize, string_alloc_start - layout.grapheme_map_layout.total_size, GraphemeMap.base_align); const grapheme_alloc_start = alignBackward(usize, grapheme_map_start - layout.grapheme_alloc_layout.total_size, GraphemeAlloc.base_align); const styles_start = alignBackward(usize, grapheme_alloc_start - layout.styles_layout.total_size, style.Set.base_align); diff --git a/src/terminal/ref_counted_set.zig b/src/terminal/ref_counted_set.zig index 0fcb84b57..3d4ef663b 100644 --- a/src/terminal/ref_counted_set.zig +++ b/src/terminal/ref_counted_set.zig @@ -141,6 +141,16 @@ pub fn RefCountedSet( assert(cap <= @as(usize, @intCast(std.math.maxInt(Id))) + 1); + // Zero-cap set is valid, return special case + if (cap == 0) return .{ + .cap = 0, + .table_cap = 0, + .table_mask = 0, + .table_start = 0, + .items_start = 0, + .total_size = 0, + }; + const table_cap: usize = std.math.ceilPowerOfTwoAssert(usize, cap); const items_cap: usize = @intFromFloat(load_factor * @as(f64, @floatFromInt(table_cap))); From 961a4b6b31e20d4aaa561a367168e85ed729ee5e Mon Sep 17 00:00:00 2001 From: Mitchell Hashimoto Date: Thu, 4 Jul 2024 14:50:47 -0700 Subject: [PATCH 27/58] terminal: support page oom with hyperlinks --- src/terminal/PageList.zig | 57 ++++++++++++++++++++++++++++++++++++ src/terminal/Screen.zig | 61 +++++++++++++++++++++++++++++++++++---- src/terminal/page.zig | 4 ++- 3 files changed, 116 insertions(+), 6 deletions(-) diff --git a/src/terminal/PageList.zig b/src/terminal/PageList.zig index 447c8b622..e339f66ba 100644 --- a/src/terminal/PageList.zig +++ b/src/terminal/PageList.zig @@ -1849,6 +1849,12 @@ pub const AdjustCapacity = struct { /// Adjust the number of available grapheme bytes in the page. grapheme_bytes: ?usize = null, + + /// Adjust the number of available hyperlink bytes in the page. + hyperlink_bytes: ?usize = null, + + /// Adjust the number of available string bytes in the page. + string_bytes: ?usize = null, }; /// Adjust the capcaity of the given page in the list. This should @@ -1884,6 +1890,14 @@ pub fn adjustCapacity( const aligned = try std.math.ceilPowerOfTwo(usize, v); cap.grapheme_bytes = @max(cap.grapheme_bytes, aligned); } + if (adjustment.hyperlink_bytes) |v| { + const aligned = try std.math.ceilPowerOfTwo(usize, v); + cap.hyperlink_bytes = @max(cap.hyperlink_bytes, aligned); + } + if (adjustment.string_bytes) |v| { + const aligned = try std.math.ceilPowerOfTwo(usize, v); + cap.string_bytes = @max(cap.string_bytes, aligned); + } log.info("adjusting page capacity={}", .{cap}); @@ -4040,6 +4054,49 @@ test "PageList adjustCapacity to increase graphemes" { } } +test "PageList adjustCapacity to increase hyperlinks" { + const testing = std.testing; + const alloc = testing.allocator; + + var s = try init(alloc, 2, 2, 0); + defer s.deinit(); + { + try testing.expect(s.pages.first == s.pages.last); + const page = &s.pages.first.?.data; + + // Write all our data so we can assert its the same after + for (0..s.rows) |y| { + for (0..s.cols) |x| { + const rac = page.getRowAndCell(x, y); + rac.cell.* = .{ + .content_tag = .codepoint, + .content = .{ .codepoint = @intCast(x) }, + }; + } + } + } + + // Increase our graphemes + _ = try s.adjustCapacity( + s.pages.first.?, + .{ .hyperlink_bytes = @max(std_capacity.hyperlink_bytes * 2, 2048) }, + ); + + { + try testing.expect(s.pages.first == s.pages.last); + const page = &s.pages.first.?.data; + for (0..s.rows) |y| { + for (0..s.cols) |x| { + const rac = page.getRowAndCell(x, y); + try testing.expectEqual( + @as(u21, @intCast(x)), + rac.cell.content.codepoint, + ); + } + } + } +} + test "PageList pageIterator single page" { const testing = std.testing; const alloc = testing.allocator; diff --git a/src/terminal/Screen.zig b/src/terminal/Screen.zig index 2c4797841..885412768 100644 --- a/src/terminal/Screen.zig +++ b/src/terminal/Screen.zig @@ -1380,19 +1380,65 @@ pub fn startHyperlink( self: *Screen, uri: []const u8, id_: ?[]const u8, +) !void { + // Loop until we have enough page memory to add the hyperlink + while (true) { + if (self.startHyperlinkOnce(uri, id_)) { + return; + } else |err| switch (err) { + // An actual self.alloc OOM is a fatal error. + error.RealOutOfMemory => return error.OutOfMemory, + + // strings table is out of memory, adjust it up + error.StringsOutOfMemory => _ = try self.pages.adjustCapacity( + self.cursor.page_pin.page, + .{ .string_bytes = self.cursor.page_pin.page.data.capacity.string_bytes * 2 }, + ), + + // hyperlink set is out of memory, adjust it up + error.SetOutOfMemory => _ = try self.pages.adjustCapacity( + self.cursor.page_pin.page, + .{ .hyperlink_bytes = self.cursor.page_pin.page.data.capacity.hyperlink_bytes * 2 }, + ), + + // hyperlink set is too full, rehash it + error.SetNeedsRehash => _ = try self.pages.adjustCapacity( + self.cursor.page_pin.page, + .{}, + ), + } + + // If we get here, we adjusted capacity so our page has changed + // so we need to reload the cursor pins. + self.cursorReload(); + self.assertIntegrity(); + } +} + +/// This is like startHyperlink but if we have to adjust page capacities +/// this returns error.PageAdjusted. This is useful so that we unwind +/// all the previous state and try again. +fn startHyperlinkOnce( + 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_); + const link = Hyperlink.create(self.alloc, uri, id_) catch |err| switch (err) { + error.OutOfMemory => return error.RealOutOfMemory, + }; errdefer link.destroy(self.alloc); // 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); + const buf = string_alloc.alloc(u8, page.memory, uri.len) catch |err| switch (err) { + error.OutOfMemory => return error.StringsOutOfMemory, + }; errdefer string_alloc.free(page.memory, buf); @memcpy(buf, uri); @@ -1408,7 +1454,9 @@ pub fn startHyperlink( // 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); + const buf = string_alloc.alloc(u8, page.memory, id.len) catch |err| switch (err) { + error.OutOfMemory => return error.StringsOutOfMemory, + }; errdefer string_alloc.free(page.memory, buf); @memcpy(buf, id); @@ -1431,11 +1479,14 @@ pub fn startHyperlink( }; // Put our hyperlink into the hyperlink set to get an ID - const id = try page.hyperlink_set.addContext( + const id = page.hyperlink_set.addContext( page.memory, .{ .id = page_id, .uri = page_uri }, .{ .page = page }, - ); + ) catch |err| switch (err) { + error.OutOfMemory => return error.SetOutOfMemory, + error.NeedsRehash => return error.SetNeedsRehash, + }; errdefer page.hyperlink_set.release(page.memory, id); // Save it all diff --git a/src/terminal/page.zig b/src/terminal/page.zig index 37b7c6458..0ee041fa0 100644 --- a/src/terminal/page.zig +++ b/src/terminal/page.zig @@ -58,7 +58,7 @@ const string_bytes_default = string_count_default * string_chunk; /// The cell multiplier is the number of cells per hyperlink entry that /// we support. A hyperlink can be longer than this multiplier; the multiplier /// just sets the total capacity to simplify adjustable size metrics. -const hyperlink_count_default = 32; +const hyperlink_count_default = 4; const hyperlink_bytes_default = hyperlink_count_default * @sizeOf(hyperlink.Set.Item); const hyperlink_cell_multiplier = 16; @@ -681,6 +681,8 @@ pub const Page = struct { .{ .page = self }, ); try self.setHyperlink(dst_row, dst_cell, dst_id); + + // TODO: copy the strings } if (src_cell.style_id != style.default_id) { dst_row.styled = true; From f8fe0445a5baca815d9dd4fc70b9b41011093fab Mon Sep 17 00:00:00 2001 From: Mitchell Hashimoto Date: Thu, 4 Jul 2024 15:08:29 -0700 Subject: [PATCH 28/58] core: clicking OSC8 links work --- src/Surface.zig | 40 +++++++++++++++++++++++++++++++++------- src/input/Link.zig | 4 ++++ 2 files changed, 37 insertions(+), 7 deletions(-) diff --git a/src/Surface.zig b/src/Surface.zig index bb90841f8..286de2625 100644 --- a/src/Surface.zig +++ b/src/Surface.zig @@ -2519,16 +2519,15 @@ fn clickMoveCursor(self: *Surface, to: terminal.Pin) !void { } /// Returns the link at the given cursor position, if any. +/// +/// Requires the renderer mutex is held. fn linkAtPos( self: *Surface, pos: apprt.CursorPos, ) !?struct { - DerivedConfig.Link, + input.Link.Action, terminal.Selection, } { - // If we have no configured links we can save a lot of work - if (self.config.links.len == 0) return null; - // Convert our cursor position to a screen point. const screen = &self.renderer_state.terminal.screen; const mouse_pin: terminal.Pin = mouse_pin: { @@ -2543,6 +2542,19 @@ fn linkAtPos( // Get our comparison mods const mouse_mods = self.mouseModsWithCapture(self.mouse.mods); + // If we have the proper modifiers set then we can check for OSC8 links. + if (mouse_mods.equal(input.ctrlOrSuper(.{}))) hyperlink: { + const rac = mouse_pin.rowAndCell(); + const cell = rac.cell; + if (!cell.hyperlink) break :hyperlink; + const sel = terminal.Selection.init(mouse_pin, mouse_pin, false); + return .{ ._open_osc8, sel }; + } + + // If we have no OSC8 links then we fallback to regex-based URL detection. + // If we have no configured links we can save a lot of work going forward. + if (self.config.links.len == 0) return null; + // Get the line we're hovering over. const line = screen.selectLine(.{ .pin = mouse_pin, @@ -2571,7 +2583,7 @@ fn linkAtPos( defer match.deinit(); const sel = match.selection(); if (!sel.contains(screen, mouse_pin)) continue; - return .{ link, sel }; + return .{ link.action, sel }; } } @@ -2602,8 +2614,8 @@ fn mouseModsWithCapture(self: *Surface, mods: input.Mods) input.Mods { /// /// Requires the renderer state mutex is held. fn processLinks(self: *Surface, pos: apprt.CursorPos) !bool { - const link, const sel = try self.linkAtPos(pos) orelse return false; - switch (link.action) { + const action, const sel = try self.linkAtPos(pos) orelse return false; + switch (action) { .open => { const str = try self.io.terminal.screen.selectionString(self.alloc, .{ .sel = sel, @@ -2612,6 +2624,20 @@ fn processLinks(self: *Surface, pos: apprt.CursorPos) !bool { defer self.alloc.free(str); try internal_os.open(self.alloc, str); }, + + ._open_osc8 => { + // Note: we probably want to put this into a helper on page. + const pin = sel.start(); + const page = &pin.page.data; + const cell = pin.rowAndCell().cell; + const link_id = page.lookupHyperlink(cell) orelse { + log.warn("failed to find hyperlink for cell", .{}); + return false; + }; + const link = page.hyperlink_set.get(page.memory, link_id); + const uri = link.uri.offset.ptr(page.memory)[0..link.uri.len]; + try internal_os.open(self.alloc, uri); + }, } return true; diff --git a/src/input/Link.zig b/src/input/Link.zig index 86a9402d5..adc52a270 100644 --- a/src/input/Link.zig +++ b/src/input/Link.zig @@ -23,6 +23,10 @@ pub const Action = union(enum) { /// Open the full matched value using the default open program. /// For example, on macOS this is "open" and on Linux this is "xdg-open". open: void, + + /// Open the OSC8 hyperlink under the mouse position. _-prefixed means + /// this can't be user-specified, it's only used internally. + _open_osc8: void, }; pub const Highlight = union(enum) { From f777e42af2a60d3866d7bb5474b86cbc432cb1d5 Mon Sep 17 00:00:00 2001 From: Mitchell Hashimoto Date: Thu, 4 Jul 2024 15:16:18 -0700 Subject: [PATCH 29/58] terminal: page clone needs to clone strings --- src/terminal/hyperlink.zig | 46 ++++++++++++++++++++++++++++++++++++++ src/terminal/page.zig | 4 +--- 2 files changed, 47 insertions(+), 3 deletions(-) diff --git a/src/terminal/hyperlink.zig b/src/terminal/hyperlink.zig index 49bf93faf..f692f1f8d 100644 --- a/src/terminal/hyperlink.zig +++ b/src/terminal/hyperlink.zig @@ -35,6 +35,52 @@ pub const Hyperlink = struct { implicit: size.OffsetInt, }; + /// Duplicate this hyperlink from one page to another. + pub fn dupe(self: *const Hyperlink, self_page: *const Page, dst_page: *Page) !Hyperlink { + var copy = self.*; + + // If the pages are the same then we can return a shallow copy. + if (self_page == dst_page) return copy; + + // Copy the URI + { + const uri = self.uri.offset.ptr(self_page.memory)[0..self.uri.len]; + const buf = try dst_page.string_alloc.alloc(u8, dst_page.memory, uri.len); + @memcpy(buf, uri); + copy.uri = .{ + .offset = size.getOffset(u8, dst_page.memory, &buf[0]), + .len = uri.len, + }; + } + errdefer dst_page.string_alloc.free( + dst_page.memory, + copy.uri.offset.ptr(dst_page.memory)[0..copy.uri.len], + ); + + // Copy the ID + switch (copy.id) { + .implicit => {}, // Shallow is fine + .explicit => |slice| { + const id = slice.offset.ptr(self_page.memory)[0..slice.len]; + const buf = try dst_page.string_alloc.alloc(u8, dst_page.memory, id.len); + @memcpy(buf, id); + copy.id = .{ .explicit = .{ + .offset = size.getOffset(u8, dst_page.memory, &buf[0]), + .len = id.len, + } }; + }, + } + errdefer switch (copy.id) { + .implicit => {}, + .explicit => |v| dst_page.string_alloc.free( + dst_page.memory, + v.offset.ptr(dst_page.memory)[0..v.len], + ), + }; + + return copy; + } + pub fn hash(self: *const Hyperlink, base: anytype) u64 { var hasher = Wyhash.init(0); autoHash(&hasher, std.meta.activeTag(self.id)); diff --git a/src/terminal/page.zig b/src/terminal/page.zig index 0ee041fa0..b8fe8e9d0 100644 --- a/src/terminal/page.zig +++ b/src/terminal/page.zig @@ -677,12 +677,10 @@ pub const Page = struct { const other_link = other.hyperlink_set.get(other.memory, id); const dst_id = try self.hyperlink_set.addContext( self.memory, - other_link.*, + try other_link.dupe(other, self), .{ .page = self }, ); try self.setHyperlink(dst_row, dst_cell, dst_id); - - // TODO: copy the strings } if (src_cell.style_id != style.default_id) { dst_row.styled = true; From 041c7795127451d3fbe02d565e53d2bf38a0807d Mon Sep 17 00:00:00 2001 From: Mitchell Hashimoto Date: Thu, 4 Jul 2024 18:58:21 -0700 Subject: [PATCH 30/58] renderer: matchSet matches OSC8 --- src/renderer/link.zig | 183 +++++++++++++++++++++++++++++++++++++++++- src/terminal/page.zig | 2 +- 2 files changed, 181 insertions(+), 4 deletions(-) diff --git a/src/renderer/link.zig b/src/renderer/link.zig index e6c7f6ba0..417fcfcf9 100644 --- a/src/renderer/link.zig +++ b/src/renderer/link.zig @@ -6,6 +6,7 @@ const inputpkg = @import("../input.zig"); const terminal = @import("../terminal/main.zig"); const point = terminal.point; const Screen = terminal.Screen; +const Terminal = terminal.Terminal; const log = std.log.scoped(.renderer_link); @@ -79,10 +80,125 @@ pub const Set = struct { var matches = std.ArrayList(terminal.Selection).init(alloc); defer matches.deinit(); + // 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() }; + } + + fn matchSetFromOSC8( + self: *const Set, + alloc: Allocator, + matches: *std.ArrayList(terminal.Selection), + screen: *Screen, + mouse_pin: terminal.Pin, + mouse_mods: inputpkg.Mods, + ) !void { + _ = alloc; + _ = self; + + // 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 = &mouse_pin.page.data; + const link_id = page.lookupHyperlink(mouse_cell) orelse { + log.warn("failed to find hyperlink for cell", .{}); + return; + }; + + // 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(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.page.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 = terminal.Selection.init( + cell_pin, + cell_pin, + false, + ); + } + + continue; + } + + // No match, if we have a current selection then complete it. + if (current) |sel| { + try matches.append(sel); + current = null; + } + } + } + } + + /// Fills matches with the matches from regex link matches. + fn matchSetFromLinks( + self: *const Set, + alloc: Allocator, + matches: *std.ArrayList(terminal.Selection), + screen: *Screen, + mouse_pin: terminal.Pin, + mouse_mods: inputpkg.Mods, + ) !void { // Iterate over all the visible lines. var lineIter = screen.lineIterator(screen.pages.pin(.{ .viewport = .{}, - }) orelse return .{}); + }) orelse return); while (lineIter.next()) |line_sel| { const strmap: terminal.StringMap = strmap: { var strmap: terminal.StringMap = undefined; @@ -141,8 +257,6 @@ pub const Set = struct { } } } - - return .{ .matches = try matches.toOwnedSlice() }; } }; @@ -391,3 +505,66 @@ test "matchset mods no match" { .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 = &t.screen; + + try t.printString("ABC"); + try t.screen.startHyperlink("http://example.com", null); + try t.printString("123"); + t.screen.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.screen, + .{ .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( + alloc, + &t.screen, + .{ .x = 3, .y = 0 }, + inputpkg.ctrlOrSuper(.{}), + ); + 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, + } }).?)); +} diff --git a/src/terminal/page.zig b/src/terminal/page.zig index b8fe8e9d0..e807904df 100644 --- a/src/terminal/page.zig +++ b/src/terminal/page.zig @@ -916,7 +916,7 @@ pub const Page = struct { } /// Returns the hyperlink ID for the given cell. - pub fn lookupHyperlink(self: *const Page, cell: *Cell) ?hyperlink.Id { + pub fn lookupHyperlink(self: *const Page, cell: *const Cell) ?hyperlink.Id { const cell_offset = getOffset(Cell, self.memory, cell); const map = self.hyperlink_map.map(self.memory); return map.get(cell_offset); From 925ad5b45c621aebbfbd5394928727bb2cdf5d24 Mon Sep 17 00:00:00 2001 From: Mitchell Hashimoto Date: Thu, 4 Jul 2024 19:55:29 -0700 Subject: [PATCH 31/58] renderer: match multiple lines for osc8 --- src/Surface.zig | 5 +---- src/renderer/Metal.zig | 2 +- src/renderer/link.zig | 15 +++++++++++++++ src/terminal/Screen.zig | 4 ++++ 4 files changed, 21 insertions(+), 5 deletions(-) diff --git a/src/Surface.zig b/src/Surface.zig index 286de2625..7df990ad4 100644 --- a/src/Surface.zig +++ b/src/Surface.zig @@ -2825,10 +2825,7 @@ pub fn cursorPosCallback( if (try self.linkAtPos(pos)) |_| { self.renderer_state.mouse.point = pos_vp; self.mouse.over_link = true; - // Mark the new link's row as dirty. - if (self.renderer_state.terminal.screen.pages.pin(.{ .viewport = pos_vp })) |pin| { - pin.markDirty(); - } + self.renderer_state.terminal.screen.dirty.hyperlink_hover = true; try self.rt_surface.setMouseShape(.pointer); try self.queueRender(); } else if (over_link) { diff --git a/src/renderer/Metal.zig b/src/renderer/Metal.zig index fc5ad382c..e459d36ab 100644 --- a/src/renderer/Metal.zig +++ b/src/renderer/Metal.zig @@ -2005,7 +2005,7 @@ fn rebuildCells( if (self.updateCell( screen, cell, - if (link_match_set.orderedContains(screen, cell)) + if (link_match_set.contains(screen, cell)) .single else null, diff --git a/src/renderer/link.zig b/src/renderer/link.zig index 417fcfcf9..67c1b0d28 100644 --- a/src/renderer/link.zig +++ b/src/renderer/link.zig @@ -274,6 +274,21 @@ pub const MatchSet = struct { 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 diff --git a/src/terminal/Screen.zig b/src/terminal/Screen.zig index 885412768..593fcc86e 100644 --- a/src/terminal/Screen.zig +++ b/src/terminal/Screen.zig @@ -72,6 +72,10 @@ pub const Dirty = packed struct { /// Set when the selection is set or unset, regardless of if the /// selection is changed or not. selection: bool = false, + + /// When an OSC8 hyperlink is hovered, we set the full screen as dirty + /// because links can span multiple lines. + hyperlink_hover: bool = false, }; /// The cursor position. From 8b02d3430cea0a1fbdd906cb5dc0f6304dc62ce6 Mon Sep 17 00:00:00 2001 From: Mitchell Hashimoto Date: Thu, 4 Jul 2024 20:00:30 -0700 Subject: [PATCH 32/58] terminal: copy hyperlinks on reflow --- src/terminal/PageList.zig | 18 ++++++++++++++++++ 1 file changed, 18 insertions(+) diff --git a/src/terminal/PageList.zig b/src/terminal/PageList.zig index e339f66ba..79551b178 100644 --- a/src/terminal/PageList.zig +++ b/src/terminal/PageList.zig @@ -1162,6 +1162,24 @@ fn reflowPage( }, } + // If the source cell has a hyperlink we need to copy it + if (src_cursor.page_cell.hyperlink) { + const src_page = src_cursor.page; + const dst_page = dst_cursor.page; + const id = src_page.lookupHyperlink(src_cursor.page_cell).?; + const src_link = src_page.hyperlink_set.get(src_page.memory, id); + const dst_id = try dst_page.hyperlink_set.addContext( + dst_page.memory, + try src_link.dupe(src_page, dst_page), + .{ .page = dst_page }, + ); + try dst_page.setHyperlink( + dst_cursor.page_row, + dst_cursor.page_cell, + dst_id, + ); + } + // If the source cell has a style, we need to copy it. if (src_cursor.page_cell.style_id != stylepkg.default_id) { const src_style = src_cursor.page.styles.get( From ff9ab700912a361169d6bdb0ffe39abde77a578d Mon Sep 17 00:00:00 2001 From: Mitchell Hashimoto Date: Thu, 4 Jul 2024 20:01:07 -0700 Subject: [PATCH 33/58] terminal: end hyperlink state when switching screens --- src/terminal/Terminal.zig | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/src/terminal/Terminal.zig b/src/terminal/Terminal.zig index d11b10788..cc4ec9d22 100644 --- a/src/terminal/Terminal.zig +++ b/src/terminal/Terminal.zig @@ -2473,6 +2473,9 @@ pub fn alternateScreen( log.warn("cursor copy failed entering alt screen err={}", .{err}); }; + // We always end hyperlink state + self.screen.endHyperlink(); + if (options.clear_on_enter) { self.eraseDisplay(.complete, false); } @@ -2506,6 +2509,9 @@ pub fn primaryScreen( // Mark our terminal as dirty self.flags.dirty.clear = true; + // We always end hyperlink state + self.screen.endHyperlink(); + // Restore the cursor from the primary screen. This should not // fail because we should not have to allocate memory since swapping // screens does not create new cursors. From e8a8b189ba8cc17c3942372da7ad8de3a35f1513 Mon Sep 17 00:00:00 2001 From: Mitchell Hashimoto Date: Thu, 4 Jul 2024 20:10:34 -0700 Subject: [PATCH 34/58] core: when over a link we must set the whole screen dirty on move --- src/Surface.zig | 11 +---------- 1 file changed, 1 insertion(+), 10 deletions(-) diff --git a/src/Surface.zig b/src/Surface.zig index 7df990ad4..cae55e6f7 100644 --- a/src/Surface.zig +++ b/src/Surface.zig @@ -2795,16 +2795,7 @@ pub fn cursorPosCallback( if (self.mouse.link_point) |last_vp| { // Mark the link's row as dirty. if (over_link) { - // TODO: This doesn't handle soft-wrapped links. Ideally this would - // be storing the link's start and end points and marking all rows - // between and including those as dirty, instead of just the row - // containing the part the cursor is hovering. This can result in - // a bit of jank. - if (self.renderer_state.terminal.screen.pages.pin(.{ - .viewport = last_vp, - })) |pin| { - pin.markDirty(); - } + self.renderer_state.terminal.screen.dirty.hyperlink_hover = true; } // If our last link viewport point is unchanged, then don't process From b0f99307d3f215a6d1949258d4d1b42fedcc0ba9 Mon Sep 17 00:00:00 2001 From: Mitchell Hashimoto Date: Thu, 4 Jul 2024 20:17:46 -0700 Subject: [PATCH 35/58] terminal: pause integrity checks in clone row until done --- src/terminal/page.zig | 10 +++++++--- 1 file changed, 7 insertions(+), 3 deletions(-) diff --git a/src/terminal/page.zig b/src/terminal/page.zig index e807904df..7efd36408 100644 --- a/src/terminal/page.zig +++ b/src/terminal/page.zig @@ -616,6 +616,13 @@ pub const Page = struct { x_start: usize, x_end_req: usize, ) CloneFromError!void { + // This whole operation breaks integrity until the end. + self.pauseIntegrityChecks(true); + defer { + self.pauseIntegrityChecks(false); + self.assertIntegrity(); + } + const cell_len = @min(self.size.cols, other.size.cols); const x_end = @min(x_end_req, cell_len); assert(x_start <= x_end); @@ -715,9 +722,6 @@ pub const Page = struct { last.wide = .narrow; } } - - // The final page should remain consistent - self.assertIntegrity(); } /// Get a single row. y must be valid. From c51682a5c21710542b81420047013f389b234f49 Mon Sep 17 00:00:00 2001 From: Mitchell Hashimoto Date: Thu, 4 Jul 2024 20:34:40 -0700 Subject: [PATCH 36/58] renderer: match no-ID OSC8 in contiguous chunks --- src/renderer/link.zig | 83 ++++++++++++++++++++++++++++++++++++++++++- 1 file changed, 82 insertions(+), 1 deletion(-) diff --git a/src/renderer/link.zig b/src/renderer/link.zig index 67c1b0d28..aa2db2b8d 100644 --- a/src/renderer/link.zig +++ b/src/renderer/link.zig @@ -113,7 +113,6 @@ pub const Set = struct { mouse_mods: inputpkg.Mods, ) !void { _ = alloc; - _ = self; // If the right mods aren't pressed, then we can't match. if (!mouse_mods.equal(inputpkg.ctrlOrSuper(.{}))) return; @@ -128,6 +127,19 @@ pub const Set = struct { 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.offset.ptr(page.memory)[0..link.uri.len]; + return try self.matchSetFromOSC8Implicit( + 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 @@ -186,6 +198,75 @@ pub const Set = struct { } } + /// 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, + 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 = &cell_pin.page.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.offset.ptr(page.memory)[0..link.uri.len]; + 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 = &cell_pin.page.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.offset.ptr(page.memory)[0..link.uri.len]; + if (!std.mem.eql(u8, uri, cell_uri)) break; + + sel.endPtr().* = cell_pin; + } + + try matches.append(sel); + } + /// Fills matches with the matches from regex link matches. fn matchSetFromLinks( self: *const Set, From eed9c23acdb9b21cacf6352d223e7805c68864ab Mon Sep 17 00:00:00 2001 From: Mitchell Hashimoto Date: Thu, 4 Jul 2024 21:06:46 -0700 Subject: [PATCH 37/58] terminal: RefCountedSet checks for existence prior to cap check --- src/terminal/ref_counted_set.zig | 30 ++++++++++++++++++++++++------ 1 file changed, 24 insertions(+), 6 deletions(-) diff --git a/src/terminal/ref_counted_set.zig b/src/terminal/ref_counted_set.zig index 3d4ef663b..5fe5fa542 100644 --- a/src/terminal/ref_counted_set.zig +++ b/src/terminal/ref_counted_set.zig @@ -226,7 +226,17 @@ pub fn RefCountedSet( self.deleteItem(base, self.next_id, ctx); } - // If we still don't have an available ID, we can't continue. + // If the item already exists, return it. + if (self.lookup(base, value, ctx)) |id| { + // Notify the context that the value is "deleted" because + // we're reusing the existing value in the set. This allows + // callers to clean up any resources associated with the value. + if (comptime @hasDecl(Context, "deleted")) ctx.deleted(value); + items[id].meta.ref += 1; + return id; + } + + // If the item doesn't exist, we need an available ID. if (self.next_id >= self.layout.cap) { // Arbitrarily chosen, threshold for rehashing. // If less than 90% of currently allocated IDs @@ -245,15 +255,15 @@ pub fn RefCountedSet( return AddError.OutOfMemory; } - const id = self.upsert(base, value, self.next_id, ctx); + const id = self.insert(base, value, self.next_id, ctx); items[id].meta.ref += 1; + assert(items[id].meta.ref == 1); + self.living += 1; + // Its possible insert returns a different ID by reusing a + // dead item so we only need to update next id if we used it. if (id == self.next_id) self.next_id += 1; - if (items[id].meta.ref == 1) { - self.living += 1; - } - return id; } @@ -494,6 +504,14 @@ pub fn RefCountedSet( return id; } + return self.insert(base, value, new_id, ctx); + } + + /// Insert the given value into the hash table with the given ID. + /// asserts that the value is not already present in the table. + fn insert(self: *Self, base: anytype, value: T, new_id: Id, ctx: Context) Id { + assert(self.lookup(base, value, ctx) == null); + const table = self.table.ptr(base); const items = self.items.ptr(base); From cdb838ea85ed6fa66cfdf3f08f496903cae094ed Mon Sep 17 00:00:00 2001 From: Mitchell Hashimoto Date: Fri, 5 Jul 2024 08:34:23 -0700 Subject: [PATCH 38/58] terminal: pause integrity checks on resize for hyperlink set --- src/terminal/PageList.zig | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/src/terminal/PageList.zig b/src/terminal/PageList.zig index 79551b178..c4a108aca 100644 --- a/src/terminal/PageList.zig +++ b/src/terminal/PageList.zig @@ -1166,6 +1166,12 @@ fn reflowPage( if (src_cursor.page_cell.hyperlink) { const src_page = src_cursor.page; const dst_page = dst_cursor.page; + + // Pause integrity checks because setHyperlink + // calls them but we're not ready yet. + dst_page.pauseIntegrityChecks(true); + defer dst_page.pauseIntegrityChecks(false); + const id = src_page.lookupHyperlink(src_cursor.page_cell).?; const src_link = src_page.hyperlink_set.get(src_page.memory, id); const dst_id = try dst_page.hyperlink_set.addContext( From 4f099af76f175317d8cf394e1d7b78f8382cc7f1 Mon Sep 17 00:00:00 2001 From: Mitchell Hashimoto Date: Fri, 5 Jul 2024 08:44:34 -0700 Subject: [PATCH 39/58] terminal: set hyperlink state on clone --- src/terminal/page.zig | 1 + 1 file changed, 1 insertion(+) diff --git a/src/terminal/page.zig b/src/terminal/page.zig index 7efd36408..1e34a497c 100644 --- a/src/terminal/page.zig +++ b/src/terminal/page.zig @@ -643,6 +643,7 @@ pub const Page = struct { copy.wrap = dst_row.wrap; copy.wrap_continuation = dst_row.wrap_continuation; copy.grapheme = dst_row.grapheme; + copy.hyperlink = dst_row.hyperlink; copy.styled = dst_row.styled; } From a6051b931e1b769dd9aed77ae8f4254fe1a5788a Mon Sep 17 00:00:00 2001 From: Mitchell Hashimoto Date: Fri, 5 Jul 2024 08:52:13 -0700 Subject: [PATCH 40/58] terminal: disable zombie styles integrity check --- src/terminal/page.zig | 17 +++++++++-------- 1 file changed, 9 insertions(+), 8 deletions(-) diff --git a/src/terminal/page.zig b/src/terminal/page.zig index 1e34a497c..5b7c4a008 100644 --- a/src/terminal/page.zig +++ b/src/terminal/page.zig @@ -290,7 +290,6 @@ pub const Page = struct { MissingStyle, UnmarkedStyleRow, MismatchedStyleRef, - ZombieStyles, InvalidStyleCount, InvalidSpacerTailLocation, InvalidSpacerHeadLocation, @@ -505,14 +504,16 @@ pub const Page = struct { } } + // NOTE: This is currently disabled because @qwerasd says that + // certain fast paths can cause this but its okay. // Just 1 zombie style might be the cursor style, so ignore it. - if (zombies > 1) { - log.warn( - "page integrity violation zombie styles count={}", - .{zombies}, - ); - return IntegrityError.ZombieStyles; - } + // if (zombies > 1) { + // log.warn( + // "page integrity violation zombie styles count={}", + // .{zombies}, + // ); + // return IntegrityError.ZombieStyles; + // } } } From 251ec0c9f32b4926331ce2ad13a5934e923fb13f Mon Sep 17 00:00:00 2001 From: Mitchell Hashimoto Date: Fri, 5 Jul 2024 18:25:34 -0700 Subject: [PATCH 41/58] terminal: on print, adjust page size if we need to grow for hyperlinks --- src/terminal/Screen.zig | 29 +++++++++++++++++++++++++++++ src/terminal/Terminal.zig | 39 +++++++++++++++++---------------------- 2 files changed, 46 insertions(+), 22 deletions(-) diff --git a/src/terminal/Screen.zig b/src/terminal/Screen.zig index 593fcc86e..37bff05a4 100644 --- a/src/terminal/Screen.zig +++ b/src/terminal/Screen.zig @@ -1524,6 +1524,35 @@ pub fn endHyperlink(self: *Screen) void { self.cursor.hyperlink = null; } +/// Set the current hyperlink state on the current cell. +pub fn cursorSetHyperlink(self: *Screen) !void { + assert(self.cursor.hyperlink_id != 0); + + var page = &self.cursor.page_pin.page.data; + if (page.setHyperlink( + self.cursor.page_row, + self.cursor.page_cell, + self.cursor.hyperlink_id, + )) { + // Success! + return; + } else |err| switch (err) { + // hyperlink_map is out of space, realloc the page to be larger + error.OutOfMemory => { + _ = try self.pages.adjustCapacity( + self.cursor.page_pin.page, + .{ .hyperlink_bytes = page.capacity.hyperlink_bytes * 2 }, + ); + + // Reload cursor since our cursor page has changed. + self.cursorReload(); + + // Retry + return try self.cursorSetHyperlink(); + }, + } +} + /// 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 diff --git a/src/terminal/Terminal.zig b/src/terminal/Terminal.zig index cc4ec9d22..af903a71d 100644 --- a/src/terminal/Terminal.zig +++ b/src/terminal/Terminal.zig @@ -601,27 +601,6 @@ fn printCell( ); } - // We check for an active hyperlink first because setHyperlink - // handles clearing the old hyperlink and an optimization if we're - // overwriting the same hyperlink. - if (self.screen.cursor.hyperlink_id > 0) { - // If we have a hyperlink configured, apply it to this cell - var page = &self.screen.cursor.page_pin.page.data; - page.setHyperlink( - self.screen.cursor.page_row, - cell, - self.screen.cursor.hyperlink_id, - ) catch |err| { - // TODO: an error can only happen if our page is out of space - // so realloc the page here. - log.err("failed to set hyperlink, ignoring err={}", .{err}); - }; - } else if (cell.hyperlink) { - // If the previous cell had a hyperlink then we need to clear it. - var page = &self.screen.cursor.page_pin.page.data; - page.clearHyperlink(self.screen.cursor.page_row, cell); - } - // We don't need to update the style refs unless the // cell's new style will be different after writing. const style_changed = cell.style_id != self.screen.cursor.style_id; @@ -635,6 +614,9 @@ fn printCell( } } + // Keep track if we had a hyperlink so we can unset it. + const had_hyperlink = cell.hyperlink; + // Write cell.* = .{ .content_tag = .codepoint, @@ -642,7 +624,6 @@ fn printCell( .style_id = self.screen.cursor.style_id, .wide = wide, .protected = self.screen.cursor.protected, - .hyperlink = self.screen.cursor.hyperlink_id > 0, }; if (style_changed) { @@ -654,6 +635,20 @@ fn printCell( self.screen.cursor.page_row.styled = true; } } + + // We check for an active hyperlink first because setHyperlink + // handles clearing the old hyperlink and an optimization if we're + // overwriting the same hyperlink. + if (self.screen.cursor.hyperlink_id > 0) { + self.screen.cursorSetHyperlink() catch |err| { + log.warn("error reallocating for more hyperlink space, ignoring hyperlink err={}", .{err}); + assert(!cell.hyperlink); + }; + } else if (had_hyperlink) { + // If the previous cell had a hyperlink then we need to clear it. + var page = &self.screen.cursor.page_pin.page.data; + page.clearHyperlink(self.screen.cursor.page_row, cell); + } } fn printWrap(self: *Terminal) !void { From d79bbaac68a144b5d5714b67933dfa12cd2dc6ed Mon Sep 17 00:00:00 2001 From: Mitchell Hashimoto Date: Fri, 5 Jul 2024 21:44:30 -0700 Subject: [PATCH 42/58] terminal: adjustCapacity handles hyperlink state --- src/terminal/Screen.zig | 22 ++++++++++++++++------ 1 file changed, 16 insertions(+), 6 deletions(-) diff --git a/src/terminal/Screen.zig b/src/terminal/Screen.zig index 37bff05a4..04cb79a8a 100644 --- a/src/terminal/Screen.zig +++ b/src/terminal/Screen.zig @@ -451,6 +451,19 @@ fn adjustCapacity( ) catch unreachable; } + // Re-add the hyperlink + if (self.cursor.hyperlink) |link| { + // So we don't attempt to free any memory in the replaced page. + self.cursor.hyperlink_id = 0; + self.cursor.hyperlink = null; + + // Re-add + self.startHyperlinkOnce(link.uri, link.id) catch unreachable; + + // Remove our old link + link.destroy(self.alloc); + } + // Reload the cursor information because the pin changed. // So our page row/cell and so on are all off. self.cursorReload(); @@ -1394,27 +1407,24 @@ pub fn startHyperlink( error.RealOutOfMemory => return error.OutOfMemory, // strings table is out of memory, adjust it up - error.StringsOutOfMemory => _ = try self.pages.adjustCapacity( + error.StringsOutOfMemory => _ = try self.adjustCapacity( self.cursor.page_pin.page, .{ .string_bytes = self.cursor.page_pin.page.data.capacity.string_bytes * 2 }, ), // hyperlink set is out of memory, adjust it up - error.SetOutOfMemory => _ = try self.pages.adjustCapacity( + error.SetOutOfMemory => _ = try self.adjustCapacity( self.cursor.page_pin.page, .{ .hyperlink_bytes = self.cursor.page_pin.page.data.capacity.hyperlink_bytes * 2 }, ), // hyperlink set is too full, rehash it - error.SetNeedsRehash => _ = try self.pages.adjustCapacity( + error.SetNeedsRehash => _ = try self.adjustCapacity( self.cursor.page_pin.page, .{}, ), } - // If we get here, we adjusted capacity so our page has changed - // so we need to reload the cursor pins. - self.cursorReload(); self.assertIntegrity(); } } From 4a861a8c8f366afbec2f265f1fc92e2260c0590f Mon Sep 17 00:00:00 2001 From: Mitchell Hashimoto Date: Sat, 6 Jul 2024 08:47:22 -0700 Subject: [PATCH 43/58] terminal: hyperlink capacity adjustment needs to call safe variant --- src/terminal/Screen.zig | 5 +---- 1 file changed, 1 insertion(+), 4 deletions(-) diff --git a/src/terminal/Screen.zig b/src/terminal/Screen.zig index 04cb79a8a..d99ca0b28 100644 --- a/src/terminal/Screen.zig +++ b/src/terminal/Screen.zig @@ -1549,14 +1549,11 @@ pub fn cursorSetHyperlink(self: *Screen) !void { } else |err| switch (err) { // hyperlink_map is out of space, realloc the page to be larger error.OutOfMemory => { - _ = try self.pages.adjustCapacity( + _ = try self.adjustCapacity( self.cursor.page_pin.page, .{ .hyperlink_bytes = page.capacity.hyperlink_bytes * 2 }, ); - // Reload cursor since our cursor page has changed. - self.cursorReload(); - // Retry return try self.cursorSetHyperlink(); }, From d5a23e78fedf1594f44677bb93cf6e9cef0826a2 Mon Sep 17 00:00:00 2001 From: Mitchell Hashimoto Date: Sat, 6 Jul 2024 10:01:35 -0700 Subject: [PATCH 44/58] macos: some disabled swiftui code that makes link tooltips show --- macos/Sources/Ghostty/SurfaceView.swift | 17 +++++++++++++++++ 1 file changed, 17 insertions(+) diff --git a/macos/Sources/Ghostty/SurfaceView.swift b/macos/Sources/Ghostty/SurfaceView.swift index 4d755e70e..34b7ff01f 100644 --- a/macos/Sources/Ghostty/SurfaceView.swift +++ b/macos/Sources/Ghostty/SurfaceView.swift @@ -145,6 +145,23 @@ extension Ghostty { } .ghosttySurfaceView(surfaceView) + // If we have a URL from hovering a link, we show that. + // TODO + if (false) { + let padding: CGFloat = 3 + HStack { + VStack(alignment: .leading) { + Spacer() + + Text(verbatim: "http://example.com") + .padding(.init(top: padding, leading: padding, bottom: padding, trailing: padding)) + .background(.background) + } + + Spacer() + } + } + // If our surface is not healthy, then we render an error view over it. if (!surfaceView.healthy) { Rectangle().fill(ghostty.config.backgroundColor) From cb790b8e399f85ba4156d59cd73c912d5ce340f2 Mon Sep 17 00:00:00 2001 From: Mitchell Hashimoto Date: Sat, 6 Jul 2024 10:25:12 -0700 Subject: [PATCH 45/58] macos: show URL on OSC8 hover --- include/ghostty.h | 2 ++ macos/Sources/Ghostty/Ghostty.App.swift | 14 +++++++++++- macos/Sources/Ghostty/SurfaceView.swift | 4 ++-- .../Sources/Ghostty/SurfaceView_AppKit.swift | 3 +++ src/Surface.zig | 22 ++++++++++++++++++- src/apprt/embedded.zig | 18 +++++++++++++++ 6 files changed, 59 insertions(+), 4 deletions(-) diff --git a/include/ghostty.h b/include/ghostty.h index 04233287f..d81e3d19a 100644 --- a/include/ghostty.h +++ b/include/ghostty.h @@ -452,6 +452,7 @@ typedef void (*ghostty_runtime_show_desktop_notification_cb)(void*, const char*); typedef void ( *ghostty_runtime_update_renderer_health)(void*, ghostty_renderer_health_e); +typedef void (*ghostty_runtime_mouse_over_link_cb)(void*, const char*, size_t); typedef struct { void* userdata; @@ -481,6 +482,7 @@ typedef struct { ghostty_runtime_set_cell_size_cb set_cell_size_cb; ghostty_runtime_show_desktop_notification_cb show_desktop_notification_cb; ghostty_runtime_update_renderer_health update_renderer_health_cb; + ghostty_runtime_mouse_over_link_cb mouse_over_link_cb; } ghostty_runtime_config_s; //------------------------------------------------------------------- diff --git a/macos/Sources/Ghostty/Ghostty.App.swift b/macos/Sources/Ghostty/Ghostty.App.swift index 2e991ecba..4fc111400 100644 --- a/macos/Sources/Ghostty/Ghostty.App.swift +++ b/macos/Sources/Ghostty/Ghostty.App.swift @@ -93,7 +93,8 @@ extension Ghostty { set_cell_size_cb: { userdata, width, height in App.setCellSize(userdata, width: width, height: height) }, show_desktop_notification_cb: { userdata, title, body in App.showUserNotification(userdata, title: title, body: body) }, - update_renderer_health_cb: { userdata, health in App.updateRendererHealth(userdata, health: health) } + update_renderer_health_cb: { userdata, health in App.updateRendererHealth(userdata, health: health) }, + mouse_over_link_cb: { userdata, ptr, len in App.mouseOverLink(userdata, uri: ptr, len: len) } ) // Create the ghostty app. @@ -523,6 +524,17 @@ extension Ghostty { let backingSize = NSSize(width: Double(width), height: Double(height)) surfaceView.cellSize = surfaceView.convertFromBacking(backingSize) } + + static func mouseOverLink(_ userdata: UnsafeMutableRawPointer?, uri: UnsafePointer?, len: Int) { + let surfaceView = self.surfaceUserdata(from: userdata) + guard len > 0 else { + surfaceView.hoverUrl = nil + return + } + + let buffer = Data(bytes: uri!, count: len) + surfaceView.hoverUrl = String(data: buffer, encoding: .utf8) + } static func showUserNotification(_ userdata: UnsafeMutableRawPointer?, title: UnsafePointer?, body: UnsafePointer?) { let surfaceView = self.surfaceUserdata(from: userdata) diff --git a/macos/Sources/Ghostty/SurfaceView.swift b/macos/Sources/Ghostty/SurfaceView.swift index 34b7ff01f..b17c2a255 100644 --- a/macos/Sources/Ghostty/SurfaceView.swift +++ b/macos/Sources/Ghostty/SurfaceView.swift @@ -147,13 +147,13 @@ extension Ghostty { // If we have a URL from hovering a link, we show that. // TODO - if (false) { + if let url = surfaceView.hoverUrl { let padding: CGFloat = 3 HStack { VStack(alignment: .leading) { Spacer() - Text(verbatim: "http://example.com") + Text(verbatim: url) .padding(.init(top: padding, leading: padding, bottom: padding, trailing: padding)) .background(.background) } diff --git a/macos/Sources/Ghostty/SurfaceView_AppKit.swift b/macos/Sources/Ghostty/SurfaceView_AppKit.swift index bfd896be1..0a9df88f8 100644 --- a/macos/Sources/Ghostty/SurfaceView_AppKit.swift +++ b/macos/Sources/Ghostty/SurfaceView_AppKit.swift @@ -26,6 +26,9 @@ extension Ghostty { // Any error while initializing the surface. @Published var error: Error? = nil + + // The hovered URL string + @Published var hoverUrl: String? = nil // An initial size to request for a window. This will only affect // then the view is moved to a new window. diff --git a/src/Surface.zig b/src/Surface.zig index cae55e6f7..390eab765 100644 --- a/src/Surface.zig +++ b/src/Surface.zig @@ -2813,14 +2813,34 @@ pub fn cursorPosCallback( } self.mouse.link_point = pos_vp; - if (try self.linkAtPos(pos)) |_| { + if (try self.linkAtPos(pos)) |link| { self.renderer_state.mouse.point = pos_vp; self.mouse.over_link = true; self.renderer_state.terminal.screen.dirty.hyperlink_hover = true; try self.rt_surface.setMouseShape(.pointer); + + switch (link[0]) { + .open => {}, + + ._open_osc8 => link: { + // Show the URL in the status bar + const pin = link[1].start(); + const page = &pin.page.data; + const cell = pin.rowAndCell().cell; + const link_id = page.lookupHyperlink(cell) orelse { + log.warn("failed to find hyperlink for cell", .{}); + break :link; + }; + const entry = page.hyperlink_set.get(page.memory, link_id); + const uri = entry.uri.offset.ptr(page.memory)[0..entry.uri.len]; + self.rt_surface.mouseOverLink(uri); + }, + } + try self.queueRender(); } else if (over_link) { try self.rt_surface.setMouseShape(self.io.terminal.mouse_shape); + self.rt_surface.mouseOverLink(null); try self.queueRender(); } } diff --git a/src/apprt/embedded.zig b/src/apprt/embedded.zig index 113d9379a..37caf7d0f 100644 --- a/src/apprt/embedded.zig +++ b/src/apprt/embedded.zig @@ -128,6 +128,11 @@ pub const App = struct { /// Called when the health of the renderer changes. update_renderer_health: ?*const fn (SurfaceUD, renderer.Health) void = null, + + /// Called when the mouse goes over a link. The link target is the + /// parameter. The link target will be null if the mouse is no longer + /// over a link. + mouse_over_link: ?*const fn (SurfaceUD, ?[*]const u8, usize) void = null, }; /// Special values for the goto_tab callback. @@ -1101,6 +1106,19 @@ pub const Surface = struct { func(self.userdata, health); } + + pub fn mouseOverLink(self: *const Surface, uri: ?[]const u8) void { + const func = self.app.opts.mouse_over_link orelse { + log.info("runtime embedder does not support over_link", .{}); + return; + }; + + if (uri) |v| { + func(self.userdata, v.ptr, v.len); + } else { + func(self.userdata, null, 0); + } + } }; /// Inspector is the state required for the terminal inspector. A terminal From 8ecc84b94321a77204a788c007608e4f729c3b9e Mon Sep 17 00:00:00 2001 From: Mitchell Hashimoto Date: Sat, 6 Jul 2024 10:27:21 -0700 Subject: [PATCH 46/58] core: helper to get osc8 URI --- src/Surface.zig | 29 +++++++++++++++-------------- 1 file changed, 15 insertions(+), 14 deletions(-) diff --git a/src/Surface.zig b/src/Surface.zig index 390eab765..6a2aa7e14 100644 --- a/src/Surface.zig +++ b/src/Surface.zig @@ -2626,16 +2626,10 @@ fn processLinks(self: *Surface, pos: apprt.CursorPos) !bool { }, ._open_osc8 => { - // Note: we probably want to put this into a helper on page. - const pin = sel.start(); - const page = &pin.page.data; - const cell = pin.rowAndCell().cell; - const link_id = page.lookupHyperlink(cell) orelse { - log.warn("failed to find hyperlink for cell", .{}); + const uri = self.osc8URI(sel.start()) orelse { + log.warn("failed to get URI for OSC8 hyperlink", .{}); return false; }; - const link = page.hyperlink_set.get(page.memory, link_id); - const uri = link.uri.offset.ptr(page.memory)[0..link.uri.len]; try internal_os.open(self.alloc, uri); }, } @@ -2643,6 +2637,17 @@ fn processLinks(self: *Surface, pos: apprt.CursorPos) !bool { return true; } +/// Return the URI for an OSC8 hyperlink at the given position or null +/// if there is no hyperlink. +fn osc8URI(self: *Surface, pin: terminal.Pin) ?[]const u8 { + _ = self; + const page = &pin.page.data; + const cell = pin.rowAndCell().cell; + const link_id = page.lookupHyperlink(cell) orelse return null; + const entry = page.hyperlink_set.get(page.memory, link_id); + return entry.uri.offset.ptr(page.memory)[0..entry.uri.len]; +} + pub fn mousePressureCallback( self: *Surface, stage: input.MousePressureStage, @@ -2825,14 +2830,10 @@ pub fn cursorPosCallback( ._open_osc8 => link: { // Show the URL in the status bar const pin = link[1].start(); - const page = &pin.page.data; - const cell = pin.rowAndCell().cell; - const link_id = page.lookupHyperlink(cell) orelse { - log.warn("failed to find hyperlink for cell", .{}); + const uri = self.osc8URI(pin) orelse { + log.warn("failed to get URI for OSC8 hyperlink", .{}); break :link; }; - const entry = page.hyperlink_set.get(page.memory, link_id); - const uri = entry.uri.offset.ptr(page.memory)[0..entry.uri.len]; self.rt_surface.mouseOverLink(uri); }, } From 36648ae397428d9256e61a072771c5e62cd99682 Mon Sep 17 00:00:00 2001 From: Mitchell Hashimoto Date: Sat, 6 Jul 2024 10:29:23 -0700 Subject: [PATCH 47/58] apprt: stubs for mouseOverLink --- src/apprt/glfw.zig | 6 ++++++ src/apprt/gtk/Surface.zig | 6 ++++++ 2 files changed, 12 insertions(+) diff --git a/src/apprt/glfw.zig b/src/apprt/glfw.zig index 911eb6f5e..81063bc69 100644 --- a/src/apprt/glfw.zig +++ b/src/apprt/glfw.zig @@ -649,6 +649,12 @@ pub const Surface = struct { self.cursor = new; } + pub fn mouseOverLink(self: *Surface, uri: ?[]const u8) void { + // We don't do anything in GLFW. + _ = self; + _ = uri; + } + /// Set the visibility of the mouse cursor. pub fn setMouseVisibility(self: *Surface, visible: bool) void { self.window.setInputModeCursor(if (visible) .normal else .hidden); diff --git a/src/apprt/gtk/Surface.zig b/src/apprt/gtk/Surface.zig index 1ee433db9..096441229 100644 --- a/src/apprt/gtk/Surface.zig +++ b/src/apprt/gtk/Surface.zig @@ -879,6 +879,12 @@ pub fn setMouseVisibility(self: *Surface, visible: bool) void { c.gtk_widget_set_cursor(@ptrCast(self.gl_area), self.app.cursor_none); } +pub fn mouseOverLink(self: *Surface, uri: ?[]const u8) void { + // TODO: GTK + _ = self; + _ = uri; +} + pub fn clipboardRequest( self: *Surface, clipboard_type: apprt.Clipboard, From 93446769602f4f914955d069b4d4a86bfdfbe532 Mon Sep 17 00:00:00 2001 From: Mitchell Hashimoto Date: Sat, 6 Jul 2024 10:45:57 -0700 Subject: [PATCH 48/58] macos: fix iOS build --- macos/Sources/Ghostty/Ghostty.App.swift | 1 + macos/Sources/Ghostty/SurfaceView_UIKit.swift | 3 +++ 2 files changed, 4 insertions(+) diff --git a/macos/Sources/Ghostty/Ghostty.App.swift b/macos/Sources/Ghostty/Ghostty.App.swift index 4fc111400..97a4aa0da 100644 --- a/macos/Sources/Ghostty/Ghostty.App.swift +++ b/macos/Sources/Ghostty/Ghostty.App.swift @@ -291,6 +291,7 @@ extension Ghostty { static func setCellSize(_ userdata: UnsafeMutableRawPointer?, width: UInt32, height: UInt32) {} static func showUserNotification(_ userdata: UnsafeMutableRawPointer?, title: UnsafePointer?, body: UnsafePointer?) {} static func updateRendererHealth(_ userdata: UnsafeMutableRawPointer?, health: ghostty_renderer_health_e) {} + static func mouseOverLink(_ userdata: UnsafeMutableRawPointer?, uri: UnsafePointer?, len: Int) {} #endif #if os(macOS) diff --git a/macos/Sources/Ghostty/SurfaceView_UIKit.swift b/macos/Sources/Ghostty/SurfaceView_UIKit.swift index bda16ced8..87a9afa53 100644 --- a/macos/Sources/Ghostty/SurfaceView_UIKit.swift +++ b/macos/Sources/Ghostty/SurfaceView_UIKit.swift @@ -25,6 +25,9 @@ extension Ghostty { // Any error while initializing the surface. @Published var error: Error? = nil + // The hovered URL + @Published var hoverUrl: String? = nil + private(set) var surface: ghostty_surface_t? init(_ app: ghostty_app_t, baseConfig: SurfaceConfiguration? = nil, uuid: UUID? = nil) { From 8858c2ba4e59b7226ca3b9cfa6e58d4823796307 Mon Sep 17 00:00:00 2001 From: Mitchell Hashimoto Date: Sat, 6 Jul 2024 15:00:52 -0700 Subject: [PATCH 49/58] apprt/gtk: convert surface to overlay so we can support the url overlay --- src/apprt/gtk/Surface.zig | 59 ++++++++++++++++++++++++++++++++------- src/apprt/gtk/Tab.zig | 3 +- 2 files changed, 50 insertions(+), 12 deletions(-) diff --git a/src/apprt/gtk/Surface.zig b/src/apprt/gtk/Surface.zig index 096441229..f6ecce8ec 100644 --- a/src/apprt/gtk/Surface.zig +++ b/src/apprt/gtk/Surface.zig @@ -72,7 +72,7 @@ pub const Container = union(enum) { /// element pub fn widget(self: Elem) *c.GtkWidget { return switch (self) { - .surface => |s| @ptrCast(s.gl_area), + .surface => |s| s.primaryWidget(), .split => |s| @ptrCast(@alignCast(s.paned)), }; } @@ -223,9 +223,15 @@ container: Container = .{ .none = {} }, /// The app we're part of app: *App, +/// The overlay, this is the primary widget +overlay: *c.GtkOverlay, + /// Our GTK area gl_area: *c.GtkGLArea, +/// If non-null this is the widget on the overlay that shows the URL. +url_widget: ?*c.GtkWidget = null, + /// Any active cursor we may have cursor: ?*c.GdkCursor = null, @@ -271,15 +277,19 @@ pub fn init(self: *Surface, app: *App, opts: Options) !void { const widget: *c.GtkWidget = c.gtk_gl_area_new(); const gl_area: *c.GtkGLArea = @ptrCast(widget); - // We grab the floating reference to GL area. This lets the - // GL area be moved around i.e. between a split, a tab, etc. + // Create an overlay so we can layer the GL area with other widgets. + const overlay: *c.GtkOverlay = @ptrCast(c.gtk_overlay_new()); + c.gtk_overlay_set_child(@ptrCast(overlay), widget); + + // We grab the floating reference to the primary widget. This allows the + // widget tree to be moved around i.e. between a split, a tab, etc. // without having to be really careful about ordering to // prevent a destroy. // // This is unref'd in the unref() method that's called by the // self.container through Elem.deinit. - _ = c.g_object_ref_sink(@ptrCast(gl_area)); - errdefer c.g_object_unref(@ptrCast(gl_area)); + _ = c.g_object_ref_sink(@ptrCast(overlay)); + errdefer c.g_object_unref(@ptrCast(overlay)); // We want the gl area to expand to fill the parent container. c.gtk_widget_set_hexpand(widget, 1); @@ -381,6 +391,7 @@ pub fn init(self: *Surface, app: *App, opts: Options) !void { self.* = .{ .app = app, .container = .{ .none = {} }, + .overlay = overlay, .gl_area = gl_area, .title_text = null, .core_surface = undefined, @@ -488,7 +499,7 @@ pub fn deinit(self: *Surface) void { // unref removes the long-held reference to the gl_area and kicks off the // deinit/destroy process for this surface. pub fn unref(self: *Surface) void { - c.g_object_unref(self.gl_area); + c.g_object_unref(self.overlay); } pub fn destroy(self: *Surface, alloc: Allocator) void { @@ -496,6 +507,10 @@ pub fn destroy(self: *Surface, alloc: Allocator) void { alloc.destroy(self); } +pub fn primaryWidget(self: *Surface) *c.GtkWidget { + return @ptrCast(@alignCast(self.overlay)); +} + fn render(self: *Surface) !void { try self.core_surface.renderer.drawFrame(self); } @@ -879,10 +894,34 @@ pub fn setMouseVisibility(self: *Surface, visible: bool) void { c.gtk_widget_set_cursor(@ptrCast(self.gl_area), self.app.cursor_none); } -pub fn mouseOverLink(self: *Surface, uri: ?[]const u8) void { - // TODO: GTK - _ = self; - _ = uri; +pub fn mouseOverLink(self: *Surface, uri_: ?[]const u8) void { + const uri = uri_ orelse { + if (self.url_widget) |widget| { + c.gtk_overlay_remove_overlay(@ptrCast(self.overlay), widget); + self.url_widget = null; + } + + return; + }; + + // We need a null-terminated string + const alloc = self.app.core_app.alloc; + const uriZ = alloc.dupeZ(u8, uri) catch return; + defer alloc.free(uriZ); + + // If we have a URL widget already just change the text. + if (self.url_widget) |widget| { + c.gtk_label_set_text(@ptrCast(widget), uriZ.ptr); + return; + } + + // Create the widget + const label = c.gtk_label_new(uriZ.ptr); + c.gtk_widget_set_halign(label, c.GTK_ALIGN_START); + c.gtk_widget_set_valign(label, c.GTK_ALIGN_END); + c.gtk_widget_set_margin_bottom(label, 2); + c.gtk_overlay_add_overlay(@ptrCast(self.overlay), label); + self.url_widget = label; } pub fn clipboardRequest( diff --git a/src/apprt/gtk/Tab.zig b/src/apprt/gtk/Tab.zig index a41c72d93..32b0c7888 100644 --- a/src/apprt/gtk/Tab.zig +++ b/src/apprt/gtk/Tab.zig @@ -104,8 +104,7 @@ pub fn init(self: *Tab, window: *Window, parent_: ?*CoreSurface) !void { self.elem = .{ .surface = surface }; // Add Surface to the Tab - const gl_area_widget = @as(*c.GtkWidget, @ptrCast(surface.gl_area)); - c.gtk_box_append(self.box, gl_area_widget); + c.gtk_box_append(self.box, surface.primaryWidget()); // Add the notebook page (create tab). const parent_page_idx = switch (window.app.config.@"window-new-tab-position") { From ecdb0a74b01a5c64173c71c56d3a15b3bb136557 Mon Sep 17 00:00:00 2001 From: Mitchell Hashimoto Date: Sat, 6 Jul 2024 15:09:59 -0700 Subject: [PATCH 50/58] apprt/gtk: style the overlay --- src/apprt/gtk/Surface.zig | 2 ++ src/apprt/gtk/style.css | 3 +++ 2 files changed, 5 insertions(+) diff --git a/src/apprt/gtk/Surface.zig b/src/apprt/gtk/Surface.zig index f6ecce8ec..d47bc9800 100644 --- a/src/apprt/gtk/Surface.zig +++ b/src/apprt/gtk/Surface.zig @@ -917,6 +917,8 @@ pub fn mouseOverLink(self: *Surface, uri_: ?[]const u8) void { // Create the widget const label = c.gtk_label_new(uriZ.ptr); + c.gtk_widget_add_css_class(@ptrCast(label), "view"); + c.gtk_widget_add_css_class(@ptrCast(label), "url-overlay"); c.gtk_widget_set_halign(label, c.GTK_ALIGN_START); c.gtk_widget_set_valign(label, c.GTK_ALIGN_END); c.gtk_widget_set_margin_bottom(label, 2); diff --git a/src/apprt/gtk/style.css b/src/apprt/gtk/style.css index e69de29bb..6857b15bf 100644 --- a/src/apprt/gtk/style.css +++ b/src/apprt/gtk/style.css @@ -0,0 +1,3 @@ +label.url-overlay { + padding: 2px; +} From 571182fb60280f3692ee8ada3b36c8d4be638f4e Mon Sep 17 00:00:00 2001 From: Mitchell Hashimoto Date: Sat, 6 Jul 2024 21:33:42 -0700 Subject: [PATCH 51/58] macos: move OSC8 URL view to right if mouse is over it --- macos/Sources/Ghostty/SurfaceView.swift | 39 ++++++++++++++++++------- 1 file changed, 29 insertions(+), 10 deletions(-) diff --git a/macos/Sources/Ghostty/SurfaceView.swift b/macos/Sources/Ghostty/SurfaceView.swift index b17c2a255..5587d538e 100644 --- a/macos/Sources/Ghostty/SurfaceView.swift +++ b/macos/Sources/Ghostty/SurfaceView.swift @@ -48,9 +48,12 @@ extension Ghostty { // Maintain whether our window has focus (is key) or not @State private var windowFocus: Bool = true - + + // True if we're hovering over the left URL view, so we can show it on the right. + @State private var isHoveringURLLeft: Bool = false + @EnvironmentObject private var ghostty: Ghostty.App - + var body: some View { let center = NotificationCenter.default @@ -146,19 +149,35 @@ extension Ghostty { .ghosttySurfaceView(surfaceView) // If we have a URL from hovering a link, we show that. - // TODO if let url = surfaceView.hoverUrl { let padding: CGFloat = 3 - HStack { - VStack(alignment: .leading) { + ZStack { + HStack { + VStack(alignment: .leading) { + Spacer() + + Text(verbatim: url) + .padding(.init(top: padding, leading: padding, bottom: padding, trailing: padding)) + .background(.background) + .opacity(isHoveringURLLeft ? 0 : 1) + .onHover(perform: { hovering in + isHoveringURLLeft = hovering + }) + } Spacer() - - Text(verbatim: url) - .padding(.init(top: padding, leading: padding, bottom: padding, trailing: padding)) - .background(.background) } - Spacer() + HStack { + Spacer() + VStack(alignment: .leading) { + Spacer() + + Text(verbatim: url) + .padding(.init(top: padding, leading: padding, bottom: padding, trailing: padding)) + .background(.background) + .opacity(isHoveringURLLeft ? 1 : 0) + } + } } } From c9accc52e2f0e6df54b5d85fc7c24b1887891b83 Mon Sep 17 00:00:00 2001 From: Mitchell Hashimoto Date: Sat, 6 Jul 2024 21:36:28 -0700 Subject: [PATCH 52/58] core: show URL even for non-OSC8 hyperlnks --- src/Surface.zig | 9 ++++++++- 1 file changed, 8 insertions(+), 1 deletion(-) diff --git a/src/Surface.zig b/src/Surface.zig index 6a2aa7e14..d9dd4de4f 100644 --- a/src/Surface.zig +++ b/src/Surface.zig @@ -2825,7 +2825,14 @@ pub fn cursorPosCallback( try self.rt_surface.setMouseShape(.pointer); switch (link[0]) { - .open => {}, + .open => { + const str = try self.io.terminal.screen.selectionString(self.alloc, .{ + .sel = link[1], + .trim = false, + }); + defer self.alloc.free(str); + self.rt_surface.mouseOverLink(str); + }, ._open_osc8 => link: { // Show the URL in the status bar From f9e5d9c10b9c3eb116cfbade4b5ea8938654c931 Mon Sep 17 00:00:00 2001 From: Mitchell Hashimoto Date: Sun, 7 Jul 2024 09:37:31 -0700 Subject: [PATCH 53/58] apprt/gtk: move url hover bar when its under the mouse --- src/apprt/gtk/Surface.zig | 103 +++++++++++++++++++++++++++++++++----- src/apprt/gtk/style.css | 8 +++ 2 files changed, 99 insertions(+), 12 deletions(-) diff --git a/src/apprt/gtk/Surface.zig b/src/apprt/gtk/Surface.zig index d47bc9800..abe7a1703 100644 --- a/src/apprt/gtk/Surface.zig +++ b/src/apprt/gtk/Surface.zig @@ -208,6 +208,92 @@ pub const Container = union(enum) { } }; +/// Represents the URL hover widgets that show the hovered URL. +/// To explain a bit how this all works since its split across a few places: +/// We create a left/right pair of labels. The left label is shown by default, +/// and the right label is hidden. When the mouse enters the left label, we +/// show the right label. When the mouse leaves the left label, we hide the +/// right label. +/// +/// The hover and styling is done with a combination of GTK event controllers +/// and CSS in style.css. +pub const URLWidget = struct { + left: *c.GtkWidget, + right: *c.GtkWidget, + + pub fn init(overlay: *c.GtkOverlay, str: [:0]const u8) URLWidget { + // Create the left + const left = c.gtk_label_new(str.ptr); + c.gtk_widget_add_css_class(@ptrCast(left), "view"); + c.gtk_widget_add_css_class(@ptrCast(left), "url-overlay"); + c.gtk_widget_set_halign(left, c.GTK_ALIGN_START); + c.gtk_widget_set_valign(left, c.GTK_ALIGN_END); + c.gtk_widget_set_margin_bottom(left, 2); + + // Create the right + const right = c.gtk_label_new(str.ptr); + c.gtk_widget_add_css_class(@ptrCast(right), "hidden"); + c.gtk_widget_add_css_class(@ptrCast(right), "view"); + c.gtk_widget_add_css_class(@ptrCast(right), "url-overlay"); + c.gtk_widget_set_halign(right, c.GTK_ALIGN_END); + c.gtk_widget_set_valign(right, c.GTK_ALIGN_END); + c.gtk_widget_set_margin_bottom(right, 2); + + // Setup our mouse hover event for the left + const ec_motion = c.gtk_event_controller_motion_new(); + errdefer c.g_object_unref(ec_motion); + c.gtk_widget_add_controller(@ptrCast(left), ec_motion); + _ = c.g_signal_connect_data( + ec_motion, + "enter", + c.G_CALLBACK(>kLeftEnter), + right, + null, + c.G_CONNECT_DEFAULT, + ); + _ = c.g_signal_connect_data( + ec_motion, + "leave", + c.G_CALLBACK(>kLeftLeave), + right, + null, + c.G_CONNECT_DEFAULT, + ); + + // Show it + c.gtk_overlay_add_overlay(@ptrCast(overlay), left); + c.gtk_overlay_add_overlay(@ptrCast(overlay), right); + + return .{ + .left = left, + .right = right, + }; + } + + pub fn setText(self: *const URLWidget, str: [:0]const u8) void { + c.gtk_label_set_text(@ptrCast(self.left), str.ptr); + c.gtk_label_set_text(@ptrCast(self.right), str.ptr); + } + + fn gtkLeftEnter( + _: *c.GtkEventControllerMotion, + _: c.gdouble, + _: c.gdouble, + ud: ?*anyopaque, + ) callconv(.C) void { + const right: *c.GtkWidget = @ptrCast(@alignCast(ud orelse return)); + c.gtk_widget_remove_css_class(@ptrCast(right), "hidden"); + } + + fn gtkLeftLeave( + _: *c.GtkEventControllerMotion, + ud: ?*anyopaque, + ) callconv(.C) void { + const right: *c.GtkWidget = @ptrCast(@alignCast(ud orelse return)); + c.gtk_widget_add_css_class(@ptrCast(right), "hidden"); + } +}; + /// Whether the surface has been realized or not yet. When a surface is /// "realized" it means that the OpenGL context is ready and the core /// surface has been initialized. @@ -230,7 +316,7 @@ overlay: *c.GtkOverlay, gl_area: *c.GtkGLArea, /// If non-null this is the widget on the overlay that shows the URL. -url_widget: ?*c.GtkWidget = null, +url_widget: ?URLWidget = null, /// Any active cursor we may have cursor: ?*c.GdkCursor = null, @@ -896,8 +982,9 @@ pub fn setMouseVisibility(self: *Surface, visible: bool) void { pub fn mouseOverLink(self: *Surface, uri_: ?[]const u8) void { const uri = uri_ orelse { + if (true) return; if (self.url_widget) |widget| { - c.gtk_overlay_remove_overlay(@ptrCast(self.overlay), widget); + widget.deinit(self.overlay); self.url_widget = null; } @@ -911,19 +998,11 @@ pub fn mouseOverLink(self: *Surface, uri_: ?[]const u8) void { // If we have a URL widget already just change the text. if (self.url_widget) |widget| { - c.gtk_label_set_text(@ptrCast(widget), uriZ.ptr); + widget.setText(uriZ); return; } - // Create the widget - const label = c.gtk_label_new(uriZ.ptr); - c.gtk_widget_add_css_class(@ptrCast(label), "view"); - c.gtk_widget_add_css_class(@ptrCast(label), "url-overlay"); - c.gtk_widget_set_halign(label, c.GTK_ALIGN_START); - c.gtk_widget_set_valign(label, c.GTK_ALIGN_END); - c.gtk_widget_set_margin_bottom(label, 2); - c.gtk_overlay_add_overlay(@ptrCast(self.overlay), label); - self.url_widget = label; + self.url_widget = URLWidget.init(self.overlay, uriZ); } pub fn clipboardRequest( diff --git a/src/apprt/gtk/style.css b/src/apprt/gtk/style.css index 6857b15bf..4f52015f4 100644 --- a/src/apprt/gtk/style.css +++ b/src/apprt/gtk/style.css @@ -1,3 +1,11 @@ label.url-overlay { padding: 2px; } + +label.url-overlay:hover { + opacity: 0; +} + +label.url-overlay.hidden { + opacity: 0; +} From 10a3214cb4b4219dec1f67d2ac4bed1131ba0fc2 Mon Sep 17 00:00:00 2001 From: Mitchell Hashimoto Date: Sun, 7 Jul 2024 09:39:41 -0700 Subject: [PATCH 54/58] apprt/gtk: forgot to remove debug code to hide overlay --- src/apprt/gtk/Surface.zig | 1 - 1 file changed, 1 deletion(-) diff --git a/src/apprt/gtk/Surface.zig b/src/apprt/gtk/Surface.zig index abe7a1703..7476b4643 100644 --- a/src/apprt/gtk/Surface.zig +++ b/src/apprt/gtk/Surface.zig @@ -982,7 +982,6 @@ pub fn setMouseVisibility(self: *Surface, visible: bool) void { pub fn mouseOverLink(self: *Surface, uri_: ?[]const u8) void { const uri = uri_ orelse { - if (true) return; if (self.url_widget) |widget| { widget.deinit(self.overlay); self.url_widget = null; From 45d0653f467ba039d1837cfe6ef51160393d4292 Mon Sep 17 00:00:00 2001 From: Mitchell Hashimoto Date: Sun, 7 Jul 2024 09:48:39 -0700 Subject: [PATCH 55/58] apprt/gtk: add deinit for url widget --- src/apprt/gtk/Surface.zig | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/src/apprt/gtk/Surface.zig b/src/apprt/gtk/Surface.zig index 7476b4643..a83a1089f 100644 --- a/src/apprt/gtk/Surface.zig +++ b/src/apprt/gtk/Surface.zig @@ -270,6 +270,11 @@ pub const URLWidget = struct { }; } + pub fn deinit(self: *URLWidget, overlay: *c.GtkOverlay) void { + c.gtk_overlay_remove_overlay(@ptrCast(overlay), @ptrCast(self.left)); + c.gtk_overlay_remove_overlay(@ptrCast(overlay), @ptrCast(self.right)); + } + pub fn setText(self: *const URLWidget, str: [:0]const u8) void { c.gtk_label_set_text(@ptrCast(self.left), str.ptr); c.gtk_label_set_text(@ptrCast(self.right), str.ptr); @@ -982,7 +987,7 @@ pub fn setMouseVisibility(self: *Surface, visible: bool) void { pub fn mouseOverLink(self: *Surface, uri_: ?[]const u8) void { const uri = uri_ orelse { - if (self.url_widget) |widget| { + if (self.url_widget) |*widget| { widget.deinit(self.overlay); self.url_widget = null; } From b7699b9af929c17515dfc2f092dac8a2b92b5805 Mon Sep 17 00:00:00 2001 From: Mitchell Hashimoto Date: Sun, 7 Jul 2024 12:17:40 -0700 Subject: [PATCH 56/58] apprt/gtk: add all event handlers to the overlay so both receive --- src/apprt/gtk/Surface.zig | 72 +++++++++++++++++++++------------------ 1 file changed, 39 insertions(+), 33 deletions(-) diff --git a/src/apprt/gtk/Surface.zig b/src/apprt/gtk/Surface.zig index a83a1089f..91de624be 100644 --- a/src/apprt/gtk/Surface.zig +++ b/src/apprt/gtk/Surface.zig @@ -221,7 +221,7 @@ pub const URLWidget = struct { left: *c.GtkWidget, right: *c.GtkWidget, - pub fn init(overlay: *c.GtkOverlay, str: [:0]const u8) URLWidget { + pub fn init(surface: *const Surface, str: [:0]const u8) URLWidget { // Create the left const left = c.gtk_label_new(str.ptr); c.gtk_widget_add_css_class(@ptrCast(left), "view"); @@ -261,8 +261,8 @@ pub const URLWidget = struct { ); // Show it - c.gtk_overlay_add_overlay(@ptrCast(overlay), left); - c.gtk_overlay_add_overlay(@ptrCast(overlay), right); + c.gtk_overlay_add_overlay(@ptrCast(surface.overlay), left); + c.gtk_overlay_add_overlay(@ptrCast(surface.overlay), right); return .{ .left = left, @@ -365,12 +365,15 @@ pub fn create(alloc: Allocator, app: *App, opts: Options) !*Surface { } pub fn init(self: *Surface, app: *App, opts: Options) !void { - const widget: *c.GtkWidget = c.gtk_gl_area_new(); - const gl_area: *c.GtkGLArea = @ptrCast(widget); + const gl_area = c.gtk_gl_area_new(); // Create an overlay so we can layer the GL area with other widgets. - const overlay: *c.GtkOverlay = @ptrCast(c.gtk_overlay_new()); - c.gtk_overlay_set_child(@ptrCast(overlay), widget); + const overlay = c.gtk_overlay_new(); + c.gtk_overlay_set_child(@ptrCast(overlay), gl_area); + + // Overlay is not focusable, but the GL area is. + c.gtk_widget_set_focusable(@ptrCast(overlay), 0); + c.gtk_widget_set_focus_on_click(@ptrCast(overlay), 0); // We grab the floating reference to the primary widget. This allows the // widget tree to be moved around i.e. between a split, a tab, etc. @@ -383,45 +386,45 @@ pub fn init(self: *Surface, app: *App, opts: Options) !void { errdefer c.g_object_unref(@ptrCast(overlay)); // We want the gl area to expand to fill the parent container. - c.gtk_widget_set_hexpand(widget, 1); - c.gtk_widget_set_vexpand(widget, 1); + c.gtk_widget_set_hexpand(gl_area, 1); + c.gtk_widget_set_vexpand(gl_area, 1); // Various other GL properties - c.gtk_widget_set_cursor_from_name(@ptrCast(gl_area), "text"); - c.gtk_gl_area_set_required_version(gl_area, 3, 3); - c.gtk_gl_area_set_has_stencil_buffer(gl_area, 0); - c.gtk_gl_area_set_has_depth_buffer(gl_area, 0); - c.gtk_gl_area_set_use_es(gl_area, 0); + c.gtk_widget_set_cursor_from_name(@ptrCast(overlay), "text"); + c.gtk_gl_area_set_required_version(@ptrCast(gl_area), 3, 3); + c.gtk_gl_area_set_has_stencil_buffer(@ptrCast(gl_area), 0); + c.gtk_gl_area_set_has_depth_buffer(@ptrCast(gl_area), 0); + c.gtk_gl_area_set_use_es(@ptrCast(gl_area), 0); // Key event controller will tell us about raw keypress events. const ec_key = c.gtk_event_controller_key_new(); errdefer c.g_object_unref(ec_key); - c.gtk_widget_add_controller(widget, ec_key); - errdefer c.gtk_widget_remove_controller(widget, ec_key); + c.gtk_widget_add_controller(@ptrCast(overlay), ec_key); + errdefer c.gtk_widget_remove_controller(@ptrCast(overlay), ec_key); // Focus controller will tell us about focus enter/exit events const ec_focus = c.gtk_event_controller_focus_new(); errdefer c.g_object_unref(ec_focus); - c.gtk_widget_add_controller(widget, ec_focus); - errdefer c.gtk_widget_remove_controller(widget, ec_focus); + c.gtk_widget_add_controller(@ptrCast(overlay), ec_focus); + errdefer c.gtk_widget_remove_controller(@ptrCast(overlay), ec_focus); // Create a second key controller so we can receive the raw // key-press events BEFORE the input method gets them. const ec_key_press = c.gtk_event_controller_key_new(); errdefer c.g_object_unref(ec_key_press); - c.gtk_widget_add_controller(widget, ec_key_press); - errdefer c.gtk_widget_remove_controller(widget, ec_key_press); + c.gtk_widget_add_controller(@ptrCast(overlay), ec_key_press); + errdefer c.gtk_widget_remove_controller(@ptrCast(overlay), ec_key_press); // Clicks const gesture_click = c.gtk_gesture_click_new(); errdefer c.g_object_unref(gesture_click); c.gtk_gesture_single_set_button(@ptrCast(gesture_click), 0); - c.gtk_widget_add_controller(widget, @ptrCast(gesture_click)); + c.gtk_widget_add_controller(@ptrCast(@alignCast(overlay)), @ptrCast(gesture_click)); // Mouse movement const ec_motion = c.gtk_event_controller_motion_new(); errdefer c.g_object_unref(ec_motion); - c.gtk_widget_add_controller(widget, ec_motion); + c.gtk_widget_add_controller(@ptrCast(@alignCast(overlay)), ec_motion); // Scroll events const ec_scroll = c.gtk_event_controller_scroll_new( @@ -429,7 +432,7 @@ pub fn init(self: *Surface, app: *App, opts: Options) !void { c.GTK_EVENT_CONTROLLER_SCROLL_DISCRETE, ); errdefer c.g_object_unref(ec_scroll); - c.gtk_widget_add_controller(widget, ec_scroll); + c.gtk_widget_add_controller(@ptrCast(overlay), ec_scroll); // The input method context that we use to translate key events into // characters. This doesn't have an event key controller attached because @@ -438,8 +441,8 @@ pub fn init(self: *Surface, app: *App, opts: Options) !void { errdefer c.g_object_unref(im_context); // The GL area has to be focusable so that it can receive events - c.gtk_widget_set_focusable(widget, 1); - c.gtk_widget_set_focus_on_click(widget, 1); + c.gtk_widget_set_focusable(gl_area, 1); + c.gtk_widget_set_focus_on_click(gl_area, 1); // Inherit the parent's font size if we have a parent. const font_size: ?font.face.DesiredSize = font_size: { @@ -482,8 +485,8 @@ pub fn init(self: *Surface, app: *App, opts: Options) !void { self.* = .{ .app = app, .container = .{ .none = {} }, - .overlay = overlay, - .gl_area = gl_area, + .overlay = @ptrCast(overlay), + .gl_area = @ptrCast(gl_area), .title_text = null, .core_surface = undefined, .font_size = font_size, @@ -961,8 +964,9 @@ pub fn setMouseShape( // Set our new cursor. We only do this if the cursor we currently // have is NOT set to "none" because setting the cursor causes it // to become visible again. - if (c.gtk_widget_get_cursor(@ptrCast(self.gl_area)) != self.app.cursor_none) { - c.gtk_widget_set_cursor(@ptrCast(self.gl_area), cursor); + const overlay_widget: *c.GtkWidget = @ptrCast(@alignCast(self.overlay)); + if (c.gtk_widget_get_cursor(overlay_widget) != self.app.cursor_none) { + c.gtk_widget_set_cursor(overlay_widget, cursor); } // Free our existing cursor @@ -975,18 +979,20 @@ pub fn setMouseVisibility(self: *Surface, visible: bool) void { // Note in there that self.cursor or cursor_none may be null. That's // not a problem because NULL is a valid argument for set cursor // which means to just use the parent value. + const overlay_widget: *c.GtkWidget = @ptrCast(@alignCast(self.overlay)); if (visible) { - c.gtk_widget_set_cursor(@ptrCast(self.gl_area), self.cursor); + c.gtk_widget_set_cursor(overlay_widget, self.cursor); return; } // Set our new cursor to the app "none" cursor - c.gtk_widget_set_cursor(@ptrCast(self.gl_area), self.app.cursor_none); + c.gtk_widget_set_cursor(overlay_widget, self.app.cursor_none); } pub fn mouseOverLink(self: *Surface, uri_: ?[]const u8) void { const uri = uri_ orelse { + if (true) return; if (self.url_widget) |*widget| { widget.deinit(self.overlay); self.url_widget = null; @@ -1006,7 +1012,7 @@ pub fn mouseOverLink(self: *Surface, uri_: ?[]const u8) void { return; } - self.url_widget = URLWidget.init(self.overlay, uriZ); + self.url_widget = URLWidget.init(self, uriZ); } pub fn clipboardRequest( @@ -1166,7 +1172,7 @@ fn gtkRealize(area: *c.GtkGLArea, ud: ?*anyopaque) callconv(.C) void { // When we have a realized surface, we also attach our input method context. // We do this here instead of init because this allows us to relase the ref // to the GLArea when we unrealized. - c.gtk_im_context_set_client_widget(self.im_context, @ptrCast(self.gl_area)); + c.gtk_im_context_set_client_widget(self.im_context, @ptrCast(@alignCast(self.overlay))); } /// This is called when the underlying OpenGL resources must be released. From f1561a4cae656d4e90279f97c0e96ba651744a0e Mon Sep 17 00:00:00 2001 From: Mitchell Hashimoto Date: Sun, 7 Jul 2024 12:25:08 -0700 Subject: [PATCH 57/58] apprt/gtk: committed the forever status bar again --- src/apprt/gtk/Surface.zig | 1 - 1 file changed, 1 deletion(-) diff --git a/src/apprt/gtk/Surface.zig b/src/apprt/gtk/Surface.zig index 91de624be..406c7bece 100644 --- a/src/apprt/gtk/Surface.zig +++ b/src/apprt/gtk/Surface.zig @@ -992,7 +992,6 @@ pub fn setMouseVisibility(self: *Surface, visible: bool) void { pub fn mouseOverLink(self: *Surface, uri_: ?[]const u8) void { const uri = uri_ orelse { - if (true) return; if (self.url_widget) |*widget| { widget.deinit(self.overlay); self.url_widget = null; From a32007bfebb6cb81674c957986e103d54794661b Mon Sep 17 00:00:00 2001 From: Mitchell Hashimoto Date: Sun, 7 Jul 2024 12:26:40 -0700 Subject: [PATCH 58/58] core: when mouse reporting, clear link state --- src/Surface.zig | 10 +++++++--- 1 file changed, 7 insertions(+), 3 deletions(-) diff --git a/src/Surface.zig b/src/Surface.zig index d9dd4de4f..149a2c5eb 100644 --- a/src/Surface.zig +++ b/src/Surface.zig @@ -2736,9 +2736,13 @@ pub fn cursorPosCallback( try self.mouseReport(button, .motion, self.mouse.mods, pos); - // If we were previously over a link, we need to queue a - // render to undo the link state. - if (over_link) try self.queueRender(); + // If we were previously over a link, we need to undo the link state. + // We also queue a render so the renderer can undo the rendered link + // state. + if (over_link) { + self.rt_surface.mouseOverLink(null); + try self.queueRender(); + } // If we're doing mouse motion tracking, we do not support text // selection.