From 8a834448f91eea39b50675d929dcb6840c8f3965 Mon Sep 17 00:00:00 2001 From: Daneel Date: Fri, 27 Feb 2026 12:27:20 +0100 Subject: [PATCH] =?UTF-8?q?patches:=20systematic=20notification=20strategy?= =?UTF-8?q?=20=E2=80=94=20eliminate=20double-speech?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit SelectedTextChanged → only for selection changes (mark active) AnnouncementRequested → only for cursor moves (char/line) Never both for the same event. Fixes double-speech globally. --- ...oundsForRange-for-macOS-Zoom-cursor-.patch | 217 +++++++++--------- 1 file changed, 109 insertions(+), 108 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 5a6efc9..4889934 100644 --- a/patches/0001-ns-implement-AXBoundsForRange-for-macOS-Zoom-cursor-.patch +++ b/patches/0001-ns-implement-AXBoundsForRange-for-macOS-Zoom-cursor-.patch @@ -1,30 +1,22 @@ -From b206fdbfb23830030e31a8075e296d3b8df0b0d4 Mon Sep 17 00:00:00 2001 +From c5c1040c973929ece99d40200fb47569d347a5af Mon Sep 17 00:00:00 2001 From: Martin Sukany -Date: Fri, 27 Feb 2026 12:16:23 +0100 +Date: Fri, 27 Feb 2026 12:27:09 +0100 Subject: [PATCH] ns: implement VoiceOver accessibility (AXBoundsForRange, line nav, completions, interactive spans) +MIME-Version: 1.0 +Content-Type: text/plain; charset=UTF-8 +Content-Transfer-Encoding: 8bit -* 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 (main thread); - no Lisp calls from AX server thread - - GC-safe: span-scanning symbols via DEFSYM in syms_of_nsterm - - SelectedTextChanged only for focused element (prevents double-speech - in completions buffer) - - Evil block-cursor: AnnouncementRequested(char AT point) posted - after SelectedTextChanged; VoiceOver cancels its own reading +* src/nsterm.m: Notification strategy: SelectedTextChanged only for + mark/selection changes; AnnouncementRequested only for cursor moves. + Eliminates double-speech systematically — VoiceOver cannot suppress + SelectedTextChanged speech even with High-priority announcements. + Character moves announce char AT point (evil block-cursor correct). + Line moves announce full line text or completion candidate. --- src/nsterm.h | 109 ++ - src/nsterm.m | 2724 +++++++++++++++++++++++++++++++++++++++++++++++--- - 2 files changed, 2684 insertions(+), 149 deletions(-) + src/nsterm.m | 2733 +++++++++++++++++++++++++++++++++++++++++++++++--- + 2 files changed, 2693 insertions(+), 149 deletions(-) diff --git a/src/nsterm.h b/src/nsterm.h index 7c1ee4c..6c95673 100644 @@ -161,7 +153,7 @@ index 7c1ee4c..6c95673 100644 diff --git a/src/nsterm.m b/src/nsterm.m -index 932d209..9455730 100644 +index 932d209..9ec2cfa 100644 --- a/src/nsterm.m +++ b/src/nsterm.m @@ -46,6 +46,7 @@ Updated by Christian Limpach (chris@nice.ch) @@ -222,7 +214,7 @@ index 932d209..9455730 100644 ns_focus (f, NULL, 0); NSGraphicsContext *ctx = [NSGraphicsContext currentContext]; -@@ -6849,219 +6886,2277 @@ - (BOOL)fulfillService: (NSString *)name withArg: (NSString *)arg +@@ -6849,219 +6886,2286 @@ - (BOOL)fulfillService: (NSString *)name withArg: (NSString *)arg /* ========================================================================== @@ -1933,6 +1925,7 @@ index 932d209..9455730 100644 + else if (point != self.cachedPoint || markActive != self.cachedMarkActive) + { + ptrdiff_t oldPoint = self.cachedPoint; ++ BOOL oldMarkActive = self.cachedMarkActive; + self.cachedPoint = point; + self.cachedMarkActive = markActive; + @@ -1981,48 +1974,60 @@ index 932d209..9455730 100644 + granularity = ns_ax_text_selection_granularity_line; + } + -+ /* Post SelectedTextChanged only for the FOCUSED element. -+ For non-focused buffers (e.g. *Completions* while minibuffer has -+ focus), SelectedTextChanged causes VoiceOver to read the old -+ selection text, followed by our AnnouncementRequested reading the -+ new candidate — resulting in double speech. Non-focused buffers -+ use only AnnouncementRequested (see below). */ ++ /* --- NOTIFICATION STRATEGY --- ++ SelectedTextChanged triggers VoiceOver speech that CANNOT be ++ suppressed or overridden by AnnouncementRequested — both play ++ sequentially, causing double-speech. ++ ++ Therefore: ++ - Selection active or mark-state changed: SelectedTextChanged ++ (VoiceOver reads selected/deselected text — correct behaviour). ++ - Pure cursor move (no mark): AnnouncementRequested ONLY. ++ We control exactly what VoiceOver says: ++ * Character moves: char AT point (evil block-cursor correct) ++ * Line moves: full line text (or completion candidate) ++ - Non-focused buffer: AnnouncementRequested only (see below). */ + if ([self isAccessibilityFocused]) + { -+ NSDictionary *moveInfo = @{ -+ @"AXTextStateChangeType": @(ns_ax_text_state_change_selection_move), -+ @"AXTextSelectionDirection": @(direction), -+ @"AXTextSelectionGranularity": @(granularity), -+ @"AXTextChangeElement": self -+ }; -+ NSAccessibilityPostNotificationWithUserInfo ( -+ self, -+ NSAccessibilitySelectedTextChangedNotification, -+ moveInfo); -+ -+ /* Character navigation: announce the character AT point. -+ VoiceOver's default for SelectedTextChanged reads the character -+ BEFORE the new cursor position — correct for insert-mode but -+ wrong for evil block-cursor which sits ON the character at point. -+ Post AnnouncementRequested AFTER SelectedTextChanged: VoiceOver -+ cancels its SelectedTextChanged speech in favour of the explicit -+ announcement when both arrive in the same runloop cycle. */ -+ if (cachedText -+ && granularity == ns_ax_text_selection_granularity_character -+ && (direction == ns_ax_text_selection_direction_next -+ || direction == ns_ax_text_selection_direction_previous)) ++ if (markActive || markActive != oldMarkActive) + { -+ NSUInteger point_idx = [self accessibilityIndexForCharpos:point]; ++ /* Selection change — SelectedTextChanged is appropriate. ++ VoiceOver reads the selected text range or announces ++ deselection. No AnnouncementRequested needed. */ ++ NSDictionary *moveInfo = @{ ++ @"AXTextStateChangeType": ++ @(ns_ax_text_state_change_selection_move), ++ @"AXTextSelectionDirection": @(direction), ++ @"AXTextSelectionGranularity": @(granularity), ++ @"AXTextChangeElement": self ++ }; ++ NSAccessibilityPostNotificationWithUserInfo ( ++ self, ++ NSAccessibilitySelectedTextChangedNotification, ++ moveInfo); ++ } ++ else if (cachedText ++ && granularity ++ == ns_ax_text_selection_granularity_character) ++ { ++ /* Character move — announce char AT point. ++ Correct for evil block-cursor (cursor ON the character) ++ and harmless for insert-mode (same character VoiceOver ++ would have read via its default behaviour). */ ++ 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 ++ rangeOfComposedCharacterSequenceAtIndex: point_idx]; ++ if (charRange.location != NSNotFound ++ && charRange.length > 0 + && NSMaxRange (charRange) <= tlen) + { -+ NSString *ch = [cachedText substringWithRange: charRange]; -+ /* Skip bare newlines — VoiceOver says "new line". */ ++ NSString *ch ++ = [cachedText substringWithRange: charRange]; ++ /* Skip bare newlines — VoiceOver handles those. */ + if (![ch isEqualToString: @"\n"]) + { + NSDictionary *annInfo = @{ @@ -2038,60 +2043,56 @@ index 932d209..9455730 100644 + } + } + } -+ } -+ -+ /* Emit an explicit announcement whenever point lands on a new line. -+ Triggering on granularity=line covers ALL line-motion commands -+ in ALL modes (next-line, dired-next-line, next-completion, …) -+ without enumerating command names. Character and word moves -+ (granularity ≠ line) are left to VoiceOver's default behaviour. -+ -+ For buffers using horizontal multi-column completion layout -+ (completion-list-mode) we read the completion--string text -+ property at point rather than the whole line, which would -+ otherwise announce two candidates at once. */ -+ if ([self isAccessibilityFocused] -+ && cachedText -+ && granularity == ns_ax_text_selection_granularity_line) -+ { -+ NSString *announceText = nil; -+ -+ /* 1. completion--string at point (completion-list-mode). */ -+ Lisp_Object cstr = Fget_char_property (make_fixnum (point), -+ intern ("completion--string"), -+ Qnil); -+ announceText = ns_ax_completion_string_from_prop (cstr); -+ -+ /* 2. Fallback: full line text. */ -+ if (!announceText) ++ else if (cachedText ++ && granularity ++ == ns_ax_text_selection_granularity_line) + { -+ NSUInteger point_idx = [self accessibilityIndexForCharpos:point]; -+ if (point_idx <= [cachedText length]) ++ /* Line move — announce line text (or completion candidate ++ for horizontal multi-column layouts). */ ++ NSString *announceText = nil; ++ ++ /* 1. completion--string at point (completion-list-mode). */ ++ Lisp_Object cstr = Fget_char_property (make_fixnum (point), ++ intern ("completion--string"), ++ Qnil); ++ announceText = ns_ax_completion_string_from_prop (cstr); ++ ++ /* 2. Fallback: full line text. */ ++ if (!announceText) + { -+ 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]; ++ 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) ++ if (announceText) + { -+ NSDictionary *annInfo = @{ -+ NSAccessibilityAnnouncementKey: announceText, -+ NSAccessibilityPriorityKey: @(NSAccessibilityPriorityMedium) -+ }; -+ NSAccessibilityPostNotificationWithUserInfo ( -+ NSApp, -+ NSAccessibilityAnnouncementRequestedNotification, -+ annInfo); ++ announceText = [announceText ++ stringByTrimmingCharactersInSet: ++ [NSCharacterSet whitespaceAndNewlineCharacterSet]]; ++ if ([announceText length] > 0) ++ { ++ NSDictionary *annInfo = @{ ++ NSAccessibilityAnnouncementKey: announceText, ++ NSAccessibilityPriorityKey: ++ @(NSAccessibilityPriorityMedium) ++ }; ++ NSAccessibilityPostNotificationWithUserInfo ( ++ NSApp, ++ NSAccessibilityAnnouncementRequestedNotification, ++ annInfo); ++ } + } + } + } @@ -2649,7 +2650,7 @@ index 932d209..9455730 100644 unsigned fnKeysym = 0; static NSMutableArray *nsEvArray; unsigned int flags = [theEvent modifierFlags]; -@@ -8237,6 +10332,28 @@ - (void)windowDidBecomeKey /* for direct calls */ +@@ -8237,6 +10341,28 @@ - (void)windowDidBecomeKey /* for direct calls */ XSETFRAME (event.frame_or_window, emacsframe); kbd_buffer_store_event (&event); ns_send_appdefined (-1); // Kick main loop @@ -2678,7 +2679,7 @@ index 932d209..9455730 100644 } -@@ -9474,6 +11591,307 @@ - (int) fullscreenState +@@ -9474,6 +11600,307 @@ - (int) fullscreenState return fs_state; } @@ -2986,7 +2987,7 @@ index 932d209..9455730 100644 @end /* EmacsView */ -@@ -11303,6 +13721,14 @@ Convert an X font name (XLFD) to an NS font name. +@@ -11303,6 +13730,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");