From 1363f6bdcc8b4e7017b26caf1901c4d9f83e795f Mon Sep 17 00:00:00 2001 From: Chen Asraf Date: Mon, 6 Apr 2026 01:18:25 +0300 Subject: [PATCH] feat: keymaps window instead of help line --- README.md | 196 ++++++++++++++++------------ doc/input-form.txt | 8 +- lua/input-form/config.lua | 8 +- lua/input-form/form.lua | 263 ++++++++++++++++++++++++++++++++++---- tests/test_config.lua | 2 +- 5 files changed, 369 insertions(+), 108 deletions(-) diff --git a/README.md b/README.md index 144cdd2..6fadf96 100644 --- a/README.md +++ b/README.md @@ -1,9 +1,8 @@ # 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. +A small Neovim plugin for building bordered, keyboard-navigable **forms** in a floating window. +Create a single window containing multiple typed inputs (single-line text, multiline text, select +dropdowns, checkboxes), collect results via an `on_submit` callback. ![input form example](/input-form.gif) @@ -11,13 +10,17 @@ floating window. Create a single window containing multiple typed inputs - Bordered floating window with optional title - Keyboard-navigable: `` / `` to move between inputs -- Input types: `text`, `multiline`, `select`, `checkbox` +- Input types: `text`, `multiline`, `select`, `checkbox`, plus `spacer` (visual-only gap between + fields) - Select dropdowns open with ``; arrows navigate; `` confirms +- Checkbox toggles with `` or `` - Submit with `` — results delivered as a `{ [name] = value }` table -- Cancel with `` +- Cancel with `` or `q` +- Built-in toggleable help popup (`?`) listing every active keymap — updates automatically when you + remap keys - Lazy: `create_form` builds the form; `:show()` renders it when you want - `:hide()` / `:show()` re-open a form while preserving in-progress values -- Fully configurable keymaps, border, width, title +- Fully configurable keymaps (strings or lists), border, width, title - Auto-generated help doc (`:h input-form`) - Tested with `mini.test` @@ -83,9 +86,8 @@ local form = f.create_form({ 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: +`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() @@ -95,19 +97,19 @@ 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. | +| 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). +Most inputs share `name` (string, required — the key in the result table) and `label` (string, shown +above the field). `spacer` is the only exception: it's visual-only and needs no `name`. #### `text` @@ -121,8 +123,7 @@ All inputs share `name` (string, required — the key in the result table) and { name = "body", label = "Notes", type = "multiline", default = "", height = 5 } ``` -- `height` (optional) — number of rows for the input; falls back to - `config.multiline.height`. +- `height` (optional) — number of rows for the input; falls back to `config.multiline.height`. #### `select` @@ -147,9 +148,8 @@ All inputs share `name` (string, required — the key in the result table) and { name = "agree", label = "I agree", type = "checkbox", default = false } ``` -Unlike text/multiline/select, checkboxes render **inline** — no border, no -separate label row. The glyph sits immediately next to the label, and any -validation error is appended on the same line: +Unlike text/multiline/select, checkboxes render **inline** — no border, no separate label row. The +glyph sits immediately next to the label, and any validation error is appended on the same line: ``` ☐ I agree (must be checked) @@ -157,13 +157,34 @@ validation error is appended on the same line: - `default` (optional) — boolean (defaults to `false`). - `value()` returns a boolean. -- Toggled with the configured `keymaps.toggle` key (default ``) or - the `keymaps.open_select` key (default ``) — both work so users get - a consistent "interact with this field" key. -- Glyphs come from `style.checkbox.{checked, unchecked}` (defaults - `"☑"` / `"☐"`). -- Pair with `validators.checked()` to require the box to be ticked (see - [Validation](#validation)). +- Toggled with the configured `keymaps.toggle` key (default ``) or the `keymaps.open_select` + key (default ``) — both work so users get a consistent "interact with this field" key. +- Glyphs come from `style.checkbox.{checked, unchecked}` (defaults `"☑"` / `"☐"`). +- A blank row is rendered above and below each checkbox to visually separate it from adjacent + bordered inputs. Tune via `style.checkbox.padding` (default `1`, set to `0` to pack tight). +- Pair with `validators.checked()` to require the box to be ticked (see [Validation](#validation)). + +#### `spacer` + +```lua +{ type = "spacer" } -- 1 blank row +{ type = "spacer", height = 2 } -- 2 blank rows +``` + +A visual-only faux input that reserves blank rows in the layout. It has no window, no focus, no +validator, and never appears in `results()`. Use it to group related inputs visually: + +```lua +inputs = { + { name = "first", label = "First name", type = "text" }, + { name = "last", label = "Last name", type = "text" }, + { type = "spacer" }, + { name = "email", label = "Email", type = "text" }, +} +``` + +- `height` (optional) — number of blank rows (default `1`). +- `` / `` skip over spacers automatically. ## Validation @@ -173,16 +194,15 @@ Each input spec accepts an optional `validator` function: validator = fun(value: any): string|nil ``` -Return a non-empty error message string to mark the input invalid, or `nil` / -`""` when valid. The error message is shown in the input's bottom border -(red), and the border + label turn red too. Validation runs: +Return a non-empty error message string to mark the input invalid, or `nil` / `""` when valid. The +error message is shown in the input's bottom border (red), and the border + label turn red too. +Validation runs: -- **On blur** — the first time the user leaves the field it is marked - "touched" and the validator runs. Nothing is shown before that. +- **On blur** — the first time the user leaves the field it is marked "touched" and the validator + runs. Nothing is shown before that. - **On change** — once touched, each buffer change re-runs the validator. -- **On submit** — `form:submit()` force-validates every input (touched or - not). If any input has an error, submission is blocked, all errors are - rendered, and focus moves to the first invalid input. +- **On submit** — `form:submit()` force-validates every input (touched or not). If any input has an + error, submission is blocked, all errors are rendered, and focus moves to the first invalid input. ### Built-in validators @@ -234,8 +254,8 @@ f.create_form({ }):show() ``` -Custom validators are just functions — no need to use the builder helpers if -you'd rather write one inline: +Custom validators are just functions — no need to use the builder helpers if you'd rather write one +inline: ```lua validator = function(value) @@ -245,7 +265,7 @@ validator = function(value) end ``` -### Checkbox glyphs +### Checkbox glyphs and padding Override the characters rendered by `checkbox` inputs via `style.checkbox`: @@ -255,18 +275,30 @@ require("input-form").setup({ checkbox = { checked = "☑", -- default unchecked = "☐", -- default + padding = 1, -- default: blank rows above/below each checkbox }, }, }) ``` -Alternatives that render well in most fonts: `[x]` / `[ ]`, `✔` / `·`, -`●` / `○`. +Alternatives that render well in most fonts: `[x]` / `[ ]`, `✔` / `·`, `●` / `○`. Set `padding = 0` +to pack checkboxes flush against adjacent bordered inputs. + +### Help popup + +The form's bottom border shows a compact `? help` hint on the right. Press `?` (configurable via +`keymaps.help`) to toggle a floating popup below the form that lists **every active keymap** — it +reads from `config.keymaps` at render time, so if you remap `submit` to `` the popup +reflects that automatically. The popup matches the form's width, wraps long descriptions, and flips +above the form if it would overflow the editor bottom. It only lists keys relevant to the current +form (e.g. `toggle checkbox` only appears when a checkbox is present). + +Set `keymaps.help = false` to disable both the popup and the footer hint. ### Select chevrons -The glyphs shown on the right side of `select` inputs to indicate the -dropdown state are configurable under `style.chevron`: +The glyphs shown on the right side of `select` inputs to indicate the dropdown state are +configurable under `style.chevron`: ```lua require("input-form").setup({ @@ -279,22 +311,19 @@ require("input-form").setup({ }) ``` -Use whatever you like — e.g. ASCII fallbacks for terminals without good -Unicode support: +Use whatever you like — e.g. ASCII fallbacks for terminals without good Unicode support: ```lua style = { chevron = { closed = " v", open = " ^" } } ``` -A leading space is recommended so the glyph doesn't sit flush against the -label. +A leading space is recommended so the glyph doesn't sit flush against the label. ### Highlight groups -All highlight groups the plugin uses are listed under `style.highlights` in -the config and can be overridden via `setup()`. Each entry is passed directly -to `vim.api.nvim_set_hl(0, name, spec)`, so anything `nvim_set_hl` accepts -works (`fg`, `bg`, `link`, `bold`, `italic`, `default`, etc.). +All highlight groups the plugin uses are listed under `style.highlights` in the config and can be +overridden via `setup()`. Each entry is passed directly to `vim.api.nvim_set_hl(0, name, spec)`, so +anything `nvim_set_hl` accepts works (`fg`, `bg`, `link`, `bold`, `italic`, `default`, etc.). ```lua require("input-form").setup({ @@ -320,7 +349,7 @@ Available groups: | `InputFormNormal` | Parent form window background | | `InputFormBorder` | Parent form border | | `InputFormTitle` | Parent form title | -| `InputFormHelp` | Footer help line (key hints) | +| `InputFormHelp` | Footer `? help` hint on the form border | | `InputFormField` | Individual input field background | | `InputFormFieldBorder` | Individual input field border | | `InputFormFieldTitle` | Individual input field label (on top border) | @@ -330,11 +359,10 @@ Available groups: | `InputFormDropdown` | Select dropdown background | | `InputFormDropdownActive` | Highlighted dropdown row | -User overrides fully **replace** the default spec per group (they are not -deep-merged at the field level), so you don't need to re-specify -`default = true`. Highlights are re-applied on every `form:show()`, so a -`setup({ style = { highlights = ... } })` call that happens after the first -form has been rendered still takes effect on the next open. +User overrides fully **replace** the default spec per group (they are not deep-merged at the field +level), so you don't need to re-specify `default = true`. Highlights are re-applied on every +`form:show()`, so a `setup({ style = { highlights = ... } })` call that happens after the first form +has been rendered still takes effect on the next open. ## Configuration @@ -352,12 +380,15 @@ require("input-form").setup({ gap = 0, -- blank rows between adjacent inputs }, keymaps = { + -- Every keymap accepts either a single key string or a list of keys. + -- Set any value to `false` to disable. next = "", prev = "", submit = "", - cancel = "", + cancel = { "", "q" }, -- list form: both keys cancel the form open_select = "", toggle = "", + help = "?", -- toggle the help popup (set `false` to hide) }, select = { max_height = 10, @@ -365,6 +396,14 @@ require("input-form").setup({ multiline = { height = 5, }, + style = { + checkbox = { + checked = "☑", + unchecked = "☐", + padding = 1, -- blank rows above/below each checkbox + }, + -- ...chevron, highlights, etc. — see sections above. + }, }) ``` @@ -372,8 +411,8 @@ 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: +Help tags are registered automatically on the first `require('input-form')`, so `setup()` is not +required for them either: ``` :h input-form @@ -381,8 +420,8 @@ so `setup()` is not required for them either: ## 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: +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: @@ -400,19 +439,18 @@ input_form.create_form({ 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. +- **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 { diff --git a/doc/input-form.txt b/doc/input-form.txt index 2c18f26..07d14c4 100644 --- a/doc/input-form.txt +++ b/doc/input-form.txt @@ -113,12 +113,16 @@ Default configuration for |input-form|. prev = "", --- Submit the form and invoke `on_submit(results)`. submit = "", - --- Cancel the form and invoke `on_cancel()` if provided. - cancel = "", + --- Cancel the form and invoke `on_cancel()` if provided. Accepts a + --- single key string or a list of keys — all listed keys trigger cancel. + cancel = { "", "q" }, --- Open the dropdown when focused on a `select` input. open_select = "", --- Toggle the value of a `checkbox` input. toggle = "", + --- Toggle a help popup listing every active keymap. The popup opens + --- directly below the form window and closes on the same key. + help = "?", }, --- Options for `select` inputs. select = { diff --git a/lua/input-form/config.lua b/lua/input-form/config.lua index 8fd2098..5e599d1 100644 --- a/lua/input-form/config.lua +++ b/lua/input-form/config.lua @@ -37,12 +37,16 @@ M.defaults = { prev = "", --- Submit the form and invoke `on_submit(results)`. submit = "", - --- Cancel the form and invoke `on_cancel()` if provided. - cancel = "", + --- Cancel the form and invoke `on_cancel()` if provided. Accepts a + --- single key string or a list of keys — all listed keys trigger cancel. + cancel = { "", "q" }, --- Open the dropdown when focused on a `select` input. open_select = "", --- Toggle the value of a `checkbox` input. toggle = "", + --- Toggle a help popup listing every active keymap. The popup opens + --- directly below the form window and closes on the same key. + help = "?", }, --- Options for `select` inputs. select = { diff --git a/lua/input-form/form.lua b/lua/input-form/form.lua index d3df440..c3637b0 100644 --- a/lua/input-form/form.lua +++ b/lua/input-form/form.lua @@ -50,12 +50,12 @@ function M:_compute_layout() -- `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 + -- Grow the window to fit the footer hint if the user's configured width + -- is too narrow. The footer string is " " so we need at least + -- #hint + 2 (leading/trailing space) + 2 (corners) cells of outer width. + local hint = self:_help_hint() + if hint and hint ~= "" then + local needed = vim.fn.strdisplaywidth(hint) + 4 if outer_width < needed then outer_width = needed end @@ -184,10 +184,10 @@ function M:show() win_opts.title_pos = config.options.window.title_pos end if vim.fn.has("nvim-0.10") == 1 then - local footer = self:_help_line() + local footer = self:_help_hint() if footer and footer ~= "" then win_opts.footer = " " .. footer .. " " - win_opts.footer_pos = "center" + win_opts.footer_pos = "right" end end self._parent_win = vim.api.nvim_open_win(self._parent_buf, false, win_opts) @@ -268,6 +268,7 @@ function M:hide() if not self._visible then return end + self:_close_help() for _, input in ipairs(self._inputs) do input:unmount() end @@ -475,23 +476,57 @@ function M:_render_validation(input) end end ---- Build a help-line string describing the active keymaps. -function M:_help_line() +--- Format a keymap value (string or list of strings) for display. Returns +--- `nil` if the value is effectively empty / disabled. +local function format_keys(val) + if not val or val == false or val == "" then + return nil + end + if type(val) == "table" then + local parts = {} + for _, k in ipairs(val) do + if k and k ~= false and k ~= "" then + table.insert(parts, k) + end + end + if #parts == 0 then + return nil + end + return table.concat(parts, " / ") + end + return tostring(val) +end + +--- Short footer hint shown on the form's bottom border (e.g. `"? help"`). +--- Returns `nil` if the help keymap is disabled. +function M:_help_hint() + local key = format_keys(config.options.keymaps.help) + if not key then + return nil + end + return key .. " help" +end + +--- Collect `{ keys, description }` pairs for every active keymap, filtered +--- to what this form actually uses. Consumed by the help popup. +function M:_help_entries() local km = config.options.keymaps - local parts = {} + local entries = {} local function add(keys, desc) - if keys and keys ~= false and keys ~= "" then - table.insert(parts, keys .. " " .. desc) + local display = format_keys(keys) + if display then + table.insert(entries, { display, desc }) end end + local nxt, prv = format_keys(km.next), format_keys(km.prev) local nav - if km.next and km.prev then - nav = km.next .. "/" .. km.prev + if nxt and prv then + nav = nxt .. " / " .. prv else - nav = km.next or km.prev + nav = nxt or prv end if nav then - table.insert(parts, nav .. " navigate") + table.insert(entries, { nav, "navigate fields" }) end -- Only advertise type-specific keys if the form actually has such an input. local has_select, has_checkbox = false, false @@ -503,14 +538,180 @@ function M:_help_line() end end if has_select then - add(km.open_select, "open") + add(km.open_select, "open dropdown") end if has_checkbox then - add(km.toggle, "toggle") + add(km.toggle, "toggle checkbox") + end + add(km.submit, "submit form") + add(km.cancel, "cancel form") + add(km.help, "toggle this help") + return entries +end + +--- Build the wrapped lines of the help popup given a maximum width (the +--- popup's content width). Each keymap occupies its own row formatted as +--- `" "` with keys right-padded to a common column so +--- descriptions line up. If an entry exceeds `max_w` the description wraps +--- onto a hanging indent. +function M:_help_lines(max_w) + local entries = self:_help_entries() + if #entries == 0 then + return {} + end + -- Cap the key column so a single oversized key doesn't eat the whole row. + local max_key_w = 0 + for _, e in ipairs(entries) do + local w = vim.fn.strdisplaywidth(e[1]) + if w > max_key_w then + max_key_w = w + end + end + max_key_w = math.min(max_key_w, math.max(4, math.floor(max_w / 2))) + + local gap = " " + local gap_w = vim.fn.strdisplaywidth(gap) + local indent = string.rep(" ", max_key_w + gap_w) + + local lines = {} + for _, e in ipairs(entries) do + local keys, desc = e[1], e[2] + local key_w = vim.fn.strdisplaywidth(keys) + local pad = string.rep(" ", math.max(0, max_key_w - key_w)) + local prefix = keys .. pad .. gap + -- Wrap the description into the remaining width. `avail` is the + -- width available for description text (max_w minus key column). + local avail = math.max(1, max_w - vim.fn.strdisplaywidth(prefix)) + local chunks = M._wrap_text(desc, avail) + table.insert(lines, prefix .. (chunks[1] or "")) + for i = 2, #chunks do + table.insert(lines, indent .. chunks[i]) + end + end + return lines +end + +--- Word-wrap `text` to rows no wider than `width` display cells. Falls back +--- to a hard character cut for a single token longer than `width`. +function M._wrap_text(text, width) + if width <= 0 then + return { text } + end + if vim.fn.strdisplaywidth(text) <= width then + return { text } + end + local out = {} + local line = "" + for word in string.gmatch(text, "%S+") do + if line == "" then + line = word + else + local candidate = line .. " " .. word + if vim.fn.strdisplaywidth(candidate) <= width then + line = candidate + else + table.insert(out, line) + line = word + end + end + -- Single word wider than width: hard-cut on character boundaries. + while vim.fn.strdisplaywidth(line) > width do + local cut = vim.fn.strcharpart(line, 0, width) + table.insert(out, cut) + line = vim.fn.strcharpart(line, vim.fn.strchars(cut)) + end + end + if line ~= "" then + table.insert(out, line) + end + return out +end + +--- Open the help popup directly below the form window. No-op if already +--- open or if the form is not visible. +function M:_open_help() + if not self._visible or not self._layout then + return + end + if self._help_win and vim.api.nvim_win_is_valid(self._help_win) then + return + end + local layout = self._layout + -- Match the parent's outer width so borders align vertically. + local content_w = layout.parent_inner_w + local lines = self:_help_lines(content_w) + if #lines == 0 then + return + end + + local buf = vim.api.nvim_create_buf(false, true) + vim.bo[buf].buftype = "nofile" + vim.bo[buf].bufhidden = "wipe" + vim.bo[buf].swapfile = false + utils.mark_form_buffer(buf) + vim.api.nvim_buf_set_lines(buf, 0, -1, false, lines) + vim.bo[buf].modifiable = false + + -- Parent's outer bottom-border row = parent_row + parent_inner_h + 1 + -- (parent_row is the border origin). The help popup's top border sits + -- one row below that. + local help_row = layout.parent_row + layout.parent_inner_h + 2 + local help_col = layout.parent_col + + -- If the popup would overflow the editor below the form, flip it above. + local outer_h = #lines + 2 + local max_row = vim.o.lines - outer_h - 2 + if help_row > max_row then + local above = layout.parent_row - outer_h + if above >= 0 then + help_row = above + else + help_row = math.max(0, max_row) + end + end + + local win = vim.api.nvim_open_win(buf, false, { + relative = "editor", + row = help_row, + col = help_col, + width = content_w, + height = #lines, + style = "minimal", + border = config.options.window.border, + focusable = false, + zindex = 60, + title = " Help ", + title_pos = "left", + }) + vim.wo[win].winblend = config.options.window.winblend + vim.wo[win].winhl = table.concat({ + "NormalFloat:InputFormNormal", + "FloatBorder:InputFormBorder", + "FloatTitle:InputFormTitle", + }, ",") + self._help_win = win + self._help_buf = buf +end + +--- Close the help popup. No-op if not open. +function M:_close_help() + if self._help_win and vim.api.nvim_win_is_valid(self._help_win) then + pcall(vim.api.nvim_win_close, self._help_win, true) + end + if self._help_buf and vim.api.nvim_buf_is_valid(self._help_buf) then + pcall(vim.api.nvim_buf_delete, self._help_buf, { force = true }) + end + self._help_win = nil + self._help_buf = nil +end + +--- Toggle the help popup. +function M:toggle_help() + if self._help_win and vim.api.nvim_win_is_valid(self._help_win) then + self:_close_help() + else + self:_open_help() end - add(km.submit, "submit") - add(km.cancel, "cancel") - return table.concat(parts, " ") end --- Advance from `start` by `step` (+1 or -1), wrapping, until a focusable @@ -559,9 +760,17 @@ function M:_install_keymaps(input) if not buf then return end + -- `lhs` may be a single key string or a list of keys. All listed keys + -- are bound to the same callback. local function map(mode, lhs, fn) - if lhs and lhs ~= false then - vim.keymap.set(mode, lhs, fn, { buffer = buf, nowait = true, silent = true }) + if not lhs or lhs == false or lhs == "" then + return + end + local keys = type(lhs) == "table" and lhs or { lhs } + for _, k in ipairs(keys) do + if k and k ~= false and k ~= "" then + vim.keymap.set(mode, k, fn, { buffer = buf, nowait = true, silent = true }) + end end end @@ -584,6 +793,12 @@ function M:_install_keymaps(input) self:cancel() end) + -- Help popup toggle (normal mode only so `?` stays usable inside text/ + -- multiline inputs during insert). + map("n", km.help, function() + self:toggle_help() + end) + if input.type == "select" then map("n", km.open_select, function() input:open_dropdown() diff --git a/tests/test_config.lua b/tests/test_config.lua index 1024fa3..983a304 100644 --- a/tests/test_config.lua +++ b/tests/test_config.lua @@ -30,7 +30,7 @@ T["setup()"]["exposes defaults"] = function() eq_config(child, "keymaps.next", "") eq_config(child, "keymaps.prev", "") eq_config(child, "keymaps.submit", "") - eq_config(child, "keymaps.cancel", "") + eq_config(child, "keymaps.cancel", { "", "q" }) eq_config(child, "keymaps.open_select", "") eq_config(child, "select.max_height", 10) eq_config(child, "multiline.height", 5)