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