Files
plezy/lib/focus/key_event_utils.dart
edde746 bac2a0d201 fix(player): keep Delete and Home editing text in player sheets
Bare Backspace and Home are player navigation keys, but they are also
caret editing keys. The player screen's Focus wraps its OverlaySheetHost,
so it saw them before the subtitle-search field could act: the press was
consumed on key-down, DefaultTextEditingShortcuts never turned it into a
deletion, and the back pipeline hid the chrome and then left the player.

A focused text editor now takes both keys back, but only for physical
keyboard presses — a synthesized dpad/gamepad press has no caret, and
browserHome has no editing role at all.

The screen also resolved its overlay-sheet controller from the State's
own context, which sits above the host it was querying, so the lookup
always returned null and Back skipped the sheet stage entirely. Resolve
it from a context below the host instead, matching NowPlayingScreen.

close #1741
2026-08-02 07:37:12 +02:00

259 lines
9.3 KiB
Dart

import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
import '../utils/platform_detector.dart';
import 'dpad_navigator.dart';
/// Handles back key events by popping the current route.
///
/// Optionally pass a [result] to return to the previous route.
///
/// Use this as an `onKeyEvent` callback for Focus widgets that need
/// simple back navigation behavior:
///
/// ```dart
/// Focus(
/// onKeyEvent: (node, event) => handleBackKeyNavigation(context, event),
/// child: ...
/// )
/// ```
///
/// With a result value:
/// ```dart
/// Focus(
/// onKeyEvent: (node, event) => handleBackKeyNavigation(
/// context,
/// event,
/// result: _hasChanges,
/// ),
/// child: ...
/// )
/// ```
class BackKeyCoordinator {
static bool _handledThisFrame = false;
static bool _clearScheduled = false;
static int _clearGeneration = 0;
static void markHandled() {
_handledThisFrame = true;
if (_clearScheduled) return;
_clearScheduled = true;
final clearGeneration = _clearGeneration;
WidgetsBinding.instance.addPostFrameCallback((_) {
if (clearGeneration != _clearGeneration) return;
_handledThisFrame = false;
_clearScheduled = false;
});
// addPostFrameCallback does not request a frame. Ensure the one-shot
// marker cannot leak when handling Back does not otherwise schedule one.
WidgetsBinding.instance.scheduleFrame();
}
static void clear() {
_handledThisFrame = false;
_clearScheduled = false;
// Invalidate a pending callback so it cannot clear a newer marker.
_clearGeneration++;
}
static bool consumeIfHandled() {
if (!_handledThisFrame) return false;
clear();
return true;
}
}
/// Consumes KeyDown/KeyRepeat to avoid duplicate actions, runs [onBack] on KeyUp.
KeyEventResult handleBackKeyAction(KeyEvent event, VoidCallback onBack) {
if (!event.logicalKey.isBackKey) return KeyEventResult.ignored;
// Check if this BACK event should be suppressed (e.g., after modal closed)
if (BackKeyUpSuppressor.consumeIfSuppressed(event)) {
return KeyEventResult.handled;
}
// AppleTV back (Siri Remote Menu via engine-synthesized escape): run onBack
// on KeyDown only; consume KeyUp silently. Some engine paths report Menu as
// a non-keyboard device, but the same down-only handling is still required.
// The suppressor-based "arm-on-KeyDown, clear-on-KeyUp" pattern leaks here
// because onBack typically calls Navigator.pop, swapping the focus tree
// before the matching KeyUp is dispatched — the orphaned KeyUp then never
// reaches a consumeIfSuppressed call, pinning the suppressor armed and
// silently swallowing the next press's KeyDown.
if (PlatformDetector.isAppleTV()) {
if (event is KeyDownEvent) {
BackKeyCoordinator.markHandled();
onBack();
}
return KeyEventResult.handled;
}
if (event is KeyUpEvent) {
BackKeyCoordinator.markHandled();
onBack();
return KeyEventResult.handled;
}
if (event is KeyDownEvent || event is KeyRepeatEvent) {
return KeyEventResult.handled;
}
return KeyEventResult.ignored;
}
KeyEventResult handleBackKeyNavigation<T>(BuildContext context, KeyEvent event, {T? result}) {
// Let system handle back if there's nothing to pop (e.g. root screen)
if (!Navigator.canPop(context)) {
return KeyEventResult.ignored;
}
// Don't handle back when a dialog/overlay is on top of our route —
// the overlay handles its own dismissal via DismissAction.
if (ModalRoute.of(context)?.isCurrent != true) {
return KeyEventResult.ignored;
}
// Handle on KeyUpEvent to prevent double-pop when returning from child screens
// (KeyDownEvent can be received by both the popping screen and the returned-to screen)
return handleBackKeyAction(event, () => Navigator.pop(context, result));
}
/// Consumes all select-key events (down, repeat, up) so they don't reach
/// platform-level handlers; fires [onActivate] on the initial KeyDown only.
KeyEventResult handleOneShotSelect(KeyEvent event, VoidCallback onActivate) {
if (!event.logicalKey.isSelectKey) return KeyEventResult.ignored;
if (event is KeyDownEvent) onActivate();
return KeyEventResult.handled;
}
/// Whether the primary focus currently belongs to an active text editor.
///
/// Ancestor key handlers use this to stay off keys a focused field owns.
/// Flutter dispatches a key event from the focused node upwards, and the
/// editing shortcuts that turn Backspace into a deletion live in
/// [DefaultTextEditingShortcuts] at the very top of the app — *above* any
/// screen. An ancestor that claims Backspace as "back" therefore both steals
/// the navigation and stops the character from ever being deleted (#1741).
///
/// [EditableText] builds its [Focus] internally, so the focused node's context
/// resolves to the owning [EditableTextState].
bool isTextEditingFocused() {
final context = FocusManager.instance.primaryFocus?.context;
if (context == null) return false;
return context.findAncestorStateOfType<EditableTextState>() != null;
}
/// Expands a UTF-16 [range] to whole extended grapheme clusters in [text].
///
/// Flutter selections use UTF-16 code-unit offsets. Custom editors must pass a
/// non-empty range containing the code units they intend to replace; the
/// returned range is normalized, clamped, and safe to use with
/// [String.replaceRange] without splitting a user-perceived character.
TextRange expandToGraphemeRange(String text, TextRange range) {
if (text.isEmpty) return TextRange.empty;
final first = range.start.clamp(0, text.length);
final second = range.end.clamp(0, text.length);
final start = first <= second ? first : second;
final end = first <= second ? second : first;
if (start == end) return TextRange.collapsed(start);
final boundary = CharacterBoundary(text);
return TextRange(
start: boundary.getLeadingTextBoundaryAt(start) ?? 0,
end: boundary.getTrailingTextBoundaryAt(end - 1) ?? text.length,
);
}
/// Creates a [FocusOnKeyEventCallback] that dispatches d-pad / arrow keys to
/// the provided directional callbacks.
///
/// Each callback is optional. Directions without a callback are ignored
/// (passed through to the framework). Directions mapped to a callback
/// automatically return [KeyEventResult.handled].
///
/// When [trapHorizontalEdges] is true, LEFT/RIGHT with no callback return
/// [KeyEventResult.handled] (consumed) instead of being passed through. Use
/// this for a self-contained horizontal group (e.g. a button row) so D-pad
/// can't escape off the edge into an off-screen "black hole" (#1181); wire an
/// explicit [onLeft]/[onRight] only where edge-escape into another region is
/// intended. UP/DOWN are unaffected and always pass through when unmapped.
///
/// Directional keys repeat on [KeyRepeatEvent] (via [isActionable]).
/// Select is one-shot: fires on [KeyDownEvent] only, consumes repeat and up.
///
/// ```dart
/// Focus(
/// onKeyEvent: dpadKeyHandler(
/// onUp: () => _focusAppBar(),
/// onDown: () => _focusContent(),
/// onLeft: () => _navigateToSidebar(),
/// onSelect: () => _play(),
/// ),
/// child: ...
/// )
/// ```
FocusOnKeyEventCallback dpadKeyHandler({
VoidCallback? onUp,
VoidCallback? onDown,
VoidCallback? onLeft,
VoidCallback? onRight,
VoidCallback? onSelect,
bool trapHorizontalEdges = false,
}) {
return (FocusNode _, KeyEvent event) {
// Select: one-shot activation (no repeat), must run before isActionable
// filter so KeyUpEvent is also consumed.
if (onSelect != null) {
final result = handleOneShotSelect(event, onSelect);
if (result != KeyEventResult.ignored) return result;
}
if (!event.isActionable) return KeyEventResult.ignored;
final key = event.logicalKey;
if (key.isUpKey && onUp != null) {
onUp();
return KeyEventResult.handled;
}
if (key.isDownKey && onDown != null) {
onDown();
return KeyEventResult.handled;
}
if (key.isLeftKey) {
if (onLeft != null) {
onLeft();
return KeyEventResult.handled;
}
if (trapHorizontalEdges) return KeyEventResult.handled;
}
if (key.isRightKey) {
if (onRight != null) {
onRight();
return KeyEventResult.handled;
}
if (trapHorizontalEdges) return KeyEventResult.handled;
}
return KeyEventResult.ignored;
};
}
/// Navigator observer that automatically suppresses stray back KeyUp events
/// after any route pop caused by a back key press.
///
/// This catches pops triggered by Flutter's built-in DismissAction (which fires
/// on KeyDown for dialogs) and Android TV system back gestures, preventing the
/// orphaned KeyUp from propagating to the underlying screen's back handler.
class BackKeySuppressorObserver extends NavigatorObserver {
@override
void didPop(Route route, Route? previousRoute) {
// On AppleTV, handleBackKeyAction consumes the KeyUp silently regardless,
// so the suppressor isn't needed and arming it would pin state across the
// pop's focus-tree swap. (The atomic engine fix delivers KeyDown+KeyUp in
// a single recognizer Began callback, so didPop fires squarely inside the
// window where BackKeyPressTracker.isBackKeyDown is true.)
if (PlatformDetector.isAppleTV()) return;
if (BackKeyPressTracker.isBackKeyDown) {
BackKeyUpSuppressor.suppressBackUntilKeyUp();
}
}
}