‘keymap-popup’ defines a normal Emacs keymap with embedded descriptions, and renders it as a popup menu on demand. One definition, two uses: direct key dispatch and an interactive menu.
Requires Emacs 29.1 or later (for ‘defvar-keymap’). No external dependencies.
(keymap-popup-define my-commands-map
"My commands."
:group "Edit"
"c" ("Comment" comment-dwim)
"r" ("Rename" rename-file)
:group "View"
"g" ("Refresh" revert-buffer)
"b" ("Bury buffer" bury-buffer))
(keymap-set some-mode-map "C-c m" my-commands-map)
The macro auto-binds ‘h’ inside the keymap, so ‘C-c m h’ opens the popup. ‘q’ or ‘C-g’ dismisses it.
(keymap-popup-define MAP-NAME DOCSTRING :popup-key KEY :exit-key KEY :parent KEYMAP :description STRING-OR-FN :persistent BOOL BINDING...)
Only ‘MAP-NAME’ and the bindings are required; every keyword above is optional and may appear in any order. ‘keymap-popup-annotate’ takes the same keywords but does not apply defaults for ‘:popup-key’ or ‘:exit-key’ – it never binds keys unless asked.
A binding takes one of three forms:
KEY (DESC COMMAND [PROP VALUE...]) ; ordinary command KEY (DESC :switch VAR [PROP VALUE...]) ; toggle KEY (DESC :keymap MAP [PROP VALUE...]) ; sub-menu
(keymap-popup-define my-feed-map
:group "Navigation"
"n" ("Next" next-line :stay-open t)
"p" ("Previous" previous-line :stay-open t)
:group "Tags"
"r" ("Toggle read" my-feed-toggle-read :stay-open t)
:row
:group "Feeds"
"A" ("Add feed" my-feed-add)
"D" ("Remove feed" my-feed-remove))
‘:row’ between groups starts a fresh row of columns beneath the first. Groups may attach their own predicates: ‘:group ("Bulk" :if PRED :inapt-if PRED)’.
(keymap-popup-define my-encryption-map
:description (lambda ()
(format "Encryption: %s" my-chat-encryption))
"o" ("OMEMO" my-chat-encryption-set-omemo)
"g" ("OpenPGP" my-chat-encryption-set-openpgp)
"p" ("Plaintext" my-chat-encryption-set-plaintext))
The header is re-rendered after each command.
Same semantics as ‘defvar-keymap’’s ‘:parent’. The popup walks the parent chain, so parent bindings appear in the child’s menu under their original group.
If a key is bound in both parent and child, the child wins for both dispatch and display. Shadowed parent entries are dropped from the popup, and a parent group whose entries are all shadowed disappears entirely.
(keymap-popup-define my-items-mode-map
:parent my-dashboard-common-map
:group "Items"
"RET" ("Open" my-items-open)
"q" ("Back" my-items-back))
(keymap-popup-add-entry my-commands-map
"l" "Log message" #'my-log-message
"Edit")
(keymap-popup-remove-entry my-commands-map "l")
Signatures:
Both mutate the keymap and its descriptions in place. Re-adding a key replaces the prior entry (in whatever group it was in), matching ‘keymap-set’’s semantics.
;;;###autoload (defun yeetube () (interactive) (keymap-popup yeetube-mode-map))
‘C-g’ always dismisses, regardless of the configured ‘:exit-key’.
‘keymap-popup-backend’ selects ‘keymap-popup-backend-side-window’ (default) or ‘keymap-popup-backend-child-frame’. Bind it locally for a one-off floating popup:
(defun my-commands-floating ()
(interactive)
(let ((keymap-popup-backend #'keymap-popup-backend-child-frame))
(keymap-popup my-commands-map)))
By default the popup dismisses after a suffix command runs. ‘:persistent t’ keeps it open until the exit key or ‘C-g’:
(keymap-popup-define my-map :persistent t ...) (keymap-popup-annotate dired-mode-map :popup-key "?" :persistent t ...) (setopt keymap-popup-persistent t) ; global default
Per-keymap setting wins over the global one.
Call ‘keymap-popup-dismiss’ from a command to close the popup before transitioning elsewhere:
(defun my-open-detail () (interactive) (keymap-popup-dismiss) (pop-to-buffer "*detail*"))
Predicate quick reference:
(keymap-popup-define yeetube-mode-map
:group "Actions"
"o" ("Settings" :switch yeetube--show-settings)
:group "Settings"
"V" ("No video" :switch yeetube-mpv-no-video
:if (lambda () yeetube--show-settings)))
The variable is auto-declared via ‘defvar’ + ‘make-variable-buffer-local’; each buffer gets its own value on first set. Switches always keep the popup open – no need to add ‘:stay-open t’.
(keymap-popup-define my-mark-map
:group "Mark"
"m" ("Mark" dired-mark :stay-open t)
"u" ("Unmark" dired-unmark :stay-open t)
"U" ("Unmark all" dired-unmark-all-marks))
For an entire popup, prefer ‘:persistent t’ (see Persistent popups).
(keymap-popup-define yeetube-mode-map
:group "Settings"
"p" ("Toggle pause" yeetube-mpv-toggle-pause
:inapt-if (lambda () (not (get-process yeetube-mpv--process-name)))))
;; Group-level applies to every entry in the group:
(keymap-popup-define my-bulk-map
:group ("Bulk" :inapt-if (lambda () (not (my-marked-files))))
"c" ("Chmod" my-do-chmod)
"z" ("Compress" my-do-compress))
Inapt entries render in ‘keymap-popup-inapt’ and print “Command unavailable” when pressed.
(keymap-popup-define yeetube-mode-map
"o" ("Settings" :switch yeetube--show-settings)
:group ("Playback" :if (lambda () yeetube--show-settings))
"p" ("Toggle pause" yeetube-mpv-toggle-pause)
"v" ("Toggle video" yeetube-mpv-toggle-video))
(keymap-popup-define my-greet-map
:group "Actions"
"a" ("Greet" my-greet :c-u "SHOUT (C-u)"))
The ‘:c-u’ label renders in ‘shadow’ alongside the description as a hint. Pressing ‘C-u’ enters prefix-arg mode: ‘:c-u’ entries switch to ‘warning’, all other entries dim with ‘shadow’, and ‘universal-argument-map’ takes over so further ‘C-u’, digits, and ‘-’ compose the prefix as usual. Pressing the key then runs the command with ‘current-prefix-arg’ set, exactly like any ‘interactive "P"’ command.
Suffix commands dismiss the popup when they return. For commands that prompt (‘read-string’, ‘completing-read’, …) and should leave the popup visible, either set ‘:persistent t’ on the keymap or re-invoke ‘keymap-popup’ at the end of the command:
(defun my-edit-set-title ()
(interactive)
(let ((new (read-string "Title: ")))
(my-db-set-title new))
(keymap-popup my-edit-map))
(keymap-popup-define my-edit-map
:group "Edit"
"t" ("Title" my-edit-set-title))
The same pattern works with a lambda in the binding itself:
(keymap-popup-define my-settings-map
:group "Settings"
"n" ((lambda () (format "Limit: %d" my--limit))
(lambda ()
(interactive)
(setq-local my--limit (read-number "Limit: "))
(keymap-popup my-settings-map))))
‘keymap-popup-annotate’ attaches descriptions to a keymap you don’t own (like ‘dired-mode-map’) without rebinding its keys. Keys are resolved at popup time via ‘where-is-internal’, so the popup tracks the user’s current bindings.
(keymap-popup-annotate dired-mode-map
:popup-key "?"
:persistent t
:description (lambda ()
(format "Dired: %s" (abbreviate-file-name default-directory)))
:group "File"
dired-find-file "Open"
dired-do-copy "Copy"
dired-do-rename "Rename"
dired-do-delete "Delete"
:group "Navigate"
dired-up-directory "Up"
dired-previous-line "Prev"
dired-next-line "Next"
:group "More"
"M" ("Mark operations" :keymap my-dired-mark-map))
Entries are ‘COMMAND-SYMBOL DESCRIPTION’ (the key comes from the user’s binding). String-key entries are allowed too – that’s how sub-menus attach to an annotated keymap.
‘transient’ is its own abstraction: a class hierarchy built on EIEIO, with prefix/suffix/infix objects and a custom dispatch loop. The menu is not an Emacs keymap. It fits codebases that already lean on EIEIO and benefit from typed, extensible class machinery (magit being the canonical example).
‘keymap-popup’ is just a ‘defvar-keymap’ plus metadata, displayed via the built-in ‘set-transient-map’ primitive (which predates the ‘transient’ package by years and is unrelated to it, despite the naming collision). Use it when you want a real keymap that also knows how to draw itself: bindings show up in ‘where-is’ and ‘describe-key’, and the popup does not block ‘M-x’. There is no class hierarchy to learn, and state lives wherever you choose to put it.
Source is a single file, ‘keymap-popup.el’, with tests in ‘tests/keymap-popup-tests.el’. Run them with:
emacs --batch -l ert -l keymap-popup.el \
-l tests/keymap-popup-tests.el \
-f ert-run-tests-batch-and-exit