charposForAccessibilityIndex: walked composed character sequences from the start of a visible run to the target AX index. For a run covering an entire ASCII buffer, this is O(cursor_position): moving to line 10,000 requires ~500,000 iterations per call. The method is called on every SelectedTextChanged notification response (accessibilityBoundsForRange: from the AX server for cursor tracking), making cursor movement O(position) in large files. Fix: when ax_length == length for a run (all characters are single AX index units — true for all ASCII/Latin text), the charpos offset is simply ax_idx - run.ax_start. O(1) instead of O(position). Multi-byte runs (emoji, CJK, non-BMP) fall back to the sequence walk, bounded by run length (visible window size), not total buffer size.
447 lines
15 KiB
Diff
447 lines
15 KiB
Diff
From 03a3e77f9ff5f46429964863a2f320e119c0686c Mon Sep 17 00:00:00 2001
|
||
From: Martin Sukany <martin@sukany.cz>
|
||
Date: Sat, 28 Feb 2026 22:39:35 +0100
|
||
Subject: [PATCH 0/8] 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.
|
||
|
||
Basic cursor tracking:
|
||
* 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 (e.g. after C-x o window switch).
|
||
Gated by zoomCursorUpdated to avoid double calls.
|
||
|
||
Completion candidate tracking:
|
||
* src/nsterm.m (ns_zoom_face_is_selected): New predicate.
|
||
Match 'current', 'selected', and 'selection' in face symbol
|
||
names to identify the highlighted completion candidate.
|
||
(ns_zoom_find_overlay_candidate_line): Scan overlay
|
||
before-string/after-string for the selected candidate line.
|
||
Handles Vertico, Icomplete, Ivy, and similar overlay frameworks.
|
||
(ns_zoom_find_child_frame_candidate): Scan child frame buffer
|
||
text for the selected candidate. Handles Corfu, Company-box,
|
||
and similar child frame frameworks.
|
||
(ns_zoom_track_completion): Called from ns_update_end after
|
||
cursor tracking. Overrides Zoom focus to the selected
|
||
completion candidate when one is found.
|
||
|
||
Coordinate conversion: EmacsView pixels (AppKit, flipped) ->
|
||
NSWindow -> NSScreen -> CGRect with y-flip for CoreGraphics
|
||
top-left origin.
|
||
|
||
Tested on macOS 14 with Zoom enabled: cursor tracking works across
|
||
window splits, switches (C-x o), and completion frameworks.
|
||
---
|
||
etc/NEWS | 11 ++
|
||
src/nsterm.h | 6 +
|
||
src/nsterm.m | 336 +++++++++++++++++++++++++++++++++++++++++++++++++++
|
||
3 files changed, 353 insertions(+)
|
||
|
||
diff --git a/etc/NEWS b/etc/NEWS
|
||
index ef36df5..80661a9 100644
|
||
--- a/etc/NEWS
|
||
+++ b/etc/NEWS
|
||
@@ -82,6 +82,17 @@ 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. Completion frameworks (Vertico, Icomplete, Ivy for overlay
|
||
+candidates; Corfu, Company-box for child frame popups) are also
|
||
+tracked: Zoom follows the selected candidate rather than the text
|
||
+cursor during completion.
|
||
+
|
||
+++
|
||
** '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..5498d7a 100644
|
||
--- a/src/nsterm.m
|
||
+++ b/src/nsterm.m
|
||
@@ -1081,6 +1081,268 @@ static NSRect constrain_frame_rect(NSRect frameRect, bool isFullscreen)
|
||
}
|
||
|
||
|
||
+
|
||
+#ifdef NS_IMPL_COCOA
|
||
+#if defined (MAC_OS_X_VERSION_MIN_REQUIRED) \
|
||
+ && MAC_OS_X_VERSION_MIN_REQUIRED >= 101000
|
||
+
|
||
+/* Cached wrapper around ns_zoom_enabled_p ().
|
||
+ ns_zoom_enabled_p () performs a synchronous Mach IPC roundtrip to the
|
||
+ macOS Accessibility server (~50-200 µs per call). With call sites
|
||
+ in ns_draw_window_cursor, ns_update_end, and ns_zoom_track_completion,
|
||
+ the overhead accumulates to ~150-600 µs per redisplay cycle. Zoom
|
||
+ state changes only on explicit user action in System Settings, so a
|
||
+ 1-second TTL is safe and indistinguishable from querying every frame.
|
||
+ Uses CFAbsoluteTimeGetCurrent() (~5 ns, a VDSO read) for timing. */
|
||
+static BOOL ns_zoom_cached_enabled;
|
||
+static CFAbsoluteTime ns_zoom_cache_time;
|
||
+
|
||
+static BOOL
|
||
+ns_zoom_enabled_p (void)
|
||
+{
|
||
+ CFAbsoluteTime now = CFAbsoluteTimeGetCurrent ();
|
||
+ if (now - ns_zoom_cache_time > 1.0)
|
||
+ {
|
||
+ ns_zoom_cached_enabled = UAZoomEnabled ();
|
||
+ ns_zoom_cache_time = now;
|
||
+ }
|
||
+ return ns_zoom_cached_enabled;
|
||
+}
|
||
+
|
||
+/* Identify faces that mark a selected completion candidate.
|
||
+ Matches vertico-current, corfu-current, icomplete-selected-match,
|
||
+ ivy-current-match, etc. by checking the face symbol name.
|
||
+ Defined here so the Zoom patch compiles independently of the
|
||
+ VoiceOver patches. */
|
||
+static bool
|
||
+ns_zoom_face_is_selected (Lisp_Object face)
|
||
+{
|
||
+ if (SYMBOLP (face))
|
||
+ {
|
||
+ const char *name = SSDATA (SYMBOL_NAME (face));
|
||
+ return (strstr (name, "current") != NULL
|
||
+ || strstr (name, "selected") != NULL
|
||
+ || strstr (name, "selection") != NULL);
|
||
+ }
|
||
+ if (CONSP (face))
|
||
+ {
|
||
+ Lisp_Object tail;
|
||
+ for (tail = face; CONSP (tail); tail = XCDR (tail))
|
||
+ if (ns_zoom_face_is_selected (XCAR (tail)))
|
||
+ return true;
|
||
+ }
|
||
+ return false;
|
||
+}
|
||
+
|
||
+/* Scan overlay before-string / after-string properties in the
|
||
+ selected window for a completion candidate with a "selected"
|
||
+ face. Return the 0-based visual line index of the selected
|
||
+ candidate, or -1 if none found. */
|
||
+static int
|
||
+ns_zoom_find_overlay_candidate_line (struct window *w)
|
||
+{
|
||
+ /* Overlay completion frameworks (Vertico, Icomplete, Ivy) place
|
||
+ candidates as overlay strings in the minibuffer only. Scanning
|
||
+ overlays in large normal buffers causes O(overlays) work per
|
||
+ redisplay --- return immediately for non-minibuffer windows. */
|
||
+ if (!MINI_WINDOW_P (w))
|
||
+ return -1;
|
||
+
|
||
+ struct buffer *b = XBUFFER (w->contents);
|
||
+ ptrdiff_t beg = marker_position (w->start);
|
||
+ ptrdiff_t end = BUF_ZV (b);
|
||
+ Lisp_Object overlays = Foverlays_in (make_fixnum (beg),
|
||
+ make_fixnum (end));
|
||
+ Lisp_Object tail;
|
||
+
|
||
+ for (tail = overlays; CONSP (tail); tail = XCDR (tail))
|
||
+ {
|
||
+ Lisp_Object ov = XCAR (tail);
|
||
+ Lisp_Object str = Foverlay_get (ov, Qbefore_string);
|
||
+
|
||
+ if (NILP (str))
|
||
+ str = Foverlay_get (ov, Qafter_string);
|
||
+ if (!STRINGP (str) || SCHARS (str) < 2)
|
||
+ continue;
|
||
+
|
||
+ /* Walk the string line by line, checking faces. */
|
||
+ ptrdiff_t len = SCHARS (str);
|
||
+ int line = 0;
|
||
+ ptrdiff_t line_start = 0;
|
||
+
|
||
+ for (ptrdiff_t i = 0; i <= len; i++)
|
||
+ {
|
||
+ bool at_newline = (i == len
|
||
+ || SREF (str, i) == '\n');
|
||
+ if (at_newline && i > line_start)
|
||
+ {
|
||
+ /* Check the face at line_start. */
|
||
+ Lisp_Object face
|
||
+ = Fget_text_property (make_fixnum (line_start),
|
||
+ Qface, str);
|
||
+ if (ns_zoom_face_is_selected (face))
|
||
+ return line;
|
||
+ line++;
|
||
+ line_start = i + 1;
|
||
+ }
|
||
+ else if (at_newline)
|
||
+ {
|
||
+ line++;
|
||
+ line_start = i + 1;
|
||
+ }
|
||
+ }
|
||
+ }
|
||
+ return -1;
|
||
+}
|
||
+
|
||
+/* Scan child frames for a completion popup with a selected
|
||
+ candidate. Return the 0-based line index, or -1 if none.
|
||
+ Set *CHILD_FRAME to the child frame if found. */
|
||
+static int
|
||
+ns_zoom_find_child_frame_candidate (struct frame *f,
|
||
+ struct frame **child_frame)
|
||
+{
|
||
+ Lisp_Object frames, tail;
|
||
+
|
||
+ FOR_EACH_FRAME (tail, frames)
|
||
+ {
|
||
+ struct frame *cf = XFRAME (frames);
|
||
+ if (!FRAME_NS_P (cf) || !FRAME_LIVE_P (cf))
|
||
+ continue;
|
||
+ if (FRAME_PARENT_FRAME (cf) != f)
|
||
+ continue;
|
||
+ /* Small buffer = likely completion popup. */
|
||
+ struct window *cw = XWINDOW (cf->selected_window);
|
||
+ struct buffer *b = XBUFFER (cw->contents);
|
||
+ if (BUF_ZV (b) - BUF_BEGV (b) > 10000)
|
||
+ continue;
|
||
+
|
||
+ ptrdiff_t beg = BUF_BEGV (b);
|
||
+ ptrdiff_t zv = BUF_ZV (b);
|
||
+ int line = 0;
|
||
+
|
||
+ specpdl_ref count = SPECPDL_INDEX ();
|
||
+ record_unwind_current_buffer ();
|
||
+ set_buffer_internal_1 (b);
|
||
+
|
||
+ ptrdiff_t pos = beg;
|
||
+ while (pos < zv)
|
||
+ {
|
||
+ Lisp_Object face
|
||
+ = Fget_char_property (make_fixnum (pos), Qface,
|
||
+ cw->contents);
|
||
+ if (ns_zoom_face_is_selected (face))
|
||
+ {
|
||
+ unbind_to (count, Qnil);
|
||
+ *child_frame = cf;
|
||
+ return line;
|
||
+ }
|
||
+ /* Advance to next line. */
|
||
+ ptrdiff_t next = find_newline (pos, -1, zv, -1,
|
||
+ 1, NULL, NULL, false);
|
||
+ if (next <= pos)
|
||
+ break;
|
||
+ pos = next;
|
||
+ line++;
|
||
+ }
|
||
+ unbind_to (count, Qnil);
|
||
+ }
|
||
+ return -1;
|
||
+}
|
||
+
|
||
+/* Update Zoom focus based on completion candidates.
|
||
+ Called from ns_update_end after normal cursor tracking.
|
||
+ If a completion candidate is selected (overlay or child frame),
|
||
+ move Zoom to that candidate instead of the text cursor. */
|
||
+static void
|
||
+ns_zoom_track_completion (struct frame *f, EmacsView *view)
|
||
+{
|
||
+ if (!ns_zoom_enabled_p ())
|
||
+ return;
|
||
+ if (!WINDOWP (f->selected_window))
|
||
+ return;
|
||
+ /* Child frames (e.g. the Corfu popup itself) have no children to
|
||
+ scan for completion candidates; their parent frame's ns_update_end
|
||
+ will scan them via FOR_EACH_FRAME. Return early to avoid a
|
||
+ redundant O(frames) scan on every child-frame redisplay cycle.
|
||
+ Note: the rate limit that was here caused corfu tracking to fail:
|
||
+ the child frame's ns_update_end reset the timer, so the parent
|
||
+ frame's subsequent ns_update_end returned early without scanning. */
|
||
+ if (FRAME_PARENT_FRAME (f))
|
||
+ return;
|
||
+
|
||
+ specpdl_ref count = SPECPDL_INDEX ();
|
||
+ record_unwind_current_buffer ();
|
||
+
|
||
+ struct window *w = XWINDOW (f->selected_window);
|
||
+ int line_h = FRAME_LINE_HEIGHT (f);
|
||
+
|
||
+ /* 1. Check overlay completions (Vertico, Icomplete, Ivy). */
|
||
+ int ov_line = ns_zoom_find_overlay_candidate_line (w);
|
||
+ if (ov_line >= 0)
|
||
+ {
|
||
+ /* Overlay candidates typically start after the input line,
|
||
+ so the visual offset is (ov_line + 1) * line_h from
|
||
+ the window top. */
|
||
+ int y_off = (ov_line + 1) * line_h;
|
||
+ if (y_off < w->pixel_height)
|
||
+ {
|
||
+ NSRect r = NSMakeRect (
|
||
+ WINDOW_TEXT_TO_FRAME_PIXEL_X (w, 0),
|
||
+ WINDOW_TO_FRAME_PIXEL_Y (w, y_off),
|
||
+ FRAME_COLUMN_WIDTH (f),
|
||
+ line_h);
|
||
+
|
||
+ 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);
|
||
+ unbind_to (count, Qnil);
|
||
+ return;
|
||
+ }
|
||
+ }
|
||
+
|
||
+ /* 2. Check child frame completions (Corfu, Company-box). */
|
||
+ struct frame *cf = NULL;
|
||
+ int cf_line = ns_zoom_find_child_frame_candidate (f, &cf);
|
||
+ if (cf_line >= 0 && cf)
|
||
+ {
|
||
+ EmacsView *cv = FRAME_NS_VIEW (cf);
|
||
+ struct window *cw
|
||
+ = XWINDOW (cf->selected_window);
|
||
+ int cf_line_h = FRAME_LINE_HEIGHT (cf);
|
||
+ int y_off = cf_line * cf_line_h;
|
||
+
|
||
+ NSRect r = NSMakeRect (
|
||
+ WINDOW_TEXT_TO_FRAME_PIXEL_X (cw, 0),
|
||
+ WINDOW_TO_FRAME_PIXEL_Y (cw, y_off),
|
||
+ FRAME_COLUMN_WIDTH (cf),
|
||
+ cf_line_h);
|
||
+
|
||
+ NSRect windowRect = [cv convertRect:r toView:nil];
|
||
+ NSRect screenRect
|
||
+ = [[cv 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 /* NS_IMPL_COCOA */
|
||
+
|
||
static void
|
||
ns_update_end (struct frame *f)
|
||
/* --------------------------------------------------------------------------
|
||
@@ -1104,6 +1366,41 @@ 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 && ns_zoom_enabled_p ()
|
||
+ && !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
|
||
+
|
||
+ /* Track completion candidates for Zoom (overlay and child frame).
|
||
+ Runs after cursor tracking so the selected candidate overrides
|
||
+ the default cursor position. */
|
||
+ if (view)
|
||
+ ns_zoom_track_completion (f, view);
|
||
+#endif /* NS_IMPL_COCOA */
|
||
}
|
||
|
||
static void
|
||
@@ -3232,6 +3529,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 (ns_zoom_enabled_p ())
|
||
+ {
|
||
+ 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
|
||
|