Sync the clipboard on ⌘C, ⌘X and ⌘V, and touch the drawn display

The Edit menu's Copy, Cut and Paste now reach the VM view, which carries
the clipboard through vphoned: paste sends a changed Mac clipboard to the
guest before the guest pastes, and copy and cut bring the guest clipboard
back once the guest has written it. Only small text and images are
synced (VPhoneClipboardTransfer); anything else stays on its own side.
Without a connected agent the items are disabled and the keys reach the
guest unchanged.

capturesSystemKeys makes VZVirtualMachineView swallow every key in a
local event monitor it re-adds on each focus change, so a monitor of ours
always ran after it. VPhoneApplication asks the main menu in
sendEvent(_:), which runs before any local monitor, and drops the key-up
of a key a menu item took.

A full-screen VM letterboxes the guest display, so touches measured
against the view mapped the bars onto the guest screen. They are now
measured against the drawn display (VPhoneDisplayGeometry): points on a
bar clamp to the nearest edge and count as near it for edge swipes.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
Lakr
2026-09-29 23:22:00 +09:00
co-authored by Claude Opus 5.5
parent 18964659b8
commit 8682c98aaa
10 changed files with 580 additions and 66 deletions
@@ -0,0 +1,138 @@
import AppKit
import Foundation
import VPhoneCoreKit
// MARK: - Clipboard Sync
/// ⌘C, ⌘X and ⌘V in the VM window carry the clipboard through vphoned.
/// Paste sends a changed Mac clipboard to the guest before the guest pastes;
/// copy and cut bring the guest clipboard back once the guest has written it.
/// Only small text and images are synced (`VPhoneClipboardTransfer`).
@MainActor
final class VPhoneClipboardSync {
private weak var control: VPhoneGuestControl?
/// The Mac clipboard as last sent to or written from the guest. An
/// unchanged Mac clipboard is not sent again, so a guest copy made by
/// touch is not overwritten by an older Mac one.
private var syncedMacChangeCount: Int?
private var copyTask: Task<Void, Never>?
/// How long copy waits for the guest to write its clipboard.
private static let copyPolls = 20
private static let copyPollInterval: Duration = .milliseconds(100)
init(control: VPhoneGuestControl) {
self.control = control
}
var isAvailable: Bool {
guard let control else { return false }
return control.isConnected && control.guestCapabilities.contains("clipboard")
}
// MARK: - Actions
func paste() {
guard let control else { return }
let pasteboard = NSPasteboard.general
let changeCount = pasteboard.changeCount
let payload = changeCount == syncedMacChangeCount ? nil : Self.macPayload(from: pasteboard)
Task {
if let payload {
do {
switch payload {
case let .text(text): try await control.clipboardSet(text: text)
case let .image(data): try await control.clipboardSet(imageData: data)
}
syncedMacChangeCount = changeCount
} catch {
print("[clipboard] Mac to guest: \(error)")
}
}
Self.sendChord(usage: Self.usageV, to: control)
}
}
func copy(cut: Bool) {
guard let control else { return }
copyTask?.cancel()
copyTask = Task {
// The count read before the key, so any later change is this copy.
let before = try? await control.clipboardInfoAfterQueuedInput().changeCount
Self.sendChord(usage: cut ? Self.usageX : Self.usageC, to: control)
guard let before else { return }
for _ in 0 ..< Self.copyPolls {
try? await Task.sleep(for: Self.copyPollInterval)
guard !Task.isCancelled else { return }
guard let info = try? await control.clipboardInfoAfterQueuedInput(),
info.changeCount != before
else { continue }
await writeToMac(info, control: control)
return
}
}
}
// MARK: - Guest to Mac
private func writeToMac(_ info: VPhoneGuestControl.ClipboardContent, control: VPhoneGuestControl) async {
let pasteboard = NSPasteboard.general
switch VPhoneClipboardTransfer.guestKind(text: info.text, hasImage: info.hasImage) {
case .text:
guard let text = info.text, VPhoneClipboardTransfer.fits(text: text) else { return }
pasteboard.clearContents()
pasteboard.setString(text, forType: .string)
case .image:
guard let png = try? await control.clipboardImagePNG(),
VPhoneClipboardTransfer.fits(image: png)
else { return }
pasteboard.clearContents()
pasteboard.setData(png, forType: .png)
case nil:
return
}
syncedMacChangeCount = pasteboard.changeCount
}
// MARK: - Mac to Guest
private enum Payload {
case text(String)
case image(Data)
}
private static func macPayload(from pasteboard: NSPasteboard) -> Payload? {
let types = pasteboard.types?.map(\.rawValue) ?? []
switch VPhoneClipboardTransfer.macKind(types: types) {
case .text:
guard let text = pasteboard.string(forType: .string), VPhoneClipboardTransfer.fits(text: text)
else { return nil }
return .text(text)
case let .image(type):
guard var data = pasteboard.data(forType: NSPasteboard.PasteboardType(type)) else { return nil }
if !VPhoneClipboardTransfer.guestImageTypes.contains(type) {
guard let png = NSBitmapImageRep(data: data)?.representation(using: .png, properties: [:])
else { return nil }
data = png
}
return VPhoneClipboardTransfer.fits(image: data) ? .image(data) : nil
case nil:
return nil
}
}
// MARK: - Keys
/// Keyboard page (0x07) usages.
private static let usageC: UInt32 = 0x06
private static let usageV: UInt32 = 0x19
private static let usageX: UInt32 = 0x1B
private static let usageLeftCommand: UInt32 = 0xE3
/// ⌘ plus one key, queued behind any input already sent.
private static func sendChord(usage: UInt32, to control: VPhoneGuestControl) {
control.sendHIDDown(page: 0x07, usage: usageLeftCommand)
control.sendHIDPress(page: 0x07, usage: usage)
control.sendHIDUp(page: 0x07, usage: usageLeftCommand)
}
}
@@ -406,6 +406,26 @@ final class VPhoneGuestControl {
)
}
/// The guest clipboard without its image, read after the input already
/// queued so it sees the effect of a key sent before it.
func clipboardInfoAfterQueuedInput() async throws -> ClipboardContent {
let info = try await callAfterQueuedInput("clipboard.get")
return ClipboardContent(
text: info["text"] as? String,
types: info["types"] as? [String] ?? [],
hasImage: info["has_image"] as? Bool ?? false,
changeCount: info["change_count"] as? Int ?? 0,
imageData: nil,
)
}
/// The guest clipboard image as PNG.
func clipboardImagePNG() async throws -> Data {
let response = try await http(method: "GET", path: "/v1/clipboard/image")
guard response.status == 200 else { throw try httpError(response) }
return response.body
}
func clipboardSet(text: String) async throws {
_ = try await call("clipboard.set", params: ["text": text])
}
@@ -0,0 +1,38 @@
import AppKit
// MARK: - Application
/// Offers each key press in the VM window to the main menu before the VM view
/// can take it.
///
/// `capturesSystemKeys` makes `VZVirtualMachineView` install a local event
/// monitor that sends every key to the guest and swallows it. AppKit runs
/// local monitors in the order they were added, and the view adds its own
/// again each time it becomes first responder or its window becomes key, so
/// a monitor of ours always runs after it. `sendEvent(_:)` runs before any
/// local monitor, so the menu is asked here instead.
final class VPhoneApplication: NSApplication {
/// Keys whose key-down a menu item took. Their key-up is dropped too, so
/// the guest never sees a release without a press.
private var menuKeyCodes = Set<UInt16>()
override func sendEvent(_ event: NSEvent) {
guard event.type == .keyDown || event.type == .keyUp,
event.window?.firstResponder is VPhoneVirtualMachineView
else {
super.sendEvent(event)
return
}
if event.type == .keyUp {
if menuKeyCodes.remove(event.keyCode) == nil {
super.sendEvent(event)
}
return
}
if mainMenu?.performKeyEquivalent(with: event) == true {
menuKeyCodes.insert(event.keyCode)
return
}
super.sendEvent(event)
}
}
@@ -8,7 +8,6 @@ class VPhoneVirtualMachineWindowController: NSObject {
private weak var control: VPhoneGuestControl?
private weak var virtualMachineView: VPhoneVirtualMachineView?
private(set) var touchIDMonitor: VPhoneTouchIDMonitor?
private var menuKeyMonitor: Any?
private var homeButton: NSButton?
private var subtitleLabel: NSTextField?
@@ -33,6 +32,7 @@ class VPhoneVirtualMachineWindowController: NSObject {
view.capturesSystemKeys = true
view.keySender = keySender
view.control = control
view.clipboardSync = VPhoneClipboardSync(control: control)
virtualMachineView = view
let vmView: NSView = view
@@ -83,17 +83,6 @@ class VPhoneVirtualMachineWindowController: NSObject {
controller.showWindow(nil)
windowController = controller
// capturesSystemKeys lets the VM view take every shortcut before the menu
// bar sees it. Offer each key press to the menu first; the guest gets
// only what no enabled menu item handles.
menuKeyMonitor = NSEvent.addLocalMonitorForEvents(matching: .keyDown) { [weak window] event in
let handledByMenu = MainActor.assumeIsolated {
guard let window, event.window === window else { return false }
return NSApp.mainMenu?.performKeyEquivalent(with: event) == true
}
return handledByMenu ? nil : event
}
keySender.window = window
NSApp.activate(ignoringOtherApps: true)
window.makeKeyAndOrderFront(nil)
@@ -209,8 +198,12 @@ class VPhoneVirtualMachineWindowController: NSObject {
guard let window = windowController?.window else { return }
var parts: [String] = []
if control.isConnected {
if let version = control.guestIOSVersion, !version.isEmpty { parts.append("iOS \(version)") }
if let address = control.guestIPAddress, !address.isEmpty { parts.append(address) }
if let version = control.guestIOSVersion, !version.isEmpty {
parts.append("iOS \(version)")
}
if let address = control.guestIPAddress, !address.isEmpty {
parts.append(address)
}
}
let subtitle = parts.joined(separator: " - ")
if window.subtitle != subtitle {
@@ -16,7 +16,8 @@ public enum VPhoneGuestApp {
/// isolation was implicit, and moving the code into a function loses it.
@MainActor
public static func run(_ boot: VPhoneBootCommand) -> Never {
let app = NSApplication.shared
// The first `shared` call picks the class, so this must come first.
let app = VPhoneApplication.shared
let delegate = VPhoneVirtualMachineAppDelegate(command: boot)
app.delegate = delegate
app.run()
@@ -80,6 +80,25 @@ class VPhoneVirtualMachineView: VZVirtualMachineView {
keySender.sendHome()
}
// MARK: - Clipboard
/// The Edit menu's Copy, Cut and Paste reach the guest through here, so
/// ⌘C, ⌘X and ⌘V sync the clipboard. Without a connected agent the items
/// are disabled and the keys go to the guest unchanged.
var clipboardSync: VPhoneClipboardSync?
@objc func copy(_: Any?) {
clipboardSync?.copy(cut: false)
}
@objc func cut(_: Any?) {
clipboardSync?.copy(cut: true)
}
@objc func paste(_: Any?) {
clipboardSync?.paste()
}
// MARK: - Drag and Drop
/// Every dropped file goes through vphoned: an .ipa or .tipa is installed,
@@ -173,12 +192,10 @@ class VPhoneVirtualMachineView: VZVirtualMachineView {
/// Convert screenshot pixel coordinates to NSView local coordinates.
private func pixelToLocal(pixelX: Double, pixelY: Double, screenWidth: Int, screenHeight: Int) -> NSPoint {
let w = bounds.width
let h = bounds.height
let localX = pixelX / Double(screenWidth) * w
// Screenshot y=0 is top, NSView y=0 is bottom (non-flipped)
let localY = (1.0 - pixelY / Double(screenHeight)) * h
return NSPoint(x: localX, y: localY)
displayGeometry.viewPoint(normalized: CGPoint(
x: pixelX / Double(screenWidth),
y: pixelY / Double(screenHeight),
))
}
/// Synthesize an NSEvent at a given window point.
@@ -310,55 +327,34 @@ class VPhoneVirtualMachineView: VZVirtualMachineView {
// MARK: - Coordinate Helpers
/// The guest display as drawn in this view. Full screen letterboxes it,
/// so touches are measured against it rather than `bounds`.
private var displayGeometry: VPhoneDisplayGeometry {
VPhoneDisplayGeometry(
viewBounds: bounds,
displaySize: recordingGraphicsDisplay?.sizeInPixels ?? .zero,
isFlipped: isFlipped,
)
}
private func normalizeCoordinate(_ localPoint: NSPoint) -> CGPoint {
let w = bounds.width
let h = bounds.height
guard w > 0, h > 0 else { return .zero }
var nx = Double(localPoint.x / w)
var ny = Double(localPoint.y / h)
// Clamp
nx = max(0.0, min(1.0, nx))
ny = max(0.0, min(1.0, ny))
if !isFlipped {
ny = 1.0 - ny
}
return CGPoint(x: nx, y: ny)
displayGeometry.normalizedPoint(localPoint)
}
private func hitTestEdge(at point: CGPoint) -> Int {
let w = bounds.width
let h = bounds.height
let edgeThreshold: CGFloat = 32.0
let distLeft = point.x
let distRight = w - point.x
let distTop = isFlipped ? point.y : (h - point.y)
let distBottom = isFlipped ? (h - point.y) : point.y
var minDist = distLeft
var edgeCode = 8 // Left
if distRight < minDist {
minDist = distRight
edgeCode = 4 // Right
displayGeometry.edge(at: point).rawValue
}
}
// MARK: - Menu Validation
extension VPhoneVirtualMachineView: NSMenuItemValidation {
func validateMenuItem(_ item: NSMenuItem) -> Bool {
switch item.action {
case #selector(copy(_:)), #selector(cut(_:)), #selector(paste(_:)):
clipboardSync?.isAvailable == true
default:
true
}
if distBottom < minDist {
minDist = distBottom
edgeCode = 2 // Bottom (Home bar swipe up)
}
if distTop < minDist {
minDist = distTop
edgeCode = 1 // Top (Notification Center)
}
return minDist < edgeThreshold ? edgeCode : 0
}
}
@@ -0,0 +1,60 @@
import Foundation
// MARK: - Clipboard Transfer
/// What ⌘C and ⌘V carry between the Mac clipboard and the guest clipboard.
/// Only text and images are synced, and only when they are small: a file,
/// rich text or a large image stays on its own side.
public enum VPhoneClipboardTransfer {
/// Largest text synced, in UTF-8 bytes.
public static let maxTextBytes = 1024 * 1024
/// Largest image synced, in encoded bytes.
public static let maxImageBytes = 16 * 1024 * 1024
/// Image types the guest decodes directly. Any other image is converted
/// to PNG before it is sent.
public static let guestImageTypes: Set<String> = ["public.png", "public.jpeg", "public.heic"]
/// Image types taken from the Mac clipboard.
public static let macImageTypes: Set<String> = guestImageTypes.union(["public.tiff"])
/// Plain text on the Mac clipboard.
public static let macTextType = "public.utf8-plain-text"
public enum Kind: Equatable, Sendable {
case text
/// An image, read from the Mac clipboard as this type.
case image(type: String)
}
/// The representation to send for a Mac clipboard whose types are listed
/// in the owner's order of preference. The first text or image type wins,
/// so a copied screenshot sends its image and copied text sends its text
/// even when an image rendering is offered after it.
public static func macKind(types: [String]) -> Kind? {
for type in types {
if type == macTextType {
return .text
}
if macImageTypes.contains(type) {
return .image(type: type)
}
}
return nil
}
/// The representation to take from the guest clipboard. Text wins over an
/// image, since an app that offers both usually copied text.
public static func guestKind(text: String?, hasImage: Bool) -> Kind? {
if let text, !text.isEmpty {
return .text
}
return hasImage ? .image(type: "public.png") : nil
}
public static func fits(text: String) -> Bool {
text.utf8.count <= maxTextBytes
}
public static func fits(image: Data) -> Bool {
!image.isEmpty && image.count <= maxImageBytes
}
}
@@ -0,0 +1,92 @@
import CoreGraphics
// MARK: - Display Geometry
/// Where the guest display sits inside the view that shows it.
///
/// The view draws the display aspect-fit and centered. A windowed VM keeps
/// the display's aspect ratio, so the two coincide; a full-screen VM fills
/// the Mac screen and letterboxes the display. Touches must be measured
/// against the drawn display, not the view, or a letterboxed display maps
/// the bars onto the guest screen and the real edges land inside it.
public struct VPhoneDisplayGeometry: Equatable, Sendable {
/// Edge a touch starts near, as `_VZTouch`'s `swipeAim` takes it.
public enum Edge: Int, Sendable {
case none = 0
case top = 1
case bottom = 2
case right = 4
case left = 8
}
/// Distance, in view points, within which a touch starts an edge swipe.
public static let edgeThreshold: CGFloat = 32
/// The drawn display, in the view's coordinates.
public let displayRect: CGRect
/// Whether the view's y axis points down.
public let isFlipped: Bool
/// A display with no size, or a view with no area, maps onto the whole
/// view: there is nothing better to measure against.
public init(viewBounds: CGRect, displaySize: CGSize, isFlipped: Bool) {
self.isFlipped = isFlipped
guard displaySize.width > 0, displaySize.height > 0,
viewBounds.width > 0, viewBounds.height > 0
else {
displayRect = viewBounds
return
}
let scale = min(viewBounds.width / displaySize.width, viewBounds.height / displaySize.height)
let size = CGSize(width: displaySize.width * scale, height: displaySize.height * scale)
displayRect = CGRect(
x: viewBounds.midX - size.width / 2,
y: viewBounds.midY - size.height / 2,
width: size.width,
height: size.height,
)
}
/// A view point as a 0...1 display position with y down. Points on the
/// letterbox bars clamp to the nearest display edge.
public func normalizedPoint(_ point: CGPoint) -> CGPoint {
guard displayRect.width > 0, displayRect.height > 0 else { return .zero }
let x = (point.x - displayRect.minX) / displayRect.width
let y = (point.y - displayRect.minY) / displayRect.height
return CGPoint(
x: min(1, max(0, x)),
y: min(1, max(0, isFlipped ? y : 1 - y)),
)
}
/// The view point for a 0...1 display position with y down.
public func viewPoint(normalized point: CGPoint) -> CGPoint {
let y = isFlipped ? point.y : 1 - point.y
return CGPoint(
x: displayRect.minX + point.x * displayRect.width,
y: displayRect.minY + y * displayRect.height,
)
}
/// The display edge nearest to a view point, when that point is within
/// `edgeThreshold` of it. A point on a letterbox bar is past the edge
/// beside it and counts as near it.
public func edge(at point: CGPoint) -> Edge {
let left = point.x - displayRect.minX
let right = displayRect.maxX - point.x
let lowY = point.y - displayRect.minY
let highY = displayRect.maxY - point.y
let candidates: [(Edge, CGFloat)] = [
(.left, left),
(.right, right),
(.bottom, isFlipped ? highY : lowY),
(.top, isFlipped ? lowY : highY),
]
// Ties keep the earlier entry, so a corner resolves to left or right.
var nearest = candidates[0]
for candidate in candidates.dropFirst() where candidate.1 < nearest.1 {
nearest = candidate
}
return nearest.1 < Self.edgeThreshold ? nearest.0 : .none
}
}
@@ -0,0 +1,70 @@
import Foundation
import Testing
@testable import VPhoneCoreKit
struct ClipboardTransferTests {
// MARK: - Mac to Guest
@Test func `copied text sends its text`() {
let types = ["public.utf8-plain-text", "public.rtf", "public.html"]
#expect(VPhoneClipboardTransfer.macKind(types: types) == .text)
}
@Test func `a screenshot sends its image`() {
#expect(VPhoneClipboardTransfer.macKind(types: ["public.png"]) == .image(type: "public.png"))
#expect(VPhoneClipboardTransfer.macKind(types: ["public.tiff"]) == .image(type: "public.tiff"))
}
@Test func `the owner's first type wins`() {
#expect(
VPhoneClipboardTransfer.macKind(types: ["public.tiff", "public.utf8-plain-text"])
== .image(type: "public.tiff"),
)
#expect(VPhoneClipboardTransfer.macKind(types: ["public.utf8-plain-text", "public.png"]) == .text)
}
@Test func `a copied file sends its name as text`() {
let types = ["public.file-url", "com.apple.finder.node", "public.utf8-plain-text"]
#expect(VPhoneClipboardTransfer.macKind(types: types) == .text)
}
@Test func `nothing syncable sends nothing`() {
#expect(VPhoneClipboardTransfer.macKind(types: []) == nil)
#expect(VPhoneClipboardTransfer.macKind(types: ["public.file-url", "public.rtf", "com.adobe.pdf"]) == nil)
}
@Test func `TIFF is converted before it reaches the guest`() {
#expect(!VPhoneClipboardTransfer.guestImageTypes.contains("public.tiff"))
#expect(VPhoneClipboardTransfer.macImageTypes.isSuperset(of: VPhoneClipboardTransfer.guestImageTypes))
}
// MARK: - Guest to Mac
@Test func `guest text wins over a guest image`() {
#expect(VPhoneClipboardTransfer.guestKind(text: "hello", hasImage: true) == .text)
#expect(VPhoneClipboardTransfer.guestKind(text: "", hasImage: true) == .image(type: "public.png"))
#expect(VPhoneClipboardTransfer.guestKind(text: nil, hasImage: true) == .image(type: "public.png"))
}
@Test func `an empty guest clipboard brings nothing back`() {
#expect(VPhoneClipboardTransfer.guestKind(text: "", hasImage: false) == nil)
#expect(VPhoneClipboardTransfer.guestKind(text: nil, hasImage: false) == nil)
}
// MARK: - Size
@Test func `text is limited by its UTF-8 size`() {
let limit = VPhoneClipboardTransfer.maxTextBytes
#expect(VPhoneClipboardTransfer.fits(text: String(repeating: "a", count: limit)))
#expect(!VPhoneClipboardTransfer.fits(text: String(repeating: "a", count: limit + 1)))
// Three bytes each, so fewer characters than the limit still exceed it.
#expect(!VPhoneClipboardTransfer.fits(text: String(repeating: "界", count: limit / 3 + 1)))
}
@Test func `images are limited and must not be empty`() {
let limit = VPhoneClipboardTransfer.maxImageBytes
#expect(VPhoneClipboardTransfer.fits(image: Data(count: limit)))
#expect(!VPhoneClipboardTransfer.fits(image: Data(count: limit + 1)))
#expect(!VPhoneClipboardTransfer.fits(image: Data()))
}
}
@@ -0,0 +1,106 @@
import CoreGraphics
import Testing
@testable import VPhoneCoreKit
/// Issue #525: in full screen the display is letterboxed, and touches were
/// normalized against the whole view, so the bars mapped onto the screen.
struct DisplayGeometryTests {
/// A 1179x2556 guest at 3x in its own window.
static let display = CGSize(width: 1179, height: 2556)
static let window = CGRect(x: 0, y: 0, width: 393, height: 852)
/// The same guest full screen on a 1512x982 Mac screen.
static let fullScreen = CGRect(x: 0, y: 0, width: 1512, height: 982)
static func geometry(_ bounds: CGRect, flipped: Bool = false) -> VPhoneDisplayGeometry {
VPhoneDisplayGeometry(viewBounds: bounds, displaySize: display, isFlipped: flipped)
}
static func close(_ a: CGPoint, _ b: CGPoint) -> Bool {
abs(a.x - b.x) < 1e-6 && abs(a.y - b.y) < 1e-6
}
// MARK: - Display Rect
@Test func `a window of the display's aspect ratio is all display`() {
let rect = Self.geometry(Self.window).displayRect
#expect(abs(rect.minX) < 0.5 && abs(rect.minY) < 0.5)
#expect(abs(rect.width - 393) < 0.5 && abs(rect.height - 852) < 0.5)
}
@Test func `full screen centers the display between bars`() {
let rect = Self.geometry(Self.fullScreen).displayRect
#expect(rect.minY == 0 && rect.height == 982)
let expectedWidth = 982 * 1179.0 / 2556.0
#expect(abs(rect.width - expectedWidth) < 1e-9)
#expect(abs(rect.minX - (1512 - expectedWidth) / 2) < 1e-9)
}
@Test func `a wide display in a tall view gets bars above and below`() {
let geometry = VPhoneDisplayGeometry(
viewBounds: CGRect(x: 0, y: 0, width: 100, height: 400),
displaySize: CGSize(width: 200, height: 100),
isFlipped: false,
)
#expect(geometry.displayRect == CGRect(x: 0, y: 175, width: 100, height: 50))
}
@Test func `an unknown display size falls back to the view`() {
let geometry = VPhoneDisplayGeometry(viewBounds: Self.fullScreen, displaySize: .zero, isFlipped: false)
#expect(geometry.displayRect == Self.fullScreen)
}
// MARK: - Normalizing
@Test func `full screen display edges normalize to 0 and 1`() {
let geometry = Self.geometry(Self.fullScreen)
let rect = geometry.displayRect
#expect(Self.close(geometry.normalizedPoint(CGPoint(x: rect.minX, y: rect.maxY)), CGPoint(x: 0, y: 0)))
#expect(Self.close(geometry.normalizedPoint(CGPoint(x: rect.maxX, y: rect.minY)), CGPoint(x: 1, y: 1)))
#expect(Self.close(geometry.normalizedPoint(CGPoint(x: rect.midX, y: rect.midY)), CGPoint(x: 0.5, y: 0.5)))
}
@Test func `points on the bars clamp to the nearest display edge`() {
let geometry = Self.geometry(Self.fullScreen)
#expect(Self.close(geometry.normalizedPoint(CGPoint(x: 10, y: 491)), CGPoint(x: 0, y: 0.5)))
#expect(Self.close(geometry.normalizedPoint(CGPoint(x: 1500, y: 491)), CGPoint(x: 1, y: 0.5)))
}
@Test func `a flipped view keeps y down`() {
let geometry = Self.geometry(Self.fullScreen, flipped: true)
let rect = geometry.displayRect
#expect(Self.close(geometry.normalizedPoint(CGPoint(x: rect.minX, y: rect.minY)), CGPoint(x: 0, y: 0)))
}
@Test(arguments: [false, true])
func `viewPoint inverts normalizedPoint`(flipped: Bool) {
let geometry = Self.geometry(Self.fullScreen, flipped: flipped)
for point in [CGPoint(x: 0, y: 0), CGPoint(x: 0.25, y: 0.8), CGPoint(x: 1, y: 1)] {
#expect(Self.close(geometry.normalizedPoint(geometry.viewPoint(normalized: point)), point))
}
}
// MARK: - Edges
@Test func `edges are measured from the display, not the view`() {
let geometry = Self.geometry(Self.fullScreen)
let rect = geometry.displayRect
#expect(geometry.edge(at: CGPoint(x: rect.minX + 5, y: rect.midY)) == .left)
#expect(geometry.edge(at: CGPoint(x: rect.maxX - 5, y: rect.midY)) == .right)
#expect(geometry.edge(at: CGPoint(x: rect.midX, y: rect.minY + 5)) == .bottom)
#expect(geometry.edge(at: CGPoint(x: rect.midX, y: rect.maxY - 5)) == .top)
#expect(geometry.edge(at: CGPoint(x: rect.midX, y: rect.midY)) == .none)
}
@Test func `a point on a bar is at the edge beside it`() {
let geometry = Self.geometry(Self.fullScreen)
#expect(geometry.edge(at: CGPoint(x: 10, y: 491)) == .left)
#expect(geometry.edge(at: CGPoint(x: 1500, y: 491)) == .right)
}
@Test func `a flipped view swaps top and bottom`() {
let geometry = Self.geometry(Self.fullScreen, flipped: true)
let rect = geometry.displayRect
#expect(geometry.edge(at: CGPoint(x: rect.midX, y: rect.minY + 5)) == .top)
#expect(geometry.edge(at: CGPoint(x: rect.midX, y: rect.maxY - 5)) == .bottom)
}
}