From 361aecfc858d712943921435454d9a7235d145ed Mon Sep 17 00:00:00 2001 From: Martin Sukany Date: Sat, 28 Feb 2026 12:58:11 +0100 Subject: [PATCH 3/8] ns: add buffer notification dispatch and mode-line element Add VoiceOver notification dispatch and mode-line readout. * src/nsterm.m (EmacsAccessibilityBuffer(Notifications)): New category. (postTextChangedNotification:): Post NSAccessibilityValueChangedNotification with AXTextEditType/AXTextChangeValue details. (postFocusedCursorNotification:direction:granularity:markActive: oldMarkActive:): Post NSAccessibilitySelectedTextChangedNotification following the WebKit hybrid pattern; announce character at point for character moves. (postCompletionAnnouncementForBuffer:point:): Announce completion candidates in non-focused (completion) buffers. Lisp/buffer access is performed inside block_input; ObjC AX calls are made after unblock_input to avoid holding block_input during @synchronized. (postAccessibilityNotificationsForFrame:): Main dispatch entry point; detects text edit, cursor/mark change, or overlay change. (EmacsAccessibilityModeLine): Implement AXStaticText element for the mode line. --- src/nsterm.m | 606 +++++++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 606 insertions(+) diff --git a/src/nsterm.m b/src/nsterm.m index 6256dbc22e..9e0e317237 100644 --- a/src/nsterm.m +++ b/src/nsterm.m @@ -8740,6 +8740,612 @@ - (NSRect)accessibilityFrame @end + + +/* =================================================================== + EmacsAccessibilityBuffer (Notifications) — AX event dispatch + + These methods notify VoiceOver of text and selection changes. + Called from the redisplay cycle (postAccessibilityUpdates). + =================================================================== */ + +@implementation EmacsAccessibilityBuffer (Notifications) + +- (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 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 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 word moves: explicit announcement of word AT new point. + VO auto-speech from SelectedTextChanged with direction=next + and granularity=word reads the word that was traversed (the + source word), not the word arrived at. This explicit + announcement reads the destination word instead, matching + user expectation ("w" jumps to next word and reads it). */ + BOOL isWordMove + = (!markActive && !oldMarkActive + && granularity == ns_ax_text_selection_granularity_word + && direction == ns_ax_text_selection_direction_discontiguous); + if (isWordMove && cachedText) + { + NSCharacterSet *ws + = [NSCharacterSet whitespaceAndNewlineCharacterSet]; + NSUInteger point_idx + = [self accessibilityIndexForCharpos:point]; + NSUInteger tlen = [cachedText length]; + if (point_idx < tlen + && ![ws characterIsMember: + [cachedText characterAtIndex:point_idx]]) + { + /* Find word boundaries around point. */ + NSUInteger wstart = point_idx; + while (wstart > 0 + && ![ws characterIsMember: + [cachedText characterAtIndex:wstart - 1]]) + wstart--; + NSUInteger wend = point_idx; + while (wend < tlen + && ![ws characterIsMember: + [cachedText characterAtIndex:wend]]) + wend++; + if (wend > wstart) + { + NSString *word + = [cachedText substringWithRange: + NSMakeRange (wstart, wend - wstart)]; + NSMutableCharacterSet *trims + = [ws mutableCopy]; + [trims formUnionWithCharacterSet: + [NSCharacterSet punctuationCharacterSet]]; + word = [word stringByTrimmingCharactersInSet:trims]; + [trims release]; + if ([word length] > 0) + { + NSDictionary *annInfo = @{ + NSAccessibilityAnnouncementKey: word, + 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; + + block_input (); + specpdl_ref count2 = SPECPDL_INDEX (); + record_unwind_protect_void (unblock_input); + 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