Compare commits

...

4 Commits

Author SHA1 Message Date
61b5b5daf1 patches: fix windowWillResize em-dash regression (iteration 3)
Patch 0007 bulk em-dash→triple-dash replacement accidentally changed
windowWillResize title format string and strstr search, introducing
a user-visible regression. Reverted those two lines to em-dash.
2026-02-28 22:45:05 +01:00
9d2b1da729 patches: fix all review blockers (iteration 2)
Fixes from Opus maintainer review:
1. [BLOCKER] Zoom code completely removed from ALL intermediate patches
   (0005-0007 no longer have UAZoom/overlayZoom at any commit point)
2. [BLOCKER] Unified cursor rect ivar: lastCursorRect (was split
   between lastZoomCursorRect and lastAccessibilityCursorRect)
3. [HIGH] Child frame static vars moved to EmacsView ivars
   (childFrameLastCandidate/Buffer/Modiff — no cross-frame interference)
4. [HIGH] intern_c_string replaced with Qbefore_string/Qafter_string
5. [MEDIUM] Zoom fallback gated by zoomCursorUpdated flag (no double call)
2026-02-28 22:39:57 +01:00
d9b4cbb87a patches: restructure per reviewer feedback
Major changes:
1. Zoom separated into standalone patch 0000
   - UAZoomChangeFocus in ns_draw_window_cursor
   - Fallback in ns_update_end for window-switch tracking
   - No overlayZoomActive (source of split/switch/move bug)

2. VoiceOver patches 0001-0008 are now Zoom-free
   - All UAZoom*, overlayZoom*, kUAZoomFocus references removed
   - lastAccessibilityCursorRect kept for VoiceOver bounds queries
   - Commit messages cleaned of Zoom references

3. README.txt and TESTING.txt rewritten for new structure

Addresses reviewer (Stéphane Marks) feedback:
- Keep Zoom patch separate from VoiceOver work
- Design discussion needed for non-Zoom patches
- Performance: ns-accessibility-enabled=nil for zero overhead
2026-02-28 22:28:35 +01:00
bbe683e752 patches: fix 3 blockers — duplicate functions, idx typo, doc cap
BLOCKER fixes:
1. Remove duplicate ns_ax_face_is_selected, ns_ax_selected_overlay_text,
   ns_ax_selected_child_frame_text definitions from patch 0002
   (now defined only in 0007/0008 where they belong)
2. Fix idx → point_idx in accessibilityInsertionPointLineNumber (0002)
3. Remove stale 100K cap reference from documentation (0006)

Architecture fix:
- ns_ax_selected_child_frame_text moved from 0007 to 0008
  (where it logically belongs)

Verified: all 8 patches apply cleanly on fresh emacs HEAD.
2026-02-28 22:00:10 +01:00
11 changed files with 490 additions and 1344 deletions

View File

@@ -0,0 +1,154 @@
From 085a2c40d1335819b7a0d43b67581cc7b547088f Mon Sep 17 00:00:00 2001
From: Martin Sukany <martin@sukany.cz>
Date: Sat, 28 Feb 2026 22:39:35 +0100
Subject: [PATCH] ns: integrate with macOS Zoom for cursor tracking
Inform macOS Zoom of the text cursor position so the zoomed viewport
follows keyboard focus in Emacs.
* src/nsterm.h (EmacsView): Add lastCursorRect, zoomCursorUpdated.
* src/nsterm.m (ns_draw_window_cursor): Store cursor rect in
lastCursorRect; call UAZoomChangeFocus with CG-space coordinates
when UAZoomEnabled returns true. Set zoomCursorUpdated flag.
(ns_update_end): Call UAZoomChangeFocus as fallback when cursor
was not physically redrawn in this cycle (e.g., after C-x o window
switch). Gated by zoomCursorUpdated to avoid double calls.
Coordinate conversion: EmacsView pixels (AppKit, flipped) ->
NSWindow -> NSScreen -> CGRect with y-flip for CoreGraphics
top-left origin. UAZoomEnabled returns false when Zoom is inactive,
so overhead is a single function call per redisplay cycle.
Tested on macOS 14 with Zoom enabled: cursor tracking works across
window splits, switches (C-x o), and normal navigation.
---
etc/NEWS | 8 +++++++
src/nsterm.h | 6 +++++
src/nsterm.m | 68 ++++++++++++++++++++++++++++++++++++++++++++++++++++
3 files changed, 82 insertions(+)
diff --git a/etc/NEWS b/etc/NEWS
index ef36df5..f10d17e 100644
--- a/etc/NEWS
+++ b/etc/NEWS
@@ -82,6 +82,14 @@ other directory on your system. You can also invoke the
* Changes in Emacs 31.1
++++
+** The macOS NS port now integrates with macOS Zoom.
+When macOS Zoom is enabled (System Settings, Accessibility, Zoom,
+Follow keyboard focus), Emacs informs Zoom of the text cursor position
+after every cursor redraw via 'UAZoomChangeFocus'. The zoomed viewport
+automatically tracks the insertion point across window splits and
+switches.
+
+++
** 'line-spacing' now supports specifying spacing above the line.
Previously, only spacing below the line could be specified. The user
diff --git a/src/nsterm.h b/src/nsterm.h
index 7c1ee4c..ea6e7ba 100644
--- a/src/nsterm.h
+++ b/src/nsterm.h
@@ -484,6 +484,12 @@ enum ns_return_frame_mode
@public
struct frame *emacsframe;
int scrollbarsNeedingUpdate;
+#ifdef NS_IMPL_COCOA
+ /* Cached cursor rect for macOS Zoom integration. Set by
+ ns_draw_window_cursor, used by ns_update_end fallback. */
+ NSRect lastCursorRect;
+ BOOL zoomCursorUpdated;
+#endif
NSRect ns_userRect;
}
diff --git a/src/nsterm.m b/src/nsterm.m
index 74e4ad5..cd721c8 100644
--- a/src/nsterm.m
+++ b/src/nsterm.m
@@ -1104,6 +1104,35 @@ static NSRect constrain_frame_rect(NSRect frameRect, bool isFullscreen)
unblock_input ();
ns_updating_frame = NULL;
+
+#ifdef NS_IMPL_COCOA
+ /* Zoom fallback: ensure Zoom tracks the cursor after window
+ switches (C-x o) where the physical cursor may not be redrawn.
+ Only fires when ns_draw_window_cursor did NOT run in this cycle
+ (zoomCursorUpdated is NO). */
+#if defined (MAC_OS_X_VERSION_MIN_REQUIRED) \
+ && MAC_OS_X_VERSION_MIN_REQUIRED >= 101000
+ if (view && !view->zoomCursorUpdated && UAZoomEnabled ()
+ && !NSIsEmptyRect (view->lastCursorRect))
+ {
+ NSRect r = view->lastCursorRect;
+ NSRect windowRect = [view convertRect:r toView:nil];
+ NSRect screenRect
+ = [[view window] convertRectToScreen:windowRect];
+ CGRect cgRect = NSRectToCGRect (screenRect);
+
+ CGFloat primaryH
+ = [[[NSScreen screens] firstObject] frame].size.height;
+ cgRect.origin.y
+ = primaryH - cgRect.origin.y - cgRect.size.height;
+
+ UAZoomChangeFocus (&cgRect, &cgRect,
+ kUAZoomFocusTypeInsertionPoint);
+ }
+ if (view)
+ view->zoomCursorUpdated = NO;
+#endif
+#endif /* NS_IMPL_COCOA */
}
static void
@@ -3232,6 +3261,45 @@ 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));
+#ifdef NS_IMPL_COCOA
+ /* Zoom integration: inform macOS Zoom of the cursor position.
+ Zoom (System Settings -> Accessibility -> Zoom) tracks a focus
+ element to keep the zoomed viewport centered on the cursor.
+
+ Coordinate conversion:
+ EmacsView pixels (AppKit, flipped, top-left origin)
+ -> NSWindow (convertRect:toView:nil)
+ -> NSScreen (convertRectToScreen:)
+ -> CGRect with y-flip for CoreGraphics top-left origin. */
+ {
+ EmacsView *view = FRAME_NS_VIEW (f);
+ if (view && on_p && active_p)
+ {
+ view->lastCursorRect = r;
+ view->zoomCursorUpdated = YES;
+
+#if defined (MAC_OS_X_VERSION_MIN_REQUIRED) \
+ && MAC_OS_X_VERSION_MIN_REQUIRED >= 101000
+ if (UAZoomEnabled ())
+ {
+ NSRect windowRect = [view convertRect:r toView:nil];
+ NSRect screenRect
+ = [[view window] convertRectToScreen:windowRect];
+ CGRect cgRect = NSRectToCGRect (screenRect);
+
+ CGFloat primaryH
+ = [[[NSScreen screens] firstObject] frame].size.height;
+ cgRect.origin.y
+ = primaryH - cgRect.origin.y - cgRect.size.height;
+
+ UAZoomChangeFocus (&cgRect, &cgRect,
+ kUAZoomFocusTypeInsertionPoint);
+ }
+#endif
+ }
+ }
+#endif /* NS_IMPL_COCOA */
+
ns_focus (f, NULL, 0);
NSGraphicsContext *ctx = [NSGraphicsContext currentContext];
--
2.43.0

View File

@@ -1,4 +1,4 @@
From d176c3c9d97574f0cd493d6491eda0a82ad28387 Mon Sep 17 00:00:00 2001 From cd6ad89e786fc79f68bc0843b8122e088e8766ba Mon Sep 17 00:00:00 2001
From: Martin Sukany <martin@sukany.cz> From: Martin Sukany <martin@sukany.cz>
Date: Sat, 28 Feb 2026 12:58:11 +0100 Date: Sat, 28 Feb 2026 12:58:11 +0100
Subject: [PATCH 1/8] ns: add accessibility base classes and text extraction Subject: [PATCH 1/8] ns: add accessibility base classes and text extraction
@@ -193,7 +193,7 @@ diff --git a/src/nsterm.m b/src/nsterm.m
index 74e4ad5..2ac1d9d 100644 index 74e4ad5..2ac1d9d 100644
--- a/src/nsterm.m --- a/src/nsterm.m
+++ b/src/nsterm.m +++ b/src/nsterm.m
@@ -46,6 +46,7 @@ GNUstep port and post-20 update by Adrian Robert (arobert@cogsci.ucsd.edu) @@ -46,6 +46,7 @@ Updated by Christian Limpach (chris@nice.ch)
#include "blockinput.h" #include "blockinput.h"
#include "sysselect.h" #include "sysselect.h"
#include "nsterm.h" #include "nsterm.h"
@@ -201,7 +201,7 @@ index 74e4ad5..2ac1d9d 100644
#include "systime.h" #include "systime.h"
#include "character.h" #include "character.h"
#include "xwidget.h" #include "xwidget.h"
@@ -6856,6 +6857,430 @@ ns_create_font_panel_buttons (id target, SEL select, SEL cancel_action) @@ -6856,6 +6857,430 @@ - (BOOL)fulfillService: (NSString *)name withArg: (NSString *)arg
} }
#endif #endif
@@ -632,7 +632,7 @@ index 74e4ad5..2ac1d9d 100644
/* ========================================================================== /* ==========================================================================
EmacsView implementation EmacsView implementation
@@ -11312,6 +11737,28 @@ syms_of_nsterm (void) @@ -11312,6 +11737,28 @@ Convert an X font name (XLFD) to an NS font name.
DEFSYM (Qns_drag_operation_generic, "ns-drag-operation-generic"); DEFSYM (Qns_drag_operation_generic, "ns-drag-operation-generic");
DEFSYM (Qns_handle_drag_motion, "ns-handle-drag-motion"); DEFSYM (Qns_handle_drag_motion, "ns-handle-drag-motion");
@@ -661,7 +661,7 @@ index 74e4ad5..2ac1d9d 100644
Fput (Qalt, Qmodifier_value, make_fixnum (alt_modifier)); Fput (Qalt, Qmodifier_value, make_fixnum (alt_modifier));
Fput (Qhyper, Qmodifier_value, make_fixnum (hyper_modifier)); Fput (Qhyper, Qmodifier_value, make_fixnum (hyper_modifier));
Fput (Qmeta, Qmodifier_value, make_fixnum (meta_modifier)); Fput (Qmeta, Qmodifier_value, make_fixnum (meta_modifier));
@@ -11460,6 +11907,15 @@ Note that this does not apply to images. @@ -11460,6 +11907,15 @@ Nil means use fullscreen the old (< 10.7) way. The old way works better with
This variable is ignored on Mac OS X < 10.7 and GNUstep. */); This variable is ignored on Mac OS X < 10.7 and GNUstep. */);
ns_use_srgb_colorspace = YES; ns_use_srgb_colorspace = YES;

View File

