Skip to content

Commit 5c0b7f2

Browse files
committed
feat: dropdown chevron
1 parent c8e2795 commit 5c0b7f2

7 files changed

Lines changed: 270 additions & 13 deletions

File tree

lua/input-form/form.lua

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -118,6 +118,10 @@ function M:show()
118118
end
119119
self._visible = true
120120

121+
-- Lazy: teach known UI plugins (nvim-scrollbar, satellite, ...) to skip
122+
-- form buffers. Runs once per nvim session.
123+
utils.register_ui_exclusions()
124+
121125
local layout = self:_compute_layout()
122126
self._layout = layout
123127

@@ -126,6 +130,7 @@ function M:show()
126130
vim.bo[self._parent_buf].buftype = "nofile"
127131
vim.bo[self._parent_buf].bufhidden = "wipe"
128132
vim.bo[self._parent_buf].swapfile = false
133+
utils.mark_form_buffer(self._parent_buf)
129134

130135
local parent_lines = {}
131136
for _ = 1, layout.parent_inner_h do

lua/input-form/inputs/multiline.lua

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
--- Multi-line text input component.
22

33
local config = require("input-form.config")
4+
local utils = require("input-form.utils")
45

56
local M = {}
67
M.__index = M
@@ -31,6 +32,7 @@ function M:mount(layout)
3132
vim.bo[self.buf].buftype = "nofile"
3233
vim.bo[self.buf].bufhidden = "wipe"
3334
vim.bo[self.buf].swapfile = false
35+
utils.mark_form_buffer(self.buf)
3436
local lines = vim.split(self._value, "\n", { plain = true })
3537
vim.api.nvim_buf_set_lines(self.buf, 0, -1, false, lines)
3638

lua/input-form/inputs/select.lua

Lines changed: 31 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@
55
--- window; j/k/arrows navigate, <CR> confirms, <Esc> cancels.
66

77
local config = require("input-form.config")
8+
local utils = require("input-form.utils")
89

910
local M = {}
1011
M.__index = M
@@ -47,35 +48,46 @@ local function label_for(options, id)
4748
return ""
4849
end
4950

50-
local function format_display(options, id)
51-
return label_for(options, id)
51+
local CHEVRON_CLOSED = ""
52+
local CHEVRON_OPEN = ""
53+
54+
local function format_display(options, id, width, open)
55+
local label = label_for(options, id)
56+
local chevron = open and CHEVRON_OPEN or CHEVRON_CLOSED
57+
if not width or width <= 0 then
58+
return label .. chevron
59+
end
60+
local label_w = vim.fn.strdisplaywidth(label)
61+
local chev_w = vim.fn.strdisplaywidth(chevron)
62+
local pad = width - label_w - chev_w
63+
if pad < 1 then
64+
pad = 1
65+
end
66+
return label .. string.rep(" ", pad) .. chevron
5267
end
5368

5469
function M:_render_display()
5570
if self.buf and vim.api.nvim_buf_is_valid(self.buf) then
71+
local line = format_display(self.options, self._selected_id, self._width, self._open)
5672
vim.bo[self.buf].modifiable = true
57-
vim.api.nvim_buf_set_lines(
58-
self.buf,
59-
0,
60-
-1,
61-
false,
62-
{ format_display(self.options, self._selected_id) }
63-
)
73+
vim.api.nvim_buf_set_lines(self.buf, 0, -1, false, { line })
6474
vim.bo[self.buf].modifiable = false
6575
end
6676
end
6777

6878
function M:mount(layout)
79+
self._width = layout.width
6980
self.buf = vim.api.nvim_create_buf(false, true)
7081
vim.bo[self.buf].buftype = "nofile"
7182
vim.bo[self.buf].bufhidden = "wipe"
7283
vim.bo[self.buf].swapfile = false
84+
utils.mark_form_buffer(self.buf)
7385
vim.api.nvim_buf_set_lines(
7486
self.buf,
7587
0,
7688
-1,
7789
false,
78-
{ format_display(self.options, self._selected_id) }
90+
{ format_display(self.options, self._selected_id, self._width, self._open) }
7991
)
8092
vim.bo[self.buf].modifiable = false
8193

@@ -119,6 +131,10 @@ end
119131
function M:focus()
120132
if self.win and vim.api.nvim_win_is_valid(self.win) then
121133
vim.api.nvim_set_current_win(self.win)
134+
-- Park the cursor at col 0 so the terminal cursor block sits on the label
135+
-- (clean state) or on the dirty-shifted chevron's left neighbour (dirty
136+
-- state), never on top of the chevron itself.
137+
pcall(vim.api.nvim_win_set_cursor, self.win, { 1, 0 })
122138
end
123139
end
124140

