mirror of
https://github.com/chenasraf/input-form.nvim.git
synced 2026-05-17 17:38:01 +00:00
feat: initial commit
Release-As: 0.1.0
This commit is contained in:
19
.editorconfig
Normal file
19
.editorconfig
Normal 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
79
.github/workflows/ci.yml
vendored
Normal 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
33
.github/workflows/release.yml
vendored
Normal 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
3
.gitignore
vendored
Normal file
@@ -0,0 +1,3 @@
|
||||
deps
|
||||
**.DS_Store
|
||||
.luarc.json
|
||||
3
.release-please-manifest.json
Normal file
3
.release-please-manifest.json
Normal file
@@ -0,0 +1,3 @@
|
||||
{
|
||||
".": "0.1.0"
|
||||
}
|
||||
2
.styluaignore
Normal file
2
.styluaignore
Normal file
@@ -0,0 +1,2 @@
|
||||
deps/
|
||||
**/mini/
|
||||
21
LICENSE
Normal file
21
LICENSE
Normal 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
58
Makefile
Normal 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
237
README.md
Normal 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
140
doc/input-form.txt
Normal 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
10
doc/tags
Normal 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
67
lua/input-form/config.lua
Normal 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
333
lua/input-form/form.lua
Normal 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
82
lua/input-form/init.lua
Normal 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
|
||||
23
lua/input-form/inputs/init.lua
Normal file
23
lua/input-form/inputs/init.lua
Normal 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
|
||||
84
lua/input-form/inputs/multiline.lua
Normal file
84
lua/input-form/inputs/multiline.lua
Normal 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
|
||||
212
lua/input-form/inputs/select.lua
Normal file
212
lua/input-form/inputs/select.lua
Normal 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
|
||||
85
lua/input-form/inputs/text.lua
Normal file
85
lua/input-form/inputs/text.lua
Normal 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
42
lua/input-form/utils.lua
Normal 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
6
plugin/input-form.lua
Normal file
@@ -0,0 +1,6 @@
|
||||
-- Load guard so the plugin is only initialized once.
|
||||
if _G.InputFormLoaded then
|
||||
return
|
||||
end
|
||||
|
||||
_G.InputFormLoaded = true
|
||||
13
release-please-config.json
Normal file
13
release-please-config.json
Normal 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
8
scripts/docgen.lua
Normal 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
10
scripts/minimal_init.lua
Normal 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
5
stylua.toml
Normal 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
100
tests/helpers.lua
Normal 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
62
tests/test_config.lua
Normal 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
171
tests/test_form.lua
Normal 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
|
||||
52
tests/test_inputs_multiline.lua
Normal file
52
tests/test_inputs_multiline.lua
Normal 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
|
||||
104
tests/test_inputs_select.lua
Normal file
104
tests/test_inputs_select.lua
Normal 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
|
||||
54
tests/test_inputs_text.lua
Normal file
54
tests/test_inputs_text.lua
Normal 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
|
||||
Reference in New Issue
Block a user