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> From: Martin Sukany <martin@sukany.cz>
Date: Tue, 25 Feb 2026 21:00:00 +0100 Date: Tue, 25 Feb 2026 21:30:00 +0100
Subject: [PATCH] ns: implement macOS Zoom cursor tracking and VoiceOver Subject: [PATCH] ns: macOS Zoom cursor tracking and VoiceOver support
support
Add comprehensive accessibility support for macOS: Comprehensive accessibility for macOS NS port:
1. UAZoomChangeFocus() from ApplicationServices/UniversalAccess.h: 1. UAZoomChangeFocus() for Zoom viewport tracking.
Directly tells macOS Zoom where the cursor is. Only fires for the Only fires for active window cursor (on_p && active_p guard).
active window cursor draw (on_p && active_p guard).
Ref: https://developer.apple.com/documentation/applicationservices/1458830-uazoomchangefocus
2. NSAccessibility protocol on EmacsView (macOS 10.10+): 2. NSAccessibility protocol on EmacsView for VoiceOver:
Full text area protocol for VoiceOver: - Rich SelectedTextChanged notifications with direction and
- Rich typing echo via NSAccessibilityPostNotificationWithUserInfo granularity userInfo (kAXTextStateChangeTypeSelectionMove).
with kAXTextEditTypeTyping (requires macOS 10.11+, falls back to VoiceOver uses these to decide what to speak on cursor movement
bare notification on 10.10) (character, word, or line). Direction/granularity inferred from
- BUF_MODIFF tracking to distinguish content edits from cursor movement old vs new cursor position.
- Text content: accessibilityValue, accessibilitySelectedText, - Rich ValueChanged for typing echo (kAXTextEditTypeTyping).
accessibilityNumberOfCharacters, accessibilityStringForRange: - Buffer-line-based line numbering (count_lines, find_newline_no_quit)
- Text navigation: accessibilityLineForIndex:, accessibilityRangeForLine:, for consistent accessibilityInsertionPointLineNumber,
accessibilityInsertionPointLineNumber, accessibilityVisibleCharacterRange accessibilityLineForIndex:, and accessibilityRangeForLine:.
- Cursor geometry: accessibilityBoundsForRange:, accessibilityFrameForRange: - Skip notification on position-unchanged redraws to avoid
- Notifications: ValueChanged (edit), SelectedTextChanged (cursor), VoiceOver repeating the same text.
FocusedUIElementChanged (window focus) - 10000-char cap on accessibilityValue for large buffers.
Ref: https://developer.apple.com/documentation/appkit/nsaccessibilityprotocol - Multibyte-safe byte_range via buf_charpos_to_bytepos.
Uses raw string literals ("AXTextStateChangeType" etc.) for semi-private Uses raw string literals for semi-private AX keys to avoid type
notification keys to avoid type conflicts between SDK versions (these conflicts across SDK versions (macOS 26 added them to public headers).
symbols were added to public AppKit headers in macOS 26).
--- ---
diff --git a/src/nsterm.h b/src/nsterm.h diff --git a/src/nsterm.h b/src/nsterm.h
index 7c1ee4c..5e5f61b 100644 index 7c1ee4c..7b79170 100644
--- a/src/nsterm.h --- a/src/nsterm.h
+++ b/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; struct frame *emacsframe;
int scrollbarsNeedingUpdate; int scrollbarsNeedingUpdate;
NSRect ns_userRect; NSRect ns_userRect;
+#ifdef NS_IMPL_COCOA +#ifdef NS_IMPL_COCOA
+ NSRect lastAccessibilityCursorRect; + NSRect lastAccessibilityCursorRect;
+ ptrdiff_t lastAccessibilityModiff; + ptrdiff_t lastAccessibilityModiff;
+ ptrdiff_t lastAccessibilityCursorPos;
+ ptrdiff_t lastAccessibilityCursorLine;
+#endif +#endif
} }
/* AppKit-side interface. */ /* AppKit-side interface. */
diff --git a/src/nsterm.m b/src/nsterm.m diff --git a/src/nsterm.m b/src/nsterm.m
index 932d209..6bfc080 100644 index 932d209..265ccb1 100644
--- a/src/nsterm.m --- a/src/nsterm.m
+++ b/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. */ /* Prevent the cursor from being drawn outside the text area. */
r = NSIntersectionRect (r, ns_row_rect (w, glyph_row, 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 ( + NSAccessibilityPostNotification (
+ view, NSAccessibilitySelectedTextChangedNotification); + view, NSAccessibilitySelectedTextChangedNotification);
+ }
+
+ skip_selection_notification:
+ view->lastAccessibilityCursorPos = pt;
+ view->lastAccessibilityCursorLine = cur_line;
+ }
+ +
+ /* Tell macOS Zoom where the cursor is. UAZoomChangeFocus() + /* Tell macOS Zoom where the cursor is. UAZoomChangeFocus()
+ expects top-left origin (CG coordinate space). */ + expects top-left origin (CG coordinate space). */
@@ -164,7 +243,7 @@ index 932d209..6bfc080 100644
ns_focus (f, NULL, 0); ns_focus (f, NULL, 0);
NSGraphicsContext *ctx = [NSGraphicsContext currentContext]; 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); 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
@@ -179,7 +258,7 @@ index 932d209..6bfc080 100644
} }
@@ -9474,6 +9591,421 @@ - (int) fullscreenState @@ -9474,6 +9672,434 @@ - (int) fullscreenState
return fs_state; return fs_state;
} }
@@ -333,8 +412,8 @@ index 932d209..6bfc080 100644
+ +
+- (NSInteger)accessibilityInsertionPointLineNumber +- (NSInteger)accessibilityInsertionPointLineNumber
+{ +{
+ /* Return the visual line number of the cursor (vpos = line within + /* Return the buffer line number at point. VoiceOver uses this for
+ the window). VoiceOver uses this for line navigation. */ + line navigation and to announce "line N" when moving by line. */
+ if (!emacsframe) + if (!emacsframe)
+ return 0; + return 0;
+ +
@@ -342,7 +421,11 @@ index 932d209..6bfc080 100644
+ if (!w) + if (!w)
+ return 0; + 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 +- (NSRange)accessibilityVisibleCharacterRange
@@ -407,67 +490,76 @@ index 932d209..6bfc080 100644
+ +
+- (NSInteger)accessibilityLineForIndex:(NSInteger)index +- (NSInteger)accessibilityLineForIndex:(NSInteger)index
+{ +{
+ /* Convert character index to visual line number. Used by VoiceOver + /* Convert character index to buffer line number. VoiceOver uses
+ for line-by-line reading (VO+Up/Down). + this for line-by-line reading (VO+Up/Down). Must be consistent
+ + with accessibilityInsertionPointLineNumber. */
+ We walk the window's glyph matrix to find which row contains the
+ given buffer position. Falls back to 0 if not found. */
+ if (!emacsframe) + if (!emacsframe)
+ return 0; + return 0;
+ +
+ struct window *w = XWINDOW (emacsframe->selected_window); + struct window *w = XWINDOW (emacsframe->selected_window);
+ if (!w || !w->current_matrix) + if (!w)
+ return 0; + return 0;
+ +
+ struct buffer *curbuf = XBUFFER (w->contents); + struct buffer *b = XBUFFER (w->contents);
+ if (!curbuf) + if (!b)
+ return 0; + return 0;
+ +
+ ptrdiff_t charpos = BUF_BEGV (curbuf) + (ptrdiff_t) index; + ptrdiff_t charpos = BUF_BEGV (b) + (ptrdiff_t) index;
+ struct glyph_matrix *matrix = w->current_matrix; + 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++) + ptrdiff_t bytepos = buf_charpos_to_bytepos (b, charpos);
+ { + return (NSInteger) count_lines (BUF_BEGV_BYTE (b), bytepos);
+ 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;
+} +}
+ +
+- (NSRange)accessibilityRangeForLine:(NSInteger)line +- (NSRange)accessibilityRangeForLine:(NSInteger)line
+{ +{
+ /* Return the character range for a visual line. VoiceOver uses + /* Return the character range for buffer line N. VoiceOver uses
+ this to read an entire line when navigating by line. */ + this to read an entire line when navigating by line. Must be
+ consistent with accessibilityLineForIndex: (buffer lines). */
+ if (!emacsframe) + if (!emacsframe)
+ return NSMakeRange (0, 0); + return NSMakeRange (0, 0);
+ +
+ struct window *w = XWINDOW (emacsframe->selected_window); + struct window *w = XWINDOW (emacsframe->selected_window);
+ if (!w || !w->current_matrix) + if (!w)
+ return NSMakeRange (0, 0); + return NSMakeRange (0, 0);
+ +
+ struct buffer *curbuf = XBUFFER (w->contents); + struct buffer *b = XBUFFER (w->contents);
+ if (!curbuf) + if (!b)
+ return NSMakeRange (0, 0); + return NSMakeRange (0, 0);
+ +
+ struct glyph_matrix *matrix = w->current_matrix; + /* Walk from buffer start to find line N. Use find_newline_no_quit
+ if (line < 0 || line >= matrix->nrows) + (from, frombyte, count, &bytepos) to avoid signaling. */
+ return NSMakeRange (0, 0); + 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; + /* Skip N newlines to reach line N. */
+ if (!row->enabled_p) + if (line > 0)
+ return NSMakeRange (0, 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 line_start = pos;
+ ptrdiff_t end = MATRIX_ROW_END_CHARPOS (row) - BUF_BEGV (curbuf);
+ if (start < 0) start = 0;
+ if (end < start) end = start;
+ +
+ 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 +- (NSRect)accessibilityFrameForRange:(NSRange)range