patches: systematic notification strategy — eliminate double-speech
SelectedTextChanged → only for selection changes (mark active) AnnouncementRequested → only for cursor moves (char/line) Never both for the same event. Fixes double-speech globally.
This commit is contained in:
@@ -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 <martin@sukany.cz>
|
From: Martin Sukany <martin@sukany.cz>
|
||||||
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
|
Subject: [PATCH] ns: implement VoiceOver accessibility (AXBoundsForRange, line
|
||||||
nav, completions, interactive spans)
|
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,
|
* src/nsterm.m: Notification strategy: SelectedTextChanged only for
|
||||||
EmacsAccessibilityModeLine, EmacsAccessibilityInteractiveSpan classes;
|
mark/selection changes; AnnouncementRequested only for cursor moves.
|
||||||
ns_ax_visible_run struct; new EmacsView ivars for AX tree.
|
Eliminates double-speech systematically — VoiceOver cannot suppress
|
||||||
|
SelectedTextChanged speech even with High-priority announcements.
|
||||||
* src/nsterm.m: Implement full VoiceOver support:
|
Character moves announce char AT point (evil block-cursor correct).
|
||||||
- AXBoundsForRange for macOS Zoom cursor tracking
|
Line moves announce full line text or completion candidate.
|
||||||
- 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.h | 109 ++
|
src/nsterm.h | 109 ++
|
||||||
src/nsterm.m | 2724 +++++++++++++++++++++++++++++++++++++++++++++++---
|
src/nsterm.m | 2733 +++++++++++++++++++++++++++++++++++++++++++++++---
|
||||||
2 files changed, 2684 insertions(+), 149 deletions(-)
|
2 files changed, 2693 insertions(+), 149 deletions(-)
|
||||||
|
|
||||||
diff --git a/src/nsterm.h b/src/nsterm.h
|
diff --git a/src/nsterm.h b/src/nsterm.h
|
||||||
index 7c1ee4c..6c95673 100644
|
index 7c1ee4c..6c95673 100644
|
||||||
@@ -161,7 +153,7 @@ index 7c1ee4c..6c95673 100644
|
|||||||
|
|
||||||
|
|
||||||
diff --git a/src/nsterm.m b/src/nsterm.m
|
diff --git a/src/nsterm.m b/src/nsterm.m
|
||||||
index 932d209..9455730 100644
|
index 932d209..9ec2cfa 100644
|
||||||
--- a/src/nsterm.m
|
--- a/src/nsterm.m
|
||||||
+++ b/src/nsterm.m
|
+++ b/src/nsterm.m
|
||||||
@@ -46,6 +46,7 @@ Updated by Christian Limpach (chris@nice.ch)
|
@@ -46,6 +46,7 @@ Updated by Christian Limpach (chris@nice.ch)
|
||||||
@@ -222,7 +214,7 @@ index 932d209..9455730 100644
|
|||||||
ns_focus (f, NULL, 0);
|
ns_focus (f, NULL, 0);
|
||||||
|
|
||||||
NSGraphicsContext *ctx = [NSGraphicsContext currentContext];
|
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)
|
+ else if (point != self.cachedPoint || markActive != self.cachedMarkActive)
|
||||||
+ {
|
+ {
|
||||||
+ ptrdiff_t oldPoint = self.cachedPoint;
|
+ ptrdiff_t oldPoint = self.cachedPoint;
|
||||||
|
+ BOOL oldMarkActive = self.cachedMarkActive;
|
||||||
+ self.cachedPoint = point;
|
+ self.cachedPoint = point;
|
||||||
+ self.cachedMarkActive = markActive;
|
+ self.cachedMarkActive = markActive;
|
||||||
+
|
+
|
||||||
@@ -1981,16 +1974,29 @@ index 932d209..9455730 100644
|
|||||||
+ granularity = ns_ax_text_selection_granularity_line;
|
+ granularity = ns_ax_text_selection_granularity_line;
|
||||||
+ }
|
+ }
|
||||||
+
|
+
|
||||||
+ /* Post SelectedTextChanged only for the FOCUSED element.
|
+ /* --- NOTIFICATION STRATEGY ---
|
||||||
+ For non-focused buffers (e.g. *Completions* while minibuffer has
|
+ SelectedTextChanged triggers VoiceOver speech that CANNOT be
|
||||||
+ focus), SelectedTextChanged causes VoiceOver to read the old
|
+ suppressed or overridden by AnnouncementRequested — both play
|
||||||
+ selection text, followed by our AnnouncementRequested reading the
|
+ sequentially, causing double-speech.
|
||||||
+ new candidate — resulting in double speech. Non-focused buffers
|
+
|
||||||
+ use only AnnouncementRequested (see below). */
|
+ 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])
|
+ if ([self isAccessibilityFocused])
|
||||||
+ {
|
+ {
|
||||||
|
+ if (markActive || markActive != oldMarkActive)
|
||||||
|
+ {
|
||||||
|
+ /* Selection change — SelectedTextChanged is appropriate.
|
||||||
|
+ VoiceOver reads the selected text range or announces
|
||||||
|
+ deselection. No AnnouncementRequested needed. */
|
||||||
+ NSDictionary *moveInfo = @{
|
+ NSDictionary *moveInfo = @{
|
||||||
+ @"AXTextStateChangeType": @(ns_ax_text_state_change_selection_move),
|
+ @"AXTextStateChangeType":
|
||||||
|
+ @(ns_ax_text_state_change_selection_move),
|
||||||
+ @"AXTextSelectionDirection": @(direction),
|
+ @"AXTextSelectionDirection": @(direction),
|
||||||
+ @"AXTextSelectionGranularity": @(granularity),
|
+ @"AXTextSelectionGranularity": @(granularity),
|
||||||
+ @"AXTextChangeElement": self
|
+ @"AXTextChangeElement": self
|
||||||
@@ -1999,30 +2005,29 @@ index 932d209..9455730 100644
|
|||||||
+ self,
|
+ self,
|
||||||
+ NSAccessibilitySelectedTextChangedNotification,
|
+ NSAccessibilitySelectedTextChangedNotification,
|
||||||
+ moveInfo);
|
+ moveInfo);
|
||||||
+
|
+ }
|
||||||
+ /* Character navigation: announce the character AT point.
|
+ else if (cachedText
|
||||||
+ VoiceOver's default for SelectedTextChanged reads the character
|
+ && granularity
|
||||||
+ BEFORE the new cursor position — correct for insert-mode but
|
+ == ns_ax_text_selection_granularity_character)
|
||||||
+ 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))
|
|
||||||
+ {
|
+ {
|
||||||
+ NSUInteger point_idx = [self accessibilityIndexForCharpos:point];
|
+ /* 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];
|
+ NSUInteger tlen = [cachedText length];
|
||||||
+ if (point_idx < tlen)
|
+ if (point_idx < tlen)
|
||||||
+ {
|
+ {
|
||||||
+ NSRange charRange = [cachedText
|
+ NSRange charRange = [cachedText
|
||||||
+ rangeOfComposedCharacterSequenceAtIndex: point_idx];
|
+ rangeOfComposedCharacterSequenceAtIndex: point_idx];
|
||||||
+ if (charRange.location != NSNotFound && charRange.length > 0
|
+ if (charRange.location != NSNotFound
|
||||||
|
+ && charRange.length > 0
|
||||||
+ && NSMaxRange (charRange) <= tlen)
|
+ && NSMaxRange (charRange) <= tlen)
|
||||||
+ {
|
+ {
|
||||||
+ NSString *ch = [cachedText substringWithRange: charRange];
|
+ NSString *ch
|
||||||
+ /* Skip bare newlines — VoiceOver says "new line". */
|
+ = [cachedText substringWithRange: charRange];
|
||||||
|
+ /* Skip bare newlines — VoiceOver handles those. */
|
||||||
+ if (![ch isEqualToString: @"\n"])
|
+ if (![ch isEqualToString: @"\n"])
|
||||||
+ {
|
+ {
|
||||||
+ NSDictionary *annInfo = @{
|
+ NSDictionary *annInfo = @{
|
||||||
@@ -2038,22 +2043,12 @@ index 932d209..9455730 100644
|
|||||||
+ }
|
+ }
|
||||||
+ }
|
+ }
|
||||||
+ }
|
+ }
|
||||||
+ }
|
+ else if (cachedText
|
||||||
+
|
+ && granularity
|
||||||
+ /* Emit an explicit announcement whenever point lands on a new line.
|
+ == ns_ax_text_selection_granularity_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)
|
|
||||||
+ {
|
+ {
|
||||||
|
+ /* Line move — announce line text (or completion candidate
|
||||||
|
+ for horizontal multi-column layouts). */
|
||||||
+ NSString *announceText = nil;
|
+ NSString *announceText = nil;
|
||||||
+
|
+
|
||||||
+ /* 1. completion--string at point (completion-list-mode). */
|
+ /* 1. completion--string at point (completion-list-mode). */
|
||||||
@@ -2065,28 +2060,33 @@ index 932d209..9455730 100644
|
|||||||
+ /* 2. Fallback: full line text. */
|
+ /* 2. Fallback: full line text. */
|
||||||
+ if (!announceText)
|
+ if (!announceText)
|
||||||
+ {
|
+ {
|
||||||
+ NSUInteger point_idx = [self accessibilityIndexForCharpos:point];
|
+ NSUInteger point_idx
|
||||||
|
+ = [self accessibilityIndexForCharpos:point];
|
||||||
+ if (point_idx <= [cachedText length])
|
+ if (point_idx <= [cachedText length])
|
||||||
+ {
|
+ {
|
||||||
+ NSInteger lineNum = [self accessibilityLineForIndex:point_idx];
|
+ NSInteger lineNum
|
||||||
+ NSRange lineRange = [self accessibilityRangeForLine:lineNum];
|
+ = [self accessibilityLineForIndex:point_idx];
|
||||||
|
+ NSRange lineRange
|
||||||
|
+ = [self accessibilityRangeForLine:lineNum];
|
||||||
+ if (lineRange.location != NSNotFound
|
+ if (lineRange.location != NSNotFound
|
||||||
+ && lineRange.length > 0
|
+ && lineRange.length > 0
|
||||||
+ && lineRange.location + lineRange.length
|
+ && NSMaxRange (lineRange) <= [cachedText length])
|
||||||
+ <= [cachedText length])
|
+ announceText
|
||||||
+ announceText = [cachedText substringWithRange:lineRange];
|
+ = [cachedText substringWithRange:lineRange];
|
||||||
+ }
|
+ }
|
||||||
+ }
|
+ }
|
||||||
+
|
+
|
||||||
+ if (announceText)
|
+ if (announceText)
|
||||||
+ {
|
+ {
|
||||||
+ announceText = [announceText stringByTrimmingCharactersInSet:
|
+ announceText = [announceText
|
||||||
|
+ stringByTrimmingCharactersInSet:
|
||||||
+ [NSCharacterSet whitespaceAndNewlineCharacterSet]];
|
+ [NSCharacterSet whitespaceAndNewlineCharacterSet]];
|
||||||
+ if ([announceText length] > 0)
|
+ if ([announceText length] > 0)
|
||||||
+ {
|
+ {
|
||||||
+ NSDictionary *annInfo = @{
|
+ NSDictionary *annInfo = @{
|
||||||
+ NSAccessibilityAnnouncementKey: announceText,
|
+ NSAccessibilityAnnouncementKey: announceText,
|
||||||
+ NSAccessibilityPriorityKey: @(NSAccessibilityPriorityMedium)
|
+ NSAccessibilityPriorityKey:
|
||||||
|
+ @(NSAccessibilityPriorityMedium)
|
||||||
+ };
|
+ };
|
||||||
+ NSAccessibilityPostNotificationWithUserInfo (
|
+ NSAccessibilityPostNotificationWithUserInfo (
|
||||||
+ NSApp,
|
+ NSApp,
|
||||||
@@ -2095,6 +2095,7 @@ index 932d209..9455730 100644
|
|||||||
+ }
|
+ }
|
||||||
+ }
|
+ }
|
||||||
+ }
|
+ }
|
||||||
|
+ }
|
||||||
+
|
+
|
||||||
+ /* --- Completions announcement ---
|
+ /* --- Completions announcement ---
|
||||||
+ When point changes in a non-focused buffer (e.g. *Completions*
|
+ When point changes in a non-focused buffer (e.g. *Completions*
|
||||||
@@ -2649,7 +2650,7 @@ index 932d209..9455730 100644
|
|||||||
unsigned fnKeysym = 0;
|
unsigned fnKeysym = 0;
|
||||||
static NSMutableArray *nsEvArray;
|
static NSMutableArray *nsEvArray;
|
||||||
unsigned int flags = [theEvent modifierFlags];
|
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);
|
XSETFRAME (event.frame_or_window, emacsframe);
|
||||||
kbd_buffer_store_event (&event);
|
kbd_buffer_store_event (&event);
|
||||||
ns_send_appdefined (-1); // Kick main loop
|
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;
|
return fs_state;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -2986,7 +2987,7 @@ index 932d209..9455730 100644
|
|||||||
@end /* EmacsView */
|
@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_drag_operation_generic, "ns-drag-operation-generic");
|
||||||
DEFSYM (Qns_handle_drag_motion, "ns-handle-drag-motion");
|
DEFSYM (Qns_handle_drag_motion, "ns-handle-drag-motion");
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user