From: Martin Sukany Date: Tue, 25 Feb 2026 19:20:00 +0100 Subject: [PATCH] ns: implement macOS Zoom cursor tracking and VoiceOver support via UAZoomChangeFocus + NSAccessibility Add cursor tracking and screen reader support for macOS accessibility: 1. UAZoomChangeFocus() from ApplicationServices/UniversalAccess.h: Directly tells macOS Zoom where the cursor is. Ref: https://developer.apple.com/documentation/applicationservices/universalaccess_h Ref: https://developer.apple.com/documentation/applicationservices/1458830-uazoomchangefocus 2. NSAccessibility protocol on EmacsView (macOS 10.10+): Full text area protocol for VoiceOver: accessibilityValue (buffer text), accessibilitySelectedText, accessibilitySelectedTextRange, accessibilityInsertionPointLineNumber, accessibilityVisibleCharacterRange, accessibilityStringForRange:, accessibilityBoundsForRange:. Notifications: ValueChanged (typing echo), SelectedTextChanged (cursor movement), FocusedUIElementChanged (window focus). Ref: https://developer.apple.com/documentation/appkit/nsaccessibilityprotocol accessibilityFrame returns the view's frame (standard behavior) so VoiceOver draws its focus ring around the text area. Cursor position is exposed via accessibilityBoundsForRange: only. Both mechanisms are needed: UAZoomChangeFocus serves Zoom; NSAccessibility serves VoiceOver. Same dual pattern used by iTerm2. --- diff --git a/src/nsterm.h b/src/nsterm.h index 7c1ee4c..6c1ff34 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 932d209..2576c96 100644 --- a/src/nsterm.m +++ b/src/nsterm.m @@ -3232,6 +3232,72 @@ 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: + - ValueChangedNotification: tells VoiceOver the buffer content + changed (triggers typing echo and re-read of accessibilityValue). + - SelectedTextChangedNotification: tells AT tools the cursor moved + (triggers re-query of accessibilitySelectedTextRange and + accessibilityBoundsForRange:). + + 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. + Same approach used by iTerm2 (PTYTextView.m:refreshAccessibility). + + Ref: https://developer.apple.com/documentation/applicationservices/universalaccess_h + Ref: https://developer.apple.com/documentation/applicationservices/1458830-uazoomchangefocus + + Both mechanisms are needed: NSAccessibility serves VoiceOver and + screen readers (which query the accessibility tree); UAZoomChangeFocus + serves macOS Zoom's "Follow keyboard focus" feature. */ + { + EmacsView *view = FRAME_NS_VIEW (f); + if (view) + { + /* Store cursor rect for accessibilityBoundsForRange: queries. */ + view->lastAccessibilityCursorRect = r; + + /* Notify VoiceOver that buffer content may have changed. + This triggers typing echo (character-by-character feedback) + and re-read of accessibilityValue. iTerm2 posts this same + notification in its refreshAccessibility method. */ + NSAccessibilityPostNotification (view, + NSAccessibilityValueChangedNotification); + + /* Notify AT tools that the cursor (selection) moved. */ + 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. */ + 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 +8303,15 @@ - (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 } @@ -9474,6 +9549,310 @@ - (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 read buffer contents, track cursor, echo typing + - Accessibility Inspector shows correct element hierarchy + + The primary Zoom tracking mechanism is UAZoomChangeFocus() in + ns_draw_window_cursor above. These methods serve VoiceOver and + other AT tools that query the accessibility tree directly. + + IMPORTANT: accessibilityFrame returns the VIEW's frame (standard + behavior). VoiceOver uses this to draw its focus ring around the + entire text area. The CURSOR position is exposed via + accessibilityBoundsForRange: which AT tools call with the + selectedTextRange to locate the insertion point. Returning the + cursor rect from accessibilityFrame causes VoiceOver to draw a + duplicate cursor overlay. + + Ref: https://developer.apple.com/documentation/appkit/nsaccessibilityprotocol + ---------------------------------------------------------------- */ + +- (BOOL)accessibilityIsIgnored +{ + return NO; +} + +- (BOOL)isAccessibilityElement +{ + return YES; +} + +- (id)accessibilityFocusedUIElement +{ + return self; +} + +- (NSString *)accessibilityRole +{ + return NSAccessibilityTextAreaRole; +} + +- (NSString *)accessibilityRoleDescription +{ + return NSAccessibilityRoleDescription (NSAccessibilityTextAreaRole, nil); +} + +/* ---- Text content methods for VoiceOver ---- */ + +- (id)accessibilityValue +{ + /* Return visible buffer text. VoiceOver reads this when the user + navigates to the text area and after ValueChangedNotification. */ + if (!emacsframe) + return @""; + + struct buffer *curbuf + = XBUFFER (XWINDOW (emacsframe->selected_window)->contents); + if (!curbuf) + return @""; + + ptrdiff_t start_byte = BUF_BEGV_BYTE (curbuf); + ptrdiff_t byte_range = BUF_ZV_BYTE (curbuf) - start_byte; + ptrdiff_t range = BUF_ZV (curbuf) - BUF_BEGV (curbuf); + + if (range > 10000) + { + range = 10000; + byte_range = 10000; + } + + Lisp_Object str; + if (! NILP (BVAR (curbuf, enable_multibyte_characters))) + str = make_uninit_multibyte_string (range, byte_range); + else + str = make_uninit_string (range); + memcpy (SDATA (str), BYTE_POS_ADDR (start_byte), byte_range); + + return [NSString stringWithLispString:str]; +} + +- (NSInteger)accessibilityNumberOfCharacters +{ + if (!emacsframe) + return 0; + + struct buffer *curbuf + = XBUFFER (XWINDOW (emacsframe->selected_window)->contents); + if (!curbuf) + return 0; + + ptrdiff_t range = BUF_ZV (curbuf) - BUF_BEGV (curbuf); + return (NSInteger) MIN (range, 10000); +} + +- (NSString *)accessibilitySelectedText +{ + if (!emacsframe) + return @""; + + struct buffer *curbuf + = XBUFFER (XWINDOW (emacsframe->selected_window)->contents); + if (!curbuf || NILP (BVAR (curbuf, mark_active))) + return @""; + + Lisp_Object str = ns_get_local_selection (QPRIMARY, QUTF8_STRING); + if (CONSP (str) && SYMBOLP (XCAR (str))) + { + str = XCDR (str); + if (CONSP (str) && NILP (XCDR (str))) + str = XCAR (str); + } + if (STRINGP (str)) + return [NSString stringWithLispString:str]; + + return @""; +} + +- (NSRange)accessibilitySelectedTextRange +{ + if (!emacsframe) + return NSMakeRange (0, 0); + + struct buffer *curbuf + = XBUFFER (XWINDOW (emacsframe->selected_window)->contents); + if (!curbuf) + return NSMakeRange (0, 0); + + ptrdiff_t pt = BUF_PT (curbuf) - BUF_BEGV (curbuf); + return NSMakeRange ((NSUInteger) pt, 0); +} + +- (NSInteger)accessibilityInsertionPointLineNumber +{ + if (!emacsframe) + return 0; + + struct window *w = XWINDOW (emacsframe->selected_window); + if (!w) + return 0; + + return (NSInteger) (w->cursor.vpos); +} + +- (NSRange)accessibilityVisibleCharacterRange +{ + if (!emacsframe) + return NSMakeRange (0, 0); + + struct buffer *curbuf + = XBUFFER (XWINDOW (emacsframe->selected_window)->contents); + if (!curbuf) + return NSMakeRange (0, 0); + + ptrdiff_t range = BUF_ZV (curbuf) - BUF_BEGV (curbuf); + return NSMakeRange (0, (NSUInteger) MIN (range, 10000)); +} + +- (NSString *)accessibilityStringForRange:(NSRange)nsrange +{ + if (!emacsframe) + return @""; + + struct buffer *curbuf + = XBUFFER (XWINDOW (emacsframe->selected_window)->contents); + if (!curbuf) + return @""; + + ptrdiff_t start = BUF_BEGV (curbuf) + (ptrdiff_t) nsrange.location; + ptrdiff_t end = start + (ptrdiff_t) nsrange.length; + ptrdiff_t buf_end = BUF_ZV (curbuf); + + if (start < BUF_BEGV (curbuf)) start = BUF_BEGV (curbuf); + if (end > buf_end) end = buf_end; + if (start >= end) return @""; + + ptrdiff_t start_byte = buf_charpos_to_bytepos (curbuf, start); + ptrdiff_t end_byte = buf_charpos_to_bytepos (curbuf, end); + ptrdiff_t range = end - start; + ptrdiff_t byte_range = end_byte - start_byte; + + Lisp_Object str; + if (! NILP (BVAR (curbuf, enable_multibyte_characters))) + str = make_uninit_multibyte_string (range, byte_range); + else + str = make_uninit_string (range); + memcpy (SDATA (str), BUF_BYTE_ADDRESS (curbuf, start_byte), byte_range); + + return [NSString stringWithLispString:str]; +} + +/* ---- Cursor position for Zoom (via accessibilityBoundsForRange:) ---- + + accessibilityFrame intentionally returns the VIEW's frame (standard + behavior) so VoiceOver draws its focus ring around the entire text + area rather than at the cursor position. The cursor location is + exposed through accessibilityBoundsForRange: which AT tools query + using the selectedTextRange. */ + +- (NSRect)accessibilityFrame +{ + /* Return the view's screen frame (standard NSView behavior). + VoiceOver uses this for its focus ring. Do NOT return the cursor + rect here -- that causes a duplicate cursor overlay. */ + return [super accessibilityFrame]; +} + +- (NSArray *)accessibilityAttributeNames +{ + NSArray *superAttrs = [super accessibilityAttributeNames]; + if (superAttrs == nil) + superAttrs = @[]; + return [superAttrs arrayByAddingObjectsFromArray: + @[NSAccessibilityRoleAttribute, + NSAccessibilityValueAttribute, + NSAccessibilitySelectedTextAttribute, + NSAccessibilitySelectedTextRangeAttribute, + NSAccessibilityNumberOfCharactersAttribute, + NSAccessibilityVisibleCharacterRangeAttribute, + NSAccessibilityInsertionPointLineNumberAttribute]]; +} + +- (id)accessibilityAttributeValue:(NSString *)attribute +{ + if ([attribute isEqualToString:NSAccessibilityRoleAttribute]) + return NSAccessibilityTextAreaRole; + + if ([attribute isEqualToString:NSAccessibilityValueAttribute]) + return [self accessibilityValue]; + + if ([attribute isEqualToString:NSAccessibilitySelectedTextAttribute]) + return [self accessibilitySelectedText]; + + if ([attribute isEqualToString:NSAccessibilitySelectedTextRangeAttribute]) + return [NSValue valueWithRange:[self accessibilitySelectedTextRange]]; + + if ([attribute isEqualToString:NSAccessibilityNumberOfCharactersAttribute]) + return @([self accessibilityNumberOfCharacters]); + + if ([attribute isEqualToString:NSAccessibilityVisibleCharacterRangeAttribute]) + return [NSValue valueWithRange:[self accessibilityVisibleCharacterRange]]; + + if ([attribute isEqualToString:NSAccessibilityInsertionPointLineNumberAttribute]) + return @([self accessibilityInsertionPointLineNumber]); + + return [super accessibilityAttributeValue:attribute]; +} + +/* Modern NSAccessibilityProtocol (macOS 10.10+). */ +- (NSRect)accessibilityBoundsForRange:(NSRange)range +{ + /* Return cursor screen rect. AT tools call this with the + selectedTextRange to locate the insertion point. We return the + cursor position regardless of the requested range because Emacs + does not expose character-level geometry to AppKit. */ + 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. */ +- (NSArray *)accessibilityParameterizedAttributeNames +{ + NSArray *superAttrs = [super accessibilityParameterizedAttributeNames]; + if (superAttrs == nil) + superAttrs = @[]; + return [superAttrs arrayByAddingObjectsFromArray: + @[NSAccessibilityBoundsForRangeParameterizedAttribute, + NSAccessibilityStringForRangeParameterizedAttribute]]; +} + +- (id)accessibilityAttributeValue:(NSString *)attribute + forParameter:(id)parameter +{ + if ([attribute isEqualToString: + NSAccessibilityBoundsForRangeParameterizedAttribute]) + return [NSValue valueWithRect: + [self accessibilityBoundsForRange:NSMakeRange (0, 0)]]; + + if ([attribute isEqualToString: + NSAccessibilityStringForRangeParameterizedAttribute]) + { + NSRange range = [(NSValue *) parameter rangeValue]; + return [self accessibilityStringForRange:range]; + } + + return [super accessibilityAttributeValue:attribute forParameter:parameter]; +} +#endif /* NS_IMPL_COCOA */ + + @end /* EmacsView */