diff --git a/lib/services/gamepad_service.dart b/lib/services/gamepad_service.dart index 19ebe0286..d57bbbb53 100644 --- a/lib/services/gamepad_service.dart +++ b/lib/services/gamepad_service.dart @@ -216,6 +216,14 @@ class GamepadService with WindowListener { GamepadService._({GamepadDuplicateInputGuard? duplicateInputGuard}) : _duplicateInputGuard = duplicateInputGuard ?? GamepadDuplicateInputGuard(enabled: () => Platform.isWindows); + /// Standalone instance for tests; never wired to the platform stream. + @visibleForTesting + factory GamepadService.forTesting({GamepadDuplicateInputGuard? duplicateInputGuard}) = GamepadService._; + + /// Feeds [event] through the production event handler. + @visibleForTesting + void debugHandleGamepadEvent(GamepadEvent event) => _handleGamepadEvent(event); + key_sim.KeyEventSimulatorController get _simulator { return _keyEventSimulator ??= key_sim.KeyEventSimulatorController( deviceType: ui.KeyEventDeviceType.gamepad, @@ -291,6 +299,18 @@ class GamepadService with WindowListener { @override void onWindowBlur() { _windowFocused = false; + _releaseHeldInputState(); + + // Release native device handles so other apps can use the gamepad. + Gamepad.instance.pause(); + } + + /// Stops direction repeat and clears every held button and stick latch. + /// + /// Held-input state is global, not per-controller: any single gamepad + /// disconnecting (or the window blurring) clears held state for all + /// controllers. + void _releaseHeldInputState() { _stopDirectionRepeat(); // Release all face buttons in one frame so held widget state cannot stick. @@ -307,9 +327,6 @@ class GamepadService with WindowListener { _leftStickDown = false; _leftStickLeft = false; _leftStickRight = false; - - // Release native device handles so other apps can use the gamepad. - Gamepad.instance.pause(); } void _registerNativeKeyHandler() { @@ -383,6 +400,10 @@ class GamepadService with WindowListener { if (TextInputDiagnostics.enabled) { _logGamepadDiag('connection connected=${e.connected} info=${e.info.name}/${e.info.id}'); } + // A controller that vanishes mid-hold never sends its releases: drop + // the repeat timer and held keys so navigation cannot run away. The + // plugin stays live for any remaining controllers. + if (!e.connected) _releaseHeldInputState(); case final GamepadButtonEvent e: _handleButton(e); case final GamepadAxisEvent e: diff --git a/test/services/gamepad_service_test.dart b/test/services/gamepad_service_test.dart new file mode 100644 index 000000000..2b843d718 --- /dev/null +++ b/test/services/gamepad_service_test.dart @@ -0,0 +1,126 @@ +import 'package:flutter/material.dart'; +import 'package:flutter/services.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:plezy/services/gamepad_service.dart'; +import 'package:universal_gamepad/universal_gamepad.dart'; + +void main() { + testWidgets('gamepad disconnect stops held-direction repeat and releases held keys', (tester) async { + final events = await _pumpKeyEventRecorder(tester); + final service = GamepadService.forTesting(duplicateInputGuard: GamepadDuplicateInputGuard(enabled: () => false)); + + service.debugHandleGamepadEvent(_button(GamepadButton.dpadRight, pressed: true)); + service.debugHandleGamepadEvent(_button(GamepadButton.a, pressed: true)); + await tester.pump(); + + expect(_downCount(events, LogicalKeyboardKey.arrowRight), 1); + expect(_downCount(events, LogicalKeyboardKey.enter), 1); + + // Let the auto-repeat engage: 400ms initial delay, then 80ms intervals. + await tester.pump(const Duration(milliseconds: 400)); + await tester.pump(const Duration(milliseconds: 80)); + await tester.pump(); + expect(_downCount(events, LogicalKeyboardKey.arrowRight), greaterThan(1)); + + service.debugHandleGamepadEvent(_disconnect()); + await tester.pump(); + + // The held A key was released by the disconnect teardown. + expect(_upCount(events, LogicalKeyboardKey.enter), 1); + + final repeatsAtDisconnect = _downCount(events, LogicalKeyboardKey.arrowRight); + await tester.pump(const Duration(milliseconds: 400)); + await tester.pump(const Duration(milliseconds: 400)); + await tester.pump(); + expect( + _downCount(events, LogicalKeyboardKey.arrowRight), + repeatsAtDisconnect, + reason: 'no synthesized repeats may fire after the controller disconnects', + ); + + // Held-button state was cleared: the release that never arrived from the + // dead controller cannot double-fire when a reconnected pad sends it. + service.debugHandleGamepadEvent(_button(GamepadButton.a, pressed: false)); + await tester.pump(); + expect(_upCount(events, LogicalKeyboardKey.enter), 1); + }); + + testWidgets('gamepad disconnect clears stick latches so a reconnected stick navigates again', (tester) async { + final events = await _pumpKeyEventRecorder(tester); + final service = GamepadService.forTesting(duplicateInputGuard: GamepadDuplicateInputGuard(enabled: () => false)); + + service.debugHandleGamepadEvent(_axis(GamepadAxis.leftStickY, 1.0)); + await tester.pump(); + expect(_downCount(events, LogicalKeyboardKey.arrowDown), 1); + + service.debugHandleGamepadEvent(_disconnect()); + await tester.pump(); + + final repeatsAtDisconnect = _downCount(events, LogicalKeyboardKey.arrowDown); + await tester.pump(const Duration(milliseconds: 500)); + await tester.pump(); + expect(_downCount(events, LogicalKeyboardKey.arrowDown), repeatsAtDisconnect); + + // The latch was cleared, so a fresh deflection navigates immediately + // without first passing back through the deadzone. + service.debugHandleGamepadEvent(_axis(GamepadAxis.leftStickY, 1.0)); + await tester.pump(); + expect(_downCount(events, LogicalKeyboardKey.arrowDown), repeatsAtDisconnect + 1); + + // Return the stick to the deadzone so the new repeat timer is cancelled. + service.debugHandleGamepadEvent(_axis(GamepadAxis.leftStickY, 0.0)); + await tester.pump(); + }); +} + +GamepadButtonEvent _button(GamepadButton button, {required bool pressed}) { + return GamepadButtonEvent(gamepadId: 1, timestamp: 0, button: button, pressed: pressed, value: pressed ? 1.0 : 0.0); +} + +GamepadAxisEvent _axis(GamepadAxis axis, double value) { + return GamepadAxisEvent(gamepadId: 1, timestamp: 0, axis: axis, value: value); +} + +GamepadConnectionEvent _disconnect() { + return GamepadConnectionEvent( + gamepadId: 1, + timestamp: 0, + connected: false, + info: const GamepadInfo(id: 1, name: 'Test Pad'), + ); +} + +int _downCount(List events, LogicalKeyboardKey key) { + return events.whereType().where((e) => e.logicalKey == key).length; +} + +int _upCount(List events, LogicalKeyboardKey key) { + return events.whereType().where((e) => e.logicalKey == key).length; +} + +Future> _pumpKeyEventRecorder(WidgetTester tester) async { + final events = []; + late BuildContext focusContext; + + await tester.pumpWidget( + MaterialApp( + home: Focus( + autofocus: true, + onKeyEvent: (_, event) { + events.add(event); + return KeyEventResult.handled; + }, + child: Builder( + builder: (context) { + focusContext = context; + return const SizedBox.shrink(); + }, + ), + ), + ), + ); + Focus.of(focusContext).requestFocus(); + await tester.pump(); + expect(Focus.of(focusContext).hasPrimaryFocus, isTrue); + return events; +}