refactor(input): share gamepad key simulation
This commit is contained in:
@@ -13,11 +13,6 @@ import '../utils/key_event_simulator.dart' as key_sim;
|
|||||||
import '../utils/platform_detector.dart';
|
import '../utils/platform_detector.dart';
|
||||||
import '../utils/text_input_diagnostics.dart';
|
import '../utils/text_input_diagnostics.dart';
|
||||||
|
|
||||||
String _describeGamepadKeyEvent(KeyEvent event) {
|
|
||||||
return 'type=${event.runtimeType} logical=${event.logicalKey.keyLabel}/${event.logicalKey.keyId} '
|
|
||||||
'physical=${event.physicalKey.usbHidUsage} deviceType=${event.deviceType} character=${event.character}';
|
|
||||||
}
|
|
||||||
|
|
||||||
String _describeGamepadButton(GamepadButtonEvent event) {
|
String _describeGamepadButton(GamepadButtonEvent event) {
|
||||||
return 'button=${event.button} pressed=${event.pressed} value=${event.value} gamepad=${event.gamepadId}';
|
return 'button=${event.button} pressed=${event.pressed} value=${event.value} gamepad=${event.gamepadId}';
|
||||||
}
|
}
|
||||||
@@ -131,6 +126,18 @@ class GamepadService with WindowListener {
|
|||||||
GamepadButton.x: LogicalKeyboardKey.gameButtonX,
|
GamepadButton.x: LogicalKeyboardKey.gameButtonX,
|
||||||
};
|
};
|
||||||
|
|
||||||
|
static final Map<LogicalKeyboardKey, PhysicalKeyboardKey> _gamepadPhysicalKeyByLogicalKey = {
|
||||||
|
LogicalKeyboardKey.arrowUp: PhysicalKeyboardKey.arrowUp,
|
||||||
|
LogicalKeyboardKey.arrowDown: PhysicalKeyboardKey.arrowDown,
|
||||||
|
LogicalKeyboardKey.arrowLeft: PhysicalKeyboardKey.arrowLeft,
|
||||||
|
LogicalKeyboardKey.arrowRight: PhysicalKeyboardKey.arrowRight,
|
||||||
|
LogicalKeyboardKey.enter: PhysicalKeyboardKey.enter,
|
||||||
|
LogicalKeyboardKey.escape: PhysicalKeyboardKey.escape,
|
||||||
|
LogicalKeyboardKey.gameButtonA: PhysicalKeyboardKey.gameButtonA,
|
||||||
|
LogicalKeyboardKey.gameButtonB: PhysicalKeyboardKey.gameButtonB,
|
||||||
|
LogicalKeyboardKey.gameButtonX: PhysicalKeyboardKey.gameButtonX,
|
||||||
|
};
|
||||||
|
|
||||||
static GamepadService? _instance;
|
static GamepadService? _instance;
|
||||||
StreamSubscription<GamepadEvent>? _subscription;
|
StreamSubscription<GamepadEvent>? _subscription;
|
||||||
final GamepadDuplicateInputGuard _duplicateInputGuard;
|
final GamepadDuplicateInputGuard _duplicateInputGuard;
|
||||||
@@ -154,7 +161,7 @@ class GamepadService with WindowListener {
|
|||||||
static const Duration _repeatInitialDelay = Duration(milliseconds: 400);
|
static const Duration _repeatInitialDelay = Duration(milliseconds: 400);
|
||||||
static const Duration _repeatInterval = Duration(milliseconds: 80);
|
static const Duration _repeatInterval = Duration(milliseconds: 80);
|
||||||
|
|
||||||
Timer? _repeatTimer;
|
key_sim.KeyEventSimulatorController? _keyEventSimulator;
|
||||||
|
|
||||||
// Track stick state to detect deadzone crossings
|
// Track stick state to detect deadzone crossings
|
||||||
bool _leftStickUp = false;
|
bool _leftStickUp = false;
|
||||||
@@ -165,8 +172,6 @@ class GamepadService with WindowListener {
|
|||||||
// Track button states to prevent repeated events from button holds
|
// Track button states to prevent repeated events from button holds
|
||||||
final Set<GamepadButton> _pressedButtons = {};
|
final Set<GamepadButton> _pressedButtons = {};
|
||||||
final Set<GamepadButton> _suppressedButtons = {};
|
final Set<GamepadButton> _suppressedButtons = {};
|
||||||
final Map<LogicalKeyboardKey, FocusNode> _heldFocusNodes = {};
|
|
||||||
|
|
||||||
// Whether the app window is currently focused — ignore gamepad input when false
|
// Whether the app window is currently focused — ignore gamepad input when false
|
||||||
bool _windowFocused = true;
|
bool _windowFocused = true;
|
||||||
bool _nativeKeyHandlerRegistered = false;
|
bool _nativeKeyHandlerRegistered = false;
|
||||||
@@ -178,6 +183,14 @@ class GamepadService with WindowListener {
|
|||||||
GamepadService._({GamepadDuplicateInputGuard? duplicateInputGuard})
|
GamepadService._({GamepadDuplicateInputGuard? duplicateInputGuard})
|
||||||
: _duplicateInputGuard = duplicateInputGuard ?? GamepadDuplicateInputGuard(enabled: () => Platform.isWindows);
|
: _duplicateInputGuard = duplicateInputGuard ?? GamepadDuplicateInputGuard(enabled: () => Platform.isWindows);
|
||||||
|
|
||||||
|
key_sim.KeyEventSimulatorController get _simulator {
|
||||||
|
return _keyEventSimulator ??= key_sim.KeyEventSimulatorController(
|
||||||
|
deviceType: ui.KeyEventDeviceType.gamepad,
|
||||||
|
physicalKeyByLogicalKey: _gamepadPhysicalKeyByLogicalKey,
|
||||||
|
log: _logGamepadDiag,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
static GamepadService get instance {
|
static GamepadService get instance {
|
||||||
_instance ??= GamepadService._();
|
_instance ??= GamepadService._();
|
||||||
return _instance!;
|
return _instance!;
|
||||||
@@ -227,7 +240,8 @@ class GamepadService with WindowListener {
|
|||||||
_subscription = null;
|
_subscription = null;
|
||||||
_duplicateInputGuard.clear();
|
_duplicateInputGuard.clear();
|
||||||
_suppressedButtons.clear();
|
_suppressedButtons.clear();
|
||||||
_heldFocusNodes.clear();
|
_keyEventSimulator?.dispose();
|
||||||
|
_keyEventSimulator = null;
|
||||||
if (_isDesktop) {
|
if (_isDesktop) {
|
||||||
windowManager.removeListener(this);
|
windowManager.removeListener(this);
|
||||||
}
|
}
|
||||||
@@ -246,18 +260,13 @@ class GamepadService with WindowListener {
|
|||||||
_windowFocused = false;
|
_windowFocused = false;
|
||||||
_stopDirectionRepeat();
|
_stopDirectionRepeat();
|
||||||
|
|
||||||
// Send key-up for any face buttons that are mid-hold so widgets
|
// Release all face buttons in one frame so held widget state cannot stick.
|
||||||
// waiting for the release (e.g. long-press timers) don't get stuck.
|
_simulator.releaseKeys([
|
||||||
if (_pressedButtons.contains(GamepadButton.a)) {
|
if (_pressedButtons.contains(GamepadButton.a)) LogicalKeyboardKey.enter,
|
||||||
_simulateKeyUp(LogicalKeyboardKey.enter);
|
if (_pressedButtons.contains(GamepadButton.x)) LogicalKeyboardKey.gameButtonX,
|
||||||
}
|
]);
|
||||||
if (_pressedButtons.contains(GamepadButton.x)) {
|
|
||||||
_simulateKeyUp(LogicalKeyboardKey.gameButtonX);
|
|
||||||
}
|
|
||||||
_pressedButtons.clear();
|
_pressedButtons.clear();
|
||||||
_suppressedButtons.clear();
|
_suppressedButtons.clear();
|
||||||
SchedulerBinding.instance.addPostFrameCallback((_) => _heldFocusNodes.clear());
|
|
||||||
key_sim.scheduleFrameIfIdle();
|
|
||||||
_duplicateInputGuard.clear();
|
_duplicateInputGuard.clear();
|
||||||
|
|
||||||
// Reset analog stick state so re-focus doesn't inherit stale direction
|
// Reset analog stick state so re-focus doesn't inherit stale direction
|
||||||
@@ -299,7 +308,7 @@ class GamepadService with WindowListener {
|
|||||||
_stopDirectionRepeat();
|
_stopDirectionRepeat();
|
||||||
_pressedButtons.clear();
|
_pressedButtons.clear();
|
||||||
_suppressedButtons.clear();
|
_suppressedButtons.clear();
|
||||||
_heldFocusNodes.clear();
|
_keyEventSimulator?.clearHeldKeys();
|
||||||
_duplicateInputGuard.clear();
|
_duplicateInputGuard.clear();
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -466,34 +475,22 @@ class GamepadService with WindowListener {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
void _moveFocus(TraversalDirection direction) {
|
|
||||||
// Convert direction to arrow key and simulate a key press
|
|
||||||
// This allows widgets like HubSection that intercept key events to handle navigation
|
|
||||||
final logicalKey = _directionToKey(direction);
|
|
||||||
_logGamepadDiag(
|
|
||||||
'moveFocus direction=$direction logicalKey=${logicalKey.keyLabel}/${logicalKey.keyId} nativeTextInputFocused=$_nativeTextInputFocused',
|
|
||||||
);
|
|
||||||
_simulateKeyPress(logicalKey);
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Fire [direction] immediately, then auto-repeat after an initial delay.
|
/// Fire [direction] immediately, then auto-repeat after an initial delay.
|
||||||
void _startDirectionRepeat(TraversalDirection direction) {
|
void _startDirectionRepeat(TraversalDirection direction) {
|
||||||
_logGamepadDiag('startDirectionRepeat direction=$direction');
|
_logGamepadDiag('startDirectionRepeat direction=$direction');
|
||||||
_stopDirectionRepeat();
|
_stopDirectionRepeat();
|
||||||
_moveFocus(direction);
|
final logicalKey = _directionToKey(direction);
|
||||||
_repeatTimer = Timer(_repeatInitialDelay, () {
|
_logGamepadDiag(
|
||||||
_repeatTimer = Timer.periodic(_repeatInterval, (_) {
|
'moveFocus direction=$direction logicalKey=${logicalKey.keyLabel}/${logicalKey.keyId} nativeTextInputFocused=$_nativeTextInputFocused',
|
||||||
_moveFocus(direction);
|
);
|
||||||
});
|
_simulator.startKeyRepeat(logicalKey, initialDelay: _repeatInitialDelay, interval: _repeatInterval);
|
||||||
});
|
|
||||||
}
|
}
|
||||||
|
|
||||||
void _stopDirectionRepeat() {
|
void _stopDirectionRepeat() {
|
||||||
if (_repeatTimer != null) {
|
if (_keyEventSimulator?.isRepeating ?? false) {
|
||||||
_logGamepadDiag('stopDirectionRepeat');
|
_logGamepadDiag('stopDirectionRepeat');
|
||||||
}
|
}
|
||||||
_repeatTimer?.cancel();
|
_keyEventSimulator?.stopKeyRepeat();
|
||||||
_repeatTimer = null;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
LogicalKeyboardKey _directionToKey(TraversalDirection direction) {
|
LogicalKeyboardKey _directionToKey(TraversalDirection direction) {
|
||||||
@@ -511,114 +508,18 @@ class GamepadService with WindowListener {
|
|||||||
|
|
||||||
/// Simulate a full key press (down + up) in a single frame.
|
/// Simulate a full key press (down + up) in a single frame.
|
||||||
void _simulateKeyPress(LogicalKeyboardKey logicalKey) {
|
void _simulateKeyPress(LogicalKeyboardKey logicalKey) {
|
||||||
key_sim.scheduleFrameIfIdle();
|
_simulator.simulateKeyPress(logicalKey);
|
||||||
SchedulerBinding.instance.addPostFrameCallback((_) {
|
|
||||||
_dispatchKeyDown(logicalKey);
|
|
||||||
_dispatchKeyUp(logicalKey);
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Simulate only key down — pair with [_simulateKeyUp] on release
|
/// Simulate only key down — pair with [_simulateKeyUp] on release
|
||||||
/// so widget-level long-press timers see real hold duration.
|
/// so widget-level long-press timers see real hold duration.
|
||||||
void _simulateKeyDown(LogicalKeyboardKey logicalKey) {
|
void _simulateKeyDown(LogicalKeyboardKey logicalKey) {
|
||||||
key_sim.scheduleFrameIfIdle();
|
_simulator.simulateKeyDown(logicalKey);
|
||||||
SchedulerBinding.instance.addPostFrameCallback((_) {
|
|
||||||
_dispatchKeyDown(logicalKey);
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Simulate only key up — the release half of [_simulateKeyDown].
|
/// Simulate only key up — the release half of [_simulateKeyDown].
|
||||||
void _simulateKeyUp(LogicalKeyboardKey logicalKey) {
|
void _simulateKeyUp(LogicalKeyboardKey logicalKey) {
|
||||||
key_sim.scheduleFrameIfIdle();
|
_simulator.simulateKeyUp(logicalKey);
|
||||||
SchedulerBinding.instance.addPostFrameCallback((_) {
|
|
||||||
_dispatchKeyUp(logicalKey);
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
void _dispatchKeyDown(LogicalKeyboardKey logicalKey) {
|
|
||||||
final focusNode = FocusManager.instance.primaryFocus;
|
|
||||||
if (focusNode == null) {
|
|
||||||
_logGamepadDiag('dispatchKeyDown dropped reason=no-focus logical=${logicalKey.keyLabel}/${logicalKey.keyId}');
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
_heldFocusNodes[logicalKey] = focusNode;
|
|
||||||
_logGamepadDiag('dispatchKeyDown logical=${logicalKey.keyLabel}/${logicalKey.keyId}');
|
|
||||||
_dispatchKeyEvent(
|
|
||||||
KeyDownEvent(
|
|
||||||
physicalKey: _getPhysicalKey(logicalKey),
|
|
||||||
logicalKey: logicalKey,
|
|
||||||
timeStamp: Duration(milliseconds: DateTime.now().millisecondsSinceEpoch),
|
|
||||||
deviceType: ui.KeyEventDeviceType.gamepad,
|
|
||||||
),
|
|
||||||
startNode: focusNode,
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
void _dispatchKeyUp(LogicalKeyboardKey logicalKey) {
|
|
||||||
final heldFocusNode = _heldFocusNodes.remove(logicalKey);
|
|
||||||
final focusNode = heldFocusNode ?? FocusManager.instance.primaryFocus;
|
|
||||||
if (focusNode == null) {
|
|
||||||
_logGamepadDiag('dispatchKeyUp dropped reason=no-focus logical=${logicalKey.keyLabel}/${logicalKey.keyId}');
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
if (heldFocusNode != null && heldFocusNode.context == null) {
|
|
||||||
_logGamepadDiag(
|
|
||||||
'dispatchKeyUp dropped reason=held-focus-detached logical=${logicalKey.keyLabel}/${logicalKey.keyId}',
|
|
||||||
);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
_logGamepadDiag('dispatchKeyUp logical=${logicalKey.keyLabel}/${logicalKey.keyId}');
|
|
||||||
_dispatchKeyEvent(
|
|
||||||
KeyUpEvent(
|
|
||||||
physicalKey: _getPhysicalKey(logicalKey),
|
|
||||||
logicalKey: logicalKey,
|
|
||||||
timeStamp: Duration(milliseconds: DateTime.now().millisecondsSinceEpoch),
|
|
||||||
deviceType: ui.KeyEventDeviceType.gamepad,
|
|
||||||
),
|
|
||||||
startNode: focusNode,
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
void _dispatchKeyEvent(KeyEvent event, {FocusNode? startNode}) {
|
|
||||||
FocusNode? node = startNode ?? FocusManager.instance.primaryFocus;
|
|
||||||
_logGamepadDiag('dispatch start focus=${node?.debugLabel} key=(${_describeGamepadKeyEvent(event)})');
|
|
||||||
while (node != null) {
|
|
||||||
if (node.onKeyEvent != null) {
|
|
||||||
final result = node.onKeyEvent!(node, event);
|
|
||||||
_logGamepadDiag('dispatch node=${node.debugLabel} result=$result key=(${_describeGamepadKeyEvent(event)})');
|
|
||||||
if (result != KeyEventResult.ignored) {
|
|
||||||
_logGamepadDiag('dispatch stopped node=${node.debugLabel} result=$result');
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
node = node.parent;
|
|
||||||
}
|
|
||||||
if (node == null) {
|
|
||||||
_logGamepadDiag('dispatch reached root ignored key=(${_describeGamepadKeyEvent(event)})');
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
PhysicalKeyboardKey _getPhysicalKey(LogicalKeyboardKey logicalKey) {
|
|
||||||
if (logicalKey == LogicalKeyboardKey.gameButtonA) {
|
|
||||||
return PhysicalKeyboardKey.gameButtonA;
|
|
||||||
} else if (logicalKey == LogicalKeyboardKey.gameButtonB) {
|
|
||||||
return PhysicalKeyboardKey.gameButtonB;
|
|
||||||
} else if (logicalKey == LogicalKeyboardKey.gameButtonX) {
|
|
||||||
return PhysicalKeyboardKey.gameButtonX;
|
|
||||||
} else if (logicalKey == LogicalKeyboardKey.arrowUp) {
|
|
||||||
return PhysicalKeyboardKey.arrowUp;
|
|
||||||
} else if (logicalKey == LogicalKeyboardKey.arrowDown) {
|
|
||||||
return PhysicalKeyboardKey.arrowDown;
|
|
||||||
} else if (logicalKey == LogicalKeyboardKey.arrowLeft) {
|
|
||||||
return PhysicalKeyboardKey.arrowLeft;
|
|
||||||
} else if (logicalKey == LogicalKeyboardKey.arrowRight) {
|
|
||||||
return PhysicalKeyboardKey.arrowRight;
|
|
||||||
} else if (logicalKey == LogicalKeyboardKey.escape) {
|
|
||||||
return PhysicalKeyboardKey.escape;
|
|
||||||
}
|
|
||||||
return PhysicalKeyboardKey.enter;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// W3C: leftStickY -1.0 = up, 1.0 = down
|
// W3C: leftStickY -1.0 = up, 1.0 = down
|
||||||
|
|||||||
@@ -1,3 +1,4 @@
|
|||||||
|
import 'dart:async';
|
||||||
import 'dart:ui' as ui;
|
import 'dart:ui' as ui;
|
||||||
|
|
||||||
import 'package:flutter/scheduler.dart';
|
import 'package:flutter/scheduler.dart';
|
||||||
@@ -15,110 +16,183 @@ void _logKeySimulator(String message) {
|
|||||||
TextInputDiagnostics.log('KeySimulator', message);
|
TextInputDiagnostics.log('KeySimulator', message);
|
||||||
}
|
}
|
||||||
|
|
||||||
final Map<LogicalKeyboardKey, FocusNode> _heldFocusNodes = {};
|
final KeyEventSimulatorController _defaultSimulator = KeyEventSimulatorController();
|
||||||
|
|
||||||
/// Shared utility for simulating key press events through the focus tree.
|
/// Shared utility for simulating key press events through the focus tree.
|
||||||
///
|
///
|
||||||
/// Used by companion remotes, Apple TV touch input, and gamepad services to
|
/// Used by companion remotes, Apple TV touch input, and gamepad services to
|
||||||
/// translate external input into focus-tree key events.
|
/// translate external input into focus-tree key events.
|
||||||
void simulateKeyPress(LogicalKeyboardKey logicalKey) {
|
void simulateKeyPress(LogicalKeyboardKey logicalKey) {
|
||||||
_logKeySimulator('simulateKeyPress scheduled logical=${logicalKey.keyLabel}/${logicalKey.keyId}');
|
_defaultSimulator.simulateKeyPress(logicalKey);
|
||||||
// The dispatch below is deferred via addPostFrameCallback to ensure the
|
|
||||||
// focus tree is settled before we walk it. That post-frame callback only
|
|
||||||
// fires after a frame actually renders — and when Flutter is idle (no
|
|
||||||
// animations, no rebuilds), the engine will never schedule one on its
|
|
||||||
// own, so the callback hangs indefinitely. Force a frame so external
|
|
||||||
// input (gamepad, tvOS remote, companion remote) always advances focus
|
|
||||||
// immediately rather than batching until something else wakes the engine.
|
|
||||||
scheduleFrameIfIdle();
|
|
||||||
SchedulerBinding.instance.addPostFrameCallback((_) {
|
|
||||||
final focusNode = FocusManager.instance.primaryFocus;
|
|
||||||
if (focusNode == null) return;
|
|
||||||
|
|
||||||
final physicalKey = _getPhysicalKey(logicalKey);
|
|
||||||
|
|
||||||
final keyDownEvent = KeyDownEvent(
|
|
||||||
physicalKey: physicalKey,
|
|
||||||
logicalKey: logicalKey,
|
|
||||||
timeStamp: Duration(milliseconds: DateTime.now().millisecondsSinceEpoch),
|
|
||||||
deviceType: ui.KeyEventDeviceType.directionalPad,
|
|
||||||
);
|
|
||||||
|
|
||||||
_dispatchKeyEvent(focusNode, keyDownEvent);
|
|
||||||
|
|
||||||
final keyUpEvent = KeyUpEvent(
|
|
||||||
physicalKey: physicalKey,
|
|
||||||
logicalKey: logicalKey,
|
|
||||||
timeStamp: Duration(milliseconds: DateTime.now().millisecondsSinceEpoch),
|
|
||||||
deviceType: ui.KeyEventDeviceType.directionalPad,
|
|
||||||
);
|
|
||||||
|
|
||||||
_dispatchKeyEvent(focusNode, keyUpEvent);
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Simulate only key down. Pair with [simulateKeyUp] for held buttons.
|
/// Simulate only key down. Pair with [simulateKeyUp] for held buttons.
|
||||||
void simulateKeyDown(LogicalKeyboardKey logicalKey) {
|
void simulateKeyDown(LogicalKeyboardKey logicalKey) {
|
||||||
_logKeySimulator('simulateKeyDown scheduled logical=${logicalKey.keyLabel}/${logicalKey.keyId}');
|
_defaultSimulator.simulateKeyDown(logicalKey);
|
||||||
scheduleFrameIfIdle();
|
|
||||||
SchedulerBinding.instance.addPostFrameCallback((_) {
|
|
||||||
final focusNode = FocusManager.instance.primaryFocus;
|
|
||||||
if (focusNode == null) return;
|
|
||||||
|
|
||||||
_heldFocusNodes[logicalKey] = focusNode;
|
|
||||||
_dispatchKeyEvent(
|
|
||||||
focusNode,
|
|
||||||
KeyDownEvent(
|
|
||||||
physicalKey: _getPhysicalKey(logicalKey),
|
|
||||||
logicalKey: logicalKey,
|
|
||||||
timeStamp: Duration(milliseconds: DateTime.now().millisecondsSinceEpoch),
|
|
||||||
deviceType: ui.KeyEventDeviceType.directionalPad,
|
|
||||||
),
|
|
||||||
);
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Simulate only key up. The release half of [simulateKeyDown].
|
/// Simulate only key up. The release half of [simulateKeyDown].
|
||||||
void simulateKeyUp(LogicalKeyboardKey logicalKey) {
|
void simulateKeyUp(LogicalKeyboardKey logicalKey) {
|
||||||
_logKeySimulator('simulateKeyUp scheduled logical=${logicalKey.keyLabel}/${logicalKey.keyId}');
|
_defaultSimulator.simulateKeyUp(logicalKey);
|
||||||
scheduleFrameIfIdle();
|
}
|
||||||
SchedulerBinding.instance.addPostFrameCallback((_) {
|
|
||||||
|
/// Simulates key events for one external input source.
|
||||||
|
///
|
||||||
|
/// Separate instances isolate held keys and repeat timers when multiple input
|
||||||
|
/// sources are active.
|
||||||
|
class KeyEventSimulatorController {
|
||||||
|
final ui.KeyEventDeviceType deviceType;
|
||||||
|
final Map<LogicalKeyboardKey, PhysicalKeyboardKey> physicalKeyByLogicalKey;
|
||||||
|
final void Function(String) _log;
|
||||||
|
|
||||||
|
final Map<LogicalKeyboardKey, FocusNode> _heldFocusNodes = {};
|
||||||
|
Timer? _repeatTimer;
|
||||||
|
bool _disposed = false;
|
||||||
|
|
||||||
|
KeyEventSimulatorController({
|
||||||
|
this.deviceType = ui.KeyEventDeviceType.directionalPad,
|
||||||
|
this.physicalKeyByLogicalKey = const {},
|
||||||
|
void Function(String)? log,
|
||||||
|
}) : _log = log ?? _logKeySimulator;
|
||||||
|
|
||||||
|
bool get isRepeating => _repeatTimer != null;
|
||||||
|
|
||||||
|
/// Simulates a full key press (down and up) in one frame.
|
||||||
|
void simulateKeyPress(LogicalKeyboardKey logicalKey) {
|
||||||
|
if (_disposed) return;
|
||||||
|
_log('simulateKeyPress scheduled logical=${logicalKey.keyLabel}/${logicalKey.keyId}');
|
||||||
|
_schedule((focusNode) {
|
||||||
|
final physicalKey = _physicalKeyFor(logicalKey);
|
||||||
|
_dispatchKeyEvent(focusNode, _keyDownEvent(logicalKey, physicalKey));
|
||||||
|
_dispatchKeyEvent(focusNode, _keyUpEvent(logicalKey, physicalKey));
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Simulates key down and remembers its focus until key up.
|
||||||
|
void simulateKeyDown(LogicalKeyboardKey logicalKey) {
|
||||||
|
if (_disposed) return;
|
||||||
|
_log('simulateKeyDown scheduled logical=${logicalKey.keyLabel}/${logicalKey.keyId}');
|
||||||
|
_schedule((focusNode) {
|
||||||
|
_heldFocusNodes[logicalKey] = focusNode;
|
||||||
|
_dispatchKeyEvent(focusNode, _keyDownEvent(logicalKey, _physicalKeyFor(logicalKey)));
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Simulates key up on the focus that received the matching key down.
|
||||||
|
void simulateKeyUp(LogicalKeyboardKey logicalKey) {
|
||||||
|
if (_disposed) return;
|
||||||
|
_log('simulateKeyUp scheduled logical=${logicalKey.keyLabel}/${logicalKey.keyId}');
|
||||||
|
scheduleFrameIfIdle();
|
||||||
|
SchedulerBinding.instance.addPostFrameCallback((_) {
|
||||||
|
if (!_disposed) _dispatchKeyUp(logicalKey);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Releases [logicalKeys] together after previously scheduled key downs.
|
||||||
|
///
|
||||||
|
/// Any remaining held state is cleared after the release burst.
|
||||||
|
void releaseKeys(Iterable<LogicalKeyboardKey> logicalKeys) {
|
||||||
|
if (_disposed) return;
|
||||||
|
final keys = logicalKeys.toList(growable: false);
|
||||||
|
scheduleFrameIfIdle();
|
||||||
|
SchedulerBinding.instance.addPostFrameCallback((_) {
|
||||||
|
if (_disposed) return;
|
||||||
|
for (final logicalKey in keys) {
|
||||||
|
_dispatchKeyUp(logicalKey);
|
||||||
|
}
|
||||||
|
_heldFocusNodes.clear();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Starts with an immediate press, then repeats after [initialDelay].
|
||||||
|
void startKeyRepeat(LogicalKeyboardKey logicalKey, {required Duration initialDelay, required Duration interval}) {
|
||||||
|
if (_disposed) return;
|
||||||
|
stopKeyRepeat();
|
||||||
|
simulateKeyPress(logicalKey);
|
||||||
|
_repeatTimer = Timer(initialDelay, () {
|
||||||
|
if (_disposed) return;
|
||||||
|
_repeatTimer = Timer.periodic(interval, (_) => simulateKeyPress(logicalKey));
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
void stopKeyRepeat() {
|
||||||
|
_repeatTimer?.cancel();
|
||||||
|
_repeatTimer = null;
|
||||||
|
}
|
||||||
|
|
||||||
|
void clearHeldKeys() {
|
||||||
|
_heldFocusNodes.clear();
|
||||||
|
}
|
||||||
|
|
||||||
|
void dispose() {
|
||||||
|
if (_disposed) return;
|
||||||
|
_disposed = true;
|
||||||
|
stopKeyRepeat();
|
||||||
|
_heldFocusNodes.clear();
|
||||||
|
}
|
||||||
|
|
||||||
|
void _schedule(void Function(FocusNode focusNode) dispatch) {
|
||||||
|
// Post-frame dispatch lets focus settle. Requesting a frame is essential
|
||||||
|
// when external input arrives while Flutter is otherwise idle.
|
||||||
|
scheduleFrameIfIdle();
|
||||||
|
SchedulerBinding.instance.addPostFrameCallback((_) {
|
||||||
|
if (_disposed) return;
|
||||||
|
final focusNode = FocusManager.instance.primaryFocus;
|
||||||
|
if (focusNode != null) dispatch(focusNode);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
void _dispatchKeyUp(LogicalKeyboardKey logicalKey) {
|
||||||
final heldFocusNode = _heldFocusNodes.remove(logicalKey);
|
final heldFocusNode = _heldFocusNodes.remove(logicalKey);
|
||||||
final focusNode = heldFocusNode ?? FocusManager.instance.primaryFocus;
|
final focusNode = heldFocusNode ?? FocusManager.instance.primaryFocus;
|
||||||
if (focusNode == null) return;
|
if (focusNode == null) return;
|
||||||
if (heldFocusNode != null && heldFocusNode.context == null) {
|
if (heldFocusNode != null && heldFocusNode.context == null) {
|
||||||
_logKeySimulator('simulateKeyUp dropped detached held focus logical=${logicalKey.keyLabel}/${logicalKey.keyId}');
|
_log('simulateKeyUp dropped detached held focus logical=${logicalKey.keyLabel}/${logicalKey.keyId}');
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
_dispatchKeyEvent(
|
_dispatchKeyEvent(focusNode, _keyUpEvent(logicalKey, _physicalKeyFor(logicalKey)));
|
||||||
focusNode,
|
|
||||||
KeyUpEvent(
|
|
||||||
physicalKey: _getPhysicalKey(logicalKey),
|
|
||||||
logicalKey: logicalKey,
|
|
||||||
timeStamp: Duration(milliseconds: DateTime.now().millisecondsSinceEpoch),
|
|
||||||
deviceType: ui.KeyEventDeviceType.directionalPad,
|
|
||||||
),
|
|
||||||
);
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
void _dispatchKeyEvent(FocusNode focusNode, KeyEvent event) {
|
|
||||||
_logKeySimulator('dispatch start focus=${focusNode.debugLabel} key=(${_describeSimulatedKey(event)})');
|
|
||||||
FocusNode? node = focusNode;
|
|
||||||
while (node != null) {
|
|
||||||
if (node.onKeyEvent != null) {
|
|
||||||
final result = node.onKeyEvent!(node, event);
|
|
||||||
_logKeySimulator('dispatch node=${node.debugLabel} result=$result key=(${_describeSimulatedKey(event)})');
|
|
||||||
if (result != KeyEventResult.ignored) {
|
|
||||||
_logKeySimulator('dispatch stopped node=${node.debugLabel} result=$result');
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
node = node.parent;
|
|
||||||
}
|
}
|
||||||
if (node == null) {
|
|
||||||
_logKeySimulator('dispatch reached root ignored key=(${_describeSimulatedKey(event)})');
|
KeyDownEvent _keyDownEvent(LogicalKeyboardKey logicalKey, PhysicalKeyboardKey physicalKey) {
|
||||||
|
return KeyDownEvent(
|
||||||
|
physicalKey: physicalKey,
|
||||||
|
logicalKey: logicalKey,
|
||||||
|
timeStamp: Duration(milliseconds: DateTime.now().millisecondsSinceEpoch),
|
||||||
|
deviceType: deviceType,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
KeyUpEvent _keyUpEvent(LogicalKeyboardKey logicalKey, PhysicalKeyboardKey physicalKey) {
|
||||||
|
return KeyUpEvent(
|
||||||
|
physicalKey: physicalKey,
|
||||||
|
logicalKey: logicalKey,
|
||||||
|
timeStamp: Duration(milliseconds: DateTime.now().millisecondsSinceEpoch),
|
||||||
|
deviceType: deviceType,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
void _dispatchKeyEvent(FocusNode focusNode, KeyEvent event) {
|
||||||
|
_log('dispatch start focus=${focusNode.debugLabel} key=(${_describeSimulatedKey(event)})');
|
||||||
|
FocusNode? node = focusNode;
|
||||||
|
while (node != null) {
|
||||||
|
if (node.onKeyEvent != null) {
|
||||||
|
final result = node.onKeyEvent!(node, event);
|
||||||
|
_log('dispatch node=${node.debugLabel} result=$result key=(${_describeSimulatedKey(event)})');
|
||||||
|
if (result != KeyEventResult.ignored) {
|
||||||
|
_log('dispatch stopped node=${node.debugLabel} result=$result');
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
node = node.parent;
|
||||||
|
}
|
||||||
|
if (node == null) {
|
||||||
|
_log('dispatch reached root ignored key=(${_describeSimulatedKey(event)})');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
PhysicalKeyboardKey _physicalKeyFor(LogicalKeyboardKey logicalKey) {
|
||||||
|
return physicalKeyByLogicalKey[logicalKey] ?? _getPhysicalKey(logicalKey);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -32,6 +32,73 @@ void main() {
|
|||||||
expect(events.map((event) => event.deviceType), everyElement(ui.KeyEventDeviceType.directionalPad));
|
expect(events.map((event) => event.deviceType), everyElement(ui.KeyEventDeviceType.directionalPad));
|
||||||
});
|
});
|
||||||
|
|
||||||
|
testWidgets('custom simulator preserves gamepad device and physical key mapping', (tester) async {
|
||||||
|
final events = await _pumpKeyEventRecorder(tester);
|
||||||
|
final simulator = KeyEventSimulatorController(
|
||||||
|
deviceType: ui.KeyEventDeviceType.gamepad,
|
||||||
|
physicalKeyByLogicalKey: {LogicalKeyboardKey.enter: PhysicalKeyboardKey.gameButtonA},
|
||||||
|
);
|
||||||
|
addTearDown(simulator.dispose);
|
||||||
|
|
||||||
|
simulator.simulateKeyPress(LogicalKeyboardKey.enter);
|
||||||
|
await tester.pump();
|
||||||
|
|
||||||
|
expect(events, hasLength(2));
|
||||||
|
expect(events.map((event) => event.deviceType), everyElement(ui.KeyEventDeviceType.gamepad));
|
||||||
|
expect(events.map((event) => event.physicalKey), everyElement(PhysicalKeyboardKey.gameButtonA));
|
||||||
|
});
|
||||||
|
|
||||||
|
testWidgets('key repeat waits for the initial delay and then uses the repeat interval', (tester) async {
|
||||||
|
final events = await _pumpKeyEventRecorder(tester);
|
||||||
|
final simulator = KeyEventSimulatorController();
|
||||||
|
addTearDown(simulator.dispose);
|
||||||
|
|
||||||
|
simulator.startKeyRepeat(
|
||||||
|
LogicalKeyboardKey.arrowDown,
|
||||||
|
initialDelay: const Duration(milliseconds: 400),
|
||||||
|
interval: const Duration(milliseconds: 80),
|
||||||
|
);
|
||||||
|
await tester.pump();
|
||||||
|
|
||||||
|
expect(events, hasLength(2));
|
||||||
|
expect(simulator.isRepeating, isTrue);
|
||||||
|
|
||||||
|
await tester.pump(const Duration(milliseconds: 400));
|
||||||
|
expect(events, hasLength(2));
|
||||||
|
|
||||||
|
await tester.pump(const Duration(milliseconds: 79));
|
||||||
|
expect(events, hasLength(2));
|
||||||
|
|
||||||
|
await tester.pump(const Duration(milliseconds: 1));
|
||||||
|
expect(events, hasLength(4));
|
||||||
|
|
||||||
|
simulator.stopKeyRepeat();
|
||||||
|
await tester.pump(const Duration(milliseconds: 160));
|
||||||
|
expect(events, hasLength(4));
|
||||||
|
expect(simulator.isRepeating, isFalse);
|
||||||
|
});
|
||||||
|
|
||||||
|
testWidgets('releaseKeys dispatches a held-key release burst after pending downs', (tester) async {
|
||||||
|
final events = await _pumpKeyEventRecorder(tester);
|
||||||
|
final simulator = KeyEventSimulatorController(deviceType: ui.KeyEventDeviceType.gamepad);
|
||||||
|
addTearDown(simulator.dispose);
|
||||||
|
|
||||||
|
simulator.simulateKeyDown(LogicalKeyboardKey.enter);
|
||||||
|
simulator.simulateKeyDown(LogicalKeyboardKey.gameButtonX);
|
||||||
|
simulator.releaseKeys([LogicalKeyboardKey.enter, LogicalKeyboardKey.gameButtonX]);
|
||||||
|
await tester.pump();
|
||||||
|
|
||||||
|
expect(events, hasLength(4));
|
||||||
|
expect(events.map((event) => event.runtimeType), [KeyDownEvent, KeyDownEvent, KeyUpEvent, KeyUpEvent]);
|
||||||
|
expect(events.map((event) => event.logicalKey), [
|
||||||
|
LogicalKeyboardKey.enter,
|
||||||
|
LogicalKeyboardKey.gameButtonX,
|
||||||
|
LogicalKeyboardKey.enter,
|
||||||
|
LogicalKeyboardKey.gameButtonX,
|
||||||
|
]);
|
||||||
|
expect(events.map((event) => event.deviceType), everyElement(ui.KeyEventDeviceType.gamepad));
|
||||||
|
});
|
||||||
|
|
||||||
testWidgets('simulateKeyUp returns to the key-down focus when focus changes', (tester) async {
|
testWidgets('simulateKeyUp returns to the key-down focus when focus changes', (tester) async {
|
||||||
final firstNode = FocusNode(debugLabel: 'first');
|
final firstNode = FocusNode(debugLabel: 'first');
|
||||||
final secondNode = FocusNode(debugLabel: 'second');
|
final secondNode = FocusNode(debugLabel: 'second');
|
||||||
@@ -123,6 +190,27 @@ void main() {
|
|||||||
expect(childEvents, hasLength(2));
|
expect(childEvents, hasLength(2));
|
||||||
expect(parentEvents, isEmpty);
|
expect(parentEvents, isEmpty);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
testWidgets('dispose cancels queued dispatch and repeat timers', (tester) async {
|
||||||
|
final events = await _pumpKeyEventRecorder(tester);
|
||||||
|
final simulator = KeyEventSimulatorController();
|
||||||
|
|
||||||
|
simulator.simulateKeyDown(LogicalKeyboardKey.enter);
|
||||||
|
await tester.pump();
|
||||||
|
expect(events, hasLength(1));
|
||||||
|
|
||||||
|
simulator.startKeyRepeat(
|
||||||
|
LogicalKeyboardKey.arrowRight,
|
||||||
|
initialDelay: const Duration(milliseconds: 400),
|
||||||
|
interval: const Duration(milliseconds: 80),
|
||||||
|
);
|
||||||
|
simulator.dispose();
|
||||||
|
simulator.simulateKeyUp(LogicalKeyboardKey.enter);
|
||||||
|
|
||||||
|
await tester.pump(const Duration(seconds: 1));
|
||||||
|
expect(events, hasLength(1));
|
||||||
|
expect(events.single, isA<KeyDownEvent>());
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
Future<List<KeyEvent>> _pumpKeyEventRecorder(WidgetTester tester) async {
|
Future<List<KeyEvent>> _pumpKeyEventRecorder(WidgetTester tester) async {
|
||||||
|
|||||||
Reference in New Issue
Block a user