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, checkboxes), collect results via an on_submit callback.

Features
- Bordered floating window with optional title
- Keyboard-navigable:
<Tab>/<S-Tab>to move between inputs - Input types:
text,multiline,select,checkbox, plusspacer(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>orq - Built-in toggleable help popup (
?) listing every active keymap — updates automatically when you remap keys - Lazy:
create_formbuilds the form;:show()renders it when you want :hide()/:show()re-open a form while preserving in-progress values- Fully configurable keymaps (strings or lists), border, width, title
- Auto-generated help doc (
:h input-form) - Tested with
mini.test
Installation
lazy.nvim
{
"chenasraf/input-form.nvim",
config = function()
require("input-form").setup()
end,
}
packer.nvim
use({
"chenasraf/input-form.nvim",
config = function()
require("input-form").setup()
end,
})
vim-plug
Plug 'chenasraf/input-form.nvim'
lua require('input-form').setup()
Usage
local f = require("input-form")
local form = f.create_form({
inputs = {
{ name = "id", label = "Enter ID", type = "text", default = "sample ID" },
{
name = "choice",
label = "Select an option",
type = "select",
options = {
{ id = "opt1", label = "Option 1" },
{ id = "opt2", label = "Option 2" },
},
},
{ name = "body", label = "Enter multiline text", type = "multiline" },
},
on_submit = function(results)
vim.print(results) -- { id = "...", choice = "opt1", body = "..." }
end,
on_cancel = function()
vim.notify("cancelled")
end,
})
-- Create once, show on demand:
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:
vim.keymap.set("n", "<leader>xf", function()
form:show()
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. |
Input spec reference
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
{ name = "id", label = "Enter ID", type = "text", default = "sample ID" }
multiline
{ name = "body", label = "Notes", type = "multiline", default = "", height = 5 }
height(optional) — number of rows for the input; falls back toconfig.multiline.height.
select
{
name = "choice",
label = "Pick one",
type = "select",
default = "opt1", -- optional; defaults to first option's id
options = {
{ id = "opt1", label = "Option 1" },
{ id = "opt2", label = "Option 2" },
},
}
value() returns the selected id (not the label).
checkbox
{ 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:
☐ I agree (must be checked)
default(optional) — boolean (defaults tofalse).value()returns a boolean.- Toggled with the configured
keymaps.togglekey (default<Space>) or thekeymaps.open_selectkey (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(default1, set to0to pack tight). - Pair with
validators.checked()to require the box to be ticked (see Validation).
spacer
{ 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:
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 (default1).<Tab>/<S-Tab>skip over spacers automatically.
Validation
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:
- 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.
Built-in validators
local V = require("input-form").validators
V.non_empty([msg]) -- require a non-empty value
V.min_length(n, [msg]) -- at least `n` characters
V.max_length(n, [msg]) -- at most `n` characters
V.matches(lua_pattern, [msg]) -- match a Lua pattern
V.is_number([msg]) -- tonumber() must succeed
V.checked([required], [msg]) -- checkbox must equal `required`
-- (default true; default messages
-- "(must be checked)" /
-- "(must be unchecked)")
V.one_of({ "a", "b", ... }, [msg]) -- value must be in the list
V.custom(predicate, msg) -- wrap a `fun(v): boolean` predicate
V.chain(v1, v2, ...) -- run validators in order, first error wins
Example:
local f = require("input-form")
local V = f.validators
f.create_form({
inputs = {
{
name = "id",
label = "Enter ID",
type = "text",
validator = V.chain(
V.non_empty(),
V.min_length(3),
V.matches("^[%w_-]+$", "Only letters, digits, - and _")
),
},
{
name = "age",
label = "Age",
type = "text",
validator = V.chain(V.non_empty(), V.is_number()),
},
},
on_submit = function(results)
vim.print(results) -- only runs if every validator passes
end,
}):show()
Custom validators are just functions — no need to use the builder helpers if you’d rather write one inline:
validator = function(value)
if value == "admin" then
return "Username 'admin' is reserved"
end
end
Checkbox glyphs and padding
Override the characters rendered by checkbox inputs via style.checkbox:
require("input-form").setup({
style = {
checkbox = {
checked = "☑", -- default
unchecked = "☐", -- default
padding = 1, -- default: blank rows above/below each checkbox
},
},
})
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:
require("input-form").setup({
style = {
chevron = {
closed = "⌄", -- default
open = "⌃", -- default
},
},
})
Use whatever you like — e.g. ASCII fallbacks for terminals without good Unicode support:
style = { chevron = { closed = " v", open = " ^" } }
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.).
require("input-form").setup({
style = {
highlights = {
-- error state
InputFormFieldError = { fg = "#ff5555", italic = true },
InputFormFieldErrorBorder = { fg = "#ff5555" },
InputFormFieldErrorTitle = { fg = "#ff5555", bold = true },
-- help footer
InputFormHelp = { fg = "#88ccff" },
-- parent frame border via link
InputFormBorder = { link = "Comment" },
},
},
})
Available groups:
| Group | Purpose |
|---|---|
InputFormNormal | Parent form window background |
InputFormBorder | Parent form border |
InputFormTitle | Parent form title |
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) |
InputFormFieldError | Error message footer on an invalid field |
InputFormFieldErrorBorder | Invalid field border |
InputFormFieldErrorTitle | Invalid field label |
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.
Configuration
Defaults:
require("input-form").setup({
window = {
border = "rounded", -- any nvim_open_win border
width = 60, -- number of columns; <= 1 treated as ratio
title = " Form ",
title_pos = "center",
winblend = 0,
padding = 0, -- cells between the outer border and inputs (all sides)
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>", "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,
},
multiline = {
height = 5,
},
style = {
checkbox = {
checked = "☑",
unchecked = "☐",
padding = 1, -- blank rows above/below each checkbox
},
-- ...chevron, highlights, etc. — see sections above.
},
})
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:
:h input-form
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:
-- In your plugin's code:
local ok, input_form = pcall(require, 'input-form')
if not ok then
vim.notify('my-plugin: input-form.nvim is required', vim.log.levels.ERROR)
return
end
input_form.create_form({
inputs = { ... },
on_submit = function(results) ... end,
}):show()
Key points:
- No
setup()required. Defaults are loaded at module-load time andcreate_form/form:show()work on a barerequire('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 thecreate_formspec — 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:{ 'your-name/your-plugin.nvim', dependencies = { 'chenasraf/input-form.nvim' }, }
Contributing & development
make deps # install mini.nvim into deps/
make test # run the test suite (mini.test)
make documentation # regenerate doc/input-form.txt (mini.doc)
make lint # stylua check
License
MIT — see LICENSE.