mirror of
https://github.com/rustdesk/rustdesk.git
synced 2026-05-07 06:38:11 +03:00
feat(shortcuts): user-configurable keyboard shortcuts for session actions
Adds a keyboard shortcut feature (Rust matcher + Dart UI + cross-language
parity tests) that lets users bind combinations like Ctrl+Alt+Shift+P to
session actions. Bindings are stored in LocalConfig under
`keyboard-shortcuts`; the matcher gates dispatch on `enabled` and
`pass_through` flags so flipping the master switch off is a hard stop.
Wire-up summary:
- src/keyboard/shortcuts.rs: matcher, default bindings, parity test against
flutter/test/fixtures/default_keyboard_shortcuts.json
- src/keyboard.rs: shortcut intercept in process_event{,_with_session},
feature-gated to `flutter`; runs before key swapping so users bind to
physical keys
- src/flutter_ffi.rs: main_reload_keyboard_shortcuts +
main_get_default_keyboard_shortcuts; reload_from_config seeded in main_init
- flutter/lib/common/widgets/keyboard_shortcuts/: shared config page body,
recording dialog, shortcut display formatter, action group registry
- flutter/lib/desktop/pages/desktop_keyboard_shortcuts_page.dart and
flutter/lib/mobile/pages/mobile_keyboard_shortcuts_page.dart: platform
shells around the shared body
- flutter/lib/models/shortcut_model.dart: per-session ShortcutModel +
registerSessionShortcutActions for actions with no toolbar TToggleMenu /
TRadioMenu (fullscreen, switch display/tab, close tab, voice call, etc.)
- flutter/lib/common/widgets/toolbar.dart: optional `actionId` field on
TToggleMenu / TRadioMenu, plus per-helper auto-register pass that wires
tagged entries' existing onChanged into the ShortcutModel
- flutter/test/keyboard_shortcuts_test.dart + fixtures: cross-language
parity (default bindings, supported key vocabulary)
Design principles applied during review:
1. Additions are fine; modifications to original logic must be deliberate.
Tagging an existing TToggleMenu entry with `actionId:` is an addition.
Rewriting its onChanged to satisfy a new contract is a modification —
and was reverted for every case where the original click behavior was
working. Four closures were touched and then reverted (mobile View
Mode, Privacy mode multi-impl, Relative mouse mode, Reverse mouse
wheel); their shortcuts are wired via standalone closures in
shortcut_model.dart instead.
2. Toolbar auto-register is reserved for entries whose onChanged is
inherently self-flipping — typically `sessionToggleOption(name)` where
the named option is flipped in place and the input bool is unused. The
register pass passes `!menu.value` from registration time, which is
harmless under self-flipping but wrong for closures that consume the
input bool directly. Tagging a non-self-flipping entry forces a closure
rewrite; choose non-toolbar registration in that case.
3. When shortcuts are disabled, toolbar behavior must be bit-for-bit
unchanged. The matcher's `enabled`-gate already guarantees no
dispatch; the auto-register pass is left unconditional (its only effect
is HashMap operations on a separate ShortcutModel) so mid-session
enable works without a reconnect. The trade-off is intentional and
documented at the top of toolbarControls.
4. Comments stay terse. Rationale lives in one place — the doc comment of
the helper or registration site, not duplicated at every call site.
5. Where an existing helper needs a new optional behavior (e.g.
`_OptionCheckBox` gaining a tooltip slot), the new branch must reduce
to byte-identical output for existing callers (`trailing == null`
case → original `Expanded(Text)` layout). Verified.
6. Action IDs and labels stay consistent. Renamed `reset_cursor` →
`reset_canvas` so the action ID matches its user-facing label
("Reset canvas") and capability flag.
Out-of-scope but included:
- AGENTS.md: documents flutter_rust_bridge no-codegen workflow and the
Web target's hand-written TS client, since both are load-bearing for
any new FFI work.
- remote_toolbar.dart: i18n fix for the per-monitor tooltip ("All
monitors" / "Monitor #N"), unrelated to shortcuts but kept here.
This commit is contained in:
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();
|
||||
});
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user