feat: spacer

This commit is contained in:
2026-04-06 00:55:14 +03:00
parent 94a784f64c
commit 0424bdf633
3 changed files with 112 additions and 7 deletions

View File

@@ -89,8 +89,10 @@ function M:_compute_layout()
if type(input.is_bordered) == "function" then
bordered = input:is_bordered()
end
local top_pad = (not bordered) and cb_pad or 0
local bot_pad = (not bordered) and cb_pad or 0
-- Only actual checkboxes get the configured blank padding. Spacers are
-- also borderless but their `height` is the user's exact request.
local top_pad = (input.type == "checkbox") and cb_pad or 0
local bot_pad = (input.type == "checkbox") and cb_pad or 0
local outer_h = bordered and (h + 2) or (h + top_pad + bot_pad)
-- Editor-row offset from `parent_row` to pass as `nvim_open_win`'s `row`
-- parameter for this child. `row` refers to the window's OUTER top-left
@@ -203,6 +205,11 @@ function M:show()
-- (not their border column) so everything lines up visually.
local border = config.options.window.border
for i, input in ipairs(self._inputs) do
-- Spacers are visual-only; they reserve layout rows but never mount a
-- window and don't participate in keymaps/validation/focus.
if input.type == "spacer" then
goto continue
end
local r = layout.rows[i]
-- Bordered children get `+1` to clear the parent's left border; their
-- content then sits at `+2`. Borderless children shift an extra column
@@ -220,12 +227,31 @@ function M:show()
input:mount(mount_opts)
self:_install_keymaps(input)
self:_install_validation(input)
::continue::
end
self:_focus(1)
self:_focus(self:_first_focusable() or 1)
return self
end
--- Return `true` if `input` participates in focus navigation.
local function is_focusable(input)
if input == nil then
return false
end
return input.focusable ~= false and input.type ~= "spacer"
end
--- Index of the first focusable input, or `nil` if none exist.
function M:_first_focusable()
for i, input in ipairs(self._inputs) do
if is_focusable(input) then
return i
end
end
return nil
end
--- Apply all configured highlight groups. Called from `show()` so live
--- `setup({ style = { highlights = ... } })` edits take effect on the next
--- form open.
@@ -263,10 +289,13 @@ function M:close()
end
--- Collect current values from all inputs into a { [name] = value } table.
--- Spacers have no name/value and are skipped.
function M:results()
local out = {}
for _, input in ipairs(self._inputs) do
out[input.name] = input:value()
if input.type ~= "spacer" and input.name then
out[input.name] = input:value()
end
end
return out
end
@@ -484,19 +513,44 @@ function M:_help_line()
return table.concat(parts, " ")
end
--- Advance from `start` by `step` (+1 or -1), wrapping, until a focusable
--- input is found. Returns the new index, or `start` if no input is
--- focusable.
function M:_next_focusable(start, step)
local n = #self._inputs
if n == 0 then
return start
end
local idx = ((start - 1) % n + n) % n + 1
for _ = 1, n do
if is_focusable(self._inputs[idx]) then
return idx
end
idx = ((idx - 1 + step) % n + n) % n + 1
end
return start
end
function M:_focus(idx)
local n = #self._inputs
if n == 0 then
return
end
idx = ((idx - 1) % n + n) % n + 1
-- If the requested index isn't focusable, advance forward to the next one.
if not is_focusable(self._inputs[idx]) then
idx = self:_next_focusable(idx, 1)
end
self._focus_idx = idx
self._inputs[idx]:focus()
end
function M:focus_next()
self:_focus(self._focus_idx + 1)
self:_focus(self:_next_focusable(self._focus_idx + 1, 1))
end
function M:focus_prev()
self:_focus(self._focus_idx - 1)
self:_focus(self:_next_focusable(self._focus_idx - 1, -1))
end
function M:_install_keymaps(input)

View File

@@ -7,6 +7,7 @@ M.types = {
multiline = require("input-form.inputs.multiline"),
select = require("input-form.inputs.select"),
checkbox = require("input-form.inputs.checkbox"),
spacer = require("input-form.inputs.spacer"),
}
--- Build an input component instance from a user-provided spec.
@@ -14,8 +15,14 @@ M.types = {
---@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"
-- Spacers are a visual-only faux input and don't require a `name`.
if t ~= "spacer" then
assert(
type(spec.name) == "string" and spec.name ~= "",
"input spec requires a non-empty 'name'"
)
end
local impl = M.types[t]
assert(impl, "unknown input type: " .. tostring(t))
local input = impl.new(spec)

View File

@@ -0,0 +1,44 @@
--- Spacer: a non-interactive faux input that reserves blank rows in the
--- form layout. Has no window, no focus, no validation, no value — it only
--- exists so callers can visually separate groups of real inputs.
local M = {}
M.__index = M
--- Create a new spacer from its spec.
---@param spec table { height? }
---@return table
function M.new(spec)
return setmetatable({
type = "spacer",
name = spec.name, -- optional, not required; never appears in results
focusable = false,
_height = math.max(0, tonumber(spec.height) or 1),
buf = nil,
win = nil,
}, M)
end
function M:height()
return self._height
end
function M:is_bordered()
return false
end
--- No-op: spacers never mount a window.
function M:mount(_) end
--- No-op: nothing to tear down.
function M:unmount() end
--- No-op: spacers are not focusable.
function M:focus() end
--- Spacers carry no value; `results()` skips them entirely.
function M:value()
return nil
end
return M