From 2f1fbd30d2e93fdb24a8fb28ab209156d79952d7 Mon Sep 17 00:00:00 2001 From: Daneel Date: Wed, 25 Feb 2026 21:42:20 +0100 Subject: [PATCH] v11 patch: fix VoiceOver cursor movement (iTerm2 pattern) Key insight from iTerm2 analysis: VoiceOver does NOT need rich userInfo on SelectedTextChanged for cursor movement. Bare notifications + correct protocol methods (accessibilityStringForRange:, accessibilityLineForIndex:, accessibilityRangeForLine:) are sufficient. Rich userInfo with wrong values was likely causing VoiceOver to silently discard notifications. Changes from v10: - SelectedTextChanged: bare notification (removed rich userInfo) - Added SelectedRowsChanged + SelectedColumnsChanged (iTerm2 triple pattern) - Added bare ValueChanged on every cursor draw (not just edits) - Fixed current_buffer safety: BUF_BYTE_ADDRESS, set_buffer_internal_1 - Added accessibilityRangeForIndex:, accessibilitySelectedTextRanges, isAccessibilityFocused, accessibilityLabel --- ...oundsForRange-for-macOS-Zoom-cursor-.patch | 202 +++++++++--------- 1 file changed, 105 insertions(+), 97 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 f86c0b0..1a66860 100644 --- a/patches/0001-ns-implement-AXBoundsForRange-for-macOS-Zoom-cursor-.patch +++ b/patches/0001-ns-implement-AXBoundsForRange-for-macOS-Zoom-cursor-.patch @@ -1,29 +1,29 @@ From: Martin Sukany -Date: Tue, 25 Feb 2026 21:30:00 +0100 +Date: Tue, 25 Feb 2026 21:40:00 +0100 Subject: [PATCH] ns: macOS Zoom cursor tracking and VoiceOver support -Comprehensive accessibility for macOS NS port: +Comprehensive accessibility for the macOS NS port: -1. UAZoomChangeFocus() for Zoom viewport tracking. - Only fires for active window cursor (on_p && active_p guard). +1. UAZoomChangeFocus() for Zoom viewport tracking (on_p && active_p guard). 2. NSAccessibility protocol on EmacsView for VoiceOver: - - Rich SelectedTextChanged notifications with direction and - granularity userInfo (kAXTextStateChangeTypeSelectionMove). - VoiceOver uses these to decide what to speak on cursor movement - (character, word, or line). Direction/granularity inferred from - old vs new cursor position. - - Rich ValueChanged for typing echo (kAXTextEditTypeTyping). - - Buffer-line-based line numbering (count_lines, find_newline_no_quit) - for consistent accessibilityInsertionPointLineNumber, - accessibilityLineForIndex:, and accessibilityRangeForLine:. - - Skip notification on position-unchanged redraws to avoid - VoiceOver repeating the same text. - - 10000-char cap on accessibilityValue for large buffers. - - Multibyte-safe byte_range via buf_charpos_to_bytepos. + - Bare SelectedTextChanged + SelectedRowsChanged + SelectedColumnsChanged + triple notification on cursor movement (matches iTerm2 pattern — + no rich userInfo needed for cursor move, VoiceOver queries + accessibilityStringForRange: to get the text at the new position). + - Rich ValueChanged with typing echo userInfo for content edits + (kAXTextEditTypeTyping via semi-private AppKit keys, macOS 10.11+). + - Bare ValueChanged on every cursor draw so VoiceOver refreshes + its cached text model. + - Buffer-line-based numbering via count_lines/find_newline_no_quit + with set_buffer_internal_1 safety wrappers (accessibilityLineForIndex:, + accessibilityRangeForLine:, accessibilityInsertionPointLineNumber). + - BUF_BYTE_ADDRESS (not BYTE_POS_ADDR) for correct buffer data access + when current_buffer != queried buffer. + - 10000-char cap on accessibilityValue, multibyte-safe ranges. Uses raw string literals for semi-private AX keys to avoid type -conflicts across SDK versions (macOS 26 added them to public headers). +conflicts across SDK versions. --- diff --git a/src/nsterm.h b/src/nsterm.h index 7c1ee4c..7b79170 100644 @@ -43,10 +43,10 @@ index 7c1ee4c..7b79170 100644 /* AppKit-side interface. */ diff --git a/src/nsterm.m b/src/nsterm.m -index 932d209..265ccb1 100644 +index 932d209..7811687 100644 --- a/src/nsterm.m +++ b/src/nsterm.m -@@ -3232,6 +3232,196 @@ Note that CURSOR_WIDTH is meaningful only for (h)bar cursors. +@@ -3232,6 +3232,148 @@ Note that CURSOR_WIDTH is meaningful only for (h)bar cursors. /* Prevent the cursor from being drawn outside the text area. */ r = NSIntersectionRect (r, ns_row_rect (w, glyph_row, TEXT_AREA)); @@ -136,88 +136,40 @@ index 932d209..265ccb1 100644 + } + + /* Notify AT that cursor position (selection) changed. -+ VoiceOver requires rich userInfo with direction and -+ granularity to know what to speak. We infer these by -+ comparing old vs new cursor position, using count_lines() -+ for line delta detection. ++ Post bare notifications WITHOUT userInfo — iTerm2 proves ++ this is sufficient for VoiceOver. Rich userInfo with ++ semi-private AXTextStateChangeType keys can cause VoiceOver ++ to silently ignore notifications if any value is unexpected. + -+ Keys and enum values from Apple's AX API: -+ kAXTextStateChangeTypeSelectionMove = 2 -+ Direction: Previous=4, Next=5, Discontiguous=6 -+ Granularity: Character=1, Word=2, Line=3 */ ++ Post three notifications together (matching iTerm2): ++ 1. SelectedTextChanged — cursor/selection moved ++ 2. SelectedRowsChanged — triggers line announcement ++ 3. SelectedColumnsChanged — triggers column tracking ++ ++ Also post ValueChanged on every cursor draw (not just edits) ++ so VoiceOver refreshes its internal text model. iTerm2 does ++ this on every dirty screen refresh. */ + { + ptrdiff_t pt = curbuf ? BUF_PT (curbuf) : 0; -+ ptrdiff_t pt_byte = curbuf ? BUF_PT_BYTE (curbuf) : 0; + ptrdiff_t old_pt = view->lastAccessibilityCursorPos; -+ ptrdiff_t old_line = view->lastAccessibilityCursorLine; + -+ /* Compute current line number via count_lines (efficient, -+ uses region cache and memchr). */ -+ ptrdiff_t cur_line = 0; -+ if (curbuf) -+ cur_line = count_lines (BUF_BEGV_BYTE (curbuf), pt_byte); ++ /* Always post ValueChanged so VoiceOver re-queries ++ accessibilityValue and keeps its text model current. */ ++ NSAccessibilityPostNotification ( ++ view, NSAccessibilityValueChangedNotification); + -+ if (@available (macOS 10.11, *)) -+ { -+ int direction; -+ int granularity; -+ -+ if (old_pt == 0 && old_line == 0 && pt > 1) -+ { -+ /* First cursor draw — no previous position. */ -+ direction = 6; /* Discontiguous */ -+ granularity = 3; /* Line */ -+ } -+ else if (cur_line != old_line) -+ { -+ ptrdiff_t line_delta -+ = cur_line > old_line -+ ? cur_line - old_line : old_line - cur_line; -+ direction = (cur_line > old_line) ? 5 : 4; -+ /* Large jump → treat as discontiguous. */ -+ if (line_delta > 5) -+ { -+ direction = 6; -+ granularity = 3; -+ } -+ else -+ granularity = 3; /* Line */ -+ } -+ else if (pt != old_pt) -+ { -+ ptrdiff_t char_delta -+ = pt > old_pt ? pt - old_pt : old_pt - pt; -+ direction = (pt > old_pt) ? 5 : 4; -+ /* >1 char on same line → Word granularity heuristic -+ (covers M-f, M-b, C-a, C-e). */ -+ granularity = (char_delta > 1) ? 2 : 1; -+ } -+ else -+ { -+ /* Position unchanged (window redraw, no movement). -+ Skip notification to avoid VoiceOver repeating. */ -+ goto skip_selection_notification; -+ } -+ -+ NSDictionary *userInfo = @{ -+ @"AXTextStateChangeType": @2, -+ @"AXTextSelectionDirection": @(direction), -+ @"AXTextSelectionGranularity": @(granularity), -+ @"AXTextChangeElement": view -+ }; -+ NSAccessibilityPostNotificationWithUserInfo ( -+ view, NSAccessibilitySelectedTextChangedNotification, -+ userInfo); -+ } -+ else ++ if (pt != old_pt) + { ++ /* Cursor actually moved — post the selection trio. */ + NSAccessibilityPostNotification ( + view, NSAccessibilitySelectedTextChangedNotification); ++ NSAccessibilityPostNotification ( ++ view, NSAccessibilitySelectedRowsChangedNotification); ++ NSAccessibilityPostNotification ( ++ view, NSAccessibilitySelectedColumnsChangedNotification); + } + -+ skip_selection_notification: + view->lastAccessibilityCursorPos = pt; -+ view->lastAccessibilityCursorLine = cur_line; + } + + /* Tell macOS Zoom where the cursor is. UAZoomChangeFocus() @@ -243,7 +195,7 @@ index 932d209..265ccb1 100644 ns_focus (f, NULL, 0); NSGraphicsContext *ctx = [NSGraphicsContext currentContext]; -@@ -8237,6 +8427,14 @@ - (void)windowDidBecomeKey /* for direct calls */ +@@ -8237,6 +8379,14 @@ - (void)windowDidBecomeKey /* for direct calls */ XSETFRAME (event.frame_or_window, emacsframe); kbd_buffer_store_event (&event); ns_send_appdefined (-1); // Kick main loop @@ -258,7 +210,7 @@ index 932d209..265ccb1 100644 } -@@ -9474,6 +9672,434 @@ - (int) fullscreenState +@@ -9474,6 +9624,490 @@ - (int) fullscreenState return fs_state; } @@ -350,7 +302,7 @@ index 932d209..265ccb1 100644 + str = make_uninit_multibyte_string (range, byte_range); + else + str = make_uninit_string (range); -+ memcpy (SDATA (str), BYTE_POS_ADDR (start_byte), byte_range); ++ memcpy (SDATA (str), BUF_BYTE_ADDRESS (curbuf, start_byte), byte_range); + + return [NSString stringWithLispString:str]; +} @@ -425,7 +377,12 @@ index 932d209..265ccb1 100644 + if (!b) + return 0; + -+ return (NSInteger) count_lines (BUF_BEGV_BYTE (b), BUF_PT_BYTE (b)); ++ /* count_lines operates on current_buffer, so temporarily switch. */ ++ struct buffer *old = current_buffer; ++ set_buffer_internal_1 (b); ++ ptrdiff_t lines = count_lines (BUF_BEGV_BYTE (b), BUF_PT_BYTE (b)); ++ set_buffer_internal_1 (old); ++ return (NSInteger) lines; +} + +- (NSRange)accessibilityVisibleCharacterRange @@ -511,7 +468,13 @@ index 932d209..265ccb1 100644 + charpos = BUF_BEGV (b); + + ptrdiff_t bytepos = buf_charpos_to_bytepos (b, charpos); -+ return (NSInteger) count_lines (BUF_BEGV_BYTE (b), bytepos); ++ ++ /* count_lines operates on current_buffer, so temporarily switch. */ ++ struct buffer *old = current_buffer; ++ set_buffer_internal_1 (b); ++ ptrdiff_t lines = count_lines (BUF_BEGV_BYTE (b), bytepos); ++ set_buffer_internal_1 (old); ++ return (NSInteger) lines; +} + +- (NSRange)accessibilityRangeForLine:(NSInteger)line @@ -530,13 +493,15 @@ index 932d209..265ccb1 100644 + if (!b) + return NSMakeRange (0, 0); + -+ /* Walk from buffer start to find line N. Use find_newline_no_quit -+ (from, frombyte, count, &bytepos) to avoid signaling. */ ++ /* find_newline_no_quit operates on current_buffer, so switch. */ + ptrdiff_t begv = BUF_BEGV (b); + ptrdiff_t zv = BUF_ZV (b); + ptrdiff_t pos = begv; + ptrdiff_t pos_byte = BUF_BEGV_BYTE (b); + ++ struct buffer *old = current_buffer; ++ set_buffer_internal_1 (b); ++ + /* Skip N newlines to reach line N. */ + if (line > 0) + { @@ -552,6 +517,8 @@ index 932d209..265ccb1 100644 + if (line_end == 0) + line_end = zv; /* No newline found — last line. */ + ++ set_buffer_internal_1 (old); ++ + /* Convert to 0-based accessibility indices. */ + ptrdiff_t start_idx = line_start - begv; + ptrdiff_t end_idx = line_end - begv; @@ -580,6 +547,47 @@ index 932d209..265ccb1 100644 + return NSMakeRange (0, 0); +} + ++- (NSRange)accessibilityRangeForIndex:(NSInteger)index ++{ ++ /* Return the range of the character at the given index. VoiceOver ++ uses this for character-by-character navigation. */ ++ if (!emacsframe) ++ return NSMakeRange (0, 0); ++ ++ struct buffer *curbuf ++ = XBUFFER (XWINDOW (emacsframe->selected_window)->contents); ++ if (!curbuf) ++ return NSMakeRange (0, 0); ++ ++ ptrdiff_t range = BUF_ZV (curbuf) - BUF_BEGV (curbuf); ++ ptrdiff_t maxrange = MIN (range, 10000); ++ ++ if (index < 0 || index >= maxrange) ++ return NSMakeRange (0, 0); ++ ++ return NSMakeRange ((NSUInteger) index, 1); ++} ++ ++- (NSArray *)accessibilitySelectedTextRanges ++{ ++ /* Return array of selected ranges. VoiceOver may query this ++ (plural) form instead of the singular selectedTextRange. */ ++ NSRange r = [self accessibilitySelectedTextRange]; ++ return @[[NSValue valueWithRange:r]]; ++} ++ ++- (BOOL)isAccessibilityFocused ++{ ++ /* Always report focused — matches iTerm2 behavior. */ ++ return YES; ++} ++ ++- (NSString *)accessibilityLabel ++{ ++ /* Provide an identifying label for VoiceOver. */ ++ return @"Emacs"; ++} ++ +/* ---- Cursor position for Zoom (via accessibilityBoundsForRange:) ---- + + accessibilityFrame intentionally returns the VIEW's frame (standard