From ea5bbd8e9f881fee9b50f8493c73dff869f1badf Mon Sep 17 00:00:00 2001 From: Chen Asraf Date: Sun, 5 Apr 2026 01:19:14 +0300 Subject: [PATCH] feat: dropdown chevron --- lua/input-form/form.lua | 12 +++++ lua/input-form/inputs/multiline.lua | 2 + lua/input-form/inputs/select.lua | 41 +++++++++++---- lua/input-form/inputs/text.lua | 3 ++ lua/input-form/utils.lua | 81 +++++++++++++++++++++++++++++ tests/test_inputs_select.lua | 12 +++-- 6 files changed, 138 insertions(+), 13 deletions(-) diff --git a/lua/input-form/form.lua b/lua/input-form/form.lua index d000d22..a6bc910 100644 --- a/lua/input-form/form.lua +++ b/lua/input-form/form.lua @@ -118,6 +118,10 @@ function M:show() end self._visible = true + -- Lazy: teach known UI plugins (nvim-scrollbar, satellite, ...) to skip + -- form buffers. Runs once per nvim session. + utils.register_ui_exclusions() + local layout = self:_compute_layout() self._layout = layout @@ -126,6 +130,7 @@ function M:show() vim.bo[self._parent_buf].buftype = "nofile" vim.bo[self._parent_buf].bufhidden = "wipe" vim.bo[self._parent_buf].swapfile = false + utils.mark_form_buffer(self._parent_buf) local parent_lines = {} for _ = 1, layout.parent_inner_h do @@ -183,8 +188,15 @@ function M:show() border = border, }) self:_install_keymaps(input) + 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 diff --git a/lua/input-form/inputs/multiline.lua b/lua/input-form/inputs/multiline.lua index d9e51de..4e8ec47 100644 --- a/lua/input-form/inputs/multiline.lua +++ b/lua/input-form/inputs/multiline.lua @@ -1,6 +1,7 @@ --- Multi-line text input component. local config = require("input-form.config") +local utils = require("input-form.utils") local M = {} M.__index = M @@ -31,6 +32,7 @@ function M:mount(layout) vim.bo[self.buf].buftype = "nofile" vim.bo[self.buf].bufhidden = "wipe" vim.bo[self.buf].swapfile = false + utils.mark_form_buffer(self.buf) local lines = vim.split(self._value, "\n", { plain = true }) vim.api.nvim_buf_set_lines(self.buf, 0, -1, false, lines) diff --git a/lua/input-form/inputs/select.lua b/lua/input-form/inputs/select.lua index aca3b28..987ec6a 100644 --- a/lua/input-form/inputs/select.lua +++ b/lua/input-form/inputs/select.lua @@ -5,6 +5,7 @@ --- window; j/k/arrows navigate, confirms, cancels. local config = require("input-form.config") +local utils = require("input-form.utils") local M = {} M.__index = M @@ -47,35 +48,46 @@ local function label_for(options, id) return "" end -local function format_display(options, id) - return label_for(options, id) +local CHEVRON_CLOSED = " ▼" +local CHEVRON_OPEN = " ▲" + +local function format_display(options, id, width, open) + local label = label_for(options, id) + local chevron = open and CHEVRON_OPEN or CHEVRON_CLOSED + if not width or width <= 0 then + return label .. chevron + end + local label_w = vim.fn.strdisplaywidth(label) + local chev_w = vim.fn.strdisplaywidth(chevron) + local pad = width - label_w - chev_w + if pad < 1 then + pad = 1 + end + return label .. string.rep(" ", pad) .. chevron end function M:_render_display() if self.buf and vim.api.nvim_buf_is_valid(self.buf) then + local line = format_display(self.options, self._selected_id, self._width, self._open) vim.bo[self.buf].modifiable = true - vim.api.nvim_buf_set_lines( - self.buf, - 0, - -1, - false, - { format_display(self.options, self._selected_id) } - ) + vim.api.nvim_buf_set_lines(self.buf, 0, -1, false, { line }) vim.bo[self.buf].modifiable = false 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) vim.api.nvim_buf_set_lines( self.buf, 0, -1, false, - { format_display(self.options, self._selected_id) } + { format_display(self.options, self._selected_id, self._width, self._open) } ) vim.bo[self.buf].modifiable = false @@ -119,6 +131,10 @@ 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) + -- Park the cursor at col 0 so the terminal cursor block sits on the label + -- (clean state) or on the dirty-shifted chevron's left neighbour (dirty + -- state), never on top of the chevron itself. + pcall(vim.api.nvim_win_set_cursor, self.win, { 1, 0 }) end end @@ -140,6 +156,7 @@ function M:open_dropdown() self.dropdown_buf = vim.api.nvim_create_buf(false, true) vim.bo[self.dropdown_buf].buftype = "nofile" vim.bo[self.dropdown_buf].bufhidden = "wipe" + utils.mark_form_buffer(self.dropdown_buf) vim.api.nvim_buf_set_lines(self.dropdown_buf, 0, -1, false, lines) vim.bo[self.dropdown_buf].modifiable = false @@ -160,6 +177,8 @@ function M:open_dropdown() focusable = true, zindex = 100, }) + self._open = true + self:_render_display() vim.wo[self.dropdown_win].cursorline = true vim.wo[self.dropdown_win].winhl = "NormalFloat:InputFormDropdown,CursorLine:InputFormDropdownActive" @@ -192,6 +211,8 @@ function M:close_dropdown() end self.dropdown_win = nil self.dropdown_buf = nil + self._open = false + self:_render_display() if self.win and vim.api.nvim_win_is_valid(self.win) then vim.api.nvim_set_current_win(self.win) end diff --git a/lua/input-form/inputs/text.lua b/lua/input-form/inputs/text.lua index 931e633..a12e994 100644 --- a/lua/input-form/inputs/text.lua +++ b/lua/input-form/inputs/text.lua @@ -1,5 +1,7 @@ --- Single-line text input component. +local utils = require("input-form.utils") + local M = {} M.__index = M @@ -29,6 +31,7 @@ function M:mount(layout) vim.bo[self.buf].buftype = "nofile" vim.bo[self.buf].bufhidden = "wipe" vim.bo[self.buf].swapfile = false + utils.mark_form_buffer(self.buf) vim.api.nvim_buf_set_lines(self.buf, 0, -1, false, { self._value }) local win_cfg = { diff --git a/lua/input-form/utils.lua b/lua/input-form/utils.lua index c4f873f..2ecbae4 100644 --- a/lua/input-form/utils.lua +++ b/lua/input-form/utils.lua @@ -39,4 +39,85 @@ function M.clamp(v, lo, hi) return v end +--- The filetype set on every buffer the plugin owns. Users can add this to +--- their UI plugins' exclusion lists as a fallback. +M.FORM_FILETYPE = "input-form" + +local _excluded_registered = false + +-- Append `ft` to a list-shaped config field if missing. +local function ensure_excluded(cfg, key, ft) + if type(cfg) ~= "table" then + return + end + cfg[key] = cfg[key] or {} + if not vim.tbl_contains(cfg[key], ft) then + table.insert(cfg[key], ft) + end +end + +--- Tell known UI plugins (scrollbars, indent guides, etc.) to skip buffers +--- with filetype `input-form`. Idempotent. Called lazily on the first +--- `form:show()` so it works whether or not the user called `setup()`. +function M.register_ui_exclusions() + if _excluded_registered then + return + end + _excluded_registered = true + + -- nvim-scrollbar (petertriho/nvim-scrollbar). + -- Its real config lives at `require("scrollbar.config")` — the module stores + -- the active table under `.config` and exposes it via `.get()`. We patch + -- both paths defensively in case the module layout differs across versions. + local ok, sbar_cfg = pcall(require, "scrollbar.config") + if ok and sbar_cfg then + if type(sbar_cfg.get) == "function" then + local cfg = sbar_cfg.get() + ensure_excluded(cfg, "excluded_filetypes", M.FORM_FILETYPE) + ensure_excluded(cfg, "excluded_buftypes", "nofile") + end + if type(sbar_cfg.config) == "table" then + ensure_excluded(sbar_cfg.config, "excluded_filetypes", M.FORM_FILETYPE) + end + end + -- Some older layouts exposed the config directly on the main module. + local ok_top, sbar = pcall(require, "scrollbar") + if ok_top and type(sbar) == "table" and type(sbar.config) == "table" then + ensure_excluded(sbar.config, "excluded_filetypes", M.FORM_FILETYPE) + end + + -- satellite.nvim (lewis6991/satellite.nvim). + local ok2, sat_cfg = pcall(require, "satellite.config") + if ok2 and sat_cfg then + if type(sat_cfg.user_config) == "table" then + ensure_excluded(sat_cfg.user_config, "excluded_filetypes", M.FORM_FILETYPE) + end + if type(sat_cfg.config) == "table" then + ensure_excluded(sat_cfg.config, "excluded_filetypes", M.FORM_FILETYPE) + end + end +end + +--- Mark a buffer as an internal form buffer so third-party UI plugins +--- (scrollbars, indent guides, git signs, etc.) skip it. +--- +--- Sets `filetype = "input-form"` plus the opt-out buffer variables +--- recognized by common plugins. Users whose plugins don't honour these can +--- add `input-form` to their plugin's exclusion list. +function M.mark_form_buffer(buf) + if not (buf and vim.api.nvim_buf_is_valid(buf)) then + return + end + vim.bo[buf].filetype = M.FORM_FILETYPE + -- nvim-scrollbar (petertriho/nvim-scrollbar) + vim.b[buf].scrollbar_disabled = true + -- satellite.nvim (lewis6991/satellite.nvim) + vim.b[buf].satellite_disable = true + -- mini.indentscope / mini.map + vim.b[buf].miniindentscope_disable = true + vim.b[buf].minimap_disable = true + -- gitsigns (defensive; unlikely on a nofile buf but cheap) + vim.b[buf].gitsigns_disable = true +end + return M diff --git a/tests/test_inputs_select.lua b/tests/test_inputs_select.lua index 74f181f..0e8e0b5 100644 --- a/tests/test_inputs_select.lua +++ b/tests/test_inputs_select.lua @@ -32,13 +32,17 @@ T["select input"] = MiniTest.new_set() T["select input"]["defaults to first option when none given"] = function() child.lua([[_G.t = _G.mk(nil); _G.t:mount({ row = 5, col = 5, width = 30 })]]) eq(child.lua_get([[_G.t:value()]]), "a") - eq(child.lua_get([[vim.api.nvim_buf_get_lines(_G.t.buf, 0, -1, false)]]), { "Alpha" }) + 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, "▼") end T["select input"]["honors explicit default"] = function() child.lua([[_G.t = _G.mk('b'); _G.t:mount({ row = 5, col = 5, width = 30 })]]) eq(child.lua_get([[_G.t:value()]]), "b") - eq(child.lua_get([[vim.api.nvim_buf_get_lines(_G.t.buf, 0, -1, false)]]), { "Beta" }) + 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, "▼") end T["select input"]["display buffer is read-only"] = function() @@ -54,7 +58,9 @@ T["select input"]["select_id updates value and display"] = function() ]]) eq(child.lua_get([[_G.ok]]), true) eq(child.lua_get([[_G.t:value()]]), "c") - eq(child.lua_get([[vim.api.nvim_buf_get_lines(_G.t.buf, 0, -1, false)]]), { "Gamma" }) + 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, "▼") end T["select input"]["open_dropdown shows all options and confirms"] = function()