From 3447fcc8d5ea50e57bcc2c5ea1b5c824ebe75c5c Mon Sep 17 00:00:00 2001 From: Daneel Date: Fri, 27 Feb 2026 12:49:55 +0100 Subject: [PATCH] =?UTF-8?q?patches:=20hybrid=20notification=20=E2=80=94=20?= =?UTF-8?q?SelectedTextChanged=20+=20selective=20AnnouncementRequested?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 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 --- ...oundsForRange-for-macOS-Zoom-cursor-.patch | 217 +++++++++--------- 1 file changed, 108 insertions(+), 109 deletions(-) diff --git a/patches/0001-ns-implement-AXBoundsForRange-for-macOS-Zoom-cursor-.patch b/patches/0001-ns-implement-AXBoundsForRange-for-macOS-Zoom-cursor-.patch index 90860b2..6a7f5ca 100644 --- a/patches/0001-ns-implement-AXBoundsForRange-for-macOS-Zoom-cursor-.patch +++ b/patches/0001-ns-implement-AXBoundsForRange-for-macOS-Zoom-cursor-.patch @@ -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 -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) -+ granularity = ns_ax_text_selection_granularity_character; ++ 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) ++ 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 + = [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,56 +2061,31 @@ index 932d209..bd1ab48 100644 + } + } + } -+ else if (cachedText -+ && granularity -+ == ns_ax_text_selection_granularity_line) ++ ++ /* 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) + { -+ /* 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) ++ Lisp_Object cstr ++ = Fget_char_property (make_fixnum (point), ++ intern ("completion--string"), Qnil); ++ NSString *compText ++ = ns_ax_completion_string_from_prop (cstr); ++ if (compText) + { -+ 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) -+ { -+ NSDictionary *annInfo = @{ -+ NSAccessibilityAnnouncementKey: announceText, -+ NSAccessibilityPriorityKey: -+ @(NSAccessibilityPriorityHigh) -+ }; -+ NSAccessibilityPostNotificationWithUserInfo ( -+ NSApp, -+ NSAccessibilityAnnouncementRequestedNotification, -+ annInfo); -+ } ++ NSDictionary *annInfo = @{ ++ NSAccessibilityAnnouncementKey: compText, ++ NSAccessibilityPriorityKey: ++ @(NSAccessibilityPriorityHigh) ++ }; ++ NSAccessibilityPostNotificationWithUserInfo ( ++ NSApp, ++ NSAccessibilityAnnouncementRequestedNotification, ++ annInfo); + } + } + } @@ -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");