import SwiftUI
import MetalKit
import PhysarumEngine
#if os(macOS)
typealias ViewRepresentable = NSViewRepresentable
typealias PlatformContainer = NSView
#else
typealias ViewRepresentable = UIViewRepresentable
typealias PlatformContainer = UIView
#endif
/// SwiftUI wrapper around an MTKView driven by a RenderCoordinator. The MTKView is
/// pinned to fill a plain container view — a bare MTKView inside a representable keeps
/// its 320×480 default frame instead of filling the SwiftUI layout, which letterboxes
/// the render. With the container it fills the screen; the renderer then cover-crops
/// the square sim into the resulting (non-square) drawable via a viewport.
struct MetalView: ViewRepresentable {
let initialField: FieldSize
let selectedPreset: Preset
let editedParams: PresetParams
var cover: Bool = false
var format: SimFormat = .auto
var tier: FieldTier = .current
var paused: Bool = false
var audioInput: AudioInput? = nil
var audioReactive: AudioReactive = AudioReactive(enabled: false, gain: 1, autoGain: true, routes: [])
var audioTuning: AudioTuning = AudioTuning()
var remoteAudioFactorsSource: (() -> AudioFactorFrame?)? = nil // T2b: tvOS sink for phone-streamed factors
var snapshotProvider: LiveSnapshotProvider? = nil
func makeCoordinator() -> RenderCoordinator? {
RenderCoordinator(initialField: initialField, tier: tier, initialPreset: selectedPreset, live: editedParams)
}
private func makeContainer(_ coordinator: RenderCoordinator?) -> PlatformContainer {
let container = PlatformContainer()
let mtk = MTKView()
if let c = coordinator {
mtk.device = c.device
mtk.delegate = c
}
mtk.colorPixelFormat = .bgra8Unorm
mtk.preferredFramesPerSecond = 60
mtk.translatesAutoresizingMaskIntoConstraints = false
container.addSubview(mtk)
NSLayoutConstraint.activate([
mtk.leadingAnchor.constraint(equalTo: container.leadingAnchor),
mtk.trailingAnchor.constraint(equalTo: container.trailingAnchor),
mtk.topAnchor.constraint(equalTo: container.topAnchor),
mtk.bottomAnchor.constraint(equalTo: container.bottomAnchor),
])
return container
}
private func sync(_ coordinator: RenderCoordinator?, container: PlatformContainer) {
// Pausing stops the MTKView display link entirely — no sim, no render,
// no GPU work while a covering sheet hides the canvas or the scene is
// inactive. Audio analysis keeps running; factors re-apply on resume.
if let mtk = container.subviews.first as? MTKView {
if mtk.isPaused != paused {
mtk.isPaused = paused
#if os(tvOS)
// Finish in-flight GPU work before tvOS suspends the app — pausing the
// display link alone doesn't drain the up-to-3 frames already submitted.
if paused { coordinator?.drain() }
#endif
}
#if os(iOS)
// UIKit resets contentScaleFactor to the screen's native scale on
// window attach, so re-cap on every sync (idempotent, cheap).
let capped = cappedDrawableScale(mtk.contentScaleFactor, tier: tier)
if mtk.contentScaleFactor != capped { mtk.contentScaleFactor = capped }
#endif
}
guard let c = coordinator else { return }
snapshotProvider?.coordinator = c
c.cover = cover
c.format = format
c.audioInput = audioInput
c.audioReactive = audioReactive
c.audioTuning = audioTuning
c.remoteAudioFactorsSource = remoteAudioFactorsSource
c.switchPreset(selectedPreset, live: editedParams) // re-seed on preset change
c.setParams(editedParams) // live edits + debounced respawn
}
#if os(macOS)
func makeNSView(context: Context) -> NSView { makeContainer(context.coordinator) }
func updateNSView(_ nsView: NSView, context: Context) { sync(context.coordinator, container: nsView) }
#else
func makeUIView(context: Context) -> UIView { makeContainer(context.coordinator) }
func updateUIView(_ uiView: UIView, context: Context) { sync(context.coordinator, container: uiView) }
#endif
}