feat(client): 3-finger swipe toggles the on-screen keyboard mid-stream

iOS + Android: a three-finger vertical swipe up/down summons/dismisses the
device soft keyboard while streaming (trackpad + pointer modes). Mobile scroll
is now exactly two fingers so it never collides with the 3+-finger gesture
(3+ only fell into the old `>= 2` scroll path by accident).

Android: a TYPE_NULL KeyCaptureView plus IME meta-shift wrapping feeds key
events through. iOS: UIKeyInput plus a SoftKeyMap char->VK table with a
GCKeyboard dup gate so a hardware keyboard and the soft keyboard don't
double-emit.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
2026-07-14 14:21:20 +02:00
parent 60d4653083
commit 6db91cbf40
7 changed files with 283 additions and 10 deletions
@@ -698,6 +698,7 @@ final class StreamLayerUIView: UIView {
let mouse = TouchMouse()
mouse.send = { [weak self] event in self?.onTouchEvent?(event) }
mouse.hostPoint = { [weak self] point in self?.hostPoint(from: point) }
mouse.onKeyboardGesture = { [weak self] show in self?.setSoftKeyboardVisible(show) }
return mouse
}()
/// The finger route latched at gesture start a Settings change mid-gesture applies to
@@ -708,6 +709,22 @@ final class StreamLayerUIView: UIView {
func resetTouchInput() {
touchMouse.reset()
fingerRoute = nil
setSoftKeyboardVisible(false) // a stream that's gone takes its keyboard with it
}
/// The soft keyboard is keyed off first-responder status: the three-finger swipe
/// (TouchMouse) summons/dismisses it here, and the UIKeyInput conformance below turns
/// what it types into wire key events. Also the reason `canBecomeFirstResponder` is true
/// on iOS (tvOS anchors the responder chain on the CONTROLLER instead see
/// StreamViewController.viewDidAppear).
override var canBecomeFirstResponder: Bool { true }
func setSoftKeyboardVisible(_ visible: Bool) {
if visible {
becomeFirstResponder()
} else if isFirstResponder {
resignFirstResponder()
}
}
#endif
@@ -879,4 +896,46 @@ final class StreamLayerUIView: UIView {
}
#endif
}
#if os(iOS)
// The soft keyboard's output wire key events. UIKeyInput is deliberately minimal (no
// UITextInput): the stream needs keystrokes, not an editing buffer insertions map through
// `SoftKeyMap` to US-positional VKs (with a VK_LSHIFT wrap for shifted characters) and
// characters outside the map (emoji, non-Latin scripts) are dropped, matching the wire's VK
// contract. Events ride the same `onTouchEvent` path as the touch-driven mouse, so they're
// gated on captureEnabled with everything else and can't leak past a trust prompt.
extension StreamLayerUIView: UIKeyInput {
// Keep the IME literal no autocorrect/smart substitutions; a remote desktop is not prose,
// and the host does its own text handling.
var autocorrectionType: UITextAutocorrectionType { get { .no } set {} }
var autocapitalizationType: UITextAutocapitalizationType { get { .none } set {} }
var spellCheckingType: UITextSpellCheckingType { get { .no } set {} }
var smartQuotesType: UITextSmartQuotesType { get { .no } set {} }
var smartDashesType: UITextSmartDashesType { get { .no } set {} }
var smartInsertDeleteType: UITextSmartInsertDeleteType { get { .no } set {} }
var keyboardType: UIKeyboardType { get { .asciiCapable } set {} }
var hasText: Bool { false }
func insertText(_ text: String) {
// A hardware keyboard's presses reach the host through GCKeyboard AND arrive here as
// UIKeyInput insertions while we're first responder forwarding both would double
// every character, so the HID path owns keys whenever a hardware keyboard is attached.
guard GCKeyboard.coalesced == nil else { return }
for ch in text {
guard let key = SoftKeyMap.vk(for: ch) else { continue }
if key.shift { onTouchEvent?(.key(0xA0, down: true)) } // VK_LSHIFT
onTouchEvent?(.key(key.vk, down: true))
onTouchEvent?(.key(key.vk, down: false))
if key.shift { onTouchEvent?(.key(0xA0, down: false)) }
}
}
func deleteBackward() {
guard GCKeyboard.coalesced == nil else { return } // see insertText
onTouchEvent?(.key(0x08, down: true)) // VK_BACK
onTouchEvent?(.key(0x08, down: false))
}
}
#endif
#endif