mirror of https://github.com/cirruslabs/tart.git
feat: scaffold guest-side drop synthesis (host plumbing only)
Adds the host-side hook for the forthcoming tart-guest-agent DragAndDrop RPC that will perform a real drag-and-drop at the cursor's position inside the guest instead of dumping files into a generic share folder. - DropGeometry.normalize: pure helper turning a view-local drop point into top-left (0..1) coordinates the guest agent can map onto its screen. - VMContainerView.performDragOperation: capture draggingLocation on the main thread (only valid here), compute normalized point, then dispatch the copy off-main as before. - synthesizeGuestDrop: documented no-op stub called from the copy completion path. When the agent RPC lands, this becomes the real call site; until then it returns false and the existing share-folder copy is the user-visible result. No behavior change for users today.
This commit is contained in:
parent
2c5037ea50
commit
474fd47b6f
|
|
@ -960,6 +960,27 @@ struct VMView: NSViewRepresentable {
|
|||
|
||||
// MARK: - Drag and Drop
|
||||
|
||||
/// Pure coordinate-conversion helpers. Kept as a stand-alone enum so it can be
|
||||
/// unit-tested without dragging in AppKit/SwiftUI types.
|
||||
enum DropGeometry {
|
||||
/// Normalizes a drop point given in a view's coordinate space to (0..1, 0..1)
|
||||
/// with (0, 0) at top-left and (1, 1) at bottom-right. AppKit views default
|
||||
/// to a bottom-left origin; we flip Y so the guest agent receives a top-down
|
||||
/// coordinate that matches CGEvent's global screen space without needing to
|
||||
/// know the host view's orientation.
|
||||
static func normalize(point: CGPoint, inViewBoundsSize size: CGSize, isViewFlipped: Bool) -> CGPoint {
|
||||
let width = max(size.width, 1)
|
||||
let height = max(size.height, 1)
|
||||
let xNorm = point.x / width
|
||||
let yLocal = point.y / height
|
||||
let yNorm = isViewFlipped ? yLocal : (1 - yLocal)
|
||||
return CGPoint(
|
||||
x: max(0, min(1, xNorm)),
|
||||
y: max(0, min(1, yNorm))
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
/// Layer-backed highlight overlay. Added as a subview of TartVirtualMachineView
|
||||
/// so its CALayer composites on top of Metal rendering. hitTest returns nil so
|
||||
/// it is completely transparent to pointer events.
|
||||
|
|
@ -1082,6 +1103,18 @@ class VMContainerView: NSView {
|
|||
guard let dropZoneURL = machineView.dropZoneURL else { return false }
|
||||
let urls = fileURLs(from: sender)
|
||||
guard !urls.isEmpty else { return false }
|
||||
|
||||
// Compute the drop location in normalized view coordinates while we're
|
||||
// still on the main thread (dragging info is only valid here). The
|
||||
// forthcoming guest-side drop synthesis uses this to place files where
|
||||
// the user actually pointed instead of in a generic share folder.
|
||||
let localPoint = self.convert(sender.draggingLocation, from: nil)
|
||||
let normalizedPoint = DropGeometry.normalize(
|
||||
point: localPoint,
|
||||
inViewBoundsSize: self.bounds.size,
|
||||
isViewFlipped: self.isFlipped
|
||||
)
|
||||
|
||||
// Copy off the main thread: large files would otherwise freeze the VM
|
||||
// window (which is the same view that's rendering the VM's framebuffer).
|
||||
copyQueue.async {
|
||||
|
|
@ -1092,6 +1125,15 @@ class VMContainerView: NSView {
|
|||
try FileManager.default.removeItem(at: dest)
|
||||
}
|
||||
try FileManager.default.copyItem(at: url, to: dest)
|
||||
|
||||
// After the file lands in the shared drop zone, ask the guest agent
|
||||
// to perform a real drop at the cursor's normalized position. Today
|
||||
// this is a no-op (returns false) so the user-visible result remains
|
||||
// "file appears in /Volumes/My Shared Files/Dropped Files/".
|
||||
let destPath = dest.path
|
||||
Task {
|
||||
_ = await synthesizeGuestDrop(path: destPath, atNormalized: normalizedPoint)
|
||||
}
|
||||
} catch {
|
||||
let name = url.lastPathComponent
|
||||
let message = error.localizedDescription
|
||||
|
|
@ -1116,6 +1158,30 @@ class VMContainerView: NSView {
|
|||
}
|
||||
}
|
||||
|
||||
/// Asks the in-guest tart-guest-agent to perform a drag-and-drop of `path` at
|
||||
/// the given normalized coordinates (origin top-left, (1, 1) bottom-right) so
|
||||
/// that whatever window the cursor is over receives the file as a real drop.
|
||||
///
|
||||
/// Returns true if the guest agent reports a successful drop; in that case the
|
||||
/// caller treats the share-folder copy as a side effect rather than the
|
||||
/// user-visible result. Returns false when the agent is unreachable, returns
|
||||
/// an error, or the corresponding RPC isn't available — the caller's existing
|
||||
/// fallback (file accessible at /Volumes/My Shared Files/Dropped Files/) then
|
||||
/// remains the user-visible outcome.
|
||||
///
|
||||
/// Today this is a no-op stub. Wiring it up requires:
|
||||
/// 1. A new `DragAndDrop` RPC in tart-guest-agent-proto.
|
||||
/// 2. A handler in tart-guest-agent (likely NSView.beginDraggingSession from
|
||||
/// a hidden 1x1 NSWindow at the cursor's screen position, with an
|
||||
/// AppleScript-into-Finder fallback).
|
||||
/// 3. Pulling the running VM's controlSocketURL into the call site (cf.
|
||||
/// Exec.swift) and reusing the existing AgentAsyncClient pattern.
|
||||
private func synthesizeGuestDrop(path: String, atNormalized normalized: CGPoint) async -> Bool {
|
||||
_ = path
|
||||
_ = normalized
|
||||
return false
|
||||
}
|
||||
|
||||
struct AdditionalDisk {
|
||||
let configuration: VZStorageDeviceConfiguration
|
||||
|
||||
|
|
|
|||
|
|
@ -0,0 +1,90 @@
|
|||
import XCTest
|
||||
@testable import tart
|
||||
|
||||
final class DropGeometryTests: XCTestCase {
|
||||
func testTopLeftOfUnflippedView() throws {
|
||||
// Bottom-up view: a point at (0, viewHeight) is the top-left corner.
|
||||
let point = DropGeometry.normalize(
|
||||
point: CGPoint(x: 0, y: 1000),
|
||||
inViewBoundsSize: CGSize(width: 1280, height: 1000),
|
||||
isViewFlipped: false
|
||||
)
|
||||
XCTAssertEqual(point.x, 0, accuracy: 0.0001)
|
||||
XCTAssertEqual(point.y, 0, accuracy: 0.0001)
|
||||
}
|
||||
|
||||
func testBottomRightOfUnflippedView() throws {
|
||||
let point = DropGeometry.normalize(
|
||||
point: CGPoint(x: 1280, y: 0),
|
||||
inViewBoundsSize: CGSize(width: 1280, height: 1000),
|
||||
isViewFlipped: false
|
||||
)
|
||||
XCTAssertEqual(point.x, 1, accuracy: 0.0001)
|
||||
XCTAssertEqual(point.y, 1, accuracy: 0.0001)
|
||||
}
|
||||
|
||||
func testCenterOfUnflippedView() throws {
|
||||
let point = DropGeometry.normalize(
|
||||
point: CGPoint(x: 640, y: 500),
|
||||
inViewBoundsSize: CGSize(width: 1280, height: 1000),
|
||||
isViewFlipped: false
|
||||
)
|
||||
XCTAssertEqual(point.x, 0.5, accuracy: 0.0001)
|
||||
XCTAssertEqual(point.y, 0.5, accuracy: 0.0001)
|
||||
}
|
||||
|
||||
func testTopLeftOfFlippedView() throws {
|
||||
// Flipped view: y increases downward, so (0, 0) is already top-left.
|
||||
let point = DropGeometry.normalize(
|
||||
point: CGPoint(x: 0, y: 0),
|
||||
inViewBoundsSize: CGSize(width: 1280, height: 1000),
|
||||
isViewFlipped: true
|
||||
)
|
||||
XCTAssertEqual(point.x, 0, accuracy: 0.0001)
|
||||
XCTAssertEqual(point.y, 0, accuracy: 0.0001)
|
||||
}
|
||||
|
||||
func testBottomRightOfFlippedView() throws {
|
||||
let point = DropGeometry.normalize(
|
||||
point: CGPoint(x: 1280, y: 1000),
|
||||
inViewBoundsSize: CGSize(width: 1280, height: 1000),
|
||||
isViewFlipped: true
|
||||
)
|
||||
XCTAssertEqual(point.x, 1, accuracy: 0.0001)
|
||||
XCTAssertEqual(point.y, 1, accuracy: 0.0001)
|
||||
}
|
||||
|
||||
// The guest agent expects coordinates in [0, 1]; out-of-bounds points (which
|
||||
// AppKit can occasionally deliver right at the view edge) get clamped so the
|
||||
// agent never receives a negative or >1 value.
|
||||
func testClampsBelowZero() throws {
|
||||
let point = DropGeometry.normalize(
|
||||
point: CGPoint(x: -5, y: 1010),
|
||||
inViewBoundsSize: CGSize(width: 1280, height: 1000),
|
||||
isViewFlipped: false
|
||||
)
|
||||
XCTAssertEqual(point.x, 0)
|
||||
XCTAssertEqual(point.y, 0)
|
||||
}
|
||||
|
||||
func testClampsAboveOne() throws {
|
||||
let point = DropGeometry.normalize(
|
||||
point: CGPoint(x: 1500, y: -100),
|
||||
inViewBoundsSize: CGSize(width: 1280, height: 1000),
|
||||
isViewFlipped: false
|
||||
)
|
||||
XCTAssertEqual(point.x, 1)
|
||||
XCTAssertEqual(point.y, 1)
|
||||
}
|
||||
|
||||
// Zero-sized view shouldn't divide by zero; max(size, 1) protects us.
|
||||
func testZeroSizedViewDoesNotCrash() throws {
|
||||
let point = DropGeometry.normalize(
|
||||
point: CGPoint(x: 0, y: 0),
|
||||
inViewBoundsSize: .zero,
|
||||
isViewFlipped: false
|
||||
)
|
||||
XCTAssertEqual(point.x, 0)
|
||||
XCTAssertEqual(point.y, 1) // y flipped: (1 - 0/1) = 1, clamped
|
||||
}
|
||||
}
|
||||
Loading…
Reference in New Issue