diff --git a/etc/NEWS b/etc/NEWS index a6abd23b4ac..91ced37e847 100644 --- a/etc/NEWS +++ b/etc/NEWS @@ -4679,11 +4679,3 @@ GNU General Public License for more details. You should have received a copy of the GNU General Public License along with GNU Emacs. If not, see . - - -Local variables: -coding: utf-8 -mode: outline -mode: emacs-news -paragraph-separate: "[ ]" -end: diff --git a/src/nsterm.m b/src/nsterm.m index 0dbb59344a3..b443f5cc0c7 100644 --- a/src/nsterm.m +++ b/src/nsterm.m @@ -1205,6 +1205,304 @@ ns_UAZoomChangeFocus (EmacsView *view, BOOL force) else ns_update_was_UAZoomEnabled = NO; } + +/* 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_ZOOM_MAX_COMPLETION_CHARS 10000 + +/* Identify faces that mark a selected completion candidate. + Matches face names containing "current", "selected", or "selection", + as used by common completion frameworks. + Used by Zoom cursor tracking to identify the selected candidate. */ +/* Depth limit for CONSP recursion to guard against malformed + circular or deeply nested face specs. */ +#define NS_FACE_NAME_MATCH_DEPTH_LIMIT 10 + +static bool +ns_face_name_matches_selected_p_1 (Lisp_Object face, int depth) +{ + if (depth > NS_FACE_NAME_MATCH_DEPTH_LIMIT) + return false; + 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_face_name_matches_selected_p_1 (XCAR (tail), depth + 1)) + return true; + } + return false; +} + +static bool +ns_face_name_matches_selected_p (Lisp_Object face) +{ + return ns_face_name_matches_selected_p_1 (face, 0); +} + +/* 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 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; + + /* block_input must come before record_unwind_protect_void (unblock_input) + so that the unwind handler is never invoked without a matching + block_input, even if Foverlays_in or Foverlay_get signals. */ + specpdl_ref count = SPECPDL_INDEX (); + block_input (); + record_unwind_protect_void (unblock_input); + + if (!BUFFERP (w->contents)) + { + unbind_to (count, Qnil); + return -1; + } + struct buffer *b = XBUFFER (w->contents); + /* Foverlays_in operates on current_buffer, so switch if needed. */ + record_unwind_current_buffer (); + if (b != current_buffer) + set_buffer_internal_1 (b); + + /* Guard against dead markers: w->start may have buffer == NULL + after aggressive window manipulation (e.g. org-agenda refresh). + marker_position on a dead marker signals, which longjmps through + block_input and can cause abort on unblock_input underflow. */ + if (!MARKERP (w->start) || !XMARKER (w->start)->buffer) + { + unbind_to (count, Qnil); + return -1; + } + 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); + if (!OVERLAYP (ov)) + continue; + 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. + Use byte-level iteration to correctly handle multibyte + strings (SREF uses byte indices, not character indices). */ + const unsigned char *data = SDATA (str); + ptrdiff_t nbytes = SBYTES (str); + int line = 0; + ptrdiff_t char_pos = 0, byte_pos = 0, line_start_char = 0; + + while (byte_pos < nbytes) + { + if (data[byte_pos] == '\n') + { + if (char_pos > line_start_char) + { + /* Check the face at line_start_char. */ + Lisp_Object face + = Fget_text_property (make_fixnum (line_start_char), + Qface, str); + if (ns_face_name_matches_selected_p (face)) + { + unbind_to (count, Qnil); + return line; + } + } + line++; + line_start_char = char_pos + 1; + } + if (STRING_MULTIBYTE (str)) + byte_pos += BYTES_BY_CHAR_HEAD (data[byte_pos]); + else + byte_pos++; + char_pos++; + } + /* Check last line (no trailing newline). */ + if (char_pos > line_start_char) + { + Lisp_Object face + = Fget_text_property (make_fixnum (line_start_char), + Qface, str); + if (ns_face_name_matches_selected_p (face)) + { + unbind_to (count, Qnil); + return line; + } + } + } + unbind_to (count, Qnil); + 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_ZOOM_MAX_COMPLETION_CHARS) + continue; + + ptrdiff_t beg = BUF_BEGV (b); + ptrdiff_t zv = BUF_ZV (b); + int line = 0; + + specpdl_ref count = SPECPDL_INDEX (); + block_input (); + record_unwind_protect_void (unblock_input); + 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_face_name_matches_selected_p (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_ua_zoom_enabled_p ()) + return; + if (!WINDOWP (f->selected_window)) + return; + /* Child frame completion popups have no children to scan; + 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. */ + if (FRAME_PARENT_FRAME (f)) + return; + + specpdl_ref count = SPECPDL_INDEX (); + block_input (); + record_unwind_protect_void (unblock_input); + record_unwind_current_buffer (); + + struct window *w = XWINDOW (f->selected_window); + int line_h = FRAME_LINE_HEIGHT (f); + + /* 1. Check overlay-based completion candidates. */ + 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. This assumes the input line occupies the + first row of the minibuffer window. */ + 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 = ns_cg_rect_flip_y (NSRectToCGRect (screenRect)); + + UAZoomChangeFocus (&cgRect, &cgRect, + kUAZoomFocusTypeInsertionPoint); + unbind_to (count, Qnil); + return; + } + } + + /* 2. Check child frame completions. */ + 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 = ns_cg_rect_flip_y (NSRectToCGRect (screenRect)); + + UAZoomChangeFocus (&cgRect, &cgRect, + kUAZoomFocusTypeInsertionPoint); + } + unbind_to (count, Qnil); +} + #endif /* NS_IMPL_COCOA */ static void @@ -1230,6 +1528,12 @@ ns_update_end (struct frame *f) #ifdef NS_IMPL_COCOA ns_UAZoomChangeFocus (view, false); + + /* 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 unblock_input ();