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>
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
subclasses, enabling VoiceOver to read buffer contents, track cursor
movement, and announce window switches on macOS.
Implement dual accessibility support for macOS:
New classes:
- EmacsAccessibilityElement: base class with coordinate conversion
- EmacsAccessibilityBuffer: AXTextArea virtual element per Emacs window
1. UAZoomChangeFocus() in ns_draw_window_cursor: directly tells macOS
Zoom where the cursor is, using CG coordinates.
EmacsView becomes an AXGroup containing EmacsAccessibilityBuffer children.
Notification hooks fire on cursor movement, text edits, and window changes.
Uses unsafe_unretained references (MRC compatible) and proper retain/release.
2. Virtual accessibility tree with EmacsAccessibilityElement base class
and EmacsAccessibilityBuffer (AXTextArea per Emacs window) for
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
+++ 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 @@
/* ==========================================================================
@@ -51,16 +54,18 @@ Uses unsafe_unretained references (MRC compatible) and proper retain/release.
The main Emacs view
========================================================================== */
@@ -471,6 +499,8 @@
@@ -471,6 +499,10 @@
#ifdef NS_IMPL_COCOA
char *old_title;
BOOL maximizing_resize;
+ NSMutableArray *accessibilityElements;
+ Lisp_Object lastSelectedWindow;
+ NSRect lastAccessibilityCursorRect;
+ ptrdiff_t lastAccessibilityModiff;
#endif
BOOL font_panel_active;
NSFont *font_panel_result;
@@ -528,6 +558,12 @@
@@ -528,6 +560,12 @@
- (void)windowWillExitFullScreen;
- (void)windowDidExitFullScreen;
- (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
+++ 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 @@
unblock_input ();
@@ -87,7 +92,46 @@ Uses unsafe_unretained references (MRC compatible) and proper retain/release.
}
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,
+ 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 = @{
+ @"AXTextStateChangeType" : @1, /* AXTextStateChangeTypeEdit */
+ @"AXTextEditType" : @0 /* kAXTextEditTypeTyping */
+ @"AXTextStateChangeType": @1,
+ @"AXTextChangeValues": @[change]
+ };
+ NSAccessibilityPostNotificationWithUserInfo(
+ self, NSAccessibilityValueChangedNotification, userInfo);
@@ -698,7 +763,7 @@ Uses unsafe_unretained references (MRC compatible) and proper retain/release.
EmacsView implementation
========================================================================== */
@@ -6889,6 +7498,7 @@
@@ -6889,6 +7551,7 @@
[layer release];
#endif
@@ -706,7 +771,7 @@ Uses unsafe_unretained references (MRC compatible) and proper retain/release.
[[self menu] release];
[super dealloc];
}
@@ -9474,6 +10084,144 @@
@@ -9474,6 +10137,293 @@
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 */
+
@end /* EmacsView */
@@ -9941,6 +10689,14 @@
@@ -9941,6 +10891,14 @@
return [super accessibilityAttributeValue:attribute];
}