-- 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