Compare commits
3 Commits
main
...
fix/scroll
| Author | SHA1 | Date | |
|---|---|---|---|
| e9b4a42e07 | |||
| fb905ec4fe | |||
| 3b709f464f |
+1
-1
@@ -1,4 +1,4 @@
|
||||
*fff.nvim.txt* For Neovim >= 0.10.0 Last change: 2025 December 17
|
||||
*fff.nvim.txt* For Neovim >= 0.10.0 Last change: 2025 December 19
|
||||
|
||||
==============================================================================
|
||||
Table of Contents *fff.nvim-table-of-contents*
|
||||
|
||||
+35
-10
@@ -10,6 +10,8 @@ local overlay_state = {
|
||||
last_row = nil,
|
||||
last_col = nil,
|
||||
last_border_hl = nil,
|
||||
-- Track if combo was rendered in last call
|
||||
was_rendered = false,
|
||||
}
|
||||
|
||||
local LEFT_OVERLAY_CONTENT = '├────'
|
||||
@@ -161,6 +163,7 @@ local function clear_overlays_internal()
|
||||
overlay_state.last_row = nil
|
||||
overlay_state.last_col = nil
|
||||
overlay_state.last_border_hl = nil
|
||||
-- Note: we intentionally don't clear was_rendered here to track the transition
|
||||
end
|
||||
|
||||
function M.detect_and_prepare(items, file_picker, win_width, combo_boost_score_multiplier, disable_combo_display)
|
||||
@@ -172,6 +175,8 @@ function M.detect_and_prepare(items, file_picker, win_width, combo_boost_score_m
|
||||
return true, header_line, text_len, combo_item_index
|
||||
end
|
||||
|
||||
--- Render combo highlights and overlays
|
||||
--- @return boolean was_hidden True if combo was just hidden (was rendered before, not now)
|
||||
function M.render_highlights_and_overlays(
|
||||
combo_item_index,
|
||||
text_len,
|
||||
@@ -182,24 +187,44 @@ function M.render_highlights_and_overlays(
|
||||
item_to_lines,
|
||||
prompt_position
|
||||
)
|
||||
local was_rendered_before = overlay_state.was_rendered
|
||||
local is_rendering_now = false
|
||||
|
||||
if not combo_item_index then
|
||||
clear_overlays_internal()
|
||||
return
|
||||
else
|
||||
local combo_item_lines = item_to_lines[combo_item_index]
|
||||
if not combo_item_lines then
|
||||
clear_overlays_internal()
|
||||
else
|
||||
local combo_header_line_idx = combo_item_lines.first
|
||||
apply_header_highlights(list_buf, ns_id, combo_header_line_idx, text_len, border_hl)
|
||||
update_overlays(list_win, combo_header_line_idx, border_hl, prompt_position)
|
||||
is_rendering_now = true
|
||||
end
|
||||
end
|
||||
|
||||
local combo_item_lines = item_to_lines[combo_item_index]
|
||||
if not combo_item_lines then
|
||||
clear_overlays_internal()
|
||||
return
|
||||
end
|
||||
overlay_state.was_rendered = is_rendering_now
|
||||
|
||||
local combo_header_line_idx = combo_item_lines.first
|
||||
apply_header_highlights(list_buf, ns_id, combo_header_line_idx, text_len, border_hl)
|
||||
update_overlays(list_win, combo_header_line_idx, border_hl, prompt_position)
|
||||
-- Return true if combo was just hidden (transition from visible to hidden)
|
||||
return was_rendered_before and not is_rendering_now
|
||||
end
|
||||
|
||||
--- Get the combo header text for a given item
|
||||
--- @param combo_count number The combo multiplier count
|
||||
--- @param win_width number Window width for formatting
|
||||
--- @param disable_combo_display boolean Whether to show combo count
|
||||
--- @return string header_text The formatted header line
|
||||
--- @return number text_len Length of the header text (without padding)
|
||||
function M.get_combo_header_text(combo_count, win_width, disable_combo_display)
|
||||
return create_header_text(combo_count, win_width, disable_combo_display)
|
||||
end
|
||||
|
||||
function M.get_overlay_widths() return LEFT_OVERLAY_WIDTH, RIGHT_OVERLAY_WIDTH end
|
||||
|
||||
function M.cleanup() clear_overlays_internal() end
|
||||
function M.cleanup()
|
||||
clear_overlays_internal()
|
||||
overlay_state.was_rendered = false
|
||||
end
|
||||
|
||||
return M
|
||||
|
||||
@@ -0,0 +1,248 @@
|
||||
--- File Renderer
|
||||
--- Simple renderer for file items with 2 functions: render_line and apply_highlights
|
||||
local M = {}
|
||||
|
||||
--- Render Context passed to renderer functions
|
||||
--- @class RenderContext
|
||||
--- @field config table User configuration from conf.get()
|
||||
--- @field items table[] Array of file items being rendered
|
||||
--- @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 Maximum width for file paths
|
||||
--- @field debug_enabled boolean Whether debug mode is enabled (shows frecency scores)
|
||||
--- @field prompt_position string Prompt position: 'top' or 'bottom'
|
||||
--- @field has_combo boolean Whether combo boost is active
|
||||
--- @field combo_header_line string Formatted combo header line (if has_combo)
|
||||
--- @field combo_header_text_len number Length of combo header text (if has_combo)
|
||||
--- @field combo_item_index number Index of item with combo (usually 1)
|
||||
--- @field display_start number Start index for displayed items
|
||||
--- @field display_end number End index for displayed items
|
||||
--- @field iter_start number Iteration start (may differ from display_start for bottom prompt)
|
||||
--- @field iter_end number Iteration end (may differ from display_end for bottom prompt)
|
||||
--- @field iter_step number Iteration step (1 for top prompt, -1 for bottom prompt)
|
||||
--- @field format_file_display fun(item: table, max_width: number): string, string Helper function to format filename and dir path
|
||||
--- @field selected_files table<string, boolean> Map of selected file paths
|
||||
--- @field query string Current search query
|
||||
--- @field renderer table|nil Custom renderer (if provided via opts)
|
||||
|
||||
--- File Item structure from Rust
|
||||
--- @class FileItem
|
||||
--- @field path string Absolute file path
|
||||
--- @field relative_path string Relative file path from base directory
|
||||
--- @field name string File name
|
||||
--- @field extension string File extension
|
||||
--- @field size number File size in bytes
|
||||
--- @field modified number Last modified timestamp
|
||||
--- @field total_frecency_score number Total frecency score
|
||||
--- @field access_frecency_score number Access-based frecency score
|
||||
--- @field modification_frecency_score number Modification-based frecency score
|
||||
--- @field git_status number|nil Git status enum (if file is in git repo)
|
||||
|
||||
--- Renderer Interface:
|
||||
--- @field render_line fun(item: FileItem, ctx: RenderContext, item_idx: number): string[] Returns array of line strings
|
||||
--- @field apply_highlights fun(item: FileItem, ctx: RenderContext, item_idx: number, buf: number, ns_id: number, line_idx: number, line_content: string): nil Applies highlights to the rendered line
|
||||
|
||||
--- Render a file item line
|
||||
--- @param item FileItem File item from Rust
|
||||
--- @param ctx RenderContext Render context with all state
|
||||
--- @param item_idx number Item index (1-based)
|
||||
--- @return string[] Array of line strings (1 or 2 lines if combo)
|
||||
function M.render_line(item, ctx, item_idx)
|
||||
local icons = require('fff.file_picker.icons')
|
||||
local lines = {}
|
||||
|
||||
-- Check if this should have combo header (first item with combo boost)
|
||||
local has_combo = item_idx == 1 and ctx.has_combo and ctx.combo_header_line
|
||||
|
||||
if has_combo then table.insert(lines, ctx.combo_header_line) end
|
||||
|
||||
-- Get icon
|
||||
local icon, icon_hl_group = icons.get_icon(item.name, item.extension, false)
|
||||
|
||||
-- Build frecency indicator (debug mode only)
|
||||
local frecency = ''
|
||||
if ctx.debug_enabled then
|
||||
local total = item.total_frecency_score or 0
|
||||
local access = item.access_frecency_score or 0
|
||||
local mod = item.modification_frecency_score or 0
|
||||
|
||||
if total > 0 then
|
||||
local indicator = ''
|
||||
if mod >= 6 then
|
||||
indicator = '🔥'
|
||||
elseif access >= 4 then
|
||||
indicator = '⭐'
|
||||
elseif total >= 3 then
|
||||
indicator = '✨'
|
||||
elseif total >= 1 then
|
||||
indicator = '•'
|
||||
end
|
||||
frecency = string.format(' %s%d', indicator, total)
|
||||
end
|
||||
end
|
||||
|
||||
-- Format filename and path
|
||||
local icon_width = icon and (vim.fn.strdisplaywidth(icon) + 1) or 0
|
||||
local available_width = math.max(ctx.max_path_width - icon_width - #frecency, 40)
|
||||
local filename, dir_path = ctx.format_file_display(item, available_width)
|
||||
|
||||
-- Build line
|
||||
local line = icon and string.format('%s %s %s%s', icon, filename, dir_path, frecency)
|
||||
or string.format('%s %s%s', filename, dir_path, frecency)
|
||||
|
||||
local padding = math.max(0, ctx.win_width - vim.fn.strdisplaywidth(line) + 5)
|
||||
table.insert(lines, line .. string.rep(' ', padding))
|
||||
|
||||
return lines
|
||||
end
|
||||
|
||||
--- Apply highlights to a rendered line
|
||||
--- @param item FileItem File item from Rust
|
||||
--- @param ctx RenderContext Render context with all state
|
||||
--- @param item_idx number Item index (1-based)
|
||||
--- @param buf number Buffer handle
|
||||
--- @param ns_id number Namespace ID
|
||||
--- @param line_idx number 1-based line index in buffer
|
||||
--- @param line_content string The actual line content
|
||||
function M.apply_highlights(item, ctx, item_idx, buf, ns_id, line_idx, line_content)
|
||||
local icons = require('fff.file_picker.icons')
|
||||
local git_utils = require('fff.git_utils')
|
||||
local file_picker = require('fff.file_picker')
|
||||
|
||||
local is_cursor = (ctx.cursor == item_idx)
|
||||
local score = file_picker.get_file_score(item_idx)
|
||||
local is_current_file = score and score.current_file_penalty and score.current_file_penalty < 0
|
||||
|
||||
-- Get icon and paths
|
||||
local icon, icon_hl_group = icons.get_icon(item.name, item.extension, false)
|
||||
local icon_width = icon and (vim.fn.strdisplaywidth(icon) + 1) or 0
|
||||
local available_width = math.max(ctx.max_path_width - icon_width, 40)
|
||||
local filename, dir_path = ctx.format_file_display(item, available_width)
|
||||
|
||||
-- 1. Cursor highlight
|
||||
if is_cursor then
|
||||
vim.api.nvim_buf_set_extmark(buf, ns_id, line_idx - 1, 0, {
|
||||
end_col = 0,
|
||||
end_row = line_idx,
|
||||
hl_group = ctx.config.hl.active_file,
|
||||
hl_eol = true,
|
||||
priority = 100,
|
||||
})
|
||||
end
|
||||
|
||||
-- 2. Icon
|
||||
if icon and icon_hl_group and vim.fn.strdisplaywidth(icon) > 0 then
|
||||
local icon_hl = is_current_file and 'Comment' or icon_hl_group
|
||||
vim.api.nvim_buf_add_highlight(buf, ns_id, icon_hl, line_idx - 1, 0, vim.fn.strdisplaywidth(icon))
|
||||
end
|
||||
|
||||
-- 3. Git text color (filename)
|
||||
if ctx.config.git and ctx.config.git.status_text_color and icon and #filename > 0 then
|
||||
local git_text_hl = item.git_status and git_utils.get_text_highlight(item.git_status) or nil
|
||||
if git_text_hl and git_text_hl ~= '' and not is_current_file then
|
||||
local filename_start = #icon + 1
|
||||
vim.api.nvim_buf_add_highlight(buf, ns_id, git_text_hl, line_idx - 1, filename_start, filename_start + #filename)
|
||||
end
|
||||
end
|
||||
|
||||
-- 4. Frecency indicator
|
||||
if ctx.debug_enabled then
|
||||
local start_pos, end_pos = line_content:find('[⭐🔥✨•]%d+')
|
||||
if start_pos then
|
||||
vim.api.nvim_buf_add_highlight(buf, ns_id, ctx.config.hl.frecency, line_idx - 1, start_pos - 1, end_pos)
|
||||
end
|
||||
end
|
||||
|
||||
-- 5. Directory path (dimmed)
|
||||
if #filename > 0 and #dir_path > 0 then
|
||||
local prefix_len = #filename + 1 -- filename bytes + space
|
||||
if icon then
|
||||
prefix_len = prefix_len + #icon + 1 -- if icon add icon bytes + space
|
||||
end
|
||||
vim.api.nvim_buf_add_highlight(
|
||||
buf,
|
||||
ns_id,
|
||||
ctx.config.hl.directory_path or 'Comment',
|
||||
line_idx - 1,
|
||||
prefix_len,
|
||||
prefix_len + #dir_path
|
||||
)
|
||||
end
|
||||
|
||||
-- 6. Current file
|
||||
if is_current_file then
|
||||
if not is_cursor then vim.api.nvim_buf_add_highlight(buf, ns_id, 'Comment', line_idx - 1, 0, -1) end
|
||||
local virt_text_hl = is_cursor and ctx.config.hl.active_file or 'Comment'
|
||||
vim.api.nvim_buf_set_extmark(buf, ns_id, line_idx - 1, 0, {
|
||||
virt_text = { { ' (current)', virt_text_hl } },
|
||||
virt_text_pos = 'right_align',
|
||||
})
|
||||
end
|
||||
|
||||
-- 7. Git sign
|
||||
if item.git_status and git_utils.should_show_border(item.git_status) then
|
||||
local border_char = git_utils.get_border_char(item.git_status)
|
||||
local border_hl
|
||||
|
||||
if is_cursor then
|
||||
local base_hl = git_utils.get_border_highlight(item.git_status)
|
||||
if base_hl and base_hl ~= '' then
|
||||
local border_fg = vim.fn.synIDattr(vim.fn.synIDtrans(vim.fn.hlID(base_hl)), 'fg')
|
||||
local cursor_bg = vim.fn.synIDattr(vim.fn.synIDtrans(vim.fn.hlID(ctx.config.hl.active_file)), 'bg')
|
||||
local temp_hl_name = 'FFFGitBorderSelected_' .. item_idx
|
||||
if border_fg ~= '' and cursor_bg ~= '' then
|
||||
vim.api.nvim_set_hl(0, temp_hl_name, { fg = border_fg, bg = cursor_bg })
|
||||
border_hl = temp_hl_name
|
||||
else
|
||||
border_hl = git_utils.get_border_highlight_selected(item.git_status)
|
||||
end
|
||||
else
|
||||
border_hl = ctx.config.hl.active_file
|
||||
end
|
||||
else
|
||||
border_hl = git_utils.get_border_highlight(item.git_status)
|
||||
end
|
||||
|
||||
if border_hl and border_hl ~= '' then
|
||||
vim.api.nvim_buf_set_extmark(buf, ns_id, line_idx - 1, 0, {
|
||||
sign_text = border_char,
|
||||
sign_hl_group = border_hl,
|
||||
priority = 1000,
|
||||
})
|
||||
end
|
||||
elseif is_cursor then
|
||||
vim.api.nvim_buf_set_extmark(buf, ns_id, line_idx - 1, 0, {
|
||||
sign_text = ' ',
|
||||
sign_hl_group = ctx.config.hl.active_file,
|
||||
priority = 1000,
|
||||
})
|
||||
end
|
||||
|
||||
-- 8. Selection
|
||||
if ctx.selected_files and ctx.selected_files[item.path] then
|
||||
local selection_hl = is_cursor and ctx.config.hl.selected_active or ctx.config.hl.selected
|
||||
vim.api.nvim_buf_set_extmark(buf, ns_id, line_idx - 1, 0, {
|
||||
sign_text = '▊',
|
||||
sign_hl_group = selection_hl,
|
||||
priority = 1001,
|
||||
})
|
||||
end
|
||||
|
||||
-- 9. Query match
|
||||
if ctx.query and ctx.query ~= '' then
|
||||
local match_start, match_end = string.find(line_content, ctx.query, 1)
|
||||
if match_start and match_end then
|
||||
vim.api.nvim_buf_add_highlight(
|
||||
buf,
|
||||
ns_id,
|
||||
ctx.config.hl.matched or 'IncSearch',
|
||||
line_idx - 1,
|
||||
match_start - 1,
|
||||
match_end
|
||||
)
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
return M
|
||||
+3
-2
@@ -10,10 +10,11 @@ M.state = { initialized = false }
|
||||
function M.setup(config) vim.g.fff = config end
|
||||
|
||||
--- Find files in current directory
|
||||
function M.find_files()
|
||||
--- @param opts? table Optional configuration {renderer = custom_renderer}
|
||||
function M.find_files(opts)
|
||||
local picker_ok, picker_ui = pcall(require, 'fff.picker_ui')
|
||||
if picker_ok then
|
||||
picker_ui.open()
|
||||
picker_ui.open(opts)
|
||||
else
|
||||
vim.notify('Failed to load picker UI: ' .. picker_ui, vim.log.levels.ERROR)
|
||||
end
|
||||
|
||||
+169
-281
@@ -3,8 +3,6 @@ local M = {}
|
||||
local conf = require('fff.conf')
|
||||
local file_picker = require('fff.file_picker')
|
||||
local preview = require('fff.file_picker.preview')
|
||||
local icons = require('fff.file_picker.icons')
|
||||
local git_utils = require('fff.git_utils')
|
||||
local utils = require('fff.utils')
|
||||
local location_utils = require('fff.location_utils')
|
||||
local combo_renderer = require('fff.combo_renderer')
|
||||
@@ -274,6 +272,9 @@ M.state = {
|
||||
|
||||
config = nil,
|
||||
|
||||
-- Custom renderer (optional, defaults to file_renderer if not provided)
|
||||
renderer = nil,
|
||||
|
||||
ns_id = nil,
|
||||
|
||||
last_status_info = nil,
|
||||
@@ -1038,53 +1039,41 @@ local function scroll_to_bottom()
|
||||
end)
|
||||
end
|
||||
|
||||
function M.render_list()
|
||||
if not M.state.active then return end
|
||||
|
||||
--- Build rendering context with all necessary data
|
||||
--- @return table Context object with items, config, dimensions, combo info, etc.
|
||||
local function build_render_context()
|
||||
local config = conf.get()
|
||||
local items = M.state.filtered_items
|
||||
local max_path_width = config.ui and config.ui.max_path_width or 80
|
||||
local debug_enabled = config and config.debug and config.debug.show_scores
|
||||
local win_height = vim.api.nvim_win_get_height(M.state.list_win)
|
||||
local win_width = vim.api.nvim_win_get_width(M.state.list_win)
|
||||
local empty_lines_needed = 0
|
||||
|
||||
local combo_boost_score_multiplier = config.history and config.history.combo_boost_multiplier or 100
|
||||
local has_combo, combo_header_line, combo_header_text_len, combo_item_index = combo_renderer.detect_and_prepare(
|
||||
items,
|
||||
file_picker,
|
||||
win_width,
|
||||
combo_boost_score_multiplier,
|
||||
-- disable rendering of (xn combo) if cycling through history or user wants to always show the last match
|
||||
M.state.next_search_force_combo_boost or config.history.min_combo_count == 0
|
||||
)
|
||||
M.state.next_search_force_combo_boost = false -- effectively reset if set by the history recall
|
||||
|
||||
-- If combo was detected but we've navigated too far, hide it
|
||||
if has_combo and not M.state.combo_visible then
|
||||
has_combo = false
|
||||
combo_item_index = nil
|
||||
end
|
||||
|
||||
local prompt_position = get_prompt_position()
|
||||
|
||||
-- All items in M.state.items should be displayed (already paginated)
|
||||
local display_start = 1
|
||||
local display_end = #items
|
||||
|
||||
-- Simple cursor validation
|
||||
-- Cursor validation
|
||||
if M.state.cursor < 1 then
|
||||
M.state.cursor = 1
|
||||
elseif M.state.cursor > #items then
|
||||
M.state.cursor = #items
|
||||
end
|
||||
|
||||
local padded_lines = {}
|
||||
local icon_data = {}
|
||||
local path_data = {}
|
||||
local item_to_lines = {} -- Maps item index to its line indices {first_line, last_line}
|
||||
-- Combo detection
|
||||
local combo_boost_score_multiplier = config.history and config.history.combo_boost_multiplier or 100
|
||||
local has_combo, combo_header_line, combo_header_text_len, combo_item_index = combo_renderer.detect_and_prepare(
|
||||
items,
|
||||
file_picker,
|
||||
win_width,
|
||||
combo_boost_score_multiplier,
|
||||
M.state.next_search_force_combo_boost or config.history.min_combo_count == 0
|
||||
)
|
||||
M.state.next_search_force_combo_boost = false
|
||||
|
||||
-- For bottom prompt, iterate in reverse order to render best results at bottom
|
||||
if has_combo and not M.state.combo_visible then
|
||||
has_combo = false
|
||||
combo_item_index = nil
|
||||
end
|
||||
|
||||
-- Determine iteration order
|
||||
local display_start = 1
|
||||
local display_end = #items
|
||||
local iter_start, iter_end, iter_step
|
||||
if prompt_position == 'bottom' then
|
||||
iter_start, iter_end, iter_step = display_end, display_start, -1
|
||||
@@ -1092,289 +1081,187 @@ function M.render_list()
|
||||
iter_start, iter_end, iter_step = display_start, display_end, 1
|
||||
end
|
||||
|
||||
for i = iter_start, iter_end, iter_step do
|
||||
local item = items[i]
|
||||
local item_start_line = #padded_lines + 1
|
||||
return {
|
||||
config = config,
|
||||
items = items,
|
||||
cursor = M.state.cursor,
|
||||
win_height = win_height,
|
||||
win_width = win_width,
|
||||
max_path_width = config.ui and config.ui.max_path_width or 80,
|
||||
debug_enabled = config and config.debug and config.debug.show_scores,
|
||||
prompt_position = prompt_position,
|
||||
has_combo = has_combo,
|
||||
combo_header_line = combo_header_line,
|
||||
combo_header_text_len = combo_header_text_len,
|
||||
combo_item_index = combo_item_index,
|
||||
display_start = display_start,
|
||||
display_end = display_end,
|
||||
iter_start = iter_start,
|
||||
iter_end = iter_end,
|
||||
iter_step = iter_step,
|
||||
renderer = M.state.renderer, -- Custom renderer (if provided)
|
||||
}
|
||||
end
|
||||
|
||||
-- For combo items, insert header first as a padded line
|
||||
if has_combo and combo_item_index and i == combo_item_index then table.insert(padded_lines, combo_header_line) end
|
||||
--- Generate all display lines
|
||||
--- Uses renderer.render_line for each item
|
||||
--- @param ctx table Render context
|
||||
--- @return table lines Array of padded line strings
|
||||
--- @return table item_to_lines Mapping of item index to {first, last} line indices
|
||||
local function generate_item_lines(ctx)
|
||||
local lines = {}
|
||||
local item_to_lines = {}
|
||||
|
||||
local icon, icon_hl_group = icons.get_icon(item.name, item.extension, false)
|
||||
icon_data[i] = { icon, icon_hl_group }
|
||||
-- Add format_file_display to context for renderers
|
||||
ctx.format_file_display = format_file_display
|
||||
|
||||
local frecency = ''
|
||||
if debug_enabled then
|
||||
local total_frecency = (item.total_frecency_score or 0)
|
||||
local access_frecency = (item.access_frecency_score or 0)
|
||||
local mod_frecency = (item.modification_frecency_score or 0)
|
||||
-- Use custom renderer if provided, otherwise use default file_renderer
|
||||
local renderer = ctx.renderer
|
||||
if not renderer then renderer = require('fff.file_renderer') end
|
||||
|
||||
if total_frecency > 0 then
|
||||
local indicator = ''
|
||||
if mod_frecency >= 6 then
|
||||
indicator = '🔥'
|
||||
elseif access_frecency >= 4 then
|
||||
indicator = '⭐'
|
||||
elseif total_frecency >= 3 then
|
||||
indicator = '✨'
|
||||
elseif total_frecency >= 1 then
|
||||
indicator = '•'
|
||||
end
|
||||
frecency = string.format(' %s%d', indicator, total_frecency)
|
||||
end
|
||||
for i = ctx.iter_start, ctx.iter_end, ctx.iter_step do
|
||||
local item = ctx.items[i]
|
||||
local item_start_line = #lines + 1
|
||||
|
||||
-- Render item lines using renderer.render_line
|
||||
local item_lines = renderer.render_line(item, ctx, i)
|
||||
|
||||
-- Add rendered lines
|
||||
for _, line in ipairs(item_lines) do
|
||||
table.insert(lines, line)
|
||||
end
|
||||
|
||||
local icon_width = icon and (vim.fn.strdisplaywidth(icon) + 1) or 0
|
||||
local available_width = math.max(max_path_width - icon_width - #frecency, 40)
|
||||
|
||||
local filename, dir_path = format_file_display(item, available_width)
|
||||
path_data[i] = { filename, dir_path }
|
||||
|
||||
local line = icon and string.format('%s %s %s%s', icon, filename, dir_path, frecency)
|
||||
or string.format('%s %s%s', filename, dir_path, frecency)
|
||||
|
||||
local line_len = vim.fn.strdisplaywidth(line)
|
||||
local padding = math.max(0, win_width - line_len + 5)
|
||||
table.insert(padded_lines, line .. string.rep(' ', padding))
|
||||
|
||||
-- Record line range for this item
|
||||
local item_end_line = #padded_lines
|
||||
local item_end_line = #lines
|
||||
item_to_lines[i] = {
|
||||
first = item_start_line,
|
||||
last = item_end_line,
|
||||
}
|
||||
end
|
||||
|
||||
-- Handle bottom positioning: add empty lines at the top
|
||||
local empty_line_offset = 0
|
||||
if prompt_position == 'bottom' then
|
||||
local total_content_lines = #padded_lines
|
||||
empty_lines_needed = math.max(0, win_height - total_content_lines)
|
||||
return lines, item_to_lines
|
||||
end
|
||||
|
||||
if empty_lines_needed > 0 then
|
||||
-- Insert empty lines at the beginning
|
||||
for i = empty_lines_needed, 1, -1 do
|
||||
table.insert(padded_lines, 1, string.rep(' ', win_width + 5))
|
||||
end
|
||||
empty_line_offset = empty_lines_needed
|
||||
--- Apply bottom padding for bottom prompt position
|
||||
--- Adds empty lines at the top and adjusts all line indices
|
||||
--- @param lines table Array of line strings (mutated in place)
|
||||
--- @param item_to_lines table Item to lines mapping (mutated in place)
|
||||
--- @param ctx table Render context
|
||||
local function apply_bottom_padding(lines, item_to_lines, ctx)
|
||||
if ctx.prompt_position ~= 'bottom' then return end
|
||||
|
||||
-- Adjust item_to_lines mapping
|
||||
for i = display_start, display_end do
|
||||
if item_to_lines[i] then
|
||||
item_to_lines[i].first = item_to_lines[i].first + empty_line_offset
|
||||
item_to_lines[i].last = item_to_lines[i].last + empty_line_offset
|
||||
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
|
||||
-- Insert empty lines at the beginning
|
||||
for i = empty_lines_needed, 1, -1 do
|
||||
table.insert(lines, 1, string.rep(' ', ctx.win_width + 5))
|
||||
end
|
||||
|
||||
-- Adjust item_to_lines mapping
|
||||
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
|
||||
end
|
||||
end
|
||||
|
||||
-- Calculate cursor line based on current item
|
||||
--- Update buffer content and position cursor
|
||||
--- @param lines table Array of line strings
|
||||
--- @param item_to_lines table Item to lines mapping
|
||||
--- @param ctx table Render context
|
||||
local function update_buffer_and_cursor(lines, item_to_lines, ctx)
|
||||
-- Calculate cursor line position
|
||||
local cursor_line = 0
|
||||
if #items > 0 and M.state.cursor >= 1 and M.state.cursor <= #items then
|
||||
local cursor_item = item_to_lines[M.state.cursor]
|
||||
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
|
||||
|
||||
-- Update buffer
|
||||
vim.api.nvim_buf_set_option(M.state.list_buf, 'modifiable', true)
|
||||
vim.api.nvim_buf_set_lines(M.state.list_buf, 0, -1, false, padded_lines)
|
||||
vim.api.nvim_buf_set_lines(M.state.list_buf, 0, -1, false, lines)
|
||||
vim.api.nvim_buf_set_option(M.state.list_buf, 'modifiable', false)
|
||||
|
||||
-- Clear existing highlights
|
||||
vim.api.nvim_buf_clear_namespace(M.state.list_buf, M.state.ns_id, 0, -1)
|
||||
|
||||
-- Set cursor position
|
||||
if #items > 0 and cursor_line > 0 and cursor_line <= #padded_lines then
|
||||
-- Position cursor
|
||||
if #ctx.items > 0 and cursor_line > 0 and cursor_line <= #lines then
|
||||
vim.api.nvim_win_set_cursor(M.state.list_win, { cursor_line, 0 })
|
||||
end
|
||||
end
|
||||
|
||||
-- Apply highlighting to all items
|
||||
if #items > 0 then
|
||||
for i = display_start, display_end do
|
||||
local item = items[i]
|
||||
local item_lines = item_to_lines[i]
|
||||
if item_lines then
|
||||
local is_cursor_item = (M.state.cursor == i)
|
||||
--- Apply all highlights using renderer.apply_highlights
|
||||
--- @param lines table Array of line strings
|
||||
--- @param item_to_lines table Item to lines mapping
|
||||
--- @param ctx table Render context
|
||||
local function apply_all_highlights(lines, item_to_lines, ctx)
|
||||
ctx.selected_files = M.state.selected_files
|
||||
ctx.query = M.state.query
|
||||
|
||||
-- Highlight only the content line (last line), not the combo header
|
||||
if is_cursor_item then
|
||||
local content_line = item_lines.last
|
||||
-- Highlight entire line and extend to EOL
|
||||
vim.api.nvim_buf_set_extmark(M.state.list_buf, M.state.ns_id, content_line - 1, 0, {
|
||||
end_col = 0,
|
||||
end_row = content_line,
|
||||
hl_group = M.state.config.hl.active_file,
|
||||
hl_eol = true,
|
||||
priority = 100,
|
||||
})
|
||||
end
|
||||
local renderer = ctx.renderer
|
||||
if not renderer then renderer = require('fff.file_renderer') end
|
||||
|
||||
-- Now apply file-specific highlights to the last line
|
||||
local line_idx = item_lines.last
|
||||
local line_content = padded_lines[line_idx]
|
||||
for i = ctx.display_start, ctx.display_end do
|
||||
local item = ctx.items[i]
|
||||
local item_lines = item_to_lines[i]
|
||||
if not item_lines then goto continue end
|
||||
|
||||
if line_content then
|
||||
local icon, icon_hl_group = unpack(icon_data[i])
|
||||
local filename, dir_path = unpack(path_data[i])
|
||||
local line_idx = item_lines.last
|
||||
local line_content = lines[line_idx]
|
||||
|
||||
local score = file_picker.get_file_score(i)
|
||||
local is_current_file = score and score.current_file_penalty and score.current_file_penalty < 0
|
||||
if not line_content then goto continue end
|
||||
|
||||
-- Icon highlighting
|
||||
if icon and icon_hl_group and vim.fn.strdisplaywidth(icon) > 0 then
|
||||
local icon_highlight = is_current_file and 'Comment' or icon_hl_group
|
||||
vim.api.nvim_buf_add_highlight(
|
||||
M.state.list_buf,
|
||||
M.state.ns_id,
|
||||
icon_highlight,
|
||||
line_idx - 1,
|
||||
0,
|
||||
vim.fn.strdisplaywidth(icon)
|
||||
)
|
||||
end
|
||||
-- Apply highlights using renderer.apply_highlights
|
||||
renderer.apply_highlights(item, ctx, i, M.state.list_buf, M.state.ns_id, line_idx, line_content)
|
||||
::continue::
|
||||
end
|
||||
end
|
||||
|
||||
-- Highlights the text if configured
|
||||
if config.git and config.git.status_text_color and icon and #filename > 0 then
|
||||
local icon_byte_len = #icon
|
||||
local filename_start = icon_byte_len + 1 -- +1 for space after icon
|
||||
local filename_end = filename_start + #filename -- length is fine as sane file system doesn't allow unicode in paths
|
||||
|
||||
local git_text_hl = item.git_status and git_utils.get_text_highlight(item.git_status) or nil
|
||||
if git_text_hl and git_text_hl ~= '' and not is_current_file then
|
||||
vim.api.nvim_buf_add_highlight(
|
||||
M.state.list_buf,
|
||||
M.state.ns_id,
|
||||
git_text_hl,
|
||||
line_idx - 1,
|
||||
filename_start,
|
||||
filename_end
|
||||
)
|
||||
end
|
||||
end
|
||||
|
||||
-- Frecency highlighting
|
||||
if debug_enabled then
|
||||
local star_start, star_end = line_content:find('⭐%d+')
|
||||
if star_start then
|
||||
vim.api.nvim_buf_add_highlight(
|
||||
M.state.list_buf,
|
||||
M.state.ns_id,
|
||||
M.state.config.hl.frecency,
|
||||
line_idx - 1,
|
||||
star_start - 1,
|
||||
star_end
|
||||
)
|
||||
end
|
||||
end
|
||||
|
||||
-- Directory path highlighting
|
||||
if #filename > 0 and #dir_path > 0 then
|
||||
local prefix_len = #filename + 1 -- filename bytes + space
|
||||
if icon then
|
||||
prefix_len = prefix_len + #icon + 1 -- if icon add icon bytes + space
|
||||
end
|
||||
vim.api.nvim_buf_add_highlight(
|
||||
M.state.list_buf,
|
||||
M.state.ns_id,
|
||||
config.hl.directory_path or 'Comment',
|
||||
line_idx - 1,
|
||||
prefix_len,
|
||||
prefix_len + #dir_path
|
||||
)
|
||||
end
|
||||
|
||||
if is_current_file then
|
||||
if not is_cursor_item then
|
||||
vim.api.nvim_buf_add_highlight(M.state.list_buf, M.state.ns_id, 'Comment', line_idx - 1, 0, -1)
|
||||
end
|
||||
|
||||
local virt_text_hl = is_cursor_item and M.state.config.hl.active_file or 'Comment'
|
||||
vim.api.nvim_buf_set_extmark(M.state.list_buf, M.state.ns_id, line_idx - 1, 0, {
|
||||
virt_text = { { ' (current)', virt_text_hl } },
|
||||
virt_text_pos = 'right_align',
|
||||
})
|
||||
end
|
||||
|
||||
if item.git_status and git_utils.should_show_border(item.git_status) then
|
||||
local border_char = git_utils.get_border_char(item.git_status)
|
||||
local border_hl
|
||||
|
||||
if is_cursor_item then
|
||||
-- When selected, create a combined highlight: border color on cursor background
|
||||
local base_hl = git_utils.get_border_highlight(item.git_status)
|
||||
if base_hl and base_hl ~= '' then
|
||||
-- Get the foreground color from the border highlight
|
||||
local border_fg = vim.fn.synIDattr(vim.fn.synIDtrans(vim.fn.hlID(base_hl)), 'fg')
|
||||
-- Get the background from cursor highlight
|
||||
local cursor_bg = vim.fn.synIDattr(vim.fn.synIDtrans(vim.fn.hlID(M.state.config.hl.active_file)), 'bg')
|
||||
|
||||
-- Create temporary highlight group
|
||||
local temp_hl_name = 'FFFGitBorderSelected_' .. i
|
||||
if border_fg ~= '' and cursor_bg ~= '' then
|
||||
vim.api.nvim_set_hl(0, temp_hl_name, { fg = border_fg, bg = cursor_bg })
|
||||
border_hl = temp_hl_name
|
||||
else
|
||||
border_hl = git_utils.get_border_highlight_selected(item.git_status)
|
||||
end
|
||||
else
|
||||
border_hl = M.state.config.hl.active_file
|
||||
end
|
||||
else
|
||||
border_hl = git_utils.get_border_highlight(item.git_status)
|
||||
end
|
||||
|
||||
if border_hl and border_hl ~= '' then
|
||||
vim.api.nvim_buf_set_extmark(M.state.list_buf, M.state.ns_id, line_idx - 1, 0, {
|
||||
sign_text = border_char,
|
||||
sign_hl_group = border_hl,
|
||||
priority = 1000,
|
||||
})
|
||||
end
|
||||
elseif is_cursor_item then
|
||||
vim.api.nvim_buf_set_extmark(M.state.list_buf, M.state.ns_id, line_idx - 1, 0, {
|
||||
sign_text = ' ',
|
||||
sign_hl_group = M.state.config.hl.active_file,
|
||||
priority = 1000,
|
||||
})
|
||||
end
|
||||
|
||||
if M.state.selected_files[item.path] then
|
||||
local selection_hl = is_cursor_item and M.state.config.hl.selected_active or M.state.config.hl.selected
|
||||
|
||||
vim.api.nvim_buf_set_extmark(M.state.list_buf, M.state.ns_id, line_idx - 1, 0, {
|
||||
sign_text = '▊',
|
||||
sign_hl_group = selection_hl,
|
||||
priority = 1001, -- Higher than git status (1000)
|
||||
})
|
||||
end
|
||||
|
||||
local match_start, match_end = string.find(line_content, M.state.query, 1)
|
||||
if match_start and match_end then
|
||||
vim.api.nvim_buf_add_highlight(
|
||||
M.state.list_buf,
|
||||
M.state.ns_id,
|
||||
config.hl.matched or 'IncSearch',
|
||||
line_idx - 1,
|
||||
match_start - 1,
|
||||
match_end
|
||||
)
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
combo_renderer.render_highlights_and_overlays(
|
||||
combo_item_index,
|
||||
combo_header_text_len,
|
||||
M.state.list_buf,
|
||||
M.state.list_win,
|
||||
M.state.ns_id,
|
||||
M.state.config.hl.border,
|
||||
item_to_lines,
|
||||
prompt_position
|
||||
)
|
||||
-- Renders all virtual buffer overalys
|
||||
local function finalize_render(item_to_lines, ctx)
|
||||
-- Get text_len from item_to_lines if combo exists
|
||||
local combo_text_len = nil
|
||||
if ctx.combo_item_index and item_to_lines[ctx.combo_item_index] then
|
||||
combo_text_len = item_to_lines[ctx.combo_item_index].combo_header_text_len
|
||||
end
|
||||
|
||||
-- Render scrollbar (will be created lazily if needed)
|
||||
local prompt_position = get_prompt_position()
|
||||
scrollbar.render(M.state.layout, M.state.config, M.state.list_win, M.state.pagination, prompt_position)
|
||||
-- Render combo overlays
|
||||
local combo_was_hidden = combo_renderer.render_highlights_and_overlays(
|
||||
ctx.combo_item_index,
|
||||
combo_text_len or ctx.combo_header_text_len,
|
||||
M.state.list_buf,
|
||||
M.state.list_win,
|
||||
M.state.ns_id,
|
||||
ctx.config.hl.border,
|
||||
item_to_lines,
|
||||
ctx.prompt_position
|
||||
)
|
||||
|
||||
-- Handle combo hiding with scroll adjustment
|
||||
if combo_was_hidden and ctx.prompt_position == 'bottom' then scroll_to_bottom() end
|
||||
|
||||
-- Render scrollbar
|
||||
scrollbar.render(M.state.layout, ctx.config, M.state.list_win, M.state.pagination, ctx.prompt_position)
|
||||
end
|
||||
|
||||
function M.render_list()
|
||||
if not M.state.active then return end
|
||||
|
||||
local ctx = build_render_context()
|
||||
local lines, item_to_lines = generate_item_lines(ctx)
|
||||
|
||||
apply_bottom_padding(lines, item_to_lines, ctx)
|
||||
update_buffer_and_cursor(lines, item_to_lines, ctx)
|
||||
|
||||
if #ctx.items > 0 then apply_all_highlights(lines, item_to_lines, ctx) end
|
||||
|
||||
-- 6. Finalize with combo overlays and scrollbar
|
||||
finalize_render(item_to_lines, ctx)
|
||||
end
|
||||
|
||||
function M.update_preview()
|
||||
@@ -2101,11 +1988,12 @@ end
|
||||
--- @param opts.layout.prompt_position? string|function Prompt position: 'top'|'bottom' or function(terminal_width, terminal_height): string (default: 'bottom')
|
||||
--- @param opts.layout.preview_position? string|function Preview position: 'left'|'right'|'top'|'bottom' or function(terminal_width, terminal_height): string (default: 'right')
|
||||
--- @param opts.layout.preview_size? number|function Preview size as ratio (0.0-1.0) or function(terminal_width, terminal_height): number (default: 0.5)
|
||||
--- @param opts.renderer? table Custom renderer implementing {render_line, apply_highlights} interface (default: file_renderer)
|
||||
function M.open(opts)
|
||||
if M.state.active then return end
|
||||
|
||||
-- Initialize selection state
|
||||
M.state.selected_files = {}
|
||||
M.state.renderer = opts and opts.renderer or nil
|
||||
|
||||
local merged_config, base_path = initialize_picker(opts)
|
||||
if not merged_config then return end
|
||||
|
||||
Reference in New Issue
Block a user