diff options
| author | Thanos Apollo <public@thanosapollo.org> | 2026-04-27 19:03:55 +0300 |
|---|---|---|
| committer | Thanos Apollo <public@thanosapollo.org> | 2026-04-27 19:03:55 +0300 |
| commit | 2a5acca81fcdd634d4c7237622ad5e664f1b9eb6 (patch) | |
| tree | eca8c605b0cab5f753f99b864c96721388d93193 | |
| parent | 1cc8167e01d67386407d371d0aa51c37c285e0d2 (diff) | |
feat: Add keymap-popup-annotate
| -rw-r--r-- | keymap-popup.el | 108 | ||||
| -rw-r--r-- | tests/keymap-popup-tests.el | 82 |
2 files changed, 163 insertions, 27 deletions
diff --git a/keymap-popup.el b/keymap-popup.el index 74be872..0f83936 100644 --- a/keymap-popup.el +++ b/keymap-popup.el @@ -77,27 +77,39 @@ Recognized keys: :if, :inapt-if, :reader, :prompt, :stay-open, :c-u." (defun keymap-popup--parse-entry (key spec) "Parse binding SPEC for KEY into a plist. -SPEC is (DESCRIPTION COMMAND-OR-TYPE &rest PROPS)." - (let* ((description (car spec)) - (second (cadr spec)) - (rest (cddr spec))) - (pcase second - (:switch - `(:key ,key :description ,description :type switch - :variable ,(car rest) - ,@(keymap-popup--extract-props (cdr rest)))) - (:option - `(:key ,key :description ,description :type option - :variable ,(car rest) - ,@(keymap-popup--extract-props (cdr rest)))) - (:keymap - `(:key ,key :description ,description :type keymap - :target ,(car rest) - ,@(keymap-popup--extract-props (cdr rest)))) - (_ - `(:key ,key :description ,description :type suffix - :command ,second - ,@(keymap-popup--extract-props rest)))))) +KEY is a key string for normal entries, or a command symbol for +annotated entries. SPEC is (DESCRIPTION COMMAND-OR-TYPE &rest PROPS) +for key-based entries, or (DESCRIPTION &rest PROPS) for annotated ones." + (if (symbolp key) + ;; Annotated entry: key is a command symbol, spec is (DESC . PROPS) + ;; or a bare string + (let* ((spec (if (stringp spec) (list spec) spec)) + (description (car spec)) + (props (cdr spec))) + `(:key nil :description ,description :type suffix + :command ,key + ,@(keymap-popup--extract-props props))) + ;; Normal entry: key is a string + (let* ((description (car spec)) + (second (cadr spec)) + (rest (cddr spec))) + (pcase second + (:switch + `(:key ,key :description ,description :type switch + :variable ,(car rest) + ,@(keymap-popup--extract-props (cdr rest)))) + (:option + `(:key ,key :description ,description :type option + :variable ,(car rest) + ,@(keymap-popup--extract-props (cdr rest)))) + (:keymap + `(:key ,key :description ,description :type keymap + :target ,(car rest) + ,@(keymap-popup--extract-props (cdr rest)))) + (_ + `(:key ,key :description ,description :type suffix + :command ,second + ,@(keymap-popup--extract-props rest))))))) (defun keymap-popup--split-groups (bindings) "Split BINDINGS at :group and :row keywords. @@ -318,6 +330,21 @@ KEYMAP, optional :description STRING-OR-FUNCTION, followed by ,@(when description `((put ',name 'keymap-popup--description ,description)))))) +;;;###autoload +(defmacro keymap-popup-annotate (map-symbol &rest body) + "Annotate existing keymap MAP-SYMBOL with popup descriptions. +BODY is :group keywords and COMMAND-SYMBOL DESCRIPTION pairs. +COMMAND-SYMBOL is a function symbol already bound in the keymap. +DESCRIPTION is a string or (STRING &rest PROPS). +Keys are resolved dynamically via `where-is-internal' at display +time, so the popup always reflects the user's current bindings." + (declare (indent 1)) + (let ((rows (keymap-popup--parse-bindings body))) + `(progn + (put ',map-symbol 'keymap-popup--descriptions + ,(keymap-popup--build-descriptions-form rows)) + (put ',map-symbol 'keymap-popup--annotated t)))) + ;;; Public API (defun keymap-popup--map-groups (rows fn) @@ -601,9 +628,29 @@ prefix argument highlighting." (when (and win (window-live-p win)) (fit-window-to-buffer win)))) +(defun keymap-popup--resolve-key (entry keymap) + "Resolve ENTRY's :command to a key in KEYMAP. +Returns entry with :key filled in, or nil if unbound." + (if (plist-get entry :key) entry + (and-let* ((cmd (plist-get entry :command)) + (keys (where-is-internal cmd keymap t))) + (plist-put (copy-sequence entry) :key (key-description keys))))) + +(defun keymap-popup--resolve-descriptions (rows keymap) + "Resolve command symbols to keys in ROWS using KEYMAP. +Drops entries whose command has no binding." + (keymap-popup--map-groups + rows + (lambda (group) + (plist-put (copy-sequence group) :entries + (cl-loop for entry in (plist-get group :entries) + when (keymap-popup--resolve-key entry keymap) + collect it))))) + (defun keymap-popup--prepare-buffer (map-symbol) "Create and populate the popup buffer for MAP-SYMBOL. -Includes descriptions inherited from parent keymaps." +Includes descriptions inherited from parent keymaps. +For annotated keymaps, resolves command symbols to key bindings." (let ((buf (get-buffer-create "*keymap-popup*"))) (or (get map-symbol 'keymap-popup--descriptions) (user-error "No descriptions for `%s'" map-symbol)) @@ -611,10 +658,14 @@ Includes descriptions inherited from parent keymaps." (setq-local buffer-read-only t) (setq-local cursor-type nil) (setq-local mode-line-format nil)) - (keymap-popup--refresh-buffer - buf nil - (keymap-popup--collect-descriptions map-symbol) - (get map-symbol 'keymap-popup--description)) + (let ((descriptions (keymap-popup--collect-descriptions map-symbol))) + (keymap-popup--refresh-buffer + buf nil + (if (get map-symbol 'keymap-popup--annotated) + (keymap-popup--resolve-descriptions + descriptions (symbol-value map-symbol)) + descriptions) + (get map-symbol 'keymap-popup--description))) buf)) (defun keymap-popup--read-loop (buf win keymap descriptions docstring exit-key) @@ -688,7 +739,10 @@ execute and re-render without closing. Command keys and dismiss keys close the popup." (let* ((buf (keymap-popup--prepare-buffer map-symbol)) (keymap (symbol-value map-symbol)) - (descriptions (keymap-popup--collect-descriptions map-symbol)) + (raw-descriptions (keymap-popup--collect-descriptions map-symbol)) + (descriptions (if (get map-symbol 'keymap-popup--annotated) + (keymap-popup--resolve-descriptions raw-descriptions keymap) + raw-descriptions)) (docstring (get map-symbol 'keymap-popup--description)) (exit-key (or (get map-symbol 'keymap-popup--exit-key) ?q))) (unwind-protect diff --git a/tests/keymap-popup-tests.el b/tests/keymap-popup-tests.el index aba1471..51fa445 100644 --- a/tests/keymap-popup-tests.el +++ b/tests/keymap-popup-tests.el @@ -1044,5 +1044,87 @@ (should (string-match-p "Beta" content)))) (kill-buffer buf)))) +;;; Annotate tests + +(defvar keymap-popup--test-annotate-map + (let ((map (make-sparse-keymap))) + (keymap-set map "a" #'forward-char) + (keymap-set map "b" #'backward-char) + (keymap-set map "c" #'kill-line) + map)) + +(ert-deftest keymap-popup-test-annotate-parse () + (let ((entry (keymap-popup--parse-entry 'forward-char '("Forward")))) + (should (eq (plist-get entry :command) 'forward-char)) + (should-not (plist-get entry :key)) + (should (equal (plist-get entry :description) "Forward")))) + +(ert-deftest keymap-popup-test-annotate-parse-with-props () + (let ((entry (keymap-popup--parse-entry 'forward-char '("Forward" :stay-open t)))) + (should (eq (plist-get entry :command) 'forward-char)) + (should (plist-get entry :stay-open)))) + +(ert-deftest keymap-popup-test-annotate-parse-bare-string () + (let ((entry (keymap-popup--parse-entry 'forward-char "Forward"))) + (should (eq (plist-get entry :command) 'forward-char)) + (should (equal (plist-get entry :description) "Forward")))) + +(ert-deftest keymap-popup-test-resolve-key () + (let* ((entry (list :key nil :description "Forward" :type 'suffix + :command 'forward-char)) + (resolved (keymap-popup--resolve-key entry keymap-popup--test-annotate-map))) + (should resolved) + (should (equal (plist-get resolved :key) "a")))) + +(ert-deftest keymap-popup-test-resolve-key-unbound () + (let ((entry (list :key nil :description "Nope" :type 'suffix + :command 'some-nonexistent-command-xyz))) + (should-not (keymap-popup--resolve-key entry keymap-popup--test-annotate-map)))) + +(ert-deftest keymap-popup-test-resolve-descriptions () + (let* ((rows (list (list (list :name "Test" + :entries (list (list :key nil :description "Forward" + :type 'suffix :command 'forward-char) + (list :key nil :description "Nope" + :type 'suffix :command 'nonexistent-xyz)))))) + (resolved (keymap-popup--resolve-descriptions rows keymap-popup--test-annotate-map)) + (entries (plist-get (car (car resolved)) :entries))) + (should (= (length entries) 1)) + (should (equal (plist-get (car entries) :key) "a")) + (should (equal (plist-get (car entries) :description) "Forward")))) + +(ert-deftest keymap-popup-test-annotate-macro () + (eval '(keymap-popup-annotate keymap-popup--test-annotate-map + :group "Move" + forward-char "Forward" + backward-char "Backward") + t) + (should (get 'keymap-popup--test-annotate-map 'keymap-popup--annotated)) + (let* ((descs (get 'keymap-popup--test-annotate-map 'keymap-popup--descriptions)) + (entries (plist-get (car (car descs)) :entries))) + (should (= (length entries) 2)) + (should (eq (plist-get (car entries) :command) 'forward-char)))) + +(ert-deftest keymap-popup-test-annotate-prepare-buffer () + (eval '(keymap-popup-annotate keymap-popup--test-annotate-map + :group "Move" + forward-char "Forward" + backward-char "Backward" + :group "Edit" + kill-line "Kill line") + t) + (let ((buf (keymap-popup--prepare-buffer 'keymap-popup--test-annotate-map))) + (unwind-protect + (with-current-buffer buf + (let ((content (buffer-string))) + (should (string-match-p "Forward" content)) + (should (string-match-p "Backward" content)) + (should (string-match-p "Kill line" content)) + ;; Resolved keys should appear + (should (string-match-p "a" content)) + (should (string-match-p "b" content)) + (should (string-match-p "c" content)))) + (kill-buffer buf)))) + (provide 'keymap-popup-tests) ;;; keymap-popup-tests.el ends here |
