From bc6d007d8b24e6d1f24342d43aa31c447f2d0cbc Mon Sep 17 00:00:00 2001 From: Lakr Date: Tue, 29 Sep 2026 23:39:22 +0900 Subject: [PATCH] Turn the window with the guest's interface orientation MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit vphoned gains display.orientation, which asks SpringBoard's activeInterfaceOrientation through AXSpringBoardServer instead of capturing the screen, so the host can poll it every second. Guests that report display_orientation are polled; others stay portrait. The window's content view now holds the VM view turned to that orientation. The VM view keeps its portrait bounds, so touch mapping is unchanged: AppKit's conversion undoes the rotation. A windowed VM swaps its sides around its center and shrinks to fit the screen; full screen letterboxes the turned panel. A frame saved while sideways is turned back to portrait at launch. The Device menu gains Rotate Left (⌘←), Rotate Right (⌘→) and an Orientation submenu checked by the current orientation, enabled only while the agent reports display, so the keys otherwise reach the guest. An orientation the front app refuses is skipped by the rotate keys and reported by the submenu. New strings are translated for ja, ko, vi and zh-Hans. Co-Authored-By: Claude Opus 5.5 --- Research/vphoned_http_api.md | 2 +- VPhoneDaemon/Daemon/GuestAPI+Device.swift | 39 +++++ .../Resources/Localizable.xcstrings | 144 +++++++++++++++++- .../VPhoneGuestControl.swift | 13 ++ .../Menu/VPhoneMenuController.swift | 1 + .../UserInterface/Menu/VPhoneMenuDevice.swift | 102 +++++++++++++ .../Menu/VPhoneMenuDiagnostics.swift | 3 + ...VPhoneVirtualMachineWindowController.swift | 56 ++++++- .../VPhoneDisplayContainerView.swift | 49 ++++++ .../VPhoneDisplayOrientation.swift | 66 ++++++++ .../DisplayOrientationTests.swift | 89 +++++++++++ 11 files changed, 560 insertions(+), 4 deletions(-) create mode 100644 VPhoneExecutable/VPhoneVirtualization/UI/VirtualMachine/VPhoneDisplayContainerView.swift create mode 100644 VPhoneKit/VPhoneCoreKit/VirtualMachine/VPhoneDisplayOrientation.swift create mode 100644 VPhoneKit/VPhoneCoreKitTests/VirtualMachine/DisplayOrientationTests.swift diff --git a/Research/vphoned_http_api.md b/Research/vphoned_http_api.md index 88418b9..023cb94 100644 --- a/Research/vphoned_http_api.md +++ b/Research/vphoned_http_api.md @@ -270,7 +270,7 @@ request carries `"force": true`. | Area | Methods | | --- | --- | | Device | `device.snapshot`, `device.info` (snapshot plus network, screen, rotation, brightness, volume, low power, Developer Mode, agent), `device.screen`, `device.network`, `device.ioreg {plane}`, `device.environment`, `device.basebin {archive?}` | -| Display, audio | `display.brightness {value?}`, `display.rotation {orientation?}`, `display.rotation_lock {locked}`, `audio.volume {value?, category?}`, `audio.state` | +| Display, audio | `display.brightness {value?}`, `display.rotation {orientation?}`, `display.orientation` (`{degrees, source}`: SpringBoard's interface orientation without a screen capture, for polling; capability `display_orientation`), `display.rotation_lock {locked}`, `audio.volume {value?, category?}`, `audio.state` | | Input | `input.touch`, `input.hid`, `input.button {name}`, `input.key {name}`, `input.type {text, delay_ms?}`, `input.paste {text}`, `input.tap`, `input.double_tap`, `input.long_press`, `input.swipe`, `input.drag {points}`, `input.touch_sequence {events}` — gesture coordinates are screen points | | UI | `ui.tree` (alias `accessibility.tree`), `ui.element_at`, `ui.tap_element`, `ui.wait`, `ui.wait_gone`, `ui.ocr {languages?, min_confidence?}`, `ui.describe`, `screen.screenshot` | | Processes | `processes.list {filter?}`, `processes.kill {pid, signal?}` **force**, `memory.jetsam`, `memory.pressure` (only the three kernel memory sysctls, for polling) | diff --git a/VPhoneDaemon/Daemon/GuestAPI+Device.swift b/VPhoneDaemon/Daemon/GuestAPI+Device.swift index f5a4fb3..3024b0b 100644 --- a/VPhoneDaemon/Daemon/GuestAPI+Device.swift +++ b/VPhoneDaemon/Daemon/GuestAPI+Device.swift @@ -37,6 +37,8 @@ extension GuestAPI { return try setRotation(orientation) } return rotationInfo() + case "display.orientation": + return interfaceOrientation() case "display.rotation_lock": guard let locked = params["locked"] as? Bool else { throw GuestAPIError.invalidRequest("locked must be true or false") @@ -71,6 +73,43 @@ extension GuestAPI { } } + /// The interface orientation in `display.rotation`'s degrees, cheap + /// enough for the host to ask every second. SpringBoard is asked + /// directly; `rotationInfo()` captures the screen, and is only the + /// fallback when SpringBoard does not answer. + private static func interfaceOrientation() -> [String: Any] { + if let degrees = springBoardInterfaceDegrees() { + return ["degrees": degrees, "source": "springboard"] + } + let degrees = (rotationInfo()["degrees"] as? NSNumber)?.intValue ?? 0 + return ["degrees": degrees, "source": "screen"] + } + + /// AXSpringBoardServer's activeInterfaceOrientation, a + /// UIInterfaceOrientation, in degrees clockwise from portrait. + private static func springBoardInterfaceDegrees() -> Int? { + _ = dlopen( + "/System/Library/PrivateFrameworks/AccessibilityUtilities.framework/AccessibilityUtilities", + RTLD_NOW, + ) + let serverSelector = NSSelectorFromString("server") + let orientationSelector = NSSelectorFromString("activeInterfaceOrientation") + guard let serverClass = NSClassFromString("AXSpringBoardServer") as? NSObject.Type, + serverClass.responds(to: serverSelector), + let server = serverClass.perform(serverSelector)?.takeUnretainedValue() as? NSObject, + server.responds(to: orientationSelector) + else { return nil } + typealias Getter = @convention(c) (NSObject, Selector) -> Int + let getter = unsafeBitCast(server.method(for: orientationSelector), to: Getter.self) + switch getter(server, orientationSelector) { + case 1: return 0 + case 2: return 180 + case 3: return 270 + case 4: return 90 + default: return nil + } + } + private static func brightnessState() -> [String: Any] { ["value": brightness(), "auto": autoBrightness().map { $0 as Any } ?? NSNull()] } diff --git a/VPhoneExecutable/VPhoneVirtualization/Resources/Localizable.xcstrings b/VPhoneExecutable/VPhoneVirtualization/Resources/Localizable.xcstrings index cb31989..64d3583 100644 --- a/VPhoneExecutable/VPhoneVirtualization/Resources/Localizable.xcstrings +++ b/VPhoneExecutable/VPhoneVirtualization/Resources/Localizable.xcstrings @@ -1948,6 +1948,9 @@ } } } + }, + "Allow Network" : { + }, "Allow Wi-Fi and cellular data for this app" : { "localizations" : { @@ -1976,6 +1979,9 @@ } } } + }, + "Allowing network access…" : { + }, "Always" : { "localizations" : { @@ -13406,6 +13412,9 @@ } } } + }, + "Network Access" : { + }, "Network Policy" : { "localizations" : { @@ -13434,6 +13443,12 @@ } } } + }, + "Network access for %@ did not change. Relaunch the app and try again." : { + + }, + "Network access is restricted" : { + }, "Network access may be restricted" : { "localizations" : { @@ -18779,6 +18794,74 @@ } } }, + "Rotate Left" : { + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Rotate Left" + } + }, + "ja" : { + "stringUnit" : { + "state" : "translated", + "value" : "左に回転" + } + }, + "ko" : { + "stringUnit" : { + "state" : "translated", + "value" : "왼쪽으로 회전" + } + }, + "vi" : { + "stringUnit" : { + "state" : "translated", + "value" : "Xoay trái" + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "向左旋转" + } + } + } + }, + "Rotate Right" : { + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Rotate Right" + } + }, + "ja" : { + "stringUnit" : { + "state" : "translated", + "value" : "右に回転" + } + }, + "ko" : { + "stringUnit" : { + "state" : "translated", + "value" : "오른쪽으로 회전" + } + }, + "vi" : { + "stringUnit" : { + "state" : "translated", + "value" : "Xoay phải" + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "向右旋转" + } + } + } + }, "Rotate the guest interface" : { "localizations" : { "ja" : { @@ -22873,6 +22956,34 @@ } } }, + "The app in front does not support this orientation." : { + "localizations" : { + "ja" : { + "stringUnit" : { + "state" : "translated", + "value" : "前面のアプリはこの向きに対応していません。" + } + }, + "ko" : { + "stringUnit" : { + "state" : "translated", + "value" : "앞에 있는 앱이 이 방향을 지원하지 않습니다." + } + }, + "vi" : { + "stringUnit" : { + "state" : "translated", + "value" : "Ứng dụng đang mở không hỗ trợ hướng này." + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "前台应用不支持这个方向。" + } + } + } + }, "The app, its data container and its plug-ins' data are removed from the guest. This cannot be undone." : { "localizations" : { "ja" : { @@ -24333,6 +24444,34 @@ } } }, + "Unable to Rotate" : { + "localizations" : { + "ja" : { + "stringUnit" : { + "state" : "translated", + "value" : "回転できません" + } + }, + "ko" : { + "stringUnit" : { + "state" : "translated", + "value" : "회전할 수 없음" + } + }, + "vi" : { + "stringUnit" : { + "state" : "translated", + "value" : "Không thể xoay" + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "无法旋转" + } + } + } + }, "Unable to add the keychain item. Try again." : { "localizations" : { "en" : { @@ -24366,6 +24505,9 @@ } } } + }, + "Unable to allow network access for %@." : { + }, "Unable to capture the guest log. Check the connection. Retrying in 3 seconds." : { "localizations" : { @@ -29732,4 +29874,4 @@ } }, "version" : "1.3" -} +} \ No newline at end of file diff --git a/VPhoneExecutable/VPhoneVirtualization/UI/GuestCommunication/VPhoneGuestControl.swift b/VPhoneExecutable/VPhoneVirtualization/UI/GuestCommunication/VPhoneGuestControl.swift index f08224f..2be3ca1 100644 --- a/VPhoneExecutable/VPhoneVirtualization/UI/GuestCommunication/VPhoneGuestControl.swift +++ b/VPhoneExecutable/VPhoneVirtualization/UI/GuestCommunication/VPhoneGuestControl.swift @@ -2,6 +2,7 @@ import CryptoKit import Darwin import Foundation import Virtualization +import VPhoneCoreKit /// The VM UI's direct HTTP client over VSOCK. It does not open a host TCP /// listener; only --api-listen creates one through VPhoneAPIProxy. @@ -47,6 +48,17 @@ final class VPhoneGuestControl { @ObservationIgnored var onConnect: (([String]) -> Void)? @ObservationIgnored var onDisconnect: (() -> Void)? + /// The guest interface orientation the window last read; nil until it + /// has read one, and again after a disconnect. + var interfaceOrientation: VPhoneDisplayOrientation? { + didSet { + guard interfaceOrientation != oldValue else { return } + onInterfaceOrientationChange?(interfaceOrientation) + } + } + + @ObservationIgnored var onInterfaceOrientationChange: ((VPhoneDisplayOrientation?) -> Void)? + var useGuestTouchInjection: Bool { guard isConnected, guestCapabilities.contains("touch"), let major = guestIOSVersion.flatMap({ Int($0.split(separator: ".").first ?? "") }) @@ -136,6 +148,7 @@ final class VPhoneGuestControl { guestCapabilities = [] guestIPAddress = nil guestIOSVersion = nil + interfaceOrientation = nil if wasConnected { onDisconnect?() } diff --git a/VPhoneExecutable/VPhoneVirtualization/UI/UserInterface/Menu/VPhoneMenuController.swift b/VPhoneExecutable/VPhoneVirtualization/UI/UserInterface/Menu/VPhoneMenuController.swift index 40432c3..c76ec3b 100644 --- a/VPhoneExecutable/VPhoneVirtualization/UI/UserInterface/Menu/VPhoneMenuController.swift +++ b/VPhoneExecutable/VPhoneVirtualization/UI/UserInterface/Menu/VPhoneMenuController.swift @@ -35,6 +35,7 @@ class VPhoneMenuController { var settingsSetItem: NSMenuItem? var restartGuestItem: NSMenuItem? var panelMenuItems: [VPhoneGuestPanel: NSMenuItem] = [:] + var rotateMenuItems: [NSMenuItem] = [] var touchIDMonitor: VPhoneTouchIDMonitor? { didSet { touchIDMonitor?.isEnabled = touchIDMenuItem?.state == .on } } diff --git a/VPhoneExecutable/VPhoneVirtualization/UI/UserInterface/Menu/VPhoneMenuDevice.swift b/VPhoneExecutable/VPhoneVirtualization/UI/UserInterface/Menu/VPhoneMenuDevice.swift index 35535b2..e0b2512 100644 --- a/VPhoneExecutable/VPhoneVirtualization/UI/UserInterface/Menu/VPhoneMenuDevice.swift +++ b/VPhoneExecutable/VPhoneVirtualization/UI/UserInterface/Menu/VPhoneMenuDevice.swift @@ -1,5 +1,6 @@ import AppKit import LocalAuthentication +import VPhoneCoreKit // MARK: - Device Menu @@ -23,6 +24,31 @@ extension VPhoneMenuController { menu.addItem(makeItem("Volume Up", action: #selector(sendVolumeUp), symbol: "speaker.plus")) menu.addItem(makeItem("Volume Down", action: #selector(sendVolumeDown), symbol: "speaker.minus")) menu.addItem(NSMenuItem.separator()) + let rotateLeft = makeItem( + "Rotate Left", + action: #selector(rotateLeft), + keyEquivalent: String(UnicodeScalar(NSLeftArrowFunctionKey)!), + symbol: "rotate.left", + ) + let rotateRight = makeItem( + "Rotate Right", + action: #selector(rotateRight), + keyEquivalent: String(UnicodeScalar(NSRightArrowFunctionKey)!), + symbol: "rotate.right", + ) + let orientationItem = NSMenuItem(title: "Orientation", action: nil, keyEquivalent: "") + orientationItem.image = menuSymbol("rectangle.portrait.rotate") + orientationItem.submenu = buildOrientationMenu() + // Disabled until the agent connects, so ⌘← and ⌘→ reach the guest. + for rotate in [rotateLeft, rotateRight, orientationItem] { + rotate.isEnabled = false + menu.addItem(rotate) + } + rotateMenuItems = [rotateLeft, rotateRight, orientationItem] + control.onInterfaceOrientationChange = { [weak self] orientation in + self?.updateOrientationChecks(orientation) + } + menu.addItem(NSMenuItem.separator()) menu.addItem(makeItem("Open Guest Spotlight", action: #selector(sendSpotlight), symbol: "magnifyingglass")) let tidItem = makeItem("Touch ID Home Forwarding", action: #selector(toggleTouchIDForwarding)) if hasTouchID { @@ -63,6 +89,82 @@ extension VPhoneMenuController { keySender.sendSpotlight() } + // MARK: - Rotate + + /// The four interface orientations, checked by the one the window last + /// read from the guest. + private func buildOrientationMenu() -> NSMenu { + let menu = NSMenu(title: "Orientation") + let orientations: [(VPhoneDisplayOrientation, String)] = [ + (.portrait, "Portrait"), + (.landscapeLeft, "Landscape Left"), + (.landscapeRight, "Landscape Right"), + (.upsideDown, "Upside Down"), + ] + for (orientation, title) in orientations { + let item = makeItem(title, action: #selector(chooseOrientation(_:))) + item.representedObject = orientation.rawValue + menu.addItem(item) + } + return menu + } + + private func updateOrientationChecks(_ orientation: VPhoneDisplayOrientation?) { + guard let menu = rotateMenuItems.last?.submenu else { return } + for item in menu.items { + item.state = (item.representedObject as? Int) == orientation?.rawValue ? .on : .off + } + } + + @objc func chooseOrientation(_ sender: NSMenuItem) { + guard let degrees = sender.representedObject as? Int else { return } + Task { + do { + _ = try await control.call("display.rotation", params: ["orientation": String(degrees)]) + } catch { + print("[rotate] \(degrees) refused: \(error)") + VPhoneAlert.present( + title: "Unable to Rotate", + message: "The app in front does not support this orientation.", + style: .warning, + ) + } + } + } + + @objc func rotateLeft() { + rotate(clockwise: false) + } + + @objc func rotateRight() { + rotate(clockwise: true) + } + + /// Turns the guest a quarter turn from its current interface orientation. + /// An orientation the foreground app refuses, such as upside down on the + /// Home Screen, is skipped for the one after it. The window follows on + /// its next orientation poll. + private func rotate(clockwise: Bool) { + Task { + let method = control.guestCapabilities.contains("display_orientation") + ? "display.orientation" : "display.rotation" + guard let result = try? await control.call(method), + let degrees = (result["degrees"] as? NSNumber)?.intValue, + let current = VPhoneDisplayOrientation(degrees: degrees) + else { return } + var target = current.turned(clockwise: clockwise) + for _ in 0 ..< 2 { + do { + _ = try await control.call("display.rotation", params: ["orientation": String(target.rawValue)]) + return + } catch { + print("[rotate] \(target) refused: \(error)") + target = target.turned(clockwise: clockwise) + } + } + } + } + // MARK: - Restart func updateRestartAvailability(available: Bool) { diff --git a/VPhoneExecutable/VPhoneVirtualization/UI/UserInterface/Menu/VPhoneMenuDiagnostics.swift b/VPhoneExecutable/VPhoneVirtualization/UI/UserInterface/Menu/VPhoneMenuDiagnostics.swift index 7c7e63a..dc29abc 100644 --- a/VPhoneExecutable/VPhoneVirtualization/UI/UserInterface/Menu/VPhoneMenuDiagnostics.swift +++ b/VPhoneExecutable/VPhoneVirtualization/UI/UserInterface/Menu/VPhoneMenuDiagnostics.swift @@ -75,6 +75,9 @@ extension VPhoneMenuController { for (panel, item) in panelMenuItems { item.isEnabled = capabilities.contains(panel.capability) } + for item in rotateMenuItems { + item.isEnabled = capabilities.contains("display") + } } @objc func openPanel(_ sender: NSMenuItem) { diff --git a/VPhoneExecutable/VPhoneVirtualization/UI/UserInterface/VPhoneVirtualMachineWindowController.swift b/VPhoneExecutable/VPhoneVirtualization/UI/UserInterface/VPhoneVirtualMachineWindowController.swift index c9ed959..75837fd 100644 --- a/VPhoneExecutable/VPhoneVirtualization/UI/UserInterface/VPhoneVirtualMachineWindowController.swift +++ b/VPhoneExecutable/VPhoneVirtualization/UI/UserInterface/VPhoneVirtualMachineWindowController.swift @@ -1,6 +1,7 @@ import AppKit import Foundation import Virtualization +import VPhoneCoreKit @MainActor class VPhoneVirtualMachineWindowController: NSObject { @@ -34,13 +35,15 @@ class VPhoneVirtualMachineWindowController: NSObject { view.control = control view.clipboardSync = VPhoneClipboardSync(control: control) virtualMachineView = view - let vmView: NSView = view + let container = VPhoneDisplayContainerView(displayView: view) + displayContainer = container let scale = CGFloat(screenScale) let windowSize = NSSize( width: CGFloat(screenWidth) / scale, height: CGFloat(screenHeight) / scale, ) + panelSize = windowSize let window = NSWindow( contentRect: NSRect(origin: .zero, size: windowSize), @@ -54,7 +57,7 @@ class VPhoneVirtualMachineWindowController: NSObject { VPhoneAlert.hostWindow = window window.contentAspectRatio = windowSize window.title = name - window.contentView = vmView + window.contentView = container // The scene belongs to the VM, not to the app: every VM directory keeps // its own window frame, and a newly created VM opens centered instead @@ -65,6 +68,9 @@ class VPhoneVirtualMachineWindowController: NSObject { window.center() } window.setFrameAutosaveName(sceneName) + // A frame saved while the guest was sideways is turned back: the guest + // boots in portrait, and the orientation poll turns it again if not. + applyOrientation(.portrait, to: window, force: true) // An empty unified toolbar gives the title bar its full height. The Home // button is a titlebar accessory rather than a toolbar item so that a @@ -101,6 +107,52 @@ class VPhoneVirtualMachineWindowController: NSObject { self.updateSubtitle(control: control) } } + + _ = Timer.scheduledTimer(withTimeInterval: 1, repeats: true) { [weak self] _ in + Task { @MainActor in self?.pollOrientation() } + } + } + + // MARK: - Orientation + + private weak var displayContainer: VPhoneDisplayContainerView? + private var panelSize: NSSize = .zero + private var orientationPollInFlight = false + + /// Asks vphoned for the interface orientation once a second and turns the + /// window to it. Guests without `display.orientation` stay portrait. + private func pollOrientation() { + guard !orientationPollInFlight, + let control, control.isConnected, + control.guestCapabilities.contains("display_orientation") + else { return } + orientationPollInFlight = true + Task { + defer { orientationPollInFlight = false } + guard let result = try? await control.call("display.orientation"), + let degrees = (result["degrees"] as? NSNumber)?.intValue, + let orientation = VPhoneDisplayOrientation(degrees: degrees), + let window = windowController?.window + else { return } + control.interfaceOrientation = orientation + applyOrientation(orientation, to: window) + } + } + + /// Turns the VM view and gives the window the turned panel's aspect + /// ratio. A windowed VM is resized around its center; a full-screen one + /// keeps the screen and letterboxes the turned panel. + private func applyOrientation(_ orientation: VPhoneDisplayOrientation, to window: NSWindow, force: Bool = false) { + guard let container = displayContainer, force || container.orientation != orientation else { return } + container.orientation = orientation + let displayed = orientation.displayedSize(panel: panelSize) + window.contentAspectRatio = displayed + guard !window.styleMask.contains(.fullScreen) else { return } + let current = window.contentRect(forFrameRect: window.frame) + let visible = window.screen.map { window.contentRect(forFrameRect: $0.visibleFrame) } ?? .zero + let target = orientation.contentRect(from: current, panel: panelSize, within: visible) + guard target != current else { return } + window.setFrame(window.frameRect(forContentRect: target), display: true, animate: !force) } // MARK: - Title diff --git a/VPhoneExecutable/VPhoneVirtualization/UI/VirtualMachine/VPhoneDisplayContainerView.swift b/VPhoneExecutable/VPhoneVirtualization/UI/VirtualMachine/VPhoneDisplayContainerView.swift new file mode 100644 index 0000000..163dfc0 --- /dev/null +++ b/VPhoneExecutable/VPhoneVirtualization/UI/VirtualMachine/VPhoneDisplayContainerView.swift @@ -0,0 +1,49 @@ +import AppKit +import VPhoneCoreKit + +// MARK: - Display Container + +/// The window's content view. It holds the VM view turned to the guest's +/// interface orientation and fills itself with it. +/// +/// The VM view keeps its portrait bounds under the turn, so touch mapping, +/// which converts window points into those bounds, needs no change: AppKit's +/// conversion undoes the rotation. +final class VPhoneDisplayContainerView: NSView { + let displayView: NSView + + var orientation: VPhoneDisplayOrientation = .portrait { + didSet { + guard orientation != oldValue else { return } + needsLayout = true + } + } + + init(displayView: NSView) { + self.displayView = displayView + super.init(frame: .zero) + wantsLayer = true + displayView.autoresizingMask = [] + addSubview(displayView) + } + + @available(*, unavailable) + required init?(coder _: NSCoder) { + fatalError("init(coder:) is not supported") + } + + override func layout() { + super.layout() + // Sized unturned, then turned about its center: a quarter turn swaps + // the frame's sides, so the turned view fills the container. + let size = orientation.isSideways ? NSSize(width: bounds.height, height: bounds.width) : bounds.size + displayView.frameCenterRotation = 0 + displayView.frame = NSRect( + x: bounds.midX - size.width / 2, + y: bounds.midY - size.height / 2, + width: size.width, + height: size.height, + ) + displayView.frameCenterRotation = orientation.viewRotation + } +} diff --git a/VPhoneKit/VPhoneCoreKit/VirtualMachine/VPhoneDisplayOrientation.swift b/VPhoneKit/VPhoneCoreKit/VirtualMachine/VPhoneDisplayOrientation.swift new file mode 100644 index 0000000..d74e488 --- /dev/null +++ b/VPhoneKit/VPhoneCoreKit/VirtualMachine/VPhoneDisplayOrientation.swift @@ -0,0 +1,66 @@ +import CoreGraphics + +// MARK: - Display Orientation + +/// The guest interface orientation, and how the window shows it. +/// +/// The guest frame buffer is always the portrait panel; a rotated interface +/// is drawn sideways into it, as on a real iPhone. The window turns the panel +/// the way the device was turned so the interface reads upright, and takes +/// the turned panel's aspect ratio. +public enum VPhoneDisplayOrientation: Int, CaseIterable, Sendable { + case portrait = 0 + /// Interface landscape-left: the device turned clockwise. + case landscapeLeft = 90 + case upsideDown = 180 + /// Interface landscape-right: the device turned counterclockwise. + case landscapeRight = 270 + + /// `degrees` as vphoned's `display.orientation` and `display.rotation` + /// report it, clockwise from portrait. Any other value is nil. + public init?(degrees: Int) { + let normalized = ((degrees % 360) + 360) % 360 + self.init(rawValue: normalized) + } + + /// The panel's rotation on the Mac, counterclockwise in degrees, as + /// `NSView.frameCenterRotation` takes it: the device's clockwise turn. + public var viewRotation: CGFloat { + CGFloat((360 - rawValue) % 360) + } + + /// The orientation after turning the device a quarter turn, as the + /// Simulator's Rotate Left (⌘←) and Rotate Right (⌘→) do. + public func turned(clockwise: Bool) -> VPhoneDisplayOrientation { + VPhoneDisplayOrientation(degrees: rawValue + (clockwise ? 90 : -90)) ?? .portrait + } + + public var isSideways: Bool { + self == .landscapeLeft || self == .landscapeRight + } + + /// The panel's size as the window shows it. + public func displayedSize(panel: CGSize) -> CGSize { + isSideways ? CGSize(width: panel.height, height: panel.width) : panel + } + + /// The window content rect after turning to this orientation: the current + /// rect's long side becomes the side this orientation makes long, around + /// the same center, scaled down to fit `visible` when it would not. + public func contentRect(from current: CGRect, panel: CGSize, within visible: CGRect) -> CGRect { + let target = displayedSize(panel: panel) + guard target.width > 0, target.height > 0 else { return current } + let longSide = max(current.width, current.height) + var scale = longSide / max(target.width, target.height) + if visible.width > 0, visible.height > 0 { + scale = min(scale, visible.width / target.width, visible.height / target.height) + } + let size = CGSize(width: (target.width * scale).rounded(), height: (target.height * scale).rounded()) + var rect = CGRect(x: current.midX - size.width / 2, y: current.midY - size.height / 2, width: size.width, height: size.height) + if visible.width > 0, visible.height > 0 { + rect.origin.x = min(max(rect.minX, visible.minX), visible.maxX - rect.width) + rect.origin.y = min(max(rect.minY, visible.minY), visible.maxY - rect.height) + } + return rect + } +} diff --git a/VPhoneKit/VPhoneCoreKitTests/VirtualMachine/DisplayOrientationTests.swift b/VPhoneKit/VPhoneCoreKitTests/VirtualMachine/DisplayOrientationTests.swift new file mode 100644 index 0000000..00d59ea --- /dev/null +++ b/VPhoneKit/VPhoneCoreKitTests/VirtualMachine/DisplayOrientationTests.swift @@ -0,0 +1,89 @@ +import CoreGraphics +import Testing +@testable import VPhoneCoreKit + +struct DisplayOrientationTests { + static let panel = CGSize(width: 393, height: 852) + static let screen = CGRect(x: 0, y: 0, width: 1512, height: 944) + + // MARK: - Degrees + + @Test func `vphoned degrees map to orientations`() { + #expect(VPhoneDisplayOrientation(degrees: 0) == .portrait) + #expect(VPhoneDisplayOrientation(degrees: 90) == .landscapeLeft) + #expect(VPhoneDisplayOrientation(degrees: 180) == .upsideDown) + #expect(VPhoneDisplayOrientation(degrees: 270) == .landscapeRight) + #expect(VPhoneDisplayOrientation(degrees: -90) == .landscapeRight) + #expect(VPhoneDisplayOrientation(degrees: 450) == .landscapeLeft) + #expect(VPhoneDisplayOrientation(degrees: 45) == nil) + } + + /// The Mac repeats the device's clockwise turn; NSView rotation counts + /// counterclockwise. + @Test func `the view turns the way the device turned`() { + #expect(VPhoneDisplayOrientation.portrait.viewRotation == 0) + #expect(VPhoneDisplayOrientation.landscapeLeft.viewRotation == 270) + #expect(VPhoneDisplayOrientation.upsideDown.viewRotation == 180) + #expect(VPhoneDisplayOrientation.landscapeRight.viewRotation == 90) + } + + @Test func `rotate right turns clockwise and rotate left undoes it`() { + #expect(VPhoneDisplayOrientation.portrait.turned(clockwise: true) == .landscapeLeft) + #expect(VPhoneDisplayOrientation.landscapeLeft.turned(clockwise: true) == .upsideDown) + #expect(VPhoneDisplayOrientation.landscapeRight.turned(clockwise: true) == .portrait) + #expect(VPhoneDisplayOrientation.portrait.turned(clockwise: false) == .landscapeRight) + for orientation in VPhoneDisplayOrientation.allCases { + #expect(orientation.turned(clockwise: true).turned(clockwise: false) == orientation) + } + } + + @Test func `only landscape swaps the panel's sides`() { + #expect(VPhoneDisplayOrientation.portrait.displayedSize(panel: Self.panel) == Self.panel) + #expect(VPhoneDisplayOrientation.upsideDown.displayedSize(panel: Self.panel) == Self.panel) + let sideways = CGSize(width: 852, height: 393) + #expect(VPhoneDisplayOrientation.landscapeLeft.displayedSize(panel: Self.panel) == sideways) + #expect(VPhoneDisplayOrientation.landscapeRight.displayedSize(panel: Self.panel) == sideways) + } + + // MARK: - Window + + @Test func `turning keeps the long side and the center`() { + let portrait = CGRect(x: 400, y: 50, width: 393, height: 852) + let rect = VPhoneDisplayOrientation.landscapeLeft.contentRect( + from: portrait, panel: Self.panel, within: Self.screen, + ) + #expect(rect.size == CGSize(width: 852, height: 393)) + #expect(abs(rect.midX - portrait.midX) <= 0.5 && abs(rect.midY - portrait.midY) <= 0.5) + } + + @Test func `turning back restores portrait`() { + let landscape = CGRect(x: 170, y: 280, width: 852, height: 393) + let rect = VPhoneDisplayOrientation.portrait.contentRect( + from: landscape, panel: Self.panel, within: Self.screen, + ) + #expect(rect.size == CGSize(width: 393, height: 852)) + } + + @Test func `a turned window too large for the screen shrinks to fit`() { + let tall = CGRect(x: 0, y: 0, width: 800, height: 1734) + let small = CGRect(x: 0, y: 0, width: 1200, height: 800) + let rect = VPhoneDisplayOrientation.landscapeRight.contentRect(from: tall, panel: Self.panel, within: small) + #expect(rect.width <= 1200 && rect.height <= 800) + #expect(abs(rect.width / rect.height - 852.0 / 393.0) < 0.01) + #expect(small.contains(rect)) + } + + @Test func `a turned window stays on the screen`() { + let nearEdge = CGRect(x: 0, y: 0, width: 393, height: 852) + let rect = VPhoneDisplayOrientation.landscapeLeft.contentRect( + from: nearEdge, panel: Self.panel, within: Self.screen, + ) + #expect(Self.screen.contains(rect)) + } + + @Test func `a portrait window left portrait is unchanged`() { + let portrait = CGRect(x: 400, y: 50, width: 393, height: 852) + let rect = VPhoneDisplayOrientation.portrait.contentRect(from: portrait, panel: Self.panel, within: Self.screen) + #expect(rect == portrait) + } +}