From: Martin Sukany Date: Tue, 25 Feb 2026 21:00:00 +0100 Subject: [PATCH] ns: implement macOS Zoom cursor tracking and VoiceOver support Add comprehensive accessibility support for macOS: 1. UAZoomChangeFocus() from ApplicationServices/UniversalAccess.h: Directly tells macOS Zoom where the cursor is. Only fires for the active window cursor draw (on_p && active_p guard). Ref: https://developer.apple.com/documentation/applicationservices/1458830-uazoomchangefocus 2. NSAccessibility protocol on EmacsView (macOS 10.10+): Full text area protocol for VoiceOver: - Rich typing echo via NSAccessibilityPostNotificationWithUserInfo with kAXTextEditTypeTyping (requires macOS 10.11+, falls back to bare notification on 10.10) - BUF_MODIFF tracking to distinguish content edits from cursor movement - Text content: accessibilityValue, accessibilitySelectedText, accessibilityNumberOfCharacters, accessibilityStringForRange: - Text navigation: accessibilityLineForIndex:, accessibilityRangeForLine:, accessibilityInsertionPointLineNumber, accessibilityVisibleCharacterRange - Cursor geometry: accessibilityBoundsForRange:, accessibilityFrameForRange: - Notifications: ValueChanged (edit), SelectedTextChanged (cursor), FocusedUIElementChanged (window focus) Ref: https://developer.apple.com/documentation/appkit/nsaccessibilityprotocol Uses raw string literals ("AXTextStateChangeType" etc.) for semi-private notification keys to avoid type conflicts between SDK versions (these symbols were added to public AppKit headers in macOS 26). --- diff --git a/src/nsterm.h b/src/nsterm.h index 7c1ee4c..5e5f61b 100644 --- a/src/nsterm.h +++ b/src/nsterm.h @@ -485,6 +485,10 @@ enum ns_return_frame_mode struct frame *emacsframe; int scrollbarsNeedingUpdate; NSRect ns_userRect; +#ifdef NS_IMPL_COCOA + NSRect lastAccessibilityCursorRect; + ptrdiff_t lastAccessibilityModiff; +#endif } /* AppKit-side interface. */ diff --git a/src/nsterm.m b/src/nsterm.m index 932d209..6bfc080 100644 --- a/src/nsterm.m +++ b/src/nsterm.m @@ -3232,6 +3232,115 @@ 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 + /* NSAccessibility keys for rich text change notifications. + Used by WebKit/Safari for VoiceOver typing echo. These symbols + have been exported by AppKit since macOS 10.11 but were only + added to the public headers in the macOS 26 SDK. We use raw + string literals here to avoid redeclaration type conflicts + across different SDK versions. */ + + /* 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: + + 1. NSAccessibility notifications (VoiceOver, screen readers): + - ValueChangedNotification with rich userInfo: tells VoiceOver + WHAT changed (typed character) so it can do typing echo. + We track BUF_MODIFF to distinguish content edits from cursor + movement. Only content changes get ValueChanged. + - SelectedTextChangedNotification: tells AT the cursor moved. + + 2. UAZoomChangeFocus() from UniversalAccess.h: + Directly tells macOS Zoom where to move its viewport. + Ref: https://developer.apple.com/documentation/applicationservices/1458830-uazoomchangefocus */ + { + EmacsView *view = FRAME_NS_VIEW (f); + /* Only notify AT when drawing the cursor in the active (selected) + window. Without this guard, C-x o triggers UAZoomChangeFocus + for the old window last, snapping Zoom back. */ + if (view && on_p && active_p) + { + /* Store cursor rect for accessibilityBoundsForRange: queries. */ + view->lastAccessibilityCursorRect = r; + + struct buffer *curbuf + = XBUFFER (XWINDOW (f->selected_window)->contents); + + if (curbuf && BUF_MODIFF (curbuf) != view->lastAccessibilityModiff) + { + /* Buffer content changed since last cursor draw — this is + an edit (typing, yank, undo, etc.). Post ValueChanged + with rich userInfo so VoiceOver can do typing echo. + + The semi-private keys (NSAccessibilityTextStateChangeTypeKey + etc.) are NSString* extern constants available in AppKit + since macOS 10.11. WebKit uses the same approach. + Ref: WebKit AXObjectCacheMac.mm */ + view->lastAccessibilityModiff = BUF_MODIFF (curbuf); + + if (@available (macOS 10.11, *)) + { + /* Get the just-typed character: char at cursor - 1. */ + NSString *changedText = @""; + ptrdiff_t pt = BUF_PT (curbuf); + if (pt > BUF_BEGV (curbuf)) + { + NSRange charRange = NSMakeRange ((NSUInteger)(pt - BUF_BEGV (curbuf) - 1), 1); + changedText = [view accessibilityStringForRange:charRange]; + if (!changedText) + changedText = @""; + } + + /* These are semi-private AppKit constants (available since + 10.11). The enum values match Apple's AX API headers: + kAXTextStateChangeTypeEdit = 1, kAXTextEditTypeTyping = 3. */ + + NSDictionary *change = @{ + @"AXTextEditType": @3, + @"AXTextChangeValue": changedText + }; + NSDictionary *userInfo = @{ + @"AXTextStateChangeType": @1, + @"AXTextChangeValues": @[change] + }; + NSAccessibilityPostNotificationWithUserInfo ( + view, NSAccessibilityValueChangedNotification, userInfo); + } + else + { + /* Fallback for macOS < 10.11: bare notification. */ + NSAccessibilityPostNotification ( + view, NSAccessibilityValueChangedNotification); + } + } + + /* Always notify that cursor position (selection) changed. */ + NSAccessibilityPostNotification ( + view, NSAccessibilitySelectedTextChangedNotification); + + /* Tell macOS Zoom where the cursor is. UAZoomChangeFocus() + expects top-left origin (CG coordinate space). */ + 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 +8346,14 @@ - (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. + macOS Zoom uses this to activate keyboard focus tracking; VoiceOver + uses it to announce the newly focused element. */ + NSAccessibilityPostNotification (self, + NSAccessibilityFocusedUIElementChangedNotification); +#endif } @@ -9474,6 +9591,421 @@ - (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 + + accessibilityFrame returns the VIEW's frame (standard behavior). + VoiceOver uses this for its focus ring around the entire text area. + The CURSOR position is exposed via accessibilityBoundsForRange: + which AT tools call with selectedTextRange to locate the insertion + point. Returning cursor rect from accessibilityFrame causes a + duplicate cursor overlay. + + Ref: https://developer.apple.com/documentation/appkit/nsaccessibilityprotocol + Ref: https://developer.apple.com/documentation/appkit/accessibility/nsaccessibility/text-specific_parameterized_attributes + ---------------------------------------------------------------- */ + +- (BOOL)accessibilityIsIgnored +{ + /* Must return NO so assistive technology discovers this view. */ + return NO; +} + +- (BOOL)isAccessibilityElement +{ + return YES; +} + +- (id)accessibilityFocusedUIElement +{ + /* EmacsView is the focused element — there are no child accessible + elements within the custom-drawn view. */ + return self; +} + +- (NSString *)accessibilityRole +{ + /* TextArea role enables VoiceOver's text navigation commands + (VO+arrows for character/word/line reading). */ + return NSAccessibilityTextAreaRole; +} + +- (NSString *)accessibilityRoleDescription +{ + return NSAccessibilityRoleDescription (NSAccessibilityTextAreaRole, nil); +} + +/* ---- Text content methods for VoiceOver ---- */ + +- (id)accessibilityValue +{ + /* Return visible buffer text (capped at 10000 chars for safety). + VoiceOver reads this when navigating 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); + + /* Cap at 10000 characters to avoid performance issues with large + buffers. Recompute byte_range from the capped char range to + avoid truncating multibyte sequences (UTF-8 chars can be up to + 4 bytes, so byte_range != range for non-ASCII content). */ + if (range > 10000) + { + range = 10000; + ptrdiff_t end_byte = buf_charpos_to_bytepos (curbuf, + BUF_BEGV (curbuf) + range); + 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), 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 +{ + /* Return text of the active region (Emacs selection). Empty string + if no mark is active. */ + 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 +{ + /* Return cursor position as a zero-length range. VoiceOver uses + this with accessibilityBoundsForRange: to locate the caret. */ + 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 +{ + /* Return the visual line number of the cursor (vpos = line within + the window). VoiceOver uses this for line navigation. */ + if (!emacsframe) + return 0; + + struct window *w = XWINDOW (emacsframe->selected_window); + if (!w) + return 0; + + return (NSInteger) (w->cursor.vpos); +} + +- (NSRange)accessibilityVisibleCharacterRange +{ + /* Return range of characters visible in the current window. + Simplified to 0..min(bufsize,10000) matching accessibilityValue. */ + 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 +{ + /* Return buffer text for the given character range. VoiceOver calls + this during character/word/line reading (VO+arrow keys). */ + 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]; +} + +- (NSAttributedString *)accessibilityAttributedStringForRange:(NSRange)nsrange +{ + /* Return attributed string for the range. VoiceOver requires this + for text navigation. We return a plain (unstyled) attributed + string — sufficient for screen reader use. */ + NSString *str = [self accessibilityStringForRange:nsrange]; + return [[NSAttributedString alloc] initWithString:str]; +} + +- (NSInteger)accessibilityLineForIndex:(NSInteger)index +{ + /* Convert character index to visual line number. Used by VoiceOver + for line-by-line reading (VO+Up/Down). + + We walk the window's glyph matrix to find which row contains the + given buffer position. Falls back to 0 if not found. */ + if (!emacsframe) + return 0; + + struct window *w = XWINDOW (emacsframe->selected_window); + if (!w || !w->current_matrix) + return 0; + + struct buffer *curbuf = XBUFFER (w->contents); + if (!curbuf) + return 0; + + ptrdiff_t charpos = BUF_BEGV (curbuf) + (ptrdiff_t) index; + struct glyph_matrix *matrix = w->current_matrix; + + for (int i = 0; i < matrix->nrows; i++) + { + struct glyph_row *row = matrix->rows + i; + if (!row->enabled_p) + continue; + if (MATRIX_ROW_START_CHARPOS (row) <= charpos + && charpos < MATRIX_ROW_END_CHARPOS (row)) + return (NSInteger) i; + } + + return 0; +} + +- (NSRange)accessibilityRangeForLine:(NSInteger)line +{ + /* Return the character range for a visual line. VoiceOver uses + this to read an entire line when navigating by line. */ + if (!emacsframe) + return NSMakeRange (0, 0); + + struct window *w = XWINDOW (emacsframe->selected_window); + if (!w || !w->current_matrix) + return NSMakeRange (0, 0); + + struct buffer *curbuf = XBUFFER (w->contents); + if (!curbuf) + return NSMakeRange (0, 0); + + struct glyph_matrix *matrix = w->current_matrix; + if (line < 0 || line >= matrix->nrows) + return NSMakeRange (0, 0); + + struct glyph_row *row = matrix->rows + line; + if (!row->enabled_p) + return NSMakeRange (0, 0); + + ptrdiff_t start = MATRIX_ROW_START_CHARPOS (row) - BUF_BEGV (curbuf); + ptrdiff_t end = MATRIX_ROW_END_CHARPOS (row) - BUF_BEGV (curbuf); + if (start < 0) start = 0; + if (end < start) end = start; + + return NSMakeRange ((NSUInteger) start, (NSUInteger) (end - start)); +} + +- (NSRect)accessibilityFrameForRange:(NSRange)range +{ + /* Return screen rect for a character range. This is the modern + NSAccessibilityProtocol equivalent of accessibilityBoundsForRange:. + We delegate to the same implementation. */ + return [self accessibilityBoundsForRange:range]; +} + +- (NSRange)accessibilityRangeForPosition:(NSPoint)point +{ + /* Return character range at a screen point. VoiceOver uses this + for mouse-based exploration. Stub: returns empty range at 0. + A full implementation would need hit-testing against the glyph + matrix, which Emacs doesn't expose to AppKit. */ + (void) point; + return NSMakeRange (0, 0); +} + +/* ---- Cursor position for Zoom (via accessibilityBoundsForRange:) ---- + + accessibilityFrame intentionally returns the VIEW's frame (standard + behavior) so VoiceOver draws its focus ring around the text area. + The cursor location is exposed through accessibilityBoundsForRange: + which AT tools query using the selectedTextRange. */ + +- (NSRect)accessibilityFrame +{ + /* View's screen frame — standard NSView behavior. Do NOT return + the cursor rect here; that causes a duplicate cursor overlay. */ + return [super accessibilityFrame]; +} + +- (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 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 attribute APIs (pre-10.10 compatibility) ---- */ + +- (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]; +} + +- (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]) + { + NSRange range = [(NSValue *) parameter rangeValue]; + return [NSValue valueWithRect: + [self accessibilityBoundsForRange:range]]; + } + + 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 */