Multi-Monitor Compatible Code to Center Emacs Frames on Screen

When centering my Emacs windows (aka ‘frames’) on my monitors, I noticed that with 2 monitors active, the computation doesn’t work: It doesn’t center in the main monitor; it centers in the area of both monitors combined. That’s not what I want.

Here’s a fix and an explanation of the problem.

Update 2022-04-22: There’s a much shorter version that is likely even more robust shared by Louis Brauer. See the new solution.

Why 2 monitors caused trouble

Looking at the documentation for display-pixel-width, I learned that this is intended.

There’s a distinction between “monitor” and “display” in Emacs that was absolutely not what I expected. “Display” is the total available space, while “monitor” is a single device.

To get per-monitor geometry information, the documentation advices the reader to look at display-monitor-attributes-list. That shows information for all monitors known to the system, though, so you’d have to find the right onw; looking at the docs there accidentally brought up a related function, frame-monitor-attributes, that limits the information to the monitor of the current Emacs frame. Since I want to center a newly created frame on screen, this works perfectly.

Using the monitor workarea

The result of (frame-monitor-attributes) for the main monitor is a list of attributes and value like this:

((geometry 0 0 3440 1440)
 (workarea 0 25 3440 1415)
 (mm-size 801 335)
 (frames #<frame  *Minibuf-1* 0x7f9c773b3418>)
 (source . "NS"))

This has the added benefit of defining a ‘workarea’ next to ‘geometry’ that excludes the space taken up by main menu. So I don’t need to compute this area myself.

I never worked with an attribute list like that, so I had to look up a couple of functions for hash maps, propertly lists, dictionaries etc. until one eventually worked. In the process I noticed there also is the function (frame-monitor-workarea) that does the job, so I’ll be using that. In case you’re curious how to unpack an attribute, though, try: (alist-get 'workarea (frame-monitor-attributes)).

Now the result of this is the list of values:

(0 25 3440 1415)

To get to the 4th item in this list, i.e. the usable height, cadddr is used. That’s a shorthand:

  • 3x cdr calls, which would be cdddr. In modern languages, we’d be calling this “drop first”. It returns rest of the list sans the first element.
  • That on its own would produce a list with 1 item, (1415). If you’re new to Lisp, thats basically an array with 1 element.
  • So we add a car call that fetches the tip of the list. For example (car '(foo bar fizz buzz)) returns foo. This gives us the number.

To unpack the 3rd item in this list, i.e. the usable width, we use caddr – note it does one less cdr call, so the temporary list result is (3440 1415), and then the car fetches the number, 3440.

Update 2021-06-11: You can also use (nth INDEX LIST). I prefer that because even though it looks like I’m a Lisp noob, it’s easier to figure out which element you get.

Armed with this knowledge, I’ve added these two functions to get the width and height, including proper documentation:

(defun ct/frame-monitor-usable-height (&optional frame)
  "Return the usable height in pixels of the monitor of FRAME.
FRAME can be a frame name, a terminal name, or a frame.
If FRAME is omitted or nil, use currently selected frame.

Uses the monitor's workarea. See `display-monitor-attributes-list'."
  (cadddr (frame-monitor-workarea frame)))

(defun ct/frame-monitor-usable-width (&optional frame)
  "Return the usable width in pixels of the monitor of FRAME.
FRAME can be a frame name, a terminal name, or a frame.
If FRAME is omitted or nil, use currently selected frame.

Uses the monitor's workarea. See `display-monitor-attributes-list'."
  (caddr (frame-monitor-workarea frame)))

If you never ever use these functions anywhere else, the following would do the trick, too, as local variables in a function:

;; ...
  (let* ((workarea (frame-monitor-workarea frame))
          (width (caddr workarea))
          (height (cadddr workarea)))
    ;; use width and height here
    )
;; ...

I guess with more Elisp experience, I’d be using these directly, but at the moment I benefit from dedicated functions with documentation to encapsulate concepts like “unpack the width from a monitor workarea value list”.

Update 2021-06-11: Göktuğ Kayaalp shared a condensed single-function approach that I tweaked a bit and shared in another post.

Resulting code to center a frame on screen

Here is the complete, fully updated version:

(defun ct/frame-monitor-usable-height (&optional frame)
  "Return the usable height in pixels of the monitor of FRAME.
FRAME can be a frame name, a terminal name, or a frame.
If FRAME is omitted or nil, use currently selected frame.

Uses the monitor's workarea. See `display-monitor-attributes-list'."
  (cadddr (frame-monitor-workarea frame)))

(defun ct/frame-monitor-usable-width (&optional frame)
  "Return the usable width in pixels of the monitor of FRAME.
FRAME can be a frame name, a terminal name, or a frame.
If FRAME is omitted or nil, use currently selected frame.

Uses the monitor's workarea. See `display-monitor-attributes-list'."
  (caddr (frame-monitor-workarea frame)))

(defun ct/center-box (w h cw ch)
  "Center a box inside another box.

Returns a list of `(TOP LEFT)' representing the centered position
of the box `(w h)' inside the box `(cw ch)'."
  (list (/ (- cw w) 2) (/ (- ch h) 2)))

(defun ct/frame-get-center (frame)
  "Return the center position of FRAME on it's display."
  (ct/center-box (frame-pixel-width frame) (frame-pixel-height frame)
                 (ct/frame-monitor-usable-width frame) (ct/frame-monitor-usable-height frame)))

(defun ct/frame-center (&optional frame)
  "Center a frame on the screen."
  (interactive)
  (let* ((frame (or (and (boundp 'frame) frame) (selected-frame)))
         (center (ct/frame-get-center frame)))
    (apply 'set-frame-position (flatten-list (list frame center)))))

Update 2021-11-06: I noticed that some time after I began using Emacs 28, (and (boundp 'frame) frame) caused trouble and would fall-back to selected-frame too often. A working solution to auto-center a frame after making it is to omit the boundp check and just write this: (let* ((frame (or frame (selected-frame))) ...

Now it works, without any hacks and manual macOS menu offsetting.

Also see the shorter single-function variant.