From 38bbd275736251862f4b2fb433ee0a84799f0cec Mon Sep 17 00:00:00 2001 From: Martin Sukany Date: Sat, 28 Feb 2026 22:39:35 +0100 Subject: [PATCH 1/9] 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 | 339 +++++++++++++++++++++++++++++++++++++++++++++++++++ 3 files changed, 356 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..6527750 100644 --- a/src/nsterm.m +++ b/src/nsterm.m @@ -1081,6 +1081,271 @@ 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; + + /* Rate-limit completion scan to 20 Hz (50 ms). UAZoomEnabled() is + now cached so the main cost is the overlay/child-frame scan. + 50 ms is imperceptible for completion navigation while preventing + per-frame FOR_EACH_FRAME overhead. */ + { + static CFAbsoluteTime last_check; + CFAbsoluteTime now = CFAbsoluteTimeGetCurrent (); + if (now - last_check < 0.05) + return; + last_check = now; + } + + 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 +1369,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 +3532,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