mirror of
https://github.com/rustdesk/rustdesk.git
synced 2026-05-02 10:16:28 +02: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:
4
.gitignore
vendored
4
.gitignore
vendored
@@ -55,4 +55,6 @@ examples/**/target/
|
||||
vcpkg_installed
|
||||
flutter/lib/generated_plugin_registrant.dart
|
||||
libsciter.dylib
|
||||
flutter/web/
|
||||
flutter/web/
|
||||
# Local git worktrees
|
||||
.worktrees/
|
||||
|
||||
24
AGENTS.md
24
AGENTS.md
@@ -53,6 +53,30 @@
|
||||
* Use `spawn_blocking` or dedicated threads for blocking work.
|
||||
* Do not use `std::thread::sleep()` in async code.
|
||||
|
||||
## Flutter Rust Bridge
|
||||
|
||||
* Do **not** run `flutter_rust_bridge_codegen` — it requires a specific pinned version that is not easy to set up locally.
|
||||
* When adding new FFI functions in `src/flutter_ffi.rs`, hand-write the corresponding Dart wrappers instead of regenerating.
|
||||
* Web bridge (committed): edit `flutter/lib/web/bridge.dart` directly. Follow the existing patterns there for `SyncReturn<T>` / `Future<T>` and the `dart:js` glue.
|
||||
* Native bridge (`flutter/lib/generated_bridge.dart`, `src/bridge_generated.rs`, `src/bridge_generated.io.rs`): these are gitignored and regenerated by the project's CI codegen. Manually editing them locally is fine for development testing, but those edits do not persist into commits.
|
||||
|
||||
## Web (Flutter Web) Architecture
|
||||
|
||||
Flutter Web in this repo is **not** "Dart compiled to JS via Flutter alone". The runtime is split:
|
||||
|
||||
* **Native targets (Win/Mac/Linux/Android/iOS)**: Rust drives sessions via `flutter_rust_bridge`; Dart only renders UI.
|
||||
* **Web target**: Rust does **not** run. There is a separate hand-written TypeScript / JavaScript client at `flutter/web/js/` (gitignored — not present in this repo, lives in the maintainer's local tree). It owns connection, codec, keyboard, clipboard, etc. — basically a JS port of the Rust client. The Dart UI talks to it through `flutter/lib/web/bridge.dart`, which uses `dart:js` to call JS-side functions and to register Dart-side callbacks on `window.*`.
|
||||
|
||||
Implications when adding any session-runtime feature (keyboard, clipboard, audio, …):
|
||||
|
||||
* The Rust implementation in `src/` is for **native only**. Don't try to compile it to wasm.
|
||||
* The matching Web-side logic must be written in TS/JS under `flutter/web/js/src/`. It's a translation of the Rust logic, usually simpler — Web is single-window, so any per-session-id plumbing in Rust collapses to a single global on Web.
|
||||
* `flutter/lib/web/bridge.dart` is the only place where Dart sees JS. Other Dart code stays platform-agnostic and goes through `bind`. Don't sprinkle `if (isWeb)` runtime branches in shared Dart files to call Web-specific logic — put the platform divergence in the bridge.
|
||||
* For JS → Dart events (e.g., a Web matcher firing), the convention is: Dart sets `js.context['onFooBar'] = (...) {...}` once at startup (typically in `mainInit`); the JS side calls `window.onFooBar(...)`. See `onLoadAbFinished`, `onLoadGroupFinished` for reference.
|
||||
* The maintainer cannot easily run `flutter_rust_bridge_codegen`, so when a new FFI function lands in `src/flutter_ffi.rs`:
|
||||
1. add the Web counterpart to `flutter/lib/web/bridge.dart` by hand;
|
||||
2. note that on the Web target it may need to be a no-op or a JS bridge call rather than a real Rust invocation.
|
||||
|
||||
## Editing Hygiene
|
||||
|
||||
* Change only what is required.
|
||||
|
||||
110
flutter/lib/common/widgets/keyboard_shortcuts/display.dart
Normal file
110
flutter/lib/common/widgets/keyboard_shortcuts/display.dart
Normal file
@@ -0,0 +1,110 @@
|
||||
// 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();
|
||||
}
|
||||
}
|
||||
484
flutter/lib/common/widgets/keyboard_shortcuts/page_body.dart
Normal file
484
flutter/lib/common/widgets/keyboard_shortcuts/page_body.dart
Normal file
@@ -0,0 +1,484 @@
|
||||
// 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 'display.dart';
|
||||
import 'recording_dialog.dart';
|
||||
import 'shortcut_actions.dart';
|
||||
import 'shortcut_utils.dart';
|
||||
|
||||
/// 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;
|
||||
|
||||
/// Whether to render the master Enable + Pass-through toggles inside the
|
||||
/// body. Desktop shells set this to false because the General settings tab
|
||||
/// already exposes both checkboxes (and is the only entry point to this
|
||||
/// page on desktop). Mobile defaults to true: its entry point is a plain
|
||||
/// nav tile in Settings, so this page is the only place the user can
|
||||
/// flip the master switches.
|
||||
final bool showMasterToggles;
|
||||
|
||||
const KeyboardShortcutsPageBody({
|
||||
Key? key,
|
||||
this.compact = true,
|
||||
this.editButtonHint,
|
||||
this.headerBanner,
|
||||
this.showMasterToggles = true,
|
||||
}) : 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 {
|
||||
await ShortcutModel.setEnabled(v);
|
||||
if (mounted) setState(() {});
|
||||
}
|
||||
|
||||
Future<void> _setPassThrough(bool v) async {
|
||||
await ShortcutModel.setPassThrough(v);
|
||||
if (mounted) setState(() {});
|
||||
}
|
||||
|
||||
Future<void> _resetToDefaults() async {
|
||||
final json = _readJson();
|
||||
// Single source of truth lives in `ShortcutModel.currentPlatformCapabilities`
|
||||
// — the same helper feeds the first-enable seed pass, this Reset action,
|
||||
// and the action-list filter below, so the three can never disagree on
|
||||
// which actions belong on this platform.
|
||||
json['bindings'] = filterDefaultBindingsForPlatform(
|
||||
jsonDecode(bind.mainGetDefaultKeyboardShortcuts()) as List,
|
||||
ShortcutModel.currentPlatformCapabilities(),
|
||||
);
|
||||
await _writeJson(json);
|
||||
}
|
||||
|
||||
String _labelFor(String actionId) {
|
||||
// Intentionally walks the unfiltered list (via the recursive helper, so
|
||||
// both direct entries and subgroup entries are covered) — a stale
|
||||
// cross-platform binding (e.g. Toggle Toolbar carried over from
|
||||
// desktop) should still resolve to its human-readable label in conflict
|
||||
// warnings.
|
||||
for (final entry in allActionEntries(kKeyboardShortcutActionGroups)) {
|
||||
if (entry.id == actionId) return translate(entry.labelKey);
|
||||
}
|
||||
return actionId;
|
||||
}
|
||||
|
||||
/// Action groups visible on the current platform. Reads the same
|
||||
/// capability set as the seed-defaults / reset-to-defaults paths from
|
||||
/// `ShortcutModel.currentPlatformCapabilities`, so the UI lists exactly
|
||||
/// the actions whose handlers the matcher can dispatch here.
|
||||
List<KeyboardShortcutActionGroup> _groupsForCurrentPlatform() {
|
||||
return filterKeyboardShortcutActionGroupsForPlatform(
|
||||
ShortcutModel.currentPlatformCapabilities(),
|
||||
);
|
||||
}
|
||||
|
||||
// ----- 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),
|
||||
],
|
||||
if (widget.showMasterToggles) ...[
|
||||
_toggleRow(
|
||||
enabled,
|
||||
'Enable keyboard shortcuts in remote session',
|
||||
(v) => _setEnabled(v),
|
||||
),
|
||||
if (enabled)
|
||||
_toggleRow(
|
||||
ShortcutModel.isPassThrough(),
|
||||
'Pass-through to remote',
|
||||
(v) => _setPassThrough(v),
|
||||
),
|
||||
],
|
||||
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),
|
||||
// Bindings list and configuration entry only show when shortcuts are
|
||||
// enabled — there is nothing to configure while the matcher is off.
|
||||
if (enabled)
|
||||
Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
for (final group in _groupsForCurrentPlatform())
|
||||
_buildGroup(context, group),
|
||||
],
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
Widget _toggleRow(
|
||||
bool value, String labelKey, Future<void> Function(bool) onChanged,
|
||||
{String? tooltipKey}) {
|
||||
return Row(
|
||||
children: [
|
||||
Checkbox(
|
||||
value: value,
|
||||
onChanged: (v) async {
|
||||
if (v == null) return;
|
||||
await onChanged(v);
|
||||
},
|
||||
),
|
||||
const SizedBox(width: 4),
|
||||
Expanded(
|
||||
child: GestureDetector(
|
||||
behavior: HitTestBehavior.opaque,
|
||||
onTap: () => onChanged(!value),
|
||||
child: Text(translate(labelKey)),
|
||||
),
|
||||
),
|
||||
if (tooltipKey != null) InfoTooltipIcon(tipKey: tooltipKey),
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
// One indent unit per nesting level. Both "top item under top heading"
|
||||
// and "subgroup heading under top group" are *one* level deeper than the
|
||||
// top heading, so they share this indent — meaning a top-level direct
|
||||
// item and a sibling subgroup heading line up at exactly the same x.
|
||||
// Subgroup items are *two* levels deeper.
|
||||
static const double _kIndentStep = 16.0;
|
||||
|
||||
/// Top-level group: heading at zero indent, then walk `children` in
|
||||
/// declaration order. Direct entries get [_kIndentStep] of indent so
|
||||
/// they read as "items under this heading"; subgroup headings sit at
|
||||
/// the same indent (a subgroup is a sibling of the direct items, just
|
||||
/// with its own nested entries below).
|
||||
Widget _buildGroup(BuildContext context, KeyboardShortcutActionGroup group) {
|
||||
return Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
const SizedBox(height: 12),
|
||||
_buildHeading(context, group.titleKey, isSub: false),
|
||||
const SizedBox(height: 4),
|
||||
for (final child in group.children)
|
||||
switch (child) {
|
||||
KeyboardShortcutActionEntry() => Padding(
|
||||
padding: const EdgeInsets.only(left: _kIndentStep),
|
||||
child: _buildEntryRow(context, child),
|
||||
),
|
||||
KeyboardShortcutActionSubgroup() =>
|
||||
_buildSubgroup(context, child),
|
||||
},
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildSubgroup(
|
||||
BuildContext context, KeyboardShortcutActionSubgroup subgroup) {
|
||||
return Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
const SizedBox(height: 8),
|
||||
_buildHeading(context, subgroup.titleKey, isSub: true),
|
||||
const SizedBox(height: 4),
|
||||
for (final entry in subgroup.entries)
|
||||
Padding(
|
||||
// Two indent steps: one for "subgroup heading is nested under
|
||||
// top heading" (matches the heading's own indent) and one for
|
||||
// "this entry is under the subgroup heading".
|
||||
padding: const EdgeInsets.only(left: _kIndentStep * 2),
|
||||
child: _buildEntryRow(context, entry),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildHeading(BuildContext context, String titleKey,
|
||||
{required bool isSub}) {
|
||||
// Subgroup heading nests one step under the top heading — same indent
|
||||
// as a top-level direct item, so the two line up at the same x.
|
||||
final indent = isSub ? _kIndentStep : 0.0;
|
||||
return Padding(
|
||||
padding: EdgeInsets.only(left: 8 + indent, right: 8),
|
||||
child: Row(
|
||||
children: [
|
||||
Text(
|
||||
translate(titleKey),
|
||||
style: TextStyle(
|
||||
fontWeight: isSub ? FontWeight.w500 : FontWeight.w600,
|
||||
color: isSub
|
||||
? Theme.of(context).hintColor
|
||||
: Theme.of(context).colorScheme.primary,
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 8),
|
||||
Expanded(child: Divider(thickness: isSub ? 0.5 : 1)),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildEntryRow(
|
||||
BuildContext context, KeyboardShortcutActionEntry entry) {
|
||||
return widget.compact
|
||||
? _buildCompactRow(context, entry)
|
||||
: _buildTouchRow(context, entry);
|
||||
}
|
||||
|
||||
/// Desktop dense row: label | shortcut | edit | clear, all in one Row.
|
||||
Widget _buildCompactRow(
|
||||
BuildContext context, KeyboardShortcutActionEntry entry) {
|
||||
final shortcut = ShortcutDisplay.formatFor(entry.id, requireEnabled: false);
|
||||
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 = ShortcutDisplay.formatFor(entry.id, requireEnabled: false);
|
||||
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),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// Small help-icon tooltip used for inline explanations next to a checkbox /
|
||||
/// row. Triggers on hover (desktop) and tap (mobile). Public so the desktop
|
||||
/// General settings tab can reuse it.
|
||||
class InfoTooltipIcon extends StatelessWidget {
|
||||
final String tipKey;
|
||||
const InfoTooltipIcon({Key? key, required this.tipKey}) : super(key: key);
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Tooltip(
|
||||
message: translate(tipKey),
|
||||
triggerMode: TooltipTriggerMode.tap,
|
||||
preferBelow: false,
|
||||
waitDuration: const Duration(milliseconds: 250),
|
||||
showDuration: const Duration(seconds: 6),
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 6),
|
||||
child: Icon(
|
||||
Icons.help_outline,
|
||||
size: 16,
|
||||
color: Theme.of(context).hintColor,
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,400 @@
|
||||
// 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';
|
||||
import 'shortcut_utils.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;
|
||||
|
||||
// Human-readable label for the most recent press that we couldn't bind to
|
||||
// (e.g. F13, media keys). null when the last press was either supported or
|
||||
// a modifier-only press. Cleared whenever a supported key arrives, so a
|
||||
// user who hits an unsupported key after a valid capture sees the warning
|
||||
// until they press something else. Distinct from `_key == null` so the
|
||||
// status line can tell the user *why* their press was ignored instead of
|
||||
// silently doing nothing.
|
||||
String? _unsupportedKey;
|
||||
|
||||
// Modifier LogicalKeyboardKeys we should *not* treat as "unsupported" when
|
||||
// they fail to map to a key name. A modifier-only press is normal during
|
||||
// combo capture (the user is building up their combo) — only non-modifier
|
||||
// unmapped keys deserve the warning.
|
||||
static final _modifierKeys = <LogicalKeyboardKey>{
|
||||
LogicalKeyboardKey.shift,
|
||||
LogicalKeyboardKey.shiftLeft,
|
||||
LogicalKeyboardKey.shiftRight,
|
||||
LogicalKeyboardKey.control,
|
||||
LogicalKeyboardKey.controlLeft,
|
||||
LogicalKeyboardKey.controlRight,
|
||||
LogicalKeyboardKey.alt,
|
||||
LogicalKeyboardKey.altLeft,
|
||||
LogicalKeyboardKey.altRight,
|
||||
LogicalKeyboardKey.meta,
|
||||
LogicalKeyboardKey.metaLeft,
|
||||
LogicalKeyboardKey.metaRight,
|
||||
LogicalKeyboardKey.capsLock,
|
||||
LogicalKeyboardKey.numLock,
|
||||
LogicalKeyboardKey.scrollLock,
|
||||
LogicalKeyboardKey.fn,
|
||||
LogicalKeyboardKey.fnLock,
|
||||
};
|
||||
|
||||
@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 at least one modifier. Lower bound
|
||||
/// for any sensible binding — pure single-key bindings would swallow normal
|
||||
/// typing the moment shortcuts are enabled. Beyond one mod the user is on
|
||||
/// their own; the in-session pass-through toggle is the escape hatch when
|
||||
/// a chosen combo collides with something needed on the remote.
|
||||
bool get _hasRequiredPrefix => _mods.isNotEmpty;
|
||||
|
||||
/// 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 = logicalKeyName(logical);
|
||||
|
||||
// Mirror of `normalize_modifiers` in src/keyboard/shortcuts.rs:
|
||||
// * macOS: Cmd → primary, Ctrl → ctrl (distinct).
|
||||
// * Win/Linux: Ctrl → primary, no separate Ctrl modifier.
|
||||
// The two halves must agree on labels, otherwise saved bindings will not
|
||||
// match the events the matcher sees at runtime.
|
||||
final mods = <String>{};
|
||||
if (HardwareKeyboard.instance.isAltPressed) mods.add('alt');
|
||||
if (HardwareKeyboard.instance.isShiftPressed) mods.add('shift');
|
||||
if (_isMac) {
|
||||
if (HardwareKeyboard.instance.isMetaPressed) mods.add('primary');
|
||||
if (HardwareKeyboard.instance.isControlPressed) mods.add('ctrl');
|
||||
} else {
|
||||
if (HardwareKeyboard.instance.isControlPressed) 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;
|
||||
_unsupportedKey = null;
|
||||
} else if (!_modifierKeys.contains(logical)) {
|
||||
// Non-modifier key we don't recognize (e.g. F13, media keys, IME
|
||||
// compose keys). Surface a warning instead of silently dropping the
|
||||
// press — the dialog otherwise looks unresponsive.
|
||||
final label = logical.keyLabel.isNotEmpty
|
||||
? logical.keyLabel
|
||||
: (logical.debugName ?? 'this key');
|
||||
_unsupportedKey = label;
|
||||
}
|
||||
});
|
||||
return KeyEventResult.handled;
|
||||
}
|
||||
|
||||
void _onSave() {
|
||||
if (_key == null || !_hasRequiredPrefix) return;
|
||||
final ordered = canonicalShortcutModsForSave(_mods);
|
||||
final binding = <String, dynamic>{
|
||||
'action': widget.actionId,
|
||||
'mods': ordered,
|
||||
'key': _key!,
|
||||
};
|
||||
Navigator.of(context).pop(RecordingResult(binding, _conflictActionId));
|
||||
}
|
||||
|
||||
String _formatPrefix() {
|
||||
// Used in the "must include..." validation row; lists the modifier set
|
||||
// a binding can pick from. Localised modifier glyphs aren't used here so
|
||||
// the names stay greppable for users searching for "Option" / "Cmd".
|
||||
if (_isMac) return 'Cmd / Control / Option / Shift';
|
||||
return 'Ctrl / Alt / Shift';
|
||||
}
|
||||
|
||||
String _formatCombo() {
|
||||
// Plain-text labels (see same rationale in display.dart::_keyDisplay).
|
||||
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;
|
||||
}
|
||||
}
|
||||
if (_key != null) {
|
||||
parts.add(_keyDisplay(_key!));
|
||||
}
|
||||
if (parts.isEmpty) return translate('shortcut-recording-press-keys-tip');
|
||||
return parts.join('+');
|
||||
}
|
||||
|
||||
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);
|
||||
return key.toUpperCase();
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final hasKey = _key != null;
|
||||
final conflictId = _conflictActionId;
|
||||
final hasConflict = conflictId != null;
|
||||
// The Save button still fires for the previously-captured combo even if
|
||||
// the user just hit an unsupported key — the captured state is what gets
|
||||
// saved, the warning is just feedback that the latest press was rejected.
|
||||
final canSave = hasKey && _hasRequiredPrefix;
|
||||
|
||||
Widget statusLine;
|
||||
if (_unsupportedKey != null) {
|
||||
// Most recent press was unsupported. Take precedence over the
|
||||
// captured-combo states so the user gets explicit feedback that their
|
||||
// last keystroke was ignored, regardless of whether a previous combo
|
||||
// is still captured.
|
||||
statusLine = Row(
|
||||
children: [
|
||||
const Icon(Icons.close, size: 16, color: Colors.red),
|
||||
const SizedBox(width: 6),
|
||||
Flexible(
|
||||
child: Text(
|
||||
translate('shortcut-key-not-supported')
|
||||
.replaceAll('{}', _unsupportedKey!),
|
||||
style: const TextStyle(color: Colors.red),
|
||||
),
|
||||
),
|
||||
],
|
||||
);
|
||||
} else 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-modifiers')
|
||||
.replaceAll('{}', _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),
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,288 @@
|
||||
import 'shortcut_constants.dart';
|
||||
import 'shortcut_utils.dart';
|
||||
|
||||
/// Marker for the union of [KeyboardShortcutActionEntry] /
|
||||
/// [KeyboardShortcutActionSubgroup] — anything a top-level
|
||||
/// [KeyboardShortcutActionGroup] can directly contain. Sealed so renderers
|
||||
/// and filters can `switch` on it without a default branch.
|
||||
sealed class KeyboardShortcutActionGroupChild {
|
||||
const KeyboardShortcutActionGroupChild();
|
||||
}
|
||||
|
||||
/// One configurable action — id + i18n key for its label.
|
||||
class KeyboardShortcutActionEntry extends KeyboardShortcutActionGroupChild {
|
||||
final String id;
|
||||
final String labelKey;
|
||||
const KeyboardShortcutActionEntry(this.id, this.labelKey);
|
||||
}
|
||||
|
||||
/// A nested subgroup (e.g. "View Mode" under "Display"). Renders with extra
|
||||
/// indent so its items are visually distinguished from the parent group's
|
||||
/// direct items.
|
||||
class KeyboardShortcutActionSubgroup extends KeyboardShortcutActionGroupChild {
|
||||
final String titleKey;
|
||||
final List<KeyboardShortcutActionEntry> entries;
|
||||
const KeyboardShortcutActionSubgroup(this.titleKey, this.entries);
|
||||
}
|
||||
|
||||
/// A top-level group ("Display", "Keyboard", "Chat", …). `children` is an
|
||||
/// *ordered* mix of direct entries and subgroups, so layouts like
|
||||
/// "subgroups first → direct items → trailing subgroup" — exactly the
|
||||
/// shape `_DisplayMenu` uses (Privacy mode lives after the cursor / display
|
||||
/// toggles direct items) — are first-class instead of needing a wrapper
|
||||
/// "Display Settings" subgroup just to insert the items.
|
||||
class KeyboardShortcutActionGroup {
|
||||
final String titleKey;
|
||||
final List<KeyboardShortcutActionGroupChild> children;
|
||||
const KeyboardShortcutActionGroup(this.titleKey, this.children);
|
||||
}
|
||||
|
||||
/// Canonical action group definitions used by both the desktop and mobile
|
||||
/// configuration pages. The order of groups, subgroups, and entries here
|
||||
/// is the order the user sees in the UI, and mirrors the corresponding
|
||||
/// toolbar submenu (`_DisplayMenu` / `_KeyboardMenu` in
|
||||
/// `desktop/widgets/remote_toolbar.dart`) child order — modulo entries
|
||||
/// without shortcut counterparts (e.g. `_screenAdjustor.adjustWindow`,
|
||||
/// `scrollStyle`, `_ResolutionsMenu`, `localKeyboardType`).
|
||||
final List<KeyboardShortcutActionGroup> kKeyboardShortcutActionGroups = [
|
||||
KeyboardShortcutActionGroup('Monitor', [
|
||||
KeyboardShortcutActionEntry(
|
||||
kShortcutActionSwitchDisplayNext, 'Switch to next display'),
|
||||
KeyboardShortcutActionEntry(
|
||||
kShortcutActionSwitchDisplayPrev, 'Switch to previous display'),
|
||||
KeyboardShortcutActionEntry(
|
||||
kShortcutActionSwitchDisplayAll, 'All monitors'),
|
||||
]),
|
||||
KeyboardShortcutActionGroup('Control Actions', [
|
||||
KeyboardShortcutActionEntry(
|
||||
kShortcutActionSendClipboardKeystrokes, 'Send clipboard keystrokes'),
|
||||
KeyboardShortcutActionEntry(kShortcutActionResetCanvas, 'Reset canvas'),
|
||||
KeyboardShortcutActionEntry(
|
||||
kShortcutActionSendCtrlAltDel, 'Insert Ctrl + Alt + Del'),
|
||||
KeyboardShortcutActionEntry(
|
||||
kShortcutActionRestartRemote, 'Restart remote device'),
|
||||
KeyboardShortcutActionEntry(kShortcutActionInsertLock, 'Insert Lock'),
|
||||
KeyboardShortcutActionEntry(
|
||||
kShortcutActionToggleBlockInput, 'Block user input'),
|
||||
KeyboardShortcutActionEntry(kShortcutActionSwitchSides, 'Switch Sides'),
|
||||
KeyboardShortcutActionEntry(kShortcutActionRefresh, 'Refresh'),
|
||||
KeyboardShortcutActionEntry(
|
||||
kShortcutActionToggleRecording, 'Toggle session recording'),
|
||||
KeyboardShortcutActionEntry(kShortcutActionScreenshot, 'Take screenshot'),
|
||||
]),
|
||||
// Display: subgroups (View Mode → Image Quality → Codec → Virtual display)
|
||||
// first, then the direct items (cursor toggles + display toggles), then
|
||||
// Privacy mode subgroup last — matching `_DisplayMenu.menuChildrenGetter`
|
||||
// exactly. Rebalancing this order should also rebalance the toolbar.
|
||||
KeyboardShortcutActionGroup('Display', [
|
||||
KeyboardShortcutActionSubgroup('View Mode', [
|
||||
KeyboardShortcutActionEntry(
|
||||
kShortcutActionViewModeOriginal, 'Scale original'),
|
||||
KeyboardShortcutActionEntry(
|
||||
kShortcutActionViewModeAdaptive, 'Scale adaptive'),
|
||||
KeyboardShortcutActionEntry(
|
||||
kShortcutActionViewModeCustom, 'Scale custom'),
|
||||
]),
|
||||
KeyboardShortcutActionSubgroup('Image Quality', [
|
||||
KeyboardShortcutActionEntry(
|
||||
kShortcutActionImageQualityBest, 'Good image quality'),
|
||||
KeyboardShortcutActionEntry(
|
||||
kShortcutActionImageQualityBalanced, 'Balanced'),
|
||||
KeyboardShortcutActionEntry(
|
||||
kShortcutActionImageQualityLow, 'Optimize reaction time'),
|
||||
]),
|
||||
KeyboardShortcutActionSubgroup('Codec', [
|
||||
KeyboardShortcutActionEntry(kShortcutActionCodecAuto, 'Auto'),
|
||||
KeyboardShortcutActionEntry(kShortcutActionCodecVp8, 'VP8'),
|
||||
KeyboardShortcutActionEntry(kShortcutActionCodecVp9, 'VP9'),
|
||||
KeyboardShortcutActionEntry(kShortcutActionCodecAv1, 'AV1'),
|
||||
KeyboardShortcutActionEntry(kShortcutActionCodecH264, 'H264'),
|
||||
KeyboardShortcutActionEntry(kShortcutActionCodecH265, 'H265'),
|
||||
]),
|
||||
KeyboardShortcutActionSubgroup('Virtual display', [
|
||||
KeyboardShortcutActionEntry(
|
||||
kShortcutActionPlugOutAllVirtualDisplays, 'Plug out all'),
|
||||
]),
|
||||
// Direct items: cursorToggles + display toggles, in toolbar order.
|
||||
KeyboardShortcutActionEntry(
|
||||
kShortcutActionToggleShowRemoteCursor, 'Show remote cursor'),
|
||||
KeyboardShortcutActionEntry(
|
||||
kShortcutActionToggleFollowRemoteCursor, 'Follow remote cursor'),
|
||||
KeyboardShortcutActionEntry(
|
||||
kShortcutActionToggleFollowRemoteWindow, 'Follow remote window focus'),
|
||||
KeyboardShortcutActionEntry(
|
||||
kShortcutActionToggleZoomCursor, 'Zoom cursor'),
|
||||
KeyboardShortcutActionEntry(
|
||||
kShortcutActionToggleQualityMonitor, 'Show quality monitor'),
|
||||
KeyboardShortcutActionEntry(kShortcutActionToggleMute, 'Mute'),
|
||||
KeyboardShortcutActionEntry(
|
||||
kShortcutActionToggleEnableFileCopyPaste, 'Enable file copy and paste'),
|
||||
KeyboardShortcutActionEntry(
|
||||
kShortcutActionToggleDisableClipboard, 'Disable clipboard'),
|
||||
KeyboardShortcutActionEntry(
|
||||
kShortcutActionToggleLockAfterSessionEnd, 'Lock after session end'),
|
||||
KeyboardShortcutActionEntry(
|
||||
kShortcutActionToggleTrueColor, 'True color (4:4:4)'),
|
||||
// Privacy mode at the bottom — mirrors `_DisplayMenu` where it's the
|
||||
// last submenu added (line ~1023 of remote_toolbar.dart, after toggles).
|
||||
KeyboardShortcutActionSubgroup('Privacy mode', [
|
||||
// Reuse toolbar's existing impl-name i18n keys. The handler at
|
||||
// runtime matches `privacy_mode_impl_mag_tip` /
|
||||
// `privacy_mode_impl_virtual_display_tip` against the peer's
|
||||
// advertised impls — same logic the toolbar's `toolbarPrivacyMode`
|
||||
// submenu uses.
|
||||
KeyboardShortcutActionEntry(
|
||||
kShortcutActionPrivacyMode1, 'privacy_mode_impl_mag_tip'),
|
||||
KeyboardShortcutActionEntry(
|
||||
kShortcutActionPrivacyMode2, 'privacy_mode_impl_virtual_display_tip'),
|
||||
]),
|
||||
]),
|
||||
// Keyboard: Keyboard mode subgroup first, then direct items
|
||||
// (inputSource → viewMode → showMyCursor → toolbarKeyboardToggles),
|
||||
// matching `_KeyboardMenu.menuChildrenGetter`.
|
||||
KeyboardShortcutActionGroup('Keyboard', [
|
||||
KeyboardShortcutActionSubgroup('Keyboard mode', [
|
||||
KeyboardShortcutActionEntry(
|
||||
kShortcutActionKeyboardModeLegacy, 'Legacy mode'),
|
||||
KeyboardShortcutActionEntry(kShortcutActionKeyboardModeMap, 'Map mode'),
|
||||
KeyboardShortcutActionEntry(
|
||||
kShortcutActionKeyboardModeTranslate, 'Translate mode'),
|
||||
]),
|
||||
KeyboardShortcutActionEntry(
|
||||
kShortcutActionToggleInputSource, 'Toggle input source'),
|
||||
KeyboardShortcutActionEntry(kShortcutActionToggleViewOnly, 'View Mode'),
|
||||
KeyboardShortcutActionEntry(
|
||||
kShortcutActionToggleShowMyCursor, 'Show my cursor'),
|
||||
KeyboardShortcutActionEntry(
|
||||
kShortcutActionToggleSwapCtrlCmd, 'Swap control-command key'),
|
||||
KeyboardShortcutActionEntry(
|
||||
kShortcutActionToggleRelativeMouseMode, 'Relative mouse mode'),
|
||||
KeyboardShortcutActionEntry(
|
||||
kShortcutActionToggleReverseMouseWheel, 'Reverse mouse wheel'),
|
||||
KeyboardShortcutActionEntry(
|
||||
kShortcutActionToggleSwapLeftRightMouse, 'swap-left-right-mouse'),
|
||||
]),
|
||||
KeyboardShortcutActionGroup('Chat', [
|
||||
KeyboardShortcutActionEntry(kShortcutActionToggleChat, 'Text chat'),
|
||||
KeyboardShortcutActionEntry(kShortcutActionToggleVoiceCall, 'Voice call'),
|
||||
]),
|
||||
// "Other" collects single-icon toolbar buttons that have no dropdown
|
||||
// (Pin, Close), plus actions with no toolbar entry at all (Fullscreen —
|
||||
// driven by callback, not menu; Toggle Toolbar / tab navigation — tab
|
||||
// right-click menu, not toolbar). Combined into one group rather than
|
||||
// several 1-item groups for cleaner visual hierarchy.
|
||||
KeyboardShortcutActionGroup('Other', [
|
||||
KeyboardShortcutActionEntry(kShortcutActionPinToolbar, 'Pin Toolbar'),
|
||||
KeyboardShortcutActionEntry(
|
||||
kShortcutActionToggleFullscreen, 'Toggle fullscreen'),
|
||||
KeyboardShortcutActionEntry(kShortcutActionToggleToolbar, 'Toggle toolbar'),
|
||||
KeyboardShortcutActionEntry(kShortcutActionCloseTab, 'Close tab'),
|
||||
KeyboardShortcutActionEntry(
|
||||
kShortcutActionSwitchTabNext, 'Switch to next tab'),
|
||||
KeyboardShortcutActionEntry(
|
||||
kShortcutActionSwitchTabPrev, 'Switch to previous tab'),
|
||||
]),
|
||||
];
|
||||
|
||||
/// Walk the (filtered or unfiltered) group tree and yield every
|
||||
/// [KeyboardShortcutActionEntry], regardless of whether it sits as a direct
|
||||
/// child of a top-level group or inside a subgroup. Useful for label
|
||||
/// lookups, ghost-action tests, and any consumer that just wants the flat
|
||||
/// list of action ids.
|
||||
Iterable<KeyboardShortcutActionEntry> allActionEntries(
|
||||
Iterable<KeyboardShortcutActionGroup> groups,
|
||||
) sync* {
|
||||
for (final group in groups) {
|
||||
for (final child in group.children) {
|
||||
switch (child) {
|
||||
case KeyboardShortcutActionEntry():
|
||||
yield child;
|
||||
case KeyboardShortcutActionSubgroup():
|
||||
yield* child.entries;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Return [kKeyboardShortcutActionGroups] with actions that aren't supported
|
||||
/// on the current platform stripped out. Subgroups whose every entry was
|
||||
/// filtered are dropped; top-level groups whose every child (direct entry
|
||||
/// or subgroup) was dropped are themselves dropped.
|
||||
///
|
||||
/// Mirrors the capability flags used by [filterDefaultBindingsForPlatform]
|
||||
/// so the configuration UI shows only what the matcher can actually
|
||||
/// dispatch on this platform.
|
||||
///
|
||||
/// Note: callers should still walk the unfiltered
|
||||
/// [kKeyboardShortcutActionGroups] for label lookups (e.g. conflict
|
||||
/// warnings about a stale cross-platform binding), so an action bound on
|
||||
/// desktop and carried over to mobile still has a human-readable name in
|
||||
/// dialogs.
|
||||
List<KeyboardShortcutActionGroup> filterKeyboardShortcutActionGroupsForPlatform(
|
||||
ShortcutPlatformCapabilities cap,
|
||||
) {
|
||||
bool allowed(String id) {
|
||||
if (!cap.includeFullscreenShortcut &&
|
||||
id == kShortcutActionToggleFullscreen) {
|
||||
return false;
|
||||
}
|
||||
if (!cap.includeScreenshotShortcut && id == kShortcutActionScreenshot) {
|
||||
return false;
|
||||
}
|
||||
if (!cap.includeTabShortcuts && isSwitchTabShortcutAction(id)) return false;
|
||||
if (!cap.includeToolbarShortcut && id == kShortcutActionToggleToolbar) {
|
||||
return false;
|
||||
}
|
||||
if (!cap.includeCloseTabShortcut && id == kShortcutActionCloseTab) {
|
||||
return false;
|
||||
}
|
||||
if (!cap.includeSwitchSidesShortcut && id == kShortcutActionSwitchSides) {
|
||||
return false;
|
||||
}
|
||||
if (!cap.includeRecordingShortcut && id == kShortcutActionToggleRecording) {
|
||||
return false;
|
||||
}
|
||||
if (!cap.includeResetCanvasShortcut && id == kShortcutActionResetCanvas) {
|
||||
return false;
|
||||
}
|
||||
if (!cap.includePinToolbarShortcut && id == kShortcutActionPinToolbar) {
|
||||
return false;
|
||||
}
|
||||
if (!cap.includeViewModeShortcut &&
|
||||
(id == kShortcutActionViewModeOriginal ||
|
||||
id == kShortcutActionViewModeAdaptive ||
|
||||
id == kShortcutActionViewModeCustom)) {
|
||||
return false;
|
||||
}
|
||||
if (!cap.includeInputSourceShortcut &&
|
||||
id == kShortcutActionToggleInputSource) {
|
||||
return false;
|
||||
}
|
||||
if (!cap.includeVoiceCallShortcut && id == kShortcutActionToggleVoiceCall) {
|
||||
return false;
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
final out = <KeyboardShortcutActionGroup>[];
|
||||
for (final group in kKeyboardShortcutActionGroups) {
|
||||
final filteredChildren = <KeyboardShortcutActionGroupChild>[];
|
||||
for (final child in group.children) {
|
||||
switch (child) {
|
||||
case KeyboardShortcutActionEntry():
|
||||
if (allowed(child.id)) filteredChildren.add(child);
|
||||
case KeyboardShortcutActionSubgroup():
|
||||
final entries =
|
||||
child.entries.where((e) => allowed(e.id)).toList();
|
||||
if (entries.isNotEmpty) {
|
||||
filteredChildren.add(
|
||||
KeyboardShortcutActionSubgroup(child.titleKey, entries));
|
||||
}
|
||||
}
|
||||
}
|
||||
if (filteredChildren.isNotEmpty) {
|
||||
out.add(KeyboardShortcutActionGroup(group.titleKey, filteredChildren));
|
||||
}
|
||||
}
|
||||
return out;
|
||||
}
|
||||
@@ -0,0 +1,104 @@
|
||||
/// Keyboard shortcut action IDs - must match
|
||||
/// src/keyboard/shortcuts.rs::action_id.
|
||||
const kShortcutActionSendCtrlAltDel = 'send_ctrl_alt_del';
|
||||
const kShortcutActionToggleFullscreen = 'toggle_fullscreen';
|
||||
const kShortcutActionSwitchDisplayNext = 'switch_display_next';
|
||||
const kShortcutActionSwitchDisplayPrev = 'switch_display_prev';
|
||||
const kShortcutActionSwitchDisplayAll = 'switch_display_all';
|
||||
const kShortcutActionScreenshot = 'screenshot';
|
||||
const kShortcutActionInsertLock = 'insert_lock';
|
||||
const kShortcutActionRefresh = 'refresh';
|
||||
const kShortcutActionToggleBlockInput = 'toggle_block_input';
|
||||
const kShortcutActionToggleRecording = 'toggle_recording';
|
||||
const kShortcutActionSwitchSides = 'switch_sides';
|
||||
const kShortcutActionCloseTab = 'close_tab';
|
||||
const kShortcutActionToggleToolbar = 'toggle_toolbar';
|
||||
const kShortcutActionRestartRemote = 'restart_remote';
|
||||
const kShortcutActionResetCanvas = 'reset_canvas';
|
||||
const kShortcutActionSwitchTabNext = 'switch_tab_next';
|
||||
const kShortcutActionSwitchTabPrev = 'switch_tab_prev';
|
||||
const kShortcutActionToggleMute = 'toggle_mute';
|
||||
const kShortcutActionPinToolbar = 'pin_toolbar';
|
||||
const kShortcutActionViewModeOriginal = 'view_mode_original';
|
||||
const kShortcutActionViewModeAdaptive = 'view_mode_adaptive';
|
||||
const kShortcutActionToggleChat = 'toggle_chat';
|
||||
const kShortcutActionToggleQualityMonitor = 'toggle_quality_monitor';
|
||||
const kShortcutActionToggleShowRemoteCursor = 'toggle_show_remote_cursor';
|
||||
const kShortcutActionToggleShowMyCursor = 'toggle_show_my_cursor';
|
||||
const kShortcutActionToggleDisableClipboard = 'toggle_disable_clipboard';
|
||||
const kShortcutActionPrivacyMode1 = 'privacy_mode_1';
|
||||
const kShortcutActionPrivacyMode2 = 'privacy_mode_2';
|
||||
// Keyboard mode (Map / Translate / Legacy).
|
||||
const kShortcutActionKeyboardModeMap = 'keyboard_mode_map';
|
||||
const kShortcutActionKeyboardModeTranslate = 'keyboard_mode_translate';
|
||||
const kShortcutActionKeyboardModeLegacy = 'keyboard_mode_legacy';
|
||||
// Codec preference (Auto + the four optional codecs the toolbar surfaces).
|
||||
const kShortcutActionCodecAuto = 'codec_auto';
|
||||
const kShortcutActionCodecVp8 = 'codec_vp8';
|
||||
const kShortcutActionCodecVp9 = 'codec_vp9';
|
||||
const kShortcutActionCodecAv1 = 'codec_av1';
|
||||
const kShortcutActionCodecH264 = 'codec_h264';
|
||||
const kShortcutActionCodecH265 = 'codec_h265';
|
||||
// Plug out every virtual display in one shot — toolbar exposes this in
|
||||
// both IDD modes (RustDesk and Amyuni). Per-index virtual-display toggles
|
||||
// (RustDesk IDD's 4 checkboxes) and the +/- count buttons (Amyuni-only)
|
||||
// are NOT exposed as shortcuts: per-index is too granular, and +/- has
|
||||
// no toolbar counterpart on RustDesk IDD peers.
|
||||
const kShortcutActionPlugOutAllVirtualDisplays =
|
||||
'plug_out_all_virtual_displays';
|
||||
const kShortcutActionToggleRelativeMouseMode = 'toggle_relative_mouse_mode';
|
||||
const kShortcutActionToggleFollowRemoteCursor = 'toggle_follow_remote_cursor';
|
||||
const kShortcutActionToggleFollowRemoteWindow = 'toggle_follow_remote_window';
|
||||
const kShortcutActionToggleZoomCursor = 'toggle_zoom_cursor';
|
||||
const kShortcutActionToggleReverseMouseWheel = 'toggle_reverse_mouse_wheel';
|
||||
const kShortcutActionToggleSwapLeftRightMouse = 'toggle_swap_left_right_mouse';
|
||||
const kShortcutActionToggleLockAfterSessionEnd = 'toggle_lock_after_session_end';
|
||||
const kShortcutActionToggleTrueColor = 'toggle_true_color';
|
||||
const kShortcutActionToggleSwapCtrlCmd = 'toggle_swap_ctrl_cmd';
|
||||
const kShortcutActionToggleEnableFileCopyPaste = 'toggle_enable_file_copy_paste';
|
||||
const kShortcutActionViewModeCustom = 'view_mode_custom';
|
||||
const kShortcutActionImageQualityBest = 'image_quality_best';
|
||||
const kShortcutActionImageQualityBalanced = 'image_quality_balanced';
|
||||
const kShortcutActionImageQualityLow = 'image_quality_low';
|
||||
const kShortcutActionSendClipboardKeystrokes = 'send_clipboard_keystrokes';
|
||||
const kShortcutActionToggleInputSource = 'toggle_input_source';
|
||||
const kShortcutActionToggleVoiceCall = 'toggle_voice_call';
|
||||
const kShortcutActionToggleViewOnly = 'toggle_view_only';
|
||||
|
||||
const kShortcutLocalConfigKey = 'keyboard-shortcuts';
|
||||
const kShortcutEventName = 'shortcut_triggered';
|
||||
|
||||
/// Canonical default keyboard-shortcut bindings, mirroring Rust's
|
||||
/// `default_bindings()` in `src/keyboard/shortcuts.rs`. Used by:
|
||||
/// * the Web bridge (`flutter/lib/web/bridge.dart::mainGetDefaultKeyboardShortcuts`)
|
||||
/// — Web has no Rust at runtime, so the seed list is read from this Dart
|
||||
/// constant instead of going through FFI.
|
||||
/// * the configuration page when seeding defaults on first enable, after
|
||||
/// [filterDefaultBindingsForPlatform] has trimmed platform-specific
|
||||
/// entries.
|
||||
///
|
||||
/// Parity with Rust is unit-tested on both sides against
|
||||
/// `flutter/test/fixtures/default_keyboard_shortcuts.json` — see the
|
||||
/// `kDefaultShortcutBindings matches fixture` test in
|
||||
/// `flutter/test/keyboard_shortcuts_test.dart` and
|
||||
/// `default_bindings_match_fixture_json` in `src/keyboard/shortcuts.rs`.
|
||||
/// Any change here MUST also update the fixture and the Rust source, or CI
|
||||
/// will fail in the side that drifted.
|
||||
final List<Map<String, Object>> kDefaultShortcutBindings = [
|
||||
for (final entry in <List<Object>>[
|
||||
[kShortcutActionSendCtrlAltDel, 'delete'],
|
||||
[kShortcutActionToggleFullscreen, 'enter'],
|
||||
[kShortcutActionSwitchDisplayNext, 'arrow_right'],
|
||||
[kShortcutActionSwitchDisplayPrev, 'arrow_left'],
|
||||
[kShortcutActionScreenshot, 'p'],
|
||||
[kShortcutActionToggleShowRemoteCursor, 'm'],
|
||||
[kShortcutActionToggleMute, 's'],
|
||||
[kShortcutActionToggleBlockInput, 'i'],
|
||||
[kShortcutActionToggleChat, 'c'],
|
||||
])
|
||||
{
|
||||
'action': entry[0],
|
||||
'mods': const ['primary', 'alt', 'shift'],
|
||||
'key': entry[1],
|
||||
},
|
||||
];
|
||||
@@ -0,0 +1,200 @@
|
||||
import 'package:flutter/services.dart';
|
||||
|
||||
import 'shortcut_constants.dart';
|
||||
|
||||
List<String> canonicalShortcutModsForSave(Set<String> mods) {
|
||||
return <String>[
|
||||
if (mods.contains('primary')) 'primary',
|
||||
if (mods.contains('ctrl')) 'ctrl',
|
||||
if (mods.contains('alt')) 'alt',
|
||||
if (mods.contains('shift')) 'shift',
|
||||
];
|
||||
}
|
||||
|
||||
bool isSwitchTabShortcutAction(String? actionId) {
|
||||
return actionId == kShortcutActionSwitchTabNext ||
|
||||
actionId == kShortcutActionSwitchTabPrev;
|
||||
}
|
||||
|
||||
/// Map a [LogicalKeyboardKey] to the canonical key name used in saved
|
||||
/// bindings, or `null` for keys we don't accept as shortcuts.
|
||||
///
|
||||
/// 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. Cross-language parity is enforced by:
|
||||
/// * `flutter/test/fixtures/supported_shortcut_keys.json` — the
|
||||
/// authoritative list of names this function must produce.
|
||||
/// * Dart `supported keys` test in `keyboard_shortcuts_test.dart` —
|
||||
/// asserts the (LogicalKeyboardKey → name) mapping covers the fixture.
|
||||
/// * Rust `supported_keys_match_fixture` test in `shortcuts.rs` — the
|
||||
/// Rust-side mirror against the same fixture.
|
||||
/// A drift in any of the three breaks one of the two tests.
|
||||
String? logicalKeyName(LogicalKeyboardKey k) {
|
||||
// Singletons that map 1:1.
|
||||
if (k == LogicalKeyboardKey.delete) return 'delete';
|
||||
if (k == LogicalKeyboardKey.backspace) return 'backspace';
|
||||
// Numpad Enter shares the "enter" name with the main Return key — matches
|
||||
// the Rust matcher (`Return | KpReturn` → "enter") and matches user
|
||||
// expectation that the two physical Enters are interchangeable.
|
||||
if (k == LogicalKeyboardKey.enter || k == LogicalKeyboardKey.numpadEnter) {
|
||||
return 'enter';
|
||||
}
|
||||
if (k == LogicalKeyboardKey.tab) return 'tab';
|
||||
if (k == LogicalKeyboardKey.space) return 'space';
|
||||
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';
|
||||
if (k == LogicalKeyboardKey.home) return 'home';
|
||||
if (k == LogicalKeyboardKey.end) return 'end';
|
||||
if (k == LogicalKeyboardKey.pageUp) return 'page_up';
|
||||
if (k == LogicalKeyboardKey.pageDown) return 'page_down';
|
||||
if (k == LogicalKeyboardKey.insert) return 'insert';
|
||||
|
||||
// Letter / digit / F-key tables. `LogicalKeyboardKey` constants are
|
||||
// `static final` (not `const`), so the maps can't be `const` — but they
|
||||
// initialize once per process and the lookup is O(1).
|
||||
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',
|
||||
};
|
||||
final letter = letters[k];
|
||||
if (letter != null) return letter;
|
||||
|
||||
final digits = <LogicalKeyboardKey, String>{
|
||||
LogicalKeyboardKey.digit0: 'digit0',
|
||||
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',
|
||||
};
|
||||
final digit = digits[k];
|
||||
if (digit != null) return digit;
|
||||
|
||||
final fkeys = <LogicalKeyboardKey, String>{
|
||||
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',
|
||||
};
|
||||
return fkeys[k];
|
||||
}
|
||||
|
||||
/// Bundle of "is this shortcut available on the current platform" flags.
|
||||
///
|
||||
/// Production code reaches a single source of truth via
|
||||
/// [ShortcutModel.currentPlatformCapabilities] (which encodes the per-runtime
|
||||
/// rules in one place); tests construct one directly with whichever flags
|
||||
/// they want to exercise. Two filter functions consume this:
|
||||
/// [filterDefaultBindingsForPlatform] (for trimming default-binding JSON
|
||||
/// before it hits LocalConfig) and [filterKeyboardShortcutActionGroupsForPlatform]
|
||||
/// (for trimming the configuration UI's action list). Both must agree on the
|
||||
/// same capability set, otherwise a default binding could be seeded for an
|
||||
/// action the user has no UI to manage.
|
||||
class ShortcutPlatformCapabilities {
|
||||
final bool includeFullscreenShortcut;
|
||||
final bool includeScreenshotShortcut;
|
||||
final bool includeTabShortcuts;
|
||||
final bool includeToolbarShortcut;
|
||||
final bool includeCloseTabShortcut;
|
||||
final bool includeSwitchSidesShortcut;
|
||||
final bool includeRecordingShortcut;
|
||||
final bool includeResetCanvasShortcut;
|
||||
final bool includePinToolbarShortcut;
|
||||
final bool includeViewModeShortcut;
|
||||
final bool includeInputSourceShortcut;
|
||||
final bool includeVoiceCallShortcut;
|
||||
|
||||
const ShortcutPlatformCapabilities({
|
||||
required this.includeFullscreenShortcut,
|
||||
required this.includeScreenshotShortcut,
|
||||
required this.includeTabShortcuts,
|
||||
required this.includeToolbarShortcut,
|
||||
required this.includeCloseTabShortcut,
|
||||
required this.includeSwitchSidesShortcut,
|
||||
required this.includeRecordingShortcut,
|
||||
required this.includeResetCanvasShortcut,
|
||||
required this.includePinToolbarShortcut,
|
||||
required this.includeViewModeShortcut,
|
||||
required this.includeInputSourceShortcut,
|
||||
required this.includeVoiceCallShortcut,
|
||||
});
|
||||
}
|
||||
|
||||
List<Map<String, dynamic>> filterDefaultBindingsForPlatform(
|
||||
Iterable<dynamic> bindings,
|
||||
ShortcutPlatformCapabilities cap,
|
||||
) {
|
||||
final filtered = <Map<String, dynamic>>[];
|
||||
for (final raw in bindings) {
|
||||
if (raw is! Map) continue;
|
||||
final binding = Map<String, dynamic>.from(raw);
|
||||
final action = binding['action'] as String?;
|
||||
if (!cap.includeFullscreenShortcut &&
|
||||
action == kShortcutActionToggleFullscreen) {
|
||||
continue;
|
||||
}
|
||||
if (!cap.includeScreenshotShortcut && action == kShortcutActionScreenshot) {
|
||||
continue;
|
||||
}
|
||||
if (!cap.includeTabShortcuts && isSwitchTabShortcutAction(action)) {
|
||||
continue;
|
||||
}
|
||||
if (!cap.includeToolbarShortcut &&
|
||||
action == kShortcutActionToggleToolbar) {
|
||||
continue;
|
||||
}
|
||||
if (!cap.includeCloseTabShortcut && action == kShortcutActionCloseTab) {
|
||||
continue;
|
||||
}
|
||||
if (!cap.includeSwitchSidesShortcut &&
|
||||
action == kShortcutActionSwitchSides) {
|
||||
continue;
|
||||
}
|
||||
if (!cap.includeRecordingShortcut &&
|
||||
action == kShortcutActionToggleRecording) {
|
||||
continue;
|
||||
}
|
||||
if (!cap.includeResetCanvasShortcut &&
|
||||
action == kShortcutActionResetCanvas) {
|
||||
continue;
|
||||
}
|
||||
if (!cap.includePinToolbarShortcut && action == kShortcutActionPinToolbar) {
|
||||
continue;
|
||||
}
|
||||
if (!cap.includeViewModeShortcut &&
|
||||
(action == kShortcutActionViewModeOriginal ||
|
||||
action == kShortcutActionViewModeAdaptive ||
|
||||
action == kShortcutActionViewModeCustom)) {
|
||||
continue;
|
||||
}
|
||||
if (!cap.includeInputSourceShortcut &&
|
||||
action == kShortcutActionToggleInputSource) {
|
||||
continue;
|
||||
}
|
||||
if (!cap.includeVoiceCallShortcut &&
|
||||
action == kShortcutActionToggleVoiceCall) {
|
||||
continue;
|
||||
}
|
||||
filtered.add(binding);
|
||||
}
|
||||
return filtered;
|
||||
}
|
||||
@@ -11,26 +11,17 @@ import 'package:flutter_hbb/consts.dart';
|
||||
import 'package:flutter_hbb/desktop/widgets/remote_toolbar.dart';
|
||||
import 'package:flutter_hbb/models/model.dart';
|
||||
import 'package:flutter_hbb/models/platform_model.dart';
|
||||
import 'package:flutter_hbb/models/shortcut_model.dart';
|
||||
import 'package:flutter_hbb/utils/multi_window_manager.dart';
|
||||
import 'package:get/get.dart';
|
||||
|
||||
bool isEditOsPassword = false;
|
||||
|
||||
/// Action IDs that `toolbarControls` is the sole registrar for. Each call to
|
||||
/// `toolbarControls` (e.g. opening the toolbar menu after a permission was
|
||||
/// revoked or a state changed) wipes these so a previously-registered closure
|
||||
/// can't outlive the menu entry that owns it. The for-loop at the bottom of
|
||||
/// `toolbarControls` then re-registers whichever entries are still present in
|
||||
/// the rebuilt menu list.
|
||||
///
|
||||
/// Actions registered elsewhere — `registerSessionShortcutActions` on desktop
|
||||
/// owns toggle_recording, fullscreen, switch_display, switch_tab, close_tab,
|
||||
/// toggle_toolbar — MUST NOT appear here, otherwise this list would clobber
|
||||
/// their registration on every menu rebuild.
|
||||
///
|
||||
/// `kShortcutActionToggleRecording` is platform-conditional (mobile-only —
|
||||
/// see the `!(isDesktop || isWeb)` guard in `toolbarControls`). It is handled
|
||||
/// separately in the unregister pass rather than appearing in this const list.
|
||||
/// Action IDs that `toolbarControls` is the sole registrar for. Wiped on
|
||||
/// every call so stale closures don't outlive the menu entry that owned
|
||||
/// them. Actions registered by `registerSessionShortcutActions` MUST NOT
|
||||
/// appear here. `kShortcutActionToggleRecording` is platform-conditional
|
||||
/// and handled separately in the unregister pass below.
|
||||
const _kToolbarOwnedActionIds = <String>[
|
||||
kShortcutActionSendCtrlAltDel,
|
||||
kShortcutActionRestartRemote,
|
||||
@@ -39,6 +30,8 @@ const _kToolbarOwnedActionIds = <String>[
|
||||
kShortcutActionSwitchSides,
|
||||
kShortcutActionRefresh,
|
||||
kShortcutActionScreenshot,
|
||||
kShortcutActionResetCanvas,
|
||||
kShortcutActionSendClipboardKeystrokes,
|
||||
];
|
||||
|
||||
class TTextMenu {
|
||||
@@ -74,20 +67,61 @@ class TRadioMenu<T> {
|
||||
final T value;
|
||||
final T groupValue;
|
||||
final ValueChanged<T?>? onChanged;
|
||||
final String? actionId;
|
||||
|
||||
TRadioMenu(
|
||||
{required this.child,
|
||||
required this.value,
|
||||
required this.groupValue,
|
||||
required this.onChanged});
|
||||
required this.onChanged,
|
||||
this.actionId});
|
||||
}
|
||||
|
||||
class TToggleMenu {
|
||||
final Widget child;
|
||||
final bool value;
|
||||
final ValueChanged<bool?>? onChanged;
|
||||
final String? actionId;
|
||||
TToggleMenu(
|
||||
{required this.child, required this.value, required this.onChanged});
|
||||
{required this.child,
|
||||
required this.value,
|
||||
required this.onChanged,
|
||||
this.actionId});
|
||||
}
|
||||
|
||||
/// Register each tagged entry's `onChanged` with the session [ShortcutModel].
|
||||
/// Passthrough — returns [menus] so a caller can wrap `return [...]` directly.
|
||||
List<TToggleMenu> _registerToggleMenuShortcuts(
|
||||
FFI ffi, List<TToggleMenu> menus) {
|
||||
for (final menu in menus) {
|
||||
final actionId = menu.actionId;
|
||||
if (actionId == null) continue;
|
||||
final onChanged = menu.onChanged;
|
||||
if (onChanged == null) {
|
||||
ffi.shortcutModel.unregister(actionId);
|
||||
} else {
|
||||
final value = menu.value;
|
||||
ffi.shortcutModel.register(actionId, () => onChanged(!value));
|
||||
}
|
||||
}
|
||||
return menus;
|
||||
}
|
||||
|
||||
/// Radio variant of [_registerToggleMenuShortcuts].
|
||||
List<TRadioMenu<T>> _registerRadioMenuShortcuts<T>(
|
||||
FFI ffi, List<TRadioMenu<T>> menus) {
|
||||
for (final menu in menus) {
|
||||
final actionId = menu.actionId;
|
||||
if (actionId == null) continue;
|
||||
final onChanged = menu.onChanged;
|
||||
if (onChanged == null) {
|
||||
ffi.shortcutModel.unregister(actionId);
|
||||
} else {
|
||||
final value = menu.value;
|
||||
ffi.shortcutModel.register(actionId, () => onChanged(value));
|
||||
}
|
||||
}
|
||||
return menus;
|
||||
}
|
||||
|
||||
handleOsPasswordEditIcon(
|
||||
@@ -121,16 +155,13 @@ List<TTextMenu> toolbarControls(BuildContext context, String id, FFI ffi) {
|
||||
final sessionId = ffi.sessionId;
|
||||
final isDefaultConn = ffi.connType == ConnType.defaultConn;
|
||||
|
||||
// Wipe everything `toolbarControls` could have registered last call so
|
||||
// stale closures (e.g. for a menu entry whose permission has since been
|
||||
// revoked) don't outlive the menu rebuild. See _kToolbarOwnedActionIds.
|
||||
// Wipe stale registrations from previous menu builds before re-registering
|
||||
// below; runs unconditionally so mid-session enable works without reconnect.
|
||||
for (final actionId in _kToolbarOwnedActionIds) {
|
||||
ffi.shortcutModel.unregister(actionId);
|
||||
}
|
||||
// toggle_recording is platform-conditional — toolbarControls only builds
|
||||
// the menu entry on `!(isDesktop || isWeb)`. On desktop the registration
|
||||
// is owned by `registerSessionShortcutActions` and must NOT be touched
|
||||
// here. See the recording menu entry below.
|
||||
// toggle_recording is mobile-only here; desktop's registration is owned by
|
||||
// `registerSessionShortcutActions` and must not be touched.
|
||||
if (!(isDesktop || isWeb)) {
|
||||
ffi.shortcutModel.unregister(kShortcutActionToggleRecording);
|
||||
}
|
||||
@@ -188,13 +219,15 @@ List<TTextMenu> toolbarControls(BuildContext context, String id, FFI ffi) {
|
||||
bind.sessionInputString(
|
||||
sessionId: sessionId, value: data.text ?? "");
|
||||
}
|
||||
}));
|
||||
},
|
||||
actionId: kShortcutActionSendClipboardKeystrokes));
|
||||
}
|
||||
// reset canvas
|
||||
if (isDefaultConn && isMobile) {
|
||||
v.add(TTextMenu(
|
||||
child: Text(translate('Reset canvas')),
|
||||
onPressed: () => ffi.cursorModel.reset()));
|
||||
onPressed: () => ffi.cursorModel.reset(),
|
||||
actionId: kShortcutActionResetCanvas));
|
||||
}
|
||||
|
||||
// https://github.com/rustdesk/rustdesk/pull/9731
|
||||
@@ -409,19 +442,8 @@ 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 action IDs already cleared at the top of this function (i.e. those
|
||||
// in [_kToolbarOwnedActionIds] plus the conditional toggle_recording),
|
||||
// the `else` branch below is a redundant idempotent no-op — `unregister`
|
||||
// just calls `Map.remove` on something already absent.
|
||||
//
|
||||
// The branch is kept as **defense in depth** for the case where a future
|
||||
// contributor tags a menu item with an actionId that they forget to add
|
||||
// to [_kToolbarOwnedActionIds]: without this `else`, the original
|
||||
// "stale-closure-outlives-disabled-state" bug (e.g. Screenshot cooldown
|
||||
// bypass) would silently come back for that new action only.
|
||||
// Register tagged TTextMenu callbacks. The else-unregister is defense in
|
||||
// depth for actionIds tagged but missing from `_kToolbarOwnedActionIds`.
|
||||
for (final menu in v) {
|
||||
final actionId = menu.actionId;
|
||||
if (actionId == null) continue;
|
||||
@@ -445,23 +467,26 @@ Future<List<TRadioMenu<String>>> toolbarViewStyle(
|
||||
.then((_) => ffi.canvasModel.updateViewStyle());
|
||||
}
|
||||
|
||||
return [
|
||||
return _registerRadioMenuShortcuts(ffi, [
|
||||
TRadioMenu<String>(
|
||||
child: Text(translate('Scale original')),
|
||||
value: kRemoteViewStyleOriginal,
|
||||
groupValue: groupValue,
|
||||
onChanged: onChanged),
|
||||
onChanged: onChanged,
|
||||
actionId: kShortcutActionViewModeOriginal),
|
||||
TRadioMenu<String>(
|
||||
child: Text(translate('Scale adaptive')),
|
||||
value: kRemoteViewStyleAdaptive,
|
||||
groupValue: groupValue,
|
||||
onChanged: onChanged),
|
||||
onChanged: onChanged,
|
||||
actionId: kShortcutActionViewModeAdaptive),
|
||||
TRadioMenu<String>(
|
||||
child: Text(translate('Scale custom')),
|
||||
value: kRemoteViewStyleCustom,
|
||||
groupValue: groupValue,
|
||||
onChanged: onChanged)
|
||||
];
|
||||
onChanged: onChanged,
|
||||
actionId: kShortcutActionViewModeCustom)
|
||||
]);
|
||||
}
|
||||
|
||||
Future<List<TRadioMenu<String>>> toolbarImageQuality(
|
||||
@@ -473,22 +498,25 @@ Future<List<TRadioMenu<String>>> toolbarImageQuality(
|
||||
await bind.sessionSetImageQuality(sessionId: ffi.sessionId, value: value);
|
||||
}
|
||||
|
||||
return [
|
||||
return _registerRadioMenuShortcuts(ffi, [
|
||||
TRadioMenu<String>(
|
||||
child: Text(translate('Good image quality')),
|
||||
value: kRemoteImageQualityBest,
|
||||
groupValue: groupValue,
|
||||
onChanged: onChanged),
|
||||
onChanged: onChanged,
|
||||
actionId: kShortcutActionImageQualityBest),
|
||||
TRadioMenu<String>(
|
||||
child: Text(translate('Balanced')),
|
||||
value: kRemoteImageQualityBalanced,
|
||||
groupValue: groupValue,
|
||||
onChanged: onChanged),
|
||||
onChanged: onChanged,
|
||||
actionId: kShortcutActionImageQualityBalanced),
|
||||
TRadioMenu<String>(
|
||||
child: Text(translate('Optimize reaction time')),
|
||||
value: kRemoteImageQualityLow,
|
||||
groupValue: groupValue,
|
||||
onChanged: onChanged),
|
||||
onChanged: onChanged,
|
||||
actionId: kShortcutActionImageQualityLow),
|
||||
TRadioMenu<String>(
|
||||
child: Text(translate('Custom')),
|
||||
value: kRemoteImageQualityCustom,
|
||||
@@ -498,7 +526,7 @@ Future<List<TRadioMenu<String>>> toolbarImageQuality(
|
||||
customImageQualityDialog(ffi.sessionId, id, ffi);
|
||||
},
|
||||
),
|
||||
];
|
||||
]);
|
||||
}
|
||||
|
||||
Future<List<TRadioMenu<String>>> toolbarCodec(
|
||||
@@ -533,12 +561,14 @@ Future<List<TRadioMenu<String>>> toolbarCodec(
|
||||
bind.sessionChangePreferCodec(sessionId: sessionId);
|
||||
}
|
||||
|
||||
TRadioMenu<String> radio(String label, String value, bool enabled) {
|
||||
TRadioMenu<String> radio(
|
||||
String label, String value, bool enabled, String actionId) {
|
||||
return TRadioMenu<String>(
|
||||
child: Text(label),
|
||||
value: value,
|
||||
groupValue: groupValue,
|
||||
onChanged: enabled ? onChanged : null);
|
||||
onChanged: enabled ? onChanged : null,
|
||||
actionId: actionId);
|
||||
}
|
||||
|
||||
var autoLabel = translate('Auto');
|
||||
@@ -546,14 +576,14 @@ Future<List<TRadioMenu<String>>> toolbarCodec(
|
||||
ffi.qualityMonitorModel.data.codecFormat != null) {
|
||||
autoLabel = '$autoLabel (${ffi.qualityMonitorModel.data.codecFormat})';
|
||||
}
|
||||
return [
|
||||
radio(autoLabel, 'auto', true),
|
||||
if (codecs[0]) radio('VP8', 'vp8', codecs[0]),
|
||||
radio('VP9', 'vp9', true),
|
||||
if (codecs[1]) radio('AV1', 'av1', codecs[1]),
|
||||
if (codecs[2]) radio('H264', 'h264', codecs[2]),
|
||||
if (codecs[3]) radio('H265', 'h265', codecs[3]),
|
||||
];
|
||||
return _registerRadioMenuShortcuts(ffi, [
|
||||
radio(autoLabel, 'auto', true, kShortcutActionCodecAuto),
|
||||
if (codecs[0]) radio('VP8', 'vp8', codecs[0], kShortcutActionCodecVp8),
|
||||
radio('VP9', 'vp9', true, kShortcutActionCodecVp9),
|
||||
if (codecs[1]) radio('AV1', 'av1', codecs[1], kShortcutActionCodecAv1),
|
||||
if (codecs[2]) radio('H264', 'h264', codecs[2], kShortcutActionCodecH264),
|
||||
if (codecs[3]) radio('H265', 'h265', codecs[3], kShortcutActionCodecH265),
|
||||
]);
|
||||
}
|
||||
|
||||
Future<List<TToggleMenu>> toolbarCursor(
|
||||
@@ -578,6 +608,7 @@ Future<List<TToggleMenu>> toolbarCursor(
|
||||
v.add(TToggleMenu(
|
||||
child: Text(translate('Show remote cursor')),
|
||||
value: state.value,
|
||||
actionId: kShortcutActionToggleShowRemoteCursor,
|
||||
onChanged: enabled && !lockState.value
|
||||
? (value) async {
|
||||
if (value == null) return;
|
||||
@@ -614,6 +645,7 @@ Future<List<TToggleMenu>> toolbarCursor(
|
||||
v.add(TToggleMenu(
|
||||
child: Text(translate('Follow remote cursor')),
|
||||
value: value,
|
||||
actionId: kShortcutActionToggleFollowRemoteCursor,
|
||||
onChanged: (value) async {
|
||||
if (value == null) return;
|
||||
await bind.sessionToggleOption(sessionId: sessionId, value: option);
|
||||
@@ -642,6 +674,7 @@ Future<List<TToggleMenu>> toolbarCursor(
|
||||
v.add(TToggleMenu(
|
||||
child: Text(translate('Follow remote window focus')),
|
||||
value: value,
|
||||
actionId: kShortcutActionToggleFollowRemoteWindow,
|
||||
onChanged: (value) async {
|
||||
if (value == null) return;
|
||||
await bind.sessionToggleOption(sessionId: sessionId, value: option);
|
||||
@@ -659,6 +692,7 @@ Future<List<TToggleMenu>> toolbarCursor(
|
||||
v.add(TToggleMenu(
|
||||
child: Text(translate('Zoom cursor')),
|
||||
value: peerState.value,
|
||||
actionId: kShortcutActionToggleZoomCursor,
|
||||
onChanged: (value) async {
|
||||
if (value == null) return;
|
||||
await bind.sessionToggleOption(sessionId: sessionId, value: option);
|
||||
@@ -667,7 +701,7 @@ Future<List<TToggleMenu>> toolbarCursor(
|
||||
},
|
||||
));
|
||||
}
|
||||
return v;
|
||||
return _registerToggleMenuShortcuts(ffi, v);
|
||||
}
|
||||
|
||||
Future<List<TToggleMenu>> toolbarDisplayToggle(
|
||||
@@ -683,6 +717,7 @@ Future<List<TToggleMenu>> toolbarDisplayToggle(
|
||||
final option = 'show-quality-monitor';
|
||||
v.add(TToggleMenu(
|
||||
value: bind.sessionGetToggleOptionSync(sessionId: sessionId, arg: option),
|
||||
actionId: kShortcutActionToggleQualityMonitor,
|
||||
onChanged: (value) async {
|
||||
if (value == null) return;
|
||||
await bind.sessionToggleOption(sessionId: sessionId, value: option);
|
||||
@@ -696,6 +731,7 @@ Future<List<TToggleMenu>> toolbarDisplayToggle(
|
||||
bind.sessionGetToggleOptionSync(sessionId: sessionId, arg: option);
|
||||
v.add(TToggleMenu(
|
||||
value: value,
|
||||
actionId: kShortcutActionToggleMute,
|
||||
onChanged: (value) {
|
||||
if (value == null) return;
|
||||
bind.sessionToggleOption(sessionId: sessionId, value: option);
|
||||
@@ -720,6 +756,7 @@ Future<List<TToggleMenu>> toolbarDisplayToggle(
|
||||
sessionId: sessionId, arg: kOptionEnableFileCopyPaste);
|
||||
v.add(TToggleMenu(
|
||||
value: value,
|
||||
actionId: kShortcutActionToggleEnableFileCopyPaste,
|
||||
onChanged: enabled
|
||||
? (value) {
|
||||
if (value == null) return;
|
||||
@@ -738,6 +775,7 @@ Future<List<TToggleMenu>> toolbarDisplayToggle(
|
||||
if (ffiModel.viewOnly) value = true;
|
||||
v.add(TToggleMenu(
|
||||
value: value,
|
||||
actionId: kShortcutActionToggleDisableClipboard,
|
||||
onChanged: enabled
|
||||
? (value) {
|
||||
if (value == null) return;
|
||||
@@ -754,6 +792,7 @@ Future<List<TToggleMenu>> toolbarDisplayToggle(
|
||||
bind.sessionGetToggleOptionSync(sessionId: sessionId, arg: option);
|
||||
v.add(TToggleMenu(
|
||||
value: value,
|
||||
actionId: kShortcutActionToggleLockAfterSessionEnd,
|
||||
onChanged: enabled
|
||||
? (value) {
|
||||
if (value == null) return;
|
||||
@@ -804,6 +843,7 @@ Future<List<TToggleMenu>> toolbarDisplayToggle(
|
||||
bind.sessionGetToggleOptionSync(sessionId: sessionId, arg: option);
|
||||
v.add(TToggleMenu(
|
||||
value: value,
|
||||
actionId: kShortcutActionToggleTrueColor,
|
||||
onChanged: (value) async {
|
||||
if (value == null) return;
|
||||
await bind.sessionToggleOption(sessionId: sessionId, value: option);
|
||||
@@ -828,7 +868,7 @@ Future<List<TToggleMenu>> toolbarDisplayToggle(
|
||||
},
|
||||
child: Text(translate('View Mode'))));
|
||||
}
|
||||
return v;
|
||||
return _registerToggleMenuShortcuts(ffi, v);
|
||||
}
|
||||
|
||||
var togglePrivacyModeTime = DateTime.now().subtract(const Duration(hours: 1));
|
||||
@@ -927,6 +967,7 @@ List<TToggleMenu> toolbarKeyboardToggles(FFI ffi) {
|
||||
final enabled = !ffi.ffiModel.viewOnly;
|
||||
v.add(TToggleMenu(
|
||||
value: value,
|
||||
actionId: kShortcutActionToggleSwapCtrlCmd,
|
||||
onChanged: enabled ? onChanged : null,
|
||||
child: Text(translate('Swap control-command key'))));
|
||||
}
|
||||
@@ -992,10 +1033,26 @@ List<TToggleMenu> toolbarKeyboardToggles(FFI ffi) {
|
||||
final enabled = !ffi.ffiModel.viewOnly;
|
||||
v.add(TToggleMenu(
|
||||
value: value,
|
||||
actionId: kShortcutActionToggleSwapLeftRightMouse,
|
||||
onChanged: enabled ? onChanged : null,
|
||||
child: Text(translate('swap-left-right-mouse'))));
|
||||
}
|
||||
return v;
|
||||
return _registerToggleMenuShortcuts(ffi, v);
|
||||
}
|
||||
|
||||
/// Drive each toolbar helper for its registration side effect, so a shortcut
|
||||
/// fires from the first keystroke without needing the user to open the
|
||||
/// matching submenu. Mobile gets `toolbarKeyboardToggles` via
|
||||
/// `toolbarDisplayToggle`'s `isMobile` branch — calling it explicitly there
|
||||
/// would double-register.
|
||||
void registerToolbarShortcuts(BuildContext context, String id, FFI ffi) {
|
||||
if (isDesktop) toolbarKeyboardToggles(ffi);
|
||||
unawaited(toolbarCursor(context, id, ffi));
|
||||
unawaited(toolbarDisplayToggle(context, id, ffi));
|
||||
unawaited(toolbarViewStyle(context, id, ffi));
|
||||
unawaited(toolbarImageQuality(context, id, ffi));
|
||||
unawaited(toolbarCodec(context, id, ffi));
|
||||
toolbarPrivacyMode(PrivacyModeState.find(id), context, id, ffi);
|
||||
}
|
||||
|
||||
bool showVirtualDisplayMenu(FFI ffi) {
|
||||
|
||||
@@ -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,
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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),
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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,
|
||||
|
||||
95
flutter/lib/mobile/pages/mobile_keyboard_shortcuts_page.dart
Normal file
95
flutter/lib/mobile/pages/mobile_keyboard_shortcuts_page.dart
Normal file
@@ -0,0 +1,95 @@
|
||||
// flutter/lib/mobile/pages/mobile_keyboard_shortcuts_page.dart
|
||||
//
|
||||
// Mobile shell for the Keyboard Shortcuts configuration page. Mirrors
|
||||
// `desktop/pages/desktop_keyboard_shortcuts_page.dart` but with a touch-
|
||||
// friendly layout (ListTile rows instead of dense rows) and a hint banner
|
||||
// that explains the recording flow only works with a physical keyboard.
|
||||
//
|
||||
// All actual logic — group definitions, JSON I/O, conflict-replace flow,
|
||||
// recording-dialog round-trip, "Reset to defaults" — lives in the shared
|
||||
// `common/widgets/keyboard_shortcuts/page_body.dart`. This file only
|
||||
// supplies the AppBar, the AppBar action, and the platform hint banner.
|
||||
//
|
||||
// Mobile keyboard detection limitation: Flutter has no reliable
|
||||
// "is a physical keyboard attached?" API on iOS or Android. Soft keyboards
|
||||
// don't generate the `KeyDownEvent`s the recording dialog listens for, so
|
||||
// in practice the dialog only does anything useful when the user actually
|
||||
// has a hardware keyboard plugged in (USB / Bluetooth / Smart Connector).
|
||||
// For V1 we don't try to detect attachment — we just surface the
|
||||
// requirement as an in-page hint instead of disabling the Edit button.
|
||||
|
||||
import 'package:flutter/material.dart';
|
||||
|
||||
import '../../common.dart';
|
||||
import '../../common/widgets/keyboard_shortcuts/page_body.dart';
|
||||
|
||||
class MobileKeyboardShortcutsPage extends StatefulWidget {
|
||||
const MobileKeyboardShortcutsPage({Key? key}) : super(key: key);
|
||||
|
||||
@override
|
||||
State<MobileKeyboardShortcutsPage> createState() =>
|
||||
_MobileKeyboardShortcutsPageState();
|
||||
}
|
||||
|
||||
class _MobileKeyboardShortcutsPageState
|
||||
extends State<MobileKeyboardShortcutsPage> {
|
||||
final GlobalKey<KeyboardShortcutsPageBodyState> _bodyKey = GlobalKey();
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final theme = Theme.of(context);
|
||||
return Scaffold(
|
||||
appBar: AppBar(
|
||||
title: Text(translate('Keyboard Shortcuts')),
|
||||
actions: [
|
||||
IconButton(
|
||||
tooltip: translate('Reset to defaults'),
|
||||
onPressed: () =>
|
||||
_bodyKey.currentState?.resetToDefaultsWithConfirm(),
|
||||
icon: const Icon(Icons.restore),
|
||||
),
|
||||
],
|
||||
),
|
||||
body: KeyboardShortcutsPageBody(
|
||||
key: _bodyKey,
|
||||
compact: false,
|
||||
editButtonHint: translate('shortcut-mobile-physical-keyboard-tip'),
|
||||
headerBanner: _PhysicalKeyboardHintBanner(theme: theme),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// A muted info banner shown above the master toggle on mobile. We can't
|
||||
/// reliably detect whether a physical keyboard is attached, so instead of
|
||||
/// disabling the Edit button we surface the requirement up front.
|
||||
class _PhysicalKeyboardHintBanner extends StatelessWidget {
|
||||
final ThemeData theme;
|
||||
const _PhysicalKeyboardHintBanner({required this.theme});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final color = theme.colorScheme.primary.withOpacity(0.08);
|
||||
return Container(
|
||||
padding: const EdgeInsets.all(12),
|
||||
decoration: BoxDecoration(
|
||||
color: color,
|
||||
borderRadius: BorderRadius.circular(8),
|
||||
),
|
||||
child: Row(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Icon(Icons.info_outline,
|
||||
size: 18, color: theme.colorScheme.primary),
|
||||
const SizedBox(width: 8),
|
||||
Expanded(
|
||||
child: Text(
|
||||
translate('shortcut-mobile-physical-keyboard-tip'),
|
||||
style: TextStyle(color: theme.colorScheme.onSurface),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -127,10 +127,10 @@ class _RemotePageState extends State<RemotePage> with WidgetsBindingObserver {
|
||||
// what we want here.
|
||||
if (mounted) {
|
||||
toolbarControls(context, widget.id, gFFI);
|
||||
// Mobile has no DesktopTabController, so tab-switch shortcuts
|
||||
// remain unregistered (they will simply log a no-handler debug
|
||||
// line if a mobile user binds one — they have no tabs to switch).
|
||||
// Mobile has no DesktopTabController, so tab-switch shortcuts will
|
||||
// log a no-handler debug line if a user binds one.
|
||||
registerSessionShortcutActions(gFFI);
|
||||
registerToolbarShortcuts(context, widget.id, gFFI);
|
||||
}
|
||||
});
|
||||
WidgetsBinding.instance.addObserver(this);
|
||||
|
||||
536
flutter/lib/models/shortcut_model.dart
Normal file
536
flutter/lib/models/shortcut_model.dart
Normal file
@@ -0,0 +1,536 @@
|
||||
import 'dart:convert';
|
||||
|
||||
import 'package:flutter/foundation.dart';
|
||||
|
||||
import '../common.dart';
|
||||
import '../common/shared_state.dart' show PrivacyModeState;
|
||||
import '../common/widgets/dialog.dart'
|
||||
show desktopTryShowTabAuditDialogCloseCancelled;
|
||||
import '../common/widgets/keyboard_shortcuts/shortcut_utils.dart';
|
||||
import '../consts.dart';
|
||||
import '../desktop/widgets/remote_toolbar.dart' show ToolbarState;
|
||||
import 'chat_model.dart' show VoiceCallStatus;
|
||||
import '../desktop/widgets/tabbar_widget.dart' show DesktopTabController;
|
||||
import '../models/model.dart';
|
||||
import '../models/platform_model.dart';
|
||||
import '../models/state_model.dart';
|
||||
|
||||
/// Per-session shortcut dispatcher. Attached to FFI when a session is created.
|
||||
///
|
||||
/// The Rust matcher (src/keyboard/shortcuts.rs) emits `shortcut_triggered`
|
||||
/// session events containing the matched `action` id. The session event
|
||||
/// listener in [FfiModel.startEventListener] forwards those to this model
|
||||
/// via [onTriggered], which runs whatever callback the toolbar / menu
|
||||
/// builders previously registered for that action id.
|
||||
class ShortcutModel {
|
||||
final WeakReference<FFI> parent;
|
||||
final Map<String, VoidCallback> _callbacks = {};
|
||||
|
||||
ShortcutModel(this.parent);
|
||||
|
||||
/// Called by toolbar / menu builders to register what to do when the
|
||||
/// matched shortcut fires.
|
||||
void register(String actionId, VoidCallback callback) {
|
||||
_callbacks[actionId] = callback;
|
||||
}
|
||||
|
||||
void unregister(String actionId) {
|
||||
_callbacks.remove(actionId);
|
||||
}
|
||||
|
||||
/// Called by the session event listener when a `shortcut_triggered` event
|
||||
/// arrives for this session.
|
||||
void onTriggered(String actionId) {
|
||||
final cb = _callbacks[actionId];
|
||||
if (cb != null) {
|
||||
cb();
|
||||
} else {
|
||||
debugPrint('shortcut_triggered: no handler for $actionId');
|
||||
}
|
||||
}
|
||||
|
||||
/// Read the bindings JSON from LocalConfig.
|
||||
static List<Map<String, dynamic>> readBindings() {
|
||||
final raw = bind.mainGetLocalOption(key: kShortcutLocalConfigKey);
|
||||
if (raw.isEmpty) return [];
|
||||
try {
|
||||
final parsed = jsonDecode(raw) as Map<String, dynamic>;
|
||||
final list = (parsed['bindings'] as List?) ?? [];
|
||||
return list.cast<Map<String, dynamic>>();
|
||||
} catch (_) {
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
static bool isEnabled() {
|
||||
final raw = bind.mainGetLocalOption(key: kShortcutLocalConfigKey);
|
||||
if (raw.isEmpty) return false;
|
||||
try {
|
||||
final parsed = jsonDecode(raw) as Map<String, dynamic>;
|
||||
return parsed['enabled'] == true;
|
||||
} catch (_) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
static bool isPassThrough() {
|
||||
final raw = bind.mainGetLocalOption(key: kShortcutLocalConfigKey);
|
||||
if (raw.isEmpty) return false;
|
||||
try {
|
||||
final parsed = jsonDecode(raw) as Map<String, dynamic>;
|
||||
return parsed['pass_through'] == true;
|
||||
} catch (_) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/// Persistent companion to [isEnabled]: when on, the matchers return early
|
||||
/// and every keystroke flows through to the remote (i.e. all bindings are
|
||||
/// suspended). Stored in the same JSON blob so a single reload refreshes
|
||||
/// both flags on every active matcher.
|
||||
static Future<void> setPassThrough(bool v) async {
|
||||
final raw = bind.mainGetLocalOption(key: kShortcutLocalConfigKey);
|
||||
Map<String, dynamic> json = {};
|
||||
if (raw.isNotEmpty) {
|
||||
try {
|
||||
json = jsonDecode(raw) as Map<String, dynamic>;
|
||||
} catch (_) {
|
||||
json = {};
|
||||
}
|
||||
}
|
||||
json['pass_through'] = v;
|
||||
await bind.mainSetLocalOption(
|
||||
key: kShortcutLocalConfigKey, value: jsonEncode(json));
|
||||
bind.mainReloadKeyboardShortcuts();
|
||||
}
|
||||
|
||||
/// Flip the master `enabled` flag and persist. On the first enable we seed
|
||||
/// the default bindings so common combos work out of the box; otherwise we
|
||||
/// preserve whatever the user already has. Refreshes the matcher cache so
|
||||
/// the change takes effect immediately (Rust on native, JS via the bridge
|
||||
/// on Web).
|
||||
static Future<void> setEnabled(bool v) async {
|
||||
final raw = bind.mainGetLocalOption(key: kShortcutLocalConfigKey);
|
||||
Map<String, dynamic> json = {};
|
||||
if (raw.isNotEmpty) {
|
||||
try {
|
||||
json = jsonDecode(raw) as Map<String, dynamic>;
|
||||
} catch (_) {
|
||||
json = {};
|
||||
}
|
||||
}
|
||||
json['enabled'] = v;
|
||||
final list = (json['bindings'] as List?) ?? const [];
|
||||
if (v && list.isEmpty) {
|
||||
json['bindings'] = filterDefaultBindingsForPlatform(
|
||||
jsonDecode(bind.mainGetDefaultKeyboardShortcuts()) as List,
|
||||
currentPlatformCapabilities(),
|
||||
);
|
||||
} else {
|
||||
json['bindings'] ??= <dynamic>[];
|
||||
}
|
||||
await bind.mainSetLocalOption(
|
||||
key: kShortcutLocalConfigKey, value: jsonEncode(json));
|
||||
bind.mainReloadKeyboardShortcuts();
|
||||
}
|
||||
|
||||
/// Single source of truth for the per-platform "is this shortcut applicable"
|
||||
/// decisions. Both [setEnabled]'s default-seeding pass and the configuration
|
||||
/// page's reset / list-rendering paths read from here, so the seed list and
|
||||
/// the visible action list can never disagree on which platform a given
|
||||
/// action belongs to.
|
||||
///
|
||||
/// Capability rationale:
|
||||
/// * Fullscreen / Toolbar / Pin / View Mode: rendered wherever the
|
||||
/// desktop layout applies (native desktop + Web). Native mobile is
|
||||
/// permanently full-screen and doesn't have a desktop-style toolbar.
|
||||
/// * Screenshot / Switch Sides: native desktop only. The Web bridge
|
||||
/// throws UnimplementedError for `sessionTakeScreenshot`; mobile
|
||||
/// toolbars don't surface either action.
|
||||
/// * Tab navigation / Close Tab: only native desktop ships
|
||||
/// `DesktopTabController`; Web's `RemotePage` is invoked without one.
|
||||
/// * Recording: native desktop has the `_RecordMenu` widget +
|
||||
/// `registerSessionShortcutActions` registration; native Android has
|
||||
/// the `toolbarControls` entry; iOS short-circuits inside
|
||||
/// `recordingModel.toggle()`; Web has no implementation.
|
||||
/// * Reset Canvas: only the mobile toolbar builds the menu entry
|
||||
/// (`isDefaultConn && isMobile` in `toolbarControls`).
|
||||
/// * Input Source: Web only ships a single source so toggling is a
|
||||
/// no-op; the toolbar menu hides itself when fewer than 2 sources are
|
||||
/// advertised.
|
||||
/// * Voice Call: Web bridge throws `UnimplementedError` for both
|
||||
/// `sessionRequestVoiceCall` and `sessionCloseVoiceCall`.
|
||||
static ShortcutPlatformCapabilities currentPlatformCapabilities() {
|
||||
final desktopLayout = isDesktop || isWeb;
|
||||
return ShortcutPlatformCapabilities(
|
||||
includeFullscreenShortcut: desktopLayout,
|
||||
includeScreenshotShortcut: isDesktop,
|
||||
includeTabShortcuts: isDesktop,
|
||||
includeToolbarShortcut: desktopLayout,
|
||||
includeCloseTabShortcut: isDesktop,
|
||||
includeSwitchSidesShortcut: isDesktop,
|
||||
includeRecordingShortcut: !isWeb && !isIOS,
|
||||
includeResetCanvasShortcut: isMobile,
|
||||
includePinToolbarShortcut: desktopLayout,
|
||||
includeViewModeShortcut: desktopLayout,
|
||||
includeInputSourceShortcut: !isWeb,
|
||||
includeVoiceCallShortcut: !isWeb,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// Register the default-bound shortcut actions that aren't already wired by
|
||||
/// `toolbarControls(...)` (which handles things like Ctrl+Alt+Shift+Del and the
|
||||
/// screenshot action). Called once per session from the desktop / mobile
|
||||
/// remote page, after the toolbar registrations have run.
|
||||
///
|
||||
/// We register unconditionally — even when shortcuts are master-disabled —
|
||||
/// because the matcher (Rust + JS) gates dispatch via the `enabled` flag,
|
||||
/// so registered closures are functionally invisible until the user flips
|
||||
/// shortcuts on. This keeps the wiring simple (no rebind callbacks across
|
||||
/// sessions) and lets the user toggle shortcuts mid-session without
|
||||
/// reconnecting.
|
||||
///
|
||||
/// [tabController] is the desktop window's tab controller; `null` on mobile /
|
||||
/// web (where tab-switch shortcuts don't apply).
|
||||
///
|
||||
/// Each callback below is a no-op when the underlying state required to
|
||||
/// service the action isn't available (e.g. only one display, only one tab).
|
||||
void registerSessionShortcutActions(
|
||||
FFI ffi, {
|
||||
DesktopTabController? tabController,
|
||||
ToolbarState? toolbarState,
|
||||
}) {
|
||||
final sessionId = ffi.sessionId;
|
||||
|
||||
// Note on disposal: every closure registered below captures `ffi` via
|
||||
// closure environment, so the FFI object stays alive for the duration of
|
||||
// the closure's execution — even across awaits, even if the session is
|
||||
// closed mid-execution. We therefore don't add per-closure liveness
|
||||
// guards: a `WeakReference<FFI>` check would never go null while the
|
||||
// closure is on the call stack, and the underlying `bind.session*` /
|
||||
// model setters tolerate stale-session calls (they no-op on torn-down
|
||||
// sessions). ShortcutModel.onTriggered's existing entry guard
|
||||
// (`_callbacks` lookup returning null after disposal) is the actual
|
||||
// liveness gate.
|
||||
|
||||
// Toggle Fullscreen — available wherever the desktop layout renders
|
||||
// (native desktop + every Web browser, since Web uses the desktop
|
||||
// RemotePage). `stateGlobal.setFullscreen` handles native window vs.
|
||||
// browser fullscreen. Native mobile is permanently full-screen, so the
|
||||
// action is intentionally not registered there.
|
||||
if (isDesktop || isWeb) {
|
||||
ffi.shortcutModel.register(kShortcutActionToggleFullscreen, () {
|
||||
stateGlobal.setFullscreen(!stateGlobal.fullscreen.value);
|
||||
});
|
||||
}
|
||||
|
||||
// Toggle Recording — desktop only here. Mobile already wires this through
|
||||
// `toolbarControls` (which adds a recording entry on `!(isDesktop||isWeb)`),
|
||||
// but the desktop toolbar uses a separate `_RecordMenu` widget that has no
|
||||
// `actionId`. Without this explicit registration a desktop user could bind
|
||||
// Toggle Recording in settings and the press would have no handler.
|
||||
// `recordingModel.toggle()` itself short-circuits on iOS and on sessions
|
||||
// without recording permission.
|
||||
if (isDesktop) {
|
||||
ffi.shortcutModel.register(kShortcutActionToggleRecording, () {
|
||||
ffi.recordingModel.toggle();
|
||||
});
|
||||
}
|
||||
|
||||
// Switch Display Next / Prev — requires the peer to have at least 2
|
||||
// displays. From the "All displays" merged view, Next jumps to display 0
|
||||
// and Prev to the last display, so the user can always escape the merged
|
||||
// view via these shortcuts.
|
||||
void switchDisplayBy(int delta) {
|
||||
final pi = ffi.ffiModel.pi;
|
||||
final count = pi.displays.length;
|
||||
if (count <= 1) return;
|
||||
final current = pi.currentDisplay;
|
||||
final int next;
|
||||
if (current == kAllDisplayValue) {
|
||||
next = delta > 0 ? 0 : count - 1;
|
||||
} else {
|
||||
next = ((current + delta) % count + count) % count;
|
||||
}
|
||||
bind.sessionSwitchDisplay(
|
||||
isDesktop: isDesktop,
|
||||
sessionId: sessionId,
|
||||
value: Int32List.fromList([next]),
|
||||
);
|
||||
if (pi.isSupportMultiUiSession) {
|
||||
// On multi-ui-session peers no switch-display message is sent back, so
|
||||
// update the local state directly (mirrors `model.dart` handling).
|
||||
ffi.ffiModel.switchToNewDisplay(next, sessionId, ffi.id);
|
||||
}
|
||||
}
|
||||
|
||||
ffi.shortcutModel.register(kShortcutActionSwitchDisplayNext, () {
|
||||
switchDisplayBy(1);
|
||||
});
|
||||
ffi.shortcutModel.register(kShortcutActionSwitchDisplayPrev, () {
|
||||
switchDisplayBy(-1);
|
||||
});
|
||||
|
||||
// Switch to all-monitors view — mirrors the toolbar Monitor menu's
|
||||
// "all monitors" button (only built when peer has >1 display). Not a
|
||||
// toggle: the toolbar button just sets the merged view; another action
|
||||
// (Switch to next/previous display, or another monitor button) takes
|
||||
// you back to a single display.
|
||||
//
|
||||
// Use `openMonitorInTheSameTab(kAllDisplayValue, ...)` rather than calling
|
||||
// `sessionSwitchDisplay` with `[kAllDisplayValue]` directly — the toolbar
|
||||
// path treats `kAllDisplayValue` as a UI sentinel and expands it to the
|
||||
// real display index list (`[0, 1, ...]`) before sending, then updates
|
||||
// local FfiModel state. Sending `[-1]` raw produces a wire value the
|
||||
// remote can't act on and skips the local state update, so the merged
|
||||
// view never engages.
|
||||
ffi.shortcutModel.register(kShortcutActionSwitchDisplayAll, () {
|
||||
final pi = ffi.ffiModel.pi;
|
||||
if (pi.displays.length <= 1) return;
|
||||
if (pi.currentDisplay == kAllDisplayValue) return;
|
||||
openMonitorInTheSameTab(kAllDisplayValue, ffi, pi);
|
||||
});
|
||||
|
||||
// Switch tab next / prev — desktop only. The remote-screen tabs live in
|
||||
// the window-scoped DesktopTabController, not on the FFI itself, so we
|
||||
// need the controller from the page that owns this session. We
|
||||
// intentionally don't expose positional ("Switch to tab N") shortcuts:
|
||||
// counting tabs in a long list is impractical, and AnyDesk / Chrome
|
||||
// standard practice is to favour next/prev navigation.
|
||||
if (tabController != null) {
|
||||
void switchTabBy(int delta) {
|
||||
final tabs = tabController.state.value.tabs;
|
||||
if (tabs.length <= 1) return;
|
||||
final cur = tabs.indexWhere((t) => t.key == ffi.id);
|
||||
if (cur < 0) return;
|
||||
final next = (cur + delta + tabs.length) % tabs.length;
|
||||
tabController.jumpTo(next);
|
||||
}
|
||||
|
||||
ffi.shortcutModel
|
||||
.register(kShortcutActionSwitchTabNext, () => switchTabBy(1));
|
||||
ffi.shortcutModel
|
||||
.register(kShortcutActionSwitchTabPrev, () => switchTabBy(-1));
|
||||
|
||||
// Close Tab — desktop only. Mirrors the tab right-click "Close" entry,
|
||||
// including the audit-log confirmation dialog so a shortcut close goes
|
||||
// through the same path as a menu close.
|
||||
ffi.shortcutModel.register(kShortcutActionCloseTab, () async {
|
||||
if (await desktopTryShowTabAuditDialogCloseCancelled(
|
||||
id: ffi.id,
|
||||
tabController: tabController,
|
||||
)) {
|
||||
return;
|
||||
}
|
||||
tabController.closeBy(ffi.id);
|
||||
});
|
||||
}
|
||||
|
||||
// Toggle Toolbar — desktop only. ToolbarState is window/session-scoped,
|
||||
// owned by the RemotePage that hosts this session.
|
||||
if (toolbarState != null) {
|
||||
ffi.shortcutModel.register(kShortcutActionToggleToolbar, () {
|
||||
toolbarState.switchHide(sessionId);
|
||||
});
|
||||
ffi.shortcutModel.register(kShortcutActionPinToolbar, () {
|
||||
toolbarState.switchPin();
|
||||
});
|
||||
}
|
||||
|
||||
// Toggle Chat overlay (open/close the chat panel for this session).
|
||||
// _ChatMenu is a standalone toolbar icon — not part of any toolbar
|
||||
// helper that returns a TToggleMenu list — so its handler is wired
|
||||
// here rather than picked up by helper auto-register.
|
||||
ffi.shortcutModel.register(kShortcutActionToggleChat, () {
|
||||
ffi.chatModel.toggleChatOverlay();
|
||||
});
|
||||
|
||||
// Toggle Voice Call — start when idle, hang up when active. Mirrors the
|
||||
// toolbar's `_VoiceCallMenu` state-driven button. Web bridge throws
|
||||
// UnimplementedError on both sessionRequestVoiceCall and
|
||||
// sessionCloseVoiceCall, so we don't register on web.
|
||||
if (!isWeb) {
|
||||
ffi.shortcutModel.register(kShortcutActionToggleVoiceCall, () {
|
||||
final status = ffi.chatModel.voiceCallStatus.value;
|
||||
if (status == VoiceCallStatus.connected ||
|
||||
status == VoiceCallStatus.waitingForResponse) {
|
||||
bind.sessionCloseVoiceCall(sessionId: sessionId);
|
||||
} else {
|
||||
bind.sessionRequestVoiceCall(sessionId: sessionId);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
// ── Inline _KeyboardMenu items + actions with no toolbar TToggleMenu/TRadioMenu ─
|
||||
// The toolbar's TToggleMenu / TRadioMenu helpers (toolbarDisplayToggle,
|
||||
// toolbarCursor, toolbarKeyboardToggles, toolbarCodec, toolbarPrivacyMode,
|
||||
// toolbarViewStyle, toolbarImageQuality) auto-register their tagged entries
|
||||
// from the bottom of each helper. The handlers below cover what those
|
||||
// helpers DON'T own:
|
||||
// * Show my cursor / Keyboard mode (Map/Translate/Legacy) / View Only
|
||||
// (desktop) — built as widgets directly in `_KeyboardMenu`, not as
|
||||
// TToggleMenu lists. (Mobile View Only IS in toolbarDisplayToggle and
|
||||
// auto-registers; the desktop session-start handler below registers
|
||||
// first and the helper's auto-register on mobile takes over after its
|
||||
// unawaited future resolves.)
|
||||
// * Plug out all virtual displays — built in `getVirtualDisplayMenuChildren`
|
||||
// as a MenuButton, not a TToggleMenu.
|
||||
// * Toggle Input Source — cycle action; the toolbar exposes per-source
|
||||
// radios but no single "cycle to next source" entry.
|
||||
|
||||
// Show my cursor — toolbar (`_KeyboardMenu.showMyCursor`) pushes the new
|
||||
// value into FfiModel.setShowMyCursor and auto-enables view-only when the
|
||||
// toggle goes on, so the user can never control the remote with their own
|
||||
// cursor visible.
|
||||
ffi.shortcutModel.register(kShortcutActionToggleShowMyCursor, () async {
|
||||
await bind.sessionToggleOption(
|
||||
sessionId: sessionId, value: kOptionToggleShowMyCursor);
|
||||
final showMyCursor = await bind.sessionGetToggleOption(
|
||||
sessionId: sessionId, arg: kOptionToggleShowMyCursor) ??
|
||||
false;
|
||||
ffi.ffiModel.setShowMyCursor(showMyCursor);
|
||||
if (showMyCursor && !ffi.ffiModel.viewOnly) {
|
||||
await bind.sessionToggleOption(
|
||||
sessionId: sessionId, value: kOptionToggleViewOnly);
|
||||
final viewOnly = await bind.sessionGetToggleOption(
|
||||
sessionId: sessionId, arg: kOptionToggleViewOnly) ??
|
||||
false;
|
||||
ffi.ffiModel.setViewOnly(ffi.id, viewOnly);
|
||||
}
|
||||
});
|
||||
|
||||
// Keyboard mode (Map / Translate / Legacy). Mirrors the radio buttons in
|
||||
// `_KeyboardMenu.keyboardMode()` (built as RdoMenuButton, not TRadioMenu).
|
||||
void registerKeyboardMode(String actionId, String mode) {
|
||||
ffi.shortcutModel.register(actionId, () async {
|
||||
await bind.sessionSetKeyboardMode(sessionId: sessionId, value: mode);
|
||||
await ffi.inputModel.updateKeyboardMode();
|
||||
});
|
||||
}
|
||||
|
||||
registerKeyboardMode(kShortcutActionKeyboardModeMap, kKeyMapMode);
|
||||
registerKeyboardMode(kShortcutActionKeyboardModeTranslate, kKeyTranslateMode);
|
||||
registerKeyboardMode(kShortcutActionKeyboardModeLegacy, kKeyLegacyMode);
|
||||
|
||||
// Plug out all virtual displays (Windows + IDD only). Mirrors the toolbar's
|
||||
// "Plug out all" button — present in both IDD modes (RustDesk + Amyuni),
|
||||
// built as a MenuButton inside `getVirtualDisplayMenuChildren`.
|
||||
ffi.shortcutModel.register(kShortcutActionPlugOutAllVirtualDisplays, () {
|
||||
bind.sessionToggleVirtualDisplay(
|
||||
sessionId: sessionId,
|
||||
index: kAllVirtualDisplay,
|
||||
on: false,
|
||||
);
|
||||
});
|
||||
|
||||
// Privacy mode 1 / 2 — fallback handlers for the single-impl and null-impls
|
||||
// branches of `toolbarPrivacyMode`. The multi-impl branch tags each entry
|
||||
// with the matching actionId and `_registerToggleMenuShortcuts` overrides
|
||||
// these closures with the toolbar's own onChanged. But when the peer only
|
||||
// advertises a single impl (older Linux peers, certain platform configs)
|
||||
// toolbarPrivacyMode returns a `getDefaultMenu` entry without an actionId,
|
||||
// so the auto-register pass skips it — these fallbacks are what actually
|
||||
// wire the shortcut in that case.
|
||||
String? findPrivacyImpl(String nameKey) {
|
||||
final impls = ffi.ffiModel.pi
|
||||
.platformAdditions[kPlatformAdditionsSupportedPrivacyModeImpl]
|
||||
as List<dynamic>?;
|
||||
if (impls == null) return null;
|
||||
for (final e in impls) {
|
||||
if (e is List && e.length >= 2 && e[1] == nameKey) return e[0] as String;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
// Match the multi-impl branch of `toolbarPrivacyMode`: turn this impl on iff
|
||||
// the active impl isn't already this one. Comparing `.value == implKey`
|
||||
// (rather than `.value.isEmpty`) means pressing the mode-1 shortcut while
|
||||
// mode 2 is on correctly turns mode 1 ON, instead of misreading the
|
||||
// "any-mode-active" state as "this-mode-active" and toggling OFF.
|
||||
ffi.shortcutModel.register(kShortcutActionPrivacyMode1, () {
|
||||
final implKey = findPrivacyImpl('privacy_mode_impl_mag_tip');
|
||||
if (implKey == null) return;
|
||||
bind.sessionTogglePrivacyMode(
|
||||
sessionId: sessionId,
|
||||
implKey: implKey,
|
||||
on: PrivacyModeState.find(ffi.id).value != implKey,
|
||||
);
|
||||
});
|
||||
ffi.shortcutModel.register(kShortcutActionPrivacyMode2, () {
|
||||
final implKey = findPrivacyImpl('privacy_mode_impl_virtual_display_tip');
|
||||
if (implKey == null) return;
|
||||
bind.sessionTogglePrivacyMode(
|
||||
sessionId: sessionId,
|
||||
implKey: implKey,
|
||||
on: PrivacyModeState.find(ffi.id).value != implKey,
|
||||
);
|
||||
});
|
||||
|
||||
// View Only — desktop toolbar exposes this inline in `_KeyboardMenu.viewMode`
|
||||
// (mobile is in toolbarDisplayToggle and goes through helper auto-register).
|
||||
// Mirrors the desktop callback: toggle + sync FfiModel.viewOnly +
|
||||
// FfiModel.showMyCursor (the toolbar keeps these in step).
|
||||
ffi.shortcutModel.register(kShortcutActionToggleViewOnly, () async {
|
||||
await bind.sessionToggleOption(
|
||||
sessionId: sessionId, value: kOptionToggleViewOnly);
|
||||
final viewOnly = await bind.sessionGetToggleOption(
|
||||
sessionId: sessionId, arg: kOptionToggleViewOnly) ??
|
||||
false;
|
||||
ffi.ffiModel.setViewOnly(ffi.id, viewOnly);
|
||||
final showMyCursor = await bind.sessionGetToggleOption(
|
||||
sessionId: sessionId, arg: kOptionToggleShowMyCursor) ??
|
||||
false;
|
||||
ffi.ffiModel.setShowMyCursor(showMyCursor);
|
||||
});
|
||||
|
||||
// Toggle Reverse mouse wheel — read current 'Y'/'N' (falling back to user
|
||||
// default), flip, write back.
|
||||
ffi.shortcutModel.register(kShortcutActionToggleReverseMouseWheel, () async {
|
||||
var cur = bind.sessionGetReverseMouseWheelSync(sessionId: sessionId) ?? '';
|
||||
if (cur == '') {
|
||||
cur = bind.mainGetUserDefaultOption(key: kKeyReverseMouseWheel);
|
||||
}
|
||||
final next = cur == 'Y' ? 'N' : 'Y';
|
||||
await bind.sessionSetReverseMouseWheel(sessionId: sessionId, value: next);
|
||||
});
|
||||
|
||||
// Toggle Relative mouse mode (gaming mode). Desktop only.
|
||||
if (isDesktop && !isWeb) {
|
||||
ffi.shortcutModel.register(kShortcutActionToggleRelativeMouseMode, () {
|
||||
ffi.inputModel.toggleRelativeMouseMode();
|
||||
});
|
||||
}
|
||||
|
||||
// Toggle Input Source — flips between the available keyboard-event capture
|
||||
// backends (e.g. JS vs Flutter on desktop). Mirrors the radio menu in
|
||||
// remote_toolbar.dart::inputSource(); when fewer than 2 sources are
|
||||
// available the menu hides itself, so this handler is a no-op too.
|
||||
// Useful for accessibility: screen-reader users sometimes need to swap
|
||||
// sources to regain control of the local keyboard (discussion #1933).
|
||||
// Web only ships a single source, so we don't register on web.
|
||||
if (!isWeb) {
|
||||
ffi.shortcutModel.register(kShortcutActionToggleInputSource, () async {
|
||||
final raw = bind.mainSupportedInputSource();
|
||||
if (raw.isEmpty) return;
|
||||
final List<dynamic> list;
|
||||
try {
|
||||
list = jsonDecode(raw) as List<dynamic>;
|
||||
} catch (_) {
|
||||
return;
|
||||
}
|
||||
if (list.length < 2) return;
|
||||
final ids = list
|
||||
.map((e) => (e is List && e.isNotEmpty) ? e[0] as String : '')
|
||||
.where((s) => s.isNotEmpty)
|
||||
.toList();
|
||||
if (ids.length < 2) return;
|
||||
final current = stateGlobal.getInputSource();
|
||||
final idx = ids.indexOf(current);
|
||||
final next = ids[(idx < 0 ? 0 : idx + 1) % ids.length];
|
||||
await stateGlobal.setInputSource(sessionId, next);
|
||||
await ffi.ffiModel.checkDesktopKeyboardMode();
|
||||
await ffi.inputModel.updateKeyboardMode();
|
||||
});
|
||||
}
|
||||
}
|
||||
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');
|
||||
}
|
||||
});
|
||||
}
|
||||
@@ -575,6 +575,7 @@ pub fn session_handle_flutter_key_event(
|
||||
if let Some(session) = sessions::get_session_by_session_id(&session_id) {
|
||||
let keyboard_mode = session.get_keyboard_mode();
|
||||
session.handle_flutter_key_event(
|
||||
session_id,
|
||||
&keyboard_mode,
|
||||
&character,
|
||||
usb_hid,
|
||||
@@ -595,6 +596,7 @@ pub fn session_handle_flutter_raw_key_event(
|
||||
if let Some(session) = sessions::get_session_by_session_id(&session_id) {
|
||||
let keyboard_mode = session.get_keyboard_mode();
|
||||
session.handle_flutter_raw_key_event(
|
||||
session_id,
|
||||
&keyboard_mode,
|
||||
&name,
|
||||
platform_code,
|
||||
@@ -1728,6 +1730,7 @@ pub fn cm_get_clients_length() -> usize {
|
||||
|
||||
pub fn main_init(app_dir: String, custom_client_config: String) {
|
||||
initialize(&app_dir, &custom_client_config);
|
||||
crate::keyboard::shortcuts::reload_from_config();
|
||||
}
|
||||
|
||||
pub fn main_device_id(id: String) {
|
||||
@@ -2247,6 +2250,17 @@ pub fn main_init_input_source() -> SyncReturn<()> {
|
||||
SyncReturn(())
|
||||
}
|
||||
|
||||
pub fn main_reload_keyboard_shortcuts() -> SyncReturn<()> {
|
||||
crate::keyboard::shortcuts::reload_from_config();
|
||||
SyncReturn(())
|
||||
}
|
||||
|
||||
pub fn main_get_default_keyboard_shortcuts() -> SyncReturn<String> {
|
||||
let bindings = crate::keyboard::shortcuts::default_bindings();
|
||||
let json = serde_json::to_string(&bindings).unwrap_or_default();
|
||||
SyncReturn(json)
|
||||
}
|
||||
|
||||
pub fn main_is_installed_lower_version() -> SyncReturn<bool> {
|
||||
SyncReturn(is_installed_lower_version())
|
||||
}
|
||||
|
||||
@@ -10,6 +10,7 @@ use crate::{client::get_key_state, common::GrabState};
|
||||
#[cfg(not(any(target_os = "android", target_os = "ios")))]
|
||||
use hbb_common::log;
|
||||
use hbb_common::message_proto::*;
|
||||
use hbb_common::SessionID;
|
||||
#[cfg(any(target_os = "windows", target_os = "macos"))]
|
||||
use rdev::KeyCode;
|
||||
use rdev::{Event, EventType, Key};
|
||||
@@ -79,6 +80,8 @@ lazy_static::lazy_static! {
|
||||
};
|
||||
}
|
||||
|
||||
pub mod shortcuts;
|
||||
|
||||
pub mod client {
|
||||
use super::*;
|
||||
|
||||
@@ -319,6 +322,32 @@ pub mod client {
|
||||
}
|
||||
|
||||
pub fn process_event(keyboard_mode: &str, event: &Event, lock_modes: Option<i32>) {
|
||||
// Shortcut intercept — must come before any wire encoding.
|
||||
// Only fires on KeyPress (event_to_key_name in shortcuts.rs returns None
|
||||
// for KeyRelease and other non-press events), so flushed releases from
|
||||
// release_remote_keys pass straight through to the encode/forward path.
|
||||
//
|
||||
// NOTE: Shortcut matching intentionally happens BEFORE any key swapping
|
||||
// (swap_modifier_key) so that shortcuts bind to the physical keys pressed,
|
||||
// not the swapped keys. This makes shortcut setup intuitive: users bind
|
||||
// shortcuts to the actual keys they press, regardless of swap settings.
|
||||
// Key swapping only affects what gets sent to the remote.
|
||||
//
|
||||
// Gated on `feature = "flutter"` because the dispatch target
|
||||
// (`flutter::push_session_event`) is Flutter-only. Sciter builds never
|
||||
// call `reload_from_config`, so the cache stays disabled and the
|
||||
// matcher would no-op anyway — but we still skip the call entirely so
|
||||
// a hand-edited config can't silently swallow keys on a UI that has
|
||||
// no way to surface the action.
|
||||
//
|
||||
// `None` for session_id makes the helper resolve through
|
||||
// `flutter::get_cur_session_id()` — the rdev grab loop is process-wide
|
||||
// and has no per-event session context to thread.
|
||||
#[cfg(feature = "flutter")]
|
||||
if crate::keyboard::shortcuts::try_dispatch(None, event) {
|
||||
return;
|
||||
}
|
||||
|
||||
let keyboard_mode = get_keyboard_mode_enum(keyboard_mode);
|
||||
if is_long_press(&event) {
|
||||
return;
|
||||
@@ -334,7 +363,20 @@ pub mod client {
|
||||
event: &Event,
|
||||
lock_modes: Option<i32>,
|
||||
session: &Session<T>,
|
||||
session_id: SessionID,
|
||||
) {
|
||||
// Shortcut intercept — see the long comment in `process_event` above
|
||||
// for the KeyPress-only / feature-gate rationale. The only difference
|
||||
// here is that the Flutter FFI path threads an explicit SessionID
|
||||
// through, so dispatch targets the exact tab the keystroke originated
|
||||
// from — no dependency on the global focus tracker.
|
||||
#[cfg(feature = "flutter")]
|
||||
if crate::keyboard::shortcuts::try_dispatch(Some(&session_id), event) {
|
||||
return;
|
||||
}
|
||||
#[cfg(not(feature = "flutter"))]
|
||||
let _ = session_id;
|
||||
|
||||
let keyboard_mode = get_keyboard_mode_enum(keyboard_mode);
|
||||
if is_long_press(&event) {
|
||||
return;
|
||||
|
||||
707
src/keyboard/shortcuts.rs
Normal file
707
src/keyboard/shortcuts.rs
Normal file
@@ -0,0 +1,707 @@
|
||||
//! Keyboard shortcuts for triggering session actions locally.
|
||||
|
||||
use std::sync::{Arc, RwLock};
|
||||
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
const LOCAL_CONFIG_KEY: &str = "keyboard-shortcuts";
|
||||
|
||||
lazy_static::lazy_static! {
|
||||
static ref CACHE: RwLock<Arc<Bindings>> = RwLock::new(Arc::new(Bindings::default()));
|
||||
}
|
||||
|
||||
/// Registry of all valid action ids that may appear in `Binding.action`.
|
||||
/// Source-of-truth lives on the Flutter side (`flutter/lib/consts.dart`,
|
||||
/// `kShortcutAction*`); these mirror that vocabulary so Rust code can reach
|
||||
/// for them without re-stringifying.
|
||||
#[allow(dead_code)]
|
||||
pub mod action_id {
|
||||
pub const SEND_CTRL_ALT_DEL: &str = "send_ctrl_alt_del";
|
||||
pub const TOGGLE_FULLSCREEN: &str = "toggle_fullscreen";
|
||||
pub const SWITCH_DISPLAY_NEXT: &str = "switch_display_next";
|
||||
pub const SWITCH_DISPLAY_PREV: &str = "switch_display_prev";
|
||||
pub const SWITCH_DISPLAY_ALL: &str = "switch_display_all";
|
||||
pub const SCREENSHOT: &str = "screenshot";
|
||||
pub const INSERT_LOCK: &str = "insert_lock";
|
||||
pub const REFRESH: &str = "refresh";
|
||||
pub const TOGGLE_BLOCK_INPUT: &str = "toggle_block_input";
|
||||
pub const TOGGLE_RECORDING: &str = "toggle_recording";
|
||||
pub const SWITCH_SIDES: &str = "switch_sides";
|
||||
pub const CLOSE_TAB: &str = "close_tab";
|
||||
pub const TOGGLE_TOOLBAR: &str = "toggle_toolbar";
|
||||
pub const RESTART_REMOTE: &str = "restart_remote";
|
||||
pub const RESET_CANVAS: &str = "reset_canvas";
|
||||
pub const TOGGLE_MUTE: &str = "toggle_mute";
|
||||
pub const PIN_TOOLBAR: &str = "pin_toolbar";
|
||||
pub const VIEW_MODE_ORIGINAL: &str = "view_mode_original";
|
||||
pub const VIEW_MODE_ADAPTIVE: &str = "view_mode_adaptive";
|
||||
pub const TOGGLE_CHAT: &str = "toggle_chat";
|
||||
pub const TOGGLE_QUALITY_MONITOR: &str = "toggle_quality_monitor";
|
||||
pub const TOGGLE_SHOW_REMOTE_CURSOR: &str = "toggle_show_remote_cursor";
|
||||
pub const TOGGLE_SHOW_MY_CURSOR: &str = "toggle_show_my_cursor";
|
||||
pub const TOGGLE_DISABLE_CLIPBOARD: &str = "toggle_disable_clipboard";
|
||||
pub const PRIVACY_MODE_1: &str = "privacy_mode_1";
|
||||
pub const PRIVACY_MODE_2: &str = "privacy_mode_2";
|
||||
pub const KEYBOARD_MODE_MAP: &str = "keyboard_mode_map";
|
||||
pub const KEYBOARD_MODE_TRANSLATE: &str = "keyboard_mode_translate";
|
||||
pub const KEYBOARD_MODE_LEGACY: &str = "keyboard_mode_legacy";
|
||||
pub const CODEC_AUTO: &str = "codec_auto";
|
||||
pub const CODEC_VP8: &str = "codec_vp8";
|
||||
pub const CODEC_VP9: &str = "codec_vp9";
|
||||
pub const CODEC_AV1: &str = "codec_av1";
|
||||
pub const CODEC_H264: &str = "codec_h264";
|
||||
pub const CODEC_H265: &str = "codec_h265";
|
||||
pub const PLUG_OUT_ALL_VIRTUAL_DISPLAYS: &str = "plug_out_all_virtual_displays";
|
||||
pub const TOGGLE_RELATIVE_MOUSE_MODE: &str = "toggle_relative_mouse_mode";
|
||||
pub const TOGGLE_FOLLOW_REMOTE_CURSOR: &str = "toggle_follow_remote_cursor";
|
||||
pub const TOGGLE_FOLLOW_REMOTE_WINDOW: &str = "toggle_follow_remote_window";
|
||||
pub const TOGGLE_ZOOM_CURSOR: &str = "toggle_zoom_cursor";
|
||||
pub const TOGGLE_REVERSE_MOUSE_WHEEL: &str = "toggle_reverse_mouse_wheel";
|
||||
pub const TOGGLE_SWAP_LEFT_RIGHT_MOUSE: &str = "toggle_swap_left_right_mouse";
|
||||
pub const TOGGLE_LOCK_AFTER_SESSION_END: &str = "toggle_lock_after_session_end";
|
||||
pub const TOGGLE_TRUE_COLOR: &str = "toggle_true_color";
|
||||
pub const TOGGLE_SWAP_CTRL_CMD: &str = "toggle_swap_ctrl_cmd";
|
||||
pub const TOGGLE_ENABLE_FILE_COPY_PASTE: &str = "toggle_enable_file_copy_paste";
|
||||
pub const VIEW_MODE_CUSTOM: &str = "view_mode_custom";
|
||||
pub const IMAGE_QUALITY_BEST: &str = "image_quality_best";
|
||||
pub const IMAGE_QUALITY_BALANCED: &str = "image_quality_balanced";
|
||||
pub const IMAGE_QUALITY_LOW: &str = "image_quality_low";
|
||||
pub const SEND_CLIPBOARD_KEYSTROKES: &str = "send_clipboard_keystrokes";
|
||||
pub const TOGGLE_INPUT_SOURCE: &str = "toggle_input_source";
|
||||
pub const SWITCH_TAB_NEXT: &str = "switch_tab_next";
|
||||
pub const SWITCH_TAB_PREV: &str = "switch_tab_prev";
|
||||
pub const TOGGLE_VOICE_CALL: &str = "toggle_voice_call";
|
||||
pub const TOGGLE_VIEW_ONLY: &str = "toggle_view_only";
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "snake_case")]
|
||||
pub enum Modifier {
|
||||
Primary,
|
||||
Ctrl,
|
||||
Alt,
|
||||
Shift,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
|
||||
pub struct Binding {
|
||||
pub action: String,
|
||||
pub mods: Vec<Modifier>,
|
||||
pub key: String,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, Default)]
|
||||
pub struct Bindings {
|
||||
#[serde(default)]
|
||||
pub enabled: bool,
|
||||
/// Persistent companion to `enabled`: when true, the matcher returns early
|
||||
/// and every keystroke flows through to the remote (i.e. all bindings are
|
||||
/// suspended). Stored alongside `enabled` and `bindings` so a single
|
||||
/// reload refreshes both flags.
|
||||
#[serde(default)]
|
||||
pub pass_through: bool,
|
||||
#[serde(default)]
|
||||
pub bindings: Vec<Binding>,
|
||||
}
|
||||
|
||||
pub fn default_bindings() -> Vec<Binding> {
|
||||
let prefix = || vec![Modifier::Primary, Modifier::Alt, Modifier::Shift];
|
||||
// Defaults align with AnyDesk's M/S/I/C/Delete/Arrow/Digit conventions
|
||||
// where applicable; "P" for screenshot also matches AnyDesk.
|
||||
vec![
|
||||
Binding { action: action_id::SEND_CTRL_ALT_DEL.into(), mods: prefix(), key: "delete".into() },
|
||||
Binding { action: action_id::TOGGLE_FULLSCREEN.into(), mods: prefix(), key: "enter".into() },
|
||||
Binding { action: action_id::SWITCH_DISPLAY_NEXT.into(), mods: prefix(), key: "arrow_right".into() },
|
||||
Binding { action: action_id::SWITCH_DISPLAY_PREV.into(), mods: prefix(), key: "arrow_left".into() },
|
||||
Binding { action: action_id::SCREENSHOT.into(), mods: prefix(), key: "p".into() },
|
||||
Binding { action: action_id::TOGGLE_SHOW_REMOTE_CURSOR.into(), mods: prefix(), key: "m".into() },
|
||||
Binding { action: action_id::TOGGLE_MUTE.into(), mods: prefix(), key: "s".into() },
|
||||
Binding { action: action_id::TOGGLE_BLOCK_INPUT.into(), mods: prefix(), key: "i".into() },
|
||||
Binding { action: action_id::TOGGLE_CHAT.into(), mods: prefix(), key: "c".into() },
|
||||
]
|
||||
}
|
||||
|
||||
/// Match a normalized (key, modifiers) pair against the given bindings.
|
||||
/// Returns the matched action ID, or None when the matcher is off
|
||||
/// (`enabled == false`), suspended (`pass_through == true`), or no binding
|
||||
/// fires for this combo.
|
||||
///
|
||||
/// Defense-in-depth: bindings with an empty modifier list are skipped here
|
||||
/// even though the recording dialog refuses to save them. A hand-edited
|
||||
/// config (or a future writer-side bug) that lets an empty-mods binding
|
||||
/// through would otherwise turn that key's every press into a swallowed
|
||||
/// shortcut, breaking normal typing in the remote session — a much worse
|
||||
/// failure than the binding simply not firing.
|
||||
pub fn match_normalized<'a>(key: &str, mods: &[Modifier], b: &'a Bindings) -> Option<&'a str> {
|
||||
if !b.enabled || b.pass_through {
|
||||
return None;
|
||||
}
|
||||
for binding in &b.bindings {
|
||||
if binding.mods.is_empty() {
|
||||
continue;
|
||||
}
|
||||
if binding.key == key && mods_equal(&binding.mods, mods) {
|
||||
return Some(binding.action.as_str());
|
||||
}
|
||||
}
|
||||
None
|
||||
}
|
||||
|
||||
pub fn normalize_modifiers(alt: bool, ctrl: bool, shift: bool, command: bool) -> Vec<Modifier> {
|
||||
// iOS shares Apple's keyboard semantics with macOS — recording dialog
|
||||
// already treats iOS as `_isMac`, so the matcher must too.
|
||||
//
|
||||
// AltGr conflation: `get_modifiers_state` ORs Alt and AltGr, so an
|
||||
// AltGr+key press satisfies `Modifier::Alt`. Theoretical collision only;
|
||||
// fix at `get_modifiers_state` if a real bug surfaces.
|
||||
let mut v = Vec::new();
|
||||
if cfg!(any(target_os = "macos", target_os = "ios")) {
|
||||
if command { v.push(Modifier::Primary); }
|
||||
if ctrl { v.push(Modifier::Ctrl); }
|
||||
} else {
|
||||
if ctrl { v.push(Modifier::Primary); }
|
||||
}
|
||||
if alt { v.push(Modifier::Alt); }
|
||||
if shift { v.push(Modifier::Shift); }
|
||||
v
|
||||
}
|
||||
|
||||
/// Map an rdev::Event to a string key name, matching the storage schema.
|
||||
/// Returns None for events we don't intercept (modifier-only presses, releases, etc.).
|
||||
pub fn event_to_key_name(event: &rdev::Event) -> Option<String> {
|
||||
use rdev::{EventType, Key};
|
||||
let key = match event.event_type {
|
||||
EventType::KeyPress(k) => k,
|
||||
_ => return None,
|
||||
};
|
||||
Some(match key {
|
||||
Key::Delete => "delete".into(),
|
||||
Key::Backspace => "backspace".into(),
|
||||
Key::Tab => "tab".into(),
|
||||
Key::Space => "space".into(),
|
||||
Key::Home => "home".into(),
|
||||
Key::End => "end".into(),
|
||||
Key::PageUp => "page_up".into(),
|
||||
Key::PageDown => "page_down".into(),
|
||||
Key::Insert => "insert".into(),
|
||||
// Numpad Enter (`KpReturn`) shares the "enter" name with the main
|
||||
// Return key — matches the Web matcher (`NumpadEnter` -> "enter") and
|
||||
// matches user expectation that the two physical Enters are
|
||||
// interchangeable for shortcuts.
|
||||
Key::Return | Key::KpReturn => "enter".into(),
|
||||
Key::LeftArrow => "arrow_left".into(),
|
||||
Key::RightArrow => "arrow_right".into(),
|
||||
Key::UpArrow => "arrow_up".into(),
|
||||
Key::DownArrow => "arrow_down".into(),
|
||||
Key::KeyA => "a".into(),
|
||||
Key::KeyB => "b".into(),
|
||||
Key::KeyC => "c".into(),
|
||||
Key::KeyD => "d".into(),
|
||||
Key::KeyE => "e".into(),
|
||||
Key::KeyF => "f".into(),
|
||||
Key::KeyG => "g".into(),
|
||||
Key::KeyH => "h".into(),
|
||||
Key::KeyI => "i".into(),
|
||||
Key::KeyJ => "j".into(),
|
||||
Key::KeyK => "k".into(),
|
||||
Key::KeyL => "l".into(),
|
||||
Key::KeyM => "m".into(),
|
||||
Key::KeyN => "n".into(),
|
||||
Key::KeyO => "o".into(),
|
||||
Key::KeyP => "p".into(),
|
||||
Key::KeyQ => "q".into(),
|
||||
Key::KeyR => "r".into(),
|
||||
Key::KeyS => "s".into(),
|
||||
Key::KeyT => "t".into(),
|
||||
Key::KeyU => "u".into(),
|
||||
Key::KeyV => "v".into(),
|
||||
Key::KeyW => "w".into(),
|
||||
Key::KeyX => "x".into(),
|
||||
Key::KeyY => "y".into(),
|
||||
Key::KeyZ => "z".into(),
|
||||
Key::Num0 => "digit0".into(),
|
||||
Key::Num1 => "digit1".into(),
|
||||
Key::Num2 => "digit2".into(),
|
||||
Key::Num3 => "digit3".into(),
|
||||
Key::Num4 => "digit4".into(),
|
||||
Key::Num5 => "digit5".into(),
|
||||
Key::Num6 => "digit6".into(),
|
||||
Key::Num7 => "digit7".into(),
|
||||
Key::Num8 => "digit8".into(),
|
||||
Key::Num9 => "digit9".into(),
|
||||
Key::F1 => "f1".into(),
|
||||
Key::F2 => "f2".into(),
|
||||
Key::F3 => "f3".into(),
|
||||
Key::F4 => "f4".into(),
|
||||
Key::F5 => "f5".into(),
|
||||
Key::F6 => "f6".into(),
|
||||
Key::F7 => "f7".into(),
|
||||
Key::F8 => "f8".into(),
|
||||
Key::F9 => "f9".into(),
|
||||
Key::F10 => "f10".into(),
|
||||
Key::F11 => "f11".into(),
|
||||
Key::F12 => "f12".into(),
|
||||
_ => return None,
|
||||
})
|
||||
}
|
||||
|
||||
/// Read keyboard-shortcut bindings from `LocalConfig` and refresh the cache.
|
||||
///
|
||||
/// Empty or invalid JSON falls back to `Bindings::default()` (disabled, no
|
||||
/// bindings). Call this once at startup and again whenever the config is
|
||||
/// written.
|
||||
pub fn reload_from_config() {
|
||||
let raw = hbb_common::config::LocalConfig::get_option(LOCAL_CONFIG_KEY);
|
||||
let parsed = if raw.is_empty() {
|
||||
Bindings::default()
|
||||
} else {
|
||||
serde_json::from_str(&raw).unwrap_or_default()
|
||||
};
|
||||
if let Ok(mut w) = CACHE.write() {
|
||||
*w = Arc::new(parsed);
|
||||
}
|
||||
}
|
||||
|
||||
/// Snapshot of the currently cached bindings. Cheap (one atomic increment) —
|
||||
/// safe to call on every keystroke.
|
||||
pub fn current() -> Arc<Bindings> {
|
||||
CACHE
|
||||
.read()
|
||||
.map(|b| Arc::clone(&b))
|
||||
.unwrap_or_else(|_| Arc::new(Bindings::default()))
|
||||
}
|
||||
|
||||
/// Match an `rdev::Event` against the cached bindings. Returns the matched
|
||||
/// action id, or `None` if no binding fires. The Flutter side ignores unknown
|
||||
/// action ids (logged as "no handler"), so no whitelist check is needed here.
|
||||
///
|
||||
/// ── Two known minor warts. DO NOT add global state to "fix" either: ──
|
||||
///
|
||||
/// 1. Orphan KeyRelease forwarded to peer.
|
||||
/// When a shortcut matches we eat the KeyPress, but the matching
|
||||
/// KeyRelease (whose `event_type` returns None from `event_to_key_name`)
|
||||
/// still flows through to the peer. The remote sees a release for a
|
||||
/// press it never received. Every input server we forward to ignores
|
||||
/// releases for unpressed keys, so user-visible impact is nil — the
|
||||
/// pre-existing hard-coded screenshot-shortcut path had the same shape
|
||||
/// for years without a single bug report.
|
||||
///
|
||||
/// 2. OS auto-repeat re-dispatches a held shortcut.
|
||||
/// rdev does not expose an `is_repeat` flag, so a held combo
|
||||
/// (Cmd+Alt+Shift+P) would dispatch every ~30-50ms while the keys are
|
||||
/// down — toggle actions oscillate, screenshot fires many times. In
|
||||
/// practice the OS initial auto-repeat delay is ~250ms and a normal
|
||||
/// shortcut press is 50-100ms, so the user has to *deliberately* hold
|
||||
/// the combo to hit this. The Web side gets a free fix via the
|
||||
/// browser's `KeyboardEvent.repeat`; on native we accept the wart.
|
||||
///
|
||||
/// The "fix" for either would be a process-global `HashSet<rdev::Key>` (or
|
||||
/// equivalent) with paired insert-on-press / remove-on-release logic in
|
||||
/// both `process_event*` paths plus a clear-on-leave hook. The cost:
|
||||
///
|
||||
/// * Lock contention on the hot keystroke path.
|
||||
/// * Three input sources (rdev grab, Flutter raw key, Flutter USB HID)
|
||||
/// all converge to `rdev::Key`, so correctness depends on
|
||||
/// `rdev::key_from_code` / `rdev::usb_hid_key_from_code` /
|
||||
/// `rdev::get_win_key` agreeing on the same physical key — the project
|
||||
/// already has scattered swap_modifier_key / ControlLeft↔MetaLeft
|
||||
/// fixups for places where they historically *didn't* agree. Any new
|
||||
/// mismatch silently leaks the set; "shortcut stopped responding"
|
||||
/// after a stuck entry is a worse failure mode than "shortcut fired
|
||||
/// twice."
|
||||
/// * Leak risk on focus loss / disconnect, requiring a clear hook the
|
||||
/// callers must remember to invoke.
|
||||
/// * Two new code paths to keep in lockstep with two existing keyboard
|
||||
/// pipelines.
|
||||
///
|
||||
/// For two warts whose user-visible impact is nil-to-marginal, that
|
||||
/// trade-off goes the wrong way. Leave it. If a real user bug shows up
|
||||
/// here, revisit then with concrete repro — not pre-emptively.
|
||||
pub fn match_event(event: &rdev::Event) -> Option<String> {
|
||||
let bindings = current();
|
||||
if !bindings.enabled || bindings.pass_through {
|
||||
return None;
|
||||
}
|
||||
// Note: `match_normalized` re-checks both flags below — this short-circuit
|
||||
// is just to avoid the `event_to_key_name` + `get_modifiers_state` work
|
||||
// in the common bypass case.
|
||||
let key_name = event_to_key_name(event)?;
|
||||
let (alt, ctrl, shift, command) =
|
||||
crate::keyboard::client::get_modifiers_state(false, false, false, false);
|
||||
let mods = normalize_modifiers(alt, ctrl, shift, command);
|
||||
match_normalized(&key_name, &mods, &bindings).map(str::to_owned)
|
||||
}
|
||||
|
||||
/// Match `event` against the cached bindings; if it matched, push a
|
||||
/// `shortcut_triggered` Flutter session event and return `true` so the caller
|
||||
/// can `return` early. Returns `false` when no shortcut fired (caller should
|
||||
/// continue with normal key handling).
|
||||
///
|
||||
/// `session_id`:
|
||||
/// * `Some(&id)` — Flutter FFI path: dispatch to the exact session whose key
|
||||
/// event we're processing. No dependence on the global focus tracker.
|
||||
/// * `None` — rdev grab loop: the loop is process-wide and has no way to know
|
||||
/// which Flutter session id the keystroke was meant for, so route to the
|
||||
/// globally-current session via `flutter::get_cur_session_id()`.
|
||||
#[cfg(feature = "flutter")]
|
||||
pub fn try_dispatch(session_id: Option<&hbb_common::SessionID>, event: &rdev::Event) -> bool {
|
||||
let Some(action_id) = match_event(event) else {
|
||||
return false;
|
||||
};
|
||||
let resolved;
|
||||
let sid = match session_id {
|
||||
Some(id) => id,
|
||||
None => {
|
||||
resolved = crate::flutter::get_cur_session_id();
|
||||
&resolved
|
||||
}
|
||||
};
|
||||
crate::flutter::push_session_event(sid, "shortcut_triggered", vec![("action", &action_id)]);
|
||||
true
|
||||
}
|
||||
|
||||
fn mods_bits(m: &[Modifier]) -> u8 {
|
||||
let mut bits = 0u8;
|
||||
for x in m {
|
||||
bits |= match x {
|
||||
Modifier::Primary => 1,
|
||||
Modifier::Alt => 2,
|
||||
Modifier::Shift => 4,
|
||||
// macOS users can bind shortcuts that use Control independently
|
||||
// of Command. On Win/Linux this variant should never appear in a
|
||||
// saved binding (`normalize_modifiers` collapses Ctrl into
|
||||
// Primary), but we still give it a distinct bit so a hand-edited
|
||||
// config can't accidentally collide with another modifier.
|
||||
Modifier::Ctrl => 8,
|
||||
};
|
||||
}
|
||||
bits
|
||||
}
|
||||
|
||||
fn mods_equal(a: &[Modifier], b: &[Modifier]) -> bool {
|
||||
mods_bits(a) == mods_bits(b)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
fn make_press(k: rdev::Key) -> rdev::Event {
|
||||
rdev::Event {
|
||||
time: std::time::SystemTime::now(),
|
||||
unicode: None,
|
||||
platform_code: 0,
|
||||
position_code: 0,
|
||||
event_type: rdev::EventType::KeyPress(k),
|
||||
usb_hid: 0,
|
||||
#[cfg(any(target_os = "windows", target_os = "macos"))]
|
||||
extra_data: 0,
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn event_to_key_name_handles_f_keys() {
|
||||
use rdev::Key;
|
||||
assert_eq!(event_to_key_name(&make_press(Key::F1)), Some("f1".into()));
|
||||
assert_eq!(event_to_key_name(&make_press(Key::F5)), Some("f5".into()));
|
||||
assert_eq!(event_to_key_name(&make_press(Key::F12)), Some("f12".into()));
|
||||
}
|
||||
|
||||
/// Cross-language parity for default bindings. The fixture file is the
|
||||
/// shared source of truth — Dart has a mirror test against the same file
|
||||
/// (`kDefaultShortcutBindings matches fixture` in
|
||||
/// `flutter/test/keyboard_shortcuts_test.dart`). Any drift on either
|
||||
/// side breaks one of the two tests.
|
||||
#[test]
|
||||
fn default_bindings_match_fixture_json() {
|
||||
let fixture: serde_json::Value = serde_json::from_str(include_str!(
|
||||
"../../flutter/test/fixtures/default_keyboard_shortcuts.json"
|
||||
))
|
||||
.expect("fixture is valid JSON");
|
||||
let actual: serde_json::Value =
|
||||
serde_json::to_value(default_bindings()).expect("serialize defaults");
|
||||
assert_eq!(
|
||||
fixture, actual,
|
||||
"default_bindings() drifted from \
|
||||
flutter/test/fixtures/default_keyboard_shortcuts.json — update \
|
||||
shortcuts.rs, the fixture, and Dart kDefaultShortcutBindings together"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn event_to_key_name_treats_numpad_enter_as_enter() {
|
||||
use rdev::{Event, EventType, Key};
|
||||
let make = |k: Key| Event {
|
||||
time: std::time::SystemTime::now(),
|
||||
unicode: None,
|
||||
platform_code: 0,
|
||||
position_code: 0,
|
||||
event_type: EventType::KeyPress(k),
|
||||
usb_hid: 0,
|
||||
#[cfg(any(target_os = "windows", target_os = "macos"))]
|
||||
extra_data: 0,
|
||||
};
|
||||
assert_eq!(event_to_key_name(&make(Key::Return)), Some("enter".into()));
|
||||
assert_eq!(event_to_key_name(&make(Key::KpReturn)), Some("enter".into()));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn bindings_round_trip_json() {
|
||||
let json = r#"{
|
||||
"enabled": true,
|
||||
"bindings": [
|
||||
{"action": "send_ctrl_alt_del", "mods": ["primary","alt","shift"], "key": "delete"},
|
||||
{"action": "toggle_fullscreen", "mods": ["primary","alt","shift"], "key": "enter"}
|
||||
]
|
||||
}"#;
|
||||
let parsed: Bindings = serde_json::from_str(json).expect("parse");
|
||||
assert!(parsed.enabled);
|
||||
assert_eq!(parsed.bindings.len(), 2);
|
||||
assert_eq!(parsed.bindings[0].action, "send_ctrl_alt_del");
|
||||
assert_eq!(parsed.bindings[0].key, "delete");
|
||||
|
||||
let serialized = serde_json::to_string(&parsed).expect("serialize");
|
||||
let reparsed: Bindings = serde_json::from_str(&serialized).expect("reparse");
|
||||
assert_eq!(parsed, reparsed);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn defaults_match_design_doc() {
|
||||
let defaults = default_bindings();
|
||||
let actions: Vec<&str> = defaults.iter().map(|b| b.action.as_str()).collect();
|
||||
assert!(actions.contains(&action_id::SEND_CTRL_ALT_DEL));
|
||||
assert!(actions.contains(&action_id::TOGGLE_FULLSCREEN));
|
||||
assert!(actions.contains(&action_id::SWITCH_DISPLAY_NEXT));
|
||||
assert!(actions.contains(&action_id::SWITCH_DISPLAY_PREV));
|
||||
assert!(actions.contains(&action_id::SCREENSHOT));
|
||||
assert!(actions.contains(&action_id::TOGGLE_SHOW_REMOTE_CURSOR));
|
||||
assert!(actions.contains(&action_id::TOGGLE_MUTE));
|
||||
assert!(actions.contains(&action_id::TOGGLE_BLOCK_INPUT));
|
||||
assert!(actions.contains(&action_id::TOGGLE_CHAT));
|
||||
// every default binding includes the three-modifier prefix
|
||||
for b in &defaults {
|
||||
assert!(b.mods.contains(&Modifier::Primary));
|
||||
assert!(b.mods.contains(&Modifier::Alt));
|
||||
assert!(b.mods.contains(&Modifier::Shift));
|
||||
}
|
||||
}
|
||||
|
||||
fn match_for_test<'a>(key: &str, mods: &[Modifier], b: &'a Bindings) -> Option<&'a str> {
|
||||
match_normalized(key, mods, b)
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn match_returns_none_when_pass_through() {
|
||||
let bindings = Bindings {
|
||||
enabled: true,
|
||||
pass_through: true,
|
||||
bindings: default_bindings(),
|
||||
};
|
||||
let result = match_normalized(
|
||||
"p",
|
||||
&[Modifier::Primary, Modifier::Alt, Modifier::Shift],
|
||||
&bindings,
|
||||
);
|
||||
assert_eq!(result, None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn match_returns_none_when_disabled() {
|
||||
let bindings = Bindings { enabled: false, pass_through: false, bindings: default_bindings() };
|
||||
let result = match_for_test("p", &[Modifier::Primary, Modifier::Alt, Modifier::Shift], &bindings);
|
||||
assert_eq!(result, None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn match_screenshot_when_enabled() {
|
||||
let bindings = Bindings { enabled: true, pass_through: false, bindings: default_bindings() };
|
||||
let result = match_for_test("p", &[Modifier::Primary, Modifier::Alt, Modifier::Shift], &bindings);
|
||||
assert_eq!(result, Some(action_id::SCREENSHOT));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn match_returns_none_when_modifiers_partial() {
|
||||
let bindings = Bindings { enabled: true, pass_through: false, bindings: default_bindings() };
|
||||
// missing Shift
|
||||
let result = match_for_test("p", &[Modifier::Primary, Modifier::Alt], &bindings);
|
||||
assert_eq!(result, None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn match_does_not_fire_on_extra_unbound_keys() {
|
||||
let bindings = Bindings { enabled: true, pass_through: false, bindings: default_bindings() };
|
||||
let result = match_for_test("z", &[Modifier::Primary, Modifier::Alt, Modifier::Shift], &bindings);
|
||||
assert_eq!(result, None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn match_handles_duplicate_modifiers_in_input() {
|
||||
// A user-edited config could contain duplicate modifiers; the matcher must
|
||||
// treat the modifier list as a set, not a multiset.
|
||||
let bindings = Bindings {
|
||||
enabled: true,
|
||||
pass_through: false,
|
||||
bindings: vec![Binding {
|
||||
action: "x".into(),
|
||||
mods: vec![Modifier::Primary, Modifier::Alt],
|
||||
key: "a".into(),
|
||||
}],
|
||||
};
|
||||
// Caller passes Primary twice — must not match a binding with Primary+Alt.
|
||||
assert_eq!(
|
||||
match_normalized("a", &[Modifier::Primary, Modifier::Primary], &bindings),
|
||||
None,
|
||||
);
|
||||
// Caller passes Primary+Alt with one duplicate — should still match.
|
||||
assert_eq!(
|
||||
match_normalized("a", &[Modifier::Primary, Modifier::Alt, Modifier::Alt], &bindings),
|
||||
Some("x"),
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn modifier_normalization_primary_resolves_per_os() {
|
||||
// On Win/Linux: pressing Ctrl satisfies Primary
|
||||
let mods = normalize_modifiers(/*alt=*/true, /*ctrl=*/true, /*shift=*/true, /*command=*/false);
|
||||
if cfg!(any(target_os = "macos", target_os = "ios")) {
|
||||
// On Apple platforms Ctrl is NOT primary
|
||||
assert!(!mods.contains(&Modifier::Primary));
|
||||
assert!(mods.contains(&Modifier::Ctrl));
|
||||
} else {
|
||||
assert!(mods.contains(&Modifier::Primary));
|
||||
}
|
||||
assert!(mods.contains(&Modifier::Alt));
|
||||
assert!(mods.contains(&Modifier::Shift));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn modifier_normalization_command_is_primary_on_apple() {
|
||||
let mods = normalize_modifiers(true, false, true, /*command=*/true);
|
||||
if cfg!(any(target_os = "macos", target_os = "ios")) {
|
||||
assert!(mods.contains(&Modifier::Primary));
|
||||
} else {
|
||||
// On Win/Linux Command/Meta is NOT primary
|
||||
assert!(!mods.contains(&Modifier::Primary));
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn match_refuses_zero_modifier_bindings() {
|
||||
// Defense-in-depth: a hand-edited config with empty `mods` must NOT
|
||||
// turn every plain "P" press into a screenshot shortcut, which would
|
||||
// swallow all typing in the remote session. The recording dialog
|
||||
// already refuses to save such bindings, but the matcher must hold
|
||||
// the line independently.
|
||||
let bindings = Bindings {
|
||||
enabled: true,
|
||||
pass_through: false,
|
||||
bindings: vec![Binding {
|
||||
action: "screenshot".into(),
|
||||
mods: vec![],
|
||||
key: "p".into(),
|
||||
}],
|
||||
};
|
||||
assert_eq!(match_normalized("p", &[], &bindings), None);
|
||||
// Even with extra modifiers held by the user, a zero-mod binding
|
||||
// still doesn't match (no shape of held modifiers can equal the
|
||||
// empty saved set after the empty-check skips the entry).
|
||||
assert_eq!(
|
||||
match_normalized("p", &[Modifier::Primary], &bindings),
|
||||
None,
|
||||
);
|
||||
}
|
||||
|
||||
/// Cross-language parity for the full set of shortcut-bindable key
|
||||
/// names (not just the defaults). The fixture lists every name the
|
||||
/// matcher accepts; this test verifies the (rdev::Key → name) round-trip
|
||||
/// covers exactly that set. Dart has a mirror test against the same
|
||||
/// fixture (`logicalKeyName covers the supported-keys fixture` in
|
||||
/// `flutter/test/keyboard_shortcuts_test.dart`).
|
||||
///
|
||||
/// Adding a key requires updates in three places: the fixture, this
|
||||
/// table, and the Dart `logicalKeyName` — that's the price of the
|
||||
/// parity guarantee. Drift on any side breaks one of the two tests.
|
||||
#[test]
|
||||
fn supported_keys_match_fixture() {
|
||||
use rdev::Key;
|
||||
use std::collections::BTreeSet;
|
||||
|
||||
let table: &[(&str, Key)] = &[
|
||||
("a", Key::KeyA), ("b", Key::KeyB), ("c", Key::KeyC),
|
||||
("d", Key::KeyD), ("e", Key::KeyE), ("f", Key::KeyF),
|
||||
("g", Key::KeyG), ("h", Key::KeyH), ("i", Key::KeyI),
|
||||
("j", Key::KeyJ), ("k", Key::KeyK), ("l", Key::KeyL),
|
||||
("m", Key::KeyM), ("n", Key::KeyN), ("o", Key::KeyO),
|
||||
("p", Key::KeyP), ("q", Key::KeyQ), ("r", Key::KeyR),
|
||||
("s", Key::KeyS), ("t", Key::KeyT), ("u", Key::KeyU),
|
||||
("v", Key::KeyV), ("w", Key::KeyW), ("x", Key::KeyX),
|
||||
("y", Key::KeyY), ("z", Key::KeyZ),
|
||||
("digit0", Key::Num0), ("digit1", Key::Num1),
|
||||
("digit2", Key::Num2), ("digit3", Key::Num3),
|
||||
("digit4", Key::Num4), ("digit5", Key::Num5),
|
||||
("digit6", Key::Num6), ("digit7", Key::Num7),
|
||||
("digit8", Key::Num8), ("digit9", Key::Num9),
|
||||
("f1", Key::F1), ("f2", Key::F2), ("f3", Key::F3),
|
||||
("f4", Key::F4), ("f5", Key::F5), ("f6", Key::F6),
|
||||
("f7", Key::F7), ("f8", Key::F8), ("f9", Key::F9),
|
||||
("f10", Key::F10), ("f11", Key::F11), ("f12", Key::F12),
|
||||
("delete", Key::Delete),
|
||||
("backspace", Key::Backspace),
|
||||
("tab", Key::Tab),
|
||||
("space", Key::Space),
|
||||
("enter", Key::Return),
|
||||
("enter", Key::KpReturn),
|
||||
("arrow_left", Key::LeftArrow),
|
||||
("arrow_right", Key::RightArrow),
|
||||
("arrow_up", Key::UpArrow),
|
||||
("arrow_down", Key::DownArrow),
|
||||
("home", Key::Home),
|
||||
("end", Key::End),
|
||||
("page_up", Key::PageUp),
|
||||
("page_down", Key::PageDown),
|
||||
("insert", Key::Insert),
|
||||
];
|
||||
|
||||
// Round-trip: every entry in the table must map through
|
||||
// event_to_key_name to its declared name.
|
||||
for (name, key) in table {
|
||||
assert_eq!(
|
||||
event_to_key_name(&make_press(*key)).as_deref(),
|
||||
Some(*name),
|
||||
"rdev::Key::{:?} should map to {:?}",
|
||||
key, name,
|
||||
);
|
||||
}
|
||||
|
||||
// The set of names produced by the table must equal the fixture.
|
||||
let actual: BTreeSet<&str> = table.iter().map(|(n, _)| *n).collect();
|
||||
let fixture_raw: Vec<String> = serde_json::from_str(include_str!(
|
||||
"../../flutter/test/fixtures/supported_shortcut_keys.json"
|
||||
))
|
||||
.expect("fixture is valid JSON");
|
||||
let expected: BTreeSet<&str> =
|
||||
fixture_raw.iter().map(String::as_str).collect();
|
||||
assert_eq!(
|
||||
actual, expected,
|
||||
"event_to_key_name vocabulary drifted from \
|
||||
flutter/test/fixtures/supported_shortcut_keys.json — update \
|
||||
shortcuts.rs, the fixture, and Dart logicalKeyName together"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn reload_handles_missing_and_invalid_json() {
|
||||
// empty (no value set) → defaults
|
||||
hbb_common::config::LocalConfig::set_option(LOCAL_CONFIG_KEY.into(), String::new());
|
||||
reload_from_config();
|
||||
let b = current();
|
||||
assert!(!b.enabled);
|
||||
assert!(b.bindings.is_empty());
|
||||
|
||||
// invalid JSON → defaults (no panic)
|
||||
hbb_common::config::LocalConfig::set_option(LOCAL_CONFIG_KEY.into(), "not json".into());
|
||||
reload_from_config();
|
||||
let b = current();
|
||||
assert!(!b.enabled);
|
||||
}
|
||||
}
|
||||
@@ -743,5 +743,39 @@ pub static ref T: std::collections::HashMap<&'static str, &'static str> =
|
||||
("Display Name", "显示名称"),
|
||||
("password-hidden-tip", "永久密码已设置(已隐藏)"),
|
||||
("preset-password-in-use-tip", "当前使用预设密码"),
|
||||
("Keyboard Shortcuts", "键盘快捷键"),
|
||||
("Configure shortcuts...", "配置快捷键..."),
|
||||
("Enable keyboard shortcuts in remote session", "在远程会话中启用键盘快捷键"),
|
||||
("shortcut-page-description", "为下列每项会话操作绑定一个组合键。每个绑定至少需要包含一个修饰符。"),
|
||||
("shortcut-passthrough-tip", "开启后,所有已绑定的组合键都会原样转发到远端。适合在某个组合键与远端需要使用的快捷键冲突时打开。"),
|
||||
("Pass-through to remote", "穿透到远端"),
|
||||
("Reset to defaults", "恢复默认设置"),
|
||||
("shortcut-reset-confirm-tip", "这将以默认快捷键替换所有当前绑定。是否继续?"),
|
||||
("Monitor", "显示器"),
|
||||
("Keyboard", "键盘"),
|
||||
("Toggle fullscreen", "切换全屏"),
|
||||
("Switch to next display", "切换到下一个显示器"),
|
||||
("Switch to previous display", "切换到上一个显示器"),
|
||||
("All monitors", "所有显示器"),
|
||||
("Monitor #{}", "{} 号显示器"),
|
||||
("Switch to next tab", "切换到下一个标签"),
|
||||
("Switch to previous tab", "切换到上一个标签"),
|
||||
("Toggle session recording", "切换会话录制"),
|
||||
("Close tab", "关闭标签页"),
|
||||
("Toggle toolbar", "切换工具栏可见性"),
|
||||
("Toggle input source", "切换输入源"),
|
||||
("Edit", "编辑"),
|
||||
("Save", "保存"),
|
||||
("Set Shortcut", "设置快捷键"),
|
||||
("shortcut-recording-instruction", "请按下您想使用的组合键。"),
|
||||
("shortcut-recording-press-keys-tip", "请按下组合键..."),
|
||||
("shortcut-must-include-modifiers", "必须至少包含一个修饰符:{}"),
|
||||
("shortcut-already-bound-to", "已绑定到"),
|
||||
("Replace", "替换"),
|
||||
("Valid", "有效"),
|
||||
("shortcut-mobile-physical-keyboard-tip", "录制需要使用物理键盘,不支持软键盘。"),
|
||||
("shortcut-key-not-supported", "“{}” 不能用作快捷键。"),
|
||||
("On", "开"),
|
||||
("Off", "关"),
|
||||
].iter().cloned().collect();
|
||||
}
|
||||
|
||||
@@ -274,5 +274,14 @@ pub static ref T: std::collections::HashMap<&'static str, &'static str> =
|
||||
("keep-awake-during-incoming-sessions-label", "Keep screen awake during incoming sessions"),
|
||||
("password-hidden-tip", "Permanent password is set (hidden)."),
|
||||
("preset-password-in-use-tip", "Preset password is currently in use."),
|
||||
("shortcut-page-description", "Bind a key combination to each session action below. Each binding must include at least one modifier."),
|
||||
("shortcut-passthrough-tip", "When on, every bound combination is forwarded to the remote. Useful when a binding collides with something you need on the remote."),
|
||||
("shortcut-reset-confirm-tip", "This will replace all current bindings with the default set. Continue?"),
|
||||
("shortcut-recording-instruction", "Press the key combination you want to use."),
|
||||
("shortcut-recording-press-keys-tip", "Press a key combination..."),
|
||||
("shortcut-must-include-modifiers", "Must include at least one modifier: {}"),
|
||||
("shortcut-already-bound-to", "Already bound to"),
|
||||
("shortcut-mobile-physical-keyboard-tip", "Recording requires a physical keyboard. Soft keyboards are not supported."),
|
||||
("shortcut-key-not-supported", "\"{}\" can't be used as a shortcut."),
|
||||
].iter().cloned().collect();
|
||||
}
|
||||
|
||||
@@ -23,7 +23,7 @@ use hbb_common::{
|
||||
sync::mpsc,
|
||||
time::{Duration as TokioDuration, Instant},
|
||||
},
|
||||
whoami, Stream,
|
||||
whoami, SessionID, Stream,
|
||||
};
|
||||
use rdev::{Event, EventType::*, KeyCode};
|
||||
#[cfg(all(feature = "vram", feature = "flutter"))]
|
||||
@@ -913,6 +913,7 @@ impl<T: InvokeUiSession> Session<T> {
|
||||
#[cfg(any(target_os = "ios"))]
|
||||
pub fn handle_flutter_raw_key_event(
|
||||
&self,
|
||||
_session_id: SessionID,
|
||||
_keyboard_mode: &str,
|
||||
_name: &str,
|
||||
_platform_code: i32,
|
||||
@@ -925,6 +926,7 @@ impl<T: InvokeUiSession> Session<T> {
|
||||
#[cfg(not(any(target_os = "ios")))]
|
||||
pub fn handle_flutter_raw_key_event(
|
||||
&self,
|
||||
session_id: SessionID,
|
||||
keyboard_mode: &str,
|
||||
name: &str,
|
||||
platform_code: i32,
|
||||
@@ -936,6 +938,7 @@ impl<T: InvokeUiSession> Session<T> {
|
||||
self._handle_key_flutter_simulation(keyboard_mode, platform_code, down_or_up);
|
||||
} else {
|
||||
self._handle_raw_key_non_flutter_simulation(
|
||||
session_id,
|
||||
keyboard_mode,
|
||||
platform_code,
|
||||
position_code,
|
||||
@@ -948,6 +951,7 @@ impl<T: InvokeUiSession> Session<T> {
|
||||
#[cfg(not(any(target_os = "ios")))]
|
||||
fn _handle_raw_key_non_flutter_simulation(
|
||||
&self,
|
||||
session_id: SessionID,
|
||||
keyboard_mode: &str,
|
||||
platform_code: i32,
|
||||
position_code: i32,
|
||||
@@ -981,11 +985,18 @@ impl<T: InvokeUiSession> Session<T> {
|
||||
#[cfg(any(target_os = "windows", target_os = "macos"))]
|
||||
extra_data: 0,
|
||||
};
|
||||
keyboard::client::process_event_with_session(keyboard_mode, &event, Some(lock_modes), self);
|
||||
keyboard::client::process_event_with_session(
|
||||
keyboard_mode,
|
||||
&event,
|
||||
Some(lock_modes),
|
||||
self,
|
||||
session_id,
|
||||
);
|
||||
}
|
||||
|
||||
pub fn handle_flutter_key_event(
|
||||
&self,
|
||||
session_id: SessionID,
|
||||
keyboard_mode: &str,
|
||||
character: &str,
|
||||
usb_hid: i32,
|
||||
@@ -996,6 +1007,7 @@ impl<T: InvokeUiSession> Session<T> {
|
||||
self._handle_key_flutter_simulation(keyboard_mode, usb_hid, down_or_up);
|
||||
} else {
|
||||
self._handle_key_non_flutter_simulation(
|
||||
session_id,
|
||||
keyboard_mode,
|
||||
character,
|
||||
usb_hid,
|
||||
@@ -1031,6 +1043,7 @@ impl<T: InvokeUiSession> Session<T> {
|
||||
|
||||
fn _handle_key_non_flutter_simulation(
|
||||
&self,
|
||||
session_id: SessionID,
|
||||
keyboard_mode: &str,
|
||||
character: &str,
|
||||
usb_hid: i32,
|
||||
@@ -1092,7 +1105,13 @@ impl<T: InvokeUiSession> Session<T> {
|
||||
#[cfg(any(target_os = "windows", target_os = "macos"))]
|
||||
extra_data: 0,
|
||||
};
|
||||
keyboard::client::process_event_with_session(keyboard_mode, &event, Some(lock_modes), self);
|
||||
keyboard::client::process_event_with_session(
|
||||
keyboard_mode,
|
||||
&event,
|
||||
Some(lock_modes),
|
||||
self,
|
||||
session_id,
|
||||
);
|
||||
}
|
||||
|
||||
// flutter only TODO new input
|
||||
|
||||
Reference in New Issue
Block a user