From 559d6c6bf207e4e768a88e7f727ac12a87c768b7 Mon Sep 17 00:00:00 2001 From: Folke Lemaitre Date: Tue, 14 Jan 2025 22:53:59 +0100 Subject: [PATCH] feat(snacks): added `snacks.picker` (#445) ## Description More info coming tomorrow. In short: - very fast. pretty much realtime filtering/sorting in huge repos (like 1.7 million files) - extensible - easy to customize the layout (and lots of presets) with `snacks.layout` - simple to create custom pickers - `vim.ui.select` - lots of builtin pickers - uses treesitter highlighting wherever it makes sense - fast lua fuzzy matcher which supports the [fzf syntax](https://junegunn.github.io/fzf/search-syntax/) and additionally supports field filters, like `file:lua$ 'function` There's no snacks picker command, just use lua. ```lua -- all pickers Snacks.picker() -- run files picker Snacks.picker.files(opts) Snacks.picker.pick("files", opts) Snacks.picker.pick({source = "files", ...}) ``` ## Todo - [x] issue with preview loc not always correct when scrolling fast in list (probably due to `snacks.scroll`) - [x] `grep` (`live_grep`) is sometimes too fast in large repos and can impact ui rendering. Not very noticeable, but something I want to look at. - [x] docs - [x] treesitter highlights are broken. Messed something up somewhere ## Related Issue(s) ## Screenshots --- README.md | 4 + doc/snacks-init.txt | 24 +- doc/snacks-input.txt | 1 + doc/snacks-layout.txt | 181 ++ doc/snacks-meta.txt | 9 + doc/snacks-picker.txt | 1965 ++++++++++++++++++++++ doc/snacks-scroll.txt | 12 +- doc/snacks-styles.txt | 17 + doc/snacks-win.txt | 70 +- docs/examples/picker.lua | 91 + docs/init.md | 20 +- docs/layout.md | 146 ++ docs/meta.md | 7 + docs/picker.md | 1718 +++++++++++++++++++ docs/styles.md | 14 + docs/win.md | 58 +- lua/snacks/dashboard.lua | 5 + lua/snacks/init.lua | 2 +- lua/snacks/layout.lua | 403 +++++ lua/snacks/meta/docs.lua | 244 ++- lua/snacks/meta/init.lua | 12 +- lua/snacks/meta/types.lua | 4 + lua/snacks/picker/actions.lua | 328 ++++ lua/snacks/picker/config/defaults.lua | 261 +++ lua/snacks/picker/config/highlights.lua | 81 + lua/snacks/picker/config/init.lua | 119 ++ lua/snacks/picker/config/layouts.lua | 137 ++ lua/snacks/picker/config/sources.lua | 509 ++++++ lua/snacks/picker/core/_health.lua | 51 + lua/snacks/picker/core/actions.lua | 89 + lua/snacks/picker/core/filter.lua | 101 ++ lua/snacks/picker/core/finder.lua | 88 + lua/snacks/picker/core/input.lua | 147 ++ lua/snacks/picker/core/list.lua | 465 +++++ lua/snacks/picker/core/main.lua | 50 + lua/snacks/picker/core/matcher.lua | 465 +++++ lua/snacks/picker/core/picker.lua | 501 ++++++ lua/snacks/picker/core/preview.lua | 230 +++ lua/snacks/picker/format.lua | 381 +++++ lua/snacks/picker/init.lua | 91 + lua/snacks/picker/preview.lua | 250 +++ lua/snacks/picker/select.lua | 50 + lua/snacks/picker/sort.lua | 45 + lua/snacks/picker/source/buffers.lua | 45 + lua/snacks/picker/source/diagnostics.lua | 38 + lua/snacks/picker/source/files.lua | 114 ++ lua/snacks/picker/source/git.lua | 102 ++ lua/snacks/picker/source/grep.lua | 109 ++ lua/snacks/picker/source/help.lua | 64 + lua/snacks/picker/source/lines.lua | 27 + lua/snacks/picker/source/lsp.lua | 355 ++++ lua/snacks/picker/source/meta.lua | 46 + lua/snacks/picker/source/proc.lua | 118 ++ lua/snacks/picker/source/qf.lua | 74 + lua/snacks/picker/source/recent.lua | 61 + lua/snacks/picker/source/system.lua | 64 + lua/snacks/picker/source/vim.lua | 290 ++++ lua/snacks/picker/util/async.lua | 310 ++++ lua/snacks/picker/util/highlight.lua | 178 ++ lua/snacks/picker/util/init.lua | 106 ++ lua/snacks/picker/util/minheap.lua | 147 ++ lua/snacks/picker/util/queue.lua | 39 + lua/snacks/scroll.lua | 10 + lua/snacks/util.lua | 3 + lua/snacks/win.lua | 266 ++- tests/picker/matcher_spec.lua | 96 ++ tests/picker/minheap_spec.lua | 31 + 67 files changed, 12013 insertions(+), 126 deletions(-) create mode 100644 doc/snacks-layout.txt create mode 100644 doc/snacks-picker.txt create mode 100644 docs/examples/picker.lua create mode 100644 docs/layout.md create mode 100644 docs/picker.md create mode 100644 lua/snacks/layout.lua create mode 100644 lua/snacks/picker/actions.lua create mode 100644 lua/snacks/picker/config/defaults.lua create mode 100644 lua/snacks/picker/config/highlights.lua create mode 100644 lua/snacks/picker/config/init.lua create mode 100644 lua/snacks/picker/config/layouts.lua create mode 100644 lua/snacks/picker/config/sources.lua create mode 100644 lua/snacks/picker/core/_health.lua create mode 100644 lua/snacks/picker/core/actions.lua create mode 100644 lua/snacks/picker/core/filter.lua create mode 100644 lua/snacks/picker/core/finder.lua create mode 100644 lua/snacks/picker/core/input.lua create mode 100644 lua/snacks/picker/core/list.lua create mode 100644 lua/snacks/picker/core/main.lua create mode 100644 lua/snacks/picker/core/matcher.lua create mode 100644 lua/snacks/picker/core/picker.lua create mode 100644 lua/snacks/picker/core/preview.lua create mode 100644 lua/snacks/picker/format.lua create mode 100644 lua/snacks/picker/init.lua create mode 100644 lua/snacks/picker/preview.lua create mode 100644 lua/snacks/picker/select.lua create mode 100644 lua/snacks/picker/sort.lua create mode 100644 lua/snacks/picker/source/buffers.lua create mode 100644 lua/snacks/picker/source/diagnostics.lua create mode 100644 lua/snacks/picker/source/files.lua create mode 100644 lua/snacks/picker/source/git.lua create mode 100644 lua/snacks/picker/source/grep.lua create mode 100644 lua/snacks/picker/source/help.lua create mode 100644 lua/snacks/picker/source/lines.lua create mode 100644 lua/snacks/picker/source/lsp.lua create mode 100644 lua/snacks/picker/source/meta.lua create mode 100644 lua/snacks/picker/source/proc.lua create mode 100644 lua/snacks/picker/source/qf.lua create mode 100644 lua/snacks/picker/source/recent.lua create mode 100644 lua/snacks/picker/source/system.lua create mode 100644 lua/snacks/picker/source/vim.lua create mode 100644 lua/snacks/picker/util/async.lua create mode 100644 lua/snacks/picker/util/highlight.lua create mode 100644 lua/snacks/picker/util/init.lua create mode 100644 lua/snacks/picker/util/minheap.lua create mode 100644 lua/snacks/picker/util/queue.lua create mode 100644 tests/picker/matcher_spec.lua create mode 100644 tests/picker/minheap_spec.lua diff --git a/README.md b/README.md index 8a65b79c..dff71a3d 100644 --- a/README.md +++ b/README.md @@ -18,9 +18,11 @@ A collection of small QoL plugins for Neovim. | [gitbrowse](https://github.com/folke/snacks.nvim/blob/main/docs/gitbrowse.md) | Open the current file, branch, commit, or repo in a browser (e.g. GitHub, GitLab, Bitbucket) | | | [indent](https://github.com/folke/snacks.nvim/blob/main/docs/indent.md) | Indent guides and scopes | | | [input](https://github.com/folke/snacks.nvim/blob/main/docs/input.md) | Better `vim.ui.input` | ‼️ | +| [layout](https://github.com/folke/snacks.nvim/blob/main/docs/layout.md) | Window layouts | | | [lazygit](https://github.com/folke/snacks.nvim/blob/main/docs/lazygit.md) | Open LazyGit in a float, auto-configure colorscheme and integration with Neovim | | | [notifier](https://github.com/folke/snacks.nvim/blob/main/docs/notifier.md) | Pretty `vim.notify` | ‼️ | | [notify](https://github.com/folke/snacks.nvim/blob/main/docs/notify.md) | Utility functions to work with Neovim's `vim.notify` | | +| [picker](https://github.com/folke/snacks.nvim/blob/main/docs/picker.md) | Picker for selecting items | ‼️ | | [profiler](https://github.com/folke/snacks.nvim/blob/main/docs/profiler.md) | Neovim lua profiler | | | [quickfile](https://github.com/folke/snacks.nvim/blob/main/docs/quickfile.md) | When doing `nvim somefile.txt`, it will render the file as quickly as possible, before loading your plugins. | ‼️ | | [rename](https://github.com/folke/snacks.nvim/blob/main/docs/rename.md) | LSP-integrated file renaming with support for plugins like [neo-tree.nvim](https://github.com/nvim-neo-tree/neo-tree.nvim) and [mini.files](https://github.com/echasnovski/mini.files). | | @@ -104,8 +106,10 @@ Please refer to the readme of each plugin for their specific configuration. ---@field gitbrowse? snacks.gitbrowse.Config ---@field indent? snacks.indent.Config ---@field input? snacks.input.Config +---@field layout? snacks.layout.Config ---@field lazygit? snacks.lazygit.Config ---@field notifier? snacks.notifier.Config +---@field picker? snacks.picker.Config ---@field profiler? snacks.profiler.Config ---@field quickfile? snacks.quickfile.Config ---@field scope? snacks.scope.Config diff --git a/doc/snacks-init.txt b/doc/snacks-init.txt index 11455267..be4209b5 100644 --- a/doc/snacks-init.txt +++ b/doc/snacks-init.txt @@ -6,10 +6,10 @@ Table of Contents *snacks-init-table-of-contents* 1. Config |snacks-init-config| 2. Types |snacks-init-types| 3. Module |snacks-init-module| - - Snacks.config.example() |snacks-init-module-snacks.config.example()| - - Snacks.config.get() |snacks-init-module-snacks.config.get()| - - Snacks.config.style() |snacks-init-module-snacks.config.style()| - - Snacks.setup() |snacks-init-module-snacks.setup()| + - Snacks.init.config.example()|snacks-init-module-snacks.init.config.example()| + - Snacks.init.config.get() |snacks-init-module-snacks.init.config.get()| + - Snacks.init.config.style() |snacks-init-module-snacks.init.config.style()| + - Snacks.init.setup() |snacks-init-module-snacks.init.setup()| ============================================================================== 1. Config *snacks-init-config* @@ -92,7 +92,7 @@ Table of Contents *snacks-init-table-of-contents* < -`Snacks.config.example()` *Snacks.config.example()* +`Snacks.init.config.example()` *Snacks.init.config.example()* Get an example config from the docs/examples directory. @@ -100,11 +100,11 @@ Get an example config from the docs/examples directory. ---@param snack string ---@param name string ---@param opts? table - Snacks.config.example(snack, name, opts) + Snacks.init.config.example(snack, name, opts) < -`Snacks.config.get()` *Snacks.config.get()* +`Snacks.init.config.get()` *Snacks.init.config.get()* >lua ---@generic T: table @@ -112,11 +112,11 @@ Get an example config from the docs/examples directory. ---@param defaults T ---@param ... T[] ---@return T - Snacks.config.get(snack, defaults, ...) + Snacks.init.config.get(snack, defaults, ...) < -`Snacks.config.style()` *Snacks.config.style()* +`Snacks.init.config.style()` *Snacks.init.config.style()* Register a new window style config. @@ -124,15 +124,15 @@ Register a new window style config. ---@param name string ---@param defaults snacks.win.Config|{} ---@return string - Snacks.config.style(name, defaults) + Snacks.init.config.style(name, defaults) < -`Snacks.setup()` *Snacks.setup()* +`Snacks.init.setup()` *Snacks.init.setup()* >lua ---@param opts snacks.Config? - Snacks.setup(opts) + Snacks.init.setup(opts) < Generated by panvimdoc diff --git a/doc/snacks-input.txt b/doc/snacks-input.txt index e89af58e..49ae51b1 100644 --- a/doc/snacks-input.txt +++ b/doc/snacks-input.txt @@ -98,6 +98,7 @@ INPUT *snacks-input-styles-input* i_esc = { "", { "cmp_close", "stopinsert" }, mode = "i", expr = true }, i_cr = { "", { "cmp_accept", "confirm" }, mode = "i", expr = true }, i_tab = { "", { "cmp_select_next", "cmp" }, mode = "i", expr = true }, + i_ctrl_w = { "", "", mode = "i", expr = true }, q = "cancel", }, } diff --git a/doc/snacks-layout.txt b/doc/snacks-layout.txt new file mode 100644 index 00000000..800045ff --- /dev/null +++ b/doc/snacks-layout.txt @@ -0,0 +1,181 @@ +*snacks-layout.txt* snacks.nvim + +============================================================================== +Table of Contents *snacks-layout-table-of-contents* + +1. Setup |snacks-layout-setup| +2. Config |snacks-layout-config| +3. Types |snacks-layout-types| +4. Module |snacks-layout-module| + - Snacks.layout.new() |snacks-layout-module-snacks.layout.new()| + - layout:close() |snacks-layout-module-layout:close()| + - layout:each() |snacks-layout-module-layout:each()| + - layout:is_enabled() |snacks-layout-module-layout:is_enabled()| + - layout:is_hidden() |snacks-layout-module-layout:is_hidden()| + - layout:maximize() |snacks-layout-module-layout:maximize()| + - layout:show() |snacks-layout-module-layout:show()| + - layout:toggle() |snacks-layout-module-layout:toggle()| + - layout:valid() |snacks-layout-module-layout:valid()| + +============================================================================== +1. Setup *snacks-layout-setup* + +>lua + -- lazy.nvim + { + "folke/snacks.nvim", + ---@type snacks.Config + opts = { + layout = { + -- your layout configuration comes here + -- or leave it empty to use the default settings + -- refer to the configuration section below + } + } + } +< + + +============================================================================== +2. Config *snacks-layout-config* + +>lua + ---@class snacks.layout.Config + ---@field show? boolean show the layout on creation (default: true) + ---@field wins table windows to include in the layout + ---@field layout snacks.layout.Box layout definition + ---@field fullscreen? boolean open in fullscreen + ---@field hidden? string[] list of windows that will be excluded from the layout (but can be toggled) + ---@field on_update? fun(layout: snacks.layout) + { + layout = { + width = 0.6, + height = 0.6, + zindex = 50, + }, + } +< + + +============================================================================== +3. Types *snacks-layout-types* + +>lua + ---@class snacks.layout.Win: snacks.win.Config,{} + ---@field depth? number + ---@field win string layout window name +< + +>lua + ---@class snacks.layout.Box: snacks.layout.Win,{} + ---@field box "horizontal" | "vertical" + ---@field id? number + ---@field [number] snacks.layout.Win | snacks.layout.Box children +< + +>lua + ---@alias snacks.layout.Widget snacks.layout.Win | snacks.layout.Box +< + + +============================================================================== +4. Module *snacks-layout-module* + +>lua + ---@class snacks.layout + ---@field opts snacks.layout.Config + ---@field root snacks.win + ---@field wins table + ---@field box_wins snacks.win[] + ---@field win_opts table + ---@field closed? boolean + Snacks.layout = {} +< + + +`Snacks.layout.new()` *Snacks.layout.new()* + +>lua + ---@param opts snacks.layout.Config + Snacks.layout.new(opts) +< + + +LAYOUT:CLOSE() *snacks-layout-module-layout:close()* + +Close the layout + +>lua + ---@param opts? {wins?: boolean} + layout:close(opts) +< + + +LAYOUT:EACH() *snacks-layout-module-layout:each()* + +>lua + ---@param cb fun(widget: snacks.layout.Widget, parent?: snacks.layout.Box) + ---@param opts? {wins?:boolean, boxes?:boolean, box?:snacks.layout.Box} + layout:each(cb, opts) +< + + +LAYOUT:IS_ENABLED() *snacks-layout-module-layout:is_enabled()* + +Check if the window has been used in the layout + +>lua + ---@param w string + layout:is_enabled(w) +< + + +LAYOUT:IS_HIDDEN() *snacks-layout-module-layout:is_hidden()* + +Check if a window is hidden + +>lua + ---@param win string + layout:is_hidden(win) +< + + +LAYOUT:MAXIMIZE() *snacks-layout-module-layout:maximize()* + +Toggle fullscreen + +>lua + layout:maximize() +< + + +LAYOUT:SHOW() *snacks-layout-module-layout:show()* + +Show the layout + +>lua + layout:show() +< + + +LAYOUT:TOGGLE() *snacks-layout-module-layout:toggle()* + +Toggle a window + +>lua + ---@param win string + layout:toggle(win) +< + + +LAYOUT:VALID() *snacks-layout-module-layout:valid()* + +Check if layout is valid (visible) + +>lua + layout:valid() +< + +Generated by panvimdoc + +vim:tw=78:ts=8:noet:ft=help:norl: diff --git a/doc/snacks-meta.txt b/doc/snacks-meta.txt index 71cc4d22..5f6a41bd 100644 --- a/doc/snacks-meta.txt +++ b/doc/snacks-meta.txt @@ -5,6 +5,7 @@ Table of Contents *snacks-meta-table-of-contents* 1. Types |snacks-meta-types| 2. Module |snacks-meta-module| + - Snacks.meta.file() |snacks-meta-module-snacks.meta.file()| - Snacks.meta.get() |snacks-meta-module-snacks.meta.get()| Meta functions for Snacks @@ -22,6 +23,7 @@ Meta functions for Snacks ---@field health? boolean ---@field types? boolean ---@field config? boolean + ---@field merge? { [string|number]: string } < >lua @@ -37,6 +39,13 @@ Meta functions for Snacks 2. Module *snacks-meta-module* +`Snacks.meta.file()` *Snacks.meta.file()* + +>lua + Snacks.meta.file(name) +< + + `Snacks.meta.get()` *Snacks.meta.get()* Get the metadata for all snacks plugins diff --git a/doc/snacks-picker.txt b/doc/snacks-picker.txt new file mode 100644 index 00000000..58af365d --- /dev/null +++ b/doc/snacks-picker.txt @@ -0,0 +1,1965 @@ +*snacks-picker.txt* snacks.nvim + +============================================================================== +Table of Contents *snacks-picker-table-of-contents* + +1. Setup |snacks-picker-setup| +2. Config |snacks-picker-config| +3. Examples |snacks-picker-examples| + - general |snacks-picker-examples-general| + - todo_comments |snacks-picker-examples-todo_comments| + - trouble |snacks-picker-examples-trouble| +4. Types |snacks-picker-types| +5. Module |snacks-picker-module| + - Snacks.picker() |snacks-picker-module-snacks.picker()| + - Snacks.picker.pick() |snacks-picker-module-snacks.picker.pick()| + - Snacks.picker.select() |snacks-picker-module-snacks.picker.select()| +6. Sources |snacks-picker-sources| + - autocmds |snacks-picker-sources-autocmds| + - buffers |snacks-picker-sources-buffers| + - cliphist |snacks-picker-sources-cliphist| + - colorschemes |snacks-picker-sources-colorschemes| + - command_history |snacks-picker-sources-command_history| + - commands |snacks-picker-sources-commands| + - diagnostics |snacks-picker-sources-diagnostics| + - diagnostics_buffer |snacks-picker-sources-diagnostics_buffer| + - files |snacks-picker-sources-files| + - git_files |snacks-picker-sources-git_files| + - git_log |snacks-picker-sources-git_log| + - git_log_file |snacks-picker-sources-git_log_file| + - git_log_line |snacks-picker-sources-git_log_line| + - git_status |snacks-picker-sources-git_status| + - grep |snacks-picker-sources-grep| + - grep_buffers |snacks-picker-sources-grep_buffers| + - grep_word |snacks-picker-sources-grep_word| + - help |snacks-picker-sources-help| + - highlights |snacks-picker-sources-highlights| + - jumps |snacks-picker-sources-jumps| + - keymaps |snacks-picker-sources-keymaps| + - lines |snacks-picker-sources-lines| + - loclist |snacks-picker-sources-loclist| + - lsp_declarations |snacks-picker-sources-lsp_declarations| + - lsp_definitions |snacks-picker-sources-lsp_definitions| + - lsp_implementations |snacks-picker-sources-lsp_implementations| + - lsp_references |snacks-picker-sources-lsp_references| + - lsp_symbols |snacks-picker-sources-lsp_symbols| + - lsp_type_definitions |snacks-picker-sources-lsp_type_definitions| + - man |snacks-picker-sources-man| + - marks |snacks-picker-sources-marks| + - picker_actions |snacks-picker-sources-picker_actions| + - picker_format |snacks-picker-sources-picker_format| + - picker_layouts |snacks-picker-sources-picker_layouts| + - picker_preview |snacks-picker-sources-picker_preview| + - pickers |snacks-picker-sources-pickers| + - projects |snacks-picker-sources-projects| + - qflist |snacks-picker-sources-qflist| + - recent |snacks-picker-sources-recent| + - registers |snacks-picker-sources-registers| + - resume |snacks-picker-sources-resume| + - search_history |snacks-picker-sources-search_history| + - zoxide |snacks-picker-sources-zoxide| +7. Layouts |snacks-picker-layouts| + - default |snacks-picker-layouts-default| + - dropdown |snacks-picker-layouts-dropdown| + - ivy |snacks-picker-layouts-ivy| + - select |snacks-picker-layouts-select| + - telescope |snacks-picker-layouts-telescope| + - vertical |snacks-picker-layouts-vertical| + - vscode |snacks-picker-layouts-vscode| +8. snacks.picker.actions |snacks-picker-snacks.picker.actions| + - Snacks.picker.actions.cmd()|snacks-picker-snacks.picker.actions-snacks.picker.actions.cmd()| + - Snacks.picker.actions.copy()|snacks-picker-snacks.picker.actions-snacks.picker.actions.copy()| + - Snacks.picker.actions.cycle_win()|snacks-picker-snacks.picker.actions-snacks.picker.actions.cycle_win()| + - Snacks.picker.actions.edit()|snacks-picker-snacks.picker.actions-snacks.picker.actions.edit()| + - Snacks.picker.actions.edit_split()|snacks-picker-snacks.picker.actions-snacks.picker.actions.edit_split()| + - Snacks.picker.actions.edit_tab()|snacks-picker-snacks.picker.actions-snacks.picker.actions.edit_tab()| + - Snacks.picker.actions.edit_vsplit()|snacks-picker-snacks.picker.actions-snacks.picker.actions.edit_vsplit()| + - Snacks.picker.actions.focus_input()|snacks-picker-snacks.picker.actions-snacks.picker.actions.focus_input()| + - Snacks.picker.actions.focus_list()|snacks-picker-snacks.picker.actions-snacks.picker.actions.focus_list()| + - Snacks.picker.actions.focus_preview()|snacks-picker-snacks.picker.actions-snacks.picker.actions.focus_preview()| + - Snacks.picker.actions.help()|snacks-picker-snacks.picker.actions-snacks.picker.actions.help()| + - Snacks.picker.actions.history_back()|snacks-picker-snacks.picker.actions-snacks.picker.actions.history_back()| + - Snacks.picker.actions.history_forward()|snacks-picker-snacks.picker.actions-snacks.picker.actions.history_forward()| + - Snacks.picker.actions.list_bottom()|snacks-picker-snacks.picker.actions-snacks.picker.actions.list_bottom()| + - Snacks.picker.actions.list_down()|snacks-picker-snacks.picker.actions-snacks.picker.actions.list_down()| + - Snacks.picker.actions.list_scroll_bottom()|snacks-picker-snacks.picker.actions-snacks.picker.actions.list_scroll_bottom()| + - Snacks.picker.actions.list_scroll_center()|snacks-picker-snacks.picker.actions-snacks.picker.actions.list_scroll_center()| + - Snacks.picker.actions.list_scroll_down()|snacks-picker-snacks.picker.actions-snacks.picker.actions.list_scroll_down()| + - Snacks.picker.actions.list_scroll_top()|snacks-picker-snacks.picker.actions-snacks.picker.actions.list_scroll_top()| + - Snacks.picker.actions.list_scroll_up()|snacks-picker-snacks.picker.actions-snacks.picker.actions.list_scroll_up()| + - Snacks.picker.actions.list_scroll_wheel_down()|snacks-picker-snacks.picker.actions-snacks.picker.actions.list_scroll_wheel_down()| + - Snacks.picker.actions.list_scroll_wheel_up()|snacks-picker-snacks.picker.actions-snacks.picker.actions.list_scroll_wheel_up()| + - Snacks.picker.actions.list_top()|snacks-picker-snacks.picker.actions-snacks.picker.actions.list_top()| + - Snacks.picker.actions.list_up()|snacks-picker-snacks.picker.actions-snacks.picker.actions.list_up()| + - Snacks.picker.actions.load_session()|snacks-picker-snacks.picker.actions-snacks.picker.actions.load_session()| + - Snacks.picker.actions.loclist()|snacks-picker-snacks.picker.actions-snacks.picker.actions.loclist()| + - Snacks.picker.actions.preview_scroll_down()|snacks-picker-snacks.picker.actions-snacks.picker.actions.preview_scroll_down()| + - Snacks.picker.actions.preview_scroll_up()|snacks-picker-snacks.picker.actions-snacks.picker.actions.preview_scroll_up()| + - Snacks.picker.actions.qflist()|snacks-picker-snacks.picker.actions-snacks.picker.actions.qflist()| + - Snacks.picker.actions.search()|snacks-picker-snacks.picker.actions-snacks.picker.actions.search()| + - Snacks.picker.actions.select_and_next()|snacks-picker-snacks.picker.actions-snacks.picker.actions.select_and_next()| + - Snacks.picker.actions.select_and_prev()|snacks-picker-snacks.picker.actions-snacks.picker.actions.select_and_prev()| + - Snacks.picker.actions.toggle_focus()|snacks-picker-snacks.picker.actions-snacks.picker.actions.toggle_focus()| + - Snacks.picker.actions.toggle_hidden()|snacks-picker-snacks.picker.actions-snacks.picker.actions.toggle_hidden()| + - Snacks.picker.actions.toggle_ignored()|snacks-picker-snacks.picker.actions-snacks.picker.actions.toggle_ignored()| + - Snacks.picker.actions.toggle_live()|snacks-picker-snacks.picker.actions-snacks.picker.actions.toggle_live()| + - Snacks.picker.actions.toggle_maximize()|snacks-picker-snacks.picker.actions-snacks.picker.actions.toggle_maximize()| + - Snacks.picker.actions.toggle_preview()|snacks-picker-snacks.picker.actions-snacks.picker.actions.toggle_preview()| +9. snacks.picker.core.picker |snacks-picker-snacks.picker.core.picker| + - picker:action() |snacks-picker-snacks.picker.core.picker-picker:action()| + - picker:close() |snacks-picker-snacks.picker.core.picker-picker:close()| + - picker:count() |snacks-picker-snacks.picker.core.picker-picker:count()| + - picker:current()|snacks-picker-snacks.picker.core.picker-picker:current()| + - picker:empty() |snacks-picker-snacks.picker.core.picker-picker:empty()| + - picker:filter() |snacks-picker-snacks.picker.core.picker-picker:filter()| + - picker:find() |snacks-picker-snacks.picker.core.picker-picker:find()| + - picker:hist() |snacks-picker-snacks.picker.core.picker-picker:hist()| + - picker:is_active()|snacks-picker-snacks.picker.core.picker-picker:is_active()| + - picker:items() |snacks-picker-snacks.picker.core.picker-picker:items()| + - picker:iter() |snacks-picker-snacks.picker.core.picker-picker:iter()| + - picker:match() |snacks-picker-snacks.picker.core.picker-picker:match()| + - picker:selected()|snacks-picker-snacks.picker.core.picker-picker:selected()| + - picker:set_layout()|snacks-picker-snacks.picker.core.picker-picker:set_layout()| + - picker:word() |snacks-picker-snacks.picker.core.picker-picker:word()| + +============================================================================== +1. Setup *snacks-picker-setup* + +>lua + -- lazy.nvim + { + "folke/snacks.nvim", + ---@type snacks.Config + opts = { + picker = { + -- your picker configuration comes here + -- or leave it empty to use the default settings + -- refer to the configuration section below + } + } + } +< + + +============================================================================== +2. Config *snacks-picker-config* + +>lua + ---@class snacks.picker.Config + ---@field source? string source name and config to use + ---@field pattern? string|fun(picker:snacks.Picker):string pattern used to filter items by the matcher + ---@field search? string|fun(picker:snacks.Picker):string search string used by finders + ---@field cwd? string current working directory + ---@field live? boolean when true, typing will trigger live searches + ---@field limit? number when set, the finder will stop after finding this number of items. useful for live searches + ---@field ui_select? boolean set `vim.ui.select` to a snacks picker + --- Source definition + ---@field items? snacks.picker.finder.Item[] items to show instead of using a finder + ---@field format? snacks.picker.format|string format function or preset + ---@field finder? snacks.picker.finder|string finder function or preset + ---@field preview? snacks.picker.preview|string preview function or preset + ---@field matcher? snacks.picker.matcher.Config matcher config + ---@field sort? snacks.picker.sort|snacks.picker.sort.Config sort function or config + --- UI + ---@field win? snacks.picker.win.Config + ---@field layout? snacks.picker.layout.Config|string|{}|fun(source:string):(snacks.picker.layout.Config|string) + ---@field icons? snacks.picker.icons + ---@field prompt? string prompt text / icon + --- Preset options + ---@field previewers? snacks.picker.preview.Config + ---@field sources? snacks.picker.sources.Config|{} + ---@field layouts? table + --- Actions + ---@field actions? table actions used by keymaps + ---@field confirm? snacks.picker.Action.spec shortcut for confirm action + ---@field auto_confirm? boolean automatically confirm if there is only one item + ---@field main? snacks.picker.main.Config main editor window config + ---@field on_change? fun(picker:snacks.Picker, item:snacks.picker.Item) called when the cursor changes + ---@field on_show? fun(picker:snacks.Picker) called when the picker is shown + { + prompt = " ", + sources = {}, + layout = { + cycle = true, + preset = function() + return vim.o.columns >= 120 and "default" or "vertical" + end, + }, + ui_select = true, -- replace `vim.ui.select` with the snacks picker + previewers = { + file = { + max_size = 1024 * 1024, -- 1MB + max_line_length = 500, + }, + }, + win = { + list = { + keys = { + [""] = "confirm", + ["gg"] = "list_top", + ["G"] = "list_bottom", + ["i"] = "focus_input", + ["j"] = "list_down", + ["k"] = "list_up", + ["q"] = "close", + [""] = "select_and_next", + [""] = "select_and_prev", + [""] = "list_down", + [""] = "list_up", + [""] = "list_scroll_down", + [""] = "list_scroll_up", + ["zt"] = "list_scroll_top", + ["zb"] = "list_scroll_bottom", + ["zz"] = "list_scroll_center", + ["/"] = "toggle_focus", + [""] = "list_scroll_wheel_down", + [""] = "list_scroll_wheel_up", + [""] = "preview_scroll_down", + [""] = "preview_scroll_up", + [""] = "edit_vsplit", + [""] = "edit_split", + [""] = "list_down", + [""] = "list_up", + [""] = "list_down", + [""] = "list_up", + [""] = "cycle_win", + [""] = "close", + }, + }, + input = { + keys = { + [""] = "close", + [""] = "confirm", + ["G"] = "list_bottom", + ["gg"] = "list_top", + ["j"] = "list_down", + ["k"] = "list_up", + ["/"] = "toggle_focus", + ["q"] = "close", + ["?"] = "toggle_help", + [""] = { "toggle_maximize", mode = { "i", "n" } }, + [""] = { "toggle_preview", mode = { "i", "n" } }, + [""] = { "cycle_win", mode = { "i", "n" } }, + [""] = { "", mode = { "i" }, expr = true, desc = "delete word" }, + [""] = { "history_back", mode = { "i", "n" } }, + [""] = { "history_forward", mode = { "i", "n" } }, + [""] = { "select_and_next", mode = { "i", "n" } }, + [""] = { "select_and_prev", mode = { "i", "n" } }, + [""] = { "list_down", mode = { "i", "n" } }, + [""] = { "list_up", mode = { "i", "n" } }, + [""] = { "list_down", mode = { "i", "n" } }, + [""] = { "list_up", mode = { "i", "n" } }, + [""] = { "list_down", mode = { "i", "n" } }, + [""] = { "list_up", mode = { "i", "n" } }, + [""] = { "preview_scroll_up", mode = { "i", "n" } }, + [""] = { "list_scroll_down", mode = { "i", "n" } }, + [""] = { "preview_scroll_down", mode = { "i", "n" } }, + [""] = { "toggle_live", mode = { "i", "n" } }, + [""] = { "list_scroll_up", mode = { "i", "n" } }, + [""] = { "list_scroll_wheel_down", mode = { "i", "n" } }, + [""] = { "list_scroll_wheel_up", mode = { "i", "n" } }, + [""] = { "edit_vsplit", mode = { "i", "n" } }, + [""] = { "edit_split", mode = { "i", "n" } }, + [""] = { "qflist", mode = { "i", "n" } }, + [""] = { "toggle_ignored", mode = { "i", "n" } }, + [""] = { "toggle_hidden", mode = { "i", "n" } }, + }, + b = { + minipairs_disable = true, + }, + }, + preview = { + minimal = false, + wo = { + cursorline = false, + colorcolumn = "", + }, + keys = { + [""] = "close", + ["q"] = "close", + ["i"] = "focus_input", + [""] = "list_scroll_wheel_down", + [""] = "list_scroll_wheel_up", + [""] = "cycle_win", + }, + }, + }, + ---@class snacks.picker.icons + icons = { + indent = { + vertical = "│ ", + middle = "├╴", + last = "└╴", + }, + ui = { + live = "󰐰 ", + selected = "● ", + -- selected = " ", + }, + git = { + commit = "󰜘 ", + }, + diagnostics = { + Error = " ", + Warn = " ", + Hint = " ", + Info = " ", + }, + kinds = { + Array = " ", + Boolean = "󰨙 ", + Class = " ", + Color = " ", + Control = " ", + Collapsed = " ", + Constant = "󰏿 ", + Constructor = " ", + Copilot = " ", + Enum = " ", + EnumMember = " ", + Event = " ", + Field = " ", + File = " ", + Folder = " ", + Function = "󰊕 ", + Interface = " ", + Key = " ", + Keyword = " ", + Method = "󰊕 ", + Module = " ", + Namespace = "󰦮 ", + Null = " ", + Number = "󰎠 ", + Object = " ", + Operator = " ", + Package = " ", + Property = " ", + Reference = " ", + Snippet = "󱄽 ", + String = " ", + Struct = "󰆼 ", + Text = " ", + TypeParameter = " ", + Unit = " ", + Uknown = " ", + Value = " ", + Variable = "󰀫 ", + }, + }, + } +< + + +============================================================================== +3. Examples *snacks-picker-examples* + + +GENERAL *snacks-picker-examples-general* + +>lua + { + "folke/snacks.nvim", + opts = { + picker = {}, + }, + keys = { + { ",", function() Snacks.picker.buffers() end, desc = "Buffers" }, + { "/", function() Snacks.picker.grep() end, desc = "Grep" }, + { ":", function() Snacks.picker.command_history() end, desc = "Command History" }, + { "", function() Snacks.picker.files() end, desc = "Find Files" }, + -- find + { "fb", function() Snacks.picker.buffers() end, desc = "Buffers" }, + { "fc", function() Snacks.picker.files({ cwd = vim.fn.stdpath("config") }) end, desc = "Find Config File" }, + { "ff", function() Snacks.picker.files() end, desc = "Find Files" }, + { "fg", function() Snacks.picker.git_files() end, desc = "Find Git Files" }, + { "fr", function() Snacks.picker.recent() end, desc = "Recent" }, + -- git + { "gc", function() Snacks.picker.git_log() end, desc = "Git Log" }, + { "gs", function() Snacks.picker.git_status() end, desc = "Git Status" }, + -- Grep + { "sb", function() Snacks.picker.lines() end, desc = "Buffer Lines" }, + { "sB", function() Snacks.picker.grep_buffers() end, desc = "Grep Open Buffers" }, + { "sg", function() Snacks.picker.grep() end, desc = "Grep" }, + { "sw", function() Snacks.picker.grep_word() end, desc = "Visual selection or word", mode = { "n", "x" } }, + -- search + { 's"', function() Snacks.picker.registers() end, desc = "Registers" }, + { "sa", function() Snacks.picker.autocmds() end, desc = "Autocmds" }, + { "sc", function() Snacks.picker.command_history() end, desc = "Command History" }, + { "sC", function() Snacks.picker.commands() end, desc = "Commands" }, + { "sd", function() Snacks.picker.diagnostics() end, desc = "Diagnostics" }, + { "sh", function() Snacks.picker.help() end, desc = "Help Pages" }, + { "sH", function() Snacks.picker.highlights() end, desc = "Highlights" }, + { "sj", function() Snacks.picker.jumps() end, desc = "Jumps" }, + { "sk", function() Snacks.picker.keymaps() end, desc = "Keymaps" }, + { "sl", function() Snacks.picker.loclist() end, desc = "Location List" }, + { "sM", function() Snacks.picker.man() end, desc = "Man Pages" }, + { "sm", function() Snacks.picker.marks() end, desc = "Marks" }, + { "sR", function() Snacks.picker.resume() end, desc = "Resume" }, + { "sq", function() Snacks.picker.qflist() end, desc = "Quickfix List" }, + { "uC", function() Snacks.picker.colorschemes() end, desc = "Colorschemes" }, + { "qp", function() Snacks.picker.projects() end, desc = "Projects" }, + -- LSP + { "gd", function() Snacks.picker.lsp_definitions() end, desc = "Goto Definition" }, + { "gr", function() Snacks.picker.lsp_references() end, nowait = true, desc = "References" }, + { "gI", function() Snacks.picker.lsp_implementations() end, desc = "Goto Implementation" }, + { "gy", function() Snacks.picker.lsp_type_definitions() end, desc = "Goto T[y]pe Definition" }, + { "ss", function() Snacks.picker.lsp_symbols() end, desc = "LSP Symbols" }, + }, + } +< + + +TODO_COMMENTS *snacks-picker-examples-todo_comments* + +>lua + { + "folke/todo-comments.nvim", + optional = true, + keys = { + { "st", function() Snacks.picker.todo_comments() end, desc = "Todo" }, + { "sT", function () Snacks.picker.todo_comments({ keywords = { "TODO", "FIX", "FIXME" } }) end, desc = "Todo/Fix/Fixme" }, + }, + } +< + + +TROUBLE *snacks-picker-examples-trouble* + +>lua + { + "folke/trouble.nvim", + optional = true, + specs = { + "folke/snacks.nvim", + opts = function(_, opts) + return vim.tbl_deep_extend("force", opts or {}, { + picker = { + actions = require("trouble.sources.snacks").actions, + win = { + input = { + keys = { + [""] = { + "trouble_open", + mode = { "n", "i" }, + }, + }, + }, + }, + }, + }) + end, + }, + } +< + + +============================================================================== +4. Types *snacks-picker-types* + +>lua + ---@class snacks.picker.Last + ---@field opts snacks.picker.Config + ---@field selected snacks.picker.Item[] + ---@field filter snacks.picker.Filter +< + +>lua + ---@alias snacks.picker.Extmark vim.api.keyset.set_extmark|{col:number, row?:number} + ---@alias snacks.picker.Text {[1]:string, [2]:string?, virtual?:boolean} + ---@alias snacks.picker.Highlight snacks.picker.Text|snacks.picker.Extmark + ---@alias snacks.picker.format fun(item:snacks.picker.Item, picker:snacks.Picker):snacks.picker.Highlight[] + ---@alias snacks.picker.preview fun(ctx: snacks.picker.preview.ctx):boolean? + ---@alias snacks.picker.sort fun(a:snacks.picker.Item, b:snacks.picker.Item):boolean +< + +>lua + ---@class snacks.picker.finder.Item: snacks.picker.Item + ---@field idx? number + ---@field score? number +< + +Generic filter used by finders to pre-filter items + +>lua + ---@class snacks.picker.filter.Config + ---@field cwd? boolean|string only show files for the given cwd + ---@field buf? boolean|number only show items for the current or given buffer + ---@field paths? table only show items that include or exclude the given paths + ---@field filter? fun(item:snacks.picker.finder.Item):boolean custom filter function +< + +>lua + ---@class snacks.picker.Item + ---@field [string] any + ---@field idx number + ---@field score number + ---@field match_tick? number + ---@field text string + ---@field pos? {[1]:number, [2]:number} + ---@field end_pos? {[1]:number, [2]:number} + ---@field highlights? snacks.picker.Highlight[][] +< + +>lua + ---@class snacks.picker.sources.Config +< + +>lua + ---@class snacks.picker.preview.Config + ---@field man_pager? string MANPAGER env to use for `man` preview + ---@field file snacks.picker.preview.file.Config +< + +>lua + ---@class snacks.picker.preview.file.Config + ---@field max_size? number default 1MB + ---@field max_line_length? number defaults to 500 + ---@field ft? string defaults to auto-detect +< + +>lua + ---@class snacks.picker.layout.Config + ---@field layout snacks.layout.Box + ---@field reverse? boolean when true, the list will be reversed (bottom-up) + ---@field fullscreen? boolean open in fullscreen + ---@field cycle? boolean cycle through the list + ---@field preview? boolean|"main" show preview window in the picker or the main window + ---@field preset? string|fun(source:string):string +< + +>lua + ---@class snacks.picker.win.Config + ---@field input? snacks.win.Config|{} + ---@field list? snacks.win.Config|{} + ---@field preview? snacks.win.Config|{} +< + + +============================================================================== +5. Module *snacks-picker-module* + +>lua + ---@class snacks.picker + ---@field actions snacks.picker.actions + ---@field config snacks.picker.config + ---@field format snacks.picker.formatters + ---@field preview snacks.picker.previewers + ---@field sort snacks.picker.sorters + ---@field util snacks.picker.util + ---@field current? snacks.Picker + ---@field highlight snacks.picker.highlight + ---@field resume fun(opts?: snacks.picker.Config):snacks.Picker + ---@field sources snacks.picker.sources.Config + Snacks.picker = {} +< + + +`Snacks.picker()` *Snacks.picker()* + +>lua + ---@type fun(source: string, opts: snacks.picker.Config): snacks.Picker + Snacks.picker() +< + +>lua + ---@type fun(opts: snacks.picker.Config): snacks.Picker + Snacks.picker() +< + + +`Snacks.picker.pick()` *Snacks.picker.pick()* + +Create a new picker + +>lua + ---@param source? string + ---@param opts? snacks.picker.Config + ---@overload fun(opts: snacks.picker.Config): snacks.Picker + Snacks.picker.pick(source, opts) +< + + +`Snacks.picker.select()` *Snacks.picker.select()* + +Implementation for `vim.ui.select` + +>lua + ---@type snacks.picker.ui_select + Snacks.picker.select(...) +< + + +============================================================================== +6. Sources *snacks-picker-sources* + + +AUTOCMDS *snacks-picker-sources-autocmds* + +>lua + { + finder = "vim_autocmds", + format = "autocmd", + preview = "preview", + } +< + + +BUFFERS *snacks-picker-sources-buffers* + +>lua + ---@class snacks.picker.buffers.Config: snacks.picker.Config + ---@field hidden? boolean show hidden buffers (unlisted) + ---@field unloaded? boolean show loaded buffers + ---@field current? boolean show current buffer + ---@field nofile? boolean show `buftype=nofile` buffers + ---@field sort_lastused? boolean sort by last used + ---@field filter? snacks.picker.filter.Config + { + finder = "buffers", + format = "buffer", + hidden = false, + unloaded = true, + current = true, + sort_lastused = true, + } +< + + +CLIPHIST *snacks-picker-sources-cliphist* + +>lua + { + finder = "system_cliphist", + format = "text", + preview = "preview", + confirm = { "copy", "close" }, + } +< + + +COLORSCHEMES *snacks-picker-sources-colorschemes* + +Neovim colorschemes with live preview + +>lua + { + finder = "vim_colorschemes", + format = "text", + preview = "colorscheme", + preset = "vertical", + confirm = function(picker, item) + picker:close() + if item then + picker.preview.state.colorscheme = nil + vim.schedule(function() + vim.cmd("colorscheme " .. item.text) + end) + end + end, + } +< + + +COMMAND_HISTORY *snacks-picker-sources-command_history* + +Neovim command history + +>lua + ---@type snacks.picker.history.Config + { + finder = "vim_history", + name = "cmd", + format = "text", + preview = "none", + layout = { + preset = "vscode", + }, + confirm = "cmd", + } +< + + +COMMANDS *snacks-picker-sources-commands* + +Neovim commands + +>lua + { + finder = "vim_commands", + format = "text", + preview = "preview", + confirm = "cmd", + } +< + + +DIAGNOSTICS *snacks-picker-sources-diagnostics* + +>lua + ---@class snacks.picker.diagnostics.Config: snacks.picker.Config + ---@field filter? snacks.picker.filter.Config + ---@field severity? vim.diagnostic.SeverityFilter + { + finder = "diagnostics", + format = "diagnostic", + sort = { + fields = { + "is_current", + "is_cwd", + "severity", + "file", + "lnum", + }, + }, + -- only show diagnostics from the cwd by default + filter = { cwd = true }, + } +< + + +DIAGNOSTICS_BUFFER *snacks-picker-sources-diagnostics_buffer* + +>lua + ---@type snacks.picker.diagnostics.Config + { + finder = "diagnostics", + format = "diagnostic", + sort = { + fields = { "severity", "file", "lnum" }, + }, + filter = { buf = true }, + } +< + + +FILES *snacks-picker-sources-files* + +>lua + ---@class snacks.picker.files.Config: snacks.picker.proc.Config + ---@field cmd? string + ---@field hidden? boolean show hidden files + ---@field ignored? boolean show ignored files + ---@field dirs? string[] directories to search + ---@field follow? boolean follow symlinks + { + finder = "files", + format = "file", + hidden = false, + ignored = false, + follow = false, + supports_live = true, + } +< + + +GIT_FILES *snacks-picker-sources-git_files* + +Find git files + +>lua + ---@class snacks.picker.git.files.Config: snacks.picker.Config + ---@field untracked? boolean show untracked files + ---@field submodules? boolean show submodule files + { + finder = "git_files", + format = "file", + untracked = false, + submodules = false, + } +< + + +GIT_LOG *snacks-picker-sources-git_log* + +Git log + +>lua + ---@class snacks.picker.git.log.Config: snacks.picker.Config + ---@field follow? boolean track file history across renames + ---@field current_file? boolean show current file log + ---@field current_line? boolean show current line log + { + finder = "git_log", + format = "git_log", + preview = "git_show", + confirm = "close", + } +< + + +GIT_LOG_FILE *snacks-picker-sources-git_log_file* + +>lua + ---@type snacks.picker.git.log.Config + { + finder = "git_log", + format = "git_log", + preview = "git_show", + current_file = true, + follow = true, + confirm = "close", + } +< + + +GIT_LOG_LINE *snacks-picker-sources-git_log_line* + +>lua + ---@type snacks.picker.git.log.Config + { + finder = "git_log", + format = "git_log", + preview = "git_show", + current_line = true, + follow = true, + confirm = "close", + } +< + + +GIT_STATUS *snacks-picker-sources-git_status* + +>lua + { + finder = "git_status", + format = "git_status", + preview = "git_status", + } +< + + +GREP *snacks-picker-sources-grep* + +>lua + ---@class snacks.picker.grep.Config: snacks.picker.proc.Config + ---@field cmd? string + ---@field hidden? boolean show hidden files + ---@field ignored? boolean show ignored files + ---@field dirs? string[] directories to search + ---@field follow? boolean follow symlinks + ---@field glob? string|string[] glob file pattern(s) + ---@field buffers? boolean search in open buffers + ---@field need_search? boolean require a search pattern + { + finder = "grep", + format = "file", + live = true, -- live grep by default + supports_live = true, + } +< + + +GREP_BUFFERS *snacks-picker-sources-grep_buffers* + +>lua + ---@type snacks.picker.grep.Config + { + finder = "grep", + format = "file", + live = true, + buffers = true, + need_search = false, + supports_live = true, + } +< + + +GREP_WORD *snacks-picker-sources-grep_word* + +>lua + ---@type snacks.picker.grep.Config + { + finder = "grep", + format = "file", + search = function(picker) + return picker:word() + end, + live = false, + supports_live = true, + } +< + + +HELP *snacks-picker-sources-help* + +Neovim help tags + +>lua + ---@class snacks.picker.help.Config: snacks.picker.Config + ---@field lang? string[] defaults to `vim.opt.helplang` + { + finder = "help", + format = "text", + previewers = { + file = { ft = "help" }, + }, + win = { + preview = { + minimal = true, + }, + }, + confirm = "help", + } +< + + +HIGHLIGHTS *snacks-picker-sources-highlights* + +>lua + { + finder = "vim_highlights", + format = "hl", + preview = "preview", + } +< + + +JUMPS *snacks-picker-sources-jumps* + +>lua + { + finder = "vim_jumps", + format = "file", + } +< + + +KEYMAPS *snacks-picker-sources-keymaps* + +>lua + ---@class snacks.picker.keymaps.Config: snacks.picker.Config + ---@field global? boolean show global keymaps + ---@field local? boolean show buffer keymaps + ---@field modes? string[] + { + finder = "vim_keymaps", + format = "keymap", + preview = "preview", + global = true, + ["local"] = true, + modes = { "n", "v", "x", "s", "o", "i", "c", "t" }, + confirm = function(picker, item) + picker:close() + if item then + vim.api.nvim_input(item.item.lhs) + end + end, + } +< + + +LINES *snacks-picker-sources-lines* + +Search lines in the current buffer + +>lua + ---@class snacks.picker.lines.Config: snacks.picker.Config + ---@field buf? number + { + finder = "lines", + format = "lines", + layout = { + preview = "main", + preset = "ivy", + }, + -- allow any window to be used as the main window + main = { current = true }, + ---@param picker snacks.Picker + on_show = function(picker) + local cursor = vim.api.nvim_win_get_cursor(picker.main) + local info = vim.api.nvim_win_call(picker.main, vim.fn.winsaveview) + picker.list:view(cursor[1], info.topline) + picker:show_preview() + end, + } +< + + +LOCLIST *snacks-picker-sources-loclist* + +Loclist + +>lua + ---@type snacks.picker.qf.Config + { + finder = "qf", + format = "file", + qf_win = 0, + } +< + + +LSP_DECLARATIONS *snacks-picker-sources-lsp_declarations* + +LSP declarations + +>lua + ---@type snacks.picker.lsp.Config + { + finder = "lsp_declarations", + format = "file", + include_current = false, + auto_confirm = true, + } +< + + +LSP_DEFINITIONS *snacks-picker-sources-lsp_definitions* + +LSP definitions + +>lua + ---@type snacks.picker.lsp.Config + { + finder = "lsp_definitions", + format = "file", + include_current = false, + auto_confirm = true, + } +< + + +LSP_IMPLEMENTATIONS *snacks-picker-sources-lsp_implementations* + +LSP implementations + +>lua + ---@type snacks.picker.lsp.Config + { + finder = "lsp_implementations", + format = "file", + include_current = false, + auto_confirm = true, + } +< + + +LSP_REFERENCES *snacks-picker-sources-lsp_references* + +LSP references + +>lua + ---@class snacks.picker.lsp.references.Config: snacks.picker.lsp.Config + ---@field include_declaration? boolean default true + { + finder = "lsp_references", + format = "file", + include_declaration = true, + include_current = false, + auto_confirm = true, + } +< + + +LSP_SYMBOLS *snacks-picker-sources-lsp_symbols* + +LSP document symbols + +>lua + ---@class snacks.picker.lsp.symbols.Config: snacks.picker.Config + ---@field hierarchy? boolean show symbol hierarchy + ---@field filter table? symbol kind filter + { + finder = "lsp_symbols", + format = "lsp_symbol", + hierarchy = true, + filter = { + default = { + "Class", + "Constructor", + "Enum", + "Field", + "Function", + "Interface", + "Method", + "Module", + "Namespace", + "Package", + "Property", + "Struct", + "Trait", + }, + -- set to `true` to include all symbols + markdown = true, + help = true, + -- you can specify a different filter for each filetype + lua = { + "Class", + "Constructor", + "Enum", + "Field", + "Function", + "Interface", + "Method", + "Module", + "Namespace", + -- "Package", -- remove package since luals uses it for control flow structures + "Property", + "Struct", + "Trait", + }, + }, + } +< + + +LSP_TYPE_DEFINITIONS *snacks-picker-sources-lsp_type_definitions* + +LSP type definitions + +>lua + ---@type snacks.picker.lsp.Config + { + finder = "lsp_type_definitions", + format = "file", + include_current = false, + auto_confirm = true, + } +< + + +MAN *snacks-picker-sources-man* + +>lua + { + finder = "system_man", + format = "man", + preview = "man", + confirm = function(picker, item) + picker:close() + if item then + vim.schedule(function() + vim.cmd("Man " .. item.ref) + end) + end + end, + } +< + + +MARKS *snacks-picker-sources-marks* + +>lua + ---@class snacks.picker.marks.Config: snacks.picker.Config + ---@field global? boolean show global marks + ---@field local? boolean show buffer marks + { + finder = "vim_marks", + format = "file", + global = true, + ["local"] = true, + } +< + + +PICKER_ACTIONS *snacks-picker-sources-picker_actions* + +>lua + { + finder = "meta_actions", + format = "text", + } +< + + +PICKER_FORMAT *snacks-picker-sources-picker_format* + +>lua + { + finder = "meta_format", + format = "text", + } +< + + +PICKER_LAYOUTS *snacks-picker-sources-picker_layouts* + +>lua + { + finder = "meta_layouts", + format = "text", + on_change = function(picker, item) + vim.schedule(function() + picker:set_layout(item.text) + end) + end, + } +< + + +PICKER_PREVIEW *snacks-picker-sources-picker_preview* + +>lua + { + finder = "meta_preview", + format = "text", + } +< + + +PICKERS *snacks-picker-sources-pickers* + +List all available sources + +>lua + { + finder = "meta_pickers", + format = "text", + confirm = function(picker, item) + picker:close() + if item then + Snacks.picker(item.text) + end + end, + } +< + + +PROJECTS *snacks-picker-sources-projects* + +Open recent projects + +>lua + ---@class snacks.picker.projects.Config: snacks.picker.Config + ---@field filter? snacks.picker.filter.Config + { + finder = "recent_projects", + format = "file", + confirm = "load_session", + win = { + preview = { + minimal = true, + }, + }, + } +< + + +QFLIST *snacks-picker-sources-qflist* + +Quickfix list + +>lua + ---@type snacks.picker.qf.Config + { + finder = "qf", + format = "file", + } +< + + +RECENT *snacks-picker-sources-recent* + +Find recent files + +>lua + ---@class snacks.picker.recent.Config: snacks.picker.Config + ---@field filter? snacks.picker.filter.Config + { + finder = "recent_files", + format = "file", + filter = { + paths = { + [vim.fn.stdpath("data")] = false, + [vim.fn.stdpath("cache")] = false, + [vim.fn.stdpath("state")] = false, + }, + }, + } +< + + +REGISTERS *snacks-picker-sources-registers* + +Neovim registers + +>lua + { + finder = "vim_registers", + format = "register", + preview = "preview", + confirm = { "copy", "close" }, + } +< + + +RESUME *snacks-picker-sources-resume* + +Special picker that resumes the last picker + +>lua + {} +< + + +SEARCH_HISTORY *snacks-picker-sources-search_history* + +Neovim search history + +>lua + ---@type snacks.picker.history.Config + { + finder = "vim_history", + name = "search", + format = "text", + preview = "none", + layout = { + preset = "vscode", + }, + confirm = "search", + } +< + + +ZOXIDE *snacks-picker-sources-zoxide* + +Open a project from zoxide + +>lua + { + finder = "files_zoxide", + format = "file", + confirm = "load_session", + win = { + preview = { + minimal = true, + }, + }, + } +< + + +============================================================================== +7. Layouts *snacks-picker-layouts* + + +DEFAULT *snacks-picker-layouts-default* + +>lua + { + layout = { + box = "horizontal", + width = 0.8, + min_width = 120, + height = 0.8, + { + box = "vertical", + border = "rounded", + title = "{source} {live}", + title_pos = "center", + { win = "input", height = 1, border = "bottom" }, + { win = "list", border = "none" }, + }, + { win = "preview", border = "rounded", width = 0.5 }, + }, + } +< + + +DROPDOWN *snacks-picker-layouts-dropdown* + +>lua + { + layout = { + backdrop = false, + row = 1, + width = 0.4, + min_width = 80, + height = 0.8, + border = "none", + box = "vertical", + { win = "preview", height = 0.4, border = "rounded" }, + { + box = "vertical", + border = "rounded", + title = "{source} {live}", + title_pos = "center", + { win = "input", height = 1, border = "bottom" }, + { win = "list", border = "none" }, + }, + }, + } +< + + +IVY *snacks-picker-layouts-ivy* + +>lua + { + layout = { + box = "vertical", + backdrop = false, + row = -1, + width = 0, + height = 0.4, + border = "top", + title = " {source} {live}", + title_pos = "left", + { win = "input", height = 1, border = "bottom" }, + { + box = "horizontal", + { win = "list", border = "none" }, + { win = "preview", width = 0.6, border = "left" }, + }, + }, + } +< + + +SELECT *snacks-picker-layouts-select* + +>lua + { + preview = false, + layout = { + backdrop = false, + width = 0.5, + min_width = 80, + height = 0.4, + min_height = 10, + box = "vertical", + border = "rounded", + title = " Select ", + title_pos = "center", + { win = "input", height = 1, border = "bottom" }, + { win = "list", border = "none" }, + { win = "preview", height = 0.4, border = "top" }, + }, + } +< + + +TELESCOPE *snacks-picker-layouts-telescope* + +>lua + { + reverse = true, + layout = { + box = "horizontal", + backdrop = false, + width = 0.8, + height = 0.9, + border = "none", + { + box = "vertical", + { win = "list", title = " Results ", title_pos = "center", border = "rounded" }, + { win = "input", height = 1, border = "rounded", title = "{source} {live}", title_pos = "center" }, + }, + { + win = "preview", + width = 0.45, + border = "rounded", + title = " Preview ", + title_pos = "center", + }, + }, + } +< + + +VERTICAL *snacks-picker-layouts-vertical* + +>lua + { + layout = { + backdrop = false, + width = 0.5, + min_width = 80, + height = 0.8, + min_height = 30, + box = "vertical", + border = "rounded", + title = "{source} {live}", + title_pos = "center", + { win = "input", height = 1, border = "bottom" }, + { win = "list", border = "none" }, + { win = "preview", height = 0.4, border = "top" }, + }, + } +< + + +VSCODE *snacks-picker-layouts-vscode* + +>lua + { + preview = false, + layout = { + backdrop = false, + row = 1, + width = 0.4, + min_width = 80, + height = 0.4, + border = "none", + box = "vertical", + { win = "input", height = 1, border = "rounded", title = "{source} {live}", title_pos = "center" }, + { win = "list", border = "hpad" }, + { win = "preview", border = "rounded" }, + }, + } +< + + +============================================================================== +8. snacks.picker.actions *snacks-picker-snacks.picker.actions* + +>lua + ---@class snacks.picker.actions + ---@field [string] snacks.picker.Action.spec + local M = {} +< + + +SNACKS.PICKER.ACTIONS.CMD()*snacks-picker-snacks.picker.actions-snacks.picker.actions.cmd()* + +>lua + Snacks.picker.actions.cmd(picker, item) +< + + +SNACKS.PICKER.ACTIONS.COPY()*snacks-picker-snacks.picker.actions-snacks.picker.actions.copy()* + +>lua + Snacks.picker.actions.copy(_, item) +< + + +SNACKS.PICKER.ACTIONS.CYCLE_WIN()*snacks-picker-snacks.picker.actions-snacks.picker.actions.cycle_win()* + +>lua + Snacks.picker.actions.cycle_win(picker) +< + + +SNACKS.PICKER.ACTIONS.EDIT()*snacks-picker-snacks.picker.actions-snacks.picker.actions.edit()* + +>lua + Snacks.picker.actions.edit(picker) +< + + +SNACKS.PICKER.ACTIONS.EDIT_SPLIT()*snacks-picker-snacks.picker.actions-snacks.picker.actions.edit_split()* + +>lua + Snacks.picker.actions.edit_split(picker) +< + + +SNACKS.PICKER.ACTIONS.EDIT_TAB()*snacks-picker-snacks.picker.actions-snacks.picker.actions.edit_tab()* + +>lua + Snacks.picker.actions.edit_tab(picker) +< + + +SNACKS.PICKER.ACTIONS.EDIT_VSPLIT()*snacks-picker-snacks.picker.actions-snacks.picker.actions.edit_vsplit()* + +>lua + Snacks.picker.actions.edit_vsplit(picker) +< + + +SNACKS.PICKER.ACTIONS.FOCUS_INPUT()*snacks-picker-snacks.picker.actions-snacks.picker.actions.focus_input()* + +>lua + Snacks.picker.actions.focus_input(picker) +< + + +SNACKS.PICKER.ACTIONS.FOCUS_LIST()*snacks-picker-snacks.picker.actions-snacks.picker.actions.focus_list()* + +>lua + Snacks.picker.actions.focus_list(picker) +< + + +SNACKS.PICKER.ACTIONS.FOCUS_PREVIEW()*snacks-picker-snacks.picker.actions-snacks.picker.actions.focus_preview()* + +>lua + Snacks.picker.actions.focus_preview(picker) +< + + +SNACKS.PICKER.ACTIONS.HELP()*snacks-picker-snacks.picker.actions-snacks.picker.actions.help()* + +>lua + Snacks.picker.actions.help(picker) +< + + +SNACKS.PICKER.ACTIONS.HISTORY_BACK()*snacks-picker-snacks.picker.actions-snacks.picker.actions.history_back()* + +>lua + Snacks.picker.actions.history_back(picker) +< + + +SNACKS.PICKER.ACTIONS.HISTORY_FORWARD()*snacks-picker-snacks.picker.actions-snacks.picker.actions.history_forward()* + +>lua + Snacks.picker.actions.history_forward(picker) +< + + +SNACKS.PICKER.ACTIONS.LIST_BOTTOM()*snacks-picker-snacks.picker.actions-snacks.picker.actions.list_bottom()* + +>lua + Snacks.picker.actions.list_bottom(picker) +< + + +SNACKS.PICKER.ACTIONS.LIST_DOWN()*snacks-picker-snacks.picker.actions-snacks.picker.actions.list_down()* + +>lua + Snacks.picker.actions.list_down(picker) +< + + +SNACKS.PICKER.ACTIONS.LIST_SCROLL_BOTTOM()*snacks-picker-snacks.picker.actions-snacks.picker.actions.list_scroll_bottom()* + +>lua + Snacks.picker.actions.list_scroll_bottom(picker) +< + + +SNACKS.PICKER.ACTIONS.LIST_SCROLL_CENTER()*snacks-picker-snacks.picker.actions-snacks.picker.actions.list_scroll_center()* + +>lua + Snacks.picker.actions.list_scroll_center(picker) +< + + +SNACKS.PICKER.ACTIONS.LIST_SCROLL_DOWN()*snacks-picker-snacks.picker.actions-snacks.picker.actions.list_scroll_down()* + +>lua + Snacks.picker.actions.list_scroll_down(picker) +< + + +SNACKS.PICKER.ACTIONS.LIST_SCROLL_TOP()*snacks-picker-snacks.picker.actions-snacks.picker.actions.list_scroll_top()* + +>lua + Snacks.picker.actions.list_scroll_top(picker) +< + + +SNACKS.PICKER.ACTIONS.LIST_SCROLL_UP()*snacks-picker-snacks.picker.actions-snacks.picker.actions.list_scroll_up()* + +>lua + Snacks.picker.actions.list_scroll_up(picker) +< + + +SNACKS.PICKER.ACTIONS.LIST_SCROLL_WHEEL_DOWN()*snacks-picker-snacks.picker.actions-snacks.picker.actions.list_scroll_wheel_down()* + +>lua + Snacks.picker.actions.list_scroll_wheel_down(picker) +< + + +SNACKS.PICKER.ACTIONS.LIST_SCROLL_WHEEL_UP()*snacks-picker-snacks.picker.actions-snacks.picker.actions.list_scroll_wheel_up()* + +>lua + Snacks.picker.actions.list_scroll_wheel_up(picker) +< + + +SNACKS.PICKER.ACTIONS.LIST_TOP()*snacks-picker-snacks.picker.actions-snacks.picker.actions.list_top()* + +>lua + Snacks.picker.actions.list_top(picker) +< + + +SNACKS.PICKER.ACTIONS.LIST_UP()*snacks-picker-snacks.picker.actions-snacks.picker.actions.list_up()* + +>lua + Snacks.picker.actions.list_up(picker) +< + + +SNACKS.PICKER.ACTIONS.LOAD_SESSION()*snacks-picker-snacks.picker.actions-snacks.picker.actions.load_session()* + +Tries to load the session, if it fails, it will open the picker. + +>lua + Snacks.picker.actions.load_session(picker) +< + + +SNACKS.PICKER.ACTIONS.LOCLIST()*snacks-picker-snacks.picker.actions-snacks.picker.actions.loclist()* + +Send selected or all items to the location list. + +>lua + Snacks.picker.actions.loclist(picker) +< + + +SNACKS.PICKER.ACTIONS.PREVIEW_SCROLL_DOWN()*snacks-picker-snacks.picker.actions-snacks.picker.actions.preview_scroll_down()* + +>lua + Snacks.picker.actions.preview_scroll_down(picker) +< + + +SNACKS.PICKER.ACTIONS.PREVIEW_SCROLL_UP()*snacks-picker-snacks.picker.actions-snacks.picker.actions.preview_scroll_up()* + +>lua + Snacks.picker.actions.preview_scroll_up(picker) +< + + +SNACKS.PICKER.ACTIONS.QFLIST()*snacks-picker-snacks.picker.actions-snacks.picker.actions.qflist()* + +Send selected or all items to the quickfix list. + +>lua + Snacks.picker.actions.qflist(picker) +< + + +SNACKS.PICKER.ACTIONS.SEARCH()*snacks-picker-snacks.picker.actions-snacks.picker.actions.search()* + +>lua + Snacks.picker.actions.search(picker, item) +< + + +SNACKS.PICKER.ACTIONS.SELECT_AND_NEXT()*snacks-picker-snacks.picker.actions-snacks.picker.actions.select_and_next()* + +Toggles the selection of the current item, and moves the cursor to the next +item. + +>lua + Snacks.picker.actions.select_and_next(picker) +< + + +SNACKS.PICKER.ACTIONS.SELECT_AND_PREV()*snacks-picker-snacks.picker.actions-snacks.picker.actions.select_and_prev()* + +Toggles the selection of the current item, and moves the cursor to the prev +item. + +>lua + Snacks.picker.actions.select_and_prev(picker) +< + + +SNACKS.PICKER.ACTIONS.TOGGLE_FOCUS()*snacks-picker-snacks.picker.actions-snacks.picker.actions.toggle_focus()* + +>lua + Snacks.picker.actions.toggle_focus(picker) +< + + +SNACKS.PICKER.ACTIONS.TOGGLE_HIDDEN()*snacks-picker-snacks.picker.actions-snacks.picker.actions.toggle_hidden()* + +>lua + Snacks.picker.actions.toggle_hidden(picker) +< + + +SNACKS.PICKER.ACTIONS.TOGGLE_IGNORED()*snacks-picker-snacks.picker.actions-snacks.picker.actions.toggle_ignored()* + +>lua + Snacks.picker.actions.toggle_ignored(picker) +< + + +SNACKS.PICKER.ACTIONS.TOGGLE_LIVE()*snacks-picker-snacks.picker.actions-snacks.picker.actions.toggle_live()* + +>lua + Snacks.picker.actions.toggle_live(picker) +< + + +SNACKS.PICKER.ACTIONS.TOGGLE_MAXIMIZE()*snacks-picker-snacks.picker.actions-snacks.picker.actions.toggle_maximize()* + +>lua + Snacks.picker.actions.toggle_maximize(picker) +< + + +SNACKS.PICKER.ACTIONS.TOGGLE_PREVIEW()*snacks-picker-snacks.picker.actions-snacks.picker.actions.toggle_preview()* + +>lua + Snacks.picker.actions.toggle_preview(picker) +< + + +============================================================================== +9. snacks.picker.core.picker *snacks-picker-snacks.picker.core.picker* + +>lua + ---@class snacks.Picker + ---@field opts snacks.picker.Config + ---@field finder snacks.picker.Finder + ---@field format snacks.picker.format + ---@field input snacks.picker.input + ---@field layout snacks.layout + ---@field resolved_layout snacks.picker.layout.Config + ---@field list snacks.picker.list + ---@field matcher snacks.picker.Matcher + ---@field main number + ---@field preview snacks.picker.Preview + ---@field shown? boolean + ---@field sort snacks.picker.sort + ---@field updater uv.uv_timer_t + ---@field start_time number + ---@field source_name string + ---@field closed? boolean + ---@field hist_idx number + ---@field hist_cursor number + ---@field visual? snacks.picker.Visual + local M = {} +< + + +PICKER:ACTION() *snacks-picker-snacks.picker.core.picker-picker:action()* + +Execute the given action(s) + +>lua + ---@param actions string|string[] + picker:action(actions) +< + + +PICKER:CLOSE() *snacks-picker-snacks.picker.core.picker-picker:close()* + +Close the picker + +>lua + picker:close() +< + + +PICKER:COUNT() *snacks-picker-snacks.picker.core.picker-picker:count()* + +Total number of items in the picker + +>lua + picker:count() +< + + +PICKER:CURRENT() *snacks-picker-snacks.picker.core.picker-picker:current()* + +Get the current item at the cursor + +>lua + picker:current() +< + + +PICKER:EMPTY() *snacks-picker-snacks.picker.core.picker-picker:empty()* + +Check if the picker is empty + +>lua + picker:empty() +< + + +PICKER:FILTER() *snacks-picker-snacks.picker.core.picker-picker:filter()* + +Get the active filter + +>lua + picker:filter() +< + + +PICKER:FIND() *snacks-picker-snacks.picker.core.picker-picker:find()* + +Clear the list and run the finder and matcher + +>lua + ---@param opts? {on_done?: fun()} Callback when done + picker:find(opts) +< + + +PICKER:HIST() *snacks-picker-snacks.picker.core.picker-picker:hist()* + +Move the history cursor + +>lua + ---@param forward? boolean + picker:hist(forward) +< + + +PICKER:IS_ACTIVE()*snacks-picker-snacks.picker.core.picker-picker:is_active()* + +Check if the finder or matcher is running + +>lua + picker:is_active() +< + + +PICKER:ITEMS() *snacks-picker-snacks.picker.core.picker-picker:items()* + +Get all finder items + +>lua + picker:items() +< + + +PICKER:ITER() *snacks-picker-snacks.picker.core.picker-picker:iter()* + +Returns an iterator over the items in the picker. Items will be in sorted +order. + +>lua + ---@return fun():snacks.picker.Item? + picker:iter() +< + + +PICKER:MATCH() *snacks-picker-snacks.picker.core.picker-picker:match()* + +Run the matcher with the current pattern. May also trigger a new find if the +search string has changed, like during live searches. + +>lua + picker:match() +< + + +PICKER:SELECTED() *snacks-picker-snacks.picker.core.picker-picker:selected()* + +Get the selected items. If `fallback=true` and there is no selection, return +the current item. + +>lua + ---@param opts? {fallback?: boolean} default is `false` + picker:selected(opts) +< + + +PICKER:SET_LAYOUT()*snacks-picker-snacks.picker.core.picker-picker:set_layout()* + +Set the picker layout. Can be either the name of a preset layout or a custom +layout configuration. + +>lua + ---@param layout? string|snacks.picker.layout.Config + picker:set_layout(layout) +< + + +PICKER:WORD() *snacks-picker-snacks.picker.core.picker-picker:word()* + +Get the word under the cursor or the current visual selection + +>lua + picker:word() +< + +Generated by panvimdoc + +vim:tw=78:ts=8:noet:ft=help:norl: diff --git a/doc/snacks-scroll.txt b/doc/snacks-scroll.txt index adc3db4a..65955702 100644 --- a/doc/snacks-scroll.txt +++ b/doc/snacks-scroll.txt @@ -41,13 +41,19 @@ Similar plugins: >lua ---@class snacks.scroll.Config - ---@field animate snacks.animate.Config + ---@field animate snacks.animate.Config|{} + ---@field animate_repeat snacks.animate.Config|{}|{delay:number} { animate = { duration = { step = 15, total = 250 }, easing = "linear", }, - spamming = 10, -- threshold for spamming detection + -- faster animation when repeating scroll after delay + animate_repeat = { + delay = 100, -- delay in ms before using the repeat animation + duration = { step = 5, total = 50 }, + easing = "linear", + }, -- what buffers to animate filter = function(buf) return vim.g.snacks_scroll ~= false and vim.b[buf].snacks_scroll ~= false and vim.bo[buf].buftype ~= "terminal" @@ -73,6 +79,8 @@ Similar plugins: ---@field target vim.fn.winsaveview.ret ---@field scrolloff number ---@field virtualedit? string + ---@field changedtick number + ---@field last number vim.uv.hrtime of last scroll < diff --git a/doc/snacks-styles.txt b/doc/snacks-styles.txt index 591f3028..4abf4236 100644 --- a/doc/snacks-styles.txt +++ b/doc/snacks-styles.txt @@ -8,6 +8,7 @@ Table of Contents *snacks-styles-table-of-contents* - blame_line |snacks-styles-styles-blame_line| - dashboard |snacks-styles-styles-dashboard| - float |snacks-styles-styles-float| + - help |snacks-styles-styles-help| - input |snacks-styles-styles-input| - lazygit |snacks-styles-styles-lazygit| - minimal |snacks-styles-styles-minimal| @@ -116,6 +117,20 @@ FLOAT *snacks-styles-styles-float* < +HELP *snacks-styles-styles-help* + +>lua + { + position = "float", + backdrop = false, + border = "top", + row = -1, + width = 0, + height = 0.3, + } +< + + INPUT *snacks-styles-styles-input* >lua @@ -149,6 +164,7 @@ INPUT *snacks-styles-styles-input* i_esc = { "", { "cmp_close", "stopinsert" }, mode = "i", expr = true }, i_cr = { "", { "cmp_accept", "confirm" }, mode = "i", expr = true }, i_tab = { "", { "cmp_select_next", "cmp" }, mode = "i", expr = true }, + i_ctrl_w = { "", "", mode = "i", expr = true }, q = "cancel", }, } @@ -170,6 +186,7 @@ MINIMAL *snacks-styles-styles-minimal* cursorcolumn = false, cursorline = false, cursorlineopt = "both", + colorcolumn = "", fillchars = "eob: ,lastline:…", list = false, listchars = "extends:…,tab: ", diff --git a/doc/snacks-win.txt b/doc/snacks-win.txt index e23bee07..9c88b246 100644 --- a/doc/snacks-win.txt +++ b/doc/snacks-win.txt @@ -8,6 +8,7 @@ Table of Contents *snacks-win-table-of-contents* 3. Config |snacks-win-config| 4. Styles |snacks-win-styles| - float |snacks-win-styles-float| + - help |snacks-win-styles-help| - minimal |snacks-win-styles-minimal| - split |snacks-win-styles-split| 5. Types |snacks-win-types| @@ -21,6 +22,7 @@ Table of Contents *snacks-win-table-of-contents* - win:buf_valid() |snacks-win-module-win:buf_valid()| - win:close() |snacks-win-module-win:close()| - win:dim() |snacks-win-module-win:dim()| + - win:execute() |snacks-win-module-win:execute()| - win:focus() |snacks-win-module-win:focus()| - win:has_border() |snacks-win-module-win:has_border()| - win:hide() |snacks-win-module-win:hide()| @@ -28,13 +30,17 @@ Table of Contents *snacks-win-table-of-contents* - win:line() |snacks-win-module-win:line()| - win:lines() |snacks-win-module-win:lines()| - win:on() |snacks-win-module-win:on()| + - win:on_resize() |snacks-win-module-win:on_resize()| - win:parent_size() |snacks-win-module-win:parent_size()| - win:redraw() |snacks-win-module-win:redraw()| + - win:scratch() |snacks-win-module-win:scratch()| - win:scroll() |snacks-win-module-win:scroll()| + - win:set_title() |snacks-win-module-win:set_title()| - win:show() |snacks-win-module-win:show()| - win:size() |snacks-win-module-win:size()| - win:text() |snacks-win-module-win:text()| - win:toggle() |snacks-win-module-win:toggle()| + - win:toggle_help() |snacks-win-module-win:toggle_help()| - win:update() |snacks-win-module-win:update()| - win:valid() |snacks-win-module-win:valid()| - win:win_valid() |snacks-win-module-win:win_valid()| @@ -97,7 +103,7 @@ Easily create and manage floating windows or splits ---@field row? number|fun(self:snacks.win):number Row of the window. Use <1 for relative row. (default: center) ---@field minimal? boolean Disable a bunch of options to make the window minimal (default: true) ---@field position? "float"|"bottom"|"top"|"left"|"right" - ---@field border? "none"|"top"|"right"|"bottom"|"left"|"rounded"|"single"|"double"|"solid"|"shadow"|string[]|false + ---@field border? "none"|"top"|"right"|"bottom"|"left"|"hpad"|"vpad"|"rounded"|"single"|"double"|"solid"|"shadow"|string[]|false ---@field buf? number If set, use this buffer instead of creating a new one ---@field file? string If set, use this file instead of creating a new buffer ---@field enter? boolean Enter the window after opening (default: false) @@ -114,6 +120,7 @@ Easily create and manage floating windows or splits ---@field fixbuf? boolean don't allow other buffers to be opened in this window ---@field text? string|string[]|fun():(string[]|string) Initial lines to set in the buffer ---@field actions? table Actions that can be used in key mappings + ---@field resize? boolean Automatically resize the window when the editor is resized { show = true, fixbuf = true, @@ -152,6 +159,20 @@ FLOAT *snacks-win-styles-float* < +HELP *snacks-win-styles-help* + +>lua + { + position = "float", + backdrop = false, + border = "top", + row = -1, + width = 0, + height = 0.3, + } +< + + MINIMAL *snacks-win-styles-minimal* >lua @@ -160,6 +181,7 @@ MINIMAL *snacks-win-styles-minimal* cursorcolumn = false, cursorline = false, cursorlineopt = "both", + colorcolumn = "", fillchars = "eob: ,lastline:…", list = false, listchars = "extends:…,tab: ", @@ -201,7 +223,7 @@ SPLIT *snacks-win-styles-split* ---@class snacks.win.Event: vim.api.keyset.create_autocmd ---@field buf? true ---@field win? true - ---@field callback? fun(self: snacks.win) + ---@field callback? fun(self: snacks.win, ev:vim.api.keyset.create_autocmd.callback_args):boolean? < >lua @@ -237,12 +259,15 @@ SPLIT *snacks-win-styles-split* ---@class snacks.win ---@field id number ---@field buf? number + ---@field scratch_buf? number ---@field win? number ---@field opts snacks.win.Config ---@field augroup? number ---@field backdrop? snacks.win ---@field keys snacks.win.Keys[] ---@field events (snacks.win.Event|{event:string|string[]})[] + ---@field meta table + ---@field closed? boolean Snacks.win = {} < @@ -319,6 +344,14 @@ WIN:DIM() *snacks-win-module-win:dim()* < +WIN:EXECUTE() *snacks-win-module-win:execute()* + +>lua + ---@param actions string|string[] + win:execute(actions) +< + + WIN:FOCUS() *snacks-win-module-win:focus()* >lua @@ -367,12 +400,19 @@ WIN:ON() *snacks-win-module-win:on()* >lua ---@param event string|string[] - ---@param cb fun(self: snacks.win) + ---@param cb fun(self: snacks.win, ev:vim.api.keyset.create_autocmd.callback_args):boolean? ---@param opts? snacks.win.Event win:on(event, cb, opts) < +WIN:ON_RESIZE() *snacks-win-module-win:on_resize()* + +>lua + win:on_resize() +< + + WIN:PARENT_SIZE() *snacks-win-module-win:parent_size()* >lua @@ -388,6 +428,13 @@ WIN:REDRAW() *snacks-win-module-win:redraw()* < +WIN:SCRATCH() *snacks-win-module-win:scratch()* + +>lua + win:scratch() +< + + WIN:SCROLL() *snacks-win-module-win:scroll()* >lua @@ -396,6 +443,15 @@ WIN:SCROLL() *snacks-win-module-win:scroll()* < +WIN:SET_TITLE() *snacks-win-module-win:set_title()* + +>lua + ---@param title string + ---@param pos? "center"|"left"|"right" + win:set_title(title, pos) +< + + WIN:SHOW() *snacks-win-module-win:show()* >lua @@ -427,6 +483,14 @@ WIN:TOGGLE() *snacks-win-module-win:toggle()* < +WIN:TOGGLE_HELP() *snacks-win-module-win:toggle_help()* + +>lua + ---@param opts? {col_width?: number, key_width?: number, win?: snacks.win.Config} + win:toggle_help(opts) +< + + WIN:UPDATE() *snacks-win-module-win:update()* >lua diff --git a/docs/examples/picker.lua b/docs/examples/picker.lua new file mode 100644 index 00000000..f27ae7c1 --- /dev/null +++ b/docs/examples/picker.lua @@ -0,0 +1,91 @@ +local M = {} + +M.examples = {} + +M.examples.general = { + "folke/snacks.nvim", + opts = { + picker = {}, + }, + -- stylua: ignore + keys = { + { ",", function() Snacks.picker.buffers() end, desc = "Buffers" }, + { "/", function() Snacks.picker.grep() end, desc = "Grep" }, + { ":", function() Snacks.picker.command_history() end, desc = "Command History" }, + { "", function() Snacks.picker.files() end, desc = "Find Files" }, + -- find + { "fb", function() Snacks.picker.buffers() end, desc = "Buffers" }, + { "fc", function() Snacks.picker.files({ cwd = vim.fn.stdpath("config") }) end, desc = "Find Config File" }, + { "ff", function() Snacks.picker.files() end, desc = "Find Files" }, + { "fg", function() Snacks.picker.git_files() end, desc = "Find Git Files" }, + { "fr", function() Snacks.picker.recent() end, desc = "Recent" }, + -- git + { "gc", function() Snacks.picker.git_log() end, desc = "Git Log" }, + { "gs", function() Snacks.picker.git_status() end, desc = "Git Status" }, + -- Grep + { "sb", function() Snacks.picker.lines() end, desc = "Buffer Lines" }, + { "sB", function() Snacks.picker.grep_buffers() end, desc = "Grep Open Buffers" }, + { "sg", function() Snacks.picker.grep() end, desc = "Grep" }, + { "sw", function() Snacks.picker.grep_word() end, desc = "Visual selection or word", mode = { "n", "x" } }, + -- search + { 's"', function() Snacks.picker.registers() end, desc = "Registers" }, + { "sa", function() Snacks.picker.autocmds() end, desc = "Autocmds" }, + { "sc", function() Snacks.picker.command_history() end, desc = "Command History" }, + { "sC", function() Snacks.picker.commands() end, desc = "Commands" }, + { "sd", function() Snacks.picker.diagnostics() end, desc = "Diagnostics" }, + { "sh", function() Snacks.picker.help() end, desc = "Help Pages" }, + { "sH", function() Snacks.picker.highlights() end, desc = "Highlights" }, + { "sj", function() Snacks.picker.jumps() end, desc = "Jumps" }, + { "sk", function() Snacks.picker.keymaps() end, desc = "Keymaps" }, + { "sl", function() Snacks.picker.loclist() end, desc = "Location List" }, + { "sM", function() Snacks.picker.man() end, desc = "Man Pages" }, + { "sm", function() Snacks.picker.marks() end, desc = "Marks" }, + { "sR", function() Snacks.picker.resume() end, desc = "Resume" }, + { "sq", function() Snacks.picker.qflist() end, desc = "Quickfix List" }, + { "uC", function() Snacks.picker.colorschemes() end, desc = "Colorschemes" }, + { "qp", function() Snacks.picker.projects() end, desc = "Projects" }, + -- LSP + { "gd", function() Snacks.picker.lsp_definitions() end, desc = "Goto Definition" }, + { "gr", function() Snacks.picker.lsp_references() end, nowait = true, desc = "References" }, + { "gI", function() Snacks.picker.lsp_implementations() end, desc = "Goto Implementation" }, + { "gy", function() Snacks.picker.lsp_type_definitions() end, desc = "Goto T[y]pe Definition" }, + { "ss", function() Snacks.picker.lsp_symbols() end, desc = "LSP Symbols" }, + }, +} + +M.examples.trouble = { + "folke/trouble.nvim", + optional = true, + specs = { + "folke/snacks.nvim", + opts = function(_, opts) + return vim.tbl_deep_extend("force", opts or {}, { + picker = { + actions = require("trouble.sources.snacks").actions, + win = { + input = { + keys = { + [""] = { + "trouble_open", + mode = { "n", "i" }, + }, + }, + }, + }, + }, + }) + end, + }, +} + +M.examples.todo_comments = { + "folke/todo-comments.nvim", + optional = true, + -- stylua: ignore + keys = { + { "st", function() Snacks.picker.todo_comments() end, desc = "Todo" }, + { "sT", function () Snacks.picker.todo_comments({ keywords = { "TODO", "FIX", "FIXME" } }) end, desc = "Todo/Fix/Fixme" }, + }, +} + +return M diff --git a/docs/init.md b/docs/init.md index 4feb59c5..e8768af4 100644 --- a/docs/init.md +++ b/docs/init.md @@ -13,8 +13,10 @@ ---@field gitbrowse? snacks.gitbrowse.Config ---@field indent? snacks.indent.Config ---@field input? snacks.input.Config +---@field layout? snacks.layout.Config ---@field lazygit? snacks.lazygit.Config ---@field notifier? snacks.notifier.Config +---@field picker? snacks.picker.Config ---@field profiler? snacks.profiler.Config ---@field quickfile? snacks.quickfile.Config ---@field scope? snacks.scope.Config @@ -53,10 +55,12 @@ ---@field health snacks.health ---@field indent snacks.indent ---@field input snacks.input +---@field layout snacks.layout ---@field lazygit snacks.lazygit ---@field meta snacks.meta ---@field notifier snacks.notifier ---@field notify snacks.notify +---@field picker snacks.picker ---@field profiler snacks.profiler ---@field quickfile snacks.quickfile ---@field rename snacks.rename @@ -73,7 +77,7 @@ Snacks = {} ``` -### `Snacks.config.example()` +### `Snacks.init.config.example()` Get an example config from the docs/examples directory. @@ -81,10 +85,10 @@ Get an example config from the docs/examples directory. ---@param snack string ---@param name string ---@param opts? table -Snacks.config.example(snack, name, opts) +Snacks.init.config.example(snack, name, opts) ``` -### `Snacks.config.get()` +### `Snacks.init.config.get()` ```lua ---@generic T: table @@ -92,10 +96,10 @@ Snacks.config.example(snack, name, opts) ---@param defaults T ---@param ... T[] ---@return T -Snacks.config.get(snack, defaults, ...) +Snacks.init.config.get(snack, defaults, ...) ``` -### `Snacks.config.style()` +### `Snacks.init.config.style()` Register a new window style config. @@ -103,12 +107,12 @@ Register a new window style config. ---@param name string ---@param defaults snacks.win.Config|{} ---@return string -Snacks.config.style(name, defaults) +Snacks.init.config.style(name, defaults) ``` -### `Snacks.setup()` +### `Snacks.init.setup()` ```lua ---@param opts snacks.Config? -Snacks.setup(opts) +Snacks.init.setup(opts) ``` diff --git a/docs/layout.md b/docs/layout.md new file mode 100644 index 00000000..9f26e576 --- /dev/null +++ b/docs/layout.md @@ -0,0 +1,146 @@ +# 🍿 layout + + + +## 📦 Setup + +```lua +-- lazy.nvim +{ + "folke/snacks.nvim", + ---@type snacks.Config + opts = { + layout = { + -- your layout configuration comes here + -- or leave it empty to use the default settings + -- refer to the configuration section below + } + } +} +``` + +## ⚙️ Config + +```lua +---@class snacks.layout.Config +---@field show? boolean show the layout on creation (default: true) +---@field wins table windows to include in the layout +---@field layout snacks.layout.Box layout definition +---@field fullscreen? boolean open in fullscreen +---@field hidden? string[] list of windows that will be excluded from the layout (but can be toggled) +---@field on_update? fun(layout: snacks.layout) +{ + layout = { + width = 0.6, + height = 0.6, + zindex = 50, + }, +} +``` + +## 📚 Types + +```lua +---@class snacks.layout.Win: snacks.win.Config,{} +---@field depth? number +---@field win string layout window name +``` + +```lua +---@class snacks.layout.Box: snacks.layout.Win,{} +---@field box "horizontal" | "vertical" +---@field id? number +---@field [number] snacks.layout.Win | snacks.layout.Box children +``` + +```lua +---@alias snacks.layout.Widget snacks.layout.Win | snacks.layout.Box +``` + +## 📦 Module + +```lua +---@class snacks.layout +---@field opts snacks.layout.Config +---@field root snacks.win +---@field wins table +---@field box_wins snacks.win[] +---@field win_opts table +---@field closed? boolean +Snacks.layout = {} +``` + +### `Snacks.layout.new()` + +```lua +---@param opts snacks.layout.Config +Snacks.layout.new(opts) +``` + +### `layout:close()` + +Close the layout + +```lua +---@param opts? {wins?: boolean} +layout:close(opts) +``` + +### `layout:each()` + +```lua +---@param cb fun(widget: snacks.layout.Widget, parent?: snacks.layout.Box) +---@param opts? {wins?:boolean, boxes?:boolean, box?:snacks.layout.Box} +layout:each(cb, opts) +``` + +### `layout:is_enabled()` + +Check if the window has been used in the layout + +```lua +---@param w string +layout:is_enabled(w) +``` + +### `layout:is_hidden()` + +Check if a window is hidden + +```lua +---@param win string +layout:is_hidden(win) +``` + +### `layout:maximize()` + +Toggle fullscreen + +```lua +layout:maximize() +``` + +### `layout:show()` + +Show the layout + +```lua +layout:show() +``` + +### `layout:toggle()` + +Toggle a window + +```lua +---@param win string +layout:toggle(win) +``` + +### `layout:valid()` + +Check if layout is valid (visible) + +```lua +layout:valid() +``` diff --git a/docs/meta.md b/docs/meta.md index 139c0dda..4c49fd9e 100644 --- a/docs/meta.md +++ b/docs/meta.md @@ -16,6 +16,7 @@ Meta functions for Snacks ---@field health? boolean ---@field types? boolean ---@field config? boolean +---@field merge? { [string|number]: string } ``` ```lua @@ -28,6 +29,12 @@ Meta functions for Snacks ## 📦 Module +### `Snacks.meta.file()` + +```lua +Snacks.meta.file(name) +``` + ### `Snacks.meta.get()` Get the metadata for all snacks plugins diff --git a/docs/picker.md b/docs/picker.md new file mode 100644 index 00000000..5adee869 --- /dev/null +++ b/docs/picker.md @@ -0,0 +1,1718 @@ +# 🍿 picker + + + +## 📦 Setup + +```lua +-- lazy.nvim +{ + "folke/snacks.nvim", + ---@type snacks.Config + opts = { + picker = { + -- your picker configuration comes here + -- or leave it empty to use the default settings + -- refer to the configuration section below + } + } +} +``` + +## ⚙️ Config + +```lua +---@class snacks.picker.Config +---@field source? string source name and config to use +---@field pattern? string|fun(picker:snacks.Picker):string pattern used to filter items by the matcher +---@field search? string|fun(picker:snacks.Picker):string search string used by finders +---@field cwd? string current working directory +---@field live? boolean when true, typing will trigger live searches +---@field limit? number when set, the finder will stop after finding this number of items. useful for live searches +---@field ui_select? boolean set `vim.ui.select` to a snacks picker +--- Source definition +---@field items? snacks.picker.finder.Item[] items to show instead of using a finder +---@field format? snacks.picker.format|string format function or preset +---@field finder? snacks.picker.finder|string finder function or preset +---@field preview? snacks.picker.preview|string preview function or preset +---@field matcher? snacks.picker.matcher.Config matcher config +---@field sort? snacks.picker.sort|snacks.picker.sort.Config sort function or config +--- UI +---@field win? snacks.picker.win.Config +---@field layout? snacks.picker.layout.Config|string|{}|fun(source:string):(snacks.picker.layout.Config|string) +---@field icons? snacks.picker.icons +---@field prompt? string prompt text / icon +--- Preset options +---@field previewers? snacks.picker.preview.Config +---@field sources? snacks.picker.sources.Config|{} +---@field layouts? table +--- Actions +---@field actions? table actions used by keymaps +---@field confirm? snacks.picker.Action.spec shortcut for confirm action +---@field auto_confirm? boolean automatically confirm if there is only one item +---@field main? snacks.picker.main.Config main editor window config +---@field on_change? fun(picker:snacks.Picker, item:snacks.picker.Item) called when the cursor changes +---@field on_show? fun(picker:snacks.Picker) called when the picker is shown +{ + prompt = " ", + sources = {}, + layout = { + cycle = true, + preset = function() + return vim.o.columns >= 120 and "default" or "vertical" + end, + }, + ui_select = true, -- replace `vim.ui.select` with the snacks picker + previewers = { + file = { + max_size = 1024 * 1024, -- 1MB + max_line_length = 500, + }, + }, + win = { + list = { + keys = { + [""] = "confirm", + ["gg"] = "list_top", + ["G"] = "list_bottom", + ["i"] = "focus_input", + ["j"] = "list_down", + ["k"] = "list_up", + ["q"] = "close", + [""] = "select_and_next", + [""] = "select_and_prev", + [""] = "list_down", + [""] = "list_up", + [""] = "list_scroll_down", + [""] = "list_scroll_up", + ["zt"] = "list_scroll_top", + ["zb"] = "list_scroll_bottom", + ["zz"] = "list_scroll_center", + ["/"] = "toggle_focus", + [""] = "list_scroll_wheel_down", + [""] = "list_scroll_wheel_up", + [""] = "preview_scroll_down", + [""] = "preview_scroll_up", + [""] = "edit_vsplit", + [""] = "edit_split", + [""] = "list_down", + [""] = "list_up", + [""] = "list_down", + [""] = "list_up", + [""] = "cycle_win", + [""] = "close", + }, + }, + input = { + keys = { + [""] = "close", + [""] = "confirm", + ["G"] = "list_bottom", + ["gg"] = "list_top", + ["j"] = "list_down", + ["k"] = "list_up", + ["/"] = "toggle_focus", + ["q"] = "close", + ["?"] = "toggle_help", + [""] = { "toggle_maximize", mode = { "i", "n" } }, + [""] = { "toggle_preview", mode = { "i", "n" } }, + [""] = { "cycle_win", mode = { "i", "n" } }, + [""] = { "", mode = { "i" }, expr = true, desc = "delete word" }, + [""] = { "history_back", mode = { "i", "n" } }, + [""] = { "history_forward", mode = { "i", "n" } }, + [""] = { "select_and_next", mode = { "i", "n" } }, + [""] = { "select_and_prev", mode = { "i", "n" } }, + [""] = { "list_down", mode = { "i", "n" } }, + [""] = { "list_up", mode = { "i", "n" } }, + [""] = { "list_down", mode = { "i", "n" } }, + [""] = { "list_up", mode = { "i", "n" } }, + [""] = { "list_down", mode = { "i", "n" } }, + [""] = { "list_up", mode = { "i", "n" } }, + [""] = { "preview_scroll_up", mode = { "i", "n" } }, + [""] = { "list_scroll_down", mode = { "i", "n" } }, + [""] = { "preview_scroll_down", mode = { "i", "n" } }, + [""] = { "toggle_live", mode = { "i", "n" } }, + [""] = { "list_scroll_up", mode = { "i", "n" } }, + [""] = { "list_scroll_wheel_down", mode = { "i", "n" } }, + [""] = { "list_scroll_wheel_up", mode = { "i", "n" } }, + [""] = { "edit_vsplit", mode = { "i", "n" } }, + [""] = { "edit_split", mode = { "i", "n" } }, + [""] = { "qflist", mode = { "i", "n" } }, + [""] = { "toggle_ignored", mode = { "i", "n" } }, + [""] = { "toggle_hidden", mode = { "i", "n" } }, + }, + b = { + minipairs_disable = true, + }, + }, + preview = { + minimal = false, + wo = { + cursorline = false, + colorcolumn = "", + }, + keys = { + [""] = "close", + ["q"] = "close", + ["i"] = "focus_input", + [""] = "list_scroll_wheel_down", + [""] = "list_scroll_wheel_up", + [""] = "cycle_win", + }, + }, + }, + ---@class snacks.picker.icons + icons = { + indent = { + vertical = "│ ", + middle = "├╴", + last = "└╴", + }, + ui = { + live = "󰐰 ", + selected = "● ", + -- selected = " ", + }, + git = { + commit = "󰜘 ", + }, + diagnostics = { + Error = " ", + Warn = " ", + Hint = " ", + Info = " ", + }, + kinds = { + Array = " ", + Boolean = "󰨙 ", + Class = " ", + Color = " ", + Control = " ", + Collapsed = " ", + Constant = "󰏿 ", + Constructor = " ", + Copilot = " ", + Enum = " ", + EnumMember = " ", + Event = " ", + Field = " ", + File = " ", + Folder = " ", + Function = "󰊕 ", + Interface = " ", + Key = " ", + Keyword = " ", + Method = "󰊕 ", + Module = " ", + Namespace = "󰦮 ", + Null = " ", + Number = "󰎠 ", + Object = " ", + Operator = " ", + Package = " ", + Property = " ", + Reference = " ", + Snippet = "󱄽 ", + String = " ", + Struct = "󰆼 ", + Text = " ", + TypeParameter = " ", + Unit = " ", + Uknown = " ", + Value = " ", + Variable = "󰀫 ", + }, + }, +} +``` + +## 🚀 Examples + +### `general` + +```lua +{ + "folke/snacks.nvim", + opts = { + picker = {}, + }, + keys = { + { ",", function() Snacks.picker.buffers() end, desc = "Buffers" }, + { "/", function() Snacks.picker.grep() end, desc = "Grep" }, + { ":", function() Snacks.picker.command_history() end, desc = "Command History" }, + { "", function() Snacks.picker.files() end, desc = "Find Files" }, + -- find + { "fb", function() Snacks.picker.buffers() end, desc = "Buffers" }, + { "fc", function() Snacks.picker.files({ cwd = vim.fn.stdpath("config") }) end, desc = "Find Config File" }, + { "ff", function() Snacks.picker.files() end, desc = "Find Files" }, + { "fg", function() Snacks.picker.git_files() end, desc = "Find Git Files" }, + { "fr", function() Snacks.picker.recent() end, desc = "Recent" }, + -- git + { "gc", function() Snacks.picker.git_log() end, desc = "Git Log" }, + { "gs", function() Snacks.picker.git_status() end, desc = "Git Status" }, + -- Grep + { "sb", function() Snacks.picker.lines() end, desc = "Buffer Lines" }, + { "sB", function() Snacks.picker.grep_buffers() end, desc = "Grep Open Buffers" }, + { "sg", function() Snacks.picker.grep() end, desc = "Grep" }, + { "sw", function() Snacks.picker.grep_word() end, desc = "Visual selection or word", mode = { "n", "x" } }, + -- search + { 's"', function() Snacks.picker.registers() end, desc = "Registers" }, + { "sa", function() Snacks.picker.autocmds() end, desc = "Autocmds" }, + { "sc", function() Snacks.picker.command_history() end, desc = "Command History" }, + { "sC", function() Snacks.picker.commands() end, desc = "Commands" }, + { "sd", function() Snacks.picker.diagnostics() end, desc = "Diagnostics" }, + { "sh", function() Snacks.picker.help() end, desc = "Help Pages" }, + { "sH", function() Snacks.picker.highlights() end, desc = "Highlights" }, + { "sj", function() Snacks.picker.jumps() end, desc = "Jumps" }, + { "sk", function() Snacks.picker.keymaps() end, desc = "Keymaps" }, + { "sl", function() Snacks.picker.loclist() end, desc = "Location List" }, + { "sM", function() Snacks.picker.man() end, desc = "Man Pages" }, + { "sm", function() Snacks.picker.marks() end, desc = "Marks" }, + { "sR", function() Snacks.picker.resume() end, desc = "Resume" }, + { "sq", function() Snacks.picker.qflist() end, desc = "Quickfix List" }, + { "uC", function() Snacks.picker.colorschemes() end, desc = "Colorschemes" }, + { "qp", function() Snacks.picker.projects() end, desc = "Projects" }, + -- LSP + { "gd", function() Snacks.picker.lsp_definitions() end, desc = "Goto Definition" }, + { "gr", function() Snacks.picker.lsp_references() end, nowait = true, desc = "References" }, + { "gI", function() Snacks.picker.lsp_implementations() end, desc = "Goto Implementation" }, + { "gy", function() Snacks.picker.lsp_type_definitions() end, desc = "Goto T[y]pe Definition" }, + { "ss", function() Snacks.picker.lsp_symbols() end, desc = "LSP Symbols" }, + }, +} +``` + +### `todo_comments` + +```lua +{ + "folke/todo-comments.nvim", + optional = true, + keys = { + { "st", function() Snacks.picker.todo_comments() end, desc = "Todo" }, + { "sT", function () Snacks.picker.todo_comments({ keywords = { "TODO", "FIX", "FIXME" } }) end, desc = "Todo/Fix/Fixme" }, + }, +} +``` + +### `trouble` + +```lua +{ + "folke/trouble.nvim", + optional = true, + specs = { + "folke/snacks.nvim", + opts = function(_, opts) + return vim.tbl_deep_extend("force", opts or {}, { + picker = { + actions = require("trouble.sources.snacks").actions, + win = { + input = { + keys = { + [""] = { + "trouble_open", + mode = { "n", "i" }, + }, + }, + }, + }, + }, + }) + end, + }, +} +``` + +## 📚 Types + +```lua +---@class snacks.picker.Last +---@field opts snacks.picker.Config +---@field selected snacks.picker.Item[] +---@field filter snacks.picker.Filter +``` + +```lua +---@alias snacks.picker.Extmark vim.api.keyset.set_extmark|{col:number, row?:number} +---@alias snacks.picker.Text {[1]:string, [2]:string?, virtual?:boolean} +---@alias snacks.picker.Highlight snacks.picker.Text|snacks.picker.Extmark +---@alias snacks.picker.format fun(item:snacks.picker.Item, picker:snacks.Picker):snacks.picker.Highlight[] +---@alias snacks.picker.preview fun(ctx: snacks.picker.preview.ctx):boolean? +---@alias snacks.picker.sort fun(a:snacks.picker.Item, b:snacks.picker.Item):boolean +``` + +```lua +---@class snacks.picker.finder.Item: snacks.picker.Item +---@field idx? number +---@field score? number +``` + +Generic filter used by finders to pre-filter items + +```lua +---@class snacks.picker.filter.Config +---@field cwd? boolean|string only show files for the given cwd +---@field buf? boolean|number only show items for the current or given buffer +---@field paths? table only show items that include or exclude the given paths +---@field filter? fun(item:snacks.picker.finder.Item):boolean custom filter function +``` + +```lua +---@class snacks.picker.Item +---@field [string] any +---@field idx number +---@field score number +---@field match_tick? number +---@field text string +---@field pos? {[1]:number, [2]:number} +---@field end_pos? {[1]:number, [2]:number} +---@field highlights? snacks.picker.Highlight[][] +``` + +```lua +---@class snacks.picker.sources.Config +``` + +```lua +---@class snacks.picker.preview.Config +---@field man_pager? string MANPAGER env to use for `man` preview +---@field file snacks.picker.preview.file.Config +``` + +```lua +---@class snacks.picker.preview.file.Config +---@field max_size? number default 1MB +---@field max_line_length? number defaults to 500 +---@field ft? string defaults to auto-detect +``` + +```lua +---@class snacks.picker.layout.Config +---@field layout snacks.layout.Box +---@field reverse? boolean when true, the list will be reversed (bottom-up) +---@field fullscreen? boolean open in fullscreen +---@field cycle? boolean cycle through the list +---@field preview? boolean|"main" show preview window in the picker or the main window +---@field preset? string|fun(source:string):string +``` + +```lua +---@class snacks.picker.win.Config +---@field input? snacks.win.Config|{} +---@field list? snacks.win.Config|{} +---@field preview? snacks.win.Config|{} +``` + +## 📦 Module + +```lua +---@class snacks.picker +---@field actions snacks.picker.actions +---@field config snacks.picker.config +---@field format snacks.picker.formatters +---@field preview snacks.picker.previewers +---@field sort snacks.picker.sorters +---@field util snacks.picker.util +---@field current? snacks.Picker +---@field highlight snacks.picker.highlight +---@field resume fun(opts?: snacks.picker.Config):snacks.Picker +---@field sources snacks.picker.sources.Config +Snacks.picker = {} +``` + +### `Snacks.picker()` + +```lua +---@type fun(source: string, opts: snacks.picker.Config): snacks.Picker +Snacks.picker() +``` + +```lua +---@type fun(opts: snacks.picker.Config): snacks.Picker +Snacks.picker() +``` + +### `Snacks.picker.pick()` + +Create a new picker + +```lua +---@param source? string +---@param opts? snacks.picker.Config +---@overload fun(opts: snacks.picker.Config): snacks.Picker +Snacks.picker.pick(source, opts) +``` + +### `Snacks.picker.select()` + +Implementation for `vim.ui.select` + +```lua +---@type snacks.picker.ui_select +Snacks.picker.select(...) +``` +## 🔍 Sources + +### `autocmds` + +```lua +{ + finder = "vim_autocmds", + format = "autocmd", + preview = "preview", +} +``` + +### `buffers` + +```lua +---@class snacks.picker.buffers.Config: snacks.picker.Config +---@field hidden? boolean show hidden buffers (unlisted) +---@field unloaded? boolean show loaded buffers +---@field current? boolean show current buffer +---@field nofile? boolean show `buftype=nofile` buffers +---@field sort_lastused? boolean sort by last used +---@field filter? snacks.picker.filter.Config +{ + finder = "buffers", + format = "buffer", + hidden = false, + unloaded = true, + current = true, + sort_lastused = true, +} +``` + +### `cliphist` + +```lua +{ + finder = "system_cliphist", + format = "text", + preview = "preview", + confirm = { "copy", "close" }, +} +``` + +### `colorschemes` + +Neovim colorschemes with live preview + +```lua +{ + finder = "vim_colorschemes", + format = "text", + preview = "colorscheme", + preset = "vertical", + confirm = function(picker, item) + picker:close() + if item then + picker.preview.state.colorscheme = nil + vim.schedule(function() + vim.cmd("colorscheme " .. item.text) + end) + end + end, +} +``` + +### `command_history` + +Neovim command history + +```lua +---@type snacks.picker.history.Config +{ + finder = "vim_history", + name = "cmd", + format = "text", + preview = "none", + layout = { + preset = "vscode", + }, + confirm = "cmd", +} +``` + +### `commands` + +Neovim commands + +```lua +{ + finder = "vim_commands", + format = "text", + preview = "preview", + confirm = "cmd", +} +``` + +### `diagnostics` + +```lua +---@class snacks.picker.diagnostics.Config: snacks.picker.Config +---@field filter? snacks.picker.filter.Config +---@field severity? vim.diagnostic.SeverityFilter +{ + finder = "diagnostics", + format = "diagnostic", + sort = { + fields = { + "is_current", + "is_cwd", + "severity", + "file", + "lnum", + }, + }, + -- only show diagnostics from the cwd by default + filter = { cwd = true }, +} +``` + +### `diagnostics_buffer` + +```lua +---@type snacks.picker.diagnostics.Config +{ + finder = "diagnostics", + format = "diagnostic", + sort = { + fields = { "severity", "file", "lnum" }, + }, + filter = { buf = true }, +} +``` + +### `files` + +```lua +---@class snacks.picker.files.Config: snacks.picker.proc.Config +---@field cmd? string +---@field hidden? boolean show hidden files +---@field ignored? boolean show ignored files +---@field dirs? string[] directories to search +---@field follow? boolean follow symlinks +{ + finder = "files", + format = "file", + hidden = false, + ignored = false, + follow = false, + supports_live = true, +} +``` + +### `git_files` + +Find git files + +```lua +---@class snacks.picker.git.files.Config: snacks.picker.Config +---@field untracked? boolean show untracked files +---@field submodules? boolean show submodule files +{ + finder = "git_files", + format = "file", + untracked = false, + submodules = false, +} +``` + +### `git_log` + +Git log + +```lua +---@class snacks.picker.git.log.Config: snacks.picker.Config +---@field follow? boolean track file history across renames +---@field current_file? boolean show current file log +---@field current_line? boolean show current line log +{ + finder = "git_log", + format = "git_log", + preview = "git_show", + confirm = "close", +} +``` + +### `git_log_file` + +```lua +---@type snacks.picker.git.log.Config +{ + finder = "git_log", + format = "git_log", + preview = "git_show", + current_file = true, + follow = true, + confirm = "close", +} +``` + +### `git_log_line` + +```lua +---@type snacks.picker.git.log.Config +{ + finder = "git_log", + format = "git_log", + preview = "git_show", + current_line = true, + follow = true, + confirm = "close", +} +``` + +### `git_status` + +```lua +{ + finder = "git_status", + format = "git_status", + preview = "git_status", +} +``` + +### `grep` + +```lua +---@class snacks.picker.grep.Config: snacks.picker.proc.Config +---@field cmd? string +---@field hidden? boolean show hidden files +---@field ignored? boolean show ignored files +---@field dirs? string[] directories to search +---@field follow? boolean follow symlinks +---@field glob? string|string[] glob file pattern(s) +---@field buffers? boolean search in open buffers +---@field need_search? boolean require a search pattern +{ + finder = "grep", + format = "file", + live = true, -- live grep by default + supports_live = true, +} +``` + +### `grep_buffers` + +```lua +---@type snacks.picker.grep.Config +{ + finder = "grep", + format = "file", + live = true, + buffers = true, + need_search = false, + supports_live = true, +} +``` + +### `grep_word` + +```lua +---@type snacks.picker.grep.Config +{ + finder = "grep", + format = "file", + search = function(picker) + return picker:word() + end, + live = false, + supports_live = true, +} +``` + +### `help` + +Neovim help tags + +```lua +---@class snacks.picker.help.Config: snacks.picker.Config +---@field lang? string[] defaults to `vim.opt.helplang` +{ + finder = "help", + format = "text", + previewers = { + file = { ft = "help" }, + }, + win = { + preview = { + minimal = true, + }, + }, + confirm = "help", +} +``` + +### `highlights` + +```lua +{ + finder = "vim_highlights", + format = "hl", + preview = "preview", +} +``` + +### `jumps` + +```lua +{ + finder = "vim_jumps", + format = "file", +} +``` + +### `keymaps` + +```lua +---@class snacks.picker.keymaps.Config: snacks.picker.Config +---@field global? boolean show global keymaps +---@field local? boolean show buffer keymaps +---@field modes? string[] +{ + finder = "vim_keymaps", + format = "keymap", + preview = "preview", + global = true, + ["local"] = true, + modes = { "n", "v", "x", "s", "o", "i", "c", "t" }, + confirm = function(picker, item) + picker:close() + if item then + vim.api.nvim_input(item.item.lhs) + end + end, +} +``` + +### `lines` + +Search lines in the current buffer + +```lua +---@class snacks.picker.lines.Config: snacks.picker.Config +---@field buf? number +{ + finder = "lines", + format = "lines", + layout = { + preview = "main", + preset = "ivy", + }, + -- allow any window to be used as the main window + main = { current = true }, + ---@param picker snacks.Picker + on_show = function(picker) + local cursor = vim.api.nvim_win_get_cursor(picker.main) + local info = vim.api.nvim_win_call(picker.main, vim.fn.winsaveview) + picker.list:view(cursor[1], info.topline) + picker:show_preview() + end, +} +``` + +### `loclist` + +Loclist + +```lua +---@type snacks.picker.qf.Config +{ + finder = "qf", + format = "file", + qf_win = 0, +} +``` + +### `lsp_declarations` + +LSP declarations + +```lua +---@type snacks.picker.lsp.Config +{ + finder = "lsp_declarations", + format = "file", + include_current = false, + auto_confirm = true, +} +``` + +### `lsp_definitions` + +LSP definitions + +```lua +---@type snacks.picker.lsp.Config +{ + finder = "lsp_definitions", + format = "file", + include_current = false, + auto_confirm = true, +} +``` + +### `lsp_implementations` + +LSP implementations + +```lua +---@type snacks.picker.lsp.Config +{ + finder = "lsp_implementations", + format = "file", + include_current = false, + auto_confirm = true, +} +``` + +### `lsp_references` + +LSP references + +```lua +---@class snacks.picker.lsp.references.Config: snacks.picker.lsp.Config +---@field include_declaration? boolean default true +{ + finder = "lsp_references", + format = "file", + include_declaration = true, + include_current = false, + auto_confirm = true, +} +``` + +### `lsp_symbols` + +LSP document symbols + +```lua +---@class snacks.picker.lsp.symbols.Config: snacks.picker.Config +---@field hierarchy? boolean show symbol hierarchy +---@field filter table? symbol kind filter +{ + finder = "lsp_symbols", + format = "lsp_symbol", + hierarchy = true, + filter = { + default = { + "Class", + "Constructor", + "Enum", + "Field", + "Function", + "Interface", + "Method", + "Module", + "Namespace", + "Package", + "Property", + "Struct", + "Trait", + }, + -- set to `true` to include all symbols + markdown = true, + help = true, + -- you can specify a different filter for each filetype + lua = { + "Class", + "Constructor", + "Enum", + "Field", + "Function", + "Interface", + "Method", + "Module", + "Namespace", + -- "Package", -- remove package since luals uses it for control flow structures + "Property", + "Struct", + "Trait", + }, + }, +} +``` + +### `lsp_type_definitions` + +LSP type definitions + +```lua +---@type snacks.picker.lsp.Config +{ + finder = "lsp_type_definitions", + format = "file", + include_current = false, + auto_confirm = true, +} +``` + +### `man` + +```lua +{ + finder = "system_man", + format = "man", + preview = "man", + confirm = function(picker, item) + picker:close() + if item then + vim.schedule(function() + vim.cmd("Man " .. item.ref) + end) + end + end, +} +``` + +### `marks` + +```lua +---@class snacks.picker.marks.Config: snacks.picker.Config +---@field global? boolean show global marks +---@field local? boolean show buffer marks +{ + finder = "vim_marks", + format = "file", + global = true, + ["local"] = true, +} +``` + +### `picker_actions` + +```lua +{ + finder = "meta_actions", + format = "text", +} +``` + +### `picker_format` + +```lua +{ + finder = "meta_format", + format = "text", +} +``` + +### `picker_layouts` + +```lua +{ + finder = "meta_layouts", + format = "text", + on_change = function(picker, item) + vim.schedule(function() + picker:set_layout(item.text) + end) + end, +} +``` + +### `picker_preview` + +```lua +{ + finder = "meta_preview", + format = "text", +} +``` + +### `pickers` + +List all available sources + +```lua +{ + finder = "meta_pickers", + format = "text", + confirm = function(picker, item) + picker:close() + if item then + Snacks.picker(item.text) + end + end, +} +``` + +### `projects` + +Open recent projects + +```lua +---@class snacks.picker.projects.Config: snacks.picker.Config +---@field filter? snacks.picker.filter.Config +{ + finder = "recent_projects", + format = "file", + confirm = "load_session", + win = { + preview = { + minimal = true, + }, + }, +} +``` + +### `qflist` + +Quickfix list + +```lua +---@type snacks.picker.qf.Config +{ + finder = "qf", + format = "file", +} +``` + +### `recent` + +Find recent files + +```lua +---@class snacks.picker.recent.Config: snacks.picker.Config +---@field filter? snacks.picker.filter.Config +{ + finder = "recent_files", + format = "file", + filter = { + paths = { + [vim.fn.stdpath("data")] = false, + [vim.fn.stdpath("cache")] = false, + [vim.fn.stdpath("state")] = false, + }, + }, +} +``` + +### `registers` + +Neovim registers + +```lua +{ + finder = "vim_registers", + format = "register", + preview = "preview", + confirm = { "copy", "close" }, +} +``` + +### `resume` + +Special picker that resumes the last picker + +```lua +{} +``` + +### `search_history` + +Neovim search history + +```lua +---@type snacks.picker.history.Config +{ + finder = "vim_history", + name = "search", + format = "text", + preview = "none", + layout = { + preset = "vscode", + }, + confirm = "search", +} +``` + +### `zoxide` + +Open a project from zoxide + +```lua +{ + finder = "files_zoxide", + format = "file", + confirm = "load_session", + win = { + preview = { + minimal = true, + }, + }, +} +``` + +## 🖼️ Layouts + +### `default` + +```lua +{ + layout = { + box = "horizontal", + width = 0.8, + min_width = 120, + height = 0.8, + { + box = "vertical", + border = "rounded", + title = "{source} {live}", + title_pos = "center", + { win = "input", height = 1, border = "bottom" }, + { win = "list", border = "none" }, + }, + { win = "preview", border = "rounded", width = 0.5 }, + }, +} +``` + +### `dropdown` + +```lua +{ + layout = { + backdrop = false, + row = 1, + width = 0.4, + min_width = 80, + height = 0.8, + border = "none", + box = "vertical", + { win = "preview", height = 0.4, border = "rounded" }, + { + box = "vertical", + border = "rounded", + title = "{source} {live}", + title_pos = "center", + { win = "input", height = 1, border = "bottom" }, + { win = "list", border = "none" }, + }, + }, +} +``` + +### `ivy` + +```lua +{ + layout = { + box = "vertical", + backdrop = false, + row = -1, + width = 0, + height = 0.4, + border = "top", + title = " {source} {live}", + title_pos = "left", + { win = "input", height = 1, border = "bottom" }, + { + box = "horizontal", + { win = "list", border = "none" }, + { win = "preview", width = 0.6, border = "left" }, + }, + }, +} +``` + +### `select` + +```lua +{ + preview = false, + layout = { + backdrop = false, + width = 0.5, + min_width = 80, + height = 0.4, + min_height = 10, + box = "vertical", + border = "rounded", + title = " Select ", + title_pos = "center", + { win = "input", height = 1, border = "bottom" }, + { win = "list", border = "none" }, + { win = "preview", height = 0.4, border = "top" }, + }, +} +``` + +### `telescope` + +```lua +{ + reverse = true, + layout = { + box = "horizontal", + backdrop = false, + width = 0.8, + height = 0.9, + border = "none", + { + box = "vertical", + { win = "list", title = " Results ", title_pos = "center", border = "rounded" }, + { win = "input", height = 1, border = "rounded", title = "{source} {live}", title_pos = "center" }, + }, + { + win = "preview", + width = 0.45, + border = "rounded", + title = " Preview ", + title_pos = "center", + }, + }, +} +``` + +### `vertical` + +```lua +{ + layout = { + backdrop = false, + width = 0.5, + min_width = 80, + height = 0.8, + min_height = 30, + box = "vertical", + border = "rounded", + title = "{source} {live}", + title_pos = "center", + { win = "input", height = 1, border = "bottom" }, + { win = "list", border = "none" }, + { win = "preview", height = 0.4, border = "top" }, + }, +} +``` + +### `vscode` + +```lua +{ + preview = false, + layout = { + backdrop = false, + row = 1, + width = 0.4, + min_width = 80, + height = 0.4, + border = "none", + box = "vertical", + { win = "input", height = 1, border = "rounded", title = "{source} {live}", title_pos = "center" }, + { win = "list", border = "hpad" }, + { win = "preview", border = "rounded" }, + }, +} +``` + + +## 📦 `snacks.picker.actions` + +```lua +---@class snacks.picker.actions +---@field [string] snacks.picker.Action.spec +local M = {} +``` + +### `Snacks.picker.actions.cmd()` + +```lua +Snacks.picker.actions.cmd(picker, item) +``` + +### `Snacks.picker.actions.copy()` + +```lua +Snacks.picker.actions.copy(_, item) +``` + +### `Snacks.picker.actions.cycle_win()` + +```lua +Snacks.picker.actions.cycle_win(picker) +``` + +### `Snacks.picker.actions.edit()` + +```lua +Snacks.picker.actions.edit(picker) +``` + +### `Snacks.picker.actions.edit_split()` + +```lua +Snacks.picker.actions.edit_split(picker) +``` + +### `Snacks.picker.actions.edit_tab()` + +```lua +Snacks.picker.actions.edit_tab(picker) +``` + +### `Snacks.picker.actions.edit_vsplit()` + +```lua +Snacks.picker.actions.edit_vsplit(picker) +``` + +### `Snacks.picker.actions.focus_input()` + +```lua +Snacks.picker.actions.focus_input(picker) +``` + +### `Snacks.picker.actions.focus_list()` + +```lua +Snacks.picker.actions.focus_list(picker) +``` + +### `Snacks.picker.actions.focus_preview()` + +```lua +Snacks.picker.actions.focus_preview(picker) +``` + +### `Snacks.picker.actions.help()` + +```lua +Snacks.picker.actions.help(picker) +``` + +### `Snacks.picker.actions.history_back()` + +```lua +Snacks.picker.actions.history_back(picker) +``` + +### `Snacks.picker.actions.history_forward()` + +```lua +Snacks.picker.actions.history_forward(picker) +``` + +### `Snacks.picker.actions.list_bottom()` + +```lua +Snacks.picker.actions.list_bottom(picker) +``` + +### `Snacks.picker.actions.list_down()` + +```lua +Snacks.picker.actions.list_down(picker) +``` + +### `Snacks.picker.actions.list_scroll_bottom()` + +```lua +Snacks.picker.actions.list_scroll_bottom(picker) +``` + +### `Snacks.picker.actions.list_scroll_center()` + +```lua +Snacks.picker.actions.list_scroll_center(picker) +``` + +### `Snacks.picker.actions.list_scroll_down()` + +```lua +Snacks.picker.actions.list_scroll_down(picker) +``` + +### `Snacks.picker.actions.list_scroll_top()` + +```lua +Snacks.picker.actions.list_scroll_top(picker) +``` + +### `Snacks.picker.actions.list_scroll_up()` + +```lua +Snacks.picker.actions.list_scroll_up(picker) +``` + +### `Snacks.picker.actions.list_scroll_wheel_down()` + +```lua +Snacks.picker.actions.list_scroll_wheel_down(picker) +``` + +### `Snacks.picker.actions.list_scroll_wheel_up()` + +```lua +Snacks.picker.actions.list_scroll_wheel_up(picker) +``` + +### `Snacks.picker.actions.list_top()` + +```lua +Snacks.picker.actions.list_top(picker) +``` + +### `Snacks.picker.actions.list_up()` + +```lua +Snacks.picker.actions.list_up(picker) +``` + +### `Snacks.picker.actions.load_session()` + +Tries to load the session, if it fails, it will open the picker. + +```lua +Snacks.picker.actions.load_session(picker) +``` + +### `Snacks.picker.actions.loclist()` + +Send selected or all items to the location list. + +```lua +Snacks.picker.actions.loclist(picker) +``` + +### `Snacks.picker.actions.preview_scroll_down()` + +```lua +Snacks.picker.actions.preview_scroll_down(picker) +``` + +### `Snacks.picker.actions.preview_scroll_up()` + +```lua +Snacks.picker.actions.preview_scroll_up(picker) +``` + +### `Snacks.picker.actions.qflist()` + +Send selected or all items to the quickfix list. + +```lua +Snacks.picker.actions.qflist(picker) +``` + +### `Snacks.picker.actions.search()` + +```lua +Snacks.picker.actions.search(picker, item) +``` + +### `Snacks.picker.actions.select_and_next()` + +Toggles the selection of the current item, +and moves the cursor to the next item. + +```lua +Snacks.picker.actions.select_and_next(picker) +``` + +### `Snacks.picker.actions.select_and_prev()` + +Toggles the selection of the current item, +and moves the cursor to the prev item. + +```lua +Snacks.picker.actions.select_and_prev(picker) +``` + +### `Snacks.picker.actions.toggle_focus()` + +```lua +Snacks.picker.actions.toggle_focus(picker) +``` + +### `Snacks.picker.actions.toggle_hidden()` + +```lua +Snacks.picker.actions.toggle_hidden(picker) +``` + +### `Snacks.picker.actions.toggle_ignored()` + +```lua +Snacks.picker.actions.toggle_ignored(picker) +``` + +### `Snacks.picker.actions.toggle_live()` + +```lua +Snacks.picker.actions.toggle_live(picker) +``` + +### `Snacks.picker.actions.toggle_maximize()` + +```lua +Snacks.picker.actions.toggle_maximize(picker) +``` + +### `Snacks.picker.actions.toggle_preview()` + +```lua +Snacks.picker.actions.toggle_preview(picker) +``` + +## 📦 `snacks.picker.core.picker` + +```lua +---@class snacks.Picker +---@field opts snacks.picker.Config +---@field finder snacks.picker.Finder +---@field format snacks.picker.format +---@field input snacks.picker.input +---@field layout snacks.layout +---@field resolved_layout snacks.picker.layout.Config +---@field list snacks.picker.list +---@field matcher snacks.picker.Matcher +---@field main number +---@field preview snacks.picker.Preview +---@field shown? boolean +---@field sort snacks.picker.sort +---@field updater uv.uv_timer_t +---@field start_time number +---@field source_name string +---@field closed? boolean +---@field hist_idx number +---@field hist_cursor number +---@field visual? snacks.picker.Visual +local M = {} +``` + +### `picker:action()` + +Execute the given action(s) + +```lua +---@param actions string|string[] +picker:action(actions) +``` + +### `picker:close()` + +Close the picker + +```lua +picker:close() +``` + +### `picker:count()` + +Total number of items in the picker + +```lua +picker:count() +``` + +### `picker:current()` + +Get the current item at the cursor + +```lua +picker:current() +``` + +### `picker:empty()` + +Check if the picker is empty + +```lua +picker:empty() +``` + +### `picker:filter()` + +Get the active filter + +```lua +picker:filter() +``` + +### `picker:find()` + +Clear the list and run the finder and matcher + +```lua +---@param opts? {on_done?: fun()} Callback when done +picker:find(opts) +``` + +### `picker:hist()` + +Move the history cursor + +```lua +---@param forward? boolean +picker:hist(forward) +``` + +### `picker:is_active()` + +Check if the finder or matcher is running + +```lua +picker:is_active() +``` + +### `picker:items()` + +Get all finder items + +```lua +picker:items() +``` + +### `picker:iter()` + +Returns an iterator over the items in the picker. +Items will be in sorted order. + +```lua +---@return fun():snacks.picker.Item? +picker:iter() +``` + +### `picker:match()` + +Run the matcher with the current pattern. +May also trigger a new find if the search string has changed, +like during live searches. + +```lua +picker:match() +``` + +### `picker:selected()` + +Get the selected items. +If `fallback=true` and there is no selection, return the current item. + +```lua +---@param opts? {fallback?: boolean} default is `false` +picker:selected(opts) +``` + +### `picker:set_layout()` + +Set the picker layout. Can be either the name of a preset layout +or a custom layout configuration. + +```lua +---@param layout? string|snacks.picker.layout.Config +picker:set_layout(layout) +``` + +### `picker:word()` + +Get the word under the cursor or the current visual selection + +```lua +picker:word() +``` + + diff --git a/docs/styles.md b/docs/styles.md index bc8144e4..025e49f3 100644 --- a/docs/styles.md +++ b/docs/styles.md @@ -93,6 +93,19 @@ The other options are used with `:lua Snacks.dashboard()` } ``` +### `help` + +```lua +{ + position = "float", + backdrop = false, + border = "top", + row = -1, + width = 0, + height = 0.3, +} +``` + ### `input` ```lua @@ -146,6 +159,7 @@ The other options are used with `:lua Snacks.dashboard()` cursorcolumn = false, cursorline = false, cursorlineopt = "both", + colorcolumn = "", fillchars = "eob: ,lastline:…", list = false, listchars = "extends:…,tab: ", diff --git a/docs/win.md b/docs/win.md index 160f842c..a603b935 100644 --- a/docs/win.md +++ b/docs/win.md @@ -55,7 +55,7 @@ Snacks.win({ ---@field row? number|fun(self:snacks.win):number Row of the window. Use <1 for relative row. (default: center) ---@field minimal? boolean Disable a bunch of options to make the window minimal (default: true) ---@field position? "float"|"bottom"|"top"|"left"|"right" ----@field border? "none"|"top"|"right"|"bottom"|"left"|"rounded"|"single"|"double"|"solid"|"shadow"|string[]|false +---@field border? "none"|"top"|"right"|"bottom"|"left"|"hpad"|"vpad"|"rounded"|"single"|"double"|"solid"|"shadow"|string[]|false ---@field buf? number If set, use this buffer instead of creating a new one ---@field file? string If set, use this file instead of creating a new buffer ---@field enter? boolean Enter the window after opening (default: false) @@ -72,6 +72,7 @@ Snacks.win({ ---@field fixbuf? boolean don't allow other buffers to be opened in this window ---@field text? string|string[]|fun():(string[]|string) Initial lines to set in the buffer ---@field actions? table Actions that can be used in key mappings +---@field resize? boolean Automatically resize the window when the editor is resized { show = true, fixbuf = true, @@ -105,6 +106,19 @@ docs for more information on how to customize these styles } ``` +### `help` + +```lua +{ + position = "float", + backdrop = false, + border = "top", + row = -1, + width = 0, + height = 0.3, +} +``` + ### `minimal` ```lua @@ -113,6 +127,7 @@ docs for more information on how to customize these styles cursorcolumn = false, cursorline = false, cursorlineopt = "both", + colorcolumn = "", fillchars = "eob: ,lastline:…", list = false, listchars = "extends:…,tab: ", @@ -151,7 +166,7 @@ docs for more information on how to customize these styles ---@class snacks.win.Event: vim.api.keyset.create_autocmd ---@field buf? true ---@field win? true ----@field callback? fun(self: snacks.win) +---@field callback? fun(self: snacks.win, ev:vim.api.keyset.create_autocmd.callback_args):boolean? ``` ```lua @@ -185,12 +200,15 @@ docs for more information on how to customize these styles ---@class snacks.win ---@field id number ---@field buf? number +---@field scratch_buf? number ---@field win? number ---@field opts snacks.win.Config ---@field augroup? number ---@field backdrop? snacks.win ---@field keys snacks.win.Keys[] ---@field events (snacks.win.Event|{event:string|string[]})[] +---@field meta table +---@field closed? boolean Snacks.win = {} ``` @@ -257,6 +275,13 @@ win:close(opts) win:dim(parent) ``` +### `win:execute()` + +```lua +---@param actions string|string[] +win:execute(actions) +``` + ### `win:focus()` ```lua @@ -299,11 +324,17 @@ win:lines(from, to) ```lua ---@param event string|string[] ----@param cb fun(self: snacks.win) +---@param cb fun(self: snacks.win, ev:vim.api.keyset.create_autocmd.callback_args):boolean? ---@param opts? snacks.win.Event win:on(event, cb, opts) ``` +### `win:on_resize()` + +```lua +win:on_resize() +``` + ### `win:parent_size()` ```lua @@ -317,6 +348,12 @@ win:parent_size() win:redraw() ``` +### `win:scratch()` + +```lua +win:scratch() +``` + ### `win:scroll()` ```lua @@ -324,6 +361,14 @@ win:redraw() win:scroll(up) ``` +### `win:set_title()` + +```lua +---@param title string +---@param pos? "center"|"left"|"right" +win:set_title(title, pos) +``` + ### `win:show()` ```lua @@ -351,6 +396,13 @@ win:text(from, to) win:toggle() ``` +### `win:toggle_help()` + +```lua +---@param opts? {col_width?: number, key_width?: number, win?: snacks.win.Config} +win:toggle_help(opts) +``` + ### `win:update()` ```lua diff --git a/lua/snacks/dashboard.lua b/lua/snacks/dashboard.lua index 104bef77..44bb808c 100644 --- a/lua/snacks/dashboard.lua +++ b/lua/snacks/dashboard.lua @@ -749,13 +749,18 @@ end function M.pick(cmd, opts) cmd = cmd or "files" local config = Snacks.config.get("dashboard", defaults, opts) + local picker = Snacks.picker.config.get() -- stylua: ignore local try = { function() return config.preset.pick(cmd, opts) end, function() return require("fzf-lua")[cmd](opts) end, function() return require("telescope.builtin")[cmd == "files" and "find_files" or cmd](opts) end, function() return require("mini.pick").builtin[cmd](opts) end, + function() return Snacks.picker(cmd, opts) end, } + if picker.enabled then + table.insert(try, 1, table.remove(try, #try)) + end for _, fn in ipairs(try) do if pcall(fn) then return diff --git a/lua/snacks/init.lua b/lua/snacks/init.lua index d8455386..e9fc4285 100644 --- a/lua/snacks/init.lua +++ b/lua/snacks/init.lua @@ -104,7 +104,7 @@ function M.setup(opts) BufReadPre = { "bigfile" }, BufReadPost = { "quickfile", "indent" }, LspAttach = { "words" }, - UIEnter = { "dashboard", "scroll", "input", "scope" }, + UIEnter = { "dashboard", "scroll", "input", "scope", "picker" }, } local function load(event) diff --git a/lua/snacks/layout.lua b/lua/snacks/layout.lua new file mode 100644 index 00000000..11ff341b --- /dev/null +++ b/lua/snacks/layout.lua @@ -0,0 +1,403 @@ +---@class snacks.layout +---@field opts snacks.layout.Config +---@field root snacks.win +---@field wins table +---@field box_wins snacks.win[] +---@field win_opts table +---@field closed? boolean +local M = {} +M.__index = M + +M.meta = { + desc = "Window layouts", +} + +---@class snacks.layout.Win: snacks.win.Config,{} +---@field depth? number +---@field win string layout window name + +---@class snacks.layout.Box: snacks.layout.Win,{} +---@field box "horizontal" | "vertical" +---@field id? number +---@field [number] snacks.layout.Win | snacks.layout.Box children + +---@alias snacks.layout.Widget snacks.layout.Win | snacks.layout.Box + +---@class snacks.layout.Config +---@field show? boolean show the layout on creation (default: true) +---@field wins table windows to include in the layout +---@field layout snacks.layout.Box layout definition +---@field fullscreen? boolean open in fullscreen +---@field hidden? string[] list of windows that will be excluded from the layout (but can be toggled) +---@field on_update? fun(layout: snacks.layout) +local defaults = { + layout = { + width = 0.6, + height = 0.6, + zindex = 50, + }, +} + +---@param opts snacks.layout.Config +function M.new(opts) + local self = setmetatable({}, M) + self.opts = vim.tbl_extend("force", defaults, opts) + self.win_opts = {} + self.wins = self.opts.wins or {} + self.box_wins = {} + + -- assign ids to boxes and create box wins if needed + local id = 1 + self:each(function(box, parent) + box.depth = (parent and parent.depth + 1) or 0 + if box.box then + ---@cast box snacks.layout.Box + box.id, id = id, id + 1 + local has_border = box.border and box.border ~= "" and box.border ~= "none" + local is_root = box.id == 1 + if is_root or has_border then + local backdrop = false ---@type boolean? + if is_root then + backdrop = nil + end + self.box_wins[box.id] = Snacks.win(Snacks.win.resolve(box, { + relative = is_root and "editor" or "win", + focusable = false, + enter = false, + show = false, + resize = false, + noautocmd = true, + backdrop = backdrop, + zindex = (self.opts.layout.zindex or 50) + box.depth, + bo = { filetype = "snacks_layout_box" }, + border = box.border, + })) + end + end + end) + self.root = self.box_wins[1] + assert(self.root, "no root box found") + + for w, win in pairs(self.wins) do + self.win_opts[w] = vim.deepcopy(win.opts) + end + + -- close layout when any win is closed + self.root:on("WinClosed", function(_, ev) + if self.closed then + return true + end + local wid = tonumber(ev.match) + for _, win in pairs(self:get_wins()) do + if win.win == wid then + self:close() + return true + end + end + end) + + -- update layout on VimResized + self.root:on("VimResized", function() + self:update() + end) + if self.opts.show ~= false then + vim.schedule(function() + self:show() + end) + end + return self +end + +---@param cb fun(widget: snacks.layout.Widget, parent?: snacks.layout.Box) +---@param opts? {wins?:boolean, boxes?:boolean, box?:snacks.layout.Box} +function M:each(cb, opts) + opts = opts or {} + ---@param widget snacks.layout.Widget + ---@param parent? snacks.layout.Box + local function _each(widget, parent) + if widget.box then + if opts.boxes ~= false then + cb(widget, parent) + end + ---@cast widget snacks.layout.Box + for _, child in ipairs(widget) do + _each(child, widget) + end + elseif opts.wins ~= false then + cb(widget, parent) + end + end + _each(opts.box or self.opts.layout) +end + +--- Check if a window is hidden +---@param win string +function M:is_hidden(win) + return self.opts.hidden and vim.tbl_contains(self.opts.hidden, win) +end + +--- Toggle a window +---@param win string +function M:toggle(win) + self.opts.hidden = self.opts.hidden or {} + if self:is_hidden(win) then + self.opts.hidden = vim.tbl_filter(function(w) + return w ~= win + end, self.opts.hidden) + else + table.insert(self.opts.hidden, win) + end + self:update() +end + +---@package +function M:update() + if self.closed then + return + end + vim.o.lazyredraw = true + for _, win in pairs(self.wins) do + win.enabled = false + end + local layout = vim.deepcopy(self.opts.layout) + if self.opts.fullscreen then + layout.width = 0 + layout.height = 0 + layout.col = 0 + layout.row = 0 + end + if not self.root:valid() then + self.root:show() + end + self:update_box(layout, { + col = 0, + row = 0, + width = vim.o.columns, + height = vim.o.lines, + }) + for _, win in pairs(self:get_wins()) do + win:show() + end + for w, win in pairs(self.wins) do + if not self:is_enabled(w) and win:win_valid() then + win:close() + end + end + if self.opts.on_update then + self.opts.on_update(self) + end + vim.o.lazyredraw = false +end + +---@param box snacks.layout.Box +---@param parent snacks.win.Dim +---@private +function M:update_box(box, parent) + local size_main = box.box == "horizontal" and "width" or "height" + local pos_main = box.box == "horizontal" and "col" or "row" + local is_root = box.id == 1 + + if not is_root then + box.col = box.col or 0 + box.row = box.row or 0 + end + + local children = {} ---@type snacks.layout.Widget[] + for c, child in ipairs(box) do + if not (child.win and self:is_hidden(child.win)) then + children[#children + 1] = child + end + box[c] = nil + end + for c, child in ipairs(children) do + box[c] = child + end + + local dim, border = self:dim_box(box, parent) + local orig_dim = vim.deepcopy(dim) + if is_root then + dim.col = 0 + dim.row = 0 + else + dim.col = dim.col + border.left + dim.row = dim.row + border.top + end + local free = vim.deepcopy(dim) + + local function size(child) + return child[size_main] or 0 + end + + local dims = {} ---@type table + local flex = 0 + for c, child in ipairs(box) do + flex = flex + (size(child) == 0 and 1 or 0) + if size(child) > 0 then + dims[c] = self:resolve(child, dim) + free[size_main] = free[size_main] - dims[c][size_main] + end + end + local free_main = free[size_main] + for c, child in ipairs(box) do + if size(child) == 0 then + free[size_main] = math.floor(free_main / flex) + flex = flex - 1 + free_main = free_main - free[size_main] + dims[c] = self:resolve(child, free) + end + end + -- assert(free[size_main] >= 0, "not enough space for children") + -- fix positions + local offset = 0 + for c, child in ipairs(box) do + local wins = self:get_wins(child) + for _, win in ipairs(wins) do + win.opts[pos_main] = win.opts[pos_main] + offset + end + offset = offset + dims[c][size_main] + end + + dim.width = dim.width + border.left + border.right + dim.height = dim.height + border.top + border.bottom + + local box_win = self.box_wins[box.id] + if box_win then + if not is_root then + box_win.opts.win = self.root.win + end + box_win.opts.col = orig_dim.col + box_win.opts.row = orig_dim.row + box_win.opts.width = orig_dim.width + box_win.opts.height = orig_dim.height + end + + return dim +end + +---@param widget? snacks.layout.Widget +---@package +function M:get_wins(widget) + local ret = {} ---@type snacks.win[] + self:each(function(w) + if w.box and self.box_wins[w.id] then + table.insert(ret, self.box_wins[w.id]) + elseif w.win and self:is_enabled(w.win) then + table.insert(ret, self.wins[w.win]) + end + end, { box = widget }) + return ret +end + +---@param widget snacks.layout.Widget +---@param parent snacks.win.Dim +---@private +function M:resolve(widget, parent) + if widget.box then + ---@cast widget snacks.layout.Box + return self:update_box(widget, parent) + else + assert(widget.win, "widget must have win or box") + ---@cast widget snacks.layout.Win + return self:update_win(widget, parent) + end +end + +---@param widget snacks.layout.Box +---@param parent snacks.win.Dim +---@private +function M:dim_box(widget, parent) + local opts = vim.deepcopy(widget) --[[@as snacks.win.Config]] + -- adjust max width / height + opts.max_width = math.min(parent.width, opts.max_width or parent.width) + opts.max_height = math.min(parent.height, opts.max_height or parent.height) + local fake_win = setmetatable({ opts = opts }, Snacks.win) + local ret = fake_win:dim(parent) + return ret, fake_win:border_size() +end + +---@param win snacks.layout.Win +---@param parent snacks.win.Dim +---@private +function M:update_win(win, parent) + local w = self.wins[win.win] + w.enabled = true + assert(w, ("win %s not part of layout"):format(win.win)) + -- add win opts from layout + w.opts = vim.tbl_extend( + "force", + vim.deepcopy(self.win_opts[win.win] or {}), + { + width = 0, + height = 0, + enter = false, + }, + win, + { + relative = "win", + win = self.root.win, + backdrop = false, + resize = false, + zindex = self.root.opts.zindex + win.depth, + } + ) + -- adjust max width / height + w.opts.max_width = math.min(parent.width, w.opts.max_width or parent.width) + w.opts.max_height = math.min(parent.height, w.opts.max_height or parent.height) + -- resolve width / height relative to parent box + local dim = w:dim(parent) + w.opts.width, w.opts.height = dim.width, dim.height + local border = w:border_size() + w.opts.col, w.opts.row = parent.col, parent.row + dim.width = dim.width + border.left + border.right + dim.height = dim.height + border.top + border.bottom + -- dim.col = dim.col + border.left + -- dim.row = dim.row + border.top + return dim +end + +--- Toggle fullscreen +function M:maximize() + self.opts.fullscreen = not self.opts.fullscreen + self:update() +end + +--- Close the layout +---@param opts? {wins?: boolean} +function M:close(opts) + if self.closed then + return + end + opts = opts or {} + self.closed = true + for w, win in pairs(self.wins) do + if opts.wins == false then + win.opts = self.win_opts[w] + elseif win:valid() then + win:close() + end + end + for _, win in pairs(self.box_wins) do + win:close() + end +end + +--- Check if layout is valid (visible) +function M:valid() + return self.root:valid() +end + +--- Check if the window has been used in the layout +---@param w string +function M:is_enabled(w) + return not self:is_hidden(w) and self.wins[w].enabled +end + +--- Show the layout +function M:show() + if self:valid() then + return + end + self:update() +end + +return M diff --git a/lua/snacks/meta/docs.lua b/lua/snacks/meta/docs.lua index ed6380dd..5ab4e538 100644 --- a/lua/snacks/meta/docs.lua +++ b/lua/snacks/meta/docs.lua @@ -41,6 +41,16 @@ local query = vim.treesitter.query.parse( (expression_list value: (table_constructor) @example_config) ) @example + + ;; props + (assignment_statement + (variable_list + name: (dot_index_expression + field: (identifier) @prop_name) + @_pn (#lua-match? @_pn "^M%.")) + (expression_list + value: (_) @prop_value) + ) @prop ]] ) @@ -56,14 +66,24 @@ local query = vim.treesitter.query.parse( ---@field captures snacks.docs.Capture[] ---@field comments string[] +---@class snacks.docs.Method +---@field mod string +---@field name string +---@field args string +---@field comment? string +---@field types? string +---@field type "method"|"function"}[] + ---@class snacks.docs.Info ---@field config? string ---@field mod? string ----@field methods {name: string, args: string, comment?: string, types?: string, type: "method"|"function"}[] +---@field modname? string +---@field methods snacks.docs.Method[] ---@field types string[] ---@field setup? string ---@field examples table ---@field styles {name:string, opts:string, comment?:string}[] +---@field props table ---@param lines string[] function M.parse(lines) @@ -85,6 +105,7 @@ function M.parse(lines) ---@type snacks.docs.Parse local ret = { captures = {}, comments = {} } + local used_comments = {} ---@type table for id, node in query:iter_captures(parser:trees()[1]:root(), source) do local name = query.captures[id] if not name:find("_") then @@ -92,7 +113,7 @@ function M.parse(lines) local fields = {} for id2, node2 in query:iter_captures(node, source) do local c = query.captures[id2] - if c:find(".+_") then + if c:find(name .. "_") then fields[c:gsub("^.*_", "")] = vim.treesitter.get_node_text(node2, source) end end @@ -101,7 +122,7 @@ function M.parse(lines) local comment = "" ---@type string if comments[node:start()] then comment = comments[node:start()] - comments[node:start()] = nil + used_comments[node:start()] = true end table.insert(ret.captures, { @@ -114,6 +135,9 @@ function M.parse(lines) }) end end + for l in pairs(used_comments) do + comments[l] = nil + end -- remove comments that are followed by code for l in pairs(comments) do @@ -131,7 +155,9 @@ function M.parse(lines) end ---@param lines string[] -function M.extract(lines) +---@param opts {prefix: string, name:string} +function M.extract(lines, opts) + local fqn = opts.prefix .. "." .. opts.name local parse = M.parse(lines) ---@type snacks.docs.Info local ret = { @@ -141,10 +167,16 @@ function M.extract(lines) end, parse.comments), styles = {}, examples = {}, + props = {}, } for _, c in ipairs(parse.captures) do - if c.comment:find("@private") then + if + c.comment:find("@private") + or c.comment:find("@protected") + or c.comment:find("@package") + or c.comment:find("@hide") + then -- skip private elseif c.name == "local" then if vim.tbl_contains({ "defaults", "config" }, c.fields.name) then @@ -152,13 +184,23 @@ function M.extract(lines) elseif c.fields.name == "M" then ret.mod = c.comment end + elseif c.name == "prop" then + local name = c.fields.name:sub(1) + local value = c.fields.value + ret.props[name] = c.comment == "" and value or c.comment .. "\n" .. value elseif c.name == "fun" then local name = c.fields.name:sub(2) local args = (c.fields.params or ""):sub(2, -2) local type = name:sub(1, 1) name = name:sub(2) if not name:find("^_") then - table.insert(ret.methods, { name = name, args = args, comment = c.comment, type = type }) + table.insert(ret.methods, { + mod = type == ":" and opts.name or fqn, + name = name, + args = args, + comment = c.comment, + type = type, + }) end elseif c.name == "style" then table.insert(ret.styles, { name = c.fields.name, opts = c.fields.config, comment = c.comment }) @@ -167,6 +209,27 @@ function M.extract(lines) end end + if ret.mod then + local mod_lines = vim.split(ret.mod, "\n") + mod_lines = vim.tbl_filter(function(line) + local overload = line:match("^%-%-%-%s*@overload (.*)(%s*)$") --[[@as string?]] + if overload then + table.insert(ret.methods, { + mod = fqn, + name = "", + args = "", + type = "", + comment = "---@type " .. overload, + }) + return false + elseif line:find("^%s*$") then + return false + end + return true + end, mod_lines) + ret.mod = table.concat(mod_lines, "\n") + end + return ret end @@ -186,6 +249,7 @@ end ---@param opts? {extract_comment: boolean} -- default true function M.md(str, opts) str = str or "" + str = str:gsub("\r", "") opts = opts or {} if opts.extract_comment == nil then opts.extract_comment = true @@ -223,13 +287,15 @@ function M.examples(name) return {} end local lines = vim.fn.readfile(fname) - local info = M.extract(lines) + local info = M.extract(lines, { prefix = "Snacks.examples", name = name }) return info.examples end ---@param name string ---@param info snacks.docs.Info -function M.render(name, info) +---@param opts? {setup?:boolean, config?:boolean, styles?:boolean, types?:boolean, prefix?:string, examples?:boolean} +function M.render(name, info, opts) + opts = opts or {} local lines = {} ---@type string[] local function add(line) table.insert(lines, line) @@ -239,8 +305,11 @@ function M.render(name, info) if name == "init" then prefix = "Snacks" end + if info.modname then + prefix = "local M" + end - if name ~= "init" and (info.config or info.setup) then + if name ~= "init" and (info.config or info.setup) and opts.setup ~= false then add("## 📦 Setup\n") add(([[ ```lua @@ -260,24 +329,26 @@ function M.render(name, info) ]]):format(info.setup or name, name)) end - if info.config then + if info.config and opts.config ~= false then add("## ⚙️ Config\n") add(M.md(info.config)) end - local examples = M.examples(name) - local names = vim.tbl_keys(examples) - table.sort(names) - if not vim.tbl_isempty(examples) then - add("## 🚀 Examples\n") - for _, n in ipairs(names) do - local example = examples[n] - add(("### `%s`\n"):format(n)) - add(M.md(example)) + if opts.examples ~= false then + local examples = M.examples(name) + local names = vim.tbl_keys(examples) + table.sort(names) + if not vim.tbl_isempty(examples) then + add("## 🚀 Examples\n") + for _, n in ipairs(names) do + local example = examples[n] + add(("### `%s`\n"):format(n)) + add(M.md(example)) + end end end - if #info.styles > 0 then + if #info.styles > 0 and opts.styles ~= false then table.sort(info.styles, function(a, b) return a.name < b.name end) @@ -303,57 +374,44 @@ docs for more information on how to customize these styles end end - if #info.types > 0 then + if #info.types > 0 and opts.types ~= false then add("## 📚 Types\n") for _, t in ipairs(info.types) do add(M.md(t)) end end - if info.mod or #info.methods > 0 then - add("## 📦 Module\n") + local mod_lines = info.mod and not info.mod:find("^%s*$") and vim.split(info.mod, "\n") or {} + local hide = #mod_lines == 0 or (#mod_lines == 1 and mod_lines[1]:find("@class")) + + if not hide or #info.methods > 0 then + local title = info.modname and ("`%s`"):format(info.modname) or "Module" + add(("## 📦 %s\n"):format(title)) end - if info.mod then - local mod_lines = vim.split(info.mod, "\n") - mod_lines = vim.tbl_filter(function(line) - local overload = line:match("^%-%-%-%s*@overload (.*)(%s*)$") --[[@as string?]] - if overload then - table.insert(info.methods, { - name = "", - args = "", - type = "", - comment = "---@type " .. overload, - }) - return false - elseif line:find("^%s*$") then - return false - end - return true - end, mod_lines) - local hide = #mod_lines == 1 and mod_lines[1]:find("@class") - if not hide then - table.insert(mod_lines, prefix .. " = {}") - add(M.md(table.concat(mod_lines, "\n"))) - end + if info.mod and not hide then + table.insert(mod_lines, prefix .. " = {}") + add(M.md(table.concat(mod_lines, "\n"))) end table.sort(info.methods, function(a, b) + if a.mod ~= b.mod then + return a.mod < b.mod + end if a.type == b.type then return a.name < b.name end return a.type < b.type end) + local last ---@type string? for _, method in ipairs(info.methods) do - add(("### `%s%s%s()`\n"):format(method.type == ":" and name or prefix, method.type, method.name)) - local code = ("%s\n%s%s%s(%s)"):format( - method.comment or "", - method.type == ":" and name or prefix, - method.type, - method.name, - method.args - ) + local title = ("### `%s%s%s()`\n"):format(method.mod, method.type, method.name) + if title ~= last then + last = title + add(title) + end + local code = ("%s\n%s%s%s(%s)"):format(method.comment or "", method.mod, method.type, method.name, method.args) add(M.md(code)) end @@ -383,7 +441,33 @@ function M.write(name, lines) table.insert(top, "") vim.list_extend(top, lines) - vim.fn.writefile(top, path) + vim.fn.writefile(vim.split(table.concat(top, "\n"), "\n"), path) +end + +---@param ret string[] +function M.picker(ret) + local lines = vim.fn.readfile("lua/snacks/picker/config/sources.lua") + local info = M.extract(lines, { prefix = "Snacks.picker", name = "sources" }) + local sources = vim.tbl_keys(info.props) + table.sort(sources) + table.insert(ret, "## 🔍 Sources\n") + for _, source in ipairs(sources) do + local opts = info.props[source] + table.insert(ret, ("### `%s`"):format(source)) + table.insert(ret, "") + table.insert(ret, M.md(opts)) + end + lines = vim.fn.readfile("lua/snacks/picker/config/layouts.lua") + info = M.extract(lines, { prefix = "Snacks.picker", name = "layouts" }) + sources = vim.tbl_keys(info.props) + table.sort(sources) + table.insert(ret, "## 🖼️ Layouts\n") + for _, source in ipairs(sources) do + local opts = info.props[source] + table.insert(ret, ("### `%s`"):format(source)) + table.insert(ret, "") + table.insert(ret, M.md(opts)) + end end function M._build() @@ -401,6 +485,7 @@ function M._build() examples = {}, styles = {}, setup = "---@type table\n styles", + props = {}, } for _, plugin in pairs(plugins) do @@ -408,11 +493,56 @@ function M._build() local name = plugin.name print("[gen] " .. name .. ".md") local lines = vim.fn.readfile(plugin.file) - local info = M.extract(lines) + local info = M.extract(lines, { prefix = "Snacks", name = name }) + + local children = {} ---@type snacks.docs.Info[] + for c, child in pairs(plugin.meta.merge or {}) do + local child_name = type(c) == "number" and child or c --[[@as string]] + local child_file = ("%s/%s/%s"):format(Snacks.meta.root, name, child:gsub("%.", "/")) + for _, f in ipairs({ ".lua", "/init.lua" }) do + if vim.uv.fs_stat(child_file .. f) then + child_file = child_file .. f + break + end + end + assert(vim.uv.fs_stat(child_file), ("file not found: %s"):format(child_file)) + local child_lines = vim.fn.readfile(child_file) + local child_info = M.extract(child_lines, { prefix = "Snacks." .. name, name = child_name }) + child_info.modname = "snacks." .. name .. "." .. child + if child_info.config then + assert(not info.config, "config already exists") + info.config = child_info.config + end + vim.list_extend(info.types, child_info.types) + table.insert(children, child_info) + end + vim.list_extend(styles.styles, info.styles) info.config = name ~= "init" and info.config or nil plugin.meta.config = info.config ~= nil - M.write(name, M.render(name, info)) + + local rendered = {} ---@type string[] + vim.list_extend(rendered, M.render(name, info)) + if name == "picker" then + M.picker(rendered) + end + + for _, child in ipairs(children) do + table.insert(rendered, "") + vim.list_extend( + rendered, + M.render(name, child, { + setup = false, + config = false, + styles = false, + types = false, + examples = false, + }) + ) + end + + M.write(name, rendered) + if plugin.meta.types then table.insert(types.fields, ("---@field %s snacks.%s"):format(plugin.name, plugin.name)) end @@ -454,7 +584,7 @@ end function M.readme(plugins, types) local path = "lua/snacks/init.lua" local lines = vim.fn.readfile(path) --[[ @as string[] ]] - local info = M.extract(lines) + local info = M.extract(lines, { prefix = "Snacks", name = "init" }) local readme = table.concat(vim.fn.readfile("README.md"), "\n") local example = table.concat(vim.fn.readfile("docs/examples/init.lua"), "\n") diff --git a/lua/snacks/meta/init.lua b/lua/snacks/meta/init.lua index 2ce71809..4e602445 100644 --- a/lua/snacks/meta/init.lua +++ b/lua/snacks/meta/init.lua @@ -15,6 +15,7 @@ M.meta = { ---@field health? boolean ---@field types? boolean ---@field config? boolean +---@field merge? { [string|number]: string } ---@class snacks.meta.Plugin ---@field name string @@ -22,15 +23,20 @@ M.meta = { ---@field meta snacks.meta.Meta ---@field health? fun() +M.root = vim.fn.fnamemodify(debug.getinfo(1, "S").source:sub(2), ":h:h") + +function M.file(name) + return vim.fs.normalize(("%s/%s"):format(M.root, name)) +end + --- Get the metadata for all snacks plugins ---@return snacks.meta.Plugin[] function M.get() local ret = {} ---@type snacks.meta.Plugin[] - local root = vim.fn.fnamemodify(debug.getinfo(1, "S").source:sub(2), ":h:h") - for file, t in vim.fs.dir(root, { depth = 1 }) do + for file, t in vim.fs.dir(M.root, { depth = 1 }) do local name = vim.fn.fnamemodify(file, ":t:r") file = t == "directory" and ("%s/init.lua"):format(file) or file - file = root .. "/" .. file + file = M.root .. "/" .. file local mod = name == "init" and setmetatable({ meta = { desc = "Snacks", hide = true } }, { __index = Snacks }) or Snacks[name] --[[@as snacks.meta.Plugin]] assert(type(mod) == "table", ("`Snacks.%s` not found"):format(name)) diff --git a/lua/snacks/meta/types.lua b/lua/snacks/meta/types.lua index 2bc15d0d..95067858 100644 --- a/lua/snacks/meta/types.lua +++ b/lua/snacks/meta/types.lua @@ -12,10 +12,12 @@ ---@field health snacks.health ---@field indent snacks.indent ---@field input snacks.input +---@field layout snacks.layout ---@field lazygit snacks.lazygit ---@field meta snacks.meta ---@field notifier snacks.notifier ---@field notify snacks.notify +---@field picker snacks.picker ---@field profiler snacks.profiler ---@field quickfile snacks.quickfile ---@field rename snacks.rename @@ -38,8 +40,10 @@ ---@field gitbrowse? snacks.gitbrowse.Config|{} ---@field indent? snacks.indent.Config|{} ---@field input? snacks.input.Config|{} +---@field layout? snacks.layout.Config|{} ---@field lazygit? snacks.lazygit.Config|{} ---@field notifier? snacks.notifier.Config|{} +---@field picker? snacks.picker.Config|{} ---@field profiler? snacks.profiler.Config|{} ---@field quickfile? snacks.quickfile.Config|{} ---@field scope? snacks.scope.Config|{} diff --git a/lua/snacks/picker/actions.lua b/lua/snacks/picker/actions.lua new file mode 100644 index 00000000..58bdcb97 --- /dev/null +++ b/lua/snacks/picker/actions.lua @@ -0,0 +1,328 @@ +---@class snacks.picker.actions +---@field [string] snacks.picker.Action.spec +local M = {} + +local SCROLL_WHEEL_DOWN = Snacks.util.keycode("") +local SCROLL_WHEEL_UP = Snacks.util.keycode("") + +function M.edit(picker) + picker:close() + local win = vim.api.nvim_get_current_win() + + -- save position in jump list + vim.api.nvim_win_call(win, function() + vim.cmd("normal! m'") + end) + + local items = picker:selected({ fallback = true }) + for _, item in ipairs(items) do + -- load the buffer + local buf = item.buf ---@type number + if not buf then + local path = assert(Snacks.picker.util.path(item), "Either item.buf or item.file is required") + buf = vim.fn.bufadd(path) + end + + if not vim.api.nvim_buf_is_loaded(buf) then + vim.api.nvim_buf_call(buf, function() + vim.cmd("edit") + end) + vim.bo[buf].buflisted = true + end + + -- set the buffer + vim.api.nvim_win_set_buf(win, buf) + + -- set the cursor + if item.pos then + vim.api.nvim_win_set_cursor(win, { item.pos[1], item.pos[2] }) + elseif item.search then + vim.cmd(item.search) + vim.cmd("noh") + end + -- center + vim.cmd("norm! zzzv") + end +end + +M.cancel = function() end + +M.confirm = M.edit + +function M.toggle_maximize(picker) + picker.layout:maximize() +end + +function M.toggle_preview(picker) + picker.layout:toggle("preview") + picker:show_preview() +end + +---@param items snacks.picker.Item[] +---@param opts? {win?:number} +local function setqflist(items, opts) + local qf = {} ---@type vim.quickfix.entry[] + for _, item in ipairs(items) do + qf[#qf + 1] = { + filename = item.file, + bufnr = item.buf, + lnum = item.pos and item.pos[1] or 1, + col = item.pos and item.pos[2] or 1, + end_lnum = item.end_pos and item.end_pos[1] or nil, + end_col = item.end_pos and item.end_pos[2] or nil, + text = item.text, + pattern = item.search, + valid = true, + } + end + if opts and opts.win then + vim.fn.setloclist(opts.win, qf) + vim.cmd("lopen") + else + vim.fn.setqflist(qf) + vim.cmd("copen") + end +end + +--- Send selected or all items to the quickfix list. +function M.qflist(picker) + picker:close() + local sel = picker:selected() + local items = #sel > 0 and sel or picker.finder.items + setqflist(items) +end + +--- Send selected or all items to the location list. +function M.loclist(picker) + picker:close() + local sel = picker:selected() + local items = #sel > 0 and sel or picker.finder.items + setqflist(items, { win = picker.main }) +end + +function M.copy(_, item) + if item then + vim.fn.setreg("+", item.data or item.text) + end +end + +function M.history_back(picker) + picker:hist() +end + +function M.history_forward(picker) + picker:hist(true) +end + +function M.edit_tab(picker) + picker:close() + vim.cmd("tabnew") + return picker:action("edit") +end + +function M.edit_split(picker) + picker:close() + vim.cmd("split") + return picker:action("edit") +end + +function M.edit_vsplit(picker) + picker:close() + vim.cmd("vsplit") + return picker:action("edit") +end + +--- Toggles the selection of the current item, +--- and moves the cursor to the next item. +function M.select_and_next(picker) + picker.list:select() + M.list_down(picker) +end + +--- Toggles the selection of the current item, +--- and moves the cursor to the prev item. +function M.select_and_prev(picker) + picker.list:select() + M.list_down(picker) +end + +function M.cmd(picker, item) + picker:close() + if item and item.cmd then + vim.schedule(function() + vim.cmd(item.cmd) + end) + end +end + +function M.search(picker, item) + picker:close() + if item then + vim.api.nvim_input("/" .. item.text) + end +end + +--- Tries to load the session, if it fails, it will open the picker. +function M.load_session(picker) + picker:close() + local item = picker:current() + if not item then + return + end + local dir = item.file + local session_loaded = false + vim.api.nvim_create_autocmd("SessionLoadPost", { + once = true, + callback = function() + session_loaded = true + end, + }) + vim.defer_fn(function() + if not session_loaded then + Snacks.picker.files() + end + end, 100) + vim.fn.chdir(dir) + local session = Snacks.dashboard.sections.session() + if session then + vim.cmd(session.action:sub(2)) + end +end + +function M.help(picker) + local item = picker:current() + if item then + picker:close() + vim.cmd("help " .. item.text) + end +end + +function M.preview_scroll_down(picker) + picker.preview.win:scroll() +end + +function M.preview_scroll_up(picker) + picker.preview.win:scroll(true) +end + +function M.toggle_live(picker) + if not picker.opts.supports_live then + Snacks.notify.warn("Live search is not supported for `" .. picker.source_name .. "`", { title = "Snacks Picker" }) + return + end + picker.opts.live = not picker.opts.live + picker.input:set() + picker.input:update() +end + +function M.toggle_focus(picker) + if vim.api.nvim_get_current_win() == picker.input.win.win then + picker.list.win:focus() + else + picker.input.win:focus() + end +end + +function M.cycle_win(picker) + local wins = { picker.input.win.win, picker.preview.win.win, picker.list.win.win } + wins = vim.tbl_filter(function(w) + return vim.api.nvim_win_is_valid(w) + end, wins) + local win = vim.api.nvim_get_current_win() + local idx = 1 + for i, w in ipairs(wins) do + if w == win then + idx = i + break + end + end + win = wins[idx % #wins + 1] or 1 -- cycle + vim.api.nvim_set_current_win(win) + if win == picker.input.win.win then + vim.cmd("startinsert") + end +end + +function M.focus_input(picker) + picker.input.win:focus() + vim.cmd("startinsert") +end + +function M.focus_list(picker) + picker.list.win:focus() +end + +function M.focus_preview(picker) + picker.preview.win:focus() +end + +function M.toggle_ignored(picker) + local opts = picker.opts --[[@as snacks.picker.files.Config]] + opts.ignored = not opts.ignored + picker:find() +end + +function M.toggle_hidden(picker) + local opts = picker.opts --[[@as snacks.picker.files.Config]] + opts.hidden = not opts.hidden + picker:find() +end + +function M.list_top(picker) + picker.list:move(1, true) +end + +function M.list_bottom(picker) + picker.list:move(picker.list:count(), true) +end + +function M.list_down(picker) + picker.list:move(1) +end + +function M.list_up(picker) + picker.list:move(-1) +end + +function M.list_scroll_top(picker) + local cursor = picker.list.cursor + picker.list:view(cursor, cursor) +end + +function M.list_scroll_bottom(picker) + local cursor = picker.list.cursor + picker.list:view(cursor, picker.list.cursor - picker.list:height() + 1) +end + +function M.list_scroll_center(picker) + local cursor = picker.list.cursor + picker.list:view(cursor, picker.list.cursor - math.ceil(picker.list:height() / 2) + 1) +end + +function M.list_scroll_down(picker) + picker.list:scroll(picker.list.state.scroll) +end + +function M.list_scroll_up(picker) + picker.list:scroll(-picker.list.state.scroll) +end + +function M.list_scroll_wheel_down(picker) + local mouse_win = vim.fn.getmousepos().winid + if mouse_win == picker.list.win.win then + picker.list:scroll(picker.list.state.mousescroll) + else + vim.api.nvim_feedkeys(SCROLL_WHEEL_DOWN, "n", true) + end +end + +function M.list_scroll_wheel_up(picker) + local mouse_win = vim.fn.getmousepos().winid + if mouse_win == picker.list.win.win then + picker.list:scroll(-picker.list.state.mousescroll) + else + vim.api.nvim_feedkeys(SCROLL_WHEEL_UP, "n", true) + end +end + +return M diff --git a/lua/snacks/picker/config/defaults.lua b/lua/snacks/picker/config/defaults.lua new file mode 100644 index 00000000..a7f6d64b --- /dev/null +++ b/lua/snacks/picker/config/defaults.lua @@ -0,0 +1,261 @@ +local M = {} + +---@alias snacks.picker.Extmark vim.api.keyset.set_extmark|{col:number, row?:number} +---@alias snacks.picker.Text {[1]:string, [2]:string?, virtual?:boolean} +---@alias snacks.picker.Highlight snacks.picker.Text|snacks.picker.Extmark +---@alias snacks.picker.format fun(item:snacks.picker.Item, picker:snacks.Picker):snacks.picker.Highlight[] +---@alias snacks.picker.preview fun(ctx: snacks.picker.preview.ctx):boolean? +---@alias snacks.picker.sort fun(a:snacks.picker.Item, b:snacks.picker.Item):boolean + +---@class snacks.picker.finder.Item: snacks.picker.Item +---@field idx? number +---@field score? number + +--- Generic filter used by finders to pre-filter items +---@class snacks.picker.filter.Config +---@field cwd? boolean|string only show files for the given cwd +---@field buf? boolean|number only show items for the current or given buffer +---@field paths? table only show items that include or exclude the given paths +---@field filter? fun(item:snacks.picker.finder.Item):boolean custom filter function + +---@class snacks.picker.Item +---@field [string] any +---@field idx number +---@field score number +---@field match_tick? number +---@field text string +---@field pos? {[1]:number, [2]:number} +---@field end_pos? {[1]:number, [2]:number} +---@field highlights? snacks.picker.Highlight[][] + +---@class snacks.picker.sources.Config + +---@class snacks.picker.preview.Config +---@field man_pager? string MANPAGER env to use for `man` preview +---@field file snacks.picker.preview.file.Config + +---@class snacks.picker.preview.file.Config +---@field max_size? number default 1MB +---@field max_line_length? number defaults to 500 +---@field ft? string defaults to auto-detect + +---@class snacks.picker.layout.Config +---@field layout snacks.layout.Box +---@field reverse? boolean when true, the list will be reversed (bottom-up) +---@field fullscreen? boolean open in fullscreen +---@field cycle? boolean cycle through the list +---@field preview? boolean|"main" show preview window in the picker or the main window +---@field preset? string|fun(source:string):string + +---@class snacks.picker.win.Config +---@field input? snacks.win.Config|{} +---@field list? snacks.win.Config|{} +---@field preview? snacks.win.Config|{} + +---@class snacks.picker.Config +---@field source? string source name and config to use +---@field pattern? string|fun(picker:snacks.Picker):string pattern used to filter items by the matcher +---@field search? string|fun(picker:snacks.Picker):string search string used by finders +---@field cwd? string current working directory +---@field live? boolean when true, typing will trigger live searches +---@field limit? number when set, the finder will stop after finding this number of items. useful for live searches +---@field ui_select? boolean set `vim.ui.select` to a snacks picker +--- Source definition +---@field items? snacks.picker.finder.Item[] items to show instead of using a finder +---@field format? snacks.picker.format|string format function or preset +---@field finder? snacks.picker.finder|string finder function or preset +---@field preview? snacks.picker.preview|string preview function or preset +---@field matcher? snacks.picker.matcher.Config matcher config +---@field sort? snacks.picker.sort|snacks.picker.sort.Config sort function or config +--- UI +---@field win? snacks.picker.win.Config +---@field layout? snacks.picker.layout.Config|string|{}|fun(source:string):(snacks.picker.layout.Config|string) +---@field icons? snacks.picker.icons +---@field prompt? string prompt text / icon +--- Preset options +---@field previewers? snacks.picker.preview.Config +---@field sources? snacks.picker.sources.Config|{} +---@field layouts? table +--- Actions +---@field actions? table actions used by keymaps +---@field confirm? snacks.picker.Action.spec shortcut for confirm action +---@field auto_confirm? boolean automatically confirm if there is only one item +---@field main? snacks.picker.main.Config main editor window config +---@field on_change? fun(picker:snacks.Picker, item:snacks.picker.Item) called when the cursor changes +---@field on_show? fun(picker:snacks.Picker) called when the picker is shown +local defaults = { + prompt = " ", + sources = {}, + layout = { + cycle = true, + preset = function() + return vim.o.columns >= 120 and "default" or "vertical" + end, + }, + ui_select = true, -- replace `vim.ui.select` with the snacks picker + previewers = { + file = { + max_size = 1024 * 1024, -- 1MB + max_line_length = 500, + }, + }, + win = { + list = { + keys = { + [""] = "confirm", + ["gg"] = "list_top", + ["G"] = "list_bottom", + ["i"] = "focus_input", + ["j"] = "list_down", + ["k"] = "list_up", + ["q"] = "close", + [""] = "select_and_next", + [""] = "select_and_prev", + [""] = "list_down", + [""] = "list_up", + [""] = "list_scroll_down", + [""] = "list_scroll_up", + ["zt"] = "list_scroll_top", + ["zb"] = "list_scroll_bottom", + ["zz"] = "list_scroll_center", + ["/"] = "toggle_focus", + [""] = "list_scroll_wheel_down", + [""] = "list_scroll_wheel_up", + [""] = "preview_scroll_down", + [""] = "preview_scroll_up", + [""] = "edit_vsplit", + [""] = "edit_split", + [""] = "list_down", + [""] = "list_up", + [""] = "list_down", + [""] = "list_up", + [""] = "cycle_win", + [""] = "close", + }, + }, + input = { + keys = { + [""] = "close", + [""] = "confirm", + ["G"] = "list_bottom", + ["gg"] = "list_top", + ["j"] = "list_down", + ["k"] = "list_up", + ["/"] = "toggle_focus", + ["q"] = "close", + ["?"] = "toggle_help", + [""] = { "toggle_maximize", mode = { "i", "n" } }, + [""] = { "toggle_preview", mode = { "i", "n" } }, + [""] = { "cycle_win", mode = { "i", "n" } }, + [""] = { "", mode = { "i" }, expr = true, desc = "delete word" }, + [""] = { "history_back", mode = { "i", "n" } }, + [""] = { "history_forward", mode = { "i", "n" } }, + [""] = { "select_and_next", mode = { "i", "n" } }, + [""] = { "select_and_prev", mode = { "i", "n" } }, + [""] = { "list_down", mode = { "i", "n" } }, + [""] = { "list_up", mode = { "i", "n" } }, + [""] = { "list_down", mode = { "i", "n" } }, + [""] = { "list_up", mode = { "i", "n" } }, + [""] = { "list_down", mode = { "i", "n" } }, + [""] = { "list_up", mode = { "i", "n" } }, + [""] = { "preview_scroll_up", mode = { "i", "n" } }, + [""] = { "list_scroll_down", mode = { "i", "n" } }, + [""] = { "preview_scroll_down", mode = { "i", "n" } }, + [""] = { "toggle_live", mode = { "i", "n" } }, + [""] = { "list_scroll_up", mode = { "i", "n" } }, + [""] = { "list_scroll_wheel_down", mode = { "i", "n" } }, + [""] = { "list_scroll_wheel_up", mode = { "i", "n" } }, + [""] = { "edit_vsplit", mode = { "i", "n" } }, + [""] = { "edit_split", mode = { "i", "n" } }, + [""] = { "qflist", mode = { "i", "n" } }, + [""] = { "toggle_ignored", mode = { "i", "n" } }, + [""] = { "toggle_hidden", mode = { "i", "n" } }, + }, + b = { + minipairs_disable = true, + }, + }, + preview = { + minimal = false, + wo = { + cursorline = false, + colorcolumn = "", + }, + keys = { + [""] = "close", + ["q"] = "close", + ["i"] = "focus_input", + [""] = "list_scroll_wheel_down", + [""] = "list_scroll_wheel_up", + [""] = "cycle_win", + }, + }, + }, + ---@class snacks.picker.icons + -- stylua: ignore + icons = { + indent = { + vertical = "│ ", + middle = "├╴", + last = "└╴", + }, + ui = { + live = "󰐰 ", + selected = "● ", + -- selected = " ", + }, + git = { + commit = "󰜘 ", + }, + diagnostics = { + Error = " ", + Warn = " ", + Hint = " ", + Info = " ", + }, + kinds = { + Array = " ", + Boolean = "󰨙 ", + Class = " ", + Color = " ", + Control = " ", + Collapsed = " ", + Constant = "󰏿 ", + Constructor = " ", + Copilot = " ", + Enum = " ", + EnumMember = " ", + Event = " ", + Field = " ", + File = " ", + Folder = " ", + Function = "󰊕 ", + Interface = " ", + Key = " ", + Keyword = " ", + Method = "󰊕 ", + Module = " ", + Namespace = "󰦮 ", + Null = " ", + Number = "󰎠 ", + Object = " ", + Operator = " ", + Package = " ", + Property = " ", + Reference = " ", + Snippet = "󱄽 ", + String = " ", + Struct = "󰆼 ", + Text = " ", + TypeParameter = " ", + Unit = " ", + Uknown = " ", + Value = " ", + Variable = "󰀫 ", + }, + }, +} + +M.defaults = defaults + +return M diff --git a/lua/snacks/picker/config/highlights.lua b/lua/snacks/picker/config/highlights.lua new file mode 100644 index 00000000..80e74452 --- /dev/null +++ b/lua/snacks/picker/config/highlights.lua @@ -0,0 +1,81 @@ +---@class snacks.picker.config.highlights +local M = {} + +Snacks.util.set_hl({ + Match = "Special", + Search = "Search", + Prompt = "Special", + InputSearch = "@keyword", + Special = "Special", + Label = "SnacksPickerSpecial", + Totals = "NonText", + File = "", + Dir = "NonText", + Dimmed = "Conceal", + Row = "String", + Col = "LineNr", + Comment = "Comment", + Delim = "Delimiter", + Spinner = "Special", + Selected = "Number", + Idx = "Number", + Bold = "Bold", + Indent = "LineNr", + Italic = "Italic", + Code = "@markup.raw.markdown_inline", + AuPattern = "String", + AuEvent = "Constant", + AuGroup = "Type", + DiagnosticCode = "Special", + DiagnosticSource = "Comment", + Register = "Number", + KeymapMode = "Number", + KeymapLhs = "Special", + BufNr = "Number", + BufFlags = "NonText", + KeymapRhs = "NonText", + GitCommit = "@variable.builtin", + GitBreaking = "Error", + GitDate = "Special", + GitIssue = "Number", + GitType = "Title", -- conventional commit type + GitScope = "Italic", -- conventional commit scope + GitStatus = "NonText", + GitStatusAdded = "Added", + GitStatusModified = "Changed", + GitStatusDeleted = "Removed", + GitStatusRenamed = "SnacksPickerGitStatus", + GitStatusCopied = "SnacksPickerGitStatus", + GitStatusUntracked = "SnacksPickerGitStatus", + ManSection = "Number", + ManPage = "Special", + -- LSP Symbol Kinds + IconArray = "@punctuation.bracket", + IconBoolean = "@boolean", + IconClass = "@type", + IconConstant = "@constant", + IconConstructor = "@constructor", + IconEnum = "@lsp.type.enum", + IconEnumMember = "@lsp.type.enumMember", + IconEvent = "Special", + IconField = "@variable.member", + IconFile = "Normal", + IconFunction = "@function", + IconInterface = "@lsp.type.interface", + IconKey = "@lsp.type.keyword", + IconMethod = "@function.method", + IconModule = "@module", + IconNamespace = "@module", + IconNull = "@constant.builtin", + IconNumber = "@number", + IconObject = "@constant", + IconOperator = "@operator", + IconPackage = "@module", + IconProperty = "@property", + IconString = "@string", + IconStruct = "@lsp.type.struct", + IconTypeParameter = "@lsp.type.typeParameter", + IconVariable = "@variable", +}, { prefix = "SnacksPicker", default = true }) + +return M diff --git a/lua/snacks/picker/config/init.lua b/lua/snacks/picker/config/init.lua new file mode 100644 index 00000000..ba2220fa --- /dev/null +++ b/lua/snacks/picker/config/init.lua @@ -0,0 +1,119 @@ +---@class snacks.picker.config +local M = {} + +---@param opts? snacks.picker.Config +function M.get(opts) + M.setup() + opts = opts or {} + + local sources = require("snacks.picker.config.sources") + local defaults = require("snacks.picker.config.defaults").defaults + defaults.sources = sources + local user = Snacks.config.picker or {} + + local global = Snacks.config.get("picker", defaults, opts) -- defaults + global user config + ---@type snacks.picker.Config[] + local todo = { + defaults, + user, + opts.source and global.sources[opts.source] or {}, + opts, + } + + for _, t in ipairs(todo) do + if t.confirm then + t.actions = t.actions or {} + t.actions.confirm = t.confirm + end + end + + local ret = vim.tbl_deep_extend("force", unpack(todo)) + ret.layouts = ret.layouts or {} + local layouts = require("snacks.picker.config.layouts") + for k, v in pairs(layouts or {}) do + ret.layouts[k] = ret.layouts[k] or v + end + return ret +end + +--- Resolve the layout configuration +---@param opts snacks.picker.Config|string +function M.layout(opts) + if type(opts) == "string" then + opts = M.get({ layout = { preset = opts } }) + end + local layouts = require("snacks.picker.config.layouts") + local layout = M.resolve(opts.layout or {}, opts.source) + layout = type(layout) == "string" and { preset = layout } or layout + ---@cast layout snacks.picker.layout.Config + if layout.layout then + return layout + end + local preset = M.resolve(layout.preset or "custom", opts.source) + local ret = vim.deepcopy(opts.layouts and opts.layouts[preset] or layouts[preset] or {}) + ret = vim.tbl_deep_extend("force", ret, layout or {}) + ret.preset = nil + return ret +end + +---@generic T +---@generic A +---@param v (fun(...:A):T)|unknown +---@param ... A +---@return T +function M.resolve(v, ...) + return type(v) == "function" and v(...) or v +end + +--- Get the finder +---@param finder string|snacks.picker.finder +---@return snacks.picker.finder +function M.finder(finder) + if not finder or type(finder) == "function" then + return finder + end + local mod, fn = finder:match("^(.-)_(.+)$") + if not (mod and fn) then + mod, fn = finder, finder + end + return require("snacks.picker.source." .. mod)[fn] +end + +local did_setup = false +function M.setup() + if did_setup then + return + end + did_setup = true + require("snacks.picker.config.highlights") + for source in pairs(Snacks.picker.config.get().sources) do + M.wrap(source) + end + --- Automatically wrap new sources added after setup + setmetatable(require("snacks.picker.config.sources"), { + __newindex = function(t, k, v) + rawset(t, k, v) + M.wrap(k) + end, + }) +end + +---@param source string +---@param opts? {check?: boolean} +function M.wrap(source, opts) + if opts and opts.check then + local config = M.get() + if not config.sources[source] then + return + end + end + ---@type fun(opts: snacks.picker.Config): snacks.Picker + local ret = function(_opts) + return Snacks.picker.pick(source, _opts) + end + ---@diagnostic disable-next-line: no-unknown + Snacks.picker[source] = ret + return ret +end + +return M diff --git a/lua/snacks/picker/config/layouts.lua b/lua/snacks/picker/config/layouts.lua new file mode 100644 index 00000000..71281223 --- /dev/null +++ b/lua/snacks/picker/config/layouts.lua @@ -0,0 +1,137 @@ +---@class snacks.picker.layouts +---@field [string] snacks.picker.layout.Config +local M = {} + +M.default = { + layout = { + box = "horizontal", + width = 0.8, + min_width = 120, + height = 0.8, + { + box = "vertical", + border = "rounded", + title = "{source} {live}", + title_pos = "center", + { win = "input", height = 1, border = "bottom" }, + { win = "list", border = "none" }, + }, + { win = "preview", border = "rounded", width = 0.5 }, + }, +} + +M.telescope = { + reverse = true, + layout = { + box = "horizontal", + backdrop = false, + width = 0.8, + height = 0.9, + border = "none", + { + box = "vertical", + { win = "list", title = " Results ", title_pos = "center", border = "rounded" }, + { win = "input", height = 1, border = "rounded", title = "{source} {live}", title_pos = "center" }, + }, + { + win = "preview", + width = 0.45, + border = "rounded", + title = " Preview ", + title_pos = "center", + }, + }, +} + +M.ivy = { + layout = { + box = "vertical", + backdrop = false, + row = -1, + width = 0, + height = 0.4, + border = "top", + title = " {source} {live}", + title_pos = "left", + { win = "input", height = 1, border = "bottom" }, + { + box = "horizontal", + { win = "list", border = "none" }, + { win = "preview", width = 0.6, border = "left" }, + }, + }, +} + +M.dropdown = { + layout = { + backdrop = false, + row = 1, + width = 0.4, + min_width = 80, + height = 0.8, + border = "none", + box = "vertical", + { win = "preview", height = 0.4, border = "rounded" }, + { + box = "vertical", + border = "rounded", + title = "{source} {live}", + title_pos = "center", + { win = "input", height = 1, border = "bottom" }, + { win = "list", border = "none" }, + }, + }, +} + +M.vertical = { + layout = { + backdrop = false, + width = 0.5, + min_width = 80, + height = 0.8, + min_height = 30, + box = "vertical", + border = "rounded", + title = "{source} {live}", + title_pos = "center", + { win = "input", height = 1, border = "bottom" }, + { win = "list", border = "none" }, + { win = "preview", height = 0.4, border = "top" }, + }, +} + +M.select = { + preview = false, + layout = { + backdrop = false, + width = 0.5, + min_width = 80, + height = 0.4, + min_height = 10, + box = "vertical", + border = "rounded", + title = " Select ", + title_pos = "center", + { win = "input", height = 1, border = "bottom" }, + { win = "list", border = "none" }, + { win = "preview", height = 0.4, border = "top" }, + }, +} + +M.vscode = { + preview = false, + layout = { + backdrop = false, + row = 1, + width = 0.4, + min_width = 80, + height = 0.4, + border = "none", + box = "vertical", + { win = "input", height = 1, border = "rounded", title = "{source} {live}", title_pos = "center" }, + { win = "list", border = "hpad" }, + { win = "preview", border = "rounded" }, + }, +} + +return M diff --git a/lua/snacks/picker/config/sources.lua b/lua/snacks/picker/config/sources.lua new file mode 100644 index 00000000..2e815929 --- /dev/null +++ b/lua/snacks/picker/config/sources.lua @@ -0,0 +1,509 @@ +---@class snacks.picker.Config +---@field supports_live? boolean + +---@class snacks.picker.sources.Config +---@field [string] snacks.picker.Config|{} +local M = {} + +M.autocmds = { + finder = "vim_autocmds", + format = "autocmd", + preview = "preview", +} + +---@class snacks.picker.buffers.Config: snacks.picker.Config +---@field hidden? boolean show hidden buffers (unlisted) +---@field unloaded? boolean show loaded buffers +---@field current? boolean show current buffer +---@field nofile? boolean show `buftype=nofile` buffers +---@field sort_lastused? boolean sort by last used +---@field filter? snacks.picker.filter.Config +M.buffers = { + finder = "buffers", + format = "buffer", + hidden = false, + unloaded = true, + current = true, + sort_lastused = true, +} + +M.cliphist = { + finder = "system_cliphist", + format = "text", + preview = "preview", + confirm = { "copy", "close" }, +} + +-- Neovim colorschemes with live preview +M.colorschemes = { + finder = "vim_colorschemes", + format = "text", + preview = "colorscheme", + preset = "vertical", + confirm = function(picker, item) + picker:close() + if item then + picker.preview.state.colorscheme = nil + vim.schedule(function() + vim.cmd("colorscheme " .. item.text) + end) + end + end, +} + +-- Neovim command history +---@type snacks.picker.history.Config +M.command_history = { + finder = "vim_history", + name = "cmd", + format = "text", + preview = "none", + layout = { + preset = "vscode", + }, + confirm = "cmd", +} + +-- Neovim commands +M.commands = { + finder = "vim_commands", + format = "text", + preview = "preview", + confirm = "cmd", +} + +---@class snacks.picker.diagnostics.Config: snacks.picker.Config +---@field filter? snacks.picker.filter.Config +---@field severity? vim.diagnostic.SeverityFilter +M.diagnostics = { + finder = "diagnostics", + format = "diagnostic", + sort = { + fields = { + "is_current", + "is_cwd", + "severity", + "file", + "lnum", + }, + }, + -- only show diagnostics from the cwd by default + filter = { cwd = true }, +} + +---@type snacks.picker.diagnostics.Config +M.diagnostics_buffer = { + finder = "diagnostics", + format = "diagnostic", + sort = { + fields = { "severity", "file", "lnum" }, + }, + filter = { buf = true }, +} + +---@class snacks.picker.files.Config: snacks.picker.proc.Config +---@field cmd? string +---@field hidden? boolean show hidden files +---@field ignored? boolean show ignored files +---@field dirs? string[] directories to search +---@field follow? boolean follow symlinks +M.files = { + finder = "files", + format = "file", + hidden = false, + ignored = false, + follow = false, + supports_live = true, +} + +-- Find git files +---@class snacks.picker.git.files.Config: snacks.picker.Config +---@field untracked? boolean show untracked files +---@field submodules? boolean show submodule files +M.git_files = { + finder = "git_files", + format = "file", + untracked = false, + submodules = false, +} + +-- Git log +---@class snacks.picker.git.log.Config: snacks.picker.Config +---@field follow? boolean track file history across renames +---@field current_file? boolean show current file log +---@field current_line? boolean show current line log +M.git_log = { + finder = "git_log", + format = "git_log", + preview = "git_show", + confirm = "close", +} + +---@type snacks.picker.git.log.Config +M.git_log_file = { + finder = "git_log", + format = "git_log", + preview = "git_show", + current_file = true, + follow = true, + confirm = "close", +} + +---@type snacks.picker.git.log.Config +M.git_log_line = { + finder = "git_log", + format = "git_log", + preview = "git_show", + current_line = true, + follow = true, + confirm = "close", +} + +M.git_status = { + finder = "git_status", + format = "git_status", + preview = "git_status", +} + +---@class snacks.picker.grep.Config: snacks.picker.proc.Config +---@field cmd? string +---@field hidden? boolean show hidden files +---@field ignored? boolean show ignored files +---@field dirs? string[] directories to search +---@field follow? boolean follow symlinks +---@field glob? string|string[] glob file pattern(s) +---@field buffers? boolean search in open buffers +---@field need_search? boolean require a search pattern +M.grep = { + finder = "grep", + format = "file", + live = true, -- live grep by default + supports_live = true, +} + +---@type snacks.picker.grep.Config +M.grep_buffers = { + finder = "grep", + format = "file", + live = true, + buffers = true, + need_search = false, + supports_live = true, +} + +---@type snacks.picker.grep.Config +M.grep_word = { + finder = "grep", + format = "file", + search = function(picker) + return picker:word() + end, + live = false, + supports_live = true, +} + +-- Neovim help tags +---@class snacks.picker.help.Config: snacks.picker.Config +---@field lang? string[] defaults to `vim.opt.helplang` +M.help = { + finder = "help", + format = "text", + previewers = { + file = { ft = "help" }, + }, + win = { + preview = { + minimal = true, + }, + }, + confirm = "help", +} + +M.highlights = { + finder = "vim_highlights", + format = "hl", + preview = "preview", +} + +M.jumps = { + finder = "vim_jumps", + format = "file", +} + +---@class snacks.picker.keymaps.Config: snacks.picker.Config +---@field global? boolean show global keymaps +---@field local? boolean show buffer keymaps +---@field modes? string[] +M.keymaps = { + finder = "vim_keymaps", + format = "keymap", + preview = "preview", + global = true, + ["local"] = true, + modes = { "n", "v", "x", "s", "o", "i", "c", "t" }, + confirm = function(picker, item) + picker:close() + if item then + vim.api.nvim_input(item.item.lhs) + end + end, +} + +-- Search lines in the current buffer +---@class snacks.picker.lines.Config: snacks.picker.Config +---@field buf? number +M.lines = { + finder = "lines", + format = "lines", + layout = { + preview = "main", + preset = "ivy", + }, + -- allow any window to be used as the main window + main = { current = true }, + ---@param picker snacks.Picker + on_show = function(picker) + local cursor = vim.api.nvim_win_get_cursor(picker.main) + local info = vim.api.nvim_win_call(picker.main, vim.fn.winsaveview) + picker.list:view(cursor[1], info.topline) + picker:show_preview() + end, +} + +-- Loclist +---@type snacks.picker.qf.Config +M.loclist = { + finder = "qf", + format = "file", + qf_win = 0, +} + +---@class snacks.picker.lsp.Config: snacks.picker.Config +---@field include_current? boolean default false +---@field unique_lines? boolean include only locations with unique lines +---@field filter? snacks.picker.filter.Config + +-- LSP declarations +---@type snacks.picker.lsp.Config +M.lsp_declarations = { + finder = "lsp_declarations", + format = "file", + include_current = false, + auto_confirm = true, +} + +-- LSP definitions +---@type snacks.picker.lsp.Config +M.lsp_definitions = { + finder = "lsp_definitions", + format = "file", + include_current = false, + auto_confirm = true, +} + +-- LSP implementations +---@type snacks.picker.lsp.Config +M.lsp_implementations = { + finder = "lsp_implementations", + format = "file", + include_current = false, + auto_confirm = true, +} + +-- LSP references +---@class snacks.picker.lsp.references.Config: snacks.picker.lsp.Config +---@field include_declaration? boolean default true +M.lsp_references = { + finder = "lsp_references", + format = "file", + include_declaration = true, + include_current = false, + auto_confirm = true, +} + +-- LSP document symbols +---@class snacks.picker.lsp.symbols.Config: snacks.picker.Config +---@field hierarchy? boolean show symbol hierarchy +---@field filter table? symbol kind filter +M.lsp_symbols = { + finder = "lsp_symbols", + format = "lsp_symbol", + hierarchy = true, + filter = { + default = { + "Class", + "Constructor", + "Enum", + "Field", + "Function", + "Interface", + "Method", + "Module", + "Namespace", + "Package", + "Property", + "Struct", + "Trait", + }, + -- set to `true` to include all symbols + markdown = true, + help = true, + -- you can specify a different filter for each filetype + lua = { + "Class", + "Constructor", + "Enum", + "Field", + "Function", + "Interface", + "Method", + "Module", + "Namespace", + -- "Package", -- remove package since luals uses it for control flow structures + "Property", + "Struct", + "Trait", + }, + }, +} + +-- LSP type definitions +---@type snacks.picker.lsp.Config +M.lsp_type_definitions = { + finder = "lsp_type_definitions", + format = "file", + include_current = false, + auto_confirm = true, +} + +M.man = { + finder = "system_man", + format = "man", + preview = "man", + confirm = function(picker, item) + picker:close() + if item then + vim.schedule(function() + vim.cmd("Man " .. item.ref) + end) + end + end, +} + +---@class snacks.picker.marks.Config: snacks.picker.Config +---@field global? boolean show global marks +---@field local? boolean show buffer marks +M.marks = { + finder = "vim_marks", + format = "file", + global = true, + ["local"] = true, +} + +-- List all available sources +M.pickers = { + finder = "meta_pickers", + format = "text", + confirm = function(picker, item) + picker:close() + if item then + Snacks.picker(item.text) + end + end, +} + +M.picker_actions = { + finder = "meta_actions", + format = "text", +} +M.picker_format = { + finder = "meta_format", + format = "text", +} +M.picker_layouts = { + finder = "meta_layouts", + format = "text", + on_change = function(picker, item) + vim.schedule(function() + picker:set_layout(item.text) + end) + end, +} +M.picker_preview = { + finder = "meta_preview", + format = "text", +} + +-- Open recent projects +---@class snacks.picker.projects.Config: snacks.picker.Config +---@field filter? snacks.picker.filter.Config +M.projects = { + finder = "recent_projects", + format = "file", + confirm = "load_session", + win = { + preview = { + minimal = true, + }, + }, +} + +-- Quickfix list +---@type snacks.picker.qf.Config +M.qflist = { + finder = "qf", + format = "file", +} + +-- Find recent files +---@class snacks.picker.recent.Config: snacks.picker.Config +---@field filter? snacks.picker.filter.Config +M.recent = { + finder = "recent_files", + format = "file", + filter = { + paths = { + [vim.fn.stdpath("data")] = false, + [vim.fn.stdpath("cache")] = false, + [vim.fn.stdpath("state")] = false, + }, + }, +} + +-- Neovim registers +M.registers = { + finder = "vim_registers", + format = "register", + preview = "preview", + confirm = { "copy", "close" }, +} + +-- Special picker that resumes the last picker +M.resume = {} + +-- Neovim search history +---@type snacks.picker.history.Config +M.search_history = { + finder = "vim_history", + name = "search", + format = "text", + preview = "none", + layout = { + preset = "vscode", + }, + confirm = "search", +} + +-- Open a project from zoxide +M.zoxide = { + finder = "files_zoxide", + format = "file", + confirm = "load_session", + win = { + preview = { + minimal = true, + }, + }, +} + +return M diff --git a/lua/snacks/picker/core/_health.lua b/lua/snacks/picker/core/_health.lua new file mode 100644 index 00000000..117db4ac --- /dev/null +++ b/lua/snacks/picker/core/_health.lua @@ -0,0 +1,51 @@ +local M = {} + +---@private +function M.health() + local config = Snacks.picker.config.get() + if Snacks.config.get("picker", {}).enabled and config.ui_select then + if vim.ui.select == Snacks.picker.select then + Snacks.health.ok("`vim.ui.select` is set to `Snacks.picker.select`") + else + Snacks.health.error("`vim.ui.select` is not set to `Snacks.picker.select`") + end + else + Snacks.health.warn("`vim.ui.select` for `Snacks.picker` is not enabled") + end + for _, lang in ipairs({ "regex" }) do + local has_lang = pcall(vim.treesitter.language.add, lang) + if has_lang then + Snacks.health.ok("Treesitter language `" .. lang .. "` is available") + else + Snacks.health.error("Treesitter language `" .. lang .. "` is not available") + end + end + local is_win = jit.os:find("Windows") + local function have(tool) + if vim.fn.executable(tool) == 1 then + local version = vim.fn.system(tool .. " --version") or "" + version = vim.trim(vim.split(version, "\n")[1]) + Snacks.health.ok("'" .. tool .. "' `" .. version .. "`") + return true + end + end + local required = { { "git" }, { "rg" }, { "fd", "fdfind", not is_win and "find" or nil } } + for _, tools in ipairs(required) do + local found = false + for _, tool in ipairs(tools) do + if have(tool) then + found = true + end + end + if not found then + Snacks.health.error("None of the tools found: " .. table.concat( + vim.tbl_map(function() + return "'" .. tostring(_) .. "'" + end, tools), + ", " + )) + end + end +end + +return M diff --git a/lua/snacks/picker/core/actions.lua b/lua/snacks/picker/core/actions.lua new file mode 100644 index 00000000..fcf9128e --- /dev/null +++ b/lua/snacks/picker/core/actions.lua @@ -0,0 +1,89 @@ +local M = {} + +---@alias snacks.picker.Action.fn fun(self: snacks.Picker, item?:snacks.picker.Item):(boolean|string?) +---@alias snacks.picker.Action.spec.one string|snacks.picker.Action|snacks.picker.Action.fn +---@alias snacks.picker.Action.spec snacks.picker.Action.spec.one|snacks.picker.Action.spec.one[] + +---@class snacks.picker.Action +---@field action snacks.picker.Action.fn +---@field desc? string + +---@param picker snacks.Picker +function M.get(picker) + local ref = Snacks.util.ref(picker) + ---@type table + local ret = {} + setmetatable(ret, { + ---@param t table + ---@param k string + __index = function(t, k) + if type(k) ~= "string" then + return + end + local p = ref() + if not p then + return + end + t[k] = M.resolve(k, p, k) or false + return rawget(t, k) + end, + }) + return ret +end + +---@param action snacks.picker.Action.spec +---@param picker snacks.Picker +---@param name? string +---@return snacks.picker.Action? +function M.resolve(action, picker, name) + if not action then + assert(name, "Missing action without name") + local fn, desc = picker.input.win[name], name + return { + action = function() + if not fn then + return name + end + fn(picker.input.win) + end, + desc = desc, + } + elseif type(action) == "string" then + return M.resolve( + (picker.opts.actions or {})[action] or require("snacks.picker.actions")[action], + picker, + action:gsub("_ ", " ") + ) + elseif type(action) == "table" and vim.islist(action) then + ---@type snacks.picker.Action[] + local actions = vim.tbl_map(function(a) + return M.resolve(a, picker) + end, action) + return { + action = function(_, item) + for _, a in ipairs(actions) do + a.action(picker, item) + end + end, + desc = table.concat( + vim.tbl_map(function(a) + return a.desc + end, actions), + ", " + ), + } + end + action = type(action) == "function" and { + action = action, + desc = name or nil, + } or action + ---@cast action snacks.picker.Action + return { + action = function() + return action.action(picker, picker:current()) + end, + desc = action.desc, + } +end + +return M diff --git a/lua/snacks/picker/core/filter.lua b/lua/snacks/picker/core/filter.lua new file mode 100644 index 00000000..6b1089f1 --- /dev/null +++ b/lua/snacks/picker/core/filter.lua @@ -0,0 +1,101 @@ +---@class snacks.picker.Filter +---@field pattern string Pattern used to filter items by the matcher +---@field search string Initial search string used by finders +---@field buf? number +---@field file? string +---@field cwd string +---@field all boolean +---@field paths {path:string, want:boolean}[] +---@field opts snacks.picker.filter.Config +local M = {} +M.__index = M + +local uv = vim.uv or vim.loop + +---@param picker snacks.Picker +function M.new(picker) + local opts = picker.opts ---@type snacks.picker.Config|{filter?:snacks.picker.filter.Config} + local self = setmetatable({}, M) + self.opts = opts.filter or {} + local function gets(v) + return type(v) == "function" and v(picker) or v or "" --[[@as string]] + end + self.pattern = gets(opts.pattern) + self.search = gets(opts.search) + local filter = opts.filter + self.all = not filter or not (filter.cwd or filter.buf or filter.paths or filter.filter) + self.paths = {} + local cwd = filter and filter.cwd + self.cwd = type(cwd) == "string" and cwd or opts.cwd or uv.cwd() or "." + self.cwd = vim.fs.normalize(self.cwd --[[@as string]], { _fast = true }) + if not self.all and filter then + self.buf = filter.buf == true and 0 or filter.buf --[[@as number?]] + self.buf = self.buf == 0 and vim.api.nvim_get_current_buf() or self.buf + self.file = self.buf and vim.fs.normalize(vim.api.nvim_buf_get_name(self.buf), { _fast = true }) or nil + for path, want in pairs(filter.paths or {}) do + table.insert(filter, { path = vim.fs.normalize(path), want = want }) + end + end + return self +end + +---@param opts? {trim?:boolean} +---@return snacks.picker.Filter +function M:clone(opts) + local ret = setmetatable({}, { __index = self }) + if opts and opts.trim then + ret.pattern = vim.trim(self.pattern) + ret.search = vim.trim(self.search) + else + ret.pattern = self.pattern + ret.search = self.search + end + return ret +end + +---@param item snacks.picker.finder.Item):boolean +function M:match(item) + if self.all then + return true + end + if self.opts.filter and not self.opts.filter(item) then + return false + end + if self.buf and (item.buf ~= self.buf) and (item.file ~= self.file) then + return false + end + if not (self.opts.cwd or self.opts.paths) then + return true + end + local path = Snacks.picker.util.path(item) + if not path then + return false + end + if self.opts.cwd and path:sub(1, #self.cwd) ~= self.cwd then + return false + end + if self.opts.paths then + for _, p in ipairs(self.paths) do + if (path:sub(1, #p.path) == p.path) ~= p.want then + return false + end + end + end + return true +end + +---@param items snacks.picker.finder.Item[] +function M:filter(items) + if self.all then + return items + end + local ret = {} ---@type snacks.picker.finder.Item[] + for _, item in ipairs(items) do + if self:match(item) then + table.insert(ret, item) + end + end + return ret +end + +return M diff --git a/lua/snacks/picker/core/finder.lua b/lua/snacks/picker/core/finder.lua new file mode 100644 index 00000000..cc45efba --- /dev/null +++ b/lua/snacks/picker/core/finder.lua @@ -0,0 +1,88 @@ +local Async = require("snacks.picker.util.async") + +---@class snacks.picker.Finder +---@field _find snacks.picker.finder +---@field task snacks.picker.Async +---@field items snacks.picker.finder.Item[] +---@field filter? snacks.picker.Filter +local M = {} +M.__index = M + +---@alias snacks.picker.finder fun(opts:snacks.picker.Config, filter:snacks.picker.Filter): (snacks.picker.finder.Item[] | fun(cb:async fun(item:snacks.picker.finder.Item))) + +local YIELD_FIND = 1 -- ms + +---@param find snacks.picker.finder +function M.new(find) + local self = setmetatable({}, M) + self._find = find + self.task = Async.nop() + self.items = {} + return self +end + +function M:running() + return self.task:running() +end + +function M:abort() + self.task:abort() +end + +function M:count() + return #self.items +end + +---@param search string +function M:changed(search) + search = vim.trim(search) + return not self.filter or self.filter.search ~= search +end + +---@param picker snacks.Picker +function M:run(picker) + local score = require("snacks.picker.core.matcher").DEFAULT_SCORE + self.task:abort() + self.items = {} + local yield ---@type fun() + self.filter = picker.input.filter:clone({ trim = true }) + local finder = self._find(picker.opts, self.filter) + local limit = picker.opts.limit or math.huge + + -- PERF: if finder is a table, we can skip the async part + if type(finder) == "table" then + local items = finder --[[@as snacks.picker.finder.Item[] ]] + for i, item in ipairs(items) do + item.idx, item.score = i, score + self.items[i] = item + end + return + end + + collectgarbage("stop") -- moar speed + ---@cast finder fun(cb:async fun(item:snacks.picker.finder.Item)) + ---@diagnostic disable-next-line: await-in-sync + self.task = Async.new(function() + ---@async + finder(function(item) + if #self.items >= limit then + self.task:abort() + if coroutine.running() then + Async.yield() + end + return + end + item.idx, item.score = #self.items + 1, score + self.items[item.idx] = item + picker.matcher.task:resume() + yield = yield or Async.yielder(YIELD_FIND) + yield() + end) + end):on("done", function() + collectgarbage("restart") + picker.matcher.task:resume() + picker:update() + end) +end + +return M diff --git a/lua/snacks/picker/core/input.lua b/lua/snacks/picker/core/input.lua new file mode 100644 index 00000000..e2445652 --- /dev/null +++ b/lua/snacks/picker/core/input.lua @@ -0,0 +1,147 @@ +---@class snacks.picker.input +---@field win snacks.win +---@field totals string +---@field picker snacks.Picker +---@field _statuscolumn string +---@field filter snacks.picker.Filter +local M = {} +M.__index = M + +local uv = vim.uv or vim.loop +local ns = vim.api.nvim_create_namespace("snacks.picker.input") + +---@param picker snacks.Picker +function M.new(picker) + local self = setmetatable({}, M) + self.totals = "" + self.picker = picker + self.filter = require("snacks.picker.core.filter").new(picker) + self._statuscolumn = self:statuscolumn() + + self.win = Snacks.win(Snacks.win.resolve(picker.opts.win.input, { + show = false, + enter = true, + height = 1, + text = picker.opts.live and self.filter.search or self.filter.pattern, + ft = "regex", + on_win = function() + vim.fn.prompt_setprompt(self.win.buf, "") + self.win:focus() + vim.cmd.startinsert() + vim.api.nvim_win_set_cursor(self.win.win, { 1, #self:get() + 1 }) + vim.fn.prompt_setcallback(self.win.buf, function() + self.win:execute("confirm") + end) + vim.fn.prompt_setinterrupt(self.win.buf, function() + self.win:close() + end) + end, + bo = { + filetype = "snacks_picker_input", + buftype = "prompt", + }, + wo = { + statuscolumn = self._statuscolumn, + cursorline = false, + winhighlight = Snacks.picker.highlight.winhl("SnacksPickerInput"), + }, + })) + + self.win:on( + { "TextChangedI", "TextChanged" }, + Snacks.util.throttle(function() + if not self.win:valid() then + return + end + vim.bo[self.win.buf].modified = false + local pattern = self:get() + if self.picker.opts.live then + self.filter.search = pattern + else + self.filter.pattern = pattern + end + picker:match() + end, { ms = picker.opts.live and 100 or 30 }), + { buf = true } + ) + return self +end + +function M:statuscolumn() + local parts = {} ---@type string[] + local function add(str, hl) + if str then + parts[#parts + 1] = ("%%#%s#%s%%*"):format(hl, str:gsub("%%", "%%")) + end + end + local pattern = self.picker.opts.live and self.filter.pattern or self.filter.search + if pattern ~= "" then + if #pattern > 20 then + pattern = Snacks.picker.util.truncate(pattern, 20) + end + add(pattern, "SnacksPickerInputSearch") + end + add(self.picker.opts.prompt or " ", "SnacksPickerPrompt") + return table.concat(parts, " ") +end + +function M:update() + if not self.win:valid() then + return + end + local sc = self:statuscolumn() + if self._statuscolumn ~= sc then + self._statuscolumn = sc + vim.wo[self.win.win].statuscolumn = sc + end + local line = {} ---@type snacks.picker.Highlight[] + if self.picker:is_active() then + line[#line + 1] = { M.spinner(), "SnacksPickerSpinner" } + line[#line + 1] = { " " } + end + local selected = #self.picker.list.selected + if selected > 0 then + line[#line + 1] = { ("(%d)"):format(selected), "SnacksPickerTotals" } + line[#line + 1] = { " " } + end + line[#line + 1] = { ("%d/%d"):format(self.picker.list:count(), #self.picker.finder.items), "SnacksPickerTotals" } + line[#line + 1] = { " " } + local totals = table.concat(vim.tbl_map(function(v) + return v[1] + end, line)) + if self.totals == totals then + return + end + self.totals = totals + vim.api.nvim_buf_set_extmark(self.win.buf, ns, 0, 0, { + id = 999, + virt_text = line, + virt_text_pos = "right_align", + }) +end + +function M:get() + return self.win:line() +end + +---@param pattern? string +---@param search? string +function M:set(pattern, search) + self.filter.pattern = pattern or self.filter.pattern + self.filter.search = search or self.filter.search + vim.api.nvim_buf_set_lines(self.win.buf, 0, -1, false, { + self.picker.opts.live and self.filter.search or self.filter.pattern, + }) + vim.api.nvim_win_set_cursor(self.win.win, { 1, #self:get() + 1 }) + self.totals = "" + self._statuscolumn = "" + self:update() + self.picker:update_titles() +end + +function M.spinner() + local spinner = { "⠋", "⠙", "⠹", "⠸", "⠼", "⠴", "⠦", "⠧", "⠇", "⠏" } + return spinner[math.floor(uv.hrtime() / (1e6 * 80)) % #spinner + 1] +end + +return M diff --git a/lua/snacks/picker/core/list.lua b/lua/snacks/picker/core/list.lua new file mode 100644 index 00000000..0a10d39a --- /dev/null +++ b/lua/snacks/picker/core/list.lua @@ -0,0 +1,465 @@ +---@class snacks.picker.list +---@field picker snacks.Picker +---@field items snacks.picker.Item[] +---@field top number +---@field cursor number +---@field win snacks.win +---@field dirty boolean +---@field state snacks.picker.list.State +---@field paused boolean +---@field topk snacks.picker.MinHeap +---@field _current? snacks.picker.Item +---@field did_preview? boolean +---@field reverse? boolean +---@field selected snacks.picker.Item[] +---@field selected_map table +local M = {} +M.__index = M + +---@class snacks.picker.list.State +---@field height number +---@field scrolloff number +---@field scroll number +---@field mousescroll number + +local ns = vim.api.nvim_create_namespace("snacks.picker.list") + +local function minmax(value, min, max) + return math.max(min, math.min(value, max)) +end + +---@param picker snacks.Picker +function M.new(picker) + local self = setmetatable({}, M) + self.reverse = picker.opts.layout.reverse + self.picker = picker + self.selected = {} + self.selected_map = {} + local win_opts = Snacks.win.resolve(picker.opts.win.list, { + show = false, + enter = false, + on_win = function() + self:on_show() + end, + minimal = true, + bo = { modifiable = false, filetype = "snacks_picker_list" }, + wo = { + conceallevel = 3, + concealcursor = "nvc", + cursorline = false, + winhighlight = Snacks.picker.highlight.winhl("SnacksPickerList", { CursorLine = "Visual" }), + }, + }) + self.win = Snacks.win(win_opts) + self.top, self.cursor = 1, 1 + self.items = {} + self.state = { height = 0, scrolloff = 0, scroll = 0, mousescroll = 1 } + self.dirty = true + self.topk = require("snacks.picker.util.minheap").new({ + capacity = 1000, + cmp = self.picker.sort, + }) + self.win:on("CursorMoved", function() + if not self.win:valid() then + return + end + local cursor = vim.api.nvim_win_get_cursor(self.win.win) + if cursor[1] ~= self:idx2row(self.cursor) then + local idx = self:row2idx(cursor[1]) + self:_move(idx, true, true) + end + end, { buf = true }) + + self.win:on("VimResized", function() + self.state.height = vim.api.nvim_win_get_height(self.win.win) + self.dirty = true + self:update() + end) + self.win:on("WinResized", function() + if vim.tbl_contains(vim.v.event.windows, self.win.win) then + self.state.height = vim.api.nvim_win_get_height(self.win.win) + self.dirty = true + self:update() + end + end) + self.win:on({ "WinEnter", "WinLeave" }, function() + self:update_cursorline() + end) + + return self +end + +---@param cursor number +---@param topline? number +function M:view(cursor, topline) + if topline then + self:scroll(topline, true, false) + end + self:move(cursor, true) +end + +---@param idx number +function M:idx2row(idx) + local ret = idx - self.top + 1 + if not self.reverse then + return ret + end + return self.state.height - ret + 1 +end + +---@param row number +function M:row2idx(row) + local ret = row + self.top - 1 + if not self.reverse then + return ret + end + return self.state.height - ret + 1 +end + +function M:on_show() + self.state.scrolloff = vim.wo[self.win.win].scrolloff + self.state.scroll = vim.wo[self.win.win].scroll + self.state.height = vim.api.nvim_win_get_height(self.win.win) + self.state.mousescroll = tonumber(vim.o.mousescroll:match("ver:(%d+)")) or 1 + vim.wo[self.win.win].scrolloff = 0 + self.dirty = true +end + +function M:count() + return #self.items +end + +function M:close() + self.win:close() + self.items = {} + self.topk:clear() +end + +function M:scrolloff() + local scrolloff = math.min(self.state.scrolloff, math.floor(self:height() / 2)) + local offset = math.min(self.cursor, self:count() - self.cursor) + return offset > scrolloff and scrolloff or 0 +end + +---@param to number +---@param absolute? boolean +---@param render? boolean +function M:_scroll(to, absolute, render) + local old_top = self.top + self.top = absolute and to or self.top + to + self.top = minmax(self.top, 1, self:count() - self:height() + 1) + self.cursor = absolute and to or self.cursor + to + local scrolloff = self:scrolloff() + self.cursor = minmax(self.cursor, self.top + scrolloff, self.top + self:height() - 1 - scrolloff) + self.dirty = self.dirty or self.top ~= old_top + if render ~= false then + self:render() + end +end + +---@param to number +---@param absolute? boolean +---@param render? boolean +function M:scroll(to, absolute, render) + if self.reverse then + to = absolute and (self:count() - to + 1) or -1 * to + end + self:_scroll(to, absolute, render) +end + +---@param to number +---@param absolute? boolean +---@param render? boolean +function M:_move(to, absolute, render) + local old_top = self.top + local height = self:height() + if height <= 1 then + self.cursor, self.top = 1, 1 + else + self.cursor = absolute and to or self.cursor + to + if self.picker.opts.layout.cycle then + self.cursor = (self.cursor - 1) % self:count() + 1 + end + self.cursor = minmax(self.cursor, 1, self:count()) + local scrolloff = self:scrolloff() + self.top = minmax(self.top, self.cursor - self:height() + scrolloff + 1, self.cursor - scrolloff) + end + self.dirty = self.dirty or self.top ~= old_top + if render ~= false then + self:render() + end +end + +---@param to number +---@param absolute? boolean +---@param render? boolean +function M:move(to, absolute, render) + if self.reverse then + to = absolute and (self:count() - to + 1) or -1 * to + end + self:_move(to, absolute, render) +end + +function M:clear() + self.topk:clear() + self.top, self.cursor = 1, 1 + self.items = {} + self.dirty = true + if next(self.items) == nil then + return + end + self:update() +end + +function M:pause(ms) + self.paused = true + vim.defer_fn(function() + self:unpause() + end, ms) +end + +function M:add(item) + local idx = #self.items + 1 + self.items[idx] = item + local added, prev = self.topk:add(item) + if added then + self.dirty = true + if prev then + -- replace with previous item, since new item is now in topk + self.items[idx] = prev + end + elseif not self.dirty then + self.dirty = idx >= self.top and idx <= self.top + (self.state.height or 50) + end +end + +---@return snacks.picker.Item? +function M:current() + return self:get(self.cursor) +end + +--- Returns the item at the given sorted index. +--- Item will be taken from topk if available, otherwise from items. +--- In case the matcher is running, the item will be taken from the finder. +---@param idx number +---@return snacks.picker.Item? +function M:get(idx) + return self.topk:get(idx) or self.items[idx] or self.picker.finder.items[idx] +end + +function M:height() + return math.min(self.state.height, self:count()) +end + +function M:update() + if vim.in_fast_event() then + return vim.schedule(function() + self:update() + end) + end + if self.paused and #self.items < self.state.height then + return + end + if not self.win:valid() then + return + end + self:render() +end + +-- Toggle selection of current item +function M:select() + local item = self:current() + if not item then + return + end + local key = self:select_key(item) + if self.selected_map[key] then + self.selected_map[key] = nil + self.selected = vim.tbl_filter(function(v) + return self:select_key(v) ~= key + end, self.selected) + else + self.selected_map[key] = item + table.insert(self.selected, item) + end + self.picker.input:update() + self.dirty = true + self:render() +end + +---@param item snacks.picker.Item +---@return string +function M:select_key(item) + item._select_key = item._select_key + or Snacks.picker.util.text(item, { "text", "file", "key", "id", "pos", "end_pos" }) + return item._select_key +end + +---@param items snacks.picker.Item[] +function M:set_selected(items) + self.selected = items + self.selected_map = {} + for _, item in ipairs(items) do + self.selected_map[self:select_key(item)] = item + end + self.picker.input:update() + self.dirty = true + self:update() +end + +function M:unpause() + if not self.paused then + return + end + self.paused = false + self:update() +end + +---@param item snacks.picker.Item +function M:format(item) + local line = self.picker.format(item, self.picker) + local parts = {} ---@type string[] + local ret = {} ---@type snacks.picker.Extmark[] + local selected = self.selected_map[self:select_key(item)] ~= nil + + local selw = vim.api.nvim_strwidth(self.picker.opts.icons.ui.selected) + parts[#parts + 1] = string.rep(" ", selw) + if selected then + ret[#ret + 1] = { + virt_text = { { + self.picker.opts.icons.ui.selected or parts[1], + "SnacksPickerSelected", + } }, + virt_text_pos = "overlay", + line_hl_group = "SnacksPickerSelectedLine", + col = 0, + hl_mode = "combine", + } + end + + while #line > 0 and type(line[#line][1]) == "string" and line[#line][1]:find("^%s*$") do + table.remove(line) + end + + local col = selw + for _, text in ipairs(line) do + if type(text[1]) == "string" then + ---@cast text snacks.picker.Text + if text.virtual then + table.insert(ret, { + col = col, + virt_text = { { text[1], text[2] } }, + virt_text_pos = "overlay", + hl_mode = "combine", + }) + parts[#parts + 1] = string.rep(" ", vim.api.nvim_strwidth(text[1])) + else + table.insert(ret, { + col = col, + end_col = col + #text[1], + hl_group = text[2], + }) + parts[#parts + 1] = text[1] + end + col = col + #parts[#parts] + else + text = vim.deepcopy(text) + ---@cast text snacks.picker.Extmark + -- fix extmark col and end_col + text.col = text.col + selw + if text.end_col then + text.end_col = text.end_col + selw + end + table.insert(ret, text) + end + end + local str = table.concat(parts, ""):gsub("\n", " ") + local _, positions = self.picker.matcher:match({ text = str:gsub("%s*$", ""), idx = 1, score = 0 }, { + positions = true, + force = true, + }) + for _, pos in ipairs(positions or {}) do + table.insert(ret, { + col = pos - 1, + end_col = pos, + hl_group = "SnacksPickerMatch", + }) + end + return str, ret +end + +---@param item snacks.picker.Item +---@param row number +function M:_render(item, row) + local text, extmarks = self:format(item) + vim.api.nvim_buf_set_lines(self.win.buf, row - 1, row, false, { text }) + for _, extmark in ipairs(extmarks) do + local col = extmark.col + extmark.col = nil + extmark.row = nil + vim.api.nvim_buf_set_extmark(self.win.buf, ns, row - 1, col, extmark) + end +end + +function M:update_cursorline() + if self.win.win and vim.api.nvim_win_is_valid(self.win.win) then + vim.wo[self.win.win].cursorline = self:count() > 0 + end +end + +function M:render() + self:move(0, false, false) + + local redraw = false + if self.dirty then + local height = self:height() + self.dirty = false + vim.api.nvim_win_call(self.win.win, function() + vim.fn.winrestview({ topline = 1, leftcol = 0 }) + end) + + vim.api.nvim_buf_clear_namespace(self.win.buf, ns, 0, -1) + + vim.bo[self.win.buf].modifiable = true + local lines = vim.split(string.rep("\n", self.state.height), "\n") + vim.api.nvim_buf_set_lines(self.win.buf, 0, -1, false, lines) + + for i = self.top, math.min(self:count(), self.top + height - 1) do + local item = assert(self:get(i), "item not found") + local row = self:idx2row(i) + self:_render(item, row) + end + + vim.bo[self.win.buf].modifiable = false + redraw = true + end + + -- Fix cursor and cursorline + self:update_cursorline() + local cursor = vim.api.nvim_win_get_cursor(self.win.win) + if cursor[1] ~= self:idx2row(self.cursor) then + vim.api.nvim_win_set_cursor(self.win.win, { self:idx2row(self.cursor), 0 }) + end + + -- force redraw if list changed + if redraw then + self.win:redraw() + end + + -- check if current item changed + local current = self:current() + if self._current ~= current then + self._current = current + if not self.did_preview then + -- show first preview instantly + self.did_preview = true + self.picker:show_preview() + else + vim.schedule(function() + self.picker:show_preview() + end) + end + end +end + +return M diff --git a/lua/snacks/picker/core/main.lua b/lua/snacks/picker/core/main.lua new file mode 100644 index 00000000..68d30c53 --- /dev/null +++ b/lua/snacks/picker/core/main.lua @@ -0,0 +1,50 @@ +---@class snacks.picker.main +local M = {} + +---@class snacks.picker.main.Config +---@field float? boolean main window can be a floating window (defaults to false) +---@field file? boolean main window should be a file (defaults to true) +---@field current? boolean main window should be the current window (defaults to false) + +---@param opts? snacks.picker.main.Config +function M.get(opts) + opts = vim.tbl_extend("force", { + float = false, + file = true, + current = false, + }, opts or {}) + local current = vim.api.nvim_get_current_win() + if opts.current then + return current + end + local prev = vim.fn.winnr("#") + local wins = { current, prev } + local all = vim.api.nvim_list_wins() + -- sort all by lastused of the win buffer + table.sort(all, function(a, b) + local ba = vim.api.nvim_win_get_buf(a) + local bb = vim.api.nvim_win_get_buf(b) + return vim.fn.getbufinfo(ba)[1].lastused > vim.fn.getbufinfo(bb)[1].lastused + end) + vim.list_extend(wins, all) + wins = vim.tbl_filter(function(win) + -- exclude invalid windows + if win == 0 or not vim.api.nvim_win_is_valid(win) then + return false + end + -- exclude non-file buffers + if opts.file and vim.bo[vim.api.nvim_win_get_buf(win)].buftype ~= "" then + return false + end + local win_config = vim.api.nvim_win_get_config(win) + local is_float = win_config.relative ~= "" + -- exclude floating windows and non-focusable windows + if is_float and (not opts.float or not win_config.focusable) then + return false + end + return true + end, wins) + return wins[1] or current +end + +return M diff --git a/lua/snacks/picker/core/matcher.lua b/lua/snacks/picker/core/matcher.lua new file mode 100644 index 00000000..b82df9f3 --- /dev/null +++ b/lua/snacks/picker/core/matcher.lua @@ -0,0 +1,465 @@ +local Async = require("snacks.picker.util.async") + +---@class snacks.picker.Matcher +---@field opts snacks.picker.matcher.Config +---@field mods snacks.picker.matcher.Mods[][] +---@field one? snacks.picker.matcher.Mods +---@field pattern string +---@field min_score number +---@field tick number +---@field task snacks.picker.Async +---@field live? boolean +local M = {} +M.__index = M +M.DEFAULT_SCORE = 1000 +M.INVERSE_SCORE = 1000 + +local YIELD_MATCH = 5 -- ms +local clear = require("table.clear") + +---@class snacks.picker.matcher.Config +---@field fuzzy? boolean +---@field smartcase? boolean +---@field ignorecase? boolean + +---@class snacks.picker.matcher.Mods +---@field pattern string +---@field entropy number higher entropy is less likely to match +---@field field? string +---@field ignorecase? boolean +---@field fuzzy? boolean +---@field word? boolean +---@field exact_suffix? boolean +---@field exact_prefix? boolean +---@field inverse? boolean + +-- PERF: reuse tables to avoid allocations and GC +local fuzzy_positions = {} ---@type number[] +local fuzzy_best_positions = {} ---@type number[] +local fuzzy_last_positions = {} ---@type number[] +local fuzzy_fast_positions = {} ---@type number[] + +---@param t number[] +function M.clear(t) + clear(t) -- luajit table.clear is faster + return t +end + +---@param opts? snacks.picker.matcher.Config +function M.new(opts) + local self = setmetatable({}, M) + self.opts = vim.tbl_deep_extend("force", { + fuzzy = true, + smartcase = true, + ignorecase = true, + }, opts or {}) + self.pattern = "" + self.min_score = 0 + self.task = Async.nop() + self.mods = {} + self.live = false + self.tick = 0 + return self +end + +function M:empty() + return not next(self.mods) +end + +function M:running() + return self.task:running() +end + +function M:abort() + self.task:abort() +end + +---@param picker snacks.Picker +---@param opts? {prios?: snacks.picker.Item[]} +function M:run(picker, opts) + opts = opts or {} + self.task:abort() + + -- PERF: fast path for empty pattern + if self:empty() and not picker.finder.task:running() then + picker.list.items = picker.finder.items + picker:update() + return + end + + ---@async + self.task = Async.new(function() + local yield = Async.yielder(YIELD_MATCH) + local idx = 0 + + ---@async + ---@param item snacks.picker.Item + local function check(item) + if self:update(item) and item.score > 0 then + picker.list:add(item) + end + yield() + end + + -- process high priority items first + for _, item in ipairs(opts.prios or {}) do + check(item) + end + + repeat + -- then the rest + while idx < #picker.finder.items do + idx = idx + 1 + check(picker.finder.items[idx]) + end + + -- suspend till we have more items + if picker.finder.task:running() then + Async.suspend() + end + until idx >= #picker.finder.items and not picker.finder.task:running() + + picker:update() + end) +end + +---@param opts? {pattern?: string, live?: boolean} +function M:init(opts) + opts = opts or {} + self.tick = self.tick + 1 + local pattern = vim.trim(opts.pattern or self.pattern) + self.mods = {} + self.min_score = 0 + self.pattern = pattern + self:abort() + self.one = nil + self.live = opts.live + if pattern == "" then + return + end + local is_or = false + for _, p in ipairs(vim.split(pattern, " +")) do + if p == "|" then + is_or = true + else + local mods = self:_prepare(p) + if mods.pattern ~= "" then + if is_or and #self.mods > 0 then + table.insert(self.mods[#self.mods], mods) + else + table.insert(self.mods, { mods }) + end + end + is_or = false + end + end + for _, ors in ipairs(self.mods) do + -- sort by entropy, lower entropy is more likely to match + table.sort(ors, function(a, b) + return a.entropy < b.entropy + end) + end + -- sort by entropy, higher entropy is less likely to match + table.sort(self.mods, function(a, b) + return a[1].entropy > b[1].entropy + end) + if #self.mods == 1 and #self.mods[1] == 1 then + self.one = self.mods[1][1] + end +end + +---@param pattern string +---@return snacks.picker.matcher.Mods +function M:_prepare(pattern) + ---@type snacks.picker.matcher.Mods + local mods = { pattern = pattern, entropy = 0 } + local field, p = pattern:match("^([%w_]+):(.*)$") + if field then + mods.field = field + mods.pattern = p + end + mods.ignorecase = self.opts.ignorecase + local is_lower = mods.pattern:lower() == mods.pattern + if self.opts.smartcase then + mods.ignorecase = is_lower + end + mods.fuzzy = self.opts.fuzzy + if not mods.fuzzy then + mods.entropy = mods.entropy + 10 + end + if mods.pattern:sub(1, 1) == "!" then + mods.fuzzy, mods.inverse = false, true + mods.pattern = mods.pattern:sub(2) + mods.entropy = mods.entropy - 1 + end + if mods.pattern:sub(1, 1) == "'" then + mods.fuzzy = false + mods.pattern = mods.pattern:sub(2) + mods.entropy = mods.entropy + 10 + if mods.pattern:sub(-1, -1) == "'" then + mods.word = true + mods.pattern = mods.pattern:sub(1, -2) + mods.entropy = mods.entropy + 10 + end + elseif mods.pattern:sub(1, 1) == "^" then + mods.fuzzy, mods.exact_prefix = false, true + mods.pattern = mods.pattern:sub(2) + mods.entropy = mods.entropy + 20 + end + if mods.pattern:sub(-1, -1) == "$" then + mods.fuzzy = false + mods.exact_suffix = true + mods.pattern = mods.pattern:sub(1, -2) + mods.entropy = mods.entropy + 20 + end + local rare_chars = #mods.pattern:gsub("[%w%s]", "") + mods.entropy = mods.entropy + math.min(#mods.pattern, 20) + rare_chars * 2 + if not mods.ignorecase and not is_lower then + mods.entropy = mods.entropy * 2 + end + return mods +end + +---@param item snacks.picker.Item +---@return boolean updated +function M:update(item) + if item.match_tick == self.tick then + return false + end + local score = self:match(item) + item.match_tick, item.score = self.tick, score + return true +end + +---@param item snacks.picker.Item +---@param opts? {positions: boolean, force?: boolean} +---@return number score, number[]? positions +function M:match(item, opts) + opts = opts or {} + if self:empty() or (self.live and not opts.force) then + return M.DEFAULT_SCORE -- empty pattern matches everything + end + local score = 0 + local positions = opts.positions and {} or nil ---@type number[]? + if self.one then + score = self:_match(item, self.one, positions) or 0 + return score, positions + end + for _, ors in ipairs(self.mods) do + local s = 0 ---@type number? + local p = opts.positions and {} or nil ---@type number[]? + if #ors == 1 then + s = self:_match(item, ors[1], p) + else + for _, mods in ipairs(ors) do + s = self:_match(item, mods, p) + if s then + break + end + end + end + if s then + score, positions = M:merge(score, positions, s, p) + else + return 0 + end + end + return score, positions +end + +---@param score_a? number +---@param positions_a? number[] +---@param score_b? number +---@param positions_b? number[] +function M:merge(score_a, positions_a, score_b, positions_b) + local positions = positions_a or positions_b + if positions_a and positions_b then + table.move(positions_b, 1, #positions_b, #positions + 1, positions) + end + return score_a + score_b, positions +end + +---@param str string +---@param c number +function M.is_alpha(str, c) + local b = str:byte(c, c) + return (b >= 65 and b <= 90) or (b >= 97 and b <= 122) +end + +---@param item snacks.picker.Item +---@param mods snacks.picker.matcher.Mods +---@param positions? number[] +---@return number? score +function M:_match(item, mods, positions) + local str = item.text + if mods.field then + if item[mods.field] == nil then + if mods.inverse then + return M.INVERSE_SCORE + end + return + end + str = tostring(item[mods.field]) + end + + str = mods.ignorecase and str:lower() or str + if mods.fuzzy then + return self:fuzzy(str, mods.pattern, positions) + end + local from, to ---@type number?, number? + if mods.exact_prefix then + if str:sub(1, #mods.pattern) == mods.pattern then + from, to = 1, #mods.pattern + end + elseif mods.exact_suffix then + if str:sub(-#mods.pattern) == mods.pattern then + from, to = #str - #mods.pattern + 1, #str + end + else + from, to = str:find(mods.pattern, 1, true) + + -- word match + while mods.word and from and to do + local bound_left = from == 1 or not M.is_alpha(str, from - 1) + local bound_right = to == #str or not M.is_alpha(str, to + 1) + if bound_left and bound_right then + break + end + from, to = str:find(mods.pattern, to + 1, true) + end + end + if mods.inverse then + if not from then + return M.INVERSE_SCORE + end + return + end + if from and to then + if positions then + M.positions(from, to, positions) + end + return self.score(from, to, #str) + end +end + +---@param from number +---@param to number +---@param positions number[] +function M.positions(from, to, positions) + for i = from, to do + table.insert(positions, i) + end +end + +---@param from number +---@param to number +---@param len number +function M.score(from, to, len) + return 1000 / (to - from + 1) -- calculate compactness score (distance between first and last match) + + (100 / from) -- add bonus for early match + + (100 / (len + 1)) -- add bonus for string length +end + +---@param str string +---@param pattern string +---@param positions? number[] +---@return number? score +function M:fuzzy_fast(str, pattern, positions) + local n, m, p, c = #str, #pattern, 1, 1 + positions = positions or M.clear(fuzzy_fast_positions) + while c <= n and p <= m do + local pos = str:find(pattern:sub(p, p), c, true) + if not pos then + break + end + positions[p] = pos + p = p + 1 + c = pos + 1 + end + return p > m and M.score(positions[1], positions[m], n) or nil +end + +--- Does a forward scan followed by a backward scan for each end position, +--- to find the best match. +---@param str string +---@param pattern string +---@param best_positions? number[] +---@return number? score +function M:fuzzy(str, pattern, best_positions) + local n, m, p, c = #str, #pattern, 1, 1 + -- Find last char positions first for early exit + best_positions = best_positions or M.clear(fuzzy_best_positions) + local best_score = -1 + + -- initial forward scan + while c <= n and p <= m do + local pos = str:find(pattern:sub(p, p), c, true) + if not pos then + break + end + best_positions[p] = pos + p = p + 1 + c = pos + 1 + end + + -- no full match + if p <= m then + return + end + + -- calculate score for the initial match + best_score = M.score(best_positions[1], best_positions[m], n) + + -- early exit for exact match + if best_positions[m] - best_positions[1] + 1 == m then + return best_score + end + + -- find all last positions + local last_positions = M.clear(fuzzy_last_positions) + last_positions[1] = best_positions[m] + local last_p = pattern:sub(m, m) + while c <= n do + local pos = str:find(last_p, c, true) + if not pos then + break + end + table.insert(last_positions, pos) + c = pos + 1 + end + + local rev = str:reverse() + + -- backward scan from last positions to refine the match + local positions = M.clear(fuzzy_positions) + local best = best_positions + for _, last in ipairs(last_positions) do + p = m - 1 -- Start from the second last character of the pattern + positions[m] = last + c = n - last + 1 + local score = 0 + while c > 0 and p > 0 do + local pos = rev:find(pattern:sub(p, p), c, true) + local from = n - pos + 1 + score = M.score(from, last, n) + if score <= best_score then + break + end + positions[p] = from + p = p - 1 + c = pos + 1 + end + if score > best_score then + best_score = score + positions, best = best, positions + end + end + + if best ~= best_positions then + table.move(best, 1, m, 1, best_positions) + end + + return best_score +end + +return M diff --git a/lua/snacks/picker/core/picker.lua b/lua/snacks/picker/core/picker.lua new file mode 100644 index 00000000..42a4a104 --- /dev/null +++ b/lua/snacks/picker/core/picker.lua @@ -0,0 +1,501 @@ +local Async = require("snacks.picker.util.async") +local Finder = require("snacks.picker.core.finder") + +local uv = vim.uv or vim.loop +Async.BUDGET = 10 + +---@class snacks.Picker +---@field opts snacks.picker.Config +---@field finder snacks.picker.Finder +---@field format snacks.picker.format +---@field input snacks.picker.input +---@field layout snacks.layout +---@field resolved_layout snacks.picker.layout.Config +---@field list snacks.picker.list +---@field matcher snacks.picker.Matcher +---@field main number +---@field preview snacks.picker.Preview +---@field shown? boolean +---@field sort snacks.picker.sort +---@field updater uv.uv_timer_t +---@field start_time number +---@field source_name string +---@field closed? boolean +---@field hist_idx number +---@field hist_cursor number +---@field visual? snacks.picker.Visual +local M = {} +M.__index = M + +--- Keep track of garbage collection +---@type table +M._pickers = setmetatable({}, { __mode = "k" }) +--- These are active, so don't garbage collect them +---@type table +M._active = {} + +---@class snacks.picker.Last +---@field opts snacks.picker.Config +---@field selected snacks.picker.Item[] +---@field filter snacks.picker.Filter + +---@type snacks.picker.Last? +M.last = nil + +---@type {pattern: string, search: string, live?: boolean}[] +M.history = {} + +---@hide +---@param opts? snacks.picker.Config +function M.new(opts) + local self = setmetatable({}, M) + self.opts = Snacks.picker.config.get(opts) + if self.opts.source == "resume" then + return M.resume() + end + self.visual = Snacks.picker.util.visual() + self.start_time = uv.hrtime() + Snacks.picker.current = self + self.main = require("snacks.picker.core.main").get(self.opts.main) + local actions = require("snacks.picker.core.actions").get(self) + self.opts.win.input.actions = actions + self.opts.win.list.actions = actions + self.opts.win.preview.actions = actions + self.hist_idx = #M.history + 1 + self.hist_cursor = self.hist_idx + + local sort = self.opts.sort or require("snacks.picker.sort").default() + sort = type(sort) == "table" and require("snacks.picker.sort").default(sort) or sort + ---@cast sort snacks.picker.sort + self.sort = sort + + self.updater = assert(uv.new_timer()) + self.matcher = require("snacks.picker.core.matcher").new(self.opts.matcher) + + self.finder = Finder.new(Snacks.picker.config.finder(self.opts.finder) or function() + return self.opts.items or {} + end) + + local format = type(self.opts.format) == "string" and Snacks.picker.format[self.opts.format] + or self.opts.format + or Snacks.picker.format.file + ---@cast format snacks.picker.format + self.format = format + + M._pickers[self] = true + M._active[self] = true + + local layout = Snacks.picker.config.layout(self.opts) + self.list = require("snacks.picker.core.list").new(self) + self.input = require("snacks.picker.core.input").new(self) + self.preview = require("snacks.picker.core.preview").new(self.opts, layout.preview == "main" and self.main or nil) + + M.last = { + opts = self.opts, + selected = {}, + filter = self.input.filter, + } + + self.source_name = Snacks.picker.util.title(self.opts.source or "search") + + -- properly close the picker when the window is closed + self.input.win:on("WinClosed", function() + self:close() + end, { win = true }) + + -- close if we enter a window that is not part of the picker + self.input.win:on("WinEnter", function() + local current = vim.api.nvim_get_current_win() + if not vim.tbl_contains({ self.input.win.win, self.list.win.win, self.preview.win.win }, current) then + vim.schedule(function() + self:close() + end) + end + end) + + self:init_layout(layout) + self.input.win:on("VimResized", function() + vim.schedule(function() + self:set_layout(Snacks.picker.config.layout(self.opts)) + end) + end) + + local show_preview = self.show_preview + self.show_preview = Snacks.util.throttle(function() + show_preview(self) + end, { ms = 60, name = "preview" }) + + self:find() + return self +end + +---@param layout? snacks.picker.layout.Config +---@private +function M:init_layout(layout) + layout = layout or Snacks.picker.config.layout(self.opts) + self.resolved_layout = vim.deepcopy(layout) + self.resolved_layout.cycle = nil -- not needed for applying layout + local opts = layout --[[@as snacks.layout.Config]] + local preview_main = layout.preview == "main" + local preview_hidden = layout.preview == false or preview_main + local backdrop = nil + if preview_main then + backdrop = false + end + self.layout = Snacks.layout.new(vim.tbl_deep_extend("force", opts, { + show = false, + win = { + wo = { + winhighlight = Snacks.picker.highlight.winhl("SnacksPicker"), + }, + }, + wins = { + input = self.input.win, + list = self.list.win, + preview = not preview_main and self.preview.win or nil, + }, + hidden = { preview_hidden and "preview" or nil }, + on_update = function() + self:update_titles() + end, + layout = { + backdrop = backdrop, + }, + })) + self.preview:update(preview_main and self.main or nil) + -- apply box highlight groups + local boxwhl = Snacks.picker.highlight.winhl("SnacksPickerBox") + for _, win in pairs(self.layout.box_wins) do + win.opts.wo.winhighlight = boxwhl + end + return layout +end + +--- Set the picker layout. Can be either the name of a preset layout +--- or a custom layout configuration. +---@param layout? string|snacks.picker.layout.Config +function M:set_layout(layout) + layout = layout or Snacks.picker.config.layout(self.opts) + layout = type(layout) == "string" and Snacks.picker.config.layout(layout) or layout + ---@cast layout snacks.picker.layout.Config + layout.cycle = nil -- not needed for applying layout + if vim.deep_equal(layout, self.resolved_layout) then + -- no need to update + return + end + if self.list.reverse ~= layout.reverse then + Snacks.notify.warn( + "Heads up! This layout changed the list order,\nso `up` goes down and `down` goes up.", + { title = "Snacks Picker", id = "snacks_picker_layout_change" } + ) + end + self.layout:close({ wins = false }) + self:init_layout(layout) + self.layout:show() + self.list.reverse = layout.reverse + self.list.dirty = true + self.list:update() + self.input:update() +end + +-- Get the word under the cursor or the current visual selection +function M:word() + return self.visual and self.visual.text or vim.fn.expand("") +end + +--- Update title templates +---@hide +function M:update_titles() + local data = { + source = self.source_name, + live = self.opts.live and self.opts.icons.ui.live or "", + } + local wins = { self.layout.root } + vim.list_extend(wins, vim.tbl_values(self.layout.wins)) + vim.list_extend(wins, vim.tbl_values(self.layout.box_wins)) + for _, win in pairs(wins) do + if win.opts.title then + local tpl = win.meta.title_tpl or win.opts.title + win.meta.title_tpl = tpl + win:set_title(Snacks.picker.util.tpl(tpl, data)) + end + end +end + +--- Resume the last picker +---@private +function M.resume() + local last = M.last + if not last then + Snacks.notify.error("No picker to resume") + return M.new({ source = "pickers" }) + end + last.opts.pattern = last.filter.pattern + last.opts.search = last.filter.search + local ret = M.new(last.opts) + ret.list:set_selected(last.selected) + ret.list:update() + ret.input:update() + return ret +end + +---@hide +function M:show_preview() + if self.opts.on_change then + self.opts.on_change(self, self:current()) + end + if not self.preview.win:valid() then + return + end + self.preview:show(self) +end + +---@hide +function M:show() + if self.shown or self.closed then + return + end + self.shown = true + self.layout:show() + if self.preview.main then + self.preview.win:show() + end + self.input.win:focus() + if self.opts.on_show then + self.opts.on_show(self) + end +end + +--- Returns an iterator over the items in the picker. +--- Items will be in sorted order. +---@return fun():snacks.picker.Item? +function M:iter() + local i = 0 + local n = self.finder:count() + return function() + i = i + 1 + if i <= n then + return self.list:get(i) + end + end +end + +--- Get all finder items +function M:items() + return self.finder.items +end + +--- Get the current item at the cursor +function M:current() + return self.list:current() +end + +--- Get the selected items. +--- If `fallback=true` and there is no selection, return the current item. +---@param opts? {fallback?: boolean} default is `false` +function M:selected(opts) + opts = opts or {} + local ret = vim.deepcopy(self.list.selected) + if #ret == 0 and opts.fallback then + return { self:current() } + end + return ret +end + +--- Total number of items in the picker +function M:count() + return self.finder:count() +end + +--- Check if the picker is empty +function M:empty() + return self:count() == 0 +end + +--- Close the picker +function M:close() + if self.closed then + return + end + M.last.selected = self:selected({ fallback = false }) + self.closed = true + Snacks.picker.current = nil + local current = vim.api.nvim_get_current_win() + local is_picker_win = vim.tbl_contains({ self.input.win.win, self.list.win.win, self.preview.win.win }, current) + if is_picker_win and vim.api.nvim_win_is_valid(self.main) then + vim.api.nvim_set_current_win(self.main) + end + self.preview.win:close() + self.layout:close() + self.updater:stop() + M._active[self] = nil + vim.schedule(function() + self.list:clear() + self.finder.items = {} + self.matcher:abort() + self.finder:abort() + end) +end + +--- Check if the finder or matcher is running +function M:is_active() + return self.finder:running() or self.matcher:running() +end + +---@private +function M:progress(ms) + if self.updater:is_active() then + return + end + self.updater = vim.defer_fn(function() + self:update() + if self:is_active() then + -- slower progress when we filled topk + local topk, height = self.list.topk:count(), self.list.state.height or 50 + self:progress(topk > height and 30 or 10) + end + end, ms or 10) +end + +---@hide +function M:update() + if self.closed then + return + end + + -- Schedule the update if we are in a fast event + if vim.in_fast_event() then + return vim.schedule(function() + self:update() + end) + end + + local count = self.finder:count() + local list_count = self.list:count() + -- Check if we should show the picker + if not self.shown then + -- Always show live pickers + if self.opts.live then + self:show() + elseif not self:is_active() then + if count == 0 then + -- no results found + local msg = "No results" + if self.opts.source then + msg = ("No results found for `%s`"):format(self.opts.source) + end + Snacks.notify.warn(msg, { title = "Snacks Picker" }) + self:close() + return + elseif count == 1 and self.opts.auto_confirm then + -- auto confirm if only one result + self:action("confirm") + self:close() + return + else + -- show the picker if we have results + self.list:unpause() + self:show() + end + elseif list_count > 1 or (list_count == 1 and not self.opts.auto_confirm) then -- show the picker if we have results + self:show() + end + end + + if self.shown then + if not self:is_active() then + self.list:unpause() + end + -- update list and input + if not self.list.paused then + self.input:update() + end + self.list:update() + end +end + +--- Execute the given action(s) +---@param actions string|string[] +function M:action(actions) + return self.input.win:execute(actions) +end + +--- Clear the list and run the finder and matcher +---@param opts? {on_done?: fun()} Callback when done +function M:find(opts) + self.list:clear() + self.finder:run(self) + self.matcher:run(self) + if opts and opts.on_done then + if self.matcher.task:running() then + self.matcher.task:on("done", vim.schedule_wrap(opts.on_done)) + else + opts.on_done() + end + end + self:progress() +end + +--- Add current filter to history +---@private +function M:hist_record() + M.history[self.hist_idx] = { + pattern = self.input.filter.pattern, + search = self.input.filter.search, + live = self.opts.live, + } +end + +--- Move the history cursor +---@param forward? boolean +function M:hist(forward) + self:hist_record() + self.hist_cursor = self.hist_cursor + (forward and 1 or -1) + self.hist_cursor = math.min(math.max(self.hist_cursor, 1), #M.history) + self.opts.live = M.history[self.hist_cursor].live + self.input:set(M.history[self.hist_cursor].pattern, M.history[self.hist_cursor].search) +end + +--- Run the matcher with the current pattern. +--- May also trigger a new find if the search string has changed, +--- like during live searches. +function M:match() + local pattern = vim.trim(self.input.filter.pattern) + local search = vim.trim(self.input.filter.search) + local needs_match = false + self:hist_record() + if self.matcher.pattern ~= pattern then + self.matcher:init({ pattern = pattern }) + needs_match = true + end + + if self.finder:changed(search) then + -- pause rapid list updates to prevent flickering + -- of the search results + self.list:pause(60) + return self:find() + end + + if not needs_match then + return + end + + local prios = {} ---@type snacks.picker.Item[] + -- add current topk items to be checked first + vim.list_extend(prios, self.list.topk:get()) + if not self.matcher:empty() then + -- next add the rest of the matched items + vim.list_extend(prios, self.list.items, 1, 1000) + end + + self.list:clear() + self.matcher:run(self, { prios = prios }) + self:progress() +end + +--- Get the active filter +function M:filter() + return self.input.filter:clone() +end + +return M diff --git a/lua/snacks/picker/core/preview.lua b/lua/snacks/picker/core/preview.lua new file mode 100644 index 00000000..baf55424 --- /dev/null +++ b/lua/snacks/picker/core/preview.lua @@ -0,0 +1,230 @@ +---@class snacks.picker.Preview +---@field item? snacks.picker.Item +---@field win snacks.win +---@field preview snacks.picker.preview +---@field state table +---@field main? number +---@field win_opts {main: snacks.win.Config, layout: snacks.win.Config, win: snacks.win.Config} +---@field winhl string +local M = {} +M.__index = M + +---@class snacks.picker.preview.ctx +---@field picker snacks.Picker +---@field item snacks.picker.Item +---@field prev? snacks.picker.Item +---@field preview snacks.picker.Preview +---@field buf number +---@field win number + +local ns = vim.api.nvim_create_namespace("snacks.picker.preview") +local ns_loc = vim.api.nvim_create_namespace("snacks.picker.preview.loc") + +---@param opts snacks.picker.Config +---@param main? number +function M.new(opts, main) + local self = setmetatable({}, M) + self.winhl = Snacks.picker.highlight.winhl("SnacksPickerPreview") + local win_opts = Snacks.win.resolve( + { + title_pos = "center", + }, + opts.win.preview, + { + show = false, + enter = false, + width = 0, + height = 0, + fixbuf = false, + bo = { filetype = "snacks_picker_preview" }, + on_win = function() + self.item = nil + self:reset() + end, + wo = { + winhighlight = self.winhl, + }, + } + ) + self.win_opts = { + main = { + relative = "win", + backdrop = false, + }, + layout = { + backdrop = win_opts.backdrop == true, + relative = "win", + }, + } + self.win = Snacks.win(win_opts) + self:update(main) + self.state = {} + + self.win:on("WinClosed", function() + self:clear(self.win.buf) + end, { win = true }) + + local preview = opts.preview or Snacks.picker.preview.file + preview = type(preview) == "string" and Snacks.picker.preview[preview] or preview + ---@cast preview snacks.picker.preview + self.preview = preview + return self +end + +---@param main? number +function M:update(main) + self.main = main + self.win_opts.main.win = main + self.win.opts = vim.tbl_deep_extend("force", self.win.opts, main and self.win_opts.main or self.win_opts.layout) + self.win.opts.wo.winhighlight = main and vim.wo[main].winhighlight or self.winhl + if main then + self.win:update() + end +end + +---@param picker snacks.Picker +function M:show(picker) + local item, prev = picker:current(), self.item + self.item = item + if item then + local buf = self.win.buf + local ok, err = pcall( + self.preview, + setmetatable({ + preview = self, + item = item, + prev = prev, + picker = picker, + }, { + __index = function(_, k) + if k == "buf" then + return self.win.buf + elseif k == "win" then + return self.win.win + end + end, + }) + ) + if not ok then + self:notify(err, "error") + end + if self.win.buf ~= buf then + self:clear(buf) + end + else + self:reset() + end +end + +---@param title string +function M:set_title(title) + self.win:set_title(title) +end + +---@param buf? number +function M:clear(buf) + if not (buf and vim.api.nvim_buf_is_valid(buf)) then + return + end + vim.api.nvim_buf_clear_namespace(buf, ns, 0, -1) + vim.api.nvim_buf_clear_namespace(buf, ns_loc, 0, -1) +end + +function M:reset() + if vim.api.nvim_buf_is_valid(self.win.scratch_buf) then + vim.api.nvim_win_set_buf(self.win.win, self.win.scratch_buf) + else + self.win:scratch() + end + self:set_title("") + vim.treesitter.stop(self.win.buf) + vim.bo[self.win.buf].modifiable = true + vim.api.nvim_buf_set_lines(self.win.buf, 0, -1, false, {}) + self:clear(self.win.buf) + vim.bo[self.win.buf].filetype = "snacks_picker_preview" + vim.bo[self.win.buf].syntax = "" + vim.wo[self.win.win].cursorline = false +end + +-- create a new scratch buffer +function M:scratch() + local buf = vim.api.nvim_create_buf(false, true) + vim.bo[buf].bufhidden = "wipe" + local ei = vim.o.eventignore + vim.o.eventignore = "all" + vim.bo[buf].filetype = "snacks_picker_preview" + vim.o.eventignore = ei + vim.api.nvim_win_set_buf(self.win.win, buf) + return buf +end + +--- highlight the buffer +---@param opts? {file?:string, buf?:number, ft?:string, lang?:string} +function M:highlight(opts) + opts = opts or {} + local ft = opts.ft + if not ft and (opts.file or opts.buf) then + ft = vim.filetype.match({ + buf = opts.buf or self.win.buf, + filename = opts.file, + }) + end + local lang = opts.lang or ft and vim.treesitter.language.get_lang(ft) + if not (lang and pcall(vim.treesitter.start, self.win.buf, lang)) then + if ft then + vim.bo[self.win.buf].syntax = ft + end + end +end + +-- show the item location +function M:loc() + vim.api.nvim_buf_clear_namespace(self.win.buf, ns_loc, 0, -1) + if not self.item then + return + end + local line_count = vim.api.nvim_buf_line_count(self.win.buf) + if self.item.pos and self.item.pos[1] > 0 and self.item.pos[1] <= line_count then + vim.api.nvim_win_set_cursor(self.win.win, { self.item.pos[1], 0 }) + vim.api.nvim_win_call(self.win.win, function() + vim.cmd("norm! zz") + vim.wo[self.win.win].cursorline = true + end) + if self.item.end_pos then + vim.api.nvim_buf_set_extmark(self.win.buf, ns_loc, self.item.pos[1] - 1, self.item.pos[2] - 1, { + end_row = self.item.end_pos[1] - 1, + end_col = self.item.end_pos[2] - 1, + hl_group = "SnacksPickerSearch", + }) + end + elseif self.item.search then + vim.api.nvim_win_call(self.win.win, function() + vim.cmd("keepjumps norm! gg") + if pcall(vim.cmd, self.item.search) then + vim.cmd("norm! zz") + vim.wo[self.win.win].cursorline = true + end + end) + end +end + +---@param msg string +---@param level? "info" | "warn" | "error" +---@param opts? {item?:boolean} +function M:notify(msg, level, opts) + level = level or "info" + local lines = vim.split(level .. ": " .. msg, "\n", { plain = true }) + local msg_len = #lines + if not (opts and opts.item == false) then + lines[#lines + 1] = "" + vim.list_extend(lines, vim.split(vim.inspect(self.item), "\n", { plain = true })) + end + vim.api.nvim_buf_set_lines(self.win.buf, 0, -1, false, lines) + vim.api.nvim_buf_set_extmark(self.win.buf, ns, 0, 0, { + hl_group = "Diagnostic" .. level:sub(1, 1):upper() .. level:sub(2), + end_row = msg_len, + }) + self:highlight({ lang = "lua" }) +end + +return M diff --git a/lua/snacks/picker/format.lua b/lua/snacks/picker/format.lua new file mode 100644 index 00000000..a0926b31 --- /dev/null +++ b/lua/snacks/picker/format.lua @@ -0,0 +1,381 @@ +---@class snacks.picker.formatters +---@field [string] snacks.picker.format +local M = {} + +function M.severity(item, picker) + local ret = {} ---@type snacks.picker.Highlight[] + local severity = item.severity + severity = type(severity) == "number" and vim.diagnostic.severity[severity] or severity + if not severity or type(severity) == "number" then + return ret + end + ---@cast severity string + local lower = severity:lower() + local cap = severity:sub(1, 1):upper() .. lower:sub(2) + + ret[#ret + 1] = { picker.opts.icons.diagnostics[cap], "Diagnostic" .. cap, virtual = true } + ret[#ret + 1] = { " ", virtual = true } + return ret +end + +---@param item snacks.picker.Item +function M.filename(item) + ---@type snacks.picker.Highlight[] + local ret = {} + if not item.file then + return ret + end + local path = vim.fs.normalize(item.file) + path = vim.fn.fnamemodify(path, ":~:.") + local name, cat = path, "file" + if item.buf and vim.api.nvim_buf_is_loaded(item.buf) then + name = vim.bo[item.buf].filetype + cat = "filetype" + elseif item.dir then + cat = "directory" + end + + local icon, hl = Snacks.util.icon(name, cat) + ret[#ret + 1] = { icon .. " ", hl, virtual = true } + + local dir, file = path:match("^(.*)/(.+)$") + if dir then + table.insert(ret, { dir .. "/", "SnacksPickerDir" }) + table.insert(ret, { file, "SnacksPickerFile" }) + else + table.insert(ret, { path, "SnacksPickerFile" }) + end + if item.pos then + table.insert(ret, { ":", "SnacksPickerDelim" }) + table.insert(ret, { tostring(item.pos[1]), "SnacksPickerRow" }) + if item.pos[2] > 0 then + table.insert(ret, { ":", "SnacksPickerDelim" }) + table.insert(ret, { tostring(item.pos[2]), "SnacksPickerCol" }) + end + end + ret[#ret + 1] = { " " } + return ret +end + +function M.file(item, picker) + ---@type snacks.picker.Highlight[] + local ret = {} + + if item.severity then + vim.list_extend(ret, M.severity(item, picker)) + end + + if item.label then + table.insert(ret, 1, { item.label, "SnacksPickerLabel" }) + table.insert(ret, 2, { " ", virtual = true }) + end + + vim.list_extend(ret, M.filename(item)) + + if item.comment then + table.insert(ret, { item.comment, "SnacksPickerComment" }) + table.insert(ret, { " " }) + end + + if item.line then + Snacks.picker.highlight.format(item, item.line, ret) + table.insert(ret, { " " }) + end + return ret +end + +function M.git_log(item, picker) + local a = Snacks.picker.util.align + local ret = {} ---@type snacks.picker.Highlight[] + ret[#ret + 1] = { picker.opts.icons.git.commit, "SnacksPickerGitCommit" } + ret[#ret + 1] = { item.commit, "SnacksPickerGitCommit" } + ret[#ret + 1] = { " " } + ret[#ret + 1] = { a(item.date, 16), "SnacksPickerGitDate" } + + local msg = item.msg ---@type string + local type, scope, breaking, body = msg:match("^(%S+)(%(.-%))(!?):%s*(.*)$") + if not type then + type, breaking, body = msg:match("^(%S+)(!?):%s*(.*)$") + end + local msg_hl = "SnacksPickerGitMsg" + if type and body then + local dimmed = vim.tbl_contains({ "chore", "bot", "build", "ci", "style", "test" }, type) + msg_hl = dimmed and "SnacksPickerDimmed" or "SnacksPickerGitMsg" + ret[#ret + 1] = + { type, breaking ~= "" and "SnacksPickerGitBreaking" or dimmed and "SnacksPickerBold" or "SnacksPickerGitType" } + if scope and scope ~= "" then + ret[#ret + 1] = { scope, "SnacksPickerGitScope" } + end + if breaking ~= "" then + ret[#ret + 1] = { "!", "SnacksPickerGitBreaking" } + end + ret[#ret + 1] = { ":", "SnacksPickerDelim" } + ret[#ret + 1] = { " " } + msg = body + end + ret[#ret + 1] = { msg, msg_hl } + Snacks.picker.highlight.markdown(ret) + Snacks.picker.highlight.highlight(ret, { + ["#%d+"] = "SnacksPickerGitIssue", + }) + return ret +end + +function M.lsp_symbol(item, picker) + local ret = {} ---@type snacks.picker.Highlight[] + if item.hierarchy then + local indents = picker.opts.icons.indent + local indent = {} ---@type string[] + local node = item + while node and node.depth > 0 do + local is_last, icon = node.last, "" + if node ~= item then + icon = is_last and " " or indents.vertical + else + icon = is_last and indents.last or indents.middle + end + table.insert(indent, 1, icon) + node = node.parent + end + ret[#ret + 1] = { table.concat(indent), "SnacksPickerIndent" } + end + local kind = item.kind or "Unknown" ---@type string + local kind_hl = "SnacksPickerIcon" .. kind + ret[#ret + 1] = { picker.opts.icons.kinds[kind], kind_hl } + ret[#ret + 1] = { " " } + -- ret[#ret + 1] = { kind:lower() .. string.rep(" ", 10 - #kind), kind_hl } + -- ret[#ret + 1] = { " " } + local name = vim.trim(item.name:gsub("\r?\n", " ")) + name = name == "" and item.detail or name + Snacks.picker.highlight.format(item, name, ret) + -- ret[#ret + 1] = { name } + return ret +end + +---@param kind? string +---@param count number +---@return snacks.picker.format +function M.ui_select(kind, count) + return function(item) + local ret = {} ---@type snacks.picker.Highlight[] + local idx = tostring(item.idx) + idx = (" "):rep(#tostring(count) - #idx) .. idx + ret[#ret + 1] = { idx .. ".", "SnacksPickerIdx" } + ret[#ret + 1] = { " " } + + if kind == "codeaction" then + ---@type lsp.Command|lsp.CodeAction, lsp.HandlerContext + local action, ctx = item.item.action, item.item.ctx + local client = vim.lsp.get_client_by_id(ctx.client_id) + ret[#ret + 1] = { action.title } + if client then + ret[#ret + 1] = { " " } + ret[#ret + 1] = { ("[%s]"):format(client.name), "SnacksPickerSpecial" } + end + else + ret[#ret + 1] = { item.formatted } + end + return ret + end +end + +function M.lines(item) + local ret = {} ---@type snacks.picker.Highlight[] + local line_count = vim.api.nvim_buf_line_count(item.buf) + local idx = Snacks.picker.util.align(tostring(item.idx), #tostring(line_count), { align = "right" }) + ret[#ret + 1] = { idx, "LineNr", virtual = true } + ret[#ret + 1] = { " ", virtual = true } + ret[#ret + 1] = { item.text } + + local offset = #idx + 2 + + for _, extmark in ipairs(item.highlights or {}) do + extmark = vim.deepcopy(extmark) + if type(extmark[1]) ~= "string" then + ---@cast extmark snacks.picker.Extmark + extmark.col = extmark.col + offset + if extmark.end_col then + extmark.end_col = extmark.end_col + offset + end + end + ret[#ret + 1] = extmark + end + return ret +end + +function M.text(item) + return { + { item.text }, + } +end + +function M.diagnostic(item, picker) + local ret = {} ---@type snacks.picker.Highlight[] + local diag = item.item ---@type vim.Diagnostic + if item.severity then + vim.list_extend(ret, M.severity(item, picker)) + end + + ret[#ret + 1] = { diag.message } + Snacks.picker.highlight.markdown(ret) + ret[#ret + 1] = { " " } + + if diag.source then + ret[#ret + 1] = { diag.source, "SnacksPickerDiagnosticSource" } + ret[#ret + 1] = { " " } + end + + if diag.code then + ret[#ret + 1] = { ("(%s)"):format(diag.code), "SnacksPickerDiagnosticCode" } + ret[#ret + 1] = { " " } + end + vim.list_extend(ret, M.filename(item, picker)) + return ret +end + +function M.autocmd(item) + local ret = {} ---@type snacks.picker.Highlight[] + ---@type vim.api.keyset.get_autocmds.ret + local au = item.item + local a = Snacks.picker.util.align + ret[#ret + 1] = { a(au.event, 15), "SnacksPickerAuEvent" } + ret[#ret + 1] = { " " } + ret[#ret + 1] = { a(au.pattern, 10), "SnacksPickerAuPattern" } + ret[#ret + 1] = { " " } + ret[#ret + 1] = { a(tostring(au.group_name or ""), 15), "SnacksPickerAuGroup" } + ret[#ret + 1] = { " " } + if au.command ~= "" then + Snacks.picker.highlight.format(item, au.command, ret, { lang = "vim" }) + else + ret[#ret + 1] = { "callback", "Function" } + end + return ret +end + +function M.hl(item) + local ret = {} ---@type snacks.picker.Highlight[] + ret[#ret + 1] = { item.hl_group, item.hl_group } + return ret +end + +function M.man(item) + local a = Snacks.picker.util.align + local ret = {} ---@type snacks.picker.Highlight[] + ret[#ret + 1] = { a(item.page, 20), "SnacksPickerManPage" } + ret[#ret + 1] = { " " } + ret[#ret + 1] = { ("(%s)"):format(item.section), "SnacksPickerManSection" } + ret[#ret + 1] = { " " } + ret[#ret + 1] = { item.desc, "SnacksPickerManDesc" } + return ret +end + +-- Pretty keymaps using which-key icons when available +function M.keymap(item) + local ret = {} ---@type snacks.picker.Highlight[] + ---@type vim.api.keyset.get_keymap + local k = item.item + local a = Snacks.picker.util.align + + if package.loaded["which-key"] then + local Icons = require("which-key.icons") + local icon, hl = Icons.get({ keymap = k, desc = k.desc }) + if icon then + ret[#ret + 1] = { a(icon, 3), hl } + else + ret[#ret + 1] = { " " } + end + end + local lhs = vim.fn.keytrans(Snacks.util.keycode(k.lhs)) + ret[#ret + 1] = { k.mode, "SnacksPickerKeymapMode" } + ret[#ret + 1] = { " " } + ret[#ret + 1] = { a(lhs, 15), "SnacksPickerKeymapLhs" } + ret[#ret + 1] = { " " } + local rhs_len = 0 + if k.rhs and k.rhs ~= "" then + local rhs = k.rhs or "" + rhs_len = #rhs + local cmd = rhs:lower():find("") + if cmd then + ret[#ret + 1] = { rhs:sub(1, cmd + 4), "NonText" } + rhs = rhs:sub(cmd + 5) + local cr = rhs:lower():find("$") + if cr then + rhs = rhs:sub(1, cr - 1) + end + Snacks.picker.highlight.format(item, rhs, ret, { lang = "vim" }) + if cr then + ret[#ret + 1] = { "", "NonText" } + end + elseif rhs:lower():find("^") then + ret[#ret + 1] = { "", "NonText" } + local plug = rhs:sub(7):gsub("^%(", ""):gsub("%)$", "") + ret[#ret + 1] = { "(", "SnacksPickerDelim" } + Snacks.picker.highlight.format(item, plug, ret, { lang = "vim" }) + ret[#ret + 1] = { ")", "SnacksPickerDelim" } + elseif rhs:find("v:lua%.") then + ret[#ret + 1] = { "v:lua", "NonText" } + ret[#ret + 1] = { ".", "SnacksPickerDelim" } + Snacks.picker.highlight.format(item, rhs:sub(7), ret, { lang = "lua" }) + else + ret[#ret + 1] = { k.rhs, "SnacksPickerKeymapRhs" } + end + else + ret[#ret + 1] = { "callback", "Function" } + rhs_len = 8 + end + + if rhs_len < 15 then + ret[#ret + 1] = { (" "):rep(15 - rhs_len) } + end + + ret[#ret + 1] = { " " } + ret[#ret + 1] = { a(k.desc or "", 20) } + + if item.file then + ret[#ret + 1] = { " " } + vim.list_extend(ret, M.filename(item)) + end + return ret +end + +function M.git_status(item) + local ret = {} ---@type snacks.picker.Highlight[] + local a = Snacks.picker.util.align + local s = vim.trim(item.status):sub(1, 1) + local hls = { + ["A"] = "SnacksPickerGitStatusAdded", + ["M"] = "SnacksPickerGitStatusModified", + ["D"] = "SnacksPickerGitStatusDeleted", + ["R"] = "SnacksPickerGitStatusRenamed", + ["C"] = "SnacksPickerGitStatusCopied", + ["?"] = "SnacksPickerGitStatusUntracked", + } + local hl = hls[s] or "SnacksPickerGitStatus" + ret[#ret + 1] = { a(item.status, 2, { align = "right" }), hl } + ret[#ret + 1] = { " " } + vim.list_extend(ret, M.filename(item)) + return ret +end + +function M.register(item) + local ret = {} ---@type snacks.picker.Highlight[] + ret[#ret + 1] = { " " } + ret[#ret + 1] = { "[", "SnacksPickerDelim" } + ret[#ret + 1] = { item.reg, "SnacksPickerRegister" } + ret[#ret + 1] = { "]", "SnacksPickerDelim" } + ret[#ret + 1] = { " " } + ret[#ret + 1] = { item.value } + return ret +end + +function M.buffer(item) + local ret = {} ---@type snacks.picker.Highlight[] + ret[#ret + 1] = { Snacks.picker.util.align(tostring(item.buf), 3), "SnacksPickerBufNr" } + ret[#ret + 1] = { " " } + ret[#ret + 1] = { Snacks.picker.util.align(item.flags, 2, { align = "right" }), "SnacksPickerBufFlags" } + ret[#ret + 1] = { " " } + vim.list_extend(ret, M.filename(item)) + return ret +end + +return M diff --git a/lua/snacks/picker/init.lua b/lua/snacks/picker/init.lua new file mode 100644 index 00000000..6f643fa3 --- /dev/null +++ b/lua/snacks/picker/init.lua @@ -0,0 +1,91 @@ +---@class snacks.picker +---@field actions snacks.picker.actions +---@field config snacks.picker.config +---@field format snacks.picker.formatters +---@field preview snacks.picker.previewers +---@field sort snacks.picker.sorters +---@field util snacks.picker.util +---@field current? snacks.Picker +---@field highlight snacks.picker.highlight +---@field resume fun(opts?: snacks.picker.Config):snacks.Picker +---@field sources snacks.picker.sources.Config +---@overload fun(opts: snacks.picker.Config): snacks.Picker +---@overload fun(source: string, opts: snacks.picker.Config): snacks.Picker +local M = setmetatable({}, { + __call = function(M, ...) + return M.pick(...) + end, + ---@param M snacks.picker + __index = function(M, k) + if type(k) ~= "string" then + return + end + local mods = { + "actions", + "config", + "format", + "preview", + "util", + "sort", + highlight = "util.highlight", + sources = "config.sources", + } + for m, mod in pairs(mods) do + mod = mod == k and k or m == k and mod or nil + if mod then + ---@diagnostic disable-next-line: no-unknown + M[k] = require("snacks.picker." .. mod) + return rawget(M, k) + end + end + return M.config.wrap(k, { check = true }) + end, +}) + +---@type snacks.meta.Meta +M.meta = { + desc = "Picker for selecting items", + needs_setup = true, + merge = { config = "config.defaults", picker = "core.picker", "actions" }, +} + +-- create actual picker functions for autocomplete +vim.schedule(M.config.setup) + +--- Create a new picker +---@param source? string +---@param opts? snacks.picker.Config +---@overload fun(opts: snacks.picker.Config): snacks.Picker +function M.pick(source, opts) + if not opts and type(source) == "table" then + opts, source = source, nil + end + opts = opts or {} + opts.source = source or opts.source + -- Show pickers if no source, items or finder is provided + if not (opts.source or opts.items or opts.finder) then + opts.source = "pickers" + return M.pick(opts) + end + return require("snacks.picker.core.picker").new(opts) +end + +--- Implementation for `vim.ui.select` +---@type snacks.picker.ui_select +function M.select(...) + return require("snacks.picker.select").select(...) +end + +---@private +function M.setup() + if M.config.get().ui_select then + vim.ui.select = M.select + end +end + +---@private +function M.health() + require("snacks.picker.core._health").health() +end + +return M diff --git a/lua/snacks/picker/preview.lua b/lua/snacks/picker/preview.lua new file mode 100644 index 00000000..3d9fe677 --- /dev/null +++ b/lua/snacks/picker/preview.lua @@ -0,0 +1,250 @@ +---@class snacks.picker.previewers +local M = {} + +local uv = vim.uv or vim.loop +local ns = vim.api.nvim_create_namespace("snacks.picker.preview") + +---@param ctx snacks.picker.preview.ctx +function M.directory(ctx) + ctx.preview:reset() + local ls = {} ---@type {file:string, type:"file"|"directory"}[] + for file, t in vim.fs.dir(ctx.item.file) do + ls[#ls + 1] = { file = file, type = t } + end + vim.api.nvim_buf_set_lines(ctx.buf, 0, -1, false, vim.split(string.rep("\n", #ls), "\n")) + vim.bo[ctx.buf].modifiable = false + table.sort(ls, function(a, b) + if a.type ~= b.type then + return a.type == "directory" + end + return a.file < b.file + end) + for i, item in ipairs(ls) do + local cat = item.type == "directory" and "directory" or "file" + local hl = item.type == "directory" and "Directory" or nil + local path = item.file + local icon, icon_hl = Snacks.util.icon(path, cat) + local line = { { icon .. " ", icon_hl }, { path, hl } } + vim.api.nvim_buf_set_extmark(ctx.buf, ns, i - 1, 0, { + virt_text = line, + }) + end +end + +---@param ctx snacks.picker.preview.ctx +function M.none(ctx) + ctx.preview:reset() + ctx.preview:notify("no preview available", "warn") +end + +---@param ctx snacks.picker.preview.ctx +function M.preview(ctx) + if ctx.item.preview == "file" then + return M.file(ctx) + end + assert(type(ctx.item.preview) == "table", "item.preview must be a table") + ctx.preview:reset() + local lines = vim.split(ctx.item.preview.text, "\n") + vim.api.nvim_buf_set_lines(ctx.buf, 0, -1, false, lines) + ctx.preview:highlight({ ft = ctx.item.preview.ft }) + for _, extmark in ipairs(ctx.item.preview.extmarks or {}) do + local e = vim.deepcopy(extmark) + e.col, e.row = nil, nil + vim.api.nvim_buf_set_extmark(ctx.buf, ns, (extmark.row or 1) - 1, extmark.col, e) + end + ctx.preview:loc() +end + +---@param ctx snacks.picker.preview.ctx +function M.file(ctx) + if ctx.item.buf and vim.api.nvim_buf_is_loaded(ctx.item.buf) then + local name = vim.api.nvim_buf_get_name(ctx.item.buf) + name = uv.fs_stat(name) and vim.fn.fnamemodify(name, ":t") or name + ctx.preview:set_title(name) + vim.api.nvim_win_set_buf(ctx.win, ctx.item.buf) + else + local path = Snacks.picker.util.path(ctx.item) + if not path then + ctx.preview:notify("Item has no `file`", "error") + return + end + -- re-use existing preview when path is the same + if path ~= Snacks.picker.util.path(ctx.prev) then + ctx.preview:reset() + + local name = vim.fn.fnamemodify(path, ":t") + ctx.preview:set_title(ctx.item.title or name) + + local stat = uv.fs_stat(path) + if not stat then + ctx.preview:notify("file not found: " .. path, "error") + return false + end + if stat.type == "directory" then + return M.directory(ctx) + end + local max_size = ctx.picker.opts.previewers.file.max_size or (1024 * 1024) + if stat.size > max_size then + ctx.preview:notify("large file > 1MB", "warn") + return false + end + if stat.size == 0 then + ctx.preview:notify("empty file", "warn") + return false + end + + local file = assert(io.open(path, "r")) + + local lines = {} + for line in file:lines() do + ---@cast line string + if #line > ctx.picker.opts.previewers.file.max_line_length then + line = line:sub(1, ctx.picker.opts.previewers.file.max_line_length) .. "..." + end + -- Check for binary data in the current line + if line:find("[%z\1-\8\13\14\16-\31]") then + ctx.preview:notify("binary file", "warn") + return + end + table.insert(lines, line) + end + + file:close() + + vim.api.nvim_buf_set_lines(ctx.buf, 0, -1, false, lines) + vim.bo[ctx.buf].modifiable = false + ctx.preview:highlight({ file = path, ft = ctx.picker.opts.previewers.file.ft, buf = ctx.buf }) + end + end + ctx.preview:loc() +end + +---@param cmd string[] +---@param ctx snacks.picker.preview.ctx +---@param opts? {env?:table, pty?:boolean, ft?:string} +function M.cmd(cmd, ctx, opts) + opts = opts or {} + local buf = ctx.preview:scratch() + local killed = false + local chan = vim.api.nvim_open_term(buf, {}) + local output = {} ---@type string[] + local jid = vim.fn.jobstart(cmd, { + height = vim.api.nvim_win_get_height(ctx.win), + width = vim.api.nvim_win_get_width(ctx.win), + pty = opts.pty ~= false and not opts.ft, + cwd = ctx.item.cwd, + env = vim.tbl_extend("force", { + PAGER = "cat", + DELTA_PAGER = "cat", + }, opts.env or {}), + on_stdout = function(_, data) + if not vim.api.nvim_buf_is_valid(buf) then + return + end + data = table.concat(data, "\n") + local ok = pcall(vim.api.nvim_chan_send, chan, data) + if ok then + vim.api.nvim_buf_call(buf, function() + vim.cmd("norm! gg") + end) + end + table.insert(output, data) + end, + on_exit = function(_, code) + if not killed and code ~= 0 then + Snacks.notify.error( + ("Terminal **cmd** `%s` failed with code `%d`:\n- `vim.o.shell = %q`\n\nOutput:\n%s"):format( + cmd, + code, + vim.o.shell, + vim.trim(table.concat(output, "")) + ) + ) + end + end, + }) + if opts.ft then + ctx.preview:highlight({ ft = opts.ft }) + end + vim.api.nvim_create_autocmd("BufWipeout", { + buffer = buf, + callback = function() + killed = true + vim.fn.jobstop(jid) + vim.fn.chanclose(chan) + end, + }) + if jid <= 0 then + Snacks.notify.error(("Failed to start terminal **cmd** `%s`"):format(cmd)) + end +end + +---@param ctx snacks.picker.preview.ctx +function M.git_show(ctx) + local cmd = { + "git", + "-c", + "delta." .. vim.o.background .. "=true", + "show", + ctx.item.commit, + } + M.cmd(cmd, ctx) +end + +---@param ctx snacks.picker.preview.ctx +function M.git_diff(ctx) + local cmd = { + "git", + "-c", + "delta." .. vim.o.background .. "=true", + "diff", + "--", + ctx.item.file, + } + M.cmd(cmd, ctx) +end + +---@param ctx snacks.picker.preview.ctx +function M.git_status(ctx) + local s = vim.trim(ctx.item.status):sub(1, 1) + if s == "?" then + M.file(ctx) + else + M.git_diff(ctx) + end +end + +---@param ctx snacks.picker.preview.ctx +function M.colorscheme(ctx) + if not ctx.preview.state.colorscheme then + ctx.preview.state.colorscheme = vim.g.colors_name or "default" + ctx.preview.state.background = vim.o.background + ctx.preview.win:on("WinClosed", function() + if not ctx.preview.state.colorscheme then + return + end + vim.schedule(function() + vim.cmd("colorscheme " .. ctx.preview.state.colorscheme) + vim.o.background = ctx.preview.state.background + end) + end, { win = true }) + end + vim.schedule(function() + vim.cmd("colorscheme " .. ctx.item.text) + end) + Snacks.picker.preview.file(ctx) +end + +---@param ctx snacks.picker.preview.ctx +function M.man(ctx) + M.cmd({ "man", ctx.item.section, ctx.item.page }, ctx, { + ft = "man", + env = { + MANPAGER = ctx.picker.opts.previewers.man_pager or vim.fn.executable("col") == 1 and "col -bx" or "cat", + MANWIDTH = tostring(ctx.preview.win:dim().width), + MANPATH = vim.env.MANPATH, + }, + }) +end + +return M diff --git a/lua/snacks/picker/select.lua b/lua/snacks/picker/select.lua new file mode 100644 index 00000000..f36a55ff --- /dev/null +++ b/lua/snacks/picker/select.lua @@ -0,0 +1,50 @@ +local M = {} + +---@alias snacks.picker.ui_select fun(items: any[], opts?: {prompt?: string, format_item?: (fun(item: any): string), kind?: string}, on_choice: fun(item?: any, idx?: number)) + +---@generic T +---@param items T[] Arbitrary items +---@param opts? {prompt?: string, format_item?: (fun(item: T): string), kind?: string} +---@param on_choice fun(item?: T, idx?: number) +function M.select(items, opts, on_choice) + assert(type(on_choice) == "function", "on_choice must be a function") + opts = opts or {} + + ---@type snacks.picker.finder.Item[] + local finder_items = {} + for idx, item in ipairs(items) do + local text = (opts.format_item or tostring)(item) + table.insert(finder_items, { + formatted = text, + text = idx .. " " .. text, + item = item, + idx = idx, + }) + end + + local title = opts.prompt or "Select" + title = title:gsub("^%s*", ""):gsub("[%s:]*$", "") + local layout = Snacks.picker.config.layout("select") + layout.preview = false + layout.layout.height = math.floor(math.min(vim.o.lines * 0.8 - 10, #items + 2) + 0.5) + 10 + layout.layout.title = " " .. title .. " " + layout.layout.title_pos = "center" + + ---@type snacks.picker.finder.Item[] + return Snacks.picker.pick({ + source = "select", + items = finder_items, + format = Snacks.picker.format.ui_select(opts.kind, #items), + actions = { + confirm = function(picker, item) + picker:close() + vim.schedule(function() + on_choice(item and item.item, item and item.idx) + end) + end, + }, + layout = layout, + }) +end + +return M diff --git a/lua/snacks/picker/sort.lua b/lua/snacks/picker/sort.lua new file mode 100644 index 00000000..82147620 --- /dev/null +++ b/lua/snacks/picker/sort.lua @@ -0,0 +1,45 @@ +---@class snacks.picker.sorters +local M = {} + +---@alias snacks.picker.sort.Field { name: string, desc: boolean } + +---@class snacks.picker.sort.Config +---@field fields? (snacks.picker.sort.Field|string)[] + +---@param opts? snacks.picker.sort.Config +function M.default(opts) + local fields = {} ---@type snacks.picker.sort.Field[] + for _, f in ipairs(opts and opts.fields or { { name = "score", desc = true }, "idx" }) do + if type(f) == "string" then + table.insert(fields, { name = f, desc = false }) + else + table.insert(fields, f) + end + end + + ---@param a snacks.picker.Item + ---@param b snacks.picker.Item + return function(a, b) + for _, field in ipairs(fields) do + local av, bv = a[field.name], b[field.name] + if (av ~= nil) and (bv ~= nil) and (av ~= bv) then + if field.desc then + return av > bv + else + return av < bv + end + end + end + return false + end +end + +function M.idx() + ---@param a snacks.picker.Item + ---@param b snacks.picker.Item + return function(a, b) + return a.idx < b.idx + end +end + +return M diff --git a/lua/snacks/picker/source/buffers.lua b/lua/snacks/picker/source/buffers.lua new file mode 100644 index 00000000..d00eae34 --- /dev/null +++ b/lua/snacks/picker/source/buffers.lua @@ -0,0 +1,45 @@ +local M = {} + +---@class snacks.picker +---@field buffers fun(opts?: snacks.picker.buffers.Config): snacks.Picker + +---@param opts snacks.picker.buffers.Config +---@type snacks.picker.finder +function M.buffers(opts, filter) + local items = {} ---@type snacks.picker.finder.Item[] + local current_buf = vim.api.nvim_get_current_buf() + local alternate_buf = vim.fn.bufnr("#") + for _, buf in ipairs(vim.api.nvim_list_bufs()) do + local keep = (opts.hidden or vim.bo[buf].buflisted) + and (opts.unloaded or vim.api.nvim_buf_is_loaded(buf)) + and (opts.current or buf ~= current_buf) + and (opts.nofile or vim.bo[buf].buftype ~= "nofile") + if keep then + local name = vim.api.nvim_buf_get_name(buf) + name = name == "" and "[No Name]" or name + local info = vim.fn.getbufinfo(buf)[1] + local flags = { + buf == current_buf and "%" or (buf == alternate_buf and "#" or ""), + info.hidden == 1 and "h" or "a", + vim.bo[buf].readonly and "=" or "", + info.changed == 1 and "+" or "", + } + table.insert(items, { + flags = table.concat(flags), + buf = buf, + text = name, + file = name, + info = info, + pos = { info.lnum, 0 }, + }) + end + end + if opts.sort_lastused then + table.sort(items, function(a, b) + return a.info.lastused > b.info.lastused + end) + end + return filter:filter(items) +end + +return M diff --git a/lua/snacks/picker/source/diagnostics.lua b/lua/snacks/picker/source/diagnostics.lua new file mode 100644 index 00000000..2a05f544 --- /dev/null +++ b/lua/snacks/picker/source/diagnostics.lua @@ -0,0 +1,38 @@ +local M = {} +local uv = vim.uv or vim.loop + +---@class snacks.picker +---@field diagnostics fun(opts?: snacks.picker.diagnostics.Config): snacks.Picker + +---@param opts snacks.picker.diagnostics.Config +---@type snacks.picker.finder +function M.diagnostics(opts, filter) + local items = {} ---@type snacks.picker.finder.Item[] + local current_buf = vim.api.nvim_get_current_buf() + local cwd = vim.fs.normalize(uv.cwd() or ".") + for _, diag in ipairs(vim.diagnostic.get(filter.buf, { severity = opts.severity })) do + local buf = diag.bufnr + if buf and vim.api.nvim_buf_is_valid(buf) then + local file = vim.fs.normalize(vim.api.nvim_buf_get_name(buf), { _fast = true }) + local severity = diag.severity + severity = type(severity) == "number" and vim.diagnostic.severity[severity] or severity + ---@cast severity string? + items[#items + 1] = { + text = table.concat({ severity or "", tostring(diag.code or ""), file, diag.source or "", diag.message }, " "), + file = file, + buf = diag.bufnr, + is_current = buf == current_buf and 0 or 1, + is_cwd = file:sub(1, #cwd) == cwd and 0 or 1, + lnum = diag.lnum, + severity = diag.severity, + pos = { diag.lnum + 1, diag.col + 1 }, + end_pos = diag.end_lnum and { diag.end_lnum + 1, diag.end_col + 1 } or nil, + item = diag, + comment = diag.message, + } + end + end + return filter:filter(items) +end + +return M diff --git a/lua/snacks/picker/source/files.lua b/lua/snacks/picker/source/files.lua new file mode 100644 index 00000000..d578648d --- /dev/null +++ b/lua/snacks/picker/source/files.lua @@ -0,0 +1,114 @@ +local M = {} + +---@class snacks.picker +---@field files fun(opts?: snacks.picker.files.Config): snacks.Picker +---@field zoxide fun(opts?: snacks.picker.Config): snacks.Picker + +local uv = vim.uv or vim.loop + +local commands = { + rg = { "--files", "--no-messages", "--color", "never", "-g", "!.git" }, + fd = { "--type", "f", "--color", "never", "-E", ".git" }, + find = { ".", "-type", "f", "-not", "-path", "*/.git/*" }, +} + +---@param opts snacks.picker.files.Config +---@param filter snacks.picker.Filter +local function get_cmd(opts, filter) + local cmd, args ---@type string, string[] + if vim.fn.executable("fd") == 1 then + cmd, args = "fd", commands.fd + elseif vim.fn.executable("fdfind") == 1 then + cmd, args = "fdfind", commands.fd + elseif vim.fn.executable("rg") == 1 then + cmd, args = "rg", commands.rg + elseif vim.fn.executable("find") == 1 and vim.fn.has("win-32") == 0 then + cmd, args = "find", commands.find + else + error("No supported finder found") + end + args = vim.deepcopy(args) + local is_fd, is_fd_rg, is_find, is_rg = cmd == "fd" or cmd == "fdfind", cmd ~= "find", cmd == "find", cmd == "rg" + + -- hidden + if opts.hidden and is_fd_rg then + table.insert(args, "--hidden") + elseif not opts.hidden and is_find then + vim.list_extend(args, { "-not", "-path", "*/.*" }) + end + + -- ignored + if opts.ignored and is_fd_rg then + args[#args + 1] = "--no-ignore" + end + + -- follow + if opts.follow then + args[#args + 1] = "-L" + end + + -- file glob + ---@type string? + local pattern = filter.search + pattern = pattern ~= "" and pattern or nil + if pattern then + if is_fd then + table.insert(args, pattern) + elseif is_rg then + table.insert(args, "--glob") + table.insert(args, pattern) + elseif is_find then + table.insert(args, "-name") + table.insert(args, pattern) + end + end + + -- dirs + if opts.dirs and #opts.dirs > 0 then + local dirs = vim.tbl_map(vim.fs.normalize, opts.dirs) ---@type string[] + if is_fd and not pattern then + args[#args + 1] = "." + end + if is_find then + table.remove(args, 1) + for _, d in pairs(dirs) do + table.insert(args, 1, d) + end + else + vim.list_extend(args, dirs) + end + end + + return cmd, args +end + +---@param opts snacks.picker.files.Config +---@type snacks.picker.finder +function M.files(opts, filter) + local cwd = not (opts.dirs and #opts.dirs > 0) and vim.fs.normalize(opts and opts.cwd or uv.cwd() or ".") or nil + local cmd, args = get_cmd(opts, filter) + return require("snacks.picker.source.proc").proc(vim.tbl_deep_extend("force", { + cmd = cmd, + args = args, + ---@param item snacks.picker.finder.Item + transform = function(item) + item.cwd = cwd + item.file = item.text + end, + }, opts or {})) +end + +---@param opts snacks.picker.Config +---@type snacks.picker.finder +function M.zoxide(opts) + return require("snacks.picker.source.proc").proc(vim.tbl_deep_extend("force", { + cmd = "zoxide", + args = { "query", "--list" }, + ---@param item snacks.picker.finder.Item + transform = function(item) + item.file = item.text + end, + }, opts or {})) +end + +return M diff --git a/lua/snacks/picker/source/git.lua b/lua/snacks/picker/source/git.lua new file mode 100644 index 00000000..8aeca4ec --- /dev/null +++ b/lua/snacks/picker/source/git.lua @@ -0,0 +1,102 @@ +local M = {} + +local uv = vim.uv or vim.loop + +---@class snacks.picker +---@field git_files fun(opts?: snacks.picker.git.files.Config): snacks.Picker +---@field git_log fun(opts?: snacks.picker.git.log.Config): snacks.Picker +---@field git_log_file fun(opts?: snacks.picker.git.log.Config): snacks.Picker +---@field git_log_line fun(opts?: snacks.picker.git.log.Config): snacks.Picker +---@field git_status fun(opts?: snacks.picker.Config): snacks.Picker + +---@param opts snacks.picker.git.files.Config +---@type snacks.picker.finder +function M.files(opts) + local args = { "-c", "core.quotepath=false", "ls-files", "--exclude-standard", "--cached" } + if opts.untracked then + table.insert(args, "--others") + elseif opts.submodules then + table.insert(args, "--recurse-submodules") + end + local cwd = vim.fs.normalize(opts and opts.cwd or uv.cwd() or ".") or nil + return require("snacks.picker.source.proc").proc(vim.tbl_deep_extend("force", { + cmd = "git", + args = args, + ---@param item snacks.picker.finder.Item + transform = function(item) + item.cwd = cwd + item.file = item.text + end, + }, opts or {})) +end + +---@param opts snacks.picker.git.log.Config +---@type snacks.picker.finder +function M.log(opts) + local args = { + "log", + "--pretty=format:%h %s (%ch)", + "--abbrev-commit", + "--decorate", + "--date=short", + "--color=never", + "--no-show-signature", + "--no-patch", + } + + if opts.follow and not opts.current_line then + args[#args + 1] = "--follow" + end + + if opts.current_line then + local cursor = vim.api.nvim_win_get_cursor(0) + local line = cursor[1] + args[#args + 1] = "-L" + args[#args + 1] = line .. ",+1:" .. vim.api.nvim_buf_get_name(0) + elseif opts.current_file then + args[#args + 1] = "--" + args[#args + 1] = vim.api.nvim_buf_get_name(0) + end + + local cwd = vim.fs.normalize(opts and opts.cwd or uv.cwd() or ".") or nil + return require("snacks.picker.source.proc").proc(vim.tbl_deep_extend("force", { + cmd = "git", + args = args, + ---@param item snacks.picker.finder.Item + transform = function(item) + local commit, msg, date = item.text:match("^(%S+) (.*) %((.*)%)$") + if not commit then + error(item.text) + end + item.cwd = cwd + item.commit = commit + item.msg = msg + item.date = date + item.file = item.text + end, + }, opts or {})) +end + +---@param opts snacks.picker.Config +---@type snacks.picker.finder +function M.status(opts) + local args = { + "status", + "--porcelain=v1", + } + + local cwd = vim.fs.normalize(opts and opts.cwd or uv.cwd() or ".") or nil + return require("snacks.picker.source.proc").proc(vim.tbl_deep_extend("force", { + cmd = "git", + args = args, + ---@param item snacks.picker.finder.Item + transform = function(item) + local status, file = item.text:sub(1, 2), item.text:sub(4) + item.cwd = cwd + item.status = status + item.file = file + end, + }, opts or {})) +end + +return M diff --git a/lua/snacks/picker/source/grep.lua b/lua/snacks/picker/source/grep.lua new file mode 100644 index 00000000..c1b05712 --- /dev/null +++ b/lua/snacks/picker/source/grep.lua @@ -0,0 +1,109 @@ +local M = {} + +local uv = vim.uv or vim.loop + +---@class snacks.picker +---@field grep fun(opts?: snacks.picker.grep.Config): snacks.Picker +---@field grep_word fun(opts?: snacks.picker.grep.Config): snacks.Picker +---@field grep_buffers fun(opts?: snacks.picker.grep.Config): snacks.Picker + +---@param opts snacks.picker.grep.Config +---@param filter snacks.picker.Filter +local function get_cmd(opts, filter) + local cmd = "rg" + local args = { + "--color=never", + "--no-heading", + "--with-filename", + "--line-number", + "--column", + "--smart-case", + "--max-columns=500", + "--max-columns-preview", + "-g", + "!.git", + } + + args = vim.deepcopy(args) + + -- hidden + if opts.hidden then + table.insert(args, "--hidden") + end + + -- ignored + if opts.ignored then + args[#args + 1] = "--no-ignore" + end + + -- follow + if opts.follow then + args[#args + 1] = "-L" + end + + local glob = type(opts.glob) == "table" and opts.glob or { opts.glob } + ---@cast glob string[] + for _, g in ipairs(glob) do + args[#args + 1] = "-g" + args[#args + 1] = g + end + + args[#args + 1] = "--" + + -- search pattern + table.insert(args, filter.search) + + local paths = {} ---@type string[] + + if opts.buffers then + for _, buf in ipairs(vim.api.nvim_list_bufs()) do + local name = vim.api.nvim_buf_get_name(buf) + if name ~= "" and vim.bo[buf].buflisted and uv.fs_stat(name) then + paths[#paths + 1] = name + end + end + elseif opts.dirs and #opts.dirs > 0 then + paths = opts.dirs or {} + end + + -- dirs + if #paths > 0 then + paths = vim.tbl_map(vim.fs.normalize, paths) ---@type string[] + vim.list_extend(args, paths) + end + + return cmd, args +end + +---@param opts snacks.picker.grep.Config +---@type snacks.picker.finder +function M.grep(opts, filter) + if opts.need_search ~= false and filter.search == "" then + return function() end + end + local absolute = (opts.dirs and #opts.dirs > 0) or opts.buffers + local cwd = not absolute and vim.fs.normalize(opts and opts.cwd or uv.cwd() or ".") or nil + local cmd, args = get_cmd(opts, filter) + return require("snacks.picker.source.proc").proc(vim.tbl_deep_extend("force", { + notify = false, + cmd = cmd, + args = args, + ---@param item snacks.picker.finder.Item + transform = function(item) + item.cwd = cwd + local file, line, col, text = item.text:match("^(.+):(%d+):(%d+):(.*)$") + if not file then + if not item.text:match("WARNING") then + error("invalid grep output: " .. item.text) + end + return false + else + item.line = text + item.file = file + item.pos = { tonumber(line), tonumber(col) } + end + end, + }, opts or {})) +end + +return M diff --git a/lua/snacks/picker/source/help.lua b/lua/snacks/picker/source/help.lua new file mode 100644 index 00000000..09559de9 --- /dev/null +++ b/lua/snacks/picker/source/help.lua @@ -0,0 +1,64 @@ +local M = {} + +---@class snacks.picker +---@field help fun(opts?: snacks.picker.help.Config): snacks.Picker + +---@param opts snacks.picker.help.Config +---@type snacks.picker.finder +function M.help(opts) + local langs = opts.lang or vim.opt.helplang:get() ---@type string[] + local rtp = vim.o.runtimepath + if package.loaded.lazy then + rtp = rtp .. "," .. table.concat(require("lazy.core.util").get_unloaded_rtp(""), ",") + end + local files = vim.fn.globpath(rtp, "doc/*", true, true) ---@type string[] + ---@async + ---@param cb async fun(item: snacks.picker.finder.Item) + return function(cb) + if not vim.tbl_contains(langs, "en") then + table.insert(langs, "en") + end + + local tag_files = {} ---@type table + local help_files = {} ---@type table + + for _, file in ipairs(files) do + local name = vim.fn.fnamemodify(file, ":t") + local lang = "en" + if name == "tags" or name:sub(1, 5) == "tags-" then + lang = name:match("^tags%-(..)$") or lang + if vim.tbl_contains(langs, lang) then + tag_files[lang] = tag_files[lang] or {} + table.insert(tag_files[lang], file) + end + else + help_files[name] = file + end + end + + local done = {} ---@type table + + for _, lang in ipairs(langs) do + for _, file in ipairs(tag_files[lang] or {}) do + for line in io.lines(file) do + local fields = vim.split(line, string.char(9), { plain = true }) + if not line:match("^!_TAG_") and #fields == 3 and not done[fields[1]] then + done[fields[1]] = true + ---@type snacks.picker.finder.Item + local item = { + text = fields[1], + tag = fields[1], + file = help_files[fields[2]], + search = "/\\V" .. fields[3]:sub(2), + } + if item.file then + cb(item) + end + end + end + end + end + end +end + +return M diff --git a/lua/snacks/picker/source/lines.lua b/lua/snacks/picker/source/lines.lua new file mode 100644 index 00000000..5fe411f8 --- /dev/null +++ b/lua/snacks/picker/source/lines.lua @@ -0,0 +1,27 @@ +local M = {} + +---@class snacks.picker +---@field lines fun(opts?: snacks.picker.lines.Config): snacks.Picker + +---@param opts snacks.picker.lines.Config +---@type snacks.picker.finder +function M.lines(opts) + local buf = opts.buf or 0 + buf = buf == 0 and vim.api.nvim_get_current_buf() or buf + local extmarks = require("snacks.picker.util.highlight").get_highlights({ buf = buf }) + local lines = vim.api.nvim_buf_get_lines(buf, 0, -1, false) + local items = {} ---@type snacks.picker.finder.Item[] + for l, line in ipairs(lines) do + ---@type snacks.picker.finder.Item + local item = { + buf = buf, + text = line, + pos = { l, 0 }, + highlights = extmarks[l], + } + items[#items + 1] = item + end + return items +end + +return M diff --git a/lua/snacks/picker/source/lsp.lua b/lua/snacks/picker/source/lsp.lua new file mode 100644 index 00000000..6aff6587 --- /dev/null +++ b/lua/snacks/picker/source/lsp.lua @@ -0,0 +1,355 @@ +local Async = require("snacks.picker.util.async") + +local M = {} + +---@class snacks.picker +---@field lsp_definitions? fun(opts?: snacks.picker.lsp.Config):snacks.Picker +---@field lsp_implementations? fun(opts?: snacks.picker.lsp.Config):snacks.Picker +---@field lsp_declarations? fun(opts?: snacks.picker.lsp.Config):snacks.Picker +---@field lsp_type_definitions? fun(opts?: snacks.picker.lsp.Config):snacks.Picker +---@field lsp_references? fun(opts?: snacks.picker.lsp.references.Config):snacks.Picker +---@field lsp_symbols? fun(opts?: snacks.picker.lsp.symbols.Config):snacks.Picker + +---@alias lsp.Symbol lsp.SymbolInformation|lsp.DocumentSymbol +---@alias lsp.Loc lsp.Location|lsp.LocationLink + +local kinds = nil ---@type table + +--- Gets the original symbol kind name from its number. +--- Some plugins override the symbol kind names, so this function is needed to get the original name. +---@param kind lsp.SymbolKind +---@return string +function M.symbol_kind(kind) + if not kinds then + kinds = {} + for k, v in pairs(vim.lsp.protocol.SymbolKind) do + if type(v) == "number" then + kinds[v] = k + end + end + end + return kinds[kind] +end + +--- Neovim 0.11 uses a lua class for clients, while older versions use a table. +--- Wraps older style clients to be compatible with the new style. +---@param client vim.lsp.Client +---@return vim.lsp.Client +local function wrap(client) + local meta = getmetatable(client) + if meta and meta.request then + return client + end + ---@diagnostic disable-next-line: undefined-field + if client.wrapped then + return client + end + local methods = { "request", "supports_method", "cancel_request" } + -- old style + return setmetatable({ wrapped = true }, { + __index = function(_, k) + if k == "supports_method" then + -- supports_method doesn't support the bufnr argument + return function(_, method) + return client[k](method) + end + end + if vim.tbl_contains(methods, k) then + return function(_, ...) + return client[k](...) + end + end + return client[k] + end, + }) +end + +---@param buf number +---@param method string +---@return vim.lsp.Client[] +function M.get_clients(buf, method) + ---@param client vim.lsp.Client + local clients = vim.tbl_map(function(client) + return wrap(client) + ---@diagnostic disable-next-line: deprecated + end, (vim.lsp.get_clients or vim.lsp.get_active_clients)({ bufnr = buf })) + ---@param client vim.lsp.Client + return vim.tbl_filter(function(client) + return client:supports_method(method, buf) + ---@diagnostic disable-next-line: deprecated + end, clients) +end + +---@param buf number +---@param method string +---@param params fun(client:vim.lsp.Client):table +---@param cb fun(client:vim.lsp.Client, result:table, params:table) +---@async +function M.request(buf, method, params, cb) + local async = Async.running() + local cancel = {} ---@type fun()[] + + async:on("abort", function() + for _, c in ipairs(cancel) do + c() + end + end) + vim.schedule(function() + local clients = M.get_clients(buf, method) + local remaining = #clients + for _, client in ipairs(clients) do + local p = params(client) + local status, request_id = client:request(method, p, function(_, result) + if result then + cb(client, result, p) + end + remaining = remaining - 1 + if remaining == 0 then + async:resume() + end + end) + if status and request_id then + table.insert(cancel, function() + client:cancel_request(request_id) + end) + end + end + end) + + async:suspend() +end + +-- Support for older versions of neovim +---@param locs vim.quickfix.entry[] +function M.fix_locs(locs) + for _, loc in ipairs(locs) do + local range = loc.user_data and loc.user_data.range or nil ---@type lsp.Range? + if range then + if not loc.end_lnum then + if range.start.line == range["end"].line then + loc.end_lnum = loc.lnum + loc.end_col = loc.col + range["end"].character - range.start.character + end + end + end + end +end + +---@param method string +---@param opts snacks.picker.lsp.Config|{context?:lsp.ReferenceContext} +---@param filter snacks.picker.Filter +function M.get_locations(method, opts, filter) + local win = vim.api.nvim_get_current_win() + local buf = vim.api.nvim_get_current_buf() + local fname = vim.api.nvim_buf_get_name(buf) + fname = vim.fs.normalize(fname) + local cursor = vim.api.nvim_win_get_cursor(win) + + ---@async + ---@param cb async fun(item: snacks.picker.finder.Item) + return function(cb) + M.request(buf, method, function(client) + local params = vim.lsp.util.make_position_params(win, client.offset_encoding) + ---@diagnostic disable-next-line: inject-field + params.context = opts.context + return params + end, function(client, result) + local items = vim.lsp.util.locations_to_items(result or {}, client.offset_encoding) + M.fix_locs(items) + + if not opts.include_current then + ---@param item vim.quickfix.entry + items = vim.tbl_filter(function(item) + if item.filename ~= fname then + return true + end + if not item.lnum then + return true + end + if item.lnum == cursor[1] then + return false + end + if not item.end_lnum then + return true + end + return not (item.lnum <= cursor[1] and item.end_lnum >= cursor[1]) + end, items) + end + + local done = {} ---@type table + for _, loc in ipairs(items) do + ---@type snacks.picker.finder.Item + local item = { + text = loc.filename .. " " .. loc.text, + buf = loc.bufnr, + file = loc.filename, + pos = { loc.lnum, loc.col }, + end_pos = loc.end_lnum and loc.end_col and { loc.end_lnum, loc.end_col } or nil, + comment = loc.text, + } + local loc_key = loc.filename .. ":" .. loc.lnum + if filter:match(item) and not (done[loc_key] and opts.unique_lines) then + ---@diagnostic disable-next-line: await-in-sync + cb(item) + done[loc_key] = true + end + end + end) + end +end + +---@alias lsp.ResultItem lsp.Symbol|lsp.CallHierarchyItem|{text?:string} +---@param client vim.lsp.Client +---@param results lsp.ResultItem[] +---@param opts? {default_uri?:string, filter?:fun(result:lsp.ResultItem):boolean} +function M.results_to_items(client, results, opts) + opts = opts or {} + local items = {} ---@type snacks.picker.finder.Item[] + local locs = {} ---@type lsp.Loc[] + local processed = {} ---@type table + + ---@param result lsp.ResultItem + local function process(result) + local uri = result.location and result.location.uri or result.uri or opts.default_uri + local loc = result.location or { range = result.selectionRange or result.range, uri = uri } + loc.uri = loc.uri or uri + if not loc.uri then + assert(loc.uri, "missing uri in result:\n" .. vim.inspect(result)) + end + processed[result] = { uri = uri, loc = loc } + if not opts.filter or opts.filter(result) then + locs[#locs + 1] = loc + end + for _, child in ipairs(result.children or {}) do + process(child) + end + end + + for _, result in ipairs(results) do + process(result) + end + + local loc_items = vim.lsp.util.locations_to_items(locs, client.offset_encoding) + M.fix_locs(loc_items) + local ranges = {} ---@type table + for _, i in ipairs(loc_items) do + local loc = i.user_data ---@type lsp.Loc + ranges[loc] = i + end + + local last = {} ---@type table + ---@param result lsp.ResultItem + ---@param parent snacks.picker.finder.Item + local function add(result, parent) + local loc = processed[result].loc + local sym = ranges[loc] + ---@type snacks.picker.finder.Item? + local item + if sym then + item = { + kind = M.symbol_kind(result.kind), + parent = parent, + depth = (parent.depth or 0) + 1, + detail = result.detail, + name = result.name, + text = table.concat({ M.symbol_kind(result.kind), result.name, result.detail }, " "), + file = sym.filename, + buf = sym.bufnr, + pos = { sym.lnum, sym.col }, + end_pos = sym.end_lnum and sym.end_col and { sym.end_lnum, sym.end_col }, + } + items[#items + 1] = item + last[parent] = item + parent = item + end + for _, child in ipairs(result.children or {}) do + add(child, parent) + end + result.children = nil + end + + local root = { depth = 0, text = "" } ---@type snacks.picker.finder.Item + ---@type snacks.picker.finder.Item + for _, result in ipairs(results) do + add(result, root) + end + for _, item in pairs(last) do + item.last = true + end + + return items +end + +---@param opts snacks.picker.lsp.symbols.Config +function M.symbols(opts) + local buf = vim.api.nvim_get_current_buf() + local ft = vim.bo[buf].filetype + local filter = opts.filter[ft] + if filter == nil then + filter = opts.filter.default + end + ---@param kind string? + local function want(kind) + kind = kind or "Unknown" + return type(filter) == "boolean" or vim.tbl_contains(filter, kind) + end + + ---@async + ---@param cb async fun(item: snacks.picker.finder.Item) + return function(cb) + M.request(buf, "textDocument/documentSymbol", function() + return { textDocument = vim.lsp.util.make_text_document_params(buf) } + end, function(client, result, params) + local items = M.results_to_items(client, result, { + default_uri = params.textDocument.uri, + filter = function(item) + return want(M.symbol_kind(item.kind)) + end, + }) + for _, item in ipairs(items) do + item.hierarchy = opts.hierarchy + ---@diagnostic disable-next-line: await-in-sync + cb(item) + end + end) + end +end + +---@param opts snacks.picker.lsp.references.Config +---@type snacks.picker.finder +function M.references(opts, filter) + opts = opts or {} + return M.get_locations( + "textDocument/references", + vim.tbl_deep_extend("force", opts, { + context = { includeDeclaration = opts.include_declaration }, + }), + filter + ) +end + +---@param opts snacks.picker.lsp.Config +---@type snacks.picker.finder +function M.definitions(opts, filter) + return M.get_locations("textDocument/definition", opts, filter) +end + +---@param opts snacks.picker.lsp.Config +---@type snacks.picker.finder +function M.type_definitions(opts, filter) + return M.get_locations("textDocument/typeDefinition", opts, filter) +end + +---@param opts snacks.picker.lsp.Config +---@type snacks.picker.finder +function M.implementations(opts, filter) + return M.get_locations("textDocument/implementation", opts, filter) +end + +---@param opts snacks.picker.lsp.Config +---@type snacks.picker.finder +function M.declarations(opts, filter) + return M.get_locations("textDocument/declaration", opts, filter) +end + +return M diff --git a/lua/snacks/picker/source/meta.lua b/lua/snacks/picker/source/meta.lua new file mode 100644 index 00000000..26ee0f7c --- /dev/null +++ b/lua/snacks/picker/source/meta.lua @@ -0,0 +1,46 @@ +local M = {} + +---@class snacks.picker +---@field pickers fun(opts?: snacks.picker.Config): snacks.Picker + +---@param file string +---@param t table +function M.table(file, t) + file = Snacks.meta.file(file) + local values = vim.tbl_keys(t) + table.sort(values) + ---@param value string + return vim.tbl_map(function(value) + return { + file = file, + text = value, + search = ("/^M\\.%s = \\|function M\\.%s("):format(value, value), + } + end, values) +end + +---@param opts snacks.picker.Config +---@type snacks.picker.finder +function M.pickers(opts) + return M.table("picker/config/sources.lua", opts.sources or {}) +end + +---@param opts snacks.picker.Config +---@type snacks.picker.finder +function M.layouts(opts) + return M.table("picker/config/layouts.lua", opts.layouts or {}) +end + +function M.actions() + return M.table("picker/actions.lua", require("snacks.picker.actions")) +end + +function M.preview() + return M.table("picker/preview.lua", require("snacks.picker.preview")) +end + +function M.format() + return M.table("picker/format.lua", require("snacks.picker.format")) +end + +return M diff --git a/lua/snacks/picker/source/proc.lua b/lua/snacks/picker/source/proc.lua new file mode 100644 index 00000000..5bb7120b --- /dev/null +++ b/lua/snacks/picker/source/proc.lua @@ -0,0 +1,118 @@ +local Async = require("snacks.picker.util.async") + +local M = {} + +local uv = vim.uv or vim.loop +M.USE_QUEUE = true + +---@class snacks.picker.proc.Config: snacks.picker.Config +---@field cmd string +---@field args? string[] +---@field env? table +---@field cwd? string +---@field notify? boolean Notify on failure +---@field transform? fun(item: snacks.picker.finder.Item): boolean? + +---@param opts snacks.picker.proc.Config +---@return fun(cb:async fun(item:snacks.picker.finder.Item)) +function M.proc(opts) + assert(opts.cmd, "`opts.cmd` is required") + ---@async + return function(cb) + if opts.transform then + local _cb = cb + cb = function(item) + if opts.transform(item) ~= false then + _cb(item) + end + end + end + local aborted = false + local stdout = assert(uv.new_pipe()) + opts = vim.tbl_deep_extend("force", {}, opts or {}, { + stdio = { nil, stdout, nil }, + cwd = opts.cwd and vim.fs.normalize(opts.cwd) or nil, + }) --[[@as snacks.picker.proc.Config]] + local self = Async.running() + + local handle ---@type uv.uv_process_t + handle = uv.spawn(opts.cmd, opts, function(code, _signal) + if not aborted and code ~= 0 and opts.notify ~= false then + local full = { opts.cmd or "" } + vim.list_extend(full, opts.args or {}) + return Snacks.notify.error(("Command failed:\n- cmd: `%s`"):format(table.concat(full, " "))) + end + stdout:close() + handle:close() + self:resume() + end) + if not handle then + return Snacks.notify.error("Failed to spawn " .. opts.cmd) + end + + local prev ---@type string? + + self:on("abort", function() + aborted = true + if not handle:is_closing() then + handle:kill("sigterm") + vim.defer_fn(function() + if not handle:is_closing() then + handle:kill("sigkill") + end + end, 200) + end + end) + + ---@param data? string + local function process(data) + if aborted then + return + end + if not data then + return prev and cb({ text = prev }) + end + local from = 1 + while from <= #data do + local nl = data:find("\n", from, true) + if nl then + local cr = data:byte(nl - 2, nl - 2) == 13 -- \r + local line = data:sub(from, nl - (cr and 2 or 1)) + if prev then + line, prev = prev .. line, nil + end + cb({ text = line }) + from = nl + 1 + elseif prev then + prev = prev .. data:sub(from) + break + else + prev = data:sub(from) + break + end + end + end + + local queue = require("snacks.picker.util.queue").new() + + stdout:read_start(function(err, data) + assert(not err, err) + if M.USE_QUEUE then + queue:push(data) + self:resume() + else + process(data) + end + end) + + while not (handle:is_closing() and queue:empty()) do + if queue:empty() then + self:suspend() + else + process(queue:pop()) + end + end + end +end + +return M diff --git a/lua/snacks/picker/source/qf.lua b/lua/snacks/picker/source/qf.lua new file mode 100644 index 00000000..41cac4b5 --- /dev/null +++ b/lua/snacks/picker/source/qf.lua @@ -0,0 +1,74 @@ +local M = {} + +---Represents an item in a Neovim quickfix/loclist. +---@class qf.item +---@field bufnr? number The buffer number where the item originates. +---@field filename? string +---@field lnum number The start line number for the item. +---@field end_lnum? number The end line number for the item. +---@field pattern string A pattern related to the item. It can be a search pattern or any relevant string. +---@field col? number The column number where the item starts. +---@field end_col? number The column number where the item ends. +---@field module? string Module information (if any) associated with the item. +---@field nr? number A unique number or ID for the item. +---@field text? string A description or message related to the item. +---@field type? string The type of the item. E.g., "W" might stand for "Warning". +---@field valid number A flag indicating if the item is valid (1) or not (0). +---@field user_data? any Any user data associated with the item. +---@field vcol? number Visual column number. Indicates if the column number is a visual column number (when set to 1) or a byte index (when set to 0). + +---@class snacks.picker +---@field loclist fun(opts?: snacks.picker.Config): snacks.Picker +---@field qflist fun(opts?: snacks.picker.Config): snacks.Picker + +---@class snacks.picker.qf.Config +---@field qf_win? number +---@field filter? snacks.picker.filter.Config + +local severities = { + E = vim.diagnostic.severity.ERROR, + W = vim.diagnostic.severity.WARN, + I = vim.diagnostic.severity.INFO, + H = vim.diagnostic.severity.HINT, + N = vim.diagnostic.severity.HINT, +} + +---@param opts snacks.picker.qf.Config +---@type snacks.picker.finder +function M.qf(opts, filter) + local win = opts.qf_win + win = win == 0 and vim.api.nvim_get_current_win() or win + + local list = win and vim.fn.getloclist(win, { all = true }) or vim.fn.getqflist({ all = true }) + ---@cast list { items?: qf.item[] }? + + local ret = {} ---@type snacks.picker.finder.Item[] + + for _, item in pairs(list and list.items or {}) do + local row = item.lnum == 0 and 1 or item.lnum + local col = (item.col == 0 and 1 or item.col) - 1 + local end_row = item.end_lnum == 0 and row or item.end_lnum + local end_col = item.end_col == 0 and col or (item.end_col - 1) + + if item.valid == 1 then + local file = item.filename or item.bufnr and vim.api.nvim_buf_get_name(item.bufnr) or nil + local text = item.text or "" + ret[#ret + 1] = { + pos = { row, col }, + end_pos = item.end_lnum ~= 0 and { end_row, end_col } or nil, + text = file .. " " .. text, + line = item.text, + file = file, + severity = severities[item.type] or 0, + buf = item.bufnr, + item = item, + } + elseif #ret > 0 and ret[#ret].item.text and item.text then + ret[#ret].item.text = ret[#ret].item.text .. "\n" .. item.text + ret[#ret].item.line = ret[#ret].item.line .. "\n" .. item.text + end + end + return filter:filter(ret) +end + +return M diff --git a/lua/snacks/picker/source/recent.lua b/lua/snacks/picker/source/recent.lua new file mode 100644 index 00000000..5d526d46 --- /dev/null +++ b/lua/snacks/picker/source/recent.lua @@ -0,0 +1,61 @@ +local M = {} + +local uv = vim.uv or vim.loop + +---@class snacks.picker +---@field recent fun(opts?: snacks.picker.recent.Config): snacks.Picker +---@field projects fun(opts?: snacks.picker.projects.Config): snacks.Picker + +---@param filter snacks.picker.Filter +local function oldfiles(filter) + local done = {} ---@type table + local i = 1 + return function() + while vim.v.oldfiles[i] do + local file = vim.fs.normalize(vim.v.oldfiles[i], { _fast = true, expand_env = false }) + local want = not done[file] and filter:match({ file = file, text = "" }) + done[file] = true + i = i + 1 + if want and uv.fs_stat(file) then + return file + end + end + end +end + +--- Get the most recent files, optionally filtered by the +--- current working directory or a custom directory. +---@param opts snacks.picker.recent.Config +---@type snacks.picker.finder +function M.files(opts, filter) + ---@async + ---@param cb async fun(item: snacks.picker.finder.Item) + return function(cb) + for file in oldfiles(filter) do + cb({ file = file, text = file }) + end + end +end + +--- Get the most recent projects based on git roots of recent files. +--- The default action will change the directory to the project root, +--- try to restore the session and open the picker if the session is not restored. +--- You can customize the behavior by providing a custom action. +---@param opts snacks.picker.recent.Config +---@type snacks.picker.finder +function M.projects(opts, filter) + ---@async + ---@param cb async fun(item: snacks.picker.finder.Item) + return function(cb) + local dirs = {} ---@type table + for file in oldfiles(filter) do + local dir = Snacks.git.get_root(file) + if dir and not dirs[dir] then + dirs[dir] = true + cb({ file = dir, text = file, dir = dir }) + end + end + end +end + +return M diff --git a/lua/snacks/picker/source/system.lua b/lua/snacks/picker/source/system.lua new file mode 100644 index 00000000..fda8fc94 --- /dev/null +++ b/lua/snacks/picker/source/system.lua @@ -0,0 +1,64 @@ +local M = {} + +---@class snacks.picker +---@field cliphist fun(opts?: snacks.picker.proc.Config): snacks.Picker +---@field man fun(opts?: snacks.picker.proc.Config): snacks.Picker + +---@param opts snacks.picker.proc.Config +---@type snacks.picker.finder +function M.cliphist(opts) + return require("snacks.picker.source.proc").proc(vim.tbl_deep_extend("force", { + cmd = "cliphist", + args = { "list" }, + ---@param item snacks.picker.finder.Item + transform = function(item) + local id, content = item.text:match("^(%d+)%s+(.+)$") + if id and content and not content:find("^%[%[%s+binary data") then + item.text = content + setmetatable(item, { + __index = function(_, k) + if k == "data" then + local data = vim.fn.system({ "cliphist", "decode", id }) + rawset(item, "data", data) + if vim.v.shell_error ~= 0 then + error(data) + end + return data + elseif k == "preview" then + return { + text = item.data, + ft = "text", + } + end + end, + }) + else + return false + end + end, + }, opts or {})) +end + +---@param opts snacks.picker.proc.Config +---@type snacks.picker.finder +function M.man(opts) + return require("snacks.picker.source.proc").proc(vim.tbl_deep_extend("force", { + cmd = "man", + args = { "-k", "." }, + ---@param item snacks.picker.finder.Item + transform = function(item) + local page, section, desc = item.text:match("^(%S+)%s*%((%S-)%)%s+-%s+(.+)$") + if page and section and desc then + item.section = section + item.desc = desc + item.page = page + item.section = section + item.ref = ("%s(%s)"):format(item.page, item.section or 1) + else + return false + end + end, + }, opts or {})) +end + +return M diff --git a/lua/snacks/picker/source/vim.lua b/lua/snacks/picker/source/vim.lua new file mode 100644 index 00000000..8ccf665a --- /dev/null +++ b/lua/snacks/picker/source/vim.lua @@ -0,0 +1,290 @@ +local M = {} + +---@class snacks.picker +---@field commands fun(opts?: snacks.picker.Config): snacks.Picker +---@field marks fun(opts?: snacks.picker.marks.Config): snacks.Picker +---@field jumps fun(opts?: snacks.picker.Config): snacks.Picker +---@field autocmds fun(opts?: snacks.picker.Config): snacks.Picker +---@field highlights fun(opts?: snacks.picker.Config): snacks.Picker +---@field colorschemes fun(opts?: snacks.picker.Config): snacks.Picker +---@field keymaps fun(opts?: snacks.picker.Config): snacks.Picker +---@field registers fun(opts?: snacks.picker.Config): snacks.Picker +---@field command_history fun(opts?: snacks.picker.history.Config): snacks.Picker +---@field search_history fun(opts?: snacks.picker.history.Config): snacks.Picker + +---@class snacks.picker.history.Config: snacks.picker.Config +---@field name string + +function M.commands() + local commands = vim.api.nvim_get_commands({}) + for k, v in pairs(vim.api.nvim_buf_get_commands(0, {})) do + if type(k) == "string" then -- fixes vim.empty_dict() bug + commands[k] = v + end + end + ---@async + ---@param cb async fun(item: snacks.picker.finder.Item) + return function(cb) + ---@type string[] + local names = vim.tbl_keys(commands) + table.sort(names) + for _, name in pairs(names) do + local def = commands[name] + cb({ + text = name, + command = def, + cmd = name, + preview = { + text = vim.inspect(def), + ft = "lua", + }, + }) + end + end +end + +---@param opts snacks.picker.history.Config +function M.history(opts) + local count = vim.fn.histnr(opts.name) + local items = {} + for i = count, 1, -1 do + local line = vim.fn.histget(opts.name, i) + if not line:find("^%s*$") then + table.insert(items, { + text = line, + cmd = line, + preview = { + text = line, + ft = "text", + }, + }) + end + end + return items +end + +---@param opts snacks.picker.marks.Config +function M.marks(opts) + local marks = {} ---@type vim.fn.getmarklist.ret.item[] + if opts.global then + vim.list_extend(marks, vim.fn.getmarklist()) + end + if opts["local"] then + vim.list_extend(marks, vim.fn.getmarklist(vim.api.nvim_get_current_buf())) + end + + ---@type snacks.picker.finder.Item[] + local items = {} + local bufname = vim.api.nvim_buf_get_name(0) + for _, mark in ipairs(marks) do + local file = mark.file or bufname + local buf = mark.pos[1] and mark.pos[1] > 0 and mark.pos[1] or nil + local line ---@type string? + if buf and mark.pos[2] > 0 and vim.api.nvim_buf_is_valid(mark.pos[2]) then + line = vim.api.nvim_buf_get_lines(buf, mark.pos[2] - 1, mark.pos[2], false)[1] + end + local label = mark.mark:sub(2, 2) + items[#items + 1] = { + text = table.concat({ label, file, line }, " "), + label = label, + line = line, + buf = buf, + file = file, + pos = mark.pos[2] > 0 and { mark.pos[2], mark.pos[3] }, + } + end + table.sort(items, function(a, b) + return a.label < b.label + end) + return items +end + +function M.jumps() + local jumps = vim.fn.getjumplist()[1] + local items = {} ---@type snacks.picker.finder.Item[] + for _, jump in ipairs(jumps) do + local buf = jump.bufnr and vim.api.nvim_buf_is_valid(jump.bufnr) and jump.bufnr or 0 + local file = jump.filename or buf and vim.api.nvim_buf_get_name(buf) or nil + if buf or file then + local line ---@type string? + if buf then + line = vim.api.nvim_buf_get_lines(buf, jump.lnum - 1, jump.lnum, false)[1] + end + local label = tostring(#jumps - #items) + table.insert(items, 1, { + label = Snacks.picker.util.align(label, #tostring(#jumps), { align = "right" }), + buf = buf, + line = line, + text = table.concat({ file, line }, " "), + file = file, + pos = jump.lnum and jump.lnum > 0 and { jump.lnum, jump.col } or nil, + }) + end + end + return items +end + +function M.autocmds() + local autocmds = vim.api.nvim_get_autocmds({}) + local items = {} ---@type snacks.picker.finder.Item[] + for _, au in ipairs(autocmds) do + local item = au --[[@as snacks.picker.finder.Item]] + item.text = Snacks.picker.util.text(item, { "event", "group_name", "pattern", "command" }) + item.preview = { + text = vim.inspect(au), + ft = "lua", + } + item.item = au + if au.callback then + local info = debug.getinfo(au.callback, "S") + if info.what == "Lua" then + item.file = info.source:sub(2) + item.pos = { info.linedefined, 0 } + item.preview = "file" + end + end + items[#items + 1] = item + end + return items +end + +function M.highlights() + local hls = vim.api.nvim_get_hl(0, {}) --[[@as table ]] + local items = {} ---@type snacks.picker.finder.Item[] + for group, hl in pairs(hls) do + local defs = {} ---@type {group:string, hl:vim.api.keyset.get_hl_info}[] + defs[#defs + 1] = { group = group, hl = hl } + local link = hl.link + local done = { [group] = true } ---@type table + while link and not done[link] do + done[link] = true + local hl_link = hls[link] + if hl_link then + defs[#defs + 1] = { group = link, hl = hl_link } + link = hl_link.link + else + break + end + end + local code = {} ---@type string[] + local extmarks = {} ---@type snacks.picker.Extmark[] + local row = 1 + for _, def in ipairs(defs) do + for _, prop in ipairs({ "fg", "bg", "sp" }) do + local v = def.hl[prop] + if type(v) == "number" then + def.hl[prop] = ("#%06X"):format(v) + end + end + code[#code + 1] = ("%s = %s"):format(def.group, vim.inspect(def.hl)) + extmarks[#extmarks + 1] = { row = row, col = 0, hl_group = def.group, end_col = #def.group } + row = row + #vim.split(code[#code], "\n") + 1 + end + items[#items + 1] = { + text = vim.inspect(defs):gsub("\n", " "), + hl_group = group, + preview = { + text = table.concat(code, "\n\n"), + ft = "lua", + extmarks = extmarks, + }, + } + end + table.sort(items, function(a, b) + return a.hl_group < b.hl_group + end) + return items +end + +function M.colorschemes() + local items = {} ---@type snacks.picker.finder.Item[] + local rtp = vim.o.runtimepath + if package.loaded.lazy then + rtp = rtp .. "," .. table.concat(require("lazy.core.util").get_unloaded_rtp(""), ",") + end + local files = vim.fn.globpath(rtp, "colors/*", true, true) ---@type string[] + for _, file in ipairs(files) do + local name = vim.fn.fnamemodify(file, ":t:r") + local ext = vim.fn.fnamemodify(file, ":e") + if ext == "vim" or ext == "lua" then + items[#items + 1] = { + text = name, + file = file, + } + end + end + return items +end + +---@param opts snacks.picker.keymaps.Config +function M.keymaps(opts) + local items = {} ---@type snacks.picker.finder.Item[] + local maps = {} ---@type vim.api.keyset.get_keymap[] + for _, mode in ipairs(opts.modes) do + if opts.global then + vim.list_extend(maps, vim.api.nvim_get_keymap(mode)) + end + if opts["local"] then + vim.list_extend(maps, vim.api.nvim_buf_get_keymap(0, mode)) + end + end + local done = {} ---@type table + for _, km in ipairs(maps) do + local key = Snacks.picker.util.text(km, { "mode", "lhs", "buffer" }) + if not done[key] then + done[key] = true + local item = { + mode = km.mode, + item = km, + preview = { + text = vim.inspect(km), + ft = "lua", + }, + } + if km.callback then + local info = debug.getinfo(km.callback, "S") + if info.what == "Lua" then + item.file = info.source:sub(2) + item.pos = { info.linedefined, 0 } + item.preview = "file" + end + end + item.text = Snacks.picker.util.text(km, { "mode", "lhs", "rhs", "desc" }) .. (item.file or "") + items[#items + 1] = item + end + end + return items +end + +function M.registers() + local registers = '*+"-:.%/#=_abcdefghijklmnopqrstuvwxyz0123456789' + local items = {} ---@type snacks.picker.finder.Item[] + local is_osc52 = vim.g.clipboard and vim.g.clipboard.name == "OSC 52" + local has_clipboard = vim.g.loaded_clipboard_provider == 2 + for i = 1, #registers, 1 do + local reg = registers:sub(i, i) + local value = "" + if is_osc52 and reg:match("[%+%*]") then + value = "OSC 52 detected, register not checked to maintain compatibility" + elseif has_clipboard or not reg:match("[%+%*]") then + local ok, reg_value = pcall(vim.fn.getreg, reg, 1) + value = (ok and reg_value or "") --[[@as string]] + end + if value ~= "" then + table.insert(items, { + text = ("%s: %s"):format(reg, value:gsub("\n", "\\n"):gsub("\r", "\\r")), + reg = reg, + label = reg, + data = value, + value = value, + preview = { + text = value, + ft = "text", + }, + }) + end + end + return items +end + +return M diff --git a/lua/snacks/picker/util/async.lua b/lua/snacks/picker/util/async.lua new file mode 100644 index 00000000..84617496 --- /dev/null +++ b/lua/snacks/picker/util/async.lua @@ -0,0 +1,310 @@ +---@class snacks.picker.async +local M = {} + +---@type snacks.picker.Async[] +M._active = {} +---@type snacks.picker.Async[] +M._suspended = {} +M._executor = assert((vim.uv or vim.loop).new_check()) + +M.BUDGET = 10 + +---@type table +M._threads = setmetatable({}, { __mode = "k" }) + +local uv = (vim.uv or vim.loop) + +function M.exiting() + return vim.v.exiting ~= vim.NIL +end + +---@alias snacks.picker.AsyncEvent "done" | "error" | "yield" | "ok" | "abort" + +---@class snacks.picker.Async +---@field _co? thread +---@field _fn fun() +---@field _suspended? boolean +---@field _aborted? boolean +---@field _start number +---@field _on table +local Async = {} +Async.__index = Async + +---@param fn async fun() +--- +function Async.new(fn) + local self = setmetatable({}, Async) + return self:init(fn) +end + +---@param fn async fun() +---@return snacks.picker.Async +function Async:init(fn) + self._fn = fn + self._on = {} + self._start = uv.hrtime() + self._co = coroutine.create(function() + local ok, err = pcall(self._fn) + if not ok then + if self._aborted then + self:_emit("abort") + else + self:_error(err) + end + end + self:_done() + end) + M._threads[self._co] = self + return M.add(self) +end + +function Async:_done() + self:_emit("done") + self._fn = nil + self._co = nil + self._on = {} +end + +function Async:delta() + return (uv.hrtime() - self._start) / 1e6 +end + +---@param event snacks.picker.AsyncEvent +---@param cb async fun(res:any, async:snacks.picker.Async) +function Async:on(event, cb) + if event == "done" and not self:running() then + cb(nil, self) + return self + end + self._on[event] = self._on[event] or {} + table.insert(self._on[event], cb) + return self +end + +---@private +---@param event snacks.picker.AsyncEvent +---@param res any +function Async:_emit(event, res) + for _, cb in ipairs(self._on[event] or {}) do + cb(res, self) + end +end + +function Async:_error(err) + if vim.tbl_isempty(self._on.error or {}) then + Snacks.notify.error("Unhandled async error:\n" .. err) + end + self:_emit("error", err) +end + +function Async:running() + return self._co and coroutine.status(self._co) ~= "dead" and not self._aborted +end + +---@async +function Async:sleep(ms) + vim.defer_fn(function() + self:resume() + end, ms) + self:suspend() +end + +---@async +---@param yield? boolean +function Async:suspend(yield) + self._suspended = true + if coroutine.running() == self._co and yield ~= false then + M.yield() + end +end + +function Async:resume() + if not self._suspended then + return + end + self._suspended = false + M._run() +end + +---@async +---@param yield? boolean +function Async:wake(yield) + local async = M.running() + assert(async, "Not in an async context") + self:on("done", function() + async:resume() + end) + async:suspend(yield) +end + +---@async +function Async:wait() + if coroutine.running() == self._co then + error("Cannot wait on self") + end + + local async = M.running() + if async then + self:wake() + else + while self:running() do + vim.wait(10) + end + end + return self +end + +function Async:step() + if self._suspended then + return true + end + if not self._co then + return false + end + local status = coroutine.status(self._co) + if status == "suspended" then + local ok, res = coroutine.resume(self._co) + if not ok then + error(res) + elseif res then + self:_emit("yield", res) + end + end + return self:running() +end + +function Async:abort() + if not self:running() then + return + end + self._aborted = true + coroutine.resume(self._co, "abort") +end + +function M.abort() + for _, async in ipairs(M._active) do + async:abort() + end +end + +---@async +function M.yield() + if coroutine.yield() == "abort" then + error("aborted", 2) + end +end + +function M.step() + local start = uv.hrtime() + for _ = 1, #M._active do + if M.exiting() or uv.hrtime() - start > M.BUDGET * 1e6 then + break + end + + local state = table.remove(M._active, 1) ---@type snacks.picker.Async + if state:step() then + if state._suspended then + table.insert(M._suspended, state) + else + table.insert(M._active, state) + end + end + end + for _ = 1, #M._suspended do + local state = table.remove(M._suspended, 1) + table.insert(state._suspended and M._suspended or M._active, state) + end + + -- M.debug() + if #M._active == 0 or M.exiting() then + return M._executor:stop() + end +end + +function M.debug() + local lines = { + "- active: " .. #M._active, + "- suspended: " .. #M._suspended, + } + for _, async in ipairs(M._active) do + local info = debug.getinfo(async._fn) + local file = vim.fn.fnamemodify(info.short_src:sub(1), ":~:.") + table.insert(lines, ("%s:%d"):format(file, info.linedefined)) + if #lines > 10 then + break + end + end + local msg = table.concat(lines, "\n") + M._notif = vim.notify(msg, nil, { replace = M._notif }) +end + +---@param async snacks.picker.Async +function M.add(async) + table.insert(M._active, async) + M._run() + return async +end + +---@async +function M.suspend() + local async = assert(M.running(), "Not in an async context") + async:suspend() +end + +function M._run() + if not M.exiting() and not M._executor:is_active() then + -- M._executor:start(vim.schedule_wrap(M.step)) + M._executor:start(M.step) + end +end + +function M.running() + local co = coroutine.running() + if co then + return M._threads[co] + end +end + +---@async +---@param ms number +function M.sleep(ms) + local async = M.running() + assert(async, "Not in an async context") + async:sleep(ms) +end + +---@param ms? number +function M.yielder(ms) + if not coroutine.running() then + return function() end + end + local ns, count, start = (ms or 5) * 1e6, 0, uv.hrtime() + ---@async + return function() + count = count + 1 + if count % 100 == 0 then + if uv.hrtime() - start > ns then + M.yield() + start = uv.hrtime() + end + end + end +end + +local nop ---@type snacks.picker.Async +--- Returns a no-op async function +function M.nop() + if not nop then + nop = Async.new(function() end) + nop:step() + M._active = vim.tbl_filter(function(a) + return a ~= nop + end, M._active) + end + return nop +end + +M.Async = Async +M.new = Async.new + +return M diff --git a/lua/snacks/picker/util/highlight.lua b/lua/snacks/picker/util/highlight.lua new file mode 100644 index 00000000..a4964e9b --- /dev/null +++ b/lua/snacks/picker/util/highlight.lua @@ -0,0 +1,178 @@ +---@class snacks.picker.highlight +local M = {} + +---@param opts? {buf?:number, code?:string, ft?:string, lang?:string, file?:string} +function M.get_highlights(opts) + opts = opts or {} + local source = assert(opts.buf or opts.code, "buf or code is required") + assert(not (opts.buf and opts.code), "only one of buf or code is allowed") + + local ret = {} ---@type table + + local ft = opts.ft + or (opts.buf and vim.bo[opts.buf].filetype) + or (opts.file and vim.filetype.match({ filename = opts.file, buf = 0 })) + or vim.bo.filetype + local lang = opts.lang or vim.treesitter.language.get_lang(ft) + local parser ---@type vim.treesitter.LanguageTree? + if lang then + local ok = false + if opts.buf then + ok, parser = pcall(vim.treesitter.get_parser, opts.buf, lang) + else + ok, parser = pcall(vim.treesitter.get_string_parser, source, lang) + end + parser = ok and parser or nil + end + + if parser then + parser:parse(true) + parser:for_each_tree(function(tstree, tree) + if not tstree then + return + end + local query = vim.treesitter.query.get(tree:lang(), "highlights") + -- Some injected languages may not have highlight queries. + if not query then + return + end + + for capture, node, metadata in query:iter_captures(tstree:root(), source) do + ---@type string + local name = query.captures[capture] + if name ~= "spell" then + local range = { node:range() } ---@type number[] + for row = range[1] + 1, range[3] + 1 do + ret[row] = ret[row] or {} + table.insert(ret[row], { + col = range[2], + end_col = range[4], + priority = (tonumber(metadata.priority or metadata[capture] and metadata[capture].priority) or 100), + conceal = metadata.conceal or metadata[capture] and metadata[capture].conceal, + hl_group = "@" .. name .. "." .. lang, + }) + end + end + end + end) + end + + --- Add buffer extmarks + if opts.buf then + local extmarks = vim.api.nvim_buf_get_extmarks(opts.buf, -1, 0, -1, { details = true }) + for _, extmark in pairs(extmarks) do + local row = extmark[2] + 1 + ret[row] = ret[row] or {} + local e = extmark[4] + if e then + e.sign_name = nil + e.sign_text = nil + e.ns_id = nil + e.end_row = nil + e.col = extmark[3] + if e.virt_text_pos and not vim.tbl_contains({ "eol", "overlay", "right_align", "inline" }, e.virt_text_pos) then + e.virt_text = nil + e.virt_text_pos = nil + end + table.insert(ret[row], e) + end + end + end + + return ret +end + +---@param line snacks.picker.Highlight[] +function M.offset(line) + local offset = 0 + for _, t in ipairs(line) do + if type(t[1]) == "string" then + if t.virtual then + offset = offset + vim.api.nvim_strwidth(t[1]) + else + offset = offset + #t[1] + end + end + end + return offset +end + +---@param line snacks.picker.Highlight[] +---@param item snacks.picker.Item +---@param text string +---@param opts? {hl_group?:string, lang?:string} +function M.format(item, text, line, opts) + opts = opts or {} + local offset = M.offset(line) + local highlights = M.get_highlights({ code = text, ft = item.ft, lang = opts.lang or item.lang, file = item.file })[1] + or {} + for _, extmark in ipairs(highlights) do + extmark.col = extmark.col + offset + extmark.end_col = extmark.end_col + offset + line[#line + 1] = extmark + end + line[#line + 1] = { text, opts.hl_group } +end + +---@param line snacks.picker.Highlight[] +---@param patterns table +function M.highlight(line, patterns) + local offset = M.offset(line) + local text ---@type string? + for i = #line, 1, -1 do + if type(line[i][1]) == "string" then + text = line[i][1] + break + end + end + if not text then + return + end + offset = offset - #text + for pattern, hl in pairs(patterns) do + local from, to, match = text:find(pattern) + while from do + if match then + from, to = text:find(match, from, true) + end + table.insert(line, { + col = offset + from - 1, + end_col = offset + to, + hl_group = hl, + }) + from, to = text:find(pattern, to + 1) + end + end +end + +---@param line snacks.picker.Highlight[] +function M.markdown(line) + M.highlight(line, { + ["`.-`"] = "SnacksPickerCode", + ["%*.-%*"] = "SnacksPickerItalic", + ["%*%*.-%*%*"] = "SnacksPickerBold", + }) +end + +---@param prefix string +---@param links? table +function M.winhl(prefix, links) + links = links or {} + local winhl = { + NormalFloat = "", + FloatBorder = "Border", + FloatTitle = "Title", + FloatFooter = "Footer", + CursorLine = "CursorLine", + } + local ret = {} ---@type string[] + local groups = {} ---@type table + for k, v in pairs(winhl) do + groups[v] = links[k] or (prefix == "SnacksPicker" and k or ("SnacksPicker" .. v)) + ret[#ret + 1] = ("%s:%s%s"):format(k, prefix, v) + end + Snacks.util.set_hl(groups, { prefix = prefix, default = true }) + return table.concat(ret, ",") +end + +return M diff --git a/lua/snacks/picker/util/init.lua b/lua/snacks/picker/util/init.lua new file mode 100644 index 00000000..b016e7c6 --- /dev/null +++ b/lua/snacks/picker/util/init.lua @@ -0,0 +1,106 @@ +---@class snacks.picker.util +local M = {} + +---@param item snacks.picker.Item +function M.path(item) + if not (item and item.file) then + return + end + return vim.fs.normalize(item.cwd and item.cwd .. "/" .. item.file or item.file, { _fast = true, expand_env = false }) +end + +---@param item table +---@param keys string[] +function M.text(item, keys) + local buffer = require("string.buffer").new() + for _, key in ipairs(keys) do + if item[key] then + if #buffer > 0 then + buffer:put(" ") + end + if key == "pos" or key == "end_pos" then + buffer:putf("%d:%d", item[key][1], item[key][2]) + else + buffer:put(tostring(item[key])) + end + end + end + return buffer:get() +end + +---@param text string +---@param width number +---@param opts? {align?: "left" | "right" | "center", truncate?: boolean} +function M.align(text, width, opts) + opts = opts or {} + opts.align = opts.align or "left" + local tw = vim.api.nvim_strwidth(text) + if tw > width then + return opts.truncate and (vim.fn.strcharpart(text, 0, width - 1) .. "…") or text + end + local left = math.floor((width - tw) / 2) + local right = width - tw - left + if opts.align == "left" then + left, right = 0, width - tw + elseif opts.align == "right" then + left, right = width - tw, 0 + end + return (" "):rep(left) .. text .. (" "):rep(right) +end + +---@param text string +---@param width number +function M.truncate(text, width) + if vim.api.nvim_strwidth(text) > width then + return vim.fn.strcharpart(text, 0, width - 1) .. "…" + end + return text +end + +-- Stops visual mode and returns the selected text +function M.visual() + local modes = { "v", "V", Snacks.util.keycode("") } + local mode = vim.fn.mode():sub(1, 1) ---@type string + if not vim.tbl_contains(modes, mode) then + return + end + -- stop visual mode + vim.cmd("normal! " .. mode) + + local pos = vim.api.nvim_buf_get_mark(0, "<") + local end_pos = vim.api.nvim_buf_get_mark(0, ">") + + -- for some reason, sometimes the column is off by one + -- see: https://github.com/folke/snacks.nvim/issues/190 + local col_to = math.min(end_pos[2] + 1, #vim.api.nvim_buf_get_lines(0, end_pos[1] - 1, end_pos[1], false)[1]) + + local lines = vim.api.nvim_buf_get_text(0, pos[1] - 1, pos[2], end_pos[1] - 1, col_to, {}) + local text = table.concat(lines, "\n") + ---@class snacks.picker.Visual + local ret = { + pos = pos, + end_pos = end_pos, + text = text, + } + return ret +end + +---@param str string +---@param data table +function M.tpl(str, data) + return (str:gsub("(%b{})", function(w) + return data[w:sub(2, -2)] or w + end)) +end + +---@param str string +function M.title(str) + return table.concat( + vim.tbl_map(function(s) + return s:sub(1, 1):upper() .. s:sub(2) + end, vim.split(str, "_")), + " " + ) +end + +return M diff --git a/lua/snacks/picker/util/minheap.lua b/lua/snacks/picker/util/minheap.lua new file mode 100644 index 00000000..b6e9d60a --- /dev/null +++ b/lua/snacks/picker/util/minheap.lua @@ -0,0 +1,147 @@ +---@class snacks.picker.MinHeap +---@field data any[] -- the heap array +---@field cmp fun(a:any, b:any):boolean -- determines "priority"; if cmp(a,b) == true, a is considered 'larger' for top-k +---@field capacity number +---@field sorted? snacks.picker.Item[] +local M = {} +M.__index = M + +---@class snacks.picker.minheap.Config +---@field cmp? fun(a, b):boolean +---@field capacity number + +---@param opts? snacks.picker.minheap.Config +function M.new(opts) + opts = opts or {} + local self = setmetatable({}, M) + + -- Default comparator means: a > b => a is 'better' (we want the top by value) + -- So if we want the top K largest items, the heap is min-heap based on that comparator + self.cmp = opts.cmp or function(a, b) + return a > b + end + self.capacity = assert(opts.capacity, "capacity is required") + assert(self.capacity > 0, "capacity must be greater than 0") + self.data = {} + return self +end + +function M:clear() + self.data = {} +end + +-- Private: swap two indices +function M:_swap(i, j) + self.data[i], self.data[j] = self.data[j], self.data[i] +end + +-- Private: heapify up (bubble up) +function M:_heapify_up(idx) + while idx > 1 do + local parent = math.floor(idx / 2) + -- If child is 'less' than parent under the min-heap logic, swap + -- Because self.cmp(child, parent) == true => child is 'bigger' => for min-heap we want bigger below + -- So we invert self.cmp because we want to keep the smallest at top: + if self.cmp(self.data[parent], self.data[idx]) then + self:_swap(parent, idx) + idx = parent + else + break + end + end +end + +-- Private: heapify down +function M:_heapify_down(idx) + local size = #self.data + while true do + local left = 2 * idx + local right = left + 1 + local smallest = idx + + if left <= size and self.cmp(self.data[smallest], self.data[left]) then + smallest = left + end + if right <= size and self.cmp(self.data[smallest], self.data[right]) then + smallest = right + end + if smallest ~= idx then + self:_swap(idx, smallest) + idx = smallest + else + break + end + end +end + +--- Insert value into the min-heap of capacity K. +--- If the heap is not full, just insert. +--- If it's full and the value is 'larger' than the min (root), replace the root & heapify. +---@generic T +---@param value T +---@return boolean added, T? evicted +function M:add(value) + local size = #self.data + if size < self.capacity then + -- Just insert at the end, heapify up + table.insert(self.data, value) + self:_heapify_up(#self.data) + self.sorted = nil + return true + else + -- If new value is larger than the root (which is the smallest in the min-heap), + -- then pop root & insert new value + if self.cmp(value, self.data[1]) then + local evicted = self.data[1] + self.data[1] = value + self:_heapify_down(1) + self.sorted = nil + return true, evicted + end + end + return false +end + +function M:count() + return #self.data +end + +---@return any|nil +function M:min() + return self.data[1] +end + +---@return any|nil +function M:max() + -- might need to scan if you want the max element in a min-heap + local size = #self.data + if size == 0 then + return nil + end + local maximum = self.data[1] + for i = 2, size do + if self.cmp(self.data[i], maximum) then + maximum = self.data[i] + end + end + return maximum +end + +---@param idx number +---@return snacks.picker.Item? +---@overload fun(self: snacks.picker.MinHeap): snacks.picker.Item[] +function M:get(idx) + if not self.sorted then + self.sorted = {} + for i = 1, #self.data do + table.insert(self.sorted, self.data[i]) + end + table.sort(self.sorted, self.cmp) + end + if idx then + return self.sorted[idx] + end + return self.sorted +end + +return M diff --git a/lua/snacks/picker/util/queue.lua b/lua/snacks/picker/util/queue.lua new file mode 100644 index 00000000..7313a2a7 --- /dev/null +++ b/lua/snacks/picker/util/queue.lua @@ -0,0 +1,39 @@ +--- Efficient queue implementation. +--- Prevents need to shift elements when popping. +---@class snacks.picker.queue +---@field queue any[] +---@field first number +---@field last number +local M = {} +M.__index = M + +function M.new() + local self = setmetatable({}, M) + self.first, self.last, self.queue = 0, -1, {} + return self +end + +function M:push(value) + self.last = self.last + 1 + self.queue[self.last] = value +end + +function M:size() + return self.last - self.first + 1 +end + +function M:empty() + return self:size() == 0 +end + +function M:pop() + if self:empty() then + return + end + local value = self.queue[self.first] + self.queue[self.first] = nil + self.first = self.first + 1 + return value +end + +return M diff --git a/lua/snacks/scroll.lua b/lua/snacks/scroll.lua index ac13420e..652c8e0a 100644 --- a/lua/snacks/scroll.lua +++ b/lua/snacks/scroll.lua @@ -121,6 +121,16 @@ function M.enable() end), }) + -- update state when leaving insert mode or changing text in normal mode + vim.api.nvim_create_autocmd({ "InsertLeave", "TextChanged" }, { + group = group, + callback = vim.schedule_wrap(function(ev) + for _, win in ipairs(vim.fn.win_findbuf(ev.buf)) do + get_state(win) + end + end), + }) + -- update current state on cursor move vim.api.nvim_create_autocmd({ "CursorMoved", "CursorMovedI" }, { group = group, diff --git a/lua/snacks/util.lua b/lua/snacks/util.lua index 9f890eaf..67384735 100644 --- a/lua/snacks/util.lua +++ b/lua/snacks/util.lua @@ -71,6 +71,9 @@ function M.icon(name, cat) return require("mini.icons").get(cat or "file", name) end, function() + if cat == "directory" then + return " ", "Directory" + end local Icons = require("nvim-web-devicons") if cat == "filetype" then return Icons.get_icon_by_filetype(name, { default = false }) diff --git a/lua/snacks/win.lua b/lua/snacks/win.lua index 4b391eb1..87159274 100644 --- a/lua/snacks/win.lua +++ b/lua/snacks/win.lua @@ -1,12 +1,15 @@ ---@class snacks.win ---@field id number ---@field buf? number +---@field scratch_buf? number ---@field win? number ---@field opts snacks.win.Config ---@field augroup? number ---@field backdrop? snacks.win ---@field keys snacks.win.Keys[] ---@field events (snacks.win.Event|{event:string|string[]})[] +---@field meta table +---@field closed? boolean ---@overload fun(opts? :snacks.win.Config|{}): snacks.win local M = setmetatable({}, { __call = function(t, ...) @@ -27,7 +30,7 @@ M.meta = { ---@class snacks.win.Event: vim.api.keyset.create_autocmd ---@field buf? true ---@field win? true ----@field callback? fun(self: snacks.win) +---@field callback? fun(self: snacks.win, ev:vim.api.keyset.create_autocmd.callback_args):boolean? ---@class snacks.win.Backdrop ---@field bg? string @@ -61,7 +64,7 @@ M.meta = { ---@field row? number|fun(self:snacks.win):number Row of the window. Use <1 for relative row. (default: center) ---@field minimal? boolean Disable a bunch of options to make the window minimal (default: true) ---@field position? "float"|"bottom"|"top"|"left"|"right" ----@field border? "none"|"top"|"right"|"bottom"|"left"|"rounded"|"single"|"double"|"solid"|"shadow"|string[]|false +---@field border? "none"|"top"|"right"|"bottom"|"left"|"hpad"|"vpad"|"rounded"|"single"|"double"|"solid"|"shadow"|string[]|false ---@field buf? number If set, use this buffer instead of creating a new one ---@field file? string If set, use this file instead of creating a new buffer ---@field enter? boolean Enter the window after opening (default: false) @@ -78,6 +81,7 @@ M.meta = { ---@field fixbuf? boolean don't allow other buffers to be opened in this window ---@field text? string|string[]|fun():(string[]|string) Initial lines to set in the buffer ---@field actions? table Actions that can be used in key mappings +---@field resize? boolean Automatically resize the window when the editor is resized local defaults = { show = true, fixbuf = true, @@ -101,6 +105,15 @@ Snacks.config.style("float", { zindex = 50, }) +Snacks.config.style("help", { + position = "float", + backdrop = false, + border = "top", + row = -1, + width = 0, + height = 0.3, +}) + Snacks.config.style("split", { position = "bottom", height = 0.4, @@ -112,6 +125,7 @@ Snacks.config.style("minimal", { cursorcolumn = false, cursorline = false, cursorlineopt = "both", + colorcolumn = "", fillchars = "eob: ,lastline:…", list = false, listchars = "extends:…,tab: ", @@ -168,10 +182,12 @@ local win_opts = { ---@type table local borders = { - left = { "│", "", "", "", "", "", "│", "│" }, - right = { "", "", "│", "│", "│", "", "", "" }, - top = { "─", "─", "─", "", "", "", "", "" }, - bottom = { "", "", "", "", "─", "─", "─", "" }, + left = { "", "", "", "", "", "", "", "│" }, + right = { "", "", "", "│", "", "", "", "" }, + top = { "", "─", "", "", "", "", "", "" }, + bottom = { "", "", "", "", "", "─", "", "" }, + hpad = { "", "", "", " ", "", "", "", " " }, + vpad = { "", " ", "", "", "", " ", "", "" }, } Snacks.util.set_hl({ @@ -180,9 +196,13 @@ Snacks.util.set_hl({ NormalNC = "NormalFloat", WinBar = "Title", WinBarNC = "SnacksWinBar", + WinKey = "Keyword", + WinKeySep = "NonText", + WinKeyDesc = "Function", }, { prefix = "Snacks", default = true }) local id = 0 +local event_stack = {} ---@type string[] --@private ---@param ...? snacks.win.Config|string|{} @@ -220,6 +240,7 @@ function M.new(opts) local self = setmetatable({}, M) id = id + 1 self.id = id + self.meta = {} opts = M.resolve(Snacks.config.get("win", defaults), opts) if opts.minimal then opts = M.resolve("minimal", opts) @@ -244,6 +265,8 @@ function M.new(opts) spec = { key, spec, desc = spec } elseif type(spec) == "function" then spec = { key, spec } + elseif type(spec) == "table" and spec[1] and not spec[2] then + spec[1], spec[2] = key, spec[1] end table.insert(self.keys, spec) end @@ -252,7 +275,7 @@ function M.new(opts) self:on("WinClosed", self.on_close, { win = true }) -- update window size when resizing - self:on("VimResized", self.update) + self:on("VimResized", self.on_resize) ---@cast opts snacks.win.Config self.opts = opts @@ -262,6 +285,17 @@ function M.new(opts) return self end +function M:on_resize() + if self.opts.resize ~= false then + self:update() + end +end + +---@param actions string|string[] +function M:execute(actions) + return self:action(actions)() +end + ---@param actions string|string[] ---@return (fun(): boolean|string?) action, string? desc function M:action(actions) @@ -295,8 +329,82 @@ function M:action(actions) table.concat(desc, ", ") end +---@param opts? {col_width?: number, key_width?: number, win?: snacks.win.Config} +function M:toggle_help(opts) + opts = opts or {} + local col_width, key_width = opts.col_width or 30, opts.key_width or 10 + for _, win in ipairs(vim.api.nvim_list_wins()) do + local buf = vim.api.nvim_win_get_buf(win) + if vim.bo[buf].filetype == "snacks_win_help" then + vim.api.nvim_win_close(win, true) + return + end + end + local ns = vim.api.nvim_create_namespace("snacks.win.help") + local win = M.new(M.resolve({ style = "help" }, opts.win or {}, { + show = false, + focusable = false, + zindex = self.opts.zindex + 1, + bo = { filetype = "snacks_win_help" }, + })) + self:on("WinClosed", function() + win:close() + end, { win = true }) + local dim = win:dim() + local cols = math.floor((dim.width - 1) / col_width) + local rows = math.ceil(#self.keys / cols) + win.opts.height = rows + local keys = {} ---@type vim.api.keyset.get_keymap[] + vim.list_extend(keys, vim.api.nvim_buf_get_keymap(self.buf, "n")) + vim.list_extend(keys, vim.api.nvim_buf_get_keymap(self.buf, "i")) + table.sort(keys, function(a, b) + return (a.desc or a.lhs or "") < (b.desc or b.lhs or "") + end) + local help = {} ---@type {[1]:string, [2]:string}[][] + local row, col = 0, 1 + + ---@param str string + ---@param len number + ---@param align? "left"|"right" + local function trunc(str, len, align) + local w = vim.api.nvim_strwidth(str) + if w > len then + return vim.fn.strcharpart(str, 0, len - 1) .. "…" + end + return align == "right" and (string.rep(" ", len - w) .. str) or (str .. string.rep(" ", len - w)) + end + + local done = {} ---@type table + for _, keymap in ipairs(keys) do + local key = vim.fn.keytrans(Snacks.util.keycode(keymap.lhs or "")) + if not done[key] and not (keymap.desc and keymap.desc:find("which%-key")) then + done[key] = true + row = row + 1 + if row > rows then + row, col = 1, col + 1 + end + help[row] = help[row] or {} + vim.list_extend(help[row], { + { trunc(key, key_width, "right"), "SnacksWinKey" }, + { " " }, + { "➜", "SnacksWinKeySep" }, + { " " }, + { trunc(keymap.desc or "", col_width - key_width - 3), "SnacksWinKeyDesc" }, + }) + end + end + win:show() + for l, line in ipairs(help) do + vim.api.nvim_buf_set_lines(win.buf, l - 1, l, false, { "" }) + vim.api.nvim_buf_set_extmark(win.buf, ns, l - 1, 0, { + virt_text = line, + virt_text_pos = "overlay", + }) + end +end + ---@param event string|string[] ----@param cb fun(self: snacks.win) +---@param cb fun(self: snacks.win, ev:vim.api.keyset.create_autocmd.callback_args):boolean? ---@param opts? snacks.win.Event function M:on(event, cb, opts) opts = opts or {} @@ -318,8 +426,11 @@ function M:_on(event, opts) end end event_opts.group = event_opts.group or self.augroup - event_opts.callback = function() - opts.callback(self) + event_opts.callback = function(ev) + table.insert(event_stack, ev.event) + local ok, err = pcall(opts.callback, self, ev) + table.remove(event_stack) + return not ok and error(err) or err end if event_opts.pattern or event_opts.buffer then -- don't alter the pattern or buffer @@ -347,7 +458,7 @@ end ---@param up? boolean function M:scroll(up) - vim.api.nvim_buf_call(self.buf, function() + vim.api.nvim_win_call(self.win, function() vim.cmd(("normal! %s"):format(up and SCROLL_UP or SCROLL_DOWN)) end) end @@ -355,18 +466,11 @@ end ---@param opts? { buf: boolean } function M:close(opts) opts = opts or {} - local wipe = opts.buf ~= false and not self.opts.buf and not self.opts.file + local wipe = opts.buf ~= false and self.buf == self.scratch_buf local win = self.win local buf = wipe and self.buf - -- never close modified buffers - if buf and vim.bo[buf].modified and vim.bo[buf].buftype == "" then - if not pcall(vim.api.nvim_buf_delete, buf, { force = false }) then - return - end - end - self.win = nil if buf then self.buf = nil @@ -383,14 +487,23 @@ function M:close(opts) self.augroup = nil end end - local try_close + local retries = 0 + local try_close ---@type fun() try_close = function() local ok, err = pcall(close) - if not ok and err and err:find("E565") then + if not ok and err and err:find("E565") and retries < 10 then + retries = retries + 1 vim.defer_fn(try_close, 50) + elseif not ok then + Snacks.notify.error("Failed to close window: " .. err) end end - vim.schedule(try_close) + -- HACK: WinClosed is not recursive, so we need to schedule it + -- if we're in a WinClosed event + if vim.tbl_contains(event_stack, "WinClosed") or not pcall(close) then + vim.schedule(try_close) + end + self:on_close() end function M:hide() @@ -407,11 +520,44 @@ function M:toggle() return self end +---@param title string +---@param pos? "center"|"left"|"right" +function M:set_title(title, pos) + if not self:has_border() then + return + end + title = vim.trim(title) + if title ~= "" then + -- HACK: add extra space when last char is non word + -- like for icons etc + if not title:sub(-1):match("%w") then + title = title .. " " + end + title = " " .. title .. " " + end + pos = pos or self.opts.title_pos or "center" + if self.opts.title == title and self.opts.title_pos == pos then + return + end + self.opts.title = title + self.opts.title_pos = pos + if not self:valid() then + return + end + vim.api.nvim_win_set_config(self.win, { + title = self.opts.title, + title_pos = self.opts.title_pos, + }) +end + ---@private function M:open_buf() if self.buf and vim.api.nvim_buf_is_valid(self.buf) then -- keep existing buffer self.buf = self.buf + elseif self.scratch_buf and vim.api.nvim_buf_is_valid(self.scratch_buf) then + -- keep existing scratch buffer + self.buf = self.scratch_buf elseif self.opts.file then self.buf = vim.fn.bufadd(self.opts.file) if not vim.api.nvim_buf_is_loaded(self.buf) then @@ -423,18 +569,30 @@ function M:open_buf() elseif self.opts.buf then self.buf = self.opts.buf else - self.buf = vim.api.nvim_create_buf(false, true) - local text = type(self.opts.text) == "function" and self.opts.text() or self.opts.text - text = type(text) == "string" and { text } or text - if text then - ---@cast text string[] - vim.api.nvim_buf_set_lines(self.buf, 0, -1, false, text) - end + self:scratch() + end + return self.buf +end + +function M:scratch() + if self.buf == self.scratch_buf and self:buf_valid() then + return + end + self.buf = vim.api.nvim_create_buf(false, true) + vim.bo[self.buf].swapfile = false + self.scratch_buf = self.buf + local text = type(self.opts.text) == "function" and self.opts.text() or self.opts.text + text = type(text) == "string" and { text } or text + if text then + ---@cast text string[] + vim.api.nvim_buf_set_lines(self.buf, 0, -1, false, text) end if vim.bo[self.buf].filetype == "" and not self.opts.bo.filetype then self.opts.bo.filetype = "snacks_win" end - return self.buf + if self:win_valid() then + vim.api.nvim_win_set_buf(self.win, self.buf) + end end ---@private @@ -442,7 +600,9 @@ function M:open_win() local relative = self.opts.relative or "editor" local position = self.opts.position or "float" local enter = self.opts.enter == nil or self.opts.enter or false - enter = not self.opts.focusable and enter or false + if self.opts.focusable == false then + enter = false + end local opts = self:win_opts() if position == "float" then self.win = vim.api.nvim_open_win(self.buf, enter, opts) @@ -545,6 +705,7 @@ function M:show() end self:open_win() + self.closed = false -- window local variables for k, v in pairs(self.opts.w or {}) do vim.w[self.win][k] = v @@ -631,6 +792,7 @@ function M:show() return spec[2](self) end end + spec.desc = spec.desc or opts.desc ---@cast spec snacks.win.Keys vim.keymap.set(spec.mode or "n", spec[1], rhs, opts) end @@ -647,6 +809,10 @@ function M:on_close() self.backdrop:close() self.backdrop = nil end + if self.closed then + return + end + self.closed = true if self.opts.on_close then self.opts.on_close(self) end @@ -674,6 +840,10 @@ end ---@private function M:drop() + if self.backdrop then + self.backdrop:close() + self.backdrop = nil + end local backdrop = self.opts.backdrop if not backdrop then return @@ -805,14 +975,36 @@ end --- Calculate the size of the border function M:border_size() - local border = self.opts.border and self.opts.border ~= "" and self.opts.border ~= "none" and self.opts.border - local full = border and not vim.tbl_contains({ "top", "right", "bottom", "left" }, border) + -- The array specifies the eight + -- chars building up the border in a clockwise fashion + -- starting with the top-left corner. + -- { "╔", "═" ,"╗", "║", "╝", "═", "╚", "║" } + local border = self:has_border() and self.opts.border or { "" } + border = type(border) == "string" and borders[border] or border + border = type(border) == "string" and { "x" } or border + assert(type(border) == "table", "Invalid border type") + ---@cast border string[] + while #border < 8 do + vim.list_extend(border, border) + end + -- remove border hl groups + border = vim.tbl_map(function(b) + return type(b) == "table" and b[1] or b + end, border) + local function size(from, to) + for i = from, to do + if border[i] ~= "" then + return 1 + end + end + return 0 + end ---@type { top: number, right: number, bottom: number, left: number } return { - top = (full or border == "top") and 1 or 0, - right = (full or border == "right") and 1 or 0, - bottom = (full or border == "bottom") and 1 or 0, - left = (full or border == "left") and 1 or 0, + top = size(1, 3), + right = size(3, 5), + bottom = size(5, 7), + left = math.max(size(7, 8), size(1, 1)), } end diff --git a/tests/picker/matcher_spec.lua b/tests/picker/matcher_spec.lua new file mode 100644 index 00000000..b2a2e687 --- /dev/null +++ b/tests/picker/matcher_spec.lua @@ -0,0 +1,96 @@ +---@module 'luassert' + +local M = {} +M.files = { + "lua/snacks/animate/", + "lua/snacks/animate/easing.lua", + "lua/snacks/animate/init.lua", + "lua/snacks/bigfile.lua", + "lua/snacks/bufdelete.lua", + "lua/snacks/dashboard.lua", + "lua/snacks/debug.lua", + "lua/snacks/dim.lua", + "lua/snacks/git.lua", + "lua/snacks/gitbrowse.lua", + "lua/snacks/health.lua", + "lua/snacks/indent.lua", + "lua/snacks/init.lua", + "lua/snacks/input.lua", + "lua/snacks/lazygit.lua", + "lua/snacks/meta/", + "lua/snacks/meta/docs.lua", + "lua/snacks/meta/init.lua", + "lua/snacks/meta/types.lua", + "lua/snacks/notifier.lua", + "lua/snacks/notify.lua", + "lua/snacks/picker/", + "lua/snacks/picker/async.lua", + "lua/snacks/picker/init.lua", + "lua/snacks/picker/list.lua", + "lua/snacks/picker/matcher.lua", + "lua/snacks/picker/preview.lua", + "lua/snacks/picker/queue.lua", + "lua/snacks/picker/sorter.lua", + "lua/snacks/picker/topk.lua", + "lua/snacks/profiler/", + "lua/snacks/profiler/core.lua", + "lua/snacks/profiler/init.lua", + "lua/snacks/profiler/loc.lua", + "lua/snacks/profiler/picker.lua", + "lua/snacks/profiler/tracer.lua", + "lua/snacks/profiler/ui.lua", + "lua/snacks/quickfile.lua", + "lua/snacks/rename.lua", + "lua/snacks/scope.lua", + "lua/snacks/scratch.lua", + "lua/snacks/scroll.lua", + "lua/snacks/statuscolumn.lua", + "lua/snacks/terminal.lua", + "lua/snacks/toggle.lua", + "lua/snacks/util.lua", + "lua/snacks/win.lua", + "lua/snacks/words.lua", + "lua/snacks/zen.lua", +} + +local function fuzzy(pattern) + local chars = vim.split(pattern, "") + local pat = table.concat(chars, ".*") + return vim.tbl_filter(function(v) + return v:find(pat) + end, M.files) +end + +describe("fuzzy matching", function() + local matcher = require("snacks.picker.core.matcher").new() + + local tests = { + { "mod.md", "md", { 5, 6 } }, + } + + for t, test in ipairs(tests) do + it("should find optimal match for " .. t, function() + matcher:init({ pattern = test[2] }) + local score, positions = matcher:match({ text = test[1], idx = 1, score = 0 }, { positions = true }) + assert(score and score > 0, "no match found") + assert.are.same(test[3], positions) + end) + end + + local patterns = { "snacks", "lua", "sgbs", "mark", "dcs", "xxx", "lsw" } + local algos = { "fuzzy", "fuzzy_fast" } + for _, pattern in ipairs(patterns) do + local expect = fuzzy(pattern) + for _, algo in ipairs(algos) do + it(("should find fuzzy matches for %q with %s"):format(pattern, algo), function() + local matches = {} ---@type string[] + for _, file in ipairs(M.files) do + if matcher[algo](matcher, file, pattern) then + table.insert(matches, file) + end + end + assert.are.same(expect, matches) + end) + end + end +end) diff --git a/tests/picker/minheap_spec.lua b/tests/picker/minheap_spec.lua new file mode 100644 index 00000000..068d0023 --- /dev/null +++ b/tests/picker/minheap_spec.lua @@ -0,0 +1,31 @@ +---@module 'luassert' + +local MinHeap = require("snacks.picker.util.minheap") + +describe("MinHeap", function() + local values = {} ---@type number[] + for i = 1, 2000 do + values[i] = i + end + ---@param tbl number[] + local function shuffle(tbl) + for i = #tbl, 2, -1 do + local j = math.random(i) + tbl[i], tbl[j] = tbl[j], tbl[i] + end + return tbl + end + + for _ = 1, 100 do + it("should push and pop values correctly", function() + local topk = MinHeap.new({ capacity = 10 }) + for _, v in ipairs(shuffle(values)) do + topk:add(v) + end + + table.sort(values, topk.cmp) + local topn = vim.list_slice(values, 1, 10) + assert.same(topn, topk:get()) + end) + end +end)