feat: initial commit

Release-As: 0.1.0
This commit is contained in:
2026-04-04 23:58:45 +03:00
commit 7df3775ae7
30 changed files with 2118 additions and 0 deletions

19
.editorconfig Normal file
View File

@@ -0,0 +1,19 @@
root = true
[*]
charset = utf-8
end_of_line = lf
insert_final_newline = true
trim_trailing_whitespace = true
indent_style = space
indent_size = 2
tab_width = 2
[*.lua]
indent_size = 2
[*.md]
trim_trailing_whitespace = false
[Makefile]
indent_style = tab

79
.github/workflows/ci.yml vendored Normal file
View File

@@ -0,0 +1,79 @@
name: CI
on:
push:
branches: [master]
pull_request:
branches: [master]
types: [opened, synchronize]
jobs:
lint:
runs-on: ubuntu-latest
name: lint
steps:
- uses: actions/checkout@v4
- uses: JohnnyMorganz/stylua-action@v4
with:
token: ${{ secrets.GITHUB_TOKEN }}
version: latest
args: --check .
documentation:
runs-on: ubuntu-latest
name: documentation
steps:
- uses: actions/checkout@v4
with:
fetch-depth: 2
- name: setup neovim
uses: rhysd/action-setup-vim@v1
with:
neovim: true
version: stable
- name: generate documentation
run: make documentation-ci
- name: check docs are up-to-date
run: |
git status doc
changed=$(git status --porcelain doc | wc -l | tr -d " ")
if [ "$changed" -ne 0 ]; then
echo "doc/ is out of date — run 'make documentation' and commit the result"
git diff doc
exit 1
fi
tests:
needs:
- lint
- documentation
runs-on: ubuntu-latest
timeout-minutes: 5
strategy:
fail-fast: false
matrix:
neovim_version: ['v0.9.5', 'v0.10.3', 'stable', 'nightly']
steps:
- uses: actions/checkout@v4
- run: date +%F > todays-date
- name: restore cache for today's nightly
uses: actions/cache@v4
with:
path: _neovim
key: ${{ runner.os }}-x64-${{ matrix.neovim_version }}-${{ hashFiles('todays-date') }}
- name: setup neovim
uses: rhysd/action-setup-vim@v1
with:
neovim: true
version: ${{ matrix.neovim_version }}
- name: run tests
run: make test-ci

33
.github/workflows/release.yml vendored Normal file
View File

@@ -0,0 +1,33 @@
name: Release
on:
push:
branches: [master]
permissions:
contents: write
pull-requests: write
jobs:
release-please:
runs-on: ubuntu-latest
steps:
- uses: googleapis/release-please-action@v4
id: release
with:
token: ${{ secrets.RELEASE_PLEASE_TOKEN }}
config-file: release-please-config.json
manifest-file: .release-please-manifest.json
- uses: actions/checkout@v4
if: ${{ steps.release.outputs.release_created }}
- name: tag stable version
if: ${{ steps.release.outputs.release_created }}
run: |
git config user.name "github-actions[bot]"
git config user.email "github-actions[bot]@users.noreply.github.com"
git tag -d stable 2>/dev/null || true
git push origin :refs/tags/stable 2>/dev/null || true
git tag -a stable -m "Last stable release"
git push origin stable

3
.gitignore vendored Normal file
View File

@@ -0,0 +1,3 @@
deps
**.DS_Store
.luarc.json

View File

@@ -0,0 +1,3 @@
{
".": "0.1.0"
}

2
.styluaignore Normal file
View File

@@ -0,0 +1,2 @@
deps/
**/mini/

21
LICENSE Normal file
View File

@@ -0,0 +1,21 @@
MIT License
Copyright (c) 2026 Chen Asraf
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.

58
Makefile Normal file
View File

@@ -0,0 +1,58 @@
.SUFFIXES:
all:
# runs all the test files.
test:
nvim --version | head -n 1 && echo ''
nvim --headless --noplugin -u ./scripts/minimal_init.lua \
-c "lua require('mini.test').setup()" \
-c "lua MiniTest.run({ execute = { reporter = MiniTest.gen_reporter.stdout({ group_depth = 1 }) } })"
# installs `mini.nvim`, used for both the tests and documentation.
deps:
@mkdir -p deps
git clone --depth 1 https://github.com/echasnovski/mini.nvim deps/mini.nvim
# installs deps before running tests, useful for the CI.
test-ci: deps test
# generates the documentation.
documentation:
nvim --headless --noplugin -u ./scripts/minimal_init.lua -c "luafile ./scripts/docgen.lua" -c "qa!"
# installs deps before running the documentation generation, useful for the CI.
documentation-ci: deps documentation
# performs a lint check and fixes issues if possible, following the config in `stylua.toml`.
lint:
stylua .
# installs the repo pre-commit hook that runs `make precommit` before every commit.
precommit-install:
@mkdir -p .git/hooks
@printf '#!/usr/bin/env bash\nmake precommit\n' > .git/hooks/pre-commit
@chmod +x .git/hooks/pre-commit
@echo "pre-commit hook installed at .git/hooks/pre-commit"
# runs the pre-commit checks: lints all Lua files (auto-fixing anything that
# isn't formatted), regenerates docs, and re-stages any files that changed.
precommit:
@set -eu; \
if command -v stylua >/dev/null 2>&1; then \
stylua .; \
reformatted=$$(git diff --name-only -- '*.lua'); \
if [ -n "$$reformatted" ]; then \
echo "precommit: re-staging stylua-formatted files:"; \
echo "$$reformatted" | sed 's/^/ /'; \
git add $$reformatted; \
fi; \
stylua --check .; \
else \
echo "precommit: stylua not installed; skipping lint" >&2; \
fi; \
$(MAKE) documentation; \
git add doc
clean:
rm -rf deps

237
README.md Normal file
View File

