input-form.nvim


Readme

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.

input form example

Features

  • Bordered floating window with optional title
  • Keyboard-navigable: <Tab> / <S-Tab> to move between inputs
  • 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> 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 (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

MethodDescription
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 to config.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 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 "☑" / "☐").
  • 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).

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 (default 1).
  • <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 submitform: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:

GroupPurpose
InputFormNormalParent form window background
InputFormBorderParent form border
InputFormTitleParent form title
InputFormHelpFooter ? help hint on the form border
InputFormFieldIndividual input field background
InputFormFieldBorderIndividual input field border
InputFormFieldTitleIndividual input field label (on top border)
InputFormFieldErrorError message footer on an invalid field
InputFormFieldErrorBorderInvalid field border
InputFormFieldErrorTitleInvalid field label
InputFormDropdownSelect dropdown background
InputFormDropdownActiveHighlighted 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 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:
    {
      '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.