From 8d439114e6faf50c053eac290ae0e60176dfb3bf Mon Sep 17 00:00:00 2001 From: Martin Sukany Date: Wed, 25 Feb 2026 18:10:58 +0100 Subject: [PATCH] ns: implement macOS Zoom cursor tracking + NSAccessibility support Add cursor tracking for macOS Zoom 'Follow keyboard focus' and full NSAccessibility support for VoiceOver and other AT tools. Two complementary mechanisms are used: 1. UAZoomChangeFocus() from ApplicationServices/UniversalAccess.h: Directly tells macOS Zoom where to position its viewport. This is Apple's documented API for applications to control Zoom focus. Same approach used by iTerm2 (PTYTextView.m:refreshAccessibility). Ref: developer.apple.com/documentation/applicationservices/universalaccess_h Ref: developer.apple.com/documentation/applicationservices/1458830-uazoomchangefocus 2. NSAccessibility protocol on EmacsView: reports as TextArea role, exposes accessibilityFrame / accessibilityBoundsForRange: returning cursor screen coordinates, and posts SelectedTextChanged and FocusedUIElementChanged notifications. Serves VoiceOver and AT tools that query the accessibility tree directly. Ref: developer.apple.com/documentation/appkit/nsaccessibilityprotocol Both mechanisms are needed: UAZoomChangeFocus serves Zoom's Follow keyboard focus (which does not reliably track custom views through NSAccessibility alone); NSAccessibility serves VoiceOver and other screen readers. --- src/nsterm.h | 6 ++ src/nsterm.m | 286 +++++++++++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 292 insertions(+) diff --git a/src/nsterm.h b/src/nsterm.h index 7c1ee4c..664bce3 100644 --- a/src/nsterm.h +++ b/src/nsterm.h @@ -485,6 +485,12 @@ enum ns_return_frame_mode struct frame *emacsframe; int scrollbarsNeedingUpdate; NSRect ns_userRect; +#ifdef NS_IMPL_COCOA + NSRect lastAccessibilityCursorRect; +#endif +#ifdef NS_IMPL_COCOA + NSRect lastAccessibilityCursorRect; +#endif } /* AppKit-side interface. */ diff --git a/src/nsterm.m b/src/nsterm.m index 932d209..96b9d65 100644 --- a/src/nsterm.m +++ b/src/nsterm.m @@ -3232,6 +3232,131 @@ 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 uses a custom-drawn cursor in a custom NSView. AppKit has no + knowledge of cursor position, so we must explicitly notify assistive + technology. Two complementary mechanisms are used: + + 1. NSAccessibility notifications: + Post NSAccessibilitySelectedTextChangedNotification so that + VoiceOver and other AT tools can query cursor position via + accessibilityBoundsForRange: / accessibilityFrame. + + 2. UAZoomChangeFocus() from ApplicationServices/UniversalAccess.h: + Directly tells macOS Zoom where to move its viewport. This is + the documented API for applications to control Zoom focus: + + "This header file contains functions that give applications the + ability to control the zoom focus. Using these functions, an + application can tell the macOS Universal Access zoom feature + what part of its user interface needs focus." + + Ref: https://developer.apple.com/documentation/applicationservices/universalaccess_h + Ref: https://developer.apple.com/documentation/applicationservices/1458830-uazoomchangefocus + + This is the same approach used by iTerm2 (PTYTextView.m, + method refreshAccessibility). + + Both mechanisms are needed: NSAccessibility serves VoiceOver and + screen readers; UAZoomChangeFocus serves macOS Zoom's "Follow + keyboard focus" feature, which does not reliably track custom views + through NSAccessibility notifications alone. */ + { + EmacsView *view = FRAME_NS_VIEW (f); + if (view) + { + /* Store cursor rect for accessibilityFrame and + accessibilityBoundsForRange: queries. */ + view->lastAccessibilityCursorRect = r; + + /* Post NSAccessibility notification for VoiceOver and other + AT tools. */ + NSAccessibilityPostNotification (view, + NSAccessibilitySelectedTextChangedNotification); + + /* Tell macOS Zoom where the cursor is. UAZoomChangeFocus() + expects coordinates with origin at the top-left of the + primary screen (CG accessibility coordinate space). + convertRectToScreen: returns Quartz coordinates (origin at + bottom-left), so we flip the y axis. This coordinate + conversion follows the same pattern used by iTerm2's + accessibilityConvertScreenRect: method. */ + 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); + } + } + } +#endif + + +#ifdef NS_IMPL_COCOA + /* Accessibility: update cursor tracking for macOS Zoom and VoiceOver. + + Emacs is a custom-drawn view. AppKit has no knowledge of where the + text cursor is, so we must explicitly notify assistive technology. + + Two complementary mechanisms are used, matching the pattern used by + iTerm2 (PTYTextView.m:refreshAccessibility): + + 1. NSAccessibility notifications -- inform AT clients (VoiceOver, + Zoom, Switch Control) that the selection/cursor changed. + See: Apple NSAccessibility Protocol Reference + https://developer.apple.com/documentation/appkit/nsaccessibilityprotocol + + 2. UAZoomChangeFocus() -- explicitly tell macOS Zoom where to move + its viewport. This is the Apple-documented mechanism for custom + views to control the Zoom focus. NSAccessibility notifications + alone are NOT sufficient for Zoom cursor tracking in custom views. + See: Apple UniversalAccess.h Reference + https://developer.apple.com/documentation/applicationservices/universalaccess_h + https://developer.apple.com/documentation/applicationservices/1458830-uazoomchangefocus */ + { + EmacsView *view = FRAME_NS_VIEW (f); + if (view) + { + view->lastAccessibilityCursorRect = r; + + /* Post NSAccessibility notifications for VoiceOver and other AT. */ + NSAccessibilityPostNotification (view, + NSAccessibilitySelectedTextChangedNotification); + + /* Tell macOS Zoom where the cursor is. UAZoomChangeFocus expects + screen coordinates with origin at top-left of the primary screen + (accessibility coordinate space). We convert from Quartz screen + coordinates (origin bottom-left) by flipping the y axis, matching + the pattern used by iTerm2's accessibilityConvertScreenRect. */ + 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); + } + } + } +#endif + + ns_focus (f, NULL, 0); NSGraphicsContext *ctx = [NSGraphicsContext currentContext]; @@ -8237,6 +8362,25 @@ - (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 to activate keyboard + focus tracking when the window gains focus; VoiceOver uses it + to announce the newly focused element. */ + NSAccessibilityPostNotification (self, + NSAccessibilityFocusedUIElementChangedNotification); +#endif + +#ifdef NS_IMPL_COCOA + /* Notify AT that the focused UI element changed to this Emacs view. + macOS Zoom uses this to activate keyboard focus tracking when the + window gains focus. + See: NSAccessibilityFocusedUIElementChangedNotification + https://developer.apple.com/documentation/appkit/nsaccessibilityfocuseduielementchangednotification */ + NSAccessibilityPostNotification (self, + NSAccessibilityFocusedUIElementChangedNotification); +#endif } @@ -9474,6 +9618,148 @@ - (int) fullscreenState return fs_state; } + +#ifdef NS_IMPL_COCOA +/* ---------------------------------------------------------------- + Accessibility support for macOS Zoom, VoiceOver, and other AT tools. + + EmacsView implements the NSAccessibility protocol so that: + - macOS Zoom can query cursor position (accessibilityBoundsForRange:) + - VoiceOver can identify the view as a text area + - Accessibility Inspector shows correct element hierarchy + + The primary Zoom tracking mechanism is UAZoomChangeFocus() in + ns_draw_window_cursor above. These methods provide supplementary + support for AT tools that query the accessibility tree directly. + + 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. + + Ref: https://developer.apple.com/documentation/appkit/nsaccessibilityprotocol + Ref: https://developer.apple.com/documentation/appkit/nsaccessibilityboundsforrangeparameterizedattribute + + Note: upstream EmacsWindow has separate accessibility code that + returns buffer text content. 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 as the view's accessibility + frame. This allows AT tools that query accessibilityFrame (rather + than accessibilityBoundsForRange:) to locate the cursor. + + lastAccessibilityCursorRect is in EmacsView coordinates (flipped: + origin top-left). convertRect:toView:nil handles the + flipped-to-unflipped conversion automatically (isFlipped=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+). + Ref: https://developer.apple.com/documentation/appkit/nsaccessibilityprotocol */ +- (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. + Ref: https://developer.apple.com/documentation/appkit/nsaccessibilityboundsforrangeparameterizedattribute */ +- (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