Files
Screens/Packages/VNCUI/Sources/VNCUI/Session/FramebufferUIView.swift
Trey T c1bed4f53b FramebufferUIView: disable CALayer implicit animations on frame updates
Every time Mac repainted a region we set imageLayer.contents to a new
CGImage. CALayer's default action for .contents is a 0.25s crossfade, so
big repaints (like after a click — cursor + button + window-focus) looked
like a pulse. Tap seemed "flickery"; actually the whole view was doing
quarter-second crossfades constantly, most just weren't big enough to
notice until a chunky repaint hit.

Override imageLayer.actions with NSNull for contents/contentsRect/frame/
transform so blits are instantaneous, and wrap apply() in a
CATransaction.setDisableActions(true) for safety.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-17 14:21:33 -05:00

626 lines
24 KiB
Swift

#if canImport(UIKit)
import UIKit
import VNCCore
@MainActor
final class FramebufferUIView: UIView,
UIGestureRecognizerDelegate,
UIPointerInteractionDelegate,
UIKeyInput {
// MARK: - UITextInputTraits disable every kind of autocomplete, so
// each keystroke produces exactly one insertText() call and nothing
// is swallowed by the suggestion/predictive engine.
@objc var autocorrectionType: UITextAutocorrectionType = .no
@objc var autocapitalizationType: UITextAutocapitalizationType = .none
@objc var spellCheckingType: UITextSpellCheckingType = .no
@objc var smartQuotesType: UITextSmartQuotesType = .no
@objc var smartDashesType: UITextSmartDashesType = .no
@objc var smartInsertDeleteType: UITextSmartInsertDeleteType = .no
@objc var keyboardType: UIKeyboardType = .asciiCapable
@objc var keyboardAppearance: UIKeyboardAppearance = .dark
@objc var returnKeyType: UIReturnKeyType = .default
@objc var enablesReturnKeyAutomatically: Bool = false
@objc var isSecureTextEntry: Bool = false
@objc var passwordRules: UITextInputPasswordRules?
@objc var textContentType: UITextContentType? = UITextContentType(rawValue: "")
weak var controller: SessionController?
var inputMode: InputMode = .touch
var selectedScreen: RemoteScreen? {
didSet { setNeedsLayout() }
}
var zoomScale: CGFloat = 1.0 {
didSet { setNeedsLayout() }
}
var contentTranslation: CGPoint = .zero {
didSet { setNeedsLayout() }
}
var trackpadCursorNormalized: CGPoint = CGPoint(x: 0.5, y: 0.5)
var onTrackpadCursorChanged: ((CGPoint) -> Void)?
private let imageLayer = CALayer()
private let inputMapper = InputMapper()
// Touch-mode pan with implicit left-button drag
private var touchPanActiveButtonDown = false
private var touchPanLastNormalized: CGPoint?
// Trackpad-mode pan moves the soft cursor
private var trackpadPanStartCursor: CGPoint?
private var trackpadPanStartPoint: CGPoint?
// Two-finger scroll accumulator
private var scrollAccumulator: CGPoint = .zero
private static let scrollStepPoints: CGFloat = 28
// Pinch zoom anchor
private var pinchStartScale: CGFloat = 1.0
private var pinchStartTranslation: CGPoint = .zero
// Indirect pointer (trackpad/mouse via UIPointerInteraction)
private var indirectPointerNormalized: CGPoint?
// On-screen keyboard handling direct UIKeyInput conformance.
var onKeyboardDismissed: (() -> Void)?
private lazy var functionAccessoryView: UIView = makeFunctionAccessoryView()
private var keyboardWanted = false
// A separate accessibility element for test diagnostics, because iOS
// reserves `accessibilityValue` on UIKeyInput-classed views for text.
private let diagnosticProbe: UIView = {
let v = UIView(frame: .zero)
v.alpha = 0
v.isAccessibilityElement = true
v.accessibilityIdentifier = "fb-diag"
v.accessibilityLabel = ""
return v
}()
override init(frame: CGRect) {
super.init(frame: frame)
isOpaque = true
backgroundColor = .black
isMultipleTouchEnabled = true
clipsToBounds = true
imageLayer.magnificationFilter = .nearest
imageLayer.minificationFilter = .linear
imageLayer.contentsGravity = .resize
// Kill CALayer's implicit animations we want every framebuffer
// update to be an instantaneous blit, not a 0.25s crossfade.
imageLayer.actions = [
"contents": NSNull(),
"contentsRect": NSNull(),
"position": NSNull(),
"bounds": NSNull(),
"frame": NSNull(),
"transform": NSNull(),
"backgroundColor": NSNull()
]
layer.addSublayer(imageLayer)
configureGestureRecognizers()
configurePointerInteraction()
isAccessibilityElement = true
accessibilityLabel = "Remote framebuffer"
accessibilityIdentifier = "framebuffer"
addSubview(diagnosticProbe)
}
required init?(coder: NSCoder) {
fatalError("init(coder:) has not been implemented")
}
override var canBecomeFirstResponder: Bool { true }
override func didMoveToWindow() {
super.didMoveToWindow()
if window != nil { _ = becomeFirstResponder() }
}
override var inputAccessoryView: UIView? {
keyboardWanted ? functionAccessoryView : nil
}
/// Show or hide the iOS on-screen keyboard.
func setSoftwareKeyboardVisible(_ visible: Bool) {
appendDiagnostic("set:\(visible)")
if visible {
keyboardWanted = true
if !isFirstResponder {
let became = becomeFirstResponder()
appendDiagnostic("became:\(became)")
} else {
reloadInputViews()
appendDiagnostic("reload")
}
} else {
keyboardWanted = false
if isFirstResponder {
_ = resignFirstResponder()
appendDiagnostic("resigned")
}
}
}
private func appendDiagnostic(_ tag: String) {
let prior = diagnosticProbe.accessibilityLabel ?? ""
diagnosticProbe.accessibilityLabel = prior + "[\(tag)]"
}
// MARK: - UIKeyInput
var hasText: Bool { true }
func insertText(_ text: String) {
appendDiagnostic("ins:\(text)")
controller?.type(text)
}
func deleteBackward() {
appendDiagnostic("del")
controller?.sendBackspace()
}
// MARK: Layout / image
override func layoutSubviews() {
super.layoutSubviews()
applyLayerFrame()
}
func apply(image: CGImage?, framebufferSize: CGSize) {
CATransaction.begin()
CATransaction.setDisableActions(true)
imageLayer.contents = image
applyLayerFrame()
CATransaction.commit()
}
private func applyLayerFrame() {
let bounds = self.bounds
let fbSize = framebufferContentSize()
guard fbSize.width > 0, fbSize.height > 0,
bounds.width > 0, bounds.height > 0,
let displayed = inputMapper.displayedRect(for: fbSize, in: bounds.size) else {
imageLayer.frame = bounds
imageLayer.contentsRect = CGRect(x: 0, y: 0, width: 1, height: 1)
return
}
let scaled = displayed.applying(
CGAffineTransform(translationX: -bounds.midX, y: -bounds.midY)
.concatenating(CGAffineTransform(scaleX: zoomScale, y: zoomScale))
.concatenating(CGAffineTransform(translationX: bounds.midX + contentTranslation.x,
y: bounds.midY + contentTranslation.y))
)
imageLayer.frame = scaled
if let screen = selectedScreen {
let totalWidth = framebufferAbsoluteSize().width
let totalHeight = framebufferAbsoluteSize().height
if totalWidth > 0, totalHeight > 0 {
imageLayer.contentsRect = CGRect(
x: screen.frame.origin.x / totalWidth,
y: screen.frame.origin.y / totalHeight,
width: screen.frame.width / totalWidth,
height: screen.frame.height / totalHeight
)
} else {
imageLayer.contentsRect = CGRect(x: 0, y: 0, width: 1, height: 1)
}
} else {
imageLayer.contentsRect = CGRect(x: 0, y: 0, width: 1, height: 1)
}
}
private func framebufferAbsoluteSize() -> CGSize {
guard let size = controller?.framebufferSize else { return .zero }
return CGSize(width: CGFloat(size.width), height: CGFloat(size.height))
}
private func framebufferContentSize() -> CGSize {
if let screen = selectedScreen {
return CGSize(width: screen.frame.width, height: screen.frame.height)
}
return framebufferAbsoluteSize()
}
// MARK: Gesture recognition
private func configureGestureRecognizers() {
let singleTap = UITapGestureRecognizer(target: self, action: #selector(handleSingleTap(_:)))
singleTap.numberOfTapsRequired = 1
singleTap.numberOfTouchesRequired = 1
addGestureRecognizer(singleTap)
let twoFingerTap = UITapGestureRecognizer(target: self, action: #selector(handleTwoFingerTap(_:)))
twoFingerTap.numberOfTapsRequired = 1
twoFingerTap.numberOfTouchesRequired = 2
addGestureRecognizer(twoFingerTap)
let longPress = UILongPressGestureRecognizer(target: self, action: #selector(handleLongPress(_:)))
longPress.minimumPressDuration = 0.55
longPress.delegate = self
addGestureRecognizer(longPress)
let pan = UIPanGestureRecognizer(target: self, action: #selector(handlePan(_:)))
pan.minimumNumberOfTouches = 1
pan.maximumNumberOfTouches = 1
pan.delegate = self
pan.allowedTouchTypes = [
NSNumber(value: UITouch.TouchType.direct.rawValue),
NSNumber(value: UITouch.TouchType.pencil.rawValue)
]
addGestureRecognizer(pan)
let twoFingerPan = UIPanGestureRecognizer(target: self, action: #selector(handleTwoFingerPan(_:)))
twoFingerPan.minimumNumberOfTouches = 2
twoFingerPan.maximumNumberOfTouches = 2
twoFingerPan.delegate = self
addGestureRecognizer(twoFingerPan)
let pinch = UIPinchGestureRecognizer(target: self, action: #selector(handlePinch(_:)))
pinch.delegate = self
addGestureRecognizer(pinch)
let indirectScroll = UIPanGestureRecognizer(target: self, action: #selector(handleIndirectScroll(_:)))
indirectScroll.allowedScrollTypesMask = .all
indirectScroll.allowedTouchTypes = [NSNumber(value: UITouch.TouchType.indirectPointer.rawValue)]
indirectScroll.delegate = self
addGestureRecognizer(indirectScroll)
let hover = UIHoverGestureRecognizer(target: self, action: #selector(handleHover(_:)))
addGestureRecognizer(hover)
}
// MARK: UIGestureRecognizerDelegate
func gestureRecognizer(_ gestureRecognizer: UIGestureRecognizer,
shouldRecognizeSimultaneouslyWith other: UIGestureRecognizer) -> Bool {
if gestureRecognizer is UIPinchGestureRecognizer || other is UIPinchGestureRecognizer {
return true
}
return false
}
func gestureRecognizer(_ gestureRecognizer: UIGestureRecognizer,
shouldRequireFailureOf other: UIGestureRecognizer) -> Bool {
if let tap = gestureRecognizer as? UITapGestureRecognizer,
tap.numberOfTouchesRequired == 1,
let otherTap = other as? UITapGestureRecognizer,
otherTap.numberOfTouchesRequired == 2 {
return true
}
return false
}
// MARK: Pointer Interaction
private func configurePointerInteraction() {
let interaction = UIPointerInteraction(delegate: self)
addInteraction(interaction)
}
func pointerInteraction(_ interaction: UIPointerInteraction,
styleFor region: UIPointerRegion) -> UIPointerStyle? {
UIPointerStyle.hidden()
}
func pointerInteraction(_ interaction: UIPointerInteraction,
regionFor request: UIPointerRegionRequest,
defaultRegion: UIPointerRegion) -> UIPointerRegion? {
let viewPoint = request.location
if let normalized = inputMapper.normalize(viewPoint: viewPoint,
in: bounds.size,
framebufferSize: framebufferContentSize()) {
indirectPointerNormalized = normalized
controller?.pointerMove(toNormalized: mapToFullFramebuffer(normalized: normalized))
}
return defaultRegion
}
// MARK: Gesture Handlers
@objc private func handleSingleTap(_ recognizer: UITapGestureRecognizer) {
let location = recognizer.location(in: self)
switch inputMode {
case .touch:
if let normalized = normalizedFor(location) {
controller?.pointerClick(.left,
atNormalized: mapToFullFramebuffer(normalized: normalized))
}
case .trackpad:
controller?.pointerClick(.left,
atNormalized: mapToFullFramebuffer(normalized: trackpadCursorNormalized))
}
if !isFirstResponder { _ = becomeFirstResponder() }
}
@objc private func handleTwoFingerTap(_ recognizer: UITapGestureRecognizer) {
let location = recognizer.location(in: self)
switch inputMode {
case .touch:
if let normalized = normalizedFor(location) {
controller?.pointerClick(.right,
atNormalized: mapToFullFramebuffer(normalized: normalized))
}
case .trackpad:
controller?.pointerClick(.right,
atNormalized: mapToFullFramebuffer(normalized: trackpadCursorNormalized))
}
}
@objc private func handleLongPress(_ recognizer: UILongPressGestureRecognizer) {
guard recognizer.state == .began else { return }
let location = recognizer.location(in: self)
switch inputMode {
case .touch:
if let normalized = normalizedFor(location) {
controller?.pointerClick(.right,
atNormalized: mapToFullFramebuffer(normalized: normalized))
}
case .trackpad:
controller?.pointerClick(.right,
atNormalized: mapToFullFramebuffer(normalized: trackpadCursorNormalized))
}
}
@objc private func handlePan(_ recognizer: UIPanGestureRecognizer) {
switch inputMode {
case .touch:
handleTouchPan(recognizer)
case .trackpad:
handleTrackpadPan(recognizer)
}
}
private func handleTouchPan(_ recognizer: UIPanGestureRecognizer) {
let location = recognizer.location(in: self)
guard let normalized = normalizedFor(location) else { return }
let mapped = mapToFullFramebuffer(normalized: normalized)
switch recognizer.state {
case .began:
touchPanLastNormalized = mapped
touchPanActiveButtonDown = true
controller?.pointerDown(.left, atNormalized: mapped)
case .changed:
touchPanLastNormalized = mapped
controller?.pointerMove(toNormalized: mapped)
case .ended, .cancelled, .failed:
if touchPanActiveButtonDown {
touchPanActiveButtonDown = false
let endNormalized = touchPanLastNormalized ?? mapped
controller?.pointerUp(.left, atNormalized: endNormalized)
}
touchPanLastNormalized = nil
default:
break
}
}
private func handleTrackpadPan(_ recognizer: UIPanGestureRecognizer) {
let translation = recognizer.translation(in: self)
switch recognizer.state {
case .began:
trackpadPanStartCursor = trackpadCursorNormalized
trackpadPanStartPoint = .zero
case .changed:
guard let start = trackpadPanStartCursor else { return }
let fbSize = framebufferContentSize()
guard let displayed = inputMapper.displayedRect(for: fbSize, in: bounds.size),
displayed.width > 0, displayed.height > 0 else { return }
let dx = translation.x / displayed.width
let dy = translation.y / displayed.height
let newCursor = CGPoint(
x: max(0, min(1, start.x + dx)),
y: max(0, min(1, start.y + dy))
)
trackpadCursorNormalized = newCursor
onTrackpadCursorChanged?(newCursor)
controller?.pointerMove(toNormalized: mapToFullFramebuffer(normalized: newCursor))
case .ended, .cancelled, .failed:
trackpadPanStartCursor = nil
trackpadPanStartPoint = nil
default: break
}
}
@objc private func handleTwoFingerPan(_ recognizer: UIPanGestureRecognizer) {
let translation = recognizer.translation(in: self)
recognizer.setTranslation(.zero, in: self)
scrollAccumulator.x += translation.x
scrollAccumulator.y += translation.y
emitScrollEvents()
if recognizer.state == .ended || recognizer.state == .cancelled {
scrollAccumulator = .zero
}
}
@objc private func handleIndirectScroll(_ recognizer: UIPanGestureRecognizer) {
let translation = recognizer.translation(in: self)
recognizer.setTranslation(.zero, in: self)
scrollAccumulator.x += translation.x
scrollAccumulator.y += translation.y
emitScrollEvents()
if recognizer.state == .ended || recognizer.state == .cancelled {
scrollAccumulator = .zero
}
}
private func emitScrollEvents() {
guard let controller else { return }
let cursor: CGPoint = inputMode == .trackpad
? trackpadCursorNormalized
: (indirectPointerNormalized ?? CGPoint(x: 0.5, y: 0.5))
let mapped = mapToFullFramebuffer(normalized: cursor)
let stepsY = Int((scrollAccumulator.y / Self.scrollStepPoints).rounded(.towardZero))
if stepsY != 0 {
let dir: ScrollDirection = stepsY > 0 ? .down : .up
controller.pointerScroll(dir, steps: UInt32(abs(stepsY)), atNormalized: mapped)
scrollAccumulator.y -= CGFloat(stepsY) * Self.scrollStepPoints
}
let stepsX = Int((scrollAccumulator.x / Self.scrollStepPoints).rounded(.towardZero))
if stepsX != 0 {
let dir: ScrollDirection = stepsX > 0 ? .right : .left
controller.pointerScroll(dir, steps: UInt32(abs(stepsX)), atNormalized: mapped)
scrollAccumulator.x -= CGFloat(stepsX) * Self.scrollStepPoints
}
}
@objc private func handlePinch(_ recognizer: UIPinchGestureRecognizer) {
switch recognizer.state {
case .began:
pinchStartScale = zoomScale
pinchStartTranslation = contentTranslation
case .changed:
let newScale = max(0.5, min(8.0, pinchStartScale * recognizer.scale))
zoomScale = newScale
case .ended, .cancelled, .failed:
// snap back if zoomed too much
if zoomScale < 1.01 {
zoomScale = 1.0
contentTranslation = .zero
}
default: break
}
}
@objc private func handleHover(_ recognizer: UIHoverGestureRecognizer) {
let location = recognizer.location(in: self)
guard let normalized = inputMapper.normalize(viewPoint: location,
in: bounds.size,
framebufferSize: framebufferContentSize()) else { return }
indirectPointerNormalized = normalized
if recognizer.state == .changed || recognizer.state == .began {
controller?.pointerMove(toNormalized: mapToFullFramebuffer(normalized: normalized))
}
}
private func normalizedFor(_ point: CGPoint) -> CGPoint? {
inputMapper.normalize(viewPoint: point,
in: bounds.size,
framebufferSize: framebufferContentSize())
}
private func mapToFullFramebuffer(normalized: CGPoint) -> CGPoint {
guard let screen = selectedScreen,
let totalSize = controller?.framebufferSize,
totalSize.width > 0, totalSize.height > 0 else {
return normalized
}
let totalWidth = CGFloat(totalSize.width)
let totalHeight = CGFloat(totalSize.height)
let x = (screen.frame.origin.x + normalized.x * screen.frame.width) / totalWidth
let y = (screen.frame.origin.y + normalized.y * screen.frame.height) / totalHeight
return CGPoint(x: max(0, min(1, x)), y: max(0, min(1, y)))
}
// MARK: Hardware keyboard
override func pressesBegan(_ presses: Set<UIPress>, with event: UIPressesEvent?) {
var handled = false
for press in presses {
guard let key = press.key else { continue }
handleKey(key, isDown: true)
handled = true
}
if !handled { super.pressesBegan(presses, with: event) }
}
override func pressesEnded(_ presses: Set<UIPress>, with event: UIPressesEvent?) {
var handled = false
for press in presses {
guard let key = press.key else { continue }
handleKey(key, isDown: false)
handled = true
}
if !handled { super.pressesEnded(presses, with: event) }
}
override func pressesCancelled(_ presses: Set<UIPress>, with event: UIPressesEvent?) {
for press in presses {
guard let key = press.key else { continue }
handleKey(key, isDown: false)
}
super.pressesCancelled(presses, with: event)
}
private func handleKey(_ key: UIKey, isDown: Bool) {
guard let controller else { return }
let modifiers = KeyMapping.modifierKeysyms(for: key.modifierFlags)
if isDown {
for sym in modifiers { controller.keyDown(keysym: sym) }
}
if let sym = KeyMapping.keysym(for: key) {
if isDown {
controller.keyDown(keysym: sym)
} else {
controller.keyUp(keysym: sym)
}
}
if !isDown {
for sym in modifiers.reversed() { controller.keyUp(keysym: sym) }
}
}
// MARK: Software keyboard accessory
private func makeFunctionAccessoryView() -> UIView {
let bar = UIToolbar(frame: CGRect(x: 0, y: 0, width: UIScreen.main.bounds.width, height: 44))
bar.barStyle = .black
bar.tintColor = .white
bar.isTranslucent = true
func barButton(title: String?, image: String?, action: Selector) -> UIBarButtonItem {
if let title {
return UIBarButtonItem(title: title, style: .plain, target: self, action: action)
}
return UIBarButtonItem(image: UIImage(systemName: image ?? ""),
style: .plain,
target: self,
action: action)
}
let esc = barButton(title: "esc", image: nil, action: #selector(accessoryEsc))
let tab = barButton(title: "tab", image: nil, action: #selector(accessoryTab))
let ctrl = barButton(title: "ctrl", image: nil, action: #selector(accessoryControl))
let cmd = barButton(title: "", image: nil, action: #selector(accessoryCommand))
let opt = barButton(title: "", image: nil, action: #selector(accessoryOption))
let left = barButton(title: nil, image: "arrow.left", action: #selector(accessoryLeft))
let down = barButton(title: nil, image: "arrow.down", action: #selector(accessoryDown))
let up = barButton(title: nil, image: "arrow.up", action: #selector(accessoryUp))
let right = barButton(title: nil, image: "arrow.right", action: #selector(accessoryRight))
let spacer = UIBarButtonItem.flexibleSpace()
let dismiss = barButton(title: nil,
image: "keyboard.chevron.compact.down",
action: #selector(accessoryDismiss))
bar.items = [esc, tab, ctrl, cmd, opt, spacer, left, down, up, right, spacer, dismiss]
return bar
}
@objc private func accessoryEsc() { controller?.sendEscape() }
@objc private func accessoryTab() { controller?.sendTab() }
@objc private func accessoryControl() {
controller?.pressKeyCombo([.control])
}
@objc private func accessoryCommand() {
controller?.pressKeyCombo([.command])
}
@objc private func accessoryOption() {
controller?.pressKeyCombo([.option])
}
@objc private func accessoryLeft() { controller?.sendArrow(.left) }
@objc private func accessoryDown() { controller?.sendArrow(.down) }
@objc private func accessoryUp() { controller?.sendArrow(.up) }
@objc private func accessoryRight() { controller?.sendArrow(.right) }
@objc private func accessoryDismiss() {
setSoftwareKeyboardVisible(false)
}
}
#endif