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 | |
| 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.
| -rw-r--r-- | docs/scripting.md | 2 | ||||
| -rw-r--r-- | include/slosh.h | 16 | ||||
| -rw-r--r-- | src/pane.c | 40 | ||||
| -rw-r--r-- | src/screen.c | 97 | ||||
| -rw-r--r-- | tests/test_links.py | 118 |
5 files changed, 268 insertions, 5 deletions
diff --git a/docs/scripting.md b/docs/scripting.md index 43615a2..0074782 100644 --- a/docs/scripting.md +++ b/docs/scripting.md @@ -21,7 +21,7 @@ Panes and tabs are addressed by **id**, so a background tab is scriptable. | verb | | |---|---| | `panes` `tabs` | what exists, with ids, rects, titles, purposes, state. A pane's `tab_id` is what `move-pane` and `select-tab` want; its `tab` is where that tab sits in the strip | -| `snapshot` | the composited screen, as JSON or `format:"text"` | +| `snapshot` | the composited screen, as JSON, `format:"text"`, or `format:"bytes"` — the emitter's own output for this frame (a second call is the delta) | | `deadline` | when the session wants its next frame, in ms, or -1 | | `send` | bytes as if typed, decoded like input (`"data":"\\x01\\\\"`) | | `raw` | bytes straight into the focused pane's pty | diff --git a/include/slosh.h b/include/slosh.h index 834879f..45392af 100644 --- a/include/slosh.h +++ b/include/slosh.h @@ -47,6 +47,16 @@ typedef struct { uint16_t cols, rows; cell_t *cur; /* what we want on screen */ cell_t *prev; /* what the terminal last received */ + /* OSC 8 hyperlinks, carried beside the cells rather than in them: cell_t + * is the shader ABI and a link is not a colour. 0 is no link; anything + * else is 1+index into `links`. The table is append-only for the screen's + * lifetime, 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 it is + * capped, because a hostile pane must not be able to grow it forever: a + * link past the cap is dropped, which costs a click and nothing else. */ + uint16_t *cur_link, *prev_link; + char **links; + size_t nlinks; bool force_full; hitlist_t hits; @@ -77,6 +87,12 @@ uint16_t screen_cells(const char *txt); * of characters when any of them is wide. */ uint16_t screen_text(screen_t *s, uint16_t x, uint16_t y, const char *txt, color_t fg, color_t bg, uint16_t attrs); +/* Intern a hyperlink URI into this screen's table: an id for screen_set_link, + * or 0 for none — an empty URI, one with control bytes in it, or a full + * table all answer 0, because a link that cannot be emitted safely is a link + * the frame does not carry. */ +uint16_t screen_link_id(screen_t *s, const char *uri, size_t len); +void screen_set_link(screen_t *s, uint16_t x, uint16_t y, uint16_t id); /* Diff cur against prev into s->out (the minimal byte stream for a terminal). */ void screen_render(screen_t *s); /* screen_render, then write it to fd. */ @@ -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); diff --git a/tests/test_links.py b/tests/test_links.py new file mode 100644 index 0000000..1c08f72 --- /dev/null +++ b/tests/test_links.py @@ -0,0 +1,118 @@ +#!/usr/bin/env python3 +"""OSC 8 hyperlinks pass through the compositor. + +lib-vt tracks a hyperlink per cell; without re-emission the client's terminal +is never told, so `ls --hyperlink` and friends silently lose their links +inside the session. The link rides beside the cell (cell_t is the shader ABI +and a link is not a colour), the diff wraps emitted runs in OSC 8, and chrome +painted over a link takes the link with it. + +Plain-text URLs are deliberately not slosh's job: the outer terminal's own +matcher runs over what slosh paints. (Under Ghostty, add shift to the usual +gesture while a mouse-owning app runs -- shift is its mouse-capture escape.) +""" + +import sys + +from harness import Session, check, report + +URI = "https://sl0p.foo/x" +SH = [ + "/bin/sh", + "-c", + 'printf "\\033]8;;' + URI + '\\033\\\\click me\\033]8;;\\033\\\\ done\\n";' + " stty raw -echo; cat", +] + + +def links(s): + return s.snapshot().data["links"] + + +def test_a_hyperlink_lands_on_the_cells_that_carry_it(): + with Session(SH, cols=80, rows=20) as s: + s.until_text("click me") + ls = links(s) + check("one link run", len(ls) == 1, str(ls)) + if not ls: + return + run = ls[0] + check("with the URI intact", run["uri"] == URI, str(run)) + check( + "covering exactly the linked text", + run["w"] == len("click me"), + str(run), + ) + snap = s.snapshot() + check( + "on the cells that show it", + snap.line(run["y"])[run["x"] : run["x"] + run["w"]] == "click me", + repr(snap.line(run["y"])), + ) + + +def test_the_emitted_bytes_wrap_the_run_in_osc8(): + """The model and the bytes are two different things, and this session + already paid once for asserting on one while the other was wrong.""" + with Session(SH, cols=80, rows=20) as s: + s.until_text("click me") + b = s.api("snapshot", format="bytes")["bytes"] + check( + "the frame opens the link before its text", + f"\x1b]8;;{URI}\x1b\\" in b, + repr(b[-400:]), + ) + opens = b.count("\x1b]8;;h") # an open names a scheme + closes = b.count("\x1b]8;;\x1b\\") + check("and closes what it opens", opens == closes == 1, f"{opens}/{closes}") + + delta = s.api("snapshot", format="bytes")["bytes"] + check("an unchanged frame emits nothing", delta == "", repr(delta[:80])) + + +def test_chrome_over_a_link_is_chrome(): + """Anything painted over a link takes the link with it: a modal across a + URL must not leave its own box clickable with the pane's link.""" + with Session(SH, cols=80, rows=24) as s: + s.until_text("click me") + run = links(s)[0] + s.send(r"\x01?") # the cheatsheet: an opaque box over the layout + s.settle(40) + snap = s.snapshot() + covered = snap.line(run["y"])[run["x"] : run["x"] + run["w"]] != "click me" + check("the modal covers the linked text", covered, repr(snap.line(run["y"]))) + check( + "and carries no link over it", + snap.data["links"] == [], + str(snap.data["links"]), + ) + s.send("q") + s.settle(40) + check("dismissed, the link is back", len(links(s)) == 1, str(links(s))) + + +def test_a_link_scrolled_away_offers_nothing(): + """Only what is on screen can be clicked: the viewport is the promise, + and scrollback brings the link back with the text that carries it.""" + with Session(SH, cols=80, rows=12) as s: + s.until_text("click me") + check("the link is there", len(links(s)) == 1, str(links(s))) + # Push it out of the viewport: cat echoes what raw feeds it. + s.raw(r"\n" * 30) + s.until(lambda snap: "click me" not in snap.screen()) + check("scrolled away, no link", links(s) == [], str(links(s))) + s.send(r"\x01\e[1~") # C-a Home: to the oldest line + snap = s.until_text("click me") + check( + "and back with the text, in scrollback", + len(snap.data["links"]) == 1, + str(snap.data["links"]), + ) + + +if __name__ == "__main__": + test_a_hyperlink_lands_on_the_cells_that_carry_it() + test_the_emitted_bytes_wrap_the_run_in_osc8() + test_chrome_over_a_link_is_chrome() + test_a_link_scrolled_away_offers_nothing() + sys.exit(report()) |
