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:
@@ -42,6 +42,11 @@ final class FramebufferUIView: UIView,
|
|||||||
// Indirect pointer (trackpad/mouse via UIPointerInteraction)
|
// Indirect pointer (trackpad/mouse via UIPointerInteraction)
|
||||||
private var indirectPointerNormalized: CGPoint?
|
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) {
|
override init(frame: CGRect) {
|
||||||
super.init(frame: frame)
|
super.init(frame: frame)
|
||||||
isOpaque = true
|
isOpaque = true
|
||||||
@@ -69,6 +74,32 @@ final class FramebufferUIView: UIView,
|
|||||||
if window != nil { _ = becomeFirstResponder() }
|
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
|
// MARK: Layout / image
|
||||||
|
|
||||||
override func layoutSubviews() {
|
override func layoutSubviews() {
|
||||||
@@ -468,5 +499,106 @@ final class FramebufferUIView: UIView,
|
|||||||
for sym in modifiers.reversed() { controller.keyUp(keysym: sym) }
|
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
|
#endif
|
||||||
|
|||||||
@@ -8,6 +8,7 @@ struct FramebufferView: UIViewRepresentable {
|
|||||||
let inputMode: InputMode
|
let inputMode: InputMode
|
||||||
let selectedScreen: RemoteScreen?
|
let selectedScreen: RemoteScreen?
|
||||||
@Binding var trackpadCursor: CGPoint
|
@Binding var trackpadCursor: CGPoint
|
||||||
|
@Binding var showSoftwareKeyboard: Bool
|
||||||
|
|
||||||
func makeUIView(context: Context) -> FramebufferUIView {
|
func makeUIView(context: Context) -> FramebufferUIView {
|
||||||
let view = FramebufferUIView()
|
let view = FramebufferUIView()
|
||||||
@@ -18,6 +19,9 @@ struct FramebufferView: UIViewRepresentable {
|
|||||||
view.onTrackpadCursorChanged = { [binding = $trackpadCursor] new in
|
view.onTrackpadCursorChanged = { [binding = $trackpadCursor] new in
|
||||||
binding.wrappedValue = new
|
binding.wrappedValue = new
|
||||||
}
|
}
|
||||||
|
view.onKeyboardDismissed = { [binding = $showSoftwareKeyboard] in
|
||||||
|
if binding.wrappedValue { binding.wrappedValue = false }
|
||||||
|
}
|
||||||
return view
|
return view
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -30,6 +34,7 @@ struct FramebufferView: UIViewRepresentable {
|
|||||||
}
|
}
|
||||||
uiView.apply(image: controller.currentImage,
|
uiView.apply(image: controller.currentImage,
|
||||||
framebufferSize: framebufferSize)
|
framebufferSize: framebufferSize)
|
||||||
|
uiView.setSoftwareKeyboardVisible(showSoftwareKeyboard)
|
||||||
// Touch the revision so SwiftUI re-runs us when frames arrive
|
// Touch the revision so SwiftUI re-runs us when frames arrive
|
||||||
_ = controller.imageRevision
|
_ = controller.imageRevision
|
||||||
}
|
}
|
||||||
@@ -51,6 +56,7 @@ struct FramebufferView: View {
|
|||||||
let inputMode: InputMode
|
let inputMode: InputMode
|
||||||
let selectedScreen: RemoteScreen?
|
let selectedScreen: RemoteScreen?
|
||||||
@Binding var trackpadCursor: CGPoint
|
@Binding var trackpadCursor: CGPoint
|
||||||
|
@Binding var showSoftwareKeyboard: Bool
|
||||||
|
|
||||||
var body: some View { Color.black }
|
var body: some View { Color.black }
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -16,8 +16,7 @@ public struct SessionView: View {
|
|||||||
|
|
||||||
@State private var controller: SessionController?
|
@State private var controller: SessionController?
|
||||||
@State private var inputMode: InputMode = .touch
|
@State private var inputMode: InputMode = .touch
|
||||||
@State private var showKeyboardBar = false
|
@State private var showSoftwareKeyboard = false
|
||||||
@State private var showFunctionRow = false
|
|
||||||
@State private var trackpadCursor: CGPoint = CGPoint(x: 0.5, y: 0.5)
|
@State private var trackpadCursor: CGPoint = CGPoint(x: 0.5, y: 0.5)
|
||||||
@State private var selectedScreenID: UInt32?
|
@State private var selectedScreenID: UInt32?
|
||||||
@State private var screenshotItem: ScreenshotShareItem?
|
@State private var screenshotItem: ScreenshotShareItem?
|
||||||
@@ -36,7 +35,8 @@ public struct SessionView: View {
|
|||||||
controller: controller,
|
controller: controller,
|
||||||
inputMode: inputMode,
|
inputMode: inputMode,
|
||||||
selectedScreen: selectedScreen(for: controller),
|
selectedScreen: selectedScreen(for: controller),
|
||||||
trackpadCursor: $trackpadCursor
|
trackpadCursor: $trackpadCursor,
|
||||||
|
showSoftwareKeyboard: $showSoftwareKeyboard
|
||||||
)
|
)
|
||||||
.ignoresSafeArea()
|
.ignoresSafeArea()
|
||||||
.onTapGesture(count: 3) {
|
.onTapGesture(count: 3) {
|
||||||
@@ -113,19 +113,13 @@ public struct SessionView: View {
|
|||||||
SessionToolbar(
|
SessionToolbar(
|
||||||
controller: controller,
|
controller: controller,
|
||||||
inputMode: $inputMode,
|
inputMode: $inputMode,
|
||||||
showKeyboardBar: $showKeyboardBar,
|
showKeyboardBar: $showSoftwareKeyboard,
|
||||||
selectedScreenID: $selectedScreenID,
|
selectedScreenID: $selectedScreenID,
|
||||||
onScreenshot: { takeScreenshot(controller: controller) },
|
onScreenshot: { takeScreenshot(controller: controller) },
|
||||||
onDisconnect: { stopAndDismiss() }
|
onDisconnect: { stopAndDismiss() }
|
||||||
)
|
)
|
||||||
|
|
||||||
Spacer()
|
Spacer()
|
||||||
|
|
||||||
if showKeyboardBar {
|
|
||||||
SoftKeyboardBar(controller: controller, isExpanded: $showFunctionRow)
|
|
||||||
.padding(.bottom, 12)
|
|
||||||
.transition(.move(edge: .bottom).combined(with: .opacity))
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user