Files
plezy/lib/focus/focusable_text_field.dart
T

1133 lines
38 KiB
Dart

import 'dart:async';
import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
import '../services/gamepad_service.dart';
import '../utils/platform_detector.dart';
import '../utils/text_input_diagnostics.dart';
import '../widgets/tv_virtual_keyboard.dart';
import 'dpad_navigator.dart';
import 'owned_focus_node_binding.dart';
bool _usesTvKeyboard(bool enableTvKeyboard) => enableTvKeyboard && PlatformDetector.isTV();
String? _keyboardHint(InputDecoration? decoration) => decoration?.hintText ?? decoration?.labelText;
enum TvKeyboardAutoOpenBehavior {
/// Open the TV virtual keyboard whenever the field receives focus.
onFocus,
/// Keep initial focus on the field without opening the keyboard, then open
/// automatically on later focus entries. Explicit tap/select still opens it.
afterFirstFocus,
/// Never auto-open the TV virtual keyboard on focus. Explicit tap/select
/// still opens it.
never,
}
/// Imperative handle to the TV on-screen keyboard of a [FocusableTextField] /
/// [FocusableTextFormField]. Pass the same instance to the field's
/// `tvKeyboardController`; the field's host attaches itself on mount.
///
/// Only meaningful on TV. On other platforms the OSK never exists, so every
/// method is an effective no-op.
class TvKeyboardController {
_FocusableTextInputHostState? _host;
void _attach(_FocusableTextInputHostState host) => _host = host;
void _detach(_FocusableTextInputHostState host) {
if (identical(_host, host)) _host = null;
}
/// Dismiss the OSK if it is open (and prevent it from auto-reopening while
/// the field keeps focus). No-op when nothing is open.
void closeKeyboard() => _host?._dismissTvKeyboard();
/// Focus the field but do NOT open the OSK for this focus entry. Used as a
/// fallback landing spot (e.g. a remote search that returned no results) so
/// the remote isn't stranded on an off-screen element.
void focusInputWithoutKeyboard() => _host?._focusWithoutKeyboard();
}
String _describeTextInputKey(KeyEvent event) {
return 'type=${event.runtimeType} logical=${event.logicalKey.keyLabel}/${event.logicalKey.keyId} '
'physical=${event.physicalKey.usbHidUsage} deviceType=${event.deviceType} character=${event.character}';
}
void _logTvTextInput(String message) {
TextInputDiagnostics.log('FlutterTextField', message);
}
class _NativeTvTextInputFocusBridge {
static const _channel = MethodChannel('com.plezy/text_input');
static final Set<Object> _focusedTokens = <Object>{};
static bool _lastSentFocused = false;
static void setFocused(Object token, bool focused) {
_logTvTextInput(
'NativeFocusBridge.setFocused requested focused=$focused token=$token activeTokens=${_focusedTokens.length}',
);
if (focused) {
_focusedTokens.add(token);
} else {
_focusedTokens.remove(token);
}
if (!PlatformDetector.isTV() || PlatformDetector.isAppleTV()) {
_logTvTextInput(
'NativeFocusBridge clearing without platform send isTv=${PlatformDetector.isTV()} '
'isAppleTV=${PlatformDetector.isAppleTV()}',
);
_focusedTokens.clear();
_lastSentFocused = false;
return;
}
final anyFocused = _focusedTokens.isNotEmpty;
if (_lastSentFocused == anyFocused) {
_logTvTextInput('NativeFocusBridge no-op anyFocused=$anyFocused tokenCount=${_focusedTokens.length}');
return;
}
_lastSentFocused = anyFocused;
_logTvTextInput('NativeFocusBridge sending anyFocused=$anyFocused tokenCount=${_focusedTokens.length}');
unawaited(GamepadService.setNativeTextInputFocused(anyFocused));
unawaited(_sendFocused(anyFocused));
}
static Future<void> _sendFocused(bool focused) async {
try {
await _channel.invokeMethod<void>('setNativeTextInputFocused', focused);
_logTvTextInput('NativeFocusBridge platform send complete focused=$focused');
} on MissingPluginException {
_logTvTextInput('NativeFocusBridge platform send missing plugin focused=$focused');
// Tests and non-Android embedders do not register this channel.
} on PlatformException {
_logTvTextInput('NativeFocusBridge platform send failed focused=$focused');
// Focus reporting is a best-effort native routing hint.
}
}
}
KeyEventResult _handleInputKey({
required TextEditingController controller,
required FocusNode node,
required bool usesTvKeyboard,
required bool enabled,
required VoidCallback openKeyboard,
required KeyEvent event,
TextInputType? keyboardType,
TextInputAction? textInputAction,
List<TextInputFormatter>? inputFormatters,
ValueChanged<String>? onChanged,
ValueChanged<String>? onSubmitted,
VoidCallback? onEditingComplete,
int? maxLength,
int? maxLines,
VoidCallback? onSelect,
VoidCallback? onBack,
VoidCallback? onNavigateLeft,
VoidCallback? onNavigateRight,
VoidCallback? onNavigateUp,
VoidCallback? onNavigateDown,
}) {
final key = event.logicalKey;
final diagnosticsEnabled = TextInputDiagnostics.enabled;
KeyEventResult finish(KeyEventResult result, String reason) {
if (diagnosticsEnabled) {
_logTvTextInput(
'result=$result reason=$reason key=(${_describeTextInputKey(event)}) '
'usesTvKeyboard=$usesTvKeyboard enabled=$enabled textLength=${controller.text.length} '
'selection=${controller.selection} onNav(up=${onNavigateUp != null},down=${onNavigateDown != null},'
'left=${onNavigateLeft != null},right=${onNavigateRight != null}) onSelect=${onSelect != null} onBack=${onBack != null}',
);
}
return result;
}
if (diagnosticsEnabled) {
_logTvTextInput(
'received key=(${_describeTextInputKey(event)}) usesTvKeyboard=$usesTvKeyboard enabled=$enabled '
'textLength=${controller.text.length} selection=${controller.selection}',
);
}
if (_shouldPassNativeTvKeyToPlatform(usesTvKeyboard: usesTvKeyboard, enabled: enabled, event: event)) {
return finish(KeyEventResult.skipRemainingHandlers, 'pass-native-tv-key-to-platform');
}
if (usesTvKeyboard && enabled && event.isTvSelectEvent) {
if (event is KeyDownEvent) openKeyboard();
return finish(KeyEventResult.handled, 'open-custom-tv-keyboard');
}
if (usesTvKeyboard && enabled && event.isPhysicalKeyboardEvent) {
final result = _handleTvHardwareKeyboardKey(
controller: controller,
keyboardType: keyboardType,
textInputAction: textInputAction,
inputFormatters: inputFormatters,
onChanged: onChanged,
onSubmitted: onSubmitted,
onEditingComplete: onEditingComplete,
maxLength: maxLength,
maxLines: maxLines,
event: event,
);
if (result != KeyEventResult.ignored) return finish(result, 'custom-tv-hardware-keyboard');
}
if (onBack != null && key.isBackKey) {
if (event is KeyDownEvent) onBack();
return finish(KeyEventResult.handled, 'onBack');
}
// Enter/numpad enter are left to TextField.onSubmitted. Handle only
// non-text submit keys that TV remotes/gamepads may send while editing.
if (!usesTvKeyboard &&
onSelect != null &&
(key == LogicalKeyboardKey.select || key == LogicalKeyboardKey.gameButtonA)) {
if (event is KeyDownEvent) onSelect();
return finish(KeyEventResult.handled, 'native-tv-non-text-select');
}
if (!event.isActionable) return finish(KeyEventResult.ignored, 'non-actionable');
final isMultiline = _isMultilineTextInput(keyboardType: keyboardType, maxLines: maxLines);
// Directional escape: an explicit callback always wins. Otherwise, fall back
// to the framework's geometry-based directional traversal so an un-wired
// field moves to its nearest neighbour instead of dead-ending (the field's
// own onKeyEvent runs before EditableText, which would otherwise swallow the
// arrow). Single-line only for UP/DOWN — multiline falls through so
// EditableText can move the caret between lines.
if (key.isUpKey) {
if (onNavigateUp != null) {
onNavigateUp();
return finish(KeyEventResult.handled, 'onNavigateUp');
}
if (!isMultiline) {
final moved = node.focusInDirection(TraversalDirection.up);
return finish(moved ? KeyEventResult.handled : KeyEventResult.ignored, 'focusInDirection-up');
}
}
if (key.isDownKey) {
if (onNavigateDown != null) {
onNavigateDown();
return finish(KeyEventResult.handled, 'onNavigateDown');
}
if (!isMultiline) {
final moved = node.focusInDirection(TraversalDirection.down);
return finish(moved ? KeyEventResult.handled : KeyEventResult.ignored, 'focusInDirection-down');
}
}
final sel = controller.selection;
if (sel.isCollapsed) {
if (key.isLeftKey && sel.baseOffset == 0) {
if (onNavigateLeft != null) {
onNavigateLeft();
return finish(KeyEventResult.handled, 'onNavigateLeft-at-start');
}
final moved = node.focusInDirection(TraversalDirection.left);
return finish(moved ? KeyEventResult.handled : KeyEventResult.ignored, 'focusInDirection-left-at-start');
}
if (key.isRightKey && sel.baseOffset == controller.text.length) {
if (onNavigateRight != null) {
onNavigateRight();
return finish(KeyEventResult.handled, 'onNavigateRight-at-end');
}
final moved = node.focusInDirection(TraversalDirection.right);
return finish(moved ? KeyEventResult.handled : KeyEventResult.ignored, 'focusInDirection-right-at-end');
}
}
return finish(KeyEventResult.ignored, 'fall-through');
}
bool _shouldPassNativeTvKeyToPlatform({required bool usesTvKeyboard, required bool enabled, required KeyEvent event}) {
if (!enabled || usesTvKeyboard || !PlatformDetector.isTV()) {
if (TextInputDiagnostics.enabled) {
_logTvTextInput(
'native-pass=false reason=disabled-or-custom-keyboard enabled=$enabled usesTvKeyboard=$usesTvKeyboard '
'isTv=${PlatformDetector.isTV()} key=(${_describeTextInputKey(event)})',
);
}
return false;
}
// Android TV provides its own IME. Remote keys must reach the platform so
// users can move around that keyboard instead of escaping the app field.
// Some remotes (Chromecast) are reported by Flutter as keyboard events, so
// native TV navigation cannot rely on deviceType.
final key = event.logicalKey;
final shouldPass = key.isDpadDirection || key.isBackKey || event.isTvSelectEvent;
if (TextInputDiagnostics.enabled) {
_logTvTextInput(
'native-pass=$shouldPass reason=${shouldPass ? "remote-navigation-key" : "not-navigation-key"} '
'key=(${_describeTextInputKey(event)})',
);
}
return shouldPass;
}
KeyEventResult _handleTvHardwareKeyboardKey({
required TextEditingController controller,
required KeyEvent event,
TextInputType? keyboardType,
TextInputAction? textInputAction,
List<TextInputFormatter>? inputFormatters,
ValueChanged<String>? onChanged,
ValueChanged<String>? onSubmitted,
VoidCallback? onEditingComplete,
int? maxLength,
int? maxLines,
}) {
final key = event.logicalKey;
if (event.isPhysicalKeyboardEnter) {
if (event is KeyDownEvent) {
if (_isMultilineTextInput(keyboardType: keyboardType, maxLines: maxLines)) {
_insertText(
controller: controller,
text: '\n',
inputFormatters: inputFormatters,
maxLength: maxLength,
onChanged: onChanged,
);
} else {
_submitTextInput(
controller: controller,
textInputAction: textInputAction,
onSubmitted: onSubmitted,
onEditingComplete: onEditingComplete,
);
}
}
return KeyEventResult.handled;
}
if (!event.isActionable) return KeyEventResult.ignored;
if (key == LogicalKeyboardKey.backspace) {
_backspace(controller: controller, inputFormatters: inputFormatters, maxLength: maxLength, onChanged: onChanged);
return KeyEventResult.handled;
}
if (key == LogicalKeyboardKey.delete) {
_deleteForward(
controller: controller,
inputFormatters: inputFormatters,
maxLength: maxLength,
onChanged: onChanged,
);
return KeyEventResult.handled;
}
if (key.isLeftKey || key.isRightKey) {
return _moveCaretHorizontally(controller, key.isLeftKey ? -1 : 1);
}
final character = event.character;
if (character != null && character.isNotEmpty && !key.isNavigationKey && !_isControlCharacter(character)) {
_insertText(
controller: controller,
text: character,
inputFormatters: inputFormatters,
maxLength: maxLength,
onChanged: onChanged,
);
return KeyEventResult.handled;
}
return KeyEventResult.ignored;
}
bool _isMultilineTextInput({TextInputType? keyboardType, int? maxLines}) {
return keyboardType?.index == TextInputType.multiline.index || (maxLines != null && maxLines != 1);
}
bool _isControlCharacter(String text) {
return text.runes.every((codeUnit) => codeUnit < 0x20 || codeUnit == 0x7f);
}
KeyEventResult _moveCaretHorizontally(TextEditingController controller, int delta) {
final value = controller.value;
final selection = value.selection;
if (!selection.isValid) {
controller.selection = TextSelection.collapsed(offset: value.text.length);
return KeyEventResult.handled;
}
if (!selection.isCollapsed) {
final offset = delta < 0
? (selection.start < selection.end ? selection.start : selection.end)
: (selection.start > selection.end ? selection.start : selection.end);
controller.selection = TextSelection.collapsed(offset: offset);
return KeyEventResult.handled;
}
final nextOffset = selection.extentOffset + delta;
if (nextOffset < 0 || nextOffset > value.text.length) return KeyEventResult.ignored;
controller.selection = TextSelection.collapsed(offset: nextOffset);
return KeyEventResult.handled;
}
void _submitTextInput({
required TextEditingController controller,
required TextInputAction? textInputAction,
ValueChanged<String>? onSubmitted,
VoidCallback? onEditingComplete,
}) {
if (onEditingComplete != null) {
onEditingComplete();
} else {
_defaultEditingComplete(textInputAction);
}
onSubmitted?.call(controller.text);
}
void _defaultEditingComplete(TextInputAction? textInputAction) {
final focus = FocusManager.instance.primaryFocus;
switch (textInputAction) {
case TextInputAction.next:
focus?.nextFocus();
case TextInputAction.previous:
focus?.previousFocus();
default:
focus?.unfocus();
}
}
({int start, int end}) _normalizedSelectionRange(TextEditingValue value) {
final selection = value.selection;
if (!selection.isValid) {
return (start: value.text.length, end: value.text.length);
}
return (start: selection.start.clamp(0, value.text.length), end: selection.end.clamp(0, value.text.length));
}
void _insertText({
required TextEditingController controller,
required String text,
List<TextInputFormatter>? inputFormatters,
int? maxLength,
ValueChanged<String>? onChanged,
}) {
final value = controller.value;
final range = _normalizedSelectionRange(value);
final start = range.start;
final end = range.end;
final newText = value.text.replaceRange(start, end, text);
_replaceTextValue(
controller: controller,
nextValue: value.copyWith(
text: newText,
selection: TextSelection.collapsed(offset: start + text.length),
composing: TextRange.empty,
),
inputFormatters: inputFormatters,
maxLength: maxLength,
onChanged: onChanged,
);
}
void _backspace({
required TextEditingController controller,
List<TextInputFormatter>? inputFormatters,
int? maxLength,
ValueChanged<String>? onChanged,
}) {
final value = controller.value;
final range = _normalizedSelectionRange(value);
final start = range.start;
final end = range.end;
if (start != end) {
_replaceTextRange(
controller,
start,
end,
inputFormatters: inputFormatters,
maxLength: maxLength,
onChanged: onChanged,
);
return;
}
if (start == 0) return;
_replaceTextRange(
controller,
start - 1,
start,
inputFormatters: inputFormatters,
maxLength: maxLength,
onChanged: onChanged,
);
}
void _deleteForward({
required TextEditingController controller,
List<TextInputFormatter>? inputFormatters,
int? maxLength,
ValueChanged<String>? onChanged,
}) {
final value = controller.value;
final range = _normalizedSelectionRange(value);
final start = range.start;
final end = range.end;
if (start != end) {
_replaceTextRange(
controller,
start,
end,
inputFormatters: inputFormatters,
maxLength: maxLength,
onChanged: onChanged,
);
return;
}
if (start >= value.text.length) return;
_replaceTextRange(
controller,
start,
start + 1,
inputFormatters: inputFormatters,
maxLength: maxLength,
onChanged: onChanged,
);
}
void _replaceTextRange(
TextEditingController controller,
int start,
int end, {
List<TextInputFormatter>? inputFormatters,
int? maxLength,
ValueChanged<String>? onChanged,
}) {
final value = controller.value;
_replaceTextValue(
controller: controller,
nextValue: value.copyWith(
text: value.text.replaceRange(start, end, ''),
selection: TextSelection.collapsed(offset: start),
composing: TextRange.empty,
),
inputFormatters: inputFormatters,
maxLength: maxLength,
onChanged: onChanged,
);
}
void _replaceTextValue({
required TextEditingController controller,
required TextEditingValue nextValue,
List<TextInputFormatter>? inputFormatters,
int? maxLength,
ValueChanged<String>? onChanged,
}) {
final previousValue = controller.value;
var formattedValue = nextValue;
final formatters = [
...?inputFormatters,
if (maxLength != null && maxLength > 0) LengthLimitingTextInputFormatter(maxLength),
];
for (final formatter in formatters) {
formattedValue = formatter.formatEditUpdate(previousValue, formattedValue);
}
controller.value = formattedValue;
if (formattedValue.text != previousValue.text) {
onChanged?.call(formattedValue.text);
}
}
abstract class _FocusableTextInputBase extends StatelessWidget {
final TextEditingController controller;
final FocusNode? focusNode;
final InputDecoration? decoration;
final TextInputType? keyboardType;
final TextInputAction? textInputAction;
final List<TextInputFormatter>? inputFormatters;
final ValueChanged<String>? onChanged;
final ValueChanged<String>? onSubmitted;
final VoidCallback? onEditingComplete;
final VoidCallback? onSelect;
final VoidCallback? onBack;
final bool autofocus;
final bool enabled;
final bool enableTvKeyboard;
final TvKeyboardAutoOpenBehavior tvKeyboardAutoOpenBehavior;
final TvKeyboardController? tvKeyboardController;
final bool obscureText;
final bool autocorrect;
final bool enableSuggestions;
final bool? enableInteractiveSelection;
final int? maxLength;
final int? maxLines;
final int? minLines;
final TextAlign textAlign;
final TextCapitalization textCapitalization;
final TextStyle? style;
final VoidCallback? onNavigateLeft;
final VoidCallback? onNavigateRight;
final VoidCallback? onNavigateUp;
final VoidCallback? onNavigateDown;
const _FocusableTextInputBase({
super.key,
required this.controller,
this.focusNode,
this.decoration,
this.keyboardType,
this.textInputAction,
this.inputFormatters,
this.onChanged,
this.onSubmitted,
this.onEditingComplete,
this.onSelect,
this.onBack,
this.autofocus = false,
this.enabled = true,
this.enableTvKeyboard = true,
this.tvKeyboardAutoOpenBehavior = TvKeyboardAutoOpenBehavior.onFocus,
this.tvKeyboardController,
this.obscureText = false,
this.autocorrect = true,
this.enableSuggestions = true,
this.enableInteractiveSelection,
this.maxLength,
this.maxLines = 1,
this.minLines,
this.textAlign = TextAlign.start,
this.textCapitalization = TextCapitalization.none,
this.style,
this.onNavigateLeft,
this.onNavigateRight,
this.onNavigateUp,
this.onNavigateDown,
});
bool get _hasTvKeyboard => _usesTvKeyboard(enableTvKeyboard);
bool get _usesNativeTvKeyboard => PlatformDetector.isTV() && !_hasTvKeyboard;
VoidCallback? get _effectiveOnEditingComplete {
if (onEditingComplete != null) return onEditingComplete;
if (_usesNativeTvKeyboard && onSubmitted == null) return _handleTvKeyboardAction;
return null;
}
void _handleTvKeyboardAction() {
if (onEditingComplete != null) {
onEditingComplete!();
} else if (onSelect != null) {
onSelect!();
} else if (onNavigateDown != null) {
onNavigateDown!();
} else {
_defaultEditingComplete(textInputAction);
}
}
({
TextInputType? keyboardType,
bool readOnly,
bool? showCursor,
bool? enableInteractiveSelection,
VoidCallback? onTap,
})
_tvInputConfiguration(bool usesTvKeyboard, VoidCallback openKeyboard) {
return (
keyboardType: usesTvKeyboard ? TextInputType.none : keyboardType,
readOnly: usesTvKeyboard,
showCursor: usesTvKeyboard ? true : null,
enableInteractiveSelection: usesTvKeyboard ? false : enableInteractiveSelection,
onTap: usesTvKeyboard ? openKeyboard : null,
);
}
KeyEventResult _handleKey(BuildContext context, FocusNode node, KeyEvent event, VoidCallback openKeyboard) {
return _handleInputKey(
controller: controller,
node: node,
usesTvKeyboard: _hasTvKeyboard,
enabled: enabled,
openKeyboard: openKeyboard,
event: event,
keyboardType: keyboardType,
textInputAction: textInputAction,
inputFormatters: inputFormatters,
onChanged: onChanged,
onSubmitted: onSubmitted,
onEditingComplete: onEditingComplete,
maxLength: maxLength,
maxLines: maxLines,
onSelect: onSelect,
onBack: onBack,
onNavigateLeft: onNavigateLeft,
onNavigateRight: onNavigateRight,
onNavigateUp: onNavigateUp,
onNavigateDown: onNavigateDown,
);
}
Widget buildFocusableInput(
Widget Function(bool usesTvKeyboard, FocusNode focusNode, VoidCallback openKeyboard) builder,
) {
return _FocusableTextInputHost(input: this, builder: builder);
}
}
class _FocusableTextInputHost extends StatefulWidget {
final _FocusableTextInputBase input;
final Widget Function(bool usesTvKeyboard, FocusNode focusNode, VoidCallback openKeyboard) builder;
const _FocusableTextInputHost({required this.input, required this.builder});
@override
State<_FocusableTextInputHost> createState() => _FocusableTextInputHostState();
}
class _FocusableTextInputHostState extends State<_FocusableTextInputHost> {
final OwnedFocusNodeBinding _focusNodeBinding = OwnedFocusNodeBinding();
FocusNode? _installedFocusNode;
FocusOnKeyEventCallback? _previousOnKeyEvent;
late final FocusOnKeyEventCallback _keyHandler = _handleKey;
late final VoidCallback _focusListener = _handleFocusChanged;
final Object _nativeFocusToken = Object();
bool _reportedNativeTextInputFocused = false;
TvVirtualKeyboardHandle? _tvKeyboardHandle;
bool _tvKeyboardOpen = false;
bool _tvKeyboardOpenScheduled = false;
bool _suppressTvKeyboardAutoOpen = false;
bool _hasSeenTvKeyboardFocus = false;
bool _suppressTvKeyboardForCurrentFocus = false;
FocusNode get _effectiveFocusNode => _focusNodeBinding.node;
@override
void initState() {
super.initState();
_focusNodeBinding.bind(externalNode: widget.input.focusNode, debugLabel: 'FocusableTextInput');
widget.input.tvKeyboardController?._attach(this);
}
@override
void didUpdateWidget(_FocusableTextInputHost oldWidget) {
super.didUpdateWidget(oldWidget);
if (!identical(oldWidget.input.tvKeyboardController, widget.input.tvKeyboardController)) {
oldWidget.input.tvKeyboardController?._detach(this);
widget.input.tvKeyboardController?._attach(this);
}
if (oldWidget.input.focusNode != widget.input.focusNode) {
// An open keyboard dialog intentionally survives rebuilds and focusNode
// swaps; it is closed only when this host unmounts — see dispose.
_restoreInstalledHandler();
_focusNodeBinding.bind(externalNode: widget.input.focusNode, debugLabel: 'FocusableTextInput');
_suppressTvKeyboardAutoOpen = false;
_tvKeyboardOpenScheduled = false;
_hasSeenTvKeyboardFocus = false;
_suppressTvKeyboardForCurrentFocus = false;
}
_handleFocusChanged();
}
@override
void dispose() {
widget.input.tvKeyboardController?._detach(this);
_restoreInstalledHandler();
// The keyboard is a navigator route — it must not outlive the field that
// opened it (e.g. a form section swapped out while the keyboard is up).
// Navigator mutation is unsafe during tree finalization; defer a frame.
final keyboard = _tvKeyboardHandle;
if (keyboard != null) {
WidgetsBinding.instance.addPostFrameCallback((_) => keyboard.close());
}
_focusNodeBinding.dispose();
super.dispose();
}
void _handleFocusChanged() {
_syncNativeTextInputFocus();
_syncTvKeyboardAutoOpen();
}
void _syncNativeTextInputFocus() {
final focused = _installedFocusNode?.hasFocus == true && widget.input.enabled && widget.input._usesNativeTvKeyboard;
if (TextInputDiagnostics.enabled) {
_logTvTextInput(
'Host.syncNativeTextInputFocus focused=$focused installed=${_installedFocusNode?.debugLabel} '
'hasFocus=${_installedFocusNode?.hasFocus} enabled=${widget.input.enabled} '
'usesNativeTvKeyboard=${widget.input._usesNativeTvKeyboard}',
);
}
_setNativeTextInputFocused(focused);
}
void _syncTvKeyboardAutoOpen() {
final focused = _installedFocusNode?.hasFocus == true && widget.input.enabled && widget.input._hasTvKeyboard;
final visible = _canShowTvKeyboard;
if (TextInputDiagnostics.enabled) {
_logTvTextInput(
'Host.syncTvKeyboardAutoOpen focused=$focused open=$_tvKeyboardOpen scheduled=$_tvKeyboardOpenScheduled '
'suppressed=$_suppressTvKeyboardAutoOpen behavior=${widget.input.tvKeyboardAutoOpenBehavior} '
'seenFocus=$_hasSeenTvKeyboardFocus suppressCurrent=$_suppressTvKeyboardForCurrentFocus '
'installed=${_installedFocusNode?.debugLabel} '
'hasFocus=${_installedFocusNode?.hasFocus} enabled=${widget.input.enabled} '
'usesTvKeyboard=${widget.input._hasTvKeyboard} visible=$visible',
);
}
if (!focused) {
_suppressTvKeyboardForCurrentFocus = false;
if (!_tvKeyboardOpen && !_tvKeyboardOpenScheduled) {
_suppressTvKeyboardAutoOpen = false;
}
return;
}
if (!visible) return;
if (!_shouldAutoOpenTvKeyboardForCurrentFocus()) return;
if (_suppressTvKeyboardAutoOpen || _tvKeyboardOpen || _tvKeyboardOpenScheduled) return;
_tvKeyboardOpenScheduled = true;
WidgetsBinding.instance.addPostFrameCallback((_) {
if (!mounted) return;
_tvKeyboardOpenScheduled = false;
final stillFocused = _installedFocusNode?.hasFocus == true && widget.input.enabled && widget.input._hasTvKeyboard;
if (!stillFocused || !_canShowTvKeyboard || _suppressTvKeyboardAutoOpen || _tvKeyboardOpen) return;
_openTvKeyboard();
});
}
bool get _canShowTvKeyboard {
final route = ModalRoute.of(context);
return TickerMode.valuesOf(context).enabled && (route?.isCurrent ?? true);
}
bool _shouldAutoOpenTvKeyboardForCurrentFocus() {
switch (widget.input.tvKeyboardAutoOpenBehavior) {
case TvKeyboardAutoOpenBehavior.onFocus:
return true;
case TvKeyboardAutoOpenBehavior.afterFirstFocus:
if (!_hasSeenTvKeyboardFocus) {
_hasSeenTvKeyboardFocus = true;
_suppressTvKeyboardForCurrentFocus = true;
return false;
}
return !_suppressTvKeyboardForCurrentFocus;
case TvKeyboardAutoOpenBehavior.never:
return false;
}
}
void _openTvKeyboard() {
if (!mounted || !widget.input.enabled || !widget.input._hasTvKeyboard || !_canShowTvKeyboard || _tvKeyboardOpen) {
return;
}
_tvKeyboardOpenScheduled = false;
_tvKeyboardOpen = true;
_hasSeenTvKeyboardFocus = true;
_suppressTvKeyboardForCurrentFocus = false;
_suppressTvKeyboardAutoOpen = true;
_logTvTextInput('Host.openTvKeyboard node=${_installedFocusNode?.debugLabel}');
// The dialog outlives input rebuilds (e.g. a search field whose
// onNavigateDown appears once results arrive while the keyboard is up),
// so only static configuration may be snapshotted here — the callbacks
// must resolve against widget.input at invoke time.
final input = widget.input;
final keyboard = showTvVirtualKeyboard(
context: context,
controller: input.controller,
hintText: _keyboardHint(input.decoration),
keyboardType: input.keyboardType,
textInputAction: input.textInputAction,
inputFormatters: input.inputFormatters,
obscureText: input.obscureText,
maxLength: input.maxLength,
maxLines: input.maxLines,
onChanged: (text) {
if (!mounted) return;
widget.input.onChanged?.call(text);
},
onSubmitted: (text) {
if (!mounted) return;
final current = widget.input;
if (current.onSubmitted != null) {
current.onSubmitted!(text);
} else {
current._handleTvKeyboardAction();
}
},
);
if (keyboard == null) {
_tvKeyboardOpen = false;
return;
}
_tvKeyboardHandle = keyboard;
unawaited(
keyboard.closed.whenComplete(() {
_tvKeyboardHandle = null;
if (!mounted) return;
_tvKeyboardOpen = false;
WidgetsBinding.instance.addPostFrameCallback((_) {
if (!mounted) return;
if (_installedFocusNode?.hasFocus != true) {
_suppressTvKeyboardAutoOpen = false;
}
_syncTvKeyboardAutoOpen();
});
}),
);
}
/// Dismiss an open OSK imperatively (e.g. a companion-remote search that must
/// show results instead of the keyboard). Suppresses auto-reopen so a field
/// that regains focus when the dialog route pops does not relaunch it.
void _dismissTvKeyboard() {
// No-op when nothing is up: setting the suppress flag here (with no
// compensating unfocus to clear it) would block a later legitimate
// auto-open when the user deliberately focuses the field.
if (!_tvKeyboardOpen && !_tvKeyboardOpenScheduled) return;
_tvKeyboardOpenScheduled = false;
_suppressTvKeyboardAutoOpen = true;
_tvKeyboardHandle?.close();
}
/// Focus the field without opening the OSK for this focus entry. Suppress is
/// set BEFORE requestFocus so the resulting focus-change sync already sees it;
/// the flag persists for this focus and resets on the next unfocus. If a
/// same-turn focus request supersedes this one, clear the unused suppression
/// after Flutter resolves its pending focus change.
void _focusWithoutKeyboard() {
_suppressTvKeyboardAutoOpen = true;
_tvKeyboardOpenScheduled = false;
final focusNode = _installedFocusNode ?? _effectiveFocusNode;
focusNode.requestFocus();
scheduleMicrotask(() {
if (!mounted || focusNode.hasFocus || _tvKeyboardOpen || _tvKeyboardOpenScheduled) return;
_suppressTvKeyboardAutoOpen = false;
});
}
void _setNativeTextInputFocused(bool focused) {
if (_reportedNativeTextInputFocused == focused) {
_logTvTextInput('Host.setNativeTextInputFocused no-op focused=$focused');
return;
}
_logTvTextInput('Host.setNativeTextInputFocused old=$_reportedNativeTextInputFocused new=$focused');
_reportedNativeTextInputFocused = focused;
_NativeTvTextInputFocusBridge.setFocused(_nativeFocusToken, focused);
}
KeyEventResult _handleKey(FocusNode node, KeyEvent event) {
final previous = _previousOnKeyEvent;
if (previous != null && !identical(previous, _keyHandler)) {
final result = previous(node, event);
_logTvTextInput(
'Host.previousOnKeyEvent node=${node.debugLabel} result=$result key=(${_describeTextInputKey(event)})',
);
if (result != KeyEventResult.ignored) return result;
}
return widget.input._handleKey(context, node, event, _openTvKeyboard);
}
void _installKeyHandler(FocusNode node) {
// Handle D-pad escapes on the field's own node so EditableText shortcuts
// can't consume directions before our reusable navigation callbacks run.
if (_installedFocusNode == node) {
if (identical(node.onKeyEvent, _keyHandler)) return;
_previousOnKeyEvent = node.onKeyEvent;
node.onKeyEvent = _keyHandler;
_logTvTextInput('Host.reinstalled key handler node=${node.debugLabel} previous=${_previousOnKeyEvent != null}');
return;
}
_restoreInstalledHandler();
_installedFocusNode = node;
_previousOnKeyEvent = node.onKeyEvent;
node.onKeyEvent = _keyHandler;
node.addListener(_focusListener);
_logTvTextInput('Host.installed key handler node=${node.debugLabel} previous=${_previousOnKeyEvent != null}');
}
void _restoreInstalledHandler() {
_logTvTextInput('Host.restoreInstalledHandler node=${_installedFocusNode?.debugLabel}');
_setNativeTextInputFocused(false);
final node = _installedFocusNode;
if (node != null) {
node.removeListener(_focusListener);
if (identical(node.onKeyEvent, _keyHandler)) {
node.onKeyEvent = _previousOnKeyEvent;
}
}
_installedFocusNode = null;
_previousOnKeyEvent = null;
}
@override
Widget build(BuildContext context) {
final focusNode = _effectiveFocusNode;
_installKeyHandler(focusNode);
_handleFocusChanged();
return widget.builder(widget.input._hasTvKeyboard, focusNode, _openTvKeyboard);
}
}
/// A [TextField] wrapper that exposes D-pad navigation callbacks with
/// caret-aware edge escapes — so LEFT at the start of the field and RIGHT
/// at the end escape to neighbouring focus targets instead of bouncing
/// against the caret boundary, while UP/DOWN always escape.
///
/// Collapsed selection only: if text is selected, LEFT/RIGHT fall through
/// to the TextField's default caret movement.
class FocusableTextField extends _FocusableTextInputBase {
const FocusableTextField({
super.key,
required super.controller,
super.focusNode,
super.decoration,
super.keyboardType,
super.textInputAction,
super.inputFormatters,
super.onChanged,
super.onSubmitted,
super.onEditingComplete,
super.onSelect,
super.onBack,
super.autofocus,
super.enabled,
super.enableTvKeyboard,
super.tvKeyboardAutoOpenBehavior,
super.tvKeyboardController,
super.obscureText,
super.autocorrect,
super.enableSuggestions,
super.enableInteractiveSelection,
super.maxLength,
super.maxLines,
super.minLines,
super.textAlign,
super.textCapitalization,
super.style,
super.onNavigateLeft,
super.onNavigateRight,
super.onNavigateUp,
super.onNavigateDown,
});
@override
Widget build(BuildContext context) {
return buildFocusableInput((usesTvKeyboard, effectiveFocusNode, openKeyboard) {
final tvInput = _tvInputConfiguration(usesTvKeyboard, openKeyboard);
return TextField(
controller: controller,
focusNode: effectiveFocusNode,
enabled: enabled,
decoration: decoration,
keyboardType: tvInput.keyboardType,
textInputAction: textInputAction,
inputFormatters: inputFormatters,
onChanged: onChanged,
onSubmitted: onSubmitted,
onEditingComplete: _effectiveOnEditingComplete,
autofocus: autofocus,
autocorrect: autocorrect,
enableSuggestions: enableSuggestions,
obscureText: obscureText,
maxLength: maxLength,
maxLines: maxLines,
minLines: minLines,
textAlign: textAlign,
textCapitalization: textCapitalization,
style: style,
readOnly: tvInput.readOnly,
showCursor: tvInput.showCursor,
enableInteractiveSelection: tvInput.enableInteractiveSelection,
onTap: tvInput.onTap,
);
});
}
}
class FocusableTextFormField extends _FocusableTextInputBase {
final ValueChanged<String>? onFieldSubmitted;
final FormFieldValidator<String>? validator;
final AutovalidateMode? autovalidateMode;
final FormFieldSetter<String>? onSaved;
const FocusableTextFormField({
super.key,
required super.controller,
super.focusNode,
super.decoration,
super.keyboardType,
super.textInputAction,
super.inputFormatters,
super.onChanged,
this.onFieldSubmitted,
super.onEditingComplete,
super.onSelect,
super.onBack,
this.validator,
this.autovalidateMode,
this.onSaved,
super.autofocus,
super.enabled,
super.enableTvKeyboard,
super.tvKeyboardAutoOpenBehavior,
super.tvKeyboardController,
super.obscureText,
super.autocorrect,
super.enableSuggestions,
super.enableInteractiveSelection,
super.maxLength,
super.maxLines,
super.minLines,
super.textAlign,
super.textCapitalization,
super.style,
super.onNavigateLeft,
super.onNavigateRight,
super.onNavigateUp,
super.onNavigateDown,
}) : super(onSubmitted: onFieldSubmitted);
@override
Widget build(BuildContext context) {
return buildFocusableInput((usesTvKeyboard, effectiveFocusNode, openKeyboard) {
final tvInput = _tvInputConfiguration(usesTvKeyboard, openKeyboard);
return TextFormField(
controller: controller,
focusNode: effectiveFocusNode,
enabled: enabled,
decoration: decoration,
keyboardType: tvInput.keyboardType,
textInputAction: textInputAction,
inputFormatters: inputFormatters,
onChanged: onChanged,
onFieldSubmitted: onFieldSubmitted,
onEditingComplete: _effectiveOnEditingComplete,
validator: validator,
autovalidateMode: autovalidateMode,
onSaved: onSaved,
autofocus: autofocus,
autocorrect: autocorrect,
enableSuggestions: enableSuggestions,
obscureText: obscureText,
maxLength: maxLength,
maxLines: maxLines,
minLines: minLines,
textAlign: textAlign,
textCapitalization: textCapitalization,
style: style,
readOnly: tvInput.readOnly,
showCursor: tvInput.showCursor,
enableInteractiveSelection: tvInput.enableInteractiveSelection,
onTap: tvInput.onTap,
);
});
}
}