|
| 1 | +--- Show the full URL in a small floating window at the bottom-right of the |
| 2 | +--- editor while the mouse hovers over a link in a md-render preview. |
| 3 | + |
| 4 | +local M = {} |
| 5 | + |
| 6 | +local DEBOUNCE_MS = 100 |
| 7 | +local WINBLEND = 15 |
| 8 | +local AUGROUP = "md_render_url_hover" |
| 9 | + |
| 10 | +---@type table<integer, { buf: integer, ns: integer }> |
| 11 | +local registered = {} |
| 12 | + |
| 13 | +local state = { |
| 14 | + ---@type integer? |
| 15 | + hover_win = nil, |
| 16 | + ---@type integer? |
| 17 | + hover_buf = nil, |
| 18 | + ---@type string? |
| 19 | + current_url = nil, |
| 20 | + ---@type integer? |
| 21 | + current_win = nil, |
| 22 | + ---@type string? |
| 23 | + pending_url = nil, |
| 24 | + ---@type table? |
| 25 | + pending_token = nil, |
| 26 | +} |
| 27 | + |
| 28 | +local augroup_initialized = false |
| 29 | + |
| 30 | +local function ensure_hover_buf() |
| 31 | + if state.hover_buf and vim.api.nvim_buf_is_valid(state.hover_buf) then |
| 32 | + return state.hover_buf |
| 33 | + end |
| 34 | + state.hover_buf = vim.api.nvim_create_buf(false, true) |
| 35 | + vim.bo[state.hover_buf].bufhidden = "hide" |
| 36 | + return state.hover_buf |
| 37 | +end |
| 38 | + |
| 39 | +---@param url string |
| 40 | +---@param max_width integer |
| 41 | +---@return string |
| 42 | +local function truncate_url(url, max_width) |
| 43 | + if max_width <= 0 then return "" end |
| 44 | + if vim.api.nvim_strwidth(url) <= max_width then return url end |
| 45 | + if max_width == 1 then return "…" end |
| 46 | + |
| 47 | + local result_width = 0 |
| 48 | + local pieces = {} |
| 49 | + local len = vim.fn.strchars(url) |
| 50 | + for i = 0, len - 1 do |
| 51 | + local ch = vim.fn.strcharpart(url, i, 1) |
| 52 | + local w = vim.api.nvim_strwidth(ch) |
| 53 | + if result_width + w + 1 > max_width then break end |
| 54 | + pieces[#pieces + 1] = ch |
| 55 | + result_width = result_width + w |
| 56 | + end |
| 57 | + return table.concat(pieces) .. "…" |
| 58 | +end |
| 59 | + |
| 60 | +local function cancel_pending() |
| 61 | + state.pending_token = nil |
| 62 | + state.pending_url = nil |
| 63 | +end |
| 64 | + |
| 65 | +local function close_hover() |
| 66 | + if state.hover_win and vim.api.nvim_win_is_valid(state.hover_win) then |
| 67 | + pcall(vim.api.nvim_win_close, state.hover_win, true) |
| 68 | + end |
| 69 | + state.hover_win = nil |
| 70 | + state.current_url = nil |
| 71 | + state.current_win = nil |
| 72 | +end |
| 73 | + |
| 74 | +--- How many rows at the bottom of the editor are occupied by cmdline + |
| 75 | +--- statusline. The hover is placed just above this band so it doesn't |
| 76 | +--- overlap either. |
| 77 | +---@return integer |
| 78 | +local function bottom_reserved_rows() |
| 79 | + local rows = vim.o.cmdheight |
| 80 | + local ls = vim.o.laststatus |
| 81 | + if ls == 2 or ls == 3 then |
| 82 | + rows = rows + 1 |
| 83 | + elseif ls == 1 and #vim.api.nvim_tabpage_list_wins(0) > 1 then |
| 84 | + rows = rows + 1 |
| 85 | + end |
| 86 | + return rows |
| 87 | +end |
| 88 | + |
| 89 | +---@param url string |
| 90 | +---@param source_win integer |
| 91 | +local function show_hover(url, source_win) |
| 92 | + if state.current_url == url and state.current_win == source_win then |
| 93 | + return |
| 94 | + end |
| 95 | + |
| 96 | + local max_width = math.max(1, math.floor(vim.o.columns / 2)) |
| 97 | + local display = truncate_url(url, max_width) |
| 98 | + local width = math.max(1, vim.api.nvim_strwidth(display)) |
| 99 | + local row = math.max(0, vim.o.lines - bottom_reserved_rows() - 1) |
| 100 | + local col = math.max(0, vim.o.columns - width) |
| 101 | + |
| 102 | + if state.hover_win and vim.api.nvim_win_is_valid(state.hover_win) then |
| 103 | + local buf = vim.api.nvim_win_get_buf(state.hover_win) |
| 104 | + vim.api.nvim_buf_set_lines(buf, 0, -1, false, { display }) |
| 105 | + vim.api.nvim_win_set_config(state.hover_win, { |
| 106 | + relative = "editor", |
| 107 | + width = width, |
| 108 | + height = 1, |
| 109 | + row = row, |
| 110 | + col = col, |
| 111 | + }) |
| 112 | + else |
| 113 | + local buf = ensure_hover_buf() |
| 114 | + vim.api.nvim_buf_set_lines(buf, 0, -1, false, { display }) |
| 115 | + state.hover_win = vim.api.nvim_open_win(buf, false, { |
| 116 | + relative = "editor", |
| 117 | + width = width, |
| 118 | + height = 1, |
| 119 | + row = row, |
| 120 | + col = col, |
| 121 | + style = "minimal", |
| 122 | + border = "none", |
| 123 | + focusable = false, |
| 124 | + zindex = 250, |
| 125 | + noautocmd = true, |
| 126 | + }) |
| 127 | + vim.wo[state.hover_win].winblend = WINBLEND |
| 128 | + vim.wo[state.hover_win].winhighlight = "Normal:Comment,NormalFloat:Comment" |
| 129 | + end |
| 130 | + |
| 131 | + state.current_url = url |
| 132 | + state.current_win = source_win |
| 133 | +end |
| 134 | + |
| 135 | +---@param mouse { winid: integer, line: integer, column: integer } |
| 136 | +---@param buf integer |
| 137 | +---@param ns integer |
| 138 | +---@return string? |
| 139 | +local function url_at_mouse(mouse, buf, ns) |
| 140 | + if mouse.line < 1 or mouse.column < 1 then return nil end |
| 141 | + local line = mouse.line - 1 |
| 142 | + local col = mouse.column - 1 |
| 143 | + |
| 144 | + if not vim.api.nvim_buf_is_valid(buf) then return nil end |
| 145 | + local line_count = vim.api.nvim_buf_line_count(buf) |
| 146 | + if line >= line_count then return nil end |
| 147 | + |
| 148 | + local ok, marks = pcall( |
| 149 | + vim.api.nvim_buf_get_extmarks, |
| 150 | + buf, ns, { line, 0 }, { line + 1, 0 }, { details = true } |
| 151 | + ) |
| 152 | + if not ok then return nil end |
| 153 | + |
| 154 | + for _, mark in ipairs(marks) do |
| 155 | + local _, _, start_col, details = unpack(mark) |
| 156 | + if details and details.url then |
| 157 | + local end_col = details.end_col or (start_col + 1) |
| 158 | + if col >= start_col and col < end_col then |
| 159 | + return details.url |
| 160 | + end |
| 161 | + end |
| 162 | + end |
| 163 | + return nil |
| 164 | +end |
| 165 | + |
| 166 | +local function handle_mouse_move() |
| 167 | + local mouse = vim.fn.getmousepos() |
| 168 | + local entry = registered[mouse.winid] |
| 169 | + |
| 170 | + if not entry then |
| 171 | + cancel_pending() |
| 172 | + close_hover() |
| 173 | + return |
| 174 | + end |
| 175 | + |
| 176 | + local url = url_at_mouse(mouse, entry.buf, entry.ns) |
| 177 | + |
| 178 | + if not url then |
| 179 | + cancel_pending() |
| 180 | + close_hover() |
| 181 | + return |
| 182 | + end |
| 183 | + |
| 184 | + if state.current_url == url and state.current_win == mouse.winid then |
| 185 | + cancel_pending() |
| 186 | + return |
| 187 | + end |
| 188 | + |
| 189 | + if state.pending_url == url then return end |
| 190 | + |
| 191 | + local token = {} |
| 192 | + state.pending_token = token |
| 193 | + state.pending_url = url |
| 194 | + local source_win = mouse.winid |
| 195 | + |
| 196 | + vim.defer_fn(function() |
| 197 | + if state.pending_token ~= token then return end |
| 198 | + state.pending_token = nil |
| 199 | + state.pending_url = nil |
| 200 | + if not registered[source_win] then return end |
| 201 | + show_hover(url, source_win) |
| 202 | + end, DEBOUNCE_MS) |
| 203 | +end |
| 204 | + |
| 205 | +local function ensure_initialized() |
| 206 | + if augroup_initialized then return end |
| 207 | + augroup_initialized = true |
| 208 | + vim.o.mousemoveevent = true |
| 209 | + -- <MouseMove> is a keycode, not an autocmd event. The mapping below fires |
| 210 | + -- whenever 'mousemoveevent' is on and the mouse moves; the global handler |
| 211 | + -- checks the current mouse position against the registered windows. |
| 212 | + vim.keymap.set({ "n", "i", "v" }, "<MouseMove>", function() |
| 213 | + handle_mouse_move() |
| 214 | + end, { silent = true, desc = "md-render: URL hover" }) |
| 215 | +end |
| 216 | + |
| 217 | +--- Start showing URL hovers for the given preview window. |
| 218 | +---@param buf integer |
| 219 | +---@param ns integer |
| 220 | +---@param win integer |
| 221 | +function M.attach(buf, ns, win) |
| 222 | + ensure_initialized() |
| 223 | + registered[win] = { buf = buf, ns = ns } |
| 224 | + vim.api.nvim_create_autocmd("WinClosed", { |
| 225 | + group = vim.api.nvim_create_augroup(AUGROUP, { clear = false }), |
| 226 | + pattern = tostring(win), |
| 227 | + once = true, |
| 228 | + callback = function() |
| 229 | + registered[win] = nil |
| 230 | + cancel_pending() |
| 231 | + if state.current_win == win then |
| 232 | + close_hover() |
| 233 | + end |
| 234 | + end, |
| 235 | + }) |
| 236 | +end |
| 237 | + |
| 238 | +--- Exposed for tests. |
| 239 | +function M._internal() |
| 240 | + return { |
| 241 | + state = state, |
| 242 | + registered = registered, |
| 243 | + truncate_url = truncate_url, |
| 244 | + url_at_mouse = url_at_mouse, |
| 245 | + handle_mouse_move = handle_mouse_move, |
| 246 | + show_hover = show_hover, |
| 247 | + close_hover = close_hover, |
| 248 | + bottom_reserved_rows = bottom_reserved_rows, |
| 249 | + DEBOUNCE_MS = DEBOUNCE_MS, |
| 250 | + } |
| 251 | +end |
| 252 | + |
| 253 | +return M |
0 commit comments