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.sorter fun(lst: artio.Picker.item[], input: string): artio.Picker.match[]
6---@alias artio.Picker.hl [[integer, integer], string]
7---@alias artio.Picker.action fun(self: artio.Picker)
8
9---@class artio.Picker.config
10---@field items artio.Picker.item[]|string[]
11---@field fn artio.Picker.sorter
12---@field on_close fun(text: string, idx: integer)
13---@field get_items? fun(input: string): artio.Picker.item[]
14---@field format_item? fun(item: any): string
15---@field preview_item? fun(item: any): integer, fun(win: integer)
16---@field get_icon? fun(item: artio.Picker.item): string, string
17---@field hl_item? fun(item: artio.Picker.item): artio.Picker.hl[]
18---@field prompt? string
19---@field defaulttext? string
20---@field prompttext? string
21---@field opts? artio.config.opts
22---@field win? artio.config.win
23---@field actions? table<string, artio.Picker.action>
24---@field mappings? table<string, 'up'|'down'|'accept'|'cancel'|'togglepreview'|string>
25
26---@class artio.Picker : artio.Picker.config
27---@field co thread|nil
28---@field idx integer 1-indexed
29---@field matches artio.Picker.match[]
30---@field marked table<integer, true|nil>
31local Picker = {}
32Picker.__index = Picker
33Picker.active_picker = nil
34
35---@param props artio.Picker.config
36function Picker:new(props)
37 vim.validate("Picker.items", props.items, "table")
38 vim.validate("Picker:fn", props.fn, "function")
39 vim.validate("Picker:on_close", props.on_close, "function")
40
41 local t = vim.tbl_deep_extend("force", {
42 closed = false,
43 prompt = "",
44 idx = 0,
45 items = {},
46 matches = {},
47 marked = {},
48 }, require("artio.config").get(), props)
49
50 if not t.prompttext then
51 t.prompttext = t.opts.prompt_title and ("%s %s"):format(t.prompt, t.opts.promptprefix) or t.opts.promptprefix
52 end
53
54 Picker.getitems(t, "")
55
56 return setmetatable(t, Picker)
57end
58
59local action_enum = {
60 accept = 0,
61 cancel = 1,
62}
63
64function Picker:open()
65 if Picker.active_picker then
66 Picker.active_picker:close()
67 end
68 Picker.active_picker = self
69
70 self.view = View:new(self)
71
72 coroutine.wrap(function()
73 self.view:open()
74
75 self:initkeymaps()
76
77 local co, ismain = coroutine.running()
78 assert(not ismain, "must be called from a coroutine")
79 self.co = co
80
81 local result = coroutine.yield()
82
83 self:close()
84
85 if result == action_enum.cancel or result ~= action_enum.accept then
86 return
87 end
88
89 local current = self.matches[self.idx] and self.matches[self.idx][1]
90 if not current then
91 return
92 end
93
94 local item = self.items[current]
95 if item then
96 self.on_close(item.v, item.id)
97 end
98 end)()
99end
100
101function Picker:close()
102 if self.closed then
103 return
104 end
105
106 if self.view then
107 self.view:close()
108 end
109
110 self:delkeymaps()
111
112 self.closed = true
113end
114
115function Picker:initkeymaps()
116 local ext = require("vim._extui.shared")
117
118 ---@type vim.keymap.set.Opts
119 local opts = { buffer = ext.bufs.cmd }
120
121 if self.actions then
122 vim.iter(pairs(self.actions)):each(function(k, v)
123 vim.keymap.set("i", ("<Plug>(artio-action-%s)"):format(k), v, opts)
124 end)
125 end
126 if self.mappings then
127 vim.iter(pairs(self.mappings)):each(function(k, v)
128 vim.keymap.set("i", k, ("<Plug>(artio-action-%s)"):format(v), opts)
129 end)
130 end
131end
132
133function Picker:delkeymaps()
134 local ext = require("vim._extui.shared")
135
136 local keymaps = vim.api.nvim_buf_get_keymap(ext.bufs.cmd, "i")
137
138 vim.iter(ipairs(keymaps)):each(function(_, v)
139 if v.lhs:match("^<Plug>(artio-action-") or (v.rhs and v.rhs:match("^<Plug>(artio-action-")) then
140 vim.api.nvim_buf_del_keymap(ext.bufs.cmd, "i", v.lhs)
141 end
142 end)
143end
144
145function Picker:fix()
146 self.idx = math.max(self.idx, self.opts.preselect and 1 or 0)
147 self.idx = math.min(self.idx, #self.matches)
148end
149
150function Picker:getitems(input)
151 self.items = self.get_items and self.get_items(input) or self.items
152 if
153 #self.items > 0
154 and (type(self.items[1]) ~= "table" or not (self.items[1].v and self.items[1].id and self.items[1].text))
155 then
156 self.items = vim
157 .iter(ipairs(self.items))
158 :map(function(i, v)
159 local text
160 if self.format_item and vim.is_callable(self.format_item) then
161 text = self.format_item(v)
162 end
163
164 return {
165 id = i,
166 v = v,
167 text = text or v,
168 }
169 end)
170 :totable()
171 end
172end
173
174function Picker:getmatches(input)
175 self:getitems(input)
176 self.matches = self.fn(self.items, input)
177 table.sort(self.matches, function(a, b)
178 return a[3] > b[3]
179 end)
180end
181
182---@param idx integer
183---@param yes? boolean
184function Picker:mark(idx, yes)
185 self.marked[idx] = yes == nil and true or yes
186end
187
188---@return integer[]
189function Picker:getmarked()
190 return vim
191 .iter(pairs(self.marked))
192 :map(function(k, v)
193 return v and k or nil
194 end)
195 :totable()
196end
197
198return Picker