From 623b974e56fb46176dcece4d59ccdc951eea0538 Mon Sep 17 00:00:00 2001 From: Martin Sukany Date: Sun, 15 Mar 2026 16:15:42 +0100 Subject: [PATCH] ns: implement buffer accessibility element Implement EmacsAccessibilityBuffer, a virtual NSAccessibility text element (AXTextArea role) that exposes buffer contents to VoiceOver. * src/nsterm.m (ns_ax_free_runs_indirect): New unwind helper. (ns_ax_buffer_text): New static function; builds visible text and run array for accessibility clients. (ns_ax_frame_for_range): New static function; maps char ranges to screen rects. (ns_ax_post_notification_with_info): New static inline; async AX notification posting. (EmacsAccessibilityBuffer): Full NSAccessibility text protocol implementation including insertion point, selection, cursor position, styled ranges, and frame-for-range. --- src/nsterm.m | 1290 ++++++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 1290 insertions(+) diff --git a/src/nsterm.m b/src/nsterm.m index c68fc657b23..4c010f50e24 100644 --- a/src/nsterm.m +++ b/src/nsterm.m @@ -7338,6 +7338,273 @@ ns_create_font_panel_buttons (id target, SEL select, SEL cancel_action) record_unwind_protect_void (unblock_input) so that a longjmp out of a Lisp error path always restores the input-blocking refcount. */ +/* ---- Helper: extract buffer text for accessibility ---- */ + +/* Unwind handler for ns_ax_buffer_text: frees the runs array via an + indirect pointer. The caller NULLs the indirect pointer before + unbind_to on successful exit, transferring ownership to the caller. */ +static void +ns_ax_free_runs_indirect (void *arg) +{ + xfree (*(void **) arg); +} + +/* 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_buffer_text (struct window *w, ptrdiff_t *out_start, + ns_ax_visible_run **out_runs, NSUInteger *out_nruns) +{ + *out_runs = NULL; + *out_nruns = 0; + + if (!w || !WINDOW_LEAF_P (w)) + { + *out_start = 0; + return @""; + } + + if (!BUFFERP (w->contents)) + { + *out_start = 0; + return @""; + } + struct buffer *b = XBUFFER (w->contents); + + ptrdiff_t begv = BUF_BEGV (b); + ptrdiff_t zv = BUF_ZV (b); + + *out_start = begv; + + if (zv <= begv) + return @""; + + specpdl_ref count = SPECPDL_INDEX (); + /* block_input must precede record_unwind_protect_void (unblock_input): + if anything between SPECPDL_INDEX and block_input were to throw, + the unwind handler would call unblock_input without a matching + block_input, corrupting the input-blocking reference count. + Register unblock_input BEFORE record_unwind_current_buffer so that + LIFO unwind restores the buffer first (with input still blocked), + then unblocks input. */ + block_input (); + record_unwind_protect_void (unblock_input); + record_unwind_current_buffer (); + if (b != current_buffer) + set_buffer_internal_1 (b); + + /* First pass: count visible runs to allocate the mapping array. + Guard the allocation with an unwind handler via an indirect pointer + so that runs is freed if a Lisp signal occurs during the loop. */ + NSUInteger run_capacity = 64; + ns_ax_visible_run *runs = xmalloc (run_capacity + * sizeof (ns_ax_visible_run)); + void *runs_guard = runs; + record_unwind_protect_ptr (ns_ax_free_runs_indirect, &runs_guard); + NSUInteger nruns = 0; + NSUInteger ax_offset = 0; + + NSMutableString *result = [NSMutableString string]; + ptrdiff_t pos = begv; + + while (pos < zv) + { + /* Check invisible property (text properties + overlays). + Use TEXT_PROP_MEANS_INVISIBLE which respects buffer-invisibility-spec, + matching the logic in xdisp.c. This correctly handles org-mode, + outline-mode, hideshow and any mode using spec-controlled + invisibility (not just `invisible t'). */ + Lisp_Object invis = Fget_char_property (make_fixnum (pos), + Qinvisible, Qnil); + if (TEXT_PROP_MEANS_INVISIBLE (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; + + ptrdiff_t run_len = run_end - pos; + + /* 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_guard = runs; + } + runs[nruns].charpos = pos; + runs[nruns].length = run_len; + runs[nruns].ax_start = ax_offset; + runs[nruns].ax_length = ns_len; + nruns++; + + ax_offset += ns_len; + pos = run_end; + } + + /* Transfer ownership of runs to the caller; disarm the unwind + handler so it does not free runs on normal exit. */ + *out_runs = runs; + *out_nruns = nruns; + runs_guard = NULL; + [result retain]; + unbind_to (count, Qnil); + + return [result autorelease]; +} + +/* ---- Helper: screen rect for a character range via glyph matrix ---- */ + +static NSRect +ns_ax_frame_for_range (struct window *w, EmacsView *view, + ptrdiff_t charpos_start, + ptrdiff_t charpos_len) +{ + if (!w || !w->current_matrix || !view) + return NSZeroRect; + + /* Block input to prevent timer-triggered redisplay from modifying + the glyph matrix while we iterate over it. */ + specpdl_ref count = SPECPDL_INDEX (); + block_input (); + record_unwind_protect_void (unblock_input); + + /* charpos_start and charpos_len are already in buffer charpos + space --- the caller maps AX string indices through + charposForAccessibilityIndex which handles invisible text. */ + ptrdiff_t cp_start = charpos_start; + ptrdiff_t cp_end = cp_start + charpos_len; + + struct glyph_matrix *matrix = w->current_matrix; + NSRect result = NSZeroRect; + BOOL found = NO; + + /* Compute text area geometry once outside the row loop --- it + depends only on the window, not the individual row. window_box + returns frame-relative pixel coordinates. */ + int window_x, window_y, window_width, window_height; + window_box (w, TEXT_AREA, &window_x, &window_y, &window_width, + &window_height); + + for (int i = 0; i < matrix->nrows; i++) + { + struct glyph_row *row = matrix->rows + i; + if (!row->enabled_p || row->mode_line_p) + continue; + if (!row->displays_text_p && !row->ends_at_zv_p) + continue; + + ptrdiff_t row_start = MATRIX_ROW_START_CHARPOS (row); + ptrdiff_t row_end = MATRIX_ROW_END_CHARPOS (row); + + if (row_start < cp_end && row_end > cp_start) + { + NSRect rowRect; + rowRect.origin.x = window_x; + rowRect.origin.y = WINDOW_TO_FRAME_PIXEL_Y (w, MAX (0, row->y)); + rowRect.origin.y = MAX (rowRect.origin.y, window_y); + rowRect.size.width = window_width; + rowRect.size.height = row->visible_height; + + if (!found) + { + result = rowRect; + found = YES; + } + else + result = NSUnionRect (result, rowRect); + } + } + + if (!found) + { + unbind_to (count, Qnil); + return NSZeroRect; + } + + /* Clip result to text area bounds. window_box returns frame-relative + pixel coordinates, so use them directly --- no WINDOW_TO_FRAME + conversion needed. */ + { + CGFloat max_y = (CGFloat)(window_y + window_height); + if (NSMaxY (result) > max_y) + result.size.height = max_y - result.origin.y; + } + + unbind_to (count, Qnil); + + /* 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). + These values are undocumented Apple-private constants. + See WebKit Source/WebCore/accessibility/mac/AXObjectCacheMac.mm. */ +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_word = 2, + ns_ax_text_selection_granularity_line = 3, +}; + +/* Post AX notifications asynchronously to prevent deadlock. + NSAccessibilityPostNotification may synchronously invoke VoiceOver + callbacks that dispatch_sync back to the main queue. If we are + already on the main queue (e.g., inside postAccessibilityUpdates + called from ns_update_end), that dispatch_sync deadlocks. + Deferring via dispatch_async lets the current method return first, + freeing the main queue for VoiceOver's dispatch_sync calls. */ +static inline void +ns_ax_post_notification_with_info (id element, + NSAccessibilityNotificationName name, + NSDictionary *info) +{ + dispatch_async (dispatch_get_main_queue (), ^{ + NSAccessibilityPostNotificationWithUserInfo (element, name, info); + }); +} + @implementation EmacsAccessibilityElement - (instancetype)init @@ -7393,6 +7660,1029 @@ ns_create_font_panel_buttons (id target, SEL select, SEL cancel_action) @end +@implementation EmacsAccessibilityBuffer +@synthesize cachedText; +@synthesize cachedTextModiff; +@synthesize cachedOverlayModiff; +@synthesize cachedTextStart; +@synthesize cachedModiff; +@synthesize cachedPoint; +@synthesize cachedMarkActive; +@synthesize cachedCompletionAnnouncement; +@synthesize cachedCompletionOverlayStart; +@synthesize cachedCompletionOverlayEnd; +@synthesize cachedCompletionPoint; + +- (void)dealloc +{ + [cachedText release]; + [cachedCompletionAnnouncement release]; + [cachedInteractiveSpans release]; + if (visibleRuns) + xfree (visibleRuns); + if (lineStartOffsets) + xfree (lineStartOffsets); + [super dealloc]; +} + +/* ---- Text cache ---- */ + +- (void)invalidateTextCache +{ + ns_ax_visible_run *old_runs = NULL; + NSUInteger *old_offsets = NULL; + + @synchronized (self) + { + [cachedText release]; + cachedText = nil; + old_runs = visibleRuns; + visibleRuns = NULL; + visibleRunCount = 0; + old_offsets = lineStartOffsets; + lineStartOffsets = NULL; + lineCount = 0; + } + + if (old_runs) + xfree (old_runs); + if (old_offsets) + xfree (old_offsets); + + /* Interactive spans depend on the text cache; invalidate them too. + The respondsToSelector: guard ensures bisect safety: the + InteractiveSpans category implementation arrives in a later + patch. */ + if ([self respondsToSelector:@selector (invalidateInteractiveSpans)]) + [self invalidateInteractiveSpans]; +} + +/* ---- Line index helpers ---- */ + +/* Return the line number for AX string index IDX using the + precomputed lineStartOffsets array. Binary search: O(log L) + where L is the number of lines in the cached text. + + lineStartOffsets[i] holds the AX string index where line i + begins. Built once per cache rebuild in ensureTextCache. */ +- (NSInteger)lineForAXIndex:(NSUInteger)idx +{ + @synchronized (self) + { + if (!lineStartOffsets || lineCount == 0) + return 0; + + /* Binary search for the largest line whose start offset <= idx. */ + NSUInteger lo = 0, hi = lineCount; + while (lo < hi) + { + NSUInteger mid = lo + (hi - lo) / 2; + if (lineStartOffsets[mid] <= idx) + lo = mid + 1; + else + hi = mid; + } + return (NSInteger) (lo > 0 ? lo - 1 : 0); + } +} + +/* Return the AX string range for LINE using the precomputed + lineStartOffsets array. O(1) lookup. */ +- (NSRange)rangeForLine:(NSUInteger)line textLength:(NSUInteger)tlen +{ + @synchronized (self) + { + if (!lineStartOffsets || lineCount == 0) + return NSMakeRange (NSNotFound, 0); + + if (line >= lineCount) + return NSMakeRange (NSNotFound, 0); + + NSUInteger start = lineStartOffsets[line]; + NSUInteger end = (line + 1 < lineCount) + ? lineStartOffsets[line + 1] + : tlen; + return NSMakeRange (start, end - start); + } +} + +- (void)ensureTextCache +{ + NSTRACE ("EmacsAccessibilityBuffer ensureTextCache"); + /* This method is only called from the main thread (AX getters + dispatch_sync to main first). Reads of cachedText/cachedTextModiff + below are therefore safe without @synchronized --- only the + write section at the end needs synchronization to protect + against concurrent reads from AX server thread. */ + eassert ([NSThread isMainThread]); + struct window *w = [self validWindow]; + if (!w || !WINDOW_LEAF_P (w)) + return; + + if (!BUFFERP (w->contents) + || !BUFFER_LIVE_P (XBUFFER (w->contents))) + return; + struct buffer *b = XBUFFER (w->contents); + + /* Use BUF_MODIFF, not BUF_CHARS_MODIFF, for cache validity. + + Fold/unfold commands (org-mode, outline-mode, hideshow-mode) change + text visibility by modifying the 'invisible text property via + `put-text-property' or `add-text-properties'. These bump BUF_MODIFF + but NOT BUF_CHARS_MODIFF, because no characters are inserted or + deleted. Using only BUF_CHARS_MODIFF would serve stale AX text + across fold/unfold: VoiceOver would continue reading hidden content + as if it were visible, or miss newly revealed content entirely. + + BUF_MODIFF is bumped by all buffer modifications including + text-property changes (e.g. font-lock face assignments). The + per-rebuild cost is O(visible-buffer-text), but `ensureTextCache' + is called from AX getters (accessibilityValue, + accessibilitySelectedTextRange, etc.) which run at human interaction + speed, and from the cursor-moved branch of the redisplay + notification path (postAccessibilityNotificationsForFrame:) where + it is needed for granularity detection and line announcements. + The rebuild is gated on BUF_MODIFF, so repeated calls without + buffer changes are O(1). Font-lock bumps BUF_MODIFF via text + property changes, triggering an O(visible-text) rebuild, but the + visible text content is unchanged so no spurious notification + results. + + Do NOT check BUF_OVERLAY_MODIFF here. Modes like hl-line-mode + bump BUF_OVERLAY_MODIFF on every post-command-hook, which would + force an O(visible-text) cache rebuild on every cursor movement. + Running ns_ax_buffer_text (Lisp calls) that often during + ns_update_end interferes with redisplay state. + Overlay-based visibility changes (section fold/unfold, + hideshow) are handled in postAccessibilityNotificationsForFrame:, + which invalidates the text cache when BUF_OVERLAY_MODIFF changes + and sets pendingOverlayCheck. The rebuild then happens here (on + the next ensureTextCache call, triggered by cursor-moved or AX + getter), and the post-rebuild check below posts LayoutChanged if + the visible text length changed. + org-mode >= 29 (org-fold-core) uses text properties, not overlays, + for folding, so BUF_MODIFF alone correctly catches those changes. */ + modiff_count modiff = BUF_MODIFF (b); + ptrdiff_t pt = BUF_PT (b); + NSUInteger textLen = cachedText ? [cachedText length] : 0; + if (cachedText && cachedTextModiff == modiff + && cachedTextStart == BUF_BEGV (b) + && pt >= cachedTextStart + && (textLen == 0 + || [self accessibilityIndexForCharpos:pt] <= textLen)) + return; + + ptrdiff_t start; + ns_ax_visible_run *runs = NULL; + NSUInteger nruns = 0; + NSString *text = ns_ax_buffer_text (w, &start, &runs, &nruns); + + /* Build line-start index OUTSIDE @synchronized to avoid a + potential deadlock: xmalloc/xrealloc call memory_full on + failure, which longjmps. A longjmp through @synchronized + skips objc_sync_exit, permanently holding the lock. Build + into local variables, then swap under the lock. */ + NSUInteger *newLineOffsets = NULL; + NSUInteger newLineCount = 0; + NSString *textCopy = [text copy]; + NSUInteger tlen = [textCopy length]; + if (tlen > 0) + { + NSUInteger cap = 256; + newLineOffsets = xmalloc (cap * sizeof (NSUInteger)); + newLineOffsets[0] = 0; + newLineCount = 1; + NSUInteger pos = 0; + while (pos < tlen) + { + NSRange lr = [textCopy lineRangeForRange: + NSMakeRange (pos, 0)]; + NSUInteger next = NSMaxRange (lr); + if (next <= pos) + break; /* safety */ + if (next < tlen) + { + if (newLineCount >= cap) + { + cap *= 2; + newLineOffsets = xrealloc (newLineOffsets, + cap * sizeof (NSUInteger)); + } + newLineOffsets[newLineCount] = next; + newLineCount++; + } + pos = next; + } + } + + /* Swap all cached state under @synchronized. No xmalloc or + Lisp calls inside this block --- only pointer/scalar writes + and xfree (which cannot signal). */ + NSUInteger *oldLineOffsets = NULL; + ns_ax_visible_run *oldRuns = NULL; + @synchronized (self) + { + [cachedText release]; + cachedText = textCopy; /* transfer ownership */ + cachedTextModiff = modiff; + cachedTextStart = start; + + oldRuns = visibleRuns; + visibleRuns = runs; + visibleRunCount = nruns; + + oldLineOffsets = lineStartOffsets; + lineStartOffsets = newLineOffsets; + lineCount = newLineCount; + } + xfree (oldRuns); + xfree (oldLineOffsets); + +} + +/* ---- Index mapping ---- */ + +/* Convert buffer charpos to accessibility string index. */ +- (NSUInteger)accessibilityIndexForCharpos:(ptrdiff_t)charpos +{ + /* This method may be called from the AX server thread. + Synchronize on self to prevent use-after-free if the main + thread invalidates the text cache concurrently. */ + @synchronized (self) + { + if (visibleRunCount == 0) + return 0; + + /* Binary search: runs are sorted by charpos (ascending). Find the + run whose [charpos, charpos+length) range contains the target, + or the nearest run after an invisible gap. O(log n) instead of + O(n) --- matters for org-mode with many folded sections. */ + NSUInteger lo = 0, hi = visibleRunCount; + while (lo < hi) + { + NSUInteger mid = lo + (hi - lo) / 2; + ns_ax_visible_run *r = &visibleRuns[mid]; + if (charpos < r->charpos) + hi = mid; + else if (charpos >= r->charpos + r->length) + lo = mid + 1; + else + { + /* Found: charpos is inside this run. Compute UTF-16 delta. + Fast path for pure-ASCII runs (ax_length == length): every + Emacs charpos maps to exactly one UTF-16 code unit, so the + conversion is O(1). This matters because ensureTextCache + calls this method on every redisplay frame to validate the + cache --- a O(cursor_position) loop here means O(position) + cost per frame even when the buffer is unchanged. + Multi-byte runs fall through to the sequence walk, bounded + by run length (visible window), not total buffer size. */ + NSUInteger chars_in = (NSUInteger)(charpos - r->charpos); + if (chars_in == 0) + return r->ax_start; + if (r->ax_length == (NSUInteger) r->length) + return r->ax_start + chars_in; + if (!cachedText) + return r->ax_start; + NSUInteger run_end_ax = r->ax_start + r->ax_length; + NSUInteger scan = r->ax_start; + for (NSUInteger c = 0; c < chars_in && scan < run_end_ax; c++) + { + NSRange seq = [cachedText + rangeOfComposedCharacterSequenceAtIndex:scan]; + scan = NSMaxRange (seq); + } + return (scan <= run_end_ax) ? scan : run_end_ax; + } + } + /* charpos falls in an invisible gap or past the end. */ + if (lo < visibleRunCount) + return visibleRuns[lo].ax_start; + ns_ax_visible_run *last = &visibleRuns[visibleRunCount - 1]; + return last->ax_start + last->ax_length; + } /* @synchronized */ +} + +/* Convert accessibility string index to buffer charpos. + Safe to call from any thread: uses only cachedText (NSString) and + visibleRuns --- no Lisp calls. */ +- (ptrdiff_t)charposForAccessibilityIndex:(NSUInteger)ax_idx +{ + /* May be called from AX server thread --- synchronize. */ + @synchronized (self) + { + if (visibleRunCount == 0) + return cachedTextStart; + + /* Binary search: runs are sorted by ax_start (ascending). */ + NSUInteger lo = 0, hi = visibleRunCount; + while (lo < hi) + { + NSUInteger mid = lo + (hi - lo) / 2; + ns_ax_visible_run *r = &visibleRuns[mid]; + if (ax_idx < r->ax_start) + hi = mid; + else if (ax_idx >= r->ax_start + r->ax_length) + lo = mid + 1; + else + { + /* Found: ax_idx is inside this run. + Fast path for pure-ASCII runs: ax_length == length means + every Emacs charpos maps to exactly one AX string index. + The conversion is then O(1) instead of O(cursor_position). + Buffers with emoji, CJK, or other non-BMP characters use + the slow path (composed character sequence walk), which is + bounded by run length, not total buffer size. */ + if (r->ax_length == (NSUInteger) r->length) + return r->charpos + (ptrdiff_t) (ax_idx - r->ax_start); + + if (!cachedText) + return r->charpos; + NSUInteger scan = r->ax_start; + ptrdiff_t cp = r->charpos; + while (scan < ax_idx) + { + NSRange seq = [cachedText + rangeOfComposedCharacterSequenceAtIndex:scan]; + scan = NSMaxRange (seq); + cp++; + } + return cp; + } + } + /* Past end --- return last charpos. */ + if (lo > 0) + { + ns_ax_visible_run *last = &visibleRuns[visibleRunCount - 1]; + return last->charpos + last->length; + } + return cachedTextStart; + } /* @synchronized */ +} + +/* --- Threading and signal safety --- + + AX getter methods may be called from the VoiceOver server thread. + All methods that access Lisp objects or buffer state dispatch_sync + to the main thread where Emacs state is consistent. + + Longjmp safety: Lisp functions called inside dispatch_sync blocks + (Fget_char_property, Fbuffer_substring_no_properties, etc.) could + theoretically signal and longjmp through the dispatch_sync frame, + deadlocking the AX server thread. This is prevented by: + + 1. validWindow checks WINDOW_LIVE_P and BUFFERP before every + Lisp access --- the window and buffer are verified live. + 2. All dispatch_sync blocks run on the main thread where no + concurrent Lisp code can modify state between checks. + 3. block_input prevents timer events and process output from + running between precondition checks and Lisp calls. + 4. Buffer positions are clamped to BUF_BEGV/BUF_ZV before + use, preventing out-of-range signals. + 5. specpdl unwind protection ensures block_input is always + matched by unblock_input, even on longjmp. + + This matches the safety model of existing nsterm.m F-function + calls (24 direct calls, none wrapped in internal_condition_case). + + Known gap: if the Emacs window tree is modified between redisplay + cycles in a way that invalidates validWindow's cached result, + a stale dereference could occur. In practice this does not happen + because window tree modifications go through the event loop which + we are blocking via dispatch_sync. */ + +/* ---- NSAccessibility protocol ---- + + AX getter methods use dispatch_sync to the main queue when called + from VoiceOver's background thread. This is safe because the main + thread never synchronously waits for the AX thread --- see the + threading model comment above ns_ax_buffer_text. + Notification posting uses dispatch_async (ns_ax_post_notification) + to ensure the main queue is always free for these dispatch_sync + calls. */ + +- (NSAccessibilityRole)accessibilityRole +{ + if (![NSThread isMainThread]) + { + __block NSAccessibilityRole result; + dispatch_sync (dispatch_get_main_queue (), ^{ + result = [[self accessibilityRole] retain]; + }); + return [result autorelease]; + } + struct window *w = [self validWindow]; + if (w && MINI_WINDOW_P (w)) + return NSAccessibilityTextFieldRole; + return NSAccessibilityTextAreaRole; +} + +- (NSString *)accessibilityPlaceholderValue +{ + if (![NSThread isMainThread]) + { + __block NSString *result; + dispatch_sync (dispatch_get_main_queue (), ^{ + result = [[self accessibilityPlaceholderValue] retain]; + }); + return [result autorelease]; + } + struct window *w = [self validWindow]; + if (!w || !MINI_WINDOW_P (w)) + return nil; + + specpdl_ref count = SPECPDL_INDEX (); + block_input (); + record_unwind_protect_void (unblock_input); + Lisp_Object prompt = Fminibuffer_prompt (); + unbind_to (count, Qnil); + + if (STRINGP (prompt)) + return [NSString stringWithLispString: prompt]; + return nil; +} + +- (NSString *)accessibilityRoleDescription +{ + if (![NSThread isMainThread]) + { + __block NSString *result; + dispatch_sync (dispatch_get_main_queue (), ^{ + result = [[self accessibilityRoleDescription] retain]; + }); + return [result autorelease]; + } + struct window *w = [self validWindow]; + if (w && MINI_WINDOW_P (w)) + return @"minibuffer"; + return @"editor"; +} + +- (NSString *)accessibilityLabel +{ + if (![NSThread isMainThread]) + { + __block NSString *result; + dispatch_sync (dispatch_get_main_queue (), ^{ + result = [[self accessibilityLabel] retain]; + }); + return [result autorelease]; + } + struct window *w = [self validWindow]; + if (w && WINDOW_LEAF_P (w)) + { + if (MINI_WINDOW_P (w)) + return @"Minibuffer"; + + if (BUFFERP (w->contents) + && BUFFER_LIVE_P (XBUFFER (w->contents))) + { + struct buffer *b = XBUFFER (w->contents); + Lisp_Object name = BVAR (b, name); + if (STRINGP (name)) + return [NSString stringWithLispString:name]; + } + } + return @"buffer"; +} + +- (BOOL)isAccessibilityFocused +{ + if (![NSThread isMainThread]) + { + __block BOOL result; + dispatch_sync (dispatch_get_main_queue (), ^{ + result = [self isAccessibilityFocused]; + }); + return result; + } + struct window *w = [self validWindow]; + if (!w) + return NO; + EmacsView *view = self.emacsView; + if (!view || !view->emacsframe) + return NO; + struct frame *f = view->emacsframe; + return (WINDOWP (f->selected_window) + && w == XWINDOW (f->selected_window)); +} + +- (id)accessibilityValue +{ + /* AX getters can be called from any thread by the AT subsystem. + Dispatch to main thread where Emacs buffer state is consistent. */ + if (![NSThread isMainThread]) + { + __block id result; + dispatch_sync (dispatch_get_main_queue (), ^{ + result = [[self accessibilityValue] retain]; + }); + return [result autorelease]; + } + [self ensureTextCache]; + return cachedText ? cachedText : @""; +} + +- (NSInteger)accessibilityNumberOfCharacters +{ + if (![NSThread isMainThread]) + { + __block NSInteger result; + dispatch_sync (dispatch_get_main_queue (), ^{ + result = [self accessibilityNumberOfCharacters]; + }); + return result; + } + [self ensureTextCache]; + return cachedText ? [cachedText length] : 0; +} + +- (NSString *)accessibilitySelectedText +{ + if (![NSThread isMainThread]) + { + __block NSString *result; + dispatch_sync (dispatch_get_main_queue (), ^{ + result = [[self accessibilitySelectedText] retain]; + }); + return [result autorelease]; + } + struct window *w = [self validWindow]; + if (!w || !WINDOW_LEAF_P (w)) + return @""; + + if (!BUFFERP (w->contents)) + return @""; + struct buffer *b = XBUFFER (w->contents); + if (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 +{ + if (![NSThread isMainThread]) + { + __block NSRange result; + dispatch_sync (dispatch_get_main_queue (), ^{ + result = [self accessibilitySelectedTextRange]; + }); + return result; + } + struct window *w = [self validWindow]; + if (!w || !WINDOW_LEAF_P (w)) + return NSMakeRange (0, 0); + + if (!BUFFERP (w->contents)) + return NSMakeRange (0, 0); + struct buffer *b = XBUFFER (w->contents); + + [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); + + Lisp_Object mark = BVAR (b, mark); + if (!MARKERP (mark) || !XMARKER (mark)->buffer) + return NSMakeRange (point_idx, 0); + ptrdiff_t mark_pos = marker_position (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 +{ + if (![NSThread isMainThread]) + { + dispatch_async (dispatch_get_main_queue (), ^{ + [self setAccessibilitySelectedTextRange:range]; + }); + return; + } + + /* Suppress VoiceOver cursor resets until the next redisplay cycle + completes after a text edit. On macOS 14+, VoiceOver calls this + setter after receiving ValueChangedNotification, often with {0, 0} + (from an accessibilitySelectedTextRange query during the window + between invalidateTextCache and ensureTextCache). Without this + guard, every typed character causes the cursor to jump to the + beginning of the buffer. The flag is cleared at the end of + postAccessibilityNotificationsForFrame: so that VoiceOver-initiated + navigation (e.g. arrow-key browse) works again after the cycle. */ + if (self.pendingEditNotification) + return; + + struct window *w = [self validWindow]; + if (!w || !WINDOW_LEAF_P (w)) + return; + + /* Ignore cursor placement in inactive minibuffer. */ + if (MINI_WINDOW_P (w) && !minibuf_level) + return; + + if (!BUFFERP (w->contents)) + return; + struct buffer *b = XBUFFER (w->contents); + + [self ensureTextCache]; + + specpdl_ref count = SPECPDL_INDEX (); + /* block_input must precede record_unwind_protect_void. + Register unblock_input before record_unwind_current_buffer so LIFO + unwind restores the buffer first, then unblocks input. */ + block_input (); + record_unwind_protect_void (unblock_input); + record_unwind_current_buffer (); + + /* 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); + + /* Move point directly in the buffer. */ + if (b != current_buffer) + set_buffer_internal_1 (b); + + SET_PT_BOTH (charpos, CHAR_TO_BYTE (charpos)); + + /* Always deactivate mark: VoiceOver range.length is an internal + word boundary hint, not a text selection. Activating the mark + makes accessibilitySelectedTextRange return a non-zero length, + which confuses VoiceOver into positioning its browse cursor at + the END of the selection instead of the start. */ + bset_mark_active (b, Qnil); + unbind_to (count, Qnil); + + /* Update cached state so the next notification cycle doesn't + re-announce this movement. */ + self.cachedPoint = charpos; + self.cachedMarkActive = NO; +} + +- (void)setAccessibilityFocused:(BOOL)flag +{ + if (!flag) + return; + + /* VoiceOver may call this from the AX server thread. + All Lisp reads, block_input, and AppKit calls require main. */ + if (![NSThread isMainThread]) + { + dispatch_async (dispatch_get_main_queue (), ^{ + [self setAccessibilityFocused:flag]; + }); + return; + } + + struct window *w = [self validWindow]; + if (!w || !WINDOW_LEAF_P (w)) + return; + + /* Do not let VoiceOver steal keyboard focus to an inactive + minibuffer. When Org (or any package) opens a note buffer, + VoiceOver may react to the AX tree rebuild by focusing the + minibuffer element; without this guard Fselect_window would + move the cursor there. */ + if (MINI_WINDOW_P (w) && !minibuf_level) + return; + + EmacsView *view = self.emacsView; + if (!view || !view->emacsframe) + return; + + /* Use specpdl unwind protection for block_input safety. + record_unwind_current_buffer is needed because Fselect_window + may change current_buffer. */ + specpdl_ref count = SPECPDL_INDEX (); + block_input (); + record_unwind_protect_void (unblock_input); + record_unwind_current_buffer (); + + /* Select the Emacs window so keyboard focus follows VoiceOver. */ + struct frame *f = view->emacsframe; + if (!WINDOWP (f->selected_window) + || w != XWINDOW (f->selected_window)) + Fselect_window (self.lispWindow, Qnil); + + /* Raise the frame's NS window to ensure keyboard focus. */ + NSWindow *nswin = [view window]; + if (nswin && ![nswin isKeyWindow]) + [nswin makeKeyAndOrderFront:nil]; + + unbind_to (count, Qnil); + + /* 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 + }; + ns_ax_post_notification_with_info ( + self, NSAccessibilitySelectedTextChangedNotification, info); +} + +- (NSInteger)accessibilityInsertionPointLineNumber +{ + if (![NSThread isMainThread]) + { + __block NSInteger result; + dispatch_sync (dispatch_get_main_queue (), ^{ + result = [self accessibilityInsertionPointLineNumber]; + }); + return result; + } + struct window *w = [self validWindow]; + if (!w || !WINDOW_LEAF_P (w)) + return 0; + + if (!BUFFERP (w->contents)) + return 0; + struct buffer *b = XBUFFER (w->contents); + + [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]; + + return [self lineForAXIndex:point_idx]; +} + +- (NSRange)accessibilityRangeForLine:(NSInteger)line +{ + if (![NSThread isMainThread]) + { + __block NSRange result; + dispatch_sync (dispatch_get_main_queue (), ^{ + result = [self accessibilityRangeForLine:line]; + }); + return result; + } + [self ensureTextCache]; + if (!cachedText || line < 0) + return NSMakeRange (NSNotFound, 0); + + NSUInteger len = [cachedText length]; + if (len == 0) + return (line == 0) ? NSMakeRange (0, 0) + : NSMakeRange (NSNotFound, 0); + + return [self rangeForLine:(NSUInteger)line textLength:len]; +} + +- (NSInteger)accessibilityLineForIndex:(NSInteger)index +{ + if (![NSThread isMainThread]) + { + __block NSInteger result; + dispatch_sync (dispatch_get_main_queue (), ^{ + result = [self accessibilityLineForIndex:index]; + }); + return result; + } + [self ensureTextCache]; + if (!cachedText || index < 0) + return 0; + + NSUInteger idx = (NSUInteger) index; + if (idx > [cachedText length]) + idx = [cachedText length]; + + return [self lineForAXIndex:idx]; +} + +- (NSRange)accessibilityRangeForIndex:(NSInteger)index +{ + if (![NSThread isMainThread]) + { + __block NSRange result; + dispatch_sync (dispatch_get_main_queue (), ^{ + result = [self accessibilityRangeForIndex:index]; + }); + return result; + } + [self ensureTextCache]; + if (!cachedText || index < 0 + || (NSUInteger) index >= [cachedText length]) + return NSMakeRange (NSNotFound, 0); + return [cachedText + rangeOfComposedCharacterSequenceAtIndex:(NSUInteger)index]; +} + +- (NSRange)accessibilityStyleRangeForIndex:(NSInteger)index +{ + if (![NSThread isMainThread]) + { + __block NSRange result; + dispatch_sync (dispatch_get_main_queue (), ^{ + result = [self accessibilityStyleRangeForIndex:index]; + }); + return result; + } + /* Return the range of the current line. A more accurate + implementation would return face/font property boundaries, + but line granularity is acceptable for VoiceOver. */ + NSInteger line = [self accessibilityLineForIndex:index]; + return [self accessibilityRangeForLine:line]; +} + +- (NSRect)accessibilityFrameForRange:(NSRange)range +{ + if (![NSThread isMainThread]) + { + __block NSRect result; + dispatch_sync (dispatch_get_main_queue (), ^{ + result = [self accessibilityFrameForRange:range]; + }); + return result; + } + struct window *w = [self validWindow]; + EmacsView *view = self.emacsView; + if (!w || !view) + return NSZeroRect; + /* 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]; + return ns_ax_frame_for_range (w, view, cp_start, + cp_end - cp_start); +} + +- (NSRange)accessibilityRangeForPosition:(NSPoint)screenPoint +{ + if (![NSThread isMainThread]) + { + __block NSRange result; + dispatch_sync (dispatch_get_main_queue (), ^{ + result + = [self accessibilityRangeForPosition:screenPoint]; + }); + return result; + } + /* Hit test: convert screen point to buffer character index. */ + struct window *w = [self validWindow]; + EmacsView *view = self.emacsView; + if (!w || !view || !w->current_matrix) + return NSMakeRange (0, 0); + + /* Convert screen point to EmacsView coordinates. */ + NSPoint windowPoint = [[view window] convertPointFromScreen:screenPoint]; + NSPoint viewPoint = [view convertPoint:windowPoint fromView:nil]; + + /* Convert to window-relative pixel coordinates for bounds check. */ + int win_x = (int) viewPoint.x - w->pixel_left; + int y = (int) viewPoint.y - w->pixel_top; + + if (win_x < 0 || y < 0 || win_x >= w->pixel_width || y >= w->pixel_height) + return NSMakeRange (0, 0); + + /* Convert to text-area-relative x for glyph hit-testing. The glyphs + in TEXT_AREA start at offset 0, but the text area itself is inset + from the window edge by fringes and margins. */ + int x = win_x - window_box_left_offset (w, TEXT_AREA); + + /* Block input to prevent concurrent redisplay from modifying the + glyph matrix while we traverse it. Use specpdl unwind protection + so block_input is always matched by unblock_input, even if + ensureTextCache triggers a Lisp signal (longjmp). */ + specpdl_ref count = SPECPDL_INDEX (); + block_input (); + record_unwind_protect_void (unblock_input); + + /* Find the glyph row at this y coordinate. */ + struct glyph_matrix *matrix = w->current_matrix; + struct glyph_row *hit_row = NULL; + + for (int i = 0; i < matrix->nrows; i++) + { + struct glyph_row *row = matrix->rows + i; + if (!row->enabled_p || !row->displays_text_p || row->mode_line_p) + continue; + int row_top = WINDOW_TO_FRAME_PIXEL_Y (w, MAX (0, row->y)); + if ((int) viewPoint.y >= row_top + && (int) viewPoint.y < row_top + row->visible_height) + { + hit_row = row; + break; + } + } + + if (!hit_row) + { + unbind_to (count, Qnil); + 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); + + struct buffer *b = BUFFERP (w->contents) ? XBUFFER (w->contents) : NULL; + ptrdiff_t min_charpos = b ? BUF_BEGV (b) : 1; + for (; glyph < end; glyph++) + { + if (glyph->type == CHAR_GLYPH && glyph->charpos >= min_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 via mapping. */ + [self ensureTextCache]; + NSUInteger ax_idx = [self accessibilityIndexForCharpos:best_charpos]; + NSUInteger text_len = cachedText ? [cachedText length] : 0; + if (ax_idx > text_len) + ax_idx = text_len; + + unbind_to (count, Qnil); + /* Clamp length to avoid returning a range past end of text. */ + NSUInteger len = (ax_idx < text_len) ? 1 : 0; + return NSMakeRange (ax_idx, len); +} + +- (NSRange)accessibilityVisibleCharacterRange +{ + if (![NSThread isMainThread]) + { + __block NSRange result; + dispatch_sync (dispatch_get_main_queue (), ^{ + result = [self accessibilityVisibleCharacterRange]; + }); + return result; + } + /* 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 +{ + if (![NSThread isMainThread]) + { + __block NSRect result; + dispatch_sync (dispatch_get_main_queue (), ^{ + result = [self accessibilityFrame]; + }); + return result; + } + struct window *w = [self validWindow]; + 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:text_h]; +} + +/* ---- Notification dispatch (helper methods) ---- */ + +@end + #endif /* NS_IMPL_COCOA */