diff --git a/Packages/VNCUI/Sources/VNCUI/Session/FramebufferUIView.swift b/Packages/VNCUI/Sources/VNCUI/Session/FramebufferUIView.swift index 5e9bfb2..accc8bd 100644 --- a/Packages/VNCUI/Sources/VNCUI/Session/FramebufferUIView.swift +++ b/Packages/VNCUI/Sources/VNCUI/Session/FramebufferUIView.swift @@ -42,6 +42,11 @@ final class FramebufferUIView: UIView, // Indirect pointer (trackpad/mouse via UIPointerInteraction) private var indirectPointerNormalized: CGPoint? + // On-screen keyboard handling + private var keyboardActive = false + var onKeyboardDismissed: (() -> Void)? + private lazy var functionAccessoryView: UIView = makeFunctionAccessoryView() + override init(frame: CGRect) { super.init(frame: frame) isOpaque = true @@ -69,6 +74,32 @@ final class FramebufferUIView: UIView, if window != nil { _ = becomeFirstResponder() } } + override var inputAccessoryView: UIView? { + keyboardActive ? functionAccessoryView : nil + } + + /// Show or hide the iOS on-screen keyboard. + func setSoftwareKeyboardVisible(_ visible: Bool) { + guard visible != keyboardActive else { return } + keyboardActive = visible + // Force inputAccessoryView re-read by toggling first responder + _ = resignFirstResponder() + _ = becomeFirstResponder() + if visible { + reloadInputViews() + } + } + + @discardableResult + override func resignFirstResponder() -> Bool { + let result = super.resignFirstResponder() + if keyboardActive && result { + keyboardActive = false + onKeyboardDismissed?() + } + return result + } + // MARK: Layout / image override func layoutSubviews() { @@ -468,5 +499,106 @@ final class FramebufferUIView: UIView, 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) + } +} + +// MARK: - UIKeyInput (accept on-screen keyboard input) + +extension FramebufferUIView: UIKeyInput { + var hasText: Bool { true } + + func insertText(_ text: String) { + guard let controller else { return } + controller.type(text) + } + + func deleteBackward() { + controller?.sendBackspace() + } +} + +// MARK: - UITextInputTraits (sane keyboard appearance) + +extension FramebufferUIView: UITextInputTraits { + var autocorrectionType: UITextAutocorrectionType { + get { .no } set { _ = newValue } + } + var autocapitalizationType: UITextAutocapitalizationType { + get { .none } set { _ = newValue } + } + var spellCheckingType: UITextSpellCheckingType { + get { .no } set { _ = newValue } + } + var smartQuotesType: UITextSmartQuotesType { + get { .no } set { _ = newValue } + } + var smartDashesType: UITextSmartDashesType { + get { .no } set { _ = newValue } + } + var smartInsertDeleteType: UITextSmartInsertDeleteType { + get { .no } set { _ = newValue } + } + var keyboardType: UIKeyboardType { + get { .asciiCapable } set { _ = newValue } + } + var keyboardAppearance: UIKeyboardAppearance { + get { .dark } set { _ = newValue } + } + var returnKeyType: UIReturnKeyType { + get { .default } set { _ = newValue } + } } #endif diff --git a/Packages/VNCUI/Sources/VNCUI/Session/FramebufferView.swift b/Packages/VNCUI/Sources/VNCUI/Session/FramebufferView.swift index 0ab995f..44e2560 100644 --- a/Packages/VNCUI/Sources/VNCUI/Session/FramebufferView.swift +++ b/Packages/VNCUI/Sources/VNCUI/Session/FramebufferView.swift @@ -8,6 +8,7 @@ struct FramebufferView: UIViewRepresentable { let inputMode: InputMode let selectedScreen: RemoteScreen? @Binding var trackpadCursor: CGPoint + @Binding var showSoftwareKeyboard: Bool func makeUIView(context: Context) -> FramebufferUIView { let view = FramebufferUIView() @@ -18,6 +19,9 @@ struct FramebufferView: UIViewRepresentable { view.onTrackpadCursorChanged = { [binding = $trackpadCursor] new in binding.wrappedValue = new } + view.onKeyboardDismissed = { [binding = $showSoftwareKeyboard] in + if binding.wrappedValue { binding.wrappedValue = false } + } return view } @@ -30,6 +34,7 @@ struct FramebufferView: UIViewRepresentable { } uiView.apply(image: controller.currentImage, framebufferSize: framebufferSize) + uiView.setSoftwareKeyboardVisible(showSoftwareKeyboard) // Touch the revision so SwiftUI re-runs us when frames arrive _ = controller.imageRevision } @@ -51,6 +56,7 @@ struct FramebufferView: View { let inputMode: InputMode let selectedScreen: RemoteScreen? @Binding var trackpadCursor: CGPoint + @Binding var showSoftwareKeyboard: Bool var body: some View { Color.black } } diff --git a/Packages/VNCUI/Sources/VNCUI/Session/SessionView.swift b/Packages/VNCUI/Sources/VNCUI/Session/SessionView.swift index 60107bd..70a69aa 100644 --- a/Packages/VNCUI/Sources/VNCUI/Session/SessionView.swift +++ b/Packages/VNCUI/Sources/VNCUI/Session/SessionView.swift @@ -16,8 +16,7 @@ public struct SessionView: View { @State private var controller: SessionController? @State private var inputMode: InputMode = .touch - @State private var showKeyboardBar = false - @State private var showFunctionRow = false + @State private var showSoftwareKeyboard = false @State private var trackpadCursor: CGPoint = CGPoint(x: 0.5, y: 0.5) @State private var selectedScreenID: UInt32? @State private var screenshotItem: ScreenshotShareItem? @@ -36,7 +35,8 @@ public struct SessionView: View { controller: controller, inputMode: inputMode, selectedScreen: selectedScreen(for: controller), - trackpadCursor: $trackpadCursor + trackpadCursor: $trackpadCursor, + showSoftwareKeyboard: $showSoftwareKeyboard ) .ignoresSafeArea() .onTapGesture(count: 3) { @@ -113,19 +113,13 @@ public struct SessionView: View { SessionToolbar( controller: controller, inputMode: $inputMode, - showKeyboardBar: $showKeyboardBar, + showKeyboardBar: $showSoftwareKeyboard, selectedScreenID: $selectedScreenID, onScreenshot: { takeScreenshot(controller: controller) }, onDisconnect: { stopAndDismiss() } ) Spacer() - - if showKeyboardBar { - SoftKeyboardBar(controller: controller, isExpanded: $showFunctionRow) - .padding(.bottom, 12) - .transition(.move(edge: .bottom).combined(with: .opacity)) - } } }