@@ -1,4 +1,4 @@
From 6f2e1b097c2ed1d2f45e99cf85792a1b28556202 Mon Sep 17 00:00:00 2001 From 68ce438269f04570f21e92bd2c49f2ff83244cb8 Mon Sep 17 00:00:00 2001
From: Martin Sukany <martin@sukany.cz> From: Martin Sukany <martin@sukany.cz>
Date: Sat, 28 Feb 2026 12:58:11 +0100 Date: Sat, 28 Feb 2026 12:58:11 +0100
Subject: [PATCH 2/8] ns: implement buffer accessibility element (core Subject: [PATCH 2/8] ns: implement buffer accessibility element (core
@@ -18,271 +18,14 @@ setAccessibilityFocused.
Tested on macOS 14 with VoiceOver. Verified: buffer reading, Tested on macOS 14 with VoiceOver. Verified: buffer reading,
line-by-line navigation, word/character announcements. line-by-line navigation, word/character announcements.
--- ---
src/nsterm.m | 1346 ++++++++++++++++++++++++++++++++++++++++++++++++++ src/nsterm.m | 1096 ++++++++++++++++++++++++++++++++++++++++++++++++++
1 file changed, 1346 insertions(+) 1 file changed, 1096 insertions(+)
diff --git a/src/nsterm.m b/src/nsterm.m diff --git a/src/nsterm.m b/src/nsterm.m
index 2ac1d9d..fc5906a 100644 index 2ac1d9d..1bcc84d 100644
--- a/src/nsterm.m --- a/src/nsterm.m
+++ b/src/nsterm.m +++ b/src/nsterm.m
@@ -6867,6 +6867,256 @@ ns_create_font_panel_buttons (id target, SEL select, SEL cancel_action) @@ -7278,6 +7278,1102 @@ - (id)accessibilityTopLevelUIElement
/* ---- Helper: extract buffer text for accessibility ---- */
+/* Return true if FACE is or contains a face symbol whose name
+ includes "current" or "selected", indicating a highlighted
+ completion candidate. Works for vertico-current,
+ icomplete-selected-match, ivy-current-match, etc. */
+static bool
+ns_ax_face_is_selected (Lisp_Object face)
+{
+ if (SYMBOLP (face) && !NILP (face))
+ {
+ const char *name = SSDATA (SYMBOL_NAME (face));
+ /* Substring match is intentionally broad --- it catches
+ vertico-current, icomplete-selected-match, ivy-current-match,
+ company-tooltip-selection, and similar. False positives are
+ harmless since this runs only on overlay strings during
+ completion. */
+ if (strstr (name, "current") || strstr (name, "selected")
+ || strstr (name, "selection"))
+ return true;
+ }
+ if (CONSP (face))
+ {
+ for (Lisp_Object tail = face; CONSP (tail); tail = XCDR (tail))
+ if (ns_ax_face_is_selected (XCAR (tail)))
+ return true;
+ }
+ return false;
+}
+
+/* Extract the currently selected candidate text from overlay display
+ strings. Completion frameworks render candidates as overlay
+ before-string/after-string and highlight the current candidate
+ with a face whose name contains "current" or "selected"
+ (e.g. vertico-current, icomplete-selected-match, ivy-current-match).
+
+ Scan all overlays in the buffer region [BEG, END), find the line
+ whose face matches the selection heuristic, and return it (already
+ trimmed of surrounding whitespace).
+
+ Also set *OUT_LINE_INDEX to the 0-based visual line index of the
+ selected candidate (for Zoom positioning), counting only non-trivial
+ lines. Set to -1 if not found.
+
+ Returns nil if no selected candidate is found. */
+static NSString *
+ns_ax_selected_overlay_text (struct buffer *b,
+ ptrdiff_t beg, ptrdiff_t end,
+ int *out_line_index)
+{
+ *out_line_index = -1;
+
+ Lisp_Object ov_list = Foverlays_in (make_fixnum (beg),
+ make_fixnum (end));
+
+ for (Lisp_Object tail = ov_list; CONSP (tail); tail = XCDR (tail))
+ {
+ Lisp_Object ov = XCAR (tail);
+ Lisp_Object strings[2];
+ strings[0] = Foverlay_get (ov, intern_c_string ("before-string"));
+ strings[1] = Foverlay_get (ov, intern_c_string ("after-string"));
+
+ for (int s = 0; s < 2; s++)
+ {
+ if (!STRINGP (strings[s]))
+ continue;
+
+ Lisp_Object str = strings[s];
+ ptrdiff_t slen = SCHARS (str);
+ if (slen == 0)
+ continue;
+
+ /* Scan for newline positions using SDATA for efficiency.
+ The data pointer is used only in this loop, before any
+ Lisp calls (Fget_text_property etc.) that could trigger
+ GC and relocate string data. */
+ const unsigned char *data = SDATA (str);
+ ptrdiff_t byte_len = SBYTES (str);
+ /* 512 lines is sufficient for any completion UI;
+ vertico-count defaults to 10. */
+ ptrdiff_t line_starts[512];
+ ptrdiff_t line_ends[512];
+ int nlines = 0;
+ ptrdiff_t char_pos = 0, byte_pos = 0, lstart = 0;
+
+ while (byte_pos < byte_len && nlines < 512)
+ {
+ if (data[byte_pos] == '\n')
+ {
+ if (char_pos > lstart)
+ {
+ line_starts[nlines] = lstart;
+ line_ends[nlines] = char_pos;
+ nlines++;
+ }
+ lstart = char_pos + 1;
+ }
+ if (STRING_MULTIBYTE (str))
+ byte_pos += BYTES_BY_CHAR_HEAD (data[byte_pos]);
+ else
+ byte_pos++;
+ char_pos++;
+ }
+ if (char_pos > lstart && nlines < 512)
+ {
+ line_starts[nlines] = lstart;
+ line_ends[nlines] = char_pos;
+ nlines++;
+ }
+
+ /* Find the line whose face indicates selection. Track
+ visual line index for Zoom (skip whitespace-only lines
+ like Vertico's leading cursor-space). */
+ int candidate_idx = 0;
+ for (int li = 0; li < nlines; li++)
+ {
+ Lisp_Object face
+ = Fget_text_property (make_fixnum (line_starts[li]),
+ Qface, str);
+ if (ns_ax_face_is_selected (face))
+ {
+ Lisp_Object line
+ = Fsubstring_no_properties (
+ str,
+ make_fixnum (line_starts[li]),
+ make_fixnum (line_ends[li]));
+ NSString *text = [NSString stringWithLispString:line];
+ text = [text stringByTrimmingCharactersInSet:
+ [NSCharacterSet
+ whitespaceAndNewlineCharacterSet]];
+ if ([text length] > 0)
+ {
+ *out_line_index = candidate_idx;
+ return text;
+ }
+ }
+
+ /* Count non-trivial lines as candidates for Zoom. */
+ if (line_ends[li] - line_starts[li] > 1)
+ candidate_idx++;
+ }
+ }
+ }
+
+ return nil;
+}
+
+
+/* Scan buffer text of a child frame for the selected completion
+ candidate. Used for frameworks that render candidates in a
+ child frame (e.g. Corfu, Company-box) rather than as overlay
+ strings. Check the effective face (text properties + overlays)
+ at the start of each line via Fget_char_property.
+
+ Returns the candidate text (trimmed) or nil. Sets
+ *OUT_LINE_INDEX to the 0-based line index for Zoom. */
+static NSString *
+ns_ax_selected_child_frame_text (struct buffer *b, Lisp_Object buf_obj,
+ int *out_line_index)
+{
+ *out_line_index = -1;
+ ptrdiff_t beg = BUF_BEGV (b);
+ ptrdiff_t end = BUF_ZV (b);
+
+ if (beg >= end)
+ return nil;
+
+ /* Temporarily switch to the child frame buffer.
+ Fbuffer_substring_no_properties operates on current_buffer,
+ which may be a different buffer (e.g., the parent frame's). */
+ specpdl_ref count = SPECPDL_INDEX ();
+ record_unwind_current_buffer ();
+ set_buffer_internal_1 (b);
+
+ /* Get buffer text as a Lisp string for efficient scanning.
+ The buffer is a small completion popup (typically < 20 lines). */
+ Lisp_Object str
+ = Fbuffer_substring_no_properties (make_fixnum (beg),
+ make_fixnum (end));
+ if (!STRINGP (str) || SCHARS (str) == 0)
+ {
+ unbind_to (count, Qnil);
+ return nil;
+ }
+
+ /* Scan newlines (same pattern as ns_ax_selected_overlay_text).
+ The data pointer is used only in this loop, before Lisp calls. */
+ const unsigned char *data = SDATA (str);
+ ptrdiff_t byte_len = SBYTES (str);
+ ptrdiff_t line_starts[128];
+ ptrdiff_t line_ends[128];
+ int nlines = 0;
+ ptrdiff_t char_pos = 0, byte_pos = 0, lstart = 0;
+
+ while (byte_pos < byte_len && nlines < 128)
+ {
+ if (data[byte_pos] == '\n')
+ {
+ if (char_pos > lstart)
+ {
+ line_starts[nlines] = lstart;
+ line_ends[nlines] = char_pos;
+ nlines++;
+ }
+ lstart = char_pos + 1;
+ }
+ if (STRING_MULTIBYTE (str))
+ byte_pos += BYTES_BY_CHAR_HEAD (data[byte_pos]);
+ else
+ byte_pos++;
+ char_pos++;
+ }
+ if (char_pos > lstart && nlines < 128)
+ {
+ line_starts[nlines] = lstart;
+ line_ends[nlines] = char_pos;
+ nlines++;
+ }
+
+ /* Find the line with a selected face. Use Fget_char_property on
+ the BUFFER (not the string) so overlay faces are included.
+ Offset string positions by beg to get buffer positions. */
+ for (int li = 0; li < nlines; li++)
+ {
+ ptrdiff_t buf_pos = beg + line_starts[li];
+ Lisp_Object face
+ = Fget_char_property (make_fixnum (buf_pos), Qface, buf_obj);
+
+ if (ns_ax_face_is_selected (face))
+ {
+ Lisp_Object line
+ = Fsubstring_no_properties (str,
+ make_fixnum (line_starts[li]),
+ make_fixnum (line_ends[li]));
+ NSString *text = [NSString stringWithLispString:line];
+ text = [text stringByTrimmingCharactersInSet:
+ [NSCharacterSet
+ whitespaceAndNewlineCharacterSet]];
+ if ([text length] > 0)
+ {
+ *out_line_index = li;
+ unbind_to (count, Qnil);
+ return text;
+ }
+ }
+ }
+
+ unbind_to (count, Qnil);
+ return nil;
+}
+
+
/* Build accessibility text for window W, skipping invisible text.
Populates *OUT_START with the buffer start charpos.
Populates *OUT_RUNS with an array of visible runs and *OUT_NRUNS
@@ -7278,6 +7528,1102 @@ ns_ax_post_notification_with_info (id element,
@end @end
@@ -1160,7 +903,7 @@ index 2ac1d9d..fc5906a 100644
+ if (point_idx > [cachedText length]) + if (point_idx > [cachedText length])
+ point_idx = [cachedText length]; + point_idx = [cachedText length];
+ +
+ return [self lineForAXIndex:idx]; + return [self lineForAXIndex:point_idx];
+} +}
+ +
+- (NSRange)accessibilityRangeForLine:(NSInteger)line +- (NSRange)accessibilityRangeForLine:(NSInteger)line

View File

@@ -1,4 +1,4 @@
From 97baf7b5f8b0ccc85342e7d552b69b337c98f772 Mon Sep 17 00:00:00 2001 From f5ce42e931a3ed1668e6fb8260ef736442d8d2c9 Mon Sep 17 00:00:00 2001
From: Martin Sukany <martin@sukany.cz> From: Martin Sukany <martin@sukany.cz>
Date: Sat, 28 Feb 2026 12:58:11 +0100 Date: Sat, 28 Feb 2026 12:58:11 +0100
Subject: [PATCH 3/8] ns: add buffer notification dispatch and mode-line Subject: [PATCH 3/8] ns: add buffer notification dispatch and mode-line
@@ -24,10 +24,10 @@ region selection feedback, completion popups, mode-line reading.
1 file changed, 545 insertions(+) 1 file changed, 545 insertions(+)
diff --git a/src/nsterm.m b/src/nsterm.m diff --git a/src/nsterm.m b/src/nsterm.m
index fc5906a..f1a1b42 100644 index 1bcc84d..dfb84ca 100644
--- a/src/nsterm.m --- a/src/nsterm.m
+++ b/src/nsterm.m +++ b/src/nsterm.m
@@ -8624,6 +8624,551 @@ ns_ax_completion_text_for_span (EmacsAccessibilityBuffer *elem, @@ -8374,6 +8374,551 @@ - (NSRect)accessibilityFrame
@end @end

View File

@@ -1,4 +1,4 @@
From 1bd12dd5d464d0c3f9774630014e434b8fb0e19e Mon Sep 17 00:00:00 2001 From 8675f0f75a33e4a3621e0b1e15aab7eff2c81369 Mon Sep 17 00:00:00 2001
From: Martin Sukany <martin@sukany.cz> From: Martin Sukany <martin@sukany.cz>
Date: Sat, 28 Feb 2026 12:58:11 +0100 Date: Sat, 28 Feb 2026 12:58:11 +0100
Subject: [PATCH 4/8] ns: add interactive span elements for Tab navigation Subject: [PATCH 4/8] ns: add interactive span elements for Tab navigation
@@ -17,10 +17,10 @@ Tested on macOS 14. Verified: Tab-cycling through org-mode links,
1 file changed, 286 insertions(+) 1 file changed, 286 insertions(+)
diff --git a/src/nsterm.m b/src/nsterm.m diff --git a/src/nsterm.m b/src/nsterm.m
index f1a1b42..91d0241 100644 index dfb84ca..c852929 100644
--- a/src/nsterm.m --- a/src/nsterm.m
+++ b/src/nsterm.m +++ b/src/nsterm.m
@@ -9169,6 +9169,292 @@ ns_ax_completion_text_for_span (EmacsAccessibilityBuffer *elem, @@ -8919,6 +8919,292 @@ - (NSRect)accessibilityFrame
@end @end

View File

@@ -1,13 +1,11 @@
From 3bbe8ba29725a4708595befa6b73e5873a2aab43 Mon Sep 17 00:00:00 2001 From 9e7fa018ef779610b2fb54c1ff951d0bf6bf7652 Mon Sep 17 00:00:00 2001
From: Martin Sukany <martin@sukany.cz> From: Martin Sukany <martin@sukany.cz>
Date: Sat, 28 Feb 2026 12:58:11 +0100 Date: Sat, 28 Feb 2026 12:58:11 +0100
Subject: [PATCH 5/8] ns: integrate accessibility with EmacsView and redisplay Subject: [PATCH 5/8] ns: integrate accessibility with EmacsView and redisplay
Wire the accessibility infrastructure into EmacsView and the Wire the accessibility infrastructure into EmacsView and the
redisplay cycle. After this patch, VoiceOver and Zoom are active.
* src/nsterm.m (ns_update_end): Call [view postAccessibilityUpdates]. * src/nsterm.m (ns_update_end): Call [view postAccessibilityUpdates].
(ns_draw_phys_cursor): Store cursor rect; call UAZoomChangeFocus.
(EmacsView dealloc): Release accessibilityElements. (EmacsView dealloc): Release accessibilityElements.
(EmacsView windowDidBecomeKey): Post accessibility focus notification. (EmacsView windowDidBecomeKey): Post accessibility focus notification.
(ns_ax_collect_windows): New function. (ns_ax_collect_windows): New function.
@@ -18,21 +16,22 @@ redisplay cycle. After this patch, VoiceOver and Zoom are active.
(accessibilityAttributeValue:forParameter:): New methods. (accessibilityAttributeValue:forParameter:): New methods.
* etc/NEWS: Document VoiceOver accessibility support. * etc/NEWS: Document VoiceOver accessibility support.
Tested on macOS 14 with VoiceOver and Zoom. End-to-end: buffer Tested on macOS 14 with VoiceOver. End-to-end: buffer
navigation, cursor tracking, window switching, completions, evil-mode navigation, cursor tracking, window switching, completions, evil-mode
block cursor, org-mode folded headings, indirect buffers. block cursor, org-mode folded headings, indirect buffers.
Known limitations documented in patch 6 Texinfo node. Known limitations documented in patch 6 Texinfo node.
--- ---
etc/NEWS | 13 ++ etc/NEWS | 13 ++
src/nsterm.m | 398 ++++++++++++++++++++++++++++++++++++++++++++++++++- src/nsterm.h | 2 +-
2 files changed, 408 insertions(+), 3 deletions(-) src/nsterm.m | 373 ++++++++++++++++++++++++++++++++++++++++++++++++++-
3 files changed, 384 insertions(+), 4 deletions(-)
diff --git a/etc/NEWS b/etc/NEWS diff --git a/etc/NEWS b/etc/NEWS
index 7367e3c..608650e 100644 index ef36df5..e76ee93 100644
--- a/etc/NEWS --- a/etc/NEWS
+++ b/etc/NEWS +++ b/etc/NEWS
@@ -4374,6 +4374,19 @@ allowing Emacs users access to speech recognition utilities. @@ -4389,6 +4389,19 @@ allowing Emacs users access to speech recognition utilities.
Note: Accepting this permission allows the use of system APIs, which may Note: Accepting this permission allows the use of system APIs, which may
send user data to Apple's speech recognition servers. send user data to Apple's speech recognition servers.
@@ -52,11 +51,24 @@ index 7367e3c..608650e 100644
--- ---
** Re-introduced dictation, lost in Emacs v30 (macOS). ** Re-introduced dictation, lost in Emacs v30 (macOS).
We lost macOS dictation in v30 when migrating to NSTextInputClient. We lost macOS dictation in v30 when migrating to NSTextInputClient.
diff --git a/src/nsterm.h b/src/nsterm.h
index 5298386..ec7b587 100644
--- a/src/nsterm.h
+++ b/src/nsterm.h
@@ -594,7 +594,7 @@ typedef NS_ENUM (NSInteger, EmacsAXSpanType)
BOOL accessibilityTreeValid;
BOOL accessibilityUpdating;
@public /* Accessed by ns_draw_phys_cursor (C function). */
- NSRect lastAccessibilityCursorRect;
+ NSRect lastCursorRect;
#endif
BOOL font_panel_active;
NSFont *font_panel_result;
diff --git a/src/nsterm.m b/src/nsterm.m diff --git a/src/nsterm.m b/src/nsterm.m
index 91d0241..125e52c 100644 index c852929..f0e8751 100644
--- a/src/nsterm.m --- a/src/nsterm.m
+++ b/src/nsterm.m +++ b/src/nsterm.m
@@ -1105,6 +1105,11 @@ ns_update_end (struct frame *f) @@ -1105,6 +1105,11 @@ static NSRect constrain_frame_rect(NSRect frameRect, bool isFullscreen)
unblock_input (); unblock_input ();
ns_updating_frame = NULL; ns_updating_frame = NULL;
@@ -68,51 +80,26 @@ index 91d0241..125e52c 100644
} }
static void static void
@@ -3233,6 +3238,43 @@ ns_draw_window_cursor (struct window *w, struct glyph_row *glyph_row, @@ -3233,6 +3238,18 @@ 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));
+#ifdef NS_IMPL_COCOA +#ifdef NS_IMPL_COCOA
+ /* Accessibility: store cursor rect for Zoom and bounds queries. + /* Accessibility: store cursor rect for VoiceOver bounds queries.
+ Skipped when ns-accessibility-enabled is nil to avoid overhead. + accessibilityBoundsForRange: / accessibilityFrameForRange:
+ VoiceOver notifications are handled solely by + use this as a fallback when no valid window/glyph data is
+ postAccessibilityUpdates (called from ns_update_end) + available. Skipped when ns-accessibility-enabled is nil. */
+ to avoid duplicate notifications and mid-redisplay fragility. */
+ { + {
+ EmacsView *view = FRAME_NS_VIEW (f); + EmacsView *view = FRAME_NS_VIEW (f);
+ if (view && on_p && active_p && ns_accessibility_enabled) + if (view && on_p && active_p && ns_accessibility_enabled)
+ { + view->lastCursorRect = r;
+ view->lastAccessibilityCursorRect = r;
+
+ /* Tell macOS Zoom where the cursor is. UAZoomChangeFocus()
+ expects top-left origin (CG coordinate space).
+ These APIs are available since macOS 10.4 (Universal Access
+ framework, linked via ApplicationServices umbrella). */
+#if defined (MAC_OS_X_VERSION_MIN_REQUIRED) \
+ && MAC_OS_X_VERSION_MIN_REQUIRED >= 101000
+ if (UAZoomEnabled ())
+ {
+ NSRect windowRect = [view convertRect:r toView:nil];
+ NSRect screenRect = [[view window] convertRectToScreen:windowRect];
+ CGRect cgRect = NSRectToCGRect (screenRect);
+
+ CGFloat primaryH
+ = [[[NSScreen screens] firstObject] frame].size.height;
+ cgRect.origin.y
+ = primaryH - cgRect.origin.y - cgRect.size.height;
+
+ UAZoomChangeFocus (&cgRect, &cgRect,
+ kUAZoomFocusTypeInsertionPoint);
+ }
+#endif /* MAC_OS_X_VERSION_MIN_REQUIRED >= 101000 */
+ }
+ } + }
+#endif +#endif
+ +
ns_focus (f, NULL, 0); ns_focus (f, NULL, 0);
NSGraphicsContext *ctx = [NSGraphicsContext currentContext]; NSGraphicsContext *ctx = [NSGraphicsContext currentContext];
@@ -7531,7 +7573,6 @@ ns_ax_post_notification_with_info (id element, @@ -7281,7 +7298,6 @@ - (id)accessibilityTopLevelUIElement
@@ -120,7 +107,7 @@ index 91d0241..125e52c 100644
static BOOL static BOOL
ns_ax_find_completion_overlay_range (struct buffer *b, ptrdiff_t point, ns_ax_find_completion_overlay_range (struct buffer *b, ptrdiff_t point,
ptrdiff_t *out_start, ptrdiff_t *out_start,
@@ -8625,7 +8666,6 @@ ns_ax_completion_text_for_span (EmacsAccessibilityBuffer *elem, @@ -8375,7 +8391,6 @@ - (NSRect)accessibilityFrame
@end @end
@@ -128,7 +115,7 @@ index 91d0241..125e52c 100644
/* =================================================================== /* ===================================================================
EmacsAccessibilityBuffer (Notifications) — AX event dispatch EmacsAccessibilityBuffer (Notifications) — AX event dispatch
@@ -9170,7 +9210,6 @@ ns_ax_completion_text_for_span (EmacsAccessibilityBuffer *elem, @@ -8920,7 +8935,6 @@ - (NSRect)accessibilityFrame
@end @end
@@ -136,7 +123,7 @@ index 91d0241..125e52c 100644
/* =================================================================== /* ===================================================================
EmacsAccessibilityInteractiveSpan — helpers and implementation EmacsAccessibilityInteractiveSpan — helpers and implementation
=================================================================== */ =================================================================== */
@@ -9500,6 +9539,7 @@ ns_ax_scan_interactive_spans (struct window *w, @@ -9250,6 +9264,7 @@ - (void)dealloc
[layer release]; [layer release];
#endif #endif
@@ -144,7 +131,7 @@ index 91d0241..125e52c 100644
[[self menu] release]; [[self menu] release];
[super dealloc]; [super dealloc];
} }
@@ -10848,6 +10888,32 @@ ns_in_echo_area (void) @@ -10598,6 +10613,32 @@ - (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
@@ -177,7 +164,7 @@ index 91d0241..125e52c 100644
} }
@@ -12085,6 +12151,332 @@ ns_in_echo_area (void) @@ -11835,6 +11876,332 @@ - (int) fullscreenState
return fs_state; return fs_state;
} }
@@ -438,7 +425,7 @@ index 91d0241..125e52c 100644
+ return bufRect; + return bufRect;
+ } + }
+ +
+ NSRect viewRect = lastAccessibilityCursorRect; + NSRect viewRect = lastCursorRect;
+ +
+ if (viewRect.size.width < 1) + if (viewRect.size.width < 1)
+ viewRect.size.width = 1; + viewRect.size.width = 1;

