Files
emacs-doom/patches/0001-ns-implement-AXBoundsForRange-for-macOS-Zoom-cursor-.patch
Daneel 46265bdaa6 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
2026-02-26 09:03:10 +01:00

1083 lines
31 KiB
Diff

From: Martin Sukany <martin@sukany.cz>
Date: Wed, 26 Feb 2026 00:00:00 +0100
Subject: [PATCH] ns: add macOS Zoom cursor tracking and VoiceOver accessibility
Implement dual accessibility support for macOS:
1. UAZoomChangeFocus() in ns_draw_window_cursor: directly tells macOS
Zoom where the cursor is, using CG coordinates.
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 09:01:18.404357802 +0100
@@ -455,6 +455,34 @@
/* ==========================================================================
+ Accessibility virtual elements (macOS / Cocoa only)
+
+ ========================================================================== */
+
+#ifdef NS_IMPL_COCOA
+@class EmacsView;
+
+/* Base class for virtual accessibility elements attached to EmacsView. */
+@interface EmacsAccessibilityElement : NSAccessibilityElement
+@property (nonatomic, unsafe_unretained) EmacsView *emacsView;
+@property (nonatomic, assign) struct window *emacsWindow;
+- (NSRect)screenRectFromEmacsX:(int)x y:(int)y width:(int)w height:(int)h;
+@end
+
+/* Virtual AXTextArea element — one per visible Emacs window (buffer). */
+@interface EmacsAccessibilityBuffer : EmacsAccessibilityElement
+@property (nonatomic, assign) ptrdiff_t cachedModiff;
+@property (nonatomic, assign) ptrdiff_t cachedPoint;
+@property (nonatomic, assign) Lisp_Object cachedSelectedWindow;
+- (void)
+ postAccessibilityUpdatesForWindow:(struct window *)w
+ frame:(struct frame *)f;
+@end
+#endif /* NS_IMPL_COCOA */
+
+
+/* ==========================================================================
+
The main Emacs view
========================================================================== */
@@ -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 +560,12 @@
- (void)windowWillExitFullScreen;
- (void)windowDidExitFullScreen;
- (void)windowDidBecomeKey;
+
+#ifdef NS_IMPL_COCOA
+/* Accessibility support. */
+- (void)rebuildAccessibilityTree;
+- (void)postAccessibilityUpdates;
+#endif
@end
--- a/src/nsterm.m 2026-02-26 08:46:18.124172384 +0100
+++ b/src/nsterm.m 2026-02-26 09:02:44.734005575 +0100
@@ -1104,6 +1104,11 @@
unblock_input ();
ns_updating_frame = NULL;
+
+#ifdef NS_IMPL_COCOA
+ /* Post accessibility notifications after each redisplay cycle. */
+ [view postAccessibilityUpdates];
+#endif
}
static void
@@ -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 @@
/* ==========================================================================
+ Accessibility virtual elements (macOS / Cocoa only)
+
+ ========================================================================== */
+
+#ifdef NS_IMPL_COCOA
+
+/* ---- Helper: extract visible text from glyph rows of a window ---- */
+static NSString *
+ns_ax_text_from_glyph_rows (struct window *w)
+{
+ if (!w || !w->current_matrix)
+ return @"";
+
+ struct glyph_matrix *matrix = w->current_matrix;
+ NSMutableString *text = [NSMutableString stringWithCapacity:4096];
+ int nrows = matrix->nrows;
+
+ for (int i = 0; i < nrows; i++)
+ {
+ struct glyph_row *row = matrix->rows + i;
+ if (!row->enabled_p || row->mode_line_p)
+ continue;
+ if (!row->displays_text_p && !row->ends_at_zv_p)
+ continue;
+
+ struct glyph *glyph = row->glyphs[TEXT_AREA];
+ struct glyph *end = glyph + row->used[TEXT_AREA];
+
+ for (; glyph < end; glyph++)
+ {
+ if (glyph->type == CHAR_GLYPH && !glyph->padding_p)
+ {
+ unsigned ch = glyph->u.ch;
+ if (ch == '\n' || ch == '\r')
+ continue; /* row boundary handles newlines */
+ if (ch >= 32)
+ {
+ unichar uch = (unichar)ch;
+ [text appendString:[NSString stringWithCharacters:&uch
+ length:1]];
+ }
+ }
+ }
+
+ /* Add newline between rows unless this is the last displayed row. */
+ if (i + 1 < nrows)
+ {
+ struct glyph_row *next = matrix->rows + i + 1;
+ if (next->enabled_p && (next->displays_text_p || next->ends_at_zv_p)
+ && !next->mode_line_p)
+ [text appendString:@"\n"];
+ }
+ }
+
+ /* Cap at 32KB */
+ if ([text length] > 32768)
+ return [text substringToIndex:32768];
+
+ return text;
+}
+
+
+/* ---- Row geometry helpers ---- */
+
+/* Count the number of visible text rows (excluding mode line). */
+static int
+ns_ax_visible_row_count (struct window *w)
+{
+ if (!w || !w->current_matrix)
+ return 0;
+ struct glyph_matrix *matrix = w->current_matrix;
+ int count = 0;
+ for (int i = 0; i < matrix->nrows; i++)
+ {
+ struct glyph_row *row = matrix->rows + i;
+ if (row->enabled_p && !row->mode_line_p
+ && (row->displays_text_p || row->ends_at_zv_p))
+ count++;
+ }
+ return count;
+}
+
+/* Map a character index (within the glyph-extracted text) to a visual
+ row number (0-based, text rows only). */
+static int
+ns_ax_line_for_index (struct window *w, NSUInteger idx)
+{
+ if (!w || !w->current_matrix)
+ return 0;
+ struct glyph_matrix *matrix = w->current_matrix;
+ NSUInteger pos = 0;
+ int line = 0;
+
+ for (int i = 0; i < matrix->nrows; i++)
+ {
+ struct glyph_row *row = matrix->rows + i;
+ if (!row->enabled_p || row->mode_line_p)
+ continue;
+ if (!row->displays_text_p && !row->ends_at_zv_p)
+ continue;
+
+ /* Count characters in this row. */
+ int row_chars = 0;
+ struct glyph *g = row->glyphs[TEXT_AREA];
+ struct glyph *gend = g + row->used[TEXT_AREA];
+ for (; g < gend; g++)
+ {
+ if (g->type == CHAR_GLYPH && !g->padding_p)
+ {
+ unsigned ch = g->u.ch;
+ if (ch != '\n' && ch != '\r' && (ch >= 32))
+ row_chars++;
+ }
+ }
+
+ NSUInteger row_end = pos + row_chars + 1; /* +1 for newline */
+ if (idx < row_end)
+ return line;
+ pos = row_end;
+ line++;
+ }
+ return MAX(0, line - 1);
+}
+
+/* Return character range for a given visual line number. */
+static NSRange
+ns_ax_range_for_line (struct window *w, int target_line)
+{
+ if (!w || !w->current_matrix)
+ return NSMakeRange(0, 0);
+ struct glyph_matrix *matrix = w->current_matrix;
+ NSUInteger pos = 0;
+ int line = 0;
+
+ for (int i = 0; i < matrix->nrows; i++)
+ {
+ struct glyph_row *row = matrix->rows + i;
+ if (!row->enabled_p || row->mode_line_p)
+ continue;
+ if (!row->displays_text_p && !row->ends_at_zv_p)
+ continue;
+
+ int row_chars = 0;
+ struct glyph *g = row->glyphs[TEXT_AREA];
+ struct glyph *gend = g + row->used[TEXT_AREA];
+ for (; g < gend; g++)
+ {
+ if (g->type == CHAR_GLYPH && !g->padding_p)
+ {
+ unsigned ch = g->u.ch;
+ if (ch != '\n' && ch != '\r' && (ch >= 32))
+ row_chars++;
+ }
+ }
+
+ if (line == target_line)
+ return NSMakeRange(pos, row_chars);
+
+ pos += row_chars + 1; /* +1 for newline */
+ line++;
+ }
+ return NSMakeRange(NSNotFound, 0);
+}
+
+/* Compute screen rect for a character range by unioning glyph row rects. */
+static NSRect
+ns_ax_frame_for_range (struct window *w, EmacsView *view, NSRange range)
+{
+ if (!w || !w->current_matrix || !view)
+ return NSZeroRect;
+ struct glyph_matrix *matrix = w->current_matrix;
+ NSUInteger pos = 0;
+ NSRect result = NSZeroRect;
+ BOOL found = NO;
+
+ for (int i = 0; i < matrix->nrows; i++)
+ {
+ struct glyph_row *row = matrix->rows + i;
+ if (!row->enabled_p || row->mode_line_p)
+ continue;
+ if (!row->displays_text_p && !row->ends_at_zv_p)
+ continue;
+
+ int row_chars = 0;
+ struct glyph *g = row->glyphs[TEXT_AREA];
+ struct glyph *gend = g + row->used[TEXT_AREA];
+ for (; g < gend; g++)
+ {
+ if (g->type == CHAR_GLYPH && !g->padding_p)
+ {
+ unsigned ch = g->u.ch;
+ if (ch != '\n' && ch != '\r' && (ch >= 32))
+ row_chars++;
+ }
+ }
+
+ NSUInteger row_end = pos + row_chars + 1;
+ if (pos < range.location + range.length && row_end > range.location)
+ {
+ /* This row overlaps the requested range. */
+ int window_x, window_y, window_width;
+ window_box (w, TEXT_AREA, &window_x, &window_y, &window_width, 0);
+
+ NSRect rowRect;
+ rowRect.origin.x = window_x;
+ rowRect.origin.y = WINDOW_TO_FRAME_PIXEL_Y (w, MAX(0, row->y));
+ rowRect.origin.y = MAX(rowRect.origin.y, window_y);
+ rowRect.size.width = window_width;
+ rowRect.size.height = row->visible_height;
+
+ if (!found)
+ {
+ result = rowRect;
+ found = YES;
+ }
+ else
+ result = NSUnionRect(result, rowRect);
+ }
+ pos = row_end;
+ }
+
+ if (!found)
+ return NSZeroRect;
+
+ /* Convert from EmacsView (flipped) coords to screen coords. */
+ NSRect winRect = [view convertRect:result toView:nil];
+ return [[view window] convertRectToScreen:winRect];
+}
+
+
+/* Compute the character index within glyph-extracted text that
+ corresponds to the buffer point position. */
+static NSUInteger
+ns_ax_index_for_point (struct window *w)
+{
+ if (!w || !w->current_matrix || !WINDOW_LEAF_P(w))
+ return 0;
+
+ struct buffer *b = XBUFFER(w->contents);
+ if (!b)
+ return 0;
+
+ ptrdiff_t point = BUF_PT(b);
+ struct glyph_matrix *matrix = w->current_matrix;
+ NSUInteger pos = 0;
+
+ for (int i = 0; i < matrix->nrows; i++)
+ {
+ struct glyph_row *row = matrix->rows + i;
+ if (!row->enabled_p || row->mode_line_p)
+ continue;
+ if (!row->displays_text_p && !row->ends_at_zv_p)
+ continue;
+
+ ptrdiff_t row_start = MATRIX_ROW_START_CHARPOS (row);
+ ptrdiff_t row_end = MATRIX_ROW_END_CHARPOS (row);
+
+ if (point >= row_start && point < row_end)
+ {
+ /* Point is within this row. Count visible glyphs whose
+ buffer charpos is before point. */
+ int chars_before = 0;
+ struct glyph *g = row->glyphs[TEXT_AREA];
+ struct glyph *gend = g + row->used[TEXT_AREA];
+ for (; g < gend; g++)
+ {
+ if (g->type == CHAR_GLYPH && !g->padding_p
+ && g->charpos >= row_start
+ && g->charpos < point)
+ {
+ unsigned ch = g->u.ch;
+ if (ch != '\n' && ch != '\r' && ch >= 32)
+ chars_before++;
+ }
+ }
+ return pos + chars_before;
+ }
+
+ /* Count visible chars in this row + newline. */
+ int row_chars = 0;
+ struct glyph *g = row->glyphs[TEXT_AREA];
+ struct glyph *gend = g + row->used[TEXT_AREA];
+ for (; g < gend; g++)
+ {
+ if (g->type == CHAR_GLYPH && !g->padding_p)
+ {
+ unsigned ch = g->u.ch;
+ if (ch != '\n' && ch != '\r' && (ch >= 32))
+ row_chars++;
+ }
+ }
+ pos += row_chars + 1;
+ }
+ return pos > 0 ? pos - 1 : 0;
+}
+
+
+@implementation EmacsAccessibilityElement
+
+- (NSRect)screenRectFromEmacsX:(int)x y:(int)y width:(int)ew height:(int)eh
+{
+ EmacsView *view = self.emacsView;
+ if (!view || ![view window])
+ return NSZeroRect;
+
+ NSRect r = NSMakeRect(x, y, ew, eh);
+ NSRect winRect = [view convertRect:r toView:nil];
+ return [[view window] convertRectToScreen:winRect];
+}
+
+- (BOOL)isAccessibilityElement
+{
+ return YES;
+}
+
+@end
+
+
+@implementation EmacsAccessibilityBuffer
+
+/* ---- NSAccessibility protocol ---- */
+
+- (NSAccessibilityRole)accessibilityRole
+{
+ return NSAccessibilityTextAreaRole;
+}
+
+- (NSString *)accessibilityRoleDescription
+{
+ return @"editor";
+}
+
+- (NSString *)accessibilityLabel
+{
+ struct window *w = self.emacsWindow;
+ if (w && WINDOW_LEAF_P(w))
+ {
+ struct buffer *b = XBUFFER(w->contents);
+ if (b)
+ {
+ Lisp_Object name = BVAR(b, name);
+ if (STRINGP(name))
+ return [NSString stringWithLispString:name];
+ }
+ }
+ return @"buffer";
+}
+
+- (id)accessibilityValue
+{
+ struct window *w = self.emacsWindow;
+ if (!w)
+ return @"";
+ return ns_ax_text_from_glyph_rows(w);
+}
+
+- (NSInteger)accessibilityNumberOfCharacters
+{
+ NSString *text = [self accessibilityValue];
+ return [text length];
+}
+
+- (NSString *)accessibilitySelectedText
+{
+ struct window *w = self.emacsWindow;
+ if (!w || !WINDOW_LEAF_P(w))
+ return @"";
+
+ struct buffer *b = XBUFFER(w->contents);
+ if (!b || NILP(BVAR(b, mark_active)))
+ return @"";
+
+ /* Return the selected region text. */
+ NSString *text = [self accessibilityValue];
+ NSRange sel = [self accessibilitySelectedTextRange];
+ if (sel.location == NSNotFound || sel.location + sel.length > [text length])
+ return @"";
+ return [text substringWithRange:sel];
+}
+
+- (NSRange)accessibilitySelectedTextRange
+{
+ struct window *w = self.emacsWindow;
+ if (!w || !WINDOW_LEAF_P(w))
+ return NSMakeRange(0, 0);
+
+ struct buffer *b = XBUFFER(w->contents);
+ if (!b)
+ return NSMakeRange(0, 0);
+
+ NSUInteger point_idx = ns_ax_index_for_point(w);
+
+ if (NILP(BVAR(b, mark_active)))
+ return NSMakeRange(point_idx, 0);
+
+ /* With active mark, report the selection range. Map mark
+ position to accessibility index using the same glyph-based
+ mapping as point. */
+ ptrdiff_t mark_pos = marker_position (BVAR (b, mark));
+ ptrdiff_t pt_pos = BUF_PT (b);
+ ptrdiff_t begv = BUF_BEGV (b);
+ ptrdiff_t sel_start = (mark_pos < pt_pos) ? mark_pos : pt_pos;
+ ptrdiff_t sel_end = (mark_pos < pt_pos) ? pt_pos : mark_pos;
+ NSUInteger start_idx = (NSUInteger) (sel_start - begv);
+ NSUInteger len = (NSUInteger) (sel_end - sel_start);
+ return NSMakeRange(start_idx, len);
+}
+
+- (NSInteger)accessibilityInsertionPointLineNumber
+{
+ struct window *w = self.emacsWindow;
+ if (!w)
+ return 0;
+ NSUInteger idx = ns_ax_index_for_point(w);
+ return ns_ax_line_for_index(w, idx);
+}
+
+- (NSString *)accessibilityStringForRange:(NSRange)range
+{
+ NSString *text = [self accessibilityValue];
+ if (range.location + range.length > [text length])
+ return @"";
+ return [text substringWithRange:range];
+}
+
+- (NSInteger)accessibilityLineForIndex:(NSInteger)index
+{
+ struct window *w = self.emacsWindow;
+ if (!w)
+ return 0;
+ return ns_ax_line_for_index(w, (NSUInteger)index);
+}
+
+- (NSRange)accessibilityRangeForLine:(NSInteger)line
+{
+ struct window *w = self.emacsWindow;
+ if (!w)
+ return NSMakeRange(0, 0);
+ return ns_ax_range_for_line(w, (int)line);
+}
+
+- (NSRect)accessibilityFrameForRange:(NSRange)range
+{
+ struct window *w = self.emacsWindow;
+ EmacsView *view = self.emacsView;
+ if (!w || !view)
+ return NSZeroRect;
+ return ns_ax_frame_for_range(w, view, range);
+}
+
+
+- (NSRange)accessibilityRangeForPosition:(NSPoint)screenPoint
+{
+ /* Hit test: convert screen point to buffer character index.
+ Used by VoiceOver for mouse/trackpad exploration. */
+ struct window *w = self.emacsWindow;
+ EmacsView *view = self.emacsView;
+ if (!w || !view || !w->current_matrix)
+ return NSMakeRange (0, 0);
+
+ /* Convert screen point to EmacsView coordinates. */
+ NSPoint windowPoint = [[view window] convertPointFromScreen:screenPoint];
+ NSPoint viewPoint = [view convertPoint:windowPoint fromView:nil];
+
+ /* Convert to Emacs pixel coordinates (EmacsView is flipped). */
+ int x = (int) viewPoint.x - w->pixel_left;
+ int y = (int) viewPoint.y - w->pixel_top;
+
+ if (x < 0 || y < 0 || x >= w->pixel_width || y >= w->pixel_height)
+ return NSMakeRange (0, 0);
+
+ /* Find the glyph row at this y coordinate. */
+ struct glyph_matrix *matrix = w->current_matrix;
+ struct glyph_row *hit_row = NULL;
+ int row_y = 0;
+
+ for (int i = 0; i < matrix->nrows; i++)
+ {
+ struct glyph_row *row = matrix->rows + i;
+ if (!row->enabled_p || !row->displays_text_p)
+ continue;
+ if (y >= row_y && y < row_y + row->visible_height)
+ {
+ hit_row = row;
+ break;
+ }
+ row_y += row->visible_height;
+ }
+
+ if (!hit_row)
+ return NSMakeRange (0, 0);
+
+ /* Find the glyph at this x coordinate within the row. */
+ struct glyph *glyph = hit_row->glyphs[TEXT_AREA];
+ struct glyph *end = glyph + hit_row->used[TEXT_AREA];
+ int glyph_x = 0;
+ ptrdiff_t best_charpos = MATRIX_ROW_START_CHARPOS (hit_row);
+
+ for (; glyph < end; glyph++)
+ {
+ if (glyph->type == CHAR_GLYPH && glyph->charpos > 0)
+ {
+ if (x >= glyph_x && x < glyph_x + glyph->pixel_width)
+ {
+ best_charpos = glyph->charpos;
+ break;
+ }
+ best_charpos = glyph->charpos;
+ }
+ glyph_x += glyph->pixel_width;
+ }
+
+ /* Convert buffer charpos to accessibility index. */
+ struct buffer *b = XBUFFER (w->contents);
+ if (!b)
+ return NSMakeRange (0, 0);
+
+ ptrdiff_t idx = best_charpos - BUF_BEGV (b);
+ if (idx < 0) idx = 0;
+
+ return NSMakeRange ((NSUInteger) idx, 1);
+}
+
+- (NSRange)accessibilityVisibleCharacterRange
+{
+ NSString *text = [self accessibilityValue];
+ return NSMakeRange(0, [text length]);
+}
+
+- (BOOL)isAccessibilityFocused
+{
+ struct window *w = self.emacsWindow;
+ if (!w)
+ return NO;
+ EmacsView *view = self.emacsView;
+ if (!view || !view->emacsframe)
+ return NO;
+ struct frame *f = view->emacsframe;
+ return (w == XWINDOW(f->selected_window));
+}
+
+- (id)accessibilityParent
+{
+ return NSAccessibilityUnignoredAncestor (self.emacsView);
+}
+
+- (NSRect)accessibilityFrame
+{
+ struct window *w = self.emacsWindow;
+ if (!w)
+ return NSZeroRect;
+ return [self screenRectFromEmacsX:w->pixel_left
+ y:w->pixel_top
+ width:w->pixel_width
+ height:w->pixel_height];
+}
+
+/* ---- Notification dispatch ---- */
+
+- (void)postAccessibilityUpdatesForWindow:(struct window *)w
+ frame:(struct frame *)f
+{
+ if (!w || !WINDOW_LEAF_P(w))
+ return;
+
+ struct buffer *b = XBUFFER(w->contents);
+ if (!b)
+ return;
+
+ ptrdiff_t modiff = BUF_MODIFF(b);
+ ptrdiff_t point = BUF_PT(b);
+
+ /* Text content changed? */
+ if (modiff != self.cachedModiff)
+ {
+ self.cachedModiff = modiff;
+ NSAccessibilityPostNotification(self,
+ NSAccessibilityValueChangedNotification);
+
+ /* 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,
+ @"AXTextChangeValues": @[change]
+ };
+ NSAccessibilityPostNotificationWithUserInfo(
+ self, NSAccessibilityValueChangedNotification, userInfo);
+ }
+
+ /* Cursor moved? */
+ if (point != self.cachedPoint)
+ {
+ self.cachedPoint = point;
+ NSAccessibilityPostNotification(self,
+ NSAccessibilitySelectedTextChangedNotification);
+ }
+}
+
+@end
+
+#endif /* NS_IMPL_COCOA */
+
+
+/* ==========================================================================
+
EmacsView implementation
========================================================================== */
@@ -6889,6 +7551,7 @@
[layer release];
#endif
+ [accessibilityElements release];
[[self menu] release];
[super dealloc];
}
@@ -9474,6 +10137,293 @@
return fs_state;
}
+#ifdef NS_IMPL_COCOA
+
+/* ---- Accessibility: walk the Emacs window tree ---- */
+
+static void
+ns_ax_collect_windows (Lisp_Object window, EmacsView *view,
+ NSMutableArray *elements)
+{
+ if (NILP (window))
+ return;
+
+ struct window *w = XWINDOW (window);
+
+ if (WINDOW_LEAF_P (w))
+ {
+ if (MINI_WINDOW_P (w))
+ return; /* Skip minibuffer for MVP. */
+
+ EmacsAccessibilityBuffer *elem = [[EmacsAccessibilityBuffer alloc] init];
+ elem.emacsView = view;
+ elem.emacsWindow = w;
+
+ /* Initialize cached state to trigger first notification. */
+ struct buffer *b = XBUFFER (w->contents);
+ if (b)
+ {
+ elem.cachedModiff = BUF_MODIFF (b);
+ elem.cachedPoint = BUF_PT (b);
+ }
+
+ [elements addObject:elem];
+ }
+ else
+ {
+ /* Internal (combination) window — recurse into children. */
+ Lisp_Object child = w->contents;
+ while (!NILP (child))
+ {
+ ns_ax_collect_windows (child, view, elements);
+ child = XWINDOW (child)->next;
+ }
+ }
+}
+
+- (void)rebuildAccessibilityTree
+{
+ if (!emacsframe)
+ return;
+
+ NSMutableArray *newElements = [NSMutableArray arrayWithCapacity:4];
+ Lisp_Object root = FRAME_ROOT_WINDOW (emacsframe);
+ ns_ax_collect_windows (root, self, newElements);
+ [accessibilityElements release];
+ accessibilityElements = [newElements retain];
+}
+
+- (NSAccessibilityRole)accessibilityRole
+{
+ return NSAccessibilityGroupRole;
+}
+
+- (NSString *)accessibilityLabel
+{
+ return @"Emacs";
+}
+
+- (BOOL)isAccessibilityElement
+{
+ return YES;
+}
+
+- (NSArray *)accessibilityChildren
+{
+ if (!accessibilityElements || [accessibilityElements count] == 0)
+ [self rebuildAccessibilityTree];
+ return accessibilityElements;
+}
+
+- (id)accessibilityFocusedUIElement
+{
+ if (!emacsframe)
+ return self;
+
+ /* Ensure tree exists (lazy init); avoid redundant rebuild since
+ postAccessibilityUpdates already rebuilds each cycle. */
+ if (!accessibilityElements || [accessibilityElements count] == 0)
+ [self rebuildAccessibilityTree];
+
+ struct window *sel = XWINDOW (emacsframe->selected_window);
+ for (EmacsAccessibilityBuffer *elem in accessibilityElements)
+ {
+ if (elem.emacsWindow == sel)
+ return elem;
+ }
+ return self;
+}
+
+/* Called from ns_update_end to post AX notifications.
+
+ Important: post notifications BEFORE rebuilding the tree.
+ The existing elements carry cached state (modiff, point) from the
+ previous redisplay cycle. Rebuilding first would create fresh
+ elements with current values, making change detection impossible. */
+- (void)postAccessibilityUpdates
+{
+ if (!emacsframe)
+ return;
+
+ /* Post per-buffer notifications using EXISTING elements that have
+ cached state from the previous cycle. */
+ for (EmacsAccessibilityBuffer *elem in accessibilityElements)
+ {
+ struct window *w = elem.emacsWindow;
+ if (w && WINDOW_LEAF_P (w))
+ [elem postAccessibilityUpdatesForWindow:w frame:emacsframe];
+ }
+
+ /* Check for window switch (C-x o) before rebuild. */
+ Lisp_Object curSel = emacsframe->selected_window;
+ BOOL windowSwitched = !EQ (curSel, lastSelectedWindow);
+ if (windowSwitched)
+ lastSelectedWindow = curSel;
+
+ /* Now rebuild tree to pick up window configuration changes. */
+ [self rebuildAccessibilityTree];
+
+ /* Post focus change AFTER rebuild so the new element exists. */
+ if (windowSwitched)
+ {
+ id focused = [self accessibilityFocusedUIElement];
+ if (focused && focused != self)
+ NSAccessibilityPostNotification (focused,
+ NSAccessibilityFocusedUIElementChangedNotification);
+ }
+}
+
+/* ---- 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 +10891,14 @@
return [super accessibilityAttributeValue:attribute];
}
+
+- (id)accessibilityFocusedUIElement
+{
+ EmacsView *view = (EmacsView *)[self delegate];
+ if (view && [view respondsToSelector:@selector(accessibilityFocusedUIElement)])
+ return [view accessibilityFocusedUIElement];
+ return self;
+}
#endif /* NS_IMPL_COCOA */
/* Constrain size and placement of a frame.