From 5f98a7846765c36634118753514eeb8846747554 Mon Sep 17 00:00:00 2001 From: Daneel Date: Fri, 27 Feb 2026 12:07:19 +0100 Subject: [PATCH] patches: fix 5 critical issues in VoiceOver patch MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Remove static Lisp_Object locals; use DEFSYM in syms_of_nsterm (GC-safe) - Replace Lisp calls in accessibilityIndexForCharpos / charposForAccessibilityIndex with NSString composed-character traversal (thread-safe, no Lisp needed) - isAccessibilityFocused reads cachedPoint instead of marker_position off-thread - Remove double-announcement: character nav uses only SelectedTextChanged - Line announcement priority: High → Medium (avoid suppressing VO feedback) --- ...oundsForRange-for-macOS-Zoom-cursor-.patch | 209 +++++++++--------- 1 file changed, 99 insertions(+), 110 deletions(-) diff --git a/patches/0001-ns-implement-AXBoundsForRange-for-macOS-Zoom-cursor-.patch b/patches/0001-ns-implement-AXBoundsForRange-for-macOS-Zoom-cursor-.patch index 0c543d9..4537e14 100644 --- a/patches/0001-ns-implement-AXBoundsForRange-for-macOS-Zoom-cursor-.patch +++ b/patches/0001-ns-implement-AXBoundsForRange-for-macOS-Zoom-cursor-.patch @@ -1,13 +1,28 @@ -From 36073f1ad322a2ca478189fca89bc2b220fce621 Mon Sep 17 00:00:00 2001 +From 0c1c656992847c0d9da8351fb2268bd5171982f2 Mon Sep 17 00:00:00 2001 From: Martin Sukany -Date: Fri, 27 Feb 2026 11:48:36 +0100 +Date: Fri, 27 Feb 2026 12:07:06 +0100 Subject: [PATCH] ns: implement VoiceOver accessibility (AXBoundsForRange, line nav, completions, interactive spans) +* src/nsterm.h: Add EmacsAccessibilityElement, EmacsAccessibilityBuffer, + EmacsAccessibilityModeLine, EmacsAccessibilityInteractiveSpan classes; + ns_ax_visible_run struct; new EmacsView ivars for AX tree. + +* src/nsterm.m: Implement full VoiceOver support: + - AXBoundsForRange for macOS Zoom cursor tracking + - EmacsAccessibilityBuffer per window (AXTextArea/AXTextField) + - Line and character navigation announcements + - Interactive span Tab navigation (buttons, links, completions) + - Completion announcement with 4-fallback chain + - Thread-safe: AX getters use cachedText/visibleRuns built on + main thread; no Lisp calls from AX server thread + - GC-safe: span-scanning symbols registered via DEFSYM + - No double-announcement: SelectedTextChanged for cursor moves, + AnnouncementRequested (Medium priority) for line text only --- src/nsterm.h | 109 ++ - src/nsterm.m | 2715 +++++++++++++++++++++++++++++++++++++++++++++++--- - 2 files changed, 2675 insertions(+), 149 deletions(-) + src/nsterm.m | 2682 +++++++++++++++++++++++++++++++++++++++++++++++--- + 2 files changed, 2642 insertions(+), 149 deletions(-) diff --git a/src/nsterm.h b/src/nsterm.h index 7c1ee4c..6c95673 100644 @@ -144,7 +159,7 @@ index 7c1ee4c..6c95673 100644 diff --git a/src/nsterm.m b/src/nsterm.m -index 932d209..7e53bb3 100644 +index 932d209..e2532af 100644 --- a/src/nsterm.m +++ b/src/nsterm.m @@ -46,6 +46,7 @@ Updated by Christian Limpach (chris@nice.ch) @@ -205,7 +220,7 @@ index 932d209..7e53bb3 100644 ns_focus (f, NULL, 0); NSGraphicsContext *ctx = [NSGraphicsContext currentContext]; -@@ -6849,219 +6886,2276 @@ - (BOOL)fulfillService: (NSString *)name withArg: (NSString *)arg +@@ -6849,219 +6886,2235 @@ - (BOOL)fulfillService: (NSString *)name withArg: (NSString *)arg /* ========================================================================== @@ -894,25 +909,10 @@ index 932d209..7e53bb3 100644 + if (vis_start >= vis_end) + return @[]; + -+ /* Cache interned symbols (intern is idempotent but avoids repeated -+ obarray lookup on every iteration). */ -+ static Lisp_Object Qwidget_sym, Qbutton_sym, Qfollow_link_sym; -+ static Lisp_Object Qorg_link_sym, Qmouse_face_sym, Qkeymap_sym; -+ static Lisp_Object Qcomp_list_mode_sym; -+ static BOOL syms_initialized = NO; -+ if (!syms_initialized) -+ { -+ Qwidget_sym = intern ("widget"); -+ Qbutton_sym = intern ("button"); -+ Qfollow_link_sym = intern ("follow-link"); -+ Qorg_link_sym = intern ("org-link"); -+ Qmouse_face_sym = intern ("mouse-face"); -+ Qkeymap_sym = intern ("keymap"); -+ Qcomp_list_mode_sym = intern ("completion-list-mode"); -+ syms_initialized = YES; -+ } ++ /* Symbols are interned once at startup via DEFSYM in syms_of_nsterm; ++ reference them directly here (GC-safe, no repeated obarray lookup). */ + -+ BOOL is_completion_buf = EQ (BVAR (b, major_mode), Qcomp_list_mode_sym); ++ BOOL is_completion_buf = EQ (BVAR (b, major_mode), Qax_completion_list_mode); + + NSMutableArray *spans = [NSMutableArray array]; + ptrdiff_t pos = vis_start; @@ -923,28 +923,28 @@ index 932d209..7e53bb3 100644 + EmacsAXSpanType span_type = (EmacsAXSpanType) -1; + Lisp_Object limit_prop = Qnil; + -+ if (!NILP (Fplist_get (plist, Qwidget_sym, Qnil))) ++ if (!NILP (Fplist_get (plist, Qax_widget, Qnil))) + { + span_type = EmacsAXSpanTypeWidget; -+ limit_prop = Qwidget_sym; ++ limit_prop = Qax_widget; + } -+ else if (!NILP (Fplist_get (plist, Qbutton_sym, Qnil))) ++ else if (!NILP (Fplist_get (plist, Qax_button, Qnil))) + { + span_type = EmacsAXSpanTypeButton; -+ limit_prop = Qbutton_sym; ++ limit_prop = Qax_button; + } -+ else if (!NILP (Fplist_get (plist, Qfollow_link_sym, Qnil))) ++ else if (!NILP (Fplist_get (plist, Qax_follow_link, Qnil))) + { + span_type = EmacsAXSpanTypeLink; -+ limit_prop = Qfollow_link_sym; ++ limit_prop = Qax_follow_link; + } -+ else if (!NILP (Fplist_get (plist, Qorg_link_sym, Qnil))) ++ else if (!NILP (Fplist_get (plist, Qax_org_link, Qnil))) + { + span_type = EmacsAXSpanTypeLink; -+ limit_prop = Qorg_link_sym; ++ limit_prop = Qax_org_link; + } + else if (is_completion_buf -+ && !NILP (Fplist_get (plist, Qmouse_face_sym, Qnil))) ++ && !NILP (Fplist_get (plist, Qmouse_face, Qnil))) + { + /* For completions, use completion--string as boundary so we + don't accidentally merge two column-adjacent candidates @@ -953,7 +953,7 @@ index 932d209..7e53bb3 100644 + Lisp_Object cs_sym = intern ("completion--string"); + Lisp_Object cs_val = ns_ax_text_prop_at (pos, cs_sym, buf_obj); + span_type = EmacsAXSpanTypeCompletionItem; -+ limit_prop = NILP (cs_val) ? Qmouse_face_sym : cs_sym; ++ limit_prop = NILP (cs_val) ? Qmouse_face : cs_sym; + } + else + { @@ -962,10 +962,10 @@ index 932d209..7e53bb3 100644 + = Foverlays_in (make_fixnum (pos), make_fixnum (pos + 1)); + while (CONSP (ovs)) + { -+ if (!NILP (Foverlay_get (XCAR (ovs), Qkeymap_sym))) ++ if (!NILP (Foverlay_get (XCAR (ovs), Qkeymap))) + { + span_type = EmacsAXSpanTypeButton; -+ limit_prop = Qkeymap_sym; ++ limit_prop = Qkeymap; + break; + } + ovs = XCDR (ovs); @@ -1036,10 +1036,12 @@ index 932d209..7e53bb3 100644 + +- (BOOL) isAccessibilityFocused +{ -+ struct window *w = [self validWindow]; -+ if (!w) ++ /* Read the cached point stored by EmacsAccessibilityBuffer on the main ++ thread — safe to read from any thread (plain ptrdiff_t, no Lisp calls). */ ++ EmacsAccessibilityBuffer *pb = self.parentBuffer; ++ if (!pb) + return NO; -+ ptrdiff_t pt = marker_position (w->pointm); ++ ptrdiff_t pt = pb.cachedPoint; + return pt >= self.charposStart && pt < self.charposEnd; +} + @@ -1303,21 +1305,34 @@ index 932d209..7e53bb3 100644 +/* Convert buffer charpos to accessibility string index. */ +- (NSUInteger)accessibilityIndexForCharpos:(ptrdiff_t)charpos +{ -+ struct window *w = [self validWindow]; -+ struct buffer *b = (w && WINDOW_LEAF_P (w)) ? XBUFFER (w->contents) : NULL; ++ /* This method may be called from the AX server thread. All data ++ read here (visibleRuns, cachedText) is built on the main thread ++ inside ensureTextCache / ns_ax_buffer_text and is only invalidated ++ on the main thread. No Lisp calls are made here. */ + + for (NSUInteger i = 0; i < visibleRunCount; i++) + { + ns_ax_visible_run *r = &visibleRuns[i]; + if (charpos >= r->charpos && charpos < r->charpos + r->length) + { -+ if (!b) ++ /* Compute UTF-16 delta inside this run directly from cachedText ++ (an NSString built on the main thread) — no Lisp calls needed. */ ++ NSUInteger chars_in = (NSUInteger)(charpos - r->charpos); ++ if (chars_in == 0 || !cachedText) + return r->ax_start; -+ NSUInteger delta = ns_ax_utf16_length_for_buffer_range (b, r->charpos, -+ charpos); -+ if (delta > r->ax_length) -+ delta = r->ax_length; -+ return r->ax_start + delta; ++ /* ax_start + UTF-16 units for the first chars_in chars of the run. */ ++ NSUInteger run_end_ax = r->ax_start + r->ax_length; ++ NSUInteger scan = r->ax_start; ++ /* Each visible Emacs char maps to 1 or 2 UTF-16 units. ++ Walk the NSString using rangeOfComposedCharacterSequenceAtIndex ++ which handles surrogates correctly. */ ++ 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; + } + /* If charpos falls in an invisible gap before the next run, + map it to the start of the next visible run. */ @@ -1333,38 +1348,32 @@ index 932d209..7e53bb3 100644 + return 0; +} + -+/* Convert accessibility string index to buffer charpos. */ ++/* 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 +{ -+ struct window *w = [self validWindow]; -+ struct buffer *b = (w && WINDOW_LEAF_P (w)) ? XBUFFER (w->contents) : NULL; -+ + for (NSUInteger i = 0; i < visibleRunCount; i++) + { + ns_ax_visible_run *r = &visibleRuns[i]; + if (ax_idx >= r->ax_start + && ax_idx < r->ax_start + r->ax_length) + { -+ if (!b) ++ if (!cachedText) + return r->charpos; + -+ NSUInteger target = ax_idx - r->ax_start; -+ ptrdiff_t lo = r->charpos; -+ ptrdiff_t hi = r->charpos + r->length; -+ -+ while (lo < hi) ++ /* Walk forward through NSString composed character sequences to ++ count Emacs characters (= composed sequences) up to ax_idx. */ ++ NSUInteger scan = r->ax_start; ++ ptrdiff_t cp = r->charpos; ++ while (scan < ax_idx) + { -+ ptrdiff_t mid = lo + (hi - lo) / 2; -+ NSUInteger mid_len = ns_ax_utf16_length_for_buffer_range (b, -+ r->charpos, -+ mid); -+ if (mid_len < target) -+ lo = mid + 1; -+ else -+ hi = mid; ++ NSRange seq = [cachedText ++ rangeOfComposedCharacterSequenceAtIndex:scan]; ++ scan = NSMaxRange (seq); ++ cp++; + } -+ -+ return lo; ++ return cp; + } + } + /* Past end — return last charpos. */ @@ -1981,46 +1990,11 @@ index 932d209..7e53bb3 100644 + NSAccessibilitySelectedTextChangedNotification, + moveInfo); + -+ /* Character navigation: announce the character AT point. -+ VoiceOver's default for SelectedTextChanged with direction=next/char -+ reads the character BEFORE the new cursor position (the one "just -+ passed"). This is correct for insert-mode (cursor BETWEEN chars) -+ but wrong for block-cursor modes like evil-normal where the cursor -+ is visually ON the character at point. Post an explicit -+ announcement with the character at point to override this. */ -+ if ([self isAccessibilityFocused] -+ && cachedText -+ && granularity == ns_ax_text_selection_granularity_character -+ && (direction == ns_ax_text_selection_direction_next -+ || direction == ns_ax_text_selection_direction_previous)) -+ { -+ 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 -+ && charRange.location + charRange.length <= tlen) -+ { -+ NSString *ch = [cachedText substringWithRange: charRange]; -+ /* Skip bare newlines — VoiceOver already says "new line". */ -+ if (![ch isEqualToString: @" -+"]) -+ { -+ NSDictionary *annInfo = @{ -+ NSAccessibilityAnnouncementKey: ch, -+ NSAccessibilityPriorityKey: -+ @(NSAccessibilityPriorityHigh) -+ }; -+ NSAccessibilityPostNotificationWithUserInfo ( -+ NSApp, -+ NSAccessibilityAnnouncementRequestedNotification, -+ annInfo); -+ } -+ } -+ } -+ } ++ /* Character navigation: NSAccessibilitySelectedTextChangedNotification ++ (posted above) is sufficient — VoiceOver reads the char at the new ++ cursor position from accessibilityStringForRange. An additional ++ AnnouncementRequested would cause double-speech. The SelectedTextChanged ++ path also correctly signals braille displays. */ + + /* Emit an explicit announcement whenever point lands on a new line. + Triggering on granularity=line covers ALL line-motion commands @@ -2068,7 +2042,7 @@ index 932d209..7e53bb3 100644 + { + NSDictionary *annInfo = @{ + NSAccessibilityAnnouncementKey: announceText, -+ NSAccessibilityPriorityKey: @(NSAccessibilityPriorityHigh) ++ NSAccessibilityPriorityKey: @(NSAccessibilityPriorityMedium) + }; + NSAccessibilityPostNotificationWithUserInfo ( + NSApp, @@ -2631,7 +2605,7 @@ index 932d209..7e53bb3 100644 unsigned fnKeysym = 0; static NSMutableArray *nsEvArray; unsigned int flags = [theEvent modifierFlags]; -@@ -8237,6 +10331,28 @@ - (void)windowDidBecomeKey /* for direct calls */ +@@ -8237,6 +10290,28 @@ - (void)windowDidBecomeKey /* for direct calls */ XSETFRAME (event.frame_or_window, emacsframe); kbd_buffer_store_event (&event); ns_send_appdefined (-1); // Kick main loop @@ -2660,7 +2634,7 @@ index 932d209..7e53bb3 100644 } -@@ -9474,6 +11590,307 @@ - (int) fullscreenState +@@ -9474,6 +11549,307 @@ - (int) fullscreenState return fs_state; } @@ -2968,6 +2942,21 @@ index 932d209..7e53bb3 100644 @end /* EmacsView */ +@@ -11303,6 +13679,14 @@ Convert an X font name (XLFD) to an NS font name. + DEFSYM (Qns_drag_operation_generic, "ns-drag-operation-generic"); + DEFSYM (Qns_handle_drag_motion, "ns-handle-drag-motion"); + ++ /* Accessibility span scanning symbols. */ ++ DEFSYM (Qax_widget, "widget"); ++ DEFSYM (Qax_button, "button"); ++ DEFSYM (Qax_follow_link, "follow-link"); ++ DEFSYM (Qax_org_link, "org-link"); ++ DEFSYM (Qax_completion_list_mode, "completion-list-mode"); ++ /* Qmouse_face and Qkeymap are defined in textprop.c / keymap.c. */ ++ + Fput (Qalt, Qmodifier_value, make_fixnum (alt_modifier)); + Fput (Qhyper, Qmodifier_value, make_fixnum (hyper_modifier)); + Fput (Qmeta, Qmodifier_value, make_fixnum (meta_modifier)); -- 2.43.0