notes from /dev/null

by Charles Choi 최민수


Customizing the Emacs Context Menu

03 Jan 2023  Charles Choi

Summary

(best played full screen, no audio)

Novice Elisp programmer (me) succeeds in customizing Emacs right mouse button menu behavior. Uses this knowledge to copy common actions seen in native (in particular macOS) applications.

This post captures how I got here.

Background

Context menus triggered by a right mouse button event are a commonly used GUI pattern. Emacs provides support for this via context-menu-mode which was introduced in version 28. Customizing context-menu-mode requires some understanding how menus are structured. I won’t go into too much detail describing how Emacs menus work here, but hopefully will impart enough about it to get you going.

What I Did

Requirements

  • Context menu should only show menu actions based on context. Different criteria for context are enumerated below and can be used in combinatorial fashion for a particular action.
    • buffer mode (major and minor)
    • region is selected (active) or not
    • buffer-file-name is version controlled

Preconditions

My customized context menu relies on these packages being installed. YMMV. Also, you must use Emacs version ≧ 28.

Turn on context-menu-mode

I’ve got context-menu-mode turned on in both prog-mode-hook and text-mode-hook which should cover most all my programming (Python, ObjC, Swift, Elisp, C, etc.) and text (Org, Markdown) modes via inheritance. It is also turned on for dired and shell modes.

1
2
3
4
(add-hook 'text-mode-hook 'context-menu-mode)
(add-hook 'prog-mode-hook 'context-menu-mode)
(add-hook 'shell-mode-hook 'context-menu-mode)
(add-hook 'dired-mode-hook 'context-menu-mode)

At this point, you should be able to right mouse button click anywhere on a buffer see a menu. But that menu is quite unexciting. We’ll address this next.

Let’s Make a Menu

A common context menu action is to take selected text and either capitalize, upper, or lower case it, as illustrated below.

Transform

We’ll implement the above as a sub-menu with the user-defined variable cc/transform-text-menu whose value is a sparse keymap. The code below illustrates how this is implemented.

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
;; Transform Text
(defvar cc/transform-text-menu (make-sparse-keymap "Transform Text"))

(define-key cc/transform-text-menu [tranform-text-uppercase]
  '(menu-item "Make Upper Case" upcase-region
              :help "Upper case region"))

(define-key-after cc/transform-text-menu [tranform-text-lowercase]
  '(menu-item "Make Lower Case" downcase-region
              :help "Lower case region"))

(define-key-after cc/transform-text-menu [tranform-text-capitalize]
  '(menu-item "Capitalize" capitalize-region
              :help "Capitalize region"))

Note that the actual commands to execute the transformations (upcase-region, downcase-region, capitalize-region) are invoked in each menu-item call.

Note that at this point cc/transform-text-menu only defines the sub-menu but it is not attached to anything yet.

Let’s Make a Menu, Part 2

Let’s make another sub-menu with the user-defined variable cc/org-emphasize-menu, this time to format (emphasize) selected text in Org-mode as shown below.

Emphasize-Menu

The source to achieve this is described below. Note that the actual emphasis commands (cc/org-emphasize-bold|italic|code|…) in this menu are built on-top of org-emphasize, which generalizes what type of emphasis (bold, italic, code, etc.) to use as a function argument.

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
;; Org Emphasize
(defvar cc/org-emphasize-menu (make-sparse-keymap "Org Emphasize"))

(define-key cc/org-emphasize-menu [org-emphasize-bold]
  '(menu-item "Bold" cc/org-emphasize-bold
              :help "Bold"))

(define-key-after cc/org-emphasize-menu [org-emphasize-italic]
  '(menu-item "Italic" cc/org-emphasize-italic
              :help "Italic"))

(define-key-after cc/org-emphasize-menu [org-emphasize-code]
  '(menu-item "Code" cc/org-emphasize-code
              :help "Code"))

(define-key-after cc/org-emphasize-menu [org-emphasize-underline]
  '(menu-item "Underline" cc/org-emphasize-underline
              :help "Underline"))

(define-key-after cc/org-emphasize-menu [org-emphasize-verbatim]
  '(menu-item "Verbatim" cc/org-emphasize-verbatim
              :help "Verbatim"))

(define-key-after cc/org-emphasize-menu [org-emphasize-strike-through]
  '(menu-item "Strike Through" cc/org-emphasize-strike-through
              :help "Strike through"))

(define-key-after cc/org-emphasize-menu [org-emphasize-reset]
  '(menu-item "Reset" cc/org-emphasize-reset
              :help "Remove emphasis"))

;; Convenience functions to emphasize by type
(defun cc/org-emphasize-bold ()
  (interactive)
  (org-emphasize ?*))

(defun cc/org-emphasize-italic ()
  (interactive)
  (org-emphasize ?/))

(defun cc/org-emphasize-code ()
  (interactive)
  (org-emphasize ?~))

(defun cc/org-emphasize-underline ()
  (interactive)
  (org-emphasize ?_))

(defun cc/org-emphasize-verbatim ()
  (interactive)
  (org-emphasize ?=))

(defun cc/org-emphasize-strike-through ()
  (interactive)
  (org-emphasize ?+))

(defun cc/org-emphasize-reset ()
  ;; org bug: this won't work when org-hide-emphasis-markers is turned on.
  (interactive)
  (org-emphasize ?\s))

With the above source, we now have a variable cc/org-emphasis-menu that holds the emphasis sub-menu. We are ready to hook this into the context menu!

