diff --git a/docs/codeactions.md b/docs/codeactions.md index 37b7747..661f6b6 100644 --- a/docs/codeactions.md +++ b/docs/codeactions.md @@ -64,7 +64,10 @@ Behavior: - actions within each client group keep server response order - group headers are rendered as plain section rows - selection index/count is shown in the float border footer (right-aligned) -- keymaps: `j`/`k`, ``/``, ``/``, ``/``, ``/`` +- key hints (`Enter:apply q:close`) are rendered as an in-buffer footer row +- the menu opens below the cursor and flips above it when there is not enough + screen space below +- keymaps: `j`/`k`, ``/``, ``/``, ``/``, ``/``/`q` ```lua require("cosmic-ui").codeactions.open() diff --git a/docs/formatters.md b/docs/formatters.md index b7f3799..451f42e 100644 --- a/docs/formatters.md +++ b/docs/formatters.md @@ -81,6 +81,19 @@ All methods below: Opens the formatter toggle UI. Opts: `OpenOpts`. +Panel keymaps: + +- `j`/`k`, ``/``, ``/``: move the selection +- ``/``: toggle the selected formatter +- `a`: toggle all formatters +- `s`: switch scope (`buffer`/`global`) +- `r`: reset overrides for the current scope +- `f`: close the panel and format the target buffer +- `q`/``: close the panel + +When the formatter list is taller than the panel, the selection scrolls the +panel; every row stays reachable. + ### `require("cosmic-ui").formatters.toggle(opts?)` Toggles backend state (`lsp`/`conform`) for selected scope. diff --git a/docs/rename.md b/docs/rename.md index ca449ca..6c93499 100644 --- a/docs/rename.md +++ b/docs/rename.md @@ -43,9 +43,9 @@ Rename input options. | `default_value` | `string\|nil` | No | current word | Initial value in rename input. | | `on_submit` | `function\|nil` | No | built-in rename submit | Callback executed when user submits value. | | `window` | `table\|nil` | No | internal defaults + rename config | Native float window overrides. | -| `window.relative` | `string\|nil` | No | `"cursor"` | Float anchor mode. | -| `window.row` | `integer\|nil` | No | `1` | Float row offset. | -| `window.col` | `integer\|nil` | No | `0` | Float column offset. | +| `window.relative` | `string\|nil` | No | auto (cursor position) | Float anchor mode. When `relative`/`anchor`/`row`/`col` are all omitted, the prompt opens below the cursor and flips above it when there is not enough screen space below. | +| `window.row` | `integer\|nil` | No | auto | Float row offset (disables auto flip placement when set). | +| `window.col` | `integer\|nil` | No | auto | Float column offset (disables auto flip placement when set). | | `window.width` | `integer\|nil` | No | auto-fit prompt + symbol | Float width. | | `window.height` | `integer\|nil` | No | auto-fit single prompt line (`1`) | Float height. | | `window.zindex` | `integer\|nil` | No | `50` | Float z-index. | diff --git a/lua/cosmic-ui/codeactions/ui/init.lua b/lua/cosmic-ui/codeactions/ui/init.lua index 2587ec2..2fc53a7 100644 --- a/lua/cosmic-ui/codeactions/ui/init.lua +++ b/lua/cosmic-ui/codeactions/ui/init.lua @@ -20,9 +20,15 @@ local function dismiss_current() end local function build_panel_model(built) + local footer = { 'q:close' } + if #built.actions > 0 then + footer = { 'Enter:apply', 'q:close' } + end + return panel.prepare({ rows = built.rows, selected = (#built.actions > 0) and 1 or nil, + footer = footer, }) end @@ -120,6 +126,9 @@ M.open = function(results_lsp, user_opts) border = border_style, }) + local placement = window.cursor_placement() + local placed = window.cursor_float_config(placement, initial_width, initial_height, { border = border_style }) + local buf = window.create_scratch_buf({ filetype = 'cosmicui-codeactions', modifiable = false, @@ -130,11 +139,12 @@ M.open = function(results_lsp, user_opts) end local win = window.open_float(buf, { - relative = 'cursor', - row = 1, - col = 0, - width = initial_width, - height = initial_height, + relative = placed.relative, + anchor = placed.anchor, + row = placed.row, + col = placed.col, + width = placed.width, + height = placed.height, border = border_style, title = border.title, title_pos = border.title_align, @@ -152,6 +162,8 @@ M.open = function(results_lsp, user_opts) win = win, selected = (#built.actions > 0) and 1 or nil, border = border, + origin_win = origin_win, + placement = placement, ns = lifecycle.ensure_namespace(), } apply_model(ui, built, user_opts, request_state, origin_win) diff --git a/lua/cosmic-ui/codeactions/ui/input.lua b/lua/cosmic-ui/codeactions/ui/input.lua index 9142ab8..9d834b5 100644 --- a/lua/cosmic-ui/codeactions/ui/input.lua +++ b/lua/cosmic-ui/codeactions/ui/input.lua @@ -73,6 +73,7 @@ M.set_keymaps = function(ui, handlers, deps) map('n', '', dismiss) map('n', '', dismiss) + map('n', 'q', dismiss) end return M diff --git a/lua/cosmic-ui/codeactions/ui/render.lua b/lua/cosmic-ui/codeactions/ui/render.lua index fed5e71..b0fa86e 100644 --- a/lua/cosmic-ui/codeactions/ui/render.lua +++ b/lua/cosmic-ui/codeactions/ui/render.lua @@ -78,11 +78,27 @@ M.render = function(ui) local highlights = prepared.highlights if ui.win and vim.api.nvim_win_is_valid(ui.win) then - local cfg = vim.api.nvim_win_get_config(ui.win) - cfg.width = width - cfg.height = height - cfg.title = ui.border and ui.border.title or nil - cfg.title_pos = ui.border and ui.border.title_align or nil + local cfg = { + width = width, + height = height, + title = ui.border and ui.border.title or nil, + title_pos = ui.border and ui.border.title_align or nil, + } + + if ui.placement then + local border = ui.border and ui.border.style + if border == nil then + border = vim.o.winborder + end + + local placed = window.cursor_float_config(ui.placement, width, height, { border = border }) + cfg.relative = placed.relative + cfg.anchor = placed.anchor + cfg.row = placed.row + cfg.col = placed.col + cfg.height = placed.height + end + vim.api.nvim_win_set_config(ui.win, cfg) end diff --git a/lua/cosmic-ui/formatters/ui/input.lua b/lua/cosmic-ui/formatters/ui/input.lua index 9e54252..4816599 100644 --- a/lua/cosmic-ui/formatters/ui/input.lua +++ b/lua/cosmic-ui/formatters/ui/input.lua @@ -2,7 +2,7 @@ local M = {} M.footer_entries = function() return { - 'Tab:toggle', + 'Enter:toggle', 's:scope', 'r:reset', 'a:toggle all', @@ -121,6 +121,9 @@ M.set_keymaps = function(ui, handlers, deps) map('', function() move_selection(ui, 1, handlers, deps) end) + map('', function() + move_selection(ui, 1, handlers, deps) + end) map('k', function() move_selection(ui, -1, handlers, deps) @@ -128,8 +131,14 @@ M.set_keymaps = function(ui, handlers, deps) map('', function() move_selection(ui, -1, handlers, deps) end) + map('', function() + move_selection(ui, -1, handlers, deps) + end) - map('', function() + map('', function() + toggle_row(ui, handlers, deps) + end) + map('', function() toggle_row(ui, handlers, deps) end) @@ -154,7 +163,6 @@ M.set_keymaps = function(ui, handlers, deps) handlers.format_async_fn({ scope = scope, bufnr = target_bufnr }) end) - map('', deps.close_fn) map('', deps.close_fn) map('q', deps.close_fn) end diff --git a/lua/cosmic-ui/formatters/ui/render.lua b/lua/cosmic-ui/formatters/ui/render.lua index 37683d6..d34a301 100644 --- a/lua/cosmic-ui/formatters/ui/render.lua +++ b/lua/cosmic-ui/formatters/ui/render.lua @@ -154,27 +154,6 @@ M.render = function(ui, handlers, deps) local size = deps.constants.panel_size local border = window.resolve_border() - local _, max_height = window.fit_float_size(1, vim.o.lines, { - height_ratio = size.height_ratio, - border = border, - }) - if #lines > max_height then - lines = vim.list_slice(lines, 1, max_height) - lines[#lines] = '...' - - local visible_rows = {} - for _, row in ipairs(rows) do - if row.lnum < max_height then - table.insert(visible_rows, row) - end - end - rows = visible_rows - - if ui.footer_lnum and ui.footer_lnum >= #lines then - ui.footer_lnum = nil - ui.footer_spans = nil - end - end ui.rows = rows M.ensure_selection(ui) diff --git a/lua/cosmic-ui/rename/ui.lua b/lua/cosmic-ui/rename/ui.lua index 77fec78..c8cc94f 100644 --- a/lua/cosmic-ui/rename/ui.lua +++ b/lua/cosmic-ui/rename/ui.lua @@ -81,10 +81,7 @@ local function render(ui, value) if ui.win and vim.api.nvim_win_is_valid(ui.win) then local height width, height = window.fit_float_size(width, ui.fixed_height or #lines, { border = ui.border }) - local cfg = vim.api.nvim_win_get_config(ui.win) - cfg.width = width - cfg.height = height - vim.api.nvim_win_set_config(ui.win, cfg) + vim.api.nvim_win_set_config(ui.win, { width = width, height = height }) end local was_modifiable = vim.bo[ui.buf].modifiable @@ -226,7 +223,8 @@ M.open = function(opts) math.max(1, merged_window_opts.height or 5), { border = border.style } ) - local win = window.open_float(buf, { + + local float_config = { relative = merged_window_opts.relative, row = merged_window_opts.row, col = merged_window_opts.col, @@ -236,7 +234,26 @@ M.open = function(opts) border = border.style, title = border.title, title_pos = border.title_align, - }) + } + + local position_override = opts.window or {} + if + position_override.relative == nil + and position_override.anchor == nil + and position_override.row == nil + and position_override.col == nil + then + local prompt_height = math.max(1, position_override.height or 1) + local placed = + window.cursor_float_config(window.cursor_placement(), initial_width, prompt_height, { border = border.style }) + float_config.relative = placed.relative + float_config.anchor = placed.anchor + float_config.row = placed.row + float_config.col = placed.col + float_config.height = placed.height + end + + local win = window.open_float(buf, float_config) if not win then window.safe_delete_buf(buf, { force = true }) return diff --git a/lua/cosmic-ui/window.lua b/lua/cosmic-ui/window.lua index 872a391..025133e 100644 --- a/lua/cosmic-ui/window.lua +++ b/lua/cosmic-ui/window.lua @@ -55,6 +55,61 @@ M.centered_float_config = function(width, height, opts) } end +M.cursor_placement = function() + local win_row, win_col = 0, 0 + local ok_pos, pos = pcall(vim.api.nvim_win_get_position, 0) + if ok_pos and type(pos) == 'table' then + win_row = pos[1] or 0 + win_col = pos[2] or 0 + end + + local ok_line, cursor_line = pcall(vim.fn.winline) + local ok_col, cursor_col = pcall(vim.fn.wincol) + if not (ok_line and type(cursor_line) == 'number' and cursor_line >= 1) then + cursor_line = 1 + end + if not (ok_col and type(cursor_col) == 'number' and cursor_col >= 1) then + cursor_col = 1 + end + + local screen_row = win_row + cursor_line + local screen_col = win_col + cursor_col + local usable_height = math.max(1, vim.o.lines - vim.o.cmdheight) + + return { + row = screen_row - 1, + col = screen_col - 1, + above = math.max(0, screen_row - 1), + below = math.max(0, usable_height - screen_row), + } +end + +M.cursor_float_config = function(placement, width, height, opts) + opts = opts or {} + local border = border_extent(opts.border) + local flip = height + border > placement.below and placement.above > placement.below + local side_height = math.max(1, (flip and placement.above or placement.below) - border) + height = math.max(1, math.min(height, side_height)) + local col = math.max(0, math.min(placement.col, vim.o.columns - width - border)) + + local config = { + relative = 'editor', + width = width, + height = height, + col = col, + } + + if flip then + config.anchor = 'SW' + config.row = placement.row + else + config.anchor = 'NW' + config.row = placement.row + 1 + end + + return config +end + M.resolve_border = function(style) if style == nil then style = vim.o.winborder diff --git a/tests/codeactions/ui_spec.lua b/tests/codeactions/ui_spec.lua index 324fd3b..d349ee3 100644 --- a/tests/codeactions/ui_spec.lua +++ b/tests/codeactions/ui_spec.lua @@ -94,7 +94,7 @@ describe('cosmic-ui.codeactions.ui', function() assert.are.same({ '' }, seen) end) - it('does not render in-buffer footer helper rows', function() + it('renders an in-buffer key-hint footer row', function() local ui = require('cosmic-ui.codeactions.ui') ui.open({ @@ -109,8 +109,78 @@ describe('cosmic-ui.codeactions.ui', function() local state = lifecycle.get_state() local lines = vim.api.nvim_buf_get_lines(state.ui.buf, 0, -1, false) - assert.is_false(vim.tbl_contains(lines, ' Esc:close ')) - assert.is_false(vim.tbl_contains(lines, ' Enter:apply Esc:close ')) + assert.is_true(vim.tbl_contains(lines, ' Enter:apply q:close ')) + end) + + it('shows only the close hint when no actions are available', function() + local ui = require('cosmic-ui.codeactions.ui') + + ui.open({ + [1] = { + client = { id = 1, name = 'lua_ls' }, + result = {}, + }, + }, {}) + + local state = lifecycle.get_state() + local lines = vim.api.nvim_buf_get_lines(state.ui.buf, 0, -1, false) + + assert.is_true(vim.tbl_contains(lines, ' q:close ')) + assert.is_false(vim.tbl_contains(lines, ' Enter:apply q:close ')) + end) + + it('dismisses the panel with q', function() + local ui = require('cosmic-ui.codeactions.ui') + + ui.open({ + [1] = { + client = { id = 1, name = 'lua_ls' }, + result = { + { title = 'Fix' }, + }, + }, + }, {}) + + local state = lifecycle.get_state() + assert.is_not_nil(state.ui) + + vim.api.nvim_feedkeys('q', 'xt', false) + vim.cmd('redraw') + + assert.is_nil(lifecycle.get_state().ui) + end) + + it('flips the panel above the cursor near the bottom of the screen', function() + local ui = require('cosmic-ui.codeactions.ui') + local original_buf = vim.api.nvim_get_current_buf() + local buf = vim.api.nvim_create_buf(false, true) + local fill = {} + for idx = 1, vim.o.lines * 2 do + fill[idx] = 'line ' .. idx + end + vim.api.nvim_buf_set_lines(buf, 0, -1, false, fill) + vim.api.nvim_set_current_buf(buf) + vim.cmd('normal! G') + vim.cmd('redraw') + + ui.open({ + [1] = { + client = { id = 1, name = 'lua_ls' }, + result = { + { title = 'Fix' }, + }, + }, + }, {}) + + local state = lifecycle.get_state() + local cfg = vim.api.nvim_win_get_config(state.ui.win) + + lifecycle.close_current() + vim.api.nvim_set_current_buf(original_buf) + vim.api.nvim_buf_delete(buf, { force = true }) + + assert.are.equal('editor', cfg.relative) + assert.are.equal('SW', cfg.anchor) end) it('preserves configured min width and border metadata on the float', function() diff --git a/tests/formatters/input_spec.lua b/tests/formatters/input_spec.lua index 3c7b59d..7f7bf6a 100644 --- a/tests/formatters/input_spec.lua +++ b/tests/formatters/input_spec.lua @@ -1,5 +1,5 @@ describe('cosmic-ui.formatters.ui.input', function() - it('moves explicitly but keeps the selected row when Tab toggles it', function() + it('moves the selection with j and Tab and toggles the selected row with Enter', function() local input = require('cosmic-ui.formatters.ui.input') local original_buf = vim.api.nvim_get_current_buf() local buf = vim.api.nvim_create_buf(false, true) @@ -61,12 +61,20 @@ describe('cosmic-ui.formatters.ui.input', function() vim.api.nvim_feedkeys(vim.api.nvim_replace_termcodes('', true, false, true), 'xt', false) vim.cmd('redraw') + assert.are.equal(2, ui.selected) + assert.are.equal(2, update_calls) + assert.are.equal(0, render_calls) + assert.are.equal(0, toggle_calls) + + vim.api.nvim_feedkeys(vim.api.nvim_replace_termcodes('', true, false, true), 'xt', false) + vim.cmd('redraw') + vim.api.nvim_set_current_buf(original_buf) vim.api.nvim_buf_delete(buf, { force = true }) vim.api.nvim_buf_delete(target_buf, { force = true }) - assert.are.equal(3, ui.selected) - assert.are.equal(1, update_calls) + assert.are.equal(2, ui.selected) + assert.are.equal(2, update_calls) assert.are.equal(1, render_calls) assert.are.equal(1, toggle_calls) end) diff --git a/tests/formatters/rows_spec.lua b/tests/formatters/rows_spec.lua index 483bf03..447a327 100644 --- a/tests/formatters/rows_spec.lua +++ b/tests/formatters/rows_spec.lua @@ -210,16 +210,16 @@ describe('cosmic-ui.formatters.ui.rows', function() local has_subtitle_hl = false assert.are.equal(' LSP fallback', rstrip(lines[ui.rows[1].lnum])) - assert.are.equal(' Tab:toggle s:scope r:reset a:toggle all f:format q:close', rstrip(lines[ui.footer_lnum])) + assert.are.equal(' Enter:toggle s:scope r:reset a:toggle all f:format q:close', rstrip(lines[ui.footer_lnum])) assert.are.same({ highlight = 'CosmicUiPanelHintKey', start_col = 1, - end_col = 4, + end_col = 6, }, ui.footer_spans[1]) assert.are.same({ highlight = 'CosmicUiPanelHintText', - start_col = 5, - end_col = 11, + start_col = 7, + end_col = 13, }, ui.footer_spans[2]) for _, mark in ipairs(extmarks) do @@ -388,7 +388,7 @@ describe('cosmic-ui.formatters.ui.rows', function() assert.are.same({ ui.rows[2].lnum, 0 }, cursor_after) end) - it('keeps selectable rows within height-truncated formatter output', function() + it('keeps every formatter row selectable when content exceeds the panel height', function() local constants = require('cosmic-ui.formatters.constants') local render = require('cosmic-ui.formatters.ui.render') @@ -472,15 +472,17 @@ describe('cosmic-ui.formatters.ui.rows', function() border = border, }) + local cursor_after = vim.api.nvim_win_get_cursor(ui.win) + vim.api.nvim_set_current_buf(original_buf) vim.api.nvim_buf_delete(buf, { force = true }) - assert.are.equal(max_height, #ui.rendered_lines) - assert.are.equal('...', rstrip(ui.rendered_lines[#ui.rendered_lines])) - assert.is_true(#ui.rows < #all_rows) - assert.are.equal(1, ui.selected) - for _, row in ipairs(ui.rows) do - assert.is_true(row.lnum < max_height) + assert.is_true(#ui.rendered_lines > max_height) + assert.are.equal(#all_rows, #ui.rows) + assert.are.equal(30, ui.selected) + for _, line in ipairs(ui.rendered_lines) do + assert.are_not.equal('...', rstrip(line)) end + assert.are.same({ ui.rows[30].lnum, 0 }, cursor_after) end) end) diff --git a/tests/integration/lsp_smoke_spec.lua b/tests/integration/lsp_smoke_spec.lua index bfa1620..90ee333 100644 --- a/tests/integration/lsp_smoke_spec.lua +++ b/tests/integration/lsp_smoke_spec.lua @@ -376,13 +376,13 @@ describe('cosmic-ui public API headless LSP smoke', function() assert.are.equal('cosmicui-formatters', vim.bo[formatter_buf].filetype) assert.is_true(buffer_contains(formatter_buf, 'cosmic-ui-headless-smoke')) - press('') + press('') assert.is_false(cosmic.formatters.is_item_enabled({ source = 'lsp', name = 'cosmic-ui-headless-smoke-' .. tostring(source_buf), bufnr = source_buf, })) - press('') + press('') assert.is_true(cosmic.formatters.is_item_enabled({ source = 'lsp', name = 'cosmic-ui-headless-smoke-' .. tostring(source_buf), diff --git a/tests/window_spec.lua b/tests/window_spec.lua index a2b65f2..954ef9a 100644 --- a/tests/window_spec.lua +++ b/tests/window_spec.lua @@ -38,4 +38,90 @@ describe('cosmic-ui.window', function() assert.is_true(ok, err) end) + + it('places cursor floats below the cursor when there is room', function() + local window = require('cosmic-ui.window') + + local config = window.cursor_float_config({ + row = 4, + col = 10, + above = 4, + below = 18, + }, 30, 6, { border = 'single' }) + + assert.are.equal('editor', config.relative) + assert.are.equal('NW', config.anchor) + assert.are.equal(5, config.row) + assert.are.equal(10, config.col) + assert.are.equal(6, config.height) + end) + + it('flips cursor floats above the cursor when space below is insufficient', function() + local window = require('cosmic-ui.window') + + local config = window.cursor_float_config({ + row = 18, + col = 10, + above = 18, + below = 4, + }, 30, 6, { border = 'single' }) + + assert.are.equal('editor', config.relative) + assert.are.equal('SW', config.anchor) + assert.are.equal(18, config.row) + assert.are.equal(10, config.col) + assert.are.equal(6, config.height) + end) + + it('keeps cursor floats below when neither side fits but below is larger', function() + local window = require('cosmic-ui.window') + + local config = window.cursor_float_config({ + row = 4, + col = 0, + above = 4, + below = 10, + }, 30, 20, { border = 'single' }) + + assert.are.equal('NW', config.anchor) + assert.are.equal(5, config.row) + assert.are.equal(8, config.height) + end) + + it('clamps flipped cursor floats to the space above the cursor', function() + local window = require('cosmic-ui.window') + + local config = window.cursor_float_config({ + row = 15, + col = 0, + above = 15, + below = 3, + }, 30, 20, { border = 'single' }) + + assert.are.equal('SW', config.anchor) + assert.are.equal(15, config.row) + assert.are.equal(13, config.height) + end) + + it('clamps cursor float columns to the editor width', function() + local window = require('cosmic-ui.window') + local original_columns = vim.o.columns + + local ok, err = pcall(function() + vim.o.columns = 40 + + local config = window.cursor_float_config({ + row = 2, + col = 35, + above = 2, + below = 15, + }, 20, 4, { border = 'single' }) + + assert.are.equal(18, config.col) + end) + + vim.o.columns = original_columns + + assert.is_true(ok, err) + end) end)