Metal: keep cursor animations alive while Emacs idles

Cursor-only motion takes redisplay's fast path: no render cycle opens,
and the CADisplayLink stops firing while Emacs idles, so with
animations enabled the moved cursor overlay was never presented (it
appeared frozen until the next input event) and rings/trails never
animated between events.

Three fixes: note_cursor composites immediately when no cycle is open;
the animation step is factored into tickWithDt: and exposed as
mtl-anim-tick, driven by a 30fps Lisp timer while animations are
enabled (the same mechanism that drives video playback); and burst
particles use the real cursor color instead of a hardcoded pale tint
that was invisible on light backgrounds.
This commit is contained in:
Andros Fenollosa 2026-06-04 11:05:21 +02:00
parent 32b41ab8be
commit 27a98a1c36
4 changed files with 69 additions and 3 deletions

View file

@ -146,7 +146,8 @@ cursor effects, particles and the 60fps compositor."
:set (lambda (sym val)
(set-default sym val)
(when (fboundp 'mtl-animations)
(mtl-animations val)))
(mtl-animations val)
(when val (mtl--anim-pump-start))))
:group 'mtl)
(defcustom mtl-enable-on-startup nil
@ -179,13 +180,31 @@ The NS backend still handles events, menus, and scrollbars."
(mtl-scroll-duration mtl-scroll-duration)
(mtl-trail-length mtl-trail-length)
(mtl-animations mtl-animations-enabled)
(when mtl-animations-enabled (mtl--anim-pump-start))
(message "Metal GPU enabled on frame: %s (device: %s, animations: %s)"
f (mtl-device-name) (if mtl-animations-enabled "on" "off"))))
(defvar mtl--anim-timer nil
"30fps timer driving cursor animations while they are enabled.")
(defun mtl--anim-pump ()
"Advance GPU cursor animations; cancel the timer when they turn off."
(unless (and (fboundp 'mtl-anim-tick) (mtl-anim-tick))
(when (timerp mtl--anim-timer) (cancel-timer mtl--anim-timer))
(setq mtl--anim-timer nil)))
(defun mtl--anim-pump-start ()
"Start the animation pump timer (idempotent).
Emacs's event loop starves the CADisplayLink while idle, so without
this Lisp timer the cursor effects only animate during user input."
(unless (timerp mtl--anim-timer)
(setq mtl--anim-timer (run-at-time 0 0.033 #'mtl--anim-pump))))
(defun mtl-toggle-animations ()
"Toggle the Metal GPU animation layer on or off."
(interactive)
(setopt mtl-animations-enabled (not mtl-animations-enabled))
(when mtl-animations-enabled (mtl--anim-pump-start))
(message "Metal animations %s" (if mtl-animations-enabled "enabled" "disabled")))
(defun mtl-status ()

View file

@ -581,6 +581,31 @@ otherwise (letting the timer cancel itself). */)
return ok ? Qt : Qnil;
}
DEFUN ("mtl-anim-tick", Fmtl_anim_tick, Smtl_anim_tick, 0, 2, 0,
doc: /* Advance the GPU cursor animations one step and present.
DT is the step in seconds (default 0.033). Driven by a Lisp timer while
animations are enabled: Emacs's event loop starves the CADisplayLink
when idle, so rings/trails would freeze between input events otherwise
(same mechanism as `mtl-video-tick'). FRAME defaults to the selected
frame. Returns t while animations are enabled, nil otherwise. */)
(Lisp_Object dt, Lisp_Object frame)
{
if (NILP (frame)) frame = Fselected_frame ();
if (!FRAME_LIVE_P (XFRAME (frame))) return Qnil;
if (!g_mtl_animations_enabled) return Qnil;
MtlFrameData *fd = mtl_get_frame_data (XFRAME (frame));
if (!fd || !fd.animator) return Qnil;
float step = 0.033f;
if (NUMBERP (dt))
step = (float) XFLOATINT (dt);
block_input ();
if (!fd.encoder)
[fd.animator tickWithDt:step];
unblock_input ();
return Qt;
}
void
syms_of_mtlfns (void)
{
@ -606,6 +631,7 @@ syms_of_mtlfns (void)
defsubr (&Smtl_video_pause);
defsubr (&Smtl_video_move);
defsubr (&Smtl_video_tick);
defsubr (&Smtl_anim_tick);
}
#endif /* HAVE_MTL */

View file

@ -148,6 +148,10 @@ typedef struct mtl_spring {
/* Called every animation tick (CADisplayLink target) */
- (void)animationTick:(CADisplayLink *)link;
/* One animation step + composite, drivable from a Lisp timer (the display
link starves while Emacs idles). */
- (void)tickWithDt:(float)dt;
/* Spawn particles at cursor for pixiedust/sonicboom/ripple modes */
- (void)spawnParticlesAtX:(float)x y:(float)y;

View file

@ -972,13 +972,24 @@ mtl_log_seq_p (void)
p->vy = sinf(angle) * speed;
p->age = 0.0f;
p->size = (self.cursorMode == MTL_CURSOR_SONICBOOM) ? 6.0f : 3.0f;
p->color = 0x88C0D0; /* Nord frost */
/* Real cursor color: a hardcoded pale tint was invisible on light
backgrounds. */
p->color = self.cursorColor ? self.cursorColor : 0x88C0D0;
}
}
- (void)animationTick:(CADisplayLink *)link
{
float dt = (float)link.duration;
[self tickWithDt:(float) link.duration];
}
/* One animation step + composite. Factored out of the CADisplayLink
callback so a Lisp-level timer can drive it too: Emacs's event loop
starves the display link while idle (it stops firing after a couple of
ticks), so timer-driven cursor movements would spawn rings/trails that
never animate. Same medicine as video playback (mtl-video-tick). */
- (void)tickWithDt:(float)dt
{
MtlFrameData *fd = mtl_get_frame_data (self.emacsFrame);
if (!fd || !fd.metalLayer) return;
@ -2528,6 +2539,12 @@ mtl_drv_note_cursor (struct frame *f, int x, int y, int w, int h,
return false;
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
opened, so nothing would present the moved overlay (the display link
does not fire while Emacs idles). Composite now so the cursor is
never left painted at its old position. */
if (!fd.encoder)
[fd compositeToScreen];
return true;
}