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.
427 lines
17 KiB
Dart
427 lines
17 KiB
Dart
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');
|
|
}
|
|
});
|
|
}
|