// MetalHostView — an NSView that hosts the shared Core renderer in a CAMetalLayer,
// driven by a CADisplayLink (NSView.displayLink(target:selector:), macOS 14+).
// The system paces callbacks to preferredFrameRateRange (no manual vsync skipping,
// ProMotion can down-clock), and everything — start/stop/render — runs on the main
// thread, so the old display-thread teardown barrier is gone entirely.
//
// Invariant: hosts call stop() before dropping the view (CADisplayLink retains its
// target); viewDidMoveToWindow(nil) is the safety net.
import AppKit
import Metal
import QuartzCore
final class MetalHostView: NSView {
private let device = MTLCreateSystemDefaultDevice()
private var metalLayer: CAMetalLayer?
private var renderer: Renderer?
private var link: CADisplayLink?
private var lastTime: CFTimeInterval = 0
// Fallback cap for displays that don't honour the range hint exactly
// (fixed-rate external panels): skip callbacks that arrive early. dt stays
// correct (measured from the last *rendered* frame).
private var minFrameInterval: CFTimeInterval = 1.0 / 61.0
private var frameRange = CAFrameRateRange(minimum: 30, maximum: 60, preferred: 60)
private var paused = false
private var renderScale: CGFloat = 1
private let settings: Settings
private let autoCycleSec: Double
/// Wallpaper power policy: target frame rate (e.g. 30 on AC, 10 on battery).
func setFrameCap(_ fps: Double) {
let f = Float(max(1, fps))
frameRange = CAFrameRateRange(minimum: max(1, f / 3), maximum: f, preferred: f)
minFrameInterval = 1.0 / (Double(f) + 1)
link?.preferredFrameRateRange = frameRange
}
/// Power policy: pause the display link entirely (0 wakeups). The last
/// rendered frame stays on the layer (= the "frozen" still).
func setPaused(_ p: Bool) { paused = p; link?.isPaused = p }
/// Wallpaper: render at a fraction of the backing size; the CAMetalLayer
/// upscales to the window. The CRT look hides it, cost drops quadratically.
/// Fullscreen/preview never call this (stay at 1).
func setRenderScale(_ s: CGFloat) {
renderScale = min(1, max(0.25, s))
updateDrawableSize()
}
/// Manual scene-switch hotkey (deliberate fullscreen only): +1 next, -1 previous.
func cycleScene(by delta: Int) { renderer?.cycleScene(by: delta) }
init(frame: NSRect, settings: Settings, autoCycleSec: Double) {
self.settings = settings
self.autoCycleSec = autoCycleSec
super.init(frame: frame)
wantsLayer = true
layer?.backgroundColor = NSColor.black.cgColor
setupMetal()
}
@available(*, unavailable)
required init?(coder: NSCoder) { fatalError("init(coder:) not supported") }
private func setupMetal() {
guard let device else { return }
let ml = CAMetalLayer()
ml.device = device
ml.pixelFormat = .bgra8Unorm
ml.isOpaque = true
ml.frame = bounds
layer?.addSublayer(ml)
metalLayer = ml
let ctx = SceneContext(device: device, rng: LCG(seed: settings.seed ?? freshSeed()),
settings: settings, accent: settings.preset.accentRGB)
let scene = SceneRegistry.make(settings.scene, ctx: ctx)
do {
let r = try Renderer(device: device, settings: settings, scene: scene, targetFormat: .bgra8Unorm)
r.autoCycleSec = autoCycleSec
renderer = r
} catch {
// Don't hard-crash: leave renderer nil so start()/renderFrame() no-op
// (black layer) and the app stays alive with a logged reason.
NSLog("[Kuro] Renderer init failed — rendering disabled: \(error)")
renderer = nil
}
updateDrawableSize()
}
private func updateDrawableSize() {
guard let ml = metalLayer else { return }
let scale = window?.backingScaleFactor ?? 2.0
ml.frame = bounds
ml.contentsScale = scale
ml.drawableSize = CGSize(width: max(1, bounds.width * scale * renderScale),
height: max(1, bounds.height * scale * renderScale))
}
override func viewDidMoveToWindow() {
super.viewDidMoveToWindow()
if window == nil { stop() } else { updateDrawableSize() }
}
override func setFrameSize(_ newSize: NSSize) {
super.setFrameSize(newSize)
updateDrawableSize()
}
func start() {
guard link == nil, renderer != nil, metalLayer != nil else { return }
lastTime = 0
let l = displayLink(target: self, selector: #selector(tick))
l.preferredFrameRateRange = frameRange
l.isPaused = paused
l.add(to: .main, forMode: .common)
link = l
}
func stop() {
link?.invalidate() // removes it from the run loop + drops its target ref
link = nil
}
/// Render exactly ONE frame, even while paused. Called once right after the
/// view is installed, so a wallpaper that the power policy freezes still shows
/// an image. Without it a frozen-before-first-frame window stays opaque black
/// and swallows the user's desktop picture — "frozen" is meant to hold the last
/// frame, and that only works if a last frame exists.
func renderPrimingFrame() {
// Fast-forward past the CRT power-on animation first: at t=0 the scene is a
// single horizontal scan line, which is a poor thing to freeze on. Step in
// tick-sized slices so the simulation stays in its normal dt range.
// No waitFrameSlot() needed around these: this runs before the very first
// draw, so no frame can be in flight yet. `lastTime` stays 0 — renderFrame()
// seeds it itself, and setting it here would make the frame-rate limiter
// discard this very frame.
if let renderer {
for _ in 0..<primingSteps { renderer.advance(dt: primingStep) }
}
renderFrame()
}
private let primingStep: Double = 0.05 // == tick's dt clamp
private let primingSteps = 40 // ≈ 2 s of scene time
@objc private func tick() {
guard !paused else { return }
renderFrame()
}
private func renderFrame() {
guard let ml = metalLayer, let renderer else { return }
let now = CACurrentMediaTime()
if lastTime != 0, now - lastTime < minFrameInterval { return }
// Acquire the frame slot BEFORE advance(): scenes write shared vertex
// buffers there, which must never overlap a frame still on the GPU.
renderer.waitFrameSlot()
if lastTime == 0 { lastTime = now }
let dt = min(0.05, max(0, now - lastTime))
lastTime = now
renderer.advance(dt: dt)
guard let drawable = ml.nextDrawable() else {
renderer.releaseFrameSlot() // no draw → release manually
return
}
renderer.draw(into: drawable.texture)
drawable.present()
}
}