<?xml version="1.0" encoding="utf-8" standalone="yes"?><rss version="2.0" xmlns:atom="http://www.w3.org/2005/Atom"><channel><title>K@accessibility on Martin Sukany</title><link>https://sukany.cz/tags/k@accessibility/</link><description>Recent content in K@accessibility on Martin Sukany</description><generator>Hugo -- gohugo.io</generator><language>en</language><lastBuildDate>Mon, 23 Feb 2026 00:00:00 +0000</lastBuildDate><atom:link href="https://sukany.cz/tags/k@accessibility/index.xml" rel="self" type="application/rss+xml"/><item><title> Fixing macOS Zoom "Follow Keyboard Focus" in GNU Emacs</title><link>https://sukany.cz/blog/2026-02-23-emacs-macos-zoom-fix/</link><pubDate>Mon, 23 Feb 2026 00:00:00 +0000</pubDate><guid>https://sukany.cz/blog/2026-02-23-emacs-macos-zoom-fix/</guid><description>&lt;p&gt;I run macOS Accessibility Zoom at 16× magnification. Not occasionally — all the time, every day. Apple&amp;rsquo;s built-in screen magnifier has a mode called &amp;ldquo;Follow keyboard focus&amp;rdquo; that&amp;rsquo;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.&lt;/p&gt;
&lt;p&gt;For years.&lt;/p&gt;
&lt;p&gt;I type something. The cursor moves. The Zoom viewport doesn&amp;rsquo;t follow. I have to scroll manually to find it again. Then type another character. Repeat. If you&amp;rsquo;re reading this without needing magnification, the description might sound like a minor inconvenience. It isn&amp;rsquo;t. It&amp;rsquo;s the kind of friction that makes a tool feel broken — and I use Emacs all day.&lt;/p&gt;
&lt;p&gt;So I finally decided to fix it properly.&lt;/p&gt;
&lt;h2 id="the-obvious-first-attempt"&gt;The Obvious First Attempt&lt;/h2&gt;
&lt;p&gt;The &amp;ldquo;Follow keyboard focus&amp;rdquo; 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 &lt;code&gt;NSAccessibilityPostNotification()&lt;/code&gt;.&lt;/p&gt;
&lt;p&gt;Seemed straightforward: after each cursor draw, post a notification telling Zoom the selection changed.&lt;/p&gt;
&lt;div class="highlight"&gt;&lt;pre tabindex="0" class="chroma"&gt;&lt;code class="language-objc" data-lang="objc"&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="n"&gt;NSAccessibilityPostNotification&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;view&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;NSAccessibilitySelectedTextChangedNotification&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="n"&gt;NSAccessibilityPostNotification&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;view&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;NSAccessibilityFocusedUIElementChangedNotification&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;p&gt;I added this to &lt;code&gt;ns_draw_window_cursor()&lt;/code&gt; in &lt;code&gt;nsterm.m&lt;/code&gt;, rebuilt, tested.&lt;/p&gt;
&lt;p&gt;Nothing.&lt;/p&gt;
&lt;p&gt;The viewport didn&amp;rsquo;t move at all.&lt;/p&gt;
&lt;p&gt;Here&amp;rsquo;s why. When Zoom receives &lt;code&gt;AXSelectedTextChanged&lt;/code&gt; or &lt;code&gt;AXFocusedUIElementChanged&lt;/code&gt;, it doesn&amp;rsquo;t just accept the notification and move on — it &lt;em&gt;queries back&lt;/em&gt;. It calls &lt;code&gt;AXBoundsForRange&lt;/code&gt; on the focused element to find out &lt;em&gt;where&lt;/em&gt; the cursor actually is. To answer that query, the view needs to conform to the &lt;code&gt;NSAccessibility&lt;/code&gt; protocol and implement &lt;code&gt;accessibilityBoundsForRange:&lt;/code&gt;.&lt;/p&gt;
&lt;p&gt;&lt;code&gt;EmacsView&lt;/code&gt; — the main Emacs drawing surface in &lt;code&gt;nsterm.m&lt;/code&gt; — is a subclass of &lt;code&gt;NSView&lt;/code&gt;. It does not declare &lt;code&gt;NSAccessibility&lt;/code&gt; protocol conformance. There&amp;rsquo;s no &lt;code&gt;@interface EmacsView () &amp;lt;NSAccessibility&amp;gt;&lt;/code&gt;, no implementation of &lt;code&gt;accessibilityBoundsForRange:&lt;/code&gt;. So when Zoom posts the query, it gets nothing back. No bounds. Zoom shrugs and does nothing.&lt;/p&gt;
&lt;p&gt;The notification fires. Zoom hears it. Zoom asks &amp;ldquo;okay, so where is the cursor?&amp;rdquo; Emacs cannot answer. The viewport stays put.&lt;/p&gt;
&lt;p&gt;I could have gone down the road of implementing proper &lt;code&gt;NSAccessibility&lt;/code&gt; conformance on &lt;code&gt;EmacsView&lt;/code&gt;. That would technically work. It would also be a massive undertaking — you&amp;rsquo;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.&lt;/p&gt;
&lt;h2 id="finding-the-real-answer"&gt;Finding the Real Answer&lt;/h2&gt;
&lt;p&gt;When you&amp;rsquo;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.&lt;/p&gt;
&lt;p&gt;Both of them have exactly the same situation as Emacs: a custom &lt;code&gt;NSView&lt;/code&gt; for terminal or browser rendering that doesn&amp;rsquo;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.&lt;/p&gt;
&lt;p&gt;In iTerm2&amp;rsquo;s &lt;code&gt;PTYTextView.m&lt;/code&gt;, there&amp;rsquo;s a method called &lt;code&gt;refreshAccessibility&lt;/code&gt;. It calls a function I hadn&amp;rsquo;t seen before: &lt;code&gt;UAZoomChangeFocus()&lt;/code&gt;.&lt;/p&gt;
&lt;p&gt;Chromium&amp;rsquo;s &lt;code&gt;render_widget_host_view_mac.mm&lt;/code&gt; does the same thing in its cursor tracking code.&lt;/p&gt;
&lt;p&gt;&lt;code&gt;UAZoomChangeFocus()&lt;/code&gt; is part of &lt;code&gt;HIServices/UniversalAccess.h&lt;/code&gt;, accessible via &lt;code&gt;Carbon/Carbon.h&lt;/code&gt;. It&amp;rsquo;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 &amp;ldquo;where is the cursor?&amp;rdquo; query. You just call it with the cursor&amp;rsquo;s screen coordinates and it moves the viewport.&lt;/p&gt;
&lt;p&gt;The signature:&lt;/p&gt;
&lt;div class="highlight"&gt;&lt;pre tabindex="0" class="chroma"&gt;&lt;code class="language-c" data-lang="c"&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="n"&gt;OSStatus&lt;/span&gt; &lt;span class="nf"&gt;UAZoomChangeFocus&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="k"&gt;const&lt;/span&gt; &lt;span class="n"&gt;CGRect&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt;&lt;span class="n"&gt;focusedItemBounds&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; &lt;span class="k"&gt;const&lt;/span&gt; &lt;span class="n"&gt;CGRect&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt;&lt;span class="n"&gt;caretBounds&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; &lt;span class="n"&gt;UAZoomFocusType&lt;/span&gt; &lt;span class="n"&gt;focusType&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;p&gt;The &lt;code&gt;focusType&lt;/code&gt; argument is &lt;code&gt;kUAZoomFocusTypeInsertionPoint&lt;/code&gt;, which tells Zoom this is a text cursor — triggering exactly the keyboard focus tracking behavior I needed.&lt;/p&gt;
&lt;p&gt;This was the real fix. Not a notification, not an accessibility protocol — a direct API call with explicit coordinates.&lt;/p&gt;
&lt;h2 id="the-fix-and-a-coordinate-problem"&gt;The Fix, and a Coordinate Problem&lt;/h2&gt;
&lt;p&gt;The implementation goes into &lt;code&gt;ns_draw_window_cursor()&lt;/code&gt; in &lt;code&gt;nsterm.m&lt;/code&gt;, inside the &lt;code&gt;#ifdef NS_IMPL_COCOA&lt;/code&gt; block. When Emacs draws the cursor, we know exactly where it is in view-local coordinates. From there it&amp;rsquo;s a coordinate conversion chain:&lt;/p&gt;
&lt;ol&gt;
&lt;li&gt;Convert cursor rect from view-local to window coordinates&lt;/li&gt;
&lt;li&gt;Convert window coordinates to screen coordinates (AppKit convention)&lt;/li&gt;
&lt;li&gt;Convert to &lt;code&gt;CGRect&lt;/code&gt;&lt;/li&gt;
&lt;li&gt;Call &lt;code&gt;UAZoomChangeFocus()&lt;/code&gt;&lt;/li&gt;
&lt;/ol&gt;
&lt;p&gt;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.&lt;/p&gt;
&lt;p&gt;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. &lt;code&gt;UAZoomChangeFocus()&lt;/code&gt; expects CoreGraphics screen coordinates.&lt;/p&gt;
&lt;p&gt;The natural way to do this conversion is &lt;code&gt;accessibilityConvertScreenRect:&lt;/code&gt;, which handles the y-flip for you. But — and here&amp;rsquo;s the catch — that method is declared on objects that conform to the &lt;code&gt;NSAccessibility&lt;/code&gt; protocol. Which, as we&amp;rsquo;ve established, &lt;code&gt;EmacsView&lt;/code&gt; does not.&lt;/p&gt;
&lt;p&gt;I tried calling it anyway. Compilation error.&lt;/p&gt;
&lt;p&gt;So: manual y-flip. The primary screen height is the key reference point:&lt;/p&gt;
&lt;div class="highlight"&gt;&lt;pre tabindex="0" class="chroma"&gt;&lt;code class="language-objc" data-lang="objc"&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="n"&gt;CGFloat&lt;/span&gt; &lt;span class="n"&gt;primaryH&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;[[[&lt;/span&gt;&lt;span class="n"&gt;NSScreen&lt;/span&gt; &lt;span class="n"&gt;screens&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt; &lt;span class="n"&gt;firstObject&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt; &lt;span class="n"&gt;frame&lt;/span&gt;&lt;span class="p"&gt;].&lt;/span&gt;&lt;span class="n"&gt;size&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;height&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="n"&gt;cgRect&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;origin&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;y&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;primaryH&lt;/span&gt; &lt;span class="o"&gt;-&lt;/span&gt; &lt;span class="n"&gt;cgRect&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;origin&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;y&lt;/span&gt; &lt;span class="o"&gt;-&lt;/span&gt; &lt;span class="n"&gt;cgRect&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;size&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;height&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;p&gt;This converts the AppKit screen y-coordinate to the CoreGraphics y-coordinate using simple arithmetic. No protocol conformance needed.&lt;/p&gt;
&lt;p&gt;The full implementation:&lt;/p&gt;
&lt;div class="highlight"&gt;&lt;pre tabindex="0" class="chroma"&gt;&lt;code class="language-objc" data-lang="objc"&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;UAZoomEnabled&lt;/span&gt;&lt;span class="p"&gt;())&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; &lt;span class="n"&gt;NSRect&lt;/span&gt; &lt;span class="n"&gt;windowRect&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="n"&gt;view&lt;/span&gt; &lt;span class="nl"&gt;convertRect&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="n"&gt;r&lt;/span&gt; &lt;span class="nl"&gt;toView&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="nb"&gt;nil&lt;/span&gt;&lt;span class="p"&gt;];&lt;/span&gt;
&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; &lt;span class="n"&gt;NSRect&lt;/span&gt; &lt;span class="n"&gt;screenRect&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;[[&lt;/span&gt;&lt;span class="n"&gt;view&lt;/span&gt; &lt;span class="n"&gt;window&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt; &lt;span class="nl"&gt;convertRectToScreen&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="n"&gt;windowRect&lt;/span&gt;&lt;span class="p"&gt;];&lt;/span&gt;
&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; &lt;span class="n"&gt;CGRect&lt;/span&gt; &lt;span class="n"&gt;cgRect&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;NSRectToCGRect&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;screenRect&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; &lt;span class="n"&gt;CGFloat&lt;/span&gt; &lt;span class="n"&gt;primaryH&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;[[[&lt;/span&gt;&lt;span class="n"&gt;NSScreen&lt;/span&gt; &lt;span class="n"&gt;screens&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt; &lt;span class="n"&gt;firstObject&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt; &lt;span class="n"&gt;frame&lt;/span&gt;&lt;span class="p"&gt;].&lt;/span&gt;&lt;span class="n"&gt;size&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;height&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; &lt;span class="n"&gt;cgRect&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;origin&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;y&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;primaryH&lt;/span&gt; &lt;span class="o"&gt;-&lt;/span&gt; &lt;span class="n"&gt;cgRect&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;origin&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;y&lt;/span&gt; &lt;span class="o"&gt;-&lt;/span&gt; &lt;span class="n"&gt;cgRect&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;size&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;height&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; &lt;span class="n"&gt;UAZoomChangeFocus&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="o"&gt;&amp;amp;&lt;/span&gt;&lt;span class="n"&gt;cgRect&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="o"&gt;&amp;amp;&lt;/span&gt;&lt;span class="n"&gt;cgRect&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;kUAZoomFocusTypeInsertionPoint&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;p&gt;The &lt;code&gt;UAZoomEnabled()&lt;/code&gt; check at the top is important — it avoids any overhead when Zoom isn&amp;rsquo;t active, so there&amp;rsquo;s no performance cost for the common case.&lt;/p&gt;
&lt;p&gt;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.&lt;/p&gt;
&lt;h2 id="it-works"&gt;It Works&lt;/h2&gt;
&lt;p&gt;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.&lt;/p&gt;
&lt;p&gt;I typed in Emacs for ten minutes just to make sure I wasn&amp;rsquo;t imagining it.&lt;/p&gt;
&lt;p&gt;The fix itself is small — about fifteen lines of Objective-C in &lt;code&gt;ns_draw_window_cursor()&lt;/code&gt;. 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 &lt;code&gt;UAZoomChangeFocus()&lt;/code&gt;, working through the coordinate system issue, hitting the compilation error on &lt;code&gt;accessibilityConvertScreenRect:&lt;/code&gt;, figuring out the manual y-flip. That&amp;rsquo;s the actual work. The patch is just the result of it.&lt;/p&gt;
&lt;p&gt;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&amp;rsquo;t work with &amp;ldquo;Follow keyboard focus&amp;rdquo; — and it&amp;rsquo;s finally resolved. Everything works beautifully now.&lt;/p&gt;
&lt;p&gt;If you&amp;rsquo;re building a custom &lt;code&gt;NSView&lt;/code&gt; on macOS and need Zoom compatibility, skip the accessibility notification approach and go straight to &lt;code&gt;UAZoomChangeFocus()&lt;/code&gt;. That&amp;rsquo;s the right tool for the job.&lt;/p&gt;</description></item></channel></rss>