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.