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> From: Martin Sukany <martin@sukany.cz>
Date: Fri, 27 Feb 2026 12:37:38 +0100 Date: Fri, 27 Feb 2026 12:49:43 +0100
Subject: [PATCH] ns: implement VoiceOver accessibility (AXBoundsForRange, line Subject: [PATCH] ns: implement VoiceOver accessibility (hybrid notification
nav, completions, interactive spans) strategy)
AnnouncementRequested at PriorityHigh: immediately interrupts ongoing Notification architecture:
VoiceOver speech (e.g. buffer reading) when user starts navigating. - SelectedTextChanged ALWAYS posted for focused element (interrupts
Safe now that SelectedTextChanged is not posted for cursor moves. 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.h | 109 ++
src/nsterm.m | 2733 +++++++++++++++++++++++++++++++++++++++++++++++--- src/nsterm.m | 2727 +++++++++++++++++++++++++++++++++++++++++++++++---
2 files changed, 2693 insertions(+), 149 deletions(-) 2 files changed, 2687 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
@@ -147,7 +152,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..bd1ab48 100644 index 932d209..d33299b 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)
@@ -208,7 +213,7 @@ index 932d209..bd1ab48 100644
ns_focus (f, NULL, 0); ns_focus (f, NULL, 0);
NSGraphicsContext *ctx = [NSGraphicsContext currentContext]; 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; - font_panel_active = NO;
+ ns_ax_text_selection_granularity_unknown = 0, + ns_ax_text_selection_granularity_unknown = 0,
+ ns_ax_text_selection_granularity_character = 1, + ns_ax_text_selection_granularity_character = 1,
+ ns_ax_text_selection_granularity_word = 2,
+ ns_ax_text_selection_granularity_line = 3, + ns_ax_text_selection_granularity_line = 3,
+}; +};
@@ -1933,18 +1939,21 @@ index 932d209..bd1ab48 100644
+ int ctrlNP = 0; + int ctrlNP = 0;
+ bool isCtrlNP = ns_ax_event_is_ctrl_n_or_p (&ctrlNP); + bool isCtrlNP = ns_ax_event_is_ctrl_n_or_p (&ctrlNP);
+ +
+ /* Compute granularity by comparing old and new line positions. + /* --- Granularity detection ---
+ Never use delta==1 as a proxy for "character move" — a single + Compare old and new cursor positions in cachedText to determine
+ buffer character can cross a line boundary (e.g. empty lines, + what kind of move happened. Three levels:
+ org-mode invisible text gaps) and VoiceOver would say "new line" + - line: different line (lineRangeForRange)
+ for the \n character instead of reading the next line. */ + - 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; + NSInteger granularity = ns_ax_text_selection_granularity_unknown;
+ [self ensureTextCache]; + [self ensureTextCache];
+ NSUInteger oldIdx = 0, newIdx = 0;
+ if (cachedText && oldPoint > 0) + if (cachedText && oldPoint > 0)
+ { + {
+ NSUInteger tlen = [cachedText length]; + NSUInteger tlen = [cachedText length];
+ NSUInteger oldIdx = [self accessibilityIndexForCharpos:oldPoint]; + oldIdx = [self accessibilityIndexForCharpos:oldPoint];
+ NSUInteger newIdx = [self accessibilityIndexForCharpos:point]; + newIdx = [self accessibilityIndexForCharpos:point];
+ if (oldIdx > tlen) oldIdx = tlen; + if (oldIdx > tlen) oldIdx = tlen;
+ if (newIdx > tlen) newIdx = tlen; + if (newIdx > tlen) newIdx = tlen;
+ +
@@ -1954,12 +1963,19 @@ index 932d209..bd1ab48 100644
+ NSMakeRange (newIdx, 0)]; + NSMakeRange (newIdx, 0)];
+ if (oldLine.location != newLine.location) + if (oldLine.location != newLine.location)
+ granularity = ns_ax_text_selection_granularity_line; + granularity = ns_ax_text_selection_granularity_line;
+ else if (oldIdx != newIdx) + else
+ granularity = ns_ax_text_selection_granularity_character; + {
+ 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. + /* Force line semantics for explicit C-n/C-p / Tab / backtab. */
+ This isolates the key-path difference from arrow-down/up. */
+ if (isCtrlNP) + if (isCtrlNP)
+ { + {
+ direction = (ctrlNP > 0 + direction = (ctrlNP > 0
@@ -1969,45 +1985,54 @@ index 932d209..bd1ab48 100644
+ } + }
+ +
+ /* --- NOTIFICATION STRATEGY --- + /* --- NOTIFICATION STRATEGY ---
+ SelectedTextChanged triggers VoiceOver speech that CANNOT be + SelectedTextChanged ALWAYS posted for focused element:
+ suppressed or overridden by AnnouncementRequested — both play + - Interrupts VoiceOver auto-read (buffer switch reading)
+ sequentially, causing double-speech. + - Provides word/line/selection reading via VoiceOver defaults
+ +
+ Therefore: + For CHARACTER moves only: omit granularity from userInfo so
+ - Selection active or mark-state changed: SelectedTextChanged + VoiceOver cannot derive speech from SelectedTextChanged, then
+ (VoiceOver reads selected/deselected text — correct behaviour). + post AnnouncementRequested with char AT point. This avoids
+ - Pure cursor move (no mark): AnnouncementRequested ONLY. + double-speech while keeping the interrupt behaviour.
+ We control exactly what VoiceOver says: +
+ * Character moves: char AT point (evil block-cursor correct) + For WORD and LINE moves: include granularity in userInfo —
+ * Line moves: full line text (or completion candidate) + VoiceOver reads the word/line correctly on its own.
+ - Non-focused buffer: AnnouncementRequested only (see below). */ +
+ For SELECTION changes: include granularity — VoiceOver reads
+ selected/deselected text.
+
+ Non-focused buffers: AnnouncementRequested only (see below). */
+ if ([self isAccessibilityFocused]) + if ([self isAccessibilityFocused])
+ { + {
+ if (markActive || markActive != 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);
+
+ NSAccessibilityPostNotificationWithUserInfo (
+ 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)
+ { + {
+ /* 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 + NSUInteger point_idx
+ = [self accessibilityIndexForCharpos:point]; + = [self accessibilityIndexForCharpos:point];
+ NSUInteger tlen = [cachedText length]; + NSUInteger tlen = [cachedText length];
@@ -2021,7 +2046,6 @@ index 932d209..bd1ab48 100644
+ { + {
+ NSString *ch + NSString *ch
+ = [cachedText substringWithRange: charRange]; + = [cachedText substringWithRange: charRange];
+ /* Skip bare newlines — VoiceOver handles those. */
+ if (![ch isEqualToString: @"\n"]) + if (![ch isEqualToString: @"\n"])
+ { + {
+ NSDictionary *annInfo = @{ + NSDictionary *annInfo = @{
@@ -2037,56 +2061,31 @@ index 932d209..bd1ab48 100644
+ } + }
+ } + }
+ } + }
+ else if (cachedText +
+ && granularity + /* For focused line moves: announce line text (or completion
+ == ns_ax_text_selection_granularity_line) + 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)
+ { + {
+ /* Line move — announce line text (or completion candidate + Lisp_Object cstr
+ for horizontal multi-column layouts). */ + = Fget_char_property (make_fixnum (point),
+ NSString *announceText = nil; + intern ("completion--string"), Qnil);
+ + NSString *compText
+ /* 1. completion--string at point (completion-list-mode). */ + = ns_ax_completion_string_from_prop (cstr);
+ Lisp_Object cstr = Fget_char_property (make_fixnum (point), + if (compText)
+ intern ("completion--string"),
+ Qnil);
+ announceText = ns_ax_completion_string_from_prop (cstr);
+
+ /* 2. Fallback: full line text. */
+ if (!announceText)
+ { + {
+ NSUInteger point_idx + NSDictionary *annInfo = @{
+ = [self accessibilityIndexForCharpos:point]; + NSAccessibilityAnnouncementKey: compText,
+ if (point_idx <= [cachedText length]) + NSAccessibilityPriorityKey:
+ { + @(NSAccessibilityPriorityHigh)
+ NSInteger lineNum + };
+ = [self accessibilityLineForIndex:point_idx]; + NSAccessibilityPostNotificationWithUserInfo (
+ NSRange lineRange + NSApp,
+ = [self accessibilityRangeForLine:lineNum]; + NSAccessibilityAnnouncementRequestedNotification,
+ if (lineRange.location != NSNotFound + annInfo);
+ && 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)
+ };
+ NSAccessibilityPostNotificationWithUserInfo (
+ NSApp,
+ NSAccessibilityAnnouncementRequestedNotification,
+ annInfo);
+ }
+ } + }
+ } + }
+ } + }
@@ -2644,7 +2643,7 @@ index 932d209..bd1ab48 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 +10341,28 @@ - (void)windowDidBecomeKey /* for direct calls */ @@ -8237,6 +10335,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
@@ -2673,7 +2672,7 @@ index 932d209..bd1ab48 100644
} }
@@ -9474,6 +11600,307 @@ - (int) fullscreenState @@ -9474,6 +11594,307 @@ - (int) fullscreenState
return fs_state; return fs_state;
} }
@@ -2981,7 +2980,7 @@ index 932d209..bd1ab48 100644
@end /* EmacsView */ @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_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");