From 7df3775ae7a1e08758dcda3333abf0f93d51a863 Mon Sep 17 00:00:00 2001 From: Chen Asraf Date: Sat, 4 Apr 2026 23:58:45 +0300 Subject: [PATCH] feat: initial commit Release-As: 0.1.0 --- .editorconfig | 19 ++ .github/workflows/ci.yml | 79 +++++++ .github/workflows/release.yml | 33 +++ .gitignore | 3 + .release-please-manifest.json | 3 + .styluaignore | 2 + LICENSE | 21 ++ Makefile | 58 +++++ README.md | 237 ++++++++++++++++++++ doc/input-form.txt | 140 ++++++++++++ doc/tags | 10 + lua/input-form/config.lua | 67 ++++++ lua/input-form/form.lua | 333 ++++++++++++++++++++++++++++ lua/input-form/init.lua | 82 +++++++ lua/input-form/inputs/init.lua | 23 ++ lua/input-form/inputs/multiline.lua | 84 +++++++ lua/input-form/inputs/select.lua | 212 ++++++++++++++++++ lua/input-form/inputs/text.lua | 85 +++++++ lua/input-form/utils.lua | 42 ++++ plugin/input-form.lua | 6 + release-please-config.json | 13 ++ scripts/docgen.lua | 8 + scripts/minimal_init.lua | 10 + stylua.toml | 5 + tests/helpers.lua | 100 +++++++++ tests/test_config.lua | 62 ++++++ tests/test_form.lua | 171 ++++++++++++++ tests/test_inputs_multiline.lua | 52 +++++ tests/test_inputs_select.lua | 104 +++++++++ tests/test_inputs_text.lua | 54 +++++ 30 files changed, 2118 insertions(+) create mode 100644 .editorconfig create mode 100644 .github/workflows/ci.yml create mode 100644 .github/workflows/release.yml create mode 100644 .gitignore create mode 100644 .release-please-manifest.json create mode 100644 .styluaignore create mode 100644 LICENSE create mode 100644 Makefile create mode 100644 README.md create mode 100644 doc/input-form.txt create mode 100644 doc/tags create mode 100644 lua/input-form/config.lua create mode 100644 lua/input-form/form.lua create mode 100644 lua/input-form/init.lua create mode 100644 lua/input-form/inputs/init.lua create mode 100644 lua/input-form/inputs/multiline.lua create mode 100644 lua/input-form/inputs/select.lua create mode 100644 lua/input-form/inputs/text.lua create mode 100644 lua/input-form/utils.lua create mode 100644 plugin/input-form.lua create mode 100644 release-please-config.json create mode 100644 scripts/docgen.lua create mode 100644 scripts/minimal_init.lua create mode 100644 stylua.toml create mode 100644 tests/helpers.lua create mode 100644 tests/test_config.lua create mode 100644 tests/test_form.lua create mode 100644 tests/test_inputs_multiline.lua create mode 100644 tests/test_inputs_select.lua create mode 100644 tests/test_inputs_text.lua diff --git a/.editorconfig b/.editorconfig new file mode 100644 index 0000000..7388911 --- /dev/null +++ b/.editorconfig @@ -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 diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml new file mode 100644 index 0000000..4cdfe8e --- /dev/null +++ b/.github/workflows/ci.yml @@ -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 diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml new file mode 100644 index 0000000..9e9e7dd --- /dev/null +++ b/.github/workflows/release.yml @@ -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 diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..4b3caad --- /dev/null +++ b/.gitignore @@ -0,0 +1,3 @@ +deps +**.DS_Store +.luarc.json diff --git a/.release-please-manifest.json b/.release-please-manifest.json new file mode 100644 index 0000000..466df71 --- /dev/null +++ b/.release-please-manifest.json @@ -0,0 +1,3 @@ +{ + ".": "0.1.0" +} diff --git a/.styluaignore b/.styluaignore new file mode 100644 index 0000000..c7f3cb4 --- /dev/null +++ b/.styluaignore @@ -0,0 +1,2 @@ +deps/ +**/mini/ diff --git a/LICENSE b/LICENSE new file mode 100644 index 0000000..b6e0250 --- /dev/null +++ b/LICENSE @@ -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. diff --git a/Makefile b/Makefile new file mode 100644 index 0000000..3f7e5a7 --- /dev/null +++ b/Makefile @@ -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 diff --git a/README.md b/README.md new file mode 100644 index 0000000..8c9138d --- /dev/null +++ b/README.md @@ -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: `` / `` to move between inputs +- Input types: `text`, `multiline`, `select` +- Select dropdowns open with ``; arrows navigate; `` confirms +- Submit with `` — results delivered as a `{ [name] = value }` table +- Cancel with `` +- 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", "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 = "", + prev = "", + submit = "", + cancel = "", + open_select = "", + }, + 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). diff --git a/doc/input-form.txt b/doc/input-form.txt new file mode 100644 index 0000000..763d5b9 --- /dev/null +++ b/doc/input-form.txt @@ -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 = "", + --- Focus the previous input (wraps). + prev = "", + --- Submit the form and invoke `on_submit(results)`. + submit = "", + --- Cancel the form and invoke `on_cancel()` if provided. + cancel = "", + --- Open the dropdown when focused on a `select` input. + open_select = "", + }, + --- 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: \ No newline at end of file diff --git a/doc/tags b/doc/tags new file mode 100644 index 0000000..33d95f0 --- /dev/null +++ b/doc/tags @@ -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:* diff --git a/lua/input-form/config.lua b/lua/input-form/config.lua new file mode 100644 index 0000000..c840fe4 --- /dev/null +++ b/lua/input-form/config.lua @@ -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 = "", + --- Focus the previous input (wraps). + prev = "", + --- Submit the form and invoke `on_submit(results)`. + submit = "", + --- Cancel the form and invoke `on_cancel()` if provided. + cancel = "", + --- Open the dropdown when focused on a `select` input. + open_select = "", + }, + --- 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 diff --git a/lua/input-form/form.lua b/lua/input-form/form.lua new file mode 100644 index 0000000..72825ff --- /dev/null +++ b/lua/input-form/form.lua @@ -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 " " 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 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 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", "", { buffer = buf, nowait = true, silent = true }) + vim.keymap.set("n", "a", "", { buffer = buf, nowait = true, silent = true }) + end +end + +return M diff --git a/lua/input-form/init.lua b/lua/input-form/init.lua new file mode 100644 index 0000000..e3069e6 --- /dev/null +++ b/lua/input-form/init.lua @@ -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 diff --git a/lua/input-form/inputs/init.lua b/lua/input-form/inputs/init.lua new file mode 100644 index 0000000..7deb3cd --- /dev/null +++ b/lua/input-form/inputs/init.lua @@ -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 diff --git a/lua/input-form/inputs/multiline.lua b/lua/input-form/inputs/multiline.lua new file mode 100644 index 0000000..d9e51de --- /dev/null +++ b/lua/input-form/inputs/multiline.lua @@ -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 diff --git a/lua/input-form/inputs/select.lua b/lua/input-form/inputs/select.lua new file mode 100644 index 0000000..aca3b28 --- /dev/null +++ b/lua/input-form/inputs/select.lua @@ -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, confirms, 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("", confirm) + map("", 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 diff --git a/lua/input-form/inputs/text.lua b/lua/input-form/inputs/text.lua new file mode 100644 index 0000000..931e633 --- /dev/null +++ b/lua/input-form/inputs/text.lua @@ -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 diff --git a/lua/input-form/utils.lua b/lua/input-form/utils.lua new file mode 100644 index 0000000..c4f873f --- /dev/null +++ b/lua/input-form/utils.lua @@ -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 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 diff --git a/plugin/input-form.lua b/plugin/input-form.lua new file mode 100644 index 0000000..d1d5772 --- /dev/null +++ b/plugin/input-form.lua @@ -0,0 +1,6 @@ +-- Load guard so the plugin is only initialized once. +if _G.InputFormLoaded then + return +end + +_G.InputFormLoaded = true diff --git a/release-please-config.json b/release-please-config.json new file mode 100644 index 0000000..670b5fc --- /dev/null +++ b/release-please-config.json @@ -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 + } + } +} diff --git a/scripts/docgen.lua b/scripts/docgen.lua new file mode 100644 index 0000000..b60937f --- /dev/null +++ b/scripts/docgen.lua @@ -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") diff --git a/scripts/minimal_init.lua b/scripts/minimal_init.lua new file mode 100644 index 0000000..a47bd2f --- /dev/null +++ b/scripts/minimal_init.lua @@ -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 diff --git a/stylua.toml b/stylua.toml new file mode 100644 index 0000000..43cbbd3 --- /dev/null +++ b/stylua.toml @@ -0,0 +1,5 @@ +indent_type = "Spaces" +indent_width = 2 +column_width = 100 +quote_style = "AutoPreferDouble" +no_call_parentheses = false diff --git a/tests/helpers.lua b/tests/helpers.lua new file mode 100644 index 0000000..ea11b8f --- /dev/null +++ b/tests/helpers.lua @@ -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 diff --git a/tests/test_config.lua b/tests/test_config.lua new file mode 100644 index 0000000..2b2d991 --- /dev/null +++ b/tests/test_config.lua @@ -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", "") + eq_config(child, "keymaps.prev", "") + eq_config(child, "keymaps.submit", "") + eq_config(child, "keymaps.cancel", "") + eq_config(child, "keymaps.open_select", "") + 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 = "" }, + }]] + ) + + 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", "") + -- untouched keymap defaults preserved + eq_config(child, "keymaps.next", "") +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 diff --git a/tests/test_form.lua b/tests/test_form.lua new file mode 100644 index 0000000..5b6ad75 --- /dev/null +++ b/tests/test_form.lua @@ -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()]]) + -- 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 == '' then return true end + end + return false + end)() + ]]) + eq(has_tab, true) +end + +return T diff --git a/tests/test_inputs_multiline.lua b/tests/test_inputs_multiline.lua new file mode 100644 index 0000000..015b84d --- /dev/null +++ b/tests/test_inputs_multiline.lua @@ -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 diff --git a/tests/test_inputs_select.lua b/tests/test_inputs_select.lua new file mode 100644 index 0000000..74f181f --- /dev/null +++ b/tests/test_inputs_select.lua @@ -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 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 mapping we installed. + vim.api.nvim_feedkeys(vim.api.nvim_replace_termcodes('', 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"][" 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('', 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 diff --git a/tests/test_inputs_text.lua b/tests/test_inputs_text.lua new file mode 100644 index 0000000..9f5d7e0 --- /dev/null +++ b/tests/test_inputs_text.lua @@ -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