import 'package:flutter/material.dart'; import 'package:flutter/services.dart'; import 'package:flutter/rendering.dart'; import '../widgets/clickable_cursor.dart'; import '../utils/text_input_diagnostics.dart'; import 'card_focus_scope.dart'; import 'dpad_navigator.dart'; import 'dpad_select_long_press_controller.dart'; import 'focus_glow_overlay.dart'; import 'focus_theme.dart'; import 'input_mode_tracker.dart'; import 'owned_focus_node_binding.dart'; import 'key_event_utils.dart'; String _describeFocusableKey(KeyEvent event) { return 'type=${event.runtimeType} logical=${event.logicalKey.keyLabel}/${event.logicalKey.keyId} ' 'physical=${event.physicalKey.usbHidUsage} deviceType=${event.deviceType} character=${event.character}'; } void _logFocusableWrapper(String message) { TextInputDiagnostics.log('FocusableWrapper', message); } /// Applies a visual scale without changing hit-test or semantics geometry. /// /// Focus scale is paint-only: animating a [Transform] marks the transformed /// subtree's semantics dirty on every frame, which is costly for dense TV /// grids. Keeping layout and semantics static preserves the same visible /// motion without rebuilding the accessibility tree. class _PaintScale extends SingleChildRenderObjectWidget { const _PaintScale({required this.scale, required super.child}); final double scale; @override RenderObject createRenderObject(BuildContext context) => _RenderPaintScale(scale); @override void updateRenderObject(BuildContext context, _RenderPaintScale renderObject) { renderObject.scale = scale; } } class _RenderPaintScale extends RenderProxyBox { _RenderPaintScale(double scale) : _scale = scale; final Matrix4 _transform = Matrix4.identity(); double _scale; set scale(double value) { if (_scale == value) return; _scale = value; markNeedsPaint(); } @override void paint(PaintingContext context, Offset offset) { if (child == null) return; if (_scale == 1) { layer = null; super.paint(context, offset); return; } _transform ..setIdentity() ..setEntry(0, 0, _scale) ..setEntry(1, 1, _scale) ..setTranslationRaw((1 - _scale) * size.width / 2, (1 - _scale) * size.height / 2, 0); layer = context.pushTransform( needsCompositing, offset, _transform, super.paint, oldLayer: layer is TransformLayer ? layer as TransformLayer? : null, ); } } /// A wrapper widget that makes its child focusable with D-pad navigation support. /// /// Provides: /// - Visual focus indicator (border + scale animation) /// - Keyboard/D-pad event handling (Enter/Select to activate) /// - Optional auto-scroll to keep focused item visible /// - Long-press detection for SELECT key /// - Navigation callbacks (UP, BACK) class FocusableWrapper extends StatefulWidget { /// The child widget to wrap. final Widget child; /// Called when the item is selected (Enter/Select/GamepadA). /// For short press when [enableLongPress] is true. final VoidCallback? onSelect; /// Called when long press is triggered (hold SELECT key or context menu key). /// Only triggered if [enableLongPress] is true. final VoidCallback? onLongPress; /// Called when focus changes. final ValueChanged? onFocusChange; /// Called when the user presses UP and there's no focusable item above. final VoidCallback? onNavigateUp; /// Called when the user presses DOWN and there's no focusable item below. final VoidCallback? onNavigateDown; /// Called when the user presses LEFT and there's no focusable item to the left. final VoidCallback? onNavigateLeft; /// Called when the user presses RIGHT and there's no focusable item to the right. final VoidCallback? onNavigateRight; /// Called when the user presses BACK. final VoidCallback? onBack; /// Whether this widget should request focus when first built. final bool autofocus; /// Optional external FocusNode for programmatic focus control. final FocusNode? focusNode; /// Border radius for the focus indicator. final double borderRadius; /// Per-corner radii for the focus indicator; overrides [borderRadius] when /// set (M3E grouped cards: large outer / small inner corners). final BorderRadius? borderRadii; /// Whether to scroll the widget into view when focused. final bool autoScroll; /// Alignment for auto-scroll (0.0 = start, 0.5 = center, 1.0 = end). final double scrollAlignment; /// Whether to use comfortable zone scrolling (only scroll if item is outside middle 60%). /// If false, always scrolls to [scrollAlignment]. final bool useComfortableZone; /// Optional semantic label for accessibility. final String? semanticLabel; /// Optional current value announced after [semanticLabel]. final String? semanticValue; /// Whether this wrapper replaces semantics contributed by [child]. /// /// The default (`true`) replacement mode produces one operable control node for /// labeled wrappers. Set this to `false` only to supplement non-interactive /// child content; a child that already owns a role or actions would conflict /// with this wrapper's button and activation semantics. final bool excludeChildSemantics; /// Optional checked state for toggle-style controls. final bool? checked; /// Whether the wrapper can receive focus. final bool canRequestFocus; /// Custom key event handler. Return any non-ignored result to stop default handling. /// This is called before the default key handling. final KeyEventResult Function(FocusNode node, KeyEvent event)? onKeyEvent; /// Whether to enable long-press detection for SELECT key. /// When enabled, holding SELECT triggers [onLongPress] after 500ms. /// Short press triggers [onSelect]. final bool enableLongPress; /// Duration for long-press detection. final Duration longPressDuration; /// Whether to use background color instead of border for focus indicator. /// Useful for video controls where outline doesn't look good. final bool useBackgroundFocus; /// Custom color for the focus border. Only used when [useBackgroundFocus] is false. /// Useful for filled buttons where the default primary border blends in. final Color? focusColor; /// Whether to disable the scale animation on focus. /// Useful for elements like sliders where scaling looks odd. final bool disableScale; /// Scale used for the focus animation. final double focusScale; /// Whether to draw a glow around the focused widget. final bool useFocusGlow; /// Skip drawing the focus border here and expose the focus state through a /// [CardFocusScope] instead, so the child places the border on the exact /// rect it wants highlighted (e.g. MediaCard's poster image). final bool delegateFocusBorder; /// Whether descendants can receive focus. /// Set to false when the child widget has its own Focus (e.g. buttons) /// that would compete with this wrapper's focus handling. final bool descendantsAreFocusable; /// Whether the [Focus] node contributes focusable/focused semantics. /// /// Keep this enabled unless an equivalent child semantic action remains and /// accessibility navigation is known to be inactive. final bool includeFocusSemantics; const FocusableWrapper({ super.key, required this.child, this.onSelect, this.onLongPress, this.onFocusChange, this.onNavigateUp, this.onNavigateDown, this.onNavigateLeft, this.onNavigateRight, this.onBack, this.autofocus = false, this.focusNode, this.borderRadius = FocusTheme.defaultBorderRadius, this.borderRadii, this.autoScroll = true, this.scrollAlignment = 0.5, this.useComfortableZone = false, this.semanticLabel, this.semanticValue, this.excludeChildSemantics = true, this.checked, this.canRequestFocus = true, this.onKeyEvent, this.enableLongPress = false, this.longPressDuration = const Duration(milliseconds: 500), this.useBackgroundFocus = false, this.focusColor, this.disableScale = false, this.focusScale = FocusTheme.focusScale, this.useFocusGlow = false, this.delegateFocusBorder = false, this.descendantsAreFocusable = true, this.includeFocusSemantics = true, }); @override State createState() => _FocusableWrapperState(); } class _FocusableWrapperState extends State with SingleTickerProviderStateMixin { final OwnedFocusNodeBinding _focusNodeBinding = OwnedFocusNodeBinding(); FocusNode get _focusNode => _focusNodeBinding.node; bool _isFocused = false; // Created lazily on first focus/keyboard-mode build: touch scrolling builds // hundreds of these wrappers and must not pay for a Ticker per card. AnimationController? _animationController; Animation? _scaleAnimation; final _selectLongPress = DpadSelectLongPressController(); @override void initState() { super.initState(); _bindFocusNode(); } void _bindFocusNode() { _focusNodeBinding.bind(externalNode: widget.focusNode, debugLabel: widget.semanticLabel ?? 'FocusableWrapper'); _focusNode.canRequestFocus = widget.canRequestFocus; } AnimationController _ensureAnimationController() { final existing = _animationController; if (existing != null) return existing; final controller = AnimationController(vsync: this, duration: const Duration(milliseconds: 150)); _animationController = controller; _scaleAnimation = _createScaleAnimation(controller); return controller; } Animation _createScaleAnimation(AnimationController controller) { return Tween( begin: 1.0, end: widget.focusScale, ).animate(CurvedAnimation(parent: controller, curve: Curves.easeOutCubic)); } @override void didUpdateWidget(FocusableWrapper oldWidget) { super.didUpdateWidget(oldWidget); // Handle focusNode changes if (widget.focusNode != oldWidget.focusNode) { _bindFocusNode(); } // Update canRequestFocus if (widget.canRequestFocus != oldWidget.canRequestFocus) { _focusNode.canRequestFocus = widget.canRequestFocus; } if (widget.focusScale != oldWidget.focusScale) { final controller = _animationController; if (controller != null) { _scaleAnimation = _createScaleAnimation(controller); } } } @override void dispose() { _selectLongPress.dispose(); _animationController?.dispose(); _focusNodeBinding.dispose(); super.dispose(); } void _handleFocusChange(bool hasFocus) { if (_isFocused != hasFocus) { setState(() { _isFocused = hasFocus; }); // Reset long press state when focus is lost if (!hasFocus) { _selectLongPress.reset(); } // Animate scale if (hasFocus) { _ensureAnimationController().forward(); } else { _animationController?.reverse(); } // Auto-scroll into view if (hasFocus && widget.autoScroll) { _scrollIntoView(); } // Notify listener widget.onFocusChange?.call(hasFocus); } } // Extra padding for focus decoration (scale + border extends beyond item bounds) // Scale 1.02 adds ~1% on each side, plus 2.5px border = ~8px total padding needed static const double _focusDecorationPadding = 8.0; void _scrollIntoView() { WidgetsBinding.instance.addPostFrameCallback((_) { if (!mounted || !_isFocused) return; final renderObject = context.findRenderObject(); if (renderObject == null) return; // Find the nearest vertical scrollable that actually has scroll range. // Skip inner scrollables with no extent (e.g. shrinkWrap ListView // with NeverScrollableScrollPhysics inside an outer scroll view) // and horizontal scrollables (e.g. TabBarView) since we only do // vertical scroll calculations. var scrollable = Scrollable.maybeOf(context); while (scrollable != null) { final pos = scrollable.position; if (pos.axis == Axis.vertical && pos.maxScrollExtent > pos.minScrollExtent) break; scrollable = Scrollable.maybeOf(scrollable.context); } if (scrollable == null) return; final viewport = scrollable.context.findRenderObject() as RenderBox?; if (viewport == null) return; // Get item's position relative to viewport final itemBox = renderObject as RenderBox; final itemPosition = itemBox.localToGlobal(Offset.zero, ancestor: viewport); final viewportHeight = viewport.size.height; final itemHeight = itemBox.size.height; final itemVerticalCenter = itemPosition.dy + itemHeight / 2; // Account for focus decoration when checking item visibility final itemTop = itemPosition.dy - _focusDecorationPadding; final itemBottom = itemPosition.dy + itemHeight + _focusDecorationPadding; if (widget.useComfortableZone) { // Define comfortable zone - if item (including focus decoration) is within middle 60% of viewport, don't scroll final comfortZoneTop = viewportHeight * 0.2; final comfortZoneBottom = viewportHeight * 0.8; if (itemTop >= comfortZoneTop && itemBottom <= comfortZoneBottom) { // Item is in comfortable zone, no need to scroll return; } } else { // When not using comfortable zone, still skip scroll if item is already // close to target position (prevents jitter when navigating horizontally) final targetY = viewportHeight * widget.scrollAlignment; final distance = (itemVerticalCenter - targetY).abs(); // Skip scroll if within half the item height of target if (distance < itemHeight / 2) { return; } } // Calculate target scroll offset for the immediate scrollable only. // This avoids Scrollable.ensureVisible which scrolls ALL ancestor scrollables, // which can cause issues with nested scroll views (e.g., chips bar scrolling // out of view when focusing grid items in library browse tab). final position = scrollable.position; final currentOffset = position.pixels; // Target: item center should be at scrollAlignment of viewport // Add padding to ensure focus decoration is fully visible final targetViewportY = viewportHeight * widget.scrollAlignment; var scrollDelta = itemVerticalCenter - targetViewportY; // If item would be near the top edge, add extra scroll to show focus decoration final projectedItemTop = itemTop - scrollDelta; if (projectedItemTop < _focusDecorationPadding) { scrollDelta -= (_focusDecorationPadding - projectedItemTop); } if (!position.maxScrollExtent.isFinite) return; final targetOffset = (currentOffset + scrollDelta).clamp(position.minScrollExtent, position.maxScrollExtent); position.animateTo(targetOffset, duration: const Duration(milliseconds: 200), curve: Curves.easeInOut); }); } KeyEventResult _handleKeyEvent(FocusNode node, KeyEvent event) { final key = event.logicalKey; final diagnosticsEnabled = TextInputDiagnostics.enabled; KeyEventResult finish(KeyEventResult result, String reason) { if (diagnosticsEnabled) { _logFocusableWrapper( 'node=${node.debugLabel} result=$result reason=$reason key=(${_describeFocusableKey(event)}) ' 'onNav(up=${widget.onNavigateUp != null},down=${widget.onNavigateDown != null},' 'left=${widget.onNavigateLeft != null},right=${widget.onNavigateRight != null}) ' 'onSelect=${widget.onSelect != null} onBack=${widget.onBack != null}', ); } return result; } if (diagnosticsEnabled) { _logFocusableWrapper('node=${node.debugLabel} received key=(${_describeFocusableKey(event)})'); } if (SelectKeyUpSuppressor.consumeIfSuppressed(event)) { if (event is KeyUpEvent && key.isSelectKey) { _selectLongPress.reset(); } return finish(KeyEventResult.handled, 'select-key-up-suppressed'); } // Call custom key handler first if (widget.onKeyEvent != null) { final result = widget.onKeyEvent!(node, event); if (result != KeyEventResult.ignored) { return finish(result, 'custom-onKeyEvent'); } } if (widget.onBack != null) { final backResult = handleBackKeyAction(event, widget.onBack!); if (backResult != KeyEventResult.ignored) { return finish(backResult, 'onBack'); } } // Handle SELECT key with optional long-press detection if (key.isSelectKey) { if (widget.enableLongPress) { final result = _selectLongPress.handleKeyEvent( event, duration: widget.longPressDuration, isOwnerActive: () => mounted, onShortPress: () => widget.onSelect?.call(), onLongPress: () => widget.onLongPress?.call(), ); if (result != KeyEventResult.ignored) { return finish(result, 'select-long-press'); } } else if (widget.onSelect != null) { return finish(handleOneShotSelect(event, widget.onSelect!), 'one-shot-select'); } } // Ignore key up events for other keys if (!event.isActionable) { return finish(KeyEventResult.ignored, 'non-actionable'); } // Context menu key if (key.isContextMenuKey) { _selectLongPress.reset(); widget.onLongPress?.call(); return finish(KeyEventResult.handled, 'context-menu'); } // UP arrow - if callback provided, navigate up if (key == LogicalKeyboardKey.arrowUp && widget.onNavigateUp != null) { widget.onNavigateUp!(); return finish(KeyEventResult.handled, 'onNavigateUp'); } // DOWN arrow - if callback provided, navigate down if (key == LogicalKeyboardKey.arrowDown && widget.onNavigateDown != null) { widget.onNavigateDown!(); return finish(KeyEventResult.handled, 'onNavigateDown'); } // LEFT arrow - if callback provided, navigate left (caller is responsible // for only providing this callback when the item is at the left edge) if (key == LogicalKeyboardKey.arrowLeft && widget.onNavigateLeft != null) { widget.onNavigateLeft!(); return finish(KeyEventResult.handled, 'onNavigateLeft'); } // RIGHT arrow - if callback provided, navigate right (caller is responsible // for only providing this callback when the item is at the right edge) if (key == LogicalKeyboardKey.arrowRight && widget.onNavigateRight != null) { widget.onNavigateRight!(); return finish(KeyEventResult.handled, 'onNavigateRight'); } return finish(KeyEventResult.ignored, 'fall-through'); } @override Widget build(BuildContext context) { // Only show focus effects during keyboard/d-pad navigation. In pointer/ // touch mode no card ever shows focus chrome, so skip the animated // scale/border wrappers entirely — they cost real build time multiplied // by every card in a grid. The Focus node stays mounted so d-pad // traversal finds the cards the moment keyboard mode activates (which // rebuilds this widget via the inherited dependency below). final isKeyboardMode = InputModeTracker.isKeyboardMode(context); final showFocus = _isFocused && isKeyboardMode; Widget inner; if (!isKeyboardMode) { inner = widget.child; } else { final duration = FocusTheme.getAnimationDuration(context); final controller = _ensureAnimationController(); // Update animation duration if theme changes if (controller.duration != duration) { controller.duration = duration; } final shouldScale = showFocus && !widget.disableScale; // Keep the card subtree outside the scale builder. Rebuilding media-card // semantics on every animation tick is substantially more expensive than // changing the paint transform alone on dense TV grids. Widget card; if (widget.delegateFocusBorder) { card = CardFocusScope(showFocus: showFocus, child: widget.child); } else { final focusDecoration = widget.useBackgroundFocus ? FocusTheme.focusBackgroundDecoration( isFocused: showFocus, borderRadius: widget.borderRadius, radii: widget.borderRadii, ) : FocusTheme.focusDecoration( context, isFocused: showFocus, borderRadius: widget.borderRadius, radii: widget.borderRadii, color: widget.focusColor, ); card = AnimatedContainer( duration: duration, curve: Curves.easeOutCubic, decoration: focusDecoration, child: widget.child, ); } if (widget.useFocusGlow) { card = FocusGlowOverlay( isFocused: showFocus, borderRadius: widget.borderRadius, color: widget.focusColor ?? FocusTheme.getFocusBorderColor(context), child: card, ); } inner = AnimatedBuilder( animation: _scaleAnimation!, child: card, builder: (context, child) => _PaintScale(scale: shouldScale ? _scaleAnimation!.value : 1.0, child: child!), ); } Widget result = Focus( focusNode: _focusNode, autofocus: widget.autofocus, includeSemantics: widget.includeFocusSemantics, descendantsAreFocusable: widget.descendantsAreFocusable, onFocusChange: _handleFocusChange, onKeyEvent: _handleKeyEvent, child: inner, ); // Add semantics if label provided if (widget.semanticLabel != null) { result = Semantics( label: widget.semanticLabel, value: widget.semanticValue, button: true, enabled: widget.onSelect != null, checked: widget.checked, onTap: widget.onSelect, onLongPress: widget.onLongPress, excludeSemantics: widget.excludeChildSemantics, child: result, ); } if (widget.onSelect != null || widget.onLongPress != null) { result = ClickableCursor(child: result); } return result; } }