diff options
| author | user <user@clank> | 2026-08-20 23:23:07 +0200 |
|---|---|---|
| committer | user <user@clank> | 2026-08-20 23:23:07 +0200 |
| commit | 965966f9fb3808fbc69e6cb55960ad7a3c72c5f4 (patch) | |
| tree | 388d17a48155b9fae25c65fdc6c8765b6bb3ec01 /src | |
| parent | slosh: floating panes and the drop grammar (diff) | |
| download | slosh-965966f9fb3808fbc69e6cb55960ad7a3c72c5f4.tar.gz slosh-965966f9fb3808fbc69e6cb55960ad7a3c72c5f4.tar.xz slosh-965966f9fb3808fbc69e6cb55960ad7a3c72c5f4.zip | |
slosh: OSC 8 hyperlinks pass through the compositor
lib-vt tracks a hyperlink per cell and slosh dropped every one of them: the
client's terminal can only offer a link it was sent, so `ls --hyperlink`,
gcc diagnostics and friends silently lost theirs inside the session.
The link rides beside the cell -- cell_t is the shader ABI and a link is
not a colour -- as a plane of ids into an interned URI table, append-only
so an id in prev_link still means the same URI next frame (which is what
lets the diff compare ids instead of strings), and capped so a hostile pane
cannot grow it forever. URIs with control bytes are refused outright: they
are emitted verbatim inside an OSC, and a control byte in one is a way to
write escape sequences into the client's terminal. The diff wraps emitted
runs in OSC 8, closes what it opens before the frame ends, and treats a
link-only change as a change. Chrome painted over a link takes the link
with it, so a frame, modal or float across a URL is never clickable with
the pane's link; links survive scrollback with the text that carries them.
Plain-text URLs stay the outer terminal's own matcher, with the catch
documented in panes.md: under mouse capture Ghostty only offers links with
shift added to the gesture (shift+cmd hover/click), shift being its
capture escape. Wrapped URLs can never be regex-matched through a
multiplexer -- rows repaint as hard lines -- which is exactly what OSC 8
links, passing through whole, are for.
`snapshot format:"bytes"` returns the emitter's own output for a frame (a
second call is the delta): the model and the bytes are two different
things, and this codebase already paid once for asserting on one while the
other was wrong.
Diffstat (limited to 'src')
| -rw-r--r-- | src/pane.c | 40 | ||||
| -rw-r--r-- | src/screen.c | 97 |
2 files changed, 133 insertions, 4 deletions
@@ -1145,6 +1145,30 @@ static color_t to_color(const GhosttyColorRgb *c, bool ok) { return out; } +/* The hyperlink on a cell, as this screen's interned id. The raw cell only + * says *whether* there is one; the URI takes a grid-ref lookup, so the flag + * gates it and a run of cells sharing one link costs one lookup — the last + * answer is remembered for exactly as long as the flag keeps being true with + * the same URI, which is what a link is. */ +static uint16_t cell_link_id(pane_t *p, screen_t *s, uint16_t x, uint16_t y, + char *last_uri, size_t *last_len, + uint16_t *last_id) { + GhosttyGridRef ref; + if (!grid_ref_at(p, x, y, &ref)) return 0; + uint8_t uri[1024]; + size_t len = 0; + if (ghostty_grid_ref_hyperlink_uri(&ref, uri, sizeof uri, &len) != + GHOSTTY_SUCCESS || + !len) + return 0; + if (len == *last_len && memcmp(uri, last_uri, len) == 0) return *last_id; + uint16_t id = screen_link_id(s, (const char *)uri, len); + memcpy(last_uri, uri, len); + *last_len = len; + *last_id = id; + return id; +} + void pane_compose(pane_t *p, screen_t *s, uint16_t x0, uint16_t y0, bool focused) { if (ghostty_render_state_update(p->rstate, p->term) != GHOSTTY_SUCCESS) @@ -1155,6 +1179,10 @@ void pane_compose(pane_t *p, screen_t *s, uint16_t x0, uint16_t y0, &p->rows) != GHOSTTY_SUCCESS) return; + char last_uri[1024]; + size_t last_len = 0; + uint16_t last_id = 0; + uint16_t y = 0; while (ghostty_render_state_row_iterator_next(p->rows)) { if (y >= p->rows_n) break; @@ -1179,18 +1207,28 @@ void pane_compose(pane_t *p, screen_t *s, uint16_t x0, uint16_t y0, GhosttyCell raw = {0}; GhosttyCellWide wide = GHOSTTY_CELL_WIDE_NARROW; + bool linked = false; if (ghostty_render_state_row_cells_get( p->cells, GHOSTTY_RENDER_STATE_ROW_CELLS_DATA_RAW, &raw) == - GHOSTTY_SUCCESS) + GHOSTTY_SUCCESS) { ghostty_cell_get(raw, GHOSTTY_CELL_DATA_WIDE, &wide); + ghostty_cell_get(raw, GHOSTTY_CELL_DATA_HAS_HYPERLINK, &linked); + } if (wide == GHOSTTY_CELL_WIDE_SPACER_TAIL) { dst->width = 0; dst->len = 0; + screen_set_link(s, (uint16_t)(x0 + x), (uint16_t)(y0 + y), 0); x++; continue; } + /* OSC 8 hyperlinks ride beside the cell, or they would be lost: the + * client's terminal can only offer a link it was sent. */ + screen_set_link( + s, (uint16_t)(x0 + x), (uint16_t)(y0 + y), + linked ? cell_link_id(p, s, x, y, last_uri, &last_len, &last_id) : 0); + char utf8[16] = {0}; GhosttyBuffer gb = {.ptr = (uint8_t *)utf8, .cap = sizeof utf8, .len = 0}; if (ghostty_render_state_row_cells_get( diff --git a/src/screen.c b/src/screen.c index b3c8564..7bd474a 100644 --- a/src/screen.c +++ b/src/screen.c @@ -82,6 +82,8 @@ static cell_t blank_cell(void) { return c; } +#define LINKS_MAX 4096 /* unique URIs a screen will carry; then they drop */ + void screen_init(screen_t *s, uint16_t cols, uint16_t rows) { memset(s, 0, sizeof *s); s->cols = cols; @@ -89,6 +91,8 @@ void screen_init(screen_t *s, uint16_t cols, uint16_t rows) { size_t n = (size_t)cols * rows; s->cur = calloc(n, sizeof(cell_t)); s->prev = calloc(n, sizeof(cell_t)); + s->cur_link = calloc(n, sizeof(uint16_t)); + s->prev_link = calloc(n, sizeof(uint16_t)); s->force_full = true; screen_clear(s); } @@ -96,6 +100,10 @@ void screen_init(screen_t *s, uint16_t cols, uint16_t rows) { void screen_free(screen_t *s) { free(s->cur); free(s->prev); + free(s->cur_link); + free(s->prev_link); + for (size_t i = 0; i < s->nlinks; i++) free(s->links[i]); + free(s->links); free(s->out); free(s->hits.items); memset(s, 0, sizeof *s); @@ -105,11 +113,17 @@ void screen_resize(screen_t *s, uint16_t cols, uint16_t rows) { if (cols == s->cols && rows == s->rows) return; free(s->cur); free(s->prev); + free(s->cur_link); + free(s->prev_link); s->cols = cols; s->rows = rows; size_t n = (size_t)cols * rows; s->cur = calloc(n, sizeof(cell_t)); s->prev = calloc(n, sizeof(cell_t)); + s->cur_link = calloc(n, sizeof(uint16_t)); + s->prev_link = calloc(n, sizeof(uint16_t)); + /* The URI table survives a resize on purpose: ids in flight stay + * meaningful, and a resize is not a reason to forget what a link was. */ s->force_full = true; screen_clear(s); } @@ -118,6 +132,33 @@ void screen_clear(screen_t *s) { size_t n = (size_t)s->cols * s->rows; cell_t b = blank_cell(); for (size_t i = 0; i < n; i++) s->cur[i] = b; + memset(s->cur_link, 0, n * sizeof(uint16_t)); +} + +uint16_t screen_link_id(screen_t *s, const char *uri, size_t len) { + if (!uri || !len || len > 1024) return 0; + /* A URI is emitted verbatim inside an OSC, so a control byte in one is a + * way to write escape sequences into the client's terminal. lib-vt cannot + * hand us its own terminators, but the rest of C0 is cheap to refuse and + * free to never think about again. */ + for (size_t i = 0; i < len; i++) + if ((unsigned char)uri[i] < 0x20 || uri[i] == 0x7f) return 0; + for (size_t i = 0; i < s->nlinks; i++) + if (strlen(s->links[i]) == len && memcmp(s->links[i], uri, len) == 0) + return (uint16_t)(i + 1); + if (s->nlinks >= LINKS_MAX) return 0; + char *copy = malloc(len + 1); + if (!copy) return 0; + memcpy(copy, uri, len); + copy[len] = 0; + s->links = realloc(s->links, (s->nlinks + 1) * sizeof *s->links); + s->links[s->nlinks++] = copy; + return (uint16_t)s->nlinks; +} + +void screen_set_link(screen_t *s, uint16_t x, uint16_t y, uint16_t id) { + if (x >= s->cols || y >= s->rows) return; + s->cur_link[(size_t)y * s->cols + x] = id; } cell_t *screen_at(screen_t *s, uint16_t x, uint16_t y) { @@ -228,6 +269,10 @@ void screen_put_utf8(screen_t *s, uint16_t x, uint16_t y, const char *txt, memcpy(c->text, txt, len); c->len = (uint8_t)len; c->width = screen_width_of(txt, len); + /* Chrome over a link is chrome: writing a cell through here takes any + * hyperlink a pane left on it, or a frame drawn across a URL would be + * clickable. */ + screen_set_link(s, x, y, 0); /* The second half of a wide cell is a tail: never painted, and skipped by * the diff. Without claiming it, the cell to the right keeps whatever was * there and the terminal draws both. */ @@ -237,6 +282,7 @@ void screen_put_utf8(screen_t *s, uint16_t x, uint16_t y, const char *txt, memset(tail, 0, sizeof *tail); tail->fg = fg; tail->bg = bg; + screen_set_link(s, (uint16_t)(x + 1), y, 0); } else { c->width = 1; /* no room for the tail: do not claim what is not there */ } @@ -305,19 +351,30 @@ void screen_render(screen_t *s) { if (s->force_full) { out_str(s, "\x1b[?25l\x1b[0m\x1b[H\x1b[2J"); memset(s->prev, 0, (size_t)s->cols * s->rows * sizeof(cell_t)); + memset(s->prev_link, 0, (size_t)s->cols * s->rows * sizeof(uint16_t)); painted = true; } bool have_pos = false, have_style = false; uint16_t cx = 0, cy = 0; cell_t style = {0}; + /* Which hyperlink the output stream currently has open (OSC 8). Only the + * cells this frame emits pass through it — an unchanged cell keeps the + * link the terminal already holds for it — and it is closed before the + * frame ends, so link state never leaks into the cursor restore or the + * next frame's first cell. */ + uint16_t open_link = 0; for (uint16_t y = 0; y < s->rows; y++) { for (uint16_t x = 0; x < s->cols; x++) { - cell_t *c = &s->cur[(size_t)y * s->cols + x]; - cell_t *p = &s->prev[(size_t)y * s->cols + x]; + size_t idx = (size_t)y * s->cols + x; + cell_t *c = &s->cur[idx]; + cell_t *p = &s->prev[idx]; if (c->width == 0) continue; /* tail of a wide cell: never painted */ - if (cell_eq(c, p)) continue; + /* A cell whose only change is its link is still a change: the glyph + * has to be re-sent inside (or outside) the OSC 8 wrapper for the + * terminal to move it between links. */ + if (cell_eq(c, p) && s->cur_link[idx] == s->prev_link[idx]) continue; if (!painted) { out_str(s, "\x1b[?25l"); @@ -334,16 +391,27 @@ void screen_render(screen_t *s) { style = *c; have_style = true; } + if (s->cur_link[idx] != open_link) { + if (open_link) out_str(s, "\x1b]8;;\x1b\\"); + open_link = s->cur_link[idx]; + if (open_link) { + out_str(s, "\x1b]8;;"); + out_str(s, s->links[open_link - 1]); + out_str(s, "\x1b\\"); + } + } if (c->len) out_bytes(s, c->text, c->len); else out_str(s, " "); *p = *c; + s->prev_link[idx] = s->cur_link[idx]; cx += c->width ? c->width : 1; if (cx >= s->cols) have_pos = false; /* wrap is terminal-dependent */ } } + if (open_link) out_str(s, "\x1b]8;;\x1b\\"); bool cursor_moved = s->cursor_visible != s->shown_cursor_visible || s->cursor_x != s->shown_cursor_x || @@ -459,6 +527,29 @@ char *screen_dump_json(screen_t *s) { dump_style_runs(s, &j); + /* Hyperlinks as runs, like the styles: a test wants "these six cells are + * this URI", not one object per cell. */ + json_arr_open(&j, "links"); + for (uint16_t y = 0; y < s->rows; y++) { + uint16_t x = 0; + while (x < s->cols) { + uint16_t id = s->cur_link[(size_t)y * s->cols + x]; + if (!id) { + x++; + continue; + } + uint16_t x0 = x; + while (x < s->cols && s->cur_link[(size_t)y * s->cols + x] == id) x++; + json_obj_open(&j, NULL); + json_int(&j, "x", x0); + json_int(&j, "y", y); + json_int(&j, "w", (uint16_t)(x - x0)); + json_str(&j, "uri", s->links[id - 1], strlen(s->links[id - 1])); + json_obj_close(&j); + } + } + json_arr_close(&j); + json_obj_open(&j, "cursor"); json_bool(&j, "visible", s->cursor_visible); json_int(&j, "x", s->cursor_x); |
