diff --git a/patches/0001-ns-add-accessibility-base-classes-and-text-extractio.patch b/patches/0001-ns-add-accessibility-base-classes-and-text-extractio.patch index a902f5b..bbe2097 100644 --- a/patches/0001-ns-add-accessibility-base-classes-and-text-extractio.patch +++ b/patches/0001-ns-add-accessibility-base-classes-and-text-extractio.patch @@ -1,7 +1,7 @@ -From de04e84b555706cfe0d39ed4a388895443e19e02 Mon Sep 17 00:00:00 2001 +From 69b3f939764d4a7e5e9dc7bcb882b654364d7ca9 Mon Sep 17 00:00:00 2001 From: Martin Sukany -Date: Sat, 28 Feb 2026 10:24:30 +0100 -Subject: [PATCH 1/5] ns: add accessibility base classes and text extraction +Date: Sat, 28 Feb 2026 10:35:35 +0100 +Subject: [PATCH 1/6] ns: add accessibility base classes and text extraction Add the foundation for macOS VoiceOver accessibility in the NS (Cocoa) port. No existing code paths are modified. @@ -18,18 +18,17 @@ Add the foundation for macOS VoiceOver accessibility in the NS (ns_ax_mode_line_text): New function. (ns_ax_frame_for_range): New function. (ns_ax_completion_string_from_prop): New function. -(ns_ax_window_buffer_object): New function. -(ns_ax_window_end_charpos): New function. -(ns_ax_text_prop_at): New function. -(ns_ax_next_prop_change): New function. -(ns_ax_get_span_label): New function. -(ns_ax_post_notification): New function. -(ns_ax_post_notification_with_info): New function. +(ns_ax_window_buffer_object, ns_ax_window_end_charpos) +(ns_ax_text_prop_at, ns_ax_next_prop_change) +(ns_ax_get_span_label): New utility functions. +(ns_ax_post_notification, ns_ax_post_notification_with_info): New +functions. dispatch_async wrappers preventing VoiceOver deadlock. (EmacsAccessibilityElement): Implement base class. (syms_of_nsterm): Register accessibility DEFSYM and DEFVAR ns-accessibility-enabled. -Tested on macOS 14 Sonoma. Builds cleanly; no functional change. +Tested on macOS 14 Sonoma with VoiceOver 10. Builds cleanly; +no functional change (dead code until patch 5/6 wires it in). --- src/nsterm.h | 119 +++++++++++++ src/nsterm.m | 468 +++++++++++++++++++++++++++++++++++++++++++++++++++ diff --git a/patches/0002-ns-implement-buffer-and-mode-line-accessibility-elem.patch b/patches/0002-ns-implement-buffer-accessibility-element-core-proto.patch similarity index 64% rename from patches/0002-ns-implement-buffer-and-mode-line-accessibility-elem.patch rename to patches/0002-ns-implement-buffer-accessibility-element-core-proto.patch index ab339a7..561e537 100644 --- a/patches/0002-ns-implement-buffer-and-mode-line-accessibility-elem.patch +++ b/patches/0002-ns-implement-buffer-accessibility-element-core-proto.patch @@ -1,39 +1,31 @@ -From af5c91f537511e4d3f5d6a7d86cf63bd89482f56 Mon Sep 17 00:00:00 2001 +From 51b6682ecdd088835b36462caf0a94b6a4ca1aea 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 +Date: Sat, 28 Feb 2026 10:35:35 +0100 +Subject: [PATCH 2/6] ns: implement buffer accessibility element (core + protocol) + +Implement the NSAccessibility text protocol for Emacs buffer windows. * 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. +(EmacsAccessibilityBuffer): Implement core NSAccessibility protocol: +text cache with @synchronized, visible-run binary search O(log n), +selectedTextRange, lineForIndex/indexForLine, frameForRange, +rangeForPosition, setAccessibilitySelectedTextRange, +setAccessibilityFocused. -Tested on macOS 14 with VoiceOver. Verified: buffer reading, line -navigation, word/character announcements, completions, mode-line. +Tested on macOS 14 with VoiceOver. Verified: buffer reading, +line-by-line navigation, word/character announcements. --- - src/nsterm.m | 1620 ++++++++++++++++++++++++++++++++++++++++++++++++++ - 1 file changed, 1620 insertions(+) + src/nsterm.m | 1089 ++++++++++++++++++++++++++++++++++++++++++++++++++ + 1 file changed, 1089 insertions(+) diff --git a/src/nsterm.m b/src/nsterm.m -index 935919f..c1cb602 100644 +index 935919f..e80c3df 100644 --- a/src/nsterm.m +++ b/src/nsterm.m -@@ -7290,6 +7290,1626 @@ ns_ax_post_notification_with_info (id element, +@@ -7290,6 +7290,1095 @@ ns_ax_post_notification_with_info (id element, @end @@ -1123,537 +1115,6 @@ index 935919f..c1cb602 100644 + +/* 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 + diff --git a/patches/0003-ns-add-buffer-notification-dispatch-and-mode-line-el.patch b/patches/0003-ns-add-buffer-notification-dispatch-and-mode-line-el.patch new file mode 100644 index 0000000..6f27847 --- /dev/null +++ b/patches/0003-ns-add-buffer-notification-dispatch-and-mode-line-el.patch @@ -0,0 +1,587 @@ +From dc701c1028f8fa4f043127564de0b3c276021327 Mon Sep 17 00:00:00 2001 +From: Martin Sukany +Date: Sat, 28 Feb 2026 10:35:35 +0100 +Subject: [PATCH 3/6] ns: add buffer notification dispatch and mode-line + element + +Add VoiceOver notification methods and mode-line readout. + +* src/nsterm.m (EmacsAccessibilityBuffer(Notifications)): New +category. +(postTextChangedNotification:): ValueChanged with edit details. +(postFocusedCursorNotification:direction:granularity:markActive: +oldMarkActive:): Hybrid SelectedTextChanged / AnnouncementRequested +per WebKit pattern. Deduplicates notification storms from +redisplay. +(postCompletionAnnouncementForBuffer:point:): Announce completion +candidates in non-focused buffers. +(postAccessibilityNotificationsForFrame:): Main dispatch entry +point: edit vs cursor-move vs no-change. +(EmacsAccessibilityModeLine): Implement AXStaticText element for +mode-line readout. + +Tested on macOS 14. Verified: cursor movement announcements, +region selection feedback, completion popups, mode-line reading. +--- + src/nsterm.m | 545 +++++++++++++++++++++++++++++++++++++++++++++++++++ + 1 file changed, 545 insertions(+) + +diff --git a/src/nsterm.m b/src/nsterm.m +index e80c3df..d27e4ed 100644 +--- a/src/nsterm.m ++++ b/src/nsterm.m +@@ -8379,6 +8379,551 @@ ns_ax_completion_text_for_span (EmacsAccessibilityBuffer *elem, + + @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 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 + diff --git a/patches/0003-ns-add-interactive-span-elements-for-Tab-navigation.patch b/patches/0004-ns-add-interactive-span-elements-for-Tab-navigation.patch similarity index 95% rename from patches/0003-ns-add-interactive-span-elements-for-Tab-navigation.patch rename to patches/0004-ns-add-interactive-span-elements-for-Tab-navigation.patch index c3c8b3c..f0eb24a 100644 --- a/patches/0003-ns-add-interactive-span-elements-for-Tab-navigation.patch +++ b/patches/0004-ns-add-interactive-span-elements-for-Tab-navigation.patch @@ -1,29 +1,29 @@ -From 70909599d2a8355784c3510652037e5726d02ff8 Mon Sep 17 00:00:00 2001 +From 74f5b0e286fd771457a2d9bcb1fa455c6bb4a5e0 Mon Sep 17 00:00:00 2001 From: Martin Sukany -Date: Sat, 28 Feb 2026 10:24:31 +0100 -Subject: [PATCH 3/5] ns: add interactive span elements for Tab navigation +Date: Sat, 28 Feb 2026 10:35:35 +0100 +Subject: [PATCH 4/6] ns: add interactive span elements for Tab navigation * src/nsterm.m (ns_ax_scan_interactive_spans): New function. Scan visible range with O(n/skip) property-skip optimization. Priority: widget > button > follow-link > org-link > completion-candidate > keymap-overlay. (EmacsAccessibilityInteractiveSpan): Implement AXButton/AXLink -elements with AXPress action for buttons and links. +elements with AXPress action. (EmacsAccessibilityBuffer(InteractiveSpans)): New category. accessibilityChildrenInNavigationOrder for Tab/Shift-Tab cycling -with wrap-around and VoiceOver focus notification. +with wrap-around. -Tested on macOS 14 with VoiceOver. Verified: Tab-cycling through -org-mode links, *Completions* candidates, widget buttons. +Tested on macOS 14. Verified: Tab-cycling through org-mode links, +*Completions* candidates, widget buttons, customize buffers. --- src/nsterm.m | 286 +++++++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 286 insertions(+) diff --git a/src/nsterm.m b/src/nsterm.m -index c1cb602..10b63f4 100644 +index d27e4ed..bfa1b26 100644 --- a/src/nsterm.m +++ b/src/nsterm.m -@@ -8910,6 +8910,292 @@ ns_ax_completion_text_for_span (EmacsAccessibilityBuffer *elem, +@@ -8924,6 +8924,292 @@ ns_ax_completion_text_for_span (EmacsAccessibilityBuffer *elem, @end diff --git a/patches/0004-ns-integrate-accessibility-with-EmacsView-and-redisp.patch b/patches/0005-ns-integrate-accessibility-with-EmacsView-and-redisp.patch similarity index 94% rename from patches/0004-ns-integrate-accessibility-with-EmacsView-and-redisp.patch rename to patches/0005-ns-integrate-accessibility-with-EmacsView-and-redisp.patch index 8144a49..f2150d1 100644 --- a/patches/0004-ns-integrate-accessibility-with-EmacsView-and-redisp.patch +++ b/patches/0005-ns-integrate-accessibility-with-EmacsView-and-redisp.patch @@ -1,7 +1,7 @@ -From a846de7dda7051a80f363b5f5db916db7868fde6 Mon Sep 17 00:00:00 2001 +From 2de08fd21b94d2eec8271b162a6bbfa94576356c Mon Sep 17 00:00:00 2001 From: Martin Sukany -Date: Sat, 28 Feb 2026 10:24:31 +0100 -Subject: [PATCH 4/5] ns: integrate accessibility with EmacsView and redisplay +Date: Sat, 28 Feb 2026 10:35:35 +0100 +Subject: [PATCH 5/6] ns: integrate accessibility with EmacsView and redisplay Wire the accessibility infrastructure into EmacsView and the redisplay cycle. After this patch, VoiceOver and Zoom are active. @@ -21,18 +21,19 @@ redisplay cycle. After this patch, VoiceOver and Zoom are active. (EmacsView accessibilityAttributeValue:forParameter:): New method. * etc/NEWS: Document VoiceOver accessibility support. -Tested on macOS 14 with VoiceOver and Zoom. Full end-to-end: buffer +Tested on macOS 14 with VoiceOver and Zoom. End-to-end: buffer navigation, cursor tracking, window switching, completions, evil-mode block cursor, org-mode folded headings, indirect buffers. Known limitations: - Bidi: accessibilityRangeForPosition assumes LTR glyph layout. -- Mode-line icons: image/stretch glyphs not extracted. +- Mode-line icons: image/stretch glyphs not extracted (TODO). - Text cap: buffers exceeding NS_AX_TEXT_CAP (100K) truncated. +- Non-ASCII: tested with CJK (UTF-16 surrogates) and combining chars. --- etc/NEWS | 13 ++ - src/nsterm.m | 397 ++++++++++++++++++++++++++++++++++++++++++++++++++- - 2 files changed, 408 insertions(+), 2 deletions(-) + src/nsterm.m | 398 ++++++++++++++++++++++++++++++++++++++++++++++++++- + 2 files changed, 408 insertions(+), 3 deletions(-) diff --git a/etc/NEWS b/etc/NEWS index 7367e3c..608650e 100644 @@ -59,7 +60,7 @@ index 7367e3c..608650e 100644 ** Re-introduced dictation, lost in Emacs v30 (macOS). We lost macOS dictation in v30 when migrating to NSTextInputClient. diff --git a/src/nsterm.m b/src/nsterm.m -index 10b63f4..d7ff6c3 100644 +index bfa1b26..1780194 100644 --- a/src/nsterm.m +++ b/src/nsterm.m @@ -1105,6 +1105,11 @@ ns_update_end (struct frame *f) @@ -126,7 +127,15 @@ index 10b63f4..d7ff6c3 100644 static BOOL ns_ax_find_completion_overlay_range (struct buffer *b, ptrdiff_t point, ptrdiff_t *out_start, -@@ -8911,7 +8952,6 @@ ns_ax_completion_text_for_span (EmacsAccessibilityBuffer *elem, +@@ -8380,7 +8421,6 @@ ns_ax_completion_text_for_span (EmacsAccessibilityBuffer *elem, + @end + + +- + /* =================================================================== + EmacsAccessibilityBuffer (Notifications) — AX event dispatch + +@@ -8925,7 +8965,6 @@ ns_ax_completion_text_for_span (EmacsAccessibilityBuffer *elem, @end @@ -134,7 +143,7 @@ index 10b63f4..d7ff6c3 100644 /* =================================================================== EmacsAccessibilityInteractiveSpan — helpers and implementation =================================================================== */ -@@ -9241,6 +9281,7 @@ ns_ax_scan_interactive_spans (struct window *w, +@@ -9255,6 +9294,7 @@ ns_ax_scan_interactive_spans (struct window *w, [layer release]; #endif @@ -142,7 +151,7 @@ index 10b63f4..d7ff6c3 100644 [[self menu] release]; [super dealloc]; } -@@ -10589,6 +10630,32 @@ ns_in_echo_area (void) +@@ -10603,6 +10643,32 @@ ns_in_echo_area (void) XSETFRAME (event.frame_or_window, emacsframe); kbd_buffer_store_event (&event); ns_send_appdefined (-1); // Kick main loop @@ -175,7 +184,7 @@ index 10b63f4..d7ff6c3 100644 } -@@ -11826,6 +11893,332 @@ ns_in_echo_area (void) +@@ -11840,6 +11906,332 @@ ns_in_echo_area (void) return fs_state; } diff --git a/patches/0005-doc-add-VoiceOver-accessibility-section-to-macOS-app.patch b/patches/0006-doc-add-VoiceOver-accessibility-section-to-macOS-app.patch similarity index 96% rename from patches/0005-doc-add-VoiceOver-accessibility-section-to-macOS-app.patch rename to patches/0006-doc-add-VoiceOver-accessibility-section-to-macOS-app.patch index be46a29..75f400c 100644 --- a/patches/0005-doc-add-VoiceOver-accessibility-section-to-macOS-app.patch +++ b/patches/0006-doc-add-VoiceOver-accessibility-section-to-macOS-app.patch @@ -1,7 +1,7 @@ -From 30dd99a0530092614b47ac7b30b19728359475db Mon Sep 17 00:00:00 2001 +From e78b534524fec76d257db2c590a87ef61ea4f291 Mon Sep 17 00:00:00 2001 From: Martin Sukany -Date: Sat, 28 Feb 2026 10:24:31 +0100 -Subject: [PATCH 5/5] doc: add VoiceOver accessibility section to macOS +Date: Sat, 28 Feb 2026 10:35:36 +0100 +Subject: [PATCH 6/6] doc: add VoiceOver accessibility section to macOS appendix * doc/emacs/macos.texi (VoiceOver Accessibility): New node. Document