Files
emacs-doom/patches/v15-accessibility.patch
Daneel bc3731112f v15: buffer-relative accessibility rewrite (patch file)
Complete rewrite from glyph-based (v14) to buffer-relative design:
- ns_ax_buffer_text() with BUF_BEGV/ZV/PT, 100KB cap
- EmacsAccessibilityBuffer (AXTextArea per window)
- EmacsAccessibilityModeLine (AXStaticText per mode line)
- Enum fix: Edit=0, SelectionMove=1
- Text cache by BUF_MODIFF
- Tree rebuild on window config change only
- Minibuffer included
- Zoom code preserved (UAZoomChangeFocus)
- 1142 lines (nsterm.h +47, nsterm.m +1017)
2026-02-26 12:47:33 +01:00

1143 lines
30 KiB
Diff

diff --git a/nsterm.h b/nsterm.h
index 7c1ee4c..8bf21f6 100644
--- a/nsterm.h
+++ b/nsterm.h
@@ -453,6 +453,40 @@ 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, 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, retain) NSString *cachedText;
+@property (nonatomic, assign) ptrdiff_t cachedTextModiff;
+@property (nonatomic, assign) ptrdiff_t cachedTextStart;
+@property (nonatomic, assign) ptrdiff_t cachedModiff;
+@property (nonatomic, assign) ptrdiff_t cachedPoint;
+@property (nonatomic, assign) BOOL cachedMarkActive;
+- (void)invalidateTextCache;
+- (void)postAccessibilityNotificationsForFrame:(struct frame *)f;
+@end
+
+/* Virtual AXStaticText element — one per mode line. */
+@interface EmacsAccessibilityModeLine : EmacsAccessibilityElement
+@end
+#endif /* NS_IMPL_COCOA */
+
+
/* ==========================================================================
The main Emacs view
@@ -471,6 +505,12 @@ enum ns_return_frame_mode
#ifdef NS_IMPL_COCOA
char *old_title;
BOOL maximizing_resize;
+ NSMutableArray *accessibilityElements;
+ Lisp_Object lastSelectedWindow;
+ BOOL accessibilityTreeValid;
+ @public
+ NSRect lastAccessibilityCursorRect;
+ @protected
#endif
BOOL font_panel_active;
NSFont *font_panel_result;
@@ -528,6 +568,13 @@ enum ns_return_frame_mode
- (void)windowWillExitFullScreen;
- (void)windowDidExitFullScreen;
- (void)windowDidBecomeKey;
+
+#ifdef NS_IMPL_COCOA
+/* Accessibility support. */
+- (void)rebuildAccessibilityTree;
+- (void)invalidateAccessibilityTree;
+- (void)postAccessibilityUpdates;
+#endif
@end
diff --git a/nsterm.m b/nsterm.m
index 932d209..dd134dd 100644
--- a/nsterm.m
+++ b/nsterm.m
@@ -1104,6 +1104,11 @@ ns_update_end (struct frame *f)
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,37 @@ ns_draw_window_cursor (struct window *w, struct glyph_row *glyph_row,
/* 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: store cursor rect for Zoom and bounds queries.
+ VoiceOver notifications are handled solely by
+ postAccessibilityUpdates (called from ns_update_end)
+ to avoid duplicate notifications and mid-redisplay fragility. */
+ {
+ EmacsView *view = FRAME_NS_VIEW (f);
+ if (view && on_p && active_p)
+ {
+ 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];
@@ -6847,6 +6883,713 @@ ns_create_font_panel_buttons (id target, SEL select, SEL cancel_action)
}
#endif
+/* ==========================================================================
+
+ Accessibility virtual elements (macOS / Cocoa only)
+
+ ========================================================================== */
+
+#ifdef NS_IMPL_COCOA
+
+/* ---- Helper: extract buffer text for accessibility ---- */
+
+/* Maximum characters exposed via accessibilityValue. */
+#define NS_AX_TEXT_CAP 100000
+
+static NSString *
+ns_ax_buffer_text (struct window *w, ptrdiff_t *out_start)
+{
+ if (!w || !WINDOW_LEAF_P (w))
+ {
+ *out_start = 0;
+ return @"";
+ }
+
+ struct buffer *b = XBUFFER (w->contents);
+ if (!b)
+ {
+ *out_start = 0;
+ return @"";
+ }
+
+ ptrdiff_t begv = BUF_BEGV (b);
+ ptrdiff_t zv = BUF_ZV (b);
+ ptrdiff_t len = zv - begv;
+
+ /* Cap at NS_AX_TEXT_CAP characters, centered on point. */
+ if (len > NS_AX_TEXT_CAP)
+ {
+ ptrdiff_t pt = BUF_PT (b);
+ ptrdiff_t half = NS_AX_TEXT_CAP / 2;
+ ptrdiff_t start = MAX (begv, pt - half);
+ ptrdiff_t end = MIN (zv, start + NS_AX_TEXT_CAP);
+ start = MAX (begv, end - NS_AX_TEXT_CAP);
+ begv = start;
+ zv = end;
+ }
+
+ *out_start = begv;
+
+ if (zv <= begv)
+ return @"";
+
+ ptrdiff_t begv_byte = buf_charpos_to_bytepos (b, begv);
+ ptrdiff_t zv_byte = buf_charpos_to_bytepos (b, zv);
+ unsigned char *data = BUF_BYTE_ADDRESS (b, begv_byte);
+ ptrdiff_t nbytes = zv_byte - begv_byte;
+
+ Lisp_Object lstr = make_string_from_bytes ((char *) data,
+ zv - begv, nbytes);
+ return [NSString stringWithLispString:lstr];
+}
+
+
+/* ---- Helper: extract mode line text from glyph rows ---- */
+
+static NSString *
+ns_ax_mode_line_text (struct window *w)
+{
+ if (!w || !w->current_matrix)
+ return @"";
+
+ struct glyph_matrix *matrix = w->current_matrix;
+ NSMutableString *text = [NSMutableString string];
+
+ for (int i = 0; i < matrix->nrows; i++)
+ {
+ struct glyph_row *row = matrix->rows + i;
+ if (!row->enabled_p || !row->mode_line_p)
+ continue;
+
+ struct glyph *g = row->glyphs[TEXT_AREA];
+ struct glyph *end = g + row->used[TEXT_AREA];
+ for (; g < end; g++)
+ {
+ if (g->type == CHAR_GLYPH && g->u.ch >= 32)
+ {
+ unichar uch = (unichar) g->u.ch;
+ [text appendString:[NSString stringWithCharacters:&uch
+ length:1]];
+ }
+ }
+ }
+ return text;
+}
+
+
+/* ---- Helper: screen rect for a character range via glyph matrix ---- */
+
+static NSRect
+ns_ax_frame_for_range (struct window *w, EmacsView *view,
+ ptrdiff_t text_start, NSRange range)
+{
+ if (!w || !w->current_matrix || !view)
+ return NSZeroRect;
+
+ /* Convert range indices back to buffer charpos. */
+ ptrdiff_t cp_start = text_start + (ptrdiff_t) range.location;
+ ptrdiff_t cp_end = cp_start + (ptrdiff_t) range.length;
+
+ struct glyph_matrix *matrix = w->current_matrix;
+ 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;
+
+ ptrdiff_t row_start = MATRIX_ROW_START_CHARPOS (row);
+ ptrdiff_t row_end = MATRIX_ROW_END_CHARPOS (row);
+
+ if (row_start < cp_end && row_end > cp_start)
+ {
+ 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);
+ }
+ }
+
+ if (!found)
+ return NSZeroRect;
+
+ /* Convert from EmacsView (flipped) coords to screen coords. */
+ NSRect winRect = [view convertRect:result toView:nil];
+ return [[view window] convertRectToScreen:winRect];
+}
+
+
+@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;
+}
+
+/* ---- Hierarchy plumbing (required for VoiceOver to find us) ---- */
+
+- (id)accessibilityParent
+{
+ return NSAccessibilityUnignoredAncestor (self.emacsView);
+}
+
+- (id)accessibilityWindow
+{
+ return [self.emacsView window];
+}
+
+- (id)accessibilityTopLevelUIElement
+{
+ return [self.emacsView window];
+}
+
+@end
+
+
+@implementation EmacsAccessibilityBuffer
+
+- (void)dealloc
+{
+ [cachedText release];
+ [super dealloc];
+}
+
+/* ---- Text cache ---- */
+
+- (void)invalidateTextCache
+{
+ [cachedText release];
+ cachedText = nil;
+}
+
+- (void)ensureTextCache
+{
+ struct window *w = self.emacsWindow;
+ if (!w || !WINDOW_LEAF_P (w))
+ return;
+
+ struct buffer *b = XBUFFER (w->contents);
+ if (!b)
+ return;
+
+ ptrdiff_t modiff = BUF_MODIFF (b);
+ if (cachedText && cachedTextModiff == modiff)
+ return;
+
+ ptrdiff_t start;
+ NSString *text = ns_ax_buffer_text (w, &start);
+
+ [cachedText release];
+ cachedText = [text retain];
+ cachedTextModiff = modiff;
+ cachedTextStart = start;
+}
+
+/* ---- NSAccessibility protocol ---- */
+
+- (NSAccessibilityRole)accessibilityRole
+{
+ return NSAccessibilityTextAreaRole;
+}
+
+- (NSString *)accessibilityRoleDescription
+{
+ struct window *w = self.emacsWindow;
+ if (w && MINI_WINDOW_P (w))
+ return @"minibuffer";
+ return @"editor";
+}
+
+- (NSString *)accessibilityLabel
+{
+ struct window *w = self.emacsWindow;
+ if (w && WINDOW_LEAF_P (w))
+ {
+ if (MINI_WINDOW_P (w))
+ return @"Minibuffer";
+
+ struct buffer *b = XBUFFER (w->contents);
+ if (b)
+ {
+ Lisp_Object name = BVAR (b, name);
+ if (STRINGP (name))
+ return [NSString stringWithLispString:name];
+ }
+ }
+ return @"buffer";
+}
+
+- (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)accessibilityValue
+{
+ [self ensureTextCache];
+ return cachedText ? cachedText : @"";
+}
+
+- (NSInteger)accessibilityNumberOfCharacters
+{
+ [self ensureTextCache];
+ return cachedText ? [cachedText length] : 0;
+}
+
+- (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 @"";
+
+ NSRange sel = [self accessibilitySelectedTextRange];
+ [self ensureTextCache];
+ if (!cachedText || sel.location == NSNotFound
+ || sel.location + sel.length > [cachedText length])
+ return @"";
+ return [cachedText 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);
+
+ [self ensureTextCache];
+ ptrdiff_t pt = BUF_PT (b);
+ NSUInteger point_idx = (NSUInteger) (pt - cachedTextStart);
+
+ if (NILP (BVAR (b, mark_active)))
+ return NSMakeRange (point_idx, 0);
+
+ ptrdiff_t mark_pos = marker_position (BVAR (b, mark));
+ NSUInteger mark_idx = (NSUInteger) (mark_pos - cachedTextStart);
+ NSUInteger start_idx = MIN (point_idx, mark_idx);
+ NSUInteger end_idx = MAX (point_idx, mark_idx);
+ return NSMakeRange (start_idx, end_idx - start_idx);
+}
+
+- (NSInteger)accessibilityInsertionPointLineNumber
+{
+ struct window *w = self.emacsWindow;
+ if (!w || !WINDOW_LEAF_P (w))
+ return 0;
+
+ struct buffer *b = XBUFFER (w->contents);
+ if (!b)
+ return 0;
+
+ [self ensureTextCache];
+ if (!cachedText)
+ return 0;
+
+ ptrdiff_t pt = BUF_PT (b);
+ NSUInteger point_idx = (NSUInteger) (pt - cachedTextStart);
+ if (point_idx > [cachedText length])
+ point_idx = [cachedText length];
+
+ /* Count newlines from start to point_idx. */
+ NSInteger line = 0;
+ for (NSUInteger i = 0; i < point_idx; i++)
+ {
+ if ([cachedText characterAtIndex:i] == '\n')
+ line++;
+ }
+ return line;
+}
+
+- (NSString *)accessibilityStringForRange:(NSRange)range
+{
+ [self ensureTextCache];
+ if (!cachedText || range.location + range.length > [cachedText length])
+ return @"";
+ return [cachedText substringWithRange:range];
+}
+
+- (NSAttributedString *)accessibilityAttributedStringForRange:(NSRange)range
+{
+ NSString *str = [self accessibilityStringForRange:range];
+ return [[[NSAttributedString alloc] initWithString:str] autorelease];
+}
+
+- (NSInteger)accessibilityLineForIndex:(NSInteger)index
+{
+ [self ensureTextCache];
+ if (!cachedText || index < 0)
+ return 0;
+
+ NSUInteger idx = (NSUInteger) index;
+ if (idx > [cachedText length])
+ idx = [cachedText length];
+
+ /* Count newlines from start of cachedText to idx. */
+ NSInteger line = 0;
+ for (NSUInteger i = 0; i < idx; i++)
+ {
+ if ([cachedText characterAtIndex:i] == '\n')
+ line++;
+ }
+ return line;
+}
+
+- (NSRange)accessibilityRangeForLine:(NSInteger)line
+{
+ [self ensureTextCache];
+ if (!cachedText || line < 0)
+ return NSMakeRange (NSNotFound, 0);
+
+ NSUInteger len = [cachedText length];
+ NSInteger cur_line = 0;
+ NSUInteger line_start = 0;
+
+ for (NSUInteger i = 0; i <= len; i++)
+ {
+ if (cur_line == line)
+ {
+ /* Find end of this line. */
+ NSUInteger line_end = i;
+ while (line_end < len
+ && [cachedText characterAtIndex:line_end] != '\n')
+ line_end++;
+ return NSMakeRange (i, line_end - i);
+ }
+ if (i < len && [cachedText characterAtIndex:i] == '\n')
+ {
+ cur_line++;
+ line_start = i + 1;
+ }
+ }
+ return NSMakeRange (NSNotFound, 0);
+}
+
+- (NSRange)accessibilityRangeForIndex:(NSInteger)index
+{
+ [self ensureTextCache];
+ if (!cachedText || index < 0
+ || (NSUInteger) index >= [cachedText length])
+ return NSMakeRange (NSNotFound, 0);
+ return NSMakeRange ((NSUInteger) index, 1);
+}
+
+- (NSRange)accessibilityStyleRangeForIndex:(NSInteger)index
+{
+ /* Return the range of the current line — simple approach. */
+ NSInteger line = [self accessibilityLineForIndex:index];
+ return [self accessibilityRangeForLine: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, cachedTextStart, range);
+}
+
+- (NSRange)accessibilityRangeForPosition:(NSPoint)screenPoint
+{
+ /* Hit test: convert screen point to buffer character index. */
+ 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 window-relative pixel coordinates. */
+ 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;
+
+ for (int i = 0; i < matrix->nrows; i++)
+ {
+ struct glyph_row *row = matrix->rows + i;
+ if (!row->enabled_p || !row->displays_text_p || row->mode_line_p)
+ continue;
+ int row_top = WINDOW_TO_FRAME_PIXEL_Y (w, MAX (0, row->y));
+ if ((int) viewPoint.y >= row_top
+ && (int) viewPoint.y < row_top + row->visible_height)
+ {
+ hit_row = row;
+ break;
+ }
+ }
+
+ 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. */
+ [self ensureTextCache];
+ NSUInteger ax_idx = (NSUInteger) (best_charpos - cachedTextStart);
+ if (cachedText && ax_idx > [cachedText length])
+ ax_idx = [cachedText length];
+ return NSMakeRange (ax_idx, 1);
+}
+
+- (NSRange)accessibilityVisibleCharacterRange
+{
+ struct window *w = self.emacsWindow;
+ if (!w || !w->current_matrix)
+ {
+ [self ensureTextCache];
+ return NSMakeRange (0, cachedText ? [cachedText length] : 0);
+ }
+
+ [self ensureTextCache];
+ if (!cachedText)
+ return NSMakeRange (0, 0);
+
+ /* Compute visible range from window start to last visible row. */
+ ptrdiff_t vis_start = w->start_charpos;
+ ptrdiff_t vis_end = vis_start;
+
+ struct glyph_matrix *matrix = w->current_matrix;
+ for (int i = matrix->nrows - 1; i >= 0; 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))
+ {
+ vis_end = MATRIX_ROW_END_CHARPOS (row);
+ break;
+ }
+ }
+
+ NSUInteger loc = (NSUInteger) (vis_start - cachedTextStart);
+ NSUInteger end = (NSUInteger) (vis_end - cachedTextStart);
+ NSUInteger text_len = [cachedText length];
+ if (loc > text_len) loc = text_len;
+ if (end > text_len) end = text_len;
+ if (end < loc) end = loc;
+
+ return NSMakeRange (loc, end - loc);
+}
+
+- (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)postAccessibilityNotificationsForFrame:(struct frame *)f
+{
+ struct window *w = self.emacsWindow;
+ 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);
+ BOOL markActive = !NILP (BVAR (b, mark_active));
+
+ /* --- Text changed → typing echo ---
+ kAXTextStateChangeTypeEdit = 0, kAXTextEditTypeTyping = 3. */
+ if (modiff != self.cachedModiff)
+ {
+ /* Capture changed char before invalidating cache. */
+ NSString *changedChar = @"";
+ if (point > self.cachedPoint
+ && point - self.cachedPoint == 1)
+ {
+ /* Single char inserted — refresh cache and grab it. */
+ [self invalidateTextCache];
+ [self ensureTextCache];
+ if (cachedText)
+ {
+ NSUInteger idx = (NSUInteger) (point - 1 - cachedTextStart);
+ if (idx < [cachedText length])
+ changedChar = [cachedText substringWithRange:
+ NSMakeRange (idx, 1)];
+ }
+ }
+ else
+ {
+ [self invalidateTextCache];
+ }
+
+ self.cachedModiff = modiff;
+
+ NSDictionary *change = @{
+ @"AXTextEditType": @3,
+ @"AXTextChangeValue": changedChar,
+ @"AXTextChangeValueLength": @([changedChar length])
+ };
+ NSDictionary *userInfo = @{
+ @"AXTextStateChangeType": @0,
+ @"AXTextChangeValues": @[change]
+ };
+ NSAccessibilityPostNotificationWithUserInfo (
+ self, NSAccessibilityValueChangedNotification, userInfo);
+ }
+
+ /* --- Cursor moved or selection changed → line reading ---
+ kAXTextStateChangeTypeSelectionMove = 1. */
+ if (point != self.cachedPoint || markActive != self.cachedMarkActive)
+ {
+ self.cachedPoint = point;
+ self.cachedMarkActive = markActive;
+
+ NSDictionary *moveInfo = @{
+ @"AXTextStateChangeType": @1,
+ @"AXTextSelectionDirection": @4,
+ @"AXTextSelectionGranularity": @3
+ };
+ NSAccessibilityPostNotificationWithUserInfo (
+ self,
+ NSAccessibilitySelectedTextChangedNotification,
+ moveInfo);
+ }
+}
+
+@end
+
+
+@implementation EmacsAccessibilityModeLine
+
+- (NSAccessibilityRole)accessibilityRole
+{
+ return NSAccessibilityStaticTextRole;
+}
+
+- (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))
+ {
+ NSString *bufName = [NSString stringWithLispString:name];
+ return [NSString stringWithFormat:@"Mode Line - %@", bufName];
+ }
+ }
+ }
+ return @"Mode Line";
+}
+
+- (id)accessibilityValue
+{
+ struct window *w = self.emacsWindow;
+ if (!w)
+ return @"";
+ return ns_ax_mode_line_text (w);
+}
+
+- (NSRect)accessibilityFrame
+{
+ struct window *w = self.emacsWindow;
+ if (!w || !w->current_matrix)
+ return NSZeroRect;
+
+ /* Find the mode line row and return its screen rect. */
+ struct glyph_matrix *matrix = w->current_matrix;
+ for (int i = 0; i < matrix->nrows; i++)
+ {
+ struct glyph_row *row = matrix->rows + i;
+ if (row->enabled_p && row->mode_line_p)
+ {
+ return [self screenRectFromEmacsX:w->pixel_left
+ y:WINDOW_TO_FRAME_PIXEL_Y (w,
+ MAX (0, row->y))
+ width:w->pixel_width
+ height:row->visible_height];
+ }
+ }
+ return NSZeroRect;
+}
+
+@end
+
+#endif /* NS_IMPL_COCOA */
+
+
/* ==========================================================================
EmacsView implementation
@@ -6889,6 +7632,7 @@ ns_create_font_panel_buttons (id target, SEL select, SEL cancel_action)
[layer release];
#endif
+ [accessibilityElements release];
[[self menu] release];
[super dealloc];
}
@@ -8237,6 +8981,18 @@ ns_in_echo_area (void)
XSETFRAME (event.frame_or_window, emacsframe);
kbd_buffer_store_event (&event);
ns_send_appdefined (-1); // Kick main loop
+
+#ifdef NS_IMPL_COCOA
+ /* Notify VoiceOver that the focused accessibility element changed.
+ Post on the focused virtual element so VoiceOver starts tracking it.
+ This is critical for initial focus and app-switch scenarios. */
+ {
+ id focused = [self accessibilityFocusedUIElement];
+ if (focused)
+ NSAccessibilityPostNotification (focused,
+ NSAccessibilityFocusedUIElementChangedNotification);
+ }
+#endif
}
@@ -9474,6 +10230,259 @@ ns_in_echo_area (void)
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,
+ NSDictionary *existing)
+{
+ if (NILP (window))
+ return;
+
+ struct window *w = XWINDOW (window);
+
+ if (WINDOW_LEAF_P (w))
+ {
+ /* Buffer element — reuse existing if available. */
+ EmacsAccessibilityBuffer *elem
+ = [existing objectForKey:[NSValue valueWithPointer:w]];
+ if (!elem)
+ {
+ elem = [[EmacsAccessibilityBuffer alloc] init];
+ elem.emacsView = view;
+
+ /* 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);
+ elem.cachedMarkActive = !NILP (BVAR (b, mark_active));
+ }
+ }
+ else
+ {
+ [elem retain];
+ }
+ elem.emacsWindow = w;
+ [elements addObject:elem];
+ [elem release];
+
+ /* Mode line element (skip for minibuffer). */
+ if (!MINI_WINDOW_P (w))
+ {
+ EmacsAccessibilityModeLine *ml
+ = [[EmacsAccessibilityModeLine alloc] init];
+ ml.emacsView = view;
+ ml.emacsWindow = w;
+ [elements addObject:ml];
+ [ml release];
+ }
+ }
+ else
+ {
+ /* Internal (combination) window — recurse into children. */
+ Lisp_Object child = w->contents;
+ while (!NILP (child))
+ {
+ ns_ax_collect_windows (child, view, elements, existing);
+ child = XWINDOW (child)->next;
+ }
+ }
+}
+
+- (void)rebuildAccessibilityTree
+{
+ if (!emacsframe)
+ return;
+
+ /* Build map of existing elements by window pointer for reuse. */
+ NSMutableDictionary *existing = [NSMutableDictionary dictionary];
+ if (accessibilityElements)
+ {
+ for (EmacsAccessibilityElement *elem in accessibilityElements)
+ {
+ if ([elem isKindOfClass:[EmacsAccessibilityBuffer class]]
+ && elem.emacsWindow)
+ [existing setObject:elem
+ forKey:[NSValue valueWithPointer:
+ elem.emacsWindow]];
+ }
+ }
+
+ NSMutableArray *newElements = [NSMutableArray arrayWithCapacity:8];
+
+ /* Collect from main window tree. */
+ Lisp_Object root = FRAME_ROOT_WINDOW (emacsframe);
+ ns_ax_collect_windows (root, self, newElements, existing);
+
+ /* Include minibuffer. */
+ Lisp_Object mini = emacsframe->minibuffer_window;
+ if (!NILP (mini))
+ ns_ax_collect_windows (mini, self, newElements, existing);
+
+ [accessibilityElements release];
+ accessibilityElements = [newElements retain];
+ accessibilityTreeValid = YES;
+}
+
+- (void)invalidateAccessibilityTree
+{
+ accessibilityTreeValid = NO;
+}
+
+- (NSAccessibilityRole)accessibilityRole
+{
+ return NSAccessibilityGroupRole;
+}
+
+- (NSString *)accessibilityLabel
+{
+ return @"Emacs";
+}
+
+- (BOOL)isAccessibilityElement
+{
+ return YES;
+}
+
+- (NSArray *)accessibilityChildren
+{
+ if (!accessibilityElements || !accessibilityTreeValid)
+ [self rebuildAccessibilityTree];
+ return accessibilityElements;
+}
+
+- (id)accessibilityFocusedUIElement
+{
+ if (!emacsframe)
+ return self;
+
+ if (!accessibilityElements || !accessibilityTreeValid)
+ [self rebuildAccessibilityTree];
+
+ struct window *sel = XWINDOW (emacsframe->selected_window);
+ for (EmacsAccessibilityElement *elem in accessibilityElements)
+ {
+ if ([elem isKindOfClass:[EmacsAccessibilityBuffer class]]
+ && 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 (EmacsAccessibilityElement *elem in accessibilityElements)
+ {
+ if ([elem isKindOfClass:[EmacsAccessibilityBuffer class]])
+ {
+ struct window *w = elem.emacsWindow;
+ if (w && WINDOW_LEAF_P (w))
+ [(EmacsAccessibilityBuffer *) elem
+ postAccessibilityNotificationsForFrame: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;
+
+ /* Rebuild tree only if window configuration changed. */
+ if (!accessibilityTreeValid)
+ [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)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];
+}
+
+/* ---- 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 +10950,14 @@ nswindow_orderedIndex_sort (id w1, id w2, void *c)
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.