{
);
}
+ /// Puts the focus on the first control of an error page as it appears, so a
+ /// D-pad has somewhere to start. A reader typing in the address field keeps
+ /// the focus there.
+ void _focusErrorPage(Object? error) {
+ if (identical(error, _focusedError)) return;
+ _focusedError = error;
+ if (error == null) return;
+ WidgetsBinding.instance.addPostFrameCallback((_) {
+ if (!mounted || _focusInPageField()) return;
+ _errorFocus.traversalDescendants.firstOrNull?.requestFocus();
+ });
+ }
+
/// Records the posts of a subscribed page once per page load, so what is new
/// shows up on the subscriptions and posts pages (spec 6.1). The controller
/// no-ops for a page the reader is not subscribed to.
diff --git a/app/test/app_widget_test.dart b/app/test/app_widget_test.dart
index bbc5272..e77c4c3 100644
--- a/app/test/app_widget_test.dart
+++ b/app/test/app_widget_test.dart
@@ -927,6 +927,184 @@ void main() {
debugDefaultTargetPlatformOverride = null;
});
+ group('a D-pad drives the reader on a touch host', () {
+ late AppDependencies stubDeps;
+
+ setUp(() {
+ stubDeps = AppDependencies(
+ database: MewsDatabase.test(NativeDatabase.memory()),
+ fetch: FakeFetch({
+ 'https://example.com/a.html': _page(
+ 'Page A',
+ 'Page A
To page B
'
+ 'To page C
'
+ ''
+ '${'Padding paragraph to make the page scroll.
' * 60}'
+ 'Far down to page C
'),
+ 'https://example.com/b.html':
+ _page('Page B', 'Page B
The second pages content.
'),
+ 'https://example.com/c.html':
+ _page('Page C', 'Page C
The third pages content.
'),
+ }),
+ strings: strings,
+ );
+ });
+
+ tearDown(() => stubDeps.db.close());
+
+ Future openA(WidgetTester tester) async {
+ debugDefaultTargetPlatformOverride = TargetPlatform.android;
+ await tester.pumpWidget(MewsApp(deps: stubDeps, strings: strings));
+ await tester.pumpAndSettle();
+ await stubDeps.browser.open('https://example.com/a.html');
+ await tester.pumpAndSettle();
+ }
+
+ Future press(WidgetTester tester, LogicalKeyboardKey key) async {
+ await tester.sendKeyDownEvent(key);
+ await tester.sendKeyUpEvent(key);
+ await tester.pumpAndSettle();
+ }
+
+ Future hold(WidgetTester tester, LogicalKeyboardKey key) async {
+ await tester.sendKeyDownEvent(key);
+ await tester.sendKeyRepeatEvent(key);
+ await tester.sendKeyRepeatEvent(key);
+ await tester.sendKeyUpEvent(key);
+ await tester.pumpAndSettle();
+ }
+
+ String? current() => stubDeps.browser.currentPage.value?.url;
+
+ ScrollPosition pageScroll(WidgetTester tester) => tester
+ .state(find
+ .descendant(of: find.byType(ListView), matching: find.byType(Scrollable))
+ .first)
+ .position;
+
+ testWidgets('Left and Right walk the links, the center opens one',
+ (tester) async {
+ await openA(tester);
+
+ await press(tester, LogicalKeyboardKey.arrowRight);
+ await press(tester, LogicalKeyboardKey.arrowRight);
+ await press(tester, LogicalKeyboardKey.arrowLeft);
+ await press(tester, LogicalKeyboardKey.select);
+ expect(current(), 'https://example.com/b.html');
+
+ debugDefaultTargetPlatformOverride = null;
+ });
+
+ testWidgets('holding Left, Right and the center navigates', (tester) async {
+ await openA(tester);
+ await stubDeps.browser.open('https://example.com/b.html');
+ await tester.pumpAndSettle();
+
+ await hold(tester, LogicalKeyboardKey.arrowLeft);
+ expect(current(), 'https://example.com/a.html');
+ await hold(tester, LogicalKeyboardKey.arrowRight);
+ expect(current(), 'https://example.com/b.html');
+ await hold(tester, LogicalKeyboardKey.select);
+ expect(current(), MewsUrls.hub);
+
+ debugDefaultTargetPlatformOverride = null;
+ });
+
+ testWidgets('Up and Down scroll, and the walk starts from the screen',
+ (tester) async {
+ await openA(tester);
+ final scroll = pageScroll(tester);
+
+ await press(tester, LogicalKeyboardKey.arrowDown);
+ expect(scroll.pixels, greaterThan(0));
+ await press(tester, LogicalKeyboardKey.arrowUp);
+ expect(scroll.pixels, 0);
+
+ // Scrolled to the end, Right takes the first link on screen rather
+ // than the first of the page.
+ scroll.jumpTo(scroll.maxScrollExtent);
+ await tester.pumpAndSettle();
+ await press(tester, LogicalKeyboardKey.arrowRight);
+ await press(tester, LogicalKeyboardKey.enter);
+ expect(current(), 'https://example.com/c.html');
+
+ debugDefaultTargetPlatformOverride = null;
+ });
+
+ testWidgets('the walk passes through form controls', (tester) async {
+ await openA(tester);
+ FocusNode? focused() => FocusManager.instance.primaryFocus;
+
+ await press(tester, LogicalKeyboardKey.arrowRight);
+ await press(tester, LogicalKeyboardKey.arrowRight);
+ await press(tester, LogicalKeyboardKey.arrowRight);
+ final field = tester.widget(find.descendant(
+ of: find.byType(TextFormField), matching: find.byType(EditableText)));
+ expect(focused(), field.focusNode);
+
+ // Typed text keeps Left for its cursor until the cursor is at the start.
+ await tester.enterText(find.byType(TextFormField), 'Al');
+ await press(tester, LogicalKeyboardKey.arrowRight);
+ expect(focused()?.context?.findAncestorWidgetOfExactType(),
+ isNotNull);
+ await press(tester, LogicalKeyboardKey.arrowLeft);
+ expect(focused(), field.focusNode);
+ await press(tester, LogicalKeyboardKey.arrowLeft);
+ await press(tester, LogicalKeyboardKey.arrowLeft);
+ expect(focused(), field.focusNode);
+ await press(tester, LogicalKeyboardKey.arrowLeft);
+ expect(field.focusNode.hasFocus, isFalse);
+ await press(tester, LogicalKeyboardKey.select);
+ expect(current(), 'https://example.com/c.html');
+
+ debugDefaultTargetPlatformOverride = null;
+ });
+
+ testWidgets('the arrows reach the buttons of an error page', (tester) async {
+ await openA(tester);
+ await stubDeps.browser.open('https://example.com/missing.html');
+ await tester.pumpAndSettle();
+ expect(find.text('Try again'), findsOneWidget);
+
+ // The first button takes the focus, and the arrows move between them.
+ BuildContext? focused() => FocusManager.instance.primaryFocus?.context;
+ expect(focused()?.findAncestorWidgetOfExactType(), isNotNull);
+ await press(tester, LogicalKeyboardKey.arrowDown);
+ expect(focused()?.findAncestorWidgetOfExactType(), isNotNull);
+
+ debugDefaultTargetPlatformOverride = null;
+ });
+
+ testWidgets('Menu shows the chrome and Down returns to the page',
+ (tester) async {
+ await openA(tester);
+ final scroll = pageScroll(tester);
+ await press(tester, LogicalKeyboardKey.arrowDown);
+ expect(find.byType(AddressBar), findsNothing);
+
+ await press(tester, LogicalKeyboardKey.contextMenu);
+ final address = tester.widget(find.byType(TextField).first);
+ expect(address.focusNode!.hasFocus, isTrue);
+
+ // Right moves past the text to the bar's buttons.
+ await press(tester, LogicalKeyboardKey.arrowRight);
+ await press(tester, LogicalKeyboardKey.arrowRight);
+ expect(address.focusNode!.hasFocus, isFalse);
+ await press(tester, LogicalKeyboardKey.arrowLeft);
+ expect(address.focusNode!.hasFocus, isTrue);
+
+ final before = scroll.pixels;
+ await press(tester, LogicalKeyboardKey.arrowDown);
+ expect(address.focusNode!.hasFocus, isFalse);
+ await press(tester, LogicalKeyboardKey.arrowDown);
+ expect(scroll.pixels, greaterThan(before));
+
+ debugDefaultTargetPlatformOverride = null;
+ });
+ });
+
testWidgets('bookmarks can be searched and removed from their own page',
(tester) async {
await pumpApp(tester);