minimal extui fuzzy finder for neovim
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): integer, fun(win: 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|nil
31---@field input string
32---@field liveinput? string
33---@field idx integer 1-indexed
34---@field matches artio.Picker.match[]
35---@field marked table<integer, true|nil>
36---@field live boolean
37local Picker = {}
38Picker.__index = Picker
39Picker.active_picker = nil
40
41---@param props artio.Picker.config
42function Picker:new(props)
43 vim.validate("Picker.items", props.items, "table")
44 vim.validate("Picker:fn", props.fn, "function")
45 vim.validate("Picker:on_close", props.on_close, "function")
46
47 local t = vim.tbl_deep_extend("force", {
48 closed = false,
49 prompt = "",
50 input = nil,
51 liveinput = nil,
52 idx = 0,
53 items = {},
54 matches = {},
55 marked = {},
56 }, require("artio.config").get(), props)
57
58 if not t.prompttext then
59 t.prompttext = t.opts.prompt_title and ("%s %s"):format(t.prompt, t.opts.promptprefix) or t.opts.promptprefix
60 end
61
62 t.live = vim.F.if_nil(t.live, t.get_items ~= nil)
63
64 if t.live then
65 t.input = ""
66 t.liveinput = t.defaulttext or ""
67 else
68 t.input = t.defaulttext or ""
69 t.liveinput = ""
70 end
71
72 Picker.getitems(t, "")
73
74 return setmetatable(t, Picker)
75end
76
77local action_enum = {
78 accept = 0,
79 cancel = 1,
80}
81
82function Picker:open()
83 if Picker.active_picker and Picker.active_picker ~= self then
84 Picker.active_picker:close(true)
85 end
86 Picker.active_picker = self
87
88 self.view = View:new(self)
89
90 coroutine.wrap(function()
91 self.view:open()
92
93 self:initkeymaps()
94
95 local co, ismain = coroutine.running()
96 assert(not ismain, "must be called from a coroutine")
97 self.co = co
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)()
128end
129
130function Picker:resume()
131 if not self.closed then
132 return
133 end
134 self.closed = false
135
136 self:open()
137end
138
139---@param free? boolean
140function Picker:close(free)
141 if self.closed then
142 return
143 end
144
145 if self.view then
146 self.view:close()
147 end
148
149 self:delkeymaps()
150
151 self.closed = true
152
153 if free then
154 self:free()
155 end
156end
157
158function Picker:free()
159 if self == nil then
160 return
161 end
162 self.items = nil
163 self.matches = nil
164 self.marked = nil
165 self = nil
166 vim.schedule(function()
167 collectgarbage("collect")
168 end)
169end
170
171function Picker:initkeymaps()
172 local ui2 = require("vim._core.ui2")
173
174 ---@type vim.keymap.set.Opts
175 local opts = { buffer = ui2.bufs.cmd }
176
177 if self.actions then
178 vim.iter(pairs(self.actions)):each(function(k, v)
179 vim.keymap.set("i", ("<Plug>(artio-action-%s)"):format(k), v, opts)
180 end)
181 end
182 if self.mappings then
183 vim.iter(pairs(self.mappings)):each(function(k, v)
184 vim.keymap.set("i", k, ("<Plug>(artio-action-%s)"):format(v), opts)
185 end)
186 end
187end
188
189function Picker:delkeymaps()
190 local ui2 = require("vim._core.ui2")
191
192 local keymaps = vim.api.nvim_buf_get_keymap(ui2.bufs.cmd, "i")
193
194 vim.iter(ipairs(keymaps)):each(function(_, v)
195 if v.lhs:match("^<Plug>(artio-action-") or (v.rhs and v.rhs:match("^<Plug>(artio-action-")) then
196 vim.api.nvim_buf_del_keymap(ui2.bufs.cmd, "i", v.lhs)
197 end
198 end)
199end
200
201function Picker:accept()
202 coroutine.resume(self.co, action_enum.accept)
203end
204
205function Picker:cancel()
206 coroutine.resume(self.co, action_enum.cancel)
207end
208
209function Picker:fix()
210 self.idx = math.max(self.idx, self.opts.preselect and 1 or 0)
211 self.idx = math.min(self.idx, #self.matches)
212end
213
214local function item_is_structured(item)
215 return type(item) == "table" and item.id and item.v and item.text
216end
217
218function Picker:getitems(input)
219 if self.live then
220 self.items = self.get_items and self.get_items(input) or self.items
221 end
222
223 if #self.items > 0 and not item_is_structured(self.items[1]) then
224 self.items = vim
225 .iter(ipairs(self.items))
226 :map(function(i, v)
227 local text
228 if self.format_item and vim.is_callable(self.format_item) then
229 text = self.format_item(v)
230 end
231
232 return {
233 id = i,
234 v = v,
235 text = text or v,
236 }
237 end)
238 :totable()
239 end
240end
241
242---@param input? string
243function Picker:getmatches(input)
244 if not input then
245 input = self.live and self.liveinput or self.input
246 end
247 self:getitems(input)
248
249 -- if live, ignore sorting
250 if self.live then
251 self.matches = self:getallmatches()
252 return
253 end
254
255 self.matches = vim.tbl_values(self.fn(self.items, input))
256 table.sort(self.matches, function(a, b)
257 return a[3] > b[3]
258 end)
259end
260
261---@return artio.Picker.match[]
262function Picker:getallmatches()
263 return vim
264 .iter(ipairs(self.items))
265 :map(function(_, v)
266 return { v.id, {}, 0 }
267 end)
268 :totable()
269end
270
271---@param idx integer
272---@param yes? boolean
273function Picker:mark(idx, yes)
274 self.marked[idx] = yes == nil and true or yes
275end
276
277---@return integer[]
278function Picker:getmarked()
279 return vim
280 .iter(pairs(self.marked))
281 :map(function(k, v)
282 return v and k or nil
283 end)
284 :totable()
285end
286
287---@param idx? integer index in items
288---@return artio.Picker.item?
289function Picker:getcurrent(idx)
290 if not idx then
291 local i = self.idx
292 idx = self.matches[i] and self.matches[i][1]
293 end
294 if not idx then
295 return
296 end
297
298 return self.items[idx]
299end
300
301function Picker:togglelive()
302 -- check if live can be toggled
303 if not self.get_items then
304 return
305 end
306
307 -- reset fuzzy search when enabling live search
308 if not self.live then
309 self.input = ""
310 end
311 self.live = not self.live
312end
313
314return Picker