feat: more style controls in config

This commit is contained in:
2026-04-05 10:10:28 +03:00
parent 6a5f8fb22a
commit f20343f312
7 changed files with 234 additions and 21 deletions

View File

@@ -217,16 +217,79 @@ validator = function(value)
end
```
### Highlight groups
### Select chevrons
Error rendering uses three highlight groups. Override them to re-theme:
The glyphs shown on the right side of `select` inputs to indicate the
dropdown state are configurable under `style.chevron`:
```lua
vim.api.nvim_set_hl(0, "InputFormFieldError", { fg = "#ff5555" })
vim.api.nvim_set_hl(0, "InputFormFieldErrorBorder", { fg = "#ff5555" })
vim.api.nvim_set_hl(0, "InputFormFieldErrorTitle", { fg = "#ff5555", bold = true })
require("input-form").setup({
style = {
chevron = {
closed = "⌄", -- default
open = "⌃", -- default
},
},
})
```
Use whatever you like — e.g. ASCII fallbacks for terminals without good
Unicode support:
```lua
style = { chevron = { closed = " v", open = " ^" } }
```
A leading space is recommended so the glyph doesn't sit flush against the
label.
### Highlight groups
All highlight groups the plugin uses are listed under `style.highlights` in
the config and can be overridden via `setup()`. Each entry is passed directly
to `vim.api.nvim_set_hl(0, name, spec)`, so anything `nvim_set_hl` accepts
works (`fg`, `bg`, `link`, `bold`, `italic`, `default`, etc.).
```lua
require("input-form").setup({
style = {
highlights = {
-- error state
InputFormFieldError = { fg = "#ff5555", italic = true },
InputFormFieldErrorBorder = { fg = "#ff5555" },
InputFormFieldErrorTitle = { fg = "#ff5555", bold = true },
-- help footer
InputFormHelp = { fg = "#88ccff" },
-- parent frame border via link
InputFormBorder = { link = "Comment" },
},
},
})
```
Available groups:
| Group | Purpose |
| --------------------------- | -------------------------------------------- |
| `InputFormNormal` | Parent form window background |
| `InputFormBorder` | Parent form border |
| `InputFormTitle` | Parent form title |
| `InputFormHelp` | Footer help line (key hints) |
| `InputFormField` | Individual input field background |
| `InputFormFieldBorder` | Individual input field border |
| `InputFormFieldTitle` | Individual input field label (on top border) |
| `InputFormFieldError` | Error message footer on an invalid field |
| `InputFormFieldErrorBorder` | Invalid field border |
| `InputFormFieldErrorTitle` | Invalid field label |
| `InputFormDropdown` | Select dropdown background |
| `InputFormDropdownActive` | Highlighted dropdown row |
User overrides fully **replace** the default spec per group (they are not
deep-merged at the field level), so you don't need to re-specify
`default = true`. Highlights are re-applied on every `form:show()`, so a
`setup({ style = { highlights = ... } })` call that happens after the first
form has been rendered still takes effect on the next open.
## Configuration
Defaults:

View File

@@ -127,6 +127,41 @@ Default configuration for |input-form|.
multiline = {
height = 5,
},
--- Visual styling
style = {
--- Chevron glyphs shown on the right side of `select` inputs to indicate
--- the dropdown state. Override either to taste (e.g. `"v"`/`"^"` for
--- ASCII, or extra spacing for wider icons).
chevron = {
--- Glyph shown when the dropdown is closed.
closed = "⌄",
--- Glyph shown when the dropdown is open.
open = "⌃",
},
--- 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`,
--- `default`, ...) is valid. User overrides fully replace the default
--- spec for the matching group (they are NOT deep-merged field by field).
highlights = {
-- Parent form window
InputFormNormal = { link = "NormalFloat", default = true },
InputFormBorder = { link = "FloatBorder", default = true },
InputFormTitle = { link = "FloatTitle", default = true },
InputFormHelp = { fg = "Cyan", default = true },
-- Individual input fields
InputFormField = { link = "NormalFloat", default = true },
InputFormFieldBorder = { link = "FloatBorder", default = true },
InputFormFieldTitle = { link = "FloatTitle", default = true },
-- Error state for individual input fields
InputFormFieldError = { fg = "Red", default = true },
InputFormFieldErrorBorder = { fg = "Red", default = true },
InputFormFieldErrorTitle = { fg = "Red", default = true },
-- Select dropdown list
InputFormDropdown = { link = "NormalFloat", default = true },
InputFormDropdownActive = { link = "PmenuSel", default = true },
},
},
}
M.options = vim.deepcopy(M.defaults)

View File

