186 lines
6.4 KiB
Swift
186 lines
6.4 KiB
Swift
import LumaCore
|
|
import SwiftUI
|
|
|
|
#if canImport(AppKit)
|
|
|
|
struct VirtualMachineScreen: View {
|
|
let source: any VirtualMachineFrameSource
|
|
let capturing: PointerCapturePolicy
|
|
let dismissal: String?
|
|
|
|
@FocusState private var isFocused: Bool
|
|
@State private var isPointerInside = false
|
|
@State private var isPointerCaptured = false
|
|
@State private var showsReleaseHint = false
|
|
@State private var showsDismissal = true
|
|
|
|
var body: some View {
|
|
GeometryReader { geometry in
|
|
let frame = source.frame
|
|
let placement = placement(of: frame, in: geometry.size)
|
|
|
|
ZStack {
|
|
Color.black
|
|
|
|
if let frame, let image = image(of: frame, at: source.revision) {
|
|
Image(decorative: image, scale: 1)
|
|
.resizable()
|
|
.interpolation(.none)
|
|
.frame(width: placement.width, height: placement.height)
|
|
.position(x: placement.midX, y: placement.midY)
|
|
}
|
|
}
|
|
.clipShape(Self.border)
|
|
.overlay(Self.border.strokeBorder(isFocused ? Color.accentColor : .clear, lineWidth: 2))
|
|
.overlay(pointer(frame: frame, placement: placement))
|
|
.overlay(alignment: .bottom) { hintLabel }
|
|
.animation(.easeInOut(duration: 0.2), value: hint)
|
|
.contentShape(Rectangle())
|
|
.focusable()
|
|
.focusEffectDisabled()
|
|
.focused($isFocused)
|
|
.onKeyPress(phases: [.down, .up]) { press in
|
|
send(press)
|
|
return .handled
|
|
}
|
|
.onAppear { isFocused = true }
|
|
.task(id: isPointerCaptured) {
|
|
showsDismissal = true
|
|
try? await Task.sleep(for: .seconds(Self.dismissalSeconds))
|
|
guard !Task.isCancelled else { return }
|
|
showsDismissal = false
|
|
}
|
|
}
|
|
}
|
|
|
|
private static let border = RoundedRectangle(cornerRadius: 6)
|
|
|
|
@ViewBuilder
|
|
private var hintLabel: some View {
|
|
if let hint {
|
|
Text(hint)
|
|
.font(.caption2)
|
|
.padding(.horizontal, 6)
|
|
.padding(.vertical, 3)
|
|
.background(.black.opacity(0.55), in: Capsule())
|
|
.foregroundStyle(.white)
|
|
.padding(6)
|
|
.transition(.opacity)
|
|
}
|
|
}
|
|
|
|
private var hint: String? {
|
|
let notes = [pointerNote, showsDismissal ? dismissal : nil].compactMap { $0 }
|
|
guard !notes.isEmpty else { return nil }
|
|
return notes.joined(separator: " · ")
|
|
}
|
|
|
|
private var pointerNote: String? {
|
|
guard !source.pointerIsAbsolute else { return nil }
|
|
if showsReleaseHint {
|
|
return "Hold Control-Option to release the pointer"
|
|
}
|
|
return (isPointerInside && !isPointerCaptured) ? "Click to drive the pointer" : nil
|
|
}
|
|
|
|
private static let dismissalSeconds = 6
|
|
|
|
@ViewBuilder
|
|
private func pointer(frame: VirtualMachineFrame?, placement: CGRect) -> some View {
|
|
#if canImport(AppKit)
|
|
VirtualMachinePointerCapture(
|
|
placement: placement,
|
|
guestSize: frame.map { CGSize(width: $0.width, height: $0.height) },
|
|
isAbsolute: source.pointerIsAbsolute,
|
|
capturing: capturing,
|
|
send: { source.send($0) },
|
|
captureChanged: { captured in
|
|
isPointerCaptured = captured
|
|
showsReleaseHint = captured
|
|
},
|
|
hoverChanged: { isPointerInside = $0 }
|
|
)
|
|
.task(id: showsReleaseHint) {
|
|
guard showsReleaseHint else { return }
|
|
try? await Task.sleep(for: .seconds(3))
|
|
guard !Task.isCancelled else { return }
|
|
showsReleaseHint = false
|
|
}
|
|
#endif
|
|
}
|
|
|
|
private func send(_ press: KeyPress) {
|
|
if let key = VirtualMachineKey(press.key) {
|
|
let code = VirtualMachineKeyboard.code(for: key)
|
|
source.send(press.phase == .down ? .keyDown(code: code) : .keyUp(code: code))
|
|
return
|
|
}
|
|
|
|
guard press.phase == .down,
|
|
let character = press.characters.first,
|
|
let stroke = VirtualMachineKeyboard.stroke(for: character)
|
|
else { return }
|
|
|
|
stroke.events.forEach(source.send)
|
|
}
|
|
|
|
private func placement(of frame: VirtualMachineFrame?, in size: CGSize) -> CGRect {
|
|
guard let frame, frame.width > 0, frame.height > 0 else { return .zero }
|
|
|
|
let scale = min(size.width / CGFloat(frame.width), size.height / CGFloat(frame.height))
|
|
let width = CGFloat(frame.width) * scale
|
|
let height = CGFloat(frame.height) * scale
|
|
return CGRect(x: (size.width - width) / 2, y: (size.height - height) / 2, width: width, height: height)
|
|
}
|
|
|
|
private func image(of frame: VirtualMachineFrame, at revision: UInt64) -> CGImage? {
|
|
let pixels = frame.withPixels { Data($0) }
|
|
guard let provider = CGDataProvider(data: pixels as CFData) else { return nil }
|
|
|
|
return CGImage(
|
|
width: frame.width,
|
|
height: frame.height,
|
|
bitsPerComponent: 8,
|
|
bitsPerPixel: 32,
|
|
bytesPerRow: frame.stride,
|
|
space: CGColorSpaceCreateDeviceRGB(),
|
|
bitmapInfo: CGBitmapInfo(rawValue: CGImageAlphaInfo.noneSkipFirst.rawValue | CGBitmapInfo.byteOrder32Little.rawValue),
|
|
provider: provider,
|
|
decode: nil,
|
|
shouldInterpolate: false,
|
|
intent: .defaultIntent
|
|
)
|
|
}
|
|
}
|
|
|
|
extension VirtualMachineKey {
|
|
init?(_ equivalent: KeyEquivalent) {
|
|
switch equivalent {
|
|
case .escape: self = .escape
|
|
case .delete: self = .backspace
|
|
case .deleteForward: self = .delete
|
|
case .tab: self = .tab
|
|
case .return: self = .return
|
|
case .space: self = .space
|
|
case .upArrow: self = .upArrow
|
|
case .downArrow: self = .downArrow
|
|
case .leftArrow: self = .leftArrow
|
|
case .rightArrow: self = .rightArrow
|
|
case .home: self = .home
|
|
case .end: self = .end
|
|
case .pageUp: self = .pageUp
|
|
case .pageDown: self = .pageDown
|
|
default: return nil
|
|
}
|
|
}
|
|
}
|
|
|
|
extension VirtualMachineFrameSource {
|
|
var aspectRatio: CGFloat {
|
|
guard let frame, frame.height > 0 else { return 4 / 3 }
|
|
return CGFloat(frame.width) / CGFloat(frame.height)
|
|
}
|
|
}
|
|
|
|
#endif
|