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 22047a4..fa074ea 100644 --- a/patches/0001-ns-implement-AXBoundsForRange-for-macOS-Zoom-cursor-.patch +++ b/patches/0001-ns-implement-AXBoundsForRange-for-macOS-Zoom-cursor-.patch @@ -1,13 +1,13 @@ -From 9ecac3f53f402174cafbe07d7809826f9f698a23 Mon Sep 17 00:00:00 2001 +From ee45648ef5ba61c7c9b5937741666b59b172a291 Mon Sep 17 00:00:00 2001 From: Martin Sukany -Date: Fri, 27 Feb 2026 10:30:58 +0100 +Date: Fri, 27 Feb 2026 10:42:28 +0100 Subject: [PATCH] ns: implement VoiceOver accessibility (AXBoundsForRange, line nav, completions, interactive spans) --- src/nsterm.h | 109 +++ - src/nsterm.m | 2645 +++++++++++++++++++++++++++++++++++++++++++++++--- - 2 files changed, 2604 insertions(+), 150 deletions(-) + src/nsterm.m | 2651 +++++++++++++++++++++++++++++++++++++++++++++++--- + 2 files changed, 2610 insertions(+), 150 deletions(-) diff --git a/src/nsterm.h b/src/nsterm.h index 7c1ee4c..6c95673 100644 @@ -144,7 +144,7 @@ index 7c1ee4c..6c95673 100644 diff --git a/src/nsterm.m b/src/nsterm.m -index 932d209..1166ad9 100644 +index 932d209..3c3c14a 100644 --- a/src/nsterm.m +++ b/src/nsterm.m @@ -46,6 +46,7 @@ Updated by Christian Limpach (chris@nice.ch) @@ -205,7 +205,7 @@ index 932d209..1166ad9 100644 ns_focus (f, NULL, 0); NSGraphicsContext *ctx = [NSGraphicsContext currentContext]; -@@ -6849,220 +6886,2205 @@ - (BOOL)fulfillService: (NSString *)name withArg: (NSString *)arg +@@ -6849,220 +6886,2211 @@ - (BOOL)fulfillService: (NSString *)name withArg: (NSString *)arg /* ========================================================================== @@ -933,8 +933,14 @@ index 932d209..1166ad9 100644 + else if (is_completion_buf + && !NILP (Fplist_get (plist, Qmouse_face_sym, Qnil))) + { ++ /* For completions, use completion--string as boundary so we ++ don't accidentally merge two column-adjacent candidates ++ whose mouse-face regions may share padding whitespace. ++ Fall back to mouse-face if completion--string is absent. */ ++ Lisp_Object cs_sym = intern ("completion--string"); ++ Lisp_Object cs_val = ns_ax_text_prop_at (pos, cs_sym, buf_obj); + span_type = EmacsAXSpanTypeCompletionItem; -+ limit_prop = Qmouse_face_sym; ++ limit_prop = NILP (cs_val) ? Qmouse_face_sym : cs_sym; + } + else + { @@ -2561,7 +2567,7 @@ index 932d209..1166ad9 100644 static NSMutableArray *nsEvArray; unsigned int flags = [theEvent modifierFlags]; -@@ -8237,6 +10259,28 @@ - (void)windowDidBecomeKey /* for direct calls */ +@@ -8237,6 +10265,28 @@ - (void)windowDidBecomeKey /* for direct calls */ XSETFRAME (event.frame_or_window, emacsframe); kbd_buffer_store_event (&event); ns_send_appdefined (-1); // Kick main loop @@ -2590,7 +2596,7 @@ index 932d209..1166ad9 100644 } -@@ -9474,6 +11518,307 @@ - (int) fullscreenState +@@ -9474,6 +11524,307 @@ - (int) fullscreenState return fs_state; } diff --git a/patches/0001-ns-implement-AXBoundsForRange-for-macOS-Zoom-cursor-.patch.bak b/patches/0001-ns-implement-AXBoundsForRange-for-macOS-Zoom-cursor-.patch.bak new file mode 100644 index 0000000..25f621a --- /dev/null +++ b/patches/0001-ns-implement-AXBoundsForRange-for-macOS-Zoom-cursor-.patch.bak @@ -0,0 +1,1250 @@ +From: Martin Sukany +Date: Wed, 26 Feb 2026 00:00:00 +0100 +Subject: [PATCH] ns: add macOS Zoom cursor tracking and VoiceOver accessibility + +Dual accessibility: UAZoomChangeFocus for Zoom + virtual element tree for +VoiceOver. Notifications target the focused virtual element, not EmacsView. +Full hierarchy plumbing (accessibilityWindow, accessibilityParent, etc.). +MRC compatible. +--- +--- a/src/nsterm.h 2026-02-26 08:46:18.118172281 +0100 ++++ b/src/nsterm.h 2026-02-26 09:08:57.204955708 +0100 +@@ -455,6 +455,34 @@ + + /* ========================================================================== + ++ Accessibility virtual elements (macOS / Cocoa only) ++ ++ ========================================================================== */ ++ ++#ifdef NS_IMPL_COCOA ++@class EmacsView; ++ ++/* Base class for virtual accessibility elements attached to EmacsView. */ ++@interface EmacsAccessibilityElement : NSAccessibilityElement ++@property (nonatomic, unsafe_unretained) EmacsView *emacsView; ++@property (nonatomic, assign) struct window *emacsWindow; ++- (NSRect)screenRectFromEmacsX:(int)x y:(int)y width:(int)w height:(int)h; ++@end ++ ++/* Virtual AXTextArea element — one per visible Emacs window (buffer). */ ++@interface EmacsAccessibilityBuffer : EmacsAccessibilityElement ++@property (nonatomic, assign) ptrdiff_t cachedModiff; ++@property (nonatomic, assign) ptrdiff_t cachedPoint; ++@property (nonatomic, assign) Lisp_Object cachedSelectedWindow; ++- (void) ++ postAccessibilityUpdatesForWindow:(struct window *)w ++ frame:(struct frame *)f; ++@end ++#endif /* NS_IMPL_COCOA */ ++ ++ ++/* ========================================================================== ++ + The main Emacs view + + ========================================================================== */ +@@ -471,6 +499,12 @@ + #ifdef NS_IMPL_COCOA + char *old_title; + BOOL maximizing_resize; ++ NSMutableArray *accessibilityElements; ++ Lisp_Object lastSelectedWindow; ++ @public ++ NSRect lastAccessibilityCursorRect; ++ ptrdiff_t lastAccessibilityModiff; ++ @protected + #endif + BOOL font_panel_active; + NSFont *font_panel_result; +@@ -528,6 +562,12 @@ + - (void)windowWillExitFullScreen; + - (void)windowDidExitFullScreen; + - (void)windowDidBecomeKey; ++ ++#ifdef NS_IMPL_COCOA ++/* Accessibility support. */ ++- (void)rebuildAccessibilityTree; ++- (void)postAccessibilityUpdates; ++#endif + @end + + +--- a/src/nsterm.m 2026-02-26 08:46:18.124172384 +0100 ++++ b/src/nsterm.m 2026-02-26 10:46:54.607568839 +0100 +@@ -1104,6 +1104,11 @@ + + unblock_input (); + ns_updating_frame = NULL; ++ ++#ifdef NS_IMPL_COCOA ++ /* Post accessibility notifications after each redisplay cycle. */ ++ [view postAccessibilityUpdates]; ++#endif + } + + static void +@@ -3232,6 +3237,82 @@ + /* Prevent the cursor from being drawn outside the text area. */ + r = NSIntersectionRect (r, ns_row_rect (w, glyph_row, TEXT_AREA)); + ++#ifdef NS_IMPL_COCOA ++ /* Accessibility cursor tracking for macOS Zoom and VoiceOver. ++ Only notify AT when drawing the cursor in the active (selected) ++ window. Without this guard, C-x o triggers UAZoomChangeFocus ++ for the old window last, snapping Zoom back. */ ++ { ++ EmacsView *view = FRAME_NS_VIEW (f); ++ if (view && on_p && active_p) ++ { ++ /* Store cursor rect for accessibilityBoundsForRange: queries. */ ++ view->lastAccessibilityCursorRect = r; ++ ++ /* Find the focused virtual element — VoiceOver tracks IT, ++ not the EmacsView (AXGroup). Notifications must come from ++ the element VoiceOver is monitoring. */ ++ id axTarget = [view accessibilityFocusedUIElement]; ++ if (!axTarget) ++ axTarget = view; ++ ++ struct buffer *curbuf ++ = XBUFFER (XWINDOW (f->selected_window)->contents); ++ ++ if (curbuf && BUF_MODIFF (curbuf) != view->lastAccessibilityModiff) ++ { ++ /* Buffer content changed — typing echo. Post ValueChanged ++ with rich userInfo on the FOCUSED ELEMENT. ++ kAXTextStateChangeTypeEdit = 1, kAXTextEditTypeTyping = 3. */ ++ view->lastAccessibilityModiff = BUF_MODIFF (curbuf); ++ ++ NSString *changedText = @""; ++ ptrdiff_t pt = BUF_PT (curbuf); ++ if (pt > BUF_BEGV (curbuf)) ++ { ++ NSRange charRange = NSMakeRange ( ++ (NSUInteger)(pt - BUF_BEGV (curbuf) - 1), 1); ++ changedText = [view accessibilityStringForRange:charRange]; ++ if (!changedText) ++ changedText = @""; ++ } ++ ++ NSDictionary *change = @{ ++ @"AXTextEditType": @3, ++ @"AXTextChangeValue": changedText ++ }; ++ NSDictionary *userInfo = @{ ++ @"AXTextStateChangeType": @1, ++ @"AXTextChangeValues": @[change] ++ }; ++ NSAccessibilityPostNotificationWithUserInfo ( ++ axTarget, NSAccessibilityValueChangedNotification, userInfo); ++ } ++ ++ /* Always notify cursor movement on the focused element. */ ++ NSAccessibilityPostNotification ( ++ axTarget, NSAccessibilitySelectedTextChangedNotification); ++ ++ /* Tell macOS Zoom where the cursor is. UAZoomChangeFocus() ++ expects top-left origin (CG coordinate space). */ ++ if (UAZoomEnabled ()) ++ { ++ NSRect windowRect = [view convertRect:r toView:nil]; ++ NSRect screenRect = [[view window] convertRectToScreen:windowRect]; ++ CGRect cgRect = NSRectToCGRect (screenRect); ++ ++ CGFloat primaryH ++ = [[[NSScreen screens] firstObject] frame].size.height; ++ cgRect.origin.y ++ = primaryH - cgRect.origin.y - cgRect.size.height; ++ ++ UAZoomChangeFocus (&cgRect, &cgRect, ++ kUAZoomFocusTypeInsertionPoint); ++ } ++ } ++ } ++#endif ++ + ns_focus (f, NULL, 0); + + NSGraphicsContext *ctx = [NSGraphicsContext currentContext]; +@@ -6849,6 +6930,646 @@ + + /* ========================================================================== + ++ Accessibility virtual elements (macOS / Cocoa only) ++ ++ ========================================================================== */ ++ ++#ifdef NS_IMPL_COCOA ++ ++/* ---- Helper: extract visible text from glyph rows of a window ---- */ ++static NSString * ++ns_ax_text_from_glyph_rows (struct window *w) ++{ ++ if (!w || !w->current_matrix) ++ return @""; ++ ++ struct glyph_matrix *matrix = w->current_matrix; ++ NSMutableString *text = [NSMutableString stringWithCapacity:4096]; ++ int nrows = matrix->nrows; ++ ++ for (int i = 0; i < nrows; i++) ++ { ++ struct glyph_row *row = matrix->rows + i; ++ if (!row->enabled_p || row->mode_line_p) ++ continue; ++ if (!row->displays_text_p && !row->ends_at_zv_p) ++ continue; ++ ++ struct glyph *glyph = row->glyphs[TEXT_AREA]; ++ struct glyph *end = glyph + row->used[TEXT_AREA]; ++ ++ for (; glyph < end; glyph++) ++ { ++ if (glyph->type == CHAR_GLYPH && !glyph->padding_p) ++ { ++ unsigned ch = glyph->u.ch; ++ if (ch == '\n' || ch == '\r') ++ continue; /* row boundary handles newlines */ ++ if (ch >= 32) ++ { ++ unichar uch = (unichar)ch; ++ [text appendString:[NSString stringWithCharacters:&uch ++ length:1]]; ++ } ++ } ++ } ++ ++ /* Add newline between rows unless this is the last displayed row. */ ++ if (i + 1 < nrows) ++ { ++ struct glyph_row *next = matrix->rows + i + 1; ++ if (next->enabled_p && (next->displays_text_p || next->ends_at_zv_p) ++ && !next->mode_line_p) ++ [text appendString:@"\n"]; ++ } ++ } ++ ++ /* Cap at 32KB */ ++ if ([text length] > 32768) ++ return [text substringToIndex:32768]; ++ ++ return text; ++} ++ ++ ++/* ---- Row geometry helpers ---- */ ++ ++/* Count the number of visible text rows (excluding mode line). */ ++static int ++ns_ax_visible_row_count (struct window *w) ++{ ++ if (!w || !w->current_matrix) ++ return 0; ++ struct glyph_matrix *matrix = w->current_matrix; ++ int count = 0; ++ for (int i = 0; i < matrix->nrows; i++) ++ { ++ struct glyph_row *row = matrix->rows + i; ++ if (row->enabled_p && !row->mode_line_p ++ && (row->displays_text_p || row->ends_at_zv_p)) ++ count++; ++ } ++ return count; ++} ++ ++/* Map a character index (within the glyph-extracted text) to a visual ++ row number (0-based, text rows only). */ ++static int ++ns_ax_line_for_index (struct window *w, NSUInteger idx) ++{ ++ if (!w || !w->current_matrix) ++ return 0; ++ struct glyph_matrix *matrix = w->current_matrix; ++ NSUInteger pos = 0; ++ int line = 0; ++ ++ for (int i = 0; i < matrix->nrows; i++) ++ { ++ struct glyph_row *row = matrix->rows + i; ++ if (!row->enabled_p || row->mode_line_p) ++ continue; ++ if (!row->displays_text_p && !row->ends_at_zv_p) ++ continue; ++ ++ /* Count characters in this row. */ ++ int row_chars = 0; ++ struct glyph *g = row->glyphs[TEXT_AREA]; ++ struct glyph *gend = g + row->used[TEXT_AREA]; ++ for (; g < gend; g++) ++ { ++ if (g->type == CHAR_GLYPH && !g->padding_p) ++ { ++ unsigned ch = g->u.ch; ++ if (ch != '\n' && ch != '\r' && (ch >= 32)) ++ row_chars++; ++ } ++ } ++ ++ NSUInteger row_end = pos + row_chars + 1; /* +1 for newline */ ++ if (idx < row_end) ++ return line; ++ pos = row_end; ++ line++; ++ } ++ return MAX(0, line - 1); ++} ++ ++/* Return character range for a given visual line number. */ ++static NSRange ++ns_ax_range_for_line (struct window *w, int target_line) ++{ ++ if (!w || !w->current_matrix) ++ return NSMakeRange(0, 0); ++ struct glyph_matrix *matrix = w->current_matrix; ++ NSUInteger pos = 0; ++ int line = 0; ++ ++ for (int i = 0; i < matrix->nrows; i++) ++ { ++ struct glyph_row *row = matrix->rows + i; ++ if (!row->enabled_p || row->mode_line_p) ++ continue; ++ if (!row->displays_text_p && !row->ends_at_zv_p) ++ continue; ++ ++ int row_chars = 0; ++ struct glyph *g = row->glyphs[TEXT_AREA]; ++ struct glyph *gend = g + row->used[TEXT_AREA]; ++ for (; g < gend; g++) ++ { ++ if (g->type == CHAR_GLYPH && !g->padding_p) ++ { ++ unsigned ch = g->u.ch; ++ if (ch != '\n' && ch != '\r' && (ch >= 32)) ++ row_chars++; ++ } ++ } ++ ++ if (line == target_line) ++ return NSMakeRange(pos, row_chars); ++ ++ pos += row_chars + 1; /* +1 for newline */ ++ line++; ++ } ++ return NSMakeRange(NSNotFound, 0); ++} ++ ++/* Compute screen rect for a character range by unioning glyph row rects. */ ++static NSRect ++ns_ax_frame_for_range (struct window *w, EmacsView *view, NSRange range) ++{ ++ if (!w || !w->current_matrix || !view) ++ return NSZeroRect; ++ struct glyph_matrix *matrix = w->current_matrix; ++ NSUInteger pos = 0; ++ NSRect result = NSZeroRect; ++ BOOL found = NO; ++ ++ for (int i = 0; i < matrix->nrows; i++) ++ { ++ struct glyph_row *row = matrix->rows + i; ++ if (!row->enabled_p || row->mode_line_p) ++ continue; ++ if (!row->displays_text_p && !row->ends_at_zv_p) ++ continue; ++ ++ int row_chars = 0; ++ struct glyph *g = row->glyphs[TEXT_AREA]; ++ struct glyph *gend = g + row->used[TEXT_AREA]; ++ for (; g < gend; g++) ++ { ++ if (g->type == CHAR_GLYPH && !g->padding_p) ++ { ++ unsigned ch = g->u.ch; ++ if (ch != '\n' && ch != '\r' && (ch >= 32)) ++ row_chars++; ++ } ++ } ++ ++ NSUInteger row_end = pos + row_chars + 1; ++ if (pos < range.location + range.length && row_end > range.location) ++ { ++ /* This row overlaps the requested range. */ ++ int window_x, window_y, window_width; ++ window_box (w, TEXT_AREA, &window_x, &window_y, &window_width, 0); ++ ++ NSRect rowRect; ++ rowRect.origin.x = window_x; ++ rowRect.origin.y = WINDOW_TO_FRAME_PIXEL_Y (w, MAX(0, row->y)); ++ rowRect.origin.y = MAX(rowRect.origin.y, window_y); ++ rowRect.size.width = window_width; ++ rowRect.size.height = row->visible_height; ++ ++ if (!found) ++ { ++ result = rowRect; ++ found = YES; ++ } ++ else ++ result = NSUnionRect(result, rowRect); ++ } ++ pos = row_end; ++ } ++ ++ if (!found) ++ return NSZeroRect; ++ ++ /* Convert from EmacsView (flipped) coords to screen coords. */ ++ NSRect winRect = [view convertRect:result toView:nil]; ++ return [[view window] convertRectToScreen:winRect]; ++} ++ ++ ++/* Compute the character index within glyph-extracted text that ++ corresponds to the buffer point position. */ ++static NSUInteger ++ns_ax_index_for_point (struct window *w) ++{ ++ if (!w || !w->current_matrix || !WINDOW_LEAF_P(w)) ++ return 0; ++ ++ struct buffer *b = XBUFFER(w->contents); ++ if (!b) ++ return 0; ++ ++ ptrdiff_t point = BUF_PT(b); ++ struct glyph_matrix *matrix = w->current_matrix; ++ NSUInteger pos = 0; ++ ++ for (int i = 0; i < matrix->nrows; i++) ++ { ++ struct glyph_row *row = matrix->rows + i; ++ if (!row->enabled_p || row->mode_line_p) ++ continue; ++ if (!row->displays_text_p && !row->ends_at_zv_p) ++ continue; ++ ++ ptrdiff_t row_start = MATRIX_ROW_START_CHARPOS (row); ++ ptrdiff_t row_end = MATRIX_ROW_END_CHARPOS (row); ++ ++ if (point >= row_start && point < row_end) ++ { ++ /* Point is within this row. Count visible glyphs whose ++ buffer charpos is before point. */ ++ int chars_before = 0; ++ struct glyph *g = row->glyphs[TEXT_AREA]; ++ struct glyph *gend = g + row->used[TEXT_AREA]; ++ for (; g < gend; g++) ++ { ++ if (g->type == CHAR_GLYPH && !g->padding_p ++ && g->charpos >= row_start ++ && g->charpos < point) ++ { ++ unsigned ch = g->u.ch; ++ if (ch != '\n' && ch != '\r' && ch >= 32) ++ chars_before++; ++ } ++ } ++ return pos + chars_before; ++ } ++ ++ /* Count visible chars in this row + newline. */ ++ int row_chars = 0; ++ struct glyph *g = row->glyphs[TEXT_AREA]; ++ struct glyph *gend = g + row->used[TEXT_AREA]; ++ for (; g < gend; g++) ++ { ++ if (g->type == CHAR_GLYPH && !g->padding_p) ++ { ++ unsigned ch = g->u.ch; ++ if (ch != '\n' && ch != '\r' && (ch >= 32)) ++ row_chars++; ++ } ++ } ++ pos += row_chars + 1; ++ } ++ return pos > 0 ? pos - 1 : 0; ++} ++ ++ ++@implementation EmacsAccessibilityElement ++ ++- (NSRect)screenRectFromEmacsX:(int)x y:(int)y width:(int)ew height:(int)eh ++{ ++ EmacsView *view = self.emacsView; ++ if (!view || ![view window]) ++ return NSZeroRect; ++ ++ NSRect r = NSMakeRect(x, y, ew, eh); ++ NSRect winRect = [view convertRect:r toView:nil]; ++ return [[view window] convertRectToScreen:winRect]; ++} ++ ++- (BOOL)isAccessibilityElement ++{ ++ return YES; ++} ++ ++/* ---- Hierarchy plumbing (required for VoiceOver to find us) ---- */ ++ ++- (id)accessibilityParent ++{ ++ return NSAccessibilityUnignoredAncestor (self.emacsView); ++} ++ ++- (id)accessibilityWindow ++{ ++ return [self.emacsView window]; ++} ++ ++- (id)accessibilityTopLevelUIElement ++{ ++ return [self.emacsView window]; ++} ++ ++@end ++ ++ ++@implementation EmacsAccessibilityBuffer ++ ++/* ---- NSAccessibility protocol ---- */ ++ ++- (NSAccessibilityRole)accessibilityRole ++{ ++ return NSAccessibilityTextAreaRole; ++} ++ ++- (NSString *)accessibilityRoleDescription ++{ ++ return @"editor"; ++} ++ ++- (NSString *)accessibilityLabel ++{ ++ struct window *w = self.emacsWindow; ++ if (w && WINDOW_LEAF_P(w)) ++ { ++ struct buffer *b = XBUFFER(w->contents); ++ if (b) ++ { ++ Lisp_Object name = BVAR(b, name); ++ if (STRINGP(name)) ++ return [NSString stringWithLispString:name]; ++ } ++ } ++ return @"buffer"; ++} ++ ++- (BOOL)isAccessibilityFocused ++{ ++ /* Return YES when this buffer's window is the selected window. */ ++ struct window *w = self.emacsWindow; ++ if (!w) ++ return NO; ++ EmacsView *view = self.emacsView; ++ if (!view || !view->emacsframe) ++ return NO; ++ struct frame *f = view->emacsframe; ++ return (w == XWINDOW(f->selected_window)); ++} ++ ++- (id)accessibilityValue ++{ ++ struct window *w = self.emacsWindow; ++ if (!w) ++ return @""; ++ return ns_ax_text_from_glyph_rows(w); ++} ++ ++- (NSInteger)accessibilityNumberOfCharacters ++{ ++ NSString *text = [self accessibilityValue]; ++ return [text length]; ++} ++ ++- (NSString *)accessibilitySelectedText ++{ ++ struct window *w = self.emacsWindow; ++ if (!w || !WINDOW_LEAF_P(w)) ++ return @""; ++ ++ struct buffer *b = XBUFFER(w->contents); ++ if (!b || NILP(BVAR(b, mark_active))) ++ return @""; ++ ++ /* Return the selected region text. */ ++ NSString *text = [self accessibilityValue]; ++ NSRange sel = [self accessibilitySelectedTextRange]; ++ if (sel.location == NSNotFound || sel.location + sel.length > [text length]) ++ return @""; ++ return [text substringWithRange:sel]; ++} ++ ++- (NSRange)accessibilitySelectedTextRange ++{ ++ struct window *w = self.emacsWindow; ++ if (!w || !WINDOW_LEAF_P(w)) ++ return NSMakeRange(0, 0); ++ ++ struct buffer *b = XBUFFER(w->contents); ++ if (!b) ++ return NSMakeRange(0, 0); ++ ++ NSUInteger point_idx = ns_ax_index_for_point(w); ++ ++ if (NILP(BVAR(b, mark_active))) ++ return NSMakeRange(point_idx, 0); ++ ++ /* With active mark, report the selection range. Map mark ++ position to accessibility index using the same glyph-based ++ mapping as point. */ ++ ptrdiff_t mark_pos = marker_position (BVAR (b, mark)); ++ ptrdiff_t pt_pos = BUF_PT (b); ++ ptrdiff_t begv = BUF_BEGV (b); ++ ptrdiff_t sel_start = (mark_pos < pt_pos) ? mark_pos : pt_pos; ++ ptrdiff_t sel_end = (mark_pos < pt_pos) ? pt_pos : mark_pos; ++ NSUInteger start_idx = (NSUInteger) (sel_start - begv); ++ NSUInteger len = (NSUInteger) (sel_end - sel_start); ++ return NSMakeRange(start_idx, len); ++} ++ ++- (NSInteger)accessibilityInsertionPointLineNumber ++{ ++ struct window *w = self.emacsWindow; ++ if (!w) ++ return 0; ++ NSUInteger idx = ns_ax_index_for_point(w); ++ return ns_ax_line_for_index(w, idx); ++} ++ ++- (NSString *)accessibilityStringForRange:(NSRange)range ++{ ++ NSString *text = [self accessibilityValue]; ++ if (range.location + range.length > [text length]) ++ return @""; ++ return [text substringWithRange:range]; ++} ++ ++- (NSAttributedString *)accessibilityAttributedStringForRange:(NSRange)range ++{ ++ NSString *str = [self accessibilityStringForRange:range]; ++ return [[[NSAttributedString alloc] initWithString:str] autorelease]; ++} ++ ++- (NSInteger)accessibilityLineForIndex:(NSInteger)index ++{ ++ struct window *w = self.emacsWindow; ++ if (!w) ++ return 0; ++ return ns_ax_line_for_index(w, (NSUInteger)index); ++} ++ ++- (NSRange)accessibilityRangeForLine:(NSInteger)line ++{ ++ struct window *w = self.emacsWindow; ++ if (!w) ++ return NSMakeRange(0, 0); ++ return ns_ax_range_for_line(w, (int)line); ++} ++ ++- (NSRect)accessibilityFrameForRange:(NSRange)range ++{ ++ struct window *w = self.emacsWindow; ++ EmacsView *view = self.emacsView; ++ if (!w || !view) ++ return NSZeroRect; ++ return ns_ax_frame_for_range(w, view, range); ++} ++ ++ ++- (NSRange)accessibilityRangeForPosition:(NSPoint)screenPoint ++{ ++ /* Hit test: convert screen point to buffer character index. ++ Used by VoiceOver for mouse/trackpad exploration. */ ++ struct window *w = self.emacsWindow; ++ EmacsView *view = self.emacsView; ++ if (!w || !view || !w->current_matrix) ++ return NSMakeRange (0, 0); ++ ++ /* Convert screen point to EmacsView coordinates. */ ++ NSPoint windowPoint = [[view window] convertPointFromScreen:screenPoint]; ++ NSPoint viewPoint = [view convertPoint:windowPoint fromView:nil]; ++ ++ /* Convert to Emacs pixel coordinates (EmacsView is flipped). */ ++ int x = (int) viewPoint.x - w->pixel_left; ++ int y = (int) viewPoint.y - w->pixel_top; ++ ++ if (x < 0 || y < 0 || x >= w->pixel_width || y >= w->pixel_height) ++ return NSMakeRange (0, 0); ++ ++ /* Find the glyph row at this y coordinate. */ ++ struct glyph_matrix *matrix = w->current_matrix; ++ struct glyph_row *hit_row = NULL; ++ int row_y = 0; ++ ++ for (int i = 0; i < matrix->nrows; i++) ++ { ++ struct glyph_row *row = matrix->rows + i; ++ if (!row->enabled_p || !row->displays_text_p) ++ continue; ++ if (y >= row_y && y < row_y + row->visible_height) ++ { ++ hit_row = row; ++ break; ++ } ++ row_y += row->visible_height; ++ } ++ ++ if (!hit_row) ++ return NSMakeRange (0, 0); ++ ++ /* Find the glyph at this x coordinate within the row. */ ++ struct glyph *glyph = hit_row->glyphs[TEXT_AREA]; ++ struct glyph *end = glyph + hit_row->used[TEXT_AREA]; ++ int glyph_x = 0; ++ ptrdiff_t best_charpos = MATRIX_ROW_START_CHARPOS (hit_row); ++ ++ for (; glyph < end; glyph++) ++ { ++ if (glyph->type == CHAR_GLYPH && glyph->charpos > 0) ++ { ++ if (x >= glyph_x && x < glyph_x + glyph->pixel_width) ++ { ++ best_charpos = glyph->charpos; ++ break; ++ } ++ best_charpos = glyph->charpos; ++ } ++ glyph_x += glyph->pixel_width; ++ } ++ ++ /* Convert buffer charpos to accessibility index. */ ++ struct buffer *b = XBUFFER (w->contents); ++ if (!b) ++ return NSMakeRange (0, 0); ++ ++ ptrdiff_t idx = best_charpos - BUF_BEGV (b); ++ if (idx < 0) idx = 0; ++ ++ return NSMakeRange ((NSUInteger) idx, 1); ++} ++ ++- (NSRange)accessibilityVisibleCharacterRange ++{ ++ NSString *text = [self accessibilityValue]; ++ return NSMakeRange(0, [text length]); ++} ++ ++- (NSRect)accessibilityFrame ++{ ++ struct window *w = self.emacsWindow; ++ if (!w) ++ return NSZeroRect; ++ return [self screenRectFromEmacsX:w->pixel_left ++ y:w->pixel_top ++ width:w->pixel_width ++ height:w->pixel_height]; ++} ++ ++/* ---- Notification dispatch ---- */ ++ ++- (void)postAccessibilityUpdatesForWindow:(struct window *)w ++ frame:(struct frame *)f ++{ ++ if (!w || !WINDOW_LEAF_P(w)) ++ return; ++ ++ struct buffer *b = XBUFFER(w->contents); ++ if (!b) ++ return; ++ ++ ptrdiff_t modiff = BUF_MODIFF(b); ++ ptrdiff_t point = BUF_PT(b); ++ ++ /* Text content changed? */ ++ if (modiff != self.cachedModiff) ++ { ++ self.cachedModiff = modiff; ++ NSAccessibilityPostNotification(self, ++ NSAccessibilityValueChangedNotification); ++ ++ /* Rich typing echo for VoiceOver. ++ kAXTextStateChangeTypeEdit = 1, kAXTextEditTypeTyping = 3. ++ Must include AXTextChangeValues array for VoiceOver to speak. */ ++ NSString *changedText = @""; ++ ptrdiff_t pt = BUF_PT (b); ++ if (pt > BUF_BEGV (b)) ++ { ++ NSRange charRange = NSMakeRange ( ++ (NSUInteger)(pt - BUF_BEGV (b) - 1), 1); ++ changedText = [self accessibilityStringForRange:charRange]; ++ if (!changedText) ++ changedText = @""; ++ } ++ ++ NSDictionary *change = @{ ++ @"AXTextEditType": @3, ++ @"AXTextChangeValue": changedText ++ }; ++ NSDictionary *userInfo = @{ ++ @"AXTextStateChangeType": @1, ++ @"AXTextChangeValues": @[change] ++ }; ++ NSAccessibilityPostNotificationWithUserInfo( ++ self, NSAccessibilityValueChangedNotification, userInfo); ++ } ++ ++ /* Cursor moved? */ ++ if (point != self.cachedPoint) ++ { ++ self.cachedPoint = point; ++ NSAccessibilityPostNotification(self, ++ NSAccessibilitySelectedTextChangedNotification); ++ } ++} ++ ++@end ++ ++#endif /* NS_IMPL_COCOA */ ++ ++ ++/* ========================================================================== ++ + EmacsView implementation + + ========================================================================== */ +@@ -6889,6 +7610,7 @@ + [layer release]; + #endif + ++ [accessibilityElements release]; + [[self menu] release]; + [super dealloc]; + } +@@ -8237,6 +8959,18 @@ + XSETFRAME (event.frame_or_window, emacsframe); + kbd_buffer_store_event (&event); + ns_send_appdefined (-1); // Kick main loop ++ ++#ifdef NS_IMPL_COCOA ++ /* Notify VoiceOver that the focused accessibility element changed. ++ Post on the focused virtual element so VoiceOver starts tracking it. ++ This is critical for initial focus and app-switch scenarios. */ ++ { ++ id focused = [self accessibilityFocusedUIElement]; ++ if (focused) ++ NSAccessibilityPostNotification (focused, ++ NSAccessibilityFocusedUIElementChangedNotification); ++ } ++#endif + } + + +@@ -9474,6 +10208,391 @@ + return fs_state; + } + ++#ifdef NS_IMPL_COCOA ++ ++/* ---- Accessibility: walk the Emacs window tree ---- */ ++ ++static void ++ns_ax_collect_windows (Lisp_Object window, EmacsView *view, ++ NSMutableArray *elements) ++{ ++ if (NILP (window)) ++ return; ++ ++ struct window *w = XWINDOW (window); ++ ++ if (WINDOW_LEAF_P (w)) ++ { ++ if (MINI_WINDOW_P (w)) ++ return; /* Skip minibuffer for MVP. */ ++ ++ EmacsAccessibilityBuffer *elem = [[EmacsAccessibilityBuffer alloc] init]; ++ elem.emacsView = view; ++ elem.emacsWindow = w; ++ ++ /* Initialize cached state to trigger first notification. */ ++ struct buffer *b = XBUFFER (w->contents); ++ if (b) ++ { ++ elem.cachedModiff = BUF_MODIFF (b); ++ elem.cachedPoint = BUF_PT (b); ++ } ++ ++ [elements addObject:elem]; ++ } ++ else ++ { ++ /* Internal (combination) window — recurse into children. */ ++ Lisp_Object child = w->contents; ++ while (!NILP (child)) ++ { ++ ns_ax_collect_windows (child, view, elements); ++ child = XWINDOW (child)->next; ++ } ++ } ++} ++ ++- (void)rebuildAccessibilityTree ++{ ++ if (!emacsframe) ++ return; ++ ++ NSMutableArray *newElements = [NSMutableArray arrayWithCapacity:4]; ++ Lisp_Object root = FRAME_ROOT_WINDOW (emacsframe); ++ ns_ax_collect_windows (root, self, newElements); ++ [accessibilityElements release]; ++ accessibilityElements = [newElements retain]; ++} ++ ++- (NSAccessibilityRole)accessibilityRole ++{ ++ return NSAccessibilityGroupRole; ++} ++ ++- (NSString *)accessibilityLabel ++{ ++ return @"Emacs"; ++} ++ ++- (BOOL)isAccessibilityElement ++{ ++ return YES; ++} ++ ++- (NSArray *)accessibilityChildren ++{ ++ if (!accessibilityElements || [accessibilityElements count] == 0) ++ [self rebuildAccessibilityTree]; ++ return accessibilityElements; ++} ++ ++- (id)accessibilityFocusedUIElement ++{ ++ if (!emacsframe) ++ return self; ++ ++ /* Ensure tree exists (lazy init); avoid redundant rebuild since ++ postAccessibilityUpdates already rebuilds each cycle. */ ++ if (!accessibilityElements || [accessibilityElements count] == 0) ++ [self rebuildAccessibilityTree]; ++ ++ struct window *sel = XWINDOW (emacsframe->selected_window); ++ for (EmacsAccessibilityBuffer *elem in accessibilityElements) ++ { ++ if (elem.emacsWindow == sel) ++ return elem; ++ } ++ return self; ++} ++ ++/* Called from ns_update_end to post AX notifications. ++ ++ Important: post notifications BEFORE rebuilding the tree. ++ The existing elements carry cached state (modiff, point) from the ++ previous redisplay cycle. Rebuilding first would create fresh ++ elements with current values, making change detection impossible. */ ++- (void)postAccessibilityUpdates ++{ ++ if (!emacsframe) ++ return; ++ ++ /* Post per-buffer notifications using EXISTING elements that have ++ cached state from the previous cycle. */ ++ for (EmacsAccessibilityBuffer *elem in accessibilityElements) ++ { ++ struct window *w = elem.emacsWindow; ++ if (w && WINDOW_LEAF_P (w)) ++ [elem postAccessibilityUpdatesForWindow:w frame:emacsframe]; ++ } ++ ++ /* Check for window switch (C-x o) before rebuild. */ ++ Lisp_Object curSel = emacsframe->selected_window; ++ BOOL windowSwitched = !EQ (curSel, lastSelectedWindow); ++ if (windowSwitched) ++ lastSelectedWindow = curSel; ++ ++ /* Now rebuild tree to pick up window configuration changes. */ ++ [self rebuildAccessibilityTree]; ++ ++ /* Post focus change AFTER rebuild so the new element exists. */ ++ if (windowSwitched) ++ { ++ id focused = [self accessibilityFocusedUIElement]; ++ if (focused && focused != self) ++ NSAccessibilityPostNotification (focused, ++ NSAccessibilityFocusedUIElementChangedNotification); ++ } ++} ++ ++/* ---- Cursor position for Zoom (via accessibilityBoundsForRange:) ---- ++ ++ accessibilityFrame returns the VIEW's frame (standard behavior). ++ The cursor location is exposed through accessibilityBoundsForRange: ++ which AT tools query using the selectedTextRange. */ ++ ++- (NSRect)accessibilityFrame ++{ ++ return [super accessibilityFrame]; ++} ++ ++- (NSRect)accessibilityBoundsForRange:(NSRange)range ++{ ++ /* Return cursor screen rect. AT tools call this with the ++ selectedTextRange to locate the insertion point. */ ++ NSRect viewRect = lastAccessibilityCursorRect; ++ ++ if (viewRect.size.width < 1) ++ viewRect.size.width = 1; ++ if (viewRect.size.height < 1) ++ viewRect.size.height = 8; ++ ++ NSWindow *win = [self window]; ++ if (win == nil) ++ return NSZeroRect; ++ ++ NSRect windowRect = [self convertRect:viewRect toView:nil]; ++ return [win convertRectToScreen:windowRect]; ++} ++ ++- (NSRect)accessibilityFrameForRange:(NSRange)range ++{ ++ return [self accessibilityBoundsForRange:range]; ++} ++ ++/* ---- Text content methods (for Zoom and legacy AT) ---- */ ++ ++- (id)accessibilityValue ++{ ++ if (!emacsframe) ++ return @""; ++ ++ struct buffer *curbuf ++ = XBUFFER (XWINDOW (emacsframe->selected_window)->contents); ++ if (!curbuf) ++ return @""; ++ ++ ptrdiff_t start_byte = BUF_BEGV_BYTE (curbuf); ++ ptrdiff_t byte_range = BUF_ZV_BYTE (curbuf) - start_byte; ++ ptrdiff_t range = BUF_ZV (curbuf) - BUF_BEGV (curbuf); ++ ++ if (range > 10000) ++ { ++ range = 10000; ++ ptrdiff_t end_byte = buf_charpos_to_bytepos (curbuf, ++ BUF_BEGV (curbuf) + range); ++ byte_range = end_byte - start_byte; ++ } ++ ++ Lisp_Object str; ++ if (! NILP (BVAR (curbuf, enable_multibyte_characters))) ++ str = make_uninit_multibyte_string (range, byte_range); ++ else ++ str = make_uninit_string (range); ++ memcpy (SDATA (str), BYTE_POS_ADDR (start_byte), byte_range); ++ ++ return [NSString stringWithLispString:str]; ++} ++ ++- (NSRange)accessibilitySelectedTextRange ++{ ++ if (!emacsframe) ++ return NSMakeRange (0, 0); ++ ++ struct buffer *curbuf ++ = XBUFFER (XWINDOW (emacsframe->selected_window)->contents); ++ if (!curbuf) ++ return NSMakeRange (0, 0); ++ ++ ptrdiff_t pt = BUF_PT (curbuf) - BUF_BEGV (curbuf); ++ return NSMakeRange ((NSUInteger) pt, 0); ++} ++ ++- (NSString *)accessibilityStringForRange:(NSRange)nsrange ++{ ++ if (!emacsframe) ++ return @""; ++ ++ struct buffer *curbuf ++ = XBUFFER (XWINDOW (emacsframe->selected_window)->contents); ++ if (!curbuf) ++ return @""; ++ ++ ptrdiff_t start = BUF_BEGV (curbuf) + (ptrdiff_t) nsrange.location; ++ ptrdiff_t end = start + (ptrdiff_t) nsrange.length; ++ ptrdiff_t buf_end = BUF_ZV (curbuf); ++ ++ if (start < BUF_BEGV (curbuf)) start = BUF_BEGV (curbuf); ++ if (end > buf_end) end = buf_end; ++ if (start >= end) return @""; ++ ++ ptrdiff_t start_byte = buf_charpos_to_bytepos (curbuf, start); ++ ptrdiff_t end_byte = buf_charpos_to_bytepos (curbuf, end); ++ ptrdiff_t char_range = end - start; ++ ptrdiff_t brange = end_byte - start_byte; ++ ++ Lisp_Object str; ++ if (! NILP (BVAR (curbuf, enable_multibyte_characters))) ++ str = make_uninit_multibyte_string (char_range, brange); ++ else ++ str = make_uninit_string (char_range); ++ memcpy (SDATA (str), BUF_BYTE_ADDRESS (curbuf, start_byte), brange); ++ ++ return [NSString stringWithLispString:str]; ++} ++ ++- (NSInteger)accessibilityNumberOfCharacters ++{ ++ if (!emacsframe) ++ return 0; ++ struct buffer *curbuf ++ = XBUFFER (XWINDOW (emacsframe->selected_window)->contents); ++ if (!curbuf) ++ return 0; ++ ptrdiff_t range = BUF_ZV (curbuf) - BUF_BEGV (curbuf); ++ return (NSInteger) MIN (range, 10000); ++} ++ ++- (NSString *)accessibilitySelectedText ++{ ++ if (!emacsframe) ++ return @""; ++ struct buffer *curbuf ++ = XBUFFER (XWINDOW (emacsframe->selected_window)->contents); ++ if (!curbuf || NILP (BVAR (curbuf, mark_active))) ++ return @""; ++ return @""; ++} ++ ++- (NSInteger)accessibilityInsertionPointLineNumber ++{ ++ if (!emacsframe) ++ return 0; ++ struct window *w = XWINDOW (emacsframe->selected_window); ++ if (!w) ++ return 0; ++ return (NSInteger) (w->cursor.vpos); ++} ++ ++- (NSRange)accessibilityVisibleCharacterRange ++{ ++ if (!emacsframe) ++ return NSMakeRange (0, 0); ++ struct buffer *curbuf ++ = XBUFFER (XWINDOW (emacsframe->selected_window)->contents); ++ if (!curbuf) ++ return NSMakeRange (0, 0); ++ ptrdiff_t range = BUF_ZV (curbuf) - BUF_BEGV (curbuf); ++ return NSMakeRange (0, (NSUInteger) MIN (range, 10000)); ++} ++ ++- (NSAttributedString *)accessibilityAttributedStringForRange:(NSRange)range ++{ ++ NSString *str = [self accessibilityStringForRange:range]; ++ return [[[NSAttributedString alloc] initWithString:str] autorelease]; ++} ++ ++- (NSInteger)accessibilityLineForIndex:(NSInteger)index ++{ ++ if (!emacsframe) ++ return 0; ++ struct window *w = XWINDOW (emacsframe->selected_window); ++ if (!w || !w->current_matrix) ++ return 0; ++ struct buffer *curbuf = XBUFFER (w->contents); ++ if (!curbuf) ++ return 0; ++ ptrdiff_t charpos = BUF_BEGV (curbuf) + (ptrdiff_t) index; ++ struct glyph_matrix *matrix = w->current_matrix; ++ for (int i = 0; i < matrix->nrows; i++) ++ { ++ struct glyph_row *row = matrix->rows + i; ++ if (!row->enabled_p) ++ continue; ++ if (MATRIX_ROW_START_CHARPOS (row) <= charpos ++ && charpos < MATRIX_ROW_END_CHARPOS (row)) ++ return (NSInteger) i; ++ } ++ return 0; ++} ++ ++- (NSRange)accessibilityRangeForLine:(NSInteger)line ++{ ++ if (!emacsframe) ++ return NSMakeRange (0, 0); ++ struct window *w = XWINDOW (emacsframe->selected_window); ++ if (!w || !w->current_matrix) ++ return NSMakeRange (0, 0); ++ struct buffer *curbuf = XBUFFER (w->contents); ++ if (!curbuf) ++ return NSMakeRange (0, 0); ++ struct glyph_matrix *matrix = w->current_matrix; ++ if (line < 0 || line >= matrix->nrows) ++ return NSMakeRange (0, 0); ++ struct glyph_row *row = matrix->rows + line; ++ if (!row->enabled_p) ++ return NSMakeRange (0, 0); ++ ptrdiff_t start = MATRIX_ROW_START_CHARPOS (row) - BUF_BEGV (curbuf); ++ ptrdiff_t end = MATRIX_ROW_END_CHARPOS (row) - BUF_BEGV (curbuf); ++ if (start < 0) start = 0; ++ if (end < start) end = start; ++ return NSMakeRange ((NSUInteger) start, (NSUInteger) (end - start)); ++} ++ ++/* ---- Legacy parameterized attribute APIs (Zoom uses these) ---- */ ++ ++- (NSArray *)accessibilityParameterizedAttributeNames ++{ ++ NSArray *superAttrs = [super accessibilityParameterizedAttributeNames]; ++ if (superAttrs == nil) ++ superAttrs = @[]; ++ return [superAttrs arrayByAddingObjectsFromArray: ++ @[NSAccessibilityBoundsForRangeParameterizedAttribute, ++ NSAccessibilityStringForRangeParameterizedAttribute]]; ++} ++ ++- (id)accessibilityAttributeValue:(NSString *)attribute ++ forParameter:(id)parameter ++{ ++ if ([attribute isEqualToString: ++ NSAccessibilityBoundsForRangeParameterizedAttribute]) ++ { ++ NSRange range = [(NSValue *) parameter rangeValue]; ++ return [NSValue valueWithRect: ++ [self accessibilityBoundsForRange:range]]; ++ } ++ ++ if ([attribute isEqualToString: ++ NSAccessibilityStringForRangeParameterizedAttribute]) ++ { ++ NSRange range = [(NSValue *) parameter rangeValue]; ++ return [self accessibilityStringForRange:range]; ++ } ++ ++ return [super accessibilityAttributeValue:attribute forParameter:parameter]; ++} ++ ++#endif /* NS_IMPL_COCOA */ ++ + @end /* EmacsView */ + + +@@ -9941,6 +11060,14 @@ + + return [super accessibilityAttributeValue:attribute]; + } ++ ++- (id)accessibilityFocusedUIElement ++{ ++ EmacsView *view = (EmacsView *)[self delegate]; ++ if (view && [view respondsToSelector:@selector(accessibilityFocusedUIElement)]) ++ return [view accessibilityFocusedUIElement]; ++ return self; ++} + #endif /* NS_IMPL_COCOA */ + + /* Constrain size and placement of a frame.