@@ -51,6 +51,41 @@ M.defaults = {
multiline = {
height = 5,
},
--- Visual styling
style = {
--- Chevron glyphs shown on the right side of `select` inputs to indicate
--- the dropdown state. Override either to taste (e.g. `"v"`/`"^"` for
--- ASCII, or extra spacing for wider icons).
chevron = {
--- Glyph shown when the dropdown is closed.
closed = "",
--- Glyph shown when the dropdown is open.
open = "",
},
--- 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`,
--- `default`, ...) is valid. User overrides fully replace the default
--- spec for the matching group (they are NOT deep-merged field by field).
highlights = {
-- Parent form window
InputFormNormal = { link = "NormalFloat", default = true },
InputFormBorder = { link = "FloatBorder", default = true },
InputFormTitle = { link = "FloatTitle", default = true },
InputFormHelp = { fg = "Cyan", default = true },
-- Individual input fields
InputFormField = { link = "NormalFloat", default = true },
InputFormFieldBorder = { link = "FloatBorder", default = true },
InputFormFieldTitle = { link = "FloatTitle", default = true },
-- Error state for individual input fields
InputFormFieldError = { fg = "Red", default = true },
InputFormFieldErrorBorder = { fg = "Red", default = true },
InputFormFieldErrorTitle = { fg = "Red", default = true },
-- Select dropdown list
InputFormDropdown = { link = "NormalFloat", default = true },
InputFormDropdownActive = { link = "PmenuSel", default = true },
},
},
}
M.options = vim.deepcopy(M.defaults)
@@ -60,7 +95,16 @@ M.options = vim.deepcopy(M.defaults)
---@param user_opts table|nil
---@return table
function M.setup(user_opts)
M.options = utils.merge(vim.deepcopy(M.defaults), user_opts or {})
local merged = utils.merge(vim.deepcopy(M.defaults), user_opts or {})
-- Highlight specs must be replaced per-group, not deep-merged, so a user
-- override like `{ fg = "#ff5555" }` doesn't inherit the default's
-- `default = true` flag (which would let a colorscheme clobber it).
if user_opts and user_opts.style and user_opts.style.highlights then
for name, spec in pairs(user_opts.style.highlights) do
merged.style.highlights[name] = spec
end
end
M.options = merged
return M.options
end

View File

@@ -122,6 +122,10 @@ function M:show()
-- form buffers. Runs once per nvim session.
utils.register_ui_exclusions()
-- Apply configured highlight groups (user-configurable via
-- `setup({ style = { highlights = { ... } } })`).
self:_apply_highlights()
local layout = self:_compute_layout()
self._layout = layout
@@ -161,9 +165,6 @@ function M:show()
win_opts.footer_pos = "center"
end
end
-- Default highlight for the footer (help text): cyan, overridable by the user.
pcall(vim.api.nvim_set_hl, 0, "InputFormHelp", { fg = "Cyan", default = true })
self._parent_win = vim.api.nvim_open_win(self._parent_buf, false, win_opts)
vim.wo[self._parent_win].winblend = config.options.window.winblend
vim.wo[self._parent_win].winhl = table.concat({
@@ -191,16 +192,21 @@ function M:show()
self:_install_validation(input)
end
-- Default highlight groups for validation error state. `default = true`
-- means user overrides take precedence.
pcall(vim.api.nvim_set_hl, 0, "InputFormFieldError", { fg = "Red", default = true })
pcall(vim.api.nvim_set_hl, 0, "InputFormFieldErrorBorder", { fg = "Red", default = true })
pcall(vim.api.nvim_set_hl, 0, "InputFormFieldErrorTitle", { fg = "Red", default = true })
self:_focus(1)
return self
end
--- Apply all configured highlight groups. Called from `show()` so live
--- `setup({ style = { highlights = ... } })` edits take effect on the next
--- form open.
function M:_apply_highlights()
local style = config.options.style or {}
local hls = style.highlights or {}
for name, spec in pairs(hls) do
pcall(vim.api.nvim_set_hl, 0, name, spec)
end
end
--- Hide the form (close windows) but keep state so `:show()` can reopen it.
function M:hide()
if not self._visible then

View File

@@ -48,12 +48,22 @@ local function label_for(options, id)
return ""
end
local CHEVRON_CLOSED = ""
local CHEVRON_OPEN = ""
-- Fallbacks in case the config module has been mutated to a malformed state.
local DEFAULT_CHEVRON_CLOSED = ""
local DEFAULT_CHEVRON_OPEN = ""
local function chevron_for(open)
local style = config.options.style or {}
local chev = style.chevron or {}
if open then
return chev.open or DEFAULT_CHEVRON_OPEN
end
return chev.closed or DEFAULT_CHEVRON_CLOSED
end
local function format_display(options, id, width, open)
local label = label_for(options, id)
local chevron = open and CHEVRON_OPEN or CHEVRON_CLOSED
local chevron = chevron_for(open)
if not width or width <= 0 then
return label .. chevron
end

View File

@@ -2,6 +2,7 @@ local helpers = dofile("tests/helpers.lua")
local MiniTest = require("mini.test")
local child = helpers.new_child_neovim()
local eq = helpers.expect.equality
local eq_global, eq_config = helpers.expect.global_equality, helpers.expect.config_equality
local eq_type_global, eq_type_config =
helpers.expect.global_type_equality, helpers.expect.config_type_equality
@@ -35,6 +36,42 @@ T["setup()"]["exposes defaults"] = function()
eq_config(child, "multiline.height", 5)
end
T["setup()"]["exposes default highlight groups under style.highlights"] = function()
child.lua([[require('input-form').setup()]])
eq_type_config(child, "style", "table")
eq_type_config(child, "style.highlights", "table")
eq_type_config(child, "style.highlights.InputFormHelp", "table")
eq_config(child, "style.highlights.InputFormHelp.fg", "Cyan")
eq_config(child, "style.highlights.InputFormFieldErrorBorder.fg", "Red")
eq_config(child, "style.highlights.InputFormTitle.link", "FloatTitle")
end
T["setup()"]["user highlight overrides replace default specs per group"] = function()
helpers.init_plugin(
child,
[[{
style = {
highlights = {
InputFormHelp = { fg = "#88ccff", italic = true },
InputFormFieldErrorBorder = { fg = "#ff5555" },
},
},
}]]
)
-- Overridden entries take the user's values.
eq_config(child, "style.highlights.InputFormHelp.fg", "#88ccff")
eq_config(child, "style.highlights.InputFormHelp.italic", true)
-- And drop the default's `default = true` flag (replaced per-group, not
-- deep-merged).
eq(
child.lua_get([[require('input-form.config').options.style.highlights.InputFormHelp.default]]),
vim.NIL
)
eq_config(child, "style.highlights.InputFormFieldErrorBorder.fg", "#ff5555")
-- Untouched groups keep their defaults.
eq_config(child, "style.highlights.InputFormFieldError.fg", "Red")
end
T["setup()"]["deep-merges user options"] = function()
helpers.init_plugin(
child,

View File

@@ -34,7 +34,7 @@ T["select input"]["defaults to first option when none given"] = function()
eq(child.lua_get([[_G.t:value()]]), "a")
local line = child.lua_get([==[vim.api.nvim_buf_get_lines(_G.t.buf, 0, -1, false)[1]]==])
helpers.expect.match(line, "^Alpha")
helpers.expect.match(line, "")
helpers.expect.match(line, "")
end
T["select input"]["honors explicit default"] = function()
@@ -42,7 +42,7 @@ T["select input"]["honors explicit default"] = function()
eq(child.lua_get([[_G.t:value()]]), "b")
local line = child.lua_get([==[vim.api.nvim_buf_get_lines(_G.t.buf, 0, -1, false)[1]]==])
helpers.expect.match(line, "^Beta")
helpers.expect.match(line, "")
helpers.expect.match(line, "")
end
T["select input"]["display buffer is read-only"] = function()
@@ -60,7 +60,7 @@ T["select input"]["select_id updates value and display"] = function()
eq(child.lua_get([[_G.t:value()]]), "c")
local line = child.lua_get([==[vim.api.nvim_buf_get_lines(_G.t.buf, 0, -1, false)[1]]==])
helpers.expect.match(line, "^Gamma")
helpers.expect.match(line, "")
helpers.expect.match(line, "")
end
T["select input"]["open_dropdown shows all options and <CR> confirms"] = function()
@@ -95,6 +95,24 @@ T["select input"]["<Esc> closes dropdown without changing value"] = function()
eq(child.lua_get([[_G.t.dropdown_win]]), vim.NIL)
end
T["select input"]["uses custom chevrons from config"] = function()
child.lua([[
require('input-form').setup({
style = { chevron = { closed = " v", open = " ^" } }
})
_G.t = _G.mk('a')
_G.t:mount({ row = 5, col = 5, width = 30 })
]])
local closed_line = child.lua_get([==[vim.api.nvim_buf_get_lines(_G.t.buf, 0, -1, false)[1]]==])
helpers.expect.match(closed_line, " v$")
helpers.expect.no_match(closed_line, "")
-- Flip to open state and re-render.
child.lua([[_G.t._open = true; _G.t:_render_display()]])
local open_line = child.lua_get([==[vim.api.nvim_buf_get_lines(_G.t.buf, 0, -1, false)[1]]==])
helpers.expect.match(open_line, " %^$")
helpers.expect.no_match(open_line, "")
end
T["select input"]["rejects empty options list"] = function()
local ok = child.lua_get([[
(function()