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>
626 lines
24 KiB
Swift
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
|