From 53288656391ca1a6405c6176cfd63e556b373b21 Mon Sep 17 00:00:00 2001 From: edde746 <86283021+edde746@users.noreply.github.com> Date: Sun, 12 Jul 2026 06:19:12 +0200 Subject: [PATCH] refactor(input): share gamepad key simulation --- lib/services/gamepad_service.dart | 177 ++++------------- lib/utils/key_event_simulator.dart | 238 +++++++++++++++-------- test/utils/key_event_simulator_test.dart | 88 +++++++++ 3 files changed, 283 insertions(+), 220 deletions(-) diff --git a/lib/services/gamepad_service.dart b/lib/services/gamepad_service.dart index 3e109a47..362556e2 100644 --- a/lib/services/gamepad_service.dart +++ b/lib/services/gamepad_service.dart @@ -13,11 +13,6 @@ import '../utils/key_event_simulator.dart' as key_sim; import '../utils/platform_detector.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) { 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, }; + static final Map _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; StreamSubscription? _subscription; final GamepadDuplicateInputGuard _duplicateInputGuard; @@ -154,7 +161,7 @@ class GamepadService with WindowListener { static const Duration _repeatInitialDelay = Duration(milliseconds: 400); static const Duration _repeatInterval = Duration(milliseconds: 80); - Timer? _repeatTimer; + key_sim.KeyEventSimulatorController? _keyEventSimulator; // Track stick state to detect deadzone crossings bool _leftStickUp = false; @@ -165,8 +172,6 @@ class GamepadService with WindowListener { // Track button states to prevent repeated events from button holds final Set _pressedButtons = {}; final Set _suppressedButtons = {}; - final Map _heldFocusNodes = {}; - // Whether the app window is currently focused — ignore gamepad input when false bool _windowFocused = true; bool _nativeKeyHandlerRegistered = false; @@ -178,6 +183,14 @@ class GamepadService with WindowListener { GamepadService._({GamepadDuplicateInputGuard? duplicateInputGuard}) : _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 { _instance ??= GamepadService._(); return _instance!; @@ -227,7 +240,8 @@ class GamepadService with WindowListener { _subscription = null; _duplicateInputGuard.clear(); _suppressedButtons.clear(); - _heldFocusNodes.clear(); + _keyEventSimulator?.dispose(); + _keyEventSimulator = null; if (_isDesktop) { windowManager.removeListener(this); } @@ -246,18 +260,13 @@ class GamepadService with WindowListener { _windowFocused = false; _stopDirectionRepeat(); - // Send key-up for any face buttons that are mid-hold so widgets - // waiting for the release (e.g. long-press timers) don't get stuck. - if (_pressedButtons.contains(GamepadButton.a)) { - _simulateKeyUp(LogicalKeyboardKey.enter); - } - if (_pressedButtons.contains(GamepadButton.x)) { - _simulateKeyUp(LogicalKeyboardKey.gameButtonX); - } + // Release all face buttons in one frame so held widget state cannot stick. + _simulator.releaseKeys([ + if (_pressedButtons.contains(GamepadButton.a)) LogicalKeyboardKey.enter, + if (_pressedButtons.contains(GamepadButton.x)) LogicalKeyboardKey.gameButtonX, + ]); _pressedButtons.clear(); _suppressedButtons.clear(); - SchedulerBinding.instance.addPostFrameCallback((_) => _heldFocusNodes.clear()); - key_sim.scheduleFrameIfIdle(); _duplicateInputGuard.clear(); // Reset analog stick state so re-focus doesn't inherit stale direction @@ -299,7 +308,7 @@ class GamepadService with WindowListener { _stopDirectionRepeat(); _pressedButtons.clear(); _suppressedButtons.clear(); - _heldFocusNodes.clear(); + _keyEventSimulator?.clearHeldKeys(); _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. void _startDirectionRepeat(TraversalDirection direction) { _logGamepadDiag('startDirectionRepeat direction=$direction'); _stopDirectionRepeat(); - _moveFocus(direction); - _repeatTimer = Timer(_repeatInitialDelay, () { - _repeatTimer = Timer.periodic(_repeatInterval, (_) { - _moveFocus(direction); - }); - }); + final logicalKey = _directionToKey(direction); + _logGamepadDiag( + 'moveFocus direction=$direction logicalKey=${logicalKey.keyLabel}/${logicalKey.keyId} nativeTextInputFocused=$_nativeTextInputFocused', + ); + _simulator.startKeyRepeat(logicalKey, initialDelay: _repeatInitialDelay, interval: _repeatInterval); } void _stopDirectionRepeat() { - if (_repeatTimer != null) { + if (_keyEventSimulator?.isRepeating ?? false) { _logGamepadDiag('stopDirectionRepeat'); } - _repeatTimer?.cancel(); - _repeatTimer = null; + _keyEventSimulator?.stopKeyRepeat(); } LogicalKeyboardKey _directionToKey(TraversalDirection direction) { @@ -511,114 +508,18 @@ class GamepadService with WindowListener { /// Simulate a full key press (down + up) in a single frame. void _simulateKeyPress(LogicalKeyboardKey logicalKey) { - key_sim.scheduleFrameIfIdle(); - SchedulerBinding.instance.addPostFrameCallback((_) { - _dispatchKeyDown(logicalKey); - _dispatchKeyUp(logicalKey); - }); + _simulator.simulateKeyPress(logicalKey); } /// Simulate only key down — pair with [_simulateKeyUp] on release /// so widget-level long-press timers see real hold duration. void _simulateKeyDown(LogicalKeyboardKey logicalKey) { - key_sim.scheduleFrameIfIdle(); - SchedulerBinding.instance.addPostFrameCallback((_) { - _dispatchKeyDown(logicalKey); - }); + _simulator.simulateKeyDown(logicalKey); } /// Simulate only key up — the release half of [_simulateKeyDown]. void _simulateKeyUp(LogicalKeyboardKey logicalKey) { - key_sim.scheduleFrameIfIdle(); - 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; + _simulator.simulateKeyUp(logicalKey); } // W3C: leftStickY -1.0 = up, 1.0 = down diff --git a/lib/utils/key_event_simulator.dart b/lib/utils/key_event_simulator.dart index 04633c48..12f7078d 100644 --- a/lib/utils/key_event_simulator.dart +++ b/lib/utils/key_event_simulator.dart @@ -1,3 +1,4 @@ +import 'dart:async'; import 'dart:ui' as ui; import 'package:flutter/scheduler.dart'; @@ -15,110 +16,183 @@ void _logKeySimulator(String message) { TextInputDiagnostics.log('KeySimulator', message); } -final Map _heldFocusNodes = {}; +final KeyEventSimulatorController _defaultSimulator = KeyEventSimulatorController(); /// Shared utility for simulating key press events through the focus tree. /// /// Used by companion remotes, Apple TV touch input, and gamepad services to /// translate external input into focus-tree key events. void simulateKeyPress(LogicalKeyboardKey logicalKey) { - _logKeySimulator('simulateKeyPress scheduled logical=${logicalKey.keyLabel}/${logicalKey.keyId}'); - // 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); - }); + _defaultSimulator.simulateKeyPress(logicalKey); } /// Simulate only key down. Pair with [simulateKeyUp] for held buttons. void simulateKeyDown(LogicalKeyboardKey logicalKey) { - _logKeySimulator('simulateKeyDown scheduled logical=${logicalKey.keyLabel}/${logicalKey.keyId}'); - 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, - ), - ); - }); + _defaultSimulator.simulateKeyDown(logicalKey); } /// Simulate only key up. The release half of [simulateKeyDown]. void simulateKeyUp(LogicalKeyboardKey logicalKey) { - _logKeySimulator('simulateKeyUp scheduled logical=${logicalKey.keyLabel}/${logicalKey.keyId}'); - scheduleFrameIfIdle(); - SchedulerBinding.instance.addPostFrameCallback((_) { + _defaultSimulator.simulateKeyUp(logicalKey); +} + +/// 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 physicalKeyByLogicalKey; + final void Function(String) _log; + + final Map _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 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 focusNode = heldFocusNode ?? FocusManager.instance.primaryFocus; if (focusNode == null) return; 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; } - _dispatchKeyEvent( - 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; + _dispatchKeyEvent(focusNode, _keyUpEvent(logicalKey, _physicalKeyFor(logicalKey))); } - 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); } } diff --git a/test/utils/key_event_simulator_test.dart b/test/utils/key_event_simulator_test.dart index 45ae776f..e6fe2f43 100644 --- a/test/utils/key_event_simulator_test.dart +++ b/test/utils/key_event_simulator_test.dart @@ -32,6 +32,73 @@ void main() { 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 { final firstNode = FocusNode(debugLabel: 'first'); final secondNode = FocusNode(debugLabel: 'second'); @@ -123,6 +190,27 @@ void main() { expect(childEvents, hasLength(2)); 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()); + }); } Future> _pumpKeyEventRecorder(WidgetTester tester) async {