From 58173c9df5e58b40dd0572607623f199bc9e08d0 Mon Sep 17 00:00:00 2001 From: Mitchell Hashimoto Date: Sun, 9 Jun 2024 10:20:18 -0700 Subject: [PATCH] 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.