mirror of
https://github.com/rustdesk/rustdesk.git
synced 2026-05-07 06:38:11 +03:00
Adds a keyboard shortcut feature (Rust matcher + Dart UI + cross-language
parity tests) that lets users bind combinations like Ctrl+Alt+Shift+P to
session actions. Bindings are stored in LocalConfig under
`keyboard-shortcuts`; the matcher gates dispatch on `enabled` and
`pass_through` flags so flipping the master switch off is a hard stop.
Wire-up summary:
- src/keyboard/shortcuts.rs: matcher, default bindings, parity test against
flutter/test/fixtures/default_keyboard_shortcuts.json
- src/keyboard.rs: shortcut intercept in process_event{,_with_session},
feature-gated to `flutter`; runs before key swapping so users bind to
physical keys
- src/flutter_ffi.rs: main_reload_keyboard_shortcuts +
main_get_default_keyboard_shortcuts; reload_from_config seeded in main_init
- flutter/lib/common/widgets/keyboard_shortcuts/: shared config page body,
recording dialog, shortcut display formatter, action group registry
- flutter/lib/desktop/pages/desktop_keyboard_shortcuts_page.dart and
flutter/lib/mobile/pages/mobile_keyboard_shortcuts_page.dart: platform
shells around the shared body
- flutter/lib/models/shortcut_model.dart: per-session ShortcutModel +
registerSessionShortcutActions for actions with no toolbar TToggleMenu /
TRadioMenu (fullscreen, switch display/tab, close tab, voice call, etc.)
- flutter/lib/common/widgets/toolbar.dart: optional `actionId` field on
TToggleMenu / TRadioMenu, plus per-helper auto-register pass that wires
tagged entries' existing onChanged into the ShortcutModel
- flutter/test/keyboard_shortcuts_test.dart + fixtures: cross-language
parity (default bindings, supported key vocabulary)
Design principles applied during review:
1. Additions are fine; modifications to original logic must be deliberate.
Tagging an existing TToggleMenu entry with `actionId:` is an addition.
Rewriting its onChanged to satisfy a new contract is a modification —
and was reverted for every case where the original click behavior was
working. Four closures were touched and then reverted (mobile View
Mode, Privacy mode multi-impl, Relative mouse mode, Reverse mouse
wheel); their shortcuts are wired via standalone closures in
shortcut_model.dart instead.
2. Toolbar auto-register is reserved for entries whose onChanged is
inherently self-flipping — typically `sessionToggleOption(name)` where
the named option is flipped in place and the input bool is unused. The
register pass passes `!menu.value` from registration time, which is
harmless under self-flipping but wrong for closures that consume the
input bool directly. Tagging a non-self-flipping entry forces a closure
rewrite; choose non-toolbar registration in that case.
3. When shortcuts are disabled, toolbar behavior must be bit-for-bit
unchanged. The matcher's `enabled`-gate already guarantees no
dispatch; the auto-register pass is left unconditional (its only effect
is HashMap operations on a separate ShortcutModel) so mid-session
enable works without a reconnect. The trade-off is intentional and
documented at the top of toolbarControls.
4. Comments stay terse. Rationale lives in one place — the doc comment of
the helper or registration site, not duplicated at every call site.
5. Where an existing helper needs a new optional behavior (e.g.
`_OptionCheckBox` gaining a tooltip slot), the new branch must reduce
to byte-identical output for existing callers (`trailing == null`
case → original `Expanded(Text)` layout). Verified.
6. Action IDs and labels stay consistent. Renamed `reset_cursor` →
`reset_canvas` so the action ID matches its user-facing label
("Reset canvas") and capability flag.
Out-of-scope but included:
- AGENTS.md: documents flutter_rust_bridge no-codegen workflow and the
Web target's hand-written TS client, since both are load-bearing for
any new FFI work.
- remote_toolbar.dart: i18n fix for the per-monitor tooltip ("All
monitors" / "Monitor #N"), unrelated to shortcuts but kept here.
111 lines
4.5 KiB
Dart
111 lines
4.5 KiB
Dart
// flutter/lib/common/widgets/keyboard_shortcuts/display.dart
|
|
import 'dart:convert';
|
|
import 'package:flutter/foundation.dart';
|
|
import '../../../consts.dart';
|
|
import '../../../models/platform_model.dart';
|
|
|
|
/// Read the bindings JSON and produce a human-readable shortcut string for
|
|
/// `actionId`, formatted for the current OS. Returns null if unbound, or —
|
|
/// when [requireEnabled] is true (the default) — when the master toggle is
|
|
/// off. The configuration page passes `requireEnabled: false` so users still
|
|
/// see what they have bound while the feature is disabled.
|
|
class ShortcutDisplay {
|
|
// Cache parsed JSON keyed by the raw string — called per visible action on
|
|
// every menu rebuild, so the jsonDecode is the real cost. Invalidation is
|
|
// automatic: a write changes the raw and we re-parse.
|
|
static String? _cachedRaw;
|
|
static Map<String, dynamic>? _cachedParsed;
|
|
|
|
@visibleForTesting
|
|
static void resetCache() {
|
|
_cachedRaw = null;
|
|
_cachedParsed = null;
|
|
}
|
|
|
|
static String? formatFor(String actionId, {bool requireEnabled = true}) {
|
|
final raw = bind.mainGetLocalOption(key: kShortcutLocalConfigKey);
|
|
if (raw.isEmpty) return null;
|
|
Map<String, dynamic>? parsed;
|
|
if (raw == _cachedRaw) {
|
|
parsed = _cachedParsed;
|
|
} else {
|
|
try {
|
|
parsed = jsonDecode(raw) as Map<String, dynamic>;
|
|
} catch (_) {
|
|
parsed = null;
|
|
}
|
|
_cachedRaw = raw;
|
|
_cachedParsed = parsed;
|
|
}
|
|
if (parsed == null) return null;
|
|
if (requireEnabled && parsed['enabled'] != true) return null;
|
|
// When pass-through is on, the matcher returns early on every keystroke.
|
|
// Showing the bound combo next to a menu item would lie to the user — they
|
|
// would press it expecting the local action and instead the keys would go
|
|
// to the remote. Treat as unbound for display purposes.
|
|
if (requireEnabled && parsed['pass_through'] == true) return null;
|
|
final list = (parsed['bindings'] as List? ?? []).cast<Map<String, dynamic>>();
|
|
final found = list.firstWhere(
|
|
(b) => b['action'] == actionId,
|
|
orElse: () => {},
|
|
);
|
|
if (found.isEmpty) return null;
|
|
|
|
// Guard against a hand-edited / corrupt config where `key` is missing or
|
|
// not a string — silently treat the binding as unbound rather than
|
|
// crashing the toolbar render.
|
|
final keyValue = found['key'];
|
|
if (keyValue is! String) return null;
|
|
|
|
final isMac = defaultTargetPlatform == TargetPlatform.macOS ||
|
|
defaultTargetPlatform == TargetPlatform.iOS;
|
|
// `mods` similarly may be malformed; treat a non-list as no modifiers.
|
|
final modsRaw = found['mods'];
|
|
final mods = modsRaw is List
|
|
? modsRaw.whereType<String>().toList()
|
|
: const <String>[];
|
|
// Plain-text labels (Cmd / Ctrl / Alt / Shift) instead of Unicode glyphs
|
|
// (⌘ ⌃ ⌥ ⇧). Flutter Web's CanvasKit bundled fonts don't always carry the
|
|
// macOS modifier symbols, which renders as garbled boxes on Mac browsers;
|
|
// text is portable and readable on every platform.
|
|
//
|
|
// Order matches the canonical macOS order (Cmd, Control, Option, Shift)
|
|
// so the rendered hint reads naturally. `ctrl` only ever appears in
|
|
// saved bindings on macOS — Win/Linux collapses Ctrl into `primary`.
|
|
final parts = <String>[];
|
|
for (final m in ['primary', 'ctrl', 'alt', 'shift']) {
|
|
if (!mods.contains(m)) continue;
|
|
switch (m) {
|
|
case 'primary': parts.add(isMac ? 'Cmd' : 'Ctrl'); break;
|
|
case 'ctrl': parts.add(isMac ? 'Control' : 'Ctrl'); break;
|
|
case 'alt': parts.add(isMac ? 'Option' : 'Alt'); break;
|
|
case 'shift': parts.add('Shift'); break;
|
|
}
|
|
}
|
|
parts.add(_keyDisplay(keyValue));
|
|
return parts.join('+');
|
|
}
|
|
|
|
static String _keyDisplay(String key) {
|
|
switch (key) {
|
|
case 'delete': return 'Del';
|
|
case 'backspace': return 'Backspace';
|
|
case 'enter': return 'Enter';
|
|
case 'tab': return 'Tab';
|
|
case 'space': return 'Space';
|
|
case 'arrow_left': return 'Left';
|
|
case 'arrow_right':return 'Right';
|
|
case 'arrow_up': return 'Up';
|
|
case 'arrow_down': return 'Down';
|
|
case 'home': return 'Home';
|
|
case 'end': return 'End';
|
|
case 'page_up': return 'PgUp';
|
|
case 'page_down': return 'PgDn';
|
|
case 'insert': return 'Ins';
|
|
}
|
|
if (key.startsWith('digit')) return key.substring(5);
|
|
// F-keys ("f1".."f12") and single letters fall through to uppercase.
|
|
return key.toUpperCase();
|
|
}
|
|
}
|