From c066574bed2e2ef96a6e87c9e3c3091d09a151a9 Mon Sep 17 00:00:00 2001 From: fufesou Date: Sun, 27 Sep 2026 21:36:00 +0800 Subject: [PATCH] fix(mobile): correct Chat keyboard and input helper behavior - Keep soft keyboard and helper hidden when Chat opens via shortcut - Restore soft input on touch and remote focus after Chat closes - Handle modifier releases while Chat owns focus - Clear hidden virtual modifiers while preserving held keys and Pin behavior Signed-off-by: fufesou --- flutter/lib/mobile/pages/remote_page.dart | 85 ++++++++++++++++++----- flutter/lib/models/chat_model.dart | 70 +++++++++++++++++-- 2 files changed, 133 insertions(+), 22 deletions(-) diff --git a/flutter/lib/mobile/pages/remote_page.dart b/flutter/lib/mobile/pages/remote_page.dart index d345a6da1..fdf9fe04f 100644 --- a/flutter/lib/mobile/pages/remote_page.dart +++ b/flutter/lib/mobile/pages/remote_page.dart @@ -41,6 +41,26 @@ void _disableAndroidSoftKeyboard({bool? isKeyboardVisible}) { } } +bool handleMobileChatKeyEvent(KeyEvent event, + {required bool chatFocused, required InputModel inputModel}) { + if (chatFocused && + event is KeyUpEvent && + const [ + PhysicalKeyboardKey.controlLeft, + PhysicalKeyboardKey.controlRight, + PhysicalKeyboardKey.altLeft, + PhysicalKeyboardKey.altRight, + PhysicalKeyboardKey.shiftLeft, + PhysicalKeyboardKey.shiftRight, + PhysicalKeyboardKey.metaLeft, + PhysicalKeyboardKey.metaRight, + ].contains(event.physicalKey)) { + // Chat owns focus, but the remote input path still owns these modifiers. + inputModel.handleKeyEvent(event); + } + return false; +} + class RemotePage extends StatefulWidget { RemotePage( {Key? key, @@ -108,6 +128,7 @@ class _RemotePageState extends State with WidgetsBindingObserver { }); WakelockManager.enable(_uniqueKey); _physicalFocusNode.requestFocus(); + HardwareKeyboard.instance.addHandler(_handleChatKeyEvent); gFFI.inputModel.listenToMouse(true); gFFI.qualityMonitorModel.checkShowQualityMonitor(sessionId); keyboardSubscription = @@ -133,6 +154,7 @@ class _RemotePageState extends State with WidgetsBindingObserver { // Mobile has no DesktopTabController, so tab-switch shortcuts will // log a no-handler debug line if a user binds one. registerSessionShortcutActions(gFFI); + _registerMobileChatShortcut(); registerToolbarShortcuts(context, widget.id, gFFI); } }); @@ -155,6 +177,7 @@ class _RemotePageState extends State with WidgetsBindingObserver { @override Future dispose() async { + HardwareKeyboard.instance.removeHandler(_handleChatKeyEvent); WidgetsBinding.instance.removeObserver(this); // Close the session up-front. `gFFI.close()` below only calls `sessionClose` // after several awaits (canvas save, image update, the `enable_soft_keyboard` @@ -266,10 +289,10 @@ class _RemotePageState extends State with WidgetsBindingObserver { if (isIOS) { _iosKeyboardWorkaroundTimer?.cancel(); _iosKeyboardWorkaroundTimer = Timer(Duration(milliseconds: 100), () { - if (!mounted) return; + if (!mounted || gFFI.chatModel.inputNode.hasFocus) return; _physicalFocusNode.unfocus(); _iosKeyboardWorkaroundTimer = Timer(Duration(milliseconds: 50), () { - if (!mounted) return; + if (!mounted || gFFI.chatModel.inputNode.hasFocus) return; _physicalFocusNode.requestFocus(); }); }); @@ -547,6 +570,9 @@ class _RemotePageState extends State with WidgetsBindingObserver { ); } + bool _handleChatKeyEvent(KeyEvent event) => handleMobileChatKeyEvent(event, + chatFocused: gFFI.chatModel.inputNode.hasFocus, inputModel: inputModel); + Widget getRawPointerAndKeyBody(Widget child) { final ffiModel = Provider.of(context); return RawPointerMouseRegion( @@ -668,7 +694,6 @@ class _RemotePageState extends State with WidgetsBindingObserver { !gFFI.inputModel.relativeMouseMode.value; Widget getBodyForMobile() { - final keyboardIsVisible = keyboardVisibilityController.isVisible; return Container( color: MyTheme.canvasColor, child: Stack(children: () { @@ -679,9 +704,14 @@ class _RemotePageState extends State with WidgetsBindingObserver { right: 10, child: QualityMonitor(gFFI.qualityMonitorModel), ), - KeyHelpTools( - keyboardIsVisible: keyboardIsVisible, - showGestureHelp: _showGestureHelp), + Obx(() { + final chatKeyboardRequested = + gFFI.chatModel.softKeyboardRequested.value; + return KeyHelpTools( + keyboardIsVisible: keyboardVisibilityController.isVisible && + (_showEdit || chatKeyboardRequested), + showGestureHelp: _showGestureHelp); + }), SizedBox( width: 0, height: 0, @@ -818,9 +848,18 @@ class _RemotePageState extends State with WidgetsBindingObserver { }(); } - onPressedTextChat(String id) { + void _registerMobileChatShortcut() { + if (!isMobile) return; + gFFI.shortcutModel.register(kShortcutActionToggleChat, + () => onPressedTextChat(widget.id, requestSoftKeyboard: false)); + } + + onPressedTextChat(String id, {bool requestSoftKeyboard = true}) { + if (gFFI.chatModel.chatWindowOverlayEntry == null) { + setState(() => _showEdit = false); + } gFFI.chatModel.changeCurrentKey(MessageKey(id, ChatModel.clientModeID)); - gFFI.chatModel.toggleChatOverlay(); + gFFI.chatModel.toggleChatOverlay(requestSoftKeyboard: requestSoftKeyboard); } showChatOptions(String id) async { @@ -951,6 +990,22 @@ class _KeyHelpToolsState extends State { InputModel get inputModel => gFFI.inputModel; + @override + void didUpdateWidget(KeyHelpTools oldWidget) { + super.didUpdateWidget(oldWidget); + if (!_pin && oldWidget.requestShow && !widget.requestShow) { + _releaseVirtualModifiers(); + } + } + + void _releaseVirtualModifiers() { + final keyboard = HardwareKeyboard.instance; + inputModel.ctrl = inputModel.ctrl && keyboard.isControlPressed; + inputModel.alt = inputModel.alt && keyboard.isAltPressed; + inputModel.shift = inputModel.shift && keyboard.isShiftPressed; + inputModel.command = inputModel.command && keyboard.isMetaPressed; + } + Widget wrap(String text, void Function() onPressed, {bool? active, IconData? icon}) { return TextButton( @@ -988,12 +1043,7 @@ class _KeyHelpToolsState extends State { @override Widget build(BuildContext context) { - final hasModifierOn = inputModel.ctrl || - inputModel.alt || - inputModel.shift || - inputModel.command; - - if (!_pin && !hasModifierOn && !widget.requestShow) { + if (!_pin && !widget.requestShow) { gFFI.cursorModel .keyHelpToolsVisibilityChanged(null, widget.keyboardIsVisible); return Offstage(); @@ -1032,9 +1082,10 @@ class _KeyHelpToolsState extends State { active: _fn), wrap( '', - () => setState( - () => _pin = !_pin, - ), + () => setState(() { + _pin = !_pin; + if (!_pin && !widget.requestShow) _releaseVirtualModifiers(); + }), active: _pin, icon: Icons.push_pin), wrap( diff --git a/flutter/lib/models/chat_model.dart b/flutter/lib/models/chat_model.dart index d79c9f070..ab70dd330 100644 --- a/flutter/lib/models/chat_model.dart +++ b/flutter/lib/models/chat_model.dart @@ -64,8 +64,11 @@ class ChatModel with ChangeNotifier { RxBool isWindowFocus = true.obs; BlockableOverlayState _blockableOverlayState = BlockableOverlayState(); final Rx _voiceCallStatus = Rx(VoiceCallStatus.notStarted); + final RxBool _softKeyboardRequested = false.obs; + bool _usingPhysicalChatInput = false; Rx get voiceCallStatus => _voiceCallStatus; + RxBool get softKeyboardRequested => _softKeyboardRequested; TextEditingController textController = TextEditingController(); RxInt mobileUnreadSum = 0.obs; @@ -80,6 +83,8 @@ class ChatModel with ChangeNotifier { @override void dispose() { + inputNode.removeListener(_updateChatInputControl); + _restoreChatInputControl(); textController.dispose(); super.dispose(); } @@ -194,13 +199,18 @@ class ChatModel with ChangeNotifier { } } - showChatWindowOverlay({Offset? chatInitPos}) { + showChatWindowOverlay( + {Offset? chatInitPos, bool requestSoftKeyboard = true}) { if (chatWindowOverlayEntry != null) return; isWindowFocus.value = true; _blockableOverlayState.setMiddleBlocked(true); final overlayState = _blockableOverlayState.state; if (overlayState == null) return; + if (isMobile) { + _softKeyboardRequested.value = requestSoftKeyboard; + gFFI.invokeMethod("enable_soft_keyboard", true); + } if (isMobile && !gFFI.chatModel.currentKey.isOut && // not in remote page gFFI.chatModel.latestReceivedKey != null) { @@ -209,7 +219,8 @@ class ChatModel with ChangeNotifier { } final overlay = OverlayEntry(builder: (context) { return Listener( - onPointerDown: (_) { + onPointerDown: (event) { + _requestChatSoftKeyboard(event); if (!isWindowFocus.value) { isWindowFocus.value = true; _blockableOverlayState.setMiddleBlocked(true); @@ -223,6 +234,10 @@ class ChatModel with ChangeNotifier { }); overlayState.insert(overlay); chatWindowOverlayEntry = overlay; + if (isMobile && !requestSoftKeyboard) { + inputNode.addListener(_updateChatInputControl); + _updateChatInputControl(); + } requestChatInputFocus(); } @@ -231,6 +246,9 @@ class ChatModel with ChangeNotifier { _blockableOverlayState.setMiddleBlocked(false); chatWindowOverlayEntry!.remove(); chatWindowOverlayEntry = null; + _softKeyboardRequested.value = false; + inputNode.removeListener(_updateChatInputControl); + _restoreChatInputControl(); return; } } @@ -239,13 +257,14 @@ class ChatModel with ChangeNotifier { ((!(isDesktop || isWebDesktop) && chatIconOverlayEntry == null) || chatWindowOverlayEntry == null); - toggleChatOverlay({Offset? chatInitPos}) { + toggleChatOverlay({Offset? chatInitPos, bool requestSoftKeyboard = true}) { if (_isChatOverlayHide()) { - gFFI.invokeMethod("enable_soft_keyboard", true); + if (!isMobile) gFFI.invokeMethod("enable_soft_keyboard", true); if (!(isDesktop || isWebDesktop)) { showChatIconOverlay(); } - showChatWindowOverlay(chatInitPos: chatInitPos); + showChatWindowOverlay( + chatInitPos: chatInitPos, requestSoftKeyboard: requestSoftKeyboard); } else { hideChatIconOverlay(); hideChatWindowOverlay(); @@ -522,6 +541,47 @@ class ChatModel with ChangeNotifier { }); } + void _requestChatSoftKeyboard(PointerDownEvent event) { + if (!isMobile || _softKeyboardRequested.value) return; + inputNode.context?.visitAncestorElements((element) { + if (element.widget is! TextField) return true; + final box = element.findRenderObject(); + if (box is! RenderBox) return false; + final bounds = box.localToGlobal(Offset.zero) & box.size; + if (!bounds.contains(event.position)) return false; + _softKeyboardRequested.value = true; + _restoreChatInputControl(); + // Reconnect so the platform receives the keyboard type instead of none. + inputNode.unfocus(disposition: UnfocusDisposition.previouslyFocusedChild); + scheduleMicrotask(() { + if (chatWindowOverlayEntry != null && _softKeyboardRequested.value) { + inputNode.requestFocus(); + } + }); + return false; + }); + } + + void _updateChatInputControl() { + final physicalChatFocused = chatWindowOverlayEntry != null && + inputNode.hasFocus && + !_softKeyboardRequested.value; + if (!physicalChatFocused) { + _restoreChatInputControl(); + return; + } + if (_usingPhysicalChatInput) return; + _usingPhysicalChatInput = true; + // Keep the platform text-input connection for physical editing, without IME UI. + TextInput.setInputControl(null); + } + + void _restoreChatInputControl() { + if (!_usingPhysicalChatInput) return; + _usingPhysicalChatInput = false; + TextInput.restorePlatformInputControl(); + } + void onVoiceCallWaiting() { _voiceCallStatus.value = VoiceCallStatus.waitingForResponse; }