feat: keymaps window instead of help line

This commit is contained in:
2026-04-06 01:18:25 +03:00
parent 0424bdf633
commit 1363f6bdcc
5 changed files with 369 additions and 108 deletions

196
README.md
View File

@@ -1,9 +1,8 @@
# input-form.nvim # input-form.nvim
A small Neovim plugin for building bordered, keyboard-navigable **forms** in a A small Neovim plugin for building bordered, keyboard-navigable **forms** in a floating window.
floating window. Create a single window containing multiple typed inputs Create a single window containing multiple typed inputs (single-line text, multiline text, select
(single-line text, multiline text, select dropdowns), collect results via an dropdowns, checkboxes), collect results via an `on_submit` callback.
`on_submit` callback.
![input form example](/input-form.gif) ![input form example](/input-form.gif)
@@ -11,13 +10,17 @@ floating window. Create a single window containing multiple typed inputs
- Bordered floating window with optional title - Bordered floating window with optional title
- Keyboard-navigable: `<Tab>` / `<S-Tab>` to move between inputs - Keyboard-navigable: `<Tab>` / `<S-Tab>` to move between inputs
- Input types: `text`, `multiline`, `select`, `checkbox` - Input types: `text`, `multiline`, `select`, `checkbox`, plus `spacer` (visual-only gap between
fields)
- Select dropdowns open with `<CR>`; arrows navigate; `<CR>` confirms - Select dropdowns open with `<CR>`; arrows navigate; `<CR>` confirms
- Checkbox toggles with `<Space>` or `<CR>`
- Submit with `<C-s>` — results delivered as a `{ [name] = value }` table - Submit with `<C-s>` — results delivered as a `{ [name] = value }` table
- Cancel with `<Esc>` - Cancel with `<Esc>` or `q`
- Built-in toggleable help popup (`?`) listing every active keymap — updates automatically when you
remap keys
- Lazy: `create_form` builds the form; `:show()` renders it when you want - Lazy: `create_form` builds the form; `:show()` renders it when you want
- `:hide()` / `:show()` re-open a form while preserving in-progress values - `:hide()` / `:show()` re-open a form while preserving in-progress values
- Fully configurable keymaps, border, width, title - Fully configurable keymaps (strings or lists), border, width, title
- Auto-generated help doc (`:h input-form`) - Auto-generated help doc (`:h input-form`)
- Tested with `mini.test` - Tested with `mini.test`
@@ -83,9 +86,8 @@ local form = f.create_form({
form:show() form:show()
``` ```
`create_form` returns a form object. Nothing is rendered until you call `create_form` returns a form object. Nothing is rendered until you call `form:show()`. This lets you
`form:show()`. This lets you construct the form in one place and open it from a construct the form in one place and open it from a keymap, autocommand, or anywhere else:
keymap, autocommand, or anywhere else:
```lua ```lua
vim.keymap.set("n", "<leader>xf", function() vim.keymap.set("n", "<leader>xf", function()
@@ -95,19 +97,19 @@ end)
### Form methods ### Form methods
| Method | Description | | Method | Description |
| -------------- | ---------------------------------------------------------------- | | ---------------- | ---------------------------------------------------------------------- |
| `form:show()` | Open the form. No-op if already visible. | | `form:show()` | Open the form. No-op if already visible. |
| `form:hide()` | Close windows but keep values so `:show()` resumes where you left off. | | `form:hide()` | Close windows but keep values so `:show()` resumes where you left off. |
| `form:close()` | Permanently tear down the form. | | `form:close()` | Permanently tear down the form. |
| `form:submit()`| Gather values, close, and invoke `on_submit(results)`. | | `form:submit()` | Gather values, close, and invoke `on_submit(results)`. |
| `form:cancel()`| Close and invoke `on_cancel()` if provided. | | `form:cancel()` | Close and invoke `on_cancel()` if provided. |
| `form:results()`| Return `{ [name] = value }` without closing. | | `form:results()` | Return `{ [name] = value }` without closing. |
### Input spec reference ### Input spec reference
All inputs share `name` (string, required — the key in the result table) and Most inputs share `name` (string, required — the key in the result table) and `label` (string, shown
`label` (string, shown above the field). above the field). `spacer` is the only exception: it's visual-only and needs no `name`.
#### `text` #### `text`
@@ -121,8 +123,7 @@ All inputs share `name` (string, required — the key in the result table) and
{ name = "body", label = "Notes", type = "multiline", default = "", height = 5 } { name = "body", label = "Notes", type = "multiline", default = "", height = 5 }
``` ```
- `height` (optional) — number of rows for the input; falls back to - `height` (optional) — number of rows for the input; falls back to `config.multiline.height`.
`config.multiline.height`.
#### `select` #### `select`
@@ -147,9 +148,8 @@ All inputs share `name` (string, required — the key in the result table) and
{ name = "agree", label = "I agree", type = "checkbox", default = false } { name = "agree", label = "I agree", type = "checkbox", default = false }
``` ```
Unlike text/multiline/select, checkboxes render **inline** — no border, no Unlike text/multiline/select, checkboxes render **inline** — no border, no separate label row. The
separate label row. The glyph sits immediately next to the label, and any glyph sits immediately next to the label, and any validation error is appended on the same line:
validation error is appended on the same line:
``` ```
☐ I agree (must be checked) ☐ I agree (must be checked)
@@ -157,13 +157,34 @@ validation error is appended on the same line:
- `default` (optional) — boolean (defaults to `false`). - `default` (optional) — boolean (defaults to `false`).
- `value()` returns a boolean. - `value()` returns a boolean.
- Toggled with the configured `keymaps.toggle` key (default `<Space>`) or - Toggled with the configured `keymaps.toggle` key (default `<Space>`) or the `keymaps.open_select`
the `keymaps.open_select` key (default `<CR>`) — both work so users get key (default `<CR>`) — both work so users get a consistent "interact with this field" key.
a consistent "interact with this field" key. - Glyphs come from `style.checkbox.{checked, unchecked}` (defaults `"☑"` / `"☐"`).
- Glyphs come from `style.checkbox.{checked, unchecked}` (defaults - A blank row is rendered above and below each checkbox to visually separate it from adjacent
`"☑"` / `"☐"`). bordered inputs. Tune via `style.checkbox.padding` (default `1`, set to `0` to pack tight).
- Pair with `validators.checked()` to require the box to be ticked (see - Pair with `validators.checked()` to require the box to be ticked (see [Validation](#validation)).
[Validation](#validation)).
#### `spacer`
```lua
{ type = "spacer" } -- 1 blank row
{ type = "spacer", height = 2 } -- 2 blank rows
```
A visual-only faux input that reserves blank rows in the layout. It has no window, no focus, no
validator, and never appears in `results()`. Use it to group related inputs visually:
```lua
inputs = {
{ name = "first", label = "First name", type = "text" },
{ name = "last", label = "Last name", type = "text" },
{ type = "spacer" },
{ name = "email", label = "Email", type = "text" },
}
```
- `height` (optional) — number of blank rows (default `1`).
- `<Tab>` / `<S-Tab>` skip over spacers automatically.
## Validation ## Validation
@@ -173,16 +194,15 @@ Each input spec accepts an optional `validator` function:
validator = fun(value: any): string|nil validator = fun(value: any): string|nil
``` ```
Return a non-empty error message string to mark the input invalid, or `nil` / Return a non-empty error message string to mark the input invalid, or `nil` / `""` when valid. The
`""` when valid. The error message is shown in the input's bottom border error message is shown in the input's bottom border (red), and the border + label turn red too.
(red), and the border + label turn red too. Validation runs: Validation runs:
- **On blur** — the first time the user leaves the field it is marked - **On blur** — the first time the user leaves the field it is marked "touched" and the validator
"touched" and the validator runs. Nothing is shown before that. runs. Nothing is shown before that.
- **On change** — once touched, each buffer change re-runs the validator. - **On change** — once touched, each buffer change re-runs the validator.
- **On submit** — `form:submit()` force-validates every input (touched or - **On submit** — `form:submit()` force-validates every input (touched or not). If any input has an
not). If any input has an error, submission is blocked, all errors are error, submission is blocked, all errors are rendered, and focus moves to the first invalid input.
rendered, and focus moves to the first invalid input.
### Built-in validators ### Built-in validators
@@ -234,8 +254,8 @@ f.create_form({
}):show() }):show()
``` ```
Custom validators are just functions — no need to use the builder helpers if Custom validators are just functions — no need to use the builder helpers if you'd rather write one
you'd rather write one inline: inline:
```lua ```lua
validator = function(value) validator = function(value)
@@ -245,7 +265,7 @@ validator = function(value)
end end
``` ```
### Checkbox glyphs ### Checkbox glyphs and padding
Override the characters rendered by `checkbox` inputs via `style.checkbox`: Override the characters rendered by `checkbox` inputs via `style.checkbox`:
@@ -255,18 +275,30 @@ require("input-form").setup({
checkbox = { checkbox = {
checked = "☑", -- default checked = "☑", -- default
unchecked = "☐", -- default unchecked = "☐", -- default
padding = 1, -- default: blank rows above/below each checkbox
}, },
}, },
}) })
``` ```
Alternatives that render well in most fonts: `[x]` / `[ ]`, `✔` / `·`, Alternatives that render well in most fonts: `[x]` / `[ ]`, `✔` / `·`, `●` / `○`. Set `padding = 0`
`●` / `○`. to pack checkboxes flush against adjacent bordered inputs.
### Help popup
The form's bottom border shows a compact `? help` hint on the right. Press `?` (configurable via
`keymaps.help`) to toggle a floating popup below the form that lists **every active keymap** — it
reads from `config.keymaps` at render time, so if you remap `submit` to `<C-Enter>` the popup
reflects that automatically. The popup matches the form's width, wraps long descriptions, and flips
above the form if it would overflow the editor bottom. It only lists keys relevant to the current
form (e.g. `toggle checkbox` only appears when a checkbox is present).
Set `keymaps.help = false` to disable both the popup and the footer hint.
### Select chevrons ### Select chevrons
The glyphs shown on the right side of `select` inputs to indicate the The glyphs shown on the right side of `select` inputs to indicate the dropdown state are
dropdown state are configurable under `style.chevron`: configurable under `style.chevron`:
```lua ```lua
require("input-form").setup({ require("input-form").setup({
@@ -279,22 +311,19 @@ require("input-form").setup({
}) })
``` ```
Use whatever you like — e.g. ASCII fallbacks for terminals without good Use whatever you like — e.g. ASCII fallbacks for terminals without good Unicode support:
Unicode support:
```lua ```lua
style = { chevron = { closed = " v", open = " ^" } } style = { chevron = { closed = " v", open = " ^" } }
``` ```
A leading space is recommended so the glyph doesn't sit flush against the A leading space is recommended so the glyph doesn't sit flush against the label.
label.
### Highlight groups ### Highlight groups
All highlight groups the plugin uses are listed under `style.highlights` in All highlight groups the plugin uses are listed under `style.highlights` in the config and can be
the config and can be overridden via `setup()`. Each entry is passed directly overridden via `setup()`. Each entry is passed directly to `vim.api.nvim_set_hl(0, name, spec)`, so
to `vim.api.nvim_set_hl(0, name, spec)`, so anything `nvim_set_hl` accepts anything `nvim_set_hl` accepts works (`fg`, `bg`, `link`, `bold`, `italic`, `default`, etc.).
works (`fg`, `bg`, `link`, `bold`, `italic`, `default`, etc.).
```lua ```lua
require("input-form").setup({ require("input-form").setup({
@@ -320,7 +349,7 @@ Available groups:
| `InputFormNormal` | Parent form window background | | `InputFormNormal` | Parent form window background |
| `InputFormBorder` | Parent form border | | `InputFormBorder` | Parent form border |
| `InputFormTitle` | Parent form title | | `InputFormTitle` | Parent form title |
| `InputFormHelp` | Footer help line (key hints) | | `InputFormHelp` | Footer `? help` hint on the form border |
| `InputFormField` | Individual input field background | | `InputFormField` | Individual input field background |
| `InputFormFieldBorder` | Individual input field border | | `InputFormFieldBorder` | Individual input field border |
| `InputFormFieldTitle` | Individual input field label (on top border) | | `InputFormFieldTitle` | Individual input field label (on top border) |
@@ -330,11 +359,10 @@ Available groups:
| `InputFormDropdown` | Select dropdown background | | `InputFormDropdown` | Select dropdown background |
| `InputFormDropdownActive` | Highlighted dropdown row | | `InputFormDropdownActive` | Highlighted dropdown row |
User overrides fully **replace** the default spec per group (they are not User overrides fully **replace** the default spec per group (they are not deep-merged at the field
deep-merged at the field level), so you don't need to re-specify level), so you don't need to re-specify `default = true`. Highlights are re-applied on every
`default = true`. Highlights are re-applied on every `form:show()`, so a `form:show()`, so a `setup({ style = { highlights = ... } })` call that happens after the first form
`setup({ style = { highlights = ... } })` call that happens after the first has been rendered still takes effect on the next open.
form has been rendered still takes effect on the next open.
## Configuration ## Configuration
@@ -352,12 +380,15 @@ require("input-form").setup({
gap = 0, -- blank rows between adjacent inputs gap = 0, -- blank rows between adjacent inputs
}, },
keymaps = { keymaps = {
-- Every keymap accepts either a single key string or a list of keys.
-- Set any value to `false` to disable.
next = "<Tab>", next = "<Tab>",
prev = "<S-Tab>", prev = "<S-Tab>",
submit = "<C-s>", submit = "<C-s>",
cancel = "<Esc>", cancel = { "<Esc>", "q" }, -- list form: both keys cancel the form
open_select = "<CR>", open_select = "<CR>",
toggle = "<Space>", toggle = "<Space>",
help = "?", -- toggle the help popup (set `false` to hide)
}, },
select = { select = {
max_height = 10, max_height = 10,
@@ -365,6 +396,14 @@ require("input-form").setup({
multiline = { multiline = {
height = 5, height = 5,
}, },
style = {
checkbox = {
checked = "☑",
unchecked = "☐",
padding = 1, -- blank rows above/below each checkbox
},
-- ...chevron, highlights, etc. — see sections above.
},
}) })
``` ```
@@ -372,8 +411,8 @@ Per-form overrides: pass `title` and/or `width` in the `create_form` spec.
## Help ## Help
Help tags are registered automatically on the first `require('input-form')`, Help tags are registered automatically on the first `require('input-form')`, so `setup()` is not
so `setup()` is not required for them either: required for them either:
``` ```
:h input-form :h input-form
@@ -381,8 +420,8 @@ so `setup()` is not required for them either:
## For plugin developers — using input-form.nvim as a dependency ## For plugin developers — using input-form.nvim as a dependency
You can depend on `input-form.nvim` from another plugin without forcing your You can depend on `input-form.nvim` from another plugin without forcing your users to call
users to call `setup()`. The module is safe to use immediately after require: `setup()`. The module is safe to use immediately after require:
```lua ```lua
-- In your plugin's code: -- In your plugin's code:
@@ -400,19 +439,18 @@ input_form.create_form({
Key points: Key points:
- **No `setup()` required.** Defaults are loaded at module-load time and - **No `setup()` required.** Defaults are loaded at module-load time and `create_form` /
`create_form` / `form:show()` work on a bare `require('input-form')`. End `form:show()` work on a bare `require('input-form')`. End users of your plugin don't need to know
users of your plugin don't need to know input-form.nvim exists. input-form.nvim exists.
- **Per-form overrides.** Pass `title`, `width`, `on_cancel`, etc. directly in - **Per-form overrides.** Pass `title`, `width`, `on_cancel`, etc. directly in the `create_form`
the `create_form` spec — no need to mutate global config for one-off tweaks. spec — no need to mutate global config for one-off tweaks.
- **Baseline config.** If your plugin wants a different baseline (say, a - **Baseline config.** If your plugin wants a different baseline (say, a non-default border style
non-default border style for all forms it opens), call for all forms it opens), call `require('input-form').setup({ ... })` once during your plugin's own
`require('input-form').setup({ ... })` once during your plugin's own initialization. This is idempotent and safe to call even if the end user has already called setup
initialization. This is idempotent and safe to call even if the end user — later calls deep-merge over earlier ones.
has already called setup — later calls deep-merge over earlier ones. - **Respect the user.** Prefer per-form overrides over global `setup()` when possible so you don't
- **Respect the user.** Prefer per-form overrides over global `setup()` when stomp on a user who has configured input-form.nvim for their own keymaps or other plugins that use
possible so you don't stomp on a user who has configured input-form.nvim it.
for their own keymaps or other plugins that use it.
- **Declaring the dep.** With lazy.nvim, add it to your `dependencies`: - **Declaring the dep.** With lazy.nvim, add it to your `dependencies`:
```lua ```lua
{ {

View File

@@ -113,12 +113,16 @@ Default configuration for |input-form|.
prev = "<S-Tab>", prev = "<S-Tab>",
--- Submit the form and invoke `on_submit(results)`. --- Submit the form and invoke `on_submit(results)`.
submit = "<C-s>", submit = "<C-s>",
--- Cancel the form and invoke `on_cancel()` if provided. --- Cancel the form and invoke `on_cancel()` if provided. Accepts a
cancel = "<Esc>", --- single key string or a list of keys — all listed keys trigger cancel.
cancel = { "<Esc>", "q" },
--- Open the dropdown when focused on a `select` input. --- Open the dropdown when focused on a `select` input.
open_select = "<CR>", open_select = "<CR>",
--- Toggle the value of a `checkbox` input. --- Toggle the value of a `checkbox` input.
toggle = "<Space>", toggle = "<Space>",
--- Toggle a help popup listing every active keymap. The popup opens
--- directly below the form window and closes on the same key.
help = "?",
}, },
--- Options for `select` inputs. --- Options for `select` inputs.
select = { select = {

View File

@@ -37,12 +37,16 @@ M.defaults = {
prev = "<S-Tab>", prev = "<S-Tab>",
--- Submit the form and invoke `on_submit(results)`. --- Submit the form and invoke `on_submit(results)`.
submit = "<C-s>", submit = "<C-s>",
--- Cancel the form and invoke `on_cancel()` if provided. --- Cancel the form and invoke `on_cancel()` if provided. Accepts a
cancel = "<Esc>", --- single key string or a list of keys — all listed keys trigger cancel.
cancel = { "<Esc>", "q" },
--- Open the dropdown when focused on a `select` input. --- Open the dropdown when focused on a `select` input.
open_select = "<CR>", open_select = "<CR>",
--- Toggle the value of a `checkbox` input. --- Toggle the value of a `checkbox` input.
toggle = "<Space>", toggle = "<Space>",
--- Toggle a help popup listing every active keymap. The popup opens
--- directly below the form window and closes on the same key.
help = "?",
}, },
--- Options for `select` inputs. --- Options for `select` inputs.
select = { select = {

View File

@@ -50,12 +50,12 @@ function M:_compute_layout()
-- `width` is the parent's OUTER width (i.e. visible width including border). -- `width` is the parent's OUTER width (i.e. visible width including border).
local outer_width = utils.resolve_width(self._width or opts.window.width) local outer_width = utils.resolve_width(self._width or opts.window.width)
-- Grow the window to fit the footer help line if the user's configured -- Grow the window to fit the footer hint if the user's configured width
-- width is too narrow. The footer string is " <help> " so we need at least -- is too narrow. The footer string is " <hint> " so we need at least
-- #help + 2 (leading/trailing space) + 2 (corners) cells of outer width. -- #hint + 2 (leading/trailing space) + 2 (corners) cells of outer width.
local help = self:_help_line() local hint = self:_help_hint()
if help and help ~= "" then if hint and hint ~= "" then
local needed = vim.fn.strdisplaywidth(help) + 4 local needed = vim.fn.strdisplaywidth(hint) + 4
if outer_width < needed then if outer_width < needed then
outer_width = needed outer_width = needed
end end
@@ -184,10 +184,10 @@ function M:show()
win_opts.title_pos = config.options.window.title_pos win_opts.title_pos = config.options.window.title_pos
end end
if vim.fn.has("nvim-0.10") == 1 then if vim.fn.has("nvim-0.10") == 1 then
local footer = self:_help_line() local footer = self:_help_hint()
if footer and footer ~= "" then if footer and footer ~= "" then
win_opts.footer = " " .. footer .. " " win_opts.footer = " " .. footer .. " "
win_opts.footer_pos = "center" win_opts.footer_pos = "right"
end end
end end
self._parent_win = vim.api.nvim_open_win(self._parent_buf, false, win_opts) self._parent_win = vim.api.nvim_open_win(self._parent_buf, false, win_opts)
@@ -268,6 +268,7 @@ function M:hide()
if not self._visible then if not self._visible then
return return
end end
self:_close_help()
for _, input in ipairs(self._inputs) do for _, input in ipairs(self._inputs) do
input:unmount() input:unmount()
end end
@@ -475,23 +476,57 @@ function M:_render_validation(input)
end end
end end
--- Build a help-line string describing the active keymaps. --- Format a keymap value (string or list of strings) for display. Returns
function M:_help_line() --- `nil` if the value is effectively empty / disabled.
local function format_keys(val)
if not val or val == false or val == "" then
return nil
end
if type(val) == "table" then
local parts = {}
for _, k in ipairs(val) do
if k and k ~= false and k ~= "" then
table.insert(parts, k)
end
end
if #parts == 0 then
return nil
end
return table.concat(parts, " / ")
end
return tostring(val)
end
--- Short footer hint shown on the form's bottom border (e.g. `"? help"`).
--- Returns `nil` if the help keymap is disabled.
function M:_help_hint()
local key = format_keys(config.options.keymaps.help)
if not key then
return nil
end
return key .. " help"
end
--- Collect `{ keys, description }` pairs for every active keymap, filtered
--- to what this form actually uses. Consumed by the help popup.
function M:_help_entries()
local km = config.options.keymaps local km = config.options.keymaps
local parts = {} local entries = {}
local function add(keys, desc) local function add(keys, desc)
if keys and keys ~= false and keys ~= "" then local display = format_keys(keys)
table.insert(parts, keys .. " " .. desc) if display then
table.insert(entries, { display, desc })
end end
end end
local nxt, prv = format_keys(km.next), format_keys(km.prev)
local nav local nav
if km.next and km.prev then if nxt and prv then
nav = km.next .. "/" .. km.prev nav = nxt .. " / " .. prv
else else
nav = km.next or km.prev nav = nxt or prv
end end
if nav then if nav then
table.insert(parts, nav .. " navigate") table.insert(entries, { nav, "navigate fields" })
end end
-- Only advertise type-specific keys if the form actually has such an input. -- Only advertise type-specific keys if the form actually has such an input.
local has_select, has_checkbox = false, false local has_select, has_checkbox = false, false
@@ -503,14 +538,180 @@ function M:_help_line()
end end
end end
if has_select then if has_select then
add(km.open_select, "open") add(km.open_select, "open dropdown")
end end
if has_checkbox then if has_checkbox then
add(km.toggle, "toggle") add(km.toggle, "toggle checkbox")
end
add(km.submit, "submit form")
add(km.cancel, "cancel form")
add(km.help, "toggle this help")
return entries
end
--- Build the wrapped lines of the help popup given a maximum width (the
--- popup's content width). Each keymap occupies its own row formatted as
--- `"<keys> <description>"` with keys right-padded to a common column so
--- descriptions line up. If an entry exceeds `max_w` the description wraps
--- onto a hanging indent.
function M:_help_lines(max_w)
local entries = self:_help_entries()
if #entries == 0 then
return {}
end
-- Cap the key column so a single oversized key doesn't eat the whole row.
local max_key_w = 0
for _, e in ipairs(entries) do
local w = vim.fn.strdisplaywidth(e[1])
if w > max_key_w then
max_key_w = w
end
end
max_key_w = math.min(max_key_w, math.max(4, math.floor(max_w / 2)))
local gap = " "
local gap_w = vim.fn.strdisplaywidth(gap)
local indent = string.rep(" ", max_key_w + gap_w)
local lines = {}
for _, e in ipairs(entries) do
local keys, desc = e[1], e[2]
local key_w = vim.fn.strdisplaywidth(keys)
local pad = string.rep(" ", math.max(0, max_key_w - key_w))
local prefix = keys .. pad .. gap
-- Wrap the description into the remaining width. `avail` is the
-- width available for description text (max_w minus key column).
local avail = math.max(1, max_w - vim.fn.strdisplaywidth(prefix))
local chunks = M._wrap_text(desc, avail)
table.insert(lines, prefix .. (chunks[1] or ""))
for i = 2, #chunks do
table.insert(lines, indent .. chunks[i])
end
end
return lines
end
--- Word-wrap `text` to rows no wider than `width` display cells. Falls back
--- to a hard character cut for a single token longer than `width`.
function M._wrap_text(text, width)
if width <= 0 then
return { text }
end
if vim.fn.strdisplaywidth(text) <= width then
return { text }
end
local out = {}
local line = ""
for word in string.gmatch(text, "%S+") do
if line == "" then
line = word
else
local candidate = line .. " " .. word
if vim.fn.strdisplaywidth(candidate) <= width then
line = candidate
else
table.insert(out, line)
line = word
end
end
-- Single word wider than width: hard-cut on character boundaries.
while vim.fn.strdisplaywidth(line) > width do
local cut = vim.fn.strcharpart(line, 0, width)
table.insert(out, cut)
line = vim.fn.strcharpart(line, vim.fn.strchars(cut))
end
end
if line ~= "" then
table.insert(out, line)
end
return out
end
--- Open the help popup directly below the form window. No-op if already
--- open or if the form is not visible.
function M:_open_help()
if not self._visible or not self._layout then
return
end
if self._help_win and vim.api.nvim_win_is_valid(self._help_win) then
return
end
local layout = self._layout
-- Match the parent's outer width so borders align vertically.
local content_w = layout.parent_inner_w
local lines = self:_help_lines(content_w)
if #lines == 0 then
return
end
local buf = vim.api.nvim_create_buf(false, true)
vim.bo[buf].buftype = "nofile"
vim.bo[buf].bufhidden = "wipe"
vim.bo[buf].swapfile = false
utils.mark_form_buffer(buf)
vim.api.nvim_buf_set_lines(buf, 0, -1, false, lines)
vim.bo[buf].modifiable = false
-- Parent's outer bottom-border row = parent_row + parent_inner_h + 1
-- (parent_row is the border origin). The help popup's top border sits
-- one row below that.
local help_row = layout.parent_row + layout.parent_inner_h + 2
local help_col = layout.parent_col
-- If the popup would overflow the editor below the form, flip it above.
local outer_h = #lines + 2
local max_row = vim.o.lines - outer_h - 2
if help_row > max_row then
local above = layout.parent_row - outer_h
if above >= 0 then
help_row = above
else
help_row = math.max(0, max_row)
end
end
local win = vim.api.nvim_open_win(buf, false, {
relative = "editor",
row = help_row,
col = help_col,
width = content_w,
height = #lines,
style = "minimal",
border = config.options.window.border,
focusable = false,
zindex = 60,
title = " Help ",
title_pos = "left",
})
vim.wo[win].winblend = config.options.window.winblend
vim.wo[win].winhl = table.concat({
"NormalFloat:InputFormNormal",
"FloatBorder:InputFormBorder",
"FloatTitle:InputFormTitle",
}, ",")
self._help_win = win
self._help_buf = buf
end
--- Close the help popup. No-op if not open.
function M:_close_help()
if self._help_win and vim.api.nvim_win_is_valid(self._help_win) then
pcall(vim.api.nvim_win_close, self._help_win, true)
end
if self._help_buf and vim.api.nvim_buf_is_valid(self._help_buf) then
pcall(vim.api.nvim_buf_delete, self._help_buf, { force = true })
end
self._help_win = nil
self._help_buf = nil
end
--- Toggle the help popup.
function M:toggle_help()
if self._help_win and vim.api.nvim_win_is_valid(self._help_win) then
self:_close_help()
else
self:_open_help()
end end
add(km.submit, "submit")
add(km.cancel, "cancel")
return table.concat(parts, " ")
end end
--- Advance from `start` by `step` (+1 or -1), wrapping, until a focusable --- Advance from `start` by `step` (+1 or -1), wrapping, until a focusable
@@ -559,9 +760,17 @@ function M:_install_keymaps(input)
if not buf then if not buf then
return return
end end
-- `lhs` may be a single key string or a list of keys. All listed keys
-- are bound to the same callback.
local function map(mode, lhs, fn) local function map(mode, lhs, fn)
if lhs and lhs ~= false then if not lhs or lhs == false or lhs == "" then
vim.keymap.set(mode, lhs, fn, { buffer = buf, nowait = true, silent = true }) return
end
local keys = type(lhs) == "table" and lhs or { lhs }
for _, k in ipairs(keys) do
if k and k ~= false and k ~= "" then
vim.keymap.set(mode, k, fn, { buffer = buf, nowait = true, silent = true })
end
end end
end end
@@ -584,6 +793,12 @@ function M:_install_keymaps(input)
self:cancel() self:cancel()
end) end)
-- Help popup toggle (normal mode only so `?` stays usable inside text/
-- multiline inputs during insert).
map("n", km.help, function()
self:toggle_help()
end)
if input.type == "select" then if input.type == "select" then
map("n", km.open_select, function() map("n", km.open_select, function()
input:open_dropdown() input:open_dropdown()

View File

@@ -30,7 +30,7 @@ T["setup()"]["exposes defaults"] = function()
eq_config(child, "keymaps.next", "<Tab>") eq_config(child, "keymaps.next", "<Tab>")
eq_config(child, "keymaps.prev", "<S-Tab>") eq_config(child, "keymaps.prev", "<S-Tab>")
eq_config(child, "keymaps.submit", "<C-s>") eq_config(child, "keymaps.submit", "<C-s>")
eq_config(child, "keymaps.cancel", "<Esc>") eq_config(child, "keymaps.cancel", { "<Esc>", "q" })
eq_config(child, "keymaps.open_select", "<CR>") eq_config(child, "keymaps.open_select", "<CR>")
eq_config(child, "select.max_height", 10) eq_config(child, "select.max_height", 10)
eq_config(child, "multiline.height", 5) eq_config(child, "multiline.height", 5)