minimal extui fuzzy finder for neovim
at main 314 lines 7.4 kB view raw
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