diff options
| author | Thanos Apollo <public@thanosapollo.org> | 2026-04-26 02:02:32 +0300 |
|---|---|---|
| committer | Thanos Apollo <public@thanosapollo.org> | 2026-04-26 02:02:32 +0300 |
| commit | db768f7410675916827d0e850971e4114b3ab450 (patch) | |
| tree | 6c1e3c3da3b8f052ec2c4aff268f9ffcb8c976d3 | |
| parent | 368c7aeb3bceb067bf36b93f72b7ecfe97bc45e7 (diff) | |
keymap-popup: Add sub-menus, C-u prefix mode, column rows, and review fixes
| -rw-r--r-- | keymap-popup.el | 239 | ||||
| -rw-r--r-- | tests/keymap-popup-tests.el | 150 |
2 files changed, 290 insertions, 99 deletions
diff --git a/keymap-popup.el b/keymap-popup.el index 95a1b3f..d3cdb22 100644 --- a/keymap-popup.el +++ b/keymap-popup.el @@ -3,7 +3,7 @@ ;; Author: Thanos Apollo ;; Version: 0.1.0 ;; Package-Requires: ((emacs "29.1")) -;; Keywords: convenience keymaps +;; Keywords: convenience ;; URL: https://thanosapollo.org/projects/keymap-popup ;; This program is free software; you can redistribute it and/or modify @@ -50,13 +50,18 @@ "Face for infix values in the popup." :group 'keymap-popup) +(defface keymap-popup-submenu + '((t :inherit font-lock-type-face)) + "Face for sub-menu entries in the popup." + :group 'keymap-popup) + ;;; Parsers (defun keymap-popup--extract-props (plist) "Extract known properties from PLIST. -Recognized keys: :if, :reader, :prompt, :stay-open." +Recognized keys: :if, :reader, :prompt, :stay-open, :c-u." (cl-loop for (k v) on plist by #'cddr - when (memq k '(:if :reader :prompt :stay-open)) + when (memq k '(:if :reader :prompt :stay-open :c-u)) append (list k v))) (defun keymap-popup--parse-entry (key spec) @@ -74,6 +79,10 @@ SPEC is (DESCRIPTION COMMAND-OR-TYPE &rest PROPS)." `(: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 @@ -162,7 +171,9 @@ Each row is a list of group plists with :name and :entries." (pcase (plist-get entry :type) ('suffix (plist-get entry :command)) ('switch (intern (format "%s--toggle-%s" map-name (plist-get entry :variable)))) - ('option (intern (format "%s--set-%s" map-name (plist-get entry :variable)))))) + ('option (intern (format "%s--set-%s" map-name (plist-get entry :variable)))) + ('keymap (let ((target (plist-get entry :target))) + `(lambda () (interactive) (keymap-popup ',target)))))) ;;; Macro helpers @@ -174,41 +185,33 @@ MAP-NAME is used to derive generated command names." append (list (plist-get entry :key) (if (symbolp cmd) `#',cmd cmd)))) +(defun keymap-popup--quote-if-needed (form) + "Quote FORM unless it is a lambda, in which case return as-is." + (if (and (consp form) (eq (car form) 'lambda)) + form + `',form)) + (defun keymap-popup--build-entry-form (entry) "Build a `list' form for a single ENTRY that evaluates lambdas properly." - (let* ((desc (plist-get entry :description)) - (desc-form (if (and (consp desc) (eq (car desc) 'lambda)) - desc - `',desc)) - (type (plist-get entry :type))) - (pcase type - ('suffix - (let* ((cmd (plist-get entry :command)) - (cmd-form (if (and (consp cmd) (eq (car cmd) 'lambda)) - cmd - `',cmd)) - (if-pred (plist-get entry :if)) - (stay-open (plist-get entry :stay-open))) - `(list :key ,(plist-get entry :key) - :description ,desc-form - :type 'suffix - :command ,cmd-form - ,@(when if-pred (list :if if-pred)) - ,@(when stay-open (list :stay-open t))))) - ('switch - (let ((if-pred (plist-get entry :if))) - `(list :key ,(plist-get entry :key) - :description ,desc-form - :type 'switch - :variable ',(plist-get entry :variable) - ,@(when if-pred (list :if if-pred))))) - ('option - (let ((if-pred (plist-get entry :if))) - `(list :key ,(plist-get entry :key) - :description ,desc-form - :type 'option - :variable ',(plist-get entry :variable) - ,@(when if-pred (list :if if-pred)))))))) + (let* ((type (plist-get entry :type)) + (key (plist-get entry :key)) + (desc-form (keymap-popup--quote-if-needed + (plist-get entry :description))) + (type-props (pcase type + ('suffix `(:command ,(keymap-popup--quote-if-needed + (plist-get entry :command)) + ,@(when (plist-get entry :stay-open) + '(:stay-open t)))) + ('keymap `(:target ',(plist-get entry :target))) + (_ `(:variable ',(plist-get entry :variable))))) + (if-pred (plist-get entry :if))) + `(list :key ,key + :description ,desc-form + :type ',type + ,@type-props + ,@(when if-pred (list :if if-pred)) + ,@(when-let* ((c-u (plist-get entry :c-u))) + (list :c-u c-u))))) (defun keymap-popup--build-descriptions-form (rows) "Build a `list' form that constructs descriptions at load time. @@ -338,15 +341,22 @@ Updates both the keymap and the popup descriptions." "If DESC is a function, call it; otherwise return as-is." (if (functionp desc) (funcall desc) desc)) -(defun keymap-popup--render-entry (entry) - "Render ENTRY into a formatted line, or nil if :if hides it." +(defun keymap-popup--render-entry (entry &optional prefix-mode) + "Render ENTRY into a formatted line, or nil if :if hides it. +When PREFIX-MODE is non-nil, entries with :c-u are highlighted and +their :c-u description is shown; other entries are dimmed." (when (or (null (plist-get entry :if)) (funcall (plist-get entry :if))) - (let* ((desc (keymap-popup--resolve-description - (plist-get entry :description))) + (let* ((raw-desc (keymap-popup--resolve-description + (plist-get entry :description))) + (type (plist-get entry :type)) + (desc (if (eq type 'keymap) + (propertize raw-desc 'face 'keymap-popup-submenu) + raw-desc)) + (c-u-desc (plist-get entry :c-u)) (key-str (propertize (plist-get entry :key) 'face 'keymap-popup-key)) - (value-str (pcase (plist-get entry :type) + (value-str (pcase type ('switch (propertize (if (symbol-value (plist-get entry :variable)) " [on]" " [off]") @@ -355,17 +365,27 @@ Updates both the keymap and the popup descriptions." (format " =%s" (symbol-value (plist-get entry :variable))) 'face 'keymap-popup-value)) - (_ "")))) - (format " %s %s%s" key-str desc value-str)))) - -(defun keymap-popup--render-group-lines (group) + (_ ""))) + (c-u-str (when c-u-desc + (if prefix-mode + (format " (%s)" c-u-desc) + (propertize (format " (%s)" c-u-desc) + 'face 'shadow)))) + (line (format " %s %s%s%s" key-str desc value-str + (or c-u-str "")))) + (if (and prefix-mode (not c-u-desc)) + (propertize line 'face 'shadow) + line)))) + +(defun keymap-popup--render-group-lines (group &optional prefix-mode) "Render GROUP into a list of lines (strings). +When PREFIX-MODE is non-nil, pass it to entry rendering. Returns nil if the group has no visible entries." (let* ((header (when-let* ((raw-name (plist-get group :name)) (name (keymap-popup--resolve-description raw-name))) (propertize name 'face 'keymap-popup-group-header))) (lines (cl-loop for entry in (plist-get group :entries) - for line = (keymap-popup--render-entry entry) + for line = (keymap-popup--render-entry entry prefix-mode) when line collect line))) (when lines (if header (cons header lines) lines)))) @@ -406,12 +426,13 @@ Shorter columns are padded with blank lines." padded-cols separator))))) -(defun keymap-popup--rows-to-columns (rows) +(defun keymap-popup--rows-to-columns (rows &optional prefix-mode) "Render each row of ROWS into its list of column line-lists. +When PREFIX-MODE is non-nil, pass it to group rendering. Returns a list of ((col-lines ...) ...) per row, filtering empty groups." (mapcar (lambda (row) (cl-loop for group in row - for lines = (keymap-popup--render-group-lines group) + for lines = (keymap-popup--render-group-lines group prefix-mode) when lines collect lines)) rows)) @@ -424,14 +445,15 @@ Returns a list of ((col-lines ...) ...) per row, filtering empty groups." when (nth i cols) maximize (keymap-popup--column-width (nth i cols)))))) -(defun keymap-popup--render (docstring rows) +(defun keymap-popup--render (docstring rows &optional prefix-mode) "Render DOCSTRING and ROWS into a complete popup string. ROWS is a list of rows, each row a list of groups. +When PREFIX-MODE is non-nil, highlight :c-u entries and dim others. Column widths are aligned across all rows." (let* ((doc (when docstring (concat (propertize docstring 'face 'font-lock-doc-face) "\n"))) - (rendered-rows (keymap-popup--rows-to-columns rows)) + (rendered-rows (keymap-popup--rows-to-columns rows prefix-mode)) (col-widths (keymap-popup--global-col-widths rendered-rows)) (sections (cl-loop for cols in rendered-rows when cols @@ -458,23 +480,31 @@ Returns the entry plist, or nil." (when-let* ((entry (keymap-popup--find-entry-by-key descriptions key-str))) (memq (plist-get entry :type) '(switch option)))) +(defun keymap-popup--keymap-target (descriptions key-str) + "Return the target map symbol if KEY-STR is a :keymap entry in DESCRIPTIONS." + (when-let* ((entry (keymap-popup--find-entry-by-key descriptions key-str))) + (when (eq (plist-get entry :type) 'keymap) + (plist-get entry :target)))) + (defun keymap-popup--stay-open-p (descriptions key-str) - "Return non-nil if KEY-STR should keep the popup open. + "Return non-nil if KEY-STR should keep the popup open in DESCRIPTIONS. True for infixes and suffixes with :stay-open." (when-let* ((entry (keymap-popup--find-entry-by-key descriptions key-str))) (or (memq (plist-get entry :type) '(switch option)) (plist-get entry :stay-open)))) -(defun keymap-popup--refresh-buffer (buf map-symbol) - "Re-render the popup buffer BUF for MAP-SYMBOL." - (let* ((descriptions (get map-symbol 'keymap-popup--descriptions)) - (docstring (documentation-property map-symbol 'variable-documentation)) - (content (keymap-popup--render docstring descriptions))) +(defun keymap-popup--refresh-buffer (buf win descriptions &optional docstring prefix-mode) + "Re-render popup BUF with DESCRIPTIONS, fit WIN. +DOCSTRING is shown at the top if non-nil. PREFIX-MODE toggles +prefix argument highlighting." + (let ((content (keymap-popup--render docstring descriptions prefix-mode))) (with-current-buffer buf (let ((inhibit-read-only t)) (erase-buffer) (insert content) - (goto-char (point-min)))))) + (goto-char (point-min)))) + (when (and win (window-live-p win)) + (fit-window-to-buffer win)))) (defun keymap-popup--prepare-buffer (map-symbol) "Create and populate the popup buffer for MAP-SYMBOL." @@ -485,32 +515,70 @@ True for infixes and suffixes with :stay-open." (setq-local buffer-read-only t) (setq-local cursor-type nil) (setq-local mode-line-format nil)) - (keymap-popup--refresh-buffer buf map-symbol) + (keymap-popup--refresh-buffer + buf nil + (get map-symbol 'keymap-popup--descriptions) + (documentation-property map-symbol 'variable-documentation)) buf)) -(defun keymap-popup--dismiss-p (key _cmd) - "Return non-nil if KEY should dismiss the popup. -The keys `q' and `C-g' always dismiss." - (memq key '(?q ?\C-g))) - -(defun keymap-popup--read-loop (buf win map-symbol keymap descriptions) - "Read keys until a suffix or dismiss key is pressed. -MAP-SYMBOL identifies the described keymap. KEYMAP is the live -keymap for command lookup. DESCRIPTIONS is the stored metadata. -Stay-open keys (infixes and :stay-open suffixes) execute and -re-render BUF in WIN. Returns the command to dispatch, or nil." - (cl-loop for key = (read-key) +(defun keymap-popup--read-loop (buf win keymap descriptions docstring) + "Read keys in BUF displayed in WIN until a suffix or dismiss. +KEYMAP is the live keymap for command lookup. DESCRIPTIONS is the +stored row metadata. DOCSTRING is shown at the top of the popup. +Supports nested :keymap entries via a stack of (DESCS . KEYMAP) +pairs. Prefix argument mode is toggled with `universal-argument'. +Returns (CMD . PREFIX-ARG) or nil on dismiss." + (cl-loop with prefix-mode = nil + with stack = nil + with current-descs = descriptions + with current-keymap = keymap + for key = (read-key) for key-str = (key-description (vector key)) - for cmd = (keymap-lookup keymap key-str) - when (keymap-popup--dismiss-p key cmd) return nil - when (and cmd (keymap-popup--stay-open-p descriptions key-str)) + for cmd = (keymap-lookup current-keymap key-str) + for keymap-target = (keymap-popup--keymap-target current-descs key-str) + ;; C-u: toggle prefix mode + when (equal key ?\C-u) + do (progn + (setq prefix-mode (not prefix-mode)) + (keymap-popup--refresh-buffer + buf win current-descs docstring prefix-mode)) + ;; C-g: cancel prefix -> pop stack -> dismiss + else when (eq key ?\C-g) + do (cond + (prefix-mode + (setq prefix-mode nil) + (keymap-popup--refresh-buffer buf win current-descs docstring)) + (stack + (let ((prev (pop stack))) + (setq current-descs (car prev) + current-keymap (cdr prev))) + (keymap-popup--refresh-buffer buf win current-descs docstring)) + (t (cl-return nil))) + ;; q: pop stack or dismiss + else when (eq key ?q) + do (if stack + (let ((prev (pop stack))) + (setq current-descs (car prev) + current-keymap (cdr prev)) + (keymap-popup--refresh-buffer buf win current-descs docstring)) + (cl-return nil)) + ;; Keymap: push current, swap to sub-map + else when keymap-target do (progn + (push (cons current-descs current-keymap) stack) + (setq current-descs (get keymap-target 'keymap-popup--descriptions) + current-keymap (symbol-value keymap-target) + prefix-mode nil) + (keymap-popup--refresh-buffer buf win current-descs nil)) + ;; Stay-open: execute, re-render + else when (and cmd (keymap-popup--stay-open-p current-descs key-str)) + do (let ((current-prefix-arg (when prefix-mode '(4)))) (call-interactively cmd) - (keymap-popup--refresh-buffer buf map-symbol) - (when (and win (window-live-p win)) - (fit-window-to-buffer win))) - when (and cmd (not (keymap-popup--stay-open-p descriptions key-str))) - return cmd)) + (setq prefix-mode nil) + (keymap-popup--refresh-buffer buf win current-descs docstring)) + ;; Suffix: return with prefix arg + else when cmd + return (cons cmd (when prefix-mode '(4))))) ;;;###autoload (defun keymap-popup (map-symbol) @@ -518,22 +586,23 @@ re-render BUF in WIN. Returns the command to dispatch, or nil." Display in a bottom side window. Infix keys (switches/options) execute and re-render without closing. Suffix keys and dismiss keys close the popup." - (interactive) (let* ((buf (keymap-popup--prepare-buffer map-symbol)) (keymap (symbol-value map-symbol)) - (descriptions (get map-symbol 'keymap-popup--descriptions))) + (descriptions (get map-symbol 'keymap-popup--descriptions)) + (docstring (documentation-property map-symbol 'variable-documentation))) (unwind-protect (let* ((win (display-buffer buf '(display-buffer-in-side-window (side . bottom) (window-height . fit-window-to-buffer)))) (_ (when win (fit-window-to-buffer win))) - (final-cmd (keymap-popup--read-loop - buf win map-symbol keymap descriptions))) + (result (keymap-popup--read-loop + buf win keymap descriptions docstring))) (when (and win (window-live-p win)) (delete-window win)) - (when final-cmd - (call-interactively final-cmd))) + (when result + (let ((current-prefix-arg (cdr result))) + (call-interactively (car result))))) (when (buffer-live-p buf) (kill-buffer buf))))) diff --git a/tests/keymap-popup-tests.el b/tests/keymap-popup-tests.el index a5192a4..8284c51 100644 --- a/tests/keymap-popup-tests.el +++ b/tests/keymap-popup-tests.el @@ -292,20 +292,6 @@ "Signal user-error when symbol has no descriptions." (should-error (keymap-popup 'nonexistent-symbol) :type 'user-error)) -(ert-deftest keymap-popup-test-dismiss-c-g () - "C-g always dismisses." - (should (keymap-popup--dismiss-p ?\C-g #'ignore)) - (should (keymap-popup--dismiss-p ?\C-g nil))) - -(ert-deftest keymap-popup-test-dismiss-q () - "q always dismisses the popup, even if bound in the keymap." - (should (keymap-popup--dismiss-p ?q nil)) - (should (keymap-popup--dismiss-p ?q #'quit-window))) - -(ert-deftest keymap-popup-test-no-dismiss-other () - "Other keys do not dismiss." - (should-not (keymap-popup--dismiss-p ?c nil)) - (should-not (keymap-popup--dismiss-p ?c #'ignore))) ;;; Column layout tests @@ -488,5 +474,141 @@ (should-not (string-match-p "Reply" (buffer-string)))) (kill-buffer buf)))) +;;; Macro edge cases + +(ert-deftest keymap-popup-test-if-on-switch () + "Switch with :if is hidden from popup when predicate returns nil." + (eval '(define-described-keymap keymap-popup--test-if-sw + "v" ("Verbose" :switch keymap-popup--test-if-sw-var + :if (lambda () nil))) + t) + ;; Keybinding still exists + (should (keymap-lookup keymap-popup--test-if-sw "v")) + ;; But hidden in popup + (let ((buf (keymap-popup--prepare-buffer 'keymap-popup--test-if-sw))) + (unwind-protect + (with-current-buffer buf + (should-not (string-match-p "Verbose" (buffer-string)))) + (kill-buffer buf)))) + +(ert-deftest keymap-popup-test-if-on-option () + "Option with :if is hidden from popup when predicate returns nil." + (eval '(define-described-keymap keymap-popup--test-if-opt + "n" ("Count" :option keymap-popup--test-if-opt-var + :reader read-number :prompt "N: " + :if (lambda () nil))) + t) + (should (keymap-lookup keymap-popup--test-if-opt "n")) + (let ((buf (keymap-popup--prepare-buffer 'keymap-popup--test-if-opt))) + (unwind-protect + (with-current-buffer buf + (should-not (string-match-p "Count" (buffer-string)))) + (kill-buffer buf)))) + +(ert-deftest keymap-popup-test-stay-open-in-descriptions () + "Suffix with :stay-open stores the flag in descriptions." + (eval '(define-described-keymap keymap-popup--test-stay + "g" ("Refresh" ignore :stay-open t)) + t) + (let* ((descs (get 'keymap-popup--test-stay 'keymap-popup--descriptions)) + (entry (keymap-popup--find-entry-by-key descs "g"))) + (should (plist-get entry :stay-open)))) + +(ert-deftest keymap-popup-test-popup-key-with-docstring () + "Docstring and :popup-key work together." + (eval '(define-described-keymap keymap-popup--test-pkdoc + "My commands." + :popup-key "?" + :group "Actions" + "c" ("Comment" ignore)) + t) + (should (functionp (keymap-lookup keymap-popup--test-pkdoc "?"))) + (should (null (keymap-lookup keymap-popup--test-pkdoc "h"))) + (should (string-match-p "My commands" + (documentation-property 'keymap-popup--test-pkdoc + 'variable-documentation)))) + +(ert-deftest keymap-popup-test-dynamic-group-name () + "Group name can be a function called at render time." + (eval '(define-described-keymap keymap-popup--test-dyngrp + :group (lambda () "Dynamic Group") + "c" ("Comment" ignore)) + t) + (let ((buf (keymap-popup--prepare-buffer 'keymap-popup--test-dyngrp))) + (unwind-protect + (with-current-buffer buf + (should (string-match-p "Dynamic Group" (buffer-string)))) + (kill-buffer buf)))) + +;;; Keymap entry tests + +(ert-deftest keymap-popup-test-parse-keymap-entry () + "A :keymap entry parses with target symbol." + (let ((result (keymap-popup--parse-entry "a" '("Metadata" :keymap my-sub-map)))) + (should (equal (plist-get result :type) 'keymap)) + (should (equal (plist-get result :target) 'my-sub-map)))) + +(ert-deftest keymap-popup-test-keymap-target () + "Detect :keymap entries in descriptions." + (let ((descs (list (list (list :name nil + :entries (list (list :key "c" :type 'suffix :command 'ignore) + (list :key "a" :type 'keymap + :target 'my-sub))))))) + (should (eq (keymap-popup--keymap-target descs "a") 'my-sub)) + (should (null (keymap-popup--keymap-target descs "c"))))) + +(ert-deftest keymap-popup-test-keymap-entry-gets-submenu-face () + "Keymap entries render with the submenu face." + (let* ((rows (list (list (list :name nil + :entries (list (list :key "a" :description "Sub" + :type 'keymap :target 'x)))))) + (output (keymap-popup--render nil rows))) + (should (string-match-p "Sub" output)) + (let ((pos (string-match "Sub" output))) + (should (eq (get-text-property pos 'face output) 'keymap-popup-submenu))))) + +;;; C-u rendering tests + +(ert-deftest keymap-popup-test-c-u-desc-in-normal-mode () + "In normal mode, :c-u description appears in shadow face." + (let* ((rows (list (list (list :name nil + :entries (list (list :key "s" :description "Submit" + :type 'suffix :command 'ignore + :c-u "force push")))))) + (output (keymap-popup--render nil rows))) + (should (string-match-p "(force push)" output)) + (let ((pos (string-match "(force push)" output))) + (should (eq (get-text-property pos 'face output) 'shadow))))) + +(ert-deftest keymap-popup-test-c-u-desc-in-prefix-mode () + "In prefix mode, :c-u entries are normal and non-c-u entries are dimmed." + (let* ((rows (list (list (list :name nil + :entries (list (list :key "s" :description "Submit" + :type 'suffix :command 'ignore + :c-u "force") + (list :key "g" :description "Refresh" + :type 'suffix :command 'ignore)))))) + (output (keymap-popup--render nil rows t))) + ;; "force" should NOT be in shadow (it's highlighted in prefix mode) + (let ((pos (string-match "(force)" output))) + (should pos) + (should-not (eq (get-text-property pos 'face output) 'shadow))) + ;; "Refresh" line should be dimmed + (let ((pos (string-match "Refresh" output))) + (should (eq (get-text-property pos 'face output) 'shadow))))) + +;;; Stay-open detection tests + +(ert-deftest keymap-popup-test-stay-open-p () + "Stay-open detects infixes and :stay-open suffixes." + (let ((descs (list (list (list :name nil + :entries (list (list :key "c" :type 'suffix :command 'ignore) + (list :key "g" :type 'suffix :command 'ignore + :stay-open t) + (list :key "v" :type 'switch :variable 'x))))))) + (should-not (keymap-popup--stay-open-p descs "c")) + (should (keymap-popup--stay-open-p descs "g")) + (should (keymap-popup--stay-open-p descs "v")))) + (provide 'keymap-popup-tests) ;;; keymap-popup-tests.el ends here |
