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
|
# 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.
|
|
||||||
|
|
||||||

|

|
||||||
|
|
||||||
@@ -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
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -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 = {
|
||||||
|
|||||||
@@ -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 = {
|
||||||
|
|||||||
@@ -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()
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|||||||
Reference in New Issue
Block a user