feat(picker.list): better way of highlighting field patterns

This commit is contained in:
Folke Lemaitre 2025-01-17 12:40:37 +01:00
parent fd854ab9ef
commit 924a988d9a
No known key found for this signature in database
GPG key ID: 41F8B1FBACAE2040
6 changed files with 135 additions and 83 deletions

View file

@ -1,7 +1,7 @@
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.Extmark vim.api.keyset.set_extmark|{col:number, row?:number, field?:string}
---@alias snacks.picker.Text {[1]:string, [2]:string?, virtual?:boolean, field?:string}
---@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?

View file

@ -316,6 +316,11 @@ function M:set_selected(items)
self:update()
end
---@param item snacks.picker.Item
function M:is_selected(item)
return self.selected_map[self:select_key(item)] ~= nil
end
function M:unpause()
if not self.paused then
return
@ -326,93 +331,57 @@ 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",
}
-- Add selected and debug info
local prefix = {} ---@type snacks.picker.Highlight[]
vim.list_extend(prefix, Snacks.picker.format.selected(item, self.picker))
if self.picker.opts.debug.scores then
vim.list_extend(prefix, Snacks.picker.format.debug(item, self.picker))
end
local text, extmarks = Snacks.picker.highlight.to_text(prefix)
-- Add the formatted item
local line = self.picker.format(item, self.picker)
while #line > 0 and type(line[#line][1]) == "string" and line[#line][1]:find("^%s*$") do
table.remove(line)
end
local line_text, line_extmarks = Snacks.picker.highlight.to_text(line, { offset = #text })
vim.list_extend(extmarks, line_extmarks)
text = text .. line_text
if self.picker.opts.debug.scores then
local score = item.score
if not self.picker.matcher.sorting then
score = self.picker.matcher.DEFAULT_SCORE
if item.score_add then
score = score + item.score_add
end
if item.score_mul then
score = score * item.score_mul
-- Highlight match positions for field patterns
local fields = self.matcher:fields()
for _, extmark in ipairs(extmarks) do
if extmark.col and extmark.end_col and extmark.field and vim.tbl_contains(fields, extmark.field) then
local field = extmark.field --[[@as string]]
---@type snacks.picker.Item
local it = {
idx = 1,
score = 0,
file = item.file,
text = "",
}
it[field] = text:sub(extmark.col + 1, extmark.end_col)
local positions = self.matcher:positions(it)
for _, pos in ipairs(positions[field] or {}) do
table.insert(extmarks, {
col = pos - 1 + extmark.col,
end_col = pos + extmark.col,
hl_group = "SnacksPickerMatch",
})
end
end
local score_str = ("%.2f "):format(score)
parts[#parts + 1] = score_str
ret[#ret + 1] = {
col = selw,
end_col = selw + vim.api.nvim_strwidth(score_str),
hl_group = "Number",
}
selw = selw + vim.api.nvim_strwidth(score_str)
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.matcher:positions({ text = str:gsub("%s*$", ""), idx = 1, score = 0 })
for _, pos in ipairs(positions) do
table.insert(ret, {
-- Highlight match positions for text
local positions = self.matcher:positions({ text = text:gsub("%s*$", ""), idx = 1, score = 0, file = item.file })
for _, pos in ipairs(positions.text or {}) do
table.insert(extmarks, {
col = pos - 1,
end_col = pos,
hl_group = "SnacksPickerMatch",
})
end
return str, ret
return text, extmarks
end
---@param item snacks.picker.Item
@ -424,6 +393,7 @@ function M:_render(item, row)
local col = extmark.col
extmark.col = nil
extmark.row = nil
extmark.field = nil
vim.api.nvim_buf_set_extmark(self.win.buf, ns, row - 1, col, extmark)
end
end

View file

@ -262,23 +262,38 @@ function M:match(item)
return score
end
--- Returns the fields that are used in the pattern.
---@return string[]
function M:fields()
local ret = {} ---@type table<string,boolean>
for _, any in ipairs(self.mods) do
for _, mods in ipairs(any) do
ret[mods.field or "text"] = true
end
end
return vim.tbl_keys(ret)
end
--- Returns the positions of the matched pattern in the item.
--- All search patterns are combined with OR.
---@param item snacks.picker.Item
function M:positions(item)
local all = {} ---@type snacks.picker.matcher.Mods[]
local ret = {} ---@type number[]
local ret = {} ---@type table<string,number[]>
for _, any in ipairs(self.mods) do
vim.list_extend(all, any)
end
for _, mods in ipairs(all) do
local _, from, to, str = self:_match(item, mods)
if from and to and str then
local field = mods.field or "text"
ret[field] = ret[field] or {}
local pos = ret[field]
if mods.fuzzy then
vim.list_extend(ret, self:fuzzy_positions(str, mods.chars, from))
vim.list_extend(pos, self:fuzzy_positions(str, mods.chars, from))
else
for c = from, to do
ret[#ret + 1] = c
pos[#pos + 1] = c
end
end
end

View file

@ -43,15 +43,15 @@ function M.filename(item, picker)
local dir, file = path:match("^(.*)/(.+)$")
if file and dir then
if picker.opts.formatters.file.filename_first then
ret[#ret + 1] = { file, "SnacksPickerFile" }
ret[#ret + 1] = { file, "SnacksPickerFile", field = "file" }
ret[#ret + 1] = { " " }
ret[#ret + 1] = { dir, "SnacksPickerDir" }
ret[#ret + 1] = { dir, "SnacksPickerDir", field = "file" }
else
ret[#ret + 1] = { dir .. "/", "SnacksPickerDir" }
ret[#ret + 1] = { file, "SnacksPickerFile" }
ret[#ret + 1] = { dir .. "/", "SnacksPickerDir", field = "file" }
ret[#ret + 1] = { file, "SnacksPickerFile", field = "file" }
end
else
ret[#ret + 1] = { path, "SnacksPickerFile" }
ret[#ret + 1] = { path, "SnacksPickerFile", field = "file" }
end
if item.pos then
ret[#ret + 1] = { ":", "SnacksPickerDelim" }
@ -387,4 +387,29 @@ function M.buffer(item, picker)
return ret
end
function M.selected(item, picker)
local selected = picker.list:is_selected(item)
local icon = picker.opts.icons.ui.selected
local icon_width = vim.api.nvim_strwidth(icon)
local ret = {} ---@type snacks.picker.Highlight[]
ret[#ret + 1] = { selected and icon or string.rep(" ", icon_width), "SnacksPickerSelected", virtual = true }
return ret
end
function M.debug(item, picker)
local score = item.score
if not picker.matcher.sorting then
score = picker.matcher.DEFAULT_SCORE
if item.score_add then
score = score + item.score_add
end
if item.score_mul then
score = score * item.score_mul
end
end
local ret = {} ---@type snacks.picker.Highlight[]
ret[#ret + 1] = { ("%.2f "):format(score), "Number" }
return ret
end
return M

View file

@ -175,4 +175,46 @@ function M.winhl(prefix, links)
return table.concat(ret, ",")
end
---@param line snacks.picker.Highlight[]
---@param opts? {offset?:number}
function M.to_text(line, opts)
local offset = opts and opts.offset or 0
local ret = {} ---@type snacks.picker.Extmark[]
local col = offset
local parts = {} ---@type string[]
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],
field = text.field,
})
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 + offset
if text.end_col then
text.end_col = text.end_col + offset
end
table.insert(ret, text)
end
end
return table.concat(parts), ret
end
return M