#+title: Keymap Popup User Manual
#+author: Thanos Apollo
#+email: public@thanosapollo.org
#+language: en
#+options: ':t toc:t author:t email:t num:t
#+texinfo_filename: keymap-popup.info
#+texinfo_dir_category: Emacs misc features
#+texinfo_dir_title: Keymap Popup: (keymap-popup)
#+texinfo_dir_desc: Define a keymap once, dispatch keys and show a popup menu

* Table of Contents :noexport:
:PROPERTIES:
:TOC: this
:END:

- [[#introduction][Introduction]]
- [[#installation][Installation]]
- [[#quick-start][Quick start]]
- [[#defining-keymaps][Defining keymaps]]
- [[#showing-popup][Showing the popup]]
- [[#special-bindings][Special bindings]]
- [[#prompting][Prompting and re-entry]]
- [[#annotate][Annotating existing keymaps]]
- [[#customization][Customization]]
- [[#vs-transient][When to use this vs transient]]
- [[#contributing][Contributing]]
- [[#gfdl][GNU Free Documentation License]]

* Introduction
:PROPERTIES:
:CUSTOM_ID: introduction
:END:

=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.

* Installation
:PROPERTIES:
:CUSTOM_ID: installation
:END:

** From source

#+begin_src emacs-lisp
(add-to-list 'load-path "/path/to/keymap-popup")
(require 'keymap-popup)
#+end_src

** With ~use-package~

#+begin_src emacs-lisp
(use-package keymap-popup
  :ensure t)
#+end_src

* Quick start
:PROPERTIES:
:CUSTOM_ID: quick-start
:END:

#+begin_src emacs-lisp
(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)
#+end_src

The macro auto-binds =h= inside the keymap, so =C-c m h= opens the
popup.  =q= or =C-g= dismisses it.

* Defining keymaps
:PROPERTIES:
:CUSTOM_ID: defining-keymaps
:END:

** Macro syntax

#+begin_src emacs-lisp
(keymap-popup-define MAP-NAME
  DOCSTRING
  :popup-key KEY  :exit-key KEY
  :parent KEYMAP  :description STRING-OR-FN
  :persistent BOOL
  BINDING...)
#+end_src

Only =MAP-NAME= and the bindings are required; every keyword above
is optional and may appear in any order.  =keymap-popup-annotate=
accepts the display keywords above except =:parent=, but does /not/
apply defaults for =:popup-key= or =:exit-key= -- it never binds keys
unless asked.

A binding takes one of three forms:

#+begin_src emacs-lisp
KEY (DESC COMMAND     [PROP VALUE...])    ; ordinary command
KEY (DESC :switch VAR [PROP VALUE...])    ; toggle
KEY (DESC :keymap MAP [PROP VALUE...])    ; sub-menu
#+end_src

- =KEY= -- =key-valid-p= string.
- =DESC= -- string or zero-arg function (called at render time in the
  user's buffer, so it sees buffer-local state).
- =COMMAND= -- command symbol or a =(lambda () (interactive) ...)=.
- =PROP= -- one of =:if=, =:inapt-if=, =:stay-open=, =:c-u= (see
  [[#special-bindings][Special bindings]]).
  =:stay-open= is a boolean flag and may omit its =t= value.

Declarations are checked when the macro expands.  Invalid keys, malformed
entries, missing switch variables or sub-menu targets, duplicate or unknown
properties, and unsupported options report the offending entry directly.

** Groups and rows

#+begin_src emacs-lisp
(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))
#+end_src

=: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)=.

** Dynamic descriptions

#+begin_src emacs-lisp
(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))
#+end_src

The header is re-rendered after each command.

** Sharing bindings with =:parent=

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.

#+begin_src emacs-lisp
(keymap-popup-define my-items-mode-map
  :parent my-dashboard-common-map
  :group "Items"
  "RET" ("Open" my-items-open)
  "q" ("Back" my-items-back))
#+end_src

** Adding and removing entries at runtime

#+begin_src emacs-lisp
(keymap-popup-add-entry my-commands-map
                        "l" "Log message" #'my-log-message
                        "Edit")

(keymap-popup-remove-entry my-commands-map "l")
#+end_src

Signatures:

- =(keymap-popup-add-entry KEYMAP KEY DESC COMMAND &optional GROUP)=
- =(keymap-popup-remove-entry KEYMAP KEY)=

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.

* Showing the popup
:PROPERTIES:
:CUSTOM_ID: showing-popup
:END:

#+begin_src emacs-lisp
;;;###autoload
(defun yeetube ()
  (interactive)
  (keymap-popup yeetube-mode-map))
#+end_src

=C-g= always dismisses, regardless of the configured =:exit-key=.

** Display backends

=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:

#+begin_src emacs-lisp
(defun my-commands-floating ()
  (interactive)
  (let ((keymap-popup-backend #'keymap-popup-backend-child-frame))
    (keymap-popup my-commands-map)))
#+end_src

** Persistent popups
:PROPERTIES:
:CUSTOM_ID: persistent
:END:

By default the popup dismisses after a suffix command runs.
=:persistent t= keeps it open until the exit key or =C-g=:

#+begin_src emacs-lisp
(keymap-popup-define my-map :persistent t ...)
(keymap-popup-annotate dired-mode-map :popup-key "?" :persistent t ...)
(setopt keymap-popup-persistent t)        ; global default
#+end_src

Per-keymap setting wins over the global one.
Use =:persistent nil= on a definition or annotation to disable a non-nil
global default for that keymap.

** Dismissing programmatically

Call =keymap-popup-dismiss= from a command to close the popup before
transitioning elsewhere:

#+begin_src emacs-lisp
(defun my-open-detail ()
  (interactive)
  (keymap-popup-dismiss)
  (pop-to-buffer "*detail*"))
#+end_src

* Special bindings
:PROPERTIES:
:CUSTOM_ID: special-bindings
:END:

Predicate quick reference:

- =:if=       -- /hide/ the entry (or group) when the predicate returns nil.
- =:inapt-if= -- /grey out/ the entry and block its key when the predicate returns non-nil.

** Sub-menus with =:keymap=

#+begin_src emacs-lisp
(keymap-popup-define my-dashboard-mode-map
  :group "Navigate"
  "n" ("Nodes" :keymap my-dashboard-nodes-map)
  "t" ("Themata" :keymap my-dashboard-themata-map)
  :group "More"
  "x" ("Import/Export" :keymap my-dashboard-io-map))
#+end_src

Sub-menu entries use the =keymap-popup-submenu= face.  Nesting is
unlimited; the exit key pops one level.

** Toggles with =:switch=

#+begin_src emacs-lisp
(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)))
#+end_src

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=.

** Stay-open commands with =:stay-open=

#+begin_src emacs-lisp
(keymap-popup-define my-mark-map
  :group "Mark"
  "m" ("Mark" dired-mark :stay-open)
  "u" ("Unmark" dired-unmark :stay-open)
  "U" ("Unmark all" dired-unmark-all-marks))
#+end_src

For an entire popup, prefer =:persistent t= (see [[#persistent][Persistent popups]]).

** Disabling entries with =:inapt-if=

#+begin_src emacs-lisp
(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))
#+end_src

Inapt entries render in =keymap-popup-inapt= and print "Command
unavailable" when pressed.

** Hiding entries with =:if=

#+begin_src emacs-lisp
(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))
#+end_src

** Prefix-arg variants with =:c-u=

#+begin_src emacs-lisp
(keymap-popup-define my-greet-map
  :group "Actions"
  "a" ("Greet" my-greet :c-u "SHOUT (C-u)"))
#+end_src

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.

* Prompting and re-entry
:PROPERTIES:
:CUSTOM_ID: prompting
:END:

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:

#+begin_src emacs-lisp
(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))
#+end_src

The same pattern works with a lambda in the binding itself:

#+begin_src emacs-lisp
(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))))
#+end_src

* Annotating existing keymaps
:PROPERTIES:
:CUSTOM_ID: annotate
:END:

=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.

#+begin_src emacs-lisp
(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))
#+end_src

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.

** Attaching descriptions built from data

Both macros take bindings written literally in source.  When the
menu itself is computed at runtime -- an anonymous keymap, entries
generated from a list -- use the function =keymap-popup-attach=:

#+begin_src emacs-lisp
(defun my-project-popup ()
  "Pick a recent project from a popup."
  (interactive)
  (let* ((projects (seq-take (project-known-project-roots) 9))
         (map (make-sparse-keymap))
         (bindings
          (cl-loop for root in projects
                   for key from ?1
                   for cmd = (let ((r root))
                               (lambda () (interactive)
                                 (project-switch-project r)))
                   do (keymap-set map (char-to-string key) cmd)
                   append (list (char-to-string key)
                                (list (abbreviate-file-name root) cmd)))))
    (keymap-popup (keymap-popup-attach
                   map (append '(:group "Projects") bindings)))))
#+end_src

Signature:

- =(keymap-popup-attach KEYMAP BINDINGS &rest OPTS)=

BINDINGS is an evaluated list in the same shape as
=keymap-popup-annotate='s body; commands and predicates are actual
objects rather than forms.  Function symbols may precede their definitions;
they must be callable when the popup uses them.  OPTS accepts =:exit-key=,
=:description=, and =:persistent=.  Reattaching replaces prior descriptions
and options, so omitted options return to their defaults.  Unlike the macros,
no keys are bound and no commands are defined -- keyed entries should already
be bound in KEYMAP.  Runtime values and options are validated before metadata
is attached.  Returns KEYMAP.

* Customization
:PROPERTIES:
:CUSTOM_ID: customization
:END:

** User options

- =keymap-popup-backend= -- display function; built-ins are
  =keymap-popup-backend-side-window= (default) and
  =keymap-popup-backend-child-frame=.
- =keymap-popup-display-action= -- =display-buffer= action for the
  side-window backend.
- =keymap-popup-buffer-parameters= -- buffer-local parameters applied
  to the popup buffer.
- =keymap-popup-child-frame-parameters= -- frame parameters for the
  child-frame backend.
- =keymap-popup-persistent= -- global default for =:persistent=.
- =keymap-popup-default-popup-key= -- default =:popup-key= (=h=).
- =keymap-popup-default-exit-key= -- default =:exit-key= (=q=).

** Faces

- =keymap-popup-key= -- key string (inherits =help-key-binding=).
- =keymap-popup-group-header= -- group header.
- =keymap-popup-value= -- =[on]= / =[off]= and inline values.
- =keymap-popup-submenu= -- sub-menu entries.
- =keymap-popup-inapt= -- disabled entries (inherits =shadow=).

* When to use this vs =transient=
:PROPERTIES:
:CUSTOM_ID: vs-transient
:END:

=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.

* Contributing
:PROPERTIES:
:CUSTOM_ID: contributing
:END:

Source is a single file, =keymap-popup.el=, with tests in
=tests/keymap-popup-tests.el=.  Run them with:

#+begin_src sh
emacs --batch -l ert -l keymap-popup.el \
              -l tests/keymap-popup-tests.el \
              -f ert-run-tests-batch-and-exit
#+end_src