Customizing the Context Menu

Customizing the context menu requires:

  1. Defining a new function (in this case cc/context-menu-addons) to capture the customized context menu behavior.
  2. Hooking that new function (cc/context-menu-addons) into the variable context-menu-functions via the add-hook function.
     1
     2
     3
     4
     5
     6
     7
     8
     9
    10
    11
    12
    13
    14
    15
    16
    17
    18
    19
    20
    21
    22
    23
    24
    25
    26
    27
    28
    29
    30
    31
    32
    33
    34
    35
    36
    37
    38
    39
    40
    41
    42
    43
    44
    45
    46
    47
    48
    49
    50
    51
    52
    53
    54
    55
    (defun cc/context-menu-addons (menu click)
      "CC context menu additions"
      (save-excursion
        (mouse-set-point click)
        (define-key-after menu [open-in-finder]
          '(menu-item "Open in Finder" reveal-in-folder-this-buffer
                      :help "Open file (buffer) in Finder"))
    
        (when (region-active-p)
          (define-key-after menu [osx-dictionary-lookup]
            '(menu-item "Look up" osx-dictionary-search-word-at-point
                        :help "Look up in dictionary"))
    
          (define-key-after menu [occur-word-at-mouse]
            '(menu-item "Occur" occur-word-at-mouse
                        :help "Occur")))
    
        (when (and (bound-and-true-p buffer-file-name)
                   (vc-registered (buffer-file-name)))
          (define-key-after menu [vc-separator]
            '(menu-item "--single-line"))
    
          (define-key-after menu [magit-status]
            '(menu-item "Magit Status" magit-status
                        :help "Magit Status"))
          (define-key-after menu [ediff-revision]
            '(menu-item "Ediff revision…" cc/ediff-revision
                        :help "Ediff this file with revision")))
    
        (when (region-active-p)
          (define-key-after menu [transform-text-separator]
            '(menu-item "--single-line"))
          (define-key-after menu [tranform-text]
            (list 'menu-item "Transform" cc/transform-text-menu)))
    
        (when (and (derived-mode-p 'org-mode) (region-active-p))
          (define-key-after menu [org-emphasize]
            (list 'menu-item "Org Emphasize" cc/org-emphasize-menu))
    
          (define-key-after menu [org-export-to-slack]
            '(menu-item "Copy as Slack" org-slack-export-to-clipboard-as-slack
                        :help "Copy as Slack to clipboard"))
    
          (define-key-after menu [copy-as-rtf]
            '(menu-item "Copy as RTF" dm/copy-as-rtf
                        :help "Copy as RTF to clipboard")))
    
        (when (region-active-p)
          (define-key-after menu [google-search]
            '(menu-item "Search with Google" google-this-noconfirm
                        :help "Search Google with region"))))
        menu)
    
    ;; hook into context menu
    (add-hook 'context-menu-functions #'cc/context-menu-addons)
    

Note that the implementation of cc/context-menu-addons has logic to query context information such as buffer mode, whether text is selected (region-active-p), and source control (vc-registered) to determine if a menu-item is to be rendered.

Extras: Clean up Ediff UX

IMHO, Ediff has terrible UX out of the box, especially when quitting where it leaves a bunch of buffers strewn about. The code below helps mitigate some of that behavior thanks to this posted comment in StackExchange.

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
;; Pass buffer-file-name into Ediff
(defun cc/ediff-revision (e)
  "Invoke ediff-revision with buffer-file-name."
  (interactive "e")
  (ediff-revision buffer-file-name))

;; Fix Ediff quit UX behavior by closing all buffers
(defvar my-ediff-last-windows nil)

(defun my-store-pre-ediff-winconfig ()
  (setq my-ediff-last-windows (current-window-configuration)))

(defun my-restore-pre-ediff-winconfig ()
  (set-window-configuration my-ediff-last-windows))

(add-hook 'ediff-before-setup-hook #'my-store-pre-ediff-winconfig)
(add-hook 'ediff-quit-hook #'my-restore-pre-ediff-winconfig)

Also note that my Ediff variables are set as follows:

1
2
3
'(ediff-keep-variants nil)
'(ediff-split-window-function 'split-window-horizontally)
'(ediff-window-setup-function 'ediff-setup-windows-plain)

Observations

Going through this exercise, a couple of observations:

  • Customizing an Emacs menu requires a lot of boilerplate. Maybe because I’m naive to Elisp and Emacs conventions, there might be a cleaner implementation, but it seems like I gotta work too hard (both learning and implementing) to customize a menu.
  • I tried to implement context-dependent logic in the :visible property documented in Extended Menu Items in the function cc/context-menu-addons. Didn’t work. Elisp experts, definitely would like your feedback.
  • Having the function cc/context-menu-addons hooking into a global variable content-menu-functions seems like a very monolithic design. Maybe there’s a cleaner pattern here?
  • Embark looks awesome, but sometimes you just want a mouse.
  • Beyond thrilled to have gotten here. Hope you feel likewise!

Acknowledgments

Thanks to Philip Kaludercic’s post that helped considerably in my understanding of how to customize a context menu.

Thanks to deaddyfreaddy for providing informal code review on Reddit for an earlier version of this post. This current post has been amended to reflect his feedback.

References

dev   emacs   software   elisp

 

AboutMastodonInstagramGitHub

Feeds & TagsGet Captee for macOS

Powered by Pelican