feat(shortcuts): user-configurable keyboard shortcuts for session actions

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.
This commit is contained in:
rustdesk
2026-04-30 16:40:42 +08:00
parent 68e07ed7eb
commit cd7686baa2
25 changed files with 3729 additions and 78 deletions

View File

@@ -0,0 +1,63 @@
// flutter/lib/desktop/pages/desktop_keyboard_shortcuts_page.dart
//
// Desktop shell for the Keyboard Shortcuts configuration page. Users land
// here from the General settings tab. The page exposes:
// * A top-level enable/disable toggle (mirrors the General-tab toggle —
// same JSON key, same semantics).
// * A grouped, scrollable list of actions, each with a current binding and
// edit / clear icons.
// * An AppBar "Reset to defaults" action with a confirmation dialog.
//
// All edits write back to LocalConfig under [kShortcutLocalConfigKey] in the
// canonical {enabled, bindings:[{action,mods,key}]} shape that the Rust and
// Web matchers consume.
//
// The body — group definitions, JSON I/O, conflict-replace flow,
// recording-dialog round-trip — lives in
// `common/widgets/keyboard_shortcuts/page_body.dart` and is shared with the
// mobile shell at `mobile/pages/mobile_keyboard_shortcuts_page.dart`.
import 'package:flutter/material.dart';
import 'package:get/get.dart';
import '../../common.dart';
import '../../common/widgets/keyboard_shortcuts/page_body.dart';
class DesktopKeyboardShortcutsPage extends StatefulWidget {
const DesktopKeyboardShortcutsPage({Key? key}) : super(key: key);
@override
State<DesktopKeyboardShortcutsPage> createState() =>
_DesktopKeyboardShortcutsPageState();
}
class _DesktopKeyboardShortcutsPageState
extends State<DesktopKeyboardShortcutsPage> {
final GlobalKey<KeyboardShortcutsPageBodyState> _bodyKey = GlobalKey();
@override
Widget build(BuildContext context) {
return Scaffold(
appBar: AppBar(
title: Text(translate('Keyboard Shortcuts')),
actions: [
TextButton.icon(
onPressed: () =>
_bodyKey.currentState?.resetToDefaultsWithConfirm(),
icon: const Icon(Icons.restore),
label: Text(translate('Reset to defaults')),
).marginOnly(right: 12),
],
),
body: KeyboardShortcutsPageBody(
key: _bodyKey,
compact: true,
// Desktop's General settings tab already exposes the Enable +
// Pass-through checkboxes (it's the only entry point to this page),
// so we hide the duplicates here. Mobile shells keep the default
// (true) because their entry tile doesn't carry the toggles.
showMasterToggles: false,
),
);
}
}

View File

@@ -10,6 +10,7 @@ import 'package:flutter_hbb/common/widgets/audio_input.dart';
import 'package:flutter_hbb/common/widgets/setting_widgets.dart';
import 'package:flutter_hbb/consts.dart';
import 'package:flutter_hbb/desktop/pages/desktop_home_page.dart';
import 'package:flutter_hbb/common/widgets/keyboard_shortcuts/page_body.dart';
import 'package:flutter_hbb/desktop/pages/desktop_keyboard_shortcuts_page.dart';
import 'package:flutter_hbb/desktop/pages/desktop_tab_page.dart';
import 'package:flutter_hbb/desktop/widgets/remote_toolbar.dart';
@@ -459,6 +460,7 @@ class _GeneralState extends State<_General> {
await ShortcutModel.setPassThrough(v);
setLocalState(() {});
},
trailing: const InfoTooltipIcon(tipKey: 'shortcut-passthrough-tip'),
),
_ShortcutsConfigureRow(),
],
@@ -2532,6 +2534,8 @@ Widget _OptionCheckBox(
bool isServer = true,
bool Function()? optGetter,
Future<void> Function(String, bool)? optSetter,
// Optional widget rendered between the label and the trailing space.
Widget? trailing,
}) {
getOpt() => optGetter != null
? optGetter()
@@ -2575,11 +2579,23 @@ Widget _OptionCheckBox(
offstage: !ref.value || checkedIcon == null,
child: checkedIcon?.marginOnly(right: 5),
),
Expanded(
// Without `trailing`, keep the original Expanded(Text) layout.
if (trailing == null)
Expanded(
child: Text(
translate(label),
style: TextStyle(color: disabledTextColor(context, enabled)),
))
else ...[
Flexible(
child: Text(
translate(label),
style: TextStyle(color: disabledTextColor(context, enabled)),
))
translate(label),
style: TextStyle(color: disabledTextColor(context, enabled)),
),
),
trailing,
const Spacer(),
],
],
),
).marginOnly(left: _kCheckBoxLeftMargin),

View File

@@ -134,12 +134,10 @@ class _RemotePageState extends State<RemotePage>
// what we want here.
if (mounted) {
toolbarControls(context, widget.id, _ffi);
// Register the default-bound actions that `toolbarControls` doesn't
// own (fullscreen, switch display, switch tab). Done in addition,
// not instead of, the toolbar registration above.
registerSessionShortcutActions(_ffi,
tabController: widget.tabController,
toolbarState: widget.toolbarState);
registerToolbarShortcuts(context, widget.id, _ffi);
}
});
_ffi.canvasModel.initializeEdgeScrollFallback(this);

View File

@@ -611,8 +611,9 @@ class _MonitorMenu extends StatelessWidget {
tooltip: isMulti
? ''
: isAllMonitors
? 'all monitors'
: '#${i + 1} monitor',
? translate('All monitors')
: translate('Monitor #{}')
.replaceAll('{}', '${i + 1}'),
hMargin: isMulti ? null : 6,
vMargin: isMulti ? null : 12,
topLevel: false,