diff --git a/README.md b/README.md index 0b0a745..8c6b954 100644 --- a/README.md +++ b/README.md @@ -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: diff --git a/doc/input-form.txt b/doc/input-form.txt index 1d3d8f0..79d7765 100644 --- a/doc/input-form.txt +++ b/doc/input-form.txt @@ -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) diff --git a/lua/input-form/config.lua b/lua/input-form/config.lua index c840fe4..011dcb8 100644 --- a/lua/input-form/config.lua +++ b/lua/input-form/config.lua @@ -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 diff --git a/lua/input-form/form.lua b/lua/input-form/form.lua index c389612..6ee455a 100644 --- a/lua/input-form/form.lua +++ b/lua/input-form/form.lua @@ -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 diff --git a/lua/input-form/inputs/select.lua b/lua/input-form/inputs/select.lua index 1c5af13..76ef213 100644 --- a/lua/input-form/inputs/select.lua +++ b/lua/input-form/inputs/select.lua @@ -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 diff --git a/tests/test_config.lua b/tests/test_config.lua index 2b2d991..1024fa3 100644 --- a/tests/test_config.lua +++ b/tests/test_config.lua @@ -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, diff --git a/tests/test_inputs_select.lua b/tests/test_inputs_select.lua index 0e8e0b5..615b460 100644 --- a/tests/test_inputs_select.lua +++ b/tests/test_inputs_select.lua @@ -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 confirms"] = function() @@ -95,6 +95,24 @@ T["select input"][" 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()