From: Martin Sukany Date: Wed, 26 Feb 2026 00:00:00 +0100 Subject: [PATCH] ns: add macOS Zoom cursor tracking and VoiceOver accessibility Dual accessibility support: 1. UAZoomChangeFocus + accessibilityBoundsForRange on EmacsView for Zoom 2. Virtual element tree (EmacsAccessibilityBuffer) for VoiceOver 3. Typing echo from ns_draw_window_cursor on EmacsView 4. Full hierarchy plumbing: accessibilityWindow, accessibilityTopLevelUIElement, accessibilityParent, isAccessibilityFocused, FocusedUIElementChanged MRC compatible. --- --- a/src/nsterm.h 2026-02-26 08:46:18.118172281 +0100 +++ b/src/nsterm.h 2026-02-26 09:08:57.204955708 +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,12 @@ #ifdef NS_IMPL_COCOA char *old_title; BOOL maximizing_resize; + NSMutableArray *accessibilityElements; + Lisp_Object lastSelectedWindow; + @public + NSRect lastAccessibilityCursorRect; + ptrdiff_t lastAccessibilityModiff; + @protected #endif BOOL font_panel_active; NSFont *font_panel_result; @@ -528,6 +562,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 10:19:39.112307036 +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,75 @@ /* 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; + + struct buffer *curbuf + = XBUFFER (XWINDOW (f->selected_window)->contents); + + if (curbuf && BUF_MODIFF (curbuf) != view->lastAccessibilityModiff) + { + /* Buffer content changed — typing echo. Post ValueChanged + with rich userInfo on the VIEW so VoiceOver can speak. + kAXTextStateChangeTypeEdit = 1, kAXTextEditTypeTyping = 3. */ + view->lastAccessibilityModiff = BUF_MODIFF (curbuf); + + NSString *changedText = @""; + ptrdiff_t pt = BUF_PT (curbuf); + if (pt > BUF_BEGV (curbuf)) + { + NSRange charRange = NSMakeRange ( + (NSUInteger)(pt - BUF_BEGV (curbuf) - 1), 1); + changedText = [view accessibilityStringForRange:charRange]; + if (!changedText) + changedText = @""; + } + + NSDictionary *change = @{ + @"AXTextEditType": @3, + @"AXTextChangeValue": changedText + }; + NSDictionary *userInfo = @{ + @"AXTextStateChangeType": @1, + @"AXTextChangeValues": @[change] + }; + NSAccessibilityPostNotificationWithUserInfo ( + view, NSAccessibilityValueChangedNotification, userInfo); + } + + /* Always notify cursor movement. */ + NSAccessibilityPostNotification ( + view, NSAccessibilitySelectedTextChangedNotification); + + /* 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 +6923,650 @@ /* ========================================================================== + 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; +} + +/* ---- 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 + +/* ---- 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"; +} + +- (BOOL)isAccessibilityFocused +{ + /* Return YES when this buffer's window is the selected window. */ + 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 +{ + 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]; +} + +- (NSAttributedString *)accessibilityAttributedStringForRange:(NSRange)range +{ + NSString *str = [self accessibilityStringForRange:range]; + return [[[NSAttributedString alloc] initWithString:str] autorelease]; +} + +- (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]); +} + +- (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 +7607,7 @@ [layer release]; #endif + [accessibilityElements release]; [[self menu] release]; [super dealloc]; } @@ -8237,6 +8956,18 @@ 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 +10205,391 @@ 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]; +} + +- (NSInteger)accessibilityNumberOfCharacters +{ + if (!emacsframe) + return 0; + struct buffer *curbuf + = XBUFFER (XWINDOW (emacsframe->selected_window)->contents); + if (!curbuf) + return 0; + ptrdiff_t range = BUF_ZV (curbuf) - BUF_BEGV (curbuf); + return (NSInteger) MIN (range, 10000); +} + +- (NSString *)accessibilitySelectedText +{ + if (!emacsframe) + return @""; + struct buffer *curbuf + = XBUFFER (XWINDOW (emacsframe->selected_window)->contents); + if (!curbuf || NILP (BVAR (curbuf, mark_active))) + return @""; + return @""; +} + +- (NSInteger)accessibilityInsertionPointLineNumber +{ + if (!emacsframe) + return 0; + struct window *w = XWINDOW (emacsframe->selected_window); + if (!w) + return 0; + return (NSInteger) (w->cursor.vpos); +} + +- (NSRange)accessibilityVisibleCharacterRange +{ + if (!emacsframe) + return NSMakeRange (0, 0); + struct buffer *curbuf + = XBUFFER (XWINDOW (emacsframe->selected_window)->contents); + if (!curbuf) + return NSMakeRange (0, 0); + ptrdiff_t range = BUF_ZV (curbuf) - BUF_BEGV (curbuf); + return NSMakeRange (0, (NSUInteger) MIN (range, 10000)); +} + +- (NSAttributedString *)accessibilityAttributedStringForRange:(NSRange)range +{ + NSString *str = [self accessibilityStringForRange:range]; + return [[[NSAttributedString alloc] initWithString:str] autorelease]; +} + +- (NSInteger)accessibilityLineForIndex:(NSInteger)index +{ + if (!emacsframe) + return 0; + struct window *w = XWINDOW (emacsframe->selected_window); + if (!w || !w->current_matrix) + return 0; + struct buffer *curbuf = XBUFFER (w->contents); + if (!curbuf) + return 0; + ptrdiff_t charpos = BUF_BEGV (curbuf) + (ptrdiff_t) index; + 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) + continue; + if (MATRIX_ROW_START_CHARPOS (row) <= charpos + && charpos < MATRIX_ROW_END_CHARPOS (row)) + return (NSInteger) i; + } + return 0; +} + +- (NSRange)accessibilityRangeForLine:(NSInteger)line +{ + if (!emacsframe) + return NSMakeRange (0, 0); + struct window *w = XWINDOW (emacsframe->selected_window); + if (!w || !w->current_matrix) + return NSMakeRange (0, 0); + struct buffer *curbuf = XBUFFER (w->contents); + if (!curbuf) + return NSMakeRange (0, 0); + struct glyph_matrix *matrix = w->current_matrix; + if (line < 0 || line >= matrix->nrows) + return NSMakeRange (0, 0); + struct glyph_row *row = matrix->rows + line; + if (!row->enabled_p) + return NSMakeRange (0, 0); + ptrdiff_t start = MATRIX_ROW_START_CHARPOS (row) - BUF_BEGV (curbuf); + ptrdiff_t end = MATRIX_ROW_END_CHARPOS (row) - BUF_BEGV (curbuf); + if (start < 0) start = 0; + if (end < start) end = start; + return NSMakeRange ((NSUInteger) start, (NSUInteger) (end - start)); +} + +/* ---- 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 +11057,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.