minimal extui fuzzy finder for neovim
1local Actions = require("artio.actions")
2local View = require("artio.view")
3
4---@alias artio.Picker.item { id: integer, v: any, text: string, icon?: string, icon_hl?: string, hls?: artio.Picker.hl[] }
5---@alias artio.Picker.match [integer, integer[], integer] [item, pos[], score]
6---@alias artio.Picker.sorter fun(lst: artio.Picker.item[], input: string): artio.Picker.match[]
7---@alias artio.Picker.hl [[integer, integer], string]
8---@alias artio.Picker.action fun(self: artio.Picker, co: thread)
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 actions? table<string, artio.Picker.action>
20---@field prompt? string
21---@field defaulttext? string
22---@field prompttext? string
23---@field opts? artio.config.opts
24---@field win? artio.config.win
25---@field mappings? table<string, 'up'|'down'|'accept'|'cancel'|'togglepreview'|string>
26
27---@class artio.Picker : artio.Picker.config
28---@field idx integer 1-indexed
29---@field matches artio.Picker.match[]
30---@field actions? artio.Actions
31local Picker = {}
32Picker.__index = Picker
33Picker.active_picker = nil
34
35local action_enum = {
36 accept = 0,
37 cancel = 1,
38}
39
40---@type table<string, fun(self: artio.Picker, co: thread)>
41local default_actions = {
42 down = function(self, _)
43 self.idx = self.idx + 1
44 self.view:showmatches() -- adjust for scrolling
45 self.view:hlselect()
46 end,
47 up = function(self, _)
48 self.idx = self.idx - 1
49 self.view:showmatches() -- adjust for scrolling
50 self.view:hlselect()
51 end,
52 accept = function(_, co)
53 coroutine.resume(co, action_enum.accept)
54 end,
55 cancel = function(_, co)
56 coroutine.resume(co, action_enum.cancel)
57 end,
58 togglepreview = function(self, _)
59 self.view:togglepreview()
60 end,
61}
62
63---@param props artio.Picker.config
64function Picker:new(props)
65 vim.validate("Picker.items", props.items, "table")
66 vim.validate("Picker:fn", props.fn, "function")
67 vim.validate("Picker:on_close", props.on_close, "function")
68
69 local t = vim.tbl_deep_extend("force", {
70 closed = false,
71 prompt = "",
72 idx = 0,
73 items = {},
74 matches = {},
75 }, require("artio.config").get(), props)
76
77 if not t.prompttext then
78 t.prompttext = t.opts.prompt_title and ("%s %s"):format(t.prompt, t.opts.promptprefix) or t.opts.promptprefix
79 end
80
81 Picker.getitems(t, "")
82
83 t.actions = Actions:new({
84 actions = vim.tbl_extend("force", default_actions, t.actions or {}),
85 })
86
87 return setmetatable(t, Picker)
88end
89
90function Picker:open()
91 if Picker.active_picker then
92 Picker.active_picker:close()
93 end
94 Picker.active_picker = self
95
96 self.view = View:new(self)
97
98 coroutine.wrap(function()
99 self.view:open()
100
101 local result = self.actions:init(self)
102
103 self:close()
104
105 if result == action_enum.cancel or result ~= action_enum.accept then
106 return
107 end
108
109 local current = self.matches[self.idx] and self.matches[self.idx][1]
110 if not current then
111 return
112 end
113
114 local item = self.items[current]
115 if item then
116 self.on_close(item.v, item.id)
117 end
118 end)()
119end
120
121function Picker:close()
122 if self.closed then
123 return
124 end
125
126 if self.view then
127 self.view:close()
128 end
129
130 self.closed = true
131end
132
133function Picker:fix()
134 self.idx = math.max(self.idx, self.opts.preselect and 1 or 0)
135 self.idx = math.min(self.idx, #self.matches)
136end
137
138function Picker:getitems(input)
139 self.items = self.get_items and self.get_items(input) or self.items
140 if
141 #self.items > 0
142 and (type(self.items[1]) ~= "table" or not (self.items[1].v and self.items[1].id and self.items[1].text))
143 then
144 self.items = vim
145 .iter(ipairs(self.items))
146 :map(function(i, v)
147 local text
148 if self.format_item and vim.is_callable(self.format_item) then
149 text = self.format_item(v)
150 end
151
152 return {
153 id = i,
154 v = v,
155 text = text or v,
156 }
157 end)
158 :totable()
159 end
160end
161
162function Picker:getmatches(input)
163 self:getitems(input)
164 self.matches = self.fn(self.items, input)
165 table.sort(self.matches, function(a, b)
166 return a[3] > b[3]
167 end)
168end
169
170return Picker