v13.2 patch: restore Zoom cursor tracking + fix MRC + fix typing echo

- Restore UAZoomChangeFocus() in ns_draw_window_cursor (was missing in v13)
- Restore accessibilityBoundsForRange: on EmacsView (Zoom queries the view)
- Restore legacy parameterized attribute APIs (Zoom uses these)
- Add lastAccessibilityCursorRect ivar for cursor position tracking
- Fix typing echo: AXTextEditType=3 (not 0), add AXTextChangeValues array
- Keep virtual element tree (EmacsAccessibilityBuffer) for VoiceOver
- MRC fixes: retain/release for accessibilityElements array
This commit is contained in:
2026-02-26 09:03:10 +01:00
parent e5bfce500f
commit 46265bdaa6

View File

@@ -1,21 +1,24 @@
From: Martin Sukany <martin@sukany.cz> From: Martin Sukany <martin@sukany.cz>
Date: Wed, 26 Feb 2026 00:00:00 +0100 Date: Wed, 26 Feb 2026 00:00:00 +0100
Subject: [PATCH] ns: add VoiceOver accessibility support (virtual element tree) Subject: [PATCH] ns: add macOS Zoom cursor tracking and VoiceOver accessibility
Implement an accessibility tree using virtual NSAccessibilityElement Implement dual accessibility support for macOS:
subclasses, enabling VoiceOver to read buffer contents, track cursor
movement, and announce window switches on macOS.
New classes: 1. UAZoomChangeFocus() in ns_draw_window_cursor: directly tells macOS
- EmacsAccessibilityElement: base class with coordinate conversion Zoom where the cursor is, using CG coordinates.
- EmacsAccessibilityBuffer: AXTextArea virtual element per Emacs window
EmacsView becomes an AXGroup containing EmacsAccessibilityBuffer children. 2. Virtual accessibility tree with EmacsAccessibilityElement base class
Notification hooks fire on cursor movement, text edits, and window changes. and EmacsAccessibilityBuffer (AXTextArea per Emacs window) for
Uses unsafe_unretained references (MRC compatible) and proper retain/release. VoiceOver: text content, cursor tracking, window switch notifications.
3. EmacsView provides accessibilityBoundsForRange: for Zoom queries and
legacy parameterized attribute APIs. EmacsView acts as AXGroup
containing EmacsAccessibilityBuffer children.
Uses unsafe_unretained references and proper retain/release (MRC compatible).
--- ---
--- a/src/nsterm.h 2026-02-26 08:46:18.118172281 +0100 --- a/src/nsterm.h 2026-02-26 08:46:18.118172281 +0100
+++ b/src/nsterm.h 2026-02-26 08:46:06.891980688 +0100 +++ b/src/nsterm.h 2026-02-26 09:01:18.404357802 +0100
@@ -455,6 +455,34 @@ @@ -455,6 +455,34 @@
/* ========================================================================== /* ==========================================================================
@@ -51,16 +54,18 @@ Uses unsafe_unretained references (MRC compatible) and proper retain/release.
The main Emacs view The main Emacs view
========================================================================== */ ========================================================================== */
@@ -471,6 +499,8 @@ @@ -471,6 +499,10 @@
#ifdef NS_IMPL_COCOA #ifdef NS_IMPL_COCOA
char *old_title; char *old_title;
BOOL maximizing_resize; BOOL maximizing_resize;
+ NSMutableArray *accessibilityElements; + NSMutableArray *accessibilityElements;
+ Lisp_Object lastSelectedWindow; + Lisp_Object lastSelectedWindow;
+ NSRect lastAccessibilityCursorRect;
+ ptrdiff_t lastAccessibilityModiff;
#endif #endif
BOOL font_panel_active; BOOL font_panel_active;
NSFont *font_panel_result; NSFont *font_panel_result;
@@ -528,6 +558,12 @@ @@ -528,6 +560,12 @@
- (void)windowWillExitFullScreen; - (void)windowWillExitFullScreen;
- (void)windowDidExitFullScreen; - (void)windowDidExitFullScreen;
- (void)windowDidBecomeKey; - (void)windowDidBecomeKey;
@@ -74,7 +79,7 @@ Uses unsafe_unretained references (MRC compatible) and proper retain/release.
--- a/src/nsterm.m 2026-02-26 08:46:18.124172384 +0100 --- a/src/nsterm.m 2026-02-26 08:46:18.124172384 +0100
+++ b/src/nsterm.m 2026-02-26 08:52:18.397374436 +0100 +++ b/src/nsterm.m 2026-02-26 09:02:44.734005575 +0100
@@ -1104,6 +1104,11 @@ @@ -1104,6 +1104,11 @@
unblock_input (); unblock_input ();
@@ -87,7 +92,46 @@ Uses unsafe_unretained references (MRC compatible) and proper retain/release.
} }
static void static void
@@ -6849,6 +6854,610 @@ @@ -3232,6 +3237,38 @@
/* 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.
+ 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. */
+ {
+ EmacsView *view = FRAME_NS_VIEW (f);
+ if (view && on_p && active_p)
+ {
+ /* Store cursor rect for accessibilityBoundsForRange: queries. */
+ view->lastAccessibilityCursorRect = r;
+
+ /* 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];
@@ -6849,6 +6886,631 @@
/* ========================================================================== /* ==========================================================================
@@ -670,10 +714,31 @@ Uses unsafe_unretained references (MRC compatible) and proper retain/release.
+ NSAccessibilityPostNotification(self, + NSAccessibilityPostNotification(self,
+ NSAccessibilityValueChangedNotification); + NSAccessibilityValueChangedNotification);
+ +
+ /* Rich typing echo for VoiceOver. */ + /* Rich typing echo for VoiceOver.
+ kAXTextStateChangeTypeEdit = 1, kAXTextEditTypeTyping = 3.
+ Must include AXTextChangeValues array for VoiceOver to speak. */
+ NSString *changedText = @"";
+ ptrdiff_t pt = BUF_PT (b);
+ if (pt > BUF_BEGV (b))
+ {
+ EmacsView *view = self.emacsView;
+ if (view)
+ {
+ NSRange charRange = NSMakeRange (
+ (NSUInteger)(pt - BUF_BEGV (b) - 1), 1);
+ changedText = [view accessibilityStringForRange:charRange];
+ if (!changedText)
+ changedText = @"";
+ }
+ }
+
+ NSDictionary *change = @{
+ @"AXTextEditType": @3,
+ @"AXTextChangeValue": changedText
+ };
+ NSDictionary *userInfo = @{ + NSDictionary *userInfo = @{
+ @"AXTextStateChangeType" : @1, /* AXTextStateChangeTypeEdit */ + @"AXTextStateChangeType": @1,
+ @"AXTextEditType" : @0 /* kAXTextEditTypeTyping */ + @"AXTextChangeValues": @[change]
+ }; + };
+ NSAccessibilityPostNotificationWithUserInfo( + NSAccessibilityPostNotificationWithUserInfo(
+ self, NSAccessibilityValueChangedNotification, userInfo); + self, NSAccessibilityValueChangedNotification, userInfo);
@@ -698,7 +763,7 @@ Uses unsafe_unretained references (MRC compatible) and proper retain/release.
EmacsView implementation EmacsView implementation
========================================================================== */ ========================================================================== */
@@ -6889,6 +7498,7 @@ @@ -6889,6 +7551,7 @@
[layer release]; [layer release];
#endif #endif
@@ -706,7 +771,7 @@ Uses unsafe_unretained references (MRC compatible) and proper retain/release.
[[self menu] release]; [[self menu] release];
[super dealloc]; [super dealloc];
} }
@@ -9474,6 +10084,144 @@ @@ -9474,6 +10137,293 @@
return fs_state; return fs_state;
} }
@@ -846,12 +911,161 @@ Uses unsafe_unretained references (MRC compatible) and proper retain/release.
+ } + }
+} +}
+ +
+/* ---- Cursor position for Zoom (via accessibilityBoundsForRange:) ----
+
+ accessibilityFrame returns the VIEW's frame (standard behavior).
+ The cursor location is exposed through accessibilityBoundsForRange:
+ which AT tools query using the selectedTextRange. */
+
+- (NSRect)accessibilityFrame
+{
+ return [super accessibilityFrame];
+}
+
+- (NSRect)accessibilityBoundsForRange:(NSRange)range
+{
+ /* Return cursor screen rect. AT tools call this with the
+ selectedTextRange to locate the insertion point. */
+ 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];
+}
+
+- (NSRect)accessibilityFrameForRange:(NSRange)range
+{
+ return [self accessibilityBoundsForRange:range];
+}
+
+/* ---- Text content methods (for Zoom and legacy AT) ---- */
+
+- (id)accessibilityValue
+{
+ 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;
+ 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];
+}
+
+- (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);
+}
+
+- (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 char_range = end - start;
+ ptrdiff_t brange = end_byte - start_byte;
+
+ Lisp_Object str;
+ if (! NILP (BVAR (curbuf, enable_multibyte_characters)))
+ str = make_uninit_multibyte_string (char_range, brange);
+ else
+ str = make_uninit_string (char_range);
+ memcpy (SDATA (str), BUF_BYTE_ADDRESS (curbuf, start_byte), brange);
+
+ return [NSString stringWithLispString:str];
+}
+
+/* ---- Legacy parameterized attribute APIs (Zoom uses these) ---- */
+
+- (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 */ +#endif /* NS_IMPL_COCOA */
+ +
@end /* EmacsView */ @end /* EmacsView */
@@ -9941,6 +10689,14 @@ @@ -9941,6 +10891,14 @@
return [super accessibilityAttributeValue:attribute]; return [super accessibilityAttributeValue:attribute];
} }