diff --git a/README.md b/README.md index 35f3d114..20a38885 100644 --- a/README.md +++ b/README.md @@ -20,6 +20,7 @@ in a popup as you type. - 🌐 **Plugins**: built-in plugins for marks, registers, presets, and spelling suggestions - 🚀 **Operators, Motions, Text Objects**: help for operators, motions and text objects - 🐙 **Hydra Mode**: keep the popup open until you hit `` +- 🔭 **Search**: fuzzy-find a keybinding by its description and execute it ## ⚡️ Requirements @@ -395,6 +396,34 @@ When the **WhichKey** popup is open, you can use the following key bindings (the - `` scroll down - `` scroll up +## 🔭 Search + +Forgot a keybinding? Search for it by description instead of hunting through the +popup. The search collects every keybinding available for the current mode and +buffer and lets you fuzzy-find one by its description (including the parent group +names). Picking an entry executes its keybinding. + +```vim +" search keymaps for the current mode (defaults to normal mode) +:WhichKeySearch +" or for a specific mode +:WhichKeySearch v +``` + +```lua +-- or from Lua, e.g. to bind it +vim.keymap.set("n", "sk", function() + require("which-key").search() +end, { desc = "Search Keymaps" }) +``` + +The picker uses [`vim.ui.select`](https://neovim.io/doc/user/lua.html#vim.ui.select()), +so it integrates with whatever selection UI you already use (the builtin, +[telescope](https://github.com/nvim-telescope/telescope.nvim), +[snacks](https://github.com/folke/snacks.nvim), +[fzf-lua](https://github.com/ibhagwan/fzf-lua), +[dressing](https://github.com/stevearc/dressing.nvim), …). + ## 🐙 Hydra Mode Hydra mode is a special mode that keeps the popup open until you hit ``. diff --git a/lua/which-key/config.lua b/lua/which-key/config.lua index 474a2943..fd1b8b05 100644 --- a/lua/which-key/config.lua +++ b/lua/which-key/config.lua @@ -316,6 +316,14 @@ function M.setup(opts) end, { nargs = "*", }) + + vim.api.nvim_create_user_command("WhichKeySearch", function(cmd) + load() + local mode = cmd.args ~= "" and cmd.args or nil + require("which-key").search({ mode = mode }) + end, { + nargs = "?", + }) end ---@param opts? wk.Parse diff --git a/lua/which-key/init.lua b/lua/which-key/init.lua index 5387f982..17fe5869 100644 --- a/lua/which-key/init.lua +++ b/lua/which-key/init.lua @@ -22,6 +22,13 @@ function M.show(opts) end end +--- Fuzzy search keymaps by description (using `vim.ui.select`). +--- Picking an entry executes its keybind. +---@param opts? wk.Filter +function M.search(opts) + require("which-key.search").open(opts) +end + ---@param opts? wk.Opts function M.setup(opts) M.did_setup = true diff --git a/lua/which-key/search.lua b/lua/which-key/search.lua new file mode 100644 index 00000000..f69d9db4 --- /dev/null +++ b/lua/which-key/search.lua @@ -0,0 +1,113 @@ +local Buf = require("which-key.buf") + +local M = {} + +---@class wk.Search.item +---@field keys string the raw lhs key sequence used to execute the mapping +---@field display string formatted key sequence (with icons/symbols) +---@field desc string searchable description, prefixed with parent groups +---@field mode string + +--- Returns the description for a node, falling back to its rhs. +---@param node wk.Node +---@return string? +local function desc_of(node) + local desc = node.desc + if not desc and node.keymap and type(node.keymap.rhs) == "string" and node.keymap.rhs ~= "" then + desc = node.keymap.rhs --[[@as string]] + end + return desc +end + +--- Builds the breadcrumb of parent group descriptions, e.g. "git → commit". +---@param node wk.Node +local function breadcrumb(node) + local groups = {} ---@type string[] + local parent = node.parent + while parent and parent.parent do + local d = parent.desc + if d then + table.insert(groups, 1, d) + end + parent = parent.parent + end + return groups +end + +--- Collects all executable keymaps for the given mode as searchable items. +---@param mode wk.Mode +---@return wk.Search.item[] +local function collect(mode) + local View = require("which-key.view") + ---@type wk.Search.item[] + local items = {} + mode.tree:walk(function(node) + -- only real, executable keymaps are searchable keybinds + if not node.keymap then + return + end + local desc = desc_of(node) + local crumbs = breadcrumb(node) + crumbs[#crumbs + 1] = desc or "" + items[#items + 1] = { + keys = node.keys, + display = View.format(node.keys), + desc = table.concat(crumbs, " → "), + mode = mode.mode, + } + end) + table.sort(items, function(a, b) + if a.desc ~= b.desc then + return a.desc < b.desc + end + return a.keys < b.keys + end) + return items +end + +--- Executes the selected keymap by feeding its keys. +---@param item wk.Search.item +local function execute(item) + local feed = vim.api.nvim_replace_termcodes(item.keys, true, true, true) + vim.api.nvim_feedkeys(feed, "mt", false) +end + +--- Fuzzy search keymaps by their description using `vim.ui.select`. +--- Picking an entry executes its keybind. +---@param opts? wk.Filter +function M.open(opts) + opts = opts or {} + local mode = Buf.get(opts) + if not mode then + require("which-key.util").warn("No keymaps found for mode `" .. (opts.mode or "n") .. "`") + return + end + + local items = collect(mode) + if #items == 0 then + require("which-key.util").warn("No keymaps found to search") + return + end + + -- align the key column for nicer formatting + local width = 0 + for _, item in ipairs(items) do + width = math.max(width, vim.fn.strdisplaywidth(item.display)) + end + + vim.ui.select(items, { + prompt = "Search keymaps", + kind = "which-key", + ---@param item wk.Search.item + format_item = function(item) + local pad = string.rep(" ", width - vim.fn.strdisplaywidth(item.display)) + return item.display .. pad .. " " .. item.desc + end, + }, function(item) + if item then + execute(item) + end + end) +end + +return M