From af5c91f537511e4d3f5d6a7d86cf63bd89482f56 Mon Sep 17 00:00:00 2001 From: Martin Sukany Date: Sat, 28 Feb 2026 10:24:31 +0100 Subject: [PATCH 2/5] ns: implement buffer and mode-line accessibility elements * src/nsterm.m (ns_ax_find_completion_overlay_range): New function. (ns_ax_event_is_line_nav_key): New function. (ns_ax_completion_text_for_span): New function. (EmacsAccessibilityBuffer): Implement NSAccessibility protocol. (EmacsAccessibilityBuffer ensureTextCache): Text cache with visible-run array, invalidated on modiff/overlay/window changes. (EmacsAccessibilityBuffer accessibilityIndexForCharpos:): Binary search O(log n) index mapping. (EmacsAccessibilityBuffer charposForAccessibilityIndex:): Inverse. (EmacsAccessibilityBuffer postTextChangedNotification:): ValueChanged with edit type details. (EmacsAccessibilityBuffer postFocusedCursorNotification:direction: granularity:markActive:oldMarkActive:): Hybrid SelectedTextChanged / AnnouncementRequested per WebKit pattern. (EmacsAccessibilityBuffer postCompletionAnnouncementForBuffer: point:): Announce non-focused buffer completions. (EmacsAccessibilityBuffer postAccessibilityNotificationsForFrame:): Main dispatch: edit vs cursor-move vs no-change. (EmacsAccessibilityModeLine): Implement AXStaticText element. Tested on macOS 14 with VoiceOver. Verified: buffer reading, line navigation, word/character announcements, completions, mode-line. --- src/nsterm.m | 1620 ++++++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 1620 insertions(+) diff --git a/src/nsterm.m b/src/nsterm.m index 935919f..c1cb602 100644 --- a/src/nsterm.m +++ b/src/nsterm.m @@ -7290,6 +7290,1626 @@ ns_ax_post_notification_with_info (id element, @end + + + + +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 = Qns_ax_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. + Prefer point+1 over point-1: when Tab moves to a new completion, + point is at the START of the new entry while point-1 is still + inside the previous entry's overlay. Forward probe finds the + correct new entry; backward probe finds the wrong old one. */ + ptrdiff_t probes[3] = { point, point + 1, point - 1 }; + for (int i = 0; i < 3 && !found; i++) + { + ptrdiff_t p = probes[i]; + if (p < begv || p > zv) + continue; + + 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 (!found) + { + /* Bulk query: get all overlays in the buffer at once. + Avoids the previous O(n) per-character Foverlays_at loop. */ + Lisp_Object all = Foverlays_in (make_fixnum (begv), + make_fixnum (zv)); + Lisp_Object tail; + for (tail = all; 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) + { + best_start = ov_start; + best_end = ov_end; + best_dist = dist; + found = YES; + } + } + } + + if (!found) + return NO; + + *out_start = best_start; + *out_end = best_end; + return YES; +} + +/* Detect line-level navigation commands. Inspects Vthis_command + (the command symbol being executed) rather than raw key codes so + that remapped bindings (e.g., C-j -> next-line) are recognized. + Falls back to last_command_event for Tab/backtab which are not + bound to a single canonical command symbol. */ +static bool +ns_ax_event_is_line_nav_key (int *which) +{ + /* 1. Check Vthis_command for known navigation command symbols. + All symbols are registered via DEFSYM in syms_of_nsterm to avoid + per-call obarray lookups in this hot path (runs every cursor move). */ + if (SYMBOLP (Vthis_command) && !NILP (Vthis_command)) + { + Lisp_Object cmd = Vthis_command; + /* Forward line commands. */ + if (EQ (cmd, Qns_ax_next_line) + || EQ (cmd, Qns_ax_dired_next_line) + || EQ (cmd, Qns_ax_evil_next_line) + || EQ (cmd, Qns_ax_evil_next_visual_line)) + { + if (which) *which = 1; + return true; + } + /* Backward line commands. */ + if (EQ (cmd, Qns_ax_previous_line) + || EQ (cmd, Qns_ax_dired_previous_line) + || EQ (cmd, Qns_ax_evil_previous_line) + || EQ (cmd, Qns_ax_evil_previous_visual_line)) + { + if (which) *which = -1; + return true; + } + } + + /* 2. Fallback: check raw key events for Tab/backtab. */ + Lisp_Object ev = last_command_event; + if (CONSP (ev)) + ev = EVENT_HEAD (ev); + + if (SYMBOLP (ev) && EQ (ev, Qns_ax_backtab)) + { + if (which) *which = -1; + return true; + } + if (FIXNUMP (ev) && XFIXNUM (ev) == 9) /* Tab */ + { + if (which) *which = 1; + return true; + } + return false; +} + + + +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; + specpdl_ref count = SPECPDL_INDEX (); + record_unwind_current_buffer (); + /* Block input to prevent concurrent redisplay from modifying buffer + state while we read text properties. Unwind-protected so + block_input is always matched by unblock_input on signal. */ + record_unwind_protect_void (unblock_input); + block_input (); + if (b != current_buffer) + set_buffer_internal_1 (b); + + /* Prefer canonical completion candidate string from text property. + Try both completion--string (new API, set by minibuffer.el) and + completion (older API used by some modes). */ + 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), + Qns_ax_completion__string, + Qnil); + if (STRINGP (cstr)) + text = [NSString stringWithLispString:cstr]; + if (!text) + { + /* Fallback: 'completion property used by display-completion-list. */ + cstr = Fget_char_property (make_fixnum (p), + Qns_ax_completion, + 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)]; + } + + unbind_to (count, Qnil); + + if (text) + { + text = [text stringByTrimmingCharactersInSet: + [NSCharacterSet whitespaceAndNewlineCharacterSet]]; + if ([text length] == 0) + text = nil; + } + + return text; +} + +@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); + [super dealloc]; +} + +/* ---- Text cache ---- */ + +- (void)invalidateTextCache +{ + @synchronized (self) + { + [cachedText release]; + cachedText = nil; + if (visibleRuns) + { + xfree (visibleRuns); + visibleRuns = NULL; + } + visibleRunCount = 0; + } + [self invalidateInteractiveSpans]; +} + +- (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; + + struct buffer *b = XBUFFER (w->contents); + if (!b) + return; + + ptrdiff_t modiff = BUF_MODIFF (b); + ptrdiff_t overlay_modiff = BUF_OVERLAY_MODIFF (b); + ptrdiff_t pt = BUF_PT (b); + NSUInteger textLen = cachedText ? [cachedText length] : 0; + /* Track both BUF_MODIFF and BUF_OVERLAY_MODIFF. Overlay-only + changes (e.g., timer-based completion highlight move without + text edit) bump overlay_modiff but not modiff. Also detect + narrowing/widening which changes BUF_BEGV without bumping + either modiff counter. */ + if (cachedText && cachedTextModiff == modiff + && cachedOverlayModiff == overlay_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); + + @synchronized (self) + { + [cachedText release]; + cachedText = [text retain]; + cachedTextModiff = modiff; + cachedOverlayModiff = overlay_modiff; + cachedTextStart = start; + + if (visibleRuns) + xfree (visibleRuns); + visibleRuns = runs; + visibleRunCount = nruns; + } +} + +/* ---- 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 + directly from cachedText — no Lisp calls needed. */ + NSUInteger chars_in = (NSUInteger)(charpos - r->charpos); + if (chars_in == 0 || !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. Walk composed character + sequences to count Emacs characters up to ax_idx. */ + 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 ---- */ + +- (NSAccessibilityRole)accessibilityRole +{ + if (![NSThread isMainThread]) + { + __block NSAccessibilityRole result; + dispatch_sync (dispatch_get_main_queue (), ^{ + result = [self accessibilityRole]; + }); + return result; + } + 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]; + }); + return result; + } + struct window *w = [self validWindow]; + if (!w || !MINI_WINDOW_P (w)) + return nil; + Lisp_Object prompt = Fminibuffer_prompt (); + 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]; + }); + return result; + } + 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]; + }); + return result; + } + struct window *w = [self validWindow]; + 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 +{ + 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 (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]; + }); + return result; + } + [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]; + }); + return result; + } + struct window *w = [self validWindow]; + 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 +{ + 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); + 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 +{ + if (![NSThread isMainThread]) + { + dispatch_async (dispatch_get_main_queue (), ^{ + [self setAccessibilitySelectedTextRange:range]; + }); + return; + } + struct window *w = [self validWindow]; + if (!w || !WINDOW_LEAF_P (w)) + return; + + struct buffer *b = XBUFFER (w->contents); + if (!b) + return; + + [self ensureTextCache]; + + specpdl_ref count = SPECPDL_INDEX (); + record_unwind_current_buffer (); + /* Ensure block_input is always matched by unblock_input even if + Fset_marker or another Lisp call signals (longjmp). */ + record_unwind_protect_void (unblock_input); + block_input (); + + /* 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)); + + /* 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); + + unbind_to (count, Qnil); + + /* 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; + + /* 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; + + EmacsView *view = self.emacsView; + if (!view || !view->emacsframe) + return; + + /* Use specpdl unwind protection for block_input safety. */ + specpdl_ref count = SPECPDL_INDEX (); + record_unwind_protect_void (unblock_input); + block_input (); + + /* Select the Emacs window so keyboard focus follows VoiceOver. */ + struct frame *f = view->emacsframe; + if (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; + + 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 lines by iterating lineRangeForRange from the start. + Each call jumps an entire line — O(lines) not O(chars). */ + NSInteger line = 0; + NSUInteger scan = 0; + NSUInteger len = [cachedText length]; + while (scan < point_idx && scan < len) + { + NSRange lr = [cachedText lineRangeForRange:NSMakeRange (scan, 0)]; + NSUInteger next = NSMaxRange (lr); + if (next <= scan) break; /* safety */ + if (next > point_idx) break; + line++; + scan = next; + } + return line; +} + +- (NSString *)accessibilityStringForRange:(NSRange)range +{ + if (![NSThread isMainThread]) + { + __block NSString *result; + dispatch_sync (dispatch_get_main_queue (), ^{ + result = [self accessibilityStringForRange:range]; + }); + return result; + } + [self ensureTextCache]; + if (!cachedText || range.location + range.length > [cachedText length]) + return @""; + return [cachedText substringWithRange:range]; +} + +- (NSAttributedString *)accessibilityAttributedStringForRange:(NSRange)range +{ + NSString *str = [self accessibilityStringForRange:range]; + return [[[NSAttributedString alloc] initWithString:str] autorelease]; +} + +- (NSInteger)accessibilityLineForIndex:(NSInteger)index +{ + 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]; + + /* Count lines by iterating lineRangeForRange — O(lines). */ + NSInteger line = 0; + NSUInteger scan = 0; + NSUInteger len = [cachedText length]; + while (scan < idx && scan < len) + { + NSRange lr = [cachedText lineRangeForRange:NSMakeRange (scan, 0)]; + NSUInteger next = NSMaxRange (lr); + if (next <= scan) break; + if (next > idx) break; + line++; + scan = next; + } + return line; +} + +- (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); + + /* Skip to the requested line using lineRangeForRange — O(lines) + not O(chars), consistent with accessibilityLineForIndex:. */ + NSInteger cur_line = 0; + NSUInteger scan = 0; + while (cur_line < line && scan < len) + { + NSRange lr = [cachedText lineRangeForRange:NSMakeRange (scan, 0)]; + NSUInteger next = NSMaxRange (lr); + if (next <= scan) break; /* safety */ + cur_line++; + scan = next; + } + if (cur_line != line) + return NSMakeRange (NSNotFound, 0); + + /* Return the range of the target line. */ + if (scan >= len) + return NSMakeRange (len, 0); /* phantom line after final newline */ + NSRange lr = [cachedText lineRangeForRange:NSMakeRange (scan, 0)]; + return lr; +} + +- (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 +{ + /* 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. */ + int x = (int) viewPoint.x - w->pixel_left; + int y = (int) viewPoint.y - w->pixel_top; + + if (x < 0 || y < 0 || x >= w->pixel_width || y >= w->pixel_height) + return NSMakeRange (0, 0); + + /* 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 (); + record_unwind_protect_void (unblock_input); + block_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); + + for (; glyph < end; glyph++) + { + if (glyph->type == CHAR_GLYPH && glyph->charpos > 0) + { + if (x >= glyph_x && x < glyph_x + glyph->pixel_width) + { + best_charpos = glyph->charpos; + break; + } + best_charpos = glyph->charpos; + } + glyph_x += glyph->pixel_width; + } + + /* Convert buffer charpos to accessibility index via mapping. */ + [self ensureTextCache]; + NSUInteger ax_idx = [self accessibilityIndexForCharpos:best_charpos]; + if (cachedText && ax_idx > [cachedText length]) + ax_idx = [cachedText length]; + + unbind_to (count, Qnil); + return NSMakeRange (ax_idx, 1); +} + +- (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) ---- */ + +/* Post NSAccessibilityValueChangedNotification for a text edit. + Called when BUF_MODIFF changes between redisplay cycles. */ +- (void)postTextChangedNotification:(ptrdiff_t)point +{ + /* 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]; + } + + /* Update cachedPoint here so the selection-move branch 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; + + NSDictionary *change = @{ + @"AXTextEditType": @(ns_ax_text_edit_type_typing), + @"AXTextChangeValue": changedChar, + @"AXTextChangeValueLength": @([changedChar length]) + }; + NSDictionary *userInfo = @{ + @"AXTextStateChangeType": @(ns_ax_text_state_change_edit), + @"AXTextChangeValues": @[change], + @"AXTextChangeElement": self + }; + ns_ax_post_notification_with_info ( + self, NSAccessibilityValueChangedNotification, userInfo); +} + +/* Post SelectedTextChanged and AnnouncementRequested for the + focused buffer element when point or mark changes. */ +- (void)postFocusedCursorNotification:(ptrdiff_t)point + direction:(NSInteger)direction + granularity:(NSInteger)granularity + markActive:(BOOL)markActive + oldMarkActive:(BOOL)oldMarkActive +{ + BOOL isCharMove + = (!markActive && !oldMarkActive + && granularity + == ns_ax_text_selection_granularity_character); + + /* Always post SelectedTextChanged to interrupt VoiceOver reading + and update cursor tracking / braille displays. */ + NSMutableDictionary *moveInfo = [NSMutableDictionary dictionary]; + moveInfo[@"AXTextStateChangeType"] + = @(ns_ax_text_state_change_selection_move); + moveInfo[@"AXTextSelectionDirection"] = @(direction); + moveInfo[@"AXTextChangeElement"] = self; + /* Omit granularity for character moves so VoiceOver does not + derive its own speech (it would read the wrong character + for evil block-cursor mode). Include it for word/line/ + selection so VoiceOver reads the appropriate text. */ + if (!isCharMove) + moveInfo[@"AXTextSelectionGranularity"] = @(granularity); + + ns_ax_post_notification_with_info ( + self, + NSAccessibilitySelectedTextChangedNotification, + moveInfo); + + /* For character moves: explicit announcement of char AT point. + This is the ONLY speech source for character navigation. + Correct for evil block-cursor (cursor ON the character) + and harmless for insert-mode. */ + if (isCharMove && cachedText) + { + NSUInteger point_idx + = [self accessibilityIndexForCharpos:point]; + NSUInteger tlen = [cachedText length]; + if (point_idx < tlen) + { + NSRange charRange = [cachedText + rangeOfComposedCharacterSequenceAtIndex: point_idx]; + if (charRange.location != NSNotFound + && charRange.length > 0 + && NSMaxRange (charRange) <= tlen) + { + NSString *ch + = [cachedText substringWithRange: charRange]; + if (![ch isEqualToString: @"\n"]) + { + NSDictionary *annInfo = @{ + NSAccessibilityAnnouncementKey: ch, + NSAccessibilityPriorityKey: + @(NSAccessibilityPriorityHigh) + }; + ns_ax_post_notification_with_info ( + NSApp, + NSAccessibilityAnnouncementRequestedNotification, + annInfo); + } + } + } + } + + /* For focused line moves: always announce line text explicitly. + SelectedTextChanged with granularity=line works for arrow keys, + but C-n/C-p need the explicit announcement (VoiceOver processes + these keystrokes differently from arrows). + In completion-list-mode, read the completion candidate instead + of the whole line. */ + if (cachedText + && granularity == ns_ax_text_selection_granularity_line) + { + NSString *announceText = nil; + + /* 1. completion--string at point. */ + Lisp_Object cstr + = Fget_char_property (make_fixnum (point), + Qns_ax_completion__string, Qnil); + announceText = ns_ax_completion_string_from_prop (cstr); + + /* 2. Fallback: full line text. */ + 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 + && NSMaxRange (lineRange) <= [cachedText length]) + announceText + = [cachedText substringWithRange:lineRange]; + } + } + + if (announceText) + { + announceText = [announceText + stringByTrimmingCharactersInSet: + [NSCharacterSet whitespaceAndNewlineCharacterSet]]; + if ([announceText length] > 0) + { + NSDictionary *annInfo = @{ + NSAccessibilityAnnouncementKey: announceText, + NSAccessibilityPriorityKey: + @(NSAccessibilityPriorityHigh) + }; + ns_ax_post_notification_with_info ( + NSApp, + NSAccessibilityAnnouncementRequestedNotification, + annInfo); + } + } + } +} + +/* Post AnnouncementRequested for non-focused buffers (typically + *Completions* while minibuffer has keyboard focus). + VoiceOver does not automatically read changes in non-focused + elements, so we announce the selected completion explicitly. */ +- (void)postCompletionAnnouncementForBuffer:(struct buffer *)b + point:(ptrdiff_t)point +{ + NSString *announceText = nil; + ptrdiff_t currentOverlayStart = 0; + ptrdiff_t currentOverlayEnd = 0; + + specpdl_ref count2 = SPECPDL_INDEX (); + record_unwind_current_buffer (); + if (b != current_buffer) + set_buffer_internal_1 (b); + + /* 1) Prefer explicit completion candidate property. */ + Lisp_Object cstr = Fget_char_property (make_fixnum (point), + Qns_ax_completion__string, + Qnil); + announceText = ns_ax_completion_string_from_prop (cstr); + + /* 2) Fallback: mouse-face span at point. */ + 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); + + Lisp_Object prev_change + = Fprevious_single_char_property_change ( + make_fixnum (point + 1), Qmouse_face, + Qnil, make_fixnum (begv2)); + ptrdiff_t s2 + = FIXNUMP (prev_change) ? XFIXNUM (prev_change) + : begv2; + + Lisp_Object next_change + = Fnext_single_char_property_change ( + make_fixnum (point), Qmouse_face, + Qnil, make_fixnum (zv2)); + ptrdiff_t e2 + = FIXNUMP (next_change) ? XFIXNUM (next_change) + : zv2; + + 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: completions-highlight overlay at point. */ + if (!announceText) + { + Lisp_Object faceSym = Qns_ax_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: nearest completions-highlight overlay. */ + 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; + } + } + + unbind_to (count2, Qnil); + + /* Final fallback: read 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]; + } + } + + /* Deduplicate: post only when text, overlay, or point changed. */ + 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) + }; + ns_ax_post_notification_with_info ( + 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; + } +} + +/* ---- Notification dispatch (main entry point) ---- */ + +/* Dispatch accessibility notifications after a redisplay cycle. + Detects three mutually exclusive events: text edit, cursor/mark + change, or no change. Delegates to helper methods above. */ +- (void)postAccessibilityNotificationsForFrame:(struct frame *)f +{ + NSTRACE ("[EmacsView postAccessibilityNotificationsForFrame:]"); + struct window *w = [self validWindow]; + if (!w || !WINDOW_LEAF_P (w)) + return; + + struct buffer *b = XBUFFER (w->contents); + if (!b) + return; + + ptrdiff_t modiff = BUF_MODIFF (b); + ptrdiff_t point = BUF_PT (b); + BOOL markActive = !NILP (BVAR (b, mark_active)); + + /* --- Text changed (edit) --- */ + if (modiff != self.cachedModiff) + { + self.cachedModiff = modiff; + [self postTextChangedNotification:point]; + } + + /* --- Cursor moved or selection changed --- + Use 'else if' — edits and selection moves are mutually exclusive + per the WebKit/Chromium pattern. */ + else if (point != self.cachedPoint || markActive != self.cachedMarkActive) + { + ptrdiff_t oldPoint = self.cachedPoint; + BOOL oldMarkActive = self.cachedMarkActive; + self.cachedPoint = point; + 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_line_nav_key (&ctrlNP); + + /* --- Granularity detection --- */ + NSInteger granularity = ns_ax_text_selection_granularity_unknown; + [self ensureTextCache]; + if (cachedText && oldPoint > 0) + { + 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; + else + { + NSUInteger dist = (newIdx > oldIdx + ? newIdx - oldIdx + : oldIdx - newIdx); + if (dist > 1) + granularity = ns_ax_text_selection_granularity_word; + else if (dist == 1) + granularity = ns_ax_text_selection_granularity_character; + } + } + + /* Force line semantics for explicit C-n/C-p / Tab / backtab. */ + if (isCtrlNP) + { + direction = (ctrlNP > 0 + ? ns_ax_text_selection_direction_next + : ns_ax_text_selection_direction_previous); + granularity = ns_ax_text_selection_granularity_line; + } + + /* Post notifications for focused and non-focused elements. */ + if ([self isAccessibilityFocused]) + [self postFocusedCursorNotification:point + direction:direction + granularity:granularity + markActive:markActive + oldMarkActive:oldMarkActive]; + + if (![self isAccessibilityFocused] && cachedText) + [self postCompletionAnnouncementForBuffer:b point:point]; + } + else + { + /* Nothing changed. Reset completion cache for focused buffer + to avoid stale announcements. */ + if ([self isAccessibilityFocused]) + { + self.cachedCompletionAnnouncement = nil; + self.cachedCompletionOverlayStart = 0; + self.cachedCompletionOverlayEnd = 0; + self.cachedCompletionPoint = 0; + } + } +} + +@end + + +@implementation EmacsAccessibilityModeLine + +- (NSAccessibilityRole)accessibilityRole +{ + return NSAccessibilityStaticTextRole; +} + +- (NSString *)accessibilityLabel +{ + if (![NSThread isMainThread]) + { + __block NSString *result; + dispatch_sync (dispatch_get_main_queue (), ^{ + result = [self accessibilityLabel]; + }); + return result; + } + struct window *w = [self validWindow]; + 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 +{ + if (![NSThread isMainThread]) + { + __block id result; + dispatch_sync (dispatch_get_main_queue (), ^{ + result = [self accessibilityValue]; + }); + return result; + } + struct window *w = [self validWindow]; + if (!w) + return @""; + return ns_ax_mode_line_text (w); +} + +- (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 || !w->current_matrix) + return NSZeroRect; + + /* Find the mode line row and return its screen rect. */ + struct glyph_matrix *matrix = w->current_matrix; + for (int i = 0; i < matrix->nrows; i++) + { + struct glyph_row *row = matrix->rows + i; + if (row->enabled_p && row->mode_line_p) + { + return [self screenRectFromEmacsX:w->pixel_left + y:WINDOW_TO_FRAME_PIXEL_Y (w, + MAX (0, row->y)) + width:w->pixel_width + height:row->visible_height]; + } + } + return NSZeroRect; +} + +@end + #endif /* NS_IMPL_COCOA */ -- 2.43.0