Make the keyboard button actually present a keyboard

The toolbar's keyboard icon used to toggle a custom function-key bar — it
never opened the iOS system keyboard, so users couldn't type into the remote.

Fix: FramebufferUIView now conforms to UIKeyInput + UITextInputTraits, so
becoming first responder presents the iOS keyboard. Tapping the keyboard
button toggles software-keyboard visibility on the framebuffer view via a
SwiftUI binding. While the keyboard is up, an inputAccessoryView toolbar
(esc / tab / ctrl / ⌘ / ⌥ / ←↓↑→ / dismiss) sits directly above it, with
each button forwarded to the existing controller.send… APIs.

The standalone SoftKeyboardBar overlay is no longer used.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
Trey T
2026-04-16 22:34:28 -05:00
parent 689e30d59a
commit da882531d1
3 changed files with 142 additions and 10 deletions

View File

@@ -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

View File

@@ -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 }
}

View File

@@ -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))
}
}
}