From cb2c19955d681dd5fa6e865a8ddb553efa5a9ead Mon Sep 17 00:00:00 2001 From: Martin Sukany Date: Tue, 25 Feb 2026 16:30:00 +0100 Subject: [PATCH] ns: implement macOS Zoom cursor tracking via NSAccessibility + UAZoomChangeFocus Add cursor tracking support for macOS Zoom "Follow keyboard focus" and other assistive technology tools (VoiceOver, etc.). PROBLEM: Emacs is a custom-drawn view — AppKit has no knowledge of where the text cursor is. macOS Zoom's "Follow keyboard focus" requires either: (a) a proper NSAccessibility tree where the focused element reports its frame via accessibilityFrame/accessibilityBoundsForRange:, or (b) an explicit UAZoomChangeFocus() call from HIServices/UniversalAccess.h. Approach (a) is the standard mechanism used by Terminal.app, iTerm2, and Firefox. Approach (b) is a direct push that bypasses the accessibility tree entirely but only works from proper .app bundles (the window server identifies callers by bundle ID; bare binaries like src/emacs are silently ignored even when AXIsProcessTrusted() returns true). This patch implements BOTH approaches: - NSAccessibility: EmacsView reports as a TextArea, exposes accessibilityFrame (cursor screen rect), accessibilityBoundsForRange:, and posts SelectedTextChanged/FocusedUIElementChanged notifications. This works regardless of how Emacs is launched (bundle, src/emacs, etc). - UAZoomChangeFocus: called as an optimization when running from a proper .app bundle (detected via [[NSBundle mainBundle] bundleIdentifier]). The NSAccessibility approach is primary; UAZoomChangeFocus is supplementary. Changes to src/nsterm.h: - Add lastAccessibilityCursorRect ivar to EmacsView Changes to src/nsterm.m: - ns_draw_window_cursor: store cursor rect, post SelectedTextChanged, call UAZoomChangeFocus() with cursor position in screen coordinates (only when running from an app bundle with a valid bundle identifier) - windowDidBecomeKey: post FocusedUIElementChangedNotification - EmacsView: full NSAccessibility implementation for cursor tracking See also: https://github.com/nicowillis/Ghostty/issues/4053 --- src/nsterm.h | 3 + src/nsterm.m | 196 +++++++++++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 199 insertions(+) diff --git a/src/nsterm.h b/src/nsterm.h index 7c1ee4cf535..6c1ff3434a3 100644 --- a/src/nsterm.h +++ b/src/nsterm.h @@ -485,6 +485,9 @@ enum ns_return_frame_mode struct frame *emacsframe; int scrollbarsNeedingUpdate; NSRect ns_userRect; +#ifdef NS_IMPL_COCOA + NSRect lastAccessibilityCursorRect; +#endif } /* AppKit-side interface. */ diff --git a/src/nsterm.m b/src/nsterm.m index 932d209f56b..a377d70c6fb 100644 --- a/src/nsterm.m +++ b/src/nsterm.m @@ -3232,6 +3232,67 @@ Note that CURSOR_WIDTH is meaningful only for (h)bar cursors. /* Prevent the cursor from being drawn outside the text area. */ r = NSIntersectionRect (r, ns_row_rect (w, glyph_row, TEXT_AREA)); +#ifdef NS_IMPL_COCOA + /* Accessibility cursor tracking for macOS Zoom and VoiceOver. + + Emacs is a custom-drawn view: AppKit does not know where the text + cursor is, so we must explicitly notify assistive technology. + + Two mechanisms are used: + + 1. NSAccessibility notifications (PRIMARY, always active): + Post NSAccessibilitySelectedTextChangedNotification so that + macOS Zoom (and VoiceOver) queries the focused element's + accessibilityFrame / accessibilityBoundsForRange: to find + the cursor position. This is how Terminal.app, iTerm2, + and Firefox implement Zoom cursor tracking. Works regardless + of how Emacs is launched (app bundle, src/emacs, etc.). + + 2. UAZoomChangeFocus (SUPPLEMENTARY, bundle-only): + Directly tells Zoom the cursor's screen coordinates via + HIServices/UniversalAccess.h. This only works when Emacs + runs from a proper .app bundle — the window server identifies + callers by CFBundleIdentifier, and bare binaries (src/emacs) + are silently ignored even when AXIsProcessTrusted() returns + true. We detect this by checking [[NSBundle mainBundle] + bundleIdentifier] and skip the call when nil. */ + { + EmacsView *view = FRAME_NS_VIEW (f); + if (view) + { + /* Store cursor rect for accessibilityFrame and + accessibilityBoundsForRange: queries. */ + view->lastAccessibilityCursorRect = r; + + /* Mechanism 1: Standard NSAccessibility notification. + Zoom monitors this and reads accessibilityFrame from the + focused element (EmacsView) to determine cursor position. */ + NSAccessibilityPostNotification (view, + NSAccessibilitySelectedTextChangedNotification); + + /* Mechanism 2: UAZoomChangeFocus — direct push to Zoom. + Only effective from app bundles (window server ignores bare + binaries). UAZoomChangeFocus expects coordinates with origin + at the top-left of the primary screen. convertRectToScreen: + returns Quartz coordinates (origin bottom-left), so we flip + the y axis manually. */ + if (UAZoomEnabled () + && [[NSBundle mainBundle] bundleIdentifier] != nil) + { + NSRect windowRect = [view convertRect:r toView:nil]; + NSRect screenRect = [[view window] convertRectToScreen:windowRect]; + CGRect cgRect = NSRectToCGRect (screenRect); + + /* Flip y: Quartz (bottom-left origin) -> accessibility + coordinate space (top-left origin). Uses primary screen + height as reference — correct for all screens in the + global Quartz coordinate space. */ + CGFloat primaryH + = [[[NSScreen screens] firstObject] frame].size.height; + cgRect.origin.y + = primaryH - cgRect.origin.y - cgRect.size.height; + + UAZoomChangeFocus (&cgRect, &cgRect, + kUAZoomFocusTypeInsertionPoint); + } + } + } +#endif + ns_focus (f, NULL, 0); NSGraphicsContext *ctx = [NSGraphicsContext currentContext]; @@ -8237,6 +8298,17 @@ - (void)windowDidBecomeKey /* for direct calls */ XSETFRAME (event.frame_or_window, emacsframe); kbd_buffer_store_event (&event); ns_send_appdefined (-1); // Kick main loop + +#ifdef NS_IMPL_COCOA + /* Notify assistive technology that the focused UI element changed + to this Emacs view. macOS Zoom uses this notification to: + (a) activate keyboard focus tracking for the newly focused window, + (b) query accessibilityFrame on the focused element to position + the Zoom viewport. + This works for all launch methods (bundle and non-bundle). */ + NSAccessibilityPostNotification (self, + NSAccessibilityFocusedUIElementChangedNotification); +#endif } @@ -9474,6 +9546,130 @@ - (int) fullscreenState return fs_state; } +#ifdef NS_IMPL_COCOA +/* ---------------------------------------------------------------- + Accessibility support for macOS Zoom and other assistive tools. + + This implements the NSAccessibility protocol on EmacsView so that + macOS Zoom's "Follow keyboard focus" can track the text cursor. + + How it works: + - EmacsView declares itself as a TextArea role (the standard role + for editable text regions). + - When the cursor moves, ns_draw_window_cursor posts + NSAccessibilitySelectedTextChangedNotification. + - Zoom (and VoiceOver) respond by querying accessibilityFrame and/or + accessibilityBoundsForRange: on the focused element (this view). + - We return the stored cursor rectangle in screen coordinates. + + Both the modern protocol API (accessibilityBoundsForRange:, 10.10+) + and the legacy parameterized attribute API (AXBoundsForRange) are + implemented for compatibility with all macOS versions and AT tools. + + Note: upstream EmacsWindow has separate accessibility code that + returns buffer text for VoiceOver. That code operates on the window, + not the view, so there is no conflict. + ---------------------------------------------------------------- */ + +- (BOOL)accessibilityIsIgnored +{ + /* EmacsView must participate in the accessibility hierarchy. */ + return NO; +} + +- (BOOL)isAccessibilityElement +{ + return YES; +} + +- (id)accessibilityFocusedUIElement +{ + /* This view is the focused element — it contains the text cursor. */ + return self; +} + +- (NSString *)accessibilityRole +{ + return NSAccessibilityTextAreaRole; +} + +- (NSRect)accessibilityFrame +{ + /* Return the cursor's screen coordinates. This is the KEY method + that macOS Zoom reads after receiving a focus/selection notification. + Terminal.app and iTerm2 use this same pattern. + + lastAccessibilityCursorRect is in EmacsView coordinates (flipped: + origin top-left). convertRect:toView:nil automatically handles + the flipped-to-unflipped conversion because isFlipped returns YES. */ + NSRect viewRect = lastAccessibilityCursorRect; + if (NSIsEmptyRect (viewRect)) + return [super accessibilityFrame]; + + NSWindow *win = [self window]; + if (win == nil) + return [super accessibilityFrame]; + + NSRect windowRect = [self convertRect:viewRect toView:nil]; + return [win convertRectToScreen:windowRect]; +} + +- (NSArray *)accessibilityAttributeNames +{ + NSArray *superAttrs = [super accessibilityAttributeNames]; + if (superAttrs == nil) + superAttrs = @[]; + return [superAttrs arrayByAddingObjectsFromArray: + @[NSAccessibilityRoleAttribute, + NSAccessibilitySelectedTextRangeAttribute, + NSAccessibilityNumberOfCharactersAttribute]]; +} + +- (id)accessibilityAttributeValue:(NSString *)attribute +{ + if ([attribute isEqualToString:NSAccessibilityRoleAttribute]) + return NSAccessibilityTextAreaRole; + + /* Zoom queries SelectedTextRange before calling BoundsForRange. + We return {0,0} (collapsed caret); our bounds methods ignore + the range parameter and always return the actual cursor rect. */ + if ([attribute isEqualToString:NSAccessibilitySelectedTextRangeAttribute]) + return [NSValue valueWithRange:NSMakeRange (0, 0)]; + + if ([attribute isEqualToString:NSAccessibilityNumberOfCharactersAttribute]) + return @(0); + + return [super accessibilityAttributeValue:attribute]; +} + +/* Modern NSAccessibilityProtocol (macOS 10.10+). */ +- (NSRect)accessibilityBoundsForRange:(NSRange)range +{ + /* Return cursor screen rect regardless of requested range. + Emacs does not expose a character-level text model to AppKit, + so we always return the cursor position. */ + NSRect viewRect = lastAccessibilityCursorRect; + + if (viewRect.size.width < 1) + viewRect.size.width = 1; + if (viewRect.size.height < 1) + viewRect.size.height = 8; + + NSWindow *win = [self window]; + if (win == nil) + return NSZeroRect; + + NSRect windowRect = [self convertRect:viewRect toView:nil]; + return [win convertRectToScreen:windowRect]; +} + +/* Legacy parameterized attribute API — fallback for older AT tools. */ +- (NSArray *)accessibilityParameterizedAttributeNames +{ + NSArray *superAttrs = [super accessibilityParameterizedAttributeNames]; + if (superAttrs == nil) + superAttrs = @[]; + return [superAttrs arrayByAddingObjectsFromArray: + @[NSAccessibilityBoundsForRangeParameterizedAttribute]]; +} + +- (id)accessibilityAttributeValue:(NSString *)attribute + forParameter:(id)parameter +{ + if ([attribute isEqualToString: + NSAccessibilityBoundsForRangeParameterizedAttribute]) + return [NSValue valueWithRect: + [self accessibilityBoundsForRange:NSMakeRange (0, 0)]]; + + return [super accessibilityAttributeValue:attribute forParameter:parameter]; +} +#endif /* NS_IMPL_COCOA */ + @end /* EmacsView */ -- 2.43.0