UTM/Platform/macOS/Display/VMDisplayMetalWindowControl...

326 lines
14 KiB
Swift

//
// Copyright © 2020 osy. All rights reserved.
//
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.
//
class VMDisplayMetalWindowController: VMDisplayWindowController, UTMSpiceIODelegate {
var metalView: VMMetalView!
var renderer: UTMRenderer?
@objc dynamic var vmDisplay: CSDisplayMetal?
@objc dynamic var vmInput: CSInput?
private var displaySizeObserver: NSKeyValueObservation?
private var displaySize: CGSize = .zero
private var isDisplaySizeDynamic: Bool = false
private let minDynamicSize = CGSize(width: 800, height: 600)
private var ctrlKeyDown: Bool = false
// MARK: - User preferences
@Setting("NoCursorCaptureAlert") private var isCursorCaptureAlertShown: Bool = false
@Setting("AlwaysNativeResolution") private var isAlwaysNativeResolution: Bool = false
@Setting("DisplayFixed") private var isDisplayFixed: Bool = false
@Setting("CtrlRightClick") private var isCtrlRightClick: Bool = false
private var settingObservations = [NSKeyValueObservation]()
// MARK: - Init
override func windowDidLoad() {
super.windowDidLoad()
metalView = VMMetalView(frame: displayView.bounds)
metalView.autoresizingMask = [.minXMargin, .maxXMargin, .minYMargin, .maxYMargin]
metalView.device = MTLCreateSystemDefaultDevice()
guard let _ = metalView.device else {
showErrorAlert(NSLocalizedString("Metal is not supported on this device. Cannot render display.", comment: "VMDisplayMetalWindowController"))
logger.critical("Cannot find system default Metal device.")
return
}
displayView.addSubview(metalView)
renderer = UTMRenderer.init(metalKitView: metalView)
guard let renderer = self.renderer else {
showErrorAlert(NSLocalizedString("Internal error.", comment: "VMDisplayMetalWindowController"))
logger.critical("Failed to create renderer.")
return
}
renderer.mtkView(metalView, drawableSizeWillChange: metalView.drawableSize)
renderer.changeUpscaler(vmConfiguration?.displayUpscalerValue ?? .linear, downscaler: vmConfiguration?.displayDownscalerValue ?? .linear)
metalView.delegate = renderer
metalView.inputDelegate = self
settingObservations.append(UserDefaults.standard.observe(\.AlwaysNativeResolution, options: .new) { (defaults, change) in
self.displaySizeDidChange(size: self.displaySize)
})
settingObservations.append(UserDefaults.standard.observe(\.DisplayFixed, options: .new) { (defaults, change) in
self.displaySizeDidChange(size: self.displaySize)
})
if vm.state == .vmStopped || vm.state == .vmSuspended {
enterSuspended(isBusy: false)
DispatchQueue.global(qos: .userInitiated).async {
if self.vm.startVM() {
self.vm.ioDelegate = self
}
}
} else {
enterLive()
vm.ioDelegate = self
}
}
override func enterLive() {
metalView.isHidden = false
screenshotView.isHidden = true
renderer!.sourceScreen = vmDisplay
renderer!.sourceCursor = vmInput
displaySizeObserver = observe(\.vmDisplay!.displaySize, options: [.initial, .new]) { (_, change) in
guard let size = change.newValue else { return }
self.displaySizeDidChange(size: size)
}
if vmConfiguration!.shareClipboardEnabled {
UTMPasteboard.general.requestPollingMode(forHashable: self) // start clipboard polling
}
super.enterLive()
resizeConsoleToolbarItem.isEnabled = false // disable item
}
override func enterSuspended(isBusy busy: Bool) {
if !busy {
metalView.isHidden = true
screenshotView.image = vm.screenshot?.image
screenshotView.isHidden = false
}
if vmConfiguration!.shareClipboardEnabled {
UTMPasteboard.general.releasePollingMode(forHashable: self) // stop clipboard polling
}
super.enterSuspended(isBusy: busy)
}
override func captureMouseButtonPressed(_ sender: Any) {
captureMouse()
}
}
// MARK: - Screen management
extension VMDisplayMetalWindowController {
fileprivate func displaySizeDidChange(size: CGSize) {
if size == .zero {
logger.debug("Ignoring zero size display")
return
}
DispatchQueue.main.async {
logger.debug("resizing to: (\(size.width), \(size.height))")
self.displaySize = size
guard let window = self.window else { return }
guard let vmDisplay = self.vmDisplay else { return }
let currentScreenScale = window.screen?.backingScaleFactor ?? 1.0
let nativeScale = self.isAlwaysNativeResolution ? 1.0 : currentScreenScale
// change optional scale if needed
if self.isDisplaySizeDynamic || self.isDisplayFixed || (!self.isAlwaysNativeResolution && vmDisplay.viewportScale < currentScreenScale) {
vmDisplay.viewportScale = nativeScale
}
let minScaledSize = CGSize(width: size.width * nativeScale / currentScreenScale, height: size.height * nativeScale / currentScreenScale)
let fullContentWidth = size.width * vmDisplay.viewportScale / currentScreenScale
let fullContentHeight = size.height * vmDisplay.viewportScale / currentScreenScale
let contentRect = CGRect(x: window.frame.origin.x,
y: 0,
width: ceil(fullContentWidth),
height: ceil(fullContentHeight))
var windowRect = window.frameRect(forContentRect: contentRect)
windowRect.origin.y = window.frame.origin.y + window.frame.height - windowRect.height
if self.isDisplaySizeDynamic {
window.contentMinSize = self.minDynamicSize
window.contentResizeIncrements = NSSize(width: 1, height: 1)
window.setFrame(windowRect, display: false, animate: false)
} else {
window.contentMinSize = minScaledSize
window.contentAspectRatio = size
window.setFrame(windowRect, display: false, animate: true)
}
self.metalView.setFrameSize(contentRect.size)
}
}
func dynamicResolutionSupportDidChange(_ supported: Bool) {
if isDisplaySizeDynamic != supported {
displaySizeDidChange(size: displaySize)
}
isDisplaySizeDynamic = supported
}
func windowDidChangeScreen(_ notification: Notification) {
logger.debug("screen changed")
if let vmDisplay = self.vmDisplay {
displaySizeDidChange(size: vmDisplay.displaySize)
}
}
fileprivate func updateHostScaling(for window: NSWindow, frameSize: NSSize) -> NSSize {
guard !isDisplayFixed else { return frameSize }
guard displaySize != .zero else { return frameSize }
guard let vmDisplay = self.vmDisplay else { return frameSize }
let currentScreenScale = window.screen?.backingScaleFactor ?? 1.0
let targetContentSize = window.contentRect(forFrameRect: CGRect(origin: .zero, size: frameSize)).size
let targetScaleX = targetContentSize.width * currentScreenScale / displaySize.width
let targetScaleY = targetContentSize.height * currentScreenScale / displaySize.height
let targetScale = min(targetScaleX, targetScaleY)
let scaledSize = CGSize(width: displaySize.width * targetScale / currentScreenScale, height: displaySize.height * targetScale / currentScreenScale)
let targetFrameSize = window.frameRect(forContentRect: CGRect(origin: .zero, size: scaledSize)).size
vmDisplay.viewportScale = targetScale
logger.debug("changed scale \(targetScale)")
self.metalView.setFrameSize(scaledSize)
return targetFrameSize
}
fileprivate func updateGuestResolution(for window: NSWindow, frameSize: NSSize) -> NSSize {
guard let vmDisplay = self.vmDisplay else { return frameSize }
let currentScreenScale = window.screen?.backingScaleFactor ?? 1.0
let nativeScale = isAlwaysNativeResolution ? currentScreenScale : 1.0
let targetSize = window.contentRect(forFrameRect: CGRect(origin: .zero, size: frameSize)).size
let targetSizeScaled = isAlwaysNativeResolution ? targetSize.applying(CGAffineTransform(scaleX: nativeScale, y: nativeScale)) : targetSize
logger.debug("Requesting resolution: (\(targetSizeScaled.width), \(targetSizeScaled.height))")
let bounds = CGRect(origin: .zero, size: targetSizeScaled)
vmDisplay.requestResolution(bounds)
return frameSize
}
func windowWillResize(_ sender: NSWindow, to frameSize: NSSize) -> NSSize {
guard !self.isDisplaySizeDynamic else {
return frameSize
}
return updateHostScaling(for: sender, frameSize: frameSize)
}
func windowDidEndLiveResize(_ notification: Notification) {
guard self.isDisplaySizeDynamic, let window = self.window else {
return
}
_ = updateGuestResolution(for: window, frameSize: window.frame.size)
}
func windowDidBecomeKey(_ notification: Notification) {
if let window = self.window {
_ = window.makeFirstResponder(metalView)
}
}
func windowDidResignKey(_ notification: Notification) {
if let window = self.window {
_ = window.makeFirstResponder(nil)
}
}
}
// MARK: - Input events
extension VMDisplayMetalWindowController: VMMetalViewInputDelegate {
private func captureMouse() {
let action = { () -> Void in
self.vm.requestInputTablet(false)
self.metalView?.captureMouse()
}
if isCursorCaptureAlertShown {
let alert = NSAlert()
alert.messageText = NSLocalizedString("Captured mouse", comment: "VMDisplayMetalWindowController")
alert.informativeText = NSLocalizedString("To release the mouse cursor, press ⌃+⌥ (Ctrl+Opt or Ctrl+Alt) at the same time.", comment: "VMDisplayMetalWindowController")
alert.showsSuppressionButton = true
alert.beginSheetModal(for: window!) { _ in
if alert.suppressionButton?.state ?? .off == .on {
self.isCursorCaptureAlertShown = false
}
DispatchQueue.main.async(execute: action)
}
} else {
action()
}
}
private func releaseMouse() {
vm.requestInputTablet(true)
metalView?.releaseMouse()
}
func mouseMove(absolutePoint: CGPoint, button: CSInputButton) {
guard let window = self.window else { return }
let currentScreenScale = window.screen?.backingScaleFactor ?? 1.0
let viewportScale = vmDisplay?.viewportScale ?? 1.0
let frameSize = metalView.frame.size
let newX = absolutePoint.x * currentScreenScale / viewportScale
let newY = (frameSize.height - absolutePoint.y) * currentScreenScale / viewportScale
let point = CGPoint(x: newX, y: newY)
logger.debug("move cursor: cocoa (\(absolutePoint.x), \(absolutePoint.y)), native (\(newX), \(newY))")
vmInput?.sendMouseMotion(button, point: point)
vmInput?.forceCursorPosition(point) // required to show cursor on screen
}
func mouseMove(relativePoint: CGPoint, button: CSInputButton) {
let translated = CGPoint(x: relativePoint.x, y: -relativePoint.y)
vmInput?.sendMouseMotion(button, point: translated)
}
private func modifyMouseButton(_ button: CSInputButton) -> CSInputButton {
let buttonMod: CSInputButton
if button.contains(.left) && ctrlKeyDown && isCtrlRightClick {
buttonMod = button.subtracting(.left).union(.right)
} else {
buttonMod = button
}
return buttonMod
}
func mouseDown(button: CSInputButton) {
vmInput?.sendMouseButton(modifyMouseButton(button), pressed: true, point: .zero)
}
func mouseUp(button: CSInputButton) {
vmInput?.sendMouseButton(modifyMouseButton(button), pressed: false, point: .zero)
}
func mouseScroll(dy: CGFloat, button: CSInputButton) {
var scrollDy = dy
if vmConfiguration?.inputScrollInvert ?? false {
scrollDy = -scrollDy
}
vmInput?.sendMouseScroll(.smooth, button: button, dy: dy)
}
private func sendExtendedKey(_ button: CSInputKey, keyCode: Int) {
if (keyCode & 0xFF00) == 0xE000 {
vmInput?.send(button, code: Int32(0x100 | (keyCode & 0xFF)))
} else if keyCode >= 0x100 {
logger.warning("ignored invalid keycode \(keyCode)");
} else {
vmInput?.send(button, code: Int32(keyCode))
}
}
func keyDown(keyCode: Int) {
if (keyCode & 0xFF) == 0x1D { // Ctrl
ctrlKeyDown = true
}
sendExtendedKey(.press, keyCode: keyCode)
}
func keyUp(keyCode: Int) {
if (keyCode & 0xFF) == 0x1D { // Ctrl
ctrlKeyDown = false
}
sendExtendedKey(.release, keyCode: keyCode)
}
func requestReleaseCapture() {
releaseMouse()
}
}