From bd6b4722e5a6055dfcd9afe0ff9dcf6b4bfe6312 Mon Sep 17 00:00:00 2001 From: Jannik Donker Date: Thu, 11 Jun 2026 14:12:20 +0200 Subject: [PATCH] better hover --- lua/hover.lua | 182 ++++++++++++++++++++++++++++++++++++++++++++++++ lua/keymaps.lua | 13 ++-- 2 files changed, 186 insertions(+), 9 deletions(-) create mode 100644 lua/hover.lua diff --git a/lua/hover.lua b/lua/hover.lua new file mode 100644 index 0000000..990833e --- /dev/null +++ b/lua/hover.lua @@ -0,0 +1,182 @@ +-- Cycling hover: K shows line diagnostics one at a time, then the LSP hover. +-- Repeated K cycles through them; enters the floating window. +local M = {} + +M.win = nil -- floating window handle +M.buf = nil -- floating buffer handle +M.items = nil -- list of render functions for the current cycle +M.idx = 0 -- current position in the cycle +M.bufnr = nil -- source buffer the cycle was started from +M.lnum = nil -- 0-indexed source line +M.col = nil -- 0-indexed source column + +local group = vim.api.nvim_create_augroup("HoverCycle", { clear = true }) + +local SEVERITY = { "ERROR", "WARN", "INFO", "HINT" } + +local function float_open() + return M.win ~= nil and vim.api.nvim_win_is_valid(M.win) +end + +local function close_float() + if float_open() then + vim.api.nvim_win_close(M.win, true) + end + M.win = nil + M.buf = nil +end + +-- Open (or replace) the float with the given lines. +local function open_float(lines, ft) + close_float() + + local buf = vim.api.nvim_create_buf(false, true) + vim.api.nvim_buf_set_lines(buf, 0, -1, false, lines) + vim.bo[buf].bufhidden = "wipe" + + local width = 1 + for _, l in ipairs(lines) do + width = math.max(width, vim.fn.strdisplaywidth(l)) + end + width = math.min(width, math.floor(vim.o.columns * 0.8)) + local height = math.min(math.max(#lines, 1), math.floor(vim.o.lines * 0.6)) + + local win = vim.api.nvim_open_win(buf, false, { + relative = "cursor", + row = 1, + col = 0, + width = width, + height = height, + style = "minimal", + border = "rounded", + focusable = true, + }) + vim.wo[win].wrap = true + vim.wo[win].conceallevel = 2 + vim.wo[win].concealcursor = "nc" + + M.win = win + M.buf = buf + + -- Set filetype only now that the buffer is shown in a window, so the + -- FileType autocmds (treesitter + render-markdown) render into it. + if ft then vim.bo[buf].filetype = ft end + vim.bo[buf].modifiable = false + + -- Close when the cursor moves in the source buffer. Switching INTO the + -- float (via ) does not move the source cursor, so it survives that. + vim.api.nvim_clear_autocmds({ group = group }) + vim.api.nvim_create_autocmd({ "CursorMoved", "CursorMovedI", "InsertEnter" }, { + group = group, + buffer = M.bufnr, + callback = function() close_float() end, + }) + + vim.keymap.set("n", "q", close_float, { buffer = buf, nowait = true, silent = true }) +end + +local function with_header(header, body, ft) + local lines = {} + if header then + lines[1] = header + lines[2] = "" + end + vim.list_extend(lines, body) + open_float(lines, ft) +end + +local function show_lsp_hover(idx, n) + local header = n > 1 and ("Hover [%d/%d]"):format(idx, n) or nil + + local clients = vim.lsp.get_clients({ bufnr = M.bufnr, method = "textDocument/hover" }) + if #clients == 0 then + with_header(header, { "No LSP hover available" }) + return + end + + local params = vim.lsp.util.make_position_params(0, clients[1].offset_encoding) + vim.lsp.buf_request(M.bufnr, "textDocument/hover", params, function(_, result) + if M.idx ~= idx or M.bufnr ~= vim.api.nvim_get_current_buf() then + return -- cycle moved on before the response arrived + end + local body = {} + if result and result.contents then + body = vim.lsp.util.convert_input_to_markdown_lines(result.contents) + end + if vim.tbl_isempty(body) then body = { "No hover information" } end + + with_header(header, body, "markdown") + end) +end + +-- True if the diagnostic's range covers the (lnum, col) cursor position. +local function under_cursor(d, lnum, col) + local end_lnum = d.end_lnum or d.lnum + local end_col = d.end_col or (d.col + 1) + if lnum < d.lnum or lnum > end_lnum then return false end + if lnum == d.lnum and col < d.col then return false end + if lnum == end_lnum and col >= math.max(end_col, d.col + 1) then return false end + return true +end + +-- Diagnostics whose range is under the cursor, sorted by start column. +local function cursor_diags() + local diags = vim.tbl_filter( + function(d) return under_cursor(d, M.lnum, M.col) end, + vim.diagnostic.get(M.bufnr, { lnum = M.lnum })) + table.sort(diags, function(a, b) return a.col < b.col end) + return diags +end + +local function build_items() + local items = {} + local diags = cursor_diags() + + for _, d in ipairs(diags) do + items[#items + 1] = function(idx, n) + local sev = SEVERITY[d.severity] or "DIAG" + local header = n > 1 and ("Diagnostic [%d/%d] %s"):format(idx, n, sev) + or ("Diagnostic %s"):format(sev) + with_header(header, vim.split(d.message, "\n", { plain = true })) + end + end + + items[#items + 1] = show_lsp_hover + return items +end + +local function show() + M.items[M.idx](M.idx, #M.items) +end + +-- Mapped to K. +function M.hover() + if float_open() then + M.idx = M.idx % #M.items + 1 + show() + return + end + + M.bufnr = vim.api.nvim_get_current_buf() + local pos = vim.api.nvim_win_get_cursor(0) + M.lnum = pos[1] - 1 + M.col = pos[2] + + -- No diagnostic under cursor -> a single LSP-hover item (no cycle header), + -- still rendered in our managed float so can enter it. + M.items = build_items() + M.idx = 1 + show() +end + +-- Mapped to : focus the float if open, otherwise default . +function M.enter() + if float_open() then + vim.api.nvim_set_current_win(M.win) + else + vim.api.nvim_feedkeys( + vim.api.nvim_replace_termcodes("", true, false, true), "n", false) + end +end + +return M diff --git a/lua/keymaps.lua b/lua/keymaps.lua index 15b9a4c..d16ae5c 100644 --- a/lua/keymaps.lua +++ b/lua/keymaps.lua @@ -10,15 +10,10 @@ for _, mode in ipairs({ "n", "v" }) do map(mode, "k", "gk", silent) end --- Hover: diagnostics on current line if any, else LSP hover -map("n", "K", function() - local line = vim.api.nvim_win_get_cursor(0)[1] - 1 - if #vim.diagnostic.get(0, { lnum = line }) > 0 then - vim.diagnostic.open_float() - else - vim.lsp.buf.hover() - end -end, { desc = "Hover (diagnostic or LSP)" }) +-- Hover: cycle diagnostics on the current line, then LSP hover. enters. +local hover = require("hover") +map("n", "K", hover.hover, { desc = "Hover (cycle diagnostics / LSP)" }) +map("n", "", hover.enter, { desc = "Enter hover window", silent = true }) -- LSP map("n", "gf",