diff --git a/patches/0001-ns-implement-AXBoundsForRange-for-macOS-Zoom-cursor-.patch b/patches/0001-ns-implement-AXBoundsForRange-for-macOS-Zoom-cursor-.patch index 398e796..186d749 100644 --- a/patches/0001-ns-implement-AXBoundsForRange-for-macOS-Zoom-cursor-.patch +++ b/patches/0001-ns-implement-AXBoundsForRange-for-macOS-Zoom-cursor-.patch @@ -1,27 +1,30 @@ From: Martin Sukany -Date: Tue, 25 Feb 2026 19:00:00 +0100 +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. This is Apple's - documented API for applications to control Zoom focus. + 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: accessibilityValue (buffer text), + Full text area protocol for VoiceOver: accessibilityValue (buffer text), accessibilitySelectedText, accessibilitySelectedTextRange, accessibilityInsertionPointLineNumber, accessibilityVisibleCharacterRange, - accessibilityStringForRange:, accessibilityBoundsForRange:, - accessibilityFrame. Serves VoiceOver and other screen readers. + accessibilityStringForRange:, accessibilityBoundsForRange:. + Notifications: ValueChanged (typing echo), SelectedTextChanged (cursor + movement), FocusedUIElementChanged (window focus). Ref: https://developer.apple.com/documentation/appkit/nsaccessibilityprotocol -Both mechanisms are needed: UAZoomChangeFocus serves Zoom's "Follow -keyboard focus"; NSAccessibility serves VoiceOver and screen readers -that query the accessibility tree. Same dual pattern used by iTerm2. +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 @@ -38,10 +41,10 @@ index 7c1ee4c..6c1ff34 100644 /* AppKit-side interface. */ diff --git a/src/nsterm.m b/src/nsterm.m -index 932d209..6a06b2e 100644 +index 932d209..2576c96 100644 --- a/src/nsterm.m +++ b/src/nsterm.m -@@ -3232,6 +3232,67 @@ Note that CURSOR_WIDTH is meaningful only for (h)bar cursors. +@@ -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)); @@ -53,32 +56,38 @@ index 932d209..6a06b2e 100644 + 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. ++ - 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). ++ 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" (which does not reliably -+ track custom views through NSAccessibility alone). */ ++ serves macOS Zoom's "Follow keyboard focus" feature. */ + { + EmacsView *view = FRAME_NS_VIEW (f); + if (view) + { -+ /* Store cursor rect for accessibilityFrame and -+ accessibilityBoundsForRange: queries. */ ++ /* Store cursor rect for accessibilityBoundsForRange: queries. */ + view->lastAccessibilityCursorRect = r; + -+ /* Post NSAccessibility notification for VoiceOver and other -+ AT tools. */ ++ /* 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); + @@ -86,8 +95,7 @@ index 932d209..6a06b2e 100644 + 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. */ ++ bottom-left), so we flip the y axis. */ + if (UAZoomEnabled ()) + { + NSRect windowRect = [view convertRect:r toView:nil]; @@ -109,7 +117,7 @@ index 932d209..6a06b2e 100644 ns_focus (f, NULL, 0); NSGraphicsContext *ctx = [NSGraphicsContext currentContext]; -@@ -8237,6 +8298,15 @@ - (void)windowDidBecomeKey /* for direct calls */ +@@ -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 @@ -125,7 +133,7 @@ index 932d209..6a06b2e 100644 } -@@ -9474,6 +9544,326 @@ - (int) fullscreenState +@@ -9474,6 +9549,310 @@ - (int) fullscreenState return fs_state; } @@ -136,31 +144,26 @@ index 932d209..6a06b2e 100644 + + EmacsView implements the NSAccessibility protocol so that: + - macOS Zoom can query cursor position (accessibilityBoundsForRange:) -+ - VoiceOver can read buffer contents and track cursor ++ - 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. + -+ Text content methods (accessibilityValue, accessibilitySelectedText, -+ accessibilityInsertionPointLineNumber, accessibilityVisibleCharacterRange, -+ accessibilityNumberOfCharacters) read from the current buffer, mirroring -+ the pattern in EmacsWindow's accessibilityAttributeValue: but at the -+ view level where VoiceOver expects to find them. ++ 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 -+ Ref: https://developer.apple.com/documentation/appkit/nsaccessibilityboundsforrangeparameterizedattribute -+ -+ Note: EmacsWindow has a separate accessibilityAttributeValue: that -+ returns buffer text via the legacy API. These EmacsView methods use -+ the modern protocol API (10.10+) and operate on the view level where -+ VoiceOver resolves the text area role. No conflict. + ---------------------------------------------------------------- */ + +- (BOOL)accessibilityIsIgnored +{ -+ /* EmacsView must participate in the accessibility hierarchy. */ + return NO; +} + @@ -171,7 +174,6 @@ index 932d209..6a06b2e 100644 + +- (id)accessibilityFocusedUIElement +{ -+ /* This view is the focused element -- it contains the text cursor. */ + return self; +} + @@ -190,7 +192,7 @@ index 932d209..6a06b2e 100644 +- (id)accessibilityValue +{ + /* Return visible buffer text. VoiceOver reads this when the user -+ navigates to the text area. */ ++ navigates to the text area and after ValueChangedNotification. */ + if (!emacsframe) + return @""; + @@ -203,7 +205,6 @@ index 932d209..6a06b2e 100644 + ptrdiff_t byte_range = BUF_ZV_BYTE (curbuf) - start_byte; + ptrdiff_t range = BUF_ZV (curbuf) - BUF_BEGV (curbuf); + -+ /* Limit to 10000 chars to avoid performance issues with large buffers. */ + if (range > 10000) + { + range = 10000; @@ -267,7 +268,6 @@ index 932d209..6a06b2e 100644 + if (!curbuf) + return NSMakeRange (0, 0); + -+ /* Return cursor position as collapsed selection. */ + ptrdiff_t pt = BUF_PT (curbuf) - BUF_BEGV (curbuf); + return NSMakeRange ((NSUInteger) pt, 0); +} @@ -281,7 +281,6 @@ index 932d209..6a06b2e 100644 + if (!w) + return 0; + -+ /* Return cursor line relative to window start. */ + return (NSInteger) (w->cursor.vpos); +} + @@ -332,26 +331,20 @@ index 932d209..6a06b2e 100644 + return [NSString stringWithLispString:str]; +} + -+/* ---- Cursor position methods for Zoom and VoiceOver ---- */ ++/* ---- 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 cursor's screen coordinates. This is the key method -+ that macOS Zoom reads after receiving a focus/selection notification. -+ -+ 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]; ++ /* 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 @@ -395,13 +388,13 @@ index 932d209..6a06b2e 100644 + return [super accessibilityAttributeValue:attribute]; +} + -+/* Modern NSAccessibilityProtocol (macOS 10.10+). -+ Ref: https://developer.apple.com/documentation/appkit/nsaccessibilityprotocol */ ++/* Modern NSAccessibilityProtocol (macOS 10.10+). */ +- (NSRect)accessibilityBoundsForRange:(NSRange)range +{ -+ /* Return cursor screen rect regardless of requested range. -+ Emacs does not expose a character-level geometry model to AppKit, -+ so we always return the cursor position. */ ++ /* 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) @@ -417,8 +410,7 @@ index 932d209..6a06b2e 100644 + return [win convertRectToScreen:windowRect]; +} + -+/* Legacy parameterized attribute API -- fallback for older AT tools. -+ Ref: https://developer.apple.com/documentation/appkit/nsaccessibilityboundsforrangeparameterizedattribute */ ++/* Legacy parameterized attribute API. */ +- (NSArray *)accessibilityParameterizedAttributeNames +{ + NSArray *superAttrs = [super accessibilityParameterizedAttributeNames];