Files
emacs-doom/patches/0001-ns-implement-AXBoundsForRange-for-macOS-Zoom-cursor-.patch
Daneel 60afee0aec v13 patch: VoiceOver virtual accessibility tree
Complete rewrite of accessibility support. Instead of flat AXTextArea
on EmacsView, implements proper tree with virtual elements:

  EmacsWindow (AXWindow)
  └── EmacsView (AXGroup)
      ├── EmacsAccessibilityBuffer (AXTextArea) — window 1
      ├── EmacsAccessibilityBuffer (AXTextArea) — window 2
      └── ...

Each EmacsAccessibilityBuffer maps to one Emacs window and exposes:
- Buffer text via glyph row iteration (accurate, handles overlays)
- Visual line navigation (glyph_row vpos)
- Cursor tracking with glyph charpos mapping
- Hit testing (accessibilityRangeForPosition:)
- Selection range with active mark
- Screen geometry via pixel coordinate conversion
- Rich typing echo (kAXTextEditTypeTyping userInfo)
- UAZoomChangeFocus for Zoom

Dynamic tree rebuilt on each redisplay cycle from FRAME_ROOT_WINDOW.
Notifications on correct virtual elements (not container view).

3 review cycles (scores: 62 → 68 → 82), plus manual fixes for
hit testing and selection range.
2026-02-25 23:26:59 +01:00

896 lines
25 KiB
Diff

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)
Implement an accessibility tree using virtual NSAccessibilityElement
subclasses, enabling VoiceOver to read buffer contents, track cursor
movement, and announce window switches on macOS.
New classes:
- EmacsAccessibilityElement: base class with coordinate conversion
(Emacs pixel coords → NSView → screen coordinates)
- EmacsAccessibilityBuffer: AXTextArea virtual element, one per visible
Emacs window, implementing full text navigation protocol
EmacsView changes:
- Role: AXGroup (container for buffer elements)
- Dynamic children built by walking Emacs window tree
(FRAME_ROOT_WINDOW traversal, WINDOW_LEAF_P filtering)
- postAccessibilityUpdates called from ns_update_end hook
- Notifications: AXValueChanged (rich typing echo on edits),
AXSelectedTextChanged, AXFocusedUIElementChanged
Text extraction via glyph row iteration (CHAR_GLYPH glyphs),
exposing exactly what is rendered on screen, capped at 32KB.
Point-to-index mapping uses glyph charpos for accuracy.
Line navigation uses glyph_row vpos (visual screen lines).
UAZoomChangeFocus() retained for macOS Zoom viewport tracking.
Architecture modeled after TextMate's OakTextView accessibility
(~300 lines manual accessibility on custom NSView) and Xcode's
AXGroup → AXTextArea virtual element pattern.
Refs:
- Apple NSAccessibility protocol:
https://developer.apple.com/documentation/appkit/nsaccessibility
- Apple Accessibility Programming Guide:
https://developer.apple.com/library/archive/documentation/Accessibility/Conceptual/AccessibilityMacOSX/
- UAZoomChangeFocus:
https://developer.apple.com/documentation/applicationservices/1458830-uazoomchangefocus
Not implemented (future work):
- Minibuffer/echo area element
- Modeline element
- Attributed text with font/color info
- Custom VoiceOver rotors
- Editing via accessibility API (setAccessibilityValue:)
---
diff --git a/src/nsterm.h b/src/nsterm.h
index 7c1ee4c..fa758ed 100644
--- a/src/nsterm.h
+++ b/src/nsterm.h
@@ -453,6 +453,34 @@ enum ns_return_frame_mode
@end
+/* ==========================================================================
+
+ 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, weak) 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,8 @@ enum ns_return_frame_mode
#ifdef NS_IMPL_COCOA
char *old_title;
BOOL maximizing_resize;
+ NSMutableArray *accessibilityElements;
+ Lisp_Object lastSelectedWindow;
#endif
BOOL font_panel_active;
NSFont *font_panel_result;
@@ -528,6 +558,12 @@ enum ns_return_frame_mode
- (void)windowWillExitFullScreen;
- (void)windowDidExitFullScreen;
- (void)windowDidBecomeKey;
+
+#ifdef NS_IMPL_COCOA
+/* Accessibility support. */
+- (void)rebuildAccessibilityTree;
+- (void)postAccessibilityUpdates;
+#endif
@end
diff --git a/src/nsterm.m b/src/nsterm.m
index 932d209..e67edbe 100644
--- a/src/nsterm.m
+++ b/src/nsterm.m
@@ -1104,6 +1104,11 @@ static NSRect constrain_frame_rect(NSRect frameRect, bool isFullscreen)
unblock_input ();
ns_updating_frame = NULL;
+
+#ifdef NS_IMPL_COCOA
+ /* Post accessibility notifications after each redisplay cycle. */
+ [view postAccessibilityUpdates];
+#endif
}
static void
@@ -6847,6 +6852,610 @@ - (BOOL)fulfillService: (NSString *)name withArg: (NSString *)arg
}
#endif
+/* ==========================================================================
+
+ 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. */
+ NSDictionary *userInfo = @{
+ @"AXTextStateChangeType" : @1, /* AXTextStateChangeTypeEdit */
+ @"AXTextEditType" : @0 /* kAXTextEditTypeTyping */
+ };
+ NSAccessibilityPostNotificationWithUserInfo(
+ self, NSAccessibilityValueChangedNotification, userInfo);
+ }
+
+ /* Cursor moved? */
+ if (point != self.cachedPoint)
+ {
+ self.cachedPoint = point;
+ NSAccessibilityPostNotification(self,
+ NSAccessibilitySelectedTextChangedNotification);
+ }
+}
+
+@end
+
+#endif /* NS_IMPL_COCOA */
+
+
/* ==========================================================================
EmacsView implementation
@@ -9474,6 +10083,143 @@ - (int) fullscreenState
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 = newElements;
+}
+
+- (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);
+ }
+}
+
+#endif /* NS_IMPL_COCOA */
+
@end /* EmacsView */
@@ -9941,6 +10687,14 @@ - (id)accessibilityAttributeValue:(NSString *)attribute
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.