From c42239444e97c548ddec9a834e1a632c54bda2e9 Mon Sep 17 00:00:00 2001 From: delphinus Date: Tue, 26 May 2026 11:42:25 +0900 Subject: [PATCH] feat(hover): peek full URL on mouse hover over links Set up a -driven floating window that shows the full URL while the mouse hovers over a link in a md-render preview. Truncates to half the editor width, sits just above any statusline/cmdline, and uses Comment hl with winblend so it stays out of the way of the document body. Co-Authored-By: Claude Opus 4.7 (1M context) --- README.md | 2 +- doc/md-render.jax | 9 +- doc/md-render.txt | 9 +- lua/md-render/display_utils.lua | 3 + lua/md-render/url_hover.lua | 253 ++++++++++++++++++++++++++++++++ tests/url_hover_test.lua | 181 +++++++++++++++++++++++ 6 files changed, 452 insertions(+), 5 deletions(-) create mode 100644 lua/md-render/url_hover.lua create mode 100644 tests/url_hover_test.lua diff --git a/README.md b/README.md index afe9c36..0364cda 100644 --- a/README.md +++ b/README.md @@ -18,7 +18,7 @@ A Markdown rendering engine for Neovim. Transforms raw Markdown into richly high - **Video** — local and web video (MP4, WebM, MOV, AVI, MKV, M4V) played as animated frames inline - **Mermaid diagrams** — rendered as images inline - **CJK-aware word wrapping** — JIS X 4051 kinsoku shori + optional [BudouX](https://github.com/google/budoux) phrase segmentation via [budoux.lua](https://github.com/delphinus/budoux.lua) -- **Clickable links** — mouse click to open URLs; OSC 8 hyperlink support for compatible terminals +- **Clickable links** — mouse click to open URLs; hover the mouse over a link to peek the full URL in a subtle floating window; OSC 8 hyperlink support for compatible terminals - **`
` support** — collapsible sections with click-to-toggle, respecting the `open` attribute - **Library API** — use the rendering engine programmatically from your own plugins diff --git a/doc/md-render.jax b/doc/md-render.jax index 3336ff7..f1b475a 100644 --- a/doc/md-render.jax +++ b/doc/md-render.jax @@ -49,7 +49,8 @@ md-render.nvim は Neovim 用の Markdown レンダリングエンジンです - Mermaid ダイアグラムを画像としてインライン表示 - CJK 対応ワードラップ(JIS X 4051 禁則処理 + オプションの BudouX フレーズ分 割) -- クリック可能リンク(マウスクリックで URL を開く。OSC 8 ハイパーリンク対応) +- クリック可能リンク(マウスクリックで URL を開く。マウスをリンクに乗せると + 完全な URL を控え目なフロートウィンドウに表示。OSC 8 ハイパーリンク対応) - `
` 対応(クリックで折りたたみ可能なセクション) - ライブラリ API(自作プラグインからプログラム的に利用可能) @@ -140,11 +141,15 @@ lazy.nvim の場合: >lua `q` / `` プレビューウィンドウを閉じる `` コールアウトの折りたたみ / 省略領域の展開 `` リンクのクリック、折りたたみ、領域展開 + `` マウスをリンクに乗せると、エディタ下部の小さな + フロートウィンドウに完全な URL を表示。 + 'mousemoveevent' が必要 (md-render はプレビュー + ウィンドウが開かれている間、自動で有効化する)。 |:MdRender-toggle| で開かれたレンダーモードのバッファでは `q` / `` / `` は閉じる動作に**割り当てられません**。ソースに戻すには再度 |:MdRender-toggle| を呼びます。`` は折りたたみ・展開・リンクを -引き続き処理します。 +引き続き処理し、`` の URL 表示も有効なままです。 ============================================================================== コマンド *md-render-commands* diff --git a/doc/md-render.txt b/doc/md-render.txt index c66c51b..978526f 100644 --- a/doc/md-render.txt +++ b/doc/md-render.txt @@ -46,7 +46,8 @@ Features: ~ - Mermaid diagrams rendered as images inline - CJK-aware word wrapping (JIS X 4051 kinsoku shori + optional BudouX phrase segmentation) -- Clickable links (mouse click to open URLs; OSC 8 hyperlink support) +- Clickable links (mouse click to open URLs; mouse hover peeks the full URL + in a subtle floating window; OSC 8 hyperlink support) - `
` support (collapsible sections with click-to-toggle) - Library API for programmatic use from other plugins @@ -139,11 +140,15 @@ Inside the preview window, the following keys are available: `q` / `` Close the preview window `` Toggle callout fold / expand truncated region `` Click links, toggle folds, expand regions + `` Hover a link to peek its full URL in a small floating + window at the bottom of the editor. Requires + 'mousemoveevent' (md-render enables it automatically + whenever a preview window is open). Inside a render-mode buffer opened with |:MdRender-toggle|, `q` / `` / `` are NOT bound to close — call |:MdRender-toggle| again to return to source mode. `` still toggles folds, expands regions, and -opens links. +opens links; `` hover-peek also stays active. ============================================================================== COMMANDS *md-render-commands* diff --git a/lua/md-render/display_utils.lua b/lua/md-render/display_utils.lua index 31e659a..ef0971f 100644 --- a/lua/md-render/display_utils.lua +++ b/lua/md-render/display_utils.lua @@ -1,4 +1,5 @@ local FloatWin = require "md-render.float_win" +local UrlHover = require "md-render.url_hover" local M = {} @@ -284,6 +285,8 @@ function M.setup_float_keymaps(buf, ns, win, content, close_handle, opts) vim.api.nvim_buf_set_keymap(buf, "n", key, ":close", { noremap = true, silent = true }) end + UrlHover.attach(buf, ns, win) + vim.keymap.set("n", "", function() local mouse = vim.fn.getmousepos() if mouse.winid == win then diff --git a/lua/md-render/url_hover.lua b/lua/md-render/url_hover.lua new file mode 100644 index 0000000..8ab3f60 --- /dev/null +++ b/lua/md-render/url_hover.lua @@ -0,0 +1,253 @@ +--- Show the full URL in a small floating window at the bottom-right of the +--- editor while the mouse hovers over a link in a md-render preview. + +local M = {} + +local DEBOUNCE_MS = 100 +local WINBLEND = 15 +local AUGROUP = "md_render_url_hover" + +---@type table +local registered = {} + +local state = { + ---@type integer? + hover_win = nil, + ---@type integer? + hover_buf = nil, + ---@type string? + current_url = nil, + ---@type integer? + current_win = nil, + ---@type string? + pending_url = nil, + ---@type table? + pending_token = nil, +} + +local augroup_initialized = false + +local function ensure_hover_buf() + if state.hover_buf and vim.api.nvim_buf_is_valid(state.hover_buf) then + return state.hover_buf + end + state.hover_buf = vim.api.nvim_create_buf(false, true) + vim.bo[state.hover_buf].bufhidden = "hide" + return state.hover_buf +end + +---@param url string +---@param max_width integer +---@return string +local function truncate_url(url, max_width) + if max_width <= 0 then return "" end + if vim.api.nvim_strwidth(url) <= max_width then return url end + if max_width == 1 then return "…" end + + local result_width = 0 + local pieces = {} + local len = vim.fn.strchars(url) + for i = 0, len - 1 do + local ch = vim.fn.strcharpart(url, i, 1) + local w = vim.api.nvim_strwidth(ch) + if result_width + w + 1 > max_width then break end + pieces[#pieces + 1] = ch + result_width = result_width + w + end + return table.concat(pieces) .. "…" +end + +local function cancel_pending() + state.pending_token = nil + state.pending_url = nil +end + +local function close_hover() + if state.hover_win and vim.api.nvim_win_is_valid(state.hover_win) then + pcall(vim.api.nvim_win_close, state.hover_win, true) + end + state.hover_win = nil + state.current_url = nil + state.current_win = nil +end + +--- How many rows at the bottom of the editor are occupied by cmdline + +--- statusline. The hover is placed just above this band so it doesn't +--- overlap either. +---@return integer +local function bottom_reserved_rows() + local rows = vim.o.cmdheight + local ls = vim.o.laststatus + if ls == 2 or ls == 3 then + rows = rows + 1 + elseif ls == 1 and #vim.api.nvim_tabpage_list_wins(0) > 1 then + rows = rows + 1 + end + return rows +end + +---@param url string +---@param source_win integer +local function show_hover(url, source_win) + if state.current_url == url and state.current_win == source_win then + return + end + + local max_width = math.max(1, math.floor(vim.o.columns / 2)) + local display = truncate_url(url, max_width) + local width = math.max(1, vim.api.nvim_strwidth(display)) + local row = math.max(0, vim.o.lines - bottom_reserved_rows() - 1) + local col = math.max(0, vim.o.columns - width) + + if state.hover_win and vim.api.nvim_win_is_valid(state.hover_win) then + local buf = vim.api.nvim_win_get_buf(state.hover_win) + vim.api.nvim_buf_set_lines(buf, 0, -1, false, { display }) + vim.api.nvim_win_set_config(state.hover_win, { + relative = "editor", + width = width, + height = 1, + row = row, + col = col, + }) + else + local buf = ensure_hover_buf() + vim.api.nvim_buf_set_lines(buf, 0, -1, false, { display }) + state.hover_win = vim.api.nvim_open_win(buf, false, { + relative = "editor", + width = width, + height = 1, + row = row, + col = col, + style = "minimal", + border = "none", + focusable = false, + zindex = 250, + noautocmd = true, + }) + vim.wo[state.hover_win].winblend = WINBLEND + vim.wo[state.hover_win].winhighlight = "Normal:Comment,NormalFloat:Comment" + end + + state.current_url = url + state.current_win = source_win +end + +---@param mouse { winid: integer, line: integer, column: integer } +---@param buf integer +---@param ns integer +---@return string? +local function url_at_mouse(mouse, buf, ns) + if mouse.line < 1 or mouse.column < 1 then return nil end + local line = mouse.line - 1 + local col = mouse.column - 1 + + if not vim.api.nvim_buf_is_valid(buf) then return nil end + local line_count = vim.api.nvim_buf_line_count(buf) + if line >= line_count then return nil end + + local ok, marks = pcall( + vim.api.nvim_buf_get_extmarks, + buf, ns, { line, 0 }, { line + 1, 0 }, { details = true } + ) + if not ok then return nil end + + for _, mark in ipairs(marks) do + local _, _, start_col, details = unpack(mark) + if details and details.url then + local end_col = details.end_col or (start_col + 1) + if col >= start_col and col < end_col then + return details.url + end + end + end + return nil +end + +local function handle_mouse_move() + local mouse = vim.fn.getmousepos() + local entry = registered[mouse.winid] + + if not entry then + cancel_pending() + close_hover() + return + end + + local url = url_at_mouse(mouse, entry.buf, entry.ns) + + if not url then + cancel_pending() + close_hover() + return + end + + if state.current_url == url and state.current_win == mouse.winid then + cancel_pending() + return + end + + if state.pending_url == url then return end + + local token = {} + state.pending_token = token + state.pending_url = url + local source_win = mouse.winid + + vim.defer_fn(function() + if state.pending_token ~= token then return end + state.pending_token = nil + state.pending_url = nil + if not registered[source_win] then return end + show_hover(url, source_win) + end, DEBOUNCE_MS) +end + +local function ensure_initialized() + if augroup_initialized then return end + augroup_initialized = true + vim.o.mousemoveevent = true + -- is a keycode, not an autocmd event. The mapping below fires + -- whenever 'mousemoveevent' is on and the mouse moves; the global handler + -- checks the current mouse position against the registered windows. + vim.keymap.set({ "n", "i", "v" }, "", function() + handle_mouse_move() + end, { silent = true, desc = "md-render: URL hover" }) +end + +--- Start showing URL hovers for the given preview window. +---@param buf integer +---@param ns integer +---@param win integer +function M.attach(buf, ns, win) + ensure_initialized() + registered[win] = { buf = buf, ns = ns } + vim.api.nvim_create_autocmd("WinClosed", { + group = vim.api.nvim_create_augroup(AUGROUP, { clear = false }), + pattern = tostring(win), + once = true, + callback = function() + registered[win] = nil + cancel_pending() + if state.current_win == win then + close_hover() + end + end, + }) +end + +--- Exposed for tests. +function M._internal() + return { + state = state, + registered = registered, + truncate_url = truncate_url, + url_at_mouse = url_at_mouse, + handle_mouse_move = handle_mouse_move, + show_hover = show_hover, + close_hover = close_hover, + bottom_reserved_rows = bottom_reserved_rows, + DEBOUNCE_MS = DEBOUNCE_MS, + } +end + +return M diff --git a/tests/url_hover_test.lua b/tests/url_hover_test.lua new file mode 100644 index 0000000..f37a363 --- /dev/null +++ b/tests/url_hover_test.lua @@ -0,0 +1,181 @@ +-- Test url_hover module: truncation, hit-testing, and hover lifecycle. +-- Run: nvim --headless -u NONE --noplugin -l tests/url_hover_test.lua + +package.path = vim.fn.getcwd() .. "/lua/?.lua;" .. vim.fn.getcwd() .. "/lua/?/init.lua;" .. package.path + +local UrlHover = require "md-render.url_hover" +local internal = UrlHover._internal() + +local pass_count = 0 +local fail_count = 0 + +local function assert_eq(actual, expected, msg) + if vim.deep_equal(actual, expected) then + pass_count = pass_count + 1 + else + fail_count = fail_count + 1 + print("FAIL: " .. msg) + print(" expected: " .. vim.inspect(expected)) + print(" actual: " .. vim.inspect(actual)) + end +end + +local function test(name, fn) + local ok, err = pcall(fn) + if not ok then + fail_count = fail_count + 1 + print("ERROR: " .. name .. ": " .. tostring(err)) + end +end + +-- truncate_url + +test("truncate_url: short URL returned as-is", function() + assert_eq(internal.truncate_url("https://example.com", 80), "https://example.com", "short URL unchanged") +end) + +test("truncate_url: long URL truncated with ellipsis", function() + local url = "https://example.com/" .. string.rep("a", 100) + local result = internal.truncate_url(url, 30) + assert_eq(vim.api.nvim_strwidth(result), 30, "truncated to exact max width") + assert_eq(result:sub(-3), "…", "ends with ellipsis (3-byte UTF-8)") +end) + +test("truncate_url: max_width of 1 returns ellipsis", function() + assert_eq(internal.truncate_url("https://example.com", 1), "…", "edge case width=1") +end) + +test("truncate_url: max_width of 0 returns empty", function() + assert_eq(internal.truncate_url("https://example.com", 0), "", "edge case width=0") +end) + +test("truncate_url: handles multi-byte chars correctly", function() + local url = "https://example.com/日本語ページ/path" + local result = internal.truncate_url(url, 20) + assert_eq(vim.api.nvim_strwidth(result) <= 20, true, "multi-byte truncation stays within width") + assert_eq(result:sub(-3), "…", "ends with ellipsis") +end) + +-- url_at_mouse + +local function make_buf_with_url(line_text, url, start_col, end_col) + local buf = vim.api.nvim_create_buf(false, true) + local ns = vim.api.nvim_create_namespace("url_hover_test") + vim.api.nvim_buf_set_lines(buf, 0, -1, false, { line_text }) + vim.api.nvim_buf_set_extmark(buf, ns, 0, start_col, { + end_col = end_col, + url = url, + }) + return buf, ns +end + +test("url_at_mouse: returns URL when mouse is on link", function() + local buf, ns = make_buf_with_url("See docs here", "https://example.com/docs", 4, 8) + -- column is 1-indexed in getmousepos; col 5 = byte 4 (0-indexed) → start of "docs" + local mouse = { winid = 1, line = 1, column = 5 } + assert_eq(internal.url_at_mouse(mouse, buf, ns), "https://example.com/docs", "URL detected at start") + + mouse.column = 8 -- byte 7 (0-indexed) → last char of "docs" + assert_eq(internal.url_at_mouse(mouse, buf, ns), "https://example.com/docs", "URL detected at end") +end) + +test("url_at_mouse: returns nil when mouse is outside link", function() + local buf, ns = make_buf_with_url("See docs here", "https://example.com/docs", 4, 8) + local mouse = { winid = 1, line = 1, column = 1 } -- on "S" + assert_eq(internal.url_at_mouse(mouse, buf, ns), nil, "no URL before link") + + mouse.column = 10 -- on " here" + assert_eq(internal.url_at_mouse(mouse, buf, ns), nil, "no URL after link") +end) + +test("url_at_mouse: returns nil for invalid positions", function() + local buf, ns = make_buf_with_url("See docs here", "https://example.com/docs", 4, 8) + assert_eq(internal.url_at_mouse({ winid = 1, line = 0, column = 5 }, buf, ns), nil, "line=0 invalid") + assert_eq(internal.url_at_mouse({ winid = 1, line = 1, column = 0 }, buf, ns), nil, "column=0 invalid") + assert_eq(internal.url_at_mouse({ winid = 1, line = 99, column = 5 }, buf, ns), nil, "line past EOF") +end) + +test("url_at_mouse: returns nil when extmark has no URL", function() + local buf = vim.api.nvim_create_buf(false, true) + local ns = vim.api.nvim_create_namespace("url_hover_test2") + vim.api.nvim_buf_set_lines(buf, 0, -1, false, { "Some text" }) + vim.api.nvim_buf_set_extmark(buf, ns, 0, 0, { end_col = 4, hl_group = "Comment" }) + assert_eq(internal.url_at_mouse({ winid = 1, line = 1, column = 2 }, buf, ns), nil, "non-URL extmark ignored") +end) + +-- bottom_reserved_rows + +test("bottom_reserved_rows: cmdheight only when no statusline", function() + local saved_ls, saved_ch = vim.o.laststatus, vim.o.cmdheight + vim.o.laststatus = 0 + vim.o.cmdheight = 1 + assert_eq(internal.bottom_reserved_rows(), 1, "ls=0 ch=1 → 1") + vim.o.cmdheight = 0 + assert_eq(internal.bottom_reserved_rows(), 0, "ls=0 ch=0 → 0") + vim.o.laststatus, vim.o.cmdheight = saved_ls, saved_ch +end) + +test("bottom_reserved_rows: adds statusline row when laststatus=2", function() + local saved_ls, saved_ch = vim.o.laststatus, vim.o.cmdheight + vim.o.laststatus = 2 + vim.o.cmdheight = 1 + assert_eq(internal.bottom_reserved_rows(), 2, "ls=2 ch=1 → 2 (status + cmdline)") + vim.o.cmdheight = 0 + assert_eq(internal.bottom_reserved_rows(), 1, "ls=2 ch=0 → 1 (status only)") + vim.o.laststatus, vim.o.cmdheight = saved_ls, saved_ch +end) + +test("bottom_reserved_rows: adds statusline row when laststatus=3 (global)", function() + local saved_ls, saved_ch = vim.o.laststatus, vim.o.cmdheight + vim.o.laststatus = 3 + vim.o.cmdheight = 2 + assert_eq(internal.bottom_reserved_rows(), 3, "ls=3 ch=2 → 3") + vim.o.laststatus, vim.o.cmdheight = saved_ls, saved_ch +end) + +-- hover window lifecycle + +test("show_hover then close_hover: opens and closes a float", function() + internal.show_hover("https://example.com", 1) + assert_eq(internal.state.hover_win ~= nil, true, "hover window created") + assert_eq(vim.api.nvim_win_is_valid(internal.state.hover_win), true, "hover window valid") + assert_eq(internal.state.current_url, "https://example.com", "current_url tracked") + assert_eq(internal.state.current_win, 1, "current_win tracked") + + internal.close_hover() + assert_eq(internal.state.hover_win, nil, "hover window cleared from state") + assert_eq(internal.state.current_url, nil, "current_url cleared") +end) + +test("show_hover: updates existing window for new URL", function() + internal.show_hover("https://example.com", 1) + local first_win = internal.state.hover_win + internal.show_hover("https://example.org/different", 1) + assert_eq(internal.state.hover_win, first_win, "reuses same window handle") + assert_eq(internal.state.current_url, "https://example.org/different", "URL updated") + internal.close_hover() +end) + +test("attach: registers window for hover", function() + local buf = vim.api.nvim_create_buf(false, true) + vim.api.nvim_buf_set_lines(buf, 0, -1, false, { "x" }) + local ns = vim.api.nvim_create_namespace("attach_test") + local win = vim.api.nvim_open_win(buf, false, { + relative = "editor", width = 5, height = 1, row = 0, col = 0, style = "minimal", + }) + UrlHover.attach(buf, ns, win) + assert_eq(internal.registered[win] ~= nil, true, "window registered") + assert_eq(internal.registered[win].buf, buf, "buf stored") + assert_eq(internal.registered[win].ns, ns, "ns stored") + + vim.api.nvim_win_close(win, true) + -- WinClosed autocmd should clear registration + vim.wait(50, function() return internal.registered[win] == nil end) + assert_eq(internal.registered[win], nil, "WinClosed cleared registration") +end) + +-- Summary +print(string.format("\n%d passed, %d failed", pass_count, fail_count)) +if fail_count > 0 then + os.exit(1) +end