[feat] add compact mobile virtual mouse controls

This commit is contained in:
dijunkun
2026-10-09 14:26:36 +08:00
parent 41aed0984f
commit d3ecbe2cc9
12 changed files with 961 additions and 205 deletions
@@ -0,0 +1,59 @@
import Foundation
/// Shared by the video surface and the virtual buttons. Coordinates always
/// refer to the remote cursor, never to the floating controls' screen location.
final class RemoteMouseInput {
var send: ((Float, Float, Int, Int) -> Void)?
var didCancel: (() -> Void)?
var viewportSize = CGSize(width: 0, height: 0)
private(set) var x: Float = 0.5
private(set) var y: Float = 0.5
private(set) var revision: UInt64 = 0
private var gestureLeft = false
private var virtualLeft = false
private var virtualRight = false
var hasVirtualButton: Bool { virtualLeft || virtualRight }
func position(x: Float, y: Float) {
self.x = min(max(x, 0), 1)
self.y = min(max(y, 0), 1)
}
func drag(dx: Float, dy: Float) {
guard hasVirtualButton, viewportSize.width > 0, viewportSize.height > 0 else { return }
position(x: x + dx / Float(viewportSize.width) * 1.35,
y: y + dy / Float(viewportSize.height) * 1.35)
emit(0)
}
func setGestureLeft(_ down: Bool) {
guard !(down && hasVirtualButton), gestureLeft != down else { return }
gestureLeft = down
emit(down ? 1 : 2)
}
func setVirtualButton(left: Bool, down: Bool) {
guard (left ? virtualLeft : virtualRight) != down else { return }
if down {
setGestureLeft(false)
revision &+= 1
}
if left { virtualLeft = down } else { virtualRight = down }
emit(left ? (down ? 1 : 2) : (down ? 3 : 4))
}
func scroll(_ direction: Int) {
guard direction != 0 else { return }
emit(7, wheel: direction > 0 ? 1 : -1)
}
func releaseAll() {
setGestureLeft(false)
setVirtualButton(left: true, down: false)
setVirtualButton(left: false, down: false)
revision &+= 1
didCancel?()
}
private func emit(_ flag: Int, wheel: Int = 0) { send?(x, y, flag, wheel) }
}
@@ -55,6 +55,7 @@ enum RemoteVideoGeometry {
}
struct RemoteTouchInputView: UIViewRepresentable {
let mouseInput: RemoteMouseInput
let videoSize: CGSize
let controlMode: MouseControlMode
let remoteCursorPosition: CGPoint?
@@ -73,6 +74,7 @@ struct RemoteTouchInputView: UIViewRepresentable {
}
func updateUIView(_ view: RemoteTouchSurface, context: Context) {
view.mouseInput = mouseInput
view.videoSize = videoSize
view.controlMode = controlMode
view.setViewport(scale: viewportScale, offset: viewportOffset)
@@ -88,6 +90,8 @@ struct RemoteTouchInputView: UIViewRepresentable {
}
final class RemoteTouchSurface: UIView, UIGestureRecognizerDelegate {
var mouseInput: RemoteMouseInput?
private var gestureRevisions: [ObjectIdentifier: UInt64] = [:]
var videoSize = CGSize.zero {
didSet { setNeedsLayout() }
}
@@ -108,7 +112,14 @@ final class RemoteTouchSurface: UIView, UIGestureRecognizerDelegate {
var onScroll: ((Float, Float, Int, Int) -> Void)?
var onViewportChanged: ((CGFloat, CGSize) -> Void)?
private var relativeCursorPoint: (Float, Float) = (0.5, 0.5)
private var fallbackCursorPoint: (Float, Float) = (0.5, 0.5)
private var relativeCursorPoint: (Float, Float) {
get { mouseInput.map { ($0.x, $0.y) } ?? fallbackCursorPoint }
set {
fallbackCursorPoint = newValue
mouseInput?.position(x: newValue.0, y: newValue.1)
}
}
private var lastRemoteCursorPoint: (Float, Float)?
private var lastRemoteCursorRevision: UInt64?
private var relativePanActive = false
@@ -128,10 +139,12 @@ final class RemoteTouchSurface: UIView, UIGestureRecognizerDelegate {
isMultipleTouchEnabled = true
let tap = UITapGestureRecognizer(target: self, action: #selector(handleTap(_:)))
tap.delegate = self
tap.numberOfTouchesRequired = 1
addGestureRecognizer(tap)
let rightTap = UITapGestureRecognizer(target: self, action: #selector(handleRightTap(_:)))
rightTap.delegate = self
rightTap.numberOfTouchesRequired = 2
tap.require(toFail: rightTap)
addGestureRecognizer(rightTap)
@@ -174,6 +187,7 @@ final class RemoteTouchSurface: UIView, UIGestureRecognizerDelegate {
override func layoutSubviews() {
super.layoutSubviews()
mouseInput?.viewportSize = renderRect()?.size ?? .zero
let constrained = constrainedViewportOffset(viewportOffset,
scale: viewportScale)
guard constrained != viewportOffset else { return }
@@ -185,6 +199,7 @@ final class RemoteTouchSurface: UIView, UIGestureRecognizerDelegate {
let nextScale = min(max(scale, 1), maximumViewportScale)
viewportScale = nextScale
viewportOffset = constrainedViewportOffset(offset, scale: nextScale)
mouseInput?.viewportSize = renderRect()?.size ?? .zero
}
func synchronizeRemoteCursor(_ position: CGPoint?, revision: UInt64) {
@@ -203,12 +218,39 @@ final class RemoteTouchSurface: UIView, UIGestureRecognizerDelegate {
)
lastRemoteCursorPoint = point
guard controlMode == .relative,
mouseInput?.hasVirtualButton != true,
!relativePanActive,
heldDragPoint == nil,
hoverLastLocation == nil else { return }
relativeCursorPoint = point
}
func gestureRecognizer(_ gestureRecognizer: UIGestureRecognizer,
shouldReceive touch: UITouch) -> Bool {
if gestureRecognizer is UITapGestureRecognizer || gestureRecognizer is UILongPressGestureRecognizer {
guard mouseInput?.hasVirtualButton != true else { return false }
gestureRevisions[ObjectIdentifier(gestureRecognizer)] = mouseInput?.revision ?? 0
}
return true
}
private func allowsPointerGesture(_ recognizer: UIGestureRecognizer) -> Bool {
mouseInput?.hasVirtualButton != true &&
gestureRevisions[ObjectIdentifier(recognizer)] == (mouseInput?.revision ?? 0)
}
override func gestureRecognizerShouldBegin(_ gestureRecognizer: UIGestureRecognizer) -> Bool {
if gestureRecognizer is UITapGestureRecognizer || gestureRecognizer is UILongPressGestureRecognizer {
// Decline recognition before a stale long press can cancel the pan
// used by the other finger to move a held virtual mouse button.
return allowsPointerGesture(gestureRecognizer)
}
if gestureRecognizer is UIPinchGestureRecognizer {
return mouseInput?.hasVirtualButton != true
}
return true
}
func gestureRecognizer(_ gestureRecognizer: UIGestureRecognizer,
shouldRecognizeSimultaneouslyWith otherGestureRecognizer: UIGestureRecognizer) -> Bool {
let recognizers = [gestureRecognizer, otherGestureRecognizer]
@@ -223,6 +265,7 @@ final class RemoteTouchSurface: UIView, UIGestureRecognizerDelegate {
}
@objc private func handleTap(_ recognizer: UITapGestureRecognizer) {
guard allowsPointerGesture(recognizer) else { return }
let location = recognizer.location(in: self)
guard recognizer.state == .ended,
let point = pointerPoint(for: location) else { return }
@@ -240,6 +283,7 @@ final class RemoteTouchSurface: UIView, UIGestureRecognizerDelegate {
}
@objc private func handleRightTap(_ recognizer: UITapGestureRecognizer) {
guard allowsPointerGesture(recognizer) else { return }
guard recognizer.state == .ended,
let point = pointerPoint(for: recognizer.location(in: self)) else { return }
onMove?(point.0, point.1)
@@ -262,6 +306,12 @@ final class RemoteTouchSurface: UIView, UIGestureRecognizerDelegate {
}
@objc private func handleHeldDrag(_ recognizer: UILongPressGestureRecognizer) {
guard allowsPointerGesture(recognizer) else {
// A virtual button took ownership while the surface gesture was in flight.
heldDragPoint = nil
heldDragLastLocation = nil
return
}
if controlMode == .relative {
handleRelativeHeldDrag(recognizer)
return
@@ -289,6 +339,7 @@ final class RemoteTouchSurface: UIView, UIGestureRecognizerDelegate {
}
@objc private func handlePinch(_ recognizer: UIPinchGestureRecognizer) {
guard mouseInput?.hasVirtualButton != true else { pinchActive = false; return }
let focus = recognizer.location(in: self)
switch recognizer.state {
case .began:
@@ -327,6 +378,7 @@ final class RemoteTouchSurface: UIView, UIGestureRecognizerDelegate {
}
@objc private func handleWheel(_ recognizer: UIPanGestureRecognizer) {
guard mouseInput?.hasVirtualButton != true else { viewportPanActive = false; return }
if viewportScale > 1.001 || pinchActive {
switch recognizer.state {
case .began:
@@ -0,0 +1,209 @@
import SwiftUI
import UIKit
struct VirtualMouseBar: UIViewRepresentable {
let input: RemoteMouseInput
func makeUIView(context: Context) -> VirtualMouseBarView {
VirtualMouseBarView(input: input)
}
func updateUIView(_ view: VirtualMouseBarView, context: Context) {}
static func dismantleUIView(_ view: VirtualMouseBarView, coordinator: ()) {
view.input.releaseAll()
}
}
final class VirtualMouseBarView: UIView {
let input: RemoteMouseInput
private let keys: [VirtualMouseKey]
init(input: RemoteMouseInput) {
self.input = input
keys = [VirtualMouseKey(kind: .left, input: input),
VirtualMouseKey(kind: .wheel, input: input),
VirtualMouseKey(kind: .right, input: input)]
super.init(frame: .zero)
backgroundColor = .white
layer.cornerRadius = 18
layer.borderWidth = 0.5
layer.borderColor = UIColor(red: 212 / 255, green: 218 / 255, blue: 226 / 255, alpha: 1).cgColor
isMultipleTouchEnabled = true
keys.forEach(addSubview)
input.didCancel = { [weak self] in self?.keys.forEach { $0.cancelTracking() } }
}
required init?(coder: NSCoder) { fatalError("init(coder:) has not been implemented") }
override func layoutSubviews() {
super.layoutSubviews()
let width = max(0, bounds.width - 8) / 3
for (index, key) in keys.enumerated() {
key.frame = CGRect(x: 4 + CGFloat(index) * width, y: 2,
width: width, height: max(0, bounds.height - 4))
}
}
}
private final class VirtualMouseKey: UIView {
enum Kind { case left, wheel, right }
private let kind: Kind
private let input: RemoteMouseInput
private let face = UIView()
private let faceMask = CAShapeLayer()
private let wheelIcon = VirtualMouseWheelView()
private var trackedTouch: UITouch?
private var lastPoint = CGPoint.zero
private var remainder: CGFloat = 0
init(kind: Kind, input: RemoteMouseInput) {
self.kind = kind
self.input = input
super.init(frame: .zero)
isMultipleTouchEnabled = false
isAccessibilityElement = true
accessibilityTraits = kind == .wheel ? [.adjustable] : [.button]
accessibilityLabel = kind == .left ? "鼠标左键" : kind == .right ? "鼠标右键" : "鼠标滚轮"
accessibilityHint = kind == .wheel ? "上下滑动,可滑出按键范围" : "按住并拖动,可滑出按键范围,松开结束拖拽"
face.isUserInteractionEnabled = false
face.layer.mask = faceMask
addSubview(face)
wheelIcon.isHidden = kind != .wheel
wheelIcon.isUserInteractionEnabled = false
addSubview(wheelIcon)
highlight(false)
}
required init?(coder: NSCoder) { fatalError("init(coder:) has not been implemented") }
override func layoutSubviews() {
super.layoutSubviews()
// All three controls keep a 44 × 44 pt hit area; only the wheel's face
// narrows to 18 pt. Empty gutters separate it from the two paddles.
face.frame = kind == .wheel
? CGRect(x: (bounds.width - 18) / 2, y: (bounds.height - 30) / 2, width: 18, height: 30)
: bounds.insetBy(dx: 1, dy: 2)
let leftRadius: CGFloat = kind == .left ? 14 : 6
let rightRadius: CGFloat = kind == .right ? 14 : 6
faceMask.frame = face.bounds
faceMask.path = facePath(in: face.bounds, leftRadius: leftRadius, rightRadius: rightRadius).cgPath
wheelIcon.frame = face.frame.insetBy(dx: 3, dy: 3)
}
private func facePath(in rect: CGRect, leftRadius: CGFloat, rightRadius: CGFloat) -> UIBezierPath {
let path = UIBezierPath()
let left = min(leftRadius, rect.height / 2, rect.width / 2)
let right = min(rightRadius, rect.height / 2, rect.width / 2)
path.move(to: CGPoint(x: rect.minX + left, y: rect.minY))
path.addLine(to: CGPoint(x: rect.maxX - right, y: rect.minY))
path.addArc(withCenter: CGPoint(x: rect.maxX - right, y: rect.minY + right),
radius: right, startAngle: -.pi / 2, endAngle: 0, clockwise: true)
path.addLine(to: CGPoint(x: rect.maxX, y: rect.maxY - right))
path.addArc(withCenter: CGPoint(x: rect.maxX - right, y: rect.maxY - right),
radius: right, startAngle: 0, endAngle: .pi / 2, clockwise: true)
path.addLine(to: CGPoint(x: rect.minX + left, y: rect.maxY))
path.addArc(withCenter: CGPoint(x: rect.minX + left, y: rect.maxY - left),
radius: left, startAngle: .pi / 2, endAngle: .pi, clockwise: true)
path.addLine(to: CGPoint(x: rect.minX, y: rect.minY + left))
path.addArc(withCenter: CGPoint(x: rect.minX + left, y: rect.minY + left),
radius: left, startAngle: .pi, endAngle: .pi * 1.5, clockwise: true)
path.close()
return path
}
override func touchesBegan(_ touches: Set<UITouch>, with event: UIEvent?) {
guard trackedTouch == nil, let touch = touches.first else { return }
trackedTouch = touch
lastPoint = touch.location(in: window)
remainder = 0
highlight(true)
if kind != .wheel { input.setVirtualButton(left: kind == .left, down: true) }
}
override func touchesMoved(_ touches: Set<UITouch>, with event: UIEvent?) {
guard let touch = trackedTouch, touches.contains(touch) else { return }
let point = touch.location(in: window)
let delta = CGPoint(x: point.x - lastPoint.x, y: point.y - lastPoint.y)
lastPoint = point
if kind != .wheel {
input.drag(dx: Float(delta.x), dy: Float(delta.y))
return
}
wheelIcon.advance(by: delta.y)
remainder += delta.y
let ticks = Int(remainder / 10)
if ticks != 0 {
// UIKit keeps delivering this touch outside the 44 pt key. Finger
// down maps to negative wheel units (scroll down) on the desktop.
for _ in 0..<min(abs(ticks), 32) { input.scroll(ticks > 0 ? -1 : 1) }
remainder -= CGFloat(ticks) * 10
}
}
override func touchesEnded(_ touches: Set<UITouch>, with event: UIEvent?) {
guard let touch = trackedTouch, touches.contains(touch) else { return }
cancelTracking()
}
override func touchesCancelled(_ touches: Set<UITouch>, with event: UIEvent?) { cancelTracking() }
func cancelTracking() {
if trackedTouch != nil, kind != .wheel {
input.setVirtualButton(left: kind == .left, down: false)
}
trackedTouch = nil
remainder = 0
highlight(false)
}
override func accessibilityActivate() -> Bool {
if kind == .wheel { accessibilityIncrement() }
else {
input.setVirtualButton(left: kind == .left, down: true)
input.setVirtualButton(left: kind == .left, down: false)
}
return true
}
override func accessibilityIncrement() {
if kind == .wheel { wheelIcon.advance(by: 10); input.scroll(-1) }
}
override func accessibilityDecrement() {
if kind == .wheel { wheelIcon.advance(by: -10); input.scroll(1) }
}
private func highlight(_ pressed: Bool) {
face.backgroundColor = pressed ? UIColor(red: 46 / 255, green: 122 / 255, blue: 219 / 255, alpha: 1)
: UIColor(red: 237 / 255, green: 240 / 255, blue: 244 / 255, alpha: 1)
wheelIcon.strokeColor = pressed ? .white
: UIColor(red: 48 / 255, green: 54 / 255, blue: 64 / 255, alpha: 0.72)
}
}
/// A clipped conveyor of wheel treads: follows touch movement and stops on lift.
private final class VirtualMouseWheelView: UIView {
var strokeColor = UIColor.black { didSet { setNeedsDisplay() } }
private var phase: CGFloat = 2
private let pitch: CGFloat = 5
init() {
super.init(frame: .zero)
isOpaque = false
backgroundColor = .clear
}
required init?(coder: NSCoder) { fatalError("init(coder:) has not been implemented") }
func advance(by distance: CGFloat) {
phase = (phase + distance * 0.65).truncatingRemainder(dividingBy: pitch)
setNeedsDisplay()
}
override func draw(_ rect: CGRect) {
guard let context = UIGraphicsGetCurrentContext() else { return }
UIBezierPath(roundedRect: bounds, cornerRadius: 2).addClip()
context.setFillColor(strokeColor.cgColor)
for index in -1...6 {
let y = CGFloat(index) * pitch + phase
context.fill(CGRect(x: 0, y: y, width: bounds.width, height: 1.5))
}
}
}
+302 -178
View File
@@ -31,6 +31,9 @@ private struct RemoteViewportState: Equatable {
struct RemoteSessionView: View {
@ObservedObject var session: RemoteSessionModel
@Environment(\.scenePhase) private var scenePhase
@State private var mouseInput = RemoteMouseInput()
@State private var virtualMouseVisible = false
@State private var keyboardInputVisible = false
@State private var keyboardMode: RemoteKeyboardMode = .system
@State private var viewport = RemoteViewportState()
@@ -81,6 +84,7 @@ struct RemoteSessionView: View {
}
RemoteTouchInputView(
mouseInput: mouseInput,
videoSize: session.displayGeometrySize,
controlMode: session.mouseControlMode,
remoteCursorPosition: session.remoteCursorPosition,
@@ -95,20 +99,24 @@ struct RemoteSessionView: View {
offset: offset)
},
move: { x, y in
mouseInput.position(x: x, y: y)
cursorPosition = CGPoint(x: CGFloat(x), y: CGFloat(y))
session.bridge.sendPointer(x: x, y: y, action: .move)
},
leftDown: { x, y in
session.bridge.sendPointer(x: x, y: y, action: .leftDown)
mouseInput.position(x: x, y: y)
mouseInput.setGestureLeft(true)
},
leftUp: { x, y in
session.bridge.sendPointer(x: x, y: y, action: .leftUp)
mouseInput.position(x: x, y: y)
mouseInput.setGestureLeft(false)
},
rightClick: { x, y in
session.bridge.sendPointer(x: x, y: y, action: .rightDown)
session.bridge.sendPointer(x: x, y: y, action: .rightUp)
},
scroll: { x, y, dx, dy in
mouseInput.position(x: x, y: y)
cursorPosition = CGPoint(x: CGFloat(x), y: CGFloat(y))
session.bridge.sendScrollX(x, y: y,
deltaX: dx, deltaY: dy)
@@ -160,7 +168,27 @@ struct RemoteSessionView: View {
} message: {
Text(session.remoteUpdateMessage ?? "")
}
.onAppear {
mouseInput.send = { x, y, flag, wheel in
cursorPosition = CGPoint(x: CGFloat(x), y: CGFloat(y))
switch flag {
case 0: session.bridge.sendPointer(x: x, y: y, action: .move)
case 1: session.bridge.sendPointer(x: x, y: y, action: .leftDown)
case 2: session.bridge.sendPointer(x: x, y: y, action: .leftUp)
case 3: session.bridge.sendPointer(x: x, y: y, action: .rightDown)
case 4: session.bridge.sendPointer(x: x, y: y, action: .rightUp)
case 7: session.bridge.sendScrollX(x, y: y, deltaX: 0, deltaY: wheel)
default: break
}
}
if let point = session.remoteCursorPosition {
mouseInput.position(x: Float(point.x), y: Float(point.y))
}
}
.onDisappear {
mouseInput.releaseAll()
mouseInput.send = nil
virtualMouseVisible = false
keyboardInputVisible = false
viewport = RemoteViewportState()
cursorPosition = nil
@@ -169,14 +197,27 @@ struct RemoteSessionView: View {
keyboardPanelCenter = nil
}
.onChange(of: session.selectedDisplay) { _ in
mouseInput.releaseAll()
mouseInput.position(x: 0.5, y: 0.5)
cursorPosition = nil
viewport = RemoteViewportState()
}
.onChange(of: session.mouseControlMode) { _ in
mouseInput.releaseAll()
cursorPosition = session.remoteCursorPosition
if let point = cursorPosition {
mouseInput.position(x: Float(point.x), y: Float(point.y))
}
}
.onChange(of: scenePhase) { phase in
if phase != .active { mouseInput.releaseAll() }
}
.onChange(of: session.isConnected) { connected in
if !connected { mouseInput.releaseAll() }
}
.onChange(of: session.remoteCursorPositionRevision) { _ in
guard let position = session.remoteCursorPosition else { return }
guard !mouseInput.hasVirtualButton, let position = session.remoteCursorPosition else { return }
mouseInput.position(x: Float(position.x), y: Float(position.y))
cursorPosition = position
}
.fileImporter(isPresented: $showingFileImporter,
@@ -271,17 +312,16 @@ struct RemoteSessionView: View {
y: restingOrbCenter.y + statusOrbDragTranslation.height),
in: containerSize
)
let panelSize = CGSize(
width: min(340, max(280, containerSize.width - 160)),
height: min(310, max(0, containerSize.height - 16))
)
let panelCenter = floatingPanelCenter(
orbCenter: orbCenter,
panelSize: panelSize,
containerSize: containerSize
)
let menuColumns = containerSize.height < 400 && containerSize.width >= 400 ? 4 : 3
let panelWidth = min(max(1, containerSize.width - 16), menuColumns == 4
? 380 : min(340, max(280, containerSize.width - 160)))
ZStack(alignment: .topLeading) {
if virtualMouseVisible && !statusMenuVisible && session.pixelBuffer != nil {
VirtualMouseBar(input: mouseInput)
.frame(width: 140, height: 48)
.position(virtualMouseCenter(orbCenter: orbCenter, in: containerSize))
}
if statusMenuVisible {
Color.clear
.contentShape(Rectangle())
@@ -292,13 +332,18 @@ struct RemoteSessionView: View {
}
}
// Position a stable container, then animate only the panel
// inside it. Scaling a view after .position also scales its
// full-screen positioning space and makes the panel drift.
ZStack {
FloatingMenuLayout(orbCenter: orbCenter, panelWidth: panelWidth) {
if statusMenuVisible {
FloatingSessionMenu(
session: session,
columnCount: menuColumns,
virtualMouseVisible: virtualMouseVisible,
toggleMouse: {
mouseInput.releaseAll()
virtualMouseVisible.toggle()
statusMenuVisible = false
},
releaseMouse: mouseInput.releaseAll,
showKeyboard: {
statusMenuVisible = false
keyboardInputVisible.toggle()
@@ -313,6 +358,7 @@ struct RemoteSessionView: View {
}
},
disconnect: {
mouseInput.releaseAll()
statusMenuVisible = false
keyboardInputVisible = false
withAnimation(.easeOut(duration: 0.18)) {
@@ -320,13 +366,11 @@ struct RemoteSessionView: View {
}
}
)
.frame(width: panelSize.width, height: panelSize.height)
.transition(.scale(scale: 0.94, anchor: .topTrailing)
.combined(with: .opacity))
}
}
.frame(width: panelSize.width, height: panelSize.height)
.position(panelCenter)
.frame(width: containerSize.width, height: containerSize.height)
.allowsHitTesting(statusMenuVisible)
CrossDeskStatusOrb(isExpanded: statusMenuVisible)
@@ -334,6 +378,7 @@ struct RemoteSessionView: View {
.contentShape(Circle())
.position(orbCenter)
.onTapGesture {
mouseInput.releaseAll()
withAnimation(.spring(response: 0.24,
dampingFraction: 0.84)) {
statusMenuVisible.toggle()
@@ -345,6 +390,7 @@ struct RemoteSessionView: View {
state = value.translation
}
.onChanged { _ in
mouseInput.releaseAll()
if statusMenuVisible {
statusMenuVisible = false
}
@@ -359,9 +405,40 @@ struct RemoteSessionView: View {
)
}
.frame(width: containerSize.width, height: containerSize.height)
.onChange(of: containerSize) { _ in mouseInput.releaseAll() }
}
}
private func virtualMouseCenter(orbCenter: CGPoint, in size: CGSize) -> CGPoint {
let halfWidth: CGFloat = 70
let halfHeight: CGFloat = 24
let reach: CGFloat = 27 + 6 + halfWidth
var x = orbCenter.x - reach
var y = orbCenter.y
if x - halfWidth < 8 {
x = orbCenter.x + reach
if x + halfWidth > size.width - 8 {
x = orbCenter.x
y = orbCenter.y + 27 + 6 + halfHeight
if y + halfHeight > size.height - 8 {
y = orbCenter.y - 27 - 6 - halfHeight
}
}
}
x = min(max(x, halfWidth + 8), max(halfWidth + 8, size.width - halfWidth - 8))
if keyboardInputVisible {
let keyboardSize = keyboardPanelSize(in: size)
let resting = constrainedKeyboardCenter(keyboardPanelCenter ?? defaultKeyboardCenter(in: size, panelSize: keyboardSize), in: size, panelSize: keyboardSize)
let center = constrainedKeyboardCenter(CGPoint(x: resting.x + keyboardDragTranslation.width, y: resting.y + keyboardDragTranslation.height), in: size, panelSize: keyboardSize)
let keyboardRect = CGRect(x: center.x - keyboardSize.width / 2, y: center.y - keyboardSize.height / 2, width: keyboardSize.width, height: keyboardSize.height)
if keyboardRect.intersects(CGRect(x: x - halfWidth, y: y - halfHeight, width: 140, height: 48)) {
y = keyboardRect.minY - 6 - halfHeight
if y - halfHeight < 8 { y = keyboardRect.maxY + 6 + halfHeight }
}
}
return CGPoint(x: x, y: min(max(y, halfHeight + 8), max(halfHeight + 8, size.height - halfHeight - 8)))
}
private var draggableKeyboard: some View {
GeometryReader { proxy in
let containerSize = proxy.size
@@ -503,24 +580,6 @@ struct RemoteSessionView: View {
)
}
private func floatingPanelCenter(orbCenter: CGPoint,
panelSize: CGSize,
containerSize: CGSize) -> CGPoint {
let gap: CGFloat = 12
let orbRadius: CGFloat = 27
let horizontalPadding: CGFloat = 8
let verticalPadding: CGFloat = 8
let preferredX = orbCenter.x > containerSize.width / 2
? orbCenter.x - orbRadius - gap - panelSize.width / 2
: orbCenter.x + orbRadius + gap + panelSize.width / 2
return CGPoint(
x: min(max(preferredX, panelSize.width / 2 + horizontalPadding),
containerSize.width - panelSize.width / 2 - horizontalPadding),
y: min(max(orbCenter.y, panelSize.height / 2 + verticalPadding),
containerSize.height - panelSize.height / 2 - verticalPadding)
)
}
private var visibleCursorShape: Int {
guard session.hasRemoteCursorState,
session.remoteCursorVisible,
@@ -1079,8 +1138,39 @@ private struct CrossDeskStatusOrb: View {
}
}
/// Measure the menu before positioning it; no fixed height or later-frame
/// preference update is needed when changing pages, text size or orientation.
private struct FloatingMenuLayout: Layout {
let orbCenter: CGPoint
let panelWidth: CGFloat
func sizeThatFits(proposal: ProposedViewSize, subviews: Subviews,
cache: inout ()) -> CGSize {
proposal.replacingUnspecifiedDimensions()
}
func placeSubviews(in bounds: CGRect, proposal: ProposedViewSize,
subviews: Subviews, cache: inout ()) {
guard let menu = subviews.first else { return }
let size = menu.sizeThatFits(ProposedViewSize(
width: panelWidth, height: max(0, bounds.height - 16)))
let preferredX = orbCenter.x > bounds.width / 2
? orbCenter.x - 39 - size.width / 2
: orbCenter.x + 39 + size.width / 2
let center = CGPoint(
x: bounds.minX + min(max(preferredX, size.width / 2 + 8),
bounds.width - size.width / 2 - 8),
y: bounds.minY + min(max(orbCenter.y, size.height / 2 + 8),
bounds.height - size.height / 2 - 8))
menu.place(at: center, anchor: .center, proposal: ProposedViewSize(size))
}
}
private struct FloatingSessionMenu: View {
@ObservedObject var session: RemoteSessionModel
let virtualMouseVisible: Bool
let toggleMouse: () -> Void
let releaseMouse: () -> Void
private enum Page { case controls, videoSettings, networkStats }
@State private var page: Page = .controls
@State private var displayedNetworkSnapshot: RemoteNetworkSnapshot
@@ -1090,15 +1180,25 @@ private struct FloatingSessionMenu: View {
let close: () -> Void
let disconnect: () -> Void
private let columns = Array(repeating: GridItem(.flexible(), spacing: 7),
count: 3)
let columnCount: Int
private var columns: [GridItem] {
Array(repeating: GridItem(.flexible(), spacing: 7), count: columnCount)
}
init(session: RemoteSessionModel,
columnCount: Int,
virtualMouseVisible: Bool,
toggleMouse: @escaping () -> Void,
releaseMouse: @escaping () -> Void,
showKeyboard: @escaping () -> Void,
chooseFile: @escaping () -> Void,
close: @escaping () -> Void,
disconnect: @escaping () -> Void) {
self.session = session
self.columnCount = columnCount
self.virtualMouseVisible = virtualMouseVisible
self.toggleMouse = toggleMouse
self.releaseMouse = releaseMouse
self.showKeyboard = showKeyboard
self.chooseFile = chooseFile
self.close = close
@@ -1110,145 +1210,11 @@ private struct FloatingSessionMenu: View {
}
var body: some View {
VStack(spacing: 9) {
HStack(spacing: 8) {
if page != .controls {
Button {
page = .controls
} label: {
Image(systemName: "chevron.left")
.frame(width: 28, height: 32)
}
.buttonStyle(.plain)
.accessibilityLabel("返回控制栏")
Text(page == .videoSettings ? "画面设置" : "网络状态")
.font(.subheadline.weight(.semibold))
} else {
Circle()
.fill(session.isConnected ? Color.green : Color.orange)
.frame(width: 9, height: 9)
VStack(alignment: .leading, spacing: 1) {
Text(session.connectionStatus)
.font(.subheadline.weight(.semibold))
.lineLimit(1)
Text(statusDetail)
.font(.caption2.monospacedDigit())
.foregroundStyle(.secondary)
.lineLimit(1)
}
}
Spacer()
Button(action: close) {
Image(systemName: "xmark.circle.fill")
.font(.title3)
.foregroundStyle(.secondary)
}
.buttonStyle(.plain)
.accessibilityLabel("关闭控制栏")
}
if page == .videoSettings, !session.videoSettingsFeedback.isEmpty {
Text(session.videoSettingsFeedback)
.font(.caption)
.foregroundStyle(.secondary)
.frame(maxWidth: .infinity, alignment: .leading)
}
ScrollView {
if page == .videoSettings {
FloatingVideoSettings(session: session)
} else if page == .networkStats {
FloatingNetworkStatistics(snapshot: displayedNetworkSnapshot,
frameSize: displayedFrameSize)
} else {
VStack(spacing: 9) {
LazyVGrid(columns: columns, spacing: 7) {
FloatingControlButton(title: "键盘", symbol: "keyboard",
action: showKeyboard)
Menu {
ForEach(Array(session.displays.enumerated()), id: \.offset) { index, name in
Button {
session.selectDisplay(index)
} label: {
if index == session.selectedDisplay {
Label(name, systemImage: "checkmark")
} else {
Text(name)
}
}
}
} label: {
FloatingControlLabel(title: "显示器", symbol: "display")
}
.buttonStyle(.plain)
FloatingControlButton(title: "画面设置", symbol: "slider.horizontal.3") {
page = .videoSettings
}
FloatingControlButton(
title: session.audioEnabled ? "声音" : "静音",
symbol: session.audioEnabled ? "speaker.wave.2" : "speaker.slash",
action: session.toggleAudio
)
FloatingControlButton(
title: session.mouseControlMode == .relative ? "相对鼠标" : "绝对鼠标",
symbol: "computermouse",
action: {
session.mouseControlMode = session.mouseControlMode == .relative
? .absolute : .relative
}
)
FloatingControlButton(title: "发送文件",
symbol: "folder.badge.plus",
action: chooseFile)
FloatingControlButton(title: "网络状态", symbol: "chart.bar.xaxis") {
page = .networkStats
}
FloatingControlButton(title: "Ctrl+Alt+Del",
symbol: "lock.trianglebadge.exclamationmark",
action: session.sendSecureAttention)
FloatingControlButton(title: "剪贴板", symbol: "doc.on.clipboard",
action: session.sendClipboard)
}
if !session.controlStatus.isEmpty {
Text(session.controlStatus).font(.caption2).foregroundStyle(.secondary)
}
if !session.clipboardStatus.isEmpty {
Text(session.clipboardStatus).font(.caption2).foregroundStyle(.secondary)
}
if !session.transferStatus.isEmpty {
HStack(spacing: 8) {
Text(session.transferStatus)
.font(.caption2)
.foregroundStyle(.secondary)
.lineLimit(1)
Spacer(minLength: 0)
if session.transferProgress > 0 && session.transferProgress < 1 {
ProgressView(value: session.transferProgress)
.frame(width: 60)
}
if let file = session.receivedFileURL {
ShareLink(item: file) {
Image(systemName: "square.and.arrow.up")
}
}
}
}
}
}
}
Button(role: .destructive, action: disconnect) {
Text("断开连接")
.font(.subheadline.weight(.semibold))
.frame(maxWidth: .infinity)
.frame(height: 34)
}
.buttonStyle(.borderedProminent)
.tint(.red)
// Prefer the full natural-height panel. Only use a scrolling body when
// the available safe-area height cannot contain all of its content.
ViewThatFits(in: .vertical) {
panel(scrolls: false).fixedSize(horizontal: false, vertical: true)
panel(scrolls: true)
}
.padding(12)
.background(Color.white,
@@ -1274,6 +1240,164 @@ private struct FloatingSessionMenu: View {
}
}
private func panel(scrolls: Bool) -> some View {
VStack(spacing: 9) {
menuHeader
if scrolls {
ScrollView { menuContent }
} else {
menuContent
}
disconnectButton
}
}
@ViewBuilder private var menuHeader: some View {
HStack(spacing: 8) {
if page != .controls {
Button {
page = .controls
} label: {
Image(systemName: "chevron.left")
.frame(width: 28, height: 32)
}
.buttonStyle(.plain)
.accessibilityLabel("返回控制栏")
Text(page == .videoSettings ? "画面设置" : "网络状态")
.font(.subheadline.weight(.semibold))
} else {
Circle()
.fill(session.isConnected ? Color.green : Color.orange)
.frame(width: 9, height: 9)
VStack(alignment: .leading, spacing: 1) {
Text(session.connectionStatus)
.font(.subheadline.weight(.semibold))
.lineLimit(1)
Text(statusDetail)
.font(.caption2.monospacedDigit())
.foregroundStyle(.secondary)
.lineLimit(1)
}
}
Spacer()
Button(action: close) {
Image(systemName: "xmark.circle.fill")
.font(.title3)
.foregroundStyle(.secondary)
}
.buttonStyle(.plain)
.accessibilityLabel("关闭控制栏")
}
if page == .videoSettings, !session.videoSettingsFeedback.isEmpty {
Text(session.videoSettingsFeedback)
.font(.caption)
.foregroundStyle(.secondary)
.frame(maxWidth: .infinity, alignment: .leading)
}
}
@ViewBuilder private var menuContent: some View {
if page == .videoSettings {
FloatingVideoSettings(session: session)
} else if page == .networkStats {
FloatingNetworkStatistics(snapshot: displayedNetworkSnapshot,
frameSize: displayedFrameSize)
} else {
VStack(spacing: 9) {
LazyVGrid(columns: columns, spacing: 7) {
FloatingControlButton(title: "键盘", symbol: "keyboard",
action: showKeyboard)
FloatingControlButton(
title: session.mouseControlMode == .relative ? "相对鼠标" : "绝对鼠标",
symbol: "computermouse",
action: {
session.mouseControlMode = session.mouseControlMode == .relative
? .absolute : .relative
}
)
FloatingControlButton(title: virtualMouseVisible ? "收起鼠标" : "虚拟鼠标",
symbol: "computermouse", action: toggleMouse)
Menu {
ForEach(Array(session.displays.enumerated()), id: \.offset) { index, name in
Button {
releaseMouse()
session.selectDisplay(index)
} label: {
if index == session.selectedDisplay {
Label(name, systemImage: "checkmark")
} else {
Text(name)
}
}
}
} label: {
FloatingControlLabel(title: "显示器", symbol: "display")
}
.buttonStyle(.plain)
FloatingControlButton(title: "画面设置", symbol: "slider.horizontal.3") {
page = .videoSettings
}
FloatingControlButton(
title: session.audioEnabled ? "声音" : "静音",
symbol: session.audioEnabled ? "speaker.wave.2" : "speaker.slash",
action: session.toggleAudio
)
FloatingControlButton(title: "发送文件",
symbol: "folder.badge.plus",
action: chooseFile)
FloatingControlButton(title: "网络状态", symbol: "chart.bar.xaxis") {
page = .networkStats
}
FloatingControlButton(title: "Ctrl+Alt+Del",
symbol: "lock.trianglebadge.exclamationmark",
action: session.sendSecureAttention)
FloatingControlButton(title: "剪贴板", symbol: "doc.on.clipboard",
action: session.sendClipboard)
}
if !session.controlStatus.isEmpty {
Text(session.controlStatus).font(.caption2).foregroundStyle(.secondary)
}
if !session.clipboardStatus.isEmpty {
Text(session.clipboardStatus).font(.caption2).foregroundStyle(.secondary)
}
if !session.transferStatus.isEmpty {
HStack(spacing: 8) {
Text(session.transferStatus)
.font(.caption2)
.foregroundStyle(.secondary)
.lineLimit(1)
Spacer(minLength: 0)
if session.transferProgress > 0 && session.transferProgress < 1 {
ProgressView(value: session.transferProgress)
.frame(width: 60)
}
if let file = session.receivedFileURL {
ShareLink(item: file) {
Image(systemName: "square.and.arrow.up")
}
}
}
}
}
}
}
private var disconnectButton: some View {
Button(role: .destructive, action: disconnect) {
Text("断开连接")
.font(.subheadline.weight(.semibold))
.frame(maxWidth: .infinity)
.frame(height: 34)
}
.buttonStyle(.borderedProminent)
.tint(.red)
}
private var statusDetail: String {
let snapshot = displayedNetworkSnapshot
var components = [snapshot.connectionMode]