@@ -140,6 +156,7 @@ function M:open_dropdown()
140156
self.dropdown_buf = vim.api.nvim_create_buf(false, true)
141157
vim.bo[self.dropdown_buf].buftype = "nofile"
142158
vim.bo[self.dropdown_buf].bufhidden = "wipe"
159+
utils.mark_form_buffer(self.dropdown_buf)
143160
vim.api.nvim_buf_set_lines(self.dropdown_buf, 0, -1, false, lines)
144161
vim.bo[self.dropdown_buf].modifiable = false
145162

@@ -160,6 +177,8 @@ function M:open_dropdown()
160177
focusable = true,
161178
zindex = 100,
162179
})
180+
self._open = true
181+
self:_render_display()
163182
vim.wo[self.dropdown_win].cursorline = true
164183
vim.wo[self.dropdown_win].winhl =
165184
"NormalFloat:InputFormDropdown,CursorLine:InputFormDropdownActive"
@@ -192,6 +211,8 @@ function M:close_dropdown()
192211
end
193212
self.dropdown_win = nil
194213
self.dropdown_buf = nil
214+
self._open = false
215+
self:_render_display()
195216
if self.win and vim.api.nvim_win_is_valid(self.win) then
196217
vim.api.nvim_set_current_win(self.win)
197218
end

lua/input-form/inputs/text.lua

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,7 @@
11
--- Single-line text input component.
22

3+
local utils = require("input-form.utils")
4+
35
local M = {}
46
M.__index = M
57

@@ -29,6 +31,7 @@ function M:mount(layout)
2931
vim.bo[self.buf].buftype = "nofile"
3032
vim.bo[self.buf].bufhidden = "wipe"
3133
vim.bo[self.buf].swapfile = false
34+
utils.mark_form_buffer(self.buf)
3235
vim.api.nvim_buf_set_lines(self.buf, 0, -1, false, { self._value })
3336

