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:
@@ -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:
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
Reference in New Issue
Block a user