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