Skip to content

Commit 0424bdf

Browse files
committed
feat: spacer
1 parent 94a784f commit 0424bdf

3 files changed

Lines changed: 112 additions & 7 deletions

File tree

lua/input-form/form.lua

Lines changed: 60 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -89,8 +89,10 @@ function M:_compute_layout()
8989
if type(input.is_bordered) == "function" then
9090
bordered = input:is_bordered()
9191
end
92-
local top_pad = (not bordered) and cb_pad or 0
93-
local bot_pad = (not bordered) and cb_pad or 0
92+
-- Only actual checkboxes get the configured blank padding. Spacers are
93+
-- also borderless but their `height` is the user's exact request.
94+
local top_pad = (input.type == "checkbox") and cb_pad or 0
95+
local bot_pad = (input.type == "checkbox") and cb_pad or 0
9496
local outer_h = bordered and (h + 2) or (h + top_pad + bot_pad)
9597
-- Editor-row offset from `parent_row` to pass as `nvim_open_win`'s `row`
9698
-- parameter for this child. `row` refers to the window's OUTER top-left
@@ -203,6 +205,11 @@ function M:show()
203205
-- (not their border column) so everything lines up visually.
204206
local border = config.options.window.border
205207
for i, input in ipairs(self._inputs) do
208+
-- Spacers are visual-only; they reserve layout rows but never mount a
209+
-- window and don't participate in keymaps/validation/focus.
210+
if input.type == "spacer" then
211+
goto continue
212+
end
206213
local r = layout.rows[i]
207214
-- Bordered children get `+1` to clear the parent's left border; their
208215
-- content then sits at `+2`. Borderless children shift an extra column
@@ -220,12 +227,31 @@ function M:show()
220227
input:mount(mount_opts)
221228
self:_install_keymaps(input)
222229
self:_install_validation(input)
230+
::continue::
223231
end
224232

225-
self:_focus(1)
233+
self:_focus(self:_first_focusable() or 1)
226234
return self
227235
end
228236

237+
--- Return `true` if `input` participates in focus navigation.
238+
local function is_focusable(input)
239+
if input == nil then
240+
return false
241+
end
242+
return input.focusable ~= false and input.type ~= "spacer"
243+
end
244+
245+
--- Index of the first focusable input, or `nil` if none exist.
246+
function M:_first_focusable()
247+
for i, input in ipairs(self._inputs) do
248+
if is_focusable(input) then
249+
return i
250+
end
251+
end
252+
return nil
253+
end
254+
229255
--- Apply all configured highlight groups. Called from `show()` so live
230256
--- `setup({ style = { highlights = ... } })` edits take effect on the next
231257
--- form open.
@@ -263,10 +289,13 @@ function M:close()
263289
end
264290

265291
--- Collect current values from all inputs into a { [name] = value } table.
292+
--- Spacers have no name/value and are skipped.
266293
function M:results()
267294
local out = {}
268295
for _, input in ipairs(self._inputs) do
269-
out[input.name] = input:value()
296+
if input.type ~= "spacer" and input.name then
297+
out[input.name] = input:value()
298+
end
270299
end
271300
return out
272301
end
@@ -484,19 +513,44 @@ function M:_help_line()
484513
return table.concat(parts, " ")
485514
end
486515

516+
--- Advance from `start` by `step` (+1 or -1), wrapping, until a focusable
517+
--- input is found. Returns the new index, or `start` if no input is
518+
--- focusable.
519+
function M:_next_focusable(start, step)
520+
local n = #self._inputs
521+
if n == 0 then
522+
return start
523+
end
524+
local idx = ((start - 1) % n + n) % n + 1
525+
for _ = 1, n do
526+
if is_focusable(self._inputs[idx]) then
527+
return idx
528+
end
529+
idx = ((idx - 1 + step) % n + n) % n + 1
530+
end
531+
return start
532+
end
533+
487534
function M:_focus(idx)
488535
local n = #self._inputs
536+
if n == 0 then
537+
return
538+
end
489539
idx = ((idx - 1) % n + n) % n + 1
540+
-- If the requested index isn't focusable, advance forward to the next one.
541+
if not is_focusable(self._inputs[idx]) then
542+
idx = self:_next_focusable(idx, 1)
543+
end
490544
self._focus_idx = idx
491545
self._inputs[idx]:focus()
492546
end
493547

494548
function M:focus_next()
495-
self:_focus(self._focus_idx + 1)
549+
self:_focus(self:_next_focusable(self._focus_idx + 1, 1))
496550
end
497551

498552
function M:focus_prev()
499-
self:_focus(self._focus_idx - 1)
553+
self:_focus(self:_next_focusable(self._focus_idx - 1, -1))
500554
end
501555

502556
function M:_install_keymaps(input)

lua/input-form/inputs/init.lua

Lines changed: 8 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -7,15 +7,22 @@ M.types = {
77
multiline = require("input-form.inputs.multiline"),
88
select = require("input-form.inputs.select"),
99
checkbox = require("input-form.inputs.checkbox"),
10+
spacer = require("input-form.inputs.spacer"),
1011
}
1112

1213
--- Build an input component instance from a user-provided spec.
1314
---@param spec table
1415
---@return table
1516
function M.build(spec)
1617
assert(type(spec) == "table", "input spec must be a table")
17-
assert(type(spec.name) == "string" and spec.name ~= "", "input spec requires a non-empty 'name'")
1818
local t = spec.type or "text"
19+
-- Spacers are a visual-only faux input and don't require a `name`.
20+
if t ~= "spacer" then
21+
assert(
22+
type(spec.name) == "string" and spec.name ~= "",
23+
"input spec requires a non-empty 'name'"
24+
)
25+
end
1926
local impl = M.types[t]
2027
assert(impl, "unknown input type: " .. tostring(t))
2128
local input = impl.new(spec)

lua/input-form/inputs/spacer.lua

Lines changed: 44 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,44 @@
1+
--- Spacer: a non-interactive faux input that reserves blank rows in the
2+
--- form layout. Has no window, no focus, no validation, no value — it only
3+
--- exists so callers can visually separate groups of real inputs.
4+
5+
local M = {}
6+
M.__index = M
7+
8+
--- Create a new spacer from its spec.
9+
---@param spec table { height? }
10+
---@return table
11+
function M.new(spec)
12+
return setmetatable({
13+
type = "spacer",
14+
name = spec.name, -- optional, not required; never appears in results
15+
focusable = false,
16+
_height = math.max(0, tonumber(spec.height) or 1),
17+
buf = nil,
18+
win = nil,
19+
}, M)
20+
end
21+
22+
function M:height()
23+
return self._height
24+
end
25+
26+
function M:is_bordered()
27+
return false
28+
end
29+
30+
--- No-op: spacers never mount a window.
31+
function M:mount(_) end
32+
33+
--- No-op: nothing to tear down.
34+
function M:unmount() end
35+
36+
--- No-op: spacers are not focusable.
37+
function M:focus() end
38+
39+
--- Spacers carry no value; `results()` skips them entirely.
40+
function M:value()
41+
return nil
42+
end
43+
44+
return M

0 commit comments

Comments
 (0)