mirror of
https://github.com/chenasraf/input-form.nvim.git
synced 2026-05-17 17:38:01 +00:00
feat: keymaps window instead of help line
This commit is contained in:
196
README.md
196
README.md
@@ -1,9 +1,8 @@
|
||||
# input-form.nvim
|
||||
|
||||
A small Neovim plugin for building bordered, keyboard-navigable **forms** in a
|
||||
floating window. Create a single window containing multiple typed inputs
|
||||
(single-line text, multiline text, select dropdowns), collect results via an
|
||||
`on_submit` callback.
|
||||
A small Neovim plugin for building bordered, keyboard-navigable **forms** in a floating window.
|
||||
Create a single window containing multiple typed inputs (single-line text, multiline text, select
|
||||
dropdowns, checkboxes), collect results via an `on_submit` callback.
|
||||
|
||||

|
||||
|
||||
@@ -11,13 +10,17 @@ floating window. Create a single window containing multiple typed inputs
|
||||
|
||||
- Bordered floating window with optional title
|
||||
- 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
|
||||
- Checkbox toggles with `<Space>` or `<CR>`
|
||||
- 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
|
||||
- `: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`)
|
||||
- Tested with `mini.test`
|
||||
|
||||
@@ -83,9 +86,8 @@ local form = f.create_form({
|
||||
form:show()
|
||||
```
|
||||
|
||||
`create_form` returns a form object. Nothing is rendered until you call
|
||||
`form:show()`. This lets you construct the form in one place and open it from a
|
||||
keymap, autocommand, or anywhere else:
|
||||
`create_form` returns a form object. Nothing is rendered until you call `form:show()`. This lets you
|
||||
construct the form in one place and open it from a keymap, autocommand, or anywhere else:
|
||||
|
||||
```lua
|
||||
vim.keymap.set("n", "<leader>xf", function()
|
||||
@@ -95,19 +97,19 @@ end)
|
||||
|
||||
### Form methods
|
||||
|
||||
| Method | Description |
|
||||
| -------------- | ---------------------------------------------------------------- |
|
||||
| `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:close()` | Permanently tear down the form. |
|
||||
| `form:submit()`| Gather values, close, and invoke `on_submit(results)`. |
|
||||
| `form:cancel()`| Close and invoke `on_cancel()` if provided. |
|
||||
| `form:results()`| Return `{ [name] = value }` without closing. |
|
||||
| Method | Description |
|
||||
| ---------------- | ---------------------------------------------------------------------- |
|
||||
| `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:close()` | Permanently tear down the form. |
|
||||
| `form:submit()` | Gather values, close, and invoke `on_submit(results)`. |
|
||||
| `form:cancel()` | Close and invoke `on_cancel()` if provided. |
|
||||
| `form:results()` | Return `{ [name] = value }` without closing. |
|
||||
|
||||
### Input spec reference
|
||||
|
||||
All inputs share `name` (string, required — the key in the result table) and
|
||||
`label` (string, shown above the field).
|
||||
Most inputs share `name` (string, required — the key in the result table) and `label` (string, shown
|
||||
above the field). `spacer` is the only exception: it's visual-only and needs no `name`.
|
||||
|
||||
#### `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 }
|
||||
```
|
||||
|
||||
- `height` (optional) — number of rows for the input; falls back to
|
||||
`config.multiline.height`.
|
||||
- `height` (optional) — number of rows for the input; falls back to `config.multiline.height`.
|
||||
|
||||
#### `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 }
|
||||
```
|
||||
|
||||
Unlike text/multiline/select, checkboxes render **inline** — no border, no
|
||||
separate label row. The glyph sits immediately next to the label, and any
|
||||
validation error is appended on the same line:
|
||||
Unlike text/multiline/select, checkboxes render **inline** — no border, no separate label row. The
|
||||
glyph sits immediately next to the label, and any validation error is appended on the same line:
|
||||
|
||||
```
|
||||
☐ I agree (must be checked)
|
||||
@@ -157,13 +157,34 @@ validation error is appended on the same line:
|
||||
|
||||
- `default` (optional) — boolean (defaults to `false`).
|
||||
- `value()` returns a boolean.
|
||||
- Toggled with the configured `keymaps.toggle` key (default `<Space>`) or
|
||||
the `keymaps.open_select` key (default `<CR>`) — both work so users get
|
||||
a consistent "interact with this field" key.
|
||||
- Glyphs come from `style.checkbox.{checked, unchecked}` (defaults
|
||||
`"☑"` / `"☐"`).
|
||||
- Pair with `validators.checked()` to require the box to be ticked (see
|
||||
[Validation](#validation)).
|
||||
- Toggled with the configured `keymaps.toggle` key (default `<Space>`) or the `keymaps.open_select`
|
||||
key (default `<CR>`) — both work so users get a consistent "interact with this field" key.
|
||||
- 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 [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
|
||||
|
||||
@@ -173,16 +194,15 @@ Each input spec accepts an optional `validator` function:
|
||||
validator = fun(value: any): string|nil
|
||||
```
|
||||
|
||||
Return a non-empty error message string to mark the input invalid, or `nil` /
|
||||
`""` when valid. The error message is shown in the input's bottom border
|
||||
(red), and the border + label turn red too. Validation runs:
|
||||
Return a non-empty error message string to mark the input invalid, or `nil` / `""` when valid. The
|
||||
error message is shown in the input's bottom border (red), and the border + label turn red too.
|
||||
Validation runs:
|
||||
|
||||
- **On blur** — the first time the user leaves the field it is marked
|
||||
"touched" and the validator runs. Nothing is shown before that.
|
||||
- **On blur** — the first time the user leaves the field it is marked "touched" and the validator
|
||||
runs. Nothing is shown before that.
|
||||
- **On change** — once touched, each buffer change re-runs the validator.
|
||||
- **On submit** — `form:submit()` force-validates every input (touched or
|
||||
not). If any input has an error, submission is blocked, all errors are
|
||||
rendered, and focus moves to the first invalid input.
|
||||
- **On submit** — `form:submit()` force-validates every input (touched or not). If any input has an
|
||||
error, submission is blocked, all errors are rendered, and focus moves to the first invalid input.
|
||||
|
||||
### Built-in validators
|
||||
|
||||
@@ -234,8 +254,8 @@ f.create_form({
|
||||
}):show()
|
||||
```
|
||||
|
||||
Custom validators are just functions — no need to use the builder helpers if
|
||||
you'd rather write one inline:
|
||||
Custom validators are just functions — no need to use the builder helpers if you'd rather write one
|
||||
inline:
|
||||
|
||||
```lua
|
||||
validator = function(value)
|
||||
@@ -245,7 +265,7 @@ validator = function(value)
|
||||
end
|
||||
```
|
||||
|
||||
### Checkbox glyphs
|
||||
### Checkbox glyphs and padding
|
||||
|
||||
Override the characters rendered by `checkbox` inputs via `style.checkbox`:
|
||||
|
||||
@@ -255,18 +275,30 @@ require("input-form").setup({
|
||||
checkbox = {
|
||||
checked = "☑", -- 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
|
||||
|
||||
The glyphs shown on the right side of `select` inputs to indicate the
|
||||
dropdown state are configurable under `style.chevron`:
|
||||
The glyphs shown on the right side of `select` inputs to indicate the dropdown state are
|
||||
configurable under `style.chevron`:
|
||||
|
||||
```lua
|
||||
require("input-form").setup({
|
||||
@@ -279,22 +311,19 @@ require("input-form").setup({
|
||||
})
|
||||
```
|
||||
|
||||
Use whatever you like — e.g. ASCII fallbacks for terminals without good
|
||||
Unicode support:
|
||||
Use whatever you like — e.g. ASCII fallbacks for terminals without good Unicode support:
|
||||
|
||||
```lua
|
||||
style = { chevron = { closed = " v", open = " ^" } }
|
||||
```
|
||||
|
||||
A leading space is recommended so the glyph doesn't sit flush against the
|
||||
label.
|
||||
A leading space is recommended so the glyph doesn't sit flush against the label.
|
||||
|
||||
### Highlight groups
|
||||
|
||||
All highlight groups the plugin uses are listed under `style.highlights` in
|
||||
the config and can be overridden via `setup()`. Each entry is passed directly
|
||||
to `vim.api.nvim_set_hl(0, name, spec)`, so anything `nvim_set_hl` accepts
|
||||
works (`fg`, `bg`, `link`, `bold`, `italic`, `default`, etc.).
|
||||
All highlight groups the plugin uses are listed under `style.highlights` in the config and can be
|
||||
overridden via `setup()`. Each entry is passed directly to `vim.api.nvim_set_hl(0, name, spec)`, so
|
||||
anything `nvim_set_hl` accepts works (`fg`, `bg`, `link`, `bold`, `italic`, `default`, etc.).
|
||||
|
||||
```lua
|
||||
require("input-form").setup({
|
||||
@@ -320,7 +349,7 @@ Available groups:
|
||||
| `InputFormNormal` | Parent form window background |
|
||||
| `InputFormBorder` | Parent form border |
|
||||
| `InputFormTitle` | Parent form title |
|
||||
| `InputFormHelp` | Footer help line (key hints) |
|
||||
| `InputFormHelp` | Footer `? help` hint on the form border |
|
||||
| `InputFormField` | Individual input field background |
|
||||
| `InputFormFieldBorder` | Individual input field border |
|
||||
| `InputFormFieldTitle` | Individual input field label (on top border) |
|
||||
@@ -330,11 +359,10 @@ Available groups:
|
||||
| `InputFormDropdown` | Select dropdown background |
|
||||
| `InputFormDropdownActive` | Highlighted dropdown row |
|
||||
|
||||
User overrides fully **replace** the default spec per group (they are not
|
||||
deep-merged at the field level), so you don't need to re-specify
|
||||
`default = true`. Highlights are re-applied on every `form:show()`, so a
|
||||
`setup({ style = { highlights = ... } })` call that happens after the first
|
||||
form has been rendered still takes effect on the next open.
|
||||
User overrides fully **replace** the default spec per group (they are not deep-merged at the field
|
||||
level), so you don't need to re-specify `default = true`. Highlights are re-applied on every
|
||||
`form:show()`, so a `setup({ style = { highlights = ... } })` call that happens after the first form
|
||||
has been rendered still takes effect on the next open.
|
||||
|
||||
## Configuration
|
||||
|
||||
@@ -352,12 +380,15 @@ require("input-form").setup({
|
||||
gap = 0, -- blank rows between adjacent inputs
|
||||
},
|
||||
keymaps = {
|
||||
-- Every keymap accepts either a single key string or a list of keys.
|
||||
-- Set any value to `false` to disable.
|
||||
next = "<Tab>",
|
||||
prev = "<S-Tab>",
|
||||
submit = "<C-s>",
|
||||
cancel = "<Esc>",
|
||||
cancel = { "<Esc>", "q" }, -- list form: both keys cancel the form
|
||||
open_select = "<CR>",
|
||||
toggle = "<Space>",
|
||||
help = "?", -- toggle the help popup (set `false` to hide)
|
||||
},
|
||||
select = {
|
||||
max_height = 10,
|
||||
@@ -365,6 +396,14 @@ require("input-form").setup({
|
||||
multiline = {
|
||||
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 tags are registered automatically on the first `require('input-form')`,
|
||||
so `setup()` is not required for them either:
|
||||
Help tags are registered automatically on the first `require('input-form')`, so `setup()` is not
|
||||
required for them either:
|
||||
|
||||
```
|
||||
: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
|
||||
|
||||
You can depend on `input-form.nvim` from another plugin without forcing your
|
||||
users to call `setup()`. The module is safe to use immediately after require:
|
||||
You can depend on `input-form.nvim` from another plugin without forcing your users to call
|
||||
`setup()`. The module is safe to use immediately after require:
|
||||
|
||||
```lua
|
||||
-- In your plugin's code:
|
||||
@@ -400,19 +439,18 @@ input_form.create_form({
|
||||
|
||||
Key points:
|
||||
|
||||
- **No `setup()` required.** Defaults are loaded at module-load time and
|
||||
`create_form` / `form:show()` work on a bare `require('input-form')`. End
|
||||
users of your plugin don't need to know input-form.nvim exists.
|
||||
- **Per-form overrides.** Pass `title`, `width`, `on_cancel`, etc. directly in
|
||||
the `create_form` spec — no need to mutate global config for one-off tweaks.
|
||||
- **Baseline config.** If your plugin wants a different baseline (say, a
|
||||
non-default border style for all forms it opens), call
|
||||
`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 — later calls deep-merge over earlier ones.
|
||||
- **Respect the user.** Prefer per-form overrides over global `setup()` when
|
||||
possible so you don't stomp on a user who has configured input-form.nvim
|
||||
for their own keymaps or other plugins that use it.
|
||||
- **No `setup()` required.** Defaults are loaded at module-load time and `create_form` /
|
||||
`form:show()` work on a bare `require('input-form')`. End users of your plugin don't need to know
|
||||
input-form.nvim exists.
|
||||
- **Per-form overrides.** Pass `title`, `width`, `on_cancel`, etc. directly in the `create_form`
|
||||
spec — no need to mutate global config for one-off tweaks.
|
||||
- **Baseline config.** If your plugin wants a different baseline (say, a non-default border style
|
||||
for all forms it opens), call `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
|
||||
— later calls deep-merge over earlier ones.
|
||||
- **Respect the user.** Prefer per-form overrides over global `setup()` when possible so you don't
|
||||
stomp on a user who has configured input-form.nvim for their own keymaps or other plugins that use
|
||||
it.
|
||||
- **Declaring the dep.** With lazy.nvim, add it to your `dependencies`:
|
||||
```lua
|
||||
{
|
||||
|
||||
@@ -113,12 +113,16 @@ Default configuration for |input-form|.
|
||||
prev = "<S-Tab>",
|
||||
--- Submit the form and invoke `on_submit(results)`.
|
||||
submit = "<C-s>",
|
||||
--- Cancel the form and invoke `on_cancel()` if provided.
|
||||
cancel = "<Esc>",
|
||||
--- Cancel the form and invoke `on_cancel()` if provided. Accepts a
|
||||
--- 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_select = "<CR>",
|
||||
--- Toggle the value of a `checkbox` input.
|
||||
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.
|
||||
select = {
|
||||
|
||||
@@ -37,12 +37,16 @@ M.defaults = {
|
||||
prev = "<S-Tab>",
|
||||
--- Submit the form and invoke `on_submit(results)`.
|
||||
submit = "<C-s>",
|
||||
--- Cancel the form and invoke `on_cancel()` if provided.
|
||||
cancel = "<Esc>",
|
||||
--- Cancel the form and invoke `on_cancel()` if provided. Accepts a
|
||||
--- 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_select = "<CR>",
|
||||
--- Toggle the value of a `checkbox` input.
|
||||
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.
|
||||
select = {
|
||||
|
||||
@@ -50,12 +50,12 @@ function M:_compute_layout()
|
||||
-- `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)
|
||||
|
||||
-- Grow the window to fit the footer help line if the user's configured
|
||||
-- width is too narrow. The footer string is " <help> " so we need at least
|
||||
-- #help + 2 (leading/trailing space) + 2 (corners) cells of outer width.
|
||||
local help = self:_help_line()
|
||||
if help and help ~= "" then
|
||||
local needed = vim.fn.strdisplaywidth(help) + 4
|
||||
-- Grow the window to fit the footer hint if the user's configured width
|
||||
-- is too narrow. The footer string is " <hint> " so we need at least
|
||||
-- #hint + 2 (leading/trailing space) + 2 (corners) cells of outer width.
|
||||
local hint = self:_help_hint()
|
||||
if hint and hint ~= "" then
|
||||
local needed = vim.fn.strdisplaywidth(hint) + 4
|
||||
if outer_width < needed then
|
||||
outer_width = needed
|
||||
end
|
||||
@@ -184,10 +184,10 @@ function M:show()
|
||||
win_opts.title_pos = config.options.window.title_pos
|
||||
end
|
||||
if vim.fn.has("nvim-0.10") == 1 then
|
||||
local footer = self:_help_line()
|
||||
local footer = self:_help_hint()
|
||||
if footer and footer ~= "" then
|
||||
win_opts.footer = " " .. footer .. " "
|
||||
win_opts.footer_pos = "center"
|
||||
win_opts.footer_pos = "right"
|
||||
end
|
||||
end
|
||||
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
|
||||
return
|
||||
end
|
||||
self:_close_help()
|
||||
for _, input in ipairs(self._inputs) do
|
||||
input:unmount()
|
||||
end
|
||||
@@ -475,23 +476,57 @@ function M:_render_validation(input)
|
||||
end
|
||||
end
|
||||
|
||||
--- Build a help-line string describing the active keymaps.
|
||||
function M:_help_line()
|
||||
--- Format a keymap value (string or list of strings) for display. Returns
|
||||
--- `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 parts = {}
|
||||
local entries = {}
|
||||
local function add(keys, desc)
|
||||
if keys and keys ~= false and keys ~= "" then
|
||||
table.insert(parts, keys .. " " .. desc)
|
||||
local display = format_keys(keys)
|
||||
if display then
|
||||
table.insert(entries, { display, desc })
|
||||
end
|
||||
end
|
||||
local nxt, prv = format_keys(km.next), format_keys(km.prev)
|
||||
local nav
|
||||
if km.next and km.prev then
|
||||
nav = km.next .. "/" .. km.prev
|
||||
if nxt and prv then
|
||||
nav = nxt .. " / " .. prv
|
||||
else
|
||||
nav = km.next or km.prev
|
||||
nav = nxt or prv
|
||||
end
|
||||
if nav then
|
||||
table.insert(parts, nav .. " navigate")
|
||||
table.insert(entries, { nav, "navigate fields" })
|
||||
end
|
||||
-- Only advertise type-specific keys if the form actually has such an input.
|
||||
local has_select, has_checkbox = false, false
|
||||
@@ -503,14 +538,180 @@ function M:_help_line()
|
||||
end
|
||||
end
|
||||
if has_select then
|
||||
add(km.open_select, "open")
|
||||
add(km.open_select, "open dropdown")
|
||||
end
|
||||
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
|
||||
add(km.submit, "submit")
|
||||
add(km.cancel, "cancel")
|
||||
return table.concat(parts, " ")
|
||||
end
|
||||
|
||||
--- 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
|
||||
return
|
||||
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)
|
||||
if lhs and lhs ~= false then
|
||||
vim.keymap.set(mode, lhs, fn, { buffer = buf, nowait = true, silent = true })
|
||||
if not lhs or lhs == false or lhs == "" then
|
||||
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
|
||||
|
||||
@@ -584,6 +793,12 @@ function M:_install_keymaps(input)
|
||||
self:cancel()
|
||||
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
|
||||
map("n", km.open_select, function()
|
||||
input:open_dropdown()
|
||||
|
||||
@@ -30,7 +30,7 @@ T["setup()"]["exposes defaults"] = function()
|
||||
eq_config(child, "keymaps.next", "<Tab>")
|
||||
eq_config(child, "keymaps.prev", "<S-Tab>")
|
||||
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, "select.max_height", 10)
|
||||
eq_config(child, "multiline.height", 5)
|
||||
|
||||
Reference in New Issue
Block a user