Fixing macOS Zoom "Follow Keyboard Focus" in GNU Emacs


I run macOS Accessibility Zoom at 16× magnification. Not occasionally — all the time, every day. Apple’s built-in screen magnifier has a mode called “Follow keyboard focus” that’s supposed to track your text cursor as you type, keeping it visible on screen. Every app I use does this correctly. Terminal, VS Code, Safari, iTerm2 — they all work. Emacs did not.

For years.

I type something. The cursor moves. The Zoom viewport doesn’t follow. I have to scroll manually to find it again. Then type another character. Repeat. If you’re reading this without needing magnification, the description might sound like a minor inconvenience. It isn’t. It’s the kind of friction that makes a tool feel broken — and I use Emacs all day.

So I finally decided to fix it properly.

The Obvious First Attempt

The “Follow keyboard focus” feature in macOS Zoom is event-driven. When a focused UI element changes, Zoom picks it up via the Accessibility API and moves the viewport to where that element is on screen. The standard mechanism for announcing these changes is NSAccessibilityPostNotification().

Seemed straightforward: after each cursor draw, post a notification telling Zoom the selection changed.

NSAccessibilityPostNotification(view, NSAccessibilitySelectedTextChangedNotification);
NSAccessibilityPostNotification(view, NSAccessibilityFocusedUIElementChangedNotification);

I added this to ns_draw_window_cursor() in nsterm.m, rebuilt, tested.

Nothing.

The viewport didn’t move at all.

Here’s why. When Zoom receives AXSelectedTextChanged or AXFocusedUIElementChanged, it doesn’t just accept the notification and move on — it queries back. It calls AXBoundsForRange on the focused element to find out where the cursor actually is. To answer that query, the view needs to conform to the NSAccessibility protocol and implement accessibilityBoundsForRange:.

EmacsView — the main Emacs drawing surface in nsterm.m — is a subclass of NSView. It does not declare NSAccessibility protocol conformance. There’s no @interface EmacsView () <NSAccessibility>, no implementation of accessibilityBoundsForRange:. So when Zoom posts the query, it gets nothing back. No bounds. Zoom shrugs and does nothing.

The notification fires. Zoom hears it. Zoom asks “okay, so where is the cursor?” Emacs cannot answer. The viewport stays put.

I could have gone down the road of implementing proper NSAccessibility conformance on EmacsView. That would technically work. It would also be a massive undertaking — you’d need a full accessibility tree, element hierarchy, all the associated protocol methods. A multi-month project, not a patch. I needed something more surgical.

Finding the Real Answer

When you’re stuck on an obscure macOS API problem, the most useful thing you can do is read the source code of other apps that solved the same problem. iTerm2 is open source. So is Chromium.

Both of them have exactly the same situation as Emacs: a custom NSView for terminal or browser rendering that doesn’t expose a full accessibility tree. And both of them needed Zoom to follow the text cursor. I went looking for how they handled it.

In iTerm2’s PTYTextView.m, there’s a method called refreshAccessibility. It calls a function I hadn’t seen before: UAZoomChangeFocus().

Chromium’s render_widget_host_view_mac.mm does the same thing in its cursor tracking code.

UAZoomChangeFocus() is part of HIServices/UniversalAccess.h, accessible via Carbon/Carbon.h. It’s a Carbon-era API that speaks directly to the Zoom subsystem — bypassing the Accessibility notification infrastructure entirely. No protocol conformance required. No callback. No “where is the cursor?” query. You just call it with the cursor’s screen coordinates and it moves the viewport.

The signature:

OSStatus UAZoomChangeFocus(const CGRect *focusedItemBounds,
                           const CGRect *caretBounds,
                           UAZoomFocusType focusType);

The focusType argument is kUAZoomFocusTypeInsertionPoint, which tells Zoom this is a text cursor — triggering exactly the keyboard focus tracking behavior I needed.

This was the real fix. Not a notification, not an accessibility protocol — a direct API call with explicit coordinates.

The Fix, and a Coordinate Problem

The implementation goes into ns_draw_window_cursor() in nsterm.m, inside the #ifdef NS_IMPL_COCOA block. When Emacs draws the cursor, we know exactly where it is in view-local coordinates. From there it’s a coordinate conversion chain:

  1. Convert cursor rect from view-local to window coordinates
  2. Convert window coordinates to screen coordinates (AppKit convention)
  3. Convert to CGRect
  4. Call UAZoomChangeFocus()

Steps 1–3 are standard AppKit. Step 4 would have been trivial — except for a coordinate system mismatch that took me a while to sort out.

macOS has two coordinate conventions. AppKit (NSView, NSWindow) uses bottom-left as the origin, with y increasing upward. CoreGraphics (CGRect, HIServices) uses top-left as the origin, with y increasing downward. UAZoomChangeFocus() expects CoreGraphics screen coordinates.

The natural way to do this conversion is accessibilityConvertScreenRect:, which handles the y-flip for you. But — and here’s the catch — that method is declared on objects that conform to the NSAccessibility protocol. Which, as we’ve established, EmacsView does not.

I tried calling it anyway. Compilation error.

So: manual y-flip. The primary screen height is the key reference point:

CGFloat primaryH = [[[NSScreen screens] firstObject] frame].size.height;
cgRect.origin.y = primaryH - cgRect.origin.y - cgRect.size.height;

This converts the AppKit screen y-coordinate to the CoreGraphics y-coordinate using simple arithmetic. No protocol conformance needed.

The full implementation:

if (UAZoomEnabled()) {
    NSRect windowRect = [view convertRect:r toView:nil];
    NSRect screenRect = [[view window] convertRectToScreen:windowRect];
    CGRect cgRect = NSRectToCGRect(screenRect);
    CGFloat primaryH = [[[NSScreen screens] firstObject] frame].size.height;
    cgRect.origin.y = primaryH - cgRect.origin.y - cgRect.size.height;
    UAZoomChangeFocus(&cgRect, &cgRect, kUAZoomFocusTypeInsertionPoint);
}

The UAZoomEnabled() check at the top is important — it avoids any overhead when Zoom isn’t active, so there’s no performance cost for the common case.

One build note: after patching and rebuilding Emacs.app, you need to re-grant Accessibility permission in System Settings. The binary hash changes, macOS treats it as a new application, and Accessibility permissions are keyed to the binary.

It Works

After the rebuild, I enabled Zoom at 16×, opened Emacs, started typing. The viewport followed the cursor. Every character, every line movement, every jump across the file — Zoom tracked it.

I typed in Emacs for ten minutes just to make sure I wasn’t imagining it.

The fix itself is small — about fifteen lines of Objective-C in ns_draw_window_cursor(). The journey to find it was longer: trying the notification approach, understanding why it failed, going through the Accessibility API documentation, reading iTerm2 and Chromium source, finding UAZoomChangeFocus(), working through the coordinate system issue, hitting the compilation error on accessibilityConvertScreenRect:, figuring out the manual y-flip. That’s the actual work. The patch is just the result of it.

The patch has been submitted to the GNU Emacs developers. Hopefully it lands in a future release so nobody else has to track this down. This was a long-standing problem — Emacs being the one editor on macOS that didn’t work with “Follow keyboard focus” — and it’s finally resolved. Everything works beautifully now.

If you’re building a custom NSView on macOS and need Zoom compatibility, skip the accessibility notification approach and go straight to UAZoomChangeFocus(). That’s the right tool for the job.


See also