593 lines
21 KiB
Dart
593 lines
21 KiB
Dart
import 'dart:async';
|
|
import 'dart:io';
|
|
import 'dart:ui' as ui;
|
|
|
|
import 'package:flutter/services.dart';
|
|
import 'package:flutter/widgets.dart';
|
|
import 'package:universal_gamepad/universal_gamepad.dart';
|
|
import 'package:window_manager/window_manager.dart';
|
|
|
|
import '../focus/input_mode_tracker.dart';
|
|
import '../utils/app_logger.dart';
|
|
import '../utils/key_event_simulator.dart' as key_sim;
|
|
import '../utils/platform_detector.dart';
|
|
import '../utils/text_input_diagnostics.dart';
|
|
|
|
String _describeGamepadButton(GamepadButtonEvent event) {
|
|
return 'button=${event.button} pressed=${event.pressed} value=${event.value} gamepad=${event.gamepadId}';
|
|
}
|
|
|
|
String _describeGamepadAxis(GamepadAxisEvent event) {
|
|
return 'axis=${event.axis} value=${event.value} gamepad=${event.gamepadId}';
|
|
}
|
|
|
|
void _logGamepadDiag(String message) {
|
|
TextInputDiagnostics.log('GamepadService', message);
|
|
}
|
|
|
|
/// Suppresses synthetic gamepad key events when the OS has just delivered an
|
|
/// equivalent native key event, which happens with Steam Input on Windows.
|
|
class GamepadDuplicateInputGuard {
|
|
static const defaultSuppressionWindow = Duration(milliseconds: 120);
|
|
static const LogicalKeyboardKey _rawEnterKey = LogicalKeyboardKey(0x0d);
|
|
|
|
static final Map<LogicalKeyboardKey, Set<LogicalKeyboardKey>> _nativeAliasesBySyntheticKey = {
|
|
LogicalKeyboardKey.arrowUp: {LogicalKeyboardKey.arrowUp},
|
|
LogicalKeyboardKey.arrowDown: {LogicalKeyboardKey.arrowDown},
|
|
LogicalKeyboardKey.arrowLeft: {LogicalKeyboardKey.arrowLeft},
|
|
LogicalKeyboardKey.arrowRight: {LogicalKeyboardKey.arrowRight},
|
|
LogicalKeyboardKey.enter: {
|
|
LogicalKeyboardKey.enter,
|
|
_rawEnterKey,
|
|
LogicalKeyboardKey.numpadEnter,
|
|
LogicalKeyboardKey.select,
|
|
LogicalKeyboardKey.gameButtonA,
|
|
},
|
|
LogicalKeyboardKey.gameButtonB: {
|
|
LogicalKeyboardKey.escape,
|
|
LogicalKeyboardKey.goBack,
|
|
LogicalKeyboardKey.browserBack,
|
|
LogicalKeyboardKey.gameButtonB,
|
|
},
|
|
LogicalKeyboardKey.gameButtonX: {LogicalKeyboardKey.gameButtonX, LogicalKeyboardKey.contextMenu},
|
|
};
|
|
|
|
static final Set<LogicalKeyboardKey> _trackedNativeKeys = _nativeAliasesBySyntheticKey.values
|
|
.expand((keys) => keys)
|
|
.toSet();
|
|
|
|
final DateTime Function() _now;
|
|
final bool Function()? _enabled;
|
|
final Duration suppressionWindow;
|
|
final Map<LogicalKeyboardKey, DateTime> _lastNativeEvents = {};
|
|
final Set<LogicalKeyboardKey> _nativeKeysPressed = {};
|
|
|
|
GamepadDuplicateInputGuard({
|
|
DateTime Function()? now,
|
|
this._enabled,
|
|
this.suppressionWindow = defaultSuppressionWindow,
|
|
}) : _now = now ?? DateTime.now;
|
|
|
|
bool get _isEnabled => _enabled?.call() ?? true;
|
|
|
|
bool handleNativeKeyEvent(KeyEvent event) {
|
|
if (!_isEnabled || !_trackedNativeKeys.contains(event.logicalKey)) return false;
|
|
|
|
final now = _now();
|
|
_lastNativeEvents[event.logicalKey] = now;
|
|
if (event is KeyUpEvent) {
|
|
_nativeKeysPressed.remove(event.logicalKey);
|
|
} else {
|
|
_nativeKeysPressed.add(event.logicalKey);
|
|
}
|
|
_prune(now);
|
|
return false;
|
|
}
|
|
|
|
bool shouldSuppressSyntheticKey(LogicalKeyboardKey logicalKey) {
|
|
if (!_isEnabled) return false;
|
|
|
|
final now = _now();
|
|
_prune(now);
|
|
for (final key in _nativeAliasesBySyntheticKey[logicalKey] ?? {logicalKey}) {
|
|
if (_nativeKeysPressed.contains(key)) return true;
|
|
|
|
final lastNativeEvent = _lastNativeEvents[key];
|
|
if (lastNativeEvent != null && now.difference(lastNativeEvent) <= suppressionWindow) {
|
|
return true;
|
|
}
|
|
}
|
|
return false;
|
|
}
|
|
|
|
void clear() {
|
|
_lastNativeEvents.clear();
|
|
_nativeKeysPressed.clear();
|
|
}
|
|
|
|
void _prune(DateTime now) {
|
|
_lastNativeEvents.removeWhere((_, timestamp) => now.difference(timestamp) > suppressionWindow);
|
|
}
|
|
}
|
|
|
|
@visibleForTesting
|
|
bool isTvosEngineOwnedGamepadButton({required bool isAppleTV, required GamepadButton button}) {
|
|
if (!isAppleTV) return false;
|
|
return switch (button) {
|
|
GamepadButton.dpadUp ||
|
|
GamepadButton.dpadDown ||
|
|
GamepadButton.dpadLeft ||
|
|
GamepadButton.dpadRight ||
|
|
GamepadButton.a ||
|
|
GamepadButton.b => true,
|
|
_ => false,
|
|
};
|
|
}
|
|
|
|
/// Service that bridges gamepad input to Flutter's focus navigation system.
|
|
///
|
|
/// Listens to gamepad events from the `universal_gamepad` package and translates
|
|
/// them into focus navigation actions and key events that integrate with the
|
|
/// existing keyboard navigation system.
|
|
class GamepadService with WindowListener {
|
|
static final Map<GamepadButton, LogicalKeyboardKey> _syntheticKeyByButton = {
|
|
GamepadButton.dpadUp: LogicalKeyboardKey.arrowUp,
|
|
GamepadButton.dpadDown: LogicalKeyboardKey.arrowDown,
|
|
GamepadButton.dpadLeft: LogicalKeyboardKey.arrowLeft,
|
|
GamepadButton.dpadRight: LogicalKeyboardKey.arrowRight,
|
|
GamepadButton.a: LogicalKeyboardKey.enter,
|
|
GamepadButton.b: LogicalKeyboardKey.gameButtonB,
|
|
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;
|
|
StreamSubscription<GamepadEvent>? _subscription;
|
|
final GamepadDuplicateInputGuard _duplicateInputGuard;
|
|
|
|
static final Map<Object, ({VoidCallback previous, VoidCallback next, bool Function() isActive})>
|
|
_tabNavigationHandlers = {};
|
|
|
|
/// Registers owner-scoped bumper navigation. Multiple tab screens can stay
|
|
/// mounted; only the handler whose screen is currently visible runs.
|
|
static void registerTabNavigation(
|
|
Object owner, {
|
|
required VoidCallback previous,
|
|
required VoidCallback next,
|
|
required bool Function() isActive,
|
|
}) {
|
|
_tabNavigationHandlers[owner] = (previous: previous, next: next, isActive: isActive);
|
|
}
|
|
|
|
static void unregisterTabNavigation(Object owner) {
|
|
_tabNavigationHandlers.remove(owner);
|
|
}
|
|
|
|
static void _dispatchTabNavigation({required bool previous}) {
|
|
for (final handler in _tabNavigationHandlers.values) {
|
|
if (!handler.isActive()) continue;
|
|
previous ? handler.previous() : handler.next();
|
|
return;
|
|
}
|
|
}
|
|
|
|
@visibleForTesting
|
|
static void debugDispatchTabNavigation({required bool previous}) {
|
|
_dispatchTabNavigation(previous: previous);
|
|
}
|
|
|
|
@visibleForTesting
|
|
static void debugClearTabNavigationHandlers() {
|
|
_tabNavigationHandlers.clear();
|
|
}
|
|
|
|
static const double _stickDeadzone = 0.5;
|
|
|
|
static const Duration _repeatInitialDelay = Duration(milliseconds: 400);
|
|
static const Duration _repeatInterval = Duration(milliseconds: 80);
|
|
|
|
key_sim.KeyEventSimulatorController? _keyEventSimulator;
|
|
|
|
bool _leftStickUp = false;
|
|
bool _leftStickDown = false;
|
|
bool _leftStickLeft = false;
|
|
bool _leftStickRight = false;
|
|
|
|
final Set<GamepadButton> _pressedButtons = {};
|
|
final Set<GamepadButton> _suppressedButtons = {};
|
|
bool _windowFocused = true;
|
|
bool _nativeKeyHandlerRegistered = false;
|
|
bool _nativeTextInputFocused = false;
|
|
|
|
@visibleForTesting
|
|
static Future<void> Function(bool focused)? debugNativeTextInputFocusHandler;
|
|
|
|
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!;
|
|
}
|
|
|
|
static Future<void> setNativeTextInputFocused(bool focused) {
|
|
return instance._setNativeTextInputFocused(focused);
|
|
}
|
|
|
|
/// Start listening to gamepad events.
|
|
/// Only active on desktop platforms (macOS, Windows, Linux).
|
|
static bool get _isDesktop => PlatformDetector.isDesktopOS();
|
|
|
|
void start() async {
|
|
appLogger.i('GamepadService: Starting on ${Platform.operatingSystem}');
|
|
|
|
try {
|
|
final gamepads = await Gamepad.instance.listGamepads();
|
|
appLogger.i('GamepadService: Found ${gamepads.length} gamepad(s)');
|
|
for (final gamepad in gamepads) {
|
|
appLogger.i(' - ${gamepad.name} (id: ${gamepad.id})');
|
|
}
|
|
} catch (e) {
|
|
appLogger.e('GamepadService: Error listing gamepads', error: e);
|
|
}
|
|
|
|
// Track window focus so we ignore gamepad input when another app is active
|
|
// (window_manager is desktop-only)
|
|
if (_isDesktop) {
|
|
windowManager.addListener(this);
|
|
_windowFocused = await windowManager.isFocused();
|
|
}
|
|
_registerNativeKeyHandler();
|
|
|
|
unawaited(_subscription?.cancel());
|
|
_subscription = Gamepad.instance.events.listen(
|
|
_handleGamepadEvent,
|
|
onError: (e) => appLogger.e('GamepadService: Stream error', error: e),
|
|
);
|
|
appLogger.i('GamepadService: Listening for gamepad events');
|
|
}
|
|
|
|
void stop() {
|
|
_stopDirectionRepeat();
|
|
_unregisterNativeKeyHandler();
|
|
_subscription?.cancel();
|
|
_subscription = null;
|
|
_duplicateInputGuard.clear();
|
|
_suppressedButtons.clear();
|
|
_keyEventSimulator?.dispose();
|
|
_keyEventSimulator = null;
|
|
if (_isDesktop) {
|
|
windowManager.removeListener(this);
|
|
}
|
|
Gamepad.instance.dispose();
|
|
}
|
|
|
|
@override
|
|
void onWindowFocus() {
|
|
_windowFocused = true;
|
|
_duplicateInputGuard.clear();
|
|
Gamepad.instance.resume();
|
|
}
|
|
|
|
@override
|
|
void onWindowBlur() {
|
|
_windowFocused = false;
|
|
_stopDirectionRepeat();
|
|
|
|
// 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();
|
|
_duplicateInputGuard.clear();
|
|
|
|
// Reset analog stick state so re-focus doesn't inherit stale direction
|
|
_leftStickUp = false;
|
|
_leftStickDown = false;
|
|
_leftStickLeft = false;
|
|
_leftStickRight = false;
|
|
|
|
// Release native device handles so other apps can use the gamepad.
|
|
Gamepad.instance.pause();
|
|
}
|
|
|
|
void _registerNativeKeyHandler() {
|
|
if (_nativeKeyHandlerRegistered || !Platform.isWindows) return;
|
|
HardwareKeyboard.instance.addHandler(_handleNativeKeyEvent);
|
|
_nativeKeyHandlerRegistered = true;
|
|
}
|
|
|
|
void _unregisterNativeKeyHandler() {
|
|
if (!_nativeKeyHandlerRegistered) return;
|
|
HardwareKeyboard.instance.removeHandler(_handleNativeKeyEvent);
|
|
_nativeKeyHandlerRegistered = false;
|
|
}
|
|
|
|
bool _handleNativeKeyEvent(KeyEvent event) {
|
|
return _duplicateInputGuard.handleNativeKeyEvent(event);
|
|
}
|
|
|
|
Future<void> _setNativeTextInputFocused(bool focused) async {
|
|
_logGamepadDiag('setNativeTextInputFocused requested focused=$focused current=$_nativeTextInputFocused');
|
|
if (_nativeTextInputFocused == focused) {
|
|
_logGamepadDiag('setNativeTextInputFocused no-op focused=$focused');
|
|
return;
|
|
}
|
|
_nativeTextInputFocused = focused;
|
|
|
|
if (focused) {
|
|
_logGamepadDiag('native text input focused; clearing repeat/buttons/duplicate guard before pause');
|
|
_stopDirectionRepeat();
|
|
_pressedButtons.clear();
|
|
_suppressedButtons.clear();
|
|
_keyEventSimulator?.clearHeldKeys();
|
|
_duplicateInputGuard.clear();
|
|
}
|
|
|
|
final debugHandler = debugNativeTextInputFocusHandler;
|
|
if (debugHandler != null) {
|
|
_logGamepadDiag('setNativeTextInputFocused using debug handler focused=$focused');
|
|
await debugHandler(focused);
|
|
return;
|
|
}
|
|
|
|
try {
|
|
if (focused) {
|
|
_logGamepadDiag('calling Gamepad.pause for native text input');
|
|
await Gamepad.instance.pause();
|
|
_logGamepadDiag('Gamepad.pause completed for native text input');
|
|
} else {
|
|
_logGamepadDiag('calling Gamepad.resume after native text input');
|
|
await Gamepad.instance.resume();
|
|
_logGamepadDiag('Gamepad.resume completed after native text input');
|
|
}
|
|
} catch (e) {
|
|
appLogger.e('GamepadService: Failed to ${focused ? "pause" : "resume"} for native text input', error: e);
|
|
}
|
|
}
|
|
|
|
void _handleGamepadEvent(GamepadEvent event) {
|
|
_logGamepadDiag('event received type=${event.runtimeType} nativeTextInputFocused=$_nativeTextInputFocused');
|
|
switch (event) {
|
|
case final GamepadConnectionEvent e:
|
|
appLogger.i('GamepadService: Gamepad ${e.connected ? "connected" : "disconnected"}: ${e.info.name}');
|
|
_logGamepadDiag('connection connected=${e.connected} info=${e.info.name}/${e.info.id}');
|
|
case final GamepadButtonEvent e:
|
|
_handleButton(e);
|
|
case final GamepadAxisEvent e:
|
|
_handleAxis(e);
|
|
}
|
|
}
|
|
|
|
void _handleButton(GamepadButtonEvent event) {
|
|
_logGamepadDiag(
|
|
'button received ${_describeGamepadButton(event)} windowFocused=$_windowFocused nativeTextInputFocused=$_nativeTextInputFocused',
|
|
);
|
|
if (!_windowFocused) {
|
|
_logGamepadDiag('button ignored because window is not focused ${_describeGamepadButton(event)}');
|
|
return;
|
|
}
|
|
if (isTvosEngineOwnedGamepadButton(isAppleTV: PlatformDetector.isAppleTV(), button: event.button)) {
|
|
_logGamepadDiag('button ignored because tvOS engine owns its key lifecycle ${_describeGamepadButton(event)}');
|
|
return;
|
|
}
|
|
|
|
// Switch to keyboard mode on any button press
|
|
if (event.pressed) {
|
|
InputModeTracker.reportNonPointerInput();
|
|
}
|
|
// Ensure a frame is scheduled so addPostFrameCallback-based key
|
|
// simulation fires promptly. Without this, key-up events can be
|
|
// delayed indefinitely when the app is idle, causing the long-press
|
|
// timer to fire before the release is delivered.
|
|
key_sim.scheduleFrameIfIdle();
|
|
|
|
final wasPressed = _pressedButtons.contains(event.button);
|
|
|
|
if (event.pressed && !wasPressed) {
|
|
_pressedButtons.add(event.button);
|
|
if (_shouldSuppressButton(event.button)) {
|
|
_logGamepadDiag('button suppressed by duplicate guard ${_describeGamepadButton(event)}');
|
|
_suppressedButtons.add(event.button);
|
|
return;
|
|
}
|
|
|
|
// D-pad — navigate with auto-repeat while held
|
|
switch (event.button) {
|
|
case GamepadButton.dpadUp:
|
|
_logGamepadDiag('button starts direction repeat up ${_describeGamepadButton(event)}');
|
|
_startDirectionRepeat(TraversalDirection.up);
|
|
return;
|
|
case GamepadButton.dpadDown:
|
|
_logGamepadDiag('button starts direction repeat down ${_describeGamepadButton(event)}');
|
|
_startDirectionRepeat(TraversalDirection.down);
|
|
return;
|
|
case GamepadButton.dpadLeft:
|
|
_logGamepadDiag('button starts direction repeat left ${_describeGamepadButton(event)}');
|
|
_startDirectionRepeat(TraversalDirection.left);
|
|
return;
|
|
case GamepadButton.dpadRight:
|
|
_logGamepadDiag('button starts direction repeat right ${_describeGamepadButton(event)}');
|
|
_startDirectionRepeat(TraversalDirection.right);
|
|
return;
|
|
// Face buttons — send KeyDown on press, KeyUp on release
|
|
// so widget-level long-press timers work naturally
|
|
case GamepadButton.a:
|
|
_logGamepadDiag('button simulates key down enter ${_describeGamepadButton(event)}');
|
|
_simulateKeyDown(LogicalKeyboardKey.enter);
|
|
case GamepadButton.x:
|
|
_logGamepadDiag('button simulates key down context/menu ${_describeGamepadButton(event)}');
|
|
_simulateKeyDown(LogicalKeyboardKey.gameButtonX);
|
|
// Immediate actions on press
|
|
case GamepadButton.b:
|
|
_logGamepadDiag('button simulates key press back ${_describeGamepadButton(event)}');
|
|
_simulateKeyPress(LogicalKeyboardKey.gameButtonB);
|
|
case GamepadButton.leftShoulder:
|
|
_dispatchTabNavigation(previous: true);
|
|
case GamepadButton.rightShoulder:
|
|
_dispatchTabNavigation(previous: false);
|
|
default:
|
|
break;
|
|
}
|
|
} else if (!event.pressed && wasPressed) {
|
|
_pressedButtons.remove(event.button);
|
|
if (_suppressedButtons.remove(event.button)) {
|
|
_logGamepadDiag('button release consumed by suppressed set ${_describeGamepadButton(event)}');
|
|
return;
|
|
}
|
|
|
|
// D-pad release — stop repeat
|
|
switch (event.button) {
|
|
case GamepadButton.dpadUp:
|
|
case GamepadButton.dpadDown:
|
|
case GamepadButton.dpadLeft:
|
|
case GamepadButton.dpadRight:
|
|
_logGamepadDiag('button stops direction repeat ${_describeGamepadButton(event)}');
|
|
_stopDirectionRepeat();
|
|
// Face button release — send KeyUp
|
|
case GamepadButton.a:
|
|
_logGamepadDiag('button simulates key up enter ${_describeGamepadButton(event)}');
|
|
_simulateKeyUp(LogicalKeyboardKey.enter);
|
|
case GamepadButton.x:
|
|
_logGamepadDiag('button simulates key up context/menu ${_describeGamepadButton(event)}');
|
|
_simulateKeyUp(LogicalKeyboardKey.gameButtonX);
|
|
default:
|
|
break;
|
|
}
|
|
}
|
|
}
|
|
|
|
bool _shouldSuppressButton(GamepadButton button) {
|
|
final syntheticKey = _syntheticKeyByButton[button];
|
|
final suppressed = syntheticKey != null && _duplicateInputGuard.shouldSuppressSyntheticKey(syntheticKey);
|
|
_logGamepadDiag('duplicate guard button=$button syntheticKey=$syntheticKey suppressed=$suppressed');
|
|
return suppressed;
|
|
}
|
|
|
|
void _handleAxis(GamepadAxisEvent event) {
|
|
_logGamepadDiag(
|
|
'axis received ${_describeGamepadAxis(event)} windowFocused=$_windowFocused nativeTextInputFocused=$_nativeTextInputFocused',
|
|
);
|
|
if (!_windowFocused) {
|
|
_logGamepadDiag('axis ignored because window is not focused ${_describeGamepadAxis(event)}');
|
|
return;
|
|
}
|
|
|
|
// Promotion must fire on the same event that navigates: gating below the
|
|
// real deadzone would let analog-stick drift hide the desktop cursor.
|
|
if (event.value.abs() > _stickDeadzone) {
|
|
InputModeTracker.reportNonPointerInput();
|
|
}
|
|
|
|
switch (event.axis) {
|
|
case GamepadAxis.leftStickY:
|
|
_handleLeftStickY(event.value);
|
|
case GamepadAxis.leftStickX:
|
|
_handleLeftStickX(event.value);
|
|
default:
|
|
break;
|
|
}
|
|
}
|
|
|
|
/// Fire [direction] immediately, then auto-repeat after an initial delay.
|
|
void _startDirectionRepeat(TraversalDirection direction) {
|
|
_logGamepadDiag('startDirectionRepeat direction=$direction');
|
|
_stopDirectionRepeat();
|
|
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 (_keyEventSimulator?.isRepeating ?? false) {
|
|
_logGamepadDiag('stopDirectionRepeat');
|
|
}
|
|
_keyEventSimulator?.stopKeyRepeat();
|
|
}
|
|
|
|
LogicalKeyboardKey _directionToKey(TraversalDirection direction) {
|
|
switch (direction) {
|
|
case TraversalDirection.up:
|
|
return LogicalKeyboardKey.arrowUp;
|
|
case TraversalDirection.down:
|
|
return LogicalKeyboardKey.arrowDown;
|
|
case TraversalDirection.left:
|
|
return LogicalKeyboardKey.arrowLeft;
|
|
case TraversalDirection.right:
|
|
return LogicalKeyboardKey.arrowRight;
|
|
}
|
|
}
|
|
|
|
/// Simulate a full key press (down + up) in a single frame.
|
|
void _simulateKeyPress(LogicalKeyboardKey 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) {
|
|
_simulator.simulateKeyDown(logicalKey);
|
|
}
|
|
|
|
/// Simulate only key up — the release half of [_simulateKeyDown].
|
|
void _simulateKeyUp(LogicalKeyboardKey logicalKey) {
|
|
_simulator.simulateKeyUp(logicalKey);
|
|
}
|
|
|
|
// W3C: leftStickY -1.0 = up, 1.0 = down
|
|
void _handleLeftStickY(double value) {
|
|
if (value > _stickDeadzone && !_leftStickDown) {
|
|
_leftStickDown = true;
|
|
_leftStickUp = false;
|
|
_startDirectionRepeat(TraversalDirection.down);
|
|
} else if (value < -_stickDeadzone && !_leftStickUp) {
|
|
_leftStickUp = true;
|
|
_leftStickDown = false;
|
|
_startDirectionRepeat(TraversalDirection.up);
|
|
} else if (value.abs() <= _stickDeadzone) {
|
|
if (_leftStickUp || _leftStickDown) _stopDirectionRepeat();
|
|
_leftStickUp = false;
|
|
_leftStickDown = false;
|
|
}
|
|
}
|
|
|
|
void _handleLeftStickX(double value) {
|
|
if (value < -_stickDeadzone && !_leftStickLeft) {
|
|
_leftStickLeft = true;
|
|
_leftStickRight = false;
|
|
_startDirectionRepeat(TraversalDirection.left);
|
|
} else if (value > _stickDeadzone && !_leftStickRight) {
|
|
_leftStickRight = true;
|
|
_leftStickLeft = false;
|
|
_startDirectionRepeat(TraversalDirection.right);
|
|
} else if (value.abs() <= _stickDeadzone) {
|
|
if (_leftStickLeft || _leftStickRight) _stopDirectionRepeat();
|
|
_leftStickLeft = false;
|
|
_leftStickRight = false;
|
|
}
|
|
}
|
|
}
|