Multi-key selection in Emacs

The org-mks function has been around for a long time. It stands for multi-key selection and lives in the org-macs.el library since 2013, but without much use outside org-capture and org-insert-structure-template. The interface is simple, allowing users to select from a list of choices by pressing keys, so here’s an example shared on the mailing list.

(let ((entry (org-mks `(("a" "A entry" ,#'identity)
                        ("b" "B entry" ,(lambda (x) (* 2 x))))
                      "A or B")))
  (funcall (nth 2 entry) 10))

When you run it, a buffer pops up with:

A or B

[a]     A entry
[b]     B entry

Pressing a prints 10 and pressing b prints 20. There are, of course, other ways of doing this within Emacs, but for simple applications I think org-mks fits the bill well. For example, you can write ("j" "Description") to use j as a prefix for a multi-letter selection, which probably explains how the function received its name.

(defun key-pressed (key)
  (message "You pressed %s" key))

(let* ((entry (org-mks '(("j" "Press j")
                         ("jj" "Press j")
                         ("jjj" "Press j" key-pressed)
                         ("jk" "Press k" key-pressed)
                         ("k" "Press k" key-pressed)
                         ("q" "Abort" (lambda (_) nil)))
                       "Select a key" "Keys: "))
       (action (nth 2 entry)))
  (funcall action (car entry)))

And there is one last argument, an alist with ("key" "description") entries, which returns the bare key only.

(let ((entry (org-mks '(("j" "Roll dice"
                         (lambda ()
                           (+ (random 6) 1))))
                      "Select a key"
                      "Key: "
                      '(("q" "Abort")))))
  (cond ((equal entry "q")
         (user-error "Abort"))
        (t (funcall (nth 2 entry)))))

It is not possible, however, to pass a prefix argument to it, or to the read-key function. In other words, they won’t accept C-u j as an input, so I came up with the following solution.

(defun read-choice (prompt choices)
  "Prompt user to choose an action from a list of choices.
Choices should be a list of (KEY DESCRIPTION ACTION) triples.
Similar to org-mks, but accepts a prefix arg."
  (let (action current-prefix-arg)
    (catch 'exit
      (save-window-excursion
        (let (case-fold-search)
          (pop-to-buffer "*Read choice*" t t)
          (setq-local cursor-type nil)
          (erase-buffer)
          (insert "Select a key\n\n")
          (pcase-dolist (`(,key ,description) choices)
            (insert "[" key "]  " description "\n"))
          (goto-char (point-min))
          (fit-window-to-buffer)
          (let ((event (key-description (vector (read-key prompt)))))
            (when (equal event "C-g")
              (throw 'exit (user-error "Abort")))
            (if (equal event "C-u")
                (let* ((key (key-description (vector (read-key prompt))))
                       (selection (assoc key choices)))
                  (setq action (nth 2 selection)
                        current-prefix-arg '(4)))
              (let ((selection (assoc event choices)))
                ;; Return description if action is not found
                (setq action (or (nth 2 selection)
                                 (nth 1 selection))))))))
      (cond ((stringp action) (message action))
            ((commandp action) (call-interactively action))
            (t (eval action))))))

Note the difference between j and C-u j.

(read-choice
 "Key: "
 '(("j" "Press j" (lambda (arg)
                    (interactive "P")
                    (print (or current-prefix-arg "No ARG"))))
   ("q" "Abort" keyboard-quit)))

Note also that if you don’t need to run commands, Emacs comes with a read-multiple-choice function. Either way, I hope you take the right pill.

(read-multiple-choice "Select" '((?r "red") (?b "blue")))