--- Scrollbar module for pagination indicator local M = {} -- Internal state local scrollbar_state = { win = nil, buf = nil, ever_shown = false, } local ns_id = vim.api.nvim_create_namespace('fff_scrollbar') --- Render the scrollbar to show current page position --- Creates the window lazily if needed --- @param layout table Layout info with list_col, list_row, list_width, list_height, show_scrollbar --- @param config table Config with hl (highlight groups) --- @param list_win number List window handle --- @param pagination table Pagination state with page_index, page_size, total_matched --- @param prompt_position string|nil Prompt position ('top' or 'bottom', defaults to 'bottom') function M.render(layout, config, list_win, pagination, prompt_position) if layout.show_scrollbar == false then return end -- this is the most often path, we don't want to show scrollbar if use doesn't scrolling if not scrollbar_state.ever_shown and pagination.page_index == 0 then return end prompt_position = prompt_position or 'bottom' local total_pages = pagination.page_size > 0 and math.ceil(pagination.total_matched / pagination.page_size) or 1 local has_multiple_pages = total_pages > 1 local scrollbar_exists = scrollbar_state.win and vim.api.nvim_win_is_valid(scrollbar_state.win) -- If only one page, hide existing scrollbar and return if not has_multiple_pages then if scrollbar_exists then pcall(vim.api.nvim_win_hide, scrollbar_state.win) end return end -- rendering in a separate buffer to overflow the border if not scrollbar_exists then scrollbar_state.buf = vim.api.nvim_create_buf(false, true) vim.api.nvim_set_option_value('bufhidden', 'wipe', { buf = scrollbar_state.buf }) scrollbar_state.win = vim.api.nvim_open_win(scrollbar_state.buf, false, { relative = 'editor', width = 1, height = layout.list_height, col = layout.list_col + layout.list_width + 1, row = layout.list_row + 1, border = 'none', style = 'minimal', focusable = false, -- Float above the list/preview borders so the thumb is visible. Must -- be higher than the list (52) and preview (51) zindex; 200 leaves -- room for transient overlays (combo separator at 250 still wins). zindex = 200, }) local scrollbar_hl = string.format('Normal:%s', config.hl.border) vim.api.nvim_set_option_value('winhighlight', scrollbar_hl, { win = scrollbar_state.win }) scrollbar_state.ever_shown = true end if not scrollbar_state.buf or not vim.api.nvim_buf_is_valid(scrollbar_state.buf) then return end pcall(vim.api.nvim_win_set_config, scrollbar_state.win, { hide = false }) local win_height = vim.api.nvim_win_get_height(list_win) local thumb_size = math.max(1, math.floor(win_height / total_pages)) local scrollbar_range = win_height - thumb_size -- inverse the scrollbar when the position is at the bottom local thumb_start if prompt_position == 'bottom' then thumb_start = math.floor(((total_pages - 1 - pagination.page_index) / math.max(1, total_pages - 1)) * scrollbar_range) else thumb_start = math.floor((pagination.page_index / math.max(1, total_pages - 1)) * scrollbar_range) end local lines = {} for i = 1, win_height do if i >= thumb_start + 1 and i < thumb_start + thumb_size + 1 then table.insert(lines, '▊') -- Thick block for thumb else table.insert(lines, '│') -- Thin line for track end end pcall(vim.api.nvim_set_option_value, 'modifiable', true, { buf = scrollbar_state.buf }) pcall(vim.api.nvim_buf_set_lines, scrollbar_state.buf, 0, -1, false, lines) pcall(vim.api.nvim_set_option_value, 'modifiable', false, { buf = scrollbar_state.buf }) pcall(vim.api.nvim_buf_clear_namespace, scrollbar_state.buf, ns_id, 0, -1) if thumb_size > 0 then pcall(vim.api.nvim_buf_set_extmark, scrollbar_state.buf, ns_id, thumb_start, 0, { end_row = thumb_start + thumb_size, end_col = 0, hl_group = config.hl.scrollbar, hl_eol = true, }) end end function M.cleanup() if scrollbar_state.win and vim.api.nvim_win_is_valid(scrollbar_state.win) then pcall(vim.api.nvim_win_close, scrollbar_state.win, true) end if scrollbar_state.buf and vim.api.nvim_buf_is_valid(scrollbar_state.buf) then pcall(vim.api.nvim_buf_delete, scrollbar_state.buf, { force = true }) end scrollbar_state.win = nil scrollbar_state.buf = nil scrollbar_state.ever_shown = false end return M