snacks.nvim/lua/snacks/picker/core/preview.lua
Folke Lemaitre 559d6c6bf2
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", ...})
```

<!-- Describe the big picture of your changes to communicate to the
maintainers
  why we should accept this pull request. -->

## 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)

<!--
  If this PR fixes any issues, please link to the issue here.
  - Fixes #<issue_number>
-->

## Screenshots

<!-- Add screenshots of the changes if applicable. -->
2025-01-14 22:53:59 +01:00

230 lines
6.2 KiB
Lua

---@class snacks.picker.Preview
---@field item? snacks.picker.Item
---@field win snacks.win
---@field preview snacks.picker.preview
---@field state table<string, any>
---@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