diff --git a/flutter/lib/consts.dart b/flutter/lib/consts.dart index 51c08cf33..eab33a60f 100644 --- a/flutter/lib/consts.dart +++ b/flutter/lib/consts.dart @@ -164,6 +164,7 @@ const String kOptionVirtualMouseScale = "virtual-mouse-scale"; const String kOptionShowVirtualJoystick = "show-virtual-joystick"; const String kOptionAllowAskForNoteAtEndOfConnection = "allow-ask-for-note"; const String kOptionEnableShowTerminalExtraKeys = "enable-show-terminal-extra-keys"; +const String kOptionAllowLegacyKeyMapping = "allow-legacy-key-mapping"; // network options const String kOptionAllowWebSocket = "allow-websocket"; diff --git a/flutter/lib/mobile/pages/remote_page.dart b/flutter/lib/mobile/pages/remote_page.dart index 9102d163c..9edcfb165 100644 --- a/flutter/lib/mobile/pages/remote_page.dart +++ b/flutter/lib/mobile/pages/remote_page.dart @@ -1,4 +1,5 @@ import 'dart:async'; +import 'dart:ui' as ui; import 'package:flutter/material.dart'; import 'package:flutter/services.dart'; @@ -43,10 +44,10 @@ void _disableAndroidSoftKeyboard({bool? isKeyboardVisible}) { class RemotePage extends StatefulWidget { RemotePage( {Key? key, - required this.id, - this.password, - this.isSharedPassword, - this.forceRelay}) + required this.id, + this.password, + this.isSharedPassword, + this.forceRelay}) : super(key: key); final String id; @@ -64,7 +65,9 @@ class _RemotePageState extends State with WidgetsBindingObserver { bool _showGestureHelp = false; String _value = ''; Orientation? _currentOrientation; + double _viewInsetsBottom = 0; final _uniqueKey = UniqueKey(); + Timer? _timerDidChangeMetrics; Timer? _iosKeyboardWorkaroundTimer; final _blockableOverlayState = BlockableOverlayState(); @@ -79,7 +82,7 @@ class _RemotePageState extends State with WidgetsBindingObserver { SessionID get sessionId => gFFI.sessionId; final TextEditingController _textController = - TextEditingController(text: initText); + TextEditingController(text: initText); _RemotePageState(String id) { initSharedStates(id); @@ -137,6 +140,7 @@ class _RemotePageState extends State with WidgetsBindingObserver { _physicalFocusNode.dispose(); await gFFI.close(); _timer?.cancel(); + _timerDidChangeMetrics?.cancel(); _iosKeyboardWorkaroundTimer?.cancel(); gFFI.dialogManager.dismissAll(); await SystemChrome.setEnabledSystemUIMode(SystemUiMode.manual, @@ -163,20 +167,46 @@ class _RemotePageState extends State with WidgetsBindingObserver { gFFI.invokeMethod("try_sync_clipboard"); } + @override + void didChangeMetrics() { + // If the soft keyboard is visible and the canvas has been changed(panned or scaled) + // Don't try reset the view style and focus the cursor. + if (gFFI.cursorModel.lastKeyboardIsVisible && + gFFI.canvasModel.isMobileCanvasChanged) { + return; + } + + // safer fix: final newBottom = MediaQueryData.fromView(View.of(context)).viewInsets.bottom; + // Given this repo is pinned to Flutter 3.24.5, ui.window may still be tolerated, but it is indeed deprecated in newer flutter versions. + // Best fix in widget code is usually to stop using ui.window directly and use the active view from the framework side. + // These kind of fixes should be made when more general flutter version update will happen but to not introduce + // any regression current code will be kept intact + final newBottom = MediaQueryData.fromView(ui.window).viewInsets.bottom; + _timerDidChangeMetrics?.cancel(); + _timerDidChangeMetrics = Timer(Duration(milliseconds: 100), () async { + // We need this comparation because poping up the floating action will also trigger `didChangeMetrics()`. + if (newBottom != _viewInsetsBottom) { + gFFI.canvasModel.mobileFocusCanvasCursor(); + _viewInsetsBottom = newBottom; + } + }); + } + // to-do: It should be better to use transparent color instead of the bgColor. // But for now, the transparent color will cause the canvas to be white. // I'm sure that the white color is caused by the Overlay widget in BlockableOverlay. // But I don't know why and how to fix it. Widget emptyOverlay(Color bgColor) => BlockableOverlay( - /// the Overlay key will be set with _blockableOverlayState in BlockableOverlay - /// see override build() in [BlockableOverlay] - state: _blockableOverlayState, - underlying: Container( - color: bgColor, - ), - ); + /// the Overlay key will be set with _blockableOverlayState in BlockableOverlay + /// see override build() in [BlockableOverlay] + state: _blockableOverlayState, + underlying: Container( + color: bgColor, + ), + ); void onSoftKeyboardChanged(bool visible) { + inputModel.androidSoftKeyboardActive = visible; if (!visible) { SystemChrome.setEnabledSystemUIMode(SystemUiMode.manual, overlays: []); // [pi.version.isNotEmpty] -> check ready or not, avoid login without soft-keyboard @@ -227,10 +257,10 @@ class _RemotePageState extends State with WidgetsBindingObserver { // get common prefix of subNewValue and subOldValue var common = 0; for (; - common < subOldValue.length && - common < subNewValue.length && - subNewValue[common] == subOldValue[common]; - ++common) {} + common < subOldValue.length && + common < subNewValue.length && + subNewValue[common] == subOldValue[common]; + ++common) {} // get newStr from subNewValue var newStr = ""; @@ -276,10 +306,20 @@ class _RemotePageState extends State with WidgetsBindingObserver { if (newValue.length == oldValue.length) { // ? } else if (newValue.length < oldValue.length) { - final char = 'VK_BACK'; - inputModel.inputKey(char); + // Send exactly one VK_BACK per onChanged callback regardless of how many + // characters the IME removed (Samsung accelerates held-delete). The + // IME's own callback frequency provides a steady, controllable repeat + // rate instead of runaway exponential deletion. + inputModel.inputKey('VK_BACK'); } else { final content = newValue.substring(oldValue.length); + // Android IMEs like Gboard can leave modifier state (especially Shift) + // logically active even though the inserted text is already composed. + // Clear modifiers before forwarding soft-keyboard text so host-side + // legacy character input does not apply Shift twice. + inputModel.releaseTransientModifiersToHost(); + final composedContent = + inputModel.shiftLocked ? content.toUpperCase() : content; if (content.length > 1) { if (oldValue != '' && content.length == 2 && @@ -293,11 +333,11 @@ class _RemotePageState extends State with WidgetsBindingObserver { content == '()' || content == '【】')) { // can not only input content[0], because when input ], [ are also auo insert, which cause ] never be input - bind.sessionInputString(sessionId: sessionId, value: content); + bind.sessionInputString(sessionId: sessionId, value: composedContent); openKeyboard(); return; } - bind.sessionInputString(sessionId: sessionId, value: content); + bind.sessionInputString(sessionId: sessionId, value: composedContent); } else { inputChar(content); } @@ -314,12 +354,40 @@ class _RemotePageState extends State with WidgetsBindingObserver { } void inputChar(String char) { + inputModel.releaseTransientModifiersToHost(); + final useLegacyKeyMapping = + mainGetLocalBoolOptionSync(kOptionAllowLegacyKeyMapping); + final hasShortcutModifier = inputModel.ctrlLocked || + inputModel.altLocked || + inputModel.commandLocked; + final shouldUseKeyPath = useLegacyKeyMapping || hasShortcutModifier; if (char == '\n') { - char = 'VK_RETURN'; + if (shouldUseKeyPath) { + char = 'VK_RETURN'; + } else { + bind.sessionInputString(sessionId: inputModel.sessionId, value: '\n'); + return; + } } else if (char == ' ') { - char = 'VK_SPACE'; + if (shouldUseKeyPath) { + char = 'VK_SPACE'; + } else { + bind.sessionInputString(sessionId: inputModel.sessionId, value: ' '); + return; + } } - inputModel.inputKey(char); + if (!shouldUseKeyPath) { + final value = inputModel.shiftLocked ? char.toUpperCase() : char; + bind.sessionInputString(sessionId: inputModel.sessionId, value: value); + return; + } + // Android soft-keyboard text is already composed; forwarding leaked modifier + // state from IMEs like Gboard can make subsequent characters stay shifted. + inputModel.inputKey(char, + altOverride: inputModel.altLocked, + ctrlOverride: inputModel.ctrlLocked, + shiftOverride: inputModel.shiftLocked, + commandOverride: inputModel.commandLocked); } void openKeyboard() { @@ -345,8 +413,8 @@ class _RemotePageState extends State with WidgetsBindingObserver { Widget _bottomWidget() => _showGestureHelp ? getGestureHelp() : (_showBar && gFFI.ffiModel.pi.displays.isNotEmpty - ? getBottomAppBar() - : Offstage()); + ? getBottomAppBar() + : Offstage()); @override Widget build(BuildContext context) { @@ -360,53 +428,53 @@ class _RemotePageState extends State with WidgetsBindingObserver { return false; }, child: Scaffold( - // workaround for https://github.com/rustdesk/rustdesk/issues/3131 + // workaround for https://github.com/rustdesk/rustdesk/issues/3131 floatingActionButtonLocation: keyboardIsVisible ? FABLocation(FloatingActionButtonLocation.endFloat, 0, -35) : null, floatingActionButton: !showActionButton ? null : FloatingActionButton( - mini: !keyboardIsVisible, - child: Icon( - (keyboardIsVisible || _showGestureHelp) - ? Icons.expand_more - : Icons.expand_less, - color: Colors.white, - ), - backgroundColor: MyTheme.accent, - onPressed: () { - setState(() { - if (keyboardIsVisible) { - _showEdit = false; - gFFI.invokeMethod("enable_soft_keyboard", false); - _mobileFocusNode.unfocus(); - _physicalFocusNode.requestFocus(); - } else if (_showGestureHelp) { - _showGestureHelp = false; - } else { - _showBar = !_showBar; - } - }); - }), + mini: !keyboardIsVisible, + child: Icon( + (keyboardIsVisible || _showGestureHelp) + ? Icons.expand_more + : Icons.expand_less, + color: Colors.white, + ), + backgroundColor: MyTheme.accent, + onPressed: () { + setState(() { + if (keyboardIsVisible) { + _showEdit = false; + gFFI.invokeMethod("enable_soft_keyboard", false); + _mobileFocusNode.unfocus(); + _physicalFocusNode.requestFocus(); + } else if (_showGestureHelp) { + _showGestureHelp = false; + } else { + _showBar = !_showBar; + } + }); + }), bottomNavigationBar: Obx(() => Stack( - alignment: Alignment.bottomCenter, - children: [ - gFFI.ffiModel.pi.isSet.isTrue && - gFFI.ffiModel.waitForFirstImage.isTrue - ? emptyOverlay(MyTheme.canvasColor) - : () { - gFFI.ffiModel.tryShowAndroidActionsOverlay(); - return Offstage(); - }(), - _bottomWidget(), - gFFI.ffiModel.pi.isSet.isFalse - ? emptyOverlay(MyTheme.canvasColor) - : Offstage(), - ], - )), + alignment: Alignment.bottomCenter, + children: [ + gFFI.ffiModel.pi.isSet.isTrue && + gFFI.ffiModel.waitForFirstImage.isTrue + ? emptyOverlay(MyTheme.canvasColor) + : () { + gFFI.ffiModel.tryShowAndroidActionsOverlay(); + return Offstage(); + }(), + _bottomWidget(), + gFFI.ffiModel.pi.isSet.isFalse + ? emptyOverlay(MyTheme.canvasColor) + : Offstage(), + ], + )), body: Obx( - () => getRawPointerAndKeyBody(Overlay( + () => getRawPointerAndKeyBody(Overlay( initialEntries: [ OverlayEntry(builder: (context) { return Container( @@ -414,27 +482,27 @@ class _RemotePageState extends State with WidgetsBindingObserver { child: isWebDesktop ? getBodyForDesktopWithListener() : SafeArea( - child: - OrientationBuilder(builder: (ctx, orientation) { - if (_currentOrientation != orientation) { - Timer(const Duration(milliseconds: 200), () { - gFFI.dialogManager - .resetMobileActionsOverlay(ffi: gFFI); - _currentOrientation = orientation; - gFFI.canvasModel.updateViewStyle(); - }); - } - return Container( - color: MyTheme.canvasColor, - child: inputModel.isPhysicalMouse.value - ? getBodyForMobile() - : RawTouchGestureDetectorRegion( - child: getBodyForMobile(), - ffi: gFFI, - ), - ); - }), + child: + OrientationBuilder(builder: (ctx, orientation) { + if (_currentOrientation != orientation) { + Timer(const Duration(milliseconds: 200), () { + gFFI.dialogManager + .resetMobileActionsOverlay(ffi: gFFI); + _currentOrientation = orientation; + gFFI.canvasModel.updateViewStyle(); + }); + } + return Container( + color: MyTheme.canvasColor, + child: inputModel.isPhysicalMouse.value + ? getBodyForMobile() + : RawTouchGestureDetectorRegion( + child: getBodyForMobile(), + ffi: gFFI, ), + ); + }), + ), ); }) ], @@ -452,9 +520,9 @@ class _RemotePageState extends State with WidgetsBindingObserver { // The "Delete" key on the soft keyboard may be grabbed when inputting the password dialog. child: gFFI.ffiModel.pi.isSet.isTrue ? RawKeyFocusScope( - focusNode: _physicalFocusNode, - inputModel: inputModel, - child: child) + focusNode: _physicalFocusNode, + inputModel: inputModel, + child: child) : child, ); } @@ -470,70 +538,70 @@ class _RemotePageState extends State with WidgetsBindingObserver { children: [ Row( children: [ - IconButton( - color: Colors.white, - icon: Icon(Icons.clear), - onPressed: () { - clientClose(sessionId, gFFI); - }, - ), - IconButton( - color: Colors.white, - icon: Icon(Icons.tv), - onPressed: () { - setState(() => _showEdit = false); - showOptions(context, widget.id, gFFI.dialogManager); - }, - ) - ] + + IconButton( + color: Colors.white, + icon: Icon(Icons.clear), + onPressed: () { + clientClose(sessionId, gFFI); + }, + ), + IconButton( + color: Colors.white, + icon: Icon(Icons.tv), + onPressed: () { + setState(() => _showEdit = false); + showOptions(context, widget.id, gFFI.dialogManager); + }, + ) + ] + (isWebDesktop || ffiModel.viewOnly || !ffiModel.keyboard ? [] : gFFI.ffiModel.isPeerAndroid - ? [ - IconButton( - color: Colors.white, - icon: Icon(Icons.keyboard), - onPressed: openKeyboard), - IconButton( - color: Colors.white, - icon: const Icon(Icons.build), - onPressed: () => gFFI.dialogManager - .toggleMobileActionsOverlay(ffi: gFFI), - ) - ] - : [ - IconButton( - color: Colors.white, - icon: Icon(Icons.keyboard), - onPressed: openKeyboard), - IconButton( - color: Colors.white, - icon: Icon(gFFI.ffiModel.touchMode - ? Icons.touch_app - : Icons.mouse), - onPressed: () => setState( - () => _showGestureHelp = !_showGestureHelp), - ), - ]) + + ? [ + IconButton( + color: Colors.white, + icon: Icon(Icons.keyboard), + onPressed: openKeyboard), + IconButton( + color: Colors.white, + icon: const Icon(Icons.build), + onPressed: () => gFFI.dialogManager + .toggleMobileActionsOverlay(ffi: gFFI), + ) + ] + : [ + IconButton( + color: Colors.white, + icon: Icon(Icons.keyboard), + onPressed: openKeyboard), + IconButton( + color: Colors.white, + icon: Icon(gFFI.ffiModel.touchMode + ? Icons.touch_app + : Icons.mouse), + onPressed: () => setState( + () => _showGestureHelp = !_showGestureHelp), + ), + ]) + (isWeb ? [] : [ - futureBuilder( - future: gFFI.invokeMethod( - "get_value", "KEY_IS_SUPPORT_VOICE_CALL"), - hasData: (isSupportVoiceCall) => IconButton( - color: Colors.white, - icon: isAndroid && isSupportVoiceCall - ? SvgPicture.asset('assets/chat.svg', - colorFilter: ColorFilter.mode( - Colors.white, BlendMode.srcIn)) - : Icon(Icons.message), - onPressed: () => - isAndroid && isSupportVoiceCall - ? showChatOptions(widget.id) - : onPressedTextChat(widget.id), - )) - ]) + + futureBuilder( + future: gFFI.invokeMethod( + "get_value", "KEY_IS_SUPPORT_VOICE_CALL"), + hasData: (isSupportVoiceCall) => IconButton( + color: Colors.white, + icon: isAndroid && isSupportVoiceCall + ? SvgPicture.asset('assets/chat.svg', + colorFilter: ColorFilter.mode( + Colors.white, BlendMode.srcIn)) + : Icon(Icons.message), + onPressed: () => + isAndroid && isSupportVoiceCall + ? showChatOptions(widget.id) + : onPressedTextChat(widget.id), + )) + ]) + [ IconButton( color: Colors.white, @@ -545,14 +613,14 @@ class _RemotePageState extends State with WidgetsBindingObserver { ), ]), Obx(() => IconButton( - color: Colors.white, - icon: Icon(Icons.expand_more), - onPressed: gFFI.ffiModel.waitForFirstImage.isTrue - ? null - : () { - setState(() => _showBar = !_showBar); - }, - )), + color: Colors.white, + icon: Icon(Icons.expand_more), + onPressed: gFFI.ffiModel.waitForFirstImage.isTrue + ? null + : () { + setState(() => _showBar = !_showBar); + }, + )), ], ), ); @@ -560,8 +628,8 @@ class _RemotePageState extends State with WidgetsBindingObserver { bool get showCursorPaint => !gFFI.ffiModel.isPeerAndroid && - !gFFI.canvasModel.cursorEmbedded && - !gFFI.inputModel.relativeMouseMode.value; + !gFFI.canvasModel.cursorEmbedded && + !gFFI.inputModel.relativeMouseMode.value; Widget getBodyForMobile() { final keyboardIsVisible = keyboardVisibilityController.isVisible; @@ -584,29 +652,29 @@ class _RemotePageState extends State with WidgetsBindingObserver { child: !_showEdit ? Container() : TextFormField( - textInputAction: TextInputAction.newline, - autocorrect: false, - // Flutter 3.16.9 Android. - // `enableSuggestions` causes secure keyboard to be shown. - // https://github.com/flutter/flutter/issues/139143 - // https://github.com/flutter/flutter/issues/146540 - // enableSuggestions: false, - autofocus: true, - focusNode: _mobileFocusNode, - maxLines: null, - controller: _textController, - // trick way to make backspace work always - keyboardType: TextInputType.multiline, - // `onChanged` may be called depending on the input method if this widget is wrapped in - // `Focus(onKeyEvent: ..., child: ...)` - // For `Backspace` button in the soft keyboard: - // en/fr input method: - // 1. The button will not trigger `onKeyEvent` if the text field is not empty. - // 2. The button will trigger `onKeyEvent` if the text field is empty. - // ko/zh/ja input method: the button will trigger `onKeyEvent` - // and the event will not popup if `KeyEventResult.handled` is returned. - onChanged: handleSoftKeyboardInput, - ).workaroundFreezeLinuxMint(), + textInputAction: TextInputAction.newline, + autocorrect: false, + // Flutter 3.16.9 Android. + // `enableSuggestions` causes secure keyboard to be shown. + // https://github.com/flutter/flutter/issues/139143 + // https://github.com/flutter/flutter/issues/146540 + // enableSuggestions: false, + autofocus: true, + focusNode: _mobileFocusNode, + maxLines: null, + controller: _textController, + // trick way to make backspace work always + keyboardType: TextInputType.multiline, + // `onChanged` may be called depending on the input method if this widget is wrapped in + // `Focus(onKeyEvent: ..., child: ...)` + // For `Backspace` button in the soft keyboard: + // en/fr input method: + // 1. The button will not trigger `onKeyEvent` if the text field is not empty. + // 2. The button will trigger `onKeyEvent` if the text field is empty. + // ko/zh/ja input method: the button will trigger `onKeyEvent` + // and the event will not popup if `KeyEventResult.handled` is returned. + onChanged: handleSoftKeyboardInput, + ).workaroundFreezeLinuxMint(), ), ]; if (showCursorPaint) { @@ -686,15 +754,15 @@ class _RemotePageState extends State with WidgetsBindingObserver { .asMap() .entries .map((e) => - PopupMenuItem(child: e.value.getChild(), value: e.key)) + PopupMenuItem(child: e.value.getChild(), value: e.key)) .toList(), if (mobileActionMenus.isNotEmpty) PopupMenuDivider(), ...menus .asMap() .entries .map((e) => PopupMenuItem( - child: e.value.getChild(), - value: e.key + mobileActionMenus.length)) + child: e.value.getChild(), + value: e.key + mobileActionMenus.length)) .toList(), ]; () async { @@ -724,7 +792,7 @@ class _RemotePageState extends State with WidgetsBindingObserver { onPressEndVoiceCall() => bind.sessionCloseVoiceCall(sessionId: sessionId); makeTextMenu(String label, Widget icon, VoidCallback onPressed, - {TextStyle? labelStyle}) => + {TextStyle? labelStyle}) => TTextMenu( child: Text(translate(label), style: labelStyle), trailingIcon: Transform.scale( @@ -745,24 +813,24 @@ class _RemotePageState extends State with WidgetsBindingObserver { ].contains(gFFI.chatModel.voiceCallStatus.value); final menus = [ makeTextMenu('Text chat', Icon(Icons.message, color: MyTheme.accent), - () => onPressedTextChat(widget.id)), + () => onPressedTextChat(widget.id)), isInVoice ? makeTextMenu( - 'End voice call', - SvgPicture.asset( - 'assets/call_wait.svg', - colorFilter: - ColorFilter.mode(Colors.redAccent, BlendMode.srcIn), - ), - onPressEndVoiceCall, - labelStyle: TextStyle(color: Colors.redAccent)) + 'End voice call', + SvgPicture.asset( + 'assets/call_wait.svg', + colorFilter: + ColorFilter.mode(Colors.redAccent, BlendMode.srcIn), + ), + onPressEndVoiceCall, + labelStyle: TextStyle(color: Colors.redAccent)) : makeTextMenu( - 'Voice call', - SvgPicture.asset( - 'assets/call_wait.svg', - colorFilter: ColorFilter.mode(MyTheme.accent, BlendMode.srcIn), - ), - onPressVoiceCall), + 'Voice call', + SvgPicture.asset( + 'assets/call_wait.svg', + colorFilter: ColorFilter.mode(MyTheme.accent, BlendMode.srcIn), + ), + onPressVoiceCall), ]; final menuItems = menus @@ -804,24 +872,24 @@ class _RemotePageState extends State with WidgetsBindingObserver { ))); } - // * Currently mobile does not enable map mode - // void changePhysicalKeyboardInputMode() async { - // var current = await bind.sessionGetKeyboardMode(id: widget.id) ?? "legacy"; - // gFFI.dialogManager.show((setState, close) { - // void setMode(String? v) async { - // await bind.sessionSetKeyboardMode(id: widget.id, value: v ?? ""); - // setState(() => current = v ?? ''); - // Future.delayed(Duration(milliseconds: 300), close); - // } - // - // return CustomAlertDialog( - // title: Text(translate('Physical Keyboard Input Mode')), - // content: Column(mainAxisSize: MainAxisSize.min, children: [ - // getRadio('Legacy mode', 'legacy', current, setMode), - // getRadio('Map mode', 'map', current, setMode), - // ])); - // }, clickMaskDismiss: true); - // } +// * Currently mobile does not enable map mode +// void changePhysicalKeyboardInputMode() async { +// var current = await bind.sessionGetKeyboardMode(id: widget.id) ?? "legacy"; +// gFFI.dialogManager.show((setState, close) { +// void setMode(String? v) async { +// await bind.sessionSetKeyboardMode(id: widget.id, value: v ?? ""); +// setState(() => current = v ?? ''); +// Future.delayed(Duration(milliseconds: 300), close); +// } +// +// return CustomAlertDialog( +// title: Text(translate('Physical Keyboard Input Mode')), +// content: Column(mainAxisSize: MainAxisSize.min, children: [ +// getRadio('Legacy mode', 'legacy', current, setMode), +// getRadio('Map mode', 'map', current, setMode), +// ])); +// }, clickMaskDismiss: true); +// } } class KeyHelpTools extends StatefulWidget { @@ -864,7 +932,7 @@ class _KeyHelpToolsState extends State { child: icon != null ? Icon(icon, size: 14, color: Colors.white) : Text(translate(text), - style: TextStyle(color: Colors.white, fontSize: 11)), + style: TextStyle(color: Colors.white, fontSize: 11)), onPressed: onPressed); } @@ -887,7 +955,11 @@ class _KeyHelpToolsState extends State { final hasModifierOn = inputModel.ctrl || inputModel.alt || inputModel.shift || - inputModel.command; + inputModel.command || + inputModel.ctrlLocked || + inputModel.altLocked || + inputModel.shiftLocked || + inputModel.commandLocked; if (!_pin && !hasModifierOn && !widget.requestShow) { gFFI.cursorModel @@ -902,47 +974,47 @@ class _KeyHelpToolsState extends State { final isLinux = pi.platform == kPeerPlatformLinux; final modifiers = [ wrap('Ctrl ', () { - setState(() => inputModel.ctrl = !inputModel.ctrl); - }, active: inputModel.ctrl), + setState(() => inputModel.ctrlLocked = !inputModel.ctrlLocked); + }, active: inputModel.ctrl || inputModel.ctrlLocked), wrap(' Alt ', () { - setState(() => inputModel.alt = !inputModel.alt); - }, active: inputModel.alt), + setState(() => inputModel.altLocked = !inputModel.altLocked); + }, active: inputModel.alt || inputModel.altLocked), wrap('Shift', () { - setState(() => inputModel.shift = !inputModel.shift); - }, active: inputModel.shift), + setState(() => inputModel.shiftLocked = !inputModel.shiftLocked); + }, active: inputModel.shift || inputModel.shiftLocked), wrap(isMac ? ' Cmd ' : ' Win ', () { - setState(() => inputModel.command = !inputModel.command); - }, active: inputModel.command), + setState(() => inputModel.commandLocked = !inputModel.commandLocked); + }, active: inputModel.command || inputModel.commandLocked), ]; final keys = [ wrap( ' Fn ', - () => setState( + () => setState( () { - _fn = !_fn; - if (_fn) { - _more = false; - } - }, - ), + _fn = !_fn; + if (_fn) { + _more = false; + } + }, + ), active: _fn), wrap( '', - () => setState( + () => setState( () => _pin = !_pin, - ), + ), active: _pin, icon: Icons.push_pin), wrap( ' ... ', - () => setState( + () => setState( () { - _more = !_more; - if (_more) { - _fn = false; - } - }, - ), + _more = !_more; + if (_more) { + _fn = false; + } + }, + ), active: _more), ]; final fn = [ @@ -994,8 +1066,8 @@ class _KeyHelpToolsState extends State { inputModel.inputKey('VK_PAUSE'); }), if (isWin || isLinux) - // Maybe it's better to call it "Menu" - // https://en.wikipedia.org/wiki/Menu_key + // Maybe it's better to call it "Menu" + // https://en.wikipedia.org/wiki/Menu_key wrap('Menu', () { inputModel.inputKey('Apps'); }), @@ -1139,7 +1211,7 @@ void showOptions( // - light theme: 0xff2196f3 (Colors.blue) // - dark theme: 0xff212121 (the canvas color?) final numBgSelected = - Theme.of(context).colorScheme.primary.withOpacity(0.6); + Theme.of(context).colorScheme.primary.withOpacity(0.6); for (var i = 0; i < pi.displays.length; ++i) { children.add(InkWell( onTap: () { @@ -1158,7 +1230,7 @@ void showOptions( child: Text((i + 1).toString(), style: TextStyle( color: - i == cur ? numColorSelected : numColorUnselected, + i == cur ? numColorSelected : numColorUnselected, fontWeight: FontWeight.bold)))))); } displays.add(Padding( @@ -1174,13 +1246,13 @@ void showOptions( } List> viewStyleRadios = - await toolbarViewStyle(context, id, gFFI); + await toolbarViewStyle(context, id, gFFI); List> imageQualityRadios = - await toolbarImageQuality(context, id, gFFI); + await toolbarImageQuality(context, id, gFFI); List> codecRadios = await toolbarCodec(context, id, gFFI); List cursorToggles = await toolbarCursor(context, id, gFFI); List displayToggles = - await toolbarDisplayToggle(context, id, gFFI); + await toolbarDisplayToggle(context, id, gFFI); List privacyModeList = []; // privacy mode @@ -1207,9 +1279,9 @@ void showOptions( viewStyle.value, e.onChanged != null ? (v) { - e.onChanged?.call(v); - if (v != null) viewStyle.value = v; - } + e.onChanged?.call(v); + if (v != null) viewStyle.value = v; + } : null)), // Show custom scale controls when custom view style is selected Obx(() => viewStyle.value == kRemoteViewStyleCustom @@ -1223,9 +1295,9 @@ void showOptions( imageQuality.value, e.onChanged != null ? (v) { - e.onChanged?.call(v); - if (v != null) imageQuality.value = v; - } + e.onChanged?.call(v); + if (v != null) imageQuality.value = v; + } : null)), const Divider(color: MyTheme.border), for (var e in codecRadios) @@ -1235,9 +1307,9 @@ void showOptions( codec.value, e.onChanged != null ? (v) { - e.onChanged?.call(v); - if (v != null) codec.value = v; - } + e.onChanged?.call(v); + if (v != null) codec.value = v; + } : null)), if (codecRadios.isNotEmpty) const Divider(color: MyTheme.border), ]; @@ -1246,16 +1318,16 @@ void showOptions( .asMap() .entries .map((e) => Obx(() => CheckboxListTile( - contentPadding: EdgeInsets.zero, - visualDensity: VisualDensity.compact, - value: rxCursorToggleValues[e.key].value, - onChanged: e.value.onChanged != null - ? (v) { - e.value.onChanged?.call(v); - if (v != null) rxCursorToggleValues[e.key].value = v; - } - : null, - title: e.value.child))) + contentPadding: EdgeInsets.zero, + visualDensity: VisualDensity.compact, + value: rxCursorToggleValues[e.key].value, + onChanged: e.value.onChanged != null + ? (v) { + e.value.onChanged?.call(v); + if (v != null) rxCursorToggleValues[e.key].value = v; + } + : null, + title: e.value.child))) .toList(); final rxToggleValues = displayToggles.map((e) => e.value.obs).toList(); @@ -1263,16 +1335,16 @@ void showOptions( .asMap() .entries .map((e) => Obx(() => CheckboxListTile( - contentPadding: EdgeInsets.zero, - visualDensity: VisualDensity.compact, - value: rxToggleValues[e.key].value, - onChanged: e.value.onChanged != null - ? (v) { - e.value.onChanged?.call(v); - if (v != null) rxToggleValues[e.key].value = v; - } - : null, - title: e.value.child))) + contentPadding: EdgeInsets.zero, + visualDensity: VisualDensity.compact, + value: rxToggleValues[e.key].value, + onChanged: e.value.onChanged != null + ? (v) { + e.value.onChanged?.call(v); + if (v != null) rxToggleValues[e.key].value = v; + } + : null, + title: e.value.child))) .toList(); final toggles = [ ...cursorTogglesList, @@ -1373,19 +1445,19 @@ TTextMenu? getResolutionMenu(FFI ffi, String id) { ffi.dialogManager.show((setState, close, context) { final children = resolutions .map((e) => getRadio( - Text('${e.width}x${e.height}'), - '${e.width}x${e.height}', - '${display.width}x${display.height}', - (value) { - close(); - bind.sessionChangeResolution( - sessionId: ffi.sessionId, - display: pi.currentDisplay, - width: e.width, - height: e.height, - ); - }, - )) + Text('${e.width}x${e.height}'), + '${e.width}x${e.height}', + '${display.width}x${display.height}', + (value) { + close(); + bind.sessionChangeResolution( + sessionId: ffi.sessionId, + display: pi.currentDisplay, + width: e.width, + height: e.height, + ); + }, + )) .toList(); return CustomAlertDialog( title: Text(translate('Resolution')), diff --git a/flutter/lib/mobile/pages/settings_page.dart b/flutter/lib/mobile/pages/settings_page.dart index 509260636..b93d880f8 100644 --- a/flutter/lib/mobile/pages/settings_page.dart +++ b/flutter/lib/mobile/pages/settings_page.dart @@ -101,6 +101,7 @@ class _SettingsState extends State with WidgetsBindingObserver { var _isUsingPublicServer = false; var _allowAskForNoteAtEndOfConnection = false; var _preventSleepWhileConnected = true; + var _useLegacyKeyMapping = false; _SettingsState() { _enableAbr = option2bool( @@ -145,6 +146,8 @@ class _SettingsState extends State with WidgetsBindingObserver { mainGetLocalBoolOptionSync(kOptionKeepAwakeDuringOutgoingSessions); _showTerminalExtraKeys = mainGetLocalBoolOptionSync(kOptionEnableShowTerminalExtraKeys); + _useLegacyKeyMapping = + mainGetLocalBoolOptionSync(kOptionAllowLegacyKeyMapping); } @override @@ -934,6 +937,24 @@ class _SettingsState extends State with WidgetsBindingObserver { tiles: shareScreenTiles, ), if (!bind.isIncomingOnly()) defaultDisplaySection(), + if (isAndroid && !incomingOnly) + SettingsSection( + title: Text(translate('Keyboard input')), + tiles: [ + SettingsTile.switchTile( + title: Text(translate('Use Legacy Key Mapping')), + initialValue: _useLegacyKeyMapping, + onToggle: (v) async { + await mainSetLocalBoolOption(kOptionAllowLegacyKeyMapping, v); + final newValue = + mainGetLocalBoolOptionSync(kOptionAllowLegacyKeyMapping); + setState(() { + _useLegacyKeyMapping = newValue; + }); + }, + ), + ], + ), if (isAndroid && !disabledSettings && !outgoingOnly && diff --git a/flutter/lib/models/input_model.dart b/flutter/lib/models/input_model.dart index 675a95e42..c73cef72a 100644 --- a/flutter/lib/models/input_model.dart +++ b/flutter/lib/models/input_model.dart @@ -335,6 +335,19 @@ class InputModel { var ctrl = false; var alt = false; var command = false; + var shiftLocked = false; + var ctrlLocked = false; + var altLocked = false; + var commandLocked = false; + var hardwareShiftLeft = false; + var hardwareShiftRight = false; + var hardwareCtrlLeft = false; + var hardwareCtrlRight = false; + var hardwareAltLeft = false; + var hardwareAltRight = false; + var hardwareCommandLeft = false; + var hardwareCommandRight = false; + var hardwareCommandSuper = false; final ToReleaseRawKeys toReleaseRawKeys = ToReleaseRawKeys(); final ToReleaseKeys toReleaseKeys = ToReleaseKeys(); @@ -367,6 +380,12 @@ class InputModel { bool _pointerMovedAfterEnter = false; bool _pointerInsideImage = false; + /// True while the Android soft keyboard editor is active. + /// When set, key events are ignored so they flow through to the + /// hidden TextFormField's onChanged handler instead of being + /// processed here with potentially incorrect physicalKey data. + bool androidSoftKeyboardActive = false; + // mouse final isPhysicalMouse = false.obs; int _lastButtons = 0; @@ -377,7 +396,7 @@ class InputModel { static const int _wheelAccelFastThresholdUs = 40000; // 40ms static const int _wheelAccelMediumThresholdUs = 80000; // 80ms static const double _wheelBurstVelocityThreshold = - 0.002; // delta units per microsecond + 0.002; // delta units per microsecond // Wheel burst acceleration (empirical tuning). // Applies only to fast, non-smooth bursts to preserve single-step scrolling. // Flutter uses microseconds for dt, so velocity is in delta/us. @@ -526,8 +545,8 @@ class InputModel { /// - Default: `kDefaultTrackpadSpeed` Future updateTrackpadSpeed() async { _trackpadSpeed = - (await bind.sessionGetTrackpadSpeed(sessionId: sessionId) ?? - kDefaultTrackpadSpeed); + (await bind.sessionGetTrackpadSpeed(sessionId: sessionId) ?? + kDefaultTrackpadSpeed); if (_trackpadSpeed < kMinTrackpadSpeed || _trackpadSpeed > kMaxTrackpadSpeed) { _trackpadSpeed = kDefaultTrackpadSpeed; @@ -537,10 +556,15 @@ class InputModel { void handleKeyDownEventModifiers(KeyEvent e) { KeyUpEvent upEvent(e) => KeyUpEvent( - physicalKey: e.physicalKey, - logicalKey: e.logicalKey, - timeStamp: e.timeStamp, - ); + physicalKey: e.physicalKey, + logicalKey: e.logicalKey, + timeStamp: e.timeStamp, + ); + if (!(isAndroid && + androidSoftKeyboardActive && + _isAndroidSoftKeyboardEvent(e))) { + _setHardwareModifierState(e.logicalKey, true); + } if (e.logicalKey == LogicalKeyboardKey.altLeft) { if (!alt) { alt = true; @@ -590,6 +614,11 @@ class InputModel { } void handleKeyUpEventModifiers(KeyEvent e) { + if (!(isAndroid && + androidSoftKeyboardActive && + _isAndroidSoftKeyboardEvent(e))) { + _setHardwareModifierState(e.logicalKey, false); + } if (e.logicalKey == LogicalKeyboardKey.altLeft) { alt = false; toReleaseKeys.lastLAltKeyEvent = null; @@ -620,9 +649,79 @@ class InputModel { } } + bool _isSoftKeyboardPhysicalKey(PhysicalKeyboardKey key) { + final usbHidUsage = key.usbHidUsage; + // Flutter hardware keyboard HID usages are normally in the low keyboard-page + // range (for example 0x00070004 for 'A', 0x00070028 for Enter), which means + // bits above the lower 20-bit region are zero and `usbHidUsage >> 20` becomes + // 0. Some Android IME/soft-keyboard events instead report abnormal high-bit + // values (observed examples look like 0x1100000042), so shifting right by 20 + // leaves a non-zero value. We use that cheap heuristic here to treat such + // events as soft-keyboard/IME-generated rather than real hardware keys. + final isNormalUsbHidUsage = (usbHidUsage >> 20) == 0; + // Real hardware keyboard events generally have a normal keyboard HID usage. + // IME/soft-keyboard events on Android often do not, especially for modifier + // and special keys when the on-screen keyboard is visible. + return !isNormalUsbHidUsage; + } + + bool _isAndroidSoftKeyboardEvent(KeyEvent e) { + return _isSoftKeyboardPhysicalKey(e.physicalKey); + } + + bool _isModifierLogicalKey(LogicalKeyboardKey key) { + return key == LogicalKeyboardKey.shiftLeft || + key == LogicalKeyboardKey.shiftRight || + key == LogicalKeyboardKey.controlLeft || + key == LogicalKeyboardKey.controlRight || + key == LogicalKeyboardKey.altLeft || + key == LogicalKeyboardKey.altRight || + key == LogicalKeyboardKey.metaLeft || + key == LogicalKeyboardKey.metaRight || + key == LogicalKeyboardKey.superKey; + } + + bool _isAndroidSoftKeyboardRawEvent(RawKeyEvent e) { + return _isSoftKeyboardPhysicalKey(e.physicalKey); + } + + void _setHardwareModifierState(LogicalKeyboardKey key, bool down) { + if (key == LogicalKeyboardKey.shiftLeft) { + hardwareShiftLeft = down; + } else if (key == LogicalKeyboardKey.shiftRight) { + hardwareShiftRight = down; + } else if (key == LogicalKeyboardKey.controlLeft) { + hardwareCtrlLeft = down; + } else if (key == LogicalKeyboardKey.controlRight) { + hardwareCtrlRight = down; + } else if (key == LogicalKeyboardKey.altLeft) { + hardwareAltLeft = down; + } else if (key == LogicalKeyboardKey.altRight) { + hardwareAltRight = down; + } else if (key == LogicalKeyboardKey.metaLeft) { + hardwareCommandLeft = down; + } else if (key == LogicalKeyboardKey.metaRight) { + hardwareCommandRight = down; + } else if (key == LogicalKeyboardKey.superKey) { + hardwareCommandSuper = down; + } + } + KeyEventResult handleRawKeyEvent(RawKeyEvent e) { if (isViewOnly) return KeyEventResult.handled; if (isViewCamera) return KeyEventResult.handled; + final isAndroidSoftRaw = isAndroid && + androidSoftKeyboardActive && + _isAndroidSoftKeyboardRawEvent(e); + if (isAndroidSoftRaw && + !_hasHardwareModifierPressed && + _isModifierLogicalKey(e.logicalKey)) { + alt = false; + ctrl = false; + shift = false; + command = false; + return KeyEventResult.handled; + } if (!isInputSourceFlutter) { if (isDesktop) { return KeyEventResult.handled; @@ -642,6 +741,9 @@ class InputModel { final key = e.logicalKey; if (e is RawKeyDownEvent) { + if (!isAndroidSoftRaw) { + _setHardwareModifierState(key, true); + } if (!e.repeat) { if (e.isAltPressed && !alt) { alt = true; @@ -656,6 +758,9 @@ class InputModel { toReleaseRawKeys.updateKeyDown(key, e); } if (e is RawKeyUpEvent) { + if (!isAndroidSoftRaw) { + _setHardwareModifierState(key, false); + } if (key == LogicalKeyboardKey.altLeft || key == LogicalKeyboardKey.altRight) { alt = false; @@ -687,6 +792,39 @@ class InputModel { KeyEventResult handleKeyEvent(KeyEvent e) { if (isViewOnly) return KeyEventResult.handled; if (isViewCamera) return KeyEventResult.handled; + // When the Android soft keyboard is active, avoid processing key events + // through the normal input pipeline because physicalKey data from the + // soft keyboard is unreliable (Flutter issue #157771) and can corrupt + // subsequent input, causing every keypress to repeat a single character. + // + // Return `handled` (not `ignored`) so Android keeps sending key-repeat + // events for held keys and the TextFormField does not consume sentinel + // buffer characters. + // + // For Backspace and Enter, send them directly using the reliable logical + // key data. This is required because for some IMEs (ko/zh/ja) returning + // `handled` prevents the IME from processing the key through onChanged. + if (isAndroid && + androidSoftKeyboardActive && + !_hasHardwareModifierPressed && + _isModifierLogicalKey(e.logicalKey)) { + releaseTransientModifiersToHost(); + return KeyEventResult.handled; + } + + if (isAndroid && + androidSoftKeyboardActive && + _isAndroidSoftKeyboardEvent(e)) { + if (e is KeyDownEvent || e is KeyRepeatEvent) { + if (e.logicalKey == LogicalKeyboardKey.backspace) { + inputKey('VK_BACK', press: true); + } else if (e.logicalKey == LogicalKeyboardKey.enter || + e.logicalKey == LogicalKeyboardKey.numpadEnter) { + inputKey('VK_RETURN', press: true); + } + } + return KeyEventResult.handled; + } if (!isInputSourceFlutter) { if (isDesktop) { return KeyEventResult.handled; @@ -873,7 +1011,13 @@ class InputModel { /// Send key stroke event. /// [down] indicates the key's state(down or up). /// [press] indicates a click event(down and up). - void inputKey(String name, {bool? down, bool? press}) { + void inputKey(String name, + {bool? down, + bool? press, + bool? altOverride, + bool? ctrlOverride, + bool? shiftOverride, + bool? commandOverride}) { if (!keyboardPerm) return; if (isViewCamera) return; bind.sessionInputKey( @@ -881,16 +1025,16 @@ class InputModel { name: name, down: down ?? false, press: press ?? true, - alt: alt, - ctrl: ctrl, - shift: shift, - command: command); + alt: altOverride ?? (alt || altLocked), + ctrl: ctrlOverride ?? (ctrl || ctrlLocked), + shift: shiftOverride ?? (shift || shiftLocked), + command: commandOverride ?? (command || commandLocked)); } static Map getMouseEventMove() => { - 'type': _kMouseEventMove, - 'buttons': 0, - }; + 'type': _kMouseEventMove, + 'buttons': 0, + }; Map _getMouseEvent(PointerEvent evt, String type) { final Map out = {}; @@ -946,6 +1090,11 @@ class InputModel { /// Send scroll event with scroll distance [y]. Future scroll(int y) async { if (isViewCamera) return; + if (isAndroid && + androidSoftKeyboardActive && + !_hasHardwareModifierPressed) { + releaseTransientModifiersToHost(); + } await bind.sessionSendMouse( sessionId: sessionId, msg: json @@ -955,14 +1104,92 @@ class InputModel { /// Reset key modifiers to false, including [shift], [ctrl], [alt] and [command]. void resetModifiers() { shift = ctrl = alt = command = false; + shiftLocked = ctrlLocked = altLocked = commandLocked = false; + hardwareShiftLeft = hardwareShiftRight = false; + hardwareCtrlLeft = hardwareCtrlRight = false; + hardwareAltLeft = hardwareAltRight = false; + hardwareCommandLeft = hardwareCommandRight = hardwareCommandSuper = false; + } + + void releaseTransientModifiersToHost() { + if (shift && !shiftLocked) { + inputKey('VK_SHIFT', + down: false, + press: false, + altOverride: false, + ctrlOverride: false, + shiftOverride: false, + commandOverride: false); + } + if (ctrl && !ctrlLocked) { + inputKey('VK_CONTROL', + down: false, + press: false, + altOverride: false, + ctrlOverride: false, + shiftOverride: false, + commandOverride: false); + } + if (alt && !altLocked) { + inputKey('VK_MENU', + down: false, + press: false, + altOverride: false, + ctrlOverride: false, + shiftOverride: false, + commandOverride: false); + } + if (command && !commandLocked) { + inputKey('Meta', + down: false, + press: false, + altOverride: false, + ctrlOverride: false, + shiftOverride: false, + commandOverride: false); + } + shift = false; + ctrl = false; + alt = false; + command = false; + } + + bool get _hasHardwareModifierPressed { + return _hardwareShiftPressed || + _hardwareCtrlPressed || + _hardwareAltPressed || + _hardwareCommandPressed; + } + + bool get _hardwareShiftPressed { + return hardwareShiftLeft || hardwareShiftRight; + } + + bool get _hardwareCtrlPressed { + return hardwareCtrlLeft || hardwareCtrlRight; + } + + bool get _hardwareAltPressed { + return hardwareAltLeft || hardwareAltRight; + } + + bool get _hardwareCommandPressed { + return hardwareCommandLeft || hardwareCommandRight || hardwareCommandSuper; } /// Modify the given modifier map [evt] based on current modifier key status. Map modify(Map evt) { - if (ctrl) evt['ctrl'] = 'true'; - if (shift) evt['shift'] = 'true'; - if (alt) evt['alt'] = 'true'; - if (command) evt['command'] = 'true'; + if (isAndroid) { + if (_hardwareCtrlPressed || ctrlLocked) evt['ctrl'] = 'true'; + if (_hardwareShiftPressed || shiftLocked) evt['shift'] = 'true'; + if (_hardwareAltPressed || altLocked) evt['alt'] = 'true'; + if (_hardwareCommandPressed || commandLocked) evt['command'] = 'true'; + return evt; + } + if (ctrl || ctrlLocked) evt['ctrl'] = 'true'; + if (shift || shiftLocked) evt['shift'] = 'true'; + if (alt || altLocked) evt['alt'] = 'true'; + if (command || commandLocked) evt['command'] = 'true'; return evt; } @@ -970,6 +1197,11 @@ class InputModel { Future sendMouse(String type, MouseButtons button) async { if (!keyboardPerm) return; if (isViewCamera) return; + if (isAndroid && + androidSoftKeyboardActive && + !_hasHardwareModifierPressed) { + releaseTransientModifiersToHost(); + } await bind.sessionSendMouse( sessionId: sessionId, msg: json.encode(modify({'type': type, 'buttons': button.value}))); @@ -1417,7 +1649,7 @@ class InputModel { static Future fillRemoteCoordsAndGetCurFrame( List remoteWindowCoords) async { final coords = - await rustDeskWinManager.getOtherRemoteWindowCoordsFromMain(); + await rustDeskWinManager.getOtherRemoteWindowCoordsFromMain(); final wc = WindowController.fromWindowId(kWindowId!); try { final frame = await wc.getFrame(); @@ -1445,7 +1677,8 @@ class InputModel { if (e is PointerScrollEvent) { final rawDx = e.scrollDelta.dx; final rawDy = e.scrollDelta.dy; - final dominantDelta = rawDx.abs() > rawDy.abs() ? rawDx.abs() : rawDy.abs(); + final dominantDelta = + rawDx.abs() > rawDy.abs() ? rawDx.abs() : rawDy.abs(); final isSmooth = dominantDelta < 1; final nowUs = DateTime.now().microsecondsSinceEpoch; final dtUs = _lastWheelTsUs == 0 ? 0 : nowUs - _lastWheelTsUs; @@ -1489,18 +1722,18 @@ class InputModel { } void refreshMousePos() => handleMouse({ - 'buttons': 0, - 'type': _kMouseEventMove, - }, lastMousePos, edgeScroll: useEdgeScroll); + 'buttons': 0, + 'type': _kMouseEventMove, + }, lastMousePos, edgeScroll: useEdgeScroll); void tryMoveEdgeOnExit(Offset pos) => handleMouse( - { - 'buttons': 0, - 'type': _kMouseEventMove, - }, - pos, - onExit: true, - ); + { + 'buttons': 0, + 'type': _kMouseEventMove, + }, + pos, + onExit: true, + ); static double tryGetNearestRange(double v, double min, double max, double n) { if (v < min && v >= min - n) { @@ -1594,12 +1827,12 @@ class InputModel { } Map? processEventToPeer( - Map evt, - Offset offset, { - bool onExit = false, - bool moveCanvas = true, - bool edgeScroll = false, - }) { + Map evt, + Offset offset, { + bool onExit = false, + bool moveCanvas = true, + bool edgeScroll = false, + }) { if (isViewCamera) return null; double x = offset.dx; double y = max(0.0, offset.dy); @@ -1669,12 +1902,12 @@ class InputModel { } Map? handleMouse( - Map evt, - Offset offset, { - bool onExit = false, - bool moveCanvas = true, - bool edgeScroll = false, - }) { + Map evt, + Offset offset, { + bool onExit = false, + bool moveCanvas = true, + bool edgeScroll = false, + }) { final evtToPeer = processEventToPeer(evt, offset, onExit: onExit, moveCanvas: moveCanvas, edgeScroll: edgeScroll); if (evtToPeer != null) { @@ -1685,19 +1918,19 @@ class InputModel { } Point? handlePointerDevicePos( - String kind, - double x, - double y, - bool isMove, - String evtType, { - bool onExit = false, - int buttons = kPrimaryMouseButton, - bool moveCanvas = true, - bool edgeScroll = false, - }) { + String kind, + double x, + double y, + bool isMove, + String evtType, { + bool onExit = false, + int buttons = kPrimaryMouseButton, + bool moveCanvas = true, + bool edgeScroll = false, + }) { final ffiModel = parent.target!.ffiModel; CanvasCoords canvas = - CanvasCoords.fromCanvasModel(parent.target!.canvasModel); + CanvasCoords.fromCanvasModel(parent.target!.canvasModel); Rect? rect = ffiModel.rect; if (isMove) { @@ -1705,7 +1938,7 @@ class InputModel { _windowRect != null && !_isInCurrentWindow(x, y)) { final coords = - findRemoteCoords(x, y, _remoteWindowCoords, devicePixelRatio); + findRemoteCoords(x, y, _remoteWindowCoords, devicePixelRatio); if (coords != null) { isMove = false; canvas = coords.canvas; @@ -1775,16 +2008,16 @@ class InputModel { } Point? _handlePointerDevicePos( - String kind, - double x, - double y, - bool moveInCanvas, - CanvasCoords canvas, - Rect? rect, - String evtType, { - bool onExit = false, - int buttons = kPrimaryMouseButton, - }) { + String kind, + double x, + double y, + bool moveInCanvas, + CanvasCoords canvas, + Rect? rect, + String evtType, { + bool onExit = false, + int buttons = kPrimaryMouseButton, + }) { if (rect == null) { return null; } diff --git a/src/server/input_service.rs b/src/server/input_service.rs index 97dc78755..dea009a07 100644 --- a/src/server/input_service.rs +++ b/src/server/input_service.rs @@ -1593,7 +1593,15 @@ fn need_to_uppercase(en: &mut Enigo) -> bool { get_modifier_state(Key::Shift, en) || get_modifier_state(Key::CapsLock, en) } -fn process_chr(en: &mut Enigo, chr: u32, down: bool, _hotkey: bool) { +#[inline] +fn has_shift_modifier(key_event: &KeyEvent) -> bool { + key_event.modifiers.iter().any(|ck| { + let v = ck.value(); + v == ControlKey::Shift.value() || v == ControlKey::RShift.value() + }) +} + +fn process_chr(en: &mut Enigo, chr: u32, down: bool, _hotkey: bool, uppercase_hint: bool) { // On Wayland with uinput mode, use clipboard for character input #[cfg(target_os = "linux")] if !crate::platform::linux::is_x11() && wayland_use_uinput() { @@ -1608,7 +1616,35 @@ fn process_chr(en: &mut Enigo, chr: u32, down: bool, _hotkey: bool) { } } - #[cfg(any(target_os = "macos", target_os = "windows"))] + #[cfg(target_os = "windows")] + if !_hotkey { + let key = char_value_to_key(chr); + + if down { + if en.key_down(key).is_ok() { + // The current Windows OS keyboard layout provided a valid key mapping for this + // character, so it was sent through the normal key down/up path. + // Only unmappable characters need the key_sequence() fallback below. + // If something needs to be done right after the key_down it can be done here + } else { + // The character could not be mapped to a layout-dependent physical key in + // the current OS keyboard layout. In that case, inject the resulting text + // directly through key_sequence() as a fallback. + if let Ok(chr) = char::try_from(chr) { + let mut s = chr.to_string(); + if uppercase_hint { + s = s.to_uppercase(); + } + en.key_sequence(&s); + }; + } + } else { + en.key_up(key); + } + return; + } + + #[cfg(target_os = "macos")] if !_hotkey { if down { if let Ok(chr) = char::try_from(chr) { @@ -1887,7 +1923,8 @@ fn legacy_keyboard_mode(evt: &KeyEvent) { let record_key = chr as u64 + KEY_CHAR_START; record_pressed_key(KeysDown::EnigoKey(record_key), down); - process_chr(&mut en, chr, down, has_hotkey_modifiers(evt)) + let uppercase_hint = has_shift_modifier(evt) || get_modifier_state(Key::CapsLock, &mut en); + process_chr(&mut en, chr, down, has_hotkey_modifiers(evt), uppercase_hint) } Some(key_event::Union::Unicode(chr)) => { // Same as Chr: release Shift for Unicode input @@ -2140,7 +2177,7 @@ pub fn handle_key_(evt: &KeyEvent) { } #[cfg(not(any(target_os = "android", target_os = "ios")))] - let mut _lock_mode_handler = None; + let mut _lock_mode_handler = None; #[cfg(not(any(target_os = "android", target_os = "ios")))] match &evt.union { Some(key_event::Union::Unicode(..)) | Some(key_event::Union::Seq(..)) => {