3437
local win_cfg = {

lua/input-form/utils.lua

Lines changed: 81 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -39,4 +39,85 @@ function M.clamp(v, lo, hi)
3939
return v
4040
end
4141

42+
--- The filetype set on every buffer the plugin owns. Users can add this to
43+
--- their UI plugins' exclusion lists as a fallback.
44+
M.FORM_FILETYPE = "input-form"
45+
46+
local _excluded_registered = false
47+
48+
-- Append `ft` to a list-shaped config field if missing.
49+
local function ensure_excluded(cfg, key, ft)
50+
if type(cfg) ~= "table" then
51+
return
52+
end
53+
cfg[key] = cfg[key] or {}
54+
if not vim.tbl_contains(cfg[key], ft) then
55+
table.insert(cfg[key], ft)
56+
end
57+
end
58+
59+
--- Tell known UI plugins (scrollbars, indent guides, etc.) to skip buffers
60+
--- with filetype `input-form`. Idempotent. Called lazily on the first
61+
--- `form:show()` so it works whether or not the user called `setup()`.
62+
function M.register_ui_exclusions()
63+
if _excluded_registered then
64+
return
65+
end
66+
_excluded_registered = true
67+
68+
-- nvim-scrollbar (petertriho/nvim-scrollbar).
69+
-- Its real config lives at `require("scrollbar.config")` — the module stores
70+
-- the active table under `.config` and exposes it via `.get()`. We patch
71+
-- both paths defensively in case the module layout differs across versions.
72+
local ok, sbar_cfg = pcall(require, "scrollbar.config")
73+
if ok and sbar_cfg then
74+
if type(sbar_cfg.get) == "function" then
75+
local cfg = sbar_cfg.get()
76+
ensure_excluded(cfg, "excluded_filetypes", M.FORM_FILETYPE)
77+
ensure_excluded(cfg, "excluded_buftypes", "nofile")
78+
end
79+
if type(sbar_cfg.config) == "table" then
80+
ensure_excluded(sbar_cfg.config, "excluded_filetypes", M.FORM_FILETYPE)
81+
end
82+
end
83+
-- Some older layouts exposed the config directly on the main module.
84+
local ok_top, sbar = pcall(require, "scrollbar")
85+
if ok_top and type(sbar) == "table" and type(sbar.config) == "table" then
86+
ensure_excluded(sbar.config, "excluded_filetypes", M.FORM_FILETYPE)
87+
end
88+
89+
-- satellite.nvim (lewis6991/satellite.nvim).
90+
local ok2, sat_cfg = pcall(require, "satellite.config")
91+
if ok2 and sat_cfg then
92+
if type(sat_cfg.user_config) == "table" then
93+
ensure_excluded(sat_cfg.user_config, "excluded_filetypes", M.FORM_FILETYPE)
94+
end
95+
if type(sat_cfg.config) == "table" then
96+
ensure_excluded(sat_cfg.config, "excluded_filetypes", M.FORM_FILETYPE)
97+
end
98+
end
99+
end
100+
101+
--- Mark a buffer as an internal form buffer so third-party UI plugins
102+
--- (scrollbars, indent guides, git signs, etc.) skip it.
103+
---
104+
--- Sets `filetype = "input-form"` plus the opt-out buffer variables
105+
--- recognized by common plugins. Users whose plugins don't honour these can
106+
--- add `input-form` to their plugin's exclusion list.
107+
function M.mark_form_buffer(buf)
108+
if not (buf and vim.api.nvim_buf_is_valid(buf)) then
109+
return
110+
end
111+
vim.bo[buf].filetype = M.FORM_FILETYPE
112+
-- nvim-scrollbar (petertriho/nvim-scrollbar)
113+
vim.b[buf].scrollbar_disabled = true
114+
-- satellite.nvim (lewis6991/satellite.nvim)
115+
vim.b[buf].satellite_disable = true
116+
-- mini.indentscope / mini.map
117+
vim.b[buf].miniindentscope_disable = true
118+
vim.b[buf].minimap_disable = true
119+
-- gitsigns (defensive; unlikely on a nofile buf but cheap)
120+
vim.b[buf].gitsigns_disable = true
121+
end
122+
42123
return M

lua/input-form/validators.lua

Lines changed: 139 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,139 @@
1+
--- Validators for form inputs.
2+
---
3+
--- A validator is a function `fun(value): string|nil`. It receives the
4+
--- current input value and returns a non-empty error message string when
5+
--- invalid, or `nil` / `""` when valid.
6+
---
7+
--- This module exposes factory functions for common validators plus a
8+
--- `chain` combinator that runs several validators in order and returns the
9+
--- first error.
10+
---
11+
---@tag input-form.validators
12+
13+
local M = {}
14+
15+
--- Require the field to have a non-empty value.
16+
---@param msg string|nil Override error message.
17+
---@return function
18+
function M.non_empty(msg)
19+
msg = msg or "This field is required"
20+
return function(value)
21+
if value == nil then
22+
return msg
23+
end
24+
if type(value) == "string" and value == "" then
25+
return msg
26+
end
27+
return nil
28+
end
29+
end
30+
31+
--- Require the value's length to be at least `n` characters.
32+
---@param n integer
33+
---@param msg string|nil
34+
---@return function
35+
function M.min_length(n, msg)
36+
return function(value)
37+
local s = type(value) == "string" and value or tostring(value or "")
38+
if vim.fn.strchars(s) < n then
39+
return msg or ("Must be at least " .. n .. " characters")
40+
end
41+
return nil
42+
end
43+
end
44+
45+
--- Require the value's length to be at most `n` characters.
46+
---@param n integer
47+
---@param msg string|nil
48+
---@return function
49+
function M.max_length(n, msg)
50+
return function(value)
51+
local s = type(value) == "string" and value or tostring(value or "")
52+
if vim.fn.strchars(s) > n then
53+
return msg or ("Must be at most " .. n .. " characters")
54+
end
55+
return nil
56+
end
57+
end
58+
59+
--- Require the value to match a Lua pattern.
60+
---@param pattern string Lua pattern (not PCRE).
61+
---@param msg string|nil
62+
---@return function
63+
function M.matches(pattern, msg)
64+
return function(value)
65+
local s = type(value) == "string" and value or tostring(value or "")
66+
if not s:match(pattern) then
67+
return msg or "Invalid format"
68+
end
69+
return nil
70+
end
71+
end
72+
73+
--- Require the value to parse as a number.
74+
---@param msg string|nil
75+
---@return function
76+
function M.is_number(msg)
77+
return function(value)
78+
if value == nil or value == "" or tonumber(value) == nil then
79+
return msg or "Must be a number"
80+
end
81+
return nil
82+
end
83+
end
84+
85+
--- Require the value to be one of the given choices (useful for text inputs
86+
--- that must match a fixed allowlist; select inputs should use their
87+
--- `options` list instead).
88+
---@param choices table List of allowed values.
89+
---@param msg string|nil
90+
---@return function
91+
function M.one_of(choices, msg)
92+
return function(value)
93+
for _, c in ipairs(choices) do
94+
if c == value then
95+
return nil
96+
end
97+
end
98+
return msg or "Value is not allowed"
99+
end
100+
end
101+
102+
--- Wrap a predicate `fun(value): boolean` as a validator.
103+
---@param predicate function
104+
---@param msg string Error message to return when the predicate is false.
105+
---@return function
106+
function M.custom(predicate, msg)
107+
return function(value)
108+
if predicate(value) then
109+
return nil
110+
end
111+
return msg
112+
end
113+
end
114+
115+
--- Combine multiple validators. Runs them in order and returns the first
116+
--- non-empty error. Accepts either a list table or a varargs list.
117+
---@param ... function|table
118+
---@return function
119+
function M.chain(...)
120+
local validators = { ... }
121+
if
122+
#validators == 1
123+
and type(validators[1]) == "table"
124+
and type(validators[1][1]) == "function"
125+
then
126+
validators = validators[1]
127+
end
128+
return function(value)
129+
for _, v in ipairs(validators) do
130+
local err = v(value)
131+
if err and err ~= "" then
132+
return err
133+
end
134+
end
135+
return nil
136+
end
137+
end
138+
139+
return M

0 commit comments

Comments
 (0)