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:
@@ -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");
|
||||
|
||||
|
||||
Reference in New Issue
Block a user