local M = {} local utils = require('fff.utils') local file_info_renderer = require('fff.file_picker.file_info') local BORDER_PRESETS = { single = { '┌', '─', '┐', '│', '┘', '─', '└', '│' }, double = { '╔', '═', '╗', '║', '╝', '═', '╚', '║' }, rounded = { '╭', '─', '╮', '│', '╯', '─', '╰', '│' }, solid = { '▛', '▀', '▜', '▐', '▟', '▄', '▙', '▌' }, shadow = { '', '', ' ', ' ', ' ', ' ', ' ', '' }, none = { '', '', '', '', '', '', '', '' }, } local T_JUNCTION_PRESETS = { single = { '├', '┤', '┬', '┴', '┼' }, double = { '╠', '╣', '╦', '╩', '╬' }, rounded = { '├', '┤', '┬', '┴', '┼' }, solid = { '▌', '▐', '▀', '▄', '█' }, shadow = { '', '', '', '', '' }, none = { '', '', '', '', '' }, } -- Resolve which border preset to draw. `config.layout.border` overrides the -- global `winborder`; when unset we fall back to `vim.o.winborder` so fff keeps -- respecting the user's global preference. local function get_border_chars(config) local border = config and config.layout and config.layout.border if border == nil or border == '' then border = vim.o.winborder end if border == nil or border == '' then border = 'single' end if BORDER_PRESETS[border] then return BORDER_PRESETS[border], T_JUNCTION_PRESETS[border] end return BORDER_PRESETS.single, T_JUNCTION_PRESETS.single end --- Resolve a corner glyph based on adjacency flags. Each neighbour flag means --- another float shares the edge meeting at that corner, so the corner needs --- to extend a stem in that direction instead of being a plain corner. local function resolve_corner(corner, chars, j, which, n) local stems = { tl = { down = chars[8] ~= '', right = chars[2] ~= '' }, tr = { down = chars[4] ~= '', left = chars[2] ~= '' }, bl = { up = chars[8] ~= '', right = chars[6] ~= '' }, br = { up = chars[4] ~= '', left = chars[6] ~= '' }, } local s = stems[which] local up = s.up or n.up local down = s.down or n.down local left = s.left or n.left local right = s.right or n.right local count = (up and 1 or 0) + (down and 1 or 0) + (left and 1 or 0) + (right and 1 or 0) if count >= 4 then return j[5] end if up and down and (left or right) then return left and j[2] or j[1] end if left and right and (up or down) then return up and j[4] or j[3] end return corner end local function resolve_prompt_position(config) if config and config.layout and config.layout.prompt_position then return utils.resolve_config_value( config.layout.prompt_position, vim.o.columns, vim.o.lines, function(value) return utils.is_one_of(value, { 'top', 'bottom' }) end, 'bottom', 'layout.prompt_position' ) end return 'bottom' end M.resolve_prompt_position = resolve_prompt_position local function resolve_preview_position(config) if config and config.layout and config.layout.preview_position then local terminal_width = vim.o.columns local position = utils.resolve_config_value( config.layout.preview_position, terminal_width, vim.o.lines, function(value) return utils.is_one_of(value, { 'left', 'right', 'top', 'bottom' }) end, 'right', 'layout.preview_position' ) -- Flex wrap: when the terminal is narrower than `flex.size`, swap a -- side-by-side preview to a stacked top/bottom one so columns stay legible. local flex = config.layout.flex if flex then local size = flex.size or 80 local wrap = flex.wrap or 'top' if terminal_width < size then return wrap end end return position end return 'right' end --- @param cfg table Layout config produced by compute --- @return table layout dimensions and positions function M.calculate_dimensions(cfg) local BORDER_SIZE = 2 local PROMPT_HEIGHT = 2 local SEPARATOR_WIDTH = 1 local SEPARATOR_HEIGHT = 1 if not utils.is_one_of(cfg.preview_position, { 'left', 'right', 'top', 'bottom' }) then error('Invalid preview position: ' .. tostring(cfg.preview_position)) end local layout = {} local preview_enabled = cfg.preview_enabled if preview_enabled == nil then preview_enabled = true end local total_width = math.max(0, cfg.total_width - BORDER_SIZE) local total_height = math.max(0, cfg.total_height - BORDER_SIZE - PROMPT_HEIGHT) if cfg.preview_position == 'left' then local separator_width = preview_enabled and SEPARATOR_WIDTH or 0 local list_width = math.max(0, total_width - cfg.preview_width - separator_width) local list_height = total_height layout.list_col = cfg.start_col + cfg.preview_width + 2 -- +2 for borders (shared separator column) layout.list_width = list_width layout.list_height = list_height layout.input_col = layout.list_col layout.input_width = list_width if preview_enabled then layout.preview = { col = cfg.start_col + 1, row = cfg.start_row + 1, width = cfg.preview_width, height = list_height, } end elseif cfg.preview_position == 'right' then local separator_width = preview_enabled and SEPARATOR_WIDTH or 0 local list_width = math.max(0, total_width - cfg.preview_width - separator_width) local list_height = total_height layout.list_col = cfg.start_col + 1 layout.list_width = list_width layout.list_height = list_height layout.input_col = layout.list_col layout.input_width = list_width if preview_enabled then layout.preview = { col = cfg.start_col + list_width + 2, -- +2 for borders (shared separator column) row = cfg.start_row + 1, width = cfg.preview_width, height = list_height, } end elseif cfg.preview_position == 'top' then local separator_height = preview_enabled and SEPARATOR_HEIGHT or 0 local list_height = math.max(0, total_height - cfg.preview_height - separator_height) layout.list_col = cfg.start_col + 1 layout.list_width = total_width layout.list_height = list_height layout.input_col = layout.list_col layout.input_width = total_width layout.list_start_row = cfg.start_row + (preview_enabled and (cfg.preview_height + separator_height) or 0) + 1 if preview_enabled then layout.preview = { col = cfg.start_col + 1, row = cfg.start_row + 1, width = total_width, height = cfg.preview_height, } end else local separator_height = preview_enabled and SEPARATOR_HEIGHT or 0 local list_height = math.max(0, total_height - cfg.preview_height - separator_height) layout.list_col = cfg.start_col + 1 layout.list_width = total_width layout.list_height = list_height layout.input_col = layout.list_col layout.input_width = total_width layout.list_start_row = cfg.start_row + 1 if preview_enabled then layout.preview = { col = cfg.start_col + 1, width = total_width, height = cfg.preview_height, } end end if cfg.preview_position == 'left' or cfg.preview_position == 'right' then if cfg.prompt_position == 'top' then layout.input_row = cfg.start_row + 1 layout.list_row = cfg.start_row + PROMPT_HEIGHT + 1 else layout.list_row = cfg.start_row + 1 layout.input_row = cfg.start_row + cfg.total_height - BORDER_SIZE end if layout.preview then layout.preview.row = cfg.start_row + 1 layout.preview.height = cfg.total_height - BORDER_SIZE end else local list_start_row = layout.list_start_row if cfg.prompt_position == 'top' then layout.input_row = list_start_row layout.list_row = list_start_row + BORDER_SIZE layout.list_height = math.max(0, layout.list_height - BORDER_SIZE) else layout.list_row = list_start_row layout.input_row = list_start_row + layout.list_height + 1 end if cfg.preview_position == 'bottom' and layout.preview then if cfg.prompt_position == 'top' then layout.preview.row = layout.list_row + layout.list_height + 1 else layout.preview.row = layout.input_row + PROMPT_HEIGHT end end end -- Debug file_info panel. Only fits in side-by-side preview layouts; in -- compact stacked layouts squeezing it in makes both panels unreadable. local is_side_by_side = cfg.preview_position == 'left' or cfg.preview_position == 'right' if cfg.debug_enabled and preview_enabled and layout.preview and is_side_by_side then layout.file_info = { width = layout.preview.width, height = cfg.file_info_height, col = layout.preview.col, row = layout.preview.row, } -- Stack preview directly below file_info so they share a border row; -- the shared row resolves to ├──...──┤ T-junctions instead of two corners. local consumed = cfg.file_info_height + 1 layout.preview.row = layout.preview.row + consumed layout.preview.height = math.max(3, layout.preview.height - consumed) end return layout end --- Build the per-window configs (for `nvim_open_win`) from a finished layout. --- Handles all the corner/T-junction resolution so adjacent floats line up. local function build_window_configs(layout, config, prompt_position, preview_position) local border_chars, t_junctions = get_border_chars(config) local has_preview = layout.preview ~= nil local title = ' ' .. (config.title or 'FFFiles') .. ' ' local list_neighbour_input_top = prompt_position == 'top' local list_neighbour_input_bottom = prompt_position == 'bottom' local list_neighbour_preview_top = has_preview and preview_position == 'top' local list_neighbour_preview_bottom = has_preview and preview_position == 'bottom' local list_neighbour_preview_left = has_preview and preview_position == 'left' local list_neighbour_preview_right = has_preview and preview_position == 'right' local list_top_at_picker_top = (not list_neighbour_preview_top) and not list_neighbour_input_top local list_bottom_at_picker_bottom = (not list_neighbour_preview_bottom) and not list_neighbour_input_bottom local corners = { tl = resolve_corner(border_chars[1], border_chars, t_junctions, 'tl', { up = list_neighbour_preview_top or list_neighbour_input_top, down = list_neighbour_preview_left, left = list_neighbour_preview_left and list_top_at_picker_top, }), tr = resolve_corner(border_chars[3], border_chars, t_junctions, 'tr', { up = list_neighbour_preview_top or list_neighbour_input_top, down = list_neighbour_preview_right, right = list_neighbour_preview_right and list_top_at_picker_top, }), br = resolve_corner(border_chars[5], border_chars, t_junctions, 'br', { down = list_neighbour_preview_bottom or list_neighbour_input_bottom, up = list_neighbour_preview_right, right = list_neighbour_preview_right and list_bottom_at_picker_bottom, }), bl = resolve_corner(border_chars[7], border_chars, t_junctions, 'bl', { down = list_neighbour_preview_bottom or list_neighbour_input_bottom, up = list_neighbour_preview_left, left = list_neighbour_preview_left and list_bottom_at_picker_bottom, }), } local list_border = prompt_position == 'bottom' and { corners.tl, border_chars[2], corners.tr, border_chars[4], '', '', '', border_chars[8] } or { corners.tl, border_chars[2], corners.tr, border_chars[4], corners.br, border_chars[6], corners.bl, border_chars[8], } local list_cfg = { relative = 'editor', width = math.max(1, layout.list_width), height = math.max(1, layout.list_height), col = layout.list_col, row = layout.list_row, border = list_border, style = 'minimal', zindex = 52, } if prompt_position == 'bottom' then list_cfg.title = title list_cfg.title_pos = 'left' end local input_neighbour_preview_left = has_preview and preview_position == 'left' local input_neighbour_preview_right = has_preview and preview_position == 'right' local input_neighbour_preview_top = has_preview and preview_position == 'top' and prompt_position == 'top' local input_neighbour_preview_bottom = has_preview and preview_position == 'bottom' and prompt_position == 'bottom' local input_top_at_picker_top = prompt_position == 'top' local input_bottom_at_picker_bottom = prompt_position == 'bottom' local function tl_extends_up() if prompt_position == 'bottom' then return true end -- list above if input_neighbour_preview_top then return true end -- preview stack above return false end local function tr_extends_up() return tl_extends_up() end local function bl_extends_down() if prompt_position == 'top' then return true end -- list below if input_neighbour_preview_bottom then return true end -- preview stack below return false end local function br_extends_down() return bl_extends_down() end local ic = { tl = resolve_corner(border_chars[1], border_chars, t_junctions, 'tl', { up = tl_extends_up(), down = input_neighbour_preview_left and input_top_at_picker_top, left = input_neighbour_preview_left and input_top_at_picker_top, }), tr = resolve_corner(border_chars[3], border_chars, t_junctions, 'tr', { up = tr_extends_up(), down = input_neighbour_preview_right and input_top_at_picker_top, right = input_neighbour_preview_right and input_top_at_picker_top, }), br = resolve_corner(border_chars[5], border_chars, t_junctions, 'br', { down = br_extends_down(), up = input_neighbour_preview_right and input_bottom_at_picker_bottom, right = input_neighbour_preview_right and input_bottom_at_picker_bottom, }), bl = resolve_corner(border_chars[7], border_chars, t_junctions, 'bl', { down = bl_extends_down(), up = input_neighbour_preview_left and input_bottom_at_picker_bottom, left = input_neighbour_preview_left and input_bottom_at_picker_bottom, }), } -- Input always renders a full border. In top-prompt mode the bottom border -- coincides with the list's top border row; the corner glyphs (`├` / `┤`) -- are computed identically by both sides, but input is opened LAST so its -- corners win the zindex tie at the column shared with file_info's left -- vertical — without this, file_info's plain `│` would overdraw the -- T-junction and leave a disconnected corner. local input_border = { ic.tl, border_chars[2], ic.tr, border_chars[4], ic.br, border_chars[6], ic.bl, border_chars[8] } local input_cfg = { relative = 'editor', width = math.max(1, layout.input_width), height = 1, col = layout.input_col, row = layout.input_row, border = input_border, style = 'minimal', zindex = 53, } if prompt_position == 'top' then input_cfg.title = title input_cfg.title_pos = 'left' end local preview_cfg = nil if layout.preview then local has_file_info_above = layout.file_info ~= nil local pc = { tl = resolve_corner(border_chars[1], border_chars, t_junctions, 'tl', { right = preview_position == 'left', up = has_file_info_above, }), tr = resolve_corner(border_chars[3], border_chars, t_junctions, 'tr', { left = preview_position == 'right', up = has_file_info_above, }), br = resolve_corner(border_chars[5], border_chars, t_junctions, 'br', { left = preview_position == 'right', }), bl = resolve_corner(border_chars[7], border_chars, t_junctions, 'bl', { right = preview_position == 'left', }), } -- Top/bottom stacked previews share a row with the list; the matching -- horizontal edge gets T-junctions on both ends. if preview_position == 'top' then pc.bl = resolve_corner(border_chars[7], border_chars, t_junctions, 'bl', { down = true }) pc.br = resolve_corner(border_chars[5], border_chars, t_junctions, 'br', { down = true }) elseif preview_position == 'bottom' then pc.tl = resolve_corner(border_chars[1], border_chars, t_junctions, 'tl', { up = true }) pc.tr = resolve_corner(border_chars[3], border_chars, t_junctions, 'tr', { up = true }) end local preview_border = { pc.tl, border_chars[2], pc.tr, border_chars[4], pc.br, border_chars[6], pc.bl, border_chars[8] } preview_cfg = { relative = 'editor', width = math.max(1, layout.preview.width), height = math.max(1, layout.preview.height), col = layout.preview.col, row = layout.preview.row, style = 'minimal', border = preview_border, -- Title hidden when file_info renders above — its footer already says "Preview". title = layout.file_info and '' or ' Preview ', title_pos = 'left', zindex = 51, } end local file_info_cfg = nil if layout.file_info then local list_meets_fi_left = preview_position == 'right' local list_meets_fi_right = preview_position == 'left' local fc = { tl = resolve_corner(border_chars[1], border_chars, t_junctions, 'tl', { left = list_meets_fi_left }), tr = resolve_corner(border_chars[3], border_chars, t_junctions, 'tr', { right = list_meets_fi_right }), bl = resolve_corner(border_chars[7], border_chars, t_junctions, 'bl', { down = true }), br = resolve_corner(border_chars[5], border_chars, t_junctions, 'br', { down = true }), } local fi_border = { fc.tl, border_chars[2], fc.tr, border_chars[4], fc.br, border_chars[6], fc.bl, border_chars[8] } file_info_cfg = { relative = 'editor', width = math.max(1, layout.file_info.width), height = math.max(1, layout.file_info.height), col = layout.file_info.col, row = layout.file_info.row, style = 'minimal', border = fi_border, title = ' File Info ', title_pos = 'left', -- Above the list/preview zindex so its borders win the shared rows. zindex = 53, } end return { list = list_cfg, input = input_cfg, preview = preview_cfg, file_info = file_info_cfg, } end --- Compute the full layout + window configs for the picker. Auto-suppresses --- the preview when the resulting list area would be smaller than --- `config.layout.min_list_height`. --- --- @param config table Resolved picker config (M.state.config) --- @param preview_user_enabled boolean Whether the user's config has preview enabled --- @return table { layout, win_configs, debug_enabled, preview_visible } function M.compute(config, preview_user_enabled) local debug_user_enabled = preview_user_enabled and config.debug and config.debug.enabled or false local terminal_width = vim.o.columns local terminal_height = vim.o.lines local width_ratio = utils.resolve_config_value( config.layout.width, terminal_width, terminal_height, utils.is_valid_ratio, 0.8, 'layout.width' ) local height_ratio = utils.resolve_config_value( config.layout.height, terminal_width, terminal_height, utils.is_valid_ratio, 0.8, 'layout.height' ) local width = math.floor(terminal_width * width_ratio) local height = math.floor(terminal_height * height_ratio) -- Account for chrome (statusline, tabline, cmdheight) for edge-anchored positions local has_tabline = vim.o.showtabline == 2 or (vim.o.showtabline == 1 and #vim.api.nvim_list_tabpages() > 1) local has_statusline = vim.o.laststatus > 0 local top_edge = has_tabline and 1 or 0 local bottom_edge = terminal_height - vim.o.cmdheight - (has_statusline and 1 or 0) local usable_height = bottom_edge - top_edge height = math.min(height, usable_height) local anchor = utils.resolve_config_value( config.layout.anchor, terminal_width, terminal_height, function(v) return utils.is_one_of(v, { 'center', 'top_left', 'top', 'top_right', 'left', 'right', 'bottom_left', 'bottom', 'bottom_right', }) end, 'center', 'layout.anchor' ) -- Edge-flush anchors compensate for the +1 offset added by calculate_dimensions. local center_col = math.floor((terminal_width - width) / 2) local center_row = top_edge + math.floor((usable_height - height) / 2) if width >= terminal_width then center_col = -1 end if height >= usable_height then center_row = top_edge - 1 end local anchor_positions = { center = { col = center_col, row = center_row }, top_left = { col = -1, row = top_edge - 1 }, top = { col = center_col, row = top_edge - 1 }, top_right = { col = terminal_width - width - 2, row = top_edge - 1 }, left = { col = -1, row = center_row }, right = { col = terminal_width - width - 2, row = center_row }, bottom_left = { col = -1, row = bottom_edge - height - 1 }, bottom = { col = center_col, row = bottom_edge - height - 1 }, bottom_right = { col = terminal_width - width - 2, row = bottom_edge - height - 1 }, } local pos = anchor_positions[anchor] or anchor_positions.center local col = pos.col local row = pos.row -- Manual ratio overrides (backwards compat) if config.layout.col ~= nil then local col_ratio = utils.resolve_config_value( config.layout.col, terminal_width, terminal_height, utils.is_valid_ratio, col / terminal_width, 'layout.col' ) col = math.floor(terminal_width * col_ratio) end if config.layout.row ~= nil then local row_ratio = utils.resolve_config_value( config.layout.row, terminal_width, terminal_height, utils.is_valid_ratio, row / terminal_height, 'layout.row' ) row = math.floor(terminal_height * row_ratio) end local prompt_position = resolve_prompt_position(config) local preview_position = resolve_preview_position(config) local preview_size_ratio = utils.resolve_config_value( config.layout.preview_size, terminal_width, terminal_height, utils.is_valid_ratio, 0.4, 'layout.preview_size' ) local is_fullscreen = width >= terminal_width and height >= usable_height -- Panel sits above preview, so its width matches preview width. Ask the -- renderer how many rows it'll draw so we don't reserve a gap row. local preview_width_predicted = preview_user_enabled and math.floor(width * preview_size_ratio) or 0 local file_info_height = 0 if debug_user_enabled then file_info_height = file_info_renderer.calculate_required_height( config.debug and config.debug.show_file_info, preview_width_predicted ) end local dim_cfg = { total_width = width, -- Top/bottom preview with prompt-top has a 2-row chrome over-subtraction in -- calculate_dimensions (BORDER_SIZE is subtracted twice). Compensate at fullscreen. total_height = ( is_fullscreen and prompt_position == 'top' and (preview_position == 'top' or preview_position == 'bottom') ) and height + 2 or height, start_col = col, start_row = row, preview_position = preview_position, prompt_position = prompt_position, debug_enabled = debug_user_enabled and file_info_height > 0, preview_enabled = preview_user_enabled, preview_width = preview_width_predicted, preview_height = preview_user_enabled and math.floor(height * preview_size_ratio) or 0, separator_width = 3, file_info_height = file_info_height, } local layout = M.calculate_dimensions(dim_cfg) -- Auto-hide preview when the list area would be too cramped to be useful. -- Recompute giving the list all the available space. local min_list_height = utils.resolve_config_value( config.layout.min_list_height, terminal_width, terminal_height, function(v) return type(v) == 'number' and v >= 0 end, 10, 'layout.min_list_height' ) local debug_enabled = debug_user_enabled if preview_user_enabled and layout.preview and min_list_height > 0 and layout.list_height < min_list_height then dim_cfg.preview_enabled = false dim_cfg.preview_width = 0 dim_cfg.preview_height = 0 dim_cfg.debug_enabled = false layout = M.calculate_dimensions(dim_cfg) debug_enabled = false end local win_configs = build_window_configs(layout, config, prompt_position, preview_position) return { layout = layout, win_configs = win_configs, debug_enabled = debug_enabled, preview_visible = layout.preview ~= nil, } end return M