fix(gamepad): release held-direction state when a controller disconnects

Disconnecting a gamepad while holding a D-pad direction or a deflected stick left the synthetic key repeat running forever, since only window blur tore that state down. The disconnect event now runs the same teardown: stop repeats, release held keys, clear pressed/suppressed sets and stick latches.
This commit is contained in:
edde746
2026-08-21 19:23:40 +02:00
parent 5961bf8881
commit d1ccd236fd
2 changed files with 150 additions and 3 deletions
+24 -3
View File
@@ -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:
+126
View File
@@ -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<KeyEvent> events, LogicalKeyboardKey key) {
return events.whereType<KeyDownEvent>().where((e) => e.logicalKey == key).length;
}
int _upCount(List<KeyEvent> events, LogicalKeyboardKey key) {
return events.whereType<KeyUpEvent>().where((e) => e.logicalKey == key).length;
}
Future<List<KeyEvent>> _pumpKeyEventRecorder(WidgetTester tester) async {
final events = <KeyEvent>[];
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;
}