This commit is contained in:
Constantin Zagorsky 2026-04-21 05:48:49 -05:00 committed by GitHub
commit 1706cf1a9a
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
5 changed files with 723 additions and 359 deletions

View file

@ -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";

View file

@ -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<RemotePage> 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<RemotePage> 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<RemotePage> 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<RemotePage> 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<RemotePage> 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<RemotePage> 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<RemotePage> 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<RemotePage> 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<RemotePage> 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<RemotePage> 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<RemotePage> 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<RemotePage> 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<RemotePage> with WidgetsBindingObserver {
children: <Widget>[
Row(
children: <Widget>[
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
? []
: <Widget>[
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<RemotePage> 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<RemotePage> 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<RemotePage> 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<RemotePage> with WidgetsBindingObserver {
.asMap()
.entries
.map((e) =>
PopupMenuItem<int>(child: e.value.getChild(), value: e.key))
PopupMenuItem<int>(child: e.value.getChild(), value: e.key))
.toList(),
if (mobileActionMenus.isNotEmpty) PopupMenuDivider(),
...menus
.asMap()
.entries
.map((e) => PopupMenuItem<int>(
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<RemotePage> 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<RemotePage> 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<RemotePage> 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<KeyHelpTools> {
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<KeyHelpTools> {
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<KeyHelpTools> {
final isLinux = pi.platform == kPeerPlatformLinux;
final modifiers = <Widget>[
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 = <Widget>[
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 = <Widget>[
@ -994,8 +1066,8 @@ class _KeyHelpToolsState extends State<KeyHelpTools> {
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<TRadioMenu<String>> viewStyleRadios =
await toolbarViewStyle(context, id, gFFI);
await toolbarViewStyle(context, id, gFFI);
List<TRadioMenu<String>> imageQualityRadios =
await toolbarImageQuality(context, id, gFFI);
await toolbarImageQuality(context, id, gFFI);
List<TRadioMenu<String>> codecRadios = await toolbarCodec(context, id, gFFI);
List<TToggleMenu> cursorToggles = await toolbarCursor(context, id, gFFI);
List<TToggleMenu> displayToggles =
await toolbarDisplayToggle(context, id, gFFI);
await toolbarDisplayToggle(context, id, gFFI);
List<TToggleMenu> 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<String>(
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')),

View file

@ -101,6 +101,7 @@ class _SettingsState extends State<SettingsPage> 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<SettingsPage> with WidgetsBindingObserver {
mainGetLocalBoolOptionSync(kOptionKeepAwakeDuringOutgoingSessions);
_showTerminalExtraKeys =
mainGetLocalBoolOptionSync(kOptionEnableShowTerminalExtraKeys);
_useLegacyKeyMapping =
mainGetLocalBoolOptionSync(kOptionAllowLegacyKeyMapping);
}
@override
@ -934,6 +937,24 @@ class _SettingsState extends State<SettingsPage> 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 &&

View file

@ -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<void> 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<String, dynamic> getMouseEventMove() => {
'type': _kMouseEventMove,
'buttons': 0,
};
'type': _kMouseEventMove,
'buttons': 0,
};
Map<String, dynamic> _getMouseEvent(PointerEvent evt, String type) {
final Map<String, dynamic> out = {};
@ -946,6 +1090,11 @@ class InputModel {
/// Send scroll event with scroll distance [y].
Future<void> 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<String, dynamic> modify(Map<String, dynamic> 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<void> 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<Rect?> fillRemoteCoordsAndGetCurFrame(
List<RemoteWindowCoords> 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<String, dynamic>? processEventToPeer(
Map<String, dynamic> evt,
Offset offset, {
bool onExit = false,
bool moveCanvas = true,
bool edgeScroll = false,
}) {
Map<String, dynamic> 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<String, dynamic>? handleMouse(
Map<String, dynamic> evt,
Offset offset, {
bool onExit = false,
bool moveCanvas = true,
bool edgeScroll = false,
}) {
Map<String, dynamic> 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;
}

View file

@ -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(..)) => {