馃惢 minimal ui2 fuzzy finder for Neovim
codeberg.org/comfysage/artio.nvim
1local View = require("artio.view")
2
3---@alias artio.Picker.item { id: integer, v: any, text: string, icon?: string, icon_hl?: string, hls?: artio.Picker.hl[] }
4---@alias artio.Picker.match [integer, integer[], integer] [item, pos[], score]
5---@alias artio.Picker.matches table<integer, artio.Picker.match> id: match
6---@alias artio.Picker.sorter fun(lst: artio.Picker.item[], input: string): artio.Picker.matches
7---@alias artio.Picker.hl [[integer, integer], string]
8---@alias artio.Picker.action fun(self: artio.Picker)
9
10---@class artio.Picker.config
11---@field items artio.Picker.item[]|string[]
12---@field fn artio.Picker.sorter
13---@field on_close fun(text: string, idx: integer)
14---@field get_items? fun(input: string): artio.Picker.item[]
15---@field format_item? fun(item: any): string
16---@field preview_item? fun(item: any):{buf?:integer, pos?:[integer,integer], pos_end?:[integer,integer]}
17---@field get_icon? fun(item: artio.Picker.item): string, string
18---@field hl_item? fun(item: artio.Picker.item): artio.Picker.hl[]
19---@field on_quit? fun()
20---@field live? boolean
21---@field prompt? string
22---@field defaulttext? string
23---@field prompttext? string
24---@field opts? artio.config.opts
25---@field win? artio.config.win
26---@field actions? table<string, artio.Picker.action>
27---@field mappings? table<string, 'up'|'down'|'accept'|'cancel'|'togglepreview'|string>
28
29---@class artio.Picker : artio.Picker.config
30---@field co thread?
31---@field thread function
32---@field input string
33---@field liveinput? string
34---@field idx integer 1-indexed
35---@field matches artio.Picker.match[]
36---@field marked table<integer, true|nil>
37---@field live boolean
38local Picker = {}
39Picker.__index = Picker
40Picker.active_picker = nil
41
42---@param props artio.Picker.config
43function Picker:new(props)
44 vim.validate("Picker.items", props.items, "table")
45 vim.validate("Picker:fn", props.fn, "function")
46 vim.validate("Picker:on_close", props.on_close, "function")
47
48 local t = vim.tbl_deep_extend("force", {
49 closed = false,
50 prompt = "",
51 input = nil,
52 liveinput = nil,
53 idx = 0,
54 items = {},
55 matches = {},
56 marked = {},
57 }, require("artio.config").get(), props)
58
59 if not t.prompttext then
60 t.prompttext = t.opts.prompt_title and ("%s %s"):format(t.prompt, t.opts.promptprefix) or t.opts.promptprefix
61 end
62
63 t.live = vim.F.if_nil(t.live, t.get_items ~= nil)
64
65 if t.live then
66 t.input = ""
67 t.liveinput = t.defaulttext or ""
68 else
69 t.input = t.defaulttext or ""
70 t.liveinput = ""
71 end
72
73 Picker.getitems(t, "")
74
75 return setmetatable(t, Picker)
76end
77
78---@enum artio.Picker.Action
79local action_enum = {
80 accept = 0,
81 cancel = 1,
82}
83
84function Picker:open()
85 if Picker.active_picker and Picker.active_picker ~= self then
86 Picker.active_picker:close(true)
87 end
88 Picker.active_picker = self
89
90 self.view = View:new(self)
91
92 self.thread = coroutine.wrap(function()
93 self.view:open()
94
95 self:initkeymaps()
96
97 self.co = coroutine.running()
98
99 vim.api.nvim_exec_autocmds("User", { pattern = "ArtioEnter" })
100
101 local result = coroutine.yield()
102
103 self:close()
104
105 while true do
106 if result == action_enum.cancel or result ~= action_enum.accept then
107 if self.on_quit then
108 self.on_quit()
109 end
110 break
111 end
112
113 local current = self.matches[self.idx] and self.matches[self.idx][1]
114 if not current then
115 break
116 end
117
118 local item = self.items[current]
119 if item then
120 self.on_close(item.v, item.id)
121 end
122
123 break
124 end
125
126 vim.api.nvim_exec_autocmds("User", { pattern = "ArtioLeave" })
127 end)
128 self.thread()
129end
130
131function Picker:resume()
132 if not self.closed then
133 return
134 end
135 self.closed = false
136
137 self:open()
138end
139
140---@param free? boolean
141function Picker:close(free)
142 if self.closed then
143 return
144 end
145
146 if self.view then
147 self.view:close()
148 end
149
150 self:delkeymaps()
151
152 self.closed = true
153
154 if free then
155 self:free()
156 end
157end
158
159function Picker:free()
160 if self == nil then
161 return
162 end
163 self.items = nil
164 self.matches = nil
165 self.marked = nil
166 self = nil
167 vim.schedule(function()
168 collectgarbage("collect")
169 end)
170end
171
172function Picker:initkeymaps()
173 local ui2 = require("vim._core.ui2")
174
175 ---@type vim.keymap.set.Opts
176 local opts = { buffer = ui2.bufs.cmd }
177
178 if self.actions then
179 vim.iter(pairs(self.actions)):each(function(k, v)
180 vim.keymap.set("i", ("<Plug>(artio-action-%s)"):format(k), v, opts)
181 end)
182 end
183 if self.mappings then
184 vim.iter(pairs(self.mappings)):each(function(k, v)
185 vim.keymap.set("i", k, ("<Plug>(artio-action-%s)"):format(v), opts)
186 end)
187 end
188end
189
190function Picker:delkeymaps()
191 local ui2 = require("vim._core.ui2")
192
193 local keymaps = vim.api.nvim_buf_get_keymap(ui2.bufs.cmd, "i")
194
195 vim.iter(ipairs(keymaps)):each(function(_, v)
196 if v.lhs:match("^<Plug>(artio-action-") or (v.rhs and v.rhs:match("^<Plug>(artio-action-")) then
197 vim.api.nvim_buf_del_keymap(ui2.bufs.cmd, "i", v.lhs)
198 end
199 end)
200end
201
202function Picker:accept()
203 self.thread(action_enum.accept)
204end
205
206function Picker:cancel()
207 self.thread(action_enum.cancel)
208end
209
210function Picker:fix()
211 self.idx = math.max(self.idx, self.opts.preselect and 1 or 0)
212 self.idx = math.min(self.idx, #self.matches)
213end
214
215local function item_is_structured(item)
216 return type(item) == "table" and item.id and item.v and item.text
217end
218
219function Picker:getitems(input)
220 if self.live then
221 self.items = self.get_items and self.get_items(input) or self.items
222 end
223
224 if #self.items > 0 and not item_is_structured(self.items[1]) then
225 self.items = vim
226 .iter(ipairs(self.items))
227 :map(function(i, v)
228 local text
229 if self.format_item and vim.is_callable(self.format_item) then
230 text = self.format_item(v)
231 end
232
233 return {
234 id = i,
235 v = v,
236 text = text or v,
237 }
238 end)
239 :totable()
240 end
241end
242
243---@param input? string
244function Picker:getmatches(input)
245 if not input then
246 input = self.live and self.liveinput or self.input
247 end
248 self:getitems(input)
249
250 -- if live, ignore sorting
251 if self.live then
252 self.matches = self:getallmatches()
253 return
254 end
255
256 self.matches = vim.tbl_values(self.fn(self.items, input))
257 table.sort(self.matches, function(a, b)
258 return a[3] > b[3]
259 end)
260end
261
262---@return artio.Picker.match[]
263function Picker:getallmatches()
264 return vim
265 .iter(ipairs(self.items))
266 :map(function(_, v)
267 return { v.id, {}, 0 }
268 end)
269 :totable()
270end
271
272---@param idx integer
273---@param yes? boolean
274function Picker:mark(idx, yes)
275 self.marked[idx] = yes == nil and true or yes
276end
277
278---@return integer[]
279function Picker:getmarked()
280 return vim
281 .iter(pairs(self.marked))
282 :map(function(k, v)
283 return v and k or nil
284 end)
285 :totable()
286end
287
288---@param idx? integer index in items
289---@return artio.Picker.item?
290function Picker:getcurrent(idx)
291 if not idx then
292 local i = self.idx
293 idx = self.matches[i] and self.matches[i][1]
294 end
295 if not idx then
296 return
297 end
298
299 return self.items[idx]
300end
301
302function Picker:togglelive()
303 -- check if live can be toggled
304 if not self.get_items then
305 return
306 end
307
308 -- reset fuzzy search when enabling live search
309 if not self.live then
310 self.input = ""
311 end
312 self.live = not self.live
313end
314
315return Picker