feat: keyboard shortcuts in remote sessions

Add an opt-in keyboard-shortcut system that triggers session
actions (Send Ctrl+Alt+Del, Toggle Fullscreen, Switch Display,
Screenshot, Switch Tab, etc.) via three-modifier combinations
during a remote session.

Architecture
- Native: src/keyboard/shortcuts.rs intercepts at the encoder
  layer (process_event and process_event_with_session), so the
  feature is input-source-independent. Bindings persist as a
  single JSON blob in LocalConfig.
- Web: matching + keydown intercept live in the separate hand-
  written TS client at flutter/web/js/ (gitignored, not in this
  repo). flutter/lib/web/bridge.dart::mainInit registers
  window.onShortcutTriggered so the JS matcher can dispatch
  back into the active session's ShortcutModel; the bridge's
  mainReloadKeyboardShortcuts forwards to a JS reloadShortcuts
  on settings writes.
- Three-modifier prefix (Ctrl+Alt+Shift; Cmd+Option+Shift on
  macOS/iOS) sidesteps the need for a pass-through toggle.
- Flutter native path threads the explicit per-call SessionID
  for tab-precise routing; rdev path uses globally-current
  session.

UI
- Settings -> General -> Keyboard Shortcuts opens a dedicated
  configuration page; desktop and mobile share a body widget.
- Recording dialog with live capture, prefix validation, and a
  conflict-replace flow.
- Toolbar menu items display the bound shortcut inline.
- Default bindings (adapted from AnyDesk):
    +Del    Send Ctrl+Alt+Del
    +Enter  Toggle Fullscreen
    +Left/Right  Switch Display Prev/Next
    +P      Screenshot
    +1..9   Switch Session Tab

Other
- AGENTS.md: documented (a) flutter_rust_bridge_codegen needs
  a pinned version + Dart bridge wrappers should be hand-
  written, and (b) the Web-target split where flutter/web/js/
  is the runtime owner on Web rather than wasm-compiled Rust.
- 38 new i18n strings in src/lang/en.rs with Chinese
  translations in src/lang/cn.rs.

Refs discussion #1933.
This commit is contained in:
rustdesk
2026-04-28 14:28:02 +08:00
parent bfd31d21e4
commit 04faf21c78
24 changed files with 2028 additions and 12 deletions

View File

@@ -0,0 +1,65 @@
// 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.
class ShortcutDisplay {
static String? formatFor(String actionId) {
final raw = bind.mainGetLocalOption(key: kShortcutLocalConfigKey);
if (raw.isEmpty) return null;
final Map<String, dynamic> parsed;
try {
parsed = jsonDecode(raw) as Map<String, dynamic>;
} catch (_) {
return null;
}
if (parsed['enabled'] != 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>[];
final parts = <String>[];
for (final m in ['primary', 'alt', 'shift']) {
if (!mods.contains(m)) continue;
switch (m) {
case 'primary': parts.add(isMac ? '' : 'Ctrl'); break;
case 'alt': parts.add(isMac ? '' : 'Alt'); break;
case 'shift': parts.add(isMac ? '' : 'Shift'); break;
}
}
parts.add(_keyDisplay(keyValue, isMac));
return isMac ? parts.join('') : parts.join('+');
}
static String _keyDisplay(String key, bool isMac) {
switch (key) {
case 'delete': return isMac ? '' : 'Del';
case 'enter': return isMac ? '' : 'Enter';
case 'arrow_left': return '';
case 'arrow_right':return '';
case 'arrow_up': return '';
case 'arrow_down': return '';
}
if (key.startsWith('digit')) return key.substring(5);
return key.toUpperCase();
}
}

View File

@@ -0,0 +1,490 @@
// flutter/lib/common/widgets/keyboard_shortcuts/page_body.dart
//
// Shared body widget for the Keyboard Shortcuts configuration page. Both the
// desktop (`desktop/pages/desktop_keyboard_shortcuts_page.dart`) and mobile
// (`mobile/pages/mobile_keyboard_shortcuts_page.dart`) pages render this
// widget inside their own platform-styled Scaffold + AppBar shell.
//
// The body owns:
// * the top-level enable/disable toggle (mirrors the General-tab toggle —
// same JSON key, same semantics);
// * a grouped list of actions, each with its current binding plus
// edit / clear icons;
// * the JSON read/write helpers under [kShortcutLocalConfigKey] in the
// canonical {enabled, bindings:[{action,mods,key}]} shape;
// * the recording-dialog round-trip and conflict-replace bookkeeping;
// * "Reset to defaults" (called from the platform AppBar).
//
// Platform shells supply only:
// * the AppBar (with a "Reset to defaults" action that calls
// [KeyboardShortcutsPageBodyState.resetToDefaultsWithConfirm]);
// * surrounding padding / list-tile vs. dense-row visuals via the
// [compact] flag.
import 'dart:convert';
import 'package:flutter/foundation.dart';
import 'package:flutter/material.dart';
import '../../../common.dart';
import '../../../consts.dart';
import '../../../models/platform_model.dart';
import '../../../models/shortcut_model.dart';
import 'recording_dialog.dart';
/// One configurable action — id + i18n key for its label.
class KeyboardShortcutActionEntry {
final String id;
final String labelKey;
const KeyboardShortcutActionEntry(this.id, this.labelKey);
}
/// A named group of actions (e.g. "Session Control").
class KeyboardShortcutActionGroup {
final String titleKey;
final List<KeyboardShortcutActionEntry> actions;
const KeyboardShortcutActionGroup(this.titleKey, this.actions);
}
/// Canonical action group definitions used by both the desktop and mobile
/// configuration pages. The order of groups and entries here is the order
/// the user sees in the UI. (Not `const` because the per-tab ids come from
/// the `kShortcutActionSwitchTab(n)` helper in `consts.dart`.)
final List<KeyboardShortcutActionGroup> kKeyboardShortcutActionGroups = [
KeyboardShortcutActionGroup('Session Control', [
KeyboardShortcutActionEntry(
kShortcutActionSendCtrlAltDel, 'Insert Ctrl + Alt + Del'),
KeyboardShortcutActionEntry(kShortcutActionInsertLock, 'Insert Lock'),
KeyboardShortcutActionEntry(kShortcutActionRefresh, 'Refresh'),
KeyboardShortcutActionEntry(kShortcutActionSwitchSides, 'Switch Sides'),
KeyboardShortcutActionEntry(
kShortcutActionToggleRecording, 'Toggle Recording'),
KeyboardShortcutActionEntry(
kShortcutActionToggleBlockInput, 'Toggle Block User Input'),
]),
KeyboardShortcutActionGroup('Display', [
KeyboardShortcutActionEntry(
kShortcutActionToggleFullscreen, 'Toggle Fullscreen'),
KeyboardShortcutActionEntry(
kShortcutActionSwitchDisplayNext, 'Switch to next display'),
KeyboardShortcutActionEntry(
kShortcutActionSwitchDisplayPrev, 'Switch to previous display'),
KeyboardShortcutActionEntry(kShortcutActionViewMode1to1, 'View Mode 1:1'),
KeyboardShortcutActionEntry(
kShortcutActionViewModeShrink, 'View Mode Shrink'),
KeyboardShortcutActionEntry(
kShortcutActionViewModeStretch, 'View Mode Stretch'),
]),
KeyboardShortcutActionGroup('Other', [
KeyboardShortcutActionEntry(kShortcutActionScreenshot, 'Take Screenshot'),
KeyboardShortcutActionEntry(kShortcutActionToggleAudio, 'Toggle Audio'),
KeyboardShortcutActionEntry(
kShortcutActionTogglePrivacyMode, 'Toggle Privacy Mode'),
for (var n = 1; n <= 9; n++)
KeyboardShortcutActionEntry(
kShortcutActionSwitchTab(n), 'Switch Tab $n'),
]),
];
/// The shared body widget. Render this inside a platform-styled Scaffold.
///
/// [compact] toggles the desktop dense-row layout (`true`) versus the mobile
/// touch-friendly ListTile layout (`false`).
///
/// [editButtonHint] is shown as the tooltip on the Edit icon. Mobile shells
/// use this to clarify that recording requires a physical keyboard.
///
/// [headerBanner] is an optional widget rendered above the toggle. Mobile
/// uses this to show the "Recording requires a physical keyboard" hint.
class KeyboardShortcutsPageBody extends StatefulWidget {
final bool compact;
final String? editButtonHint;
final Widget? headerBanner;
const KeyboardShortcutsPageBody({
Key? key,
this.compact = true,
this.editButtonHint,
this.headerBanner,
}) : super(key: key);
@override
State<KeyboardShortcutsPageBody> createState() =>
KeyboardShortcutsPageBodyState();
}
/// Public state so platform shells can call [resetToDefaultsWithConfirm] from
/// their AppBar action.
class KeyboardShortcutsPageBodyState extends State<KeyboardShortcutsPageBody> {
// ----- Persistence helpers -----
Map<String, dynamic> _readJson() {
final raw = bind.mainGetLocalOption(key: kShortcutLocalConfigKey);
if (raw.isEmpty) return {'enabled': false, 'bindings': <dynamic>[]};
try {
final parsed = jsonDecode(raw) as Map<String, dynamic>;
parsed['bindings'] ??= <dynamic>[];
parsed['enabled'] ??= false;
return parsed;
} catch (_) {
return {'enabled': false, 'bindings': <dynamic>[]};
}
}
Future<void> _writeJson(Map<String, dynamic> json) async {
await bind.mainSetLocalOption(
key: kShortcutLocalConfigKey, value: jsonEncode(json));
// Refresh the matcher cache so writes take effect immediately. On native
// this hits the Rust matcher; on Web the bridge forwards to the JS-side
// matcher in flutter/web/js/.
bind.mainReloadKeyboardShortcuts();
if (mounted) setState(() {});
}
/// Replace the bindings entry for [actionId] with [binding]. If [binding]
/// is null, removes the existing entry. If the user is replacing a
/// conflicting binding, [clearActionId] points at the action whose
/// (now-stale) binding should be removed in the same write.
Future<void> _setBinding(
String actionId, {
Map<String, dynamic>? binding,
String? clearActionId,
}) async {
final json = _readJson();
final list = ((json['bindings'] as List?) ?? <dynamic>[])
.cast<Map<String, dynamic>>()
.toList();
list.removeWhere((b) {
final a = b['action'];
return a == actionId || (clearActionId != null && a == clearActionId);
});
if (binding != null) {
list.add(binding);
}
json['bindings'] = list;
await _writeJson(json);
}
Future<void> _setEnabled(bool v) async {
final json = _readJson();
json['enabled'] = v;
// First-time enable: seed defaults if the user has never bound anything.
final list = (json['bindings'] as List?) ?? const [];
if (v && list.isEmpty) {
json['bindings'] = jsonDecode(bind.mainGetDefaultKeyboardShortcuts());
}
await _writeJson(json);
}
Future<void> _resetToDefaults() async {
final json = _readJson();
json['bindings'] = jsonDecode(bind.mainGetDefaultKeyboardShortcuts());
await _writeJson(json);
}
String _labelFor(String actionId) {
for (final g in kKeyboardShortcutActionGroups) {
for (final a in g.actions) {
if (a.id == actionId) return translate(a.labelKey);
}
}
return actionId;
}
// ----- UI handlers -----
Future<void> _onEdit(KeyboardShortcutActionEntry entry) async {
final json = _readJson();
final bindings = ((json['bindings'] as List?) ?? <dynamic>[])
.cast<Map<String, dynamic>>();
final result = await showRecordingDialog(
context: context,
actionId: entry.id,
actionLabel: translate(entry.labelKey),
existingBindings: bindings,
actionLabelLookup: _labelFor,
);
if (result == null) return;
await _setBinding(
entry.id,
binding: result.binding,
clearActionId: result.clearActionId,
);
}
Future<void> _onClear(KeyboardShortcutActionEntry entry) async {
await _setBinding(entry.id, binding: null);
}
/// Public — invoked from the platform AppBar action.
Future<void> resetToDefaultsWithConfirm() async {
final confirmed = await showDialog<bool>(
context: context,
builder: (ctx) => AlertDialog(
title: Text(translate('Reset to defaults')),
content: Text(translate('shortcut-reset-confirm-tip')),
actions: [
dialogButton('Cancel',
onPressed: () => Navigator.of(ctx).pop(false),
isOutline: true),
dialogButton('OK', onPressed: () => Navigator.of(ctx).pop(true)),
],
),
);
if (confirmed == true) {
await _resetToDefaults();
}
}
// ----- Build -----
@override
Widget build(BuildContext context) {
final enabled = ShortcutModel.isEnabled();
final theme = Theme.of(context);
return ListView(
padding: const EdgeInsets.all(16),
children: [
if (widget.headerBanner != null) ...[
widget.headerBanner!,
const SizedBox(height: 12),
],
// Top toggle — mirrors the General-tab _OptionCheckBox semantics.
Row(
children: [
Checkbox(
value: enabled,
onChanged: (v) async {
if (v == null) return;
await _setEnabled(v);
},
),
const SizedBox(width: 4),
Expanded(
child: GestureDetector(
behavior: HitTestBehavior.opaque,
onTap: () => _setEnabled(!enabled),
child: Text(
translate('Enable keyboard shortcuts in remote session'),
),
),
),
],
),
const SizedBox(height: 8),
Padding(
padding: const EdgeInsets.symmetric(horizontal: 8),
child: Text(
translate('shortcut-page-description'),
style: TextStyle(color: theme.hintColor),
),
),
const SizedBox(height: 16),
// Disabled visual state when toggle is off — but still scrollable.
Opacity(
opacity: enabled ? 1.0 : 0.5,
child: AbsorbPointer(
absorbing: !enabled,
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
for (final group in kKeyboardShortcutActionGroups)
_buildGroup(context, group),
],
),
),
),
],
);
}
Widget _buildGroup(BuildContext context, KeyboardShortcutActionGroup group) {
return Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
const SizedBox(height: 12),
Padding(
padding: const EdgeInsets.symmetric(horizontal: 8),
child: Row(
children: [
Text(
translate(group.titleKey),
style: TextStyle(
fontWeight: FontWeight.w600,
color: Theme.of(context).colorScheme.primary,
),
),
const SizedBox(width: 8),
const Expanded(
child: Divider(thickness: 1),
),
],
),
),
const SizedBox(height: 4),
for (final action in group.actions)
widget.compact
? _buildCompactRow(context, action)
: _buildTouchRow(context, action),
],
);
}
/// Desktop dense row: label | shortcut | edit | clear, all in one Row.
Widget _buildCompactRow(
BuildContext context, KeyboardShortcutActionEntry entry) {
final shortcut = ShortcutDisplayForActionId.format(entry.id);
final hasBinding = shortcut != null;
return Padding(
padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 6),
child: Row(
children: [
Expanded(
flex: 5,
child: Text(translate(entry.labelKey)),
),
Expanded(
flex: 4,
child: Text(
shortcut ?? '',
style: TextStyle(
fontFamily: defaultTargetPlatform == TargetPlatform.windows
? 'Consolas'
: 'monospace',
color: hasBinding ? null : Theme.of(context).hintColor,
),
),
),
IconButton(
tooltip: widget.editButtonHint ?? translate('Edit'),
onPressed: () => _onEdit(entry),
icon: const Icon(Icons.edit_outlined, size: 18),
),
SizedBox(
width: 40,
child: hasBinding
? IconButton(
tooltip: translate('Clear'),
onPressed: () => _onClear(entry),
icon: const Icon(Icons.close, size: 18),
)
: const SizedBox.shrink(),
),
],
),
);
}
/// Mobile touch row: ListTile with title + subtitle + trailing icons.
Widget _buildTouchRow(
BuildContext context, KeyboardShortcutActionEntry entry) {
final shortcut = ShortcutDisplayForActionId.format(entry.id);
final hasBinding = shortcut != null;
return ListTile(
dense: false,
contentPadding: const EdgeInsets.symmetric(horizontal: 8),
title: Text(translate(entry.labelKey)),
subtitle: Text(
shortcut ?? '',
style: TextStyle(
fontFamily: defaultTargetPlatform == TargetPlatform.windows
? 'Consolas'
: 'monospace',
color: hasBinding ? null : Theme.of(context).hintColor,
),
),
trailing: Row(
mainAxisSize: MainAxisSize.min,
children: [
IconButton(
tooltip: widget.editButtonHint ?? translate('Edit'),
onPressed: () => _onEdit(entry),
icon: const Icon(Icons.edit_outlined),
),
if (hasBinding)
IconButton(
tooltip: translate('Clear'),
onPressed: () => _onClear(entry),
icon: const Icon(Icons.close),
)
else
const SizedBox(width: 48),
],
),
);
}
}
/// Thin wrapper around [ShortcutDisplay.formatFor] that ignores the
/// `enabled` flag so the configuration page can always show the user what
/// they have bound, even when the feature is currently disabled.
class ShortcutDisplayForActionId {
static String? format(String actionId) {
final raw = bind.mainGetLocalOption(key: kShortcutLocalConfigKey);
if (raw.isEmpty) return null;
final Map<String, dynamic> parsed;
try {
parsed = jsonDecode(raw) as Map<String, dynamic>;
} catch (_) {
return null;
}
final list = (parsed['bindings'] as List? ?? const [])
.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 — render the row as unbound instead of crashing the
// settings page.
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>[];
final parts = <String>[];
for (final m in ['primary', 'alt', 'shift']) {
if (!mods.contains(m)) continue;
switch (m) {
case 'primary':
parts.add(isMac ? '' : 'Ctrl');
break;
case 'alt':
parts.add(isMac ? '' : 'Alt');
break;
case 'shift':
parts.add(isMac ? '' : 'Shift');
break;
}
}
parts.add(_keyDisplay(keyValue, isMac));
return isMac ? parts.join('') : parts.join('+');
}
static String _keyDisplay(String key, bool isMac) {
switch (key) {
case 'delete':
return isMac ? '' : 'Del';
case 'enter':
return isMac ? '' : 'Enter';
case 'arrow_left':
return '';
case 'arrow_right':
return '';
case 'arrow_up':
return '';
case 'arrow_down':
return '';
}
if (key.startsWith('digit')) return key.substring(5);
return key.toUpperCase();
}
}

View File

@@ -0,0 +1,371 @@
// flutter/lib/common/widgets/keyboard_shortcuts/recording_dialog.dart
//
// Modal dialog used by the Keyboard Shortcuts settings page to capture a new
// key combination for a given action. The dialog listens for KeyDown events,
// extracts the modifier set + non-modifier key, validates against the
// "must include Ctrl+Alt+Shift (Cmd+Option+Shift on macOS)" rule, and reports
// any conflict with another already-bound action.
//
// On Save, returns the new binding map ({action, mods, key}) plus the
// optional id of the action whose binding should be cleared (the conflict
// "Replace" path). On Cancel, returns null.
import 'package:flutter/foundation.dart';
import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
import '../../../common.dart';
/// Result of the recording dialog.
class RecordingResult {
/// The new binding map to write: {action, mods, key}.
final Map<String, dynamic> binding;
/// If the chosen combo conflicted with another action, the user chose
/// "Replace" — the caller must clear this action's binding before writing
/// the new one.
final String? clearActionId;
RecordingResult(this.binding, this.clearActionId);
}
/// Show the recording dialog.
///
/// [actionId] is the action being edited (used for the title and to detect
/// "binding to itself" — that's not a conflict).
/// [actionLabel] is the translated, user-facing action name.
/// [existingBindings] is the current bindings list (used for conflict detection).
/// [actionLabelLookup] resolves an actionId to its translated label, used in
/// the conflict warning.
Future<RecordingResult?> showRecordingDialog({
required BuildContext context,
required String actionId,
required String actionLabel,
required List<Map<String, dynamic>> existingBindings,
required String Function(String) actionLabelLookup,
}) {
return showDialog<RecordingResult>(
context: context,
barrierDismissible: false,
builder: (ctx) => _RecordingDialog(
actionId: actionId,
actionLabel: actionLabel,
existingBindings: existingBindings,
actionLabelLookup: actionLabelLookup,
),
);
}
class _RecordingDialog extends StatefulWidget {
final String actionId;
final String actionLabel;
final List<Map<String, dynamic>> existingBindings;
final String Function(String) actionLabelLookup;
const _RecordingDialog({
required this.actionId,
required this.actionLabel,
required this.existingBindings,
required this.actionLabelLookup,
});
@override
State<_RecordingDialog> createState() => _RecordingDialogState();
}
class _RecordingDialogState extends State<_RecordingDialog> {
final FocusNode _focusNode = FocusNode();
// Captured combo. null until the user presses something with a non-modifier.
Set<String> _mods = {};
String? _key;
@override
void initState() {
super.initState();
WidgetsBinding.instance.addPostFrameCallback((_) {
_focusNode.requestFocus();
});
}
@override
void dispose() {
_focusNode.dispose();
super.dispose();
}
bool get _isMac =>
defaultTargetPlatform == TargetPlatform.macOS ||
defaultTargetPlatform == TargetPlatform.iOS;
/// True when the captured combo includes the required Ctrl+Alt+Shift
/// (Cmd+Option+Shift on macOS) prefix and a non-modifier key.
bool get _hasRequiredPrefix =>
_mods.contains('primary') &&
_mods.contains('alt') &&
_mods.contains('shift');
/// Return the actionId that this combo currently conflicts with, or null.
/// The action being edited is not a conflict with itself.
String? get _conflictActionId {
if (_key == null || !_hasRequiredPrefix) return null;
for (final b in widget.existingBindings) {
final otherAction = b['action'] as String?;
if (otherAction == null || otherAction == widget.actionId) continue;
final otherKey = b['key'] as String?;
final otherMods =
((b['mods'] as List?) ?? const []).cast<String>().toSet();
if (otherKey == _key &&
otherMods.length == _mods.length &&
otherMods.containsAll(_mods)) {
return otherAction;
}
}
return null;
}
KeyEventResult _onKeyEvent(FocusNode node, KeyEvent event) {
if (event is KeyDownEvent && event.logicalKey == LogicalKeyboardKey.escape) {
Navigator.of(context).pop();
return KeyEventResult.handled;
}
if (event is! KeyDownEvent) return KeyEventResult.handled;
// Ignore modifier-only KeyDowns: don't lock in a partial combo.
final logical = event.logicalKey;
final keyName = _logicalToKeyName(logical);
final mods = <String>{};
if (HardwareKeyboard.instance.isAltPressed) mods.add('alt');
if (HardwareKeyboard.instance.isShiftPressed) mods.add('shift');
final primary = _isMac
? HardwareKeyboard.instance.isMetaPressed
: HardwareKeyboard.instance.isControlPressed;
if (primary) mods.add('primary');
setState(() {
_mods = mods;
// Only lock in the key when it's a non-modifier we recognize.
// Modifier-only KeyDowns (Shift, Ctrl, etc.) leave the captured key
// untouched, so the user can adjust modifiers after the fact.
if (keyName != null) {
_key = keyName;
}
});
return KeyEventResult.handled;
}
void _onSave() {
if (_key == null || !_hasRequiredPrefix) return;
// Sort mods to match the canonical order used by Rust default_bindings:
// primary, alt, shift.
final ordered = <String>[
if (_mods.contains('primary')) 'primary',
if (_mods.contains('alt')) 'alt',
if (_mods.contains('shift')) 'shift',
];
final binding = <String, dynamic>{
'action': widget.actionId,
'mods': ordered,
'key': _key!,
};
Navigator.of(context).pop(RecordingResult(binding, _conflictActionId));
}
String _formatPrefix() {
if (_isMac) return 'Cmd+Option+Shift';
return 'Ctrl+Alt+Shift';
}
String _formatCombo() {
final parts = <String>[];
for (final m in ['primary', 'alt', 'shift']) {
if (!_mods.contains(m)) continue;
switch (m) {
case 'primary':
parts.add(_isMac ? '' : 'Ctrl');
break;
case 'alt':
parts.add(_isMac ? '' : 'Alt');
break;
case 'shift':
parts.add(_isMac ? '' : 'Shift');
break;
}
}
if (_key != null) {
parts.add(_keyDisplay(_key!));
}
if (parts.isEmpty) return translate('shortcut-recording-press-keys-tip');
return _isMac ? parts.join('') : parts.join('+');
}
String _keyDisplay(String key) {
switch (key) {
case 'delete':
return _isMac ? '' : 'Del';
case 'enter':
return _isMac ? '' : 'Enter';
case 'arrow_left':
return '';
case 'arrow_right':
return '';
case 'arrow_up':
return '';
case 'arrow_down':
return '';
}
if (key.startsWith('digit')) return key.substring(5);
return key.toUpperCase();
}
@override
Widget build(BuildContext context) {
final hasKey = _key != null;
final conflictId = _conflictActionId;
final hasConflict = conflictId != null;
final canSave = hasKey && _hasRequiredPrefix;
Widget statusLine;
if (!hasKey) {
statusLine = Text(
translate('shortcut-recording-press-keys-tip'),
style: TextStyle(color: Theme.of(context).hintColor),
);
} else if (!_hasRequiredPrefix) {
statusLine = Row(
children: [
Icon(Icons.close, size: 16, color: Colors.red),
const SizedBox(width: 6),
Flexible(
child: Text(
'${translate('shortcut-must-include-prefix')} ${_formatPrefix()}',
style: const TextStyle(color: Colors.red),
),
),
],
);
} else if (hasConflict) {
final otherLabel = widget.actionLabelLookup(conflictId);
statusLine = Row(
children: [
Icon(Icons.warning_amber_outlined,
size: 16, color: Colors.orange.shade700),
const SizedBox(width: 6),
Flexible(
child: Text(
'${translate('shortcut-already-bound-to')} "$otherLabel"',
style: TextStyle(color: Colors.orange.shade700),
),
),
],
);
} else {
statusLine = Row(
children: [
const Icon(Icons.check, size: 16, color: Colors.green),
const SizedBox(width: 6),
Text(translate('Valid'),
style: const TextStyle(color: Colors.green)),
],
);
}
final saveLabel = hasConflict ? 'Replace' : 'Save';
return AlertDialog(
title: Text(
'${translate('Set Shortcut')}: ${widget.actionLabel}',
),
content: Focus(
focusNode: _focusNode,
autofocus: true,
onKeyEvent: _onKeyEvent,
child: ConstrainedBox(
constraints: const BoxConstraints(minWidth: 380),
child: Column(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(translate('shortcut-recording-instruction')),
const SizedBox(height: 12),
Container(
width: double.infinity,
padding: const EdgeInsets.symmetric(
vertical: 18, horizontal: 12),
decoration: BoxDecoration(
border: Border.all(color: Theme.of(context).dividerColor),
borderRadius: BorderRadius.circular(4),
),
child: Text(
_formatCombo(),
textAlign: TextAlign.center,
style: TextStyle(
fontSize: 18,
fontWeight: FontWeight.w600,
color: hasKey
? Theme.of(context).textTheme.titleLarge?.color
: Theme.of(context).hintColor,
),
),
),
const SizedBox(height: 12),
statusLine,
],
),
),
),
actions: [
dialogButton('Cancel',
onPressed: () => Navigator.of(context).pop(),
isOutline: true),
dialogButton(saveLabel, onPressed: canSave ? _onSave : null),
],
);
}
/// Mirror of `event_to_key_name` in `src/keyboard/shortcuts.rs` and
/// `logicalToKeyName` in `flutter/web/js/src/shortcut_matcher.ts` — keep
/// the three in lockstep. Returns null for modifier-only or unsupported keys.
static String? _logicalToKeyName(LogicalKeyboardKey k) {
if (k == LogicalKeyboardKey.delete) return 'delete';
if (k == LogicalKeyboardKey.enter ||
k == LogicalKeyboardKey.numpadEnter) return 'enter';
if (k == LogicalKeyboardKey.arrowLeft) return 'arrow_left';
if (k == LogicalKeyboardKey.arrowRight) return 'arrow_right';
if (k == LogicalKeyboardKey.arrowUp) return 'arrow_up';
if (k == LogicalKeyboardKey.arrowDown) return 'arrow_down';
final letters = <LogicalKeyboardKey, String>{
LogicalKeyboardKey.keyA: 'a', LogicalKeyboardKey.keyB: 'b',
LogicalKeyboardKey.keyC: 'c', LogicalKeyboardKey.keyD: 'd',
LogicalKeyboardKey.keyE: 'e', LogicalKeyboardKey.keyF: 'f',
LogicalKeyboardKey.keyG: 'g', LogicalKeyboardKey.keyH: 'h',
LogicalKeyboardKey.keyI: 'i', LogicalKeyboardKey.keyJ: 'j',
LogicalKeyboardKey.keyK: 'k', LogicalKeyboardKey.keyL: 'l',
LogicalKeyboardKey.keyM: 'm', LogicalKeyboardKey.keyN: 'n',
LogicalKeyboardKey.keyO: 'o', LogicalKeyboardKey.keyP: 'p',
LogicalKeyboardKey.keyQ: 'q', LogicalKeyboardKey.keyR: 'r',
LogicalKeyboardKey.keyS: 's', LogicalKeyboardKey.keyT: 't',
LogicalKeyboardKey.keyU: 'u', LogicalKeyboardKey.keyV: 'v',
LogicalKeyboardKey.keyW: 'w', LogicalKeyboardKey.keyX: 'x',
LogicalKeyboardKey.keyY: 'y', LogicalKeyboardKey.keyZ: 'z',
};
if (letters.containsKey(k)) return letters[k];
final digits = <LogicalKeyboardKey, String>{
LogicalKeyboardKey.digit1: 'digit1',
LogicalKeyboardKey.digit2: 'digit2',
LogicalKeyboardKey.digit3: 'digit3',
LogicalKeyboardKey.digit4: 'digit4',
LogicalKeyboardKey.digit5: 'digit5',
LogicalKeyboardKey.digit6: 'digit6',
LogicalKeyboardKey.digit7: 'digit7',
LogicalKeyboardKey.digit8: 'digit8',
LogicalKeyboardKey.digit9: 'digit9',
};
if (digits.containsKey(k)) return digits[k];
return null;
}
}

View File

@@ -21,11 +21,13 @@ class TTextMenu {
final VoidCallback? onPressed;
Widget? trailingIcon;
bool divider;
final String? actionId;
TTextMenu(
{required this.child,
required this.onPressed,
this.trailingIcon,
this.divider = false});
this.divider = false,
this.actionId});
Widget getChild() {
if (trailingIcon != null) {
@@ -229,7 +231,8 @@ List<TTextMenu> toolbarControls(BuildContext context, String id, FFI ffi) {
v.add(
TTextMenu(
child: Text('${translate("Insert Ctrl + Alt + Del")}'),
onPressed: () => bind.sessionCtrlAltDel(sessionId: sessionId)),
onPressed: () => bind.sessionCtrlAltDel(sessionId: sessionId),
actionId: kShortcutActionSendCtrlAltDel),
);
}
// restart
@@ -250,7 +253,8 @@ List<TTextMenu> toolbarControls(BuildContext context, String id, FFI ffi) {
v.add(
TTextMenu(
child: Text(translate('Insert Lock')),
onPressed: () => bind.sessionLockScreen(sessionId: sessionId)),
onPressed: () => bind.sessionLockScreen(sessionId: sessionId),
actionId: kShortcutActionInsertLock),
);
}
// blockUserInput
@@ -268,7 +272,8 @@ List<TTextMenu> toolbarControls(BuildContext context, String id, FFI ffi) {
sessionId: sessionId,
value: '${blockInput.value ? 'un' : ''}block-input');
blockInput.value = !blockInput.value;
}));
},
actionId: kShortcutActionToggleBlockInput));
}
// switchSides
if (isDefaultConn &&
@@ -280,13 +285,15 @@ List<TTextMenu> toolbarControls(BuildContext context, String id, FFI ffi) {
v.add(TTextMenu(
child: Text(translate('Switch Sides')),
onPressed: () =>
showConfirmSwitchSidesDialog(sessionId, id, ffi.dialogManager)));
showConfirmSwitchSidesDialog(sessionId, id, ffi.dialogManager),
actionId: kShortcutActionSwitchSides));
}
// refresh
if (pi.version.isNotEmpty) {
v.add(TTextMenu(
child: Text(translate('Refresh')),
onPressed: () => sessionRefreshVideo(sessionId, pi),
actionId: kShortcutActionRefresh,
));
}
// record
@@ -308,7 +315,8 @@ List<TTextMenu> toolbarControls(BuildContext context, String id, FFI ffi) {
)
],
),
onPressed: () => ffi.recordingModel.toggle()));
onPressed: () => ffi.recordingModel.toggle(),
actionId: kShortcutActionToggleRecording));
}
// to-do:
@@ -342,6 +350,7 @@ List<TTextMenu> toolbarControls(BuildContext context, String id, FFI ffi) {
});
}
},
actionId: kShortcutActionScreenshot,
));
}
}
@@ -352,6 +361,13 @@ List<TTextMenu> toolbarControls(BuildContext context, String id, FFI ffi) {
onPressed: () => onCopyFingerprint(FingerprintState.find(id).value),
));
}
// Register tagged callbacks with the shortcut model so global keyboard
// shortcuts can dispatch the same actions as the toolbar menu items.
for (final menu in v) {
if (menu.actionId != null && menu.onPressed != null) {
ffi.shortcutModel.register(menu.actionId!, menu.onPressed!);
}
}
return v;
}