diff --git a/runtime/doc/api.txt b/runtime/doc/api.txt index f37d349fd8..90c9d0ccbb 100644 --- a/runtime/doc/api.txt +++ b/runtime/doc/api.txt @@ -2783,6 +2783,9 @@ nvim_buf_set_extmark({buffer}, {ns_id}, {line}, {col}, {*opts}) drawn by a UI. When set, the UI will receive win_extmark events. Note: the mark is positioned by virt_text attributes. Can be used together with virt_text. + • url: A URL to associate with this extmark. In the TUI, the + OSC 8 control sequence is used to generate a clickable + hyperlink to this URL. Return: ~ Id of the created/updated extmark diff --git a/runtime/doc/news.txt b/runtime/doc/news.txt index d63d1e6928..a7c4e8147c 100644 --- a/runtime/doc/news.txt +++ b/runtime/doc/news.txt @@ -301,6 +301,10 @@ The following new APIs and features were added. • |vim.version.le()| and |vim.version.ge()| are added to |vim.version|. +• |extmarks| can be associated with a URL and URLs are included as a new + highlight attribute. The TUI will display URLs using the OSC 8 control + sequence, enabling clickable text in supporting terminals. + ============================================================================== CHANGED FEATURES *news-changed* diff --git a/runtime/doc/ui.txt b/runtime/doc/ui.txt index c81420d1f2..b8d47923ca 100644 --- a/runtime/doc/ui.txt +++ b/runtime/doc/ui.txt @@ -328,9 +328,11 @@ numerical highlight ids to the actual attributes. `underdotted`: underdotted text. The dots have `special` color. `underdashed`: underdashed text. The dashes have `special` color. `altfont`: alternative font. - `blend`: Blend level (0-100). Could be used by UIs to + `blend`: blend level (0-100). Could be used by UIs to support blending floating windows to the background or to signal a transparent cursor. + `url`: a URL associated with this highlight. UIs can + display this URL however they wish. For absent color keys the default color should be used. Don't store the default value in the table, rather a sentinel value, so that diff --git a/runtime/lua/vim/_meta/api.lua b/runtime/lua/vim/_meta/api.lua index f773ddd75c..076aae7dbe 100644 --- a/runtime/lua/vim/_meta/api.lua +++ b/runtime/lua/vim/_meta/api.lua @@ -607,6 +607,9 @@ function vim.api.nvim_buf_line_count(buffer) end --- drawn by a UI. When set, the UI will receive win_extmark --- events. Note: the mark is positioned by virt_text --- attributes. Can be used together with virt_text. +--- • url: A URL to associate with this extmark. In the TUI, the +--- OSC 8 control sequence is used to generate a clickable +--- hyperlink to this URL. --- @return integer function vim.api.nvim_buf_set_extmark(buffer, ns_id, line, col, opts) end diff --git a/runtime/lua/vim/_meta/api_keysets.lua b/runtime/lua/vim/_meta/api_keysets.lua index 5ebedc977b..00d3ff5bb4 100644 --- a/runtime/lua/vim/_meta/api_keysets.lua +++ b/runtime/lua/vim/_meta/api_keysets.lua @@ -192,6 +192,7 @@ error('Cannot require a meta file') --- @field fg_indexed? boolean --- @field bg_indexed? boolean --- @field force? boolean +--- @field url? string --- @class vim.api.keyset.highlight_cterm --- @field bold? boolean @@ -272,6 +273,7 @@ error('Cannot require a meta file') --- @field spell? boolean --- @field ui_watched? boolean --- @field undo_restore? boolean +--- @field url? string --- @class vim.api.keyset.user_command --- @field addr? any diff --git a/src/nvim/api/extmark.c b/src/nvim/api/extmark.c index 1f0e867162..27a4b7854f 100644 --- a/src/nvim/api/extmark.c +++ b/src/nvim/api/extmark.c @@ -481,6 +481,8 @@ Array nvim_buf_get_extmarks(Buffer buffer, Integer ns_id, Object start, Object e /// by a UI. When set, the UI will receive win_extmark events. /// Note: the mark is positioned by virt_text attributes. Can be /// used together with virt_text. +/// - url: A URL to associate with this extmark. In the TUI, the OSC 8 control +/// sequence is used to generate a clickable hyperlink to this URL. /// /// @param[out] err Error details, if any /// @return Id of the created/updated extmark @@ -494,6 +496,7 @@ Integer nvim_buf_set_extmark(Buffer buffer, Integer ns_id, Integer line, Integer DecorSignHighlight sign = DECOR_SIGN_HIGHLIGHT_INIT; DecorVirtText virt_text = DECOR_VIRT_TEXT_INIT; DecorVirtText virt_lines = DECOR_VIRT_LINES_INIT; + char *url = NULL; bool has_hl = false; buf_T *buf = find_buffer_by_handle(buffer, err); @@ -678,6 +681,10 @@ Integer nvim_buf_set_extmark(Buffer buffer, Integer ns_id, Integer line, Integer has_hl = true; } + if (HAS_KEY(opts, set_extmark, url)) { + url = string_to_cstr(opts->url); + } + if (opts->ui_watched) { hl.flags |= kSHUIWatched; if (virt_text.pos == kVPosOverlay) { @@ -747,6 +754,11 @@ Integer nvim_buf_set_extmark(Buffer buffer, Integer ns_id, Integer line, Integer if (kv_size(virt_lines.data.virt_lines)) { decor_range_add_virt(&decor_state, r, c, line2, col2, decor_put_vt(virt_lines, NULL), true); } + if (url != NULL) { + DecorSignHighlight sh = DECOR_SIGN_HIGHLIGHT_INIT; + sh.url = url; + decor_range_add_sh(&decor_state, r, c, line2, col2, &sh, true, 0, 0); + } if (has_hl) { DecorSignHighlight sh = decor_sh_from_inline(hl); decor_range_add_sh(&decor_state, r, c, line2, col2, &sh, true, (uint32_t)ns_id, id); @@ -772,7 +784,14 @@ Integer nvim_buf_set_extmark(Buffer buffer, Integer ns_id, Integer line, Integer } uint32_t decor_indexed = DECOR_ID_INVALID; + if (url != NULL) { + DecorSignHighlight sh = DECOR_SIGN_HIGHLIGHT_INIT; + sh.url = url; + sh.next = decor_indexed; + decor_indexed = decor_put_sh(sh); + } if (sign.flags & kSHIsSign) { + sign.next = decor_indexed; decor_indexed = decor_put_sh(sign); if (sign.text[0]) { decor_flags |= MT_FLAG_DECOR_SIGNTEXT; @@ -814,6 +833,10 @@ Integer nvim_buf_set_extmark(Buffer buffer, Integer ns_id, Integer line, Integer error: clear_virttext(&virt_text.data.virt_text); clear_virtlines(&virt_lines.data.virt_lines); + if (url != NULL) { + xfree(url); + } + return 0; } diff --git a/src/nvim/api/keysets_defs.h b/src/nvim/api/keysets_defs.h index b2f0039eb9..811f60f4d6 100644 --- a/src/nvim/api/keysets_defs.h +++ b/src/nvim/api/keysets_defs.h @@ -54,6 +54,7 @@ typedef struct { Boolean spell; Boolean ui_watched; Boolean undo_restore; + String url; } Dict(set_extmark); typedef struct { @@ -183,6 +184,7 @@ typedef struct { Boolean fg_indexed; Boolean bg_indexed; Boolean force; + String url; } Dict(highlight); typedef struct { diff --git a/src/nvim/api/ui.c b/src/nvim/api/ui.c index b42c274411..f955b315a8 100644 --- a/src/nvim/api/ui.c +++ b/src/nvim/api/ui.c @@ -784,6 +784,14 @@ void remote_ui_hl_attr_define(UI *ui, Integer id, HlAttrs rgb_attrs, HlAttrs cte MAXSIZE_TEMP_DICT(cterm, HLATTRS_DICT_SIZE); hlattrs2dict(&rgb, NULL, rgb_attrs, true, false); hlattrs2dict(&cterm, NULL, rgb_attrs, false, false); + + // URLs are not added in hlattrs2dict since they are used only by UIs and not by the highlight + // system. So we add them here. + if (rgb_attrs.url >= 0) { + const char *url = hl_get_url((uint32_t)rgb_attrs.url); + PUT_C(rgb, "url", STRING_OBJ(cstr_as_string((char *)url))); + } + ADD_C(args, DICTIONARY_OBJ(rgb)); ADD_C(args, DICTIONARY_OBJ(cterm)); diff --git a/src/nvim/api/vim.c b/src/nvim/api/vim.c index e5a5cc059f..eea9b54a5c 100644 --- a/src/nvim/api/vim.c +++ b/src/nvim/api/vim.c @@ -176,6 +176,12 @@ void nvim_set_hl(Integer ns_id, String name, Dict(highlight) *val, Error *err) }); int link_id = -1; + // Setting URLs directly through highlight attributes is not supported + if (HAS_KEY(val, highlight, url)) { + api_free_string(val->url); + val->url = NULL_STRING; + } + HlAttrs attrs = dict2hlattrs(val, true, &link_id, err); if (!ERROR_SET(err)) { ns_hl_def((NS)ns_id, hl_id, attrs, link_id, val); diff --git a/src/nvim/decoration.c b/src/nvim/decoration.c index d3517c077f..7f1946ba05 100644 --- a/src/nvim/decoration.c +++ b/src/nvim/decoration.c @@ -118,7 +118,7 @@ void decor_redraw(buf_T *buf, int row1, int row2, DecorInline decor) void decor_redraw_sh(buf_T *buf, int row1, int row2, DecorSignHighlight sh) { - if (sh.hl_id || (sh.flags & (kSHIsSign|kSHSpellOn|kSHSpellOff))) { + if (sh.hl_id || (sh.url != NULL) || (sh.flags & (kSHIsSign|kSHSpellOn|kSHSpellOff))) { if (row2 >= row1) { redraw_buf_range_later(buf, row1 + 1, row2 + 1); } @@ -253,7 +253,7 @@ void decor_free(DecorInline decor) } } -void decor_free_inner(DecorVirtText *vt, uint32_t first_idx) +static void decor_free_inner(DecorVirtText *vt, uint32_t first_idx) { while (vt) { if (vt->flags & kVTIsLines) { @@ -273,6 +273,9 @@ void decor_free_inner(DecorVirtText *vt, uint32_t first_idx) xfree(sh->sign_name); } sh->flags = 0; + if (sh->url != NULL) { + XFREE_CLEAR(sh->url); + } if (sh->next == DECOR_ID_INVALID) { sh->next = decor_freelist; decor_freelist = first_idx; @@ -509,7 +512,8 @@ void decor_range_add_sh(DecorState *state, int start_row, int start_col, int end .draw_col = -10, }; - if (sh->hl_id || (sh->flags & (kSHConceal | kSHSpellOn | kSHSpellOff))) { + if (sh->hl_id || (sh->url != NULL) + || (sh->flags & (kSHConceal | kSHSpellOn | kSHSpellOff))) { if (sh->hl_id) { range.attr_id = syn_id2attr(sh->hl_id); } @@ -627,15 +631,22 @@ next_mark: spell = kFalse; } } + if (active && item.data.sh.url != NULL) { + attr = hl_add_url(attr, item.data.sh.url); + } if (item.start_row == state->row && item.start_col <= col && decor_virt_pos(&item) && item.draw_col == -10) { decor_init_draw_col(win_col, hidden, &item); } if (keep) { kv_A(state->active, j++) = item; - } else if (item.owned && item.kind == kDecorKindVirtText) { - clear_virttext(&item.data.vt->data.virt_text); - xfree(item.data.vt); + } else if (item.owned) { + if (item.kind == kDecorKindVirtText) { + clear_virttext(&item.data.vt->data.virt_text); + xfree(item.data.vt); + } else if (item.kind == kDecorKindHighlight) { + xfree((void *)item.data.sh.url); + } } } kv_size(state->active) = j; @@ -962,6 +973,10 @@ void decor_to_dict_legacy(Dictionary *dict, DecorInline decor, bool hl_name) PUT(*dict, "ui_watched", BOOLEAN_OBJ(true)); } + if (sh_hl.url != NULL) { + PUT(*dict, "url", STRING_OBJ(cstr_to_string(sh_hl.url))); + } + if (virt_text) { if (virt_text->hl_mode) { PUT(*dict, "hl_mode", CSTR_TO_OBJ(hl_mode_str[virt_text->hl_mode])); diff --git a/src/nvim/decoration_defs.h b/src/nvim/decoration_defs.h index 93bc202b94..8d0075b169 100644 --- a/src/nvim/decoration_defs.h +++ b/src/nvim/decoration_defs.h @@ -3,6 +3,7 @@ #include #include "klib/kvec.h" +#include "nvim/api/private/defs.h" #include "nvim/types_defs.h" #define DECOR_ID_INVALID UINT32_MAX @@ -68,10 +69,11 @@ typedef struct { int line_hl_id; int cursorline_hl_id; uint32_t next; + const char *url; } DecorSignHighlight; #define DECOR_SIGN_HIGHLIGHT_INIT { 0, DECOR_PRIORITY_BASE, 0, { 0, 0 }, NULL, 0, 0, 0, 0, \ - DECOR_ID_INVALID } + DECOR_ID_INVALID, NULL } enum { kVTIsLines = 1, diff --git a/src/nvim/highlight.c b/src/nvim/highlight.c index bd599d686f..47a87b90c3 100644 --- a/src/nvim/highlight.c +++ b/src/nvim/highlight.c @@ -42,6 +42,7 @@ static Set(HlEntry) attr_entries = SET_INIT; static Map(int, int) combine_attr_entries = MAP_INIT; static Map(int, int) blend_attr_entries = MAP_INIT; static Map(int, int) blendthrough_attr_entries = MAP_INIT; +static Set(cstr_t) urls = SET_INIT; #define attr_entry(i) attr_entries.keys[i] @@ -475,6 +476,7 @@ int hl_get_underline(void) .rgb_bg_color = -1, .rgb_sp_color = -1, .hl_blend = -1, + .url = -1, }, .kind = kHlUI, .id1 = 0, @@ -482,6 +484,43 @@ int hl_get_underline(void) }); } +/// Augment an existing attribute with the beginning or end of a URL hyperlink. +/// +/// @param attr Existing attribute to combine with +/// @param url The URL to associate with the highlight attribute +/// @return Combined attribute +int hl_add_url(int attr, const char *url) +{ + HlAttrs attrs = HLATTRS_INIT; + + MHPutStatus status; + uint32_t k = set_put_idx(cstr_t, &urls, url, &status); + if (status != kMHExisting) { + urls.keys[k] = xstrdup(url); + } + + attrs.url = (int32_t)k; + + int new = get_attr_entry((HlEntry){ + .attr = attrs, + .kind = kHlUI, + .id1 = 0, + .id2 = 0, + }); + + return hl_combine_attr(attr, new); +} + +/// Get a URL by its index. +/// +/// @param index URL index +/// @return URL +const char *hl_get_url(uint32_t index) +{ + assert(urls.keys); + return urls.keys[index]; +} + /// Get attribute code for forwarded :terminal highlights. int hl_get_term_attr(HlAttrs *aep) { @@ -492,12 +531,18 @@ int hl_get_term_attr(HlAttrs *aep) /// Clear all highlight tables. void clear_hl_tables(bool reinit) { + const char *url = NULL; + set_foreach(&urls, url, { + xfree((void *)url); + }); + if (reinit) { set_clear(HlEntry, &attr_entries); highlight_init(); map_clear(int, &combine_attr_entries); map_clear(int, &blend_attr_entries); map_clear(int, &blendthrough_attr_entries); + set_clear(cstr_t, &urls); memset(highlight_attr_last, -1, sizeof(highlight_attr_last)); highlight_attr_set_all(); highlight_changed(); @@ -508,6 +553,7 @@ void clear_hl_tables(bool reinit) map_destroy(int, &blend_attr_entries); map_destroy(int, &blendthrough_attr_entries); map_destroy(ColorKey, &ns_hls); + set_destroy(cstr_t, &urls); } } @@ -599,6 +645,11 @@ int hl_combine_attr(int char_attr, int prim_attr) new_en.hl_blend = prim_aep.hl_blend; } + if ((new_en.url == -1) && (prim_aep.url >= 0)) { + // Combined attributes borrow the string from the primary attribute + new_en.url = prim_aep.url; + } + id = get_attr_entry((HlEntry){ .attr = new_en, .kind = kHlCombine, .id1 = char_attr, .id2 = prim_attr }); if (id > 0) { @@ -680,8 +731,8 @@ int hl_blend_attrs(int back_attr, int front_attr, bool *through) } cattrs.cterm_bg_color = fattrs.cterm_bg_color; - cattrs.cterm_fg_color = cterm_blend(ratio, battrs.cterm_fg_color, - fattrs.cterm_bg_color); + cattrs.cterm_fg_color = (int16_t)cterm_blend(ratio, battrs.cterm_fg_color, + fattrs.cterm_bg_color); cattrs.rgb_ae_attr &= ~(HL_FG_INDEXED | HL_BG_INDEXED); } else { cattrs = fattrs; @@ -729,7 +780,7 @@ static int rgb_blend(int ratio, int rgb1, int rgb2) return (mr << 16) + (mg << 8) + mb; } -static int cterm_blend(int ratio, int c1, int c2) +static int cterm_blend(int ratio, int16_t c1, int16_t c2) { // 1. Convert cterm color numbers to RGB. // 2. Blend the RGB colors. @@ -1085,12 +1136,12 @@ HlAttrs dict2hlattrs(Dict(highlight) *dict, bool use_rgb, int *link_id, Error *e hlattrs.rgb_fg_color = fg; hlattrs.rgb_sp_color = sp; hlattrs.hl_blend = blend; - hlattrs.cterm_bg_color = ctermbg == -1 ? 0 : ctermbg + 1; - hlattrs.cterm_fg_color = ctermfg == -1 ? 0 : ctermfg + 1; + hlattrs.cterm_bg_color = ctermbg == -1 ? 0 : (int16_t)(ctermbg + 1); + hlattrs.cterm_fg_color = ctermfg == -1 ? 0 : (int16_t)(ctermfg + 1); hlattrs.cterm_ae_attr = cterm_mask; } else { - hlattrs.cterm_bg_color = bg == -1 ? 0 : bg + 1; - hlattrs.cterm_fg_color = fg == -1 ? 0 : fg + 1; + hlattrs.cterm_bg_color = bg == -1 ? 0 : (int16_t)(bg + 1); + hlattrs.cterm_fg_color = fg == -1 ? 0 : (int16_t)(fg + 1); hlattrs.cterm_ae_attr = mask; } diff --git a/src/nvim/highlight_defs.h b/src/nvim/highlight_defs.h index 7a10e16391..25ab9dc2d9 100644 --- a/src/nvim/highlight_defs.h +++ b/src/nvim/highlight_defs.h @@ -3,6 +3,8 @@ #include #include +#include "nvim/api/private/defs.h" + typedef int32_t RgbValue; /// Highlighting attribute bits. @@ -36,8 +38,9 @@ typedef enum { typedef struct { int16_t rgb_ae_attr, cterm_ae_attr; ///< HlAttrFlags RgbValue rgb_fg_color, rgb_bg_color, rgb_sp_color; - int cterm_fg_color, cterm_bg_color; - int hl_blend; + int16_t cterm_fg_color, cterm_bg_color; + int32_t hl_blend; + int32_t url; } HlAttrs; #define HLATTRS_INIT (HlAttrs) { \ @@ -49,6 +52,7 @@ typedef struct { .cterm_fg_color = 0, \ .cterm_bg_color = 0, \ .hl_blend = -1, \ + .url = -1, \ } /// Values for index in highlight_attr[]. diff --git a/src/nvim/highlight_group.c b/src/nvim/highlight_group.c index 14c44b0249..ef8181a4cb 100644 --- a/src/nvim/highlight_group.c +++ b/src/nvim/highlight_group.c @@ -1912,8 +1912,8 @@ static void set_hl_attr(int idx) HlGroup *sgp = hl_table + idx; at_en.cterm_ae_attr = (int16_t)sgp->sg_cterm; - at_en.cterm_fg_color = sgp->sg_cterm_fg; - at_en.cterm_bg_color = sgp->sg_cterm_bg; + at_en.cterm_fg_color = (int16_t)sgp->sg_cterm_fg; + at_en.cterm_bg_color = (int16_t)sgp->sg_cterm_bg; at_en.rgb_ae_attr = (int16_t)sgp->sg_gui; // FIXME(tarruda): The "unset value" for rgb is -1, but since hlgroup is // initialized with 0(by garray functions), check for sg_rgb_{f,b}g_name diff --git a/src/nvim/map_defs.h b/src/nvim/map_defs.h index f3c4e4ea95..836b1447c2 100644 --- a/src/nvim/map_defs.h +++ b/src/nvim/map_defs.h @@ -33,7 +33,8 @@ static inline bool equal_String(String a, String b) if (a.size != b.size) { return false; } - return memcmp(a.data, b.data, a.size) == 0; + + return (a.size == 0) || (memcmp(a.data, b.data, a.size) == 0); } #define Set(type) Set_##type diff --git a/src/nvim/terminal.c b/src/nvim/terminal.c index 8bee5071af..00acbfb602 100644 --- a/src/nvim/terminal.c +++ b/src/nvim/terminal.c @@ -913,8 +913,8 @@ void terminal_get_line_attributes(Terminal *term, win_T *wp, int linenr, int *te bool fg_indexed = VTERM_COLOR_IS_INDEXED(&cell.fg); bool bg_indexed = VTERM_COLOR_IS_INDEXED(&cell.bg); - int vt_fg_idx = ((!fg_default && fg_indexed) ? cell.fg.indexed.idx + 1 : 0); - int vt_bg_idx = ((!bg_default && bg_indexed) ? cell.bg.indexed.idx + 1 : 0); + int16_t vt_fg_idx = ((!fg_default && fg_indexed) ? cell.fg.indexed.idx + 1 : 0); + int16_t vt_bg_idx = ((!bg_default && bg_indexed) ? cell.bg.indexed.idx + 1 : 0); bool fg_set = vt_fg_idx && vt_fg_idx <= 16 && term->color_set[vt_fg_idx - 1]; bool bg_set = vt_bg_idx && vt_bg_idx <= 16 && term->color_set[vt_bg_idx - 1]; @@ -939,6 +939,7 @@ void terminal_get_line_attributes(Terminal *term, win_T *wp, int linenr, int *te .rgb_bg_color = vt_bg, .rgb_sp_color = -1, .hl_blend = -1, + .url = -1, }); } diff --git a/src/nvim/tui/tui.c b/src/nvim/tui/tui.c index f9560ce076..35867d6ce3 100644 --- a/src/nvim/tui/tui.c +++ b/src/nvim/tui/tui.c @@ -32,6 +32,7 @@ #include "nvim/os/input.h" #include "nvim/os/os.h" #include "nvim/os/os_defs.h" +#include "nvim/strings.h" #include "nvim/tui/input.h" #include "nvim/tui/terminfo.h" #include "nvim/tui/tui.h" @@ -139,6 +140,8 @@ struct TUIData { int width; int height; bool rgb; + int url; ///< Index of URL currently being printed, if any + StringBuilder urlbuf; ///< Re-usable buffer for writing OSC 8 control sequences }; static int got_winch = 0; @@ -147,6 +150,8 @@ static bool cursor_style_enabled = false; # include "tui/tui.c.generated.h" #endif +static Set(cstr_t) urls = SET_INIT; + void tui_start(TUIData **tui_p, int *width, int *height, char **term, bool *rgb) FUNC_ATTR_NONNULL_ALL { @@ -156,7 +161,9 @@ void tui_start(TUIData **tui_p, int *width, int *height, char **term, bool *rgb) tui->stopped = false; tui->seen_error_exit = 0; tui->loop = &main_loop; + tui->url = -1; kv_init(tui->invalid_regions); + kv_init(tui->urlbuf); signal_watcher_init(tui->loop, &tui->winch_handle, tui); // TODO(bfredl): zero hl is empty, send this explicitly? @@ -412,7 +419,7 @@ static void terminfo_start(TUIData *tui) static void terminfo_stop(TUIData *tui) { // Destroy output stuff - tui_mode_change(tui, (String)STRING_INIT, SHAPE_IDX_N); + tui_mode_change(tui, NULL_STRING, SHAPE_IDX_N); tui_mouse_off(tui); unibi_out(tui, unibi_exit_attribute_mode); // Reset cursor to normal before exiting alternate screen. @@ -423,7 +430,7 @@ static void terminfo_stop(TUIData *tui) tui_reset_key_encoding(tui); // May restore old title before exiting alternate screen. - tui_set_title(tui, (String)STRING_INIT); + tui_set_title(tui, NULL_STRING); if (ui_client_exit_status == 0) { ui_client_exit_status = tui->seen_error_exit; } @@ -522,7 +529,15 @@ void tui_free_all_mem(TUIData *tui) { ugrid_free(&tui->grid); kv_destroy(tui->invalid_regions); + + const char *url; + set_foreach(&urls, url, { + xfree((void *)url); + }); + set_destroy(cstr_t, &urls); + kv_destroy(tui->attrs); + kv_destroy(tui->urlbuf); xfree(tui->space_buf); xfree(tui->term); xfree(tui); @@ -550,6 +565,10 @@ static bool attrs_differ(TUIData *tui, int id1, int id2, bool rgb) HlAttrs a1 = kv_A(tui->attrs, (size_t)id1); HlAttrs a2 = kv_A(tui->attrs, (size_t)id2); + if (a1.url != a2.url) { + return true; + } + if (rgb) { return a1.rgb_fg_color != a2.rgb_fg_color || a1.rgb_bg_color != a2.rgb_bg_color @@ -709,6 +728,19 @@ static void update_attrs(TUIData *tui, int attr_id) } } + if (tui->url != attrs.url) { + if (attrs.url >= 0) { + const char *url = urls.keys[attrs.url]; + kv_size(tui->urlbuf) = 0; + kv_printf(tui->urlbuf, "\x1b]8;;%s\x1b\\", url); + out(tui, tui->urlbuf.items, kv_size(tui->urlbuf)); + } else { + out(tui, S_LEN("\x1b]8;;\x1b\\")); + } + + tui->url = attrs.url; + } + tui->default_attr = fg == -1 && bg == -1 && !bold && !italic && !has_any_underline && !reverse && !standout && !strikethrough; @@ -785,6 +817,13 @@ static void cursor_goto(TUIData *tui, int row, int col) if (row == grid->row && col == grid->col) { return; } + + // If an OSC 8 sequence is active terminate it before moving the cursor + if (tui->url >= 0) { + out(tui, S_LEN("\x1b]8;;\x1b\\")); + tui->url = -1; + } + if (0 == row && 0 == col) { unibi_out(tui, unibi_cursor_home); ugrid_goto(grid, row, col); @@ -1281,11 +1320,32 @@ void tui_grid_scroll(TUIData *tui, Integer g, Integer startrow, Integer endrow, } } +/// Add a URL to be used in an OSC 8 hyperlink. +/// +/// @param tui TUIData +/// @param url URL to add +/// @return Index of new URL, or -1 if URL is invalid +int32_t tui_add_url(TUIData *tui, const char *url) + FUNC_ATTR_NONNULL_ARG(1) +{ + if (url == NULL) { + return -1; + } + + MHPutStatus status; + uint32_t k = set_put_idx(cstr_t, &urls, url, &status); + if (status != kMHExisting) { + urls.keys[k] = xstrdup(url); + } + return (int32_t)k; +} + void tui_hl_attr_define(TUIData *tui, Integer id, HlAttrs attrs, HlAttrs cterm_attrs, Array info) { attrs.cterm_ae_attr = cterm_attrs.cterm_ae_attr; attrs.cterm_fg_color = cterm_attrs.cterm_fg_color; attrs.cterm_bg_color = cterm_attrs.cterm_bg_color; + kv_a(tui->attrs, (size_t)id) = attrs; } @@ -1302,11 +1362,11 @@ void tui_visual_bell(TUIData *tui) void tui_default_colors_set(TUIData *tui, Integer rgb_fg, Integer rgb_bg, Integer rgb_sp, Integer cterm_fg, Integer cterm_bg) { - tui->clear_attrs.rgb_fg_color = (int)rgb_fg; - tui->clear_attrs.rgb_bg_color = (int)rgb_bg; - tui->clear_attrs.rgb_sp_color = (int)rgb_sp; - tui->clear_attrs.cterm_fg_color = (int)cterm_fg; - tui->clear_attrs.cterm_bg_color = (int)cterm_bg; + tui->clear_attrs.rgb_fg_color = (RgbValue)rgb_fg; + tui->clear_attrs.rgb_bg_color = (RgbValue)rgb_bg; + tui->clear_attrs.rgb_sp_color = (RgbValue)rgb_sp; + tui->clear_attrs.cterm_fg_color = (int16_t)cterm_fg; + tui->clear_attrs.cterm_bg_color = (int16_t)cterm_bg; tui->print_attr_id = -1; invalidate(tui, 0, tui->grid.height, 0, tui->grid.width); diff --git a/src/nvim/ui_client.c b/src/nvim/ui_client.c index 055e234d67..d4dec7db83 100644 --- a/src/nvim/ui_client.c +++ b/src/nvim/ui_client.c @@ -175,7 +175,14 @@ static HlAttrs ui_client_dict2hlattrs(Dictionary d, bool rgb) // TODO(bfredl): log "err" return HLATTRS_INIT; } - return dict2hlattrs(&dict, rgb, NULL, &err); + + HlAttrs attrs = dict2hlattrs(&dict, rgb, NULL, &err); + + if (HAS_KEY(&dict, highlight, url)) { + attrs.url = tui_add_url(tui, dict.url.data); + } + + return attrs; } void ui_client_event_grid_resize(Array args) diff --git a/test/functional/api/extmark_spec.lua b/test/functional/api/extmark_spec.lua index cb16f49e7c..0a286965f2 100644 --- a/test/functional/api/extmark_spec.lua +++ b/test/functional/api/extmark_spec.lua @@ -1791,6 +1791,13 @@ describe('API/extmarks', function() feed('vj2ed') eq({}, get_extmark_by_id(ns, 4, {})) end) + + it('can set a URL', function() + set_extmark(ns, 1, 0, 0, { url = 'https://example.com', end_col = 3 }) + local extmarks = get_extmarks(ns, 0, -1, { details = true }) + eq(1, #extmarks) + eq('https://example.com', extmarks[1][4].url) + end) end) describe('Extmarks buffer api with many marks', function() diff --git a/test/functional/terminal/tui_spec.lua b/test/functional/terminal/tui_spec.lua index 46d3c98e07..5bfc7efbb3 100644 --- a/test/functional/terminal/tui_spec.lua +++ b/test/functional/terminal/tui_spec.lua @@ -1889,6 +1889,37 @@ describe('TUI', function() {3:-- TERMINAL --} | ]]) end) + + it('emits hyperlinks with OSC 8', function() + exec_lua([[ + local buf = vim.api.nvim_get_current_buf() + _G.urls = {} + vim.api.nvim_create_autocmd('TermRequest', { + buffer = buf, + callback = function(args) + local req = args.data + if not req then + return + end + local url = req:match('\027]8;;(.*)$') + if url ~= nil then + table.insert(_G.urls, url) + end + end, + }) + ]]) + child_exec_lua([[ + vim.api.nvim_buf_set_lines(0, 0, 0, true, {'Hello'}) + local ns = vim.api.nvim_create_namespace('test') + vim.api.nvim_buf_set_extmark(0, ns, 0, 1, { + end_col = 3, + url = 'https://example.com', + }) + ]]) + retry(nil, 1000, function() + eq({ 'https://example.com', '' }, exec_lua([[return _G.urls]])) + end) + end) end) describe('TUI', function() diff --git a/test/functional/ui/decorations_spec.lua b/test/functional/ui/decorations_spec.lua index 1b3d4afd5f..7b3533454c 100644 --- a/test/functional/ui/decorations_spec.lua +++ b/test/functional/ui/decorations_spec.lua @@ -2205,6 +2205,41 @@ describe('extmark decorations', function() | ]]} end) + + it('supports URLs', function() + insert(example_text) + + local url = 'https://example.com' + + local attrs = screen:get_default_attr_ids() + table.insert(attrs, { + url = url, + }) + screen:set_default_attr_ids(attrs) + + api.nvim_buf_set_extmark(0, ns, 1, 4, { + end_col = 14, + url = url, + }) + + screen:expect{grid=[[ + for _,item in ipairs(items) do | + {44:local text}, hl_id_cell, count = unpack(item) | + if hl_id_cell ~= nil then | + hl_id = hl_id_cell | + end | + for _ = 1, (count or 1) do | + local cell = line[colpos] | + cell.text = text | + cell.hl_id = hl_id | + colpos = colpos+1 | + end | + en^d | + {1:~ }| + {1:~ }| + | + ]]} + end) end) describe('decorations: inline virtual text', function() diff --git a/test/functional/ui/screen.lua b/test/functional/ui/screen.lua index a02130276d..3043a3c60a 100644 --- a/test/functional/ui/screen.lua +++ b/test/functional/ui/screen.lua @@ -1890,6 +1890,7 @@ function Screen:_equal_attrs(a, b) and a.strikethrough == b.strikethrough and a.fg_indexed == b.fg_indexed and a.bg_indexed == b.bg_indexed + and a.url == b.url end function Screen:_equal_info(a, b)