libghostty: add hyperlink URI accessor to grid_ref API

Add ghostty_grid_ref_hyperlink_uri to extract the OSC 8 hyperlink
URI from a cell at a grid reference position. Follows the same
buffer pattern as ghostty_grid_ref_graphemes: callers pass a buffer
and get back the byte length, or GHOSTTY_OUT_OF_SPACE with the
required size if the buffer is too small. Cells without a hyperlink
return success with length 0.
This commit is contained in:
Mitchell Hashimoto
2026-04-04 20:23:08 -07:00
parent 0a4cf5877e
commit b9a241d1e2
4 changed files with 122 additions and 0 deletions

View File

@@ -109,6 +109,32 @@ GHOSTTY_API GhosttyResult ghostty_grid_ref_graphemes(const GhosttyGridRef *ref,
size_t buf_len,
size_t *out_len);
/**
* Get the hyperlink URI for the cell at the grid reference's position.
*
* Writes the URI bytes into the provided buffer. If the cell has no
* hyperlink, out_len is set to 0 and GHOSTTY_SUCCESS is returned.
*
* If the buffer is too small (or NULL), the function returns
* GHOSTTY_OUT_OF_SPACE and writes the required number of bytes to
* out_len. The caller can then retry with a sufficiently sized buffer.
*
* @param ref Pointer to the grid reference
* @param buf Output buffer for the URI bytes (may be NULL)
* @param buf_len Size of the output buffer in bytes
* @param[out] out_len On success, the number of bytes written. On
* GHOSTTY_OUT_OF_SPACE, the required buffer size in bytes.
* @return GHOSTTY_SUCCESS on success, GHOSTTY_INVALID_VALUE if the ref's
* node is NULL, GHOSTTY_OUT_OF_SPACE if the buffer is too small
*
* @ingroup grid_ref
*/
GHOSTTY_API GhosttyResult ghostty_grid_ref_hyperlink_uri(
const GhosttyGridRef *ref,
uint8_t *buf,
size_t buf_len,
size_t *out_len);
/**
* Get the style of the cell at the grid reference's position.
*

View File

@@ -217,6 +217,7 @@ comptime {
@export(&c.grid_ref_cell, .{ .name = "ghostty_grid_ref_cell" });
@export(&c.grid_ref_row, .{ .name = "ghostty_grid_ref_row" });
@export(&c.grid_ref_graphemes, .{ .name = "ghostty_grid_ref_graphemes" });
@export(&c.grid_ref_hyperlink_uri, .{ .name = "ghostty_grid_ref_hyperlink_uri" });
@export(&c.grid_ref_style, .{ .name = "ghostty_grid_ref_style" });
@export(&c.build_info, .{ .name = "ghostty_build_info" });
@export(&c.type_json, .{ .name = "ghostty_type_json" });

View File

@@ -3,11 +3,13 @@ const testing = std.testing;
const lib = @import("../lib.zig");
const page = @import("../page.zig");
const PageList = @import("../PageList.zig");
const point = @import("../point.zig");
const size = @import("../size.zig");
const stylepkg = @import("../style.zig");
const cell_c = @import("cell.zig");
const row_c = @import("row.zig");
const style_c = @import("style.zig");
const terminal_c = @import("terminal.zig");
const Result = @import("result.zig").Result;
/// C: GhosttyGridRef
@@ -89,6 +91,38 @@ pub fn grid_ref_graphemes(
return .success;
}
pub fn grid_ref_hyperlink_uri(
ref: *const CGridRef,
out_buf: ?[*]u8,
buf_len: usize,
out_len: *usize,
) callconv(lib.calling_conv) Result {
const p = ref.toPin() orelse return .invalid_value;
const rac = p.node.data.getRowAndCell(p.x, p.y);
const cell = rac.cell;
if (!cell.hyperlink) {
out_len.* = 0;
return .success;
}
const link_id = p.node.data.lookupHyperlink(cell) orelse {
out_len.* = 0;
return .success;
};
const entry = p.node.data.hyperlink_set.get(p.node.data.memory, link_id);
const uri = entry.uri.slice(p.node.data.memory);
if (out_buf == null or buf_len < uri.len) {
out_len.* = uri.len;
return .out_of_space;
}
@memcpy(out_buf.?[0..uri.len], uri);
out_len.* = uri.len;
return .success;
}
pub fn grid_ref_style(
ref: *const CGridRef,
out: ?*style_c.Style,
@@ -154,3 +188,63 @@ test "grid_ref_style null out" {
const ref = CGridRef{};
try testing.expectEqual(Result.invalid_value, grid_ref_style(&ref, null));
}
test "grid_ref_hyperlink_uri null node" {
const ref = CGridRef{};
var len: usize = undefined;
try testing.expectEqual(Result.invalid_value, grid_ref_hyperlink_uri(&ref, null, 0, &len));
}
test "grid_ref_hyperlink_uri no hyperlink" {
var terminal: terminal_c.Terminal = null;
try testing.expectEqual(Result.success, terminal_c.new(
&lib.alloc.test_allocator,
&terminal,
.{ .cols = 80, .rows = 24, .max_scrollback = 10_000 },
));
defer terminal_c.free(terminal);
terminal_c.vt_write(terminal, "hello", 5);
var ref: CGridRef = undefined;
try testing.expectEqual(Result.success, terminal_c.grid_ref(
terminal,
point.Point.cval(.{ .active = .{ .x = 0, .y = 0 } }),
&ref,
));
var len: usize = undefined;
try testing.expectEqual(Result.success, grid_ref_hyperlink_uri(&ref, null, 0, &len));
try testing.expectEqual(@as(usize, 0), len);
}
test "grid_ref_hyperlink_uri with hyperlink" {
var terminal: terminal_c.Terminal = null;
try testing.expectEqual(Result.success, terminal_c.new(
&lib.alloc.test_allocator,
&terminal,
.{ .cols = 80, .rows = 24, .max_scrollback = 10_000 },
));
defer terminal_c.free(terminal);
// Write OSC 8 hyperlink: \e]8;;uri\e\\text\e]8;;\e\\
const seq = "\x1b]8;;https://example.com\x1b\\link\x1b]8;;\x1b\\";
terminal_c.vt_write(terminal, seq, seq.len);
var ref: CGridRef = undefined;
try testing.expectEqual(Result.success, terminal_c.grid_ref(
terminal,
point.Point.cval(.{ .active = .{ .x = 0, .y = 0 } }),
&ref,
));
// First query length with null buf
var len: usize = undefined;
try testing.expectEqual(Result.out_of_space, grid_ref_hyperlink_uri(&ref, null, 0, &len));
try testing.expectEqual(@as(usize, 19), len); // "https://example.com"
// Now read with a properly sized buffer
var buf: [256]u8 = undefined;
try testing.expectEqual(Result.success, grid_ref_hyperlink_uri(&ref, &buf, buf.len, &len));
try testing.expectEqualStrings("https://example.com", buf[0..len]);
}

View File

@@ -148,6 +148,7 @@ pub const type_json = types.get_json;
pub const grid_ref_cell = grid_ref.grid_ref_cell;
pub const grid_ref_row = grid_ref.grid_ref_row;
pub const grid_ref_graphemes = grid_ref.grid_ref_graphemes;
pub const grid_ref_hyperlink_uri = grid_ref.grid_ref_hyperlink_uri;
pub const grid_ref_style = grid_ref.grid_ref_style;
test {