diff --git a/patches/v15-accessibility.patch b/patches/v15-accessibility.patch new file mode 100644 index 0000000..6265cc7 --- /dev/null +++ b/patches/v15-accessibility.patch @@ -0,0 +1,1142 @@ +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.