C1 - block_input ordering in ns_ax_buffer_text: block_input() now called before record_unwind_protect_void(unblock_input). Previously the unwind handler could have been called without a matching block_input, corrupting the input-blocking reference count. C2 - unbind_to missing in patch 0004: unbind_to(blk_count, Qnil) moved from patch 0008 to patch 0004 so that ns_ax_scan_interactive_spans has a complete block_input/unbind_to pair when patches 0000-0004 are applied independently. H1 - Zoom patch forward dependency on VoiceOver: Removed forward declaration 'static bool ns_ax_face_is_selected' and the delegation from ns_zoom_face_is_selected. Restored standalone implementation of ns_zoom_face_is_selected in the Zoom patch so patch 0000 compiles and links independently of the VoiceOver patches. H2 - ns_accessibility_enabled removal undocumented: Added comment to ns_zoom_track_completion explaining that Zoom cursor tracking is gated only on ns_zoom_enabled_p(), not ns_accessibility_enabled. Users running Zoom without VoiceOver must still get completion tracking. M5 - childFrameLastBuffer GC safety undocumented: Added comment at the assignment site explaining why BVAR(b, name) (an interned symbol reachable from obarray) is GC-safe without staticpro. M6 - FOR_EACH_FRAME without block_input: Added block_input/unblock_input around the FOR_EACH_FRAME loop in postAccessibilityUpdates that checks for visible child frames. Vframe_list must not be modified by timers or process sentinels during iteration.
462 lines
16 KiB
Diff
462 lines
16 KiB
Diff
From 30d31b473d43ff800f4f8d21f913c9e50acc6ad3 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. Also track completion candidates so
|
||
Zoom follows the selected item (Vertico, Corfu, etc.) during completion.
|
||
|
||
* etc/NEWS: Document Zoom integration.
|
||
* src/nsterm.h (EmacsView): Add lastCursorRect, zoomCursorUpdated.
|
||
* src/nsterm.m: Include ApplicationServices for UAZoomEnabled and
|
||
UAZoomChangeFocus (UniversalAccess sub-framework).
|
||
[NS_IMPL_COCOA]: Define NS_AX_MAX_COMPLETION_BUFFER_CHARS.
|
||
(ns_zoom_enabled_p): New static function; caches UAZoomEnabled with
|
||
1-second TTL to avoid per-frame Mach IPC overhead.
|
||
(ns_zoom_face_is_selected): New static predicate; matches 'current',
|
||
'selected', 'selection' in face symbol names.
|
||
(ns_zoom_find_overlay_candidate_line): New static function; scans
|
||
minibuffer overlays for the selected completion candidate line.
|
||
(ns_zoom_find_child_frame_candidate): New static function; scans
|
||
child frame buffers for a selected candidate; guards against partially
|
||
initialized frames with WINDOWP and BUFFERP checks.
|
||
(ns_zoom_track_completion): New static function; overrides Zoom focus
|
||
to the selected completion candidate after normal cursor tracking.
|
||
(ns_update_end): Call ns_zoom_track_completion.
|
||
(ns_draw_window_cursor): Store cursor rect; call UAZoomChangeFocus.
|
||
---
|
||
etc/NEWS | 11 ++
|
||
src/nsterm.h | 6 +
|
||
src/nsterm.m | 354 +++++++++++++++++++++++++++++++++++++++++++++++++++
|
||
3 files changed, 371 insertions(+)
|
||
|
||
diff --git a/etc/NEWS b/etc/NEWS
|
||
index 7367e3ccbd..4c149e41d6 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 7c1ee4cf53..ea6e7ba4f5 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 932d209f56..88c9251c18 100644
|
||
--- a/src/nsterm.m
|
||
+++ b/src/nsterm.m
|
||
@@ -71,6 +71,11 @@ Updated by Christian Limpach (chris@nice.ch)
|
||
#include "macfont.h"
|
||
#include <Carbon/Carbon.h>
|
||
#include <IOSurface/IOSurface.h>
|
||
+/* ApplicationServices provides UAZoomEnabled and UAZoomChangeFocus
|
||
+ (UniversalAccess sub-framework). Carbon.h already pulls in
|
||
+ ApplicationServices on most SDK versions, but the explicit import
|
||
+ makes the dependency visible and guards against SDK changes. */
|
||
+#import <ApplicationServices/ApplicationServices.h>
|
||
#endif
|
||
|
||
static EmacsMenu *dockMenu;
|
||
@@ -1081,6 +1086,281 @@ 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
|
||
+
|
||
+/* Maximum buffer size (in characters) for a window that we consider
|
||
+ a candidate for a completion popup. Completion popups are small;
|
||
+ if the buffer is larger than this, it is not a popup and we skip it
|
||
+ to avoid O(buffer-size) work per redisplay cycle. */
|
||
+#define NS_AX_MAX_COMPLETION_BUFFER_CHARS 10000
|
||
+
|
||
+/* 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 frame, tail;
|
||
+
|
||
+ FOR_EACH_FRAME (tail, frame)
|
||
+ {
|
||
+ struct frame *cf = XFRAME (frame);
|
||
+ if (!FRAME_NS_P (cf) || !FRAME_LIVE_P (cf))
|
||
+ continue;
|
||
+ if (FRAME_PARENT_FRAME (cf) != f)
|
||
+ continue;
|
||
+ /* Small buffer = likely completion popup. Guard against
|
||
+ partially initialized frames where selected_window or its
|
||
+ buffer may not yet be live. */
|
||
+ if (!WINDOWP (cf->selected_window))
|
||
+ continue;
|
||
+ struct window *cw = XWINDOW (cf->selected_window);
|
||
+ if (!BUFFERP (cw->contents))
|
||
+ continue;
|
||
+ struct buffer *b = XBUFFER (cw->contents);
|
||
+ if (BUF_ZV (b) - BUF_BEGV (b) > NS_AX_MAX_COMPLETION_BUFFER_CHARS)
|
||
+ 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);
|
||
+ }
|
||
+ unbind_to (count, Qnil);
|
||
+}
|
||
+
|
||
+#endif /* MAC_OS_X_VERSION_MIN_REQUIRED >= 101000 */
|
||
+#endif /* NS_IMPL_COCOA */
|
||
+
|
||
static void
|
||
ns_update_end (struct frame *f)
|
||
/* --------------------------------------------------------------------------
|
||
@@ -1104,6 +1384,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 +3547,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
|
||
|