-- REMOVED: -- pickers.debouce, not sure if that was useful at all, -- but I think it just provides weird contention. We're not going to debounce HANDLING -- messages, we're only going to handle debouncing display -- TODO: use maxsize for linked list and then try to test that it's working require "telescope" local a = vim.api local async = require "plenary.async" local await_schedule = async.util.scheduler local channel = require("plenary.async.control").channel local popup = require "plenary.popup" local actions = require "telescope.actions" local config = require "telescope.config" local deprecated = require "telescope.deprecated" local log = require "telescope.log" local mappings = require "telescope.mappings" local state = require "telescope.state" local utils = require "telescope.utils" local p_window = require "telescope.pickers.window" local EntryManager = require "telescope.entry_manager" local MultiSelect = require "telescope.pickers.multi" local get_default = utils.get_default local strdisplaywidth = require("plenary.strings").strdisplaywidth local ns_telescope_prompt = a.nvim_create_namespace "telescope_prompt" local ns_telescope_prompt_prefix = a.nvim_create_namespace "telescope_prompt_prefix" local pickers = {} -- TODO: Add overscroll option for results buffer ---@class Picker ---@field selection_strategy string ---@field sorting_strategy string ---@field entry_prefix string ---@field sorter Sorter ---@field highlighter table: TODO: Document highlighter ---@field offset number: How far we've scrolled thus far, in rows. So it's a number 0-Max ---@field num_visible number: The number of possible visible entries. Not guaranteed to have that many entries ---currently. -- Picker is the main UI that shows up to interact w/ your results. -- Takes a filter & a previewer local Picker = {} Picker.__index = Picker --- Create new picker function Picker:new(opts) opts = opts or {} if opts.layout_strategy and opts.get_window_options then error "layout_strategy and get_window_options are not compatible keys" end -- TODO: Seems like we could remove this but just set some other window to be the original window if vim.fn.win_gettype() == "command" then error "Can't open telescope from command-line window. See E11" end deprecated.options(opts) -- We need to clear at the beginning not on close because after close we can still have select:post -- etc ... require("telescope.actions.mt").clear_all() -- TODO(conni2461): This seems like the better solution but it won't clear actions that were never mapped -- for _, v in ipairs(keymap_store[prompt_bufnr]) do -- pcall(v.clear) -- end local layout_strategy = get_default(opts.layout_strategy, config.values.layout_strategy) local obj = setmetatable({ finder = assert(opts.finder, "Finder is required."), sorter = opts.sorter or require("telescope.sorters").empty(), prompt_title = get_default(opts.prompt_title, config.values.prompt_title), results_title = get_default(opts.results_title, config.values.results_title), -- either whats passed in by the user or whats defined by the previewer preview_title = opts.preview_title, -- TODO: Delete this, this should be able to be configured by user. wrap_results = get_default(opts.wrap_results, config.values.wrap_results), initial_mode = get_default(opts.initial_mode, config.values.initial_mode), _original_mode = a.nvim_get_mode().mode, default_text = opts.default_text, get_status_text = get_default(opts.get_status_text, config.values.get_status_text), _on_input_filter_cb = opts.on_input_filter_cb or function() end, -- TODO: change to _all_previewers? all_previewers = opts.previewer, current_previewer_index = 1, default_selection_index = opts.default_selection_index, get_selection_window = get_default(opts.get_selection_window, config.values.get_selection_window), cwd = opts.cwd, _find_id = 0, _completion_callbacks = type(opts._completion_callbacks) == "table" and opts._completion_callbacks or {}, manager = (type(opts.manager) == "table" and getmetatable(opts.manager) == EntryManager) and opts.manager, _multi = (type(opts._multi) == "table" and getmetatable(opts._multi) == getmetatable(MultiSelect:new())) and opts._multi or MultiSelect:new(), track = get_default(opts.track, false), stats = {}, attach_mappings = opts.attach_mappings, file_ignore_patterns = get_default(opts.file_ignore_patterns, config.values.file_ignore_patterns), scroll_strategy = get_default(opts.scroll_strategy, config.values.scroll_strategy), selection_strategy = get_default(opts.selection_strategy, config.values.selection_strategy), sorting_strategy = get_default(opts.sorting_strategy, config.values.sorting_strategy), tiebreak = get_default(opts.tiebreak, config.values.tiebreak), -- TODO: This is another example of something I'd rather see we do via functions push_cursor_on_edit = get_default(opts.push_cursor_on_edit, false), push_tagstack_on_edit = get_default(opts.push_tagstack_on_edit, false), layout_strategy = layout_strategy, layout_config = config.smarter_depth_2_extend(opts.layout_config or {}, config.values.layout_config or {}), __cycle_layout_list = get_default(opts.cycle_layout_list, config.values.cycle_layout_list), window = { -- TODO: Winblend is also a bad idea. We can do this either through ftplugin or `on_window` function / list of -- functions. For example { telescope.set.winblend(0), telescope.set.wrap(true), ... } or similar. -- -- We should have less of these functions winblend = get_default( opts.winblend, type(opts.window) == "table" and opts.window.winblend or config.values.winblend ), -- Border seems to make sense though, since that's not like a "builtin" thing border = get_default(opts.border, type(opts.window) == "table" and opts.window.border or config.values.border), borderchars = get_default( opts.borderchars, type(opts.window) == "table" and opts.window.borderchars or config.values.borderchars ), }, cache_picker = config.resolve_table_opts(opts.cache_picker, vim.deepcopy(config.values.cache_picker)), }, self) obj._on_error = opts._on_error -- Separate idea, just for prompt only. Doesn't affect any of the other items. obj.prompt_prefix = get_default(opts.prompt_prefix, config.values.prompt_prefix) -- These are all for each individual row. -- TODO(1.0): I think this name should be "multiselect_icon" to more clear. -- Maybe even something a bit different from that. obj.multi_icon = get_default(opts.multi_icon, config.values.multi_icon) obj.selection_caret = get_default(opts.selection_caret, config.values.selection_caret) obj.entry_prefix = get_default(opts.entry_prefix, config.values.entry_prefix) obj._prefix_width = math.max( strdisplaywidth(obj.entry_prefix), strdisplaywidth(obj.selection_caret), strdisplaywidth(obj.multi_icon) + 1 ) obj._prefix_string = obj.entry_prefix .. string.rep(" ", obj._prefix_width - strdisplaywidth(obj.entry_prefix)) obj.get_window_options = opts.get_window_options or p_window.get_window_options -- TODO: previwer.resolve(...) or something maybe. if obj.all_previewers ~= nil and obj.all_previewers ~= false then if obj.all_previewers[1] == nil then obj.all_previewers = { obj.all_previewers } end obj.previewer = obj.all_previewers[1] if obj.preview_title == nil then obj.preview_title = obj.previewer:title(nil, config.values.dynamic_preview_title) else obj.fix_preview_title = true end else obj.previewer = false end -- TODO: This seems out of place as well. local __hide_previewer = opts.__hide_previewer if __hide_previewer then obj.hidden_previewer = obj.previewer obj.previewer = nil else obj.hidden_previewer = nil end obj.scroller = require("telescope.pickers.scroller").new(obj.scroll_strategy, obj.sorting_strategy) obj.highlighter = require("telescope.pickers.highlights").new(obj) for _, on_complete_item in ipairs(opts.on_complete or {}) do obj:register_completion_callback(on_complete_item) end return obj end --- Take an index and get a row. ---@note: Rows are 0-indexed, and `index` is 1 indexed (table index) ---@param index number: the index in line_manager ---@return number: the row for the picker to display in function Picker:get_row(index) if self.sorting_strategy == "ascending" then return index - 1 else return self.num_visible - index end end --- Take a row and get an index ---@note: Rows are 0-indexed, and `index` is 1 indexed (table index) ---@param row number: The row being displayed ---@return number: The index in line_manager function Picker:get_index(row) if self.sorting_strategy == "ascending" then return row + 1 else return self.num_visible - row end end --- Get the row number of the "best" entry ---@return number: the number of the "reset" row function Picker:get_reset_row() if self.sorting_strategy == "ascending" then return 0 else return self.num_visible - 1 end end --- Check if the given row number can be selected ---@param row number: the number of the chosen row in the results buffer ---@return boolean function Picker:can_select_row(row) -- TODO(fps): Need to handle scrolling w/ offsets now. if self.sorting_strategy == "ascending" then return row <= self.manager:num_results() and row < self.num_visible else return row >= 0 and row <= self.num_visible and row >= self.num_visible - self.manager:num_results() end end --- Check if the picker is no longer in use ---@return boolean|nil: `true` if picker is closed, `nil` otherwise function Picker:is_done() if not self.manager then return true end end --TODO: document what `find_id` is for function Picker:_next_find_id() local find_id = self._find_id + 1 self._find_id = find_id return find_id end --- A helper function for creating each of the windows in a picker ---@param bufnr number: the buffer number to be used in the window ---@param popup_opts table: options to pass to `popup.create` ---@param nowrap boolean|nil: is |'wrap'| disabled in the created window function Picker:_create_window(bufnr, popup_opts, nowrap) local what = bufnr or "" local win, opts = popup.create(what, popup_opts) a.nvim_win_set_option(win, "winblend", self.window.winblend) a.nvim_win_set_option(win, "wrap", not nowrap) local border_win = opts and opts.border and opts.border.win_id if border_win then a.nvim_win_set_option(border_win, "winblend", self.window.winblend) end return win, opts, border_win end --- Opens the given picker for the user to interact with ---@note: this is the main function for pickers, as it actually creates the interface for users function Picker:find() self:close_existing_pickers() self:reset_selection() self.original_win_id = a.nvim_get_current_win() -- User autocmd run it before create Telescope window a.nvim_exec_autocmds("User TelescopeFindPre", {}) -- Create three windows: -- 1. Prompt window -- 2. Options window -- 3. Preview window local line_count = vim.o.lines - vim.o.cmdheight if vim.o.laststatus ~= 0 then line_count = line_count - 1 end local popup_opts = p_window._set_additional_fields(self:get_window_options(vim.o.columns, line_count)) local results_win, results_opts, results_border_win = self:_create_window( "", popup_opts.results, not self.wrap_results ) local results_bufnr = a.nvim_win_get_buf(results_win) pcall(a.nvim_buf_set_option, results_bufnr, "tabstop", 1) -- #1834 self.results_bufnr = results_bufnr self.results_win = results_win self.results_border = results_opts and results_opts.border local preview_win, preview_opts, preview_bufnr, preview_border_win if popup_opts.preview then preview_win, preview_opts, preview_border_win = self:_create_window("", popup_opts.preview) preview_bufnr = a.nvim_win_get_buf(preview_win) end -- This is needed for updating the title local preview_border = preview_opts and preview_opts.border self.preview_win = preview_win self.preview_border = preview_border local prompt_win, prompt_opts, prompt_border_win = self:_create_window("", popup_opts.prompt) local prompt_bufnr = a.nvim_win_get_buf(prompt_win) pcall(a.nvim_buf_set_option, prompt_bufnr, "tabstop", 1) -- #1834 self.prompt_bufnr = prompt_bufnr self.prompt_win = prompt_win self.prompt_border = prompt_opts and prompt_opts.border -- Prompt prefix -- local prompt_prefix = self.prompt_prefix -- a.nvim_buf_set_option(prompt_bufnr, "buftype", "prompt") -- vim.fn.prompt_setprompt(prompt_bufnr, prompt_prefix) -- self.prompt_prefix = prompt_prefix -- self:_reset_prefix_color() -- -- -- TODO: This could be configurable in the future, but I don't know why you would -- -- want to scroll through more than 10,000 items. -- -- -- -- This just lets us stop doing stuff after tons of things. -- self.max_results = 1000 -- -- a.nvim_buf_set_lines(results_bufnr, 0, self.max_results, false, utils.repeated_table(self.max_results, "")) -- -- local status_updater = self:get_status_updater(prompt_win, prompt_bufnr) -- local debounced_status = debounce.throttle_leading(status_updater, 50) -- -- local tx, rx = channel.mpsc() -- self._on_lines = tx.send -- -- local find_id = self:_next_find_id() -- -- if self.default_text then -- self:set_prompt(self.default_text) -- end -- -- if vim.tbl_contains({ "insert", "normal" }, self.initial_mode) then -- local mode = vim.fn.mode() -- local keys -- if self.initial_mode == "normal" then -- -- n: A makes sure cursor is at always at end of prompt w/o default_text -- keys = mode ~= "n" and "A" or "A" -- else -- -- always fully retrigger insert mode: required for going from one picker to next -- keys = mode ~= "n" and "A" or "A" -- end -- a.nvim_feedkeys(a.nvim_replace_termcodes(keys, true, false, true), "n", true) -- else -- utils.notify( -- "pickers.find", -- { msg = "`initial_mode` should be one of ['normal', 'insert'] but passed " .. self.initial_mode, level = "ERROR" } -- ) -- end -- -- local main_loop = async.void(function() -- self.sorter:_init() -- -- -- Do filetype last, so that users can register at the last second. -- pcall(a.nvim_buf_set_option, prompt_bufnr, "filetype", "TelescopePrompt") -- pcall(a.nvim_buf_set_option, results_bufnr, "filetype", "TelescopeResults") -- -- await_schedule() -- -- while true do -- -- Wait for the next input -- rx.last() -- await_schedule() -- -- self:_reset_track() -- -- if not a.nvim_buf_is_valid(prompt_bufnr) then -- log.debug("ON_LINES: Invalid prompt_bufnr", prompt_bufnr) -- return -- end -- -- local start_time = vim.loop.hrtime() -- -- local prompt = self:_get_next_filtered_prompt() -- -- -- TODO: Entry manager should have a "bulk" setter. This can prevent a lot of redraws from display -- if self.cache_picker == false or self.cache_picker.is_cached ~= true then -- self.sorter:_start(prompt) -- self.manager = EntryManager:new(self.max_results, self.entry_adder, self.stats) -- -- self:_reset_highlights() -- local process_result = self:get_result_processor(find_id, prompt, debounced_status) -- local process_complete = self:get_result_completor(self.results_bufnr, find_id, prompt, status_updater) -- -- local ok, msg = pcall(function() -- self.finder(prompt, process_result, process_complete) -- end) -- -- if not ok then -- log.warn("Finder failed with msg: ", msg) -- end -- -- local diff_time = (vim.loop.hrtime() - start_time) / 1e6 -- if self.debounce and diff_time < self.debounce then -- async.util.sleep(self.debounce - diff_time) -- end -- else -- -- TODO(scroll): This can only happen once, I don't like where it is. -- self:_resume_picker() -- end -- end -- self:set_promptprefix(self.prompt_prefix) -- -- -- TODO: Probably should reset self.offset and what not when you type new keys?... hadn't thought about that before self.offset = 0 self.num_visible = popup_opts.results.height local status_updater = function(opts) return self:_update_status(opts) end local find_id = self:_next_find_id() self.refresher = vim.loop.new_timer() self.refresher:start(0, 100, function() self:_redraw { do_selection = true, force = true } end) local tx, rx = channel.mpsc() self._tx = tx -- Register attach a.nvim_buf_attach(prompt_bufnr, false, { on_lines = function() find_id = self:_next_find_id() status_updater { completed = false } self._tx.send() --TODO: Determine if this is good because maybe this makes more sense -- i don't think do_seleciton should happen in redraw?... not sure. -- vim.schedule(function() -- self:_do_selection(self:_get_prompt()) -- end) end, on_detach = function() self:_detach() end, }) local au_picker = a.nvim_create_augroup("PickerInsert", { clear = true }) a.nvim_create_autocmd("BufLeave", { buffer = prompt_bufnr, group = au_picker, nested = true, once = true, callback = function() require("telescope.pickers").on_close_prompt(prompt_bufnr) end, }) a.nvim_create_autocmd("VimResized", { buffer = prompt_bufnr, group = au_picker, nested = true, once = true, callback = function() require("telescope.pickers").on_resize_window(prompt_bufnr) end, }) self.prompt_bufnr = prompt_bufnr state.set_status( prompt_bufnr, setmetatable({ prompt_bufnr = prompt_bufnr, prompt_win = prompt_win, prompt_border_win = prompt_border_win, results_bufnr = results_bufnr, results_win = results_win, results_border_win = results_border_win, preview_bufnr = preview_bufnr, preview_win = preview_win, preview_border_win = preview_border_win, picker = self, }, { __mode = "kv", }) ) mappings.apply_keymap(prompt_bufnr, self.attach_mappings, config.values.mappings) async.void(function() self.sorter:_init() self.manager = EntryManager:new(self.num_visible) -- Do filetype last, so that users can register at the last second. pcall(a.nvim_buf_set_option, prompt_bufnr, "filetype", "TelescopePrompt") pcall(a.nvim_buf_set_option, results_bufnr, "filetype", "TelescopeResults") -- TODO(async): I wonder if this should actually happen _before_ we nvim_buf_attach. -- This way the buffer would always start with what we think it should when we start the loop. if self.initial_mode == "insert" or self.initial_mode == "normal" then -- required for set_prompt to work adequately vim.cmd [[startinsert!]] if self.default_text then self:set_prompt(self.default_text) end if self.initial_mode == "normal" then -- otherwise (i) insert mode exitted faster than `picker:set_prompt`; (ii) cursor on wrong pos await_schedule(function() vim.cmd [[stopinsert]] end) end else error("Invalid setting for initial_mode: " .. self.initial_mode) end -- await_schedule() local count = 0 while true do -- Try to display as fast as possible, don't wait on the first iteration -- This ensures that we try to display as fast as possible if count > 0 then -- Wait for the next input rx.last() end count = count + 1 -- We want to be able to do nvim things, so wait here. await_schedule() self:_reset_track() if not a.nvim_buf_is_valid(prompt_bufnr) then return end local prompt = self:_get_next_filtered_prompt() if self.cache_picker == false or not (self.cache_picker.is_cached == true) then self.sorter:_start(prompt) self.manager = EntryManager:new(self.num_visible) local process_result = self:get_result_processor(find_id, prompt) local process_complete = self:get_result_completor(self.results_bufnr, find_id, prompt, status_updater) local ok, msg = pcall(function() self.finder(prompt, process_result, function(...) return process_complete(...) end) end) if not ok then log.warn("Finder failed with msg: ", msg) end else -- TODO(scroll): This can only happen once, I don't like where it is. self:_resume_picker() end end end)() end --- A helper function to update picker windows when layout options are changed function Picker:recalculate_layout() local line_count = vim.o.lines - vim.o.cmdheight if vim.o.laststatus ~= 0 then line_count = line_count - 1 end local popup_opts = self:get_window_options(vim.o.columns, line_count) -- `popup.nvim` massaging so people don't have to remember minheight shenanigans popup_opts.results.minheight = popup_opts.results.height popup_opts.prompt.minheight = popup_opts.prompt.height if popup_opts.preview then popup_opts.preview.minheight = popup_opts.preview.height end local status = state.get_status(self.prompt_bufnr) local prompt_win = status.prompt_win local results_win = status.results_win local preview_win = status.preview_win local preview_opts, preview_border_win if popup_opts.preview then if preview_win ~= nil then -- Move all popups at the same time popup.move(prompt_win, popup_opts.prompt) popup.move(results_win, popup_opts.results) popup.move(preview_win, popup_opts.preview) else popup_opts.preview.highlight = "TelescopePreviewNormal" popup_opts.preview.borderhighlight = "TelescopePreviewBorder" popup_opts.preview.titlehighlight = "TelescopePreviewTitle" local preview_bufnr = status.preview_bufnr ~= nil and a.nvim_buf_is_valid(status.preview_bufnr) and status.preview_bufnr or "" preview_win, preview_opts, preview_border_win = self:_create_window(preview_bufnr, popup_opts.preview) if preview_bufnr == "" then preview_bufnr = a.nvim_win_get_buf(preview_win) end status.preview_win = preview_win status.preview_bufnr = preview_bufnr status.preview_border_win = preview_border_win state.set_status(prompt_win, status) self.preview_win = preview_win self.preview_border_win = preview_border_win self.preview_border = preview_opts and preview_opts.border if self.previewer and self.previewer.state and self.previewer.state.winid then self.previewer.state.winid = preview_win end -- Move prompt and results after preview created vim.defer_fn(function() popup.move(prompt_win, popup_opts.prompt) popup.move(results_win, popup_opts.results) end, 0) end elseif preview_win ~= nil then popup.move(prompt_win, popup_opts.prompt) popup.move(results_win, popup_opts.results) -- Remove preview after the prompt and results are moved vim.defer_fn(function() utils.win_delete("preview_win", preview_win, true) utils.win_delete("preview_win", status.preview_border_win, true) status.preview_win = nil status.preview_border_win = nil state.set_status(prompt_win, status) self.preview_win = nil self.preview_border_win = nil self.preview_border = nil end, 0) else popup.move(prompt_win, popup_opts.prompt) popup.move(results_win, popup_opts.results) end -- self.max_results = popup_opts.results.height end local update_scroll = function(win, oldinfo, oldcursor, strategy, buf_maxline) if strategy == "ascending" then a.nvim_win_set_cursor(win, { buf_maxline, 0 }) a.nvim_win_set_cursor(win, { oldinfo.topline, 0 }) a.nvim_win_set_cursor(win, oldcursor) elseif strategy == "descending" then a.nvim_win_set_cursor(win, { 1, 0 }) a.nvim_win_set_cursor(win, { oldinfo.botline, 0 }) a.nvim_win_set_cursor(win, oldcursor) else error(debug.traceback("Unknown sorting strategy: " .. (strategy or ""))) end end --- A wrapper for `Picker:recalculate_layout()` that also handles maintaining cursor position function Picker:full_layout_update() local oldinfo = vim.fn.getwininfo(self.results_win)[1] local oldcursor = a.nvim_win_get_cursor(self.results_win) self:recalculate_layout() self:refresh_previewer() -- update scrolled position local buf_maxline = #a.nvim_buf_get_lines(self.results_bufnr, 0, -1, false) -- update_scroll(self.results_win, oldinfo, oldcursor, self.sorting_strategy, buf_maxline) end -- TODO: update multi-select with the correct tag name when available --- A simple interface to remove an entry from the results window without --- closing telescope. This either deletes the current selection or all the --- selections made using multi-select. It can be used to define actions --- such as deleting buffers or files. --- --- Example usage: --- --- actions.delete_something = function(prompt_bufnr) --- local current_picker = action_state.get_current_picker(prompt_bufnr) --- current_picker:delete_selection(function(selection) --- -- delete the selection outside of telescope --- end) --- end --- --- --- Example usage in telescope: --- - `actions.delete_buffer()` ---@param delete_cb function: called with each deleted selection function Picker:delete_selection(delete_cb) vim.validate { delete_cb = { delete_cb, "f" } } local original_selection_strategy = self.selection_strategy self.selection_strategy = "row" local delete_selections = self._multi:get() local used_multi_select = true if vim.tbl_isempty(delete_selections) then table.insert(delete_selections, self:get_selection()) used_multi_select = false end local selection_index = {} for result_index, result_entry in ipairs(self.finder.results) do if vim.tbl_contains(delete_selections, result_entry) then table.insert(selection_index, result_index) end end -- Sort in reverse order as removing an entry from the table shifts down the -- other elements to close the hole. table.sort(selection_index, function(x, y) return x > y end) for _, index in ipairs(selection_index) do local selection = table.remove(self.finder.results, index) delete_cb(selection) end if used_multi_select then self._multi = MultiSelect:new() end self:refresh() vim.defer_fn(function() self.selection_strategy = original_selection_strategy end, 50) end function Picker:set_promptprefix(str) self.prompt_prefix = str if self.prompt_prefix ~= "" then a.nvim_buf_set_option(self.prompt_bufnr, "buftype", "prompt") vim.fn.prompt_setprompt(self.prompt_bufnr, self.prompt_prefix) else a.nvim_buf_set_option(self.prompt_bufnr, "buftype", "") -- TODO: Decide this -- a.nvim_buf_set_lines(self.prompt_bufnr, 0, -1, false, { self.default_text }) end end -- TODO: rebase function Picker:set_prompt(text) self:reset_prompt(text) end function Picker:set_prompt(str) a.nvim_buf_set_lines(self.prompt_bufnr, 0, -1, false, { "" }) a.nvim_feedkeys(str, "n", false) self:_reset_prefix_color() -- TODO(conni2461): As soon as prompt_buffers are fix use this: -- a.nvim_buf_set_lines(self.prompt_bufnr, 0, -1, false, { "" }) -- a.nvim_buf_set_text(self.prompt_bufnr, 0, 0, 0, 0, { str }) end --- Closes the windows for the prompt, results and preview ---@param status table: table containing information on the picker --- and associated windows. Generally obtained from `state.get_status` function Picker.close_windows(status) utils.win_delete("results_win", status.results_win, true, true) utils.win_delete("preview_win", status.preview_win, true, true) utils.win_delete("prompt_border_win", status.prompt_border_win, true, true) utils.win_delete("results_border_win", status.results_border_win, true, true) utils.win_delete("preview_border_win", status.preview_border_win, true, true) -- we cant use win_delete. We first need to close and then delete the buffer a.nvim_win_close(status.prompt_win, true) utils.buf_delete(status.prompt_bufnr) state.clear_status(status.prompt_bufnr) end --- Get the entry table of the current selection ---@return table function Picker:get_selection() return self._selection_entry end --- Get the row number of the current selection ---@return number function Picker:get_selection_row() -- TODO: Rebase -- if self._selection_row then -- -- If the current row is no longer selectable than reduce it to num_results - 1, so the next selectable row. -- -- This makes selection_strategy `row` work much better if the selected row is no longer part of the output. -- --TODO(conni2461): Maybe this can be moved to scroller. (currently in a hotfix so not viable) -- if self.selection_strategy == "row" then -- local num_results = self.manager:num_results() -- if self.sorting_strategy == "ascending" then -- if self._selection_row >= num_results then -- return num_results - 1 -- end -- else -- local max = self.max_results - num_results -- if self._selection_row < max then -- return self.max_results - num_results -- end -- end -- end -- return self._selection_row -- end -- return self.max_results -- TODO(fps): This seems WRONG -- what if it should be the first row? very confused return self._selection_row or self.num_visible end function Picker:shift_offset(change) self.offset = self.offset + change self.offset = math.min(self.offset, self.manager:num_results() - self.num_visible + 1) self.offset = math.max(self.offset, 0) self:_redraw { force = true } end --- Move the current selection by `change` steps ---@param change number function Picker:move_selection(change) -- TODO: This isn't quite right. I can get stuck :'( -- I think we just want to do it with the change as positive or negative -- and then also just delete a bunch of this other random stuff. -- -- it's so confusing :'( -- row = self.scroller(self.num_visible, self.manager:num_results(), row) -- todo: can we do this based on index?? if self.sorting_strategy == "ascending" then local new_row = self:get_selection_row() + change if new_row >= self.num_visible then if self.offset == 0 and self.scroll_strategy == "cycle" then error "ya" end self.offset = math.min(self.offset + new_row - self.num_visible + 1, self.num_visible - 1) self:set_selection(self.num_visible - 1) elseif new_row < 0 then if self.offset == 0 and self.scroll_strategy == "cycle" then -- self.offset = math.min(self.offset + new_row - self.num_visible + 1, self.num_visible - 1) -- self.offset = self.manager:num_results() - self.num_visible -- self:set_selection() -- error(vim.inspect { -- num_results = self.manager:num_results(), -- num_visible = self.num_visible, -- }) -- print( -- self.manager:num_results(), -- self.num_visible, -- math.min(self.manager:num_results() - 1, self.num_visible - 1) -- ) self.offset = math.max(0, self.manager:num_results() - self.num_visible) self:_update_results {} self:set_selection(math.min(self.num_visible - 1, self.manager:num_results() - 1)) else self.offset = math.max(self.offset - 1, 0) self:set_selection(0) end else self:set_selection(new_row) end else -- print(self.offset, self.num_visible, self.manager:num_results()) local new_row = self:get_selection_row() + change if new_row >= self.num_visible then self.offset = math.max(0, self.manager:num_results() - self.num_visible) self:_update_results {} self:set_selection(math.max(0, self.num_visible - self.manager:num_results())) elseif new_row < 0 then error "oh no" else self:set_selection(new_row) end end self:_redraw { force = true } end function Picker:_resolve_entry_display(entry) return require("telescope.pickers.entry_display").resolve(self, entry) end function Picker:_get_entry_from_row(row) return self:_get_entry_from_index(self:get_index(row)) end function Picker:_get_entry_from_index(idx) return self.manager:get_entry(self.offset, idx) end --- Add the entry of the given row to the multi-select object ---@param row number: the number of the chosen row function Picker:add_selection(row) local entry = self:_get_entry_from_row(row) self._multi:add(entry) self:_update_status() self.highlighter:highlight(row, { skip_display = true, is_multi_selected = true, }) end --- Remove the entry of the given row to the multi-select object ---@param row number: the number of the chosen row function Picker:remove_selection(row) local entry = self:_get_entry_from_row(row) self._multi:drop(entry) self:_update_status() self.highlighter:highlight(row, { skip_display = true, is_multi_selected = false, }) end --- Check if the given row is in the multi-select object ---@param entry table: table with information about the chosen entry ---@return number: the "count" associated to the entry in the multi-select --- object (if present), `nil` otherwise function Picker:is_multi_selected(entry) return self._multi:is_selected(entry) end --- Get a table containing all of the currently selected entries ---@return table: an integer indexed table of selected entries function Picker:get_multi_selection() return self._multi:get() end --- Toggle the given row in and out of the multi-select object. --- Also updates the highlighting for the given entry ---@param row number: the number of the chosen row function Picker:toggle_selection(row) local entry = self:_get_entry_from_row(row) if entry == nil then return end self._multi:toggle(entry) self:_update_status() self.highlighter:highlight(row, { skip_display = true, }) end --- Set the current selection to `nil` ---@note: generally used when a picker is first activated with `find()` function Picker:reset_selection() self._selection_entry = nil self._selection_row = nil end function Picker:_reset_prefix_color(hl_group) self._current_prefix_hl_group = hl_group or nil if self.prompt_prefix ~= "" then a.nvim_buf_add_highlight( self.prompt_bufnr, ns_telescope_prompt_prefix, self._current_prefix_hl_group or "TelescopePromptPrefix", 0, 0, strdisplaywidth(self.prompt_prefix) ) end end -- TODO(conni2461): Maybe _ prefix these next two functions -- TODO(conni2461): Next two functions only work together otherwise color doesn't work -- Probably a issue with prompt buffers --- Change the prefix in the prompt to be `new_prefix` and apply `hl_group` ---@param new_prefix string: the string to be used as the new prefix ---@param hl_group string: the name of the chosen highlight function Picker:change_prompt_prefix(new_prefix, hl_group) if not new_prefix then return end if new_prefix ~= "" then vim.fn.prompt_setprompt(self.prompt_bufnr, new_prefix) else a.nvim_buf_set_text(self.prompt_bufnr, 0, 0, 0, self._prefix_width, {}) a.nvim_buf_set_option(self.prompt_bufnr, "buftype", "") end self.prompt_prefix = new_prefix self:_reset_prefix_color(hl_group) end --- Reset the prompt to the provided `text` ---@param text string function Picker:reset_prompt(text) local prompt_text = self.prompt_prefix .. (text or "") a.nvim_buf_set_lines(self.prompt_bufnr, 0, -1, false, { prompt_text }) self:_reset_prefix_color(self._current_prefix_hl_group) if text then -- a.nvim_win_set_cursor(self.prompt_win, { 1, #prompt_text }) end end --- Refresh the current picker ---@param finder Finder: telescope finder (see telescope/finders.lua) ---@param opts table: options to pass when refreshing the picker ---@field new_prefix string|table: either as string or { new_string, hl_group } ---@field reset_prompt bool: whether to reset the prompt ---@field multi MultiSelect: multi-selection to persist upon renewing finder (see telescope/pickers/multi.lua) function Picker:refresh(finder, opts) opts = opts or {} if opts.new_prefix then local handle = type(opts.new_prefix) == "table" and unpack or function(x) return x end self:change_prompt_prefix(handle(opts.new_prefix)) end if finder then self.finder:close() self.finder = finder self._multi = vim.F.if_nil(opts.multi, MultiSelect:new()) end -- TODO: Rebase -- reset already triggers finder loop -- if opts.reset_prompt then -- self:reset_prompt() -- else -- self._on_lines(nil, nil, nil, 0, 1) -- end self._tx.send() end ---Set the selection to the provided `row` ---@param row number function Picker:set_selection(row) -- print("Setting selection to:", debug.traceback(row)) -- print("Setting selection to:", self.offset, row, "//", self.num_visible, self.manager:num_results()) if not self.manager then return end local old_row = row row = self.scroller(self.num_visible, self.manager:num_results(), row) -- print("Setting row to be:", old_row, "->", row) if not self:can_select_row(row) then -- If the current selected row exceeds number of currently displayed -- elements we have to reset it. Affects sorting_strategy = 'row'. if not self:can_select_row(self:get_selection_row()) then row = self:get_row(self.manager:num_results()) else -- error(string.format("Cannot select row: %s %s %s", row, self.manager:num_results(), self.num_visible)) return end end local results_bufnr = self.results_bufnr if not a.nvim_buf_is_valid(results_bufnr) then return end if row > a.nvim_buf_line_count(results_bufnr) then log.info( string.format("Should not be possible to get row this large %s %s", row, a.nvim_buf_line_count(results_bufnr)) ) return end local entry = self:_get_entry_from_row(row) state.set_global_key("selected_entry", entry) -- No entries, so just be done if not entry then return end -- If the entry is the current selection_entry, then just quit -- if self._selection_entry == entry and self._selection_row == row then -- return -- end -- Check if previous selection is still visible and valid -- if self._selection_entry and self.manager:find_entry(self._selection_entry) then -- -- Find old selection, and update prefix and highlights -- local old_entry = self._selection_entry -- -- local old_row = self:get_row(self.manager:find_entry(old_entry)) -- if old_row >= 0 then -- self.highlighter:hi_multiselect(old_row, self:is_multi_selected(old_entry)) -- end -- end self._selection_entry = entry self._selection_row = row self.highlighter:highlight(row, { skip_display = true, }) self:refresh_previewer() end --- Refresh the previewer based on the current `status` of the picker function Picker:refresh_previewer() local status = state.get_status(self.prompt_bufnr) if not self._selection_entry then -- if selection_entry is nil there is nothing to be previewed return end if self.previewer and status.preview_win and a.nvim_win_is_valid(status.preview_win) then self:_increment "previewed" self.previewer:preview(self._selection_entry, status) if self.preview_border then if self.fix_preview_title then return end local new_title = self.previewer:title(self._selection_entry, config.values.dynamic_preview_title) if new_title ~= nil and new_title ~= self.preview_title then self.preview_title = new_title self.preview_border:change_title(new_title) end end end end function Picker:cycle_previewers(next) local size = #self.all_previewers if size == 1 then return end self.current_previewer_index = self.current_previewer_index + next if self.current_previewer_index > size then self.current_previewer_index = 1 elseif self.current_previewer_index < 1 then self.current_previewer_index = size end if self.previewer then self.previewer = self.all_previewers[self.current_previewer_index] self:refresh_previewer() elseif self.hidden_previewer then self.hidden_previewer = self.all_previewers[self.current_previewer_index] end end --- Reset tracked information for this picker function Picker:_reset_track() self.stats.processed = 0 self.stats.displayed = 0 self.stats.display_fn = 0 self.stats.previewed = 0 self.stats.status = 0 self.stats.filtered = 0 end --- Increment the count of the tracked info at `self.stats[key]` ---@param key string function Picker:_increment(key) self.stats[key] = (self.stats[key] or 0) + 1 end --- Decrement the count of the tracked info at `self.stats[key]` ---@param key string function Picker:_decrement(key) self.stats[key] = (self.stats[key] or 0) - 1 end -- TODO: Decide how much we want to use this. -- Would allow for better debugging of items. function Picker:register_completion_callback(cb) table.insert(self._completion_callbacks, cb) end function Picker:clear_completion_callbacks() self._completion_callbacks = {} end function Picker:_on_complete() -- TODO: Should alert user that stuff broke. for _, v in ipairs(self._completion_callbacks) do local ok, msg = pcall(v, self) if not ok then self:_on_error(msg) end end end --- Close all open Telescope pickers function Picker:close_existing_pickers() for _, prompt_bufnr in ipairs(state.get_existing_prompts()) do pcall(actions.close, prompt_bufnr) end end --[[ ------- | | | | | | ------- -> self.manager:window(start, finish) window is a sorted list of entries, 1 -> (finish - start) window has idx for each entry - to calculate row for idx, use self:get_row(window_idx) Goals: - Redraw is _always_ scheduled, so it's safe to run (but does not give up control while executing) - Redraw is idemptotent (we can just keep running it and wasting cycles but we OK) --]] Picker._redraw = vim.schedule_wrap(function(self, opts) opts = opts or {} if not opts.force and not self.manager then return end self:_update_status { completed = false } if not opts.force and not self.manager.dirty then return end self.manager.dirty = false local displayed, highlights = self:_update_results() self:_update_highlights(displayed, highlights) if opts.do_selection then -- self:set_selection -- self:_do_selection(prompt) end -- a.nvim_win_set_cursor -- print("Setting win cursor", self:get_selection_row() + 1) -- a.nvim_win_set_cursor(self.results_win, { self:get_selection_row() + 1, 0 }) end) local get_displayed_and_highlights = function(self, window) -- Get the contents to display and associated highlights local displayed, highlights = {}, {} for window_idx, entry in ipairs(window) do local row = self:get_row(window_idx) displayed[row], highlights[row] = self:_resolve_entry_display(entry) end return displayed, highlights end function Picker:_update_highlights(displayed, highlights) local window = self.manager:window(1 + self.offset, self.num_visible + self.offset) -- Only look up displays and highlights if we haven't done that already if not displayed or not highlights then displayed, highlights = get_displayed_and_highlights(self, window) end local prompt = self:_get_prompt() self.highlighter:clear() for row, display_highlights in pairs(highlights) do self.highlighter:highlight(row, { prompt = prompt, entry = window[self:get_index(row)], display = displayed[row], display_highlights = display_highlights, }) end end function Picker:_update_results(opts) opts = opts or {} -- Dimensions local height = a.nvim_win_get_height(self.results_win) local window = self.manager:window(1 + self.offset, self.num_visible + self.offset) -- Get the contents to display and associated highlights local displayed, highlights = get_displayed_and_highlights(self, window) -- Push display contents into lines buffer local lines = {} for idx = 0, height do lines[idx + 1] = (displayed[idx] and self._prefix_string .. displayed[idx]) or "" end -- Display contents a.nvim_buf_set_lines(self.results_bufnr, 0, -1, false, lines) return displayed, highlights end --- Returns a function that sets virtual text for the count indicator --- e.g. "10/50" as "filtered"/"processed" function Picker:_update_status(opts) local prompt_win = self.prompt_win local prompt_bufnr = self.prompt_bufnr if self.closed or not a.nvim_buf_is_valid(prompt_bufnr) then return end local current_prompt = self:_get_prompt() if not current_prompt then return end if not a.nvim_win_is_valid(prompt_win) then return end local text = self:get_status_text(opts) a.nvim_buf_clear_namespace(prompt_bufnr, ns_telescope_prompt, 0, -1) a.nvim_buf_set_extmark(prompt_bufnr, ns_telescope_prompt, 0, 0, { virt_text = { { text, "TelescopePromptCounter" } }, virt_text_pos = "right_align", }) self:_increment "status" end --- Returns a function that will process an element. --- Returned function handles updating the "filtered" and "processed" counts --- as appropriate and runs the sorters score function ---@param find_id number ---@param prompt string ---@return function function Picker:get_result_processor(find_id, prompt) local count = 0 local cb_add = function(score, entry) self.manager:add_entry(self, score, entry) end local cb_filter = function(_) self:_increment "filtered" end return function(entry) if find_id ~= self._find_id then return true end if not entry or entry.valid == false then return end self:_increment "processed" count = count + 1 -- TODO: Probably should asyncify this / cache this / do something because this probably takes -- a ton of time on large results. log.trace("Processing result... ", entry) for _, v in ipairs(self.file_ignore_patterns or {}) do local file = vim.F.if_nil(entry.filename, type(entry.value) == "string" and entry.value) -- false if none is true if file then if string.find(file, v) then log.trace("SKIPPING", entry.value, "because", v) self:_decrement "processed" return end end end self.sorter:score(prompt, entry, cb_add, cb_filter) -- Only on the first addition do we want to set the selection. -- This allows us to handle moving the cursor to the bottom or top of the window -- depending on the strategy. -- if count == 1 then -- self:_do_selection(prompt) -- end end end --- Handles updating the picker after all the entries are scored/processed. ---@param results_bufnr number ---@param find_id number ---@param prompt string ---@param status_updater function function Picker:get_result_completor(results_bufnr, find_id, prompt, status_updater) return vim.schedule_wrap(function() if self.closed == true or self:is_done() then return end self:_do_selection(prompt) state.set_global_key("current_line", self:_get_prompt()) status_updater { completed = true } self.sorter:_finish(prompt) self:_on_complete() end) end function Picker:_do_selection(prompt) self.offset = 0 local selection_strategy = self.selection_strategy or "reset" -- TODO: Either: always leave one result or make sure we actually clean up the results when nothing matches if selection_strategy == "row" then if self._selection_row == nil and self.default_selection_index ~= nil then self:set_selection(self:get_row(self.default_selection_index)) else self:set_selection(self:get_selection_row()) end elseif selection_strategy == "follow" then if self._selection_row == nil and self.default_selection_index ~= nil then self:set_selection(self:get_row(self.default_selection_index)) else local index = self.manager:find_entry(self:get_selection()) if index then local follow_row = self:get_row(index) self:set_selection(follow_row) else self:set_selection(self:get_reset_row()) end end elseif selection_strategy == "reset" then if self.default_selection_index ~= nil then self:set_selection(self:get_row(self.default_selection_index)) else self:set_selection(self:get_reset_row()) end elseif selection_strategy == "closest" then if prompt == "" and self.default_selection_index ~= nil then self:set_selection(self:get_row(self.default_selection_index)) else self:set_selection(self:get_reset_row()) end else error("Unknown selection strategy: " .. selection_strategy) end end --- Wrapper function for `Picker:new` that incorporates user provided `opts` --- with the telescope `defaults` ---@param opts table ---@param defaults table ---@return Picker pickers.new = function(opts, defaults) opts = opts or {} defaults = defaults or {} local result = {} for k, v in pairs(opts) do assert(type(k) == "string" or type(k) == "number", "Should be string or number, found: " .. type(k)) result[k] = v end for k, v in pairs(defaults) do if result[k] == nil then assert(type(k) == "string", "Should be string, defaults") result[k] = v else -- For attach mappings, we want people to be able to pass in another function -- and apply their mappings after we've applied our defaults. if k == "attach_mappings" then local opt_value = result[k] result[k] = function(...) v(...) return opt_value(...) end end end end if result["previewer"] == false then result["previewer"] = defaults["previewer"] result["__hide_previewer"] = true elseif type(opts["preview"]) == "table" and opts["preview"]["hide_on_startup"] then result["__hide_previewer"] = true end return Picker:new(result) end --- Close the picker which has prompt with buffer number `prompt_bufnr` ---@param prompt_bufnr number function pickers.on_close_prompt(prompt_bufnr) local status = state.get_status(prompt_bufnr) local picker = status.picker require("telescope.actions.state").get_current_history():reset() picker.refresher:stop() picker.refresher:close() if type(picker.cache_picker) == "table" then local cached_pickers = state.get_global_key "cached_pickers" or {} if type(picker.cache_picker.index) == "number" then if not vim.tbl_isempty(cached_pickers) then table.remove(cached_pickers, picker.cache_picker.index) end end -- if picker was disabled post-hoc (e.g. `cache_picker = false` conclude after deletion) if picker.cache_picker.disabled ~= true then if picker.cache_picker.limit_entries > 0 then -- edge case: starting in normal mode and not having run a search means having no manager instantiated if picker.manager then picker.manager.linked_states:truncate(picker.cache_picker.limit_entries) else picker.manager = EntryManager:new(picker.num_visible) end end picker.default_text = picker:_get_prompt() picker.cache_picker.selection_row = picker._selection_row picker.cache_picker.cached_prompt = picker:_get_prompt() picker.cache_picker.is_cached = true table.insert(cached_pickers, 1, picker) -- release pickers if picker.cache_picker.num_pickers > 0 then while #cached_pickers > picker.cache_picker.num_pickers do table.remove(cached_pickers, #cached_pickers) end end state.set_global_key("cached_pickers", cached_pickers) end end if picker.sorter then picker.sorter:_destroy() end if picker.all_previewers then for _, v in ipairs(picker.all_previewers) do v:teardown() end end if picker.finder then picker.finder:close() end -- so we dont call close_windows multiple times we clear that autocmd a.nvim_clear_autocmds { group = "PickerInsert", event = "BufLeave", buffer = prompt_bufnr, } picker.close_windows(status) mappings.clear(prompt_bufnr) end function pickers.on_resize_window(prompt_bufnr) local status = state.get_status(prompt_bufnr) local picker = status.picker picker:full_layout_update() end --- Get the prompt text without the prompt prefix. function Picker:_get_prompt() return a.nvim_buf_get_lines(self.prompt_bufnr, 0, 1, false)[1]:sub(#self.prompt_prefix + 1) end function Picker:_detach() self.finder:close() self.closed = true end function Picker:_get_next_filtered_prompt() local prompt = self:_get_prompt() local on_input_result = self._on_input_filter_cb(prompt) or {} local new_prompt = on_input_result.prompt if new_prompt then prompt = new_prompt end local new_finder = on_input_result.updated_finder if new_finder then self.finder:close() self.finder = new_finder end return prompt end function Picker:_resume_picker() error "TJ Has not implemented resuming yet" -- resume previous picker local index = 1 for entry in self.manager:iter() do self:entry_adder(index, entry, _, true) index = index + 1 end self.cache_picker.is_cached = false -- if text changed, required to set anew to restart finder; otherwise hl and selection if self.cache_picker.cached_prompt ~= self.default_text then self:set_prompt(self.default_text) else -- scheduling required to apply highlighting and selection appropriately await_schedule(function() if self.cache_picker.selection_row ~= nil then self:set_selection(self.cache_picker.selection_row) end end) end end pickers._Picker = Picker return pickers