local icon = require 'std.icon' local table = require 'std.table' local position = require 'bugabinga.cmdline.position' local format = require 'bugabinga.cmdline.format' local render = require 'bugabinga.cmdline.render' local ns = vim.api.nvim_create_namespace 'bugabinga.cmdline.popupmenu' local state = { buffer = -1, window = -1, items = {}, selected = -1, grid = -1, above = false, format_type = 'cmdline', height = 0, top_idx = 0, } local on_hide local function kind_icon(kind) if not kind or kind == '' then return '' end return kind end local function format_item(item) local abbr = item[1] or '' local kind = item[2] or '' local menu = item[3] or '' local info = item[4] or '' local kind_str = kind_icon(kind) local parts = {} if kind_str ~= '' then table.insert(parts, kind_str .. ' ') end table.insert(parts, abbr) if menu ~= '' then table.insert(parts, ' ') table.insert(parts, menu) end return table.concat(parts) end local function max_width(items) local max = 0 for _, item in ipairs(items) do local text = format_item(item) max = math.max(max, #text) end return max end local function get_buf() if vim.api.nvim_buf_is_valid(state.buffer) then return state.buffer end state.buffer = vim.api.nvim_create_buf(false, true) vim.bo[state.buffer].buflisted = false vim.bo[state.buffer].filetype = 'bugabinga_popupmenu' return state.buffer end local function get_anchor(row, col, grid, popup_height) if grid == -1 then local cmdline_anchor = position.get_cmdline_anchor() if cmdline_anchor then local cmdline_row = cmdline_anchor.screenpos.row local screen_mid = vim.o.lines / 2 local above = cmdline_row > screen_mid state.above = above if above then return { row = cmdline_row - popup_height - 2, col = cmdline_anchor.screenpos.col + col, } else return { row = cmdline_row + 3, col = cmdline_anchor.screenpos.col + col, } end end end state.above = false return { row = row + 1, col = col, } end local function get_win(buf, anchor, width, height) local is_new = not vim.api.nvim_win_is_valid(state.window) if is_new then state.window = vim.api.nvim_open_win(buf, false, { relative = 'editor', width = width, height = height, row = anchor.row, col = anchor.col, style = 'minimal', border = vim.g.border_style, focusable = false, zindex = 201, }) end vim.wo[state.window].cursorline = true vim.wo[state.window].winbar = '' vim.wo[state.window].foldenable = false vim.wo[state.window].winhighlight = format.winhighlight(state.format_type, 'Pmenu') return state.window end local function apply_scrollbar(buf, height) local total = #state.items if total <= height then return end local selected = state.selected local top_idx = state.top_idx local thumb_row if selected < 0 then thumb_row = 0 else local relative = selected - top_idx thumb_row = math.max(0, math.min(height - 1, relative)) end if state.above then thumb_row = height - 1 - thumb_row end for i = 0, height - 1 do if i == thumb_row then vim.api.nvim_buf_set_extmark(buf, ns, i, 0, { virt_text = { { '┼', 'PmenuThumb' } }, virt_text_pos = 'right_align', }) else vim.api.nvim_buf_set_extmark(buf, ns, i, 0, { virt_text = { { '│', 'PmenuSbar' } }, virt_text_pos = 'right_align', }) end end end local function apply_highlights(buf, items, lines, prefix) for i = 0, #lines - 1 do local item_idx if state.above then item_idx = state.top_idx + #lines - 1 - i else item_idx = state.top_idx + i end local item = items[item_idx + 1] if not item then break end local col_offset = 0 local kind_str = kind_icon(item[2]) if kind_str ~= '' then vim.api.nvim_buf_set_extmark(buf, ns, i, 0, { end_col = #kind_str + 1, hl_group = 'PmenuKind', }) col_offset = #kind_str + 1 end local abbr = item[1] or '' if prefix ~= '' and abbr:sub(1, #prefix):lower() == prefix:lower() then vim.api.nvim_buf_set_extmark(buf, ns, i, col_offset, { end_col = col_offset + #prefix, hl_group = 'PmenuMatch', }) end end end local function on_show(items, selected, row, col, grid, prefix) if not items or #items == 0 then on_hide() return end state.items = items state.selected = selected state.grid = grid state.format_type = render.get_format_type() local width = math.min(max_width(items) + 2, vim.o.columns - 4) local pumheight = vim.o.pumheight local height = pumheight > 0 and math.min(#items, pumheight) or #items state.height = height state.top_idx = math.max(0, math.min(selected, #items - height)) local anchor = get_anchor(row, col, grid, height) local buf = get_buf() local win = get_win(buf, anchor, width, height) vim.api.nvim_win_set_config(win, { relative = 'editor', width = width, height = height, row = anchor.row, col = anchor.col, }) vim.api.nvim_buf_clear_namespace(buf, ns, 0, -1) local lines = vim.iter(items):map(function(item) return format_item(item) end):totable() if state.above then lines = vim.iter(lines):rev():totable() end vim.api.nvim_buf_set_lines(buf, 0, -1, false, lines) apply_highlights(buf, items, lines, prefix or '') apply_scrollbar(buf, height) if selected >= 0 and selected < #items then if state.above then local visible_start = #items - height if selected >= visible_start then local reversed_idx = #items - 1 - selected vim.api.nvim_win_set_cursor(win, { reversed_idx + 1, 0 }) end else vim.api.nvim_win_set_cursor(win, { selected + 1, 0 }) end end end local function on_select(selected) if not vim.api.nvim_win_is_valid(state.window) then return end local height = state.height local total = #state.items local old_top_idx = state.top_idx if height > 0 and total > 0 then if selected < state.top_idx then state.top_idx = math.max(0, selected) elseif selected >= state.top_idx + height then state.top_idx = math.min(selected, total - height) end end state.selected = selected if selected >= 0 and total > 0 then local cursor_row if state.above then cursor_row = (total - 1 - selected) - state.top_idx + 1 else cursor_row = selected - state.top_idx + 1 end cursor_row = math.max(1, math.min(height, cursor_row)) vim.api.nvim_win_set_cursor(state.window, { cursor_row, 0 }) end if state.buffer ~= -1 and state.height then vim.api.nvim_buf_clear_namespace(state.buffer, ns, 0, -1) if state.top_idx ~= old_top_idx then local visible = {} for i = state.top_idx, math.min(state.top_idx + height - 1, total - 1) do table.insert(visible, format_item(state.items[i + 1])) end if state.above then visible = vim.iter(visible):rev():totable() end vim.api.nvim_buf_set_lines(state.buffer, 0, -1, false, visible) end local lines = vim.iter(state.items):map(function(item) return format_item(item) end):totable() if state.above then lines = vim.iter(lines):rev():totable() end apply_highlights(state.buffer, state.items, lines, '') apply_scrollbar(state.buffer, state.height) end end on_hide = function() if vim.api.nvim_win_is_valid(state.window) then vim.api.nvim_win_hide(state.window) end if vim.api.nvim_buf_is_valid(state.buffer) then vim.api.nvim_buf_delete(state.buffer, { force = true }) end state.buffer = -1 state.items = {} state.selected = -1 state.grid = -1 state.above = false state.format_type = 'cmdline' state.height = 0 state.top_idx = 0 end return { on_show = on_show, on_select = on_select, on_hide = on_hide, }