fix(tv): clear stale back-suppressor arming when the next back press arrives
Backing out of a focused text field on TV fires onBack on KeyDown and arms BackKeyUpSuppressor so the orphaned KeyUp cannot run a second back. When the closing IME session swallowed that KeyUp entirely, the armed state persisted and silently consumed the next back press anywhere in the app. A matching KeyDown now proves the suppressed press ended without its KeyUp reaching us: the arming clears and the fresh press acts normally. The select suppressor keeps consuming KeyDowns because the hotkey recorder arms against the very KeyDown it re-dispatches; KeyRepeat while armed is still consumed.
This commit is contained in:
@@ -73,9 +73,10 @@ extension DpadKeyExtension on LogicalKeyboardKey {
|
||||
|
||||
/// Base class for suppressing key events after a key category triggers an
|
||||
/// action (e.g. opening a sheet). While suppressed, every event of the matched
|
||||
/// key category is consumed — including a [KeyDownEvent], because an armer
|
||||
/// running in the [HardwareKeyboard] handler phase (the hotkey recorder) arms
|
||||
/// against the very KeyDown that is then re-dispatched through the focus tree.
|
||||
/// key category is consumed — including a [KeyDownEvent] when
|
||||
/// [consumeKeyDowns] is set, because an armer running in the
|
||||
/// [HardwareKeyboard] handler phase (the hotkey recorder) arms against the
|
||||
/// very KeyDown that is then re-dispatched through the focus tree.
|
||||
///
|
||||
/// Suppression ends with the physical press: a global [HardwareKeyboard]
|
||||
/// observer watches for the matching [KeyUpEvent] and clears the armed state
|
||||
@@ -83,10 +84,19 @@ extension DpadKeyExtension on LogicalKeyboardKey {
|
||||
/// focus-phase consumers still swallow it — which guarantees a stale armed
|
||||
/// state (a KeyUp delivered to a focus chain that never consulted us) can
|
||||
/// never swallow the next press.
|
||||
///
|
||||
/// Armers that target an in-flight KeyUp ([consumeKeyDowns] unset) treat a
|
||||
/// matching [KeyDownEvent] as proof the suppressed press already ended without
|
||||
/// its KeyUp reaching us (e.g. a closing TV IME session ate it): the armed
|
||||
/// state is cleared and the fresh press passes through unconsumed.
|
||||
class _KeyUpSuppressor {
|
||||
final bool Function(LogicalKeyboardKey) _keyMatcher;
|
||||
|
||||
_KeyUpSuppressor(this._keyMatcher);
|
||||
/// Whether a matching [KeyDownEvent] is consumed while armed. See the class
|
||||
/// documentation for why armers targeting an in-flight KeyUp must unset it.
|
||||
final bool consumeKeyDowns;
|
||||
|
||||
_KeyUpSuppressor(this._keyMatcher, {this.consumeKeyDowns = true});
|
||||
|
||||
bool _suppressed = false;
|
||||
|
||||
@@ -115,11 +125,18 @@ class _KeyUpSuppressor {
|
||||
void clearSuppression() => _suppressed = false;
|
||||
|
||||
/// Returns `true` (consumed) when the event belongs to the matched key
|
||||
/// category and suppression is active. Clears suppression on [KeyUpEvent].
|
||||
/// category and suppression is active. Clears suppression on [KeyUpEvent],
|
||||
/// and on a stale [KeyDownEvent] when [consumeKeyDowns] is unset.
|
||||
bool consumeIfSuppressed(KeyEvent event) {
|
||||
if (!_suppressed) return false;
|
||||
if (!_keyMatcher(event.logicalKey)) return false;
|
||||
if (event is KeyUpEvent) _suppressed = false;
|
||||
if (event is KeyDownEvent && !consumeKeyDowns) {
|
||||
// The in-flight KeyUp never arrived; this is a fresh press, not the
|
||||
// suppressed one. Clear and let it through.
|
||||
_suppressed = false;
|
||||
return false;
|
||||
}
|
||||
return true;
|
||||
}
|
||||
}
|
||||
@@ -136,10 +153,13 @@ class SelectKeyUpSuppressor {
|
||||
/// Global helper to suppress the next BACK key-up event.
|
||||
///
|
||||
/// Armed when a modal (dialog, sheet) closes while a back key is still held —
|
||||
/// e.g. by [BackKeySuppressorObserver] when a route pops mid-press — so the
|
||||
/// in-flight key-up doesn't propagate to the underlying screen's back handler.
|
||||
/// e.g. by [BackKeySuppressorObserver] when a route pops mid-press — or when a
|
||||
/// down-only back handler moves focus before the matching KeyUp is dispatched.
|
||||
/// A matching KeyDown while armed clears the arming instead of consuming: it
|
||||
/// can only mean the suppressed press's KeyUp was swallowed off-app, so the
|
||||
/// fresh press must act normally.
|
||||
class BackKeyUpSuppressor {
|
||||
static final _instance = _KeyUpSuppressor((k) => k.isBackKey);
|
||||
static final _instance = _KeyUpSuppressor((k) => k.isBackKey, consumeKeyDowns: false);
|
||||
|
||||
static void suppressBackUntilKeyUp() => _instance.suppress();
|
||||
|
||||
|
||||
@@ -242,9 +242,11 @@ KeyEventResult _handleInputKey({
|
||||
// KeyUp is then delivered to the NEW focus chain, whose shared
|
||||
// handlers act on KeyUp — a second back action. Arm the suppressor
|
||||
// (after onBack, so a modal opened by it cannot clear the arming) so
|
||||
// whichever chain receives the KeyUp swallows it. This cannot pin: the
|
||||
// suppressor's hardware observer clears the armed state once the
|
||||
// physical press ends — see _KeyUpSuppressor.
|
||||
// whichever chain receives the KeyUp swallows it. This cannot pin:
|
||||
// the suppressor's hardware observer clears the armed state once the
|
||||
// physical press ends, and if the IME swallows that KeyUp entirely,
|
||||
// the next back KeyDown is treated as stale arming and passes
|
||||
// through — see _KeyUpSuppressor.
|
||||
BackKeyUpSuppressor.suppressBackUntilKeyUp();
|
||||
}
|
||||
return finish(KeyEventResult.handled, 'onBack');
|
||||
|
||||
@@ -1154,8 +1154,8 @@ void main() {
|
||||
buttonFocusNode.requestFocus();
|
||||
},
|
||||
),
|
||||
// No suppressor-aware handler here: the armed state goes stale
|
||||
// because the KeyUp is never consumed anywhere.
|
||||
// No suppressor-aware handler here, and the IME swallows the
|
||||
// press's KeyUp entirely: nothing ever ends the armed state.
|
||||
FilledButton(focusNode: buttonFocusNode, onPressed: () {}, child: const Text('Plain')),
|
||||
],
|
||||
),
|
||||
@@ -1166,11 +1166,11 @@ void main() {
|
||||
fieldFocusNode.requestFocus();
|
||||
await tester.pump();
|
||||
|
||||
// TV back acts on KeyDown and arms the suppressor for the matching
|
||||
// KeyUp — which never arrives (the closing IME session ate it).
|
||||
await tester.sendKeyDownEvent(LogicalKeyboardKey.escape);
|
||||
await tester.pump();
|
||||
expect(fieldBacks, 1);
|
||||
await tester.sendKeyUpEvent(LogicalKeyboardKey.escape);
|
||||
await tester.pump();
|
||||
|
||||
// The stale armed suppressor must clear itself on the next KeyDown
|
||||
// without consuming it — the second press still runs onBack.
|
||||
|
||||
Reference in New Issue
Block a user