diff --git a/patches/0001-ns-implement-AXBoundsForRange-for-macOS-Zoom-cursor-.patch b/patches/0001-ns-implement-AXBoundsForRange-for-macOS-Zoom-cursor-.patch index 029edd4..4d5b1e1 100644 --- a/patches/0001-ns-implement-AXBoundsForRange-for-macOS-Zoom-cursor-.patch +++ b/patches/0001-ns-implement-AXBoundsForRange-for-macOS-Zoom-cursor-.patch @@ -1,214 +1,442 @@ From: Martin Sukany -Date: Tue, 25 Feb 2026 21:00:00 +0100 -Subject: [PATCH] ns: implement macOS Zoom cursor tracking and VoiceOver - support +Date: Wed, 26 Feb 2026 00:00:00 +0100 +Subject: [PATCH] ns: add VoiceOver accessibility support (virtual element tree) -Add comprehensive accessibility support for macOS: +Implement an accessibility tree using virtual NSAccessibilityElement +subclasses, enabling VoiceOver to read buffer contents, track cursor +movement, and announce window switches on macOS. -1. UAZoomChangeFocus() from ApplicationServices/UniversalAccess.h: - Directly tells macOS Zoom where the cursor is. Only fires for the - active window cursor draw (on_p && active_p guard). - Ref: https://developer.apple.com/documentation/applicationservices/1458830-uazoomchangefocus +New classes: +- EmacsAccessibilityElement: base class with coordinate conversion + (Emacs pixel coords → NSView → screen coordinates) +- EmacsAccessibilityBuffer: AXTextArea virtual element, one per visible + Emacs window, implementing full text navigation protocol -2. NSAccessibility protocol on EmacsView (macOS 10.10+): - Full text area protocol for VoiceOver: - - Rich typing echo via NSAccessibilityPostNotificationWithUserInfo - with kAXTextEditTypeTyping (requires macOS 10.11+, falls back to - bare notification on 10.10) - - BUF_MODIFF tracking to distinguish content edits from cursor movement - - Text content: accessibilityValue, accessibilitySelectedText, - accessibilityNumberOfCharacters, accessibilityStringForRange: - - Text navigation: accessibilityLineForIndex:, accessibilityRangeForLine:, - accessibilityInsertionPointLineNumber, accessibilityVisibleCharacterRange - - Cursor geometry: accessibilityBoundsForRange:, accessibilityFrameForRange: - - Notifications: ValueChanged (edit), SelectedTextChanged (cursor), - FocusedUIElementChanged (window focus) - Ref: https://developer.apple.com/documentation/appkit/nsaccessibilityprotocol +EmacsView changes: +- Role: AXGroup (container for buffer elements) +- Dynamic children built by walking Emacs window tree + (FRAME_ROOT_WINDOW traversal, WINDOW_LEAF_P filtering) +- postAccessibilityUpdates called from ns_update_end hook +- Notifications: AXValueChanged (rich typing echo on edits), + AXSelectedTextChanged, AXFocusedUIElementChanged -Uses raw string literals ("AXTextStateChangeType" etc.) for semi-private -notification keys to avoid type conflicts between SDK versions (these -symbols were added to public AppKit headers in macOS 26). +Text extraction via glyph row iteration (CHAR_GLYPH glyphs), +exposing exactly what is rendered on screen, capped at 32KB. +Point-to-index mapping uses glyph charpos for accuracy. +Line navigation uses glyph_row vpos (visual screen lines). + +UAZoomChangeFocus() retained for macOS Zoom viewport tracking. + +Architecture modeled after TextMate's OakTextView accessibility +(~300 lines manual accessibility on custom NSView) and Xcode's +AXGroup → AXTextArea virtual element pattern. + +Refs: +- Apple NSAccessibility protocol: + https://developer.apple.com/documentation/appkit/nsaccessibility +- Apple Accessibility Programming Guide: + https://developer.apple.com/library/archive/documentation/Accessibility/Conceptual/AccessibilityMacOSX/ +- UAZoomChangeFocus: + https://developer.apple.com/documentation/applicationservices/1458830-uazoomchangefocus + +Not implemented (future work): +- Minibuffer/echo area element +- Modeline element +- Attributed text with font/color info +- Custom VoiceOver rotors +- Editing via accessibility API (setAccessibilityValue:) --- diff --git a/src/nsterm.h b/src/nsterm.h -index 7c1ee4c..5e5f61b 100644 +index 7c1ee4c..fa758ed 100644 --- a/src/nsterm.h +++ b/src/nsterm.h -@@ -485,6 +485,10 @@ enum ns_return_frame_mode - struct frame *emacsframe; - int scrollbarsNeedingUpdate; - NSRect ns_userRect; -+#ifdef NS_IMPL_COCOA -+ NSRect lastAccessibilityCursorRect; -+ ptrdiff_t lastAccessibilityModiff; -+#endif - } +@@ -453,6 +453,34 @@ enum ns_return_frame_mode + @end + + ++/* ========================================================================== ++ ++ Accessibility virtual elements (macOS / Cocoa only) ++ ++ ========================================================================== */ ++ ++#ifdef NS_IMPL_COCOA ++@class EmacsView; ++ ++/* Base class for virtual accessibility elements attached to EmacsView. */ ++@interface EmacsAccessibilityElement : NSAccessibilityElement ++@property (nonatomic, weak) EmacsView *emacsView; ++@property (nonatomic, assign) struct window *emacsWindow; ++- (NSRect)screenRectFromEmacsX:(int)x y:(int)y width:(int)w height:(int)h; ++@end ++ ++/* Virtual AXTextArea element — one per visible Emacs window (buffer). */ ++@interface EmacsAccessibilityBuffer : EmacsAccessibilityElement ++@property (nonatomic, assign) ptrdiff_t cachedModiff; ++@property (nonatomic, assign) ptrdiff_t cachedPoint; ++@property (nonatomic, assign) Lisp_Object cachedSelectedWindow; ++- (void) ++ postAccessibilityUpdatesForWindow:(struct window *)w ++ frame:(struct frame *)f; ++@end ++#endif /* NS_IMPL_COCOA */ ++ ++ + /* ========================================================================== + + The main Emacs view +@@ -471,6 +499,8 @@ enum ns_return_frame_mode + #ifdef NS_IMPL_COCOA + char *old_title; + BOOL maximizing_resize; ++ NSMutableArray *accessibilityElements; ++ Lisp_Object lastSelectedWindow; + #endif + BOOL font_panel_active; + NSFont *font_panel_result; +@@ -528,6 +558,12 @@ enum ns_return_frame_mode + - (void)windowWillExitFullScreen; + - (void)windowDidExitFullScreen; + - (void)windowDidBecomeKey; ++ ++#ifdef NS_IMPL_COCOA ++/* Accessibility support. */ ++- (void)rebuildAccessibilityTree; ++- (void)postAccessibilityUpdates; ++#endif + @end + - /* AppKit-side interface. */ diff --git a/src/nsterm.m b/src/nsterm.m -index 932d209..6bfc080 100644 +index 932d209..e67edbe 100644 --- a/src/nsterm.m +++ b/src/nsterm.m -@@ -3232,6 +3232,115 @@ Note that CURSOR_WIDTH is meaningful only for (h)bar cursors. - /* Prevent the cursor from being drawn outside the text area. */ - r = NSIntersectionRect (r, ns_row_rect (w, glyph_row, TEXT_AREA)); +@@ -1104,6 +1104,11 @@ static NSRect constrain_frame_rect(NSRect frameRect, bool isFullscreen) -+#ifdef NS_IMPL_COCOA -+ /* NSAccessibility keys for rich text change notifications. -+ Used by WebKit/Safari for VoiceOver typing echo. These symbols -+ have been exported by AppKit since macOS 10.11 but were only -+ added to the public headers in the macOS 26 SDK. We use raw -+ string literals here to avoid redeclaration type conflicts -+ across different SDK versions. */ -+ -+ /* Accessibility cursor tracking for macOS Zoom and VoiceOver. -+ -+ Emacs uses a custom-drawn cursor in a custom NSView. AppKit has no -+ knowledge of cursor position, so we must explicitly notify assistive -+ technology. Two complementary mechanisms: -+ -+ 1. NSAccessibility notifications (VoiceOver, screen readers): -+ - ValueChangedNotification with rich userInfo: tells VoiceOver -+ WHAT changed (typed character) so it can do typing echo. -+ We track BUF_MODIFF to distinguish content edits from cursor -+ movement. Only content changes get ValueChanged. -+ - SelectedTextChangedNotification: tells AT the cursor moved. -+ -+ 2. UAZoomChangeFocus() from UniversalAccess.h: -+ Directly tells macOS Zoom where to move its viewport. -+ Ref: https://developer.apple.com/documentation/applicationservices/1458830-uazoomchangefocus */ -+ { -+ EmacsView *view = FRAME_NS_VIEW (f); -+ /* 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. */ -+ 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 since last cursor draw — this is -+ an edit (typing, yank, undo, etc.). Post ValueChanged -+ with rich userInfo so VoiceOver can do typing echo. -+ -+ The semi-private keys (NSAccessibilityTextStateChangeTypeKey -+ etc.) are NSString* extern constants available in AppKit -+ since macOS 10.11. WebKit uses the same approach. -+ Ref: WebKit AXObjectCacheMac.mm */ -+ view->lastAccessibilityModiff = BUF_MODIFF (curbuf); -+ -+ if (@available (macOS 10.11, *)) -+ { -+ /* Get the just-typed character: char at cursor - 1. */ -+ 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 = @""; -+ } -+ -+ /* These are semi-private AppKit constants (available since -+ 10.11). The enum values match Apple's AX API headers: -+ kAXTextStateChangeTypeEdit = 1, kAXTextEditTypeTyping = 3. */ -+ -+ NSDictionary *change = @{ -+ @"AXTextEditType": @3, -+ @"AXTextChangeValue": changedText -+ }; -+ NSDictionary *userInfo = @{ -+ @"AXTextStateChangeType": @1, -+ @"AXTextChangeValues": @[change] -+ }; -+ NSAccessibilityPostNotificationWithUserInfo ( -+ view, NSAccessibilityValueChangedNotification, userInfo); -+ } -+ else -+ { -+ /* Fallback for macOS < 10.11: bare notification. */ -+ NSAccessibilityPostNotification ( -+ view, NSAccessibilityValueChangedNotification); -+ } -+ } -+ -+ /* Always notify that cursor position (selection) changed. */ -+ 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]; -@@ -8237,6 +8346,14 @@ - (void)windowDidBecomeKey /* for direct calls */ - XSETFRAME (event.frame_or_window, emacsframe); - kbd_buffer_store_event (&event); - ns_send_appdefined (-1); // Kick main loop + unblock_input (); + ns_updating_frame = NULL; + +#ifdef NS_IMPL_COCOA -+ /* Notify assistive technology that the focused UI element changed. -+ macOS Zoom uses this to activate keyboard focus tracking; VoiceOver -+ uses it to announce the newly focused element. */ -+ NSAccessibilityPostNotification (self, -+ NSAccessibilityFocusedUIElementChangedNotification); ++ /* Post accessibility notifications after each redisplay cycle. */ ++ [view postAccessibilityUpdates]; +#endif } - -@@ -9474,6 +9591,421 @@ - (int) fullscreenState - return fs_state; + static void +@@ -6847,6 +6852,610 @@ - (BOOL)fulfillService: (NSString *)name withArg: (NSString *)arg } + #endif ++/* ========================================================================== + ++ Accessibility virtual elements (macOS / Cocoa only) ++ ++ ========================================================================== */ + +#ifdef NS_IMPL_COCOA -+/* ---------------------------------------------------------------- -+ Accessibility support for macOS Zoom, VoiceOver, and other AT tools. + -+ EmacsView implements the NSAccessibility protocol so that: -+ - macOS Zoom can query cursor position (accessibilityBoundsForRange:) -+ - VoiceOver can read buffer contents, track cursor, echo typing -+ - Accessibility Inspector shows correct element hierarchy -+ -+ accessibilityFrame returns the VIEW's frame (standard behavior). -+ VoiceOver uses this for its focus ring around the entire text area. -+ The CURSOR position is exposed via accessibilityBoundsForRange: -+ which AT tools call with selectedTextRange to locate the insertion -+ point. Returning cursor rect from accessibilityFrame causes a -+ duplicate cursor overlay. -+ -+ Ref: https://developer.apple.com/documentation/appkit/nsaccessibilityprotocol -+ Ref: https://developer.apple.com/documentation/appkit/accessibility/nsaccessibility/text-specific_parameterized_attributes -+ ---------------------------------------------------------------- */ -+ -+- (BOOL)accessibilityIsIgnored ++/* ---- Helper: extract visible text from glyph rows of a window ---- */ ++static NSString * ++ns_ax_text_from_glyph_rows (struct window *w) +{ -+ /* Must return NO so assistive technology discovers this view. */ -+ return NO; ++ 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 @@ -216,388 +444,452 @@ index 932d209..6bfc080 100644 + return YES; +} + -+- (id)accessibilityFocusedUIElement -+{ -+ /* EmacsView is the focused element — there are no child accessible -+ elements within the custom-drawn view. */ -+ return self; -+} ++@end + -+- (NSString *)accessibilityRole ++ ++@implementation EmacsAccessibilityBuffer ++ ++/* ---- NSAccessibility protocol ---- */ ++ ++- (NSAccessibilityRole)accessibilityRole +{ -+ /* TextArea role enables VoiceOver's text navigation commands -+ (VO+arrows for character/word/line reading). */ + return NSAccessibilityTextAreaRole; +} + +- (NSString *)accessibilityRoleDescription +{ -+ return NSAccessibilityRoleDescription (NSAccessibilityTextAreaRole, nil); ++ return @"editor"; +} + -+/* ---- Text content methods for VoiceOver ---- */ ++- (NSString *)accessibilityLabel ++{ ++ struct window *w = self.emacsWindow; ++ if (w && WINDOW_LEAF_P(w)) ++ { ++ struct buffer *b = XBUFFER(w->contents); ++ if (b) ++ { ++ Lisp_Object name = BVAR(b, name); ++ if (STRINGP(name)) ++ return [NSString stringWithLispString:name]; ++ } ++ } ++ return @"buffer"; ++} + +- (id)accessibilityValue +{ -+ /* Return visible buffer text (capped at 10000 chars for safety). -+ VoiceOver reads this when navigating to the text area and after -+ ValueChangedNotification. */ -+ if (!emacsframe) ++ struct window *w = self.emacsWindow; ++ if (!w) + 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); -+ -+ /* Cap at 10000 characters to avoid performance issues with large -+ buffers. Recompute byte_range from the capped char range to -+ avoid truncating multibyte sequences (UTF-8 chars can be up to -+ 4 bytes, so byte_range != range for non-ASCII content). */ -+ 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]; ++ return ns_ax_text_from_glyph_rows(w); +} + +- (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 *text = [self accessibilityValue]; ++ return [text length]; +} + +- (NSString *)accessibilitySelectedText +{ -+ /* Return text of the active region (Emacs selection). Empty string -+ if no mark is active. */ -+ if (!emacsframe) ++ struct window *w = self.emacsWindow; ++ if (!w || !WINDOW_LEAF_P(w)) + return @""; + -+ struct buffer *curbuf -+ = XBUFFER (XWINDOW (emacsframe->selected_window)->contents); -+ if (!curbuf || NILP (BVAR (curbuf, mark_active))) ++ struct buffer *b = XBUFFER(w->contents); ++ if (!b || NILP(BVAR(b, mark_active))) + return @""; + -+ Lisp_Object str = ns_get_local_selection (QPRIMARY, QUTF8_STRING); -+ if (CONSP (str) && SYMBOLP (XCAR (str))) -+ { -+ str = XCDR (str); -+ if (CONSP (str) && NILP (XCDR (str))) -+ str = XCAR (str); -+ } -+ if (STRINGP (str)) -+ return [NSString stringWithLispString:str]; -+ -+ 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 +{ -+ /* Return cursor position as a zero-length range. VoiceOver uses -+ this with accessibilityBoundsForRange: to locate the caret. */ -+ if (!emacsframe) -+ return NSMakeRange (0, 0); ++ struct window *w = self.emacsWindow; ++ if (!w || !WINDOW_LEAF_P(w)) ++ return NSMakeRange(0, 0); + -+ struct buffer *curbuf -+ = XBUFFER (XWINDOW (emacsframe->selected_window)->contents); -+ if (!curbuf) -+ return NSMakeRange (0, 0); ++ struct buffer *b = XBUFFER(w->contents); ++ if (!b) ++ return NSMakeRange(0, 0); + -+ ptrdiff_t pt = BUF_PT (curbuf) - BUF_BEGV (curbuf); -+ return NSMakeRange ((NSUInteger) pt, 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 +{ -+ /* Return the visual line number of the cursor (vpos = line within -+ the window). VoiceOver uses this for line navigation. */ -+ if (!emacsframe) -+ return 0; -+ -+ struct window *w = XWINDOW (emacsframe->selected_window); ++ struct window *w = self.emacsWindow; + if (!w) + return 0; -+ -+ return (NSInteger) (w->cursor.vpos); ++ NSUInteger idx = ns_ax_index_for_point(w); ++ return ns_ax_line_for_index(w, idx); +} + -+- (NSRange)accessibilityVisibleCharacterRange ++- (NSString *)accessibilityStringForRange:(NSRange)range +{ -+ /* Return range of characters visible in the current window. -+ Simplified to 0..min(bufsize,10000) matching accessibilityValue. */ -+ 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)); -+} -+ -+- (NSString *)accessibilityStringForRange:(NSRange)nsrange -+{ -+ /* Return buffer text for the given character range. VoiceOver calls -+ this during character/word/line reading (VO+arrow keys). */ -+ if (!emacsframe) ++ NSString *text = [self accessibilityValue]; ++ if (range.location + range.length > [text length]) + 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 range = end - start; -+ ptrdiff_t 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), BUF_BYTE_ADDRESS (curbuf, start_byte), byte_range); -+ -+ return [NSString stringWithLispString:str]; -+} -+ -+- (NSAttributedString *)accessibilityAttributedStringForRange:(NSRange)nsrange -+{ -+ /* Return attributed string for the range. VoiceOver requires this -+ for text navigation. We return a plain (unstyled) attributed -+ string — sufficient for screen reader use. */ -+ NSString *str = [self accessibilityStringForRange:nsrange]; -+ return [[NSAttributedString alloc] initWithString:str]; ++ return [text substringWithRange:range]; +} + +- (NSInteger)accessibilityLineForIndex:(NSInteger)index +{ -+ /* Convert character index to visual line number. Used by VoiceOver -+ for line-by-line reading (VO+Up/Down). -+ -+ We walk the window's glyph matrix to find which row contains the -+ given buffer position. Falls back to 0 if not found. */ -+ if (!emacsframe) ++ struct window *w = self.emacsWindow; ++ if (!w) + 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; ++ return ns_ax_line_for_index(w, (NSUInteger)index); +} + +- (NSRange)accessibilityRangeForLine:(NSInteger)line +{ -+ /* Return the character range for a visual line. VoiceOver uses -+ this to read an entire line when navigating by 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)); ++ struct window *w = self.emacsWindow; ++ if (!w) ++ return NSMakeRange(0, 0); ++ return ns_ax_range_for_line(w, (int)line); +} + +- (NSRect)accessibilityFrameForRange:(NSRange)range +{ -+ /* Return screen rect for a character range. This is the modern -+ NSAccessibilityProtocol equivalent of accessibilityBoundsForRange:. -+ We delegate to the same implementation. */ -+ return [self accessibilityBoundsForRange: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)point ++ ++- (NSRange)accessibilityRangeForPosition:(NSPoint)screenPoint +{ -+ /* Return character range at a screen point. VoiceOver uses this -+ for mouse-based exploration. Stub: returns empty range at 0. -+ A full implementation would need hit-testing against the glyph -+ matrix, which Emacs doesn't expose to AppKit. */ -+ (void) point; -+ return NSMakeRange (0, 0); ++ /* 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); +} + -+/* ---- Cursor position for Zoom (via accessibilityBoundsForRange:) ---- ++- (NSRange)accessibilityVisibleCharacterRange ++{ ++ NSString *text = [self accessibilityValue]; ++ return NSMakeRange(0, [text length]); ++} + -+ accessibilityFrame intentionally returns the VIEW's frame (standard -+ behavior) so VoiceOver draws its focus ring around the text area. -+ The cursor location is exposed through accessibilityBoundsForRange: -+ which AT tools query using the selectedTextRange. */ ++- (BOOL)isAccessibilityFocused ++{ ++ struct window *w = self.emacsWindow; ++ if (!w) ++ return NO; ++ EmacsView *view = self.emacsView; ++ if (!view || !view->emacsframe) ++ return NO; ++ struct frame *f = view->emacsframe; ++ return (w == XWINDOW(f->selected_window)); ++} ++ ++- (id)accessibilityParent ++{ ++ return NSAccessibilityUnignoredAncestor (self.emacsView); ++} + +- (NSRect)accessibilityFrame +{ -+ /* View's screen frame — standard NSView behavior. Do NOT return -+ the cursor rect here; that causes a duplicate cursor overlay. */ -+ return [super accessibilityFrame]; -+} -+ -+- (NSRect)accessibilityBoundsForRange:(NSRange)range -+{ -+ /* Return cursor screen rect. AT tools call this with the -+ selectedTextRange to locate the insertion point. We return the -+ cursor position regardless of requested range because Emacs does -+ not expose character-level geometry to AppKit. */ -+ 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) ++ struct window *w = self.emacsWindow; ++ if (!w) + return NSZeroRect; -+ -+ NSRect windowRect = [self convertRect:viewRect toView:nil]; -+ return [win convertRectToScreen:windowRect]; ++ return [self screenRectFromEmacsX:w->pixel_left ++ y:w->pixel_top ++ width:w->pixel_width ++ height:w->pixel_height]; +} + -+/* ---- Legacy attribute APIs (pre-10.10 compatibility) ---- */ ++/* ---- Notification dispatch ---- */ + -+- (NSArray *)accessibilityAttributeNames ++- (void)postAccessibilityUpdatesForWindow:(struct window *)w ++ frame:(struct frame *)f +{ -+ NSArray *superAttrs = [super accessibilityAttributeNames]; -+ if (superAttrs == nil) -+ superAttrs = @[]; -+ return [superAttrs arrayByAddingObjectsFromArray: -+ @[NSAccessibilityRoleAttribute, -+ NSAccessibilityValueAttribute, -+ NSAccessibilitySelectedTextAttribute, -+ NSAccessibilitySelectedTextRangeAttribute, -+ NSAccessibilityNumberOfCharactersAttribute, -+ NSAccessibilityVisibleCharacterRangeAttribute, -+ NSAccessibilityInsertionPointLineNumberAttribute]]; -+} ++ if (!w || !WINDOW_LEAF_P(w)) ++ return; + -+- (id)accessibilityAttributeValue:(NSString *)attribute -+{ -+ if ([attribute isEqualToString:NSAccessibilityRoleAttribute]) -+ return NSAccessibilityTextAreaRole; ++ struct buffer *b = XBUFFER(w->contents); ++ if (!b) ++ return; + -+ if ([attribute isEqualToString:NSAccessibilityValueAttribute]) -+ return [self accessibilityValue]; ++ ptrdiff_t modiff = BUF_MODIFF(b); ++ ptrdiff_t point = BUF_PT(b); + -+ if ([attribute isEqualToString:NSAccessibilitySelectedTextAttribute]) -+ return [self accessibilitySelectedText]; -+ -+ if ([attribute isEqualToString:NSAccessibilitySelectedTextRangeAttribute]) -+ return [NSValue valueWithRange:[self accessibilitySelectedTextRange]]; -+ -+ if ([attribute isEqualToString:NSAccessibilityNumberOfCharactersAttribute]) -+ return @([self accessibilityNumberOfCharacters]); -+ -+ if ([attribute isEqualToString:NSAccessibilityVisibleCharacterRangeAttribute]) -+ return [NSValue valueWithRange:[self accessibilityVisibleCharacterRange]]; -+ -+ if ([attribute isEqualToString:NSAccessibilityInsertionPointLineNumberAttribute]) -+ return @([self accessibilityInsertionPointLineNumber]); -+ -+ return [super accessibilityAttributeValue:attribute]; -+} -+ -+- (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]) ++ /* Text content changed? */ ++ if (modiff != self.cachedModiff) + { -+ NSRange range = [(NSValue *) parameter rangeValue]; -+ return [NSValue valueWithRect: -+ [self accessibilityBoundsForRange:range]]; ++ self.cachedModiff = modiff; ++ NSAccessibilityPostNotification(self, ++ NSAccessibilityValueChangedNotification); ++ ++ /* Rich typing echo for VoiceOver. */ ++ NSDictionary *userInfo = @{ ++ @"AXTextStateChangeType" : @1, /* AXTextStateChangeTypeEdit */ ++ @"AXTextEditType" : @0 /* kAXTextEditTypeTyping */ ++ }; ++ NSAccessibilityPostNotificationWithUserInfo( ++ self, NSAccessibilityValueChangedNotification, userInfo); + } + -+ if ([attribute isEqualToString: -+ NSAccessibilityStringForRangeParameterizedAttribute]) ++ /* Cursor moved? */ ++ if (point != self.cachedPoint) + { -+ NSRange range = [(NSValue *) parameter rangeValue]; -+ return [self accessibilityStringForRange:range]; ++ self.cachedPoint = point; ++ NSAccessibilityPostNotification(self, ++ NSAccessibilitySelectedTextChangedNotification); ++ } ++} ++ ++@end ++ ++#endif /* NS_IMPL_COCOA */ ++ ++ + /* ========================================================================== + + EmacsView implementation +@@ -9474,6 +10083,143 @@ - (int) fullscreenState + return fs_state; + } + ++#ifdef NS_IMPL_COCOA ++ ++/* ---- Accessibility: walk the Emacs window tree ---- */ ++ ++static void ++ns_ax_collect_windows (Lisp_Object window, EmacsView *view, ++ NSMutableArray *elements) ++{ ++ if (NILP (window)) ++ return; ++ ++ struct window *w = XWINDOW (window); ++ ++ if (WINDOW_LEAF_P (w)) ++ { ++ if (MINI_WINDOW_P (w)) ++ return; /* Skip minibuffer for MVP. */ ++ ++ EmacsAccessibilityBuffer *elem = [[EmacsAccessibilityBuffer alloc] init]; ++ elem.emacsView = view; ++ elem.emacsWindow = w; ++ ++ /* Initialize cached state to trigger first notification. */ ++ struct buffer *b = XBUFFER (w->contents); ++ if (b) ++ { ++ elem.cachedModiff = BUF_MODIFF (b); ++ elem.cachedPoint = BUF_PT (b); ++ } ++ ++ [elements addObject:elem]; ++ } ++ else ++ { ++ /* Internal (combination) window — recurse into children. */ ++ Lisp_Object child = w->contents; ++ while (!NILP (child)) ++ { ++ ns_ax_collect_windows (child, view, elements); ++ child = XWINDOW (child)->next; ++ } ++ } ++} ++ ++- (void)rebuildAccessibilityTree ++{ ++ if (!emacsframe) ++ return; ++ ++ NSMutableArray *newElements = [NSMutableArray arrayWithCapacity:4]; ++ Lisp_Object root = FRAME_ROOT_WINDOW (emacsframe); ++ ns_ax_collect_windows (root, self, newElements); ++ accessibilityElements = newElements; ++} ++ ++- (NSAccessibilityRole)accessibilityRole ++{ ++ return NSAccessibilityGroupRole; ++} ++ ++- (NSString *)accessibilityLabel ++{ ++ return @"Emacs"; ++} ++ ++- (BOOL)isAccessibilityElement ++{ ++ return YES; ++} ++ ++- (NSArray *)accessibilityChildren ++{ ++ if (!accessibilityElements || [accessibilityElements count] == 0) ++ [self rebuildAccessibilityTree]; ++ return accessibilityElements; ++} ++ ++- (id)accessibilityFocusedUIElement ++{ ++ if (!emacsframe) ++ return self; ++ ++ /* Ensure tree exists (lazy init); avoid redundant rebuild since ++ postAccessibilityUpdates already rebuilds each cycle. */ ++ if (!accessibilityElements || [accessibilityElements count] == 0) ++ [self rebuildAccessibilityTree]; ++ ++ struct window *sel = XWINDOW (emacsframe->selected_window); ++ for (EmacsAccessibilityBuffer *elem in accessibilityElements) ++ { ++ if (elem.emacsWindow == sel) ++ return elem; ++ } ++ return self; ++} ++ ++/* Called from ns_update_end to post AX notifications. ++ ++ Important: post notifications BEFORE rebuilding the tree. ++ The existing elements carry cached state (modiff, point) from the ++ previous redisplay cycle. Rebuilding first would create fresh ++ elements with current values, making change detection impossible. */ ++- (void)postAccessibilityUpdates ++{ ++ if (!emacsframe) ++ return; ++ ++ /* Post per-buffer notifications using EXISTING elements that have ++ cached state from the previous cycle. */ ++ for (EmacsAccessibilityBuffer *elem in accessibilityElements) ++ { ++ struct window *w = elem.emacsWindow; ++ if (w && WINDOW_LEAF_P (w)) ++ [elem postAccessibilityUpdatesForWindow:w frame:emacsframe]; + } + -+ return [super accessibilityAttributeValue:attribute forParameter:parameter]; ++ /* Check for window switch (C-x o) before rebuild. */ ++ Lisp_Object curSel = emacsframe->selected_window; ++ BOOL windowSwitched = !EQ (curSel, lastSelectedWindow); ++ if (windowSwitched) ++ lastSelectedWindow = curSel; ++ ++ /* Now rebuild tree to pick up window configuration changes. */ ++ [self rebuildAccessibilityTree]; ++ ++ /* Post focus change AFTER rebuild so the new element exists. */ ++ if (windowSwitched) ++ { ++ id focused = [self accessibilityFocusedUIElement]; ++ if (focused && focused != self) ++ NSAccessibilityPostNotification (focused, ++ NSAccessibilityFocusedUIElementChangedNotification); ++ } +} ++ +#endif /* NS_IMPL_COCOA */ + @end /* EmacsView */ +@@ -9941,6 +10687,14 @@ - (id)accessibilityAttributeValue:(NSString *)attribute + + return [super accessibilityAttributeValue:attribute]; + } ++ ++- (id)accessibilityFocusedUIElement ++{ ++ EmacsView *view = (EmacsView *)[self delegate]; ++ if (view && [view respondsToSelector:@selector(accessibilityFocusedUIElement)]) ++ return [view accessibilityFocusedUIElement]; ++ return self; ++} + #endif /* NS_IMPL_COCOA */ + + /* Constrain size and placement of a frame.