v10 patch: rich SelectedTextChanged for VoiceOver cursor movement

Key changes from v9:
- SelectedTextChanged now includes rich userInfo:
  direction (Next/Previous/Discontiguous) and
  granularity (Character/Word/Line) inferred from
  old vs new cursor position comparison
- Track lastAccessibilityCursorPos + lastAccessibilityCursorLine
  ivars for position delta detection
- accessibilityInsertionPointLineNumber: buffer lines (count_lines)
  instead of visual vpos
- accessibilityLineForIndex: buffer lines via count_lines
- accessibilityRangeForLine: buffer lines via find_newline_no_quit
- Skip notification on unchanged position (avoids VO repeating)
- Word granularity heuristic: same line, >1 char delta
This commit is contained in:
2026-02-25 21:14:08 +01:00
parent 01ea5d1f0a
commit 54183cc8eb

View File

@@ -1,54 +1,52 @@
From: Martin Sukany <martin@sukany.cz>
Date: Tue, 25 Feb 2026 21:00:00 +0100
Subject: [PATCH] ns: implement macOS Zoom cursor tracking and VoiceOver
support
Date: Tue, 25 Feb 2026 21:30:00 +0100
Subject: [PATCH] ns: macOS Zoom cursor tracking and VoiceOver support
Add comprehensive accessibility support for macOS:
Comprehensive accessibility for macOS NS port:
1. UAZoomChangeFocus() from ApplicationServices/UniversalAccess.h:
Directly tells macOS Zoom where the cursor is. Only fires for the
active window cursor draw (on_p && active_p guard).
Ref: https://developer.apple.com/documentation/applicationservices/1458830-uazoomchangefocus
1. UAZoomChangeFocus() for Zoom viewport tracking.
Only fires for active window cursor (on_p && active_p guard).
2. NSAccessibility protocol on EmacsView (macOS 10.10+):
Full text area protocol for VoiceOver:
- Rich typing echo via NSAccessibilityPostNotificationWithUserInfo
with kAXTextEditTypeTyping (requires macOS 10.11+, falls back to
bare notification on 10.10)
- BUF_MODIFF tracking to distinguish content edits from cursor movement
- Text content: accessibilityValue, accessibilitySelectedText,
accessibilityNumberOfCharacters, accessibilityStringForRange:
- Text navigation: accessibilityLineForIndex:, accessibilityRangeForLine:,
accessibilityInsertionPointLineNumber, accessibilityVisibleCharacterRange
- Cursor geometry: accessibilityBoundsForRange:, accessibilityFrameForRange:
- Notifications: ValueChanged (edit), SelectedTextChanged (cursor),
FocusedUIElementChanged (window focus)
Ref: https://developer.apple.com/documentation/appkit/nsaccessibilityprotocol
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.
Uses raw string literals ("AXTextStateChangeType" etc.) for semi-private
notification keys to avoid type conflicts between SDK versions (these
symbols were added to public AppKit headers in macOS 26).
Uses raw string literals for semi-private AX keys to avoid type
conflicts across SDK versions (macOS 26 added them to public headers).
---
diff --git a/src/nsterm.h b/src/nsterm.h
index 7c1ee4c..5e5f61b 100644
index 7c1ee4c..7b79170 100644
--- a/src/nsterm.h
+++ b/src/nsterm.h
@@ -485,6 +485,10 @@ enum ns_return_frame_mode
@@ -485,6 +485,12 @@ enum ns_return_frame_mode
struct frame *emacsframe;
int scrollbarsNeedingUpdate;
NSRect ns_userRect;
+#ifdef NS_IMPL_COCOA
+ NSRect lastAccessibilityCursorRect;
+ ptrdiff_t lastAccessibilityModiff;
+ ptrdiff_t lastAccessibilityCursorPos;
+ ptrdiff_t lastAccessibilityCursorLine;
+#endif
}
/* AppKit-side interface. */
diff --git a/src/nsterm.m b/src/nsterm.m
index 932d209..6bfc080 100644
index 932d209..265ccb1 100644
--- a/src/nsterm.m
+++ b/src/nsterm.m
@@ -3232,6 +3232,115 @@ Note that CURSOR_WIDTH is meaningful only for (h)bar cursors.
@@ -3232,6 +3232,196 @@ 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));
@@ -137,9 +135,90 @@ index 932d209..6bfc080 100644
+ }
+ }
+
+ /* Always notify that cursor position (selection) changed. */
+ /* 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.
+
+ 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 */
+ {
+ 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);
+
+ 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
+ {
+ NSAccessibilityPostNotification (
+ view, NSAccessibilitySelectedTextChangedNotification);
+ }
+
+ skip_selection_notification:
+ view->lastAccessibilityCursorPos = pt;
+ view->lastAccessibilityCursorLine = cur_line;
+ }
+
+ /* Tell macOS Zoom where the cursor is. UAZoomChangeFocus()
+ expects top-left origin (CG coordinate space). */
@@ -164,7 +243,7 @@ index 932d209..6bfc080 100644
ns_focus (f, NULL, 0);
NSGraphicsContext *ctx = [NSGraphicsContext currentContext];
@@ -8237,6 +8346,14 @@ - (void)windowDidBecomeKey /* for direct calls */
@@ -8237,6 +8427,14 @@ - (void)windowDidBecomeKey /* for direct calls */
XSETFRAME (event.frame_or_window, emacsframe);
kbd_buffer_store_event (&event);
ns_send_appdefined (-1); // Kick main loop
@@ -179,7 +258,7 @@ index 932d209..6bfc080 100644
}
@@ -9474,6 +9591,421 @@ - (int) fullscreenState
@@ -9474,6 +9672,434 @@ - (int) fullscreenState
return fs_state;
}
@@ -333,8 +412,8 @@ index 932d209..6bfc080 100644
+
+- (NSInteger)accessibilityInsertionPointLineNumber
+{
+ /* Return the visual line number of the cursor (vpos = line within
+ the window). VoiceOver uses this for line navigation. */
+ /* Return the buffer line number at point. VoiceOver uses this for
+ line navigation and to announce "line N" when moving by line. */
+ if (!emacsframe)
+ return 0;
+
@@ -342,7 +421,11 @@ index 932d209..6bfc080 100644
+ if (!w)
+ return 0;
+
+ return (NSInteger) (w->cursor.vpos);
+ struct buffer *b = XBUFFER (w->contents);
+ if (!b)
+ return 0;
+
+ return (NSInteger) count_lines (BUF_BEGV_BYTE (b), BUF_PT_BYTE (b));
+}
+
+- (NSRange)accessibilityVisibleCharacterRange
@@ -407,67 +490,76 @@ index 932d209..6bfc080 100644
+
+- (NSInteger)accessibilityLineForIndex:(NSInteger)index
+{
+ /* Convert character index to visual line number. Used by VoiceOver
+ for line-by-line reading (VO+Up/Down).
+
+ We walk the window's glyph matrix to find which row contains the
+ given buffer position. Falls back to 0 if not found. */
+ /* Convert character index to buffer line number. VoiceOver uses
+ this for line-by-line reading (VO+Up/Down). Must be consistent
+ with accessibilityInsertionPointLineNumber. */
+ if (!emacsframe)
+ return 0;
+
+ struct window *w = XWINDOW (emacsframe->selected_window);
+ if (!w || !w->current_matrix)
+ if (!w)
+ return 0;
+
+ struct buffer *curbuf = XBUFFER (w->contents);
+ if (!curbuf)
+ struct buffer *b = XBUFFER (w->contents);
+ if (!b)
+ return 0;
+
+ ptrdiff_t charpos = BUF_BEGV (curbuf) + (ptrdiff_t) index;
+ struct glyph_matrix *matrix = w->current_matrix;
+ ptrdiff_t charpos = BUF_BEGV (b) + (ptrdiff_t) index;
+ if (charpos > BUF_ZV (b))
+ charpos = BUF_ZV (b);
+ if (charpos < BUF_BEGV (b))
+ charpos = BUF_BEGV (b);
+
+ for (int i = 0; i < matrix->nrows; i++)
+ {
+ struct glyph_row *row = matrix->rows + i;
+ if (!row->enabled_p)
+ continue;
+ if (MATRIX_ROW_START_CHARPOS (row) <= charpos
+ && charpos < MATRIX_ROW_END_CHARPOS (row))
+ return (NSInteger) i;
+ }
+
+ return 0;
+ ptrdiff_t bytepos = buf_charpos_to_bytepos (b, charpos);
+ return (NSInteger) count_lines (BUF_BEGV_BYTE (b), bytepos);
+}
+
+- (NSRange)accessibilityRangeForLine:(NSInteger)line
+{
+ /* Return the character range for a visual line. VoiceOver uses
+ this to read an entire line when navigating by line. */
+ /* Return the character range for buffer line N. VoiceOver uses
+ this to read an entire line when navigating by line. Must be
+ consistent with accessibilityLineForIndex: (buffer lines). */
+ if (!emacsframe)
+ return NSMakeRange (0, 0);
+
+ struct window *w = XWINDOW (emacsframe->selected_window);
+ if (!w || !w->current_matrix)
+ if (!w)
+ return NSMakeRange (0, 0);
+
+ struct buffer *curbuf = XBUFFER (w->contents);
+ if (!curbuf)
+ struct buffer *b = XBUFFER (w->contents);
+ if (!b)
+ return NSMakeRange (0, 0);
+
+ struct glyph_matrix *matrix = w->current_matrix;
+ if (line < 0 || line >= matrix->nrows)
+ return NSMakeRange (0, 0);
+ /* Walk from buffer start to find line N. Use find_newline_no_quit
+ (from, frombyte, count, &bytepos) to avoid signaling. */
+ 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 glyph_row *row = matrix->rows + line;
+ if (!row->enabled_p)
+ return NSMakeRange (0, 0);
+ /* Skip N newlines to reach line N. */
+ if (line > 0)
+ {
+ pos = find_newline_no_quit (pos, pos_byte, line, &pos_byte);
+ /* find_newline_no_quit returns position AFTER the newline. */
+ }
+
+ ptrdiff_t start = MATRIX_ROW_START_CHARPOS (row) - BUF_BEGV (curbuf);
+ ptrdiff_t end = MATRIX_ROW_END_CHARPOS (row) - BUF_BEGV (curbuf);
+ if (start < 0) start = 0;
+ if (end < start) end = start;
+ ptrdiff_t line_start = pos;
+
+ return NSMakeRange ((NSUInteger) start, (NSUInteger) (end - start));
+ /* Find end of this line (next newline or buffer end). */
+ ptrdiff_t end_byte = pos_byte;
+ ptrdiff_t line_end = find_newline_no_quit (pos, pos_byte, 1, &end_byte);
+ if (line_end == 0)
+ line_end = zv; /* No newline found — last line. */
+
+ /* Convert to 0-based accessibility indices. */
+ ptrdiff_t start_idx = line_start - begv;
+ ptrdiff_t end_idx = line_end - begv;
+ if (start_idx < 0) start_idx = 0;
+ if (end_idx < start_idx) end_idx = start_idx;
+
+ return NSMakeRange ((NSUInteger) start_idx,
+ (NSUInteger) (end_idx - start_idx));
+}
+
+- (NSRect)accessibilityFrameForRange:(NSRange)range