Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
- **`<details>` support** — collapsible sections with click-to-toggle, respecting the `open` attribute
- **Library API** — use the rendering engine programmatically from your own plugins

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

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

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

==============================================================================
コマンド *md-render-commands*
Expand Down
9 changes: 7 additions & 2 deletions doc/md-render.txt
Original file line number Diff line number Diff line change
Expand Up @@ -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)
- `<details>` support (collapsible sections with click-to-toggle)
- Library API for programmatic use from other plugins

Expand Down Expand Up @@ -139,11 +140,15 @@ Inside the preview window, the following keys are available:
`q` / `<Esc>` Close the preview window
`<CR>` Toggle callout fold / expand truncated region
`<LeftMouse>` Click links, toggle folds, expand regions
`<MouseMove>` 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` / `<Esc>` /
`<CR>` are NOT bound to close — call |:MdRender-toggle| again to return to
source mode. `<LeftMouse>` still toggles folds, expands regions, and
opens links.
opens links; `<MouseMove>` hover-peek also stays active.

==============================================================================
COMMANDS *md-render-commands*
Expand Down
3 changes: 3 additions & 0 deletions lua/md-render/display_utils.lua
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
local FloatWin = require "md-render.float_win"
local UrlHover = require "md-render.url_hover"

local M = {}

Expand Down Expand Up @@ -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<CR>", { noremap = true, silent = true })
end

UrlHover.attach(buf, ns, win)

vim.keymap.set("n", "<LeftRelease>", function()
local mouse = vim.fn.getmousepos()
if mouse.winid == win then
Expand Down
253 changes: 253 additions & 0 deletions lua/md-render/url_hover.lua
Original file line number Diff line number Diff line change
@@ -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<integer, { buf: integer, ns: integer }>
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
-- <MouseMove> 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" }, "<MouseMove>", 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
Loading
Loading