From f386ef2c5b22929b2da67307879ad758f09e2a93 Mon Sep 17 00:00:00 2001 From: randogoth Date: Mon, 12 Oct 2026 17:59:14 +0300 Subject: [PATCH] feat: drive the reader with a D-pad --- SPEC.md | 1 + app/lib/presentation/reader/address_bar.dart | 23 +- .../presentation/reader/mews_page_view.dart | 383 +++++++++++++++--- .../presentation/reader/reader_screen.dart | 99 ++++- app/test/app_widget_test.dart | 178 ++++++++ 5 files changed, 600 insertions(+), 84 deletions(-) diff --git a/SPEC.md b/SPEC.md index 841753a..5aee79e 100644 --- a/SPEC.md +++ b/SPEC.md @@ -82,6 +82,7 @@ The v1 client presents one reader screen: - A web page that is not a Mews page is not a broken page. The client reads pages as strict XML (spec 7.1) and the web is not well-formed XML, so such a page is named as the web page it is and offered two ways on: a simplified view, or the reader's own browser. The simplified view is the same markup read as far as the Mews subset reaches — elements Mews supports are shown, the rest are unwrapped and keep their content, and markup that is not content is left out. A form is the one exception to that: Mews has forms, but a web page's form is left out of the view, since the reader cannot usefully answer a search box or a sign-in here and submitting one would only land them on another page this client cannot show as Mews. Only the form goes; content it wrapped is kept. It renders through the same reader presentation as any page, so its links are followed in the app and fail back the same way, its text can be kept as a quotation, and it says it is simplified. Asking for it belongs to the address it was asked for, so a link from it that also fails explains itself rather than simplifying unasked. A page that carries the conformance marker and still will not parse is reported as the markup error it is: the marker, not the MIME type, is what claims a page as Mews, since Mews pages are served as `text/html`. - A table cannot hold another (spec 4.2), so a nested one — the old web's way of laying out a page — is shown as a table of its own, after the table whose cell held it, rather than flattened into that cell's text. A row belongs only to the table that owns it, never to one it is nested inside. Rows of unequal length are filled out to the widest, since a table with holes cannot be drawn; a cell spanning several columns sits in the first of them. - On pointer hosts the keyboard drives the reader: Ctrl or Cmd+L focuses the address field with its text selected, Ctrl or Cmd+R reloads the page, and the walk goes back and forward with Alt+Left and Alt+Right, or on a Mac with Cmd+Left, Cmd+Right, Cmd+[ and Cmd+]. Touch devices navigate with gestures instead, and none of these shortcuts are offered there. The links on a page are reachable by keyboard as well: the page is one stop in the focus order, and once it holds the focus, Tab highlights its links in reading order and Enter opens the highlighted one; the page scrolls to keep the highlighted link in view, Shift+Tab walks back, Escape puts the highlight away, and a Tab past either end hands the focus back to the chrome. +- A D-pad drives the reader on any host, so a phone or remote without a touch screen reaches everything. The page takes the focus when it appears, and an error page puts it on its first button. On a page, Up and Down scroll by three lines, and Left and Right walk the links and form controls in reading order, starting from the first one on screen when nothing highlighted is in view. The center opens the highlighted link. Holding Left goes back, holding Right goes forward, and holding the center opens the hub; a press acts when the key is let go, and a hold acts when the key starts to repeat. In a text field the arrows move the cursor and leave the field at the edge of its text, and Up or Down returns to the page. On a touch host the Menu key shows the address bar with the focus in its field and hides it again with the focus back on the page; Down leaves the field for the page, and Left or Right at the edge of its text moves to the bar's buttons. - A swipe right goes back and a swipe left goes forward through the history stack. On a pointer host the whole empty margin beside the text column accepts the swipe, with a 40 px floor, so the gesture stays clear of selecting text, and the address bar's buttons work as well. On a touch device, where the column leaves no margin, the page surface itself accepts the swipe: a finger that crosses the slop sideways, before the long press that starts a text selection completes, is swiping, while a finger that held still that long keeps its drag for the selection. - The client's own pages live under the `mews:` scheme. `mews:hub` names them and says what each one holds; `mews:posts`, `mews:quotations`, `mews:bookmarks`, `mews:subscriptions`, `mews:history` and `mews:settings` each carry a strip linking to the others. Bare `mews:` is the hub, and the hub is where a session with no history to return to starts. Each page is built as an ordinary Mews document and rendered by the same renderer as a fetched page, so it joins the history stack and the swipe transitions, and none of them is recorded in history or offered for subscription. - `mews:posts` is every post from every subscribed site in one list. The reader can order it by date, by site, or unread first, and limit it to unread posts; those choices ride in the address as a query string, so going back undoes one and a swipe preview shows the page the reader will land on. Unread posts are shown bold, and opening a post marks it read. diff --git a/app/lib/presentation/reader/address_bar.dart b/app/lib/presentation/reader/address_bar.dart index 485b82e..498016a 100644 --- a/app/lib/presentation/reader/address_bar.dart +++ b/app/lib/presentation/reader/address_bar.dart @@ -29,21 +29,24 @@ class _AddressBarState extends State { @override void initState() { super.initState(); - _focus.addListener(() { - if (_focus.hasFocus != _focused) { - setState(() => _focused = _focus.hasFocus); - if (_focused) { - _controller.selection = TextSelection( - baseOffset: 0, - extentOffset: _controller.text.length, - ); - } + _focus.addListener(_onFocusChange); + } + + void _onFocusChange() { + if (_focus.hasFocus != _focused) { + setState(() => _focused = _focus.hasFocus); + if (_focused) { + _controller.selection = TextSelection( + baseOffset: 0, + extentOffset: _controller.text.length, + ); } - }); + } } @override void dispose() { + _focus.removeListener(_onFocusChange); // A focus node handed in from outside belongs to whoever handed it in. if (widget.focusNode == null) _focus.dispose(); _controller.dispose(); diff --git a/app/lib/presentation/reader/mews_page_view.dart b/app/lib/presentation/reader/mews_page_view.dart index c33d914..986679f 100644 --- a/app/lib/presentation/reader/mews_page_view.dart +++ b/app/lib/presentation/reader/mews_page_view.dart @@ -3,7 +3,12 @@ import 'dart:async' show Timer; import 'dart:typed_data'; import 'package:flutter/services.dart' - show HardwareKeyboard, KeyDownEvent, LogicalKeyboardKey; + show + HardwareKeyboard, + KeyDownEvent, + KeyRepeatEvent, + KeyUpEvent, + LogicalKeyboardKey; import 'dart:ui' as ui show TextDirection; @@ -39,6 +44,11 @@ class MewsPageView extends StatefulWidget { this.scrollController, this.onSaveQuotation, this.ownPage = false, + this.focusNode, + this.autofocus = false, + this.onBack, + this.onForward, + this.onHub, super.key, }); @@ -69,6 +79,20 @@ class MewsPageView extends StatefulWidget { /// links read as controls rather than prose. final bool ownPage; + /// The page's keyboard focus, so the reader can hand it back from the + /// chrome. When null the page owns its own node. + final FocusNode? focusNode; + + /// Whether the page takes the keyboard focus when it appears, so a device + /// driven by a D-pad alone has somewhere for its keys to arrive. + final bool autofocus; + + /// What holding the D-pad left, right or center does. Null on a view the + /// reader does not navigate from, such as a swipe peek. + final VoidCallback? onBack; + final VoidCallback? onForward; + final VoidCallback? onHub; + @override State createState() => _MewsPageViewState(); } @@ -92,15 +116,43 @@ class _MewsPageViewState extends State { int _formCounter = 0; final _linkHrefs = []; final _linkSpans = []; - final _region = FocusNode(); + late final FocusNode _region = widget.focusNode ?? FocusNode(); + + /// One node around each form, by its place among the page's forms, so the + /// D-pad walk can find the form's controls. Kept across builds, since the + /// controls stay attached to them. + final _formNodes = []; + + /// The links and forms of the page as it was last built, in reading order: + /// a link by its index, a form by its node. + final _stops = []; + + /// The D-pad key pressed on the page, and whether it has been held long + /// enough to repeat. A key pressed elsewhere, such as the Enter that + /// submitted the address, does nothing when it is let go here. + LogicalKeyboardKey? _pressedKey; + bool _held = false; + + @override + void initState() { + super.initState(); + if (widget.autofocus) { + WidgetsBinding.instance.addPostFrameCallback((_) { + // A page arriving while the reader types elsewhere leaves them to it. + if (mounted && _editingText() == null) _region.requestFocus(); + }); + } + } /// Walks the keyboard through the page's links: Tab steps forward, /// Shift+Tab back, Enter opens the highlighted link, and Escape puts the /// highlight away. A Tab at either end leaves the walk to the focus - /// traversal, so the chrome comes and goes as usual. + /// traversal, so the chrome comes and goes as usual. The D-pad keys are + /// handled by [_onDpadKey]. KeyEventResult _onPageKey(FocusNode node, KeyEvent event) { - if (event is! KeyDownEvent) return KeyEventResult.ignored; final key = event.logicalKey; + if (_dpadKeys.contains(key) && !_modified()) return _onDpadKey(event); + if (event is! KeyDownEvent) return KeyEventResult.ignored; if (key == LogicalKeyboardKey.tab) { final forward = !HardwareKeyboard.instance.isShiftPressed; if (_focusedLink < 0) { @@ -116,12 +168,6 @@ class _MewsPageViewState extends State { _focusLink(next); return KeyEventResult.handled; } - if (key == LogicalKeyboardKey.enter || - key == LogicalKeyboardKey.numpadEnter) { - if (_focusedLink < 0) return KeyEventResult.ignored; - widget.onLinkTap(_linkHrefs[_focusedLink]); - return KeyEventResult.handled; - } if (key == LogicalKeyboardKey.escape && _focusedLink >= 0) { setState(() => _focusedLink = -1); return KeyEventResult.handled; @@ -129,6 +175,184 @@ class _MewsPageViewState extends State { return KeyEventResult.ignored; } + static final _vertical = { + LogicalKeyboardKey.arrowUp, + LogicalKeyboardKey.arrowDown, + }; + static final _horizontal = { + LogicalKeyboardKey.arrowLeft, + LogicalKeyboardKey.arrowRight, + }; + static final _center = { + LogicalKeyboardKey.select, + LogicalKeyboardKey.enter, + LogicalKeyboardKey.numpadEnter, + }; + static final _dpadKeys = {..._vertical, ..._horizontal, ..._center}; + + /// Whether a modifier is down, which makes an arrow a shortcut of the + /// reader's, such as Alt+Left, or a selection of the page's. + bool _modified() { + final keyboard = HardwareKeyboard.instance; + return keyboard.isAltPressed || + keyboard.isControlPressed || + keyboard.isMetaPressed || + keyboard.isShiftPressed; + } + + /// The text field holding the focus, if any. + EditableText? _editingText() => FocusManager.instance.primaryFocus?.context + ?.findAncestorWidgetOfExactType(); + + /// Drives the page from a D-pad: Up and Down scroll, Left and Right walk + /// the links and form controls, and the center opens the highlighted link. + /// Holding Left, Right or the center goes back, forward or to the hub, so + /// a press acts when it is let go and a hold acts on its first repeat. + /// Inside a text field the arrows move the cursor, and only leave the field + /// at its edges. + KeyEventResult _onDpadKey(KeyEvent event) { + final key = event.logicalKey; + final field = _editingText(); + final inForm = _formNodes.any((node) => node.hasFocus); + + if (_vertical.contains(key)) { + if (field != null && field.maxLines != 1) return KeyEventResult.ignored; + if (event is KeyUpEvent) return KeyEventResult.handled; + if (inForm) _region.requestFocus(); + _scrollBy(key == LogicalKeyboardKey.arrowDown ? 1 : -1); + return KeyEventResult.handled; + } + + // A form's own controls answer the center themselves. + if (_center.contains(key) && inForm) return KeyEventResult.ignored; + + if (field != null) { + final text = field.controller.value; + final rtl = Directionality.of(FocusManager.instance.primaryFocus!.context!) == + ui.TextDirection.rtl; + final towardEnd = (key == LogicalKeyboardKey.arrowRight) != rtl; + final atEdge = text.selection.isCollapsed && + text.selection.baseOffset == (towardEnd ? text.text.length : 0); + if (!atEdge) return KeyEventResult.ignored; + if (event is KeyDownEvent) { + _walk(forward: key == LogicalKeyboardKey.arrowRight); + } + return KeyEventResult.handled; + } + + switch (event) { + case KeyDownEvent(): + _pressedKey = key; + _held = false; + case KeyRepeatEvent(): + if (_pressedKey == key && !_held) { + _held = true; + final hold = switch (key) { + LogicalKeyboardKey.arrowLeft => widget.onBack, + LogicalKeyboardKey.arrowRight => widget.onForward, + _ => widget.onHub, + }; + hold?.call(); + } + case KeyUpEvent(): + if (_pressedKey == key && !_held) { + if (_horizontal.contains(key)) { + _walk(forward: key == LogicalKeyboardKey.arrowRight); + } else if (_focusedLink >= 0 && _onScreen(_focusedLink)) { + widget.onLinkTap(_linkHrefs[_focusedLink]); + } + } + _pressedKey = null; + } + return KeyEventResult.handled; + } + + /// Scrolls the page three lines of body text up or down. + void _scrollBy(int direction) { + final controller = widget.scrollController; + if (controller == null || !controller.hasClients) return; + final position = controller.position; + final target = (position.pixels + direction * 3 * 1.5 * 17.0 * widget.fontScale) + .clamp(position.minScrollExtent, position.maxScrollExtent) + .toDouble(); + position.animateTo( + target, + duration: const Duration(milliseconds: 100), + curve: Curves.easeOut, + ); + } + + /// The links of the page and the controls of its forms, in reading order: + /// a link by its index, a control by its focus node. + List _walkStops() => [ + for (final stop in _stops) + if (stop is FocusNode) + ...stop.traversalDescendants.where((node) => node.canRequestFocus) + else + stop, + ]; + + /// Moves the D-pad highlight to the next or previous stop. From no + /// highlight, or one scrolled out of view, it starts at the first stop + /// ahead of the screen in that direction. + void _walk({required bool forward}) { + final stops = _walkStops(); + final primary = FocusManager.instance.primaryFocus; + var current = primary == null ? -1 : stops.indexOf(primary); + if (current < 0 && _focusedLink >= 0) current = stops.indexOf(_focusedLink); + if (current >= 0 && !_onScreen(stops[current])) current = -1; + + final int next; + if (current >= 0) { + next = current + (forward ? 1 : -1); + } else { + final view = _globalRect(context.findRenderObject()); + if (view == null) return; + next = forward + ? stops.indexWhere((stop) => (_stopRect(stop)?.bottom ?? -1) > view.top) + : stops.lastIndexWhere( + (stop) => (_stopRect(stop)?.top ?? double.infinity) < view.bottom); + } + if (next < 0 || next >= stops.length) return; + + switch (stops[next]) { + case final int link: + if (_formNodes.any((node) => node.hasFocus)) _region.requestFocus(); + _focusLink(link); + case final FocusNode control: + setState(() => _focusedLink = -1); + control.requestFocus(); + Scrollable.ensureVisible( + control.context!, + alignment: 0.5, + duration: const Duration(milliseconds: 200), + curve: Curves.easeOut, + ); + } + } + + bool _onScreen(Object stop) { + final rect = _stopRect(stop); + final view = _globalRect(context.findRenderObject()); + return rect != null && view != null && rect.overlaps(view); + } + + /// Where [stop] is on screen, or null when it was not rendered. + Rect? _stopRect(Object stop) => switch (stop) { + final int link => switch (_linkBox(link)) { + (:final paragraph, :final rect) => + MatrixUtils.transformRect(paragraph.getTransformTo(null), rect), + null => null, + }, + final FocusNode control => _globalRect(control.context?.findRenderObject()), + _ => null, + }; + + Rect? _globalRect(RenderObject? object) { + if (object is! RenderBox || !object.hasSize) return null; + return object.localToGlobal(Offset.zero) & object.size; + } + @override void didUpdateWidget(MewsPageView oldWidget) { super.didUpdateWidget(oldWidget); @@ -142,7 +366,11 @@ class _MewsPageViewState extends State { @override void dispose() { - _region.dispose(); + // A focus node handed in from outside belongs to whoever handed it in. + if (widget.focusNode == null) _region.dispose(); + for (final node in _formNodes) { + node.dispose(); + } _disposeRecognizers(); super.dispose(); } @@ -162,6 +390,7 @@ class _MewsPageViewState extends State { _linkHrefs.clear(); _linkSpans.clear(); + _stops.clear(); _linkCounter = 0; _formCounter = 0; final blocks = [ @@ -506,18 +735,32 @@ class _MewsPageViewState extends State { // the reader types in this box is matched up with the box they are // typing in, so the text and the cursor stay where they are. The // position keeps two forms with one action apart. - key: ValueKey('form:${_formCounter++}:${block.action}'), - child: _MewsForm( - form: block, - foreground: foreground, - onFormSubmit: widget.onFormSubmit, - onFormChange: widget.onFormChange, - ownPage: widget.ownPage, + key: ValueKey('form:$_formCounter:${block.action}'), + child: Focus( + focusNode: _formNode(), + canRequestFocus: false, + skipTraversal: true, + child: _MewsForm( + form: block, + foreground: foreground, + onFormSubmit: widget.onFormSubmit, + onFormChange: widget.onFormChange, + ownPage: widget.ownPage, + ), ), ), }; } + /// The node around the next form built, recorded as a stop of the walk. + FocusNode _formNode() { + final index = _formCounter++; + if (index == _formNodes.length) _formNodes.add(FocusNode()); + final node = _formNodes[index]; + _stops.add(node); + return node; + } + /// An allowed image, from the platform on the web or from the protocol's /// own bytes on a capsule. A blocked or unreachable image falls back to its /// alt text, the way a missing image does. @@ -630,6 +873,7 @@ class _MewsPageViewState extends State { MewsPalette palette, ) { final focused = _focusedLink == _linkCounter; + _stops.add(_linkCounter); _linkCounter++; _linkHrefs.add(link.href); return _recordedLink( @@ -671,28 +915,9 @@ class _MewsPageViewState extends State { if (!mounted || _focusedLink < 0) return; final controller = widget.scrollController; if (controller == null || !controller.hasClients) return; - final span = _linkSpans[_focusedLink]; - - // The highlighted link is glyphs inside one of the page's paragraphs; - // the walk finds which one, and where the link sits in it. - var paragraph = renderParagraphOf(context.findRenderObject(), span); - if (paragraph == null) return; - final offset = _offsetWithin(paragraph.text, span); - if (offset == null) return; - final boxes = paragraph.getBoxesForSelection( - TextSelection( - baseOffset: offset, - extentOffset: offset + span.toPlainText().length, - ), - ); - if (boxes.isEmpty) return; - // A link wrapped across lines shows every line of it, not just the first. - final rect = Rect.fromLTRB( - boxes.first.left, - boxes.first.top, - boxes.last.right, - boxes.last.bottom, - ); + final box = _linkBox(_focusedLink); + if (box == null) return; + final (:paragraph, :rect) = box; // The boxes are in the paragraph's own coordinates, which is what the // viewport asks for when revealing; the offset it answers with is where @@ -710,45 +935,73 @@ class _MewsPageViewState extends State { ); } - /// The rendered paragraph holding [span], searched among [root]'s - /// descendants, or null when the page did not render one. - RenderParagraph? renderParagraphOf(RenderObject? root, TextSpan span) { - if (root == null) return null; - RenderParagraph? found; + /// The paragraph showing link [index], and where the link sits in it. + ({RenderParagraph paragraph, Rect rect})? _linkBox(int index) { + final span = _linkSpans[index]; + + // A link is glyphs inside one of the page's paragraphs; the walk finds + // which one, and where the link sits in it. A paragraph rebuilt with + // equal text keeps its old spans, so links are matched by equality, and + // a link equal to earlier ones is found as their next occurrence. + var skip = _linkSpans.take(index).where((other) => other == span).length; + RenderParagraph? paragraph; + var offset = 0; void walk(RenderObject node) { - if (found != null) return; - if (node is RenderParagraph && _offsetWithin(node.text, span) != null) { - found = node; - return; + if (paragraph != null) return; + if (node is RenderParagraph) { + for (final at in _offsetsWithin(node.text, span)) { + if (skip-- > 0) continue; + paragraph = node; + offset = at; + return; + } } node.visitChildren(walk); } - walk(root); - return found; + final root = context.findRenderObject(); + if (root != null) walk(root); + final found = paragraph; + if (found == null) return null; + final boxes = found.getBoxesForSelection( + TextSelection( + baseOffset: offset, + extentOffset: offset + span.toPlainText().length, + ), + ); + if (boxes.isEmpty) return null; + // A link wrapped across lines covers every line of it, not just the first. + final rect = Rect.fromLTRB( + boxes.first.left, + boxes.first.top, + boxes.last.right, + boxes.last.bottom, + ); + return (paragraph: found, rect: rect); } - /// Where [target] begins in the text of [root], counting characters down - /// the span tree, or null when it is not among [root]'s descendants. - /// Where [target] begins in the text of [root], counting characters down - /// the span tree, or null when it is not among [root]'s descendants. - /// TextSpan.visitChildren walks whole subtrees, so this walks the children - /// itself: one level per step, one character count per sibling. - int? _offsetWithin(InlineSpan root, InlineSpan target) { - int? search(InlineSpan span, int start) { - if (identical(span, target)) return start; + /// Where each span equal to [target] begins in the text of [root], + /// counting characters down the span tree. TextSpan.visitChildren walks + /// whole subtrees, so this walks the children itself: one level per step, + /// one character count per sibling. + List _offsetsWithin(InlineSpan root, InlineSpan target) { + final found = []; + void search(InlineSpan span, int start) { + if (span == target) { + found.add(start); + return; + } final children = span is TextSpan ? span.children : null; - if (children == null) return null; + if (children == null) return; var cursor = start; for (final child in children) { - final result = search(child, cursor); - if (result != null) return result; + search(child, cursor); cursor += child.toPlainText().length; } - return null; } - return search(root, 0); + search(root, 0); + return found; } TapGestureRecognizer _recognizer(MewsLink link) { diff --git a/app/lib/presentation/reader/reader_screen.dart b/app/lib/presentation/reader/reader_screen.dart index efdd9f7..b7b7604 100644 --- a/app/lib/presentation/reader/reader_screen.dart +++ b/app/lib/presentation/reader/reader_screen.dart @@ -7,7 +7,7 @@ import 'package:flutter/gestures.dart' import 'package:url_launcher/url_launcher.dart'; import 'package:flutter/material.dart'; import 'package:flutter/rendering.dart' show ScrollDirection; -import 'package:flutter/services.dart' show LogicalKeyboardKey; +import 'package:flutter/services.dart' show KeyDownEvent, LogicalKeyboardKey; import 'package:signals/signals_flutter.dart' show SignalBuilder; import '../../application/controllers/browser_controller.dart' @@ -40,6 +40,12 @@ class ReaderScreen extends StatefulWidget { class _ReaderScreenState extends State { final _scroll = ScrollController(); final _addressFocus = FocusNode(); + final _pageFocus = FocusNode(); + + /// Around the error pages, which have no page view to take the focus; and + /// the error whose page last had its first control focused. + final _errorFocus = FocusNode(canRequestFocus: false, skipTraversal: true); + Object? _focusedError; /// The address bar is on screen from the start, hidden while the reader /// scrolls down, back on any upward scroll. A pointer host with a keyboard @@ -80,6 +86,8 @@ class _ReaderScreenState extends State { _scroll.removeListener(_onScroll); _scroll.dispose(); _addressFocus.dispose(); + _pageFocus.dispose(); + _errorFocus.dispose(); super.dispose(); } @@ -144,6 +152,51 @@ class _ReaderScreenState extends State { action(); }; + /// The D-pad keys the page leaves to the reader. On a touch host the Menu + /// key shows the chrome with the focus in the address field, and hides it + /// with the focus back on the page. The address field keeps the arrows for + /// its cursor, so Down returns to the page, and Left or Right at the edge + /// of the text moves on to the bar's buttons. + KeyEventResult _onReaderKey(FocusNode node, KeyEvent event) { + if (event is! KeyDownEvent) return KeyEventResult.ignored; + final key = event.logicalKey; + + if (key == LogicalKeyboardKey.contextMenu && _isTouch) { + final show = !_chromeVisible; + setState(() => _chromeVisible = show); + if (show) { + // The field is built with the chrome, a frame from now. + WidgetsBinding.instance + .addPostFrameCallback((_) => _addressFocus.requestFocus()); + } else { + _pageFocus.requestFocus(); + } + return KeyEventResult.handled; + } + + if (!_addressFocus.hasPrimaryFocus) return KeyEventResult.ignored; + if (key == LogicalKeyboardKey.arrowDown) { + _pageFocus.requestFocus(); + return KeyEventResult.handled; + } + final direction = switch (key) { + LogicalKeyboardKey.arrowLeft => TraversalDirection.left, + LogicalKeyboardKey.arrowRight => TraversalDirection.right, + _ => null, + }; + if (direction == null) return KeyEventResult.ignored; + final text = (_addressFocus.context! + .findAncestorWidgetOfExactType())! + .controller + .value; + final atEdge = text.selection.isCollapsed && + text.selection.baseOffset == + (direction == TraversalDirection.right ? text.text.length : 0); + if (!atEdge) return KeyEventResult.ignored; + _addressFocus.focusInDirection(direction); + return KeyEventResult.handled; + } + @override Widget build(BuildContext context) { final deps = AppScope.of(context).deps; @@ -236,8 +289,19 @@ class _ReaderScreenState extends State { // takes an address (Ctrl or Cmd+L, which selects what is in it), the page // reloads (Ctrl or Cmd+R), and the walk goes back and forward // (Alt+Left/Right, or Cmd+Left/Right and Cmd+[ and Cmd+] on a Mac). - // Touch hosts navigate with gestures instead. - if (_isTouch) return reader; + // Touch hosts navigate with gestures, or with a D-pad, instead. + // + // Key events reach only the focused branch, and a session starts with + // nothing focused; this node gives them a place to arrive until the page + // takes the focus. Tapping the address field or a control moves the + // focus deeper in, where the shortcuts still apply. + final keyed = Focus( + autofocus: true, + skipTraversal: true, + onKeyEvent: _onReaderKey, + child: reader, + ); + if (_isTouch) return keyed; return CallbackShortcuts( bindings: { const SingleActivator(LogicalKeyboardKey.keyL, control: true): @@ -262,11 +326,7 @@ class _ReaderScreenState extends State { const SingleActivator(LogicalKeyboardKey.bracketRight, meta: true): deps.browser.forward, }, - // Key events reach only the focused branch, and a session starts with - // nothing focused; this node gives them a place to arrive. Tapping the - // address field or a control moves the focus deeper in, where the - // shortcuts still apply. - child: Focus(autofocus: true, skipTraversal: true, child: reader), + child: keyed, ); } @@ -276,7 +336,8 @@ class _ReaderScreenState extends State { if (page == null) { return SignalBuilder(builder: (context) { final error = deps.browser.error.value; - return switch (error) { + _focusErrorPage(error); + final body = switch (error) { final BrokenPage broken => _errorPage(context, broken), LoadFailed() => _errorPage(context, null), // A protocol status carries the site's own words for what went @@ -299,9 +360,11 @@ class _ReaderScreenState extends State { CertificateChanged() => _certificateErrorPage(context), null => _emptyPage(context), }; + return Focus(focusNode: _errorFocus, child: body); }); } + _focusedError = null; _checkSubscription(context, page); return _pageView(context, page); @@ -369,6 +432,11 @@ class _ReaderScreenState extends State { }, fetchImageBytes: deps.browser.fetchImageBytes, scrollController: _scroll, + focusNode: _pageFocus, + autofocus: true, + onBack: deps.browser.back, + onForward: deps.browser.forward, + onHub: () => deps.browser.open(MewsUrls.hub), ); }); } @@ -626,6 +694,19 @@ class _ReaderScreenState extends State { ); } + /// 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);