From 4199671047b0cb62781995a8f6a4b66fb6e8b6fd Mon Sep 17 00:00:00 2001 From: Gregory Anders <8965202+gpanders@users.noreply.github.com> Date: Thu, 15 Aug 2024 06:09:14 -0500 Subject: [PATCH] feat(term): support OSC 8 hyperlinks in :terminal (#30050) --- runtime/doc/news.txt | 2 + src/nvim/terminal.c | 61 +++++++++++++++++- src/vterm/pen.c | 70 +++++++++++++++++++++ src/vterm/screen.c | 16 ++++- src/vterm/vterm.c | 1 + src/vterm/vterm.h | 6 +- src/vterm/vterm_internal.h | 1 + test/functional/terminal/highlight_spec.lua | 20 ++++++ 8 files changed, 173 insertions(+), 4 deletions(-) diff --git a/runtime/doc/news.txt b/runtime/doc/news.txt index 68a05a99a4..7c842f42dd 100644 --- a/runtime/doc/news.txt +++ b/runtime/doc/news.txt @@ -166,6 +166,8 @@ TERMINAL • The terminal buffer now supports reflow (wrapped lines adapt when the buffer is resized horizontally). Note: Lines that are not visible and kept in 'scrollback' are not reflown. +• The |terminal| now supports OSC 8 escape sequences and will display + hyperlinks in supporting host terminals. TREESITTER diff --git a/src/nvim/terminal.c b/src/nvim/terminal.c index 54a0de9c22..2b44763ddd 100644 --- a/src/nvim/terminal.c +++ b/src/nvim/terminal.c @@ -227,11 +227,66 @@ static void schedule_termrequest(Terminal *term, char *payload, size_t payload_l term->pending_send); } +static int parse_osc8(VTermStringFragment frag, int *attr) + FUNC_ATTR_NONNULL_ALL +{ + // Parse the URI from the OSC 8 sequence and add the URL to our URL set. + // Skip the ID, we don't use it (for now) + size_t i = 0; + for (; i < frag.len; i++) { + if (frag.str[i] == ';') { + break; + } + } + + // Move past the semicolon + i++; + + if (i >= frag.len) { + // Invalid OSC sequence + return 0; + } + + // Find the terminator + const size_t start = i; + for (; i < frag.len; i++) { + if (frag.str[i] == '\a' || frag.str[i] == '\x1b') { + break; + } + } + + const size_t len = i - start; + if (len == 0) { + // Empty OSC 8, no URL + *attr = 0; + return 1; + } + + char *url = xmemdupz(&frag.str[start], len + 1); + url[len] = 0; + *attr = hl_add_url(0, url); + xfree(url); + + return 1; +} + static int on_osc(int command, VTermStringFragment frag, void *user) { + Terminal *term = user; + if (frag.str == NULL) { return 0; } + + if (command == 8) { + int attr = 0; + if (parse_osc8(frag, &attr)) { + VTermState *state = vterm_obtain_state(term->vt); + VTermValue value = { .number = attr }; + vterm_state_set_penattr(state, VTERM_ATTR_URI, VTERM_VALUETYPE_INT, &value); + } + } + if (!has_event(EVENT_TERMREQUEST)) { return 1; } @@ -239,7 +294,7 @@ static int on_osc(int command, VTermStringFragment frag, void *user) StringBuilder request = KV_INITIAL_VALUE; kv_printf(request, "\x1b]%d;", command); kv_concat_len(request, frag.str, frag.len); - schedule_termrequest(user, request.items, request.size); + schedule_termrequest(term, request.items, request.size); return 1; } @@ -992,6 +1047,10 @@ void terminal_get_line_attributes(Terminal *term, win_T *wp, int linenr, int *te }); } + if (cell.uri > 0) { + attr_id = hl_combine_attr(attr_id, cell.uri); + } + if (term->cursor.visible && term->cursor.row == row && term->cursor.col == col) { attr_id = hl_combine_attr(attr_id, diff --git a/src/vterm/pen.c b/src/vterm/pen.c index 562005435e..1876eb9881 100644 --- a/src/vterm/pen.c +++ b/src/vterm/pen.c @@ -182,6 +182,8 @@ INTERNAL void vterm_state_resetpen(VTermState *state) state->pen.fg = state->default_fg; setpenattr_col(state, VTERM_ATTR_FOREGROUND, state->default_fg); state->pen.bg = state->default_bg; setpenattr_col(state, VTERM_ATTR_BACKGROUND, state->default_bg); + + state->pen.uri = 0; setpenattr_int(state, VTERM_ATTR_URI, 0); } INTERNAL void vterm_state_savepen(VTermState *state, int save) @@ -205,6 +207,8 @@ INTERNAL void vterm_state_savepen(VTermState *state, int save) setpenattr_col( state, VTERM_ATTR_FOREGROUND, state->pen.fg); setpenattr_col( state, VTERM_ATTR_BACKGROUND, state->pen.bg); + + setpenattr_int( state, VTERM_ATTR_URI, state->pen.uri); } } @@ -600,9 +604,75 @@ int vterm_state_get_penattr(const VTermState *state, VTermAttr attr, VTermValue val->number = state->pen.baseline; return 1; + case VTERM_ATTR_URI: + val->number = state->pen.uri; + return 1; + case VTERM_N_ATTRS: return 0; } return 0; } + +int vterm_state_set_penattr(VTermState *state, VTermAttr attr, VTermValueType type, VTermValue *val) +{ + if (!val) { + return 0; + } + + if(type != vterm_get_attr_type(attr)) { + DEBUG_LOG("Cannot set attr %d as it has type %d, not type %d\n", + attr, vterm_get_attr_type(attr), type); + return 0; + } + + switch (attr) { + case VTERM_ATTR_BOLD: + state->pen.bold = val->boolean; + break; + case VTERM_ATTR_UNDERLINE: + state->pen.underline = val->number; + break; + case VTERM_ATTR_ITALIC: + state->pen.italic = val->boolean; + break; + case VTERM_ATTR_BLINK: + state->pen.blink = val->boolean; + break; + case VTERM_ATTR_REVERSE: + state->pen.reverse = val->boolean; + break; + case VTERM_ATTR_CONCEAL: + state->pen.conceal = val->boolean; + break; + case VTERM_ATTR_STRIKE: + state->pen.strike = val->boolean; + break; + case VTERM_ATTR_FONT: + state->pen.font = val->number; + break; + case VTERM_ATTR_FOREGROUND: + state->pen.fg = val->color; + break; + case VTERM_ATTR_BACKGROUND: + state->pen.bg = val->color; + break; + case VTERM_ATTR_SMALL: + state->pen.small = val->boolean; + break; + case VTERM_ATTR_BASELINE: + state->pen.baseline = val->number; + break; + case VTERM_ATTR_URI: + state->pen.uri = val->number; + break; + default: + return 0; + } + + if(state->callbacks && state->callbacks->setpenattr) + (*state->callbacks->setpenattr)(attr, val, state->cbdata); + + return 1; +} diff --git a/src/vterm/screen.c b/src/vterm/screen.c index 720d1bb939..bd3cbd6bd0 100644 --- a/src/vterm/screen.c +++ b/src/vterm/screen.c @@ -17,6 +17,9 @@ typedef struct /* After the bitfield */ VTermColor fg, bg; + /* Opaque ID that maps to a URI in a set */ + int uri; + unsigned int bold : 1; unsigned int underline : 2; unsigned int italic : 1; @@ -444,6 +447,9 @@ static int setpenattr(VTermAttr attr, VTermValue *val, void *user) case VTERM_ATTR_BASELINE: screen->pen.baseline = val->number; return 1; + case VTERM_ATTR_URI: + screen->pen.uri = val->number; + return 1; case VTERM_N_ATTRS: return 0; @@ -705,6 +711,8 @@ static void resize_buffer(VTermScreen *screen, int bufidx, int new_rows, int new dst->pen.fg = src->fg; dst->pen.bg = src->bg; + dst->pen.uri = src->uri; + if(src->width == 2 && pos.col < (new_cols-1)) (dst + 1)->chars[0] = (uint32_t) -1; } @@ -997,6 +1005,8 @@ int vterm_screen_get_cell(const VTermScreen *screen, VTermPos pos, VTermScreenCe cell->fg = intcell->pen.fg; cell->bg = intcell->pen.bg; + cell->uri = intcell->pen.uri; + if(pos.col < (screen->cols - 1) && getcell(screen, pos.row, pos.col + 1)->chars[0] == (uint32_t)-1) cell->width = 2; @@ -1116,9 +1126,11 @@ static int attrs_differ(VTermAttrMask attrs, ScreenCell *a, ScreenCell *b) return 1; if((attrs & VTERM_ATTR_BACKGROUND_MASK) && !vterm_color_is_equal(&a->pen.bg, &b->pen.bg)) return 1; - if((attrs & VTERM_ATTR_SMALL_MASK) && (a->pen.small != b->pen.small)) + if((attrs & VTERM_ATTR_SMALL_MASK) && (a->pen.small != b->pen.small)) return 1; - if((attrs & VTERM_ATTR_BASELINE_MASK) && (a->pen.baseline != b->pen.baseline)) + if((attrs & VTERM_ATTR_BASELINE_MASK) && (a->pen.baseline != b->pen.baseline)) + return 1; + if((attrs & VTERM_ATTR_URI_MASK) && (a->pen.uri != b->pen.uri)) return 1; return 0; diff --git a/src/vterm/vterm.c b/src/vterm/vterm.c index e1f676f5b6..870a61566e 100644 --- a/src/vterm/vterm.c +++ b/src/vterm/vterm.c @@ -278,6 +278,7 @@ VTermValueType vterm_get_attr_type(VTermAttr attr) case VTERM_ATTR_BACKGROUND: return VTERM_VALUETYPE_COLOR; case VTERM_ATTR_SMALL: return VTERM_VALUETYPE_BOOL; case VTERM_ATTR_BASELINE: return VTERM_VALUETYPE_INT; + case VTERM_ATTR_URI: return VTERM_VALUETYPE_INT; case VTERM_N_ATTRS: return 0; } diff --git a/src/vterm/vterm.h b/src/vterm/vterm.h index 44e15023c0..929418c63a 100644 --- a/src/vterm/vterm.h +++ b/src/vterm/vterm.h @@ -245,6 +245,7 @@ typedef enum { VTERM_ATTR_BACKGROUND, // color: 40-49 100-107 VTERM_ATTR_SMALL, // bool: 73, 74, 75 VTERM_ATTR_BASELINE, // number: 73, 74, 75 + VTERM_ATTR_URI, // number VTERM_N_ATTRS } VTermAttr; @@ -470,6 +471,7 @@ void vterm_state_set_default_colors(VTermState *state, const VTermColor *default void vterm_state_set_palette_color(VTermState *state, int index, const VTermColor *col); void vterm_state_set_bold_highbright(VTermState *state, int bold_is_highbright); int vterm_state_get_penattr(const VTermState *state, VTermAttr attr, VTermValue *val); +int vterm_state_set_penattr(VTermState *state, VTermAttr attr, VTermValueType type, VTermValue *val); int vterm_state_set_termprop(VTermState *state, VTermProp prop, VTermValue *val); void vterm_state_focus_in(VTermState *state); void vterm_state_focus_out(VTermState *state); @@ -529,6 +531,7 @@ typedef struct { char width; VTermScreenCellAttrs attrs; VTermColor fg, bg; + int uri; } VTermScreenCell; typedef struct { @@ -589,8 +592,9 @@ typedef enum { VTERM_ATTR_CONCEAL_MASK = 1 << 9, VTERM_ATTR_SMALL_MASK = 1 << 10, VTERM_ATTR_BASELINE_MASK = 1 << 11, + VTERM_ATTR_URI_MASK = 1 << 12, - VTERM_ALL_ATTRS_MASK = (1 << 12) - 1 + VTERM_ALL_ATTRS_MASK = (1 << 13) - 1 } VTermAttrMask; int vterm_screen_get_attrs_extent(const VTermScreen *screen, VTermRect *extent, VTermPos pos, VTermAttrMask attrs); diff --git a/src/vterm/vterm_internal.h b/src/vterm/vterm_internal.h index e79d74be6f..53f9b5e100 100644 --- a/src/vterm/vterm_internal.h +++ b/src/vterm/vterm_internal.h @@ -40,6 +40,7 @@ struct VTermPen { VTermColor fg; VTermColor bg; + int uri; unsigned int bold:1; unsigned int underline:2; unsigned int italic:1; diff --git a/test/functional/terminal/highlight_spec.lua b/test/functional/terminal/highlight_spec.lua index 4f3d010d02..ca41cbf4a2 100644 --- a/test/functional/terminal/highlight_spec.lua +++ b/test/functional/terminal/highlight_spec.lua @@ -380,3 +380,23 @@ describe(':terminal highlight with custom palette', function() ]]) end) end) + +describe(':terminal', function() + before_each(clear) + + it('can display URLs', function() + local screen = Screen.new(50, 7) + screen:add_extra_attr_ids { + [100] = { url = 'https://example.com' }, + } + screen:attach() + local chan = api.nvim_open_term(0, {}) + api.nvim_chan_send(chan, '\027]8;;https://example.com\027\\Example\027]8;;\027\\') + screen:expect({ + grid = [[ + {100:^Example} | + |*6 + ]], + }) + end) +end)