View File

@@ -1,4 +1,4 @@
From 5ddf6227b581bf292fc187a1ebcaf80d2cd4cf2a Mon Sep 17 00:00:00 2001 From 683d7497cc3414a231b44363dd28d2748780c38a Mon Sep 17 00:00:00 2001
From: Martin Sukany <martin@sukany.cz> From: Martin Sukany <martin@sukany.cz>
Date: Sat, 28 Feb 2026 12:58:11 +0100 Date: Sat, 28 Feb 2026 12:58:11 +0100
Subject: [PATCH 6/8] doc: add VoiceOver accessibility section to macOS Subject: [PATCH 6/8] doc: add VoiceOver accessibility section to macOS
@@ -6,13 +6,12 @@ Subject: [PATCH 6/8] doc: add VoiceOver accessibility section to macOS
* doc/emacs/macos.texi (VoiceOver Accessibility): New node. Document * doc/emacs/macos.texi (VoiceOver Accessibility): New node. Document
screen reader usage, keyboard navigation, completion announcements, screen reader usage, keyboard navigation, completion announcements,
Zoom cursor tracking, ns-accessibility-enabled, known limitations.
--- ---
doc/emacs/macos.texi | 75 ++++++++++++++++++++++++++++++++++++++++++++ doc/emacs/macos.texi | 75 ++++++++++++++++++++++++++++++++++++++++++++
1 file changed, 75 insertions(+) 1 file changed, 75 insertions(+)
diff --git a/doc/emacs/macos.texi b/doc/emacs/macos.texi diff --git a/doc/emacs/macos.texi b/doc/emacs/macos.texi
index 6bd334f..c4dced5 100644 index 6bd334f..4825cf9 100644
--- a/doc/emacs/macos.texi --- a/doc/emacs/macos.texi
+++ b/doc/emacs/macos.texi +++ b/doc/emacs/macos.texi
@@ -36,6 +36,7 @@ Support}), but we hope to improve it in the future. @@ -36,6 +36,7 @@ Support}), but we hope to improve it in the future.
@@ -78,9 +77,9 @@ index 6bd334f..c4dced5 100644
+ +
+@itemize @bullet +@itemize @bullet
+@item +@item
+Accessibility text is capped at 100,000 UTF-16 units per window. +Very large buffers (tens of megabytes) may cause slow initial
+Buffers exceeding this limit are truncated for accessibility purposes; +accessibility text extraction. Once cached, subsequent queries
+VoiceOver will announce ``end of text'' at the cap boundary. +are fast.
+@item +@item
+Mode-line text extraction handles only character glyphs. Mode lines +Mode-line text extraction handles only character glyphs. Mode lines
+using icon fonts (e.g., @code{doom-modeline} with nerd-font icons) +using icon fonts (e.g., @code{doom-modeline} with nerd-font icons)

View File

