ns: track completion candidates for macOS Zoom

When a completion framework displays candidates (overlay-based or
child-frame popup), override Zoom focus to the selected candidate
so the zoomed viewport follows the user's selection rather than the
text cursor.

* src/nsterm.m (NS_ZOOM_MAX_COMPLETION_CHARS): New macro.
(ns_face_name_matches_selected_p_1): New static helper; recursive
face name match with depth-limit guard.
(ns_face_name_matches_selected_p): New static predicate; matches
'current', 'selected', 'selection' in face names.
(ns_zoom_find_overlay_candidate_line): New static function; scans
minibuffer overlays for selected completion candidate.
(ns_zoom_find_child_frame_candidate): New static function; scans
child frame buffers for selected candidate.
(ns_zoom_track_completion): New static function; overrides Zoom
focus to selected completion candidate.
(ns_update_end): Call ns_zoom_track_completion.
* etc/NEWS: Document completion tracking for Zoom.
This commit is contained in:
2026-03-15 16:15:06 +01:00
committed by Martin Sukany
parent eda9a819ce
commit c4e6ef142b
2 changed files with 304 additions and 8 deletions

View File

@@ -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 <https://www.gnu.org/licenses/>.
Local variables:
coding: utf-8
mode: outline
mode: emacs-news
paragraph-separate: "[ ]"
end:

View File

@@ -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 ();