Metal: render non-GPU frames through the native backend, plus gap fixes

The patched rif and terminal hooks are terminal-wide, but only frames
the user enabled the GPU backend on have driver data: tooltips, child
frames (posframe and friends) and frames created without mtl-enable
came up blank.  Capture the original NS implementations before patching
(gfx_fallback) and delegate every policy entry point to them for frames
the driver is not ready on.  Verified: tooltip, posframe-style child
frame and a second top-level frame all render identically to a pure NS
session while the main frame renders through Metal.

Also resolved while auditing the display feature matrix:

- Underline styles dots and dashes (FACE_UNDERLINE_DOTS/DASHES) were
  drawn as a solid line; port ns_draw_dash ([segment, segment] pattern
  anchored at the absolute x so it joins across glyph strings).
- :underline (:position N) larger than the descent crossed the glyphs:
  NS computes the descent as unsigned, so the position underflows and
  clamps to the row bottom; mirror that clamp.
- The internal border was cleared with the frame background; port
  ns_clear_under_internal_border (internal-border/child-frame-border
  face, face remapping, top/bottom margins).
- With cursor animations enabled the GPU cursor never blinked
  (blink toggles visibility through erase_phys_cursor, which an overlay
  never sees): the animation tick now mirrors the window's cursor_off_p
  and hides the overlay cursor and trail during the off phase.
- Before the first cursor draw the overlay used a hardcoded pale tint;
  fall back to the frame cursor color.

No regressions: baseline 141 px and all capture batteries unchanged.
This commit is contained in:
Andros Fenollosa 2026-06-04 15:05:59 +02:00
parent 00f3bc2f3f
commit 2c41751831
4 changed files with 271 additions and 40 deletions

View file

@ -172,6 +172,22 @@ struct gfx_driver
Metal sets it in mtl_setup_frame). One driver per process. */
extern struct gfx_driver *gfx_drv;
/* Original platform implementations, captured before the rif/terminal
hooks were patched. The patched hooks are TERMINAL-wide, but only
frames the user enabled the GPU backend on have driver data: every
other frame on the terminal (tooltips, child frames like posframe,
frames created later without mtl-enable) must keep rendering through
the platform's own backend, or they come up blank. */
struct gfx_fallback_fns
{
struct redisplay_interface *rif;
void (*update_begin) (struct frame *f);
void (*update_end) (struct frame *f);
void (*clear_frame) (struct frame *f);
void (*frame_up_to_date) (struct frame *f);
};
extern struct gfx_fallback_fns gfx_fallback;
/* -----------------------------------------------------------------------
Platform-neutral drawing policy (gfxterm.c): the redisplay_interface /
terminal hook implementations shared by every gfx driver.

View file

@ -51,6 +51,13 @@ along with GNU Emacs. If not, see <https://www.gnu.org/licenses/>.
before any policy function can run. */
struct gfx_driver *gfx_drv = NULL;
/* Original platform implementations for frames the GPU backend is NOT
enabled on (tooltips, child frames, frames made without mtl-enable):
the patched rif/hooks are terminal-wide, so without this delegation
those frames would render nothing at all. Captured by the platform
glue before patching (mtl_patch_terminal_rif). */
struct gfx_fallback_fns gfx_fallback = { NULL, NULL, NULL, NULL, NULL };
/* Diagnostic counters surfaced by mtl-draw-stats. */
int mtl_dgs_call_count = 0; /* total draw_glyph_string calls */
int mtl_dgs_nofd_count = 0; /* no driver data or no cycle */
@ -426,9 +433,12 @@ gfx_underline_metrics (struct glyph_string *s, int *position, int *thickness)
if (!s->face->underline_pixels_above_descent_line)
pos = max (pos, minimum_offset);
/* Keep the underline inside the cell. */
if (descent <= pos) { pos = descent - 1; th = 1; }
else if (descent < pos + th) th = 1;
/* Keep the underline inside the cell. NS computes DESCENT as
unsigned, so a :position larger than the descent UNDERFLOWS and the
first clamp snaps it to the row bottom; mirror that (pos < 0 below)
or a big :position would cross the glyphs instead. */
if (pos < 0 || descent <= pos) { pos = descent - 1; th = 1; }
else if (descent < pos + th) th = 1;
*position = pos;
*thickness = th;
@ -724,14 +734,38 @@ gfx_draw_glyph_string_impl (struct glyph_string *s)
s->underline_position = position;
unsigned long uc = face->underline_defaulted_p
? fg : face->underline_color;
gfx_drv->fill_rect (f, s->x, s->ybase + position,
s->width, thickness, uc);
/* Second line above the first for double underline. */
if (face->underline == FACE_UNDERLINE_DOUBLE_LINE)
if (face->underline == FACE_UNDERLINE_DOTS
|| face->underline == FACE_UNDERLINE_DASHES)
{
int p2 = position - thickness - 1;
gfx_drv->fill_rect (f, s->x, s->ybase + p2,
/* Port of ns_draw_dash: [SEGMENT on, SEGMENT off] anchored at
the absolute x (phase = s->x in NS), so the pattern stays
continuous across adjacent glyph strings. Dots use a
segment of THICKNESS, dashes 3*THICKNESS. */
int seg = (face->underline == FACE_UNDERLINE_DOTS
? thickness : thickness * 3);
int cycle = seg * 2;
int x0 = s->x, x1 = s->x + s->width;
int cx = x0 - (((x0 % cycle) + cycle) % cycle);
for (; cx < x1; cx += cycle)
{
int from = max (cx, x0);
int to = min (cx + seg, x1);
if (to > from)
gfx_drv->fill_rect (f, from, s->ybase + position,
to - from, thickness, uc);
}
}
else
{
gfx_drv->fill_rect (f, s->x, s->ybase + position,
s->width, thickness, uc);
/* Second line above the first for double underline. */
if (face->underline == FACE_UNDERLINE_DOUBLE_LINE)
{
int p2 = position - thickness - 1;
gfx_drv->fill_rect (f, s->x, s->ybase + p2,
s->width, thickness, uc);
}
}
}
@ -776,7 +810,12 @@ gfx_draw_glyph_string_impl (struct glyph_string *s)
void
gfx_draw_glyph_string (struct glyph_string *s)
{
if (!gfx_ready (s->f)) return;
if (!gfx_ready (s->f))
{
if (gfx_fallback.rif && gfx_fallback.rif->draw_glyph_string)
gfx_fallback.rif->draw_glyph_string (s);
return;
}
if (gfx_drv->in_cycle (s->f))
{
@ -803,7 +842,12 @@ gfx_draw_glyph_string (struct glyph_string *s)
void
gfx_clear_frame (struct frame *f)
{
if (!gfx_ready (f)) return;
if (!gfx_ready (f))
{
if (gfx_fallback.clear_frame)
gfx_fallback.clear_frame (f);
return;
}
/* clear_garbaged_frames calls this BEFORE update_begin (no cycle), e.g.
when the minibuffer resizes back after a two-line message. The
@ -827,7 +871,12 @@ gfx_clear_frame (struct frame *f)
void
gfx_clear_frame_area (struct frame *f, int x, int y, int width, int height)
{
if (!gfx_ready (f)) return;
if (!gfx_ready (f))
{
if (gfx_fallback.rif && gfx_fallback.rif->clear_frame_area)
gfx_fallback.rif->clear_frame_area (f, x, y, width, height);
return;
}
gfx_drv->fill_rect (f, x, y, width, height,
gfx_drv->frame_background (f));
}
@ -835,13 +884,43 @@ gfx_clear_frame_area (struct frame *f, int x, int y, int width, int height)
void
gfx_clear_under_internal_border (struct frame *f)
{
int b = FRAME_INTERNAL_BORDER_WIDTH (f);
if (b <= 0) return;
int w = FRAME_PIXEL_WIDTH (f), h = FRAME_PIXEL_HEIGHT (f);
gfx_clear_frame_area (f, 0, 0, w, b);
gfx_clear_frame_area (f, 0, h-b, w, b);
gfx_clear_frame_area (f, 0, 0, b, h);
gfx_clear_frame_area (f, w-b, 0, b, h);
if (!gfx_ready (f))
{
if (gfx_fallback.rif && gfx_fallback.rif->clear_under_internal_border)
gfx_fallback.rif->clear_under_internal_border (f);
return;
}
int border = FRAME_INTERNAL_BORDER_WIDTH (f);
if (border <= 0 || !FRAME_LIVE_P (f)) return;
/* Port of ns_clear_under_internal_border: the border is painted with
the internal-border face background (child-frame-border for child
frames), honoring face remapping, and skips the top margin rows
(menu/tool/tab bars). */
int width = FRAME_PIXEL_WIDTH (f);
int height = FRAME_PIXEL_HEIGHT (f);
int margin = FRAME_TOP_MARGIN_HEIGHT (f);
int bottom_margin = FRAME_BOTTOM_MARGIN_HEIGHT (f);
int face_id =
(FRAME_PARENT_FRAME (f)
? (!NILP (Vface_remapping_alist)
? lookup_basic_face (NULL, f, CHILD_FRAME_BORDER_FACE_ID)
: CHILD_FRAME_BORDER_FACE_ID)
: (!NILP (Vface_remapping_alist)
? lookup_basic_face (NULL, f, INTERNAL_BORDER_FACE_ID)
: INTERNAL_BORDER_FACE_ID));
struct face *face = FACE_FROM_ID_OR_NULL (f, face_id);
if (!face)
face = FACE_FROM_ID_OR_NULL (f, DEFAULT_FACE_ID);
unsigned long bg = face ? face->background
: gfx_drv->frame_background (f);
gfx_drv->fill_rect (f, 0, margin, width, border, bg);
gfx_drv->fill_rect (f, 0, 0, border, height, bg);
gfx_drv->fill_rect (f, width - border, 0, border, height, bg);
gfx_drv->fill_rect (f, 0, height - bottom_margin - border,
width, border, bg);
}
/* -----------------------------------------------------------------------
@ -854,7 +933,12 @@ gfx_flush_display (struct frame *f)
/* Present the current cycle if one is in progress. Do NOT start a new
one here -- that is update_begin's responsibility (starting one here
once left an orphaned encoder that never got closed). */
if (!gfx_ready (f)) return;
if (!gfx_ready (f))
{
if (gfx_fallback.rif && gfx_fallback.rif->flush_display)
gfx_fallback.rif->flush_display (f);
return;
}
GFX_SEQ ("flush_display (in_cycle=%d pending=%d)",
(int) gfx_drv->in_cycle (f), (int) gfx_drv->pending_present (f));
if (gfx_drv->in_cycle (f))
@ -868,7 +952,12 @@ gfx_flush_display (struct frame *f)
void
gfx_update_begin (struct frame *f)
{
if (!gfx_ready (f)) return;
if (!gfx_ready (f))
{
if (gfx_fallback.update_begin)
gfx_fallback.update_begin (f);
return;
}
struct gfx_frame_state *st = gfx_state (f);
/* Guard: if a cycle is already open (e.g. from a re-entrant redisplay),
@ -908,7 +997,12 @@ gfx_update_begin (struct frame *f)
void
gfx_update_end (struct frame *f)
{
if (!gfx_ready (f)) return;
if (!gfx_ready (f))
{
if (gfx_fallback.update_end)
gfx_fallback.update_end (f);
return;
}
struct gfx_frame_state *st = gfx_state (f);
/* A cycle that only cleared the garbaged frame (no content drawn)
@ -927,7 +1021,12 @@ gfx_frame_up_to_date (struct frame *f)
{
/* Called when the frame display is fully up to date. If a cycle was
begun but not ended (unusual), end it now. */
if (!gfx_ready (f)) return;
if (!gfx_ready (f))
{
if (gfx_fallback.frame_up_to_date)
gfx_fallback.frame_up_to_date (f);
return;
}
if (gfx_drv->in_cycle (f))
gfx_drv->end_frame (f, true);
}
@ -940,7 +1039,12 @@ void
gfx_scroll_run (struct window *w, struct run *run)
{
struct frame *f = WINDOW_XFRAME (w);
if (!gfx_ready (f)) return;
if (!gfx_ready (f))
{
if (gfx_fallback.rif && gfx_fallback.rif->scroll_run_hook)
gfx_fallback.rif->scroll_run_hook (w, run);
return;
}
/* Move the already-rendered block of pixels inside the render target,
the same geometry the NS backend uses (ns_scroll_run): the text area
@ -966,14 +1070,26 @@ void
gfx_shift_glyphs_for_insert (struct frame *f, int x, int y,
int w, int h, int by)
{
if (!gfx_ready (f) || by == 0) return;
if (!gfx_ready (f))
{
if (gfx_fallback.rif && gfx_fallback.rif->shift_glyphs_for_insert)
gfx_fallback.rif->shift_glyphs_for_insert (f, x, y, w, h, by);
return;
}
if (by == 0) return;
gfx_drv->copy_region (f, x, y, w, h, x + by, y);
}
void
gfx_after_update_window_line (struct window *w, struct glyph_row *desired_row)
{
(void) w; (void) desired_row;
struct frame *f = WINDOW_XFRAME (w);
if (!gfx_ready (f))
{
if (gfx_fallback.rif && gfx_fallback.rif->after_update_window_line_hook)
gfx_fallback.rif->after_update_window_line_hook (w, desired_row);
return;
}
}
/* -----------------------------------------------------------------------
@ -989,8 +1105,21 @@ gfx_draw_window_cursor (struct window *w,
(void) x; (void) y; (void) active_p;
struct frame *f = WINDOW_XFRAME (w);
if (!gfx_ready (f)) return;
if (!on_p) return;
if (!gfx_ready (f))
{
if (gfx_fallback.rif && gfx_fallback.rif->draw_window_cursor)
gfx_fallback.rif->draw_window_cursor (w, row, x, y, cursor_type,
cursor_width, on_p, active_p);
return;
}
if (!on_p)
{
/* Blink-off phase: hide the animated overlay too, or a GPU cursor
would never blink (the overlay persists across presents). */
if (gfx_drv->note_cursor)
gfx_drv->note_cursor (f, 0, 0, 0, 0, 0);
return;
}
w->phys_cursor_type = cursor_type;
w->phys_cursor_on_p = on_p;
@ -1085,7 +1214,12 @@ void
gfx_draw_vertical_window_border (struct window *w, int x, int y0, int y1)
{
struct frame *f = WINDOW_XFRAME (w);
if (!gfx_ready (f)) return;
if (!gfx_ready (f))
{
if (gfx_fallback.rif && gfx_fallback.rif->draw_vertical_window_border)
gfx_fallback.rif->draw_vertical_window_border (w, x, y0, y1);
return;
}
struct face *face = FACE_FROM_ID_OR_NULL (f, VERTICAL_BORDER_FACE_ID);
unsigned long color = face ? face->foreground
: gfx_drv->frame_foreground (f);
@ -1096,7 +1230,12 @@ void
gfx_draw_window_divider (struct window *w, int x0, int x1, int y0, int y1)
{
struct frame *f = WINDOW_XFRAME (w);
if (!gfx_ready (f)) return;
if (!gfx_ready (f))
{
if (gfx_fallback.rif && gfx_fallback.rif->draw_window_divider)
gfx_fallback.rif->draw_window_divider (w, x0, x1, y0, y1);
return;
}
struct face *face = FACE_FROM_ID_OR_NULL (f, WINDOW_DIVIDER_FACE_ID);
struct face *face_first
@ -1130,9 +1269,13 @@ void
gfx_draw_fringe_bitmap (struct window *w, struct glyph_row *row,
struct draw_fringe_bitmap_params *p)
{
(void) row;
struct frame *f = WINDOW_XFRAME (w);
if (!gfx_ready (f)) return;
if (!gfx_ready (f))
{
if (gfx_fallback.rif && gfx_fallback.rif->draw_fringe_bitmap)
gfx_fallback.rif->draw_fringe_bitmap (w, row, p);
return;
}
/* Like gfx_draw_glyph_string: fringe updates can arrive outside the
update_begin/end cycle (e.g. clearing the continuation arrow when
@ -1175,15 +1318,32 @@ gfx_draw_fringe_bitmap (struct window *w, struct glyph_row *row,
void
gfx_define_fringe_bitmap (int which, unsigned short *bits, int h, int wd)
{ (void) which; (void) bits; (void) h; (void) wd; }
{
/* The gfx policy reads p->bits directly, but bitmap definitions are
GLOBAL: keep the platform backend's registry alive for the frames
that still render through it. */
if (gfx_fallback.rif && gfx_fallback.rif->define_fringe_bitmap)
gfx_fallback.rif->define_fringe_bitmap (which, bits, h, wd);
}
void
gfx_destroy_fringe_bitmap (int which)
{ (void) which; }
{
if (gfx_fallback.rif && gfx_fallback.rif->destroy_fringe_bitmap)
gfx_fallback.rif->destroy_fringe_bitmap (which);
}
void
gfx_compute_glyph_string_overhangs (struct glyph_string *s)
{ s->left_overhang = s->right_overhang = 0; }
{
if (!gfx_ready (s->f))
{
if (gfx_fallback.rif && gfx_fallback.rif->compute_glyph_string_overhangs)
gfx_fallback.rif->compute_glyph_string_overhangs (s);
return;
}
s->left_overhang = s->right_overhang = 0;
}
void
gfx_warm_glyph_cache (struct frame *f)

View file

@ -117,6 +117,8 @@ typedef struct mtl_spring {
@property (nonatomic, assign) unsigned long cursorColor; /* real frame cursor color */
@property (nonatomic, assign) MtlSpring1D springX, springY; /* animated pos */
@property (nonatomic, assign) BOOL cursorDirty;
/* Blink-off phase: hide the cursor body (trails/particles keep animating). */
@property (nonatomic, assign) BOOL cursorHidden;
/* Trail history (torpedo mode) */
@property (nonatomic, assign) NSUInteger trailHead;

View file

@ -995,6 +995,25 @@ mtl_log_seq_p (void)
BOOL needsComposite = NO;
/* Mirror the engine's cursor visibility (blink-cursor-mode toggles it
via internal-show-cursor -> erase_phys_cursor, which never reaches
the rif: it just repaints the glyph, invisible to an overlay
cursor). Poll it here so the animated cursor blinks too. */
struct frame *f = self.emacsFrame;
if (f && WINDOWP (f->selected_window))
{
struct window *w = XWINDOW (f->selected_window);
/* cursor_off_p is the blink phase itself (set by
internal-show-cursor); phys_cursor_on_p alone can stay set when
the erase path is optimized away. */
BOOL hidden = w->cursor_off_p;
if (hidden != self.cursorHidden)
{
self.cursorHidden = hidden;
needsComposite = YES;
}
}
/* Update spring cursor (spring mode only; torpedo snaps instantly) */
if (self.cursorMode == MTL_CURSOR_SPRING)
{
@ -1660,12 +1679,17 @@ mtl_log_seq_p (void)
float cy = anim.cursorMode == MTL_CURSOR_SPRING ? anim.springY.pos : anim.curTargetY;
float cw = anim.curTargetW, ch = anim.curTargetH;
/* Real frame cursor color (fed by mtl_draw_window_cursor), not cyan. */
/* Real frame cursor color (fed by note_cursor); before the first
cursor draw, fall back to the frame's cursor color, not cyan. */
float ccr, ccg, ccb;
unpack_color (anim.cursorColor ? anim.cursorColor : 0x88C0D0, &ccr, &ccg, &ccb);
unsigned long cc = anim.cursorColor;
if (!cc && self.emacsFrame)
cc = ns_color_to_pixel (FRAME_CURSOR_COLOR (self.emacsFrame));
unpack_color (cc ? cc : 0x88C0D0, &ccr, &ccg, &ccb);
/* 2. Torpedo trail */
if (anim.cursorMode == MTL_CURSOR_TORPEDO && anim.trailCount > 0)
/* 2. Torpedo trail (hidden together with the cursor body) */
if (anim.cursorMode == MTL_CURSOR_TORPEDO && anim.trailCount > 0
&& !anim.cursorHidden)
{
NSUInteger tlen = MIN(anim.trailCount, g_mtl_trail_len);
for (NSUInteger i = 0; i < tlen; i++)
@ -1700,7 +1724,9 @@ mtl_log_seq_p (void)
}
}
/* 3. Cursor (spring-interpolated position) */
/* 3. Cursor (spring-interpolated position). Hidden during the
blink-off phase; effects in flight keep animating. */
if (!anim.cursorHidden)
{
float x0=cx, y0=cy, x1=cx+cw, y1=cy+ch;
float fr=ccr,fg=ccg,fb=ccb;
@ -2537,6 +2563,19 @@ mtl_drv_note_cursor (struct frame *f, int x, int y, int w, int h,
MtlFrameData *fd = mtl_get_frame_data (f);
if (!fd || !g_mtl_animations_enabled || !fd.animator)
return false;
/* W/H <= 0: blink-off phase. Hide the overlay cursor and show the
change; effects in flight keep animating via the pump. */
if (w <= 0 || h <= 0)
{
if (!fd.animator.cursorHidden)
{
fd.animator.cursorHidden = YES;
if (!fd.encoder)
[fd compositeToScreen];
}
return true;
}
fd.animator.cursorHidden = NO;
fd.animator.cursorColor = color;
[fd.animator setCursorX:x y:y width:w height:h];
/* Cursor-only motion takes redisplay's fast path: no render cycle gets
@ -2856,6 +2895,11 @@ mtl_patch_terminal_rif (struct frame *f)
functions are replaced with Metal equivalents. */
if (!mtl_ns_rif_copy)
{
/* Capture the ORIGINAL NS implementations first: the policy
delegates to them for frames the GPU backend is not enabled on
(tooltips, child frames, frames without mtl-enable). */
gfx_fallback.rif = term->rif;
mtl_ns_rif_copy = xmalloc (sizeof (struct redisplay_interface));
*mtl_ns_rif_copy = *term->rif; /* copy all NS functions as baseline */
@ -2884,6 +2928,15 @@ mtl_patch_terminal_rif (struct frame *f)
/* Patch render cycle hooks so Metal manages the frame pixel lifecycle.
The NS backend's update_begin calls [view lockFocus] for CoreGraphics;
we bypass that entirely and use Metal command buffers instead. */
if (term->update_begin_hook != gfx_update_begin)
{
/* Same for the terminal hooks (guarded: this function runs again
on every mtl-enable-for-frame). */
gfx_fallback.update_begin = term->update_begin_hook;
gfx_fallback.update_end = term->update_end_hook;
gfx_fallback.clear_frame = term->clear_frame_hook;
gfx_fallback.frame_up_to_date = term->frame_up_to_date_hook;
}
term->update_begin_hook = gfx_update_begin;
term->update_end_hook = gfx_update_end;
term->clear_frame_hook = gfx_clear_frame;