@@ -0,0 +1,237 @@
# 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.
## Features
- Bordered floating window with optional title
- Keyboard-navigable: `<Tab>` / `<S-Tab>` to move between inputs
- Input types: `text`, `multiline`, `select`
- Select dropdowns open with `<CR>`; arrows navigate; `<CR>` confirms
- Submit with `<C-s>` — results delivered as a `{ [name] = value }` table
- Cancel with `<Esc>`
- 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
- Auto-generated help doc (`:h input-form`)
- Tested with `mini.test`
## Installation
### lazy.nvim
```lua
{
"chenasraf/input-form.nvim",
config = function()
require("input-form").setup()
end,
}
```
### packer.nvim
```lua
use({
"chenasraf/input-form.nvim",
config = function()
require("input-form").setup()
end,
})
```
### vim-plug
```vim
Plug 'chenasraf/input-form.nvim'
lua require('input-form').setup()
```
## Usage
```lua
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:
```lua
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
All inputs share `name` (string, required — the key in the result table) and
`label` (string, shown above the field).
#### `text`
```lua
{ name = "id", label = "Enter ID", type = "text", default = "sample ID" }
```
#### `multiline`
```lua
{ name = "body", label = "Notes", type = "multiline", default = "", height = 5 }
```
- `height` (optional) — number of rows for the input; falls back to
`config.multiline.height`.
#### `select`
```lua
{
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).
## Configuration
Defaults:
```lua
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 = {
next = "<Tab>",
prev = "<S-Tab>",
submit = "<C-s>",
cancel = "<Esc>",
open_select = "<CR>",
},
select = {
max_height = 10,
},
multiline = {
height = 5,
},
})
```
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:
```lua
-- 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`:
```lua
{
'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](./LICENSE).

140
doc/input-form.txt Normal file
View File

@@ -0,0 +1,140 @@
*input-form.nvim*
A small Neovim plugin for showing bordered floating-window forms made up
of multiple typed inputs (text, multiline, select). Submit results are
returned to a user callback.
==============================================================================
@tag input-form
@tag input-form.nvim
------------------------------------------------------------------------------
*register_helptags()*
`register_helptags`()
Best-effort helptag registration. Runs on first `require('input-form')` so
`:h input-form` works even if the user never calls `setup()`. Idempotent.
------------------------------------------------------------------------------
*input-form.setup*
`M.setup`({opts})
Configure the plugin. Calling this is OPTIONAL — the defaults work without
it, and `create_form` is safe to use on a bare `require('input-form')`.
Useful for end users who want to override defaults globally, or for wrapper
plugins that want to set a baseline config for their consumers.
Parameters ~
{opts} `(table|nil)` See |input-form.config|.
Return ~
`(table)` The merged options table.
Usage ~
`require("input-form").setup({ window = { border = "single" } })`
------------------------------------------------------------------------------
*input-form.create_form*
`M.create_form`({spec})
Create a new form. Does NOT display it — call `form:show()` to open it.
Parameters ~
{spec} `(table)` Form specification:
- `inputs` (table): list of input specs, each with `name`, `label`, `type`
(`"text"`, `"multiline"`, `"select"`), `default?`, and for selects
`options` (list of `{ id, label }`).
- `on_submit` (function|nil): called with a `{ [name] = value }` table.
- `on_cancel` (function|nil): called when the form is cancelled.
- `title` (string|nil): override window title for this form.
- `width` (number|nil): override window width for this form.
Return ~
`(table)` Form instance exposing `:show()`, `:hide()`, `:close()`,
`:submit()`, `:cancel()`, `:results()`.
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 = "Pick one", type = "select",
options = { { id = "a", label = "Alpha" }, { id = "b", label = "Beta" } } },
{ name = "body", label = "Multiline", type = "multiline" },
},
on_submit = function(results) vim.print(results) end,
})
form:show()
<
------------------------------------------------------------------------------
*M.Form*
`M.Form`
Expose the Form class for advanced use.
------------------------------------------------------------------------------
*M.config*
`M.config`
Expose the config module.
==============================================================================
------------------------------------------------------------------------------
*input-form.config*
*Default* *values:*
`M.defaults`
Default configuration for |input-form|.
>lua
M.defaults = {
--- Floating window appearance.
window = {
--- Border style passed to `nvim_open_win`. One of `none`, `single`,
--- `double`, `rounded`, `solid`, `shadow`, or a custom 8-element list.
border = "rounded",
--- Window width in columns. Numbers <= 1 are treated as a ratio of
--- `vim.o.columns` (e.g. `0.6` = 60% of editor width).
width = 60,
--- Window title string. Set to `nil` to omit.
title = " Form ",
--- Title alignment: `"left"`, `"center"`, or `"right"`.
title_pos = "center",
--- Pseudo-transparency (0-100).
winblend = 0,
--- Padding (in cells) between the parent border and the inputs. Applied to
--- all four sides.
padding = 0,
--- Blank rows rendered between adjacent inputs.
gap = 0,
},
--- Keymaps used inside the form. Set any value to `false` to disable.
keymaps = {
--- Focus the next input (wraps).
next = "<Tab>",
--- Focus the previous input (wraps).
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>",
--- Open the dropdown when focused on a `select` input.
open_select = "<CR>",
},
--- Options for `select` inputs.
select = {
--- Maximum number of visible rows in the dropdown before scrolling.
max_height = 10,
},
--- Default height (in rows) for `multiline` inputs that do not specify one.
multiline = {
height = 5,
},
}
M.options = vim.deepcopy(M.defaults)
<
------------------------------------------------------------------------------
*input-form.config.setup*
`M.setup`({user_opts})
Merge user options over defaults and store them on the module.
Parameters ~
{user_opts} `(table|nil)`
Return ~
`(table)`
vim:tw=78:ts=8:noet:ft=help:norl:

10
doc/tags Normal file
View File

@@ -0,0 +1,10 @@
Default input-form.txt /*Default*
M.Form input-form.txt /*M.Form*
M.config input-form.txt /*M.config*
input-form.config input-form.txt /*input-form.config*
input-form.config.setup input-form.txt /*input-form.config.setup*
input-form.create_form input-form.txt /*input-form.create_form*
input-form.nvim input-form.txt /*input-form.nvim*
input-form.setup input-form.txt /*input-form.setup*
register_helptags() input-form.txt /*register_helptags()*
values: input-form.txt /*values:*

67
lua/input-form/config.lua Normal file
View File

@@ -0,0 +1,67 @@
local utils = require("input-form.utils")
local M = {}
--- Default configuration for |input-form|.
---
---@tag input-form.config
---
--- Default values:
---@eval return MiniDoc.afterlines_to_code(MiniDoc.current.eval_section)
M.defaults = {
--- Floating window appearance.
window = {
--- Border style passed to `nvim_open_win`. One of `none`, `single`,
--- `double`, `rounded`, `solid`, `shadow`, or a custom 8-element list.
border = "rounded",
--- Window width in columns. Numbers <= 1 are treated as a ratio of
--- `vim.o.columns` (e.g. `0.6` = 60% of editor width).
width = 60,
--- Window title string. Set to `nil` to omit.
title = " Form ",
--- Title alignment: `"left"`, `"center"`, or `"right"`.
title_pos = "center",
--- Pseudo-transparency (0-100).
winblend = 0,
--- Padding (in cells) between the parent border and the inputs. Applied to
--- all four sides.
padding = 0,
--- Blank rows rendered between adjacent inputs.
gap = 0,
},
--- Keymaps used inside the form. Set any value to `false` to disable.
keymaps = {
--- Focus the next input (wraps).
next = "<Tab>",
--- Focus the previous input (wraps).
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>",
--- Open the dropdown when focused on a `select` input.
open_select = "<CR>",
},
--- Options for `select` inputs.
select = {
--- Maximum number of visible rows in the dropdown before scrolling.
max_height = 10,
},
--- Default height (in rows) for `multiline` inputs that do not specify one.
multiline = {
height = 5,
},
}
M.options = vim.deepcopy(M.defaults)
--- Merge user options over defaults and store them on the module.
---@tag input-form.config.setup
---@param user_opts table|nil
---@return table
function M.setup(user_opts)
M.options = utils.merge(vim.deepcopy(M.defaults), user_opts or {})
return M.options
end
return M

333
lua/input-form/form.lua Normal file
View File

@@ -0,0 +1,333 @@
--- Form object: manages a bordered floating window containing multiple inputs.
local config = require("input-form.config")
local inputs_factory = require("input-form.inputs")
local utils = require("input-form.utils")
local M = {}
M.__index = M
--- Create a new form from its spec. Does NOT open any windows — call `:show()`.
---@param spec table { inputs, on_submit, on_cancel?, title?, width? }
---@return table
function M.new(spec)
assert(type(spec) == "table", "create_form: spec must be a table")
assert(
type(spec.inputs) == "table" and #spec.inputs > 0,
"create_form: spec.inputs must be a non-empty list"
)
local self = setmetatable({
_spec = spec,
_inputs = {},
_on_submit = spec.on_submit,
_on_cancel = spec.on_cancel,
_title = spec.title,
_width = spec.width,
_visible = false,
_closed = false,
_parent_win = nil,
_parent_buf = nil,
_focus_idx = 1,
}, M)
for _, input_spec in ipairs(spec.inputs) do
table.insert(self._inputs, inputs_factory.build(input_spec))
end
return self
end
--- Compute geometry for the parent window and each child input window.
---
--- Each input is a separately-bordered floating window whose label is drawn on
--- its own top border. The parent window contains them all with padding.
---
--- Coordinate reminder: `row`/`col` passed to `nvim_open_win` with a border
--- describe the CONTENT origin; the border is drawn one cell outside that.
function M:_compute_layout()
local opts = config.options
-- `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
if outer_width < needed then
outer_width = needed
end
end
outer_width = utils.clamp(outer_width, 20, vim.o.columns - 4)
local padding = opts.window.padding or 0
local gap = opts.window.gap or 0
local pad_h = padding -- horizontal padding inside parent, each side
local pad_top = padding
local pad_bottom = padding
local sep = gap -- blank rows between inputs
local parent_inner_w = outer_width - 2 -- minus parent border
local child_outer_w = parent_inner_w - pad_h * 2
local child_inner_w = child_outer_w - 2 -- minus child border
local rows = {}
local inner_h = pad_top
for i, input in ipairs(self._inputs) do
local h = input:height()
table.insert(rows, {
top_border_offset = inner_h, -- row inside parent content where child's top border sits
value_height = h,
})
inner_h = inner_h + h + 2 -- child's full outer height (content + 2 border rows)
if i < #self._inputs then
inner_h = inner_h + sep
end
end
inner_h = inner_h + pad_bottom
local outer_h = inner_h + 2 -- plus parent border
local top = math.floor((vim.o.lines - outer_h) / 2)
local left = math.floor((vim.o.columns - outer_width) / 2)
-- Parent content origin (pass to nvim_open_win as row/col).
local parent_row = top + 1
local parent_col = left + 1
return {
outer_width = outer_width,
outer_height = outer_h,
parent_row = parent_row,
parent_col = parent_col,
parent_inner_w = parent_inner_w,
parent_inner_h = inner_h,
child_inner_w = child_inner_w,
pad_h = pad_h,
rows = rows,
}
end
--- Open the form on screen. No-op if already visible.
function M:show()
assert(not self._closed, "form has been closed")
if self._visible then
return self
end
self._visible = true
local layout = self:_compute_layout()
self._layout = layout
-- Parent window: an empty, bordered container that frames the inputs.
self._parent_buf = vim.api.nvim_create_buf(false, true)
vim.bo[self._parent_buf].buftype = "nofile"
vim.bo[self._parent_buf].bufhidden = "wipe"
vim.bo[self._parent_buf].swapfile = false
local parent_lines = {}
for _ = 1, layout.parent_inner_h do
table.insert(parent_lines, string.rep(" ", layout.parent_inner_w))
end
vim.api.nvim_buf_set_lines(self._parent_buf, 0, -1, false, parent_lines)
vim.bo[self._parent_buf].modifiable = false
local win_opts = {
relative = "editor",
row = layout.parent_row,
col = layout.parent_col,
width = layout.parent_inner_w,
height = layout.parent_inner_h,
style = "minimal",
border = config.options.window.border,
focusable = false,
zindex = 40,
}
if config.options.window.title and vim.fn.has("nvim-0.9") == 1 then
win_opts.title = self._title or config.options.window.title
win_opts.title_pos = config.options.window.title_pos
end
if vim.fn.has("nvim-0.10") == 1 then
local footer = self:_help_line()
if footer and footer ~= "" then
win_opts.footer = " " .. footer .. " "
win_opts.footer_pos = "center"
end
end
-- Default highlight for the footer (help text): cyan, overridable by the user.
pcall(vim.api.nvim_set_hl, 0, "InputFormHelp", { fg = "Cyan", default = true })
self._parent_win = vim.api.nvim_open_win(self._parent_buf, false, win_opts)
vim.wo[self._parent_win].winblend = config.options.window.winblend
vim.wo[self._parent_win].winhl = table.concat({
"NormalFloat:InputFormNormal",
"FloatBorder:InputFormBorder",
"FloatTitle:InputFormTitle",
"FloatFooter:InputFormHelp",
}, ",")
-- Mount each input as its own bordered child floating window.
local border = config.options.window.border
for i, input in ipairs(self._inputs) do
local r = layout.rows[i]
-- Child's content origin: inside the parent content area, offset by the
-- row's top_border_offset plus one row for the child's own top border;
-- and one col inside the parent plus horizontal padding plus one for the
-- child's own left border.
input:mount({
row = layout.parent_row + r.top_border_offset + 1,
col = layout.parent_col + layout.pad_h + 1,
width = layout.child_inner_w,
border = border,
})
self:_install_keymaps(input)
end
self:_focus(1)
return self
end
--- Hide the form (close windows) but keep state so `:show()` can reopen it.
function M:hide()
if not self._visible then
return
end
for _, input in ipairs(self._inputs) do
input:unmount()
end
if self._parent_win and vim.api.nvim_win_is_valid(self._parent_win) then
vim.api.nvim_win_close(self._parent_win, true)
end
if self._parent_buf and vim.api.nvim_buf_is_valid(self._parent_buf) then
pcall(vim.api.nvim_buf_delete, self._parent_buf, { force = true })
end
self._parent_win = nil
self._parent_buf = nil
self._visible = false
end
--- Permanently tear down the form.
function M:close()
self:hide()
self._closed = true
end
--- Collect current values from all inputs into a { [name] = value } table.
function M:results()
local out = {}
for _, input in ipairs(self._inputs) do
out[input.name] = input:value()
end
return out
end
--- Submit the form: gather values, close windows, invoke `on_submit(results)`.
function M:submit()
local results = self:results()
self:hide()
if self._on_submit then
self._on_submit(results)
end
end
--- Cancel the form: close windows, invoke `on_cancel()` if provided.
function M:cancel()
self:hide()
if self._on_cancel then
self._on_cancel()
end
end
--- Build a help-line string describing the active keymaps.
function M:_help_line()
local km = config.options.keymaps
local parts = {}
local function add(keys, desc)
if keys and keys ~= false and keys ~= "" then
table.insert(parts, keys .. " " .. desc)
end
end
local nav
if km.next and km.prev then
nav = km.next .. "/" .. km.prev
else
nav = km.next or km.prev
end
if nav then
table.insert(parts, nav .. " navigate")
end
-- Only advertise open_select if the form actually has a select input.
local has_select = false
for _, input in ipairs(self._inputs) do
if input.type == "select" then
has_select = true
break
end
end
if has_select then
add(km.open_select, "open")
end
add(km.submit, "submit")
add(km.cancel, "cancel")
return table.concat(parts, " ")
end
function M:_focus(idx)
local n = #self._inputs
idx = ((idx - 1) % n + n) % n + 1
self._focus_idx = idx
self._inputs[idx]:focus()
end
function M:focus_next()
self:_focus(self._focus_idx + 1)
end
function M:focus_prev()
self:_focus(self._focus_idx - 1)
end
function M:_install_keymaps(input)
local km = config.options.keymaps
local buf = input.buf
if not buf then
return
end
local function map(mode, lhs, fn)
if lhs and lhs ~= false then
vim.keymap.set(mode, lhs, fn, { buffer = buf, nowait = true, silent = true })
end
end
local modes = { "n", "i" }
for _, mode in ipairs(modes) do
map(mode, km.next, function()
self:focus_next()
end)
-- Don't rebind <S-Tab> in insert for multiline to allow natural editing — still useful here.
map(mode, km.prev, function()
self:focus_prev()
end)
map(mode, km.submit, function()
self:submit()
end)
end
-- Cancel only in normal mode to avoid clobbering <Esc> used to leave insert mode.
map("n", km.cancel, function()
self:cancel()
end)
if input.type == "select" then
map("n", km.open_select, function()
input:open_dropdown()
end)
-- Block insert mode on the select display buffer.
vim.keymap.set("n", "i", "<Nop>", { buffer = buf, nowait = true, silent = true })
vim.keymap.set("n", "a", "<Nop>", { buffer = buf, nowait = true, silent = true })
end
end
return M

82
lua/input-form/init.lua Normal file
View File

@@ -0,0 +1,82 @@
--- *input-form.nvim*
---
--- A small Neovim plugin for showing bordered floating-window forms made up
--- of multiple typed inputs (text, multiline, select). Submit results are
--- returned to a user callback.
---
--- ==============================================================================
--- @tag input-form
--- @tag input-form.nvim
local config = require("input-form.config")
local Form = require("input-form.form")
local M = {}
--- Best-effort helptag registration. Runs on first `require('input-form')` so
--- `:h input-form` works even if the user never calls `setup()`. Idempotent.
local function register_helptags()
local source = debug.getinfo(1, "S").source:sub(2)
local plugin_root = vim.fn.fnamemodify(source, ":h:h:h")
local doc_dir = plugin_root .. "/doc"
if vim.fn.isdirectory(doc_dir) == 1 then
pcall(vim.cmd, "silent! helptags " .. vim.fn.fnameescape(doc_dir))
end
end
--- Configure the plugin. Calling this is OPTIONAL — the defaults work without
--- it, and `create_form` is safe to use on a bare `require('input-form')`.
--- Useful for end users who want to override defaults globally, or for wrapper
--- plugins that want to set a baseline config for their consumers.
---@tag input-form.setup
---@param opts table|nil See |input-form.config|.
---@return table The merged options table.
---@usage `require("input-form").setup({ window = { border = "single" } })`
function M.setup(opts)
config.setup(opts)
register_helptags()
return config.options
end
--- Create a new form. Does NOT display it — call `form:show()` to open it.
---
---@tag input-form.create_form
---@param spec table Form specification:
--- - `inputs` (table): list of input specs, each with `name`, `label`, `type`
--- (`"text"`, `"multiline"`, `"select"`), `default?`, and for selects
--- `options` (list of `{ id, label }`).
--- - `on_submit` (function|nil): called with a `{ [name] = value }` table.
--- - `on_cancel` (function|nil): called when the form is cancelled.
--- - `title` (string|nil): override window title for this form.
--- - `width` (number|nil): override window width for this form.
---@return table Form instance exposing `:show()`, `:hide()`, `:close()`,
--- `:submit()`, `:cancel()`, `:results()`.
---@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 = "Pick one", type = "select",
--- options = { { id = "a", label = "Alpha" }, { id = "b", label = "Beta" } } },
--- { name = "body", label = "Multiline", type = "multiline" },
--- },
--- on_submit = function(results) vim.print(results) end,
--- })
--- form:show()
--- <
function M.create_form(spec)
return Form.new(spec)
end
--- Expose the Form class for advanced use.
M.Form = Form
--- Expose the config module.
M.config = config
-- Run once on first require so users/plugin devs don't have to call `setup()`
-- just to get working help tags.
register_helptags()
_G.InputForm = M
return M

View File

@@ -0,0 +1,23 @@
--- Input type registry and factory.
local M = {}
M.types = {
text = require("input-form.inputs.text"),
multiline = require("input-form.inputs.multiline"),
select = require("input-form.inputs.select"),
}
--- Build an input component instance from a user-provided spec.
---@param spec table
---@return table
function M.build(spec)
assert(type(spec) == "table", "input spec must be a table")
assert(type(spec.name) == "string" and spec.name ~= "", "input spec requires a non-empty 'name'")
local t = spec.type or "text"
local impl = M.types[t]
assert(impl, "unknown input type: " .. tostring(t))
return impl.new(spec)
end
return M

View File

@@ -0,0 +1,84 @@
--- Multi-line text input component.
local config = require("input-form.config")
local M = {}
M.__index = M
--- Create a new multiline input from its spec.
---@param spec table { name, label, default?, height? }
---@return table
function M.new(spec)
local h = spec.height or config.options.multiline.height
local default = spec.default or ""
return setmetatable({
type = "multiline",
name = spec.name,
label = spec.label or spec.name,
_value = default,
_height = h,
buf = nil,
win = nil,
}, M)
end
function M:height()
return self._height
end
function M:mount(layout)
self.buf = vim.api.nvim_create_buf(false, true)
vim.bo[self.buf].buftype = "nofile"
vim.bo[self.buf].bufhidden = "wipe"
vim.bo[self.buf].swapfile = false
local lines = vim.split(self._value, "\n", { plain = true })
vim.api.nvim_buf_set_lines(self.buf, 0, -1, false, lines)
local win_cfg = {
relative = "editor",
row = layout.row,
col = layout.col,
width = layout.width,
height = self._height,
style = "minimal",
focusable = true,
zindex = 50,
}
if layout.border then
win_cfg.border = layout.border
win_cfg.title = " " .. self.label .. " "
win_cfg.title_pos = "left"
end
self.win = vim.api.nvim_open_win(self.buf, false, win_cfg)
vim.wo[self.win].winhl =
"NormalFloat:InputFormField,FloatBorder:InputFormFieldBorder,FloatTitle:InputFormFieldTitle"
vim.wo[self.win].wrap = true
end
function M:value()
if self.buf and vim.api.nvim_buf_is_valid(self.buf) then
local lines = vim.api.nvim_buf_get_lines(self.buf, 0, -1, false)
return table.concat(lines, "\n")
end
return self._value
end
function M:unmount()
self._value = self:value()
if self.win and vim.api.nvim_win_is_valid(self.win) then
vim.api.nvim_win_close(self.win, true)
end
if self.buf and vim.api.nvim_buf_is_valid(self.buf) then
vim.api.nvim_buf_delete(self.buf, { force = true })
end
self.win = nil
self.buf = nil
end
function M:focus()
if self.win and vim.api.nvim_win_is_valid(self.win) then
vim.api.nvim_set_current_win(self.win)
end
end
return M

View File

@@ -0,0 +1,212 @@
--- Dropdown / select input component.
---
--- The value returned by `:value()` is the `id` of the selected option, not
--- its label. Opening the dropdown shows all options in a child floating
--- window; j/k/arrows navigate, <CR> confirms, <Esc> cancels.
local config = require("input-form.config")
local M = {}
M.__index = M
--- Create a new select input from its spec.
---@param spec table { name, label, options, default? }
---@return table
function M.new(spec)
assert(
type(spec.options) == "table" and #spec.options > 0,
"select input requires non-empty options"
)
local selected_id = spec.default
if selected_id == nil then
selected_id = spec.options[1].id
end
return setmetatable({
type = "select",
name = spec.name,
label = spec.label or spec.name,
options = spec.options,
_selected_id = selected_id,
buf = nil,
win = nil,
dropdown_buf = nil,
dropdown_win = nil,
}, M)
end
function M:height()
return 1
end
local function label_for(options, id)
for _, opt in ipairs(options) do
if opt.id == id then
return opt.label
end
end
return ""
end
local function format_display(options, id)
return label_for(options, id)
end
function M:_render_display()
if self.buf and vim.api.nvim_buf_is_valid(self.buf) then
vim.bo[self.buf].modifiable = true
vim.api.nvim_buf_set_lines(
self.buf,
0,
-1,
false,
{ format_display(self.options, self._selected_id) }
)
vim.bo[self.buf].modifiable = false
end
end
function M:mount(layout)
self.buf = vim.api.nvim_create_buf(false, true)
vim.bo[self.buf].buftype = "nofile"
vim.bo[self.buf].bufhidden = "wipe"
vim.bo[self.buf].swapfile = false
vim.api.nvim_buf_set_lines(
self.buf,
0,
-1,
false,
{ format_display(self.options, self._selected_id) }
)
vim.bo[self.buf].modifiable = false
local win_cfg = {
relative = "editor",
row = layout.row,
col = layout.col,
width = layout.width,
height = 1,
style = "minimal",
focusable = true,
zindex = 50,
}
if layout.border then
win_cfg.border = layout.border
win_cfg.title = " " .. self.label .. " "
win_cfg.title_pos = "left"
end
self.win = vim.api.nvim_open_win(self.buf, false, win_cfg)
vim.wo[self.win].winhl =
"NormalFloat:InputFormField,FloatBorder:InputFormFieldBorder,FloatTitle:InputFormFieldTitle"
self._layout = layout
end
function M:value()
return self._selected_id
end
function M:unmount()
self:close_dropdown()
if self.win and vim.api.nvim_win_is_valid(self.win) then
vim.api.nvim_win_close(self.win, true)
end
if self.buf and vim.api.nvim_buf_is_valid(self.buf) then
vim.api.nvim_buf_delete(self.buf, { force = true })
end
self.win = nil
self.buf = nil
end
function M:focus()
if self.win and vim.api.nvim_win_is_valid(self.win) then
vim.api.nvim_set_current_win(self.win)
end
end
--- Open the dropdown list as a child floating window anchored below the input.
function M:open_dropdown()
if self.dropdown_win and vim.api.nvim_win_is_valid(self.dropdown_win) then
return
end
local lines = {}
local init_idx = 1
for i, opt in ipairs(self.options) do
table.insert(lines, " " .. opt.label)
if opt.id == self._selected_id then
init_idx = i
end
end
self.dropdown_buf = vim.api.nvim_create_buf(false, true)
vim.bo[self.dropdown_buf].buftype = "nofile"
vim.bo[self.dropdown_buf].bufhidden = "wipe"
vim.api.nvim_buf_set_lines(self.dropdown_buf, 0, -1, false, lines)
vim.bo[self.dropdown_buf].modifiable = false
local max_h = config.options.select.max_height
local height = math.min(#lines, max_h)
-- Position the dropdown's top border immediately beneath the input's bottom
-- border. Content origin row = (input content row) + (input bottom border = 1)
-- + (dropdown top border = 1) + 1 = self._layout.row + 3.
self.dropdown_win = vim.api.nvim_open_win(self.dropdown_buf, true, {
relative = "editor",
row = self._layout.row + 3,
col = self._layout.col,
width = self._layout.width,
height = height,
style = "minimal",
border = "rounded",
focusable = true,
zindex = 100,
})
vim.wo[self.dropdown_win].cursorline = true
vim.wo[self.dropdown_win].winhl =
"NormalFloat:InputFormDropdown,CursorLine:InputFormDropdownActive"
vim.api.nvim_win_set_cursor(self.dropdown_win, { init_idx, 0 })
local function map(lhs, fn)
vim.keymap.set("n", lhs, fn, { buffer = self.dropdown_buf, nowait = true, silent = true })
end
local function confirm()
local row = vim.api.nvim_win_get_cursor(self.dropdown_win)[1]
self._selected_id = self.options[row].id
self:_render_display()
self:close_dropdown()
end
map("<CR>", confirm)
map("<Esc>", function()
self:close_dropdown()
end)
map("q", function()
self:close_dropdown()
end)
end
function M:close_dropdown()
if self.dropdown_win and vim.api.nvim_win_is_valid(self.dropdown_win) then
vim.api.nvim_win_close(self.dropdown_win, true)
end
if self.dropdown_buf and vim.api.nvim_buf_is_valid(self.dropdown_buf) then
pcall(vim.api.nvim_buf_delete, self.dropdown_buf, { force = true })
end
self.dropdown_win = nil
self.dropdown_buf = nil
if self.win and vim.api.nvim_win_is_valid(self.win) then
vim.api.nvim_set_current_win(self.win)
end
end
--- Programmatically set the selected option by id (used in tests & external callers).
function M:select_id(id)
for _, opt in ipairs(self.options) do
if opt.id == id then
self._selected_id = id
self:_render_display()
return true
end
end
return false
end
return M

View File

@@ -0,0 +1,85 @@
--- Single-line text input component.
local M = {}
M.__index = M
--- Create a new text input from its spec.
---@param spec table { name, label, default? }
---@return table
function M.new(spec)
return setmetatable({
type = "text",
name = spec.name,
label = spec.label or spec.name,
_value = spec.default or "",
buf = nil,
win = nil,
}, M)
end
--- Number of content rows (excluding the label line) this input occupies.
function M:height()
return 1
end
--- Create the backing buffer and floating window.
---@param layout table { row, col, width }
function M:mount(layout)
self.buf = vim.api.nvim_create_buf(false, true)
vim.bo[self.buf].buftype = "nofile"
vim.bo[self.buf].bufhidden = "wipe"
vim.bo[self.buf].swapfile = false
vim.api.nvim_buf_set_lines(self.buf, 0, -1, false, { self._value })
local win_cfg = {
relative = "editor",
row = layout.row,
col = layout.col,
width = layout.width,
height = 1,
style = "minimal",
focusable = true,
zindex = 50,
}
if layout.border then
win_cfg.border = layout.border
win_cfg.title = " " .. self.label .. " "
win_cfg.title_pos = "left"
end
self.win = vim.api.nvim_open_win(self.buf, false, win_cfg)
vim.wo[self.win].winhl =
"NormalFloat:InputFormField,FloatBorder:InputFormFieldBorder,FloatTitle:InputFormFieldTitle"
end
--- Return current value (from the buffer if mounted, otherwise the cached value).
function M:value()
if self.buf and vim.api.nvim_buf_is_valid(self.buf) then
local lines = vim.api.nvim_buf_get_lines(self.buf, 0, -1, false)
return lines[1] or ""
end
return self._value
end
--- Close the window and buffer, caching the current value.
function M:unmount()
self._value = self:value()
if self.win and vim.api.nvim_win_is_valid(self.win) then
vim.api.nvim_win_close(self.win, true)
end
if self.buf and vim.api.nvim_buf_is_valid(self.buf) then
vim.api.nvim_buf_delete(self.buf, { force = true })
end
self.win = nil
self.buf = nil
end
--- Give this input focus and enter insert mode at the end of the line.
function M:focus()
if self.win and vim.api.nvim_win_is_valid(self.win) then
vim.api.nvim_set_current_win(self.win)
local line = self:value()
vim.api.nvim_win_set_cursor(self.win, { 1, #line })
end
end
return M

42
lua/input-form/utils.lua Normal file
View File

@@ -0,0 +1,42 @@
local M = {}
--- Deep-merge two tables, with `t2` taking precedence over `t1`.
---@param t1 table
---@param t2 table
---@return table
function M.merge(t1, t2)
return vim.tbl_deep_extend("force", t1 or {}, t2 or {})
end
--- Resolve a width value: if a float 0<v<=1, treat as a ratio of `vim.o.columns`.
---@param value number
---@return integer
function M.resolve_width(value)
if value > 0 and value <= 1 then
return math.floor(vim.o.columns * value)
end
return math.floor(value)
end
--- Resolve a height value similarly against `vim.o.lines`.
---@param value number
---@return integer
function M.resolve_height(value)
if value > 0 and value <= 1 then
return math.floor(vim.o.lines * value)
end
return math.floor(value)
end
--- Clamp an integer into [lo, hi].
function M.clamp(v, lo, hi)
if v < lo then
return lo
end
if v > hi then
return hi
end
return v
end
return M

6
plugin/input-form.lua Normal file
View File

@@ -0,0 +1,6 @@
-- Load guard so the plugin is only initialized once.
if _G.InputFormLoaded then
return
end
_G.InputFormLoaded = true

View File

@@ -0,0 +1,13 @@
{
"$schema": "https://raw.githubusercontent.com/googleapis/release-please/main/schemas/config.json",
"packages": {
".": {
"release-type": "simple",
"package-name": "input-form.nvim",
"changelog-path": "CHANGELOG.md",
"include-component-in-tag": false,
"bump-minor-pre-major": true,
"bump-patch-for-minor-pre-major": true
}
}
}

8
scripts/docgen.lua Normal file
View File

@@ -0,0 +1,8 @@
-- Custom mini.doc entrypoint that only processes the public-facing files so
-- internal `M.new`, `M.merge`, etc. helpers don't collide on duplicate tags.
require("mini.doc").setup()
MiniDoc.generate({
"lua/input-form/init.lua",
"lua/input-form/config.lua",
}, "doc/input-form.txt")

10
scripts/minimal_init.lua Normal file
View File

@@ -0,0 +1,10 @@
-- Add current directory to 'runtimepath' so `lua/input-form/*` is loadable.
vim.cmd([[let &rtp.=','.getcwd()]])
-- Set up mini.test and mini.doc only when running headless (make test / documentation).
if #vim.api.nvim_list_uis() == 0 then
vim.cmd("set rtp+=deps/mini.nvim")
require("mini.test").setup()
require("mini.doc").setup()
end

5
stylua.toml Normal file
View File

@@ -0,0 +1,5 @@
indent_type = "Spaces"
indent_width = 2
column_width = 100
quote_style = "AutoPreferDouble"
no_call_parentheses = false

100
tests/helpers.lua Normal file
View File

@@ -0,0 +1,100 @@
local MiniTest = require("mini.test")
-- Partially adapted from https://github.com/echasnovski/mini.nvim
local Helpers = {}
Helpers.expect = vim.deepcopy(MiniTest.expect)
local function errorMessage(str, pattern)
return string.format("Pattern: %s\nObserved string: %s", vim.inspect(pattern), str)
end
--- Check equality of a global `field` against `value` in the given `child` process.
Helpers.expect.global_equality = MiniTest.new_expectation(
"variable in child process matches",
function(child, field, value)
return Helpers.expect.equality(child.lua_get(field), value)
end,
errorMessage
)
--- Check type equality of a global `field` against `value` in the given `child` process.
Helpers.expect.global_type_equality = MiniTest.new_expectation(
"variable type in child process matches",
function(child, field, value)
return Helpers.expect.global_equality(child, "type(" .. field .. ")", value)
end,
errorMessage
)
--- Check equality of a config `field` against `value` in the given `child` process.
Helpers.expect.config_equality = MiniTest.new_expectation(
"config option matches",
function(child, field, value)
return Helpers.expect.global_equality(
child,
"require('input-form.config').options." .. field,
value
)
end,
errorMessage
)
Helpers.expect.config_type_equality = MiniTest.new_expectation(
"config option type matches",
function(child, field, value)
return Helpers.expect.global_equality(
child,
"type(require('input-form.config').options." .. field .. ")",
value
)
end,
errorMessage
)
Helpers.expect.match = MiniTest.new_expectation("string matching", function(str, pattern)
return str:find(pattern) ~= nil
end, errorMessage)
Helpers.expect.no_match = MiniTest.new_expectation("no string matching", function(str, pattern)
return str:find(pattern) == nil
end, errorMessage)
--- Wrapper around `MiniTest.new_child_neovim` with a few convenience helpers.
Helpers.new_child_neovim = function()
local child = MiniTest.new_child_neovim()
local prevent_hanging = function(method)
if not child.is_blocked() then
return
end
error(string.format("Can not use `child.%s` because child process is blocked.", method))
end
child.setup = function()
child.restart({ "-u", "scripts/minimal_init.lua" })
child.bo.readonly = false
end
child.set_lines = function(arr, start, finish)
prevent_hanging("set_lines")
if type(arr) == "string" then
arr = vim.split(arr, "\n")
end
child.api.nvim_buf_set_lines(0, start or 0, finish or -1, false, arr)
end
child.get_lines = function(start, finish)
prevent_hanging("get_lines")
return child.api.nvim_buf_get_lines(0, start or 0, finish or -1, false)
end
return child
end
--- Initialize the plugin inside a child process, optionally with a config table literal.
function Helpers.init_plugin(child, config)
config = config or ""
child.lua([[require('input-form').setup(]] .. config .. [[)]])
end
return Helpers

62
tests/test_config.lua Normal file
View File

@@ -0,0 +1,62 @@
local helpers = dofile("tests/helpers.lua")
local MiniTest = require("mini.test")
local child = helpers.new_child_neovim()
local eq_global, eq_config = helpers.expect.global_equality, helpers.expect.config_equality
local eq_type_global, eq_type_config =
helpers.expect.global_type_equality, helpers.expect.config_type_equality
local T = MiniTest.new_set({
hooks = {
pre_case = function()
child.restart({ "-u", "scripts/minimal_init.lua" })
end,
post_once = child.stop,
},
})
T["setup()"] = MiniTest.new_set()
T["setup()"]["exposes defaults"] = function()
child.lua([[require('input-form').setup()]])
eq_type_global(child, "_G.InputForm", "table")
eq_type_config(child, "window", "table")
eq_config(child, "window.border", "rounded")
eq_config(child, "window.width", 60)
eq_config(child, "window.padding", 0)
eq_config(child, "window.gap", 0)
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.open_select", "<CR>")
eq_config(child, "select.max_height", 10)
eq_config(child, "multiline.height", 5)
end
T["setup()"]["deep-merges user options"] = function()
helpers.init_plugin(
child,
[[{
window = { border = "single", width = 80 },
keymaps = { submit = "<C-y>" },
}]]
)
eq_config(child, "window.border", "single")
eq_config(child, "window.width", 80)
-- untouched default preserved
eq_config(child, "window.title", " Form ")
eq_config(child, "keymaps.submit", "<C-y>")
-- untouched keymap defaults preserved
eq_config(child, "keymaps.next", "<Tab>")
end
T["setup()"]["setup() without doc dir does not error"] = function()
-- setup should be safe even if doc/ is missing (uses pcall around helptags)
child.lua([[require('input-form').setup()]])
eq_global(child, "type(_G.InputForm.create_form)", "function")
end
return T

171
tests/test_form.lua Normal file
View File

@@ -0,0 +1,171 @@
local helpers = dofile("tests/helpers.lua")
local MiniTest = require("mini.test")
local child = helpers.new_child_neovim()
local eq = helpers.expect.equality
local T = MiniTest.new_set({
hooks = {
pre_case = function()
child.restart({ "-u", "scripts/minimal_init.lua" })
child.o.lines = 40
child.o.columns = 120
child.lua([[require('input-form').setup()]])
child.lua([[
_G.make_form = function()
return require('input-form').create_form({
inputs = {
{ name = 'id', label = 'Enter ID', type = 'text', default = 'sample ID' },
{ name = 'pick', label = 'Pick one', type = 'select',
options = { { id = 'a', label = 'Alpha' }, { id = 'b', label = 'Beta' } },
default = 'a',
},
{ name = 'body', label = 'Multiline', type = 'multiline', default = 'x\ny' },
},
on_submit = function(r) _G.submit_result = r end,
on_cancel = function() _G.cancel_called = true end,
})
end
]])
end,
post_once = child.stop,
},
})
T["form"] = MiniTest.new_set()
T["form"]["create_form does not open any windows"] = function()
child.lua([[_G.f = _G.make_form()]])
eq(child.lua_get([[_G.f._visible]]), false)
eq(child.lua_get([[#_G.f._inputs]]), 3)
end
T["form"]["show() opens parent + one window per input"] = function()
child.lua([[_G.f = _G.make_form(); _G.f:show()]])
eq(child.lua_get([[_G.f._visible]]), true)
eq(child.lua_get([[vim.api.nvim_win_is_valid(_G.f._parent_win)]]), true)
eq(child.lua_get([[vim.api.nvim_win_is_valid(_G.f._inputs[1].win)]]), true)
eq(child.lua_get([[vim.api.nvim_win_is_valid(_G.f._inputs[2].win)]]), true)
eq(child.lua_get([[vim.api.nvim_win_is_valid(_G.f._inputs[3].win)]]), true)
-- First input is focused.
eq(child.lua_get([[vim.api.nvim_get_current_win() == _G.f._inputs[1].win]]), true)
end
T["form"]["show() is idempotent"] = function()
child.lua([[
_G.f = _G.make_form()
_G.f:show()
_G.w1 = _G.f._parent_win
_G.f:show()
_G.w2 = _G.f._parent_win
]])
eq(child.lua_get([[_G.w1 == _G.w2]]), true)
end
T["form"]["focus_next / focus_prev cycle and wrap"] = function()
child.lua([[_G.f = _G.make_form(); _G.f:show()]])
eq(child.lua_get([[_G.f._focus_idx]]), 1)
child.lua([[_G.f:focus_next()]])
eq(child.lua_get([[_G.f._focus_idx]]), 2)
child.lua([[_G.f:focus_next()]])
eq(child.lua_get([[_G.f._focus_idx]]), 3)
child.lua([[_G.f:focus_next()]]) -- wraps
eq(child.lua_get([[_G.f._focus_idx]]), 1)
child.lua([[_G.f:focus_prev()]]) -- wraps backwards
eq(child.lua_get([[_G.f._focus_idx]]), 3)
end
T["form"]["submit collects values and closes windows"] = function()
child.lua([[
_G.f = _G.make_form()
_G.f:show()
-- Modify the text input buffer.
vim.api.nvim_buf_set_lines(_G.f._inputs[1].buf, 0, -1, false, { 'new id' })
-- Change the select.
_G.f._inputs[2]:select_id('b')
-- Modify the multiline.
vim.api.nvim_buf_set_lines(_G.f._inputs[3].buf, 0, -1, false, { 'one', 'two' })
_G.f:submit()
]])
eq(child.lua_get([[_G.submit_result]]), { id = "new id", pick = "b", body = "one\ntwo" })
eq(child.lua_get([[_G.f._visible]]), false)
end
T["form"]["cancel invokes on_cancel and closes windows"] = function()
child.lua([[
_G.f = _G.make_form()
_G.f:show()
_G.f:cancel()
]])
eq(child.lua_get([[_G.cancel_called]]), true)
eq(child.lua_get([[_G.f._visible]]), false)
eq(child.lua_get([[_G.submit_result]]), vim.NIL)
end
T["form"]["hide preserves values across show() cycles"] = function()
child.lua([[
_G.f = _G.make_form()
_G.f:show()
vim.api.nvim_buf_set_lines(_G.f._inputs[1].buf, 0, -1, false, { 'persisted' })
_G.f:hide()
]])
eq(child.lua_get([[_G.f._visible]]), false)
child.lua([[_G.f:show()]])
eq(child.lua_get([[_G.f._inputs[1]:value()]]), "persisted")
end
T["form"]["close() marks form as unusable"] = function()
child.lua([[_G.f = _G.make_form(); _G.f:show(); _G.f:close()]])
local ok =
child.lua_get([[(function() local ok = pcall(function() _G.f:show() end) return ok end)()]])
eq(ok, false)
end
T["form"]["invalid spec raises"] = function()
local ok_no_name = child.lua_get([[
(function()
local ok = pcall(function()
require('input-form').create_form({ inputs = { { type = 'text' } } })
end)
return ok
end)()
]])
eq(ok_no_name, false)
local ok_bad_type = child.lua_get([[
(function()
local ok = pcall(function()
require('input-form').create_form({ inputs = { { name = 'x', type = 'unknown' } } })
end)
return ok
end)()
]])
eq(ok_bad_type, false)
local ok_empty = child.lua_get([[
(function()
local ok = pcall(function()
require('input-form').create_form({ inputs = {} })
end)
return ok
end)()
]])
eq(ok_empty, false)
end
T["form"]["keymaps are installed on each input buffer"] = function()
child.lua([[_G.f = _G.make_form(); _G.f:show()]])
-- <Tab> should be mapped in normal mode on the first input's buffer.
local has_tab = child.lua_get([[
(function()
local maps = vim.api.nvim_buf_get_keymap(_G.f._inputs[1].buf, 'n')
for _, m in ipairs(maps) do
if m.lhs == '<Tab>' then return true end
end
return false
end)()
]])
eq(has_tab, true)
end
return T

View File

@@ -0,0 +1,52 @@
local helpers = dofile("tests/helpers.lua")
local MiniTest = require("mini.test")
local child = helpers.new_child_neovim()
local eq = helpers.expect.equality
local T = MiniTest.new_set({
hooks = {
pre_case = function()
child.restart({ "-u", "scripts/minimal_init.lua" })
child.lua([[require('input-form').setup()]])
end,
post_once = child.stop,
},
})
T["multiline input"] = MiniTest.new_set()
T["multiline input"]["uses config default height"] = function()
child.lua([[_G.t = require('input-form.inputs.multiline').new({ name = 'm', label = 'M' })]])
eq(child.lua_get([[_G.t:height()]]), 5)
end
T["multiline input"]["respects spec-level height"] = function()
child.lua(
[[_G.t = require('input-form.inputs.multiline').new({ name = 'm', label = 'M', height = 3 })]]
)
eq(child.lua_get([[_G.t:height()]]), 3)
end
T["multiline input"]["splits default on newlines"] = function()
child.lua([[
_G.t = require('input-form.inputs.multiline').new({ name = 'm', label = 'M', default = 'line1\nline2\nline3' })
_G.t:mount({ row = 5, col = 5, width = 30 })
]])
eq(
child.lua_get([[vim.api.nvim_buf_get_lines(_G.t.buf, 0, -1, false)]]),
{ "line1", "line2", "line3" }
)
eq(child.lua_get([[_G.t:value()]]), "line1\nline2\nline3")
end
T["multiline input"]["joins buffer lines on value"] = function()
child.lua([[
_G.t = require('input-form.inputs.multiline').new({ name = 'm', label = 'M', height = 3 })
_G.t:mount({ row = 5, col = 5, width = 30 })
vim.api.nvim_buf_set_lines(_G.t.buf, 0, -1, false, { 'a', 'b', 'c' })
]])
eq(child.lua_get([[_G.t:value()]]), "a\nb\nc")
end
return T

View File

@@ -0,0 +1,104 @@
local helpers = dofile("tests/helpers.lua")
local MiniTest = require("mini.test")
local child = helpers.new_child_neovim()
local eq = helpers.expect.equality
local T = MiniTest.new_set({
hooks = {
pre_case = function()
child.restart({ "-u", "scripts/minimal_init.lua" })
child.lua([[require('input-form').setup()]])
child.lua([[
_G.mk = function(default)
return require('input-form.inputs.select').new({
name = 's', label = 'S',
default = default,
options = {
{ id = 'a', label = 'Alpha' },
{ id = 'b', label = 'Beta' },
{ id = 'c', label = 'Gamma' },
},
})
end
]])
end,
post_once = child.stop,
},
})
T["select input"] = MiniTest.new_set()
T["select input"]["defaults to first option when none given"] = function()
child.lua([[_G.t = _G.mk(nil); _G.t:mount({ row = 5, col = 5, width = 30 })]])
eq(child.lua_get([[_G.t:value()]]), "a")
eq(child.lua_get([[vim.api.nvim_buf_get_lines(_G.t.buf, 0, -1, false)]]), { "Alpha" })
end
T["select input"]["honors explicit default"] = function()
child.lua([[_G.t = _G.mk('b'); _G.t:mount({ row = 5, col = 5, width = 30 })]])
eq(child.lua_get([[_G.t:value()]]), "b")
eq(child.lua_get([[vim.api.nvim_buf_get_lines(_G.t.buf, 0, -1, false)]]), { "Beta" })
end
T["select input"]["display buffer is read-only"] = function()
child.lua([[_G.t = _G.mk('a'); _G.t:mount({ row = 5, col = 5, width = 30 })]])
eq(child.lua_get([[vim.bo[_G.t.buf].modifiable]]), false)
end
T["select input"]["select_id updates value and display"] = function()
child.lua([[
_G.t = _G.mk('a')
_G.t:mount({ row = 5, col = 5, width = 30 })
_G.ok = _G.t:select_id('c')
]])
eq(child.lua_get([[_G.ok]]), true)
eq(child.lua_get([[_G.t:value()]]), "c")
eq(child.lua_get([[vim.api.nvim_buf_get_lines(_G.t.buf, 0, -1, false)]]), { "Gamma" })
end
T["select input"]["open_dropdown shows all options and <CR> confirms"] = function()
child.lua([[
_G.t = _G.mk('a')
_G.t:mount({ row = 5, col = 5, width = 30 })
_G.t:open_dropdown()
]])
eq(
child.lua_get([[vim.api.nvim_buf_get_lines(_G.t.dropdown_buf, 0, -1, false)]]),
{ " Alpha", " Beta", " Gamma" }
)
-- Move to row 2 (Beta) and confirm via the keymap callback.
child.lua([[
vim.api.nvim_win_set_cursor(_G.t.dropdown_win, { 2, 0 })
-- Fire the <CR> mapping we installed.
vim.api.nvim_feedkeys(vim.api.nvim_replace_termcodes('<CR>', true, false, true), 'x', false)
]])
eq(child.lua_get([[_G.t:value()]]), "b")
eq(child.lua_get([[_G.t.dropdown_win]]), vim.NIL)
end
T["select input"]["<Esc> closes dropdown without changing value"] = function()
child.lua([[
_G.t = _G.mk('a')
_G.t:mount({ row = 5, col = 5, width = 30 })
_G.t:open_dropdown()
vim.api.nvim_win_set_cursor(_G.t.dropdown_win, { 3, 0 })
vim.api.nvim_feedkeys(vim.api.nvim_replace_termcodes('<Esc>', true, false, true), 'x', false)
]])
eq(child.lua_get([[_G.t:value()]]), "a")
eq(child.lua_get([[_G.t.dropdown_win]]), vim.NIL)
end
T["select input"]["rejects empty options list"] = function()
local ok = child.lua_get([[
(function()
local ok, err = pcall(function()
require('input-form.inputs.select').new({ name = 's', label = 'S', options = {} })
end)
return ok
end)()
]])
eq(ok, false)
end
return T

View File

@@ -0,0 +1,54 @@
local helpers = dofile("tests/helpers.lua")
local MiniTest = require("mini.test")
local child = helpers.new_child_neovim()
local eq = helpers.expect.equality
local T = MiniTest.new_set({
hooks = {
pre_case = function()
child.restart({ "-u", "scripts/minimal_init.lua" })
child.lua([[require('input-form').setup()]])
end,
post_once = child.stop,
},
})
T["text input"] = MiniTest.new_set()
T["text input"]["seeds default value"] = function()
child.lua([[
_G.t = require('input-form.inputs.text').new({ name = 'x', label = 'X', default = 'hello' })
_G.t:mount({ row = 5, col = 5, width = 30 })
]])
eq(child.lua_get([[_G.t:value()]]), "hello")
eq(child.lua_get([[vim.api.nvim_buf_get_lines(_G.t.buf, 0, -1, false)]]), { "hello" })
end
T["text input"]["reflects buffer edits"] = function()
child.lua([[
_G.t = require('input-form.inputs.text').new({ name = 'x', label = 'X' })
_G.t:mount({ row = 5, col = 5, width = 30 })
vim.api.nvim_buf_set_lines(_G.t.buf, 0, -1, false, { 'typed text' })
]])
eq(child.lua_get([[_G.t:value()]]), "typed text")
end
T["text input"]["unmount caches value and closes window"] = function()
child.lua([[
_G.t = require('input-form.inputs.text').new({ name = 'x', label = 'X', default = 'abc' })
_G.t:mount({ row = 5, col = 5, width = 30 })
vim.api.nvim_buf_set_lines(_G.t.buf, 0, -1, false, { 'updated' })
_G.t:unmount()
]])
eq(child.lua_get([[_G.t.win]]), vim.NIL)
eq(child.lua_get([[_G.t.buf]]), vim.NIL)
eq(child.lua_get([[_G.t:value()]]), "updated")
end
T["text input"]["height is 1"] = function()
child.lua([[_G.t = require('input-form.inputs.text').new({ name = 'x', label = 'X' })]])
eq(child.lua_get([[_G.t:height()]]), 1)
end
return T