--- List Renderer, renders any type of list results produced by fff local M = {} --- @class ListRenderContext --- @field config FffConfig User configuration --- @field items table[] Array of data items to render --- @field cursor number Current cursor position (1-based index into items) --- @field win_height number Window height in lines --- @field win_width number Window width in columns --- @field max_path_width number Actual text area width (excluding signcolumn) --- @field debug_enabled boolean Whether debug mode shows scores --- @field prompt_position string 'top' or 'bottom' --- @field has_combo boolean Whether combo boost is active --- @field display_start number Start index for displayed items (1) --- @field display_end number End index for displayed items (#items) --- @field iter_start number Iteration start --- @field iter_end number Iteration end --- @field iter_step number Iteration step (1 or -1) --- @field renderer table|nil Custom renderer with render_line/apply_highlights --- @field query string Current search query --- @field selected_files table Selected file paths set --- @field mode string|nil Current mode (nil or 'grep') --- @field format_file_display function Helper for formatting file display --- @field suggestion_source string|nil Active cross-mode suggestion source ('grep' or 'files') --- @class ItemLineMapping --- @field first number First buffer line (1-based) this item occupies --- @field last number Last buffer line (1-based) — the selectable content line --- @field virtual_count number Number of virtual (header) lines before the content line --- @class ListRenderResult --- @field lines string[] All buffer lines (including virtual rows and padding) --- @field item_to_lines table Maps item index -> line range --- @field padding_offset number Number of empty lines prepended for bottom prompt --- @field total_content_lines number Lines before padding was applied --- @field separator_line number|nil Buffer line (1-based) where separator sits, nil if none --- @class ListSeparator --- @field idx number 1-based item index — separator visually sits between this item and the previous one in the iteration order --- @field text string Label text rendered inside the separator --- @field text_hl string|nil Highlight group for label --- @field border_hl string|nil Highlight group for the dashes --- Generate all display lines from items from the renderer ctx --- @param ctx table --- @return string[] lines Array of line strings --- @return table item_to_lines --- @return number|nil separator_line 1-based buffer line of the separator, nil if none local function generate_item_lines(ctx) local lines = {} local item_to_lines = {} local separator_line = nil -- Cross-mode suggestion header: rendered above items visually. -- For top prompt that means before items; for bottom prompt after items -- (because bottom prompt iterates in reverse). local suggestion_header_lines = {} local has_suggestion_header = ctx.suggestion_source ~= nil and #ctx.items > 0 if has_suggestion_header then table.insert(suggestion_header_lines, '') if ctx.mode == 'grep' and ctx.suggestion_source == 'files' then -- Grep mode with no results — hint about mode cycling to fuzzy search local config = require('fff.conf').get() local keybind = config.keymaps.cycle_grep_modes if type(keybind) == 'table' then keybind = keybind[1] or '' end table.insert(suggestion_header_lines, ' No results, try ' .. keybind .. ' to fuzzy search') else local mode_label = ctx.suggestion_source == 'grep' and 'content matches' or 'file name matches' table.insert(suggestion_header_lines, ' No results found. Suggested ' .. mode_label .. ':') end table.insert(suggestion_header_lines, '') end -- For top prompt: suggestion header goes before items if has_suggestion_header and ctx.prompt_position ~= 'bottom' then for _, hline in ipairs(suggestion_header_lines) do table.insert(lines, hline) end end local renderer = ctx.renderer if not renderer then renderer = require('fff.picker_ui.file_renderer') end -- Insert a gap on the side of the anchor away from the prompt. -- Bottom prompt: gap BEFORE anchor in iter — anchor renders last with -- reverse iter, so gap lands just above anchor visually. -- Top prompt: gap AFTER anchor in iter — anchor renders first with -- forward iter, so gap lands just below anchor visually. -- Either way: anchor stays adjacent to the prompt, separator on far side. local gap_before_anchor = ctx.prompt_position == 'bottom' for i = ctx.iter_start, ctx.iter_end, ctx.iter_step do if ctx.separator and ctx.separator.idx == i and gap_before_anchor then table.insert(lines, '') separator_line = #lines end local item = ctx.items[i] local item_start_line = #lines + 1 local item_lines = renderer.render_line(item, ctx, i) vim.list_extend(lines, item_lines) local item_end_line = #lines local virtual_count = item_end_line - item_start_line item_to_lines[i] = { first = item_start_line, last = item_end_line, virtual_count = virtual_count, } if ctx.separator and ctx.separator.idx == i and not gap_before_anchor then table.insert(lines, '') separator_line = #lines end end -- For bottom prompt: suggestion header goes after items (appears above visually) if has_suggestion_header and ctx.prompt_position == 'bottom' then for _, hline in ipairs(suggestion_header_lines) do table.insert(lines, hline) end end return lines, item_to_lines, separator_line end --- Apply bottom padding: prepend empty lines so content sits at the bottom. --- Adjusts all line indices in item_to_lines accordingly. --- @param lines string[] Lines array (mutated) --- @param item_to_lines table Mapping (mutated) --- @param ctx table --- @param separator_line number|nil Pre-padding separator line (mutated via return value) --- @return number padding_offset Number of empty lines prepended --- @return number|nil shifted_separator_line local function apply_bottom_padding(lines, item_to_lines, ctx, separator_line) if ctx.prompt_position ~= 'bottom' then return 0, separator_line end local total_content_lines = #lines local empty_lines_needed = math.max(0, ctx.win_height - total_content_lines) if empty_lines_needed > 0 then for _ = empty_lines_needed, 1, -1 do table.insert(lines, 1, string.rep(' ', ctx.win_width + 5)) end for i = ctx.display_start, ctx.display_end do if item_to_lines[i] then item_to_lines[i].first = item_to_lines[i].first + empty_lines_needed item_to_lines[i].last = item_to_lines[i].last + empty_lines_needed end end if separator_line then separator_line = separator_line + empty_lines_needed end end return empty_lines_needed, separator_line end --- Write lines to the buffer and position the cursor on the correct line. --- The cursor always targets the content line (last) of the current item, --- never a virtual header line. --- @param lines string[] --- @param item_to_lines table --- @param ctx table --- @param list_buf number Buffer handle --- @param list_win number Window handle --- @param ns_id number Namespace id local function update_buffer_and_cursor(lines, item_to_lines, ctx, list_buf, list_win, ns_id) -- Resolve cursor to a buffer line — always the content line (last), not virtual rows local cursor_line = 0 if #ctx.items > 0 and ctx.cursor >= 1 and ctx.cursor <= #ctx.items then local cursor_item = item_to_lines[ctx.cursor] if cursor_item then cursor_line = cursor_item.last end end vim.api.nvim_set_option_value('modifiable', true, { buf = list_buf }) vim.api.nvim_buf_set_lines(list_buf, 0, -1, false, lines) vim.api.nvim_set_option_value('modifiable', false, { buf = list_buf }) vim.api.nvim_buf_clear_namespace(list_buf, ns_id, 0, -1) if #ctx.items > 0 and cursor_line > 0 and cursor_line <= #lines then vim.api.nvim_win_set_cursor(list_win, { cursor_line, 0 }) end end --- Apply highlights for all items using the renderer's apply_highlights. --- For each item, we pass the content line (last) to the renderer. --- Renderers that emit virtual rows (grep_renderer) handle their own --- header highlights internally via the item._has_group_header flag. --- @param lines string[] --- @param item_to_lines table --- @param ctx table --- @param list_buf number --- @param ns_id number local function apply_all_highlights(lines, item_to_lines, ctx, list_buf, ns_id) local renderer = ctx.renderer if not renderer then renderer = require('fff.picker_ui.file_renderer') end for i = ctx.display_start, ctx.display_end do local item = ctx.items[i] local item_lines = item_to_lines[i] if item_lines then -- The content line is always the last line in the mapping local line_idx = item_lines.last local line_content = lines[line_idx] if line_content then ---@diagnostic disable-next-line: param-type-mismatch renderer.apply_highlights(item, ctx, i, list_buf, ns_id, line_idx, line_content) end end end end --- Render the full item list into the buffer. --- This is the main entry point — replaces the inline rendering in picker_ui. --- --- @param ctx table Render context built by picker_ui --- @param list_buf number List buffer handle --- @param list_win number List window handle --- @param ns_id number Highlight namespace --- @return table item_to_lines --- @return number|nil separator_line 1-based buffer line of the separator (post-padding), nil if none function M.render(ctx, list_buf, list_win, ns_id) local lines, item_to_lines, separator_line = generate_item_lines(ctx) local _, shifted_separator_line = apply_bottom_padding(lines, item_to_lines, ctx, separator_line) separator_line = shifted_separator_line update_buffer_and_cursor(lines, item_to_lines, ctx, list_buf, list_win, ns_id) if #ctx.items > 0 then apply_all_highlights(lines, item_to_lines, ctx, list_buf, ns_id) end -- Highlight the suggestion header lines (if present) if ctx.suggestion_source and #ctx.items > 0 then local suggestion_hl = ctx.config.hl.suggestion_header or 'WarningMsg' for i = 0, #lines - 1 do local line = lines[i + 1] if line and (line:match('^%s+No results found') or line:match('^%s+No results,')) then pcall( vim.api.nvim_buf_set_extmark, list_buf, ns_id, i, 0, { end_row = i + 1, end_col = 0, hl_group = suggestion_hl } ) end end end return item_to_lines, separator_line end return M