| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328 |
- -- which-key.lua
- -- ============================================================================
- -- Displays available keybindings when a prefix key is pressed and the user
- -- waits for a configurable delay. Similar to Emacs which-key package.
- --
- -- This package uses the editor's idle time tracking and extended echo area
- -- to show hints without requiring any C++ code changes.
- -- ============================================================================
- local which_key = {}
- -- Configuration
- which_key.config = {
- delay_ms = 1000, -- Delay before showing hints (milliseconds)
- max_hints = 20, -- Max number of hints to show
- max_lines = 5, -- Max lines in echo area
- separator = " ", -- Separator between hints on same line
- show_description = true, -- Show command descriptions
- enabled = true, -- Enable which-key
- }
- -- State
- which_key.last_prefix = nil
- which_key.showing_hints = false
- -- Parse a key sequence like "C-x C-f" into parts
- local function parse_sequence(seq)
- local parts = {}
- for part in seq:gmatch("%S+") do
- table.insert(parts, part)
- end
- return parts
- end
- -- Check if sequence starts with prefix
- local function starts_with(sequence, prefix)
- local seq_parts = parse_sequence(sequence)
- local prefix_parts = parse_sequence(prefix)
- if #seq_parts < #prefix_parts then
- return false
- end
- for i, part in ipairs(prefix_parts) do
- if seq_parts[i] ~= part then
- return false
- end
- end
- return true
- end
- -- Get the next key after the prefix
- local function get_next_key(sequence, prefix)
- local seq_parts = parse_sequence(sequence)
- local prefix_parts = parse_sequence(prefix)
- if #seq_parts > #prefix_parts then
- return seq_parts[#prefix_parts + 1]
- end
- return nil
- end
- -- Get all bindings that start with the given prefix
- function which_key.get_bindings_for_prefix(prefix)
- local all_bindings = editor:get_all_bindings()
- local matching = {}
- -- Normalize prefix (remove trailing dash if present)
- prefix = prefix:gsub("%-$", "")
- for _, binding in ipairs(all_bindings) do
- if starts_with(binding.sequence, prefix) and binding.sequence ~= prefix then
- local next_key = get_next_key(binding.sequence, prefix)
- if next_key then
- -- Check if we already have this next_key
- local found = false
- for _, m in ipairs(matching) do
- if m.key == next_key then
- found = true
- break
- end
- end
- if not found then
- -- Determine if this is a prefix or a command
- local is_prefix = false
- local prefix_with_key = prefix .. " " .. next_key
- for _, b in ipairs(all_bindings) do
- if starts_with(b.sequence, prefix_with_key) and b.sequence ~= prefix_with_key then
- is_prefix = true
- break
- end
- end
- table.insert(matching, {
- key = next_key,
- command = binding.command,
- description = binding.description,
- full_sequence = binding.sequence,
- is_prefix = is_prefix,
- })
- end
- end
- end
- end
- -- Sort by key
- table.sort(matching, function(a, b)
- return a.key < b.key
- end)
- return matching
- end
- -- Format hints into lines for display
- function which_key.format_hints(bindings, max_width)
- max_width = max_width or 80
- local lines = {}
- local current_line = ""
- for i, binding in ipairs(bindings) do
- if i > which_key.config.max_hints then
- if current_line ~= "" then
- table.insert(lines, current_line)
- end
- table.insert(lines, "... and more")
- break
- end
- local hint
- if binding.is_prefix then
- hint = binding.key .. ":+"
- elseif which_key.config.show_description and binding.description ~= "" then
- -- Truncate long descriptions
- local desc = binding.description
- if #desc > 15 then
- desc = desc:sub(1, 12) .. "..."
- end
- hint = binding.key .. ":" .. desc
- else
- -- Truncate long command names
- local cmd = binding.command or "?"
- if #cmd > 15 then
- cmd = cmd:sub(1, 12) .. "..."
- end
- hint = binding.key .. ":" .. cmd
- end
- if current_line == "" then
- current_line = hint
- elseif #current_line + #which_key.config.separator + #hint <= max_width then
- current_line = current_line .. which_key.config.separator .. hint
- else
- table.insert(lines, current_line)
- current_line = hint
- if #lines >= which_key.config.max_lines then
- table.insert(lines, "...")
- return lines
- end
- end
- end
- if current_line ~= "" then
- table.insert(lines, current_line)
- end
- return lines
- end
- -- Show which-key hints in the extended echo area
- function which_key.show_hints(prefix)
- if not which_key.config.enabled then
- return
- end
- local bindings = which_key.get_bindings_for_prefix(prefix)
- if #bindings > 0 then
- local lines = which_key.format_hints(bindings)
- -- Prepend the current prefix
- table.insert(lines, 1, prefix .. "-")
- editor:set_echo_area(lines)
- which_key.showing_hints = true
- end
- end
- -- Hide which-key hints
- function which_key.hide_hints()
- if which_key.showing_hints then
- editor:clear_echo_area()
- which_key.showing_hints = false
- end
- end
- -- Check if we should show hints (called during idle)
- function which_key.check_idle()
- if not which_key.config.enabled then
- return
- end
- -- Check if we're building a key sequence
- if editor:is_building_sequence() then
- local current = editor:current_sequence()
- -- Remove trailing dash for comparison
- current = current:gsub("%-$", "")
- -- Check if we've been idle long enough
- local idle_ms = editor:idle_time_ms()
- if idle_ms >= which_key.config.delay_ms then
- -- Show hints if we haven't already or prefix changed
- if not which_key.showing_hints or which_key.last_prefix ~= current then
- which_key.show_hints(current)
- which_key.last_prefix = current
- end
- end
- else
- -- Not building a sequence, hide hints if showing
- which_key.hide_hints()
- which_key.last_prefix = nil
- end
- end
- -- Register commands
- editor:register_command("which-key", "Show available keybindings for prefix", function(args)
- if #args > 0 then
- which_key.show_hints(args[1])
- elseif editor:is_building_sequence() then
- local current = editor:current_sequence():gsub("%-$", "")
- which_key.show_hints(current)
- else
- -- Show all prefixes
- local all_bindings = editor:get_all_bindings()
- local prefixes = {}
- for _, binding in ipairs(all_bindings) do
- local parts = parse_sequence(binding.sequence)
- if #parts > 1 then
- local prefix = parts[1]
- if not prefixes[prefix] then
- prefixes[prefix] = 0
- end
- prefixes[prefix] = prefixes[prefix] + 1
- end
- end
- local hints = {}
- for prefix, count in pairs(prefixes) do
- table.insert(hints, prefix .. ":+" .. count)
- end
- table.sort(hints)
- editor:message("Prefix keys: " .. table.concat(hints, " | "))
- end
- return {success = true}
- end, {"wk"}, true, "s")
- -- Describe key binding
- editor:register_command("describe-key", "Describe a key binding", function(args)
- if #args == 0 then
- return {success = false, message = "Usage: describe-key <key-sequence>"}
- end
- local key = args[1]
- local all_bindings = editor:get_all_bindings()
- for _, binding in ipairs(all_bindings) do
- if binding.sequence == key then
- local msg = key .. " runs " .. binding.command
- if binding.description ~= "" then
- msg = msg .. " (" .. binding.description .. ")"
- end
- return {success = true, message = msg}
- end
- end
- -- Check if it's a prefix
- if editor:has_prefix_bindings(key) then
- which_key.show_hints(key)
- return {success = true}
- end
- return {success = false, message = key .. " is not bound"}
- end, {"dk"}, true, "s")
- -- Toggle which-key
- editor:register_command("which-key-mode", "Toggle which-key hints", function(args)
- which_key.config.enabled = not which_key.config.enabled
- if which_key.config.enabled then
- editor:message("which-key enabled")
- else
- which_key.hide_hints()
- editor:message("which-key disabled")
- end
- return {success = true}
- end)
- -- Set delay
- editor:register_command("which-key-delay", "Set which-key delay in milliseconds", function(args)
- if #args == 0 then
- return {success = true, message = "which-key delay: " .. which_key.config.delay_ms .. "ms"}
- end
- local delay = tonumber(args[1])
- if delay and delay >= 0 then
- which_key.config.delay_ms = delay
- return {success = true, message = "which-key delay set to " .. delay .. "ms"}
- else
- return {success = false, message = "Invalid delay value"}
- end
- end, {}, true, "s")
- -- Bind C-h k to describe-key (Emacs standard)
- editor:bind_key("C-h k", "describe-key", "Describe a key binding")
- -- Register the idle check function globally so the editor can call it
- -- This gets called during the UI's idle loop
- lumacs.which_key_check_idle = which_key.check_idle
- -- Store module
- lumacs.which_key = which_key
- print("[which-key] Package loaded")
- return which_key
|