mirror of
https://github.com/rustdesk/rustdesk.git
synced 2026-05-06 22:28:13 +03:00
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:
11
flutter/test/fixtures/default_keyboard_shortcuts.json
vendored
Normal file
11
flutter/test/fixtures/default_keyboard_shortcuts.json
vendored
Normal file
@@ -0,0 +1,11 @@
|
||||
[
|
||||
{"action": "send_ctrl_alt_del", "mods": ["primary", "alt", "shift"], "key": "delete"},
|
||||
{"action": "toggle_fullscreen", "mods": ["primary", "alt", "shift"], "key": "enter"},
|
||||
{"action": "switch_display_next", "mods": ["primary", "alt", "shift"], "key": "arrow_right"},
|
||||
{"action": "switch_display_prev", "mods": ["primary", "alt", "shift"], "key": "arrow_left"},
|
||||
{"action": "screenshot", "mods": ["primary", "alt", "shift"], "key": "p"},
|
||||
{"action": "toggle_show_remote_cursor", "mods": ["primary", "alt", "shift"], "key": "m"},
|
||||
{"action": "toggle_mute", "mods": ["primary", "alt", "shift"], "key": "s"},
|
||||
{"action": "toggle_block_input", "mods": ["primary", "alt", "shift"], "key": "i"},
|
||||
{"action": "toggle_chat", "mods": ["primary", "alt", "shift"], "key": "c"}
|
||||
]
|
||||
11
flutter/test/fixtures/supported_shortcut_keys.json
vendored
Normal file
11
flutter/test/fixtures/supported_shortcut_keys.json
vendored
Normal file
@@ -0,0 +1,11 @@
|
||||
[
|
||||
"a", "b", "c", "d", "e", "f", "g", "h", "i", "j", "k", "l", "m",
|
||||
"n", "o", "p", "q", "r", "s", "t", "u", "v", "w", "x", "y", "z",
|
||||
"digit0", "digit1", "digit2", "digit3", "digit4",
|
||||
"digit5", "digit6", "digit7", "digit8", "digit9",
|
||||
"f1", "f2", "f3", "f4", "f5", "f6",
|
||||
"f7", "f8", "f9", "f10", "f11", "f12",
|
||||
"delete", "backspace", "tab", "space", "enter",
|
||||
"arrow_left", "arrow_right", "arrow_up", "arrow_down",
|
||||
"home", "end", "page_up", "page_down", "insert"
|
||||
]
|
||||
426
flutter/test/keyboard_shortcuts_test.dart
Normal file
426
flutter/test/keyboard_shortcuts_test.dart
Normal file
@@ -0,0 +1,426 @@
|
||||
import 'dart:convert';
|
||||
import 'dart:io';
|
||||
|
||||
import 'package:flutter/services.dart';
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
import 'package:flutter_hbb/common/widgets/keyboard_shortcuts/shortcut_actions.dart';
|
||||
import 'package:flutter_hbb/common/widgets/keyboard_shortcuts/shortcut_constants.dart';
|
||||
import 'package:flutter_hbb/common/widgets/keyboard_shortcuts/shortcut_utils.dart';
|
||||
|
||||
ShortcutPlatformCapabilities capabilities({
|
||||
bool includeFullscreenShortcut = true,
|
||||
bool includeScreenshotShortcut = true,
|
||||
bool includeTabShortcuts = true,
|
||||
bool includeToolbarShortcut = true,
|
||||
bool includeCloseTabShortcut = true,
|
||||
bool includeSwitchSidesShortcut = true,
|
||||
bool includeRecordingShortcut = true,
|
||||
bool includeResetCanvasShortcut = true,
|
||||
bool includePinToolbarShortcut = true,
|
||||
bool includeViewModeShortcut = true,
|
||||
bool includeInputSourceShortcut = true,
|
||||
bool includeVoiceCallShortcut = true,
|
||||
}) {
|
||||
return ShortcutPlatformCapabilities(
|
||||
includeFullscreenShortcut: includeFullscreenShortcut,
|
||||
includeScreenshotShortcut: includeScreenshotShortcut,
|
||||
includeTabShortcuts: includeTabShortcuts,
|
||||
includeToolbarShortcut: includeToolbarShortcut,
|
||||
includeCloseTabShortcut: includeCloseTabShortcut,
|
||||
includeSwitchSidesShortcut: includeSwitchSidesShortcut,
|
||||
includeRecordingShortcut: includeRecordingShortcut,
|
||||
includeResetCanvasShortcut: includeResetCanvasShortcut,
|
||||
includePinToolbarShortcut: includePinToolbarShortcut,
|
||||
includeViewModeShortcut: includeViewModeShortcut,
|
||||
includeInputSourceShortcut: includeInputSourceShortcut,
|
||||
includeVoiceCallShortcut: includeVoiceCallShortcut,
|
||||
);
|
||||
}
|
||||
|
||||
void main() {
|
||||
test('kDefaultShortcutBindings matches fixture', () {
|
||||
// The fixture is the cross-language source of truth for default
|
||||
// bindings. Rust has its own parity test against the same file
|
||||
// (`default_bindings_match_fixture_json` in src/keyboard/shortcuts.rs),
|
||||
// so a drift on either side breaks CI.
|
||||
final fixturePath = 'test/fixtures/default_keyboard_shortcuts.json';
|
||||
final fixture =
|
||||
jsonDecode(File(fixturePath).readAsStringSync()) as List<dynamic>;
|
||||
expect(kDefaultShortcutBindings, equals(fixture),
|
||||
reason: 'kDefaultShortcutBindings drifted from $fixturePath — update '
|
||||
'shortcut_constants.dart, the fixture, and Rust default_bindings() '
|
||||
'together');
|
||||
});
|
||||
|
||||
test('save order preserves macOS control modifier', () {
|
||||
expect(canonicalShortcutModsForSave({'ctrl'}), ['ctrl']);
|
||||
expect(canonicalShortcutModsForSave({'shift', 'ctrl', 'primary', 'alt'}),
|
||||
['primary', 'ctrl', 'alt', 'shift']);
|
||||
});
|
||||
|
||||
test('non-desktop defaults exclude desktop-only and tab shortcuts', () {
|
||||
final defaults = [
|
||||
{
|
||||
'action': kShortcutActionSendCtrlAltDel,
|
||||
'mods': ['primary', 'alt', 'shift'],
|
||||
'key': 'delete',
|
||||
},
|
||||
{
|
||||
'action': kShortcutActionToggleFullscreen,
|
||||
'mods': ['primary', 'alt', 'shift'],
|
||||
'key': 'enter',
|
||||
},
|
||||
{
|
||||
'action': kShortcutActionSwitchDisplayNext,
|
||||
'mods': ['primary', 'alt', 'shift'],
|
||||
'key': 'arrow_right',
|
||||
},
|
||||
{
|
||||
'action': kShortcutActionScreenshot,
|
||||
'mods': ['primary', 'alt', 'shift'],
|
||||
'key': 'p',
|
||||
},
|
||||
{
|
||||
'action': kShortcutActionSwitchTabNext,
|
||||
'mods': ['primary', 'alt', 'shift'],
|
||||
'key': 'right_bracket',
|
||||
},
|
||||
];
|
||||
|
||||
final filtered = filterDefaultBindingsForPlatform(
|
||||
defaults,
|
||||
capabilities(
|
||||
includeFullscreenShortcut: false,
|
||||
includeScreenshotShortcut: false,
|
||||
includeTabShortcuts: false,
|
||||
includeToolbarShortcut: false,
|
||||
includeCloseTabShortcut: false,
|
||||
includeSwitchSidesShortcut: false,
|
||||
includeRecordingShortcut: false,
|
||||
includeResetCanvasShortcut: false,
|
||||
includePinToolbarShortcut: false,
|
||||
includeViewModeShortcut: false,
|
||||
includeInputSourceShortcut: false,
|
||||
includeVoiceCallShortcut: false,
|
||||
),
|
||||
);
|
||||
|
||||
expect(filtered.map((binding) => binding['action']), [
|
||||
kShortcutActionSendCtrlAltDel,
|
||||
kShortcutActionSwitchDisplayNext,
|
||||
]);
|
||||
});
|
||||
|
||||
Set<String> idSet(Iterable<KeyboardShortcutActionGroup> groups) =>
|
||||
{for (final e in allActionEntries(groups)) e.id};
|
||||
|
||||
/// Convenience: extract the children of the named group as a flat list of
|
||||
/// human-readable tokens. Subgroups appear as `'group:<title>'` followed
|
||||
/// by their entries, so call sites can assert on full ordering (subgroups
|
||||
/// interleaved with direct items) in one expectation.
|
||||
List<String> childTokens(
|
||||
List<KeyboardShortcutActionGroup> groups, String titleKey) {
|
||||
final group = groups.firstWhere((g) => g.titleKey == titleKey);
|
||||
final out = <String>[];
|
||||
for (final child in group.children) {
|
||||
switch (child) {
|
||||
case KeyboardShortcutActionEntry():
|
||||
out.add(child.id);
|
||||
case KeyboardShortcutActionSubgroup():
|
||||
out.add('group:${child.titleKey}');
|
||||
for (final entry in child.entries) {
|
||||
out.add(' ${entry.id}');
|
||||
}
|
||||
}
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
test('filterKeyboardShortcutActionGroupsForPlatform strips desktop-only', () {
|
||||
final groups = filterKeyboardShortcutActionGroupsForPlatform(
|
||||
capabilities(
|
||||
includeFullscreenShortcut: false,
|
||||
includeScreenshotShortcut: false,
|
||||
includeTabShortcuts: false,
|
||||
includeToolbarShortcut: false,
|
||||
includeCloseTabShortcut: false,
|
||||
includeSwitchSidesShortcut: false,
|
||||
// Recording / Reset Canvas are intentionally still included here —
|
||||
// they have non-desktop platforms (mobile Android / mobile both).
|
||||
includeRecordingShortcut: true,
|
||||
includeResetCanvasShortcut: true,
|
||||
includePinToolbarShortcut: false,
|
||||
includeViewModeShortcut: false,
|
||||
includeInputSourceShortcut: false,
|
||||
includeVoiceCallShortcut: false,
|
||||
),
|
||||
);
|
||||
final ids = idSet(groups);
|
||||
// Desktop-only actions are stripped.
|
||||
expect(ids, isNot(contains(kShortcutActionToggleFullscreen)));
|
||||
expect(ids, isNot(contains(kShortcutActionScreenshot)));
|
||||
expect(ids, isNot(contains(kShortcutActionToggleToolbar)));
|
||||
expect(ids, isNot(contains(kShortcutActionCloseTab)));
|
||||
expect(ids, isNot(contains(kShortcutActionSwitchSides)));
|
||||
expect(ids, isNot(contains(kShortcutActionPinToolbar)));
|
||||
expect(ids, isNot(contains(kShortcutActionViewModeOriginal)));
|
||||
expect(ids, isNot(contains(kShortcutActionViewModeAdaptive)));
|
||||
expect(ids, isNot(contains(kShortcutActionSwitchTabNext)));
|
||||
expect(ids, isNot(contains(kShortcutActionSwitchTabPrev)));
|
||||
// Cross-platform actions survive.
|
||||
expect(ids, contains(kShortcutActionSendCtrlAltDel));
|
||||
expect(ids, contains(kShortcutActionInsertLock));
|
||||
expect(ids, contains(kShortcutActionRestartRemote));
|
||||
expect(ids, contains(kShortcutActionSwitchDisplayNext));
|
||||
expect(ids, contains(kShortcutActionToggleRecording));
|
||||
expect(ids, contains(kShortcutActionResetCanvas));
|
||||
expect(ids, contains(kShortcutActionToggleMute));
|
||||
});
|
||||
|
||||
test(
|
||||
'filterKeyboardShortcutActionGroupsForPlatform hides Toggle Recording on Web/iOS',
|
||||
() {
|
||||
final groups = filterKeyboardShortcutActionGroupsForPlatform(
|
||||
capabilities(includeRecordingShortcut: false),
|
||||
);
|
||||
final ids = idSet(groups);
|
||||
expect(ids, isNot(contains(kShortcutActionToggleRecording)));
|
||||
// Other Session Control entries unaffected.
|
||||
expect(ids, contains(kShortcutActionSendCtrlAltDel));
|
||||
expect(ids, contains(kShortcutActionInsertLock));
|
||||
});
|
||||
|
||||
test(
|
||||
'filterKeyboardShortcutActionGroupsForPlatform keeps full set on desktop',
|
||||
() {
|
||||
final groups =
|
||||
filterKeyboardShortcutActionGroupsForPlatform(capabilities());
|
||||
expect(idSet(groups), equals(idSet(kKeyboardShortcutActionGroups)));
|
||||
});
|
||||
|
||||
test('shortcut action groups follow toolbar menu order', () {
|
||||
final groups = kKeyboardShortcutActionGroups;
|
||||
|
||||
// Top-level groups in toolbar order.
|
||||
expect(
|
||||
groups.map((g) => g.titleKey).toList(),
|
||||
['Monitor', 'Control Actions', 'Display', 'Keyboard', 'Chat', 'Other'],
|
||||
);
|
||||
|
||||
// Display: subgroups (View Mode → Image Quality → Codec → Virtual
|
||||
// display) first, then direct items (cursor toggles + display toggles),
|
||||
// then Privacy mode subgroup last — exactly matching `_DisplayMenu`.
|
||||
expect(childTokens(groups, 'Display'), [
|
||||
'group:View Mode',
|
||||
' $kShortcutActionViewModeOriginal',
|
||||
' $kShortcutActionViewModeAdaptive',
|
||||
' $kShortcutActionViewModeCustom',
|
||||
'group:Image Quality',
|
||||
' $kShortcutActionImageQualityBest',
|
||||
' $kShortcutActionImageQualityBalanced',
|
||||
' $kShortcutActionImageQualityLow',
|
||||
'group:Codec',
|
||||
' $kShortcutActionCodecAuto',
|
||||
' $kShortcutActionCodecVp8',
|
||||
' $kShortcutActionCodecVp9',
|
||||
' $kShortcutActionCodecAv1',
|
||||
' $kShortcutActionCodecH264',
|
||||
' $kShortcutActionCodecH265',
|
||||
'group:Virtual display',
|
||||
' $kShortcutActionPlugOutAllVirtualDisplays',
|
||||
kShortcutActionToggleShowRemoteCursor,
|
||||
kShortcutActionToggleFollowRemoteCursor,
|
||||
kShortcutActionToggleFollowRemoteWindow,
|
||||
kShortcutActionToggleZoomCursor,
|
||||
kShortcutActionToggleQualityMonitor,
|
||||
kShortcutActionToggleMute,
|
||||
kShortcutActionToggleEnableFileCopyPaste,
|
||||
kShortcutActionToggleDisableClipboard,
|
||||
kShortcutActionToggleLockAfterSessionEnd,
|
||||
kShortcutActionToggleTrueColor,
|
||||
'group:Privacy mode',
|
||||
' $kShortcutActionPrivacyMode1',
|
||||
' $kShortcutActionPrivacyMode2',
|
||||
]);
|
||||
|
||||
// Privacy mode is the last child under Display (matching the toolbar's
|
||||
// submenu order — `_DisplayMenu` adds Privacy mode after the toggles).
|
||||
final displayChildren =
|
||||
groups.firstWhere((g) => g.titleKey == 'Display').children;
|
||||
expect(displayChildren.last, isA<KeyboardShortcutActionSubgroup>());
|
||||
expect(
|
||||
(displayChildren.last as KeyboardShortcutActionSubgroup).titleKey,
|
||||
'Privacy mode',
|
||||
);
|
||||
|
||||
// Keyboard: Keyboard mode subgroup first, then direct items —
|
||||
// matching `_KeyboardMenu`.
|
||||
expect(childTokens(groups, 'Keyboard'), [
|
||||
'group:Keyboard mode',
|
||||
' $kShortcutActionKeyboardModeLegacy',
|
||||
' $kShortcutActionKeyboardModeMap',
|
||||
' $kShortcutActionKeyboardModeTranslate',
|
||||
kShortcutActionToggleInputSource,
|
||||
kShortcutActionToggleViewOnly,
|
||||
kShortcutActionToggleShowMyCursor,
|
||||
kShortcutActionToggleSwapCtrlCmd,
|
||||
kShortcutActionToggleRelativeMouseMode,
|
||||
kShortcutActionToggleReverseMouseWheel,
|
||||
kShortcutActionToggleSwapLeftRightMouse,
|
||||
]);
|
||||
});
|
||||
|
||||
test('filterKeyboardShortcutActionGroupsForPlatform drops empty groups', () {
|
||||
// Sanity: KeyboardShortcutActionGroup ctor still accepts a single direct
|
||||
// entry as a child.
|
||||
final original = [
|
||||
KeyboardShortcutActionGroup('TestGroup', [
|
||||
KeyboardShortcutActionEntry(kShortcutActionCloseTab, 'Close Tab'),
|
||||
]),
|
||||
];
|
||||
expect(original.first.children, hasLength(1));
|
||||
|
||||
// With every capability flag off, groups whose items are all behind
|
||||
// those flags get dropped. Display / Keyboard parent groups still carry
|
||||
// cross-platform direct items so they survive even when the gated
|
||||
// subgroups thin out.
|
||||
final groups = filterKeyboardShortcutActionGroupsForPlatform(
|
||||
capabilities(
|
||||
includeFullscreenShortcut: false,
|
||||
includeScreenshotShortcut: false,
|
||||
includeTabShortcuts: false,
|
||||
includeToolbarShortcut: false,
|
||||
includeCloseTabShortcut: false,
|
||||
includeSwitchSidesShortcut: false,
|
||||
includeRecordingShortcut: false,
|
||||
includeResetCanvasShortcut: false,
|
||||
includePinToolbarShortcut: false,
|
||||
includeViewModeShortcut: false,
|
||||
includeInputSourceShortcut: false,
|
||||
includeVoiceCallShortcut: false,
|
||||
),
|
||||
);
|
||||
final titles = groups.map((g) => g.titleKey).toList();
|
||||
// "Other" has nothing but platform-gated entries → dropped entirely.
|
||||
expect(titles, isNot(contains('Other')));
|
||||
// Parent groups with cross-platform direct items survive.
|
||||
expect(titles, contains('Display'));
|
||||
expect(titles, contains('Keyboard'));
|
||||
// The "View Mode" subgroup under Display is gated by includeViewModeShortcut,
|
||||
// so it must be absent from Display's surviving children.
|
||||
final displayChildren =
|
||||
groups.firstWhere((g) => g.titleKey == 'Display').children;
|
||||
final subgroupTitles = displayChildren
|
||||
.whereType<KeyboardShortcutActionSubgroup>()
|
||||
.map((s) => s.titleKey)
|
||||
.toList();
|
||||
expect(subgroupTitles, isNot(contains('View Mode')));
|
||||
// No surviving group is empty either way.
|
||||
expect(groups.every((g) => g.children.isNotEmpty), isTrue);
|
||||
// No surviving subgroup is empty.
|
||||
for (final group in groups) {
|
||||
for (final child in group.children) {
|
||||
if (child is KeyboardShortcutActionSubgroup) {
|
||||
expect(child.entries, isNotEmpty,
|
||||
reason: 'subgroup "${child.titleKey}" should not be empty');
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
test('logicalKeyName covers the supported-keys fixture', () {
|
||||
// The fixture is the cross-language source of truth for the full set of
|
||||
// shortcut-bindable key names. Rust has a mirror test against the same
|
||||
// file (`supported_keys_match_fixture` in src/keyboard/shortcuts.rs).
|
||||
// Drift on either side breaks one of the two tests.
|
||||
final fixturePath = 'test/fixtures/supported_shortcut_keys.json';
|
||||
final fixture =
|
||||
(jsonDecode(File(fixturePath).readAsStringSync()) as List<dynamic>)
|
||||
.cast<String>()
|
||||
.toSet();
|
||||
|
||||
// Hand-rolled (LogicalKeyboardKey, name) round-trip table. Adding a key
|
||||
// requires updates in three places: the fixture, this table, and Rust's
|
||||
// matching table — that's the price of the parity guarantee.
|
||||
final mappings = <(LogicalKeyboardKey, String)>[
|
||||
for (var c = 0; c < 26; c++)
|
||||
(
|
||||
LogicalKeyboardKey(0x00000000061 + c),
|
||||
String.fromCharCode(0x61 + c),
|
||||
),
|
||||
for (var d = 0; d < 10; d++)
|
||||
(LogicalKeyboardKey(0x00000000030 + d), 'digit$d'),
|
||||
(LogicalKeyboardKey.f1, 'f1'),
|
||||
(LogicalKeyboardKey.f2, 'f2'),
|
||||
(LogicalKeyboardKey.f3, 'f3'),
|
||||
(LogicalKeyboardKey.f4, 'f4'),
|
||||
(LogicalKeyboardKey.f5, 'f5'),
|
||||
(LogicalKeyboardKey.f6, 'f6'),
|
||||
(LogicalKeyboardKey.f7, 'f7'),
|
||||
(LogicalKeyboardKey.f8, 'f8'),
|
||||
(LogicalKeyboardKey.f9, 'f9'),
|
||||
(LogicalKeyboardKey.f10, 'f10'),
|
||||
(LogicalKeyboardKey.f11, 'f11'),
|
||||
(LogicalKeyboardKey.f12, 'f12'),
|
||||
(LogicalKeyboardKey.delete, 'delete'),
|
||||
(LogicalKeyboardKey.backspace, 'backspace'),
|
||||
(LogicalKeyboardKey.tab, 'tab'),
|
||||
(LogicalKeyboardKey.space, 'space'),
|
||||
(LogicalKeyboardKey.enter, 'enter'),
|
||||
(LogicalKeyboardKey.numpadEnter, 'enter'),
|
||||
(LogicalKeyboardKey.arrowLeft, 'arrow_left'),
|
||||
(LogicalKeyboardKey.arrowRight, 'arrow_right'),
|
||||
(LogicalKeyboardKey.arrowUp, 'arrow_up'),
|
||||
(LogicalKeyboardKey.arrowDown, 'arrow_down'),
|
||||
(LogicalKeyboardKey.home, 'home'),
|
||||
(LogicalKeyboardKey.end, 'end'),
|
||||
(LogicalKeyboardKey.pageUp, 'page_up'),
|
||||
(LogicalKeyboardKey.pageDown, 'page_down'),
|
||||
(LogicalKeyboardKey.insert, 'insert'),
|
||||
];
|
||||
|
||||
// Round-trip: every (key, name) pair must agree with logicalKeyName.
|
||||
for (final (key, name) in mappings) {
|
||||
expect(logicalKeyName(key), equals(name),
|
||||
reason: 'logicalKeyName($key) should be "$name"');
|
||||
}
|
||||
|
||||
// The set of names produced by the table must equal the fixture.
|
||||
final namesFromTable = mappings.map((e) => e.$2).toSet();
|
||||
expect(namesFromTable, equals(fixture),
|
||||
reason: 'logicalKeyName vocabulary drifted from $fixturePath — update '
|
||||
'shortcut_utils.dart::logicalKeyName, the fixture, and Rust '
|
||||
'event_to_key_name together');
|
||||
|
||||
// Modifier-only / unsupported keys must return null.
|
||||
expect(logicalKeyName(LogicalKeyboardKey.shift), isNull);
|
||||
expect(logicalKeyName(LogicalKeyboardKey.escape), isNull);
|
||||
expect(logicalKeyName(LogicalKeyboardKey.f13), isNull);
|
||||
});
|
||||
|
||||
test('configurable shortcut list does not include known-removed action IDs',
|
||||
() {
|
||||
// These IDs were briefly defined without handlers (a "ghost action"
|
||||
// footgun). If you intend to re-add one of these as a real action,
|
||||
// wire up its handler and add a constant + group entry — do not just
|
||||
// resurrect the literal string below.
|
||||
//
|
||||
// Note: `toggle_privacy_mode` was once on this list but is now a real
|
||||
// implemented action (registered in shortcut_model.dart). The other
|
||||
// legacy IDs (toggle_audio, view_mode_shrink/stretch, view_mode_1_to_1)
|
||||
// were renamed: their replacements are kShortcutActionToggleMute and
|
||||
// kShortcutActionViewModeOriginal/Adaptive/Custom.
|
||||
const knownRemoved = [
|
||||
'toggle_audio',
|
||||
'view_mode_1_to_1',
|
||||
'view_mode_shrink',
|
||||
'view_mode_stretch',
|
||||
];
|
||||
final actions = idSet(kKeyboardShortcutActionGroups);
|
||||
for (final id in knownRemoved) {
|
||||
expect(actions, isNot(contains(id)),
|
||||
reason:
|
||||
'"$id" was a known ghost action — wire a real handler before re-adding it');
|
||||
}
|
||||
});
|
||||
}
|
||||
Reference in New Issue
Block a user