diff --git a/modules/ensemble/doc/tv_developer_guide.md b/modules/ensemble/doc/tv_developer_guide.md index f60ea1d39..3b71e8681 100644 --- a/modules/ensemble/doc/tv_developer_guide.md +++ b/modules/ensemble/doc/tv_developer_guide.md @@ -161,12 +161,13 @@ tvOptions: margin: 8 # Margin when focused # Scroll Behavior (Optional - for horizontal lists) - fixedFocusScroll: true # Enable Netflix-style scrolling - fixedFocusOffset: 48 # Offset from left edge (pixels) - verticalScrollPadding: 100 # Extra padding when scrolling vertically + scrollMode: keepVisible # 'legacy' (default) or 'keepVisible' minimal reveal + fixedFocusScroll: true # Enable Netflix-style scrolling (legacy only) + fixedFocusOffset: 48 # Offset from left edge (pixels) (legacy only) + verticalScrollPadding: 100 # Extra padding when scrolling vertically (legacy only) scrollAnimationDuration: 200 # Scroll animation duration (ms) scrollAnimationCurve: easeOut # Animation curve - horizontalScrollPadding: 16 # Horizontal padding for visibility checks + horizontalScrollPadding: 16 # Horizontal padding for visibility checks (legacy only) # Horizontal Navigation Control (for carousels) delegateHorizontalNavigation: true # Delegate LEFT/RIGHT to parent FocusScope @@ -239,6 +240,32 @@ These properties change the **widget's appearance** when focused (not the focus | **horizontalScrollPadding** | `double` | `16.0` | Horizontal padding for visibility checks during scrolling. | | **scrollAnimationDuration** | `int` | `200` | Duration of scroll animations in milliseconds. | | **scrollAnimationCurve** | `String` | `easeOut` | Animation curve: easeIn, easeOut, easeInOut, linear, decelerate, ease. | +| **scrollMode** | `String` | `legacy` | Auto-scroll strategy: `legacy` or `keepVisible` (see below). | + +##### `scrollMode: keepVisible` + +`legacy` (default) keeps the existing behavior: manual, animated scrolling that centers +horizontal items (or pins them to `fixedFocusOffset`) and applies `verticalScrollPadding`. + +`keepVisible` reveals the focused item with **minimal movement** using each scrollable's own +`ScrollPosition.ensureVisible` with `keepVisibleAtStart`/`End`, like Flutter's default focus +traversal. It is correct for slivers, pinned/overlay headers, content padding and nested +scrollables, and it only scrolls when the item is actually offscreen. + +```yaml +Row: + styles: + tvOptions: + row: 3 + scrollMode: keepVisible # minimal, geometry-aware reveal +``` + +When `scrollMode: keepVisible` is set, `fixedFocusScroll`, `fixedFocusOffset`, +`verticalScrollPadding` and `horizontalScrollPadding` are ignored — they only apply to +`legacy`. `scrollAnimationDuration`, `scrollAnimationCurve` and `resetScrollOnFocus` still +apply. Prefer `keepVisible` for new TV layouts; `legacy` remains the default for backward +compatibility. + #### Carousel-Specific Properties diff --git a/modules/ensemble/lib/framework/tv/tv_focus_order.dart b/modules/ensemble/lib/framework/tv/tv_focus_order.dart index e146b9bee..17cff177f 100644 --- a/modules/ensemble/lib/framework/tv/tv_focus_order.dart +++ b/modules/ensemble/lib/framework/tv/tv_focus_order.dart @@ -490,11 +490,17 @@ class TVFocusOrderNode { // tv_focus_order call sites). Non-null => restrict to that group (matches // _moveFocus). // - [focusGroup] null => all focus groups. + // - [includeDescendantScan] when false, skips PASS 2 (the app-wide live + // focus-tree walk). Callers that know every focusable in scope registers a + // [TVFocusTarget] (e.g. the host's PageFocusWidget) can set this to false to + // avoid an O(all focus nodes) scan on every D-pad press. Defaults to true so + // existing behavior (Case-2 bracket/tab leaves) is unchanged. // ───────────────────────────────────────────────────────────────────────── static Map collectInScope({ required ModalRoute? route, FocusTraversalGroup? traversalGroup, String? focusGroup, + bool includeDescendantScan = true, }) { final result = {}; @@ -511,6 +517,10 @@ class TVFocusOrderNode { ); } + if (!includeDescendantScan) { + return result; + } + // PASS 2 — live focus-tree scan for unregistered Case-2 leaves. for (final focusNode in FocusManager.instance.rootScope.descendants) { final nodeContext = focusNode.context; diff --git a/modules/ensemble/lib/framework/tv/tv_focus_registry.dart b/modules/ensemble/lib/framework/tv/tv_focus_registry.dart index 31f339625..9d31e6ec2 100644 --- a/modules/ensemble/lib/framework/tv/tv_focus_registry.dart +++ b/modules/ensemble/lib/framework/tv/tv_focus_registry.dart @@ -1,3 +1,5 @@ +import 'dart:collection'; + import 'package:flutter/material.dart'; /// Explicit focus target for a TV focus coordinate. @@ -13,6 +15,7 @@ class TVFocusTarget { required this.order, required this.context, this.route, + this.traversalGroup, this.focusGroup, this.isRowEntryPoint = false, this.lockHorizontalNavigation = false, @@ -31,11 +34,18 @@ class TVFocusTarget { /// instead of a `ModalRoute.of` ancestor walk on every navigation query (which /// ran for every registered target). [ModalRoute] identity is stable across /// rebuilds, and the registrar re-registers on dependency changes, so this - /// stays current. (The enclosing FocusTraversalGroup is deliberately NOT - /// cached — its widget instance is recreated on rebuild, so [isInTraversalGroup] - /// must resolve it live.) + /// stays current. final ModalRoute? route; + /// The enclosing [FocusTraversalGroup] captured at registration time. + /// + /// The group widget instance is recreated on rebuild, so it is re-captured by + /// [TVFocusTargetRegistrar._register] on every rebuild (which is when a new + /// [TVFocusTarget] is constructed). This makes [isInTraversalGroup] an O(1) + /// identity comparison instead of a `findAncestorWidgetOfExactType` ancestor + /// walk that previously ran for every registered target on every D-pad press. + final FocusTraversalGroup? traversalGroup; + final String? focusGroup; final bool isRowEntryPoint; final bool lockHorizontalNavigation; @@ -57,6 +67,17 @@ class TVFocusTarget { if (traversalGroup == null) { return true; } + // Fast path: the group captured at registration is the one being queried. + if (identical(this.traversalGroup, traversalGroup)) { + return true; + } + // If we captured a (different) live group at registration, the target is + // known to belong to that other group and cannot be in the queried one. + // Fall back to a live walk only when registration captured nothing (e.g. a + // target registered before the group existed in its ancestry). + if (this.traversalGroup != null) { + return false; + } final targetContext = effectiveContext; return targetContext ?.findAncestorWidgetOfExactType() == @@ -65,18 +86,141 @@ class TVFocusTarget { } /// Route-aware registry of explicit TV focus targets. +/// +/// Keeps two views of the same registrations: +/// - [_targets]: flat map keyed by [FocusNode] (used by the legacy +/// [targets] scan, kept for backward compatibility). +/// - [_index]: a route → row → order index built on registration so navigation +/// can resolve a neighbour with direct lookups instead of rebuilding the whole +/// grid from every focus node on each D-pad press. class TVFocusRegistry { TVFocusRegistry._(); static final Map _targets = {}; + // routeKey -> row -> order -> target. Rows and orders are SplayTreeMaps so + // predecessor/successor (LEFT/RIGHT) and nearest-row (UP/DOWN) queries are + // O(log n) on the relevant row instead of a full scan. + static final Map>> + _index = {}; + + /// Stable map key for a route (or a shared bucket when the route is null). + static Object _routeKey(ModalRoute? route) => + route == null ? 'noRoute' : identityHashCode(route); + static void register(TVFocusTarget target) { + // Remove any prior entry for this node (re-registration on rebuild, or a + // node whose row/order changed) before inserting the fresh target. + final previous = _targets[target.focusNode]; + if (previous != null && !identical(previous, target)) { + _removeFromIndex(previous); + } _targets[target.focusNode] = target; + _insertIntoIndex(target); } static void unregister(FocusNode focusNode) { - _targets.remove(focusNode); + final removed = _targets.remove(focusNode); + if (removed != null) { + _removeFromIndex(removed); + } + } + + static void _insertIntoIndex(TVFocusTarget target) { + final orders = _index + .putIfAbsent(_routeKey(target.route), () => SplayTreeMap>()) + .putIfAbsent( + target.row, + () => SplayTreeMap()); + orders[target.order] = target; + } + + static void _removeFromIndex(TVFocusTarget target) { + final rows = _index[_routeKey(target.route)]; + if (rows == null) return; + final orders = rows[target.row]; + if (orders == null) return; + // Only drop the entry if it still points at this exact registration. + if (identical(orders[target.order], target)) { + orders.remove(target.order); + } + if (orders.isEmpty) { + rows.remove(target.row); + } + if (rows.isEmpty) { + _index.remove(_routeKey(target.route)); + } + } + + /// All requestable targets in [row] for the current [route], ordered by + /// `order`. O(log n) row lookup plus O(k) copy of the row. + static List rowTargets({ + required ModalRoute? route, + required double row, + }) { + final rows = _index[_routeKey(route)]; + final orders = rows?[row]; + if (orders == null) return const []; + return orders.values.where((t) => t.isRequestable).toList(growable: false); + } + + /// The target at the exact cell [row]/[order], or null. O(1)–O(log n). + static TVFocusTarget? cellTarget({ + required ModalRoute? route, + required double row, + required double order, + }) { + final target = _index[_routeKey(route)]?[row]?[order]; + if (target == null || !target.isRequestable) return null; + return target; + } + + /// The nearest row value strictly after [row] (down direction), or null. + static double? nextRow({ + required ModalRoute? route, + required double row, + }) { + final rows = _index[_routeKey(route)]; + return rows?.firstKeyAfter(row); + } + + /// The nearest row value strictly before [row] (up direction), or null. + static double? previousRow({ + required ModalRoute? route, + required double row, + }) { + final rows = _index[_routeKey(route)]; + return rows?.lastKeyBefore(row); + } + + /// Ordered row values present for [route], ascending. O(r) copy. + static List rowValues({required ModalRoute? route}) { + final rows = _index[_routeKey(route)]; + if (rows == null) return const []; + return rows.keys.toList(growable: false); + } + + /// Whether any requestable target exists at [row]/[order]. + static bool hasCell({ + required ModalRoute? route, + required double row, + required double order, + }) => + cellTarget(route: route, row: row, order: order) != null; + + /// Removes every registration for a route (used on route disposal, if ever + /// needed). Individual targets unregister themselves on dispose. + static void clearRoute(ModalRoute? route) { + final targetNodes = _targets.entries + .where((e) => identical(_routeKey(e.value.route), _routeKey(route))) + .map((e) => e.key) + .toList(growable: false); + for (final node in targetNodes) { + unregister(node); + } + _index.remove(_routeKey(route)); } static Iterable targets({ @@ -197,6 +341,11 @@ class _TVFocusTargetRegistrarState extends State { delegateHorizontalNavigation: widget.delegateHorizontalNavigation, context: context, route: _route, + // Resolve once at registration so navigation queries are O(1). The + // group widget is recreated on rebuild, but this registrar re-registers + // (constructing a new target) on every rebuild, so the captured group + // stays in sync. + traversalGroup: context.findAncestorWidgetOfExactType(), ), ); } diff --git a/modules/ensemble/lib/framework/tv/tv_focus_scroll.dart b/modules/ensemble/lib/framework/tv/tv_focus_scroll.dart index a8b165f3c..35f33286d 100644 --- a/modules/ensemble/lib/framework/tv/tv_focus_scroll.dart +++ b/modules/ensemble/lib/framework/tv/tv_focus_scroll.dart @@ -171,6 +171,122 @@ void scrollWidgetIntoView( ); } +/// Reveals [widgetContext] in every scrollable ancestor using each scrollable's +/// own [ScrollPosition.ensureVisible] with keep-visible alignment policies. +/// +/// Unlike [scrollVerticalOnly], this does NOT derive targets from global +/// coordinates. It therefore respects slivers, pinned/overlay headers, content +/// padding, and nested scrollables, and it moves only the minimum amount +/// required. This is the TV-focus equivalent of Flutter's default traversal +/// reveal ([Scrollable.ensureVisible] with `keepVisibleAtStart`/`End`). +/// +/// Both policies are applied per scrollable: `keepVisibleAtEnd` reveals an item +/// past the trailing edge, `keepVisibleAtStart` reveals an item before the +/// leading edge. Each is a no-op when the item is already visible on that side, +/// so at most one actually animates. Axis direction is handled internally by +/// [ScrollPosition.ensureVisible]. +/// +/// [includeHorizontal]/[includeVertical] let callers skip an axis handled +/// elsewhere (e.g. a host app that manages its own horizontal scrolling). +/// Scrollables on a skipped axis are left untouched but the walk continues to +/// their ancestors. +Future ensureWidgetVisible( + BuildContext widgetContext, { + Duration duration = Duration.zero, + Curve curve = Curves.ease, + bool includeHorizontal = true, + bool includeVertical = true, +}) async { + final renderObject = widgetContext.findRenderObject(); + if (renderObject == null || !renderObject.attached) return; + + // Record the first (innermost) revealed render object so outer scrollables + // intersect against it, keeping the target's own box as visible as possible + // when multiple scrollables are nested. See flutter/flutter#65100. + RenderObject? targetRenderObject; + var scrollable = Scrollable.maybeOf(widgetContext); + + while (scrollable != null) { + if (!scrollable.mounted) break; + final axisDirection = scrollable.axisDirection; + final isHorizontal = axisDirection == AxisDirection.left || + axisDirection == AxisDirection.right; + final include = isHorizontal ? includeHorizontal : includeVertical; + + if (include) { + final position = scrollable.position; + if (position.hasContentDimensions) { + // Single reveal per scrollable, with the alignment policy chosen from + // the item's current position (like Flutter's directional traversal). + // Calling ensureVisible twice in series starts a second 200ms animation + // after the first settles, which reads as a laggy two-phase scroll. + final policy = + _revealPolicyFor(renderObject, scrollable, targetRenderObject); + if (policy != null) { + await position.ensureVisible( + renderObject, + duration: duration, + curve: curve, + alignmentPolicy: policy, + targetRenderObject: targetRenderObject, + ); + if (!scrollable.mounted) break; + } + } + targetRenderObject ??= renderObject; + } + + final scrollableContext = scrollable.context; + scrollable = Scrollable.maybeOf(scrollableContext); + } +} + +/// Chooses the single [ScrollPositionAlignmentPolicy] that reveals +/// [target] with the least movement given its position in [scrollable]. +/// +/// Returns `keepVisibleAtEnd` when the item is past the trailing edge, +/// `keepVisibleAtStart` when it is before the leading edge, and `null` when the +/// item is already fully visible on the scrollable's main axis (no scroll +/// needed). Falls back to `keepVisibleAtEnd` if geometry cannot be resolved. +ScrollPositionAlignmentPolicy? _revealPolicyFor( + RenderObject target, + ScrollableState scrollable, + RenderObject? targetRenderObject, +) { + final targetBox = target as RenderBox?; + final scrollableBox = scrollable.context.findRenderObject() as RenderBox?; + if (targetBox == null || + scrollableBox == null || + !targetBox.hasSize || + !scrollableBox.hasSize) { + return ScrollPositionAlignmentPolicy.keepVisibleAtEnd; + } + + final horizontal = scrollable.axisDirection == AxisDirection.left || + scrollable.axisDirection == AxisDirection.right; + + // Position of the item's leading/trailing edge in the scrollable's viewport + // coordinate space. + final Offset itemTopLeft = targetBox.localToGlobal(Offset.zero); + final Offset viewportTopLeft = scrollableBox.localToGlobal(Offset.zero); + final double itemStart = horizontal ? itemTopLeft.dx : itemTopLeft.dy; + final double itemEnd = itemStart + + (horizontal ? targetBox.size.width : targetBox.size.height); + final double viewportStart = + horizontal ? viewportTopLeft.dx : viewportTopLeft.dy; + final double viewportEnd = + viewportStart + (horizontal ? scrollableBox.size.width : scrollableBox.size.height); + + if (itemEnd > viewportEnd) { + return ScrollPositionAlignmentPolicy.keepVisibleAtEnd; + } + if (itemStart < viewportStart) { + return ScrollPositionAlignmentPolicy.keepVisibleAtStart; + } + return null; +} + + // ============================================================================= // TV Focus - Active Vertical Scrollable Memory (route-scoped) // ============================================================================= @@ -191,3 +307,15 @@ ScrollableState? activeVerticalScrollable(Route? route) => void clearActiveVerticalScrollableForRoute(Route? route) { _activeVerticalScrollables.remove(_activeScrollableRouteKey(route)); } + +/// Resolves [context]'s scrollable ancestry and remembers the outermost +/// vertical scrollable for the current route so `resetScrollOnFocus` can +/// target it. No-op when there is no vertical scrollable ancestor. +void rememberActiveVerticalScrollableForContext(BuildContext context) { + final nearest = findNearestVerticalScrollable(context); + if (nearest == null) return; + rememberActiveVerticalScrollable( + ModalRoute.of(context), + findOutermostVerticalScrollable(context) ?? nearest, + ); +} diff --git a/modules/ensemble/lib/widget/helpers/box_wrapper.dart b/modules/ensemble/lib/widget/helpers/box_wrapper.dart index f264a12a4..26d60dd0f 100644 --- a/modules/ensemble/lib/widget/helpers/box_wrapper.dart +++ b/modules/ensemble/lib/widget/helpers/box_wrapper.dart @@ -1,3 +1,5 @@ +import 'dart:async'; + import 'package:ensemble/framework/bindings.dart'; import 'package:ensemble/framework/device.dart'; import 'package:ensemble/framework/model.dart'; @@ -662,12 +664,28 @@ class _TapEnabledWrapperState extends State<_TapEnabledWrapper> { final hostHandlesScroll = externalProvider?.handlesHorizontalScroll ?? false; + // Opt-in minimal reveal: delegate to each scrollable's own geometry so + // slivers, pinned headers and nested scrollables are handled correctly and + // only the minimum movement is applied. Legacy remains the default. + if (tvOptions?.scrollMode == TVScrollMode.keepVisible) { + final duration = Duration(milliseconds: scrollAnimationDuration); + final curve = + _getCurveFromName(scrollCurveName, defaultCurve: Curves.easeInOut); + rememberActiveVerticalScrollableForContext(context); + unawaited(ensureWidgetVisible( + context, + duration: duration, + curve: curve, + includeHorizontal: !hostHandlesScroll, + )); + _resetScrollOnFocus(tvOptions, duration, curve); + return; + } + // Handle vertical scrolling to ensure focused item is visible final verticalScrollable = findNearestVerticalScrollable(context); if (verticalScrollable != null) { - rememberActiveVerticalScrollable( - ModalRoute.of(context), - findOutermostVerticalScrollable(context) ?? verticalScrollable); + rememberActiveVerticalScrollableForContext(context); final verticalPadding = tvOptions?.verticalScrollPadding ?? kTVVerticalScrollPadding; final verticalCurve = @@ -678,23 +696,11 @@ class _TapEnabledWrapperState extends State<_TapEnabledWrapper> { curve: verticalCurve); } - if (tvOptions?.resetScrollOnFocus == true) { - final activeScrollable = activeVerticalScrollable(ModalRoute.of(context)); - if (activeScrollable != null && - activeScrollable.mounted && - activeScrollable.position.hasContentDimensions) { - final position = activeScrollable.position; - if ((position.pixels - position.minScrollExtent).abs() > - kTVScrollThreshold) { - position.animateTo( - position.minScrollExtent, - duration: Duration(milliseconds: scrollAnimationDuration), - curve: _getCurveFromName(scrollCurveName, - defaultCurve: Curves.easeInOut), - ); - } - } - } + _resetScrollOnFocus( + tvOptions, + Duration(milliseconds: scrollAnimationDuration), + _getCurveFromName(scrollCurveName, defaultCurve: Curves.easeInOut), + ); // Handle horizontal scrolling // Skip only if host app explicitly handles horizontal scroll @@ -723,6 +729,30 @@ class _TapEnabledWrapperState extends State<_TapEnabledWrapper> { } } + /// Resets the remembered page scroll to the top when this widget opts in via + /// `resetScrollOnFocus`. Shared by the legacy and keepVisible scroll modes. + void _resetScrollOnFocus( + TVOptionsComposite? tvOptions, + Duration duration, + Curve curve, + ) { + if (tvOptions?.resetScrollOnFocus != true) return; + final activeScrollable = activeVerticalScrollable(ModalRoute.of(context)); + if (activeScrollable != null && + activeScrollable.mounted && + activeScrollable.position.hasContentDimensions) { + final position = activeScrollable.position; + if ((position.pixels - position.minScrollExtent).abs() > + kTVScrollThreshold) { + position.animateTo( + position.minScrollExtent, + duration: duration, + curve: curve, + ); + } + } + } + /// Finds the nearest horizontal scrollable ancestor. ScrollableState? _findHorizontalScrollable() { ScrollableState? scrollable; @@ -1183,6 +1213,16 @@ class _TVFocusOnlyWrapperState extends State<_TVFocusOnlyWrapper> { final curve = _curveFromName(tvOptions?.scrollAnimationCurve, defaultCurve: Curves.easeInOut); + // Opt-in minimal reveal (see _TapEnabledWrapper._handleFocusScroll). + if (tvOptions?.scrollMode == TVScrollMode.keepVisible) { + unawaited(ensureWidgetVisible( + childContext, + duration: Duration(milliseconds: scrollAnimationDuration), + curve: curve, + )); + return; + } + // Handle vertical scrolling to ensure focused item is visible final verticalScrollable = findNearestVerticalScrollable(context); if (verticalScrollable != null) { diff --git a/modules/ensemble/lib/widget/helpers/controllers.dart b/modules/ensemble/lib/widget/helpers/controllers.dart index e629a6f65..36d99ae03 100644 --- a/modules/ensemble/lib/widget/helpers/controllers.dart +++ b/modules/ensemble/lib/widget/helpers/controllers.dart @@ -250,6 +250,26 @@ class TVFocusEdgesComposite extends WidgetCompositeProperty { }; } +/// Focus auto-scroll strategy for TV D-pad navigation. +/// +/// Selected per widget via `styles.tvOptions.scrollMode`. +enum TVScrollMode { + /// Existing behavior (default): manual, animated scrolling that centers + /// horizontal items (or pins them to `fixedFocusOffset`) and applies + /// `verticalScrollPadding`. Kept as the default so existing layouts are + /// unchanged. + legacy, + + /// Reveal the focused item with minimal movement using each scrollable's own + /// [ScrollPosition.ensureVisible] with `keepVisibleAtStart`/`End` alignment, + /// like Flutter's default focus traversal. Correct for slivers, pinned or + /// overlay headers, content padding, and nested scrollables. Honors + /// `scrollAnimationDuration`/`scrollAnimationCurve`; ignores + /// `fixedFocusScroll`, `fixedFocusOffset`, `verticalScrollPadding`, and + /// `horizontalScrollPadding`. + keepVisible, +} + /// TV/Accessibility options for D-pad navigation. /// Groups all TV-related properties under styles.tvOptions.* /// All focus styling properties can override theme values per-widget. @@ -268,6 +288,7 @@ class TVOptionsComposite extends WidgetCompositeProperty { scrollAnimationDuration = inputs['scrollAnimationDuration']; scrollAnimationCurve = inputs['scrollAnimationCurve']; horizontalScrollPadding = inputs['horizontalScrollPadding']; + scrollMode = inputs['scrollMode']; lockHorizontalNavigation = inputs['lockHorizontalNavigation']; delegateHorizontalNavigation = inputs['delegateHorizontalNavigation']; focusGroup = inputs['focusGroup']; @@ -372,6 +393,19 @@ class TVOptionsComposite extends WidgetCompositeProperty { _horizontalScrollPadding = Utils.optionalDouble(value); double? get horizontalScrollPadding => _horizontalScrollPadding; + /// Focus auto-scroll strategy. Defaults to [TVScrollMode.legacy] so existing + /// layouts are unchanged. Set `keepVisible` to reveal focused items with + /// minimal movement using the scrollable's own geometry. + TVScrollMode _scrollMode = TVScrollMode.legacy; + set scrollMode(value) { + final name = Utils.optionalString(value)?.trim().toLowerCase(); + _scrollMode = TVScrollMode.values.firstWhere( + (mode) => mode.name.toLowerCase() == name, + orElse: () => TVScrollMode.legacy, + ); + } + TVScrollMode get scrollMode => _scrollMode; + /// Prevents horizontal navigation from escaping this row at boundaries. /// When true, pressing LEFT at first item or RIGHT at last item won't move focus to another row. bool _lockHorizontalNavigation = false; @@ -486,6 +520,7 @@ class TVOptionsComposite extends WidgetCompositeProperty { 'scrollAnimationDuration': () => _scrollAnimationDuration, 'scrollAnimationCurve': () => _scrollAnimationCurve, 'horizontalScrollPadding': () => _horizontalScrollPadding, + 'scrollMode': () => _scrollMode.name, 'lockHorizontalNavigation': () => _lockHorizontalNavigation, 'delegateHorizontalNavigation': () => _delegateHorizontalNavigation, 'focusGroup': () => _focusGroup, @@ -521,6 +556,7 @@ class TVOptionsComposite extends WidgetCompositeProperty { 'scrollAnimationDuration': (value) => scrollAnimationDuration = value, 'scrollAnimationCurve': (value) => scrollAnimationCurve = value, 'horizontalScrollPadding': (value) => horizontalScrollPadding = value, + 'scrollMode': (value) => scrollMode = value, 'lockHorizontalNavigation': (value) => lockHorizontalNavigation = value, 'delegateHorizontalNavigation': (value) => delegateHorizontalNavigation = value, diff --git a/modules/ensemble/test/widget/tv_focus_registry_index_test.dart b/modules/ensemble/test/widget/tv_focus_registry_index_test.dart new file mode 100644 index 000000000..2f33c77b0 --- /dev/null +++ b/modules/ensemble/test/widget/tv_focus_registry_index_test.dart @@ -0,0 +1,202 @@ +import 'package:ensemble/framework/tv/tv_focus_order.dart'; +import 'package:ensemble/framework/tv/tv_focus_registry.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter_test/flutter_test.dart'; + +/// Exercises the route/row/order index that lets TV navigation resolve a +/// neighbour with direct lookups instead of rebuilding the grid from every +/// focus node on each D-pad press. +void main() { + // The registry is process-global; each test uses a distinct row set and + // unregisters what it adds so buckets don't leak across tests. + testWidgets('rowTargets returns the row ordered by order', (tester) async { + final nodes = []; + late BuildContext context; + + await tester.pumpWidget(MaterialApp( + home: Builder(builder: (ctx) { + context = ctx; + return const SizedBox(); + }), + )); + + // Register out of order to prove the index sorts by order. + for (final order in [2.0, 0.0, 1.0]) { + final node = FocusNode(); + nodes.add(node); + TVFocusRegistry.register(TVFocusTarget( + focusNode: node, + focusOrder: TVFocusOrder(10, order), + row: 10, + order: order, + context: context, + )); + } + // Mount the nodes so isRequestable is true. + await tester.pumpWidget(MaterialApp( + home: Column( + children: [for (final n in nodes) Focus(focusNode: n, child: const SizedBox())], + ), + )); + await tester.pump(); + + final row = TVFocusRegistry.rowTargets(route: null, row: 10); + expect(row.map((t) => t.order).toList(), [0.0, 1.0, 2.0]); + + for (final n in nodes) { + TVFocusRegistry.unregister(n); + n.dispose(); + } + }); + + testWidgets('cellTarget and hasCell resolve an exact coordinate', + (tester) async { + final node = FocusNode(); + late BuildContext context; + + await tester.pumpWidget(MaterialApp( + home: Focus(focusNode: node, child: const SizedBox()), + )); + await tester.pump(); + + // Grab a context under the Focus so isRequestable sees a mounted node. + context = tester.element(find.byType(SizedBox).last); + + TVFocusRegistry.register(TVFocusTarget( + focusNode: node, + focusOrder: const TVFocusOrder(3, 4), + row: 3, + order: 4, + context: context, + )); + + expect(TVFocusRegistry.hasCell(route: null, row: 3, order: 4), isTrue); + expect(TVFocusRegistry.cellTarget(route: null, row: 3, order: 4), isNotNull); + expect(TVFocusRegistry.hasCell(route: null, row: 3, order: 99), isFalse); + + TVFocusRegistry.unregister(node); + node.dispose(); + }); + + testWidgets('nextRow / previousRow walk rows in order', (tester) async { + final nodes = []; + late BuildContext context; + + await tester.pumpWidget(MaterialApp( + home: Column( + children: [ + for (final _ in [0, 1, 2]) Builder(builder: (ctx) { + final node = FocusNode(); + nodes.add(node); + context = ctx; + return Focus(focusNode: node, child: const SizedBox()); + }), + ], + ), + )); + await tester.pump(); + + var i = 0; + for (final row in [5.0, 7.0, 9.0]) { + TVFocusRegistry.register(TVFocusTarget( + focusNode: nodes[i], + focusOrder: TVFocusOrder(row, 0), + row: row, + order: 0, + context: context, + )); + i++; + } + + expect(TVFocusRegistry.nextRow(route: null, row: 7), 9.0); + expect(TVFocusRegistry.previousRow(route: null, row: 7), 5.0); + expect(TVFocusRegistry.nextRow(route: null, row: 9), isNull); + expect(TVFocusRegistry.previousRow(route: null, row: 5), isNull); + + for (final n in nodes) { + TVFocusRegistry.unregister(n); + n.dispose(); + } + }); + + testWidgets('unregister removes the target from the index', (tester) async { + final node = FocusNode(); + late BuildContext context; + + await tester.pumpWidget(MaterialApp( + home: Focus(focusNode: node, child: const SizedBox()), + )); + await tester.pump(); + context = tester.element(find.byType(SizedBox).last); + + TVFocusRegistry.register(TVFocusTarget( + focusNode: node, + focusOrder: const TVFocusOrder(1, 1), + row: 1, + order: 1, + context: context, + )); + expect(TVFocusRegistry.hasCell(route: null, row: 1, order: 1), isTrue); + + TVFocusRegistry.unregister(node); + expect(TVFocusRegistry.hasCell(route: null, row: 1, order: 1), isFalse); + + node.dispose(); + }); + + testWidgets( + 'rowValues / rowTargets expose rows above even when only some are built', + (tester) async { + // Mirrors the ListView/GridView escape fix: at the top of the BUILT + // window (row 12), the registry still reports that lower-valued rows + // exist, so the boundary gate can keep focus inside. + final nodes = []; + late BuildContext context; + + await tester.pumpWidget(MaterialApp( + home: Builder(builder: (ctx) { + context = ctx; + return const SizedBox(); + }), + )); + + for (final row in [1.0, 6.0, 12.0]) { + final node = FocusNode(); + nodes.add(node); + TVFocusRegistry.register(TVFocusTarget( + focusNode: node, + focusOrder: TVFocusOrder(row, 0), + row: row, + order: 0, + context: context, + focusGroup: 'list', + )); + } + // Mount the nodes so isRequestable is true. + await tester.pumpWidget(MaterialApp( + home: Column( + children: [ + for (final n in nodes) Focus(focusNode: n, child: const SizedBox()), + ], + ), + )); + await tester.pump(); + + // From row 12 there ARE rows above in the data. + expect(TVFocusRegistry.previousRow(route: null, row: 12), 6.0); + expect( + TVFocusRegistry.rowValues(route: null), + containsAll([1.0, 6.0, 12.0]), + ); + // The gate's same-group check finds an eligible row above. + final above = TVFocusRegistry.rowTargets(route: null, row: 6.0) + .where((t) => t.focusGroup == 'list'); + expect(above, isNotEmpty); + + for (final n in nodes) { + TVFocusRegistry.unregister(n); + n.dispose(); + } + }, + ); +} diff --git a/modules/ensemble/test/widget/tv_focus_registry_test.dart b/modules/ensemble/test/widget/tv_focus_registry_test.dart new file mode 100644 index 000000000..05c18aea8 --- /dev/null +++ b/modules/ensemble/test/widget/tv_focus_registry_test.dart @@ -0,0 +1,78 @@ +import 'package:ensemble/framework/tv/tv_focus_order.dart'; +import 'package:ensemble/framework/tv/tv_focus_registry.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter_test/flutter_test.dart'; + +/// Locks in the O(1) traversal-group check added to [TVFocusTarget]. +/// +/// The target captures its enclosing [FocusTraversalGroup] at registration so +/// navigation never has to walk the ancestor chain per D-pad press. +void main() { + testWidgets( + 'TVFocusTarget.isInTraversalGroup matches the captured group and rejects others', + (tester) async { + final focusNode = FocusNode(); + final groupBKey = GlobalKey(); + + late BuildContext capturedContext; + await tester.pumpWidget(MaterialApp( + home: FocusTraversalGroup( + key: groupBKey, + policy: ReadingOrderTraversalPolicy(), + child: Builder(builder: (context) { + capturedContext = context; + return const SizedBox(key: ValueKey('target')); + }), + ), + )); + + final capturedGroup = groupBKey.currentWidget as FocusTraversalGroup; + final target = TVFocusTarget( + focusNode: focusNode, + focusOrder: const TVFocusOrder(0, 0), + row: 0, + order: 0, + context: capturedContext, + traversalGroup: capturedContext + .findAncestorWidgetOfExactType(), + ); + + // Captured group identity matches. + expect(target.isInTraversalGroup(capturedGroup), isTrue); + // A different live group is rejected without an ancestor walk. + expect( + target.isInTraversalGroup( + FocusTraversalGroup(policy: ReadingOrderTraversalPolicy(), child: const SizedBox()), + ), + isFalse, + ); + // No group queried => always in scope. + expect(target.isInTraversalGroup(null), isTrue); + + focusNode.dispose(); + }, + ); + + test('TVFocusTarget without a captured group never claims a group', () { + final target = TVFocusTarget( + focusNode: FocusNode(), + focusOrder: const TVFocusOrder(0, 0), + row: 0, + order: 0, + context: _FakeContext(), + ); + expect(target.isInTraversalGroup(null), isTrue); + expect( + target.isInTraversalGroup( + FocusTraversalGroup(policy: ReadingOrderTraversalPolicy(), child: const SizedBox()), + ), + isFalse, + ); + }); +} + +/// Minimal BuildContext whose ancestor walk returns null. +class _FakeContext implements BuildContext { + @override + dynamic noSuchMethod(Invocation invocation) => null; +} diff --git a/modules/ensemble/test/widget/tv_focus_scroll_test.dart b/modules/ensemble/test/widget/tv_focus_scroll_test.dart new file mode 100644 index 000000000..ed6a17745 --- /dev/null +++ b/modules/ensemble/test/widget/tv_focus_scroll_test.dart @@ -0,0 +1,139 @@ +import 'package:ensemble/framework/tv/tv_focus_scroll.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter_test/flutter_test.dart'; + +/// Verifies the opt-in `keepVisible` reveal helper used by TV focus scrolling. +/// +/// It must move the minimum amount required to reveal the target (matching +/// Flutter's default traversal), and it must not scroll a nested axis that the +/// caller excluded. +void main() { + testWidgets( + 'ensureWidgetVisible scrolls a vertical list just enough to reveal the item', + (tester) async { + final controller = ScrollController(); + final itemKey = GlobalKey(); + addTearDown(controller.dispose); + + await tester.pumpWidget(MaterialApp( + home: Scaffold( + // A Column inside a SingleChildScrollView keeps every item built, so + // the target's render object exists even while it is offscreen. + body: SingleChildScrollView( + controller: controller, + child: Column( + children: [ + for (var i = 0; i < 50; i++) + i == 40 + ? SizedBox(key: itemKey, height: 100) + : SizedBox(height: 100, child: Text('item $i')), + ], + ), + ), + ), + )); + + expect(controller.offset, 0); + await tester.pump(); + + // Item 40 spans content offset 4000..4100. A keepVisibleAtEnd reveal + // places its bottom at the viewport bottom (600): offset 3500. + await ensureWidgetVisible(itemKey.currentContext!); + await tester.pumpAndSettle(); + + expect(controller.offset, closeTo(3500, 1.0)); + }, + ); + + testWidgets( + 'ensureWidgetVisible is a no-op when the item is already fully visible', + (tester) async { + final controller = ScrollController(); + final itemKey = GlobalKey(); + addTearDown(controller.dispose); + + await tester.pumpWidget(MaterialApp( + home: Scaffold( + body: SingleChildScrollView( + controller: controller, + child: Column( + children: [ + for (var i = 0; i < 50; i++) + i == 2 + ? SizedBox(key: itemKey, height: 100) + : SizedBox(height: 100, child: Text('item $i')), + ], + ), + ), + ), + )); + + await tester.pump(); + expect(controller.offset, 0); + + await ensureWidgetVisible(itemKey.currentContext!); + await tester.pumpAndSettle(); + + expect(controller.offset, 0); + }, + ); + + testWidgets( + 'ensureWidgetVisible leaves an excluded axis untouched', + (tester) async { + // A Row inside a horizontal SingleChildScrollView builds every child, so + // the offscreen target exists; nesting it in a vertical list exercises + // the nested-scrollable walk. + final verticalController = ScrollController(); + final itemKey = GlobalKey(); + addTearDown(verticalController.dispose); + + await tester.pumpWidget(MaterialApp( + home: Scaffold( + body: SizedBox( + height: 400, + child: ListView( + controller: verticalController, + children: [ + SizedBox( + height: 100, + child: SingleChildScrollView( + scrollDirection: Axis.horizontal, + child: Row( + children: [ + for (var col = 0; col < 40; col++) + SizedBox( + key: col == 20 ? itemKey : null, + width: 100, + height: 100, + ), + ], + ), + ), + ), + ], + ), + ), + ), + )); + + await tester.pump(); + expect(verticalController.offset, 0); + expect(itemKey.currentContext, isNotNull); + + final horizontal = Scrollable.of(itemKey.currentContext!); + expect(horizontal.position.pixels, 0); + + // Exclude vertical: the outer list must stay put while the inner lane + // reveals the item horizontally. + await ensureWidgetVisible( + itemKey.currentContext!, + includeVertical: false, + ); + await tester.pumpAndSettle(); + + expect(verticalController.offset, 0); + expect(horizontal.position.pixels, greaterThan(0)); + }, + ); +}