Skip to content

Commit c422394

Browse files
delphinusclaude
andcommitted
feat(hover): peek full URL on mouse hover over links
Set up a <MouseMove>-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) <noreply@anthropic.com>
1 parent cd751c9 commit c422394

6 files changed

Lines changed: 452 additions & 5 deletions

File tree

README.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -18,7 +18,7 @@ A Markdown rendering engine for Neovim. Transforms raw Markdown into richly high
1818
- **Video** — local and web video (MP4, WebM, MOV, AVI, MKV, M4V) played as animated frames inline
1919
- **Mermaid diagrams** — rendered as images inline
2020
- **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)
21-
- **Clickable links** — mouse click to open URLs; OSC 8 hyperlink support for compatible terminals
21+
- **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
2222
- **`<details>` support** — collapsible sections with click-to-toggle, respecting the `open` attribute
2323
- **Library API** — use the rendering engine programmatically from your own plugins
2424

doc/md-render.jax

Lines changed: 7 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -49,7 +49,8 @@ md-render.nvim は Neovim 用の Markdown レンダリングエンジンです
4949
- Mermaid ダイアグラムを画像としてインライン表示
5050
- CJK 対応ワードラップ(JIS X 4051 禁則処理 + オプションの BudouX フレーズ分
5151
割)
52-
- クリック可能リンク(マウスクリックで URL を開く。OSC 8 ハイパーリンク対応)
52+
- クリック可能リンク(マウスクリックで URL を開く。マウスをリンクに乗せると
53+
完全な URL を控え目なフロートウィンドウに表示。OSC 8 ハイパーリンク対応)
5354
- `<details>` 対応(クリックで折りたたみ可能なセクション)
5455
- ライブラリ API(自作プラグインからプログラム的に利用可能)
5556

@@ -140,11 +141,15 @@ lazy.nvim の場合: >lua
140141
`q` / `<Esc>` プレビューウィンドウを閉じる
141142
`<CR>` コールアウトの折りたたみ / 省略領域の展開
142143
`<LeftMouse>` リンクのクリック、折りたたみ、領域展開
144+
`<MouseMove>` マウスをリンクに乗せると、エディタ下部の小さな
145+
フロートウィンドウに完全な URL を表示。
146+
'mousemoveevent' が必要 (md-render はプレビュー
147+
ウィンドウが開かれている間、自動で有効化する)。
143148

144149
|:MdRender-toggle| で開かれたレンダーモードのバッファでは `q` / `<Esc>` /
145150
`<CR>` は閉じる動作に**割り当てられません**。ソースに戻すには再度
146151
|:MdRender-toggle| を呼びます。`<LeftMouse>` は折りたたみ・展開・リンクを
147-
引き続き処理します
152+
引き続き処理し、`<MouseMove>` の URL 表示も有効なままです
148153

149154
==============================================================================
150155
コマンド *md-render-commands*

doc/md-render.txt

Lines changed: 7 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -46,7 +46,8 @@ Features: ~
4646
- Mermaid diagrams rendered as images inline
4747
- CJK-aware word wrapping (JIS X 4051 kinsoku shori + optional BudouX phrase
4848
segmentation)
49-
- Clickable links (mouse click to open URLs; OSC 8 hyperlink support)
49+
- Clickable links (mouse click to open URLs; mouse hover peeks the full URL
50+
in a subtle floating window; OSC 8 hyperlink support)
5051
- `<details>` support (collapsible sections with click-to-toggle)
5152
- Library API for programmatic use from other plugins
5253

@@ -139,11 +140,15 @@ Inside the preview window, the following keys are available:
139140
`q` / `<Esc>` Close the preview window
140141
`<CR>` Toggle callout fold / expand truncated region
141142
`<LeftMouse>` Click links, toggle folds, expand regions
143+
`<MouseMove>` Hover a link to peek its full URL in a small floating
144+
window at the bottom of the editor. Requires
145+
'mousemoveevent' (md-render enables it automatically
146+
whenever a preview window is open).
142147

143148
Inside a render-mode buffer opened with |:MdRender-toggle|, `q` / `<Esc>` /
144149
`<CR>` are NOT bound to close — call |:MdRender-toggle| again to return to
145150
source mode. `<LeftMouse>` still toggles folds, expands regions, and
146-
opens links.
151+
opens links; `<MouseMove>` hover-peek also stays active.
147152

148153
==============================================================================
149154
COMMANDS *md-render-commands*

lua/md-render/display_utils.lua

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,5 @@
11
local FloatWin = require "md-render.float_win"
2+
local UrlHover = require "md-render.url_hover"
23

34
local M = {}
45

@@ -284,6 +285,8 @@ function M.setup_float_keymaps(buf, ns, win, content, close_handle, opts)
284285
vim.api.nvim_buf_set_keymap(buf, "n", key, ":close<CR>", { noremap = true, silent = true })
285286
end
286287

288+
UrlHover.attach(buf, ns, win)
289+
287290
vim.keymap.set("n", "<LeftRelease>", function()
288291
local mouse = vim.fn.getmousepos()
289292
if mouse.winid == win then

lua/md-render/url_hover.lua

Lines changed: 253 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,253 @@
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

Comments
 (0)