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 <linlong1266@gmail.com>
This commit is contained in:
fufesou
2026-09-27 21:36:00 +08:00
parent 01348c762c
commit c066574bed
2 changed files with 133 additions and 22 deletions
+68 -17
View File
@@ -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<RemotePage> 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<RemotePage> 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<RemotePage> with WidgetsBindingObserver {
@override
Future<void> 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<RemotePage> 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<RemotePage> with WidgetsBindingObserver {
);
}
bool _handleChatKeyEvent(KeyEvent event) => handleMobileChatKeyEvent(event,
chatFocused: gFFI.chatModel.inputNode.hasFocus, inputModel: inputModel);
Widget getRawPointerAndKeyBody(Widget child) {
final ffiModel = Provider.of<FfiModel>(context);
return RawPointerMouseRegion(
@@ -668,7 +694,6 @@ class _RemotePageState extends State<RemotePage> 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<RemotePage> 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<RemotePage> 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<KeyHelpTools> {
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<KeyHelpTools> {
@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<KeyHelpTools> {
active: _fn),
wrap(
'',
() => setState(
() => _pin = !_pin,
),
() => setState(() {
_pin = !_pin;
if (!_pin && !widget.requestShow) _releaseVirtualModifiers();
}),
active: _pin,
icon: Icons.push_pin),
wrap(
+65 -5
View File
@@ -64,8 +64,11 @@ class ChatModel with ChangeNotifier {
RxBool isWindowFocus = true.obs;
BlockableOverlayState _blockableOverlayState = BlockableOverlayState();
final Rx<VoiceCallStatus> _voiceCallStatus = Rx(VoiceCallStatus.notStarted);
final RxBool _softKeyboardRequested = false.obs;
bool _usingPhysicalChatInput = false;
Rx<VoiceCallStatus> 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;
}