feat(ui): add support for OSC 8 hyperlinks (#27109)

Extmarks can contain URLs which can then be drawn in any supporting UI.
In the TUI, for example, URLs are "drawn" by emitting the OSC 8 control
sequence to the TTY. On terminals which support the OSC 8 sequence this
will create clickable hyperlinks.

URLs are treated as inline highlights in the decoration subsystem, so
are included in the `DecorSignHighlight` structure. However, unlike
other inline highlights they use allocated memory which must be freed,
so they set the `ext` flag in `DecorInline` so that their lifetimes are
managed along with other allocated memory like virtual text.

The decoration subsystem then adds the URLs as a new highlight
attribute. The highlight subsystem maintains a set of unique URLs to
avoid duplicating allocations for the same string. To attach a URL to an
existing highlight attribute we call `hl_add_url` which finds the URL in
the set (allocating and adding it if it does not exist) and sets the
`url` highlight attribute to the index of the URL in the set (using an
index helps keep the size of the `HlAttrs` struct small).

This has the potential to lead to an increase in highlight attributes
if a URL is used over a range that contains many different highlight
attributes, because now each existing attribute must be combined with
the URL. In practice, however, URLs typically span a range containing a
single highlight (e.g. link text in Markdown), so this is likely just a
pathological edge case.

When a new highlight attribute is defined with a URL it is copied to all
attached UIs with the `hl_attr_define` UI event. The TUI manages its own
set of URLs (just like the highlight subsystem) to minimize allocations.
The TUI keeps track of which URL is "active" for the cell it is
printing. If no URL is active and a cell containing a URL is printed,
the opening OSC 8 sequence is emitted and that URL becomes the actively
tracked URL. If the cursor is moved while in the middle of a URL span,
we emit the terminating OSC sequence to prevent the hyperlink from
spanning multiple lines.

This does not support nested hyperlinks, but that is a rare (and,
frankly, bizarre) use case. If a valid use case for nested hyperlinks
ever presents itself we can address that issue then.
This commit is contained in:
Gregory Anders 2024-01-24 16:36:25 -06:00 committed by GitHub
parent f7bda77f9e
commit 6ea6b3fee2
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
22 changed files with 298 additions and 30 deletions

View File

@ -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

View File

@ -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*

View File

@ -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

View File

@ -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

View File

@ -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

View File

@ -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;
}

View File

@ -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 {

View File

@ -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));

View File

@ -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);

View File

@ -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]));

View File

@ -3,6 +3,7 @@
#include <stdint.h>
#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,

View File

@ -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;
}

View File

@ -3,6 +3,8 @@
#include <stdbool.h>
#include <stdint.h>
#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[].

View File

@ -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

View File

@ -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

View File

@ -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,
});
}

View File

@ -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);

View File

@ -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)

View File

@ -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()

View File

@ -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()

View File

@ -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()

View File

@ -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)