mirror of
https://github.com/chenasraf/input-form.nvim.git
synced 2026-05-17 17:38:01 +00:00
feat: checkbox field
This commit is contained in:
@@ -41,6 +41,8 @@ M.defaults = {
|
||||
cancel = "<Esc>",
|
||||
--- Open the dropdown when focused on a `select` input.
|
||||
open_select = "<CR>",
|
||||
--- Toggle the value of a `checkbox` input.
|
||||
toggle = "<Space>",
|
||||
},
|
||||
--- 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`,
|
||||
|
||||
@@ -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", "<Nop>", { buffer = buf, nowait = true, silent = true })
|
||||
vim.keymap.set("n", "a", "<Nop>", { 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 <CR> 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", "<Nop>", { buffer = buf, nowait = true, silent = true })
|
||||
vim.keymap.set("n", "a", "<Nop>", { buffer = buf, nowait = true, silent = true })
|
||||
elseif input.type == "text" then
|
||||
-- Single-line text inputs must never contain newlines. <CR> in insert
|
||||
-- mode just exits insert mode (accepting the value) rather than inserting
|
||||
|
||||
150
lua/input-form/inputs/checkbox.lua
Normal file
150
lua/input-form/inputs/checkbox.lua
Normal file
@@ -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 `<Space>`) 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
|
||||
@@ -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.
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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).
|
||||
|
||||
Reference in New Issue
Block a user