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 029edd4..f86c0b0 100644 --- a/patches/0001-ns-implement-AXBoundsForRange-for-macOS-Zoom-cursor-.patch +++ b/patches/0001-ns-implement-AXBoundsForRange-for-macOS-Zoom-cursor-.patch @@ -1,54 +1,52 @@ From: Martin Sukany -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. */ -+ NSAccessibilityPostNotification ( -+ view, NSAccessibilitySelectedTextChangedNotification); ++ /* 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