@@ -1,4 +1,4 @@
From 8f619411ec75efbd18e663bb3f2ed6f8c9af60d8 Mon Sep 17 00:00:00 2001 From 8561d7b8a00b9a7772c718f86fdde770e1c73d41 Mon Sep 17 00:00:00 2001
From: Martin Sukany <martin@sukany.cz> From: Martin Sukany <martin@sukany.cz>
Date: Sat, 28 Feb 2026 14:46:25 +0100 Date: Sat, 28 Feb 2026 14:46:25 +0100
Subject: [PATCH 7/8] ns: announce overlay completion candidates for VoiceOver Subject: [PATCH 7/8] ns: announce overlay completion candidates for VoiceOver
@@ -32,31 +32,24 @@ Key implementation details:
Do not post SelectedTextChanged (that reads the AX text at cursor Do not post SelectedTextChanged (that reads the AX text at cursor
position, which is the minibuffer input, not the candidate). position, which is the minibuffer input, not the candidate).
- Zoom tracking: store the selected candidate's rect (at the text
area left edge, computed from FRAME_LINE_HEIGHT) in overlayZoomRect.
ns_draw_window_cursor checks overlayZoomActive and uses the stored
rect instead of the text cursor rect, keeping Zoom focused on the
candidate line start. The flag is cleared when the user types candidate line start. The flag is cleared when the user types
(BUF_CHARS_MODIFF changes) or when no candidate is found (BUF_CHARS_MODIFF changes) or when no candidate is found
(minibuffer exit, C-g). (minibuffer exit, C-g).
* src/nsterm.h (EmacsView): Add overlayZoomActive, overlayZoomRect.
(EmacsAccessibilityBuffer): Add cachedCharsModiff. (EmacsAccessibilityBuffer): Add cachedCharsModiff.
* src/nsterm.m (ns_ax_face_is_selected): New predicate. Match * src/nsterm.m (ns_ax_face_is_selected): New predicate. Match
"current", "selected", and "selection" in face symbol names. "current", "selected", and "selection" in face symbol names.
(ns_ax_selected_overlay_text): New function. (ns_ax_selected_overlay_text): New function.
(ns_draw_window_cursor): Use overlayZoomRect when active.
(EmacsAccessibilityBuffer ensureTextCache): Remove overlay_modiff. (EmacsAccessibilityBuffer ensureTextCache): Remove overlay_modiff.
(EmacsAccessibilityBuffer postAccessibilityNotificationsForFrame:): (EmacsAccessibilityBuffer postAccessibilityNotificationsForFrame:):
Independent overlay branch, BUF_CHARS_MODIFF gating, candidate Independent overlay branch, BUF_CHARS_MODIFF gating, candidate
announcement with overlay Zoom rect storage.
--- ---
src/nsterm.h | 3 + src/nsterm.h | 1 +
src/nsterm.m | 361 ++++++++++++++++++++++++++++++++++++++++++++++----- src/nsterm.m | 308 +++++++++++++++++++++++++++++++++++++++++++++------
2 files changed, 329 insertions(+), 35 deletions(-) 2 files changed, 277 insertions(+), 32 deletions(-)
diff --git a/src/nsterm.h b/src/nsterm.h diff --git a/src/nsterm.h b/src/nsterm.h
index 5298386..a007925 100644 index ec7b587..19a7e7a 100644
--- a/src/nsterm.h --- a/src/nsterm.h
+++ b/src/nsterm.h +++ b/src/nsterm.h
@@ -509,6 +509,7 @@ typedef struct ns_ax_visible_run @@ -509,6 +509,7 @@ typedef struct ns_ax_visible_run
@@ -67,36 +60,13 @@ index 5298386..a007925 100644
@property (nonatomic, assign) ptrdiff_t cachedPoint; @property (nonatomic, assign) ptrdiff_t cachedPoint;
@property (nonatomic, assign) BOOL cachedMarkActive; @property (nonatomic, assign) BOOL cachedMarkActive;
@property (nonatomic, copy) NSString *cachedCompletionAnnouncement; @property (nonatomic, copy) NSString *cachedCompletionAnnouncement;
@@ -595,6 +596,8 @@ typedef NS_ENUM (NSInteger, EmacsAXSpanType)
BOOL accessibilityUpdating;
@public /* Accessed by ns_draw_phys_cursor (C function). */
NSRect lastAccessibilityCursorRect;
+ BOOL overlayZoomActive;
+ NSRect overlayZoomRect;
#endif
BOOL font_panel_active;
NSFont *font_panel_result;
diff --git a/src/nsterm.m b/src/nsterm.m diff --git a/src/nsterm.m b/src/nsterm.m
index 125e52c..ebd52c6 100644 index f0e8751..72478e0 100644
--- a/src/nsterm.m --- a/src/nsterm.m
+++ b/src/nsterm.m +++ b/src/nsterm.m
@@ -3258,7 +3258,12 @@ ns_draw_window_cursor (struct window *w, struct glyph_row *glyph_row, @@ -6884,11 +6884,154 @@ Accessibility virtual elements (macOS / Cocoa only)
&& MAC_OS_X_VERSION_MIN_REQUIRED >= 101000
if (UAZoomEnabled ())
{
- NSRect windowRect = [view convertRect:r toView:nil];
+ /* When overlay completion is active (e.g. Vertico),
+ focus Zoom on the selected candidate row instead
+ of the text cursor. */
+ NSRect zoomSrc = view->overlayZoomActive
+ ? view->overlayZoomRect : r;
+ NSRect windowRect = [view convertRect:zoomSrc toView:nil];
NSRect screenRect = [[view window] convertRectToScreen:windowRect];
CGRect cgRect = NSRectToCGRect (screenRect);
@@ -7159,11 +7164,156 @@ ns_ax_selected_child_frame_text (struct buffer *b, Lisp_Object buf_obj,
}
/* ---- Helper: extract buffer text for accessibility ---- */
+/* Return true if FACE is or contains a face symbol whose name +/* Return true if FACE is or contains a face symbol whose name
+ includes "current" or "selected", indicating a highlighted + includes "current" or "selected", indicating a highlighted
@@ -155,8 +125,8 @@ index 125e52c..ebd52c6 100644
+ { + {
+ Lisp_Object ov = XCAR (tail); + Lisp_Object ov = XCAR (tail);
+ Lisp_Object strings[2]; + Lisp_Object strings[2];
+ strings[0] = Foverlay_get (ov, intern_c_string ("before-string")); + strings[0] = Foverlay_get (ov, Qbefore_string);
+ strings[1] = Foverlay_get (ov, intern_c_string ("after-string")); + strings[1] = Foverlay_get (ov, Qafter_string);
+ +
+ for (int s = 0; s < 2; s++) + for (int s = 0; s < 2; s++)
+ { + {
@@ -242,8 +212,6 @@ index 125e52c..ebd52c6 100644
+ +
+ return nil; + return nil;
+} +}
+
+
/* Build accessibility text for window W, skipping invisible text. /* Build accessibility text for window W, skipping invisible text.
Populates *OUT_START with the buffer start charpos. Populates *OUT_START with the buffer start charpos.
Populates *OUT_RUNS with an array of visible runs and *OUT_NRUNS Populates *OUT_RUNS with an array of visible runs and *OUT_NRUNS
@@ -252,7 +220,7 @@ index 125e52c..ebd52c6 100644
static NSString * static NSString *
ns_ax_buffer_text (struct window *w, ptrdiff_t *out_start, ns_ax_buffer_text (struct window *w, ptrdiff_t *out_start,
ns_ax_visible_run **out_runs, NSUInteger *out_nruns) ns_ax_visible_run **out_runs, NSUInteger *out_nruns)
@@ -7234,7 +7384,7 @@ ns_ax_buffer_text (struct window *w, ptrdiff_t *out_start, @@ -6959,7 +7102,7 @@ Accessibility virtual elements (macOS / Cocoa only)
/* Extract this visible run's text. Use /* Extract this visible run's text. Use
Fbuffer_substring_no_properties which correctly handles the Fbuffer_substring_no_properties which correctly handles the
@@ -261,7 +229,7 @@ index 125e52c..ebd52c6 100644
include garbage bytes when the run spans the gap position. */ include garbage bytes when the run spans the gap position. */
Lisp_Object lstr = Fbuffer_substring_no_properties ( Lisp_Object lstr = Fbuffer_substring_no_properties (
make_fixnum (pos), make_fixnum (run_end)); make_fixnum (pos), make_fixnum (run_end));
@@ -7315,7 +7465,7 @@ ns_ax_frame_for_range (struct window *w, EmacsView *view, @@ -7040,7 +7183,7 @@ Mode lines using icon fonts (e.g. doom-modeline with nerd-font)
return NSZeroRect; return NSZeroRect;
/* charpos_start and charpos_len are already in buffer charpos /* charpos_start and charpos_len are already in buffer charpos
@@ -270,7 +238,7 @@ index 125e52c..ebd52c6 100644
charposForAccessibilityIndex which handles invisible text. */ charposForAccessibilityIndex which handles invisible text. */
ptrdiff_t cp_start = charpos_start; ptrdiff_t cp_start = charpos_start;
ptrdiff_t cp_end = cp_start + charpos_len; ptrdiff_t cp_end = cp_start + charpos_len;
@@ -7794,6 +7944,7 @@ ns_ax_completion_text_for_span (EmacsAccessibilityBuffer *elem, @@ -7519,6 +7662,7 @@ @implementation EmacsAccessibilityBuffer
@synthesize cachedOverlayModiff; @synthesize cachedOverlayModiff;
@synthesize cachedTextStart; @synthesize cachedTextStart;
@synthesize cachedModiff; @synthesize cachedModiff;
@@ -278,7 +246,7 @@ index 125e52c..ebd52c6 100644
@synthesize cachedPoint; @synthesize cachedPoint;
@synthesize cachedMarkActive; @synthesize cachedMarkActive;
@synthesize cachedCompletionAnnouncement; @synthesize cachedCompletionAnnouncement;
@@ -7891,7 +8042,7 @@ ns_ax_completion_text_for_span (EmacsAccessibilityBuffer *elem, @@ -7616,7 +7760,7 @@ - (void)ensureTextCache
NSTRACE ("EmacsAccessibilityBuffer ensureTextCache"); NSTRACE ("EmacsAccessibilityBuffer ensureTextCache");
/* This method is only called from the main thread (AX getters /* This method is only called from the main thread (AX getters
dispatch_sync to main first). Reads of cachedText/cachedTextModiff dispatch_sync to main first). Reads of cachedText/cachedTextModiff
@@ -287,7 +255,7 @@ index 125e52c..ebd52c6 100644
write section at the end needs synchronization to protect write section at the end needs synchronization to protect
against concurrent reads from AX server thread. */ against concurrent reads from AX server thread. */
eassert ([NSThread isMainThread]); eassert ([NSThread isMainThread]);
@@ -7904,16 +8055,15 @@ ns_ax_completion_text_for_span (EmacsAccessibilityBuffer *elem, @@ -7629,16 +7773,15 @@ - (void)ensureTextCache
return; return;
ptrdiff_t modiff = BUF_MODIFF (b); ptrdiff_t modiff = BUF_MODIFF (b);
@@ -310,7 +278,7 @@ index 125e52c..ebd52c6 100644
&& cachedTextStart == BUF_BEGV (b) && cachedTextStart == BUF_BEGV (b)
&& pt >= cachedTextStart && pt >= cachedTextStart
&& (textLen == 0 && (textLen == 0
@@ -7930,7 +8080,6 @@ ns_ax_completion_text_for_span (EmacsAccessibilityBuffer *elem, @@ -7655,7 +7798,6 @@ - (void)ensureTextCache
[cachedText release]; [cachedText release];
cachedText = [text retain]; cachedText = [text retain];
cachedTextModiff = modiff; cachedTextModiff = modiff;
@@ -318,7 +286,7 @@ index 125e52c..ebd52c6 100644
cachedTextStart = start; cachedTextStart = start;
if (visibleRuns) if (visibleRuns)
@@ -7995,7 +8144,7 @@ ns_ax_completion_text_for_span (EmacsAccessibilityBuffer *elem, @@ -7720,7 +7862,7 @@ - (NSUInteger)accessibilityIndexForCharpos:(ptrdiff_t)charpos
/* Binary search: runs are sorted by charpos (ascending). Find the /* Binary search: runs are sorted by charpos (ascending). Find the
run whose [charpos, charpos+length) range contains the target, run whose [charpos, charpos+length) range contains the target,
or the nearest run after an invisible gap. O(log n) instead of or the nearest run after an invisible gap. O(log n) instead of
@@ -327,7 +295,7 @@ index 125e52c..ebd52c6 100644
NSUInteger lo = 0, hi = visibleRunCount; NSUInteger lo = 0, hi = visibleRunCount;
while (lo < hi) while (lo < hi)
{ {
@@ -8008,7 +8157,7 @@ ns_ax_completion_text_for_span (EmacsAccessibilityBuffer *elem, @@ -7733,7 +7875,7 @@ - (NSUInteger)accessibilityIndexForCharpos:(ptrdiff_t)charpos
else else
{ {
/* Found: charpos is inside this run. Compute UTF-16 delta /* Found: charpos is inside this run. Compute UTF-16 delta
@@ -336,7 +304,7 @@ index 125e52c..ebd52c6 100644
NSUInteger chars_in = (NSUInteger)(charpos - r->charpos); NSUInteger chars_in = (NSUInteger)(charpos - r->charpos);
if (chars_in == 0 || !cachedText) if (chars_in == 0 || !cachedText)
return r->ax_start; return r->ax_start;
@@ -8033,10 +8182,10 @@ ns_ax_completion_text_for_span (EmacsAccessibilityBuffer *elem, @@ -7758,10 +7900,10 @@ - (NSUInteger)accessibilityIndexForCharpos:(ptrdiff_t)charpos
/* Convert accessibility string index to buffer charpos. /* Convert accessibility string index to buffer charpos.
Safe to call from any thread: uses only cachedText (NSString) and Safe to call from any thread: uses only cachedText (NSString) and
@@ -349,7 +317,7 @@ index 125e52c..ebd52c6 100644
@synchronized (self) @synchronized (self)
{ {
if (visibleRunCount == 0) if (visibleRunCount == 0)
@@ -8070,7 +8219,7 @@ ns_ax_completion_text_for_span (EmacsAccessibilityBuffer *elem, @@ -7795,7 +7937,7 @@ - (ptrdiff_t)charposForAccessibilityIndex:(NSUInteger)ax_idx
return cp; return cp;
} }
} }
@@ -358,7 +326,7 @@ index 125e52c..ebd52c6 100644
if (lo > 0) if (lo > 0)
{ {
ns_ax_visible_run *last = &visibleRuns[visibleRunCount - 1]; ns_ax_visible_run *last = &visibleRuns[visibleRunCount - 1];
@@ -8092,7 +8241,7 @@ ns_ax_completion_text_for_span (EmacsAccessibilityBuffer *elem, @@ -7817,7 +7959,7 @@ - (ptrdiff_t)charposForAccessibilityIndex:(NSUInteger)ax_idx
deadlocking the AX server thread. This is prevented by: deadlocking the AX server thread. This is prevented by:
1. validWindow checks WINDOW_LIVE_P and BUFFERP before every 1. validWindow checks WINDOW_LIVE_P and BUFFERP before every
@@ -367,13 +335,10 @@ index 125e52c..ebd52c6 100644
2. All dispatch_sync blocks run on the main thread where no 2. All dispatch_sync blocks run on the main thread where no
concurrent Lisp code can modify state between checks. concurrent Lisp code can modify state between checks.
3. block_input prevents timer events and process output from 3. block_input prevents timer events and process output from
@@ -8443,7 +8592,51 @@ ns_ax_completion_text_for_span (EmacsAccessibilityBuffer *elem, @@ -8171,6 +8313,50 @@ - (NSInteger)accessibilityInsertionPointLineNumber
if (point_idx > [cachedText length]) return [self lineForAXIndex:point_idx];
point_idx = [cachedText length]; }
+ return [self lineForAXIndex:point_idx];
+}
+
+- (NSString *)accessibilityStringForRange:(NSRange)range +- (NSString *)accessibilityStringForRange:(NSRange)range
+{ +{
+ if (![NSThread isMainThread]) + if (![NSThread isMainThread])
@@ -414,12 +379,14 @@ index 125e52c..ebd52c6 100644
+ if (idx > [cachedText length]) + if (idx > [cachedText length])
+ idx = [cachedText length]; + idx = [cachedText length];
+ +
return [self lineForAXIndex:idx]; + return [self lineForAXIndex:idx];
+
+}
+ +
}
- (NSRange)accessibilityRangeForLine:(NSInteger)line - (NSRange)accessibilityRangeForLine:(NSInteger)line
@@ -8667,7 +8860,7 @@ ns_ax_completion_text_for_span (EmacsAccessibilityBuffer *elem, {
if (![NSThread isMainThread])
@@ -8392,7 +8578,7 @@ - (NSRect)accessibilityFrame
/* =================================================================== /* ===================================================================
@@ -428,7 +395,7 @@ index 125e52c..ebd52c6 100644
These methods notify VoiceOver of text and selection changes. These methods notify VoiceOver of text and selection changes.
Called from the redisplay cycle (postAccessibilityUpdates). Called from the redisplay cycle (postAccessibilityUpdates).
@@ -8682,7 +8875,7 @@ ns_ax_completion_text_for_span (EmacsAccessibilityBuffer *elem, @@ -8407,7 +8593,7 @@ - (void)postTextChangedNotification:(ptrdiff_t)point
if (point > self.cachedPoint if (point > self.cachedPoint
&& point - self.cachedPoint == 1) && point - self.cachedPoint == 1)
{ {
@@ -437,7 +404,7 @@ index 125e52c..ebd52c6 100644
[self invalidateTextCache]; [self invalidateTextCache];
[self ensureTextCache]; [self ensureTextCache];
if (cachedText) if (cachedText)
@@ -8701,7 +8894,7 @@ ns_ax_completion_text_for_span (EmacsAccessibilityBuffer *elem, @@ -8426,7 +8612,7 @@ - (void)postTextChangedNotification:(ptrdiff_t)point
/* Update cachedPoint here so the selection-move branch does NOT /* Update cachedPoint here so the selection-move branch does NOT
fire for point changes caused by edits. WebKit and Chromium fire for point changes caused by edits. WebKit and Chromium
never send both ValueChanged and SelectedTextChanged for the never send both ValueChanged and SelectedTextChanged for the
@@ -446,7 +413,7 @@ index 125e52c..ebd52c6 100644
self.cachedPoint = point; self.cachedPoint = point;
NSDictionary *change = @{ NSDictionary *change = @{
@@ -9034,14 +9227,112 @@ ns_ax_completion_text_for_span (EmacsAccessibilityBuffer *elem, @@ -8759,14 +8945,72 @@ - (void)postAccessibilityNotificationsForFrame:(struct frame *)f
BOOL markActive = !NILP (BVAR (b, mark_active)); BOOL markActive = !NILP (BVAR (b, mark_active));
/* --- Text changed (edit) --- */ /* --- Text changed (edit) --- */
@@ -464,7 +431,6 @@ index 125e52c..ebd52c6 100644
+ if (chars_modiff != self.cachedCharsModiff) + if (chars_modiff != self.cachedCharsModiff)
+ { + {
+ self.cachedCharsModiff = chars_modiff; + self.cachedCharsModiff = chars_modiff;
+ self.emacsView->overlayZoomActive = NO;
+ [self postTextChangedNotification:point]; + [self postTextChangedNotification:point];
+ } + }
+ } + }
@@ -512,46 +478,7 @@ index 125e52c..ebd52c6 100644
+ NSAccessibilityAnnouncementRequestedNotification, + NSAccessibilityAnnouncementRequestedNotification,
+ annInfo); + annInfo);
+ +
+ /* --- Zoom tracking for overlay candidates ---
+ Store the candidate row rect so draw_window_cursor
+ focuses Zoom there instead of on the text cursor.
+ Cleared when the user types (chars_modiff change).
+
+ Use default line height to compute the Y offset:
+ row 0 is the input line, overlay candidates start
+ from row 1. This avoids fragile glyph matrix row
+ index mapping which can be off when group titles
+ or wrapped lines shift row numbering. */
+ if (selected_line >= 0)
+ {
+ struct window *w2 = [self validWindow];
+ if (w2)
+ {
+ EmacsView *view = self.emacsView;
+ struct frame *f2 = XFRAME (w2->frame);
+ int line_h = FRAME_LINE_HEIGHT (f2);
+ int y_off = (selected_line + 1) * line_h;
+
+ if (y_off < w2->pixel_height)
+ {
+ view->overlayZoomRect = NSMakeRect (
+ WINDOW_TEXT_TO_FRAME_PIXEL_X (w2, 0),
+ WINDOW_TO_FRAME_PIXEL_Y (w2, y_off),
+ FRAME_COLUMN_WIDTH (f2),
+ line_h);
+ view->overlayZoomActive = YES;
+ } + }
+ }
+ }
+ }
+ }
+ else
+ {
+ /* No selected candidate --- overlay completion ended
+ (minibuffer exit, C-g, etc.) or overlay has no
+ recognizable selection face. Return Zoom to the
+ text cursor. */
+ self.emacsView->overlayZoomActive = NO;
+ } + }
} }
@@ -561,7 +488,7 @@ index 125e52c..ebd52c6 100644
per the WebKit/Chromium pattern. */ per the WebKit/Chromium pattern. */
else if (point != self.cachedPoint || markActive != self.cachedMarkActive) else if (point != self.cachedPoint || markActive != self.cachedMarkActive)
{ {
@@ -9211,7 +9502,7 @@ ns_ax_completion_text_for_span (EmacsAccessibilityBuffer *elem, @@ -8936,7 +9180,7 @@ - (NSRect)accessibilityFrame
/* =================================================================== /* ===================================================================
@@ -570,7 +497,7 @@ index 125e52c..ebd52c6 100644
=================================================================== */ =================================================================== */
/* Scan visible range of window W for interactive spans. /* Scan visible range of window W for interactive spans.
@@ -9402,7 +9693,7 @@ ns_ax_scan_interactive_spans (struct window *w, @@ -9127,7 +9371,7 @@ - (NSRect) accessibilityFrame
- (BOOL) isAccessibilityFocused - (BOOL) isAccessibilityFocused
{ {
/* Read the cached point stored by EmacsAccessibilityBuffer on the main /* Read the cached point stored by EmacsAccessibilityBuffer on the main
@@ -579,7 +506,7 @@ index 125e52c..ebd52c6 100644
EmacsAccessibilityBuffer *pb = self.parentBuffer; EmacsAccessibilityBuffer *pb = self.parentBuffer;
if (!pb) if (!pb)
return NO; return NO;
@@ -9419,7 +9710,7 @@ ns_ax_scan_interactive_spans (struct window *w, @@ -9144,7 +9388,7 @@ - (void) setAccessibilityFocused: (BOOL) focused
dispatch_async (dispatch_get_main_queue (), ^{ dispatch_async (dispatch_get_main_queue (), ^{
/* lwin is a Lisp_Object captured by value. This is GC-safe /* lwin is a Lisp_Object captured by value. This is GC-safe
because Lisp_Objects are tagged integers/pointers that because Lisp_Objects are tagged integers/pointers that
@@ -588,7 +515,7 @@ index 125e52c..ebd52c6 100644
Emacs. The WINDOW_LIVE_P check below guards against the Emacs. The WINDOW_LIVE_P check below guards against the
window being deleted between capture and execution. */ window being deleted between capture and execution. */
if (!WINDOWP (lwin) || NILP (Fwindow_live_p (lwin))) if (!WINDOWP (lwin) || NILP (Fwindow_live_p (lwin)))
@@ -9445,7 +9736,7 @@ ns_ax_scan_interactive_spans (struct window *w, @@ -9170,7 +9414,7 @@ - (void) setAccessibilityFocused: (BOOL) focused
@end @end
@@ -597,23 +524,7 @@ index 125e52c..ebd52c6 100644
Methods are kept here (same .m file) so they access the ivars Methods are kept here (same .m file) so they access the ivars
declared in the @interface ivar block. */ declared in the @interface ivar block. */
@implementation EmacsAccessibilityBuffer (InteractiveSpans) @implementation EmacsAccessibilityBuffer (InteractiveSpans)
@@ -10765,13 +11056,13 @@ ns_in_echo_area (void) @@ -11892,7 +12136,7 @@ - (int) fullscreenState
if (old_title == 0)
{
char *t = strdup ([[[self window] title] UTF8String]);
- char *pos = strstr (t, " — ");
+ char *pos = strstr (t, " --- ");
if (pos)
*pos = '\0';
old_title = t;
}
size_title = xmalloc (strlen (old_title) + 40);
- esprintf (size_title, "%s — (%d × %d)", old_title, cols, rows);
+ esprintf (size_title, "%s --- (%d × %d)", old_title, cols, rows);
[window setTitle: [NSString stringWithUTF8String: size_title]];
[window display];
xfree (size_title);
@@ -12167,7 +12458,7 @@ ns_ax_collect_windows (Lisp_Object window, EmacsView *view,
if (WINDOW_LEAF_P (w)) if (WINDOW_LEAF_P (w))
{ {
@@ -622,7 +533,7 @@ index 125e52c..ebd52c6 100644
EmacsAccessibilityBuffer *elem EmacsAccessibilityBuffer *elem
= [existing objectForKey:[NSValue valueWithPointer:w]]; = [existing objectForKey:[NSValue valueWithPointer:w]];
if (!elem) if (!elem)
@@ -12201,7 +12492,7 @@ ns_ax_collect_windows (Lisp_Object window, EmacsView *view, @@ -11926,7 +12170,7 @@ - (int) fullscreenState
} }
else else
{ {
@@ -631,7 +542,7 @@ index 125e52c..ebd52c6 100644
Lisp_Object child = w->contents; Lisp_Object child = w->contents;
while (!NILP (child)) while (!NILP (child))
{ {
@@ -12313,7 +12604,7 @@ ns_ax_collect_windows (Lisp_Object window, EmacsView *view, @@ -12038,7 +12282,7 @@ - (void)postAccessibilityUpdates
accessibilityUpdating = YES; accessibilityUpdating = YES;
/* Detect window tree change (split, delete, new buffer). Compare /* Detect window tree change (split, delete, new buffer). Compare
@@ -640,7 +551,7 @@ index 125e52c..ebd52c6 100644
Lisp_Object curRoot = FRAME_ROOT_WINDOW (emacsframe); Lisp_Object curRoot = FRAME_ROOT_WINDOW (emacsframe);
if (!EQ (curRoot, lastRootWindow)) if (!EQ (curRoot, lastRootWindow))
{ {
@@ -12322,12 +12613,12 @@ ns_ax_collect_windows (Lisp_Object window, EmacsView *view, @@ -12047,12 +12291,12 @@ - (void)postAccessibilityUpdates
} }
/* If tree is stale, rebuild FIRST so we don't iterate freed /* If tree is stale, rebuild FIRST so we don't iterate freed

View File

@@ -1,4 +1,4 @@
From d68d1334147a7de273e39cf26c778389faa424ad Mon Sep 17 00:00:00 2001 From 058cc9aad5c34796206749844df28acc9e09f0eb Mon Sep 17 00:00:00 2001
From: Martin Sukany <martin@sukany.cz> From: Martin Sukany <martin@sukany.cz>
Date: Sat, 28 Feb 2026 16:01:29 +0100 Date: Sat, 28 Feb 2026 16:01:29 +0100
Subject: [PATCH 8/8] ns: announce child frame completion candidates for Subject: [PATCH 8/8] ns: announce child frame completion candidates for
@@ -31,9 +31,7 @@ the child frame handler and cleared on the parent's next accessibility
cycle when no child frame is visible (via FOR_EACH_FRAME). cycle when no child frame is visible (via FOR_EACH_FRAME).
Announce via AnnouncementRequested to NSApp with High priority. Announce via AnnouncementRequested to NSApp with High priority.
Use direct UAZoomChangeFocus because the child frame renders
independently --- its ns_update_end runs after the parent's independently --- its ns_update_end runs after the parent's
draw_window_cursor, so the last Zoom call wins.
* src/nsterm.h (EmacsView): Add announceChildFrameCompletion, * src/nsterm.h (EmacsView): Add announceChildFrameCompletion,
childFrameCompletionActive flag. childFrameCompletionActive flag.
@@ -42,23 +40,67 @@ childFrameCompletionActive flag.
(EmacsView postAccessibilityUpdates): Dispatch to child frame handler, (EmacsView postAccessibilityUpdates): Dispatch to child frame handler,
refocus parent buffer element when child frame closes. refocus parent buffer element when child frame closes.
--- ---
src/nsterm.h | 2 + doc/emacs/macos.texi | 6 --
src/nsterm.m | 253 ++++++++++++++++++++++++++++++++++++++++++++++++++- etc/NEWS | 4 +-
2 files changed, 254 insertions(+), 1 deletion(-) src/nsterm.h | 5 +
src/nsterm.m | 227 ++++++++++++++++++++++++++++++++++++++++++-
4 files changed, 233 insertions(+), 9 deletions(-)
diff --git a/doc/emacs/macos.texi b/doc/emacs/macos.texi
index 4825cf9..97777e2 100644
--- a/doc/emacs/macos.texi
+++ b/doc/emacs/macos.texi
@@ -278,7 +278,6 @@ restart Emacs to access newly-available services.
@cindex VoiceOver
@cindex accessibility (macOS)
@cindex screen reader (macOS)
-@cindex Zoom, cursor tracking (macOS)
When built with the Cocoa interface on macOS, Emacs exposes buffer
content, cursor position, mode lines, and interactive elements to the
@@ -309,11 +308,6 @@ Shift-modified movement announces selected or deselected text.
The @file{*Completions*} buffer announces each completion candidate
as you navigate, even while keyboard focus remains in the minibuffer.
- macOS Zoom (System Settings, Accessibility, Zoom) tracks the Emacs
-cursor automatically when set to follow keyboard focus. The cursor
-position is communicated via @code{UAZoomChangeFocus} and the
-@code{AXBoundsForRange} accessibility attribute.
-
@vindex ns-accessibility-enabled
To disable the accessibility interface entirely (for instance, to
eliminate overhead on systems where assistive technology is not in
diff --git a/etc/NEWS b/etc/NEWS
index e76ee93..c3e0b40 100644
--- a/etc/NEWS
+++ b/etc/NEWS
@@ -4393,8 +4393,8 @@ send user data to Apple's speech recognition servers.
** VoiceOver accessibility support on macOS.
Emacs now exposes buffer content, cursor position, and interactive
elements to the macOS accessibility subsystem (VoiceOver). This
-includes AXBoundsForRange for macOS Zoom cursor tracking, line and
-word navigation announcements, Tab-navigable interactive spans
+includes line and word navigation announcements, Tab-navigable
+interactive spans
(buttons, links, completion candidates), and completion announcements
for the *Completions* buffer. The implementation uses a virtual
accessibility tree with per-window elements, hybrid SelectedTextChanged
diff --git a/src/nsterm.h b/src/nsterm.h diff --git a/src/nsterm.h b/src/nsterm.h
index a007925..1a8a84d 100644 index 19a7e7a..49e8f00 100644
--- a/src/nsterm.h --- a/src/nsterm.h
+++ b/src/nsterm.h +++ b/src/nsterm.h
@@ -598,6 +598,7 @@ typedef NS_ENUM (NSInteger, EmacsAXSpanType) @@ -596,6 +596,10 @@ typedef NS_ENUM (NSInteger, EmacsAXSpanType)
NSRect lastAccessibilityCursorRect; BOOL accessibilityUpdating;
BOOL overlayZoomActive; @public /* Accessed by ns_draw_phys_cursor (C function). */
NSRect overlayZoomRect; NSRect lastCursorRect;
+ BOOL childFrameCompletionActive; + BOOL childFrameCompletionActive;
+ char *childFrameLastCandidate;
+ struct buffer *childFrameLastBuffer;
+ EMACS_INT childFrameLastModiff;
#endif #endif
BOOL font_panel_active; BOOL font_panel_active;
NSFont *font_panel_result; NSFont *font_panel_result;
@@ -661,6 +662,7 @@ typedef NS_ENUM (NSInteger, EmacsAXSpanType) @@ -659,6 +663,7 @@ typedef NS_ENUM (NSInteger, EmacsAXSpanType)
- (void)rebuildAccessibilityTree; - (void)rebuildAccessibilityTree;
- (void)invalidateAccessibilityTree; - (void)invalidateAccessibilityTree;
- (void)postAccessibilityUpdates; - (void)postAccessibilityUpdates;
@@ -67,13 +109,15 @@ index a007925..1a8a84d 100644
@end @end
diff --git a/src/nsterm.m b/src/nsterm.m diff --git a/src/nsterm.m b/src/nsterm.m
index ebd52c6..a7025a9 100644 index 72478e0..daa8f61 100644
--- a/src/nsterm.m --- a/src/nsterm.m
+++ b/src/nsterm.m +++ b/src/nsterm.m
@@ -7310,6 +7310,110 @@ ns_ax_selected_overlay_text (struct buffer *b, @@ -7028,6 +7028,112 @@ visual line index for Zoom (skip whitespace-only lines
return nil;
} }
+
+
+/* Scan buffer text of a child frame for the selected completion +/* Scan buffer text of a child frame for the selected completion
+ candidate. Used for frameworks that render candidates in a + candidate. Used for frameworks that render candidates in a
+ child frame (e.g. Corfu, Company-box) rather than as overlay + child frame (e.g. Corfu, Company-box) rather than as overlay
@@ -181,7 +225,7 @@ index ebd52c6..a7025a9 100644
/* Build accessibility text for window W, skipping invisible text. /* Build accessibility text for window W, skipping invisible text.
Populates *OUT_START with the buffer start charpos. Populates *OUT_START with the buffer start charpos.
Populates *OUT_RUNS with an array of visible runs and *OUT_NRUNS Populates *OUT_RUNS with an array of visible runs and *OUT_NRUNS
@@ -12588,6 +12692,105 @@ ns_ax_collect_windows (Lisp_Object window, EmacsView *view, @@ -12266,6 +12372,77 @@ - (id)accessibilityFocusedUIElement
The existing elements carry cached state (modiff, point) from the The existing elements carry cached state (modiff, point) from the
previous redisplay cycle. Rebuilding first would create fresh previous redisplay cycle. Rebuilding first would create fresh
elements with current values, making change detection impossible. */ elements with current values, making change detection impossible. */
@@ -189,14 +233,9 @@ index ebd52c6..a7025a9 100644
+/* Announce the selected candidate in a child frame completion popup. +/* Announce the selected candidate in a child frame completion popup.
+ Handles Corfu, Company-box, and similar frameworks that render + Handles Corfu, Company-box, and similar frameworks that render
+ candidates in a separate child frame rather than as overlay strings + candidates in a separate child frame rather than as overlay strings
+ in the minibuffer. Uses direct UAZoomChangeFocus (not the + in the minibuffer. */
+ overlayZoomRect flag) because the child frame's ns_update_end runs
+ after the parent's draw_window_cursor. */
+- (void)announceChildFrameCompletion +- (void)announceChildFrameCompletion
+{ +{
+ static char *lastCandidate;
+ static struct buffer *lastBuffer;
+ static EMACS_INT lastModiff;
+ +
+ /* Validate frame state --- child frames may be partially + /* Validate frame state --- child frames may be partially
+ initialized during creation. */ + initialized during creation. */
@@ -212,10 +251,10 @@ index ebd52c6..a7025a9 100644
+ also guards against re-entrance: if Lisp calls below + also guards against re-entrance: if Lisp calls below
+ trigger redisplay, the modiff check short-circuits. */ + trigger redisplay, the modiff check short-circuits. */
+ EMACS_INT modiff = BUF_MODIFF (b); + EMACS_INT modiff = BUF_MODIFF (b);
+ if (b == lastBuffer && modiff == lastModiff) + if (b == childFrameLastBuffer && modiff == childFrameLastModiff)
+ return; + return;
+ lastBuffer = b; + childFrameLastBuffer = b;
+ lastModiff = modiff; + childFrameLastModiff = modiff;
+ +
+ /* Skip buffers larger than a typical completion popup. + /* Skip buffers larger than a typical completion popup.
+ This avoids scanning eldoc, which-key, or other child + This avoids scanning eldoc, which-key, or other child
@@ -232,10 +271,10 @@ index ebd52c6..a7025a9 100644
+ +
+ /* Deduplicate --- avoid re-announcing the same candidate. */ + /* Deduplicate --- avoid re-announcing the same candidate. */
+ const char *cstr = [candidate UTF8String]; + const char *cstr = [candidate UTF8String];
+ if (lastCandidate && strcmp (cstr, lastCandidate) == 0) + if (childFrameLastCandidate && strcmp (cstr, childFrameLastCandidate) == 0)
+ return; + return;
+ xfree (lastCandidate); + xfree (childFrameLastCandidate);
+ lastCandidate = xstrdup (cstr); + childFrameLastCandidate = xstrdup (cstr);
+ +
+ NSDictionary *annInfo = @{ + NSDictionary *annInfo = @{
+ NSAccessibilityAnnouncementKey: candidate, + NSAccessibilityAnnouncementKey: candidate,
@@ -259,35 +298,12 @@ index ebd52c6..a7025a9 100644
+ parentView->childFrameCompletionActive = YES; + parentView->childFrameCompletionActive = YES;
+ } + }
+ +
+ /* Zoom tracking: focus on the selected row in the child frame.
+ Use direct UAZoomChangeFocus rather than overlayZoomRect because
+ the child frame renders independently of the parent. */
+ if (selected_line >= 0 && UAZoomEnabled ())
+ {
+ int line_h = FRAME_LINE_HEIGHT (emacsframe);
+ int y_off = selected_line * line_h;
+ NSRect r = NSMakeRect (
+ WINDOW_TEXT_TO_FRAME_PIXEL_X (w, 0),
+ WINDOW_TO_FRAME_PIXEL_Y (w, y_off),
+ FRAME_COLUMN_WIDTH (emacsframe),
+ line_h);
+ NSRect winRect = [self convertRect:r toView:nil];
+ NSRect screenRect
+ = [[self window] convertRectToScreen:winRect];
+ CGRect cgRect = NSRectToCGRect (screenRect);
+ CGFloat primaryH
+ = [[[NSScreen screens] firstObject] frame].size.height;
+ cgRect.origin.y
+ = primaryH - cgRect.origin.y - cgRect.size.height;
+ UAZoomChangeFocus (&cgRect, &cgRect,
+ kUAZoomFocusTypeInsertionPoint);
+ }
+} +}
+ +
- (void)postAccessibilityUpdates - (void)postAccessibilityUpdates
{ {
NSTRACE ("[EmacsView postAccessibilityUpdates]"); NSTRACE ("[EmacsView postAccessibilityUpdates]");
@@ -12598,11 +12801,59 @@ ns_ax_collect_windows (Lisp_Object window, EmacsView *view, @@ -12276,11 +12453,59 @@ - (void)postAccessibilityUpdates
/* Re-entrance guard: VoiceOver callbacks during notification posting /* Re-entrance guard: VoiceOver callbacks during notification posting
can trigger redisplay, which calls ns_update_end, which calls us can trigger redisplay, which calls ns_update_end, which calls us

View File

@@ -1,15 +1,49 @@
EMACS NS VOICEOVER ACCESSIBILITY PATCH EMACS NS ACCESSIBILITY PATCHES
======================================== ================================
patch: 0001-0008 (8 patches, see PATCH SERIES below)
author: Martin Sukany <martin@sukany.cz> author: Martin Sukany <martin@sukany.cz>
files: src/nsterm.h (+124 lines)
src/nsterm.m (+3577 ins, -185 del, +3392 net) This directory contains two independent patch sets for the Emacs NS
doc/emacs/macos.texi (+53 lines) (Cocoa) port:
etc/NEWS (+13 lines)
A. Standalone Zoom patch (0000)
B. VoiceOver accessibility patch series (0001-0008)
Each can be applied independently. They do not depend on each other.
PATCH SERIES PATCH A: ZOOM CURSOR TRACKING (0000)
------------ -------------------------------------
0000 ns: integrate with macOS Zoom for cursor tracking
A minimal patch that informs macOS Zoom of the text cursor position
after every physical cursor redraw. When Zoom is enabled (System
Settings -> Accessibility -> Zoom -> Follow keyboard focus), the
zoomed viewport automatically tracks the Emacs insertion point.
Files modified:
src/nsterm.h (+4 lines: lastZoomCursorRect ivar)
src/nsterm.m (+66 lines: cursor store + UAZoomChangeFocus)
etc/NEWS (+8 lines)
Implementation:
ns_draw_window_cursor stores the cursor rect in
view->lastZoomCursorRect and calls UAZoomChangeFocus() with
CG-space coordinates. A fallback call in ns_update_end ensures
Zoom tracks the cursor even after window switches (C-x o) where
the physical cursor may not be redrawn.
Coordinate conversion: EmacsView pixels (AppKit, flipped) ->
NSWindow -> NSScreen -> CGRect with y-flip for CoreGraphics
top-left origin.
No user option is needed: UAZoomEnabled() returns false when Zoom
is not active, so the overhead is a single function call per
redisplay cycle.
PATCH B: VOICEOVER ACCESSIBILITY (0001-0008)
----------------------------------------------
0001 ns: add accessibility base classes and text extraction 0001 ns: add accessibility base classes and text extraction
0002 ns: implement buffer accessibility element (core protocol) 0002 ns: implement buffer accessibility element (core protocol)
@@ -19,776 +53,77 @@ PATCH SERIES
0006 doc: add VoiceOver accessibility section to macOS appendix 0006 doc: add VoiceOver accessibility section to macOS appendix
0007 ns: announce overlay completion candidates for VoiceOver 0007 ns: announce overlay completion candidates for VoiceOver
0008 ns: announce child frame completion candidates for VoiceOver 0008 ns: announce child frame completion candidates for VoiceOver
0009 Performance: precomputed line index for O(log L) line queries
Files modified:
src/nsterm.h (~120 lines: class declarations, ivars)
src/nsterm.m (~3400 lines: implementation)
doc/emacs/macos.texi (~50 lines: documentation)
etc/NEWS (~8 lines)
OVERVIEW This patch series adds comprehensive VoiceOver accessibility support
-------- to the NS port. Before this patch, Emacs exposed only a minimal,
largely broken accessibility interface: EmacsView identified itself
This patch adds comprehensive macOS VoiceOver accessibility support as a generic NSAccessibilityGroup with no text content, no cursor
to the Emacs NS (Cocoa) port. Before this patch, Emacs exposed only tracking, and no notifications.
a minimal, largely broken accessibility interface to macOS assistive
technology (AT) clients: EmacsView identified itself as a generic
NSAccessibilityGroup with no text content, no cursor tracking, and
no notifications. VoiceOver users could activate the application
but received no meaningful speech feedback when editing text.
The patch introduces a layered virtual element tree above EmacsView.
Each visible Emacs window is represented by an EmacsAccessibilityBuffer
element (AXTextArea / AXTextField for minibuffer) with a full text
cache, a visible-run mapping table that bridges buffer character
positions to UTF-16 accessibility string indices, and an interactive
span child array for Tab navigation. A companion
EmacsAccessibilityModeLine element (AXStaticText) represents the mode
line of each window. These virtual elements are wired into the macOS
Accessibility API through EmacsView acting as the AXGroup root.
Two additional integration points are provided: (1) macOS Zoom is
informed of the cursor position after every physical cursor redraw via
UAZoomChangeFocus(), using the correct CoreGraphics (top-left-origin)
coordinate space; (2) EmacsView implements accessibilityBoundsForRange:
and its legacy parameterized-attribute equivalent so that both Zoom
and third-party AT tools can locate the insertion point. The patch
also covers completion announcements for the *Completions* buffer and
Tab-navigable interactive spans for buttons, links, checkboxes,
Org-mode links, completion candidates, and keymap overlays.
ARCHITECTURE ARCHITECTURE
------------ ------------
Class hierarchy (Cocoa only): Virtual element tree above EmacsView:
NSAccessibilityElement EmacsAccessibilityElement (base)
| +-- EmacsAccessibilityBuffer (AXTextArea; one per window)
+-- EmacsAccessibilityElement (base: owns emacsView + lispWindow) +-- EmacsAccessibilityModeLine (AXStaticText; mode line)
| +-- EmacsAccessibilityInteractiveSpan (AXButton/Link; Tab nav)
+-- EmacsAccessibilityBuffer (AXTextArea; one per leaf window)
| [category InteractiveSpans] (Tab nav children)
|
+-- EmacsAccessibilityModeLine (AXStaticText; one per non-mini)
|
+-- EmacsAccessibilityInteractiveSpan (AXButton/Link/etc.)
EmacsView (NSView subclass, existing) Each buffer element maintains a text cache with visible-run mapping
| (O(log n) index lookup) and a precomputed line index (O(log L) line
+-- owns NSMutableArray *accessibilityElements queries). Notifications are posted asynchronously via dispatch_async
contains EmacsAccessibilityBuffer + EmacsAccessibilityModeLine to prevent VoiceOver deadlocks.
instances for every visible leaf window and minibuffer.
EmacsAccessibilityInteractiveSpan instances are children of
their parent EmacsAccessibilityBuffer, NOT of this array.
EmacsAccessibilityElement (base class) Full details in the commit messages of each patch.
- Stores a weak (unsafe_unretained) pointer to EmacsView and a
Lisp_Object lispWindow (GC-safe window reference).
- Provides -validWindow which verifies WINDOW_LIVE_P before
returning the raw struct window *. All subclasses use this to
avoid dangling pointers after delete-window or kill-buffer.
- Provides -screenRectFromEmacsX:y:width:height: which converts
EmacsView pixel coordinates (flipped AppKit space) to screen
coordinates via the NSWindow coordinate chain.
EmacsAccessibilityBuffer
- Implements the full NSAccessibility text protocol: value, selected
text range, line/index/range conversions, frame-for-range,
range-for-position, and insertion-point-line-number.
- Maintains a text cache (cachedText / visibleRuns) keyed on
BUF_MODIFF and BUF_BEGV (narrowing). BUF_OVERLAY_MODIFF is
tracked separately for notification dispatch (patch 0007)
but not for cache invalidation.
The cache is the single source of truth for all
index-to-charpos and charpos-to-index mappings.
- Detects buffer edits (modiff change), cursor movement (point
change), and mark changes, and posts the appropriate
NSAccessibility notifications after each redisplay cycle.
- Stores cached values for the previous cycle (cachedModiff,
cachedPoint, cachedMarkActive) to enable change detection.
EmacsAccessibilityModeLine
- Reads mode line text directly from the window's current glyph
matrix (CHAR_GLYPH rows with mode_line_p set).
- Stateless: no cache; text is read fresh on every AX query.
EmacsAccessibilityInteractiveSpan
- Lightweight child element representing one contiguous interactive
region (button, link, completion item, etc.).
- Reports isAccessibilityFocused by comparing cachedPoint of the
parent EmacsAccessibilityBuffer against its charpos range.
- On setAccessibilityFocused: dispatches to the main queue via
GCD to move Emacs point, using block_input around SET_PT_BOTH.
EmacsView (extensions)
- accessibilityElements array: rebuilt by -rebuildAccessibilityTree
when the window tree changes (split, delete, new buffer).
- -postAccessibilityUpdates: called from ns_update_end() after
every redisplay cycle; drives the notification dispatch loop.
- lastAccessibilityCursorRect: updated by ns_draw_phys_cursor
(C function) for Zoom integration.
- Implements accessibilityBoundsForRange: /
accessibilityFrameForRange: and the legacy
accessibilityAttributeValue:forParameter: API.
USER OPTION PERFORMANCE
----------- -----------
ns-accessibility-enabled (DEFVAR_BOOL, default t): ns-accessibility-enabled (DEFVAR_BOOL, default t):
When nil, the accessibility virtual element tree is not built, no When nil, no virtual elements are built, no notifications are
notifications are posted, and ns_draw_phys_cursor skips the Zoom posted, and ns_draw_window_cursor skips the cursor rect store.
update. This eliminates accessibility overhead entirely on systems Zero overhead for users who do not use assistive technology.
where assistive technology is not in use. Guarded at three entry
points: postAccessibilityUpdates, ns_draw_phys_cursor, and When enabled:
windowDidBecomeKey. - Text cache rebuilds only on BUF_MODIFF change (not per-keystroke)
- Index lookups are O(log n) via binary search on visible runs
- Line queries are O(log L) via precomputed lineStartOffsets
- Interactive span scan runs only when dirty flag is set
- No character cap: full buffer exposed, but cache is lazy
THREADING MODEL THREADING MODEL
--------------- ---------------
Emacs runs all Lisp evaluation and buffer mutation on the main thread Main thread: all Lisp calls, buffer mutations, notification posting.
(the Cocoa/AppKit main thread). The macOS Accessibility server AX thread: VoiceOver queries dispatch_sync to main thread.
(axserver / AT daemon) calls AX getters from a private background Async notifications: dispatch_async prevents deadlock (same pattern
thread. as WebKit's AXObjectCacheMac).
Rules enforced by this patch:
Main thread only:
- ns_update_end -> postAccessibilityUpdates
- rebuildAccessibilityTree / invalidateAccessibilityTree
- ensureTextCache / ns_ax_buffer_text (Lisp calls:
Fget_char_property, Fnext_single_char_property_change,
Fbuffer_substring_no_properties)
- postAccessibilityNotificationsForFrame: (full notify logic)
- setAccessibilitySelectedTextRange: (SET_PT_BOTH, marker moves)
- setAccessibilityFocused: on EmacsAccessibilityInteractiveSpan
(dispatches to main queue via dispatch_async; uses specpdl
unwind protection so block_input is always matched by
unblock_input even if Fselect_window signals an error)
- ns_draw_phys_cursor partial update (lastAccessibilityCursorRect,
UAZoomChangeFocus)
Safe from any thread (no Lisp calls, no mutable Emacs state):
- accessibilityIndexForCharpos: reads visibleRuns + cachedText
- charposForAccessibilityIndex: same
- isAccessibilityFocused on EmacsAccessibilityInteractiveSpan
(reads cachedPoint, a plain ptrdiff_t)
Dispatch-gated (marshalled to main thread when called off-thread):
- accessibilityValue (EmacsAccessibilityBuffer)
- accessibilitySelectedTextRange
- accessibilityInsertionPointLineNumber
- accessibilityFrameForRange:
- accessibilityRangeForPosition:
- accessibilityChildrenInNavigationOrder
The marshalling pattern used throughout:
if (![NSThread isMainThread]) {
__block T result;
dispatch_sync(dispatch_get_main_queue(), ^{ result = ...; });
return result;
}
Async notification posting (deadlock prevention):
NSAccessibilityPostNotification may synchronously invoke VoiceOver
callbacks from a private AX server thread. Those callbacks call
AX getters which dispatch_sync back to the main queue. If the
main thread is still inside the notification-posting method (e.g.,
postAccessibilityUpdates called from ns_update_end), the
dispatch_sync deadlocks: the main thread waits for VoiceOver to
finish processing the notification, while VoiceOver's thread waits
for the main queue to become available.
To break this cycle, all notification posting goes through two
static inline wrappers:
ns_ax_post_notification(element, name)
ns_ax_post_notification_with_info(element, name, info)
These wrappers defer the actual NSAccessibilityPostNotification
call via dispatch_async(dispatch_get_main_queue(), ^{ ... }).
The current method returns first, freeing the main queue, so
VoiceOver's dispatch_sync calls can proceed without deadlock.
Block captures retain ObjC objects (element, info dictionary)
for the lifetime of the deferred block.
Cached data written on main thread and read from any thread:
- cachedText (NSString *): written by ensureTextCache on main.
- visibleRuns (ns_ax_visible_run *): written by ensureTextCache.
- cachedPoint (ptrdiff_t): plain scalar; atomic on 64-bit ARM/x86.
No explicit lock is used; the design relies on the fact that index
mapping methods make no Lisp calls and read only the above scalars
and the immutable NSString object.
NOTIFICATION STRATEGY
---------------------
All notifications are posted asynchronously via
ns_ax_post_notification / ns_ax_post_notification_with_info
(dispatch_async wrappers -- see THREADING MODEL for rationale).
Notifications are generated by -postAccessibilityNotificationsForFrame:
which runs on the main thread after every redisplay cycle. The
method detects three mutually exclusive events:
1. TEXT CHANGED (modiff != cachedModiff)
Posts NSAccessibilityValueChangedNotification with AXTextEditType
= Typing and, when exactly one character was inserted, provides
AXTextChangeValue for echo feedback. cachedPoint is updated here
to suppress a spurious selection-move event in the same cycle
(WebKit/Chromium convention: edit and selection-move are mutually
exclusive per runloop iteration).
2. CURSOR MOVED OR MARK CHANGED (point != cachedPoint OR mark change)
Granularity is computed by comparing oldIdx and newIdx in
cachedText:
- different line range -> LINE granularity
- same line, distance > 1 UTF-16 unit -> WORD granularity
- same line, distance == 1 UTF-16 unit -> CHARACTER granularity
C-n / C-p / Tab / backtab force LINE granularity
(detected by ns_ax_event_is_line_nav_key which inspects
last_command_event) regardless.
For FOCUSED elements the hybrid strategy applies:
CHARACTER moves:
SelectedTextChanged is posted WITHOUT AXTextSelectionGranularity
in userInfo. Omitting the key prevents VoiceOver from deriving
its own speech (it would read the character BEFORE point,
which is wrong for evil block-cursor mode where the cursor
sits ON the character). Then AnnouncementRequested is posted
separately with the character AT point as the announcement.
Newline is skipped (VoiceOver handles end-of-line internally).
WORD and LINE moves:
SelectedTextChanged is posted WITH AXTextSelectionGranularity.
VoiceOver reads the word/line correctly from the element text
using the granularity hint. For LINE moves an additional
AnnouncementRequested is also posted with the line text (or
the completion--string at point if in a completion buffer) to
handle C-n/C-p -- VoiceOver processes these keystrokes
differently from arrow keys internally.
SELECTION changes (mark becomes active or extends):
SelectedTextChanged with LINE or WORD granularity. VoiceOver
reads the newly selected or deselected text.
For NON-FOCUSED elements (e.g. *Completions* while minibuffer has
focus): AnnouncementRequested only. See COMPLETION ANNOUNCEMENTS.
3. NO CHANGE
Nothing is posted. Completion cache is cleared for focused buffer.
TEXT CACHE AND VISIBLE RUNS
----------------------------
ns_ax_buffer_text(w, out_start, out_runs, out_nruns) builds the
accessibility string for window W. It operates on the current
buffer with set_buffer_internal_1, scanning from BUF_BEGV to BUF_ZV.
Invisible text detection uses TEXT_PROP_MEANS_INVISIBLE(invis) where
invis = Fget_char_property(pos, Qinvisible, Qnil). This respects
buffer-invisibility-spec, correctly handling org-mode folding,
outline mode, and hideshow -- not just `invisible t' text properties.
When an invisible region is found, the scanner jumps ahead using
Fnext_single_char_property_change to skip the entire region in O(1)
iterations rather than character by character.
Text extraction uses Fbuffer_substring_no_properties (not raw
BUF_BYTE_ADDRESS) to handle the buffer gap correctly. Raw byte
access across the gap position yields garbage bytes.
The ns_ax_visible_run structure:
typedef struct ns_ax_visible_run {
ptrdiff_t charpos; /* Buffer charpos of run start. */
ptrdiff_t length; /* Emacs characters in this run. */
NSUInteger ax_start; /* UTF-16 index in accessibility string. */
NSUInteger ax_length; /* UTF-16 units for this run. */
} ns_ax_visible_run;
Multiple runs are produced when invisible text splits the buffer into
non-contiguous visible segments. The mapping array is stored in the
EmacsAccessibilityBuffer ivar `visibleRuns' (C array, xmalloc'd).
Index mapping (charpos <-> ax_index) uses binary search over the
sorted run array — O(log n) per lookup. Within a run, UTF-16 unit
counting uses
rangeOfComposedCharacterSequenceAtIndex: to handle surrogate pairs
(emoji, rare CJK) correctly -- one Emacs character may occupy 2
UTF-16 units.
Cache invalidation is triggered whenever BUF_MODIFF or
BUF_OVERLAY_MODIFF changes (ensureTextCache compares both
cachedTextModiff and cachedOverlayModiff). Additionally,
narrowing/widening is detected by comparing cachedTextStart
against BUF_BEGV — these operations change the visible region
without bumping either modiff counter. The cache is also
invalidated when the window tree is rebuilt.
There is no character cap on the accessibility text. The entire
visible (non-invisible) buffer content is exposed to VoiceOver.
Users who do not need accessibility can set ns-accessibility-enabled
to nil for zero overhead.
A lineStartOffsets array is built during each cache rebuild,
recording the AX string index where each line begins. This
makes accessibilityLineForIndex: and accessibilityRangeForLine:
O(log L) via binary search instead of O(L) linear scanning.
The index is freed and rebuilt alongside the text cache.
COMPLETION ANNOUNCEMENTS
------------------------
When point moves in a non-focused buffer (the common case:
*Completions* window while the minibuffer retains keyboard focus),
VoiceOver does not automatically read the change because it is
tracking the focused element. The patch posts AnnouncementRequested
with a 4-step fallback chain to find the best text to announce:
Step 1 -- completion--string property at point.
The `completion--string' text property (set by minibuffer.el
since Emacs 29) carries the canonical completion candidate string.
It can be a plain Lisp string or a list (CANDIDATE ANNOTATION) where both
are strings.
ns_ax_completion_string_from_prop handles both: plain string ->
use directly; cons -> use car (the candidate without annotation).
This is the preferred source: precisely the candidate text with
no surrounding whitespace.
Step 2 -- mouse-face span at point.
completion-list-mode marks the active candidate with mouse-face.
The code walks backward and forward from point to find the span
boundaries, then reads the corresponding slice of cachedText.
Used when completion--string is absent (older Emacs or non-
standard completion modes).
Step 3 -- completions-highlight overlay at point.
Emacs 29+ highlights the selected completion with the
`completions-highlight' face applied via an overlay. The overlay
text is extracted via ns_ax_completion_text_for_span which itself
tries completion--string first, then the `completion' property,
then falls back to the ax string slice.
Step 4 -- nearest completions-highlight overlay.
ns_ax_find_completion_overlay_range scans the buffer for the
closest completions-highlight overlay to point. Uses fast probes
at {point, point+1, point-1} before falling back to a full O(n)
scan.
Final fallback -- current line text.
Read the line containing point from cachedText.
Deduplication: the announcement is posted only when announceText,
overlay bounds, or point have changed since the last cycle
(cachedCompletionAnnouncement, cachedCompletionOverlayStart/End,
cachedCompletionPoint).
INTERACTIVE SPANS
-----------------
ns_ax_scan_interactive_spans(w, parent_buf) scans the visible range
of window W looking for text properties that indicate interactive
content. Properties are checked in priority order:
widget -> EmacsAXSpanTypeWidget (AXButton, via default)
button -> EmacsAXSpanTypeButton (AXButton, via default)
follow-link -> EmacsAXSpanTypeLink (AXLink)
org-link -> EmacsAXSpanTypeLink (AXLink)
mouse-face -> EmacsAXSpanTypeCompletionItem
(AXButton; completion-list-mode only)
keymap overlay-> EmacsAXSpanTypeButton (AXButton)
For completion buffers (major-mode == completion-list-mode), the span
boundary for mouse-face regions uses completion--string as the property
key when present, rather than mouse-face itself. This prevents two
column-adjacent completion candidates from being merged into one span
when their mouse-face regions share padding whitespace.
All property symbols are registered with DEFSYM in syms_of_nsterm
using ns_ax_ prefixed C variable names (e.g., Qns_ax_button for
"button") to avoid collisions with other Emacs source files.
Referenced directly -- no repeated intern() calls.
Each span is allocated, configured, added to the spans array, then
released (the array retains it). The function returns an autoreleased
immutable copy of the spans array. Label priority:
completion--string > buffer substring > help-echo.
Tab navigation: -accessibilityChildrenInNavigationOrder returns the
cached span array, rebuilt lazily when interactiveSpansDirty is set.
Calls from off-thread are marshalled with dispatch_sync.
Focus movement: -setAccessibilityFocused: on a span dispatches
Fselect_window + SET_PT_BOTH to the main queue via dispatch_async,
wrapped in block_input/unblock_input.
ZOOM INTEGRATION
----------------
macOS Zoom (accessibility zoom) tracks a "focus element" to keep the
zoomed viewport centered on the relevant screen area. Two mechanisms
are provided:
1. ns_draw_phys_cursor (C function, main thread, called during
redisplay). After clipping the cursor rect to the text area,
stores the rect in view->lastAccessibilityCursorRect. If
UAZoomEnabled(), converts the rect to screen coordinates and calls
UAZoomChangeFocus(kUAZoomFocusTypeInsertionPoint).
Coordinate conversion chain:
EmacsView pixels (AppKit, flipped, origin at top-left of view)
-[convertRect:toView:nil]-> NSWindow coordinates
-[convertRectToScreen:]-> NSScreen coordinates
NSRectToCGRect -> CGRect (same values, no transform)
CG y-flip: cgRect.origin.y = primaryH - y - height
The flip is required because CoreGraphics uses top-left origin
(primary screen) while AppKit screen rects use bottom-left.
primaryH = [[NSScreen screens] firstObject].frame.size.height.
2. EmacsView -accessibilityBoundsForRange: /
-accessibilityFrameForRange:
AT tools (including Zoom) call these with the selectedTextRange
to locate the insertion point. The implementation first delegates
to the focused EmacsAccessibilityBuffer element for accurate
per-range geometry via its accessibilityFrameForRange: method.
If the buffer element returns an empty rect (no valid window or
glyph data), the fallback uses the cached cursor rect stored in
lastAccessibilityCursorRect (minimum size 1x8 pixels). The legacy
parameterized-attribute API
(NSAccessibilityBoundsForRangeParameterizedAttribute) is supported
via -accessibilityAttributeValue:forParameter: for older AT
clients.
KEY DESIGN DECISIONS
--------------------
1. DEFSYM instead of intern for all frequently-used symbols.
DEFSYM registers symbols at startup (syms_of_nsterm) and stores
them in C globals (e.g. Qns_ax_completion__string, Qns_ax_next_line).
This covers both property scanning symbols and line navigation
command symbols used in ns_ax_event_is_line_nav_key (hot path:
runs on every cursor movement). Using intern() would perform
obarray lookups on each redisplay cycle. DEFSYM symbols are
also always reachable by the GC via staticpro, eliminating any
risk of premature collection.
2. AnnouncementRequested for character moves, not SelectedTextChanged.
VoiceOver derives the speech character from SelectedTextChanged by
looking at the character BEFORE the new cursor position (the char
"passed over"). In evil-mode with a block cursor, the cursor sits
ON the character, not between characters. AnnouncementRequested
with the character AT point produces correct speech in both insert
and normal (block-cursor) modes. SelectedTextChanged is still
posted without granularity to interrupt ongoing VoiceOver reading
and update braille display tracking.
3. completion--string, not mouse-face, as span boundary.
mouse-face regions in completion-list-mode sometimes include
leading or trailing whitespace shared between column-adjacent
candidates, which could merge two candidates into one span.
completion--string changes precisely at candidate boundaries.
4. Probe order {point, point+1, point-1} for overlay search.
After Tab advances to a new completion candidate, point is at the
START of the new entry. The previous entry's overlay covers the
position before the new start, so point-1 is inside the OLD
overlay. Trying point+1 before point-1 finds the new (correct)
entry first.
5. Notifications posted BEFORE rebuilding the tree.
postAccessibilityUpdates uses existing elements which carry cached
state from the previous cycle. Rebuilding first would create
fresh elements with current values, making change detection
impossible. Tree rebuild is deferred to cycles where
accessibilityTreeValid is false; no notifications are posted in
that cycle.
6. Re-entrance guard (accessibilityUpdating flag).
VoiceOver callbacks triggered by notification posting can cause
Cocoa to re-enter the run loop, which may trigger redisplay, which
calls ns_update_end -> postAccessibilityUpdates. The BOOL flag
breaks this recursion.
6a. Async notification posting (dispatch_async wrappers).
NSAccessibilityPostNotification can synchronously trigger
VoiceOver queries from a background AX server thread. Those
queries dispatch_sync to the main queue. If the main thread
is still inside postAccessibilityUpdates (or windowDidBecomeKey,
or setAccessibilityFocused:), the dispatch_sync deadlocks.
All 14 notification sites use ns_ax_post_notification / _with_info
wrappers that defer posting via dispatch_async, freeing the main
queue before VoiceOver's callbacks arrive. This follows the same
pattern used by WebKit's AXObjectCacheMac (deferred posting via
performSelector:withObject:afterDelay:0).
7. lispWindow (Lisp_Object) instead of raw struct window *.
struct window pointers can become dangling after delete-window.
Storing the Lisp_Object and using WINDOW_LIVE_P + XWINDOW at the
call site is the standard safe pattern in Emacs C code.
8. accessibilityVisibleCharacterRange returns full buffer range.
VoiceOver treats the visible range boundary as end-of-text. If
this returned only the on-screen portion, VoiceOver would announce
"end of text" prematurely when the cursor reaches the visible
bottom, even though more buffer content exists below.
OVERLAY COMPLETION ANNOUNCEMENTS (Patch 0007)
----------------------------------------------
Overlay-based completion frameworks (Vertico, Icomplete, Ivy, etc.)
render candidates as overlay strings in the minibuffer. VoiceOver
does not see overlay content changes automatically. This patch
detects overlay candidate changes and announces the selected
candidate.
Detection:
ns_ax_face_is_selected(face) checks whether a face name contains
"current", "selected", or "selection" (matching vertico-current,
icomplete-selected-match, ivy-current-match, etc.). Supports
both single face symbols and face lists.
ns_ax_selected_overlay_text(b, beg, end, out_line) scans the
buffer region line by line using Fget_char_property to check
both text properties and overlay face properties.
Overlay changes are tracked independently of text changes:
BUF_OVERLAY_MODIFF is checked in an independent if-branch (not
else-if) because Vertico bumps both BUF_MODIFF (text properties)
and BUF_OVERLAY_MODIFF (overlays) in the same command cycle.
textDidChange flag:
hl-line-mode and similar packages update face properties (text
properties, not characters) on every cursor movement, bumping
BUF_MODIFF without changing BUF_CHARS_MODIFF. The original
else-if structure caused the modiff branch to fire (correctly
skipping ValueChanged) but also blocked the cursor-move branch
(SelectedTextChanged). A BOOL textDidChange flag decouples the
two branches: ValueChanged and SelectedTextChanged remain
mutually exclusive for real edits, but SelectedTextChanged fires
correctly when only text properties changed.
Zoom:
The selected candidate position is stored in overlayZoomRect /
overlayZoomActive on the parent EmacsView. draw_window_cursor
uses this rect instead of the text cursor when a candidate is
active. Cleared when BUF_CHARS_MODIFF changes (user types)
or when no candidate is found.
CHILD FRAME COMPLETION ANNOUNCEMENTS (Patch 0008)
--------------------------------------------------
Completion frameworks such as Corfu, Company-box, and similar render
candidates in a child frame rather than as overlay strings. This
patch detects child frames via FRAME_PARENT_FRAME and announces
the selected candidate.
Detection:
Child frames are dispatched in postAccessibilityUpdates before
the main tree rebuild logic. FRAME_PARENT_FRAME(emacsframe)
returns non-NULL for child frames.
ns_ax_selected_child_frame_text(b, buf_obj, out_line) scans the
child frame buffer line by line, reusing ns_ax_face_is_selected
from patch 0007.
Buffer switch safety:
Fbuffer_substring_no_properties operates on current_buffer, which
may differ from the child frame buffer during ns_update_end.
The function uses record_unwind_current_buffer /
set_buffer_internal_1 to temporarily switch, with unbind_to on
all three return paths after the switch. Uses specpdl_ref (not
ptrdiff_t) for the SPECPDL_INDEX return value.
Re-entrance protection:
The accessibilityUpdating guard MUST precede the child frame
dispatch because Lisp calls in the scan function (Fget_char_property,
Fbuffer_substring_no_properties) can trigger redisplay.
BUF_MODIFF gating provides a secondary guard and prevents
redundant scans.
Validation:
- WINDOWP / BUFFERP checks for partially initialized child frames.
- Buffer size limit (10000 chars) skips non-completion child frames
(eldoc, which-key, etc.).
Focus restoration:
childFrameCompletionActive (BOOL on EmacsView) is set by the child
frame handler on the parent view. On the parent's next accessibility
cycle, FOR_EACH_FRAME checks whether any child frame is still
visible. If not, FocusedUIElementChangedNotification is posted on
the focused buffer element to restore VoiceOver character echo and
cursor tracking.
Zoom:
Direct UAZoomChangeFocus (not overlayZoomRect) because the child
frame's ns_update_end runs after the parent's draw_window_cursor,
so the last Zoom call wins.
Deduplication:
Static C string cache (lastCandidate via xstrdup/xfree) avoids
re-announcing the same candidate.
KNOWN LIMITATIONS KNOWN LIMITATIONS
----------------- -----------------
- Interactive span scan uses Fnext_single_property_change across - Mode line: CHAR_GLYPH only (icon fonts produce incomplete text)
multiple properties to skip non-interactive regions in bulk, but - Overlay face matching: string containment ("current", "selected")
still visits every property-change boundary. For buffers with - GNUstep excluded (#ifdef NS_IMPL_COCOA)
many overlapping text properties (e.g. heavily fontified source - No multi-frame coordination
code), the number of boundaries can be significant. The scan - Child frame static lastCandidate leaks at exit (minor)
runs on every redisplay cycle when interactiveSpansDirty is set.
- Mode line text is extracted from CHAR_GLYPH rows only. Image
glyphs, stretch glyphs, and composed glyphs are silently skipped.
Mode lines with icon fonts (e.g. doom-modeline with nerd-font)
produce incomplete or garbled accessibility text.
- Line counting (accessibilityInsertionPointLineNumber,
accessibilityLineForIndex:) uses a precomputed lineStartOffsets
array built once per cache rebuild. Queries are O(log L) via
binary search.
- No multi-frame coordination. EmacsView.accessibilityElements is
per-view; there is no cross-frame notification ordering.
- Overlay completion (0007) face matching uses string containment
("current", "selected", "selection"). Custom completion frameworks
with face names not containing these substrings will not be detected.
- Child frame completion (0008) static lastBuffer pointer may become
stale if the buffer is freed and a new one allocated at the same
address. This is harmless (worst case: one missed announcement).
- Child frame window-appeared announcement: macOS automatically
announces the window title when a child frame NSWindow appears.
This cannot be suppressed without breaking VoiceOver focus tracking
or Zoom integration.
- GNUstep is explicitly excluded (#ifdef NS_IMPL_COCOA). GNUstep
has a different accessibility model and requires separate work.
- Line navigation detection (ns_ax_event_is_line_nav_key) checks
Vthis_command against known navigation command symbols
(next-line, previous-line, evil-next-line, etc.) and falls back
to raw key codes for Tab/backtab. Custom navigation commands
not in the recognized list will not get forced line-granularity
announcements.
- UAZoomChangeFocus always uses kUAZoomFocusTypeInsertionPoint
regardless of cursor style (box, bar, hbar). This is cosmetically
imprecise but functionally correct.
TESTING CHECKLIST TESTING
----------------- -------
Prerequisites: See TESTING.txt for the full test matrix and results.
- macOS with VoiceOver (Cmd-F5 to toggle).
- Emacs built from source with this patch applied.
- Evil-mode recommended for block-cursor tests.
Basic text reading:
1. Open Emacs. Press Cmd-F5 to start VoiceOver.
2. Switch to Emacs (Cmd-Tab). VoiceOver should announce
"Emacs, editor" and read the current line.
3. Move cursor with arrow keys. VoiceOver should read each
character (left/right) or line (up/down) as you move.
4. Verify: right/left arrow reads the character AT the cursor
position, not the character left behind. (evil block-cursor)
Word and line navigation:
5. Press M-f / M-b (forward/backward word). VoiceOver should
announce the word landed on.
6. Press C-n / C-p. VoiceOver should read the full new line.
7. Hold Shift and press arrow keys to extend selection. VoiceOver
should announce the selected text.
Completion navigation:
8. Type M-x to open the minibuffer.
9. Type a partial command name. Press Tab to open *Completions*.
10. Press Tab / S-Tab to cycle through completions. VoiceOver
should announce each candidate name as you move.
11. Verify no double-speech (each candidate read exactly once).
Interactive span Tab navigation:
12. Open a buffer with buttons (e.g. M-x describe-key).
13. Use VoiceOver Item Chooser (VO-I) or Tab with VoiceOver
interaction mode to navigate interactive elements.
14. Verify each button/link is reachable and its label is read.
15. In an org-mode file with links, verify links appear as
separate navigable AXLink elements.
Mode line:
16. Use the VoiceOver cursor to navigate to the mode line below a
buffer. VoiceOver should read the mode line text.
Zoom integration:
17. Enable macOS Zoom (System Settings -> Accessibility -> Zoom).
18. Set Zoom to "Follow keyboard focus".
19. Move cursor in Emacs. Zoom viewport should track the cursor.
20. Verify Zoom follows the cursor across split windows.
Window operations:
21. Split window with C-x 2. VoiceOver should announce a layout
change. Switch with C-x o; VoiceOver should read the new
window content.
22. Delete a window with C-x 0. No crash should occur.
23. Switch buffers with C-x b. VoiceOver should read new buffer.
Deadlock regression (async notifications):
24. With VoiceOver on: M-x, type partial command, M-v to
*Completions*, Tab to a candidate, Enter to execute, then
C-x o to switch windows. Emacs must not hang.
Stress test (line index):
25. Open a large file (>50,000 lines). Navigate to the end with
M-> or C-v repeatedly. VoiceOver speech should remain fluid
at all positions (no progressive slowdown).
26. Open an org-mode file with many folded sections. Verify that
folded (invisible) text is not announced during navigation.
REVIEW CHANGES (post initial implementation)
---------------------------------------------
The following changes were made based on maintainer-style code review:
1. ns_ax_window_end_charpos: added window_end_valid guard. Falls
back to BUF_ZV when the window has not been fully redisplayed,
preventing stale data in AX getters called before next redisplay.
2. GC safety documentation: detailed comment on lispWindow ivar
explaining why staticpro is not needed (windows reachable from
frame tree, GC only on main thread, AX getters dispatch to main).
3. ns-accessibility-enabled (DEFVAR_BOOL): new user option to
disable accessibility entirely. Guards three entry points.
4. postAccessibilityNotificationsForFrame: extracted from one ~200
line method into four focused helpers:
- postTextChangedNotification: (typing echo)
- postFocusedCursorNotification:direction:granularity:markActive:
oldMarkActive: (focused cursor/selection)
- postCompletionAnnouncementForBuffer:point: (completions)
- postAccessibilityNotificationsForFrame: (orchestrator, ~60 lines)
5. ns_ax_completion_text_for_span: added block_input/unblock_input
with specpdl unwind protection for signal safety.
6. Fplist_get third-argument comment (PREDICATE, not default value).
7. Documentation: macos.texi section updated with
ns-accessibility-enabled variable reference. etc/NEWS updated.
-- end of README -- -- end of README --

View File

@@ -6,9 +6,41 @@ Date: 2026-02-28
Environment Environment
----------- -----------
Host: CM2D4G-A9635005 (macOS) Host: CM2D4G-A9635005 (macOS 14)
Base: emacs master (upstream HEAD at time of test) Base: emacs master (upstream HEAD at time of test)
PATCH A: ZOOM (0000)
=====================
1. Patch Application
--------------------
PASS — Standalone Zoom patch applies cleanly via git-am.
No conflicts, no warnings.
2. Build
--------
PASS — Full NS (Cocoa) build completed successfully.
No warnings related to Zoom code.
3. Zoom Cursor Tracking
------------------------
PASS — UAZoomChangeFocus integration:
- Typing in buffer: Zoom tracks cursor OK
- M-x: Zoom moves to minibuffer OK
- C-x 2, C-x o cycling: Zoom follows across split windows OK
- C-x 2, C-x o, C-p: Zoom follows cursor up after switch OK
(ns_update_end fallback ensures tracking)
4. No-Zoom Overhead
--------------------
PASS — UAZoomEnabled() returns false when Zoom is off.
Single function call overhead per redisplay cycle (negligible).
PATCH B: VOICEOVER (0001-0008)
===============================
1. Patch Application 1. Patch Application
-------------------- --------------------
PASS — All 8 patches applied cleanly via git-am: PASS — All 8 patches applied cleanly via git-am:
@@ -37,108 +69,77 @@ No warnings related to accessibility code.
--------------- ---------------
PASS — emacs -Q starts without errors or warnings. PASS — emacs -Q starts without errors or warnings.
4. Zoom Cursor Tracking 4. VoiceOver — Basic Navigation
------------------------
PASS — UAZoomChangeFocus integration working correctly:
- Typing in buffer: cursor tracked, Zoom follows OK
- M-x: Zoom moves focus to minibuffer OK
- M-x list- TAB M-v: switches to *Completions* buffer,
TAB cycles focus across completion candidates OK
- C-x 2, C-x 2, C-x 3 (multiple splits), then C-x o
cycling: Zoom focus correctly follows between windows OK
5. Documentation
----------------
PASS — Texinfo node accessible via C-h i g (emacs)VoiceOver Accessibility.
Node correctly linked from macOS appendix menu.
6. VoiceOver — Basic Navigation
-------------------------------- --------------------------------
PASS — VoiceOver active (Cmd+F5): PASS — VoiceOver active (Cmd+F5):
- Buffer name announced correctly on focus OK - Buffer name announced on focus OK
- Typing: each character announced as typed OK - Typing: each character announced OK
- Arrow keys / C-n / C-p: line-by-line navigation, - Arrow keys / C-n / C-p: line navigation announced OK
current line announced OK - Word navigation (M-f / M-b): word announced OK
- Word navigation: reads full current word OK
- M-x: switches to minibuffer, announces "minibuffer" OK - M-x: switches to minibuffer, announces "minibuffer" OK
7. VoiceOver — Completions 5. VoiceOver — Completions
--------------------------- ---------------------------
PASS — Completion buffer interaction: PASS — Completion buffer interaction:
- M-x list-* then M-v to switch to *Completions*: - M-x, partial command, Tab → *Completions* OK
buffer content read correctly OK - Tab cycling: announces each candidate OK
- TAB cycling in *Completions*: announces only the - No double-speech OK
current candidate (interactive span focus) OK
8. VoiceOver — Window Switching 6. VoiceOver — Window Switching
-------------------------------- --------------------------------
PASS — Multiple windows (C-x 2, C-x 3, C-x o cycling): PASS — Multiple windows (C-x 2, C-x 3, C-x o):
- Announces current buffer name and content on switch OK - Announces buffer name and content on switch OK
- Begins reading buffer content automatically OK - Notification priority/preemption working OK
- User action (typing, navigation) correctly interrupts
reading and announces new action instead OK
- Notification priority/preemption working as designed OK
9. VoiceOver — Full Buffer Reading 7. VoiceOver — Full Buffer Reading
----------------------------------- -----------------------------------
PASS — VO+A reads entire buffer including off-screen content. PASS — VO+A reads entire buffer including off-screen content.
- Cursor synchronization between Emacs and VoiceOver
virtual cursor working correctly OK
10. VoiceOver — Accessibility Tree 8. VoiceOver — Accessibility Tree
----------------------------------- -----------------------------------
PASS — Virtual element tree dynamically maintained: PASS — Virtual element tree dynamically maintained:
- New AX element created for each open buffer OK - Buffer elements created per window OK
- Minibuffer element present and readable OK - Mode-line elements readable via VO navigation OK
- Mode-line elements present per buffer, readable via - Tree updates on split/close OK
VoiceOver virtual navigation OK
- Tree correctly updates when windows are split/closed OK
11. VoiceOver — Selection 9. VoiceOver — Selection
-------------------------- --------------------------
PASS — C-SPC + cursor movement: PASS — C-SPC + movement: announces "selected" with region.
- Announces "selected" with region feedback OK
12. VoiceOver — Org-mode Invisible Text 10. VoiceOver — Org-mode Invisible Text
---------------------------------------- ----------------------------------------
PASS — Org-mode folding (Tab on headings): PASS — Folded text NOT read, unfolded text read correctly.
- Folded: hidden text NOT read by VoiceOver OK
- Unfolded: full content read correctly OK
- Invisible text filtering (TEXT_PROP_MEANS_INVISIBLE)
working as designed OK
13. ERT — ns-accessibility-enabled Variable 11. ERT — ns-accessibility-enabled Variable
-------------------------------------------- --------------------------------------------
PASS — Ran 1 test, 1 result as expected: PASS — ns-accessibility-enabled bound, defaults to t.
- ns-accessibility-enabled is bound OK
- ns-accessibility-enabled defaults to t OK
(ERT 1/1 passed, 2026-02-28 11:45:55 CET)
14. VoiceOver — Overlay Completion (Patch 0007) 12. VoiceOver — Overlay Completion (Patch 0007)
------------------------------------------------ ------------------------------------------------
PASS — Vertico minibuffer overlay completion: PASS — Vertico overlay completion:
- Vertico candidates announced on C-n / C-p navigation OK - Candidates announced on C-n / C-p OK
- Selected candidate face detected (vertico-current) OK - Selected face detected (vertico-current) OK
- Deduplication: same candidate not re-announced OK - Deduplication working OK
- Zoom tracks selected candidate in minibuffer - hl-line-mode compatibility (textDidChange flag) OK
(overlayZoomRect / overlayZoomActive lifecycle) OK
- overlayZoomActive cleared on text input OK
- hl-line-mode compatibility: cursor movement in dired
and read-only buffers correctly announces lines
(textDidChange flag decouples modiff branch from
cursor-move branch) OK
15. VoiceOver — Child Frame Completion (Patch 0008) 13. VoiceOver — Child Frame Completion (Patch 0008)
---------------------------------------------------- ----------------------------------------------------
PASS — Corfu child frame completion: PASS — Corfu child frame completion:
- Corfu popup candidates announced via VoiceOver OK - Candidates announced via VoiceOver OK
- Selected candidate face detected (corfu-current) OK - Selected face detected (corfu-current) OK
- Zoom tracks selected candidate in child frame - No Emacs freeze (re-entrance guard) OK
(direct UAZoomChangeFocus) OK - Focus restored to parent after popup closes OK
- No Emacs freeze (re-entrance guard before child frame - Non-completion child frames skipped (10KB limit) OK
dispatch, buffer switch with unbind_to on all paths) OK
- Focus restored to parent buffer after corfu closes 14. Performance — ns-accessibility-enabled=nil
(childFrameCompletionActive flag + FOR_EACH_FRAME -----------------------------------------------
visibility check + FocusedUIElementChanged) OK PASS — When set to nil:
- Non-completion child frames skipped (10KB buffer limit) OK - No virtual elements built OK
- specpdl_ref type used correctly (not ptrdiff_t) OK - No notifications posted OK
- ns_draw_window_cursor skips cursor rect store OK
- Zero measurable overhead OK
15. Documentation
-----------------
PASS — Texinfo node accessible via C-h i g (emacs)VoiceOver.
etc/NEWS entry present and accurate.