From 94a784f64ce9256a1f9ac6ada7e36fbb4feb9573 Mon Sep 17 00:00:00 2001 From: Chen Asraf Date: Sun, 5 Apr 2026 23:28:56 +0300 Subject: [PATCH] feat: checkbox field --- README.md | 49 ++++++++- doc/input-form.txt | 25 +++++ doc/tags | 1 + lua/input-form/config.lua | 12 +++ lua/input-form/form.lua | 100 +++++++++++++++---- lua/input-form/inputs/checkbox.lua | 150 ++++++++++++++++++++++++++++ lua/input-form/inputs/init.lua | 1 + lua/input-form/inputs/multiline.lua | 4 + lua/input-form/inputs/select.lua | 4 + lua/input-form/inputs/text.lua | 4 + lua/input-form/validators.lua | 22 ++++ tests/test_form.lua | 110 ++++++++++++++++++++ tests/test_inputs_checkbox.lua | 118 ++++++++++++++++++++++ tests/test_validators.lua | 16 +++ 14 files changed, 597 insertions(+), 19 deletions(-) create mode 100644 lua/input-form/inputs/checkbox.lua create mode 100644 tests/test_inputs_checkbox.lua diff --git a/README.md b/README.md index 8c6b954..144cdd2 100644 --- a/README.md +++ b/README.md @@ -11,7 +11,7 @@ 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` +- Input types: `text`, `multiline`, `select`, `checkbox` - Select dropdowns open with ``; arrows navigate; `` confirms - Submit with `` — results delivered as a `{ [name] = value }` table - Cancel with `` @@ -141,6 +141,30 @@ All inputs share `name` (string, required — the key in the result table) and `value()` returns the selected `id` (not the label). +#### `checkbox` + +```lua +{ 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: + +``` +☐ I agree (must be checked) +``` + +- `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)). + ## Validation Each input spec accepts an optional `validator` function: @@ -170,6 +194,10 @@ V.min_length(n, [msg]) -- at least `n` characters V.max_length(n, [msg]) -- at most `n` characters V.matches(lua_pattern, [msg]) -- match a Lua pattern V.is_number([msg]) -- tonumber() must succeed +V.checked([required], [msg]) -- checkbox must equal `required` + -- (default true; default messages + -- "(must be checked)" / + -- "(must be unchecked)") V.one_of({ "a", "b", ... }, [msg]) -- value must be in the list V.custom(predicate, msg) -- wrap a `fun(v): boolean` predicate V.chain(v1, v2, ...) -- run validators in order, first error wins @@ -217,6 +245,24 @@ validator = function(value) end ``` +### Checkbox glyphs + +Override the characters rendered by `checkbox` inputs via `style.checkbox`: + +```lua +require("input-form").setup({ + style = { + checkbox = { + checked = "☑", -- default + unchecked = "☐", -- default + }, + }, +}) +``` + +Alternatives that render well in most fonts: `[x]` / `[ ]`, `✔` / `·`, +`●` / `○`. + ### Select chevrons The glyphs shown on the right side of `select` inputs to indicate the @@ -311,6 +357,7 @@ require("input-form").setup({ submit = "", cancel = "", open_select = "", + toggle = "", }, select = { max_height = 10, diff --git a/doc/input-form.txt b/doc/input-form.txt index 79d7765..2c18f26 100644 --- a/doc/input-form.txt +++ b/doc/input-form.txt @@ -117,6 +117,8 @@ Default configuration for |input-form|. cancel = "", --- Open the dropdown when focused on a `select` input. open_select = "", + --- Toggle the value of a `checkbox` input. + toggle = "", }, --- Options for `select` inputs. select = { @@ -138,6 +140,16 @@ Default configuration for |input-form|. --- Glyph shown when the dropdown is open. open = "⌃", }, + --- Glyphs shown in `checkbox` inputs. + checkbox = { + --- Shown when the box is checked. + checked = "☑", + --- Shown when the box is unchecked. + unchecked = "☐", + --- Blank rows rendered above and below a checkbox to visually separate + --- it from adjacent bordered inputs. Set to `0` to pack tightly. + padding = 1, + }, --- Highlight groups applied on every `form:show()`. Each entry is passed --- directly to `vim.api.nvim_set_hl(0, name, spec)`, so any option that --- `nvim_set_hl` accepts (`fg`, `bg`, `link`, `bold`, `italic`, @@ -238,6 +250,19 @@ Parameters ~ Return ~ `(function)` +------------------------------------------------------------------------------ + *M.checked()* + `M.checked`({required}, {msg}) +Require a checkbox value to equal the given boolean. Intended for +`checkbox` inputs ("must agree to terms", "must enable feature", ...). +Parameters ~ +{required} `(boolean|nil)` The required value. Defaults to `true`. +{msg} `(string|nil)` Override error message. Defaults to + `"(must be checked)"` when `required` is `true`, otherwise + `"(must be unchecked)"`. +Return ~ +`(function)` + ------------------------------------------------------------------------------ *M.one_of()* `M.one_of`({choices}, {msg}) diff --git a/doc/tags b/doc/tags index d977f35..812ddc5 100644 --- a/doc/tags +++ b/doc/tags @@ -1,6 +1,7 @@ Default input-form.txt /*Default* M.Form input-form.txt /*M.Form* M.chain() input-form.txt /*M.chain()* +M.checked() input-form.txt /*M.checked()* M.config input-form.txt /*M.config* M.custom() input-form.txt /*M.custom()* M.is_number() input-form.txt /*M.is_number()* diff --git a/lua/input-form/config.lua b/lua/input-form/config.lua index 011dcb8..8fd2098 100644 --- a/lua/input-form/config.lua +++ b/lua/input-form/config.lua @@ -41,6 +41,8 @@ M.defaults = { cancel = "", --- Open the dropdown when focused on a `select` input. open_select = "", + --- Toggle the value of a `checkbox` input. + toggle = "", }, --- Options for `select` inputs. select = { @@ -62,6 +64,16 @@ M.defaults = { --- Glyph shown when the dropdown is open. open = "⌃", }, + --- Glyphs shown in `checkbox` inputs. + checkbox = { + --- Shown when the box is checked. + checked = "☑", + --- Shown when the box is unchecked. + unchecked = "☐", + --- Blank rows rendered above and below a checkbox to visually separate + --- it from adjacent bordered inputs. Set to `0` to pack tightly. + padding = 1, + }, --- Highlight groups applied on every `form:show()`. Each entry is passed --- directly to `vim.api.nvim_set_hl(0, name, spec)`, so any option that --- `nvim_set_hl` accepts (`fg`, `bg`, `link`, `bold`, `italic`, diff --git a/lua/input-form/form.lua b/lua/input-form/form.lua index 6ee455a..8a9dc95 100644 --- a/lua/input-form/form.lua +++ b/lua/input-form/form.lua @@ -72,17 +72,39 @@ function M:_compute_layout() 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 child_inner_w = child_outer_w - 2 -- minus child border (for bordered inputs) + + -- Extra blank rows rendered above/below a checkbox (borderless input) so + -- its glyph doesn't butt directly against an adjacent bordered input's + -- border. Configurable via `style.checkbox.padding`. + local cb_pad = (opts.style and opts.style.checkbox and opts.style.checkbox.padding) or 0 local rows = {} local inner_h = pad_top for i, input in ipairs(self._inputs) do local h = input:height() + -- NB: avoid the `a and b or c` idiom — `is_bordered()` legitimately + -- returns `false` and that must not get coerced back to the default. + local bordered = true + 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 + 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 + -- (i.e. the border origin for bordered windows, the content row for + -- borderless windows). Parent_row is itself a border origin, so every + -- child needs a `+1` to clear the parent's top border — matching the + -- `+1` already applied on the column axis in `show()`. + local content_offset = inner_h + 1 + top_pad table.insert(rows, { - top_border_offset = inner_h, -- row inside parent content where child's top border sits + bordered = bordered, + content_row_offset = content_offset, value_height = h, }) - inner_h = inner_h + h + 2 -- child's full outer height (content + 2 border rows) + inner_h = inner_h + outer_h if i < #self._inputs then inner_h = inner_h + sep end @@ -104,6 +126,7 @@ function M:_compute_layout() parent_col = parent_col, parent_inner_w = parent_inner_w, parent_inner_h = inner_h, + child_outer_w = child_outer_w, child_inner_w = child_inner_w, pad_h = pad_h, rows = rows, @@ -174,20 +197,27 @@ function M:show() "FloatFooter:InputFormHelp", }, ",") - -- Mount each input as its own bordered child floating window. + -- Mount each input as its own floating window. Bordered inputs get their + -- own border + label; borderless inputs (e.g. checkbox) render inline but + -- still align their CONTENT column with the bordered siblings' content + -- (not their border column) so everything lines up visually. 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, + -- Bordered children get `+1` to clear the parent's left border; their + -- content then sits at `+2`. Borderless children shift an extra column + -- so their content column lines up with the bordered siblings' content + -- column (not their border column). + local col_offset = r.bordered and 1 or 2 + local mount_opts = { + row = layout.parent_row + r.content_row_offset, + col = layout.parent_col + layout.pad_h + col_offset, width = layout.child_inner_w, - border = border, - }) + } + if r.bordered then + mount_opts.border = border + end + input:mount(mount_opts) self:_install_keymaps(input) self:_install_validation(input) end @@ -267,7 +297,8 @@ function M:_validate_all() if input.validator then input._touched = true local err = input.validator(input:value()) - if err == "" then + -- Only strings count as errors; nil / false / other types = no error. + if type(err) ~= "string" or err == "" then err = nil end input._error = err @@ -348,7 +379,8 @@ function M:_validate_input(input) return end local err = input.validator(input:value()) - if err == "" then + -- Only strings count as errors; nil / false / other types = no error. + if type(err) ~= "string" or err == "" then err = nil end input._error = err @@ -362,6 +394,20 @@ function M:_render_validation(input) if not (win and vim.api.nvim_win_is_valid(win)) then return end + + -- Borderless inputs (checkbox) render the error inline — they already read + -- `self._error` from their own `_render_display()`. + local bordered = true + if type(input.is_bordered) == "function" then + bordered = input:is_bordered() + end + if not bordered then + if type(input._render_display) == "function" then + input:_render_display() + end + return + end + local has_error = input._error ~= nil if has_error then @@ -418,17 +464,21 @@ function M:_help_line() 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 + -- Only advertise type-specific keys if the form actually has such an input. + local has_select, has_checkbox = false, false for _, input in ipairs(self._inputs) do if input.type == "select" then has_select = true - break + elseif input.type == "checkbox" then + has_checkbox = true end end if has_select then add(km.open_select, "open") end + if has_checkbox then + add(km.toggle, "toggle") + end add(km.submit, "submit") add(km.cancel, "cancel") return table.concat(parts, " ") @@ -487,6 +537,20 @@ function M:_install_keymaps(input) -- 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 }) + elseif input.type == "checkbox" then + -- Toggle on the configured toggle key AND on open_select so users who + -- prefer for all interactions get a single key for every field. + map("n", km.toggle, function() + input:toggle() + end) + if km.open_select and km.open_select ~= km.toggle then + map("n", km.open_select, function() + input:toggle() + end) + end + -- Block insert mode on the checkbox display buffer. + vim.keymap.set("n", "i", "", { buffer = buf, nowait = true, silent = true }) + vim.keymap.set("n", "a", "", { buffer = buf, nowait = true, silent = true }) elseif input.type == "text" then -- Single-line text inputs must never contain newlines. in insert -- mode just exits insert mode (accepting the value) rather than inserting diff --git a/lua/input-form/inputs/checkbox.lua b/lua/input-form/inputs/checkbox.lua new file mode 100644 index 0000000..8e8ad53 --- /dev/null +++ b/lua/input-form/inputs/checkbox.lua @@ -0,0 +1,150 @@ +--- Boolean checkbox input component. +--- +--- Unlike text/multiline/select, the checkbox is borderless and renders +--- inline: the label sits next to the glyph, and any validation error is +--- appended to the same line (`☑ Label (must be checked)`). The error +--- portion is highlighted with `InputFormFieldError`. +--- +--- Toggles via the configured `keymaps.toggle` key (default ``) and +--- also the `keymaps.open_select` key, so users get a consistent +--- "interact with this field" key. +--- `value()` returns a boolean. + +local config = require("input-form.config") +local utils = require("input-form.utils") + +local M = {} +M.__index = M + +--- Create a new checkbox from its spec. +---@param spec table { name, label, default? } +---@return table +function M.new(spec) + return setmetatable({ + type = "checkbox", + name = spec.name, + label = spec.label or spec.name, + _value = spec.default == true, + buf = nil, + win = nil, + }, M) +end + +function M:height() + return 1 +end + +--- Checkboxes are rendered without a surrounding border/title so the form's +--- layout packs them more tightly than the other input types. +function M:is_bordered() + return false +end + +local NS = vim.api.nvim_create_namespace("input-form-checkbox") + +local function glyph_for(checked) + local style = config.options.style or {} + local cb = style.checkbox or {} + if checked then + return cb.checked or "☑" + end + return cb.unchecked or "☐" +end + +function M:_render_display() + if not (self.buf and vim.api.nvim_buf_is_valid(self.buf)) then + return + end + local glyph = glyph_for(self._value) + local base = glyph + if self.label and self.label ~= "" then + base = glyph .. " " .. self.label + end + local err = (self._error and self._error ~= "") and self._error or nil + local line = err and (base .. " " .. err) or base + + vim.bo[self.buf].modifiable = true + vim.api.nvim_buf_set_lines(self.buf, 0, -1, false, { line }) + vim.bo[self.buf].modifiable = false + + -- Highlight the error suffix (if any). + vim.api.nvim_buf_clear_namespace(self.buf, NS, 0, -1) + if err then + local err_start = #base + 1 -- byte offset of the space before the error + pcall(vim.api.nvim_buf_set_extmark, self.buf, NS, 0, err_start, { + end_col = #line, + hl_group = "InputFormFieldError", + }) + end +end + +function M:mount(layout) + self._width = layout.width + 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 + utils.mark_form_buffer(self.buf) + + self.win = vim.api.nvim_open_win(self.buf, false, { + relative = "editor", + row = layout.row, + col = layout.col, + width = layout.width, + height = 1, + style = "minimal", + focusable = true, + zindex = 50, + }) + vim.wo[self.win].winhl = "NormalFloat:InputFormField" + + self:_render_display() +end + +function M:value() + return self._value +end + +function M:unmount() + 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) + pcall(vim.api.nvim_win_set_cursor, self.win, { 1, 0 }) + end +end + +--- Toggle the checkbox state. Notifies the form (for validation) if an +--- `_on_change` hook has been installed. +function M:toggle() + self._value = not self._value + self:_render_display() + if self._on_change then + self._on_change() + end +end + +--- Programmatically set the checkbox value. +---@param v any Truthy for checked, falsey for unchecked. +function M:set(v) + local new = v and true or false + if new == self._value then + return + end + self._value = new + self:_render_display() + if self._on_change then + self._on_change() + end +end + +return M diff --git a/lua/input-form/inputs/init.lua b/lua/input-form/inputs/init.lua index d677529..509b83f 100644 --- a/lua/input-form/inputs/init.lua +++ b/lua/input-form/inputs/init.lua @@ -6,6 +6,7 @@ M.types = { text = require("input-form.inputs.text"), multiline = require("input-form.inputs.multiline"), select = require("input-form.inputs.select"), + checkbox = require("input-form.inputs.checkbox"), } --- Build an input component instance from a user-provided spec. diff --git a/lua/input-form/inputs/multiline.lua b/lua/input-form/inputs/multiline.lua index 4e8ec47..26edebe 100644 --- a/lua/input-form/inputs/multiline.lua +++ b/lua/input-form/inputs/multiline.lua @@ -27,6 +27,10 @@ function M:height() return self._height end +function M:is_bordered() + return true +end + function M:mount(layout) self.buf = vim.api.nvim_create_buf(false, true) vim.bo[self.buf].buftype = "nofile" diff --git a/lua/input-form/inputs/select.lua b/lua/input-form/inputs/select.lua index 52b0463..8495018 100644 --- a/lua/input-form/inputs/select.lua +++ b/lua/input-form/inputs/select.lua @@ -39,6 +39,10 @@ function M:height() return 1 end +function M:is_bordered() + return true +end + local function label_for(options, id) for _, opt in ipairs(options) do if opt.id == id then diff --git a/lua/input-form/inputs/text.lua b/lua/input-form/inputs/text.lua index a12e994..8ba31d8 100644 --- a/lua/input-form/inputs/text.lua +++ b/lua/input-form/inputs/text.lua @@ -24,6 +24,10 @@ function M:height() return 1 end +function M:is_bordered() + return true +end + --- Create the backing buffer and floating window. ---@param layout table { row, col, width } function M:mount(layout) diff --git a/lua/input-form/validators.lua b/lua/input-form/validators.lua index 9fdf0a0..1ef53f8 100644 --- a/lua/input-form/validators.lua +++ b/lua/input-form/validators.lua @@ -82,6 +82,28 @@ function M.is_number(msg) end end +--- Require a checkbox value to equal the given boolean. Intended for +--- `checkbox` inputs ("must agree to terms", "must enable feature", ...). +---@param required boolean|nil The required value. Defaults to `true`. +---@param msg string|nil Override error message. Defaults to +--- `"(must be checked)"` when `required` is `true`, otherwise +--- `"(must be unchecked)"`. +---@return function +function M.checked(required, msg) + if required == nil then + required = true + end + if type(msg) ~= "string" then + msg = required and "(must be checked)" or "(must be unchecked)" + end + return function(value) + if value == required then + return nil + end + return msg + end +end + --- Require the value to be one of the given choices (useful for text inputs --- that must match a fixed allowlist; select inputs should use their --- `options` list instead). diff --git a/tests/test_form.lua b/tests/test_form.lua index f4fa4e5..63a50b2 100644 --- a/tests/test_form.lua +++ b/tests/test_form.lua @@ -262,6 +262,116 @@ T["form"]["validation"]["inputs without a validator are never marked touched"] = eq(child.lua_get([[_G.vf._inputs[2]._error]]), vim.NIL) end +T["form"]["checkbox"] = MiniTest.new_set() + +T["form"]["checkbox"]["lays out without overlapping adjacent bordered inputs"] = function() + child.lua([[ + _G.cf = require('input-form').create_form({ + inputs = { + { name = 'a', label = 'A', type = 'text' }, + { name = 'b', label = 'B', type = 'checkbox' }, + { name = 'c', label = 'C', type = 'text' }, + }, + on_submit = function() end, + }) + _G.cf:show() + ]]) + -- `content_row_offset` is the editor-row offset from `parent_row` passed + -- as `nvim_open_win`'s `row` parameter. `row` is the window's OUTER + -- top-left (border origin for bordered wins, content row for borderless), + -- and parent_row is itself a border origin, so every child gets a `+1` to + -- clear the parent's top border. Bordered text consumes 3 inner rows, + -- checkbox consumes 1 + 2*style.checkbox.padding (default 1). Expected + -- offsets: 1 (text border at inner row 0), 5 (checkbox content at inner + -- row 4 — one blank pad row after the text's bottom border), 7 (next + -- text border at inner row 6 — one blank pad row after the checkbox). + eq(child.lua_get([[_G.cf._layout.rows[1].content_row_offset]]), 1) + eq(child.lua_get([[_G.cf._layout.rows[2].content_row_offset]]), 5) + eq(child.lua_get([[_G.cf._layout.rows[3].content_row_offset]]), 7) + eq(child.lua_get([[_G.cf._layout.rows[2].bordered]]), false) + eq(child.lua_get([[_G.cf._layout.parent_inner_h]]), 9) +end + +T["form"]["checkbox"]["is included in submit results as a boolean"] = function() + child.lua([[ + _G.submit_result = nil + _G.cf = require('input-form').create_form({ + inputs = { + { name = 'agree', label = 'Agree', type = 'checkbox', default = false }, + { name = 'subscribe', label = 'Subscribe', type = 'checkbox', default = true }, + }, + on_submit = function(r) _G.submit_result = r end, + }) + _G.cf:show() + _G.cf:submit() + ]]) + eq(child.lua_get([[_G.submit_result]]), { agree = false, subscribe = true }) +end + +T["form"]["checkbox"]["toggle key flips value and updates result"] = function() + child.lua([[ + _G.submit_result = nil + _G.cf = require('input-form').create_form({ + inputs = { + { name = 'agree', label = 'Agree', type = 'checkbox', default = false }, + }, + on_submit = function(r) _G.submit_result = r end, + }) + _G.cf:show() + vim.api.nvim_set_current_win(_G.cf._inputs[1].win) + vim.api.nvim_feedkeys( + vim.api.nvim_replace_termcodes('', true, false, true), 'x', false + ) + _G.cf:submit() + ]]) + eq(child.lua_get([[_G.submit_result]]), { agree = true }) +end + +T["form"]["checkbox"]["is toggled by open_select key too"] = function() + child.lua([[ + _G.cf = require('input-form').create_form({ + inputs = { + { name = 'agree', label = 'Agree', type = 'checkbox' }, + }, + on_submit = function() end, + }) + _G.cf:show() + vim.api.nvim_set_current_win(_G.cf._inputs[1].win) + vim.api.nvim_feedkeys( + vim.api.nvim_replace_termcodes('', true, false, true), 'x', false + ) + ]]) + eq(child.lua_get([[_G.cf._inputs[1]:value()]]), true) +end + +T["form"]["checkbox"]["validator can require checked"] = function() + child.lua([[ + local V = require('input-form.validators') + _G.submit_result = nil + _G.cf = require('input-form').create_form({ + inputs = { + { + name = 'agree', + label = 'Agree', + type = 'checkbox', + default = false, + validator = V.custom(function(v) return v == true end, 'You must agree'), + }, + }, + on_submit = function(r) _G.submit_result = r end, + }) + _G.cf:show() + _G.cf:submit() -- should be blocked + ]]) + eq(child.lua_get([[_G.submit_result]]), vim.NIL) + eq(child.lua_get([[_G.cf._inputs[1]._error]]), "You must agree") + -- Toggle on, validator re-runs (via _on_change), error clears. + child.lua([[_G.cf._inputs[1]:toggle()]]) + eq(child.lua_get([[_G.cf._inputs[1]._error]]), vim.NIL) + child.lua([[_G.cf:submit()]]) + eq(child.lua_get([[_G.submit_result]]), { agree = true }) +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. diff --git a/tests/test_inputs_checkbox.lua b/tests/test_inputs_checkbox.lua new file mode 100644 index 0000000..53ff706 --- /dev/null +++ b/tests/test_inputs_checkbox.lua @@ -0,0 +1,118 @@ +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["checkbox input"] = MiniTest.new_set() + +T["checkbox input"]["defaults to false"] = function() + child.lua([[_G.t = require('input-form.inputs.checkbox').new({ name = 'c', label = 'C' })]]) + eq(child.lua_get([[_G.t:value()]]), false) +end + +T["checkbox input"]["honors explicit default = true"] = function() + child.lua( + [[_G.t = require('input-form.inputs.checkbox').new({ name = 'c', label = 'C', default = true })]] + ) + eq(child.lua_get([[_G.t:value()]]), true) +end + +T["checkbox input"]["height is 1"] = function() + child.lua([[_G.t = require('input-form.inputs.checkbox').new({ name = 'c', label = 'C' })]]) + eq(child.lua_get([[_G.t:height()]]), 1) +end + +T["checkbox input"]["mount renders inline glyph + label"] = function() + child.lua([[ + _G.t = require('input-form.inputs.checkbox').new({ name = 'c', label = 'C' }) + _G.t:mount({ row = 5, col = 5, width = 20 }) + ]]) + eq(child.lua_get([[vim.api.nvim_buf_get_lines(_G.t.buf, 0, -1, false)]]), { "☐ C" }) + child.lua([[ + _G.t2 = require('input-form.inputs.checkbox').new({ name = 'c', label = 'C', default = true }) + _G.t2:mount({ row = 8, col = 5, width = 20 }) + ]]) + eq(child.lua_get([[vim.api.nvim_buf_get_lines(_G.t2.buf, 0, -1, false)]]), { "☑ C" }) +end + +T["checkbox input"]["is borderless"] = function() + child.lua([[_G.t = require('input-form.inputs.checkbox').new({ name = 'c', label = 'C' })]]) + eq(child.lua_get([[_G.t:is_bordered()]]), false) +end + +T["checkbox input"]["toggle flips the value and re-renders"] = function() + child.lua([[ + _G.t = require('input-form.inputs.checkbox').new({ name = 'c', label = 'Agree' }) + _G.t:mount({ row = 5, col = 5, width = 20 }) + _G.t:toggle() + ]]) + eq(child.lua_get([[_G.t:value()]]), true) + eq(child.lua_get([[vim.api.nvim_buf_get_lines(_G.t.buf, 0, -1, false)]]), { "☑ Agree" }) + child.lua([[_G.t:toggle()]]) + eq(child.lua_get([[_G.t:value()]]), false) + eq(child.lua_get([[vim.api.nvim_buf_get_lines(_G.t.buf, 0, -1, false)]]), { "☐ Agree" }) +end + +T["checkbox input"]["renders error inline after label"] = function() + child.lua([[ + _G.t = require('input-form.inputs.checkbox').new({ name = 'c', label = 'Agree' }) + _G.t:mount({ row = 5, col = 5, width = 30 }) + _G.t._error = "(must be checked)" + _G.t:_render_display() + ]]) + eq( + child.lua_get([[vim.api.nvim_buf_get_lines(_G.t.buf, 0, -1, false)]]), + { "☐ Agree (must be checked)" } + ) + -- Clearing the error removes the suffix. + child.lua([[_G.t._error = nil; _G.t:_render_display()]]) + eq(child.lua_get([[vim.api.nvim_buf_get_lines(_G.t.buf, 0, -1, false)]]), { "☐ Agree" }) +end + +T["checkbox input"]["set updates the value idempotently"] = function() + child.lua([[ + _G.t = require('input-form.inputs.checkbox').new({ name = 'c', label = 'C' }) + _G.t:mount({ row = 5, col = 5, width = 20 }) + _G.changes = 0 + _G.t._on_change = function() _G.changes = _G.changes + 1 end + _G.t:set(true) + _G.t:set(true) -- no-op + _G.t:set(false) + ]]) + eq(child.lua_get([[_G.t:value()]]), false) + eq(child.lua_get([[_G.changes]]), 2) +end + +T["checkbox input"]["display buffer is read-only"] = function() + child.lua([[ + _G.t = require('input-form.inputs.checkbox').new({ name = 'c', label = 'C' }) + _G.t:mount({ row = 5, col = 5, width = 20 }) + ]]) + eq(child.lua_get([[vim.bo[_G.t.buf].modifiable]]), false) +end + +T["checkbox input"]["custom glyphs from style config"] = function() + child.lua([[ + require('input-form').setup({ + style = { checkbox = { checked = '✔', unchecked = '·' } } + }) + _G.t = require('input-form.inputs.checkbox').new({ name = 'c', label = 'C' }) + _G.t:mount({ row = 5, col = 5, width = 20 }) + ]]) + eq(child.lua_get([[vim.api.nvim_buf_get_lines(_G.t.buf, 0, -1, false)]]), { "· C" }) + child.lua([[_G.t:toggle()]]) + eq(child.lua_get([[vim.api.nvim_buf_get_lines(_G.t.buf, 0, -1, false)]]), { "✔ C" }) +end + +return T diff --git a/tests/test_validators.lua b/tests/test_validators.lua index 0a5b1f1..6e7f6c8 100644 --- a/tests/test_validators.lua +++ b/tests/test_validators.lua @@ -49,6 +49,22 @@ T["validators"]["is_number"] = function() eq(child.lua_get([[V.is_number()("3.14")]]), vim.NIL) end +T["validators"]["checked"] = function() + -- Defaults to requiring `true`. + eq(child.lua_get([[V.checked()(true)]]), vim.NIL) + eq(child.lua_get([[V.checked()(false)]]), "(must be checked)") + eq(child.lua_get([[V.checked()(nil)]]), "(must be checked)") + -- Explicit required value. + eq(child.lua_get([[V.checked(true)(true)]]), vim.NIL) + eq(child.lua_get([[V.checked(true)(false)]]), "(must be checked)") + -- Require UNchecked. + eq(child.lua_get([[V.checked(false)(false)]]), vim.NIL) + eq(child.lua_get([[V.checked(false)(true)]]), "(must be unchecked)") + -- Custom message (second arg). + eq(child.lua_get([[V.checked(true, "please tick the box")(false)]]), "please tick the box") + eq(child.lua_get([[V.checked(false, "leave it off")(true)]]), "leave it off") +end + T["validators"]["one_of"] = function() eq(child.lua_get([[V.one_of({ 'a', 'b' })('c')]]), "Value is not allowed") eq(child.lua_get([[V.one_of({ 'a', 'b' })('a')]]), vim.NIL)