From 7970024f17d83610a4fd58d7ab135b2c71783049 Mon Sep 17 00:00:00 2001 From: Martin Sukany Date: Fri, 27 Feb 2026 07:31:34 +0100 Subject: [PATCH] ns: implement VoiceOver accessibility (AXBoundsForRange, line nav, completions) --- src/nsterm.h | 47 +- src/nsterm.m | 2005 +++++++++++++++++++++++++++++++++++++++----------- 2 files changed, 1628 insertions(+), 424 deletions(-) diff --git a/src/nsterm.h b/src/nsterm.h index 4f9a1b0..22828f2 100644 --- a/src/nsterm.h +++ b/src/nsterm.h @@ -462,21 +462,49 @@ enum ns_return_frame_mode #ifdef NS_IMPL_COCOA @class EmacsView; -/* Base class for virtual accessibility elements attached to 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). */ +/* A visible run: maps a contiguous range of accessibility indices + to a contiguous range of buffer character positions. Invisible + text is skipped, so ax_start values are consecutive across runs + while charpos values may have gaps. */ +typedef struct ns_ax_visible_run +{ + ptrdiff_t charpos; /* Buffer charpos where this visible run starts. */ + ptrdiff_t length; /* Number of visible Emacs characters in this run. */ + NSUInteger ax_start; /* Starting index in the accessibility string. */ + NSUInteger ax_length; /* Length in accessibility string (UTF-16 units). */ +} ns_ax_visible_run; + +/* Virtual AXTextArea element — one per visible Emacs window (buffer). */ @interface EmacsAccessibilityBuffer : EmacsAccessibilityElement +{ + ns_ax_visible_run *visibleRuns; + NSUInteger visibleRunCount; +} +@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) Lisp_Object cachedSelectedWindow; -- (void) - postAccessibilityUpdatesForWindow:(struct window *)w - frame:(struct frame *)f; +@property (nonatomic, assign) BOOL cachedMarkActive; +@property (nonatomic, copy) NSString *cachedCompletionAnnouncement; +@property (nonatomic, assign) ptrdiff_t cachedCompletionOverlayStart; +@property (nonatomic, assign) ptrdiff_t cachedCompletionOverlayEnd; +@property (nonatomic, assign) ptrdiff_t cachedCompletionPoint; +- (void)invalidateTextCache; +- (void)postAccessibilityNotificationsForFrame:(struct frame *)f; +- (ptrdiff_t)charposForAccessibilityIndex:(NSUInteger)ax_idx; +- (NSUInteger)accessibilityIndexForCharpos:(ptrdiff_t)charpos; +@end + +/* Virtual AXStaticText element — one per mode line. */ +@interface EmacsAccessibilityModeLine : EmacsAccessibilityElement @end #endif /* NS_IMPL_COCOA */ @@ -501,6 +529,12 @@ enum ns_return_frame_mode BOOL maximizing_resize; NSMutableArray *accessibilityElements; Lisp_Object lastSelectedWindow; + Lisp_Object lastRootWindow; + BOOL accessibilityTreeValid; + BOOL accessibilityUpdating; + @public + NSRect lastAccessibilityCursorRect; + @protected #endif BOOL font_panel_active; NSFont *font_panel_result; @@ -562,6 +596,7 @@ enum ns_return_frame_mode #ifdef NS_IMPL_COCOA /* Accessibility support. */ - (void)rebuildAccessibilityTree; +- (void)invalidateAccessibilityTree; - (void)postAccessibilityUpdates; #endif @end diff --git a/src/nsterm.m b/src/nsterm.m index e67edbe..cfc5b4c 100644 --- a/src/nsterm.m +++ b/src/nsterm.m @@ -3237,6 +3237,37 @@ 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)); +#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]; @@ -6860,172 +6891,174 @@ Accessibility virtual elements (macOS / Cocoa only) #ifdef NS_IMPL_COCOA -/* ---- Helper: extract visible text from glyph rows of a window ---- */ +/* ---- Helper: extract buffer text for accessibility ---- */ + +/* Maximum characters exposed via accessibilityValue. */ +#define NS_AX_TEXT_CAP 100000 + +/* Build accessibility text for window W, skipping invisible text. + Populates *OUT_START with the buffer start charpos. + Populates *OUT_RUNS with an array of visible runs and *OUT_NRUNS + with the count. Caller must free *OUT_RUNS with xfree(). */ + static NSString * -ns_ax_text_from_glyph_rows (struct window *w) +ns_ax_buffer_text (struct window *w, ptrdiff_t *out_start, + ns_ax_visible_run **out_runs, NSUInteger *out_nruns) { - if (!w || !w->current_matrix) - return @""; - - struct glyph_matrix *matrix = w->current_matrix; - NSMutableString *text = [NSMutableString stringWithCapacity:4096]; - int nrows = matrix->nrows; + *out_runs = NULL; + *out_nruns = 0; - for (int i = 0; i < nrows; i++) + if (!w || !WINDOW_LEAF_P (w)) { - 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; + *out_start = 0; + return @""; + } - struct glyph *glyph = row->glyphs[TEXT_AREA]; - struct glyph *end = glyph + row->used[TEXT_AREA]; + struct buffer *b = XBUFFER (w->contents); + if (!b) + { + *out_start = 0; + return @""; + } - 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]]; - } - } - } + ptrdiff_t begv = BUF_BEGV (b); + ptrdiff_t zv = BUF_ZV (b); - /* 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"]; - } - } + *out_start = begv; - /* Cap at 32KB */ - if ([text length] > 32768) - return [text substringToIndex:32768]; + if (zv <= begv) + return @""; - return text; -} + struct buffer *oldb = current_buffer; + if (b != current_buffer) + set_buffer_internal_1 (b); + + /* First pass: count visible runs to allocate the mapping array. */ + NSUInteger run_capacity = 64; + ns_ax_visible_run *runs = xmalloc (run_capacity + * sizeof (ns_ax_visible_run)); + NSUInteger nruns = 0; + NSUInteger ax_offset = 0; + + NSMutableString *result = [NSMutableString string]; + ptrdiff_t pos = begv; + + while (pos < zv) + { + /* Check invisible property (text properties + overlays). */ + Lisp_Object invis = Fget_char_property (make_fixnum (pos), + Qinvisible, Qnil); + /* Check if invisible property means truly invisible. + TEXT_PROP_MEANS_INVISIBLE is defined only in xdisp.c, + so we replicate: EQ(invis, Qt), or invis is on the + buffer's invisibility-spec list. Simplified: any + non-nil invisible property hides the text. This matches + the common case (invisible t) and org-mode/dired usage. */ + if (!NILP (invis)) + { + /* Skip to the next position where invisible changes. */ + Lisp_Object next = Fnext_single_char_property_change ( + make_fixnum (pos), Qinvisible, Qnil, make_fixnum (zv)); + pos = FIXNUMP (next) ? XFIXNUM (next) : zv; + continue; + } + /* Find end of this visible run: where invisible property changes. */ + Lisp_Object next = Fnext_single_char_property_change ( + make_fixnum (pos), Qinvisible, Qnil, make_fixnum (zv)); + ptrdiff_t run_end = FIXNUMP (next) ? XFIXNUM (next) : zv; -/* ---- Row geometry helpers ---- */ + /* Cap total text at NS_AX_TEXT_CAP. */ + ptrdiff_t run_len = run_end - pos; + if (ax_offset + (NSUInteger) run_len > NS_AX_TEXT_CAP) + run_len = (ptrdiff_t) (NS_AX_TEXT_CAP - ax_offset); + if (run_len <= 0) + break; + run_end = pos + run_len; + + /* Extract this visible run's text. Use + Fbuffer_substring_no_properties which correctly handles the + buffer gap — raw BUF_BYTE_ADDRESS reads across the gap would + include garbage bytes when the run spans the gap position. */ + Lisp_Object lstr = Fbuffer_substring_no_properties ( + make_fixnum (pos), make_fixnum (run_end)); + NSString *nsstr = [NSString stringWithLispString:lstr]; + NSUInteger ns_len = [nsstr length]; + [result appendString:nsstr]; + + /* Record this visible run in the mapping. */ + if (nruns >= run_capacity) + { + run_capacity *= 2; + runs = xrealloc (runs, run_capacity + * sizeof (ns_ax_visible_run)); + } + runs[nruns].charpos = pos; + runs[nruns].length = run_len; + runs[nruns].ax_start = ax_offset; + runs[nruns].ax_length = ns_len; + nruns++; -/* 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++; + ax_offset += ns_len; + pos = run_end; } - 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; + if (b != oldb) + set_buffer_internal_1 (oldb); - 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; + *out_runs = runs; + *out_nruns = nruns; + return result; +} - /* 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); -} +/* ---- Helper: extract mode line text from glyph rows ---- */ -/* Return character range for a given visual line number. */ -static NSRange -ns_ax_range_for_line (struct window *w, int target_line) +static NSString * +ns_ax_mode_line_text (struct window *w) { if (!w || !w->current_matrix) - return NSMakeRange(0, 0); + return @""; + struct glyph_matrix *matrix = w->current_matrix; - NSUInteger pos = 0; - int line = 0; + 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; - if (!row->displays_text_p && !row->ends_at_zv_p) - continue; + if (!row->enabled_p || !row->mode_line_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++; + 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 NSMakeRange(NSNotFound, 0); + return text; } -/* Compute screen rect for a character range by unioning glyph row rects. */ + +/* ---- Helper: screen rect for a character range via glyph matrix ---- */ + static NSRect -ns_ax_frame_for_range (struct window *w, EmacsView *view, NSRange range) +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; - NSUInteger pos = 0; NSRect result = NSZeroRect; BOOL found = NO; @@ -7033,121 +7066,274 @@ row number (0-based, text rows only). */ { struct glyph_row *row = matrix->rows + i; if (!row->enabled_p || row->mode_line_p) - continue; + continue; if (!row->displays_text_p && !row->ends_at_zv_p) - continue; + 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++; - } - } + ptrdiff_t row_start = MATRIX_ROW_START_CHARPOS (row); + ptrdiff_t row_end = MATRIX_ROW_END_CHARPOS (row); - 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 (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->height; + + if (!found) + { + result = rowRect; + found = YES; + } + else + result = NSUnionRect (result, rowRect); + } } if (!found) return NSZeroRect; + /* Clip result to text area bounds. */ + { + int text_area_x, text_area_y, text_area_w, text_area_h; + window_box (w, TEXT_AREA, &text_area_x, &text_area_y, + &text_area_w, &text_area_h); + CGFloat max_y = WINDOW_TO_FRAME_PIXEL_Y (w, text_area_y + text_area_h); + if (NSMaxY (result) > max_y) + result.size.height = max_y - result.origin.y; + } + /* Convert from EmacsView (flipped) coords to screen coords. */ NSRect winRect = [view convertRect:result toView:nil]; return [[view window] convertRectToScreen:winRect]; } +/* AX enum numeric compatibility for NSAccessibility notifications. + Values match WebKit AXObjectCacheMac fallback enums + (AXTextStateChangeType / AXTextEditType / AXTextSelectionDirection / + AXTextSelectionGranularity). */ +enum { + ns_ax_text_state_change_unknown = 0, + ns_ax_text_state_change_edit = 1, + ns_ax_text_state_change_selection_move = 2, + + ns_ax_text_edit_type_typing = 3, + + ns_ax_text_selection_direction_unknown = 0, + ns_ax_text_selection_direction_previous = 3, + ns_ax_text_selection_direction_next = 4, + ns_ax_text_selection_direction_discontiguous = 5, + + ns_ax_text_selection_granularity_unknown = 0, + ns_ax_text_selection_granularity_character = 1, + ns_ax_text_selection_granularity_line = 3, +}; -/* 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) +ns_ax_utf16_length_for_buffer_range (struct buffer *b, ptrdiff_t start, + ptrdiff_t end) { - if (!w || !w->current_matrix || !WINDOW_LEAF_P(w)) + if (!b || end <= start) return 0; - struct buffer *b = XBUFFER(w->contents); - if (!b) - return 0; + struct buffer *oldb = current_buffer; + if (b != current_buffer) + set_buffer_internal_1 (b); - ptrdiff_t point = BUF_PT(b); - struct glyph_matrix *matrix = w->current_matrix; - NSUInteger pos = 0; + Lisp_Object lstr = Fbuffer_substring_no_properties (make_fixnum (start), + make_fixnum (end)); + NSString *nsstr = [NSString stringWithLispString:lstr]; + NSUInteger len = [nsstr length]; - for (int i = 0; i < matrix->nrows; i++) + if (b != oldb) + set_buffer_internal_1 (oldb); + + return len; +} + +static BOOL +ns_ax_find_completion_overlay_range (struct buffer *b, ptrdiff_t point, + ptrdiff_t *out_start, + ptrdiff_t *out_end) +{ + if (!b || !out_start || !out_end) + return NO; + + Lisp_Object faceSym = intern ("completions-highlight"); + ptrdiff_t begv = BUF_BEGV (b); + ptrdiff_t zv = BUF_ZV (b); + ptrdiff_t best_start = 0; + ptrdiff_t best_end = 0; + ptrdiff_t best_dist = PTRDIFF_MAX; + BOOL found = NO; + + /* Fast path: look at point and immediate neighbors first. */ + ptrdiff_t probes[3] = { point, point - 1, point + 1 }; + for (int i = 0; i < 3 && !found; 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 p = probes[i]; + if (p < begv || p > zv) + continue; - ptrdiff_t row_start = MATRIX_ROW_START_CHARPOS (row); - ptrdiff_t row_end = MATRIX_ROW_END_CHARPOS (row); + Lisp_Object overlays = Foverlays_at (make_fixnum (p), Qnil); + Lisp_Object tail; + for (tail = overlays; CONSP (tail); tail = XCDR (tail)) + { + Lisp_Object ov = XCAR (tail); + Lisp_Object face = Foverlay_get (ov, Qface); + if (!(EQ (face, faceSym) + || (CONSP (face) && !NILP (Fmemq (faceSym, face))))) + continue; + + ptrdiff_t ov_start = OVERLAY_START (ov); + ptrdiff_t ov_end = OVERLAY_END (ov); + if (ov_end <= ov_start) + continue; + + best_start = ov_start; + best_end = ov_end; + best_dist = 0; + found = YES; + break; + } + } - 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; - } + if (!found) + { + for (ptrdiff_t scan = begv; scan < zv; scan++) + { + Lisp_Object overlays = Foverlays_at (make_fixnum (scan), Qnil); + Lisp_Object tail; + for (tail = overlays; CONSP (tail); tail = XCDR (tail)) + { + Lisp_Object ov = XCAR (tail); + Lisp_Object face = Foverlay_get (ov, Qface); + if (!(EQ (face, faceSym) + || (CONSP (face) && !NILP (Fmemq (faceSym, face))))) + continue; + + ptrdiff_t ov_start = OVERLAY_START (ov); + ptrdiff_t ov_end = OVERLAY_END (ov); + if (ov_end <= ov_start) + continue; + + ptrdiff_t dist = 0; + if (point < ov_start) + dist = ov_start - point; + else if (point > ov_end) + dist = point - ov_end; + + if (!found || dist < best_dist + || (dist == best_dist + && (ov_start < point && best_start >= point))) + { + best_start = ov_start; + best_end = ov_end; + best_dist = dist; + found = YES; + } + } + } + } - /* 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; + if (!found) + return NO; + + *out_start = best_start; + *out_end = best_end; + return YES; +} + +static bool +ns_ax_event_is_ctrl_n_or_p (int *which) +{ + Lisp_Object ev = last_command_event; + if (CONSP (ev)) + ev = EVENT_HEAD (ev); + + if (!FIXNUMP (ev)) + return false; + + EMACS_INT c = XFIXNUM (ev); + if (c == 14) /* C-n */ + { + if (which) + *which = 1; + return true; + } + if (c == 16) /* C-p */ + { + if (which) + *which = -1; + return true; + } + return false; +} + +static bool +ns_ax_command_is_basic_line_move (void) +{ + if (!SYMBOLP (real_this_command)) + return false; + + Lisp_Object next = intern ("next-line"); + Lisp_Object prev = intern ("previous-line"); + return EQ (real_this_command, next) || EQ (real_this_command, prev); +} + +static NSString * +ns_ax_completion_text_for_span (EmacsAccessibilityBuffer *elem, + struct buffer *b, + ptrdiff_t start, + ptrdiff_t end, + NSString *cachedText) +{ + if (!elem || !b || !cachedText || end <= start) + return nil; + + NSString *text = nil; + struct buffer *oldb = current_buffer; + if (b != current_buffer) + set_buffer_internal_1 (b); + + /* Prefer canonical completion candidate string from text property. */ + ptrdiff_t probes[2] = { start, end - 1 }; + for (int i = 0; i < 2 && !text; i++) + { + ptrdiff_t p = probes[i]; + Lisp_Object cstr = Fget_char_property (make_fixnum (p), + intern ("completion--string"), + Qnil); + if (STRINGP (cstr)) + text = [NSString stringWithLispString:cstr]; + } + + if (!text) + { + NSUInteger ax_s = [elem accessibilityIndexForCharpos:start]; + NSUInteger ax_e = [elem accessibilityIndexForCharpos:end]; + if (ax_e > ax_s && ax_e <= [cachedText length]) + text = [cachedText substringWithRange:NSMakeRange (ax_s, ax_e - ax_s)]; + } + + if (b != oldb) + set_buffer_internal_1 (oldb); + + if (text) + { + text = [text stringByTrimmingCharactersInSet: + [NSCharacterSet whitespaceAndNewlineCharacterSet]]; + if ([text length] == 0) + text = nil; } - return pos > 0 ? pos - 1 : 0; + + return text; } @@ -7159,7 +7345,7 @@ - (NSRect)screenRectFromEmacsX:(int)x y:(int)y width:(int)ew height:(int)eh if (!view || ![view window]) return NSZeroRect; - NSRect r = NSMakeRect(x, y, ew, eh); + NSRect r = NSMakeRect (x, y, ew, eh); NSRect winRect = [view convertRect:r toView:nil]; return [[view window] convertRectToScreen:winRect]; } @@ -7169,130 +7355,475 @@ - (BOOL)isAccessibilityElement return YES; } -@end - - -@implementation EmacsAccessibilityBuffer +/* ---- Hierarchy plumbing (required for VoiceOver to find us) ---- */ -/* ---- NSAccessibility protocol ---- */ - -- (NSAccessibilityRole)accessibilityRole +- (id)accessibilityParent { - return NSAccessibilityTextAreaRole; + return NSAccessibilityUnignoredAncestor (self.emacsView); } -- (NSString *)accessibilityRoleDescription +- (id)accessibilityWindow { - return @"editor"; + return [self.emacsView window]; } -- (NSString *)accessibilityLabel +- (id)accessibilityTopLevelUIElement { - 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"; + return [self.emacsView window]; } -- (id)accessibilityValue -{ - struct window *w = self.emacsWindow; - if (!w) - return @""; - return ns_ax_text_from_glyph_rows(w); -} +@end -- (NSInteger)accessibilityNumberOfCharacters -{ - NSString *text = [self accessibilityValue]; - return [text length]; -} -- (NSString *)accessibilitySelectedText +@implementation EmacsAccessibilityBuffer +@synthesize cachedText; +@synthesize cachedTextModiff; +@synthesize cachedTextStart; +@synthesize cachedModiff; +@synthesize cachedPoint; +@synthesize cachedMarkActive; +@synthesize cachedCompletionAnnouncement; +@synthesize cachedCompletionOverlayStart; +@synthesize cachedCompletionOverlayEnd; +@synthesize cachedCompletionPoint; + +- (void)dealloc { - struct window *w = self.emacsWindow; - if (!w || !WINDOW_LEAF_P(w)) - return @""; + [cachedText release]; + [cachedCompletionAnnouncement release]; + if (visibleRuns) + xfree (visibleRuns); + [super dealloc]; +} - struct buffer *b = XBUFFER(w->contents); - if (!b || NILP(BVAR(b, mark_active))) - return @""; +/* ---- Text cache ---- */ - /* 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]; +- (void)invalidateTextCache +{ + [cachedText release]; + cachedText = nil; + if (visibleRuns) + { + xfree (visibleRuns); + visibleRuns = NULL; + } + visibleRunCount = 0; } -- (NSRange)accessibilitySelectedTextRange +- (void)ensureTextCache { struct window *w = self.emacsWindow; - if (!w || !WINDOW_LEAF_P(w)) - return NSMakeRange(0, 0); + if (!w || !WINDOW_LEAF_P (w)) + return; - struct buffer *b = XBUFFER(w->contents); + struct buffer *b = XBUFFER (w->contents); if (!b) - return NSMakeRange(0, 0); + return; - NSUInteger point_idx = ns_ax_index_for_point(w); + ptrdiff_t modiff = BUF_MODIFF (b); + ptrdiff_t pt = BUF_PT (b); + NSUInteger textLen = cachedText ? [cachedText length] : 0; + if (cachedText && cachedTextModiff == modiff + && pt >= cachedTextStart + && (textLen == 0 + || [self accessibilityIndexForCharpos:pt] <= textLen)) + return; - if (NILP(BVAR(b, mark_active))) - return NSMakeRange(point_idx, 0); + ptrdiff_t start; + ns_ax_visible_run *runs = NULL; + NSUInteger nruns = 0; + NSString *text = ns_ax_buffer_text (w, &start, &runs, &nruns); - /* 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); + [cachedText release]; + cachedText = [text retain]; + cachedTextModiff = modiff; + cachedTextStart = start; + + if (visibleRuns) + xfree (visibleRuns); + visibleRuns = runs; + visibleRunCount = nruns; } -- (NSInteger)accessibilityInsertionPointLineNumber +/* ---- Index mapping ---- */ + +/* Convert buffer charpos to accessibility string index. */ +- (NSUInteger)accessibilityIndexForCharpos:(ptrdiff_t)charpos { 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); + struct buffer *b = (w && WINDOW_LEAF_P (w)) ? XBUFFER (w->contents) : NULL; + + for (NSUInteger i = 0; i < visibleRunCount; i++) + { + ns_ax_visible_run *r = &visibleRuns[i]; + if (charpos >= r->charpos && charpos < r->charpos + r->length) + { + if (!b) + return r->ax_start; + NSUInteger delta = ns_ax_utf16_length_for_buffer_range (b, r->charpos, + charpos); + if (delta > r->ax_length) + delta = r->ax_length; + return r->ax_start + delta; + } + /* If charpos falls in an invisible gap before the next run, + map it to the start of the next visible run. */ + if (charpos < r->charpos) + return r->ax_start; + } + /* Past end — return total length. */ + if (visibleRunCount > 0) + { + ns_ax_visible_run *last = &visibleRuns[visibleRunCount - 1]; + return last->ax_start + last->ax_length; + } + return 0; +} + +/* Convert accessibility string index to buffer charpos. */ +- (ptrdiff_t)charposForAccessibilityIndex:(NSUInteger)ax_idx +{ + struct window *w = self.emacsWindow; + struct buffer *b = (w && WINDOW_LEAF_P (w)) ? XBUFFER (w->contents) : NULL; + + for (NSUInteger i = 0; i < visibleRunCount; i++) + { + ns_ax_visible_run *r = &visibleRuns[i]; + if (ax_idx >= r->ax_start + && ax_idx < r->ax_start + r->ax_length) + { + if (!b) + return r->charpos; + + NSUInteger target = ax_idx - r->ax_start; + ptrdiff_t lo = r->charpos; + ptrdiff_t hi = r->charpos + r->length; + + while (lo < hi) + { + ptrdiff_t mid = lo + (hi - lo) / 2; + NSUInteger mid_len = ns_ax_utf16_length_for_buffer_range (b, + r->charpos, + mid); + if (mid_len < target) + lo = mid + 1; + else + hi = mid; + } + + return lo; + } + } + /* Past end — return last charpos. */ + if (visibleRunCount > 0) + { + ns_ax_visible_run *last = &visibleRuns[visibleRunCount - 1]; + return last->charpos + last->length; + } + return cachedTextStart; +} + +/* ---- 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); + + if (!BUFFERP (w->contents)) + 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 = [self accessibilityIndexForCharpos:pt]; + + if (NILP (BVAR (b, mark_active))) + return NSMakeRange (point_idx, 0); + + ptrdiff_t mark_pos = marker_position (BVAR (b, mark)); + NSUInteger mark_idx = [self accessibilityIndexForCharpos:mark_pos]; + NSUInteger start_idx = MIN (point_idx, mark_idx); + NSUInteger end_idx = MAX (point_idx, mark_idx); + return NSMakeRange (start_idx, end_idx - start_idx); +} + +- (void)setAccessibilitySelectedTextRange:(NSRange)range +{ + struct window *w = self.emacsWindow; + if (!w || !WINDOW_LEAF_P (w)) + return; + + struct buffer *b = XBUFFER (w->contents); + if (!b) + return; + + [self ensureTextCache]; + + /* Convert accessibility index to buffer charpos via mapping. */ + ptrdiff_t charpos = [self charposForAccessibilityIndex:range.location]; + + /* Clamp to buffer bounds. */ + if (charpos < BUF_BEGV (b)) + charpos = BUF_BEGV (b); + if (charpos > BUF_ZV (b)) + charpos = BUF_ZV (b); + + block_input (); + + /* Move point directly in the buffer. Use set_point_both which + operates on the current buffer — temporarily switch if needed. */ + struct buffer *oldb = current_buffer; + if (b != current_buffer) + set_buffer_internal_1 (b); + + SET_PT_BOTH (charpos, CHAR_TO_BYTE (charpos)); + + /* Keep mark state aligned with requested selection range. */ + if (range.length > 0) + { + ptrdiff_t mark_charpos = [self charposForAccessibilityIndex: + range.location + range.length]; + if (mark_charpos > BUF_ZV (b)) + mark_charpos = BUF_ZV (b); + Fset_marker (BVAR (b, mark), make_fixnum (mark_charpos), + Fcurrent_buffer ()); + bset_mark_active (b, Qt); + } + else + bset_mark_active (b, Qnil); + + if (b != oldb) + set_buffer_internal_1 (oldb); + + unblock_input (); + + /* Update cached state so the next notification cycle doesn't + re-announce this movement. */ + self.cachedPoint = charpos; + self.cachedMarkActive = (range.length > 0); +} + +- (void)setAccessibilityFocused:(BOOL)flag +{ + if (!flag) + return; + + struct window *w = self.emacsWindow; + if (!w || !WINDOW_LEAF_P (w)) + return; + + EmacsView *view = self.emacsView; + if (!view || !view->emacsframe) + return; + + block_input (); + + /* Raise the frame's NS window to ensure keyboard focus. */ + NSWindow *nswin = [view window]; + if (nswin && ![nswin isKeyWindow]) + [nswin makeKeyAndOrderFront:nil]; + + unblock_input (); + + /* Post SelectedTextChanged so VoiceOver reads the current line + upon entering text interaction mode. + WebKit AXObjectCacheMac fallback enum: SelectionMove = 2. */ + NSDictionary *info = @{@"AXTextStateChangeType": @(ns_ax_text_state_change_selection_move), + @"AXTextChangeElement": self}; + NSAccessibilityPostNotificationWithUserInfo ( + self, NSAccessibilitySelectedTextChangedNotification, info); +} + +- (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 = [self accessibilityIndexForCharpos:pt]; + 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 { - NSString *text = [self accessibilityValue]; - if (range.location + range.length > [text length]) + [self ensureTextCache]; + if (!cachedText || range.location + range.length > [cachedText length]) return @""; - return [text substringWithRange:range]; + return [cachedText 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) + [self ensureTextCache]; + if (!cachedText || index < 0) return 0; - return ns_ax_line_for_index(w, (NSUInteger)index); + + 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 { - struct window *w = self.emacsWindow; - if (!w) - return NSMakeRange(0, 0); - return ns_ax_range_for_line(w, (int)line); + [self ensureTextCache]; + if (!cachedText || line < 0) + return NSMakeRange (NSNotFound, 0); + + NSUInteger len = [cachedText length]; + NSInteger cur_line = 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++; + /* Include the trailing newline so empty lines have length 1. */ + if (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++; + } + } + /* Phantom final line after the last newline. */ + if (cur_line == line) + return NSMakeRange (len, 0); + return NSMakeRange (NSNotFound, 0); +} + +- (NSRange)accessibilityRangeForIndex:(NSInteger)index +{ + [self ensureTextCache]; + if (!cachedText || index < 0 + || (NSUInteger) index >= [cachedText length]) + return NSMakeRange (NSNotFound, 0); + return [cachedText rangeOfComposedCharacterSequenceAtIndex:(NSUInteger)index]; +} + +- (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 @@ -7301,14 +7832,18 @@ - (NSRect)accessibilityFrameForRange:(NSRange)range EmacsView *view = self.emacsView; if (!w || !view) return NSZeroRect; - return ns_ax_frame_for_range(w, view, range); + /* Convert ax-index range to charpos range for glyph lookup. */ + [self ensureTextCache]; + ptrdiff_t cp_start = [self charposForAccessibilityIndex:range.location]; + ptrdiff_t cp_end = [self charposForAccessibilityIndex: + range.location + range.length]; + NSRange charRange = NSMakeRange (0, (NSUInteger) (cp_end - cp_start)); + return ns_ax_frame_for_range (w, view, cp_start, charRange); } - - (NSRange)accessibilityRangeForPosition:(NSPoint)screenPoint { - /* Hit test: convert screen point to buffer character index. - Used by VoiceOver for mouse/trackpad exploration. */ + /* Hit test: convert screen point to buffer character index. */ struct window *w = self.emacsWindow; EmacsView *view = self.emacsView; if (!w || !view || !w->current_matrix) @@ -7318,7 +7853,7 @@ - (NSRange)accessibilityRangeForPosition:(NSPoint)screenPoint NSPoint windowPoint = [[view window] convertPointFromScreen:screenPoint]; NSPoint viewPoint = [view convertPoint:windowPoint fromView:nil]; - /* Convert to Emacs pixel coordinates (EmacsView is flipped). */ + /* Convert to window-relative pixel coordinates. */ int x = (int) viewPoint.x - w->pixel_left; int y = (int) viewPoint.y - w->pixel_top; @@ -7328,19 +7863,19 @@ - (NSRange)accessibilityRangeForPosition:(NSPoint)screenPoint /* 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 (!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) @@ -7355,49 +7890,32 @@ - (NSRange)accessibilityRangeForPosition:(NSPoint)screenPoint 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; - } + { + 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); + /* Convert buffer charpos to accessibility index via mapping. */ + [self ensureTextCache]; + NSUInteger ax_idx = [self accessibilityIndexForCharpos:best_charpos]; + if (cachedText && ax_idx > [cachedText length]) + ax_idx = [cachedText length]; + return NSMakeRange (ax_idx, 1); } - (NSRange)accessibilityVisibleCharacterRange { - NSString *text = [self accessibilityValue]; - return NSMakeRange(0, [text length]); -} - -- (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); + /* Return the full cached text range. VoiceOver interprets the + visible range boundary as end-of-text, so we must expose the + entire buffer to avoid premature "end of text" announcements. */ + [self ensureTextCache]; + return NSMakeRange (0, cachedText ? [cachedText length] : 0); } - (NSRect)accessibilityFrame @@ -7405,50 +7923,523 @@ - (NSRect)accessibilityFrame struct window *w = self.emacsWindow; if (!w) return NSZeroRect; + + /* Subtract mode line height so the buffer element does not overlap it. */ + int text_h = w->pixel_height; + if (w->current_matrix) + { + for (int i = w->current_matrix->nrows - 1; i >= 0; i--) + { + struct glyph_row *row = w->current_matrix->rows + i; + if (row->enabled_p && row->mode_line_p) + { + text_h -= row->visible_height; + break; + } + } + } return [self screenRectFromEmacsX:w->pixel_left - y:w->pixel_top - width:w->pixel_width - height:w->pixel_height]; + y:w->pixel_top + width:w->pixel_width + height:text_h]; } /* ---- Notification dispatch ---- */ -- (void)postAccessibilityUpdatesForWindow:(struct window *)w - frame:(struct frame *)f +- (void)postAccessibilityNotificationsForFrame:(struct frame *)f { - if (!w || !WINDOW_LEAF_P(w)) + struct window *w = self.emacsWindow; + if (!w || !WINDOW_LEAF_P (w)) return; - struct buffer *b = XBUFFER(w->contents); + struct buffer *b = XBUFFER (w->contents); if (!b) return; - ptrdiff_t modiff = BUF_MODIFF(b); - ptrdiff_t point = BUF_PT(b); + ptrdiff_t modiff = BUF_MODIFF (b); + ptrdiff_t point = BUF_PT (b); + BOOL markActive = !NILP (BVAR (b, mark_active)); - /* Text content changed? */ + /* --- Text changed → typing echo --- + WebKit AXObjectCacheMac fallback enum: Edit = 1, Typing = 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 = [self accessibilityIndexForCharpos:point - 1]; + if (idx < [cachedText length]) + changedChar = [cachedText substringWithRange: + NSMakeRange (idx, 1)]; + } + } + else + { + [self invalidateTextCache]; + } + self.cachedModiff = modiff; - NSAccessibilityPostNotification(self, - NSAccessibilityValueChangedNotification); + /* Update cachedPoint here so the selection-move branch below + does NOT fire for point changes caused by edits. WebKit and + Chromium never send both ValueChanged and SelectedTextChanged + for the same user action — they are mutually exclusive. */ + self.cachedPoint = point; - /* Rich typing echo for VoiceOver. */ + NSDictionary *change = @{ + @"AXTextEditType": @(ns_ax_text_edit_type_typing), + @"AXTextChangeValue": changedChar, + @"AXTextChangeValueLength": @([changedChar length]) + }; NSDictionary *userInfo = @{ - @"AXTextStateChangeType" : @1, /* AXTextStateChangeTypeEdit */ - @"AXTextEditType" : @0 /* kAXTextEditTypeTyping */ + @"AXTextStateChangeType": @(ns_ax_text_state_change_edit), + @"AXTextChangeValues": @[change], + @"AXTextChangeElement": self }; - NSAccessibilityPostNotificationWithUserInfo( - self, NSAccessibilityValueChangedNotification, userInfo); + NSAccessibilityPostNotificationWithUserInfo ( + self, NSAccessibilityValueChangedNotification, userInfo); } - /* Cursor moved? */ - if (point != self.cachedPoint) + /* --- Cursor moved or selection changed → line reading --- + WebKit AXObjectCacheMac fallback enum: SelectionMove = 2. + Use 'else if' — edits and selection moves are mutually exclusive + per the WebKit/Chromium pattern. VoiceOver gets confused if + both notifications arrive in the same runloop iteration. */ + else if (point != self.cachedPoint || markActive != self.cachedMarkActive) { + ptrdiff_t oldPoint = self.cachedPoint; self.cachedPoint = point; - NSAccessibilityPostNotification(self, - NSAccessibilitySelectedTextChangedNotification); + self.cachedMarkActive = markActive; + + /* Compute direction. */ + NSInteger direction = ns_ax_text_selection_direction_discontiguous; + if (point > oldPoint) + direction = ns_ax_text_selection_direction_next; + else if (point < oldPoint) + direction = ns_ax_text_selection_direction_previous; + + int ctrlNP = 0; + bool isCtrlNP = ns_ax_event_is_ctrl_n_or_p (&ctrlNP); + + /* Compute granularity from movement distance. + Prefer robust line-range comparison for vertical movement, + otherwise single char (1) or unknown (0). */ + NSInteger granularity = ns_ax_text_selection_granularity_unknown; + [self ensureTextCache]; + if (cachedText && oldPoint > 0) + { + ptrdiff_t delta = point - oldPoint; + if (delta == 1 || delta == -1) + granularity = ns_ax_text_selection_granularity_character; /* Character. */ + else + { + NSUInteger tlen = [cachedText length]; + NSUInteger oldIdx = [self accessibilityIndexForCharpos:oldPoint]; + NSUInteger newIdx = [self accessibilityIndexForCharpos:point]; + if (oldIdx > tlen) + oldIdx = tlen; + if (newIdx > tlen) + newIdx = tlen; + + NSRange oldLine = [cachedText lineRangeForRange: + NSMakeRange (oldIdx, 0)]; + NSRange newLine = [cachedText lineRangeForRange: + NSMakeRange (newIdx, 0)]; + if (oldLine.location != newLine.location) + granularity = ns_ax_text_selection_granularity_line; /* Line. */ + + } + } + + /* Force line semantics for explicit C-n/C-p keystrokes. + This isolates the key-path difference from arrow-down/up. */ + if (isCtrlNP) + { + direction = (ctrlNP > 0 + ? ns_ax_text_selection_direction_next + : ns_ax_text_selection_direction_previous); + granularity = ns_ax_text_selection_granularity_line; + } + + NSDictionary *moveInfo = @{ + @"AXTextStateChangeType": @(ns_ax_text_state_change_selection_move), + @"AXTextSelectionDirection": @(direction), + @"AXTextSelectionGranularity": @(granularity), + @"AXTextChangeElement": self + }; + NSAccessibilityPostNotificationWithUserInfo ( + self, + NSAccessibilitySelectedTextChangedNotification, + moveInfo); + + /* C-n/C-p (`next-line' / `previous-line') can diverge from + arrow-down/up command paths in some major modes (completion list, + Dired, etc.). Emit an explicit line announcement for this basic + line-motion path so VoiceOver tracks the Emacs point reliably. */ + if ([self isAccessibilityFocused] + && cachedText + && (isCtrlNP || ns_ax_command_is_basic_line_move ()) + && (direction == ns_ax_text_selection_direction_next + || direction == ns_ax_text_selection_direction_previous)) + { + NSUInteger point_idx = [self accessibilityIndexForCharpos:point]; + if (point_idx <= [cachedText length]) + { + NSInteger lineNum = [self accessibilityLineForIndex:point_idx]; + NSRange lineRange = [self accessibilityRangeForLine:lineNum]; + if (lineRange.location != NSNotFound + && lineRange.length > 0 + && lineRange.location + lineRange.length <= [cachedText length]) + { + NSString *lineText = [cachedText substringWithRange:lineRange]; + lineText = [lineText stringByTrimmingCharactersInSet: + [NSCharacterSet whitespaceAndNewlineCharacterSet]]; + if ([lineText length] > 0) + { + NSDictionary *annInfo = @{ + NSAccessibilityAnnouncementKey: lineText, + NSAccessibilityPriorityKey: @(NSAccessibilityPriorityHigh) + }; + NSAccessibilityPostNotificationWithUserInfo ( + NSApp, + NSAccessibilityAnnouncementRequestedNotification, + annInfo); + } + } + } + } + + /* --- Completions announcement --- + When point changes in a non-focused buffer (e.g. *Completions* + while the minibuffer has keyboard focus), VoiceOver won't read + the change because it's tracking the focused element. Post an + announcement so the user hears the selected completion. + + If there is a `completions-highlight` overlay at point (Emacs + highlights the selected completion candidate), read its full + text instead of just the current line. */ + if (![self isAccessibilityFocused] && cachedText) + { + NSString *announceText = nil; + ptrdiff_t currentOverlayStart = 0; + ptrdiff_t currentOverlayEnd = 0; + + struct buffer *oldb2 = current_buffer; + if (b != current_buffer) + set_buffer_internal_1 (b); + + /* 1) Prefer explicit completion candidate property when present. */ + Lisp_Object cstr = Fget_char_property (make_fixnum (point), + intern ("completion--string"), + Qnil); + if (STRINGP (cstr)) + announceText = [NSString stringWithLispString:cstr]; + + /* 2) Fallback: announce the mouse-face span at point. + completion-list-mode often marks the active candidate this way. */ + if (!announceText) + { + Lisp_Object mf = Fget_char_property (make_fixnum (point), + Qmouse_face, Qnil); + if (!NILP (mf)) + { + ptrdiff_t begv2 = BUF_BEGV (b); + ptrdiff_t zv2 = BUF_ZV (b); + ptrdiff_t s2 = point; + ptrdiff_t e2 = point; + + while (s2 > begv2) + { + Lisp_Object prev = Fget_char_property ( + make_fixnum (s2 - 1), Qmouse_face, Qnil); + if (!NILP (Fequal (prev, mf))) + s2--; + else + break; + } + + while (e2 < zv2) + { + Lisp_Object cur = Fget_char_property ( + make_fixnum (e2), Qmouse_face, Qnil); + if (!NILP (Fequal (cur, mf))) + e2++; + else + break; + } + + if (e2 > s2) + { + NSUInteger ax_s = [self accessibilityIndexForCharpos:s2]; + NSUInteger ax_e = [self accessibilityIndexForCharpos:e2]; + if (ax_e > ax_s && ax_e <= [cachedText length]) + announceText = [cachedText substringWithRange: + NSMakeRange (ax_s, ax_e - ax_s)]; + } + } + } + + /* 3) Fallback: check completions-highlight overlay span at point. */ + if (!announceText) + { + Lisp_Object faceSym = intern ("completions-highlight"); + Lisp_Object overlays = Foverlays_at (make_fixnum (point), Qnil); + Lisp_Object tail; + for (tail = overlays; CONSP (tail); tail = XCDR (tail)) + { + Lisp_Object ov = XCAR (tail); + Lisp_Object face = Foverlay_get (ov, Qface); + if (EQ (face, faceSym) + || (CONSP (face) + && !NILP (Fmemq (faceSym, face)))) + { + ptrdiff_t ov_start = OVERLAY_START (ov); + ptrdiff_t ov_end = OVERLAY_END (ov); + if (ov_end > ov_start) + { + announceText = ns_ax_completion_text_for_span (self, b, + ov_start, + ov_end, + cachedText); + currentOverlayStart = ov_start; + currentOverlayEnd = ov_end; + } + break; + } + } + } + + /* 4) Fallback: select the best completions-highlight overlay. + Prefer overlay nearest to point over first-found in buffer. */ + if (!announceText) + { + ptrdiff_t ov_start = 0; + ptrdiff_t ov_end = 0; + if (ns_ax_find_completion_overlay_range (b, point, &ov_start, &ov_end)) + { + announceText = ns_ax_completion_text_for_span (self, b, + ov_start, + ov_end, + cachedText); + currentOverlayStart = ov_start; + currentOverlayEnd = ov_end; + } + } + + if (b != oldb2) + set_buffer_internal_1 (oldb2); + + /* Final fallback: read the current line at point. */ + if (!announceText) + { + NSUInteger point_idx = [self accessibilityIndexForCharpos:point]; + if (point_idx <= [cachedText length]) + { + NSInteger lineNum = [self accessibilityLineForIndex: + point_idx]; + NSRange lineRange = [self accessibilityRangeForLine:lineNum]; + if (lineRange.location != NSNotFound + && lineRange.length > 0 + && lineRange.location + lineRange.length + <= [cachedText length]) + announceText = [cachedText substringWithRange:lineRange]; + } + } + + if (announceText) + { + announceText = [announceText stringByTrimmingCharactersInSet: + [NSCharacterSet whitespaceAndNewlineCharacterSet]]; + if ([announceText length] > 0) + { + BOOL textChanged = ![announceText isEqualToString: + self.cachedCompletionAnnouncement]; + BOOL overlayChanged = + (currentOverlayStart != self.cachedCompletionOverlayStart + || currentOverlayEnd != self.cachedCompletionOverlayEnd); + BOOL pointChanged = (point != self.cachedCompletionPoint); + if (textChanged || overlayChanged || pointChanged) + { + NSDictionary *annInfo = @{ + NSAccessibilityAnnouncementKey: announceText, + NSAccessibilityPriorityKey: + @(NSAccessibilityPriorityHigh) + }; + NSAccessibilityPostNotificationWithUserInfo ( + NSApp, + NSAccessibilityAnnouncementRequestedNotification, + annInfo); + } + self.cachedCompletionAnnouncement = announceText; + self.cachedCompletionOverlayStart = currentOverlayStart; + self.cachedCompletionOverlayEnd = currentOverlayEnd; + self.cachedCompletionPoint = point; + } + else + { + self.cachedCompletionAnnouncement = nil; + self.cachedCompletionOverlayStart = 0; + self.cachedCompletionOverlayEnd = 0; + self.cachedCompletionPoint = 0; + } + } + else + { + self.cachedCompletionAnnouncement = nil; + self.cachedCompletionOverlayStart = 0; + self.cachedCompletionOverlayEnd = 0; + self.cachedCompletionPoint = 0; + } + } + + } + else + { + if ([self isAccessibilityFocused]) + { + self.cachedCompletionAnnouncement = nil; + self.cachedCompletionOverlayStart = 0; + self.cachedCompletionOverlayEnd = 0; + self.cachedCompletionPoint = 0; + } + else + { + [self ensureTextCache]; + if (cachedText) + { + NSString *announceText = nil; + ptrdiff_t currentOverlayStart = 0; + ptrdiff_t currentOverlayEnd = 0; + struct buffer *oldb2 = current_buffer; + if (b != current_buffer) + set_buffer_internal_1 (b); + + if (ns_ax_find_completion_overlay_range (b, point, + ¤tOverlayStart, + ¤tOverlayEnd)) + { + announceText = ns_ax_completion_text_for_span (self, b, + currentOverlayStart, + currentOverlayEnd, + cachedText); + } + + if (b != oldb2) + set_buffer_internal_1 (oldb2); + + if (announceText) + { + announceText = [announceText stringByTrimmingCharactersInSet: + [NSCharacterSet whitespaceAndNewlineCharacterSet]]; + if ([announceText length] > 0) + { + BOOL textChanged = ![announceText isEqualToString: + self.cachedCompletionAnnouncement]; + BOOL overlayChanged = + (currentOverlayStart != self.cachedCompletionOverlayStart + || currentOverlayEnd != self.cachedCompletionOverlayEnd); + BOOL pointChanged = (point != self.cachedCompletionPoint); + if (textChanged || overlayChanged || pointChanged) + { + NSDictionary *annInfo = @{ + NSAccessibilityAnnouncementKey: announceText, + NSAccessibilityPriorityKey: + @(NSAccessibilityPriorityHigh) + }; + NSAccessibilityPostNotificationWithUserInfo ( + NSApp, + NSAccessibilityAnnouncementRequestedNotification, + annInfo); + } + self.cachedCompletionAnnouncement = announceText; + self.cachedCompletionOverlayStart = currentOverlayStart; + self.cachedCompletionOverlayEnd = currentOverlayEnd; + self.cachedCompletionPoint = point; + } + else + { + self.cachedCompletionAnnouncement = nil; + self.cachedCompletionOverlayStart = 0; + self.cachedCompletionOverlayEnd = 0; + self.cachedCompletionPoint = 0; + } + } + else + { + self.cachedCompletionAnnouncement = nil; + self.cachedCompletionOverlayStart = 0; + self.cachedCompletionOverlayEnd = 0; + self.cachedCompletionPoint = 0; + } + } + } + } +} + +@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 @@ -7498,6 +8489,7 @@ - (void)dealloc [layer release]; #endif + [accessibilityElements release]; [[self menu] release]; [super dealloc]; } @@ -8846,6 +9838,28 @@ - (void)windowDidBecomeKey /* for direct calls */ 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 + && [focused isKindOfClass:[EmacsAccessibilityBuffer class]]) + { + NSAccessibilityPostNotification (focused, + NSAccessibilityFocusedUIElementChangedNotification); + NSDictionary *info = @{@"AXTextStateChangeType": @(ns_ax_text_state_change_selection_move), + @"AXTextChangeElement": focused}; + NSAccessibilityPostNotificationWithUserInfo (focused, + NSAccessibilitySelectedTextChangedNotification, info); + } + else if (focused) + NSAccessibilityPostNotification (focused, + NSAccessibilityFocusedUIElementChangedNotification); + } +#endif } @@ -10089,7 +11103,8 @@ - (int) fullscreenState static void ns_ax_collect_windows (Lisp_Object window, EmacsView *view, - NSMutableArray *elements) + NSMutableArray *elements, + NSDictionary *existing) { if (NILP (window)) return; @@ -10098,32 +11113,47 @@ - (int) fullscreenState if (WINDOW_LEAF_P (w)) { - if (MINI_WINDOW_P (w)) - return; /* Skip minibuffer for MVP. */ + /* Buffer element — reuse existing if available. */ + EmacsAccessibilityBuffer *elem + = [existing objectForKey:[NSValue valueWithPointer:w]]; + if (!elem) + { + elem = [[EmacsAccessibilityBuffer alloc] init]; + elem.emacsView = view; - EmacsAccessibilityBuffer *elem = [[EmacsAccessibilityBuffer alloc] init]; - elem.emacsView = view; + /* Initialize cached state to -1 to force first notification. */ + elem.cachedModiff = -1; + elem.cachedPoint = -1; + elem.cachedMarkActive = NO; + } + else + { + [elem retain]; + } 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]; + [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); - child = XWINDOW (child)->next; - } + { + ns_ax_collect_windows (child, view, elements, existing); + child = XWINDOW (child)->next; + } } } @@ -10132,10 +11162,39 @@ - (void)rebuildAccessibilityTree if (!emacsframe) return; - NSMutableArray *newElements = [NSMutableArray arrayWithCapacity:4]; + /* 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); - accessibilityElements = newElements; + 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 @@ -10155,7 +11214,7 @@ - (BOOL)isAccessibilityElement - (NSArray *)accessibilityChildren { - if (!accessibilityElements || [accessibilityElements count] == 0) + if (!accessibilityElements || !accessibilityTreeValid) [self rebuildAccessibilityTree]; return accessibilityElements; } @@ -10165,16 +11224,15 @@ - (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) + if (!accessibilityElements || !accessibilityTreeValid) [self rebuildAccessibilityTree]; struct window *sel = XWINDOW (emacsframe->selected_window); - for (EmacsAccessibilityBuffer *elem in accessibilityElements) + for (EmacsAccessibilityElement *elem in accessibilityElements) { - if (elem.emacsWindow == sel) - return elem; + if ([elem isKindOfClass:[EmacsAccessibilityBuffer class]] + && elem.emacsWindow == sel) + return elem; } return self; } @@ -10190,32 +11248,143 @@ - (void)postAccessibilityUpdates if (!emacsframe) return; + /* Re-entrance guard: VoiceOver callbacks during notification posting + can trigger redisplay, which calls ns_update_end, which calls us + again. Prevent infinite recursion. */ + if (accessibilityUpdating) + return; + accessibilityUpdating = YES; + + /* Detect window tree change (split, delete, new buffer). Compare + FRAME_ROOT_WINDOW — if it changed, the tree structure changed. */ + Lisp_Object curRoot = FRAME_ROOT_WINDOW (emacsframe); + if (!EQ (curRoot, lastRootWindow)) + { + lastRootWindow = curRoot; + accessibilityTreeValid = NO; + } + + /* If tree is stale, rebuild FIRST so we don't iterate freed + window pointers. Skip notifications for this cycle — the + freshly-built elements have no previous state to diff against. */ + if (!accessibilityTreeValid) + { + [self rebuildAccessibilityTree]; + NSAccessibilityPostNotification (self, + NSAccessibilityLayoutChangedNotification); + + /* Post focus change so VoiceOver picks up the new tree. */ + id focused = [self accessibilityFocusedUIElement]; + if (focused && focused != self) + NSAccessibilityPostNotification (focused, + NSAccessibilityFocusedUIElementChangedNotification); + + lastSelectedWindow = emacsframe->selected_window; + accessibilityUpdating = NO; + return; + } + /* Post per-buffer notifications using EXISTING elements that have - cached state from the previous cycle. */ - for (EmacsAccessibilityBuffer *elem in accessibilityElements) + cached state from the previous cycle. Validate each window + pointer before use. */ + for (EmacsAccessibilityElement *elem in accessibilityElements) { - struct window *w = elem.emacsWindow; - if (w && WINDOW_LEAF_P (w)) - [elem postAccessibilityUpdatesForWindow:w frame:emacsframe]; + if ([elem isKindOfClass:[EmacsAccessibilityBuffer class]]) + { + struct window *w = elem.emacsWindow; + if (w && WINDOW_LEAF_P (w) + && BUFFERP (w->contents) && XBUFFER (w->contents)) + [(EmacsAccessibilityBuffer *) elem + postAccessibilityNotificationsForFrame:emacsframe]; + } } - /* Check for window switch (C-x o) before rebuild. */ + /* Check for window switch (C-x o). */ Lisp_Object curSel = emacsframe->selected_window; BOOL windowSwitched = !EQ (curSel, lastSelectedWindow); if (windowSwitched) - lastSelectedWindow = curSel; + { + lastSelectedWindow = curSel; + id focused = [self accessibilityFocusedUIElement]; + if (focused && focused != self + && [focused isKindOfClass:[EmacsAccessibilityBuffer class]]) + { + NSAccessibilityPostNotification (focused, + NSAccessibilityFocusedUIElementChangedNotification); + NSDictionary *info = @{@"AXTextStateChangeType": @(ns_ax_text_state_change_selection_move), + @"AXTextChangeElement": focused}; + NSAccessibilityPostNotificationWithUserInfo (focused, + NSAccessibilitySelectedTextChangedNotification, info); + } + else if (focused && focused != self) + NSAccessibilityPostNotification (focused, + NSAccessibilityFocusedUIElementChangedNotification); + } + + accessibilityUpdating = NO; +} - /* Now rebuild tree to pick up window configuration changes. */ - [self rebuildAccessibilityTree]; +/* ---- Cursor position for Zoom (via accessibilityBoundsForRange:) ---- - /* Post focus change AFTER rebuild so the new element exists. */ - if (windowSwitched) + 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]) { - id focused = [self accessibilityFocusedUIElement]; - if (focused && focused != self) - NSAccessibilityPostNotification (focused, - NSAccessibilityFocusedUIElementChangedNotification); + 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 */ -- 2.43.0