Skip to content

Commit 94a784f

Browse files
committed
feat: checkbox field
1 parent 52d5fd2 commit 94a784f

14 files changed

Lines changed: 597 additions & 19 deletions

README.md

Lines changed: 48 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -11,7 +11,7 @@ floating window. Create a single window containing multiple typed inputs
1111

1212
- Bordered floating window with optional title
1313
- Keyboard-navigable: `<Tab>` / `<S-Tab>` to move between inputs
14-
- Input types: `text`, `multiline`, `select`
14+
- Input types: `text`, `multiline`, `select`, `checkbox`
1515
- Select dropdowns open with `<CR>`; arrows navigate; `<CR>` confirms
1616
- Submit with `<C-s>` — results delivered as a `{ [name] = value }` table
1717
- Cancel with `<Esc>`
@@ -141,6 +141,30 @@ All inputs share `name` (string, required — the key in the result table) and
141141

142142
`value()` returns the selected `id` (not the label).
143143

144+
#### `checkbox`
145+
146+
```lua
147+
{ name = "agree", label = "I agree", type = "checkbox", default = false }
148+
```
149+
150+
Unlike text/multiline/select, checkboxes render **inline** — no border, no
151+
separate label row. The glyph sits immediately next to the label, and any
152+
validation error is appended on the same line:
153+
154+
```
155+
☐ I agree (must be checked)
156+
```
157+
158+
- `default` (optional) — boolean (defaults to `false`).
159+
- `value()` returns a boolean.
160+
- Toggled with the configured `keymaps.toggle` key (default `<Space>`) or
161+
the `keymaps.open_select` key (default `<CR>`) — both work so users get
162+
a consistent "interact with this field" key.
163+
- Glyphs come from `style.checkbox.{checked, unchecked}` (defaults
164+
`"☑"` / `"☐"`).
165+
- Pair with `validators.checked()` to require the box to be ticked (see
166+
[Validation](#validation)).
167+
144168
## Validation
145169

146170
Each input spec accepts an optional `validator` function:
@@ -170,6 +194,10 @@ V.min_length(n, [msg]) -- at least `n` characters
170194
V.max_length(n, [msg]) -- at most `n` characters
171195
V.matches(lua_pattern, [msg]) -- match a Lua pattern
172196
V.is_number([msg]) -- tonumber() must succeed
197+
V.checked([required], [msg]) -- checkbox must equal `required`
198+
-- (default true; default messages
199+
-- "(must be checked)" /
200+
-- "(must be unchecked)")
173201
V.one_of({ "a", "b", ... }, [msg]) -- value must be in the list
174202
V.custom(predicate, msg) -- wrap a `fun(v): boolean` predicate
175203
V.chain(v1, v2, ...) -- run validators in order, first error wins
@@ -217,6 +245,24 @@ validator = function(value)
217245
end
218246
```
219247

248+
### Checkbox glyphs
249+
250+
Override the characters rendered by `checkbox` inputs via `style.checkbox`:
251+
252+
```lua
253+
require("input-form").setup({
254+
style = {
255+
checkbox = {
256+
checked = "", -- default
257+
unchecked = "", -- default
258+
},
259+
},
260+
})
261+
```
262+
263+
Alternatives that render well in most fonts: `[x]` / `[ ]`, `` / `·`,
264+
`` / ``.
265+
220266
### Select chevrons
221267

222268
The glyphs shown on the right side of `select` inputs to indicate the
@@ -311,6 +357,7 @@ require("input-form").setup({
311357
submit = "<C-s>",
312358
cancel = "<Esc>",
313359
open_select = "<CR>",
360+
toggle = "<Space>",
314361
},
315362
select = {
316363
max_height = 10,

doc/input-form.txt

Lines changed: 25 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -117,6 +117,8 @@ Default configuration for |input-form|.
117117
cancel = "<Esc>",
118118
--- Open the dropdown when focused on a `select` input.
119119
open_select = "<CR>",
120+
--- Toggle the value of a `checkbox` input.
121+
toggle = "<Space>",
120122
},
121123
--- Options for `select` inputs.
122124
select = {
@@ -138,6 +140,16 @@ Default configuration for |input-form|.
138140
--- Glyph shown when the dropdown is open.
139141
open = "⌃",
140142
},
143+
--- Glyphs shown in `checkbox` inputs.
144+
checkbox = {
145+
--- Shown when the box is checked.
146+
checked = "☑",
147+
--- Shown when the box is unchecked.
148+
unchecked = "☐",
149+
--- Blank rows rendered above and below a checkbox to visually separate
150+
--- it from adjacent bordered inputs. Set to `0` to pack tightly.
151+
padding = 1,
152+
},
141153
--- Highlight groups applied on every `form:show()`. Each entry is passed
142154
--- directly to `vim.api.nvim_set_hl(0, name, spec)`, so any option that
143155
--- `nvim_set_hl` accepts (`fg`, `bg`, `link`, `bold`, `italic`,
@@ -238,6 +250,19 @@ Parameters ~
238250
Return ~
239251
`(function)`
240252

253+
------------------------------------------------------------------------------
254+
*M.checked()*
255+
`M.checked`({required}, {msg})
256+
Require a checkbox value to equal the given boolean. Intended for
257+
`checkbox` inputs ("must agree to terms", "must enable feature", ...).
258+
Parameters ~
259+
{required} `(boolean|nil)` The required value. Defaults to `true`.
260+
{msg} `(string|nil)` Override error message. Defaults to
261+
`"(must be checked)"` when `required` is `true`, otherwise
262+
`"(must be unchecked)"`.
263+
Return ~
264+
`(function)`
265+
241266
------------------------------------------------------------------------------
242267
*M.one_of()*
243268
`M.one_of`({choices}, {msg})

doc/tags

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
Default input-form.txt /*Default*
22
M.Form input-form.txt /*M.Form*
33
M.chain() input-form.txt /*M.chain()*
4+
M.checked() input-form.txt /*M.checked()*
45
M.config input-form.txt /*M.config*
56
M.custom() input-form.txt /*M.custom()*
67
M.is_number() input-form.txt /*M.is_number()*

lua/input-form/config.lua

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -41,6 +41,8 @@ M.defaults = {
4141
cancel = "<Esc>",
4242
--- Open the dropdown when focused on a `select` input.
4343
open_select = "<CR>",
44+
--- Toggle the value of a `checkbox` input.
45+
toggle = "<Space>",
4446
},
4547
--- Options for `select` inputs.
4648
select = {
@@ -62,6 +64,16 @@ M.defaults = {
6264
--- Glyph shown when the dropdown is open.
6365
open = "",
6466
},
67+
--- Glyphs shown in `checkbox` inputs.
68+
checkbox = {
69+
--- Shown when the box is checked.
70+
checked = "",
71+
--- Shown when the box is unchecked.
72+
unchecked = "",
73+
--- Blank rows rendered above and below a checkbox to visually separate
74+
--- it from adjacent bordered inputs. Set to `0` to pack tightly.
75+
padding = 1,
76+
},
6577
--- Highlight groups applied on every `form:show()`. Each entry is passed
6678
--- directly to `vim.api.nvim_set_hl(0, name, spec)`, so any option that
6779
--- `nvim_set_hl` accepts (`fg`, `bg`, `link`, `bold`, `italic`,

lua/input-form/form.lua

Lines changed: 82 additions & 18 deletions
Original file line numberDiff line numberDiff line change
@@ -72,17 +72,39 @@ function M:_compute_layout()
7272

7373
local parent_inner_w = outer_width - 2 -- minus parent border
7474
local child_outer_w = parent_inner_w - pad_h * 2
75-
local child_inner_w = child_outer_w - 2 -- minus child border
75+
local child_inner_w = child_outer_w - 2 -- minus child border (for bordered inputs)
76+
77+
-- Extra blank rows rendered above/below a checkbox (borderless input) so
78+
-- its glyph doesn't butt directly against an adjacent bordered input's
79+
-- border. Configurable via `style.checkbox.padding`.
80+
local cb_pad = (opts.style and opts.style.checkbox and opts.style.checkbox.padding) or 0
7681

7782
local rows = {}
7883
local inner_h = pad_top
7984
for i, input in ipairs(self._inputs) do
8085
local h = input:height()
86+
-- NB: avoid the `a and b or c` idiom — `is_bordered()` legitimately
87+
-- returns `false` and that must not get coerced back to the default.
88+
local bordered = true
89+
if type(input.is_bordered) == "function" then
90+
bordered = input:is_bordered()
91+
end
92+
local top_pad = (not bordered) and cb_pad or 0
93+
local bot_pad = (not bordered) and cb_pad or 0
94+
local outer_h = bordered and (h + 2) or (h + top_pad + bot_pad)
95+
-- Editor-row offset from `parent_row` to pass as `nvim_open_win`'s `row`
96+
-- parameter for this child. `row` refers to the window's OUTER top-left
97+
-- (i.e. the border origin for bordered windows, the content row for
98+
-- borderless windows). Parent_row is itself a border origin, so every
99+
-- child needs a `+1` to clear the parent's top border — matching the
100+
-- `+1` already applied on the column axis in `show()`.
101+
local content_offset = inner_h + 1 + top_pad
81102
table.insert(rows, {
82-
top_border_offset = inner_h, -- row inside parent content where child's top border sits
103+
bordered = bordered,
104+
content_row_offset = content_offset,
83105
value_height = h,
84106
})
85-
inner_h = inner_h + h + 2 -- child's full outer height (content + 2 border rows)
107+
inner_h = inner_h + outer_h
86108
if i < #self._inputs then
87109
inner_h = inner_h + sep
88110
end
@@ -104,6 +126,7 @@ function M:_compute_layout()
104126
parent_col = parent_col,
105127
parent_inner_w = parent_inner_w,
106128
parent_inner_h = inner_h,
129+
child_outer_w = child_outer_w,
107130
child_inner_w = child_inner_w,
108131
pad_h = pad_h,
109132
rows = rows,
@@ -174,20 +197,27 @@ function M:show()
174197
"FloatFooter:InputFormHelp",
175198
}, ",")
176199

177-
-- Mount each input as its own bordered child floating window.
200+
-- Mount each input as its own floating window. Bordered inputs get their
201+
-- own border + label; borderless inputs (e.g. checkbox) render inline but
202+
-- still align their CONTENT column with the bordered siblings' content
203+
-- (not their border column) so everything lines up visually.
178204
local border = config.options.window.border
179205
for i, input in ipairs(self._inputs) do
180206
local r = layout.rows[i]
181-
-- Child's content origin: inside the parent content area, offset by the
182-
-- row's top_border_offset plus one row for the child's own top border;
183-
-- and one col inside the parent plus horizontal padding plus one for the
184-
-- child's own left border.
185-
input:mount({
186-
row = layout.parent_row + r.top_border_offset + 1,
187-
col = layout.parent_col + layout.pad_h + 1,
207+
-- Bordered children get `+1` to clear the parent's left border; their
208+
-- content then sits at `+2`. Borderless children shift an extra column
209+
-- so their content column lines up with the bordered siblings' content
210+
-- column (not their border column).
211+
local col_offset = r.bordered and 1 or 2
212+
local mount_opts = {
213+
row = layout.parent_row + r.content_row_offset,
214+
col = layout.parent_col + layout.pad_h + col_offset,
188215
width = layout.child_inner_w,
189-
border = border,
190-
})
216+
}
217+
if r.bordered then
218+
mount_opts.border = border
219+
end
220+
input:mount(mount_opts)
191221
self:_install_keymaps(input)
192222
self:_install_validation(input)
193223
end
@@ -267,7 +297,8 @@ function M:_validate_all()
267297
if input.validator then
268298
input._touched = true
269299
local err = input.validator(input:value())
270-
if err == "" then
300+
-- Only strings count as errors; nil / false / other types = no error.
301+
if type(err) ~= "string" or err == "" then
271302
err = nil
272303
end
273304
input._error = err
@@ -348,7 +379,8 @@ function M:_validate_input(input)
348379
return
349380
end
350381
local err = input.validator(input:value())
351-
if err == "" then
382+
-- Only strings count as errors; nil / false / other types = no error.
383+
if type(err) ~= "string" or err == "" then
352384
err = nil
353385
end
354386
input._error = err
@@ -362,6 +394,20 @@ function M:_render_validation(input)
362394
if not (win and vim.api.nvim_win_is_valid(win)) then
363395
return
364396
end
397+
398+
-- Borderless inputs (checkbox) render the error inline — they already read
399+
-- `self._error` from their own `_render_display()`.
400+
local bordered = true
401+
if type(input.is_bordered) == "function" then
402+
bordered = input:is_bordered()
403+
end
404+
if not bordered then
405+
if type(input._render_display) == "function" then
406+
input:_render_display()
407+
end
408+
return
409+
end
410+
365411
local has_error = input._error ~= nil
366412

367413
if has_error then
@@ -418,17 +464,21 @@ function M:_help_line()
418464
if nav then
419465
table.insert(parts, nav .. " navigate")
420466
end
421-
-- Only advertise open_select if the form actually has a select input.
422-
local has_select = false
467+
-- Only advertise type-specific keys if the form actually has such an input.
468+
local has_select, has_checkbox = false, false
423469
for _, input in ipairs(self._inputs) do
424470
if input.type == "select" then
425471
has_select = true
426-
break
472+
elseif input.type == "checkbox" then
473+
has_checkbox = true
427474
end
428475
end
429476
if has_select then
430477
add(km.open_select, "open")
431478
end
479+
if has_checkbox then
480+
add(km.toggle, "toggle")
481+
end
432482
add(km.submit, "submit")
433483
add(km.cancel, "cancel")
434484
return table.concat(parts, " ")
@@ -487,6 +537,20 @@ function M:_install_keymaps(input)
487537
-- Block insert mode on the select display buffer.
488538
vim.keymap.set("n", "i", "<Nop>", { buffer = buf, nowait = true, silent = true })
489539
vim.keymap.set("n", "a", "<Nop>", { buffer = buf, nowait = true, silent = true })
540+
elseif input.type == "checkbox" then
541+
-- Toggle on the configured toggle key AND on open_select so users who
542+
-- prefer <CR> for all interactions get a single key for every field.
543+
map("n", km.toggle, function()
544+
input:toggle()
545+
end)
546+
if km.open_select and km.open_select ~= km.toggle then
547+
map("n", km.open_select, function()
548+
input:toggle()
549+
end)
550+
end
551+
-- Block insert mode on the checkbox display buffer.
552+
vim.keymap.set("n", "i", "<Nop>", { buffer = buf, nowait = true, silent = true })
553+
vim.keymap.set("n", "a", "<Nop>", { buffer = buf, nowait = true, silent = true })
490554
elseif input.type == "text" then
491555
-- Single-line text inputs must never contain newlines. <CR> in insert
492556
-- mode just exits insert mode (accepting the value) rather than inserting

0 commit comments

Comments
 (0)