Files
emacs-gpu/lisp/gpu.el
T
andros e22b30263a gpu: bump version to 0.4.2
Unified animation pump (one Lisp timer and at most one present per
tick for cursor effects, cross-fades and inline video; video holds its
native 30fps now) and the stale-frame flash fix (drop the XDBE back
buffer on GL frames; an Expose could flash the half-painted startup
frame for one vblank).
2026-06-12 21:24:28 +02:00

905 lines
36 KiB
EmacsLisp

;;; gpu.el --- Metal GPU backend configuration for GNU Emacs on macOS -*- lexical-binding: t; -*-
;; Copyright (C) 2026 Free Software Foundation, Inc.
;; Author: Andros Fenollosa
;; Version: 0.4.2
;; Package-Requires: ((emacs "30.1"))
;; Keywords: hardware, display, macos, metal, gpu
;; URL: https://github.com/tanrax/emacs-gpu
;; This file is part of GNU Emacs.
;; GNU Emacs is free software: you can redistribute it and/or modify
;; it under the terms of the GNU General Public License as published
;; by the Free Software Foundation, either version 3 of the License,
;; or (at your option) any later version.
;;; Commentary:
;; This module provides user-facing configuration for the Metal GPU
;; display backend (emacs-gpu).
;;
;; The Metal backend replaces CoreGraphics rendering with Apple Metal
;; for GPU-accelerated text, cursor animations, and scroll effects.
;;
;; Usage:
;;
;; ;; Enable Metal on the current frame
;; (gpu-enable)
;;
;; ;; Configure cursor animation
;; (setq gpu-cursor-animation 'spring) ;; or 'torpedo, 'pixiedust, etc.
;;
;; ;; Configure scroll easing
;; (setq gpu-scroll-easing 'ease-out-quad)
;;
;; Cursor modes:
;; block Static filled rectangle (fastest)
;; spring Critically-damped spring (smooth, default)
;; torpedo Trail of past positions
;; sonicboom Expanding ring on jump
;; ripple 3 concentric expanding rings
;; pixiedust Radial particle burst
;; hollow Outline box
;; beam Thin vertical bar
;;
;; Scroll easing:
;; none Instant jump
;; linear Constant speed
;; ease-out-quad Decelerate (default)
;; ease-out-cubic Stronger deceleration
;; spring Spring physics
;; ease-in-out-cubic S-curve
;;; Code:
;; Primitives implemented in C (src/mtlfns.m); declared here so the byte
;; compiler knows their arity when this file is built without the backend.
(declare-function gpu-backend-p "mtlfns.m" ())
(declare-function gpu-device-name "mtlfns.m" ())
(declare-function gpu-enable-for-frame "mtlfns.m" (frame))
(declare-function gpu-cursor-mode "mtlfns.m" (mode))
(declare-function gpu-scroll-effect "mtlfns.m" (effect))
(declare-function gpu-scroll-duration "mtlfns.m" (duration))
(declare-function gpu-trail-length "mtlfns.m" (length))
(declare-function gpu-animations "mtlfns.m" (&optional enable))
(declare-function gpu-animation-status "mtlfns.m" ())
(defgroup gpu nil
"Metal GPU display backend for GNU Emacs on macOS."
:group 'display
:prefix "gpu-"
:link '(url-link "https://github.com/tanrax/emacs-gpu"))
;; ---------------------------------------------------------------------------
;; Helper functions (must be defined before defcustom :set functions use them)
(defun gpu--cursor-mode-number (mode)
"Convert cursor MODE symbol to integer for `gpu-cursor-mode'."
(pcase mode
('block 0)
('spring 1)
('torpedo 2)
('sonicboom 3)
('ripple 4)
('pixiedust 5)
('hollow 6)
('beam 7)
(_ 1)))
(defun gpu--scroll-easing-number (easing)
"Convert EASING symbol to integer for `gpu-scroll-effect'."
(pcase easing
('none 0)
('linear 1)
('ease-out-quad 2)
('ease-out-cubic 3)
('spring 4)
('ease-in-out-cubic 5)
(_ 2)))
;; ---------------------------------------------------------------------------
;; Animation pump
;;
;; ONE timer drives every continuous animation: cursor effects, buffer
;; cross-fades and inline video. Each tick calls `gpu-pump-tick', which
;; advances all of them together and presents at most one frame, so the
;; present rate stays bounded no matter how many sources are active.
;; (Per-subsystem timers used to stack presents well above the refresh
;; rate, under which some GL drivers were caught presenting a stale
;; swapchain buffer.) Emacs's event loop starves display links while
;; idle, so a Lisp timer is the only reliable clock for this.
(declare-function gpu-pump-tick "mtlfns.m" (&optional frame))
(defvar gpu--pump-timer nil
"Single timer driving every continuous GPU animation.")
(defvar gpu--pump-interval nil
"Current repeat interval of `gpu--pump-timer'.")
(defvar gpu--pump-fade-frame nil
"Frame with a running buffer cross-fade, if any.")
(defvar gpu--video-state) ; defined with the inline video code
(defun gpu--pump-start (&optional fast)
"Ensure the animation pump is running (idempotent).
With FAST non-nil tick at 60Hz (cross-fades); the pump drops itself
back to 30Hz when the fade ends (see `gpu--pump')."
(when (fboundp 'gpu-pump-tick)
(let ((want (if fast 0.016 0.033)))
(when (and (timerp gpu--pump-timer)
fast (not (eql gpu--pump-interval want)))
(cancel-timer gpu--pump-timer)
(setq gpu--pump-timer nil))
(unless (timerp gpu--pump-timer)
(setq gpu--pump-interval want
gpu--pump-timer (run-at-time 0 want #'gpu--pump))))))
(defun gpu--pump-stop ()
"Cancel the animation pump timer."
(when (timerp gpu--pump-timer) (cancel-timer gpu--pump-timer))
(setq gpu--pump-timer nil
gpu--pump-interval nil))
(defun gpu--pump-frames ()
"Frames the pump must tick: selected, video and fade frames, deduped."
(let ((fs (list (selected-frame))))
(when gpu--video-state
(let ((vf (nth 3 gpu--video-state)))
(when (frame-live-p vf) (push vf fs))))
(when (frame-live-p gpu--pump-fade-frame)
(push gpu--pump-fade-frame fs))
(delete-dups fs)))
(defun gpu--pump ()
"Advance every continuous GPU animation one step.
Re-paces the timer to 60Hz while a cross-fade runs and back to 30Hz
otherwise; cancels it once nothing needs pumping."
(gpu--video-follow)
(let ((mask 0))
(dolist (f (gpu--pump-frames))
(setq mask (logior mask (or (gpu-pump-tick f) 0))))
(when (zerop (logand mask 2))
(setq gpu--pump-fade-frame nil))
(if (zerop mask)
(gpu--pump-stop)
(let ((want (if (zerop (logand mask 2)) 0.033 0.016)))
(unless (eql want gpu--pump-interval)
(gpu--pump-stop)
(gpu--pump-start (eql want 0.016)))))))
;; Old names (pre-0.2) for the customs defined below.
(define-obsolete-variable-alias 'mtl-animations-enabled 'gpu-animations-enabled "0.2")
(define-obsolete-variable-alias 'mtl-buffer-transitions 'gpu-buffer-transitions "0.2")
(define-obsolete-variable-alias 'mtl-buffer-transition-duration 'gpu-buffer-transition-duration "0.2")
(define-obsolete-variable-alias 'mtl-cursor-animation 'gpu-cursor-animation "0.2")
(define-obsolete-variable-alias 'mtl-enable-on-startup 'gpu-enable-on-startup "0.2")
;; ---------------------------------------------------------------------------
;; Customizable variables
(defcustom gpu-cursor-animation (if (fboundp 'gpu-opengl-p) 'sonicboom 'block)
"Cursor animation mode for the GPU backend.
Defaults to `sonicboom' on the OpenGL (GNU/Linux) backend and `block'
on Metal (macOS).
Possible values:
`block' Static filled rectangle, no effect
`spring' Critically-damped spring physics
`torpedo' Trail of last N cursor positions
`sonicboom' Expanding ring when cursor jumps far
`ripple' Three concentric expanding rings
`pixiedust' Radial particle burst on jump
`hollow' Hollow outline box
`beam' Thin vertical bar"
:type '(choice (const :tag "Block (static, default)" block)
(const :tag "Spring (smooth)" spring)
(const :tag "Torpedo (trail)" torpedo)
(const :tag "Sonicboom (ring)" sonicboom)
(const :tag "Ripple (3 rings)" ripple)
(const :tag "Pixiedust (particles)" pixiedust)
(const :tag "Hollow (outline)" hollow)
(const :tag "Beam (bar)" beam))
:set (lambda (sym val)
(set-default sym val)
;; Propagate to the live driver so the change takes effect at
;; runtime, not only on the next `gpu-enable'.
(when (fboundp 'gpu-cursor-mode)
(gpu-cursor-mode (gpu--cursor-mode-number val))))
:group 'gpu)
(defcustom gpu-scroll-easing 'ease-out-quad
"Scroll animation easing for the Metal GPU backend."
:type '(choice (const :tag "None (instant)" none)
(const :tag "Linear" linear)
(const :tag "Ease out quad (default)" ease-out-quad)
(const :tag "Ease out cubic" ease-out-cubic)
(const :tag "Spring" spring)
(const :tag "Ease in-out cubic" ease-in-out-cubic))
:set #'set-default
:group 'gpu)
(defcustom gpu-scroll-duration 0.15
"Scroll animation duration in seconds (0.0 to 2.0).
Lower values are snappier; higher values are more fluid."
:type 'float
:set #'set-default
:group 'gpu)
(defcustom gpu-trail-length 20
"Length of the cursor trail in torpedo mode (1-40)."
:type 'integer
:set #'set-default
:group 'gpu)
(defcustom gpu-cursor-effects-while-typing nil
"If non-nil, the cursor effects also fire while typing.
By default they are suppressed during text-editing commands (see
`gpu-cursor-typing-commands'), so only cursor movement and jumps
trigger the rings, bursts and trail. This keeps the effects from
flashing on every inserted or deleted character."
:type 'boolean
:set #'set-default
:group 'gpu)
(defcustom gpu-cursor-typing-commands
'(self-insert-command
org-self-insert-command
newline newline-and-indent electric-newline-and-maybe-indent
open-line
delete-char delete-backward-char backward-delete-char
backward-delete-char-untabify delete-forward-char
yank yank-pop)
"Commands treated as typing/editing for cursor effects.
When `gpu-cursor-effects-while-typing' is nil, the motion cursor
effects do not fire after these commands. A command also counts as
typing when its symbol has a non-nil `gpu-typing-command' property."
:type '(repeat function)
:set #'set-default
:group 'gpu)
(defcustom gpu-animations-enabled t
"If non-nil, enable the Metal GPU animation layer.
When nil (the default), the cursor is drawn directly into the static
texture like the NS backend and no compositor overlay is drawn, which is
the correct, flicker-free baseline. Enable this to turn on the animated
cursor effects, particles and the 60fps compositor."
:type 'boolean
:set (lambda (sym val)
(set-default sym val)
(when (fboundp 'gpu-animations)
(gpu-animations val)
(when val (gpu--pump-start))))
:group 'gpu)
(defcustom gpu-enable-on-startup nil
"If non-nil, enable Metal GPU rendering on the initial frame at startup."
:type 'boolean
:group 'gpu)
;; ---------------------------------------------------------------------------
;; Typing vs. movement detection for the cursor effects
(defun gpu--cursor-typing-p ()
"Non-nil if `this-command' is a typing/editing command.
See `gpu-cursor-typing-commands'."
(let ((cmd this-command))
(and (symbolp cmd)
(or (memq cmd gpu-cursor-typing-commands)
(get cmd 'gpu-typing-command)))))
(defun gpu--cursor-pre-command ()
"Tell the driver whether the upcoming command is typing or movement.
Runs from `pre-command-hook' so the next cursor placement knows whether
to fire the motion effects. See `gpu-cursor-effects-while-typing'."
(when (fboundp 'gpu-cursor-suppress-effects)
(gpu-cursor-suppress-effects
(and (not gpu-cursor-effects-while-typing)
(gpu--cursor-typing-p)))))
;; ---------------------------------------------------------------------------
;; Public API
;;;###autoload
(defun gpu-enable (&optional frame)
"Enable Metal GPU rendering on FRAME (default: selected frame).
Adds a CAMetalLayer on top of the EmacsView and replaces the
CoreGraphics rendering pipeline with Metal shaders.
After this call, all redisplay for FRAME goes through the Metal GPU.
The NS backend still handles events, menus, and scrollbars."
(interactive)
(unless (fboundp 'gpu-backend-p)
(error "gpu-enable: GPU backend not compiled in (build with --with-gpu)"))
(unless (gpu-backend-p)
(error "gpu-enable: GPU backend is not available on this system"))
(let ((f (or frame (selected-frame))))
(unless (framep f)
(error "gpu-enable: argument is not a frame"))
(gpu-enable-for-frame f)
;; On X, drop the XDBE back buffer: the GPU FBO replaces it, but the
;; stale buffer keeps the LAST core-X render (the half-painted startup
;; frame) forever, and any leftover XdbeSwapBuffers -- the Expose
;; handler issues one unconditionally -- flashes that ancient frame on
;; screen for one vblank until the next GPU present overwrites it.
(when (fboundp 'gpu-opengl-p)
(set-frame-parameter f 'inhibit-double-buffering t))
;; Buffer-switch cross-fade: both backends expose `gpu-transition-start',
;; so wire the watcher whenever it is available.
(when (fboundp 'gpu-transition-start)
(add-hook 'pre-redisplay-functions #'gpu--transition-watch))
;; Cursor effects, scroll easing and the animation layer; apply only
;; when the backend exposes their primitives (Metal and OpenGL both do).
(if (fboundp 'gpu-cursor-mode)
(progn
(gpu-cursor-mode (gpu--cursor-mode-number gpu-cursor-animation))
(gpu-scroll-effect (gpu--scroll-easing-number gpu-scroll-easing))
(gpu-scroll-duration gpu-scroll-duration)
(gpu-trail-length gpu-trail-length)
(gpu-animations gpu-animations-enabled)
(when gpu-animations-enabled (gpu--pump-start))
;; Distinguish typing from cursor movement for the effects.
(add-hook 'pre-command-hook #'gpu--cursor-pre-command)
(message "GPU enabled on frame: %s (device: %s, animations: %s)"
f (gpu-device-name) (if gpu-animations-enabled "on" "off")))
(message "GPU enabled on frame: %s (device: %s)" f (gpu-device-name)))))
;;;###autoload
(defun gpu-toggle-animations ()
"Toggle the Metal GPU animation layer on or off."
(interactive)
(setopt gpu-animations-enabled (not gpu-animations-enabled))
(when gpu-animations-enabled (gpu--pump-start))
(message "Metal animations %s" (if gpu-animations-enabled "enabled" "disabled")))
;;;###autoload
(defun gpu-status ()
"Display current Metal GPU backend status in the minibuffer."
(interactive)
(if (not (fboundp 'gpu-backend-p))
(message "GPU backend not compiled (build with --with-gpu)")
(if (not (gpu-backend-p))
(message "GPU backend not available on this system")
;; The OpenGL backend has no animation layer, so report just the
;; device; the Metal backend reports the full animation state.
(if (not (fboundp 'gpu-animation-status))
(message "GPU backend: %s" (gpu-device-name))
(let ((status (gpu-animation-status)))
(message "Metal GPU: %s | Animations: %s | Cursor: %s | Scroll: %s (%.2fs)"
(gpu-device-name)
(if (cdr (assq 'animations status)) "on" "off")
(nth (cdr (assq 'cursor-mode status))
'(block spring torpedo sonicboom ripple pixiedust hollow beam))
(nth (cdr (assq 'scroll-easing status))
'(none linear ease-out-quad ease-out-cubic spring ease-in-out-cubic))
(cdr (assq 'scroll-duration status))))))))
;;;###autoload
(defun gpu-set-cursor (mode)
"Interactively set cursor animation MODE."
(interactive
(list (intern (completing-read "Cursor mode: "
'("block" "spring" "torpedo" "sonicboom"
"ripple" "pixiedust" "hollow" "beam")
nil t))))
(setopt gpu-cursor-animation mode)
(message "GPU cursor mode: %s" mode))
;;;###autoload
(defun gpu-set-scroll (easing)
"Interactively set scroll EASING."
(interactive
(list (intern (completing-read "Scroll easing: "
'("none" "linear" "ease-out-quad"
"ease-out-cubic" "spring" "ease-in-out-cubic")
nil t))))
(setopt gpu-scroll-easing easing))
;; ---------------------------------------------------------------------------
;; Buffer-switch transitions
(defcustom gpu-buffer-transitions t
"When non-nil, cross-fade the old content when a window changes buffer.
The previous frame content fades out over
`gpu-buffer-transition-duration' seconds while the new buffer appears
underneath. Rendered entirely by the GPU compositor."
:type 'boolean
:group 'gpu)
(defcustom gpu-buffer-transition-duration 0.15
"Seconds a buffer-switch cross-fade takes."
:type 'number
:group 'gpu)
(defvar gpu--transition-armed nil
"Non-nil while a snapshot was already taken for the ongoing redisplay.")
(defun gpu--transition-pump-start (frame)
"Pump the cross-fade on FRAME at 60Hz until it completes.
The animation pump drops back to 30Hz (or stops) by itself once the
driver reports the fade is over."
(setq gpu--pump-fade-frame frame)
(gpu--pump-start 'fast))
(defun gpu--transition-watch (window)
"Start a cross-fade when WINDOW is about to display another buffer.
Runs from `pre-redisplay-functions', before the new content is painted,
so the GPU snapshot still holds the old pixels."
(when (and gpu-buffer-transitions
(not (window-minibuffer-p window))
(fboundp 'gpu-transition-start))
(let ((old (window-parameter window 'gpu--last-buffer))
(new (window-buffer window)))
(when (and old (not (eq old new)) (not gpu--transition-armed))
(setq gpu--transition-armed t)
(run-at-time 0 nil (lambda () (setq gpu--transition-armed nil)))
(ignore-errors
(when (gpu-transition-start (float gpu-buffer-transition-duration)
(window-frame window))
(gpu--transition-pump-start (window-frame window)))))
(set-window-parameter window 'gpu--last-buffer new))))
;; ---------------------------------------------------------------------------
;; Inline video
(defvar gpu--video-state nil
"Active inline video: (MARKER WIDTH HEIGHT FRAME), or nil.")
(defun gpu--video-follow ()
"Track the inline video placeholder: move/clip the GPU rect.
Runs from the animation pump; the pump tick that follows uploads and
presents the next decoded frame. Follows scrolling and window changes;
parks the rect off-screen (still decoding) while it is not visible."
(when gpu--video-state
(pcase-let ((`(,marker ,w ,h ,frame) gpu--video-state))
(if (not (and (frame-live-p frame) (marker-buffer marker)))
(gpu-video-stop)
(let* ((win (get-buffer-window (marker-buffer marker) frame))
(vis (and win (pos-visible-in-window-p marker win t))))
(if (not (and vis (listp vis)))
(gpu-video-move 0 -32768 w h nil frame)
(let* ((edges (window-inside-pixel-edges win))
(x (+ (nth 0 edges) (nth 0 vis)))
(y (+ (nth 1 edges) (nth 1 vis))))
(gpu-video-move x y w h edges frame))))))))
;;;###autoload
(defun gpu-video-insert (file width height &optional loop)
"Insert a WIDTH x HEIGHT placeholder at point and play video FILE over it.
The placeholder is a space with a pixel-sized display spec; the GPU
composites the video at its position every frame, following scrolling
\(clipped to the window interior). With LOOP non-nil, restart playback
at the end. One video per frame; a previous one is replaced."
(interactive "fVideo file: \nnWidth (px): \nnHeight (px): ")
(gpu-video-stop)
(insert (propertize " "
'display `(space :width (,width) :height (,height))
'gpu-video file))
(let ((marker (copy-marker (1- (point)))))
;; Park off-screen; the first pump tick positions it for real.
(unless (gpu-video-open file 0 -32768 width height loop)
(error "Cannot open video file %s" file))
(setq gpu--video-state (list marker width height (selected-frame)))
(gpu--pump-start)))
;;;###autoload
(defun gpu-video-stop ()
"Stop and remove the inline video.
The animation pump notices the closed player and re-paces itself."
(interactive)
(when gpu--video-state
(let ((frame (nth 3 gpu--video-state)))
(when (frame-live-p frame) (gpu-video-close frame)))
(setq gpu--video-state nil)))
;; ---------------------------------------------------------------------------
;; Video file buffers (gpu-video-mode)
(require 'svg)
(declare-function gpu-video-open "mtlfns.m"
(file x y width height &optional loop frame))
(declare-function gpu-video-close "mtlfns.m" (&optional frame))
(declare-function gpu-video-pause "mtlfns.m" (paused &optional frame))
(declare-function gpu-video-move "mtlfns.m"
(x y width height &optional clip frame))
(declare-function gpu-video-tick "mtlfns.m" (&optional frame))
(declare-function gpu-video-duration "mtlfns.m" (&optional frame))
(declare-function gpu-video-position "mtlfns.m" (&optional frame))
(declare-function gpu-video-seek "mtlfns.m" (seconds &optional frame))
(declare-function gpu-video-playing-p "mtlfns.m" (&optional frame))
(declare-function gpu-video-size "mtlfns.m" (&optional frame))
(defcustom gpu-video-file-extensions '("mp4" "mov" "m4v" "3gp")
"File extensions opened in `gpu-video-mode'.
These are the container formats decoded by the active backend (AVFoundation
on macOS, GStreamer on GNU/Linux). Changing this takes effect on the next
call to `gpu-video-register-auto-mode'."
:type '(repeat string)
:group 'gpu)
(defcustom gpu-video-seek-step 5
"Seconds to jump with `gpu-video-seek-forward' and `gpu-video-seek-backward'."
:type 'number
:group 'gpu)
(defvar-local gpu-video--file nil
"Path of the video played in this buffer.")
(defvar-local gpu-video--frame nil
"Frame that owns this buffer's video player.")
(defvar-local gpu-video--timer nil
"Per-buffer sync timer for `gpu-video-mode'.")
(defvar-local gpu-video--vid-marker nil
"Marker at the video placeholder character.")
(defvar-local gpu-video--ctrl-start nil
"Marker where the control area begins.")
(defvar-local gpu-video--width 16
"Current width in pixels of the video rectangle.")
(defvar-local gpu-video--height 16
"Current height in pixels of the video rectangle.")
(defvar-local gpu-video--paused nil
"Non-nil when the user has paused this buffer's video.")
(defvar-local gpu-video--last-draw 0.0
"`float-time' of the last control redraw (throttling).")
(defun gpu-video--format-time (secs)
"Format SECS as MM:SS, or \"--:--\" when SECS is nil or negative."
(if (and (numberp secs) (>= secs 0))
(let ((s (floor secs)))
(format "%02d:%02d" (/ s 60) (% s 60)))
"--:--"))
(defun gpu-video--bar-svg (width fraction)
"Return an SVG progress bar WIDTH pixels wide, FRACTION (0..1) filled."
(let* ((w (max 1 width))
(h 16)
(cy (/ h 2))
(track 4)
(fillw (max 0 (min w (round (* w (or fraction 0))))))
(svg (svg-create w h)))
(svg-rectangle svg 0 (- cy (/ track 2)) w track
:rx 2 :fill "#808080" :fill-opacity 0.4)
(when (> fillw 0)
(svg-rectangle svg 0 (- cy (/ track 2)) fillw track
:rx 2 :fill "#4ea1ff"))
(svg-circle svg fillw cy 5 :fill "#4ea1ff")
(svg-image svg :scale 1 :ascent 'center)))
(defvar gpu-video--button-map
(let ((m (make-sparse-keymap)))
(define-key m [mouse-1] #'gpu-video-toggle-play)
(define-key m [follow-link] 'mouse-face)
m)
"Keymap on the play/pause button.")
(defvar gpu-video--bar-map
(let ((m (make-sparse-keymap)))
(define-key m [down-mouse-1] #'gpu-video--bar-drag)
m)
"Keymap on the timeline bar.")
(defun gpu-video--draw-controls (buf)
"Rebuild the control area (play/pause, time, timeline) of BUF."
(when (buffer-live-p buf)
(with-current-buffer buf
(when (and gpu-video--ctrl-start
(marker-position gpu-video--ctrl-start))
(let* ((inhibit-read-only t)
(frame gpu-video--frame)
(pos (and (frame-live-p frame) (gpu-video-position frame)))
(dur (and (frame-live-p frame) (gpu-video-duration frame)))
(playing (and (frame-live-p frame) (gpu-video-playing-p frame)))
(frac (if (and pos dur (> dur 0)) (/ pos dur) 0)))
(save-excursion
(goto-char gpu-video--ctrl-start)
(delete-region gpu-video--ctrl-start (point-max))
(insert (propertize (if playing " ⏸ " " ▶ ")
'face 'mode-line-emphasis
'mouse-face 'highlight
'pointer 'hand
'keymap gpu-video--button-map
'help-echo "Play/pause (SPC)"))
(insert " "
(gpu-video--format-time pos) " / "
(gpu-video--format-time dur)
"\n")
(if (and (display-graphic-p) (image-type-available-p 'svg))
(let ((start (point)))
(insert-image (gpu-video--bar-svg gpu-video--width frac) "-")
(put-text-property start (point) 'keymap gpu-video--bar-map)
(put-text-property start (point) 'pointer 'hand)
(put-text-property start (point)
'help-echo "Click or drag to seek"))
;; Text fallback (no pixel-precise seeking).
(let ((cols (max 1 (/ gpu-video--width
(max 1 (frame-char-width frame))))))
(insert (make-string (round (* cols frac)) ?=)
(make-string (- cols (round (* cols frac))) ?-)))))
(set-buffer-modified-p nil))))))
(defun gpu-video--seek-posn (posn)
"Seek the video to the timeline position described by POSN."
(let* ((xy (posn-object-x-y posn))
(dur (and (frame-live-p gpu-video--frame)
(gpu-video-duration gpu-video--frame))))
(when (and xy dur (> gpu-video--width 0))
(let ((frac (max 0.0 (min 1.0 (/ (float (car xy)) gpu-video--width)))))
(gpu-video-seek (* frac dur) gpu-video--frame)))))
(defun gpu-video--bar-drag (event)
"Seek on click and follow the pointer while dragging the timeline.
EVENT is the initiating down-mouse event."
(interactive "e")
(gpu-video--seek-posn (event-start event))
(track-mouse
(let (ev)
(while (and (setq ev (read-event))
(mouse-movement-p ev))
(gpu-video--seek-posn (event-start ev)))))
(gpu-video--draw-controls (current-buffer)))
(defun gpu-video-toggle-play ()
"Toggle play/pause of the video in the current buffer."
(interactive)
(let* ((frame gpu-video--frame)
(playing (and (frame-live-p frame) (gpu-video-playing-p frame))))
(setq gpu-video--paused playing)
(when (frame-live-p frame)
(gpu-video-pause playing frame))
(gpu-video--draw-controls (current-buffer))))
(defun gpu-video--relative-seek (delta)
"Seek DELTA seconds relative to the current position."
(let ((pos (and (frame-live-p gpu-video--frame)
(gpu-video-position gpu-video--frame))))
(when pos
(gpu-video-seek (max 0 (+ pos delta)) gpu-video--frame)
(gpu-video--draw-controls (current-buffer)))))
(defun gpu-video-seek-forward (&optional n)
"Jump forward by N times `gpu-video-seek-step' seconds (N defaults to 1)."
(interactive "p")
(gpu-video--relative-seek (* (or n 1) gpu-video-seek-step)))
(defun gpu-video-seek-backward (&optional n)
"Jump backward by N times `gpu-video-seek-step' seconds (N defaults to 1)."
(interactive "p")
(gpu-video--relative-seek (- (* (or n 1) gpu-video-seek-step))))
(defun gpu-video-seek-start ()
"Seek to the beginning of the video."
(interactive)
(when (frame-live-p gpu-video--frame)
(gpu-video-seek 0 gpu-video--frame)
(gpu-video--draw-controls (current-buffer))))
(defun gpu-video--fit (buf win frame)
"Size and position BUF's video rectangle inside WIN on FRAME.
Fits the window width, reserving room for the controls and keeping the
video's natural aspect ratio when known."
(with-current-buffer buf
(let* ((edges (window-inside-pixel-edges win))
(wpix (- (nth 2 edges) (nth 0 edges)))
(hpix (- (nth 3 edges) (nth 1 edges)))
(ch (frame-char-height frame))
(avail-h (max 16 (- hpix (* 3 ch))))
(natural (gpu-video-size frame))
(aspect (if (and natural (> (cdr natural) 0))
(/ (float (car natural)) (cdr natural))
(/ 16.0 9.0)))
(tw (max 1 wpix))
(th (max 1 (round (/ tw aspect)))))
(when (> th avail-h)
(setq th avail-h
tw (max 1 (round (* th aspect)))))
(unless (and (= tw gpu-video--width) (= th gpu-video--height))
(setq gpu-video--width tw
gpu-video--height th)
(let ((inhibit-read-only t)
(p (marker-position gpu-video--vid-marker)))
(when p
(put-text-property p (1+ p) 'display
`(space :width (,tw) :height (,th))))))
(let ((vis (pos-visible-in-window-p gpu-video--vid-marker win t)))
(if (and vis (listp vis))
(gpu-video-move (+ (nth 0 edges) (nth 0 vis))
(+ (nth 1 edges) (nth 1 vis))
tw th edges frame)
(gpu-video-move 0 -32768 tw th nil frame))))))
(defun gpu-video--claim (buf frame)
"Open BUF's video on FRAME and record BUF as the frame's video owner."
(with-current-buffer buf
(gpu-video-open gpu-video--file 0 -32768
(max 1 gpu-video--width) (max 1 gpu-video--height)
t frame)
(set-frame-parameter frame 'gpu-video-owner buf)
(setq gpu-video--paused nil)))
(defun gpu-video--sync (buf)
"Drive playback for BUF: claim, position, tick and redraw as needed.
Runs on the per-buffer timer. Only the buffer shown in its frame's
selected window plays (one video player per frame)."
(when (buffer-live-p buf)
(with-current-buffer buf
(let* ((frame gpu-video--frame)
(win (and (frame-live-p frame) (get-buffer-window buf frame))))
(cond
((not (frame-live-p frame))
(gpu-video--teardown buf))
((and win (eq buf (window-buffer (frame-selected-window frame))))
(unless (eq (frame-parameter frame 'gpu-video-owner) buf)
(gpu-video--claim buf frame))
(gpu-video--fit buf win frame)
(gpu-video-tick frame)
(let ((now (float-time)))
(when (> (- now gpu-video--last-draw) 0.2)
(setq gpu-video--last-draw now)
(gpu-video--draw-controls buf))))
((eq (frame-parameter frame 'gpu-video-owner) buf)
;; We own the player but are not focused: park it off-screen.
(gpu-video-move 0 -32768
(max 1 gpu-video--width) (max 1 gpu-video--height)
nil frame)
(gpu-video-tick frame)))))))
(defun gpu-video--teardown (buf)
"Stop playback and free the player for BUF."
(when (buffer-live-p buf)
(with-current-buffer buf
(when (timerp gpu-video--timer)
(cancel-timer gpu-video--timer))
(setq gpu-video--timer nil)
(let ((frame gpu-video--frame))
(when (and (frame-live-p frame)
(eq (frame-parameter frame 'gpu-video-owner) buf))
(gpu-video-close frame)
(set-frame-parameter frame 'gpu-video-owner nil))))))
(defun gpu-video--setup ()
"Lay out the video buffer and start playback."
(let ((inhibit-read-only t)
(file (buffer-file-name)))
(unless file
(error "Buffer is not visiting a file"))
(setq gpu-video--file file
gpu-video--frame (selected-frame)
gpu-video--width 16
gpu-video--height 16)
(erase-buffer)
(buffer-disable-undo)
(insert (propertize " "
'display '(space :width (16) :height (16))
'gpu-video t))
(setq gpu-video--vid-marker (copy-marker (1- (point))))
(insert "\n\n")
(setq gpu-video--ctrl-start (copy-marker (point) nil))
(gpu-video--draw-controls (current-buffer))
(set-buffer-modified-p nil)
(setq gpu-video--timer
(run-at-time 0 0.04 #'gpu-video--sync (current-buffer)))))
(defun gpu-video--setup-unsupported ()
"Show a notice when inline video playback is not available."
(let ((inhibit-read-only t))
(erase-buffer)
(if (and (fboundp 'gpu-backend-p) (gpu-backend-p))
;; Backend is on, but this platform's driver has no video player.
(insert "Inline video playback is only available on the macOS\n"
"(Metal) backend; the OpenGL backend has no video decoder.")
(insert "Video playback requires the GPU backend.\n\n"
"Enable it with M-x gpu-enable, then revert this buffer\n"
"with M-x revert-buffer."))
(set-buffer-modified-p nil)))
(defvar gpu-video-mode-map
(let ((m (make-sparse-keymap)))
(define-key m (kbd "SPC") #'gpu-video-toggle-play)
(define-key m (kbd "<left>") #'gpu-video-seek-backward)
(define-key m (kbd "<right>") #'gpu-video-seek-forward)
(define-key m (kbd "<") #'gpu-video-seek-start)
(define-key m (kbd "M-<") #'gpu-video-seek-start)
m)
"Keymap for `gpu-video-mode'.")
;;;###autoload
(define-derived-mode gpu-video-mode special-mode "GPU-Video"
"Major mode that plays a video file on the GPU.
The video autoplays and loops, with play/pause and a clickable timeline.
\\{gpu-video-mode-map}"
(setq-local cursor-type nil
truncate-lines t
create-lockfiles nil
buffer-offer-save nil)
(auto-save-mode -1)
(add-hook 'kill-buffer-hook
(lambda () (gpu-video--teardown (current-buffer))) nil t)
;; Inline video needs the AVFoundation player primitives, which only the
;; macOS (Metal) backend provides; the OpenGL backend has no decoder.
(if (and (fboundp 'gpu-video-open) (fboundp 'gpu-backend-p) (gpu-backend-p)
(display-graphic-p))
(gpu-video--setup)
(gpu-video--setup-unsupported)))
(defun gpu-video-register-auto-mode ()
"Register `gpu-video-mode' in `auto-mode-alist'.
Uses the extensions in `gpu-video-file-extensions'."
(setq auto-mode-alist
(rassq-delete-all 'gpu-video-mode auto-mode-alist))
(when gpu-video-file-extensions
(push (cons (concat "\\.\\(?:"
(mapconcat #'regexp-quote gpu-video-file-extensions "\\|")
"\\)\\'")
'gpu-video-mode)
auto-mode-alist)))
;;;###autoload
(add-to-list 'auto-mode-alist
'("\\.\\(?:mp4\\|mov\\|m4v\\|3gp\\)\\'" . gpu-video-mode))
;; Refresh the mapping from `gpu-video-file-extensions' when this file loads.
(gpu-video-register-auto-mode)
;; ---------------------------------------------------------------------------
;; Startup integration
(defun gpu--maybe-enable-on-startup (&optional frame)
"Enable Metal on FRAME (or the selected frame) per `gpu-enable-on-startup'."
(when (and gpu-enable-on-startup
(fboundp 'gpu-backend-p)
(gpu-backend-p))
(let ((f (or frame (selected-frame))))
;; New frames pass through here mid-creation; tooltip/child/TTY
;; frames must not abort frame creation with an error.
(when (display-graphic-p f)
(ignore-errors (gpu-enable f))))))
(add-hook 'after-make-frame-functions #'gpu--maybe-enable-on-startup)
;; ---------------------------------------------------------------------------
;; Minor mode
(defvar gpu-mode-map
(let ((map (make-sparse-keymap)))
(define-key map (kbd "C-c m e") #'gpu-enable)
(define-key map (kbd "C-c m s") #'gpu-status)
(define-key map (kbd "C-c m c") #'gpu-set-cursor)
(define-key map (kbd "C-c m S") #'gpu-set-scroll)
(define-key map (kbd "C-c m a") #'gpu-toggle-animations)
map)
"Keymap for `gpu-mode'.")
;;;###autoload
(define-minor-mode gpu-mode
"Minor mode for Metal GPU backend configuration.
Provides keybindings and ensures the Metal backend is configured.
\\{gpu-mode-map}"
:lighter " Metal"
:keymap gpu-mode-map
:global t
(if gpu-mode
(when gpu-enable-on-startup
(gpu--maybe-enable-on-startup))
nil))
;; ---------------------------------------------------------------------------
;; Backward compatibility: the public API used the mtl- prefix up to 0.1.0.
(define-obsolete-function-alias 'mtl-enable #'gpu-enable "0.2")
(define-obsolete-function-alias 'mtl-status #'gpu-status "0.2")
(define-obsolete-function-alias 'mtl-toggle-animations #'gpu-toggle-animations "0.2")
(define-obsolete-function-alias 'mtl-set-cursor #'gpu-set-cursor "0.2")
(define-obsolete-function-alias 'mtl-set-scroll #'gpu-set-scroll "0.2")
(define-obsolete-function-alias 'mtl-video-insert #'gpu-video-insert "0.2")
(define-obsolete-function-alias 'mtl-video-stop #'gpu-video-stop "0.2")
(provide 'mtl) ;; (require 'mtl) keeps working
(provide 'gpu)
;;; gpu.el ends here