Files
emacs-doom/patches/0000-ns-integrate-with-macOS-Zoom-for-cursor-tracking.patch

461 lines
16 KiB
Diff
Raw Blame History

This file contains invisible Unicode characters
This file contains invisible Unicode characters that are indistinguishable to humans but may be processed differently by a computer. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
From edb3a37f853fc9b98dfc96a7ba0434e87e1fdc3e 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 | 353 +++++++++++++++++++++++++++++++++++++++++++++++++++
3 files changed, 370 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..fc75910 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,280 @@ 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);
+ }
+}
+
+#endif /* MAC_OS_X_VERSION_MIN_REQUIRED >= 101000 */
+#endif /* NS_IMPL_COCOA */
+
static void
ns_update_end (struct frame *f)
/* --------------------------------------------------------------------------
@@ -1104,6 +1383,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 +3546,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