patches: hybrid notification — SelectedTextChanged + selective AnnouncementRequested

- SelectedTextChanged always posted (interrupts auto-read, braille)
- Character moves: granularity omitted from userInfo + AnnouncementRequested(char AT point)
- Word moves: granularity=word in userInfo (VoiceOver reads word) — fixes M-f/M-b
- Line moves: granularity=line in userInfo (VoiceOver reads line)
- Completion in focused buffer: AnnouncementRequested overrides line
This commit is contained in:
2026-02-27 12:49:55 +01:00
parent 495a5510c6
commit 3447fcc8d5

View File

@@ -1,16 +1,21 @@
From 8cdd9866c92b756094dc56fec7560a9fc89cb29a Mon Sep 17 00:00:00 2001
From f58ceb517c9bff049dcc7bf315062f1fa80c85b2 Mon Sep 17 00:00:00 2001
From: Martin Sukany <martin@sukany.cz>
Date: Fri, 27 Feb 2026 12:37:38 +0100
Subject: [PATCH] ns: implement VoiceOver accessibility (AXBoundsForRange, line
nav, completions, interactive spans)
Date: Fri, 27 Feb 2026 12:49:43 +0100
Subject: [PATCH] ns: implement VoiceOver accessibility (hybrid notification
strategy)
AnnouncementRequested at PriorityHigh: immediately interrupts ongoing
VoiceOver speech (e.g. buffer reading) when user starts navigating.
Safe now that SelectedTextChanged is not posted for cursor moves.
Notification architecture:
- SelectedTextChanged ALWAYS posted for focused element (interrupts
VoiceOver auto-read, updates braille displays)
- For character moves: omit granularity from userInfo so VoiceOver
cannot derive speech; post AnnouncementRequested with char AT point
- For word/line: include granularity (VoiceOver reads word/line correctly)
- Word granularity: same-line moves > 1 UTF-16 unit (fixes M-f/M-b)
- Non-focused: AnnouncementRequested only (completions)
---
src/nsterm.h | 109 ++
src/nsterm.m | 2733 +++++++++++++++++++++++++++++++++++++++++++++++---
2 files changed, 2693 insertions(+), 149 deletions(-)
src/nsterm.m | 2727 +++++++++++++++++++++++++++++++++++++++++++++++---
2 files changed, 2687 insertions(+), 149 deletions(-)
diff --git a/src/nsterm.h b/src/nsterm.h
index 7c1ee4c..6c95673 100644
@@ -147,7 +152,7 @@ index 7c1ee4c..6c95673 100644
diff --git a/src/nsterm.m b/src/nsterm.m
index 932d209..bd1ab48 100644
index 932d209..d33299b 100644
--- a/src/nsterm.m
+++ b/src/nsterm.m
@@ -46,6 +46,7 @@ Updated by Christian Limpach (chris@nice.ch)
@@ -208,7 +213,7 @@ index 932d209..bd1ab48 100644
ns_focus (f, NULL, 0);
NSGraphicsContext *ctx = [NSGraphicsContext currentContext];
@@ -6849,219 +6886,2286 @@ - (BOOL)fulfillService: (NSString *)name withArg: (NSString *)arg
@@ -6849,219 +6886,2280 @@ - (BOOL)fulfillService: (NSString *)name withArg: (NSString *)arg
/* ==========================================================================
@@ -575,6 +580,7 @@ index 932d209..bd1ab48 100644
- font_panel_active = NO;
+ ns_ax_text_selection_granularity_unknown = 0,
+ ns_ax_text_selection_granularity_character = 1,
+ ns_ax_text_selection_granularity_word = 2,
+ ns_ax_text_selection_granularity_line = 3,
+};
@@ -1933,18 +1939,21 @@ index 932d209..bd1ab48 100644
+ int ctrlNP = 0;
+ bool isCtrlNP = ns_ax_event_is_ctrl_n_or_p (&ctrlNP);
+
+ /* Compute granularity by comparing old and new line positions.
+ Never use delta==1 as a proxy for "character move" — a single
+ buffer character can cross a line boundary (e.g. empty lines,
+ org-mode invisible text gaps) and VoiceOver would say "new line"
+ for the \n character instead of reading the next line. */
+ /* --- Granularity detection ---
+ Compare old and new cursor positions in cachedText to determine
+ what kind of move happened. Three levels:
+ - line: different line (lineRangeForRange)
+ - word: same line, distance > 1 UTF-16 unit
+ - character: same line, distance == 1 UTF-16 unit
+ C-n/C-p force line regardless of detected granularity. */
+ NSInteger granularity = ns_ax_text_selection_granularity_unknown;
+ [self ensureTextCache];
+ NSUInteger oldIdx = 0, newIdx = 0;
+ if (cachedText && oldPoint > 0)
+ {
+ NSUInteger tlen = [cachedText length];
+ NSUInteger oldIdx = [self accessibilityIndexForCharpos:oldPoint];
+ NSUInteger newIdx = [self accessibilityIndexForCharpos:point];
+ oldIdx = [self accessibilityIndexForCharpos:oldPoint];
+ newIdx = [self accessibilityIndexForCharpos:point];
+ if (oldIdx > tlen) oldIdx = tlen;
+ if (newIdx > tlen) newIdx = tlen;
+
@@ -1954,12 +1963,19 @@ index 932d209..bd1ab48 100644
+ NSMakeRange (newIdx, 0)];
+ if (oldLine.location != newLine.location)
+ granularity = ns_ax_text_selection_granularity_line;
+ else if (oldIdx != newIdx)
+ 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 keystrokes.
+ This isolates the key-path difference from arrow-down/up. */
+ /* Force line semantics for explicit C-n/C-p / Tab / backtab. */
+ if (isCtrlNP)
+ {
+ direction = (ctrlNP > 0
@@ -1969,45 +1985,54 @@ index 932d209..bd1ab48 100644
+ }
+
+ /* --- NOTIFICATION STRATEGY ---
+ SelectedTextChanged triggers VoiceOver speech that CANNOT be
+ suppressed or overridden by AnnouncementRequested — both play
+ sequentially, causing double-speech.
+ SelectedTextChanged ALWAYS posted for focused element:
+ - Interrupts VoiceOver auto-read (buffer switch reading)
+ - Provides word/line/selection reading via VoiceOver defaults
+
+ 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). */
+ For CHARACTER moves only: omit granularity from userInfo so
+ VoiceOver cannot derive speech from SelectedTextChanged, then
+ post AnnouncementRequested with char AT point. This avoids
+ double-speech while keeping the interrupt behaviour.
+
+ For WORD and LINE moves: include granularity in userInfo —
+ VoiceOver reads the word/line correctly on its own.
+
+ For SELECTION changes: include granularity — VoiceOver reads
+ selected/deselected text.
+
+ Non-focused buffers: AnnouncementRequested only (see below). */
+ 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 = @{
+ @"AXTextStateChangeType":
+ @(ns_ax_text_state_change_selection_move),
+ @"AXTextSelectionDirection": @(direction),
+ @"AXTextSelectionGranularity": @(granularity),
+ @"AXTextChangeElement": self
+ };
+ 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);
+
+ NSAccessibilityPostNotificationWithUserInfo (
+ self,
+ NSAccessibilitySelectedTextChangedNotification,
+ moveInfo);
+ }
+ else if (cachedText
+ && granularity
+ == ns_ax_text_selection_granularity_character)
+ {
+ /* Character move — announce char AT point.
+
+ /* 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 (same character VoiceOver
+ would have read via its default behaviour). */
+ and harmless for insert-mode. */
+ if (isCharMove && cachedText)
+ {
+ NSUInteger point_idx
+ = [self accessibilityIndexForCharpos:point];
+ NSUInteger tlen = [cachedText length];
@@ -2021,7 +2046,6 @@ index 932d209..bd1ab48 100644
+ {
+ NSString *ch
+ = [cachedText substringWithRange: charRange];
+ /* Skip bare newlines — VoiceOver handles those. */
+ if (![ch isEqualToString: @"\n"])
+ {
+ NSDictionary *annInfo = @{
@@ -2037,48 +2061,24 @@ index 932d209..bd1ab48 100644
+ }
+ }
+ }
+ else if (cachedText
+ && granularity
+ == ns_ax_text_selection_granularity_line)
+ {
+ /* 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)
+ /* For focused line moves: announce line text (or completion
+ candidate for horizontal multi-column layouts).
+ VoiceOver already reads the line via SelectedTextChanged
+ with granularity=line, but for completion-list-mode we need
+ to announce just the candidate, not the whole line. */
+ if (cachedText
+ && granularity == ns_ax_text_selection_granularity_line)
+ {
+ 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)
+ Lisp_Object cstr
+ = Fget_char_property (make_fixnum (point),
+ intern ("completion--string"), Qnil);
+ NSString *compText
+ = ns_ax_completion_string_from_prop (cstr);
+ if (compText)
+ {
+ NSDictionary *annInfo = @{
+ NSAccessibilityAnnouncementKey: announceText,
+ NSAccessibilityAnnouncementKey: compText,
+ NSAccessibilityPriorityKey:
+ @(NSAccessibilityPriorityHigh)
+ };
@@ -2089,7 +2089,6 @@ index 932d209..bd1ab48 100644
+ }
+ }
+ }
+ }
+
+ /* --- Completions announcement ---
+ When point changes in a non-focused buffer (e.g. *Completions*
@@ -2644,7 +2643,7 @@ index 932d209..bd1ab48 100644
unsigned fnKeysym = 0;
static NSMutableArray *nsEvArray;
unsigned int flags = [theEvent modifierFlags];
@@ -8237,6 +10341,28 @@ - (void)windowDidBecomeKey /* for direct calls */
@@ -8237,6 +10335,28 @@ - (void)windowDidBecomeKey /* for direct calls */
XSETFRAME (event.frame_or_window, emacsframe);
kbd_buffer_store_event (&event);
ns_send_appdefined (-1); // Kick main loop
@@ -2673,7 +2672,7 @@ index 932d209..bd1ab48 100644
}
@@ -9474,6 +11600,307 @@ - (int) fullscreenState
@@ -9474,6 +11594,307 @@ - (int) fullscreenState
return fs_state;
}
@@ -2981,7 +2980,7 @@ index 932d209..bd1ab48 100644
@end /* EmacsView */
@@ -11303,6 +13730,14 @@ Convert an X font name (XLFD) to an NS font name.
@@ -11303,6 +13724,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");