diff --git a/SPEC.md b/SPEC.md index 8b61c3c..08ba2e4 100644 --- a/SPEC.md +++ b/SPEC.md @@ -77,7 +77,8 @@ Use the `actors` package only where Dart Isolate isolation is genuinely useful: The v1 client presents one reader screen: -- An address field pinned at the top; it hides when the reader scrolls down and returns when they scroll up or reach the top. Its leading icons open the hub and, on pointer platforms, go back and forward. Its trailing icons reload the page, subscribe to the site, and bookmark the page; subscribing is offered only on a page that carries dated links or declares an Atom feed. +- An address field pinned at the top; on touch devices it hides when the reader scrolls down and returns when they scroll up or reach the top, and on pointer hosts it stays on screen, since the keyboard reaches it whenever it is needed. Its leading icons open the hub and, on pointer platforms, go back and forward. Its trailing icons reload the page, subscribe to the site, and bookmark the page; subscribing is offered only on a page that carries dated links or declares an Atom feed. +- 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 swipe right goes back and a swipe left goes forward through the history stack. The whole empty margin beside the text column accepts the swipe, with a 40 px floor, so the gesture stays clear of selecting text. Touch devices navigate this way; pointer platforms get the address bar's buttons as well. - The client's own pages live under the `mews:` scheme. `mews:hub` names them and says what each one holds; `mews:posts`, `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 d127f33..485b82e 100644 --- a/app/lib/presentation/reader/address_bar.dart +++ b/app/lib/presentation/reader/address_bar.dart @@ -11,7 +11,11 @@ import '../app.dart'; /// subscribe (only when the page offers something to subscribe to) and /// bookmark the current page. class AddressBar extends StatefulWidget { - const AddressBar({super.key}); + const AddressBar({this.focusNode, super.key}); + + /// The reader can ask for the field's focus from outside, for a keyboard + /// shortcut. When null the bar owns its own node. + final FocusNode? focusNode; @override State createState() => _AddressBarState(); @@ -19,7 +23,7 @@ class AddressBar extends StatefulWidget { class _AddressBarState extends State { final _controller = TextEditingController(); - final _focus = FocusNode(); + late final FocusNode _focus = widget.focusNode ?? FocusNode(); bool _focused = false; @override @@ -40,7 +44,8 @@ class _AddressBarState extends State { @override void dispose() { - _focus.dispose(); + // A focus node handed in from outside belongs to whoever handed it in. + if (widget.focusNode == null) _focus.dispose(); _controller.dispose(); super.dispose(); } diff --git a/app/lib/presentation/reader/mews_page_view.dart b/app/lib/presentation/reader/mews_page_view.dart index 6a90ee8..3443172 100644 --- a/app/lib/presentation/reader/mews_page_view.dart +++ b/app/lib/presentation/reader/mews_page_view.dart @@ -1,8 +1,13 @@ import 'dart:typed_data'; +import 'package:flutter/services.dart' + show HardwareKeyboard, KeyDownEvent, LogicalKeyboardKey; + import 'dart:ui' as ui show TextDirection; import 'package:flutter/gestures.dart'; +import 'package:flutter/rendering.dart' + show RenderAbstractViewport, RenderObject, RenderParagraph; import 'package:flutter/material.dart'; import '../../l10n/strings.dart'; @@ -58,16 +63,64 @@ class _MewsPageViewState extends State { /// instead of asking again. A failure is forgotten, so trying again works. final _images = >{}; + /// The link the keyboard highlight sits on, or none; and the links of the + /// page as it was last built, counted in reading order. + int _focusedLink = -1; + int _linkCount = 0; + int _linkCounter = 0; + final _linkHrefs = []; + final _linkSpans = []; + final _region = FocusNode(); + + /// 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. + KeyEventResult _onPageKey(FocusNode node, KeyEvent event) { + if (event is! KeyDownEvent) return KeyEventResult.ignored; + final key = event.logicalKey; + if (key == LogicalKeyboardKey.tab) { + final forward = !HardwareKeyboard.instance.isShiftPressed; + if (_focusedLink < 0) { + if (!forward || _linkCount == 0) return KeyEventResult.ignored; + _focusLink(0); + return KeyEventResult.handled; + } + final next = _focusedLink + (forward ? 1 : -1); + if (next < 0 || next >= _linkCount) { + setState(() => _focusedLink = -1); + return KeyEventResult.ignored; + } + _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; + } + return KeyEventResult.ignored; + } + @override void didUpdateWidget(MewsPageView oldWidget) { super.didUpdateWidget(oldWidget); if (oldWidget.page != widget.page) { _disposeRecognizers(); + // The links walked by the keyboard belong to the page, so a new page + // starts with none highlighted. + _focusedLink = -1; } } @override void dispose() { + _region.dispose(); _disposeRecognizers(); super.dispose(); } @@ -85,55 +138,70 @@ class _MewsPageViewState extends State { final base = 17.0 * widget.fontScale; // body font-size: 106% final palette = widget.palette; + _linkHrefs.clear(); + _linkSpans.clear(); + _linkCounter = 0; final blocks = [ if (!widget.page.isMews) _notice(strings.t('error-non-mews'), base, palette), for (final block in widget.page.body) _block(context, block, base), ]; + _linkCount = _linkHrefs.length; - return Directionality( - textDirection: widget.page.direction == TextDirection.rtl - ? ui.TextDirection.rtl - : ui.TextDirection.ltr, - // Page text is the reader's to copy; forms manage their own selection. - child: SelectionArea( - child: ColoredBox( - color: palette.background, - child: ListView( - controller: widget.scrollController, - padding: EdgeInsets.all(base), - children: [ - Center( - child: ConstrainedBox( - // max-width: 40em for an article; our own pages are lists, so - // they get more room before they stop growing. - constraints: - BoxConstraints(maxWidth: (widget.ownPage ? 56 : 40) * base), - child: Column( - crossAxisAlignment: CrossAxisAlignment.stretch, - children: blocks, - ), + return Focus( + focusNode: _region, + onKeyEvent: _onPageKey, + // Pressing the page puts the keyboard focus on it, the way clicking a + // document body does in a browser; a Listener watches without joining + // the gesture arena, so links and scrolling keep their own taps. + child: Listener( + onPointerDown: (_) => _region.requestFocus(), + child: Directionality( + textDirection: widget.page.direction == TextDirection.rtl + ? ui.TextDirection.rtl + : ui.TextDirection.ltr, + // Page text is the reader's to copy; forms manage their own selection. + child: SelectionArea( + child: ColoredBox( + color: palette.background, + child: ListView( + controller: widget.scrollController, + padding: EdgeInsets.all(base), + children: [ + Center( + child: ConstrainedBox( + // max-width: 40em for an article; our own pages are lists, so + // they get more room before they stop growing. + constraints: BoxConstraints( + maxWidth: (widget.ownPage ? 56 : 40) * base, + ), + child: Column( + crossAxisAlignment: CrossAxisAlignment.stretch, + children: blocks, + ), + ), + ), + ], ), ), - ], - ), + ), ), ), ); } Widget _notice(String text, double base, MewsPalette palette) => Padding( - padding: EdgeInsets.only(bottom: base), - child: Text( - text, - style: TextStyle( - color: palette.foreground.withValues(alpha: 0.7), - fontFamily: widget.fontFamily, - fontFamilyFallback: fallbackFor(widget.fontFamily), - fontSize: base * 0.85, - ), - ), - ); + padding: EdgeInsets.only(bottom: base), + child: Text( + text, + style: TextStyle( + color: palette.foreground.withValues(alpha: 0.7), + fontFamily: widget.fontFamily, + fontFamilyFallback: fallbackFor(widget.fontFamily), + fontSize: base * 0.85, + ), + ), + ); // Blocks ------------------------------------------------------------------- @@ -149,191 +217,208 @@ class _MewsPageViewState extends State { return switch (block) { HeadingBlock(:final level, :final inline) => Padding( - padding: EdgeInsets.only( - top: level == 1 - ? base * 0.5 - : (widget.ownPage ? base : base * 1.6), - bottom: base * 0.5, - ), - child: Text.rich( - TextSpan(children: _inline(inline)), - style: foreground.copyWith( - fontWeight: FontWeight.bold, - height: 1.25, - fontSize: base * const [1.6, 1.3, 1.1, 1.0, 1.0, 1.0][level - 1], - // A section label on our own pages, not a document heading. - color: widget.ownPage && level > 1 - ? palette.foreground.withValues(alpha: 0.7) - : null, - letterSpacing: widget.ownPage && level > 1 ? 0.4 : null, - ), - ), + padding: EdgeInsets.only( + top: level == 1 ? base * 0.5 : (widget.ownPage ? base : base * 1.6), + bottom: base * 0.5, + ), + child: Text.rich( + TextSpan(children: _inline(inline)), + style: foreground.copyWith( + fontWeight: FontWeight.bold, + height: 1.25, + fontSize: base * const [1.6, 1.3, 1.1, 1.0, 1.0, 1.0][level - 1], + // A section label on our own pages, not a document heading. + color: widget.ownPage && level > 1 + ? palette.foreground.withValues(alpha: 0.7) + : null, + letterSpacing: widget.ownPage && level > 1 ? 0.4 : null, ), + ), + ), ParagraphBlock(:final inline) => Padding( - padding: EdgeInsets.only(bottom: widget.ownPage ? base * 0.35 : base), - child: Text.rich(TextSpan(children: _inline(inline)), style: foreground), - ), + padding: EdgeInsets.only(bottom: widget.ownPage ? base * 0.35 : base), + child: Text.rich( + TextSpan(children: _inline(inline)), + style: foreground, + ), + ), AddressBlock(:final inline) => Padding( - padding: EdgeInsets.only(bottom: base), - child: Text.rich( - TextSpan(children: _inline(inline)), - style: foreground.copyWith(fontStyle: FontStyle.italic), - ), - ), + padding: EdgeInsets.only(bottom: base), + child: Text.rich( + TextSpan(children: _inline(inline)), + style: foreground.copyWith(fontStyle: FontStyle.italic), + ), + ), QuoteBlock(:final children) => Container( - margin: EdgeInsets.only(bottom: base), - decoration: BoxDecoration( - border: BorderDirectional(start: BorderSide(color: palette.border, width: 3)), - ), - padding: EdgeInsetsDirectional.only(start: base), - child: Column( - crossAxisAlignment: CrossAxisAlignment.stretch, - children: [for (final child in children) _block(context, child, base)], - ), + margin: EdgeInsets.only(bottom: base), + decoration: BoxDecoration( + border: BorderDirectional( + start: BorderSide(color: palette.border, width: 3), ), + ), + padding: EdgeInsetsDirectional.only(start: base), + child: Column( + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + for (final child in children) _block(context, child, base), + ], + ), + ), PreBlock(:final text) => Container( - margin: EdgeInsets.only(bottom: base), - color: palette.preBackground, - padding: EdgeInsets.all(base * 0.75), - child: SingleChildScrollView( - scrollDirection: Axis.horizontal, - child: Text( - text, - style: foreground.copyWith( - fontFamily: 'monospace', - fontSize: base * 0.9, - ), - ), + margin: EdgeInsets.only(bottom: base), + color: palette.preBackground, + padding: EdgeInsets.all(base * 0.75), + child: SingleChildScrollView( + scrollDirection: Axis.horizontal, + child: Text( + text, + style: foreground.copyWith( + fontFamily: 'monospace', + fontSize: base * 0.9, ), ), + ), + ), RuleBlock() => Padding( - padding: EdgeInsets.symmetric( - vertical: widget.ownPage ? base * 0.6 : base * 2), - child: Divider(color: palette.border, height: 1), - ), + padding: EdgeInsets.symmetric( + vertical: widget.ownPage ? base * 0.6 : base * 2, + ), + child: Divider(color: palette.border, height: 1), + ), // Rows on our own pages: separated by a line rather than marked with a // bullet, so a list of bookmarks or settings reads as a list of rows. ListBlock(:final items) when widget.ownPage => Padding( - padding: EdgeInsets.only(bottom: base * 0.35), - child: Column( - crossAxisAlignment: CrossAxisAlignment.stretch, - children: [ - for (var i = 0; i < items.length; i++) ...[ - if (i > 0) Divider(color: palette.border, height: 1), - Padding( - padding: EdgeInsets.only(top: base * 0.45), - child: Column( - crossAxisAlignment: CrossAxisAlignment.stretch, - children: [ - for (final child in items[i].blocks) - _block(context, child, base), - ], - ), - ), - ], - ], - ), - ), - ListBlock(:final ordered, :final start, :final items) => Padding( - padding: EdgeInsets.only(bottom: base), - child: Column( - crossAxisAlignment: CrossAxisAlignment.stretch, - children: [ - for (var i = 0; i < items.length; i++) - Padding( - padding: EdgeInsets.only(bottom: base * 0.25), - child: Row( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - SizedBox( - width: base * 1.5, - child: Text( - ordered ? '${start + i}.' : '\u2022', - style: foreground, - ), - ), - Expanded( - child: Column( - crossAxisAlignment: CrossAxisAlignment.stretch, - children: [ - for (final child in items[i].blocks) _block(context, child, base), - ], - ), - ), - ], - ), - ), - ], - ), - ), - DefinitionListBlock(:final items) => Padding( - padding: EdgeInsets.only(bottom: base), - child: Column( - crossAxisAlignment: CrossAxisAlignment.stretch, - children: [ - for (final item in items) ...[ - Text.rich( - TextSpan(children: _inline(item.term)), - style: foreground.copyWith(fontWeight: FontWeight.bold), - ), - Padding( - padding: EdgeInsetsDirectional.only(start: base * 1.5, bottom: base * 0.5), - child: Text.rich(TextSpan(children: _inline(item.definition)), style: foreground), - ), - ], - ], - ), - ), - TableBlock(:final rows, :final caption) => Padding( - padding: EdgeInsets.only(bottom: base), - child: Column( - crossAxisAlignment: CrossAxisAlignment.stretch, - children: [ - if (caption != null) - Text(caption, - style: foreground.copyWith(fontWeight: FontWeight.bold)), - Table( - border: TableBorder.all(color: palette.border), - defaultVerticalAlignment: TableCellVerticalAlignment.top, + padding: EdgeInsets.only(bottom: base * 0.35), + child: Column( + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + for (var i = 0; i < items.length; i++) ...[ + if (i > 0) Divider(color: palette.border, height: 1), + Padding( + padding: EdgeInsets.only(top: base * 0.45), + child: Column( + crossAxisAlignment: CrossAxisAlignment.stretch, children: [ - for (final row in rows) - TableRow( - children: [ - for (final cell in row.cells) - TableCell( - child: Padding( - padding: EdgeInsets.symmetric( - horizontal: base * 0.6, - vertical: base * 0.3, - ), - child: Text.rich( - TextSpan(children: _inline(cell.inline)), - style: foreground.copyWith( - fontWeight: cell.header ? FontWeight.bold : null, - ), - ), - ), - ), - ], - ), + for (final child in items[i].blocks) + _block(context, child, base), ], ), + ), + ], + ], + ), + ), + ListBlock(:final ordered, :final start, :final items) => Padding( + padding: EdgeInsets.only(bottom: base), + child: Column( + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + for (var i = 0; i < items.length; i++) + Padding( + padding: EdgeInsets.only(bottom: base * 0.25), + child: Row( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + SizedBox( + width: base * 1.5, + child: Text( + ordered ? '${start + i}.' : '\u2022', + style: foreground, + ), + ), + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + for (final child in items[i].blocks) + _block(context, child, base), + ], + ), + ), + ], + ), + ), + ], + ), + ), + DefinitionListBlock(:final items) => Padding( + padding: EdgeInsets.only(bottom: base), + child: Column( + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + for (final item in items) ...[ + Text.rich( + TextSpan(children: _inline(item.term)), + style: foreground.copyWith(fontWeight: FontWeight.bold), + ), + Padding( + padding: EdgeInsetsDirectional.only( + start: base * 1.5, + bottom: base * 0.5, + ), + child: Text.rich( + TextSpan(children: _inline(item.definition)), + style: foreground, + ), + ), + ], + ], + ), + ), + TableBlock(:final rows, :final caption) => Padding( + padding: EdgeInsets.only(bottom: base), + child: Column( + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + if (caption != null) + Text( + caption, + style: foreground.copyWith(fontWeight: FontWeight.bold), + ), + Table( + border: TableBorder.all(color: palette.border), + defaultVerticalAlignment: TableCellVerticalAlignment.top, + children: [ + for (final row in rows) + TableRow( + children: [ + for (final cell in row.cells) + TableCell( + child: Padding( + padding: EdgeInsets.symmetric( + horizontal: base * 0.6, + vertical: base * 0.3, + ), + child: Text.rich( + TextSpan(children: _inline(cell.inline)), + style: foreground.copyWith( + fontWeight: cell.header + ? FontWeight.bold + : null, + ), + ), + ), + ), + ], + ), ], ), - ), + ], + ), + ), ImageBlock(:final src, :final alt, :final allowed) => Padding( - padding: EdgeInsets.only(bottom: base), - child: allowed ? _image(src, alt, base) : _imageFallback(alt, base), - ), + padding: EdgeInsets.only(bottom: base), + child: allowed ? _image(src, alt, base) : _imageFallback(alt, base), + ), FormBlock() => Padding( - padding: EdgeInsets.only(bottom: base), - child: _MewsForm( - form: block, - foreground: foreground, - onFormSubmit: widget.onFormSubmit, - ownPage: widget.ownPage, - ), - ), + padding: EdgeInsets.only(bottom: base), + child: _MewsForm( + form: block, + foreground: foreground, + onFormSubmit: widget.onFormSubmit, + ownPage: widget.ownPage, + ), + ), }; } @@ -359,23 +444,29 @@ class _MewsPageViewState extends State { }); return arriving; }), - builder: (context, snapshot) => snapshot.connectionState != ConnectionState.done + builder: (context, snapshot) => + snapshot.connectionState != ConnectionState.done ? const SizedBox.shrink() : snapshot.data == null - ? _imageFallback(alt, base, unavailable: true) - : Image.memory( - snapshot.data!, - fit: BoxFit.scaleDown, - errorBuilder: (_, _, _) => _imageFallback(alt, base, unavailable: true), - ), + ? _imageFallback(alt, base, unavailable: true) + : Image.memory( + snapshot.data!, + fit: BoxFit.scaleDown, + errorBuilder: (_, _, _) => + _imageFallback(alt, base, unavailable: true), + ), ); } Widget _imageFallback(String alt, double base, {bool unavailable = false}) { final palette = widget.palette; final strings = StringsScope.of(context); - final messageId = unavailable ? 'error-image-unavailable' : 'error-image-blocked'; - final message = alt.isEmpty ? strings.t(messageId) : '${strings.t(messageId)} $alt'; + final messageId = unavailable + ? 'error-image-unavailable' + : 'error-image-blocked'; + final message = alt.isEmpty + ? strings.t(messageId) + : '${strings.t(messageId)} $alt'; return Container( width: double.infinity, padding: EdgeInsets.all(base * 0.75), @@ -393,41 +484,177 @@ class _MewsPageViewState extends State { /// Builds the span tree. [linkRecognizer] carries the enclosing link's /// recognizer down to every glyph-owning span: hit-testing resolves to leaf /// spans only, so a recognizer on a parent span alone never fires. - List _inline(List inline, [TapGestureRecognizer? linkRecognizer]) { + List _inline( + List inline, [ + TapGestureRecognizer? linkRecognizer, + ]) { final palette = widget.palette; return [ for (final node in inline) switch (node) { - MewsText(:final text) => TextSpan(text: text, recognizer: linkRecognizer), + MewsText(:final text) => TextSpan( + text: text, + recognizer: linkRecognizer, + ), MewsLineBreak() => const TextSpan(text: '\n'), MewsEmphasis(:final style, :final children) => TextSpan( - style: switch (style) { - EmphasisStyle.strong => const TextStyle(fontWeight: FontWeight.bold), - EmphasisStyle.emphasis when widget.ownPage => TextStyle( - color: palette.foreground.withValues(alpha: 0.65), - fontSize: 15.0 * widget.fontScale, - ), - EmphasisStyle.emphasis => const TextStyle(fontStyle: FontStyle.italic), - EmphasisStyle.term => const TextStyle(fontStyle: FontStyle.italic), - EmphasisStyle.quote => const TextStyle(fontStyle: FontStyle.italic), - _ => const TextStyle(fontFamily: 'monospace'), - }, - children: _inline(children, linkRecognizer)), - MewsLink(:final href, :final children) => TextSpan( - style: TextStyle( - color: widget.visitedUrls.contains(href) ? palette.visited : palette.link, - // Underlines mark links in prose; on our own pages they are - // controls, so weight carries them instead. - decoration: - widget.ownPage ? TextDecoration.none : TextDecoration.underline, - fontWeight: widget.ownPage ? FontWeight.w500 : null, + style: switch (style) { + EmphasisStyle.strong => const TextStyle( + fontWeight: FontWeight.bold, ), - recognizer: _recognizer(node), - children: _inline(children, _recognizer(node))), + EmphasisStyle.emphasis when widget.ownPage => TextStyle( + color: palette.foreground.withValues(alpha: 0.65), + fontSize: 15.0 * widget.fontScale, + ), + EmphasisStyle.emphasis => const TextStyle( + fontStyle: FontStyle.italic, + ), + EmphasisStyle.term => const TextStyle( + fontStyle: FontStyle.italic, + ), + EmphasisStyle.quote => const TextStyle( + fontStyle: FontStyle.italic, + ), + _ => const TextStyle(fontFamily: 'monospace'), + }, + children: _inline(children, linkRecognizer), + ), + MewsLink(:final children) => _linkSpan(node, children, palette), }, ]; } + /// One link, in its place in the page's walk. Underlines mark links in + /// prose; on our own pages they are controls, so weight carries them + /// instead. The keyboard highlight underlines and tints whichever link + /// Tab has landed on, wherever it is. + TextSpan _linkSpan( + MewsLink link, + List children, + MewsPalette palette, + ) { + final focused = _focusedLink == _linkCounter; + _linkCounter++; + _linkHrefs.add(link.href); + return _recordedLink( + TextSpan( + style: TextStyle( + color: widget.visitedUrls.contains(link.href) + ? palette.visited + : palette.link, + decoration: focused || !widget.ownPage + ? TextDecoration.underline + : TextDecoration.none, + fontWeight: widget.ownPage ? FontWeight.w500 : null, + backgroundColor: focused + ? palette.link.withValues(alpha: 0.14) + : null, + ), + recognizer: _recognizer(link), + children: _inline(children, _recognizer(link)), + ), + ); + } + + /// Records a built link span, so the walk can find where it landed. + TextSpan _recordedLink(TextSpan span) { + _linkSpans.add(span); + return span; + } + + /// Moves the keyboard highlight and scrolls the page to keep the link in + /// view, the way focus traversal carries the viewport along. + void _focusLink(int index) { + setState(() => _focusedLink = index); + if (index >= 0) { + WidgetsBinding.instance.addPostFrameCallback((_) => _revealFocusedLink()); + } + } + + void _revealFocusedLink() { + 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, + ); + + // 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 + // to scroll to, and the walk rides there smoothly. + final viewport = RenderAbstractViewport.of(paragraph); + final reveal = viewport.getOffsetToReveal(paragraph, 0.5, rect: rect.inflate(8)); + final position = controller.position; + final target = reveal.offset + .clamp(position.minScrollExtent, position.maxScrollExtent) + .toDouble(); + position.animateTo( + target, + duration: const Duration(milliseconds: 200), + curve: Curves.easeOut, + ); + } + + /// 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; + void walk(RenderObject node) { + if (found != null) return; + if (node is RenderParagraph && _offsetWithin(node.text, span) != null) { + found = node; + return; + } + node.visitChildren(walk); + } + + walk(root); + return found; + } + + /// 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; + final children = span is TextSpan ? span.children : null; + if (children == null) return null; + var cursor = start; + for (final child in children) { + final result = search(child, cursor); + if (result != null) return result; + cursor += child.toPlainText().length; + } + return null; + } + + return search(root, 0); + } + TapGestureRecognizer _recognizer(MewsLink link) { return _recognizers.putIfAbsent(link, () { return TapGestureRecognizer()..onTap = () => widget.onLinkTap(link.href); @@ -476,15 +703,15 @@ class _MewsFormState extends State<_MewsForm> { final style = widget.foreground; Widget button(MewsSubmitField field) => FilledButton( - onPressed: () => field.reset ? _reset(form) : _submit(form), - child: Text( - field.label ?? - (field.reset - ? strings.t('form-reset-default') - : strings.t('form-submit-default')), - style: TextStyle(fontFamily: style.fontFamily), - ), - ); + onPressed: () => field.reset ? _reset(form) : _submit(form), + child: Text( + field.label ?? + (field.reset + ? strings.t('form-reset-default') + : strings.t('form-submit-default')), + style: TextStyle(fontFamily: style.fontFamily), + ), + ); if (widget.ownPage) { final inputs = form.fields.where((f) => f is! MewsSubmitField).toList(); @@ -524,47 +751,80 @@ class _MewsFormState extends State<_MewsForm> { Widget _field(MewsFormField field, TextStyle style) { Widget control = switch (field) { - MewsTextField(:final name, :final password, :final value, :final maxLength) => TextFormField( + MewsTextField( + :final name, + :final password, + :final value, + :final maxLength, + ) => + TextFormField( controller: _controllers.putIfAbsent( - name, () => TextEditingController(text: _values.putIfAbsent(name, () => value))), + name, + () => TextEditingController( + text: _values.putIfAbsent(name, () => value), + ), + ), obscureText: password, maxLength: maxLength, style: style, decoration: InputDecoration(labelText: field.label, isDense: true), ), - MewsTextAreaField(:final name, :final value, :final rows) => TextFormField( + MewsTextAreaField(:final name, :final value, :final rows) => + TextFormField( controller: _controllers.putIfAbsent( - name, () => TextEditingController(text: _values.putIfAbsent(name, () => value))), + name, + () => TextEditingController( + text: _values.putIfAbsent(name, () => value), + ), + ), minLines: rows, maxLines: rows, style: style, decoration: InputDecoration(labelText: field.label, isDense: true), ), MewsCheckboxField(:final name, :final checked) => CheckboxListTile( - title: field.label == null ? null : Text(field.label!, style: style), - value: _values.putIfAbsent(name, () => checked ? 'on' : '') == 'on', - onChanged: (value) => setState(() => _values[name] = value == true ? 'on' : ''), - ), - MewsRadioField(:final name, :final value, :final checked) => RadioGroup( + title: field.label == null ? null : Text(field.label!, style: style), + value: _values.putIfAbsent(name, () => checked ? 'on' : '') == 'on', + onChanged: (value) => + setState(() => _values[name] = value == true ? 'on' : ''), + ), + MewsRadioField(:final name, :final value, :final checked) => + RadioGroup( groupValue: _values.putIfAbsent(name, () => checked ? value : ''), onChanged: (value) => setState(() => _values[name] = value ?? ''), child: RadioListTile( value: value, - title: field.label == null ? null : Text(field.label!, style: style), + title: field.label == null + ? null + : Text(field.label!, style: style), ), ), - MewsSelectField(:final name, :final options) => options.isEmpty - ? const SizedBox.shrink() - : DropdownButtonFormField( - initialValue: _values.putIfAbsent( - name, - () => options.firstWhere((o) => o.selected, orElse: () => options.first).value), - items: [ - for (final option in options) DropdownMenuItem(value: option.value, child: Text(option.value)), - ], - onChanged: (value) => _values[name] = value ?? '', - decoration: InputDecoration(labelText: field.label, isDense: true), - ), + MewsSelectField(:final name, :final options) => + options.isEmpty + ? const SizedBox.shrink() + : DropdownButtonFormField( + initialValue: _values.putIfAbsent( + name, + () => options + .firstWhere( + (o) => o.selected, + orElse: () => options.first, + ) + .value, + ), + items: [ + for (final option in options) + DropdownMenuItem( + value: option.value, + child: Text(option.value), + ), + ], + onChanged: (value) => _values[name] = value ?? '', + decoration: InputDecoration( + labelText: field.label, + isDense: true, + ), + ), MewsSubmitField() => const SizedBox.shrink(), MewsHiddenField() => const SizedBox.shrink(), }; diff --git a/app/lib/presentation/reader/reader_screen.dart b/app/lib/presentation/reader/reader_screen.dart index 14beafb..66ddf51 100644 --- a/app/lib/presentation/reader/reader_screen.dart +++ b/app/lib/presentation/reader/reader_screen.dart @@ -1,5 +1,8 @@ +import 'package:flutter/foundation.dart' + show defaultTargetPlatform, TargetPlatform; import 'package:flutter/material.dart'; import 'package:flutter/rendering.dart' show ScrollDirection; +import 'package:flutter/services.dart' show LogicalKeyboardKey; import 'package:signals/signals_flutter.dart' show SignalBuilder; import '../../application/controllers/browser_controller.dart' @@ -32,13 +35,22 @@ class ReaderScreen extends StatefulWidget { class _ReaderScreenState extends State { final _scroll = ScrollController(); + final _addressFocus = FocusNode(); /// The address bar is on screen from the start, hidden while the reader - /// scrolls down, back on any upward scroll. + /// scrolls down, back on any upward scroll. A pointer host with a keyboard + /// has room to spare and keys to reach the bar, so it never hides. bool _chromeVisible = true; bool _showBrokenText = false; final _checkedSubscriptions = {}; + /// Touch devices navigate with gestures; the others are pointer hosts + /// whose readers reach the chrome with the mouse and the keyboard. + static bool get _isTouch => switch (defaultTargetPlatform) { + TargetPlatform.android || TargetPlatform.iOS => true, + _ => false, + }; + @override void initState() { super.initState(); @@ -49,10 +61,14 @@ class _ReaderScreenState extends State { void dispose() { _scroll.removeListener(_onScroll); _scroll.dispose(); + _addressFocus.dispose(); super.dispose(); } void _onScroll() { + // On a pointer host the bar stays on screen, so scrolling changes + // nothing about it. + if (!_isTouch) return; if (!_scroll.hasClients) return; final position = _scroll.position; @@ -70,7 +86,7 @@ class _ReaderScreenState extends State { Widget build(BuildContext context) { final deps = AppScope.of(context).deps; - return Scaffold( + final reader = Scaffold( body: Column( children: [ AnimatedSlide( @@ -82,7 +98,7 @@ class _ReaderScreenState extends State { curve: Curves.easeOut, alignment: Alignment.topCenter, child: _chromeVisible - ? const AddressBar() + ? AddressBar(focusNode: _addressFocus) : const SizedBox(width: double.infinity), ), ), @@ -134,6 +150,43 @@ class _ReaderScreenState extends State { ], ), ); + + // A pointer host drives the reader from the keyboard as well: the bar + // 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; + return CallbackShortcuts( + bindings: { + const SingleActivator(LogicalKeyboardKey.keyL, control: true): + _addressFocus.requestFocus, + const SingleActivator(LogicalKeyboardKey.keyL, meta: true): + _addressFocus.requestFocus, + const SingleActivator(LogicalKeyboardKey.keyR, control: true): + deps.browser.reload, + const SingleActivator(LogicalKeyboardKey.keyR, meta: true): + deps.browser.reload, + const SingleActivator(LogicalKeyboardKey.arrowLeft, alt: true): + deps.browser.back, + const SingleActivator(LogicalKeyboardKey.arrowRight, alt: true): + deps.browser.forward, + // The same walk, with the keys a Mac reader reaches for. + const SingleActivator(LogicalKeyboardKey.arrowLeft, meta: true): + deps.browser.back, + const SingleActivator(LogicalKeyboardKey.arrowRight, meta: true): + deps.browser.forward, + const SingleActivator(LogicalKeyboardKey.bracketLeft, meta: true): + deps.browser.back, + 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), + ); } Widget _body(BuildContext context, MewsPage? page) { diff --git a/app/test/app_widget_test.dart b/app/test/app_widget_test.dart index cfa6122..13db159 100644 --- a/app/test/app_widget_test.dart +++ b/app/test/app_widget_test.dart @@ -1,6 +1,7 @@ import 'dart:io' show File; import 'package:flutter/foundation.dart' show TargetPlatform, debugDefaultTargetPlatformOverride; +import 'package:flutter/services.dart' show LogicalKeyboardKey; import 'package:drift/native.dart'; import 'package:flutter/material.dart'; import 'package:flutter/rendering.dart' show RenderParagraph; @@ -529,6 +530,81 @@ void main() { debugDefaultTargetPlatformOverride = null; }); + testWidgets('the address bar stays on screen on pointer hosts', (tester) async { + debugDefaultTargetPlatformOverride = TargetPlatform.linux; + await pumpApp(tester); + + // A page long enough to scroll. + final tall = pageSource.replaceFirst( + '', + '

Padding paragraph to make the page scroll.

' * 80 + ''); + final page = parseSource(tall, 'https://example.com/index.html'); + deps.browser.currentPage.value = page; + await tester.pumpAndSettle(); + + // Scrolling down leaves the bar where it is: a pointer host has keys to + // reach it with, so it never hides. + await tester.drag(find.byType(ListView), const Offset(0, -300)); + await tester.pumpAndSettle(); + expect(find.byType(AddressBar), findsOneWidget); + + debugDefaultTargetPlatformOverride = null; + }); + + testWidgets('the keyboard drives the reader on pointer hosts', (tester) async { + final stubDeps = AppDependencies( + database: MewsDatabase.test(NativeDatabase.memory()), + fetch: FakeFetch({ + 'https://example.com/a.html': + _page('Page A', '

Page A

The first pages content.

'), + 'https://example.com/b.html': + _page('Page B', '

Page B

The second pages content.

'), + }), + strings: strings, + ); + debugDefaultTargetPlatformOverride = TargetPlatform.linux; + + await tester.pumpWidget(MewsApp(deps: stubDeps, strings: strings)); + await tester.pumpAndSettle(); + + await stubDeps.browser.open('https://example.com/a.html'); + await stubDeps.browser.open('https://example.com/b.html'); + await tester.pumpAndSettle(); + + // Alt+Left goes back through the walk, Alt+Right forward again. + await tester.sendKeyDownEvent(LogicalKeyboardKey.altLeft); + await tester.sendKeyDownEvent(LogicalKeyboardKey.arrowLeft); + await tester.pumpAndSettle(); + expect(stubDeps.browser.currentPage.value?.url, 'https://example.com/a.html'); + await tester.sendKeyUpEvent(LogicalKeyboardKey.arrowLeft); + await tester.sendKeyDownEvent(LogicalKeyboardKey.arrowRight); + await tester.pumpAndSettle(); + expect(stubDeps.browser.currentPage.value?.url, 'https://example.com/b.html'); + await tester.sendKeyUpEvent(LogicalKeyboardKey.altLeft); + await tester.sendKeyUpEvent(LogicalKeyboardKey.arrowRight); + + // The Mac spellings walk the same way. + await tester.sendKeyDownEvent(LogicalKeyboardKey.metaLeft); + await tester.sendKeyDownEvent(LogicalKeyboardKey.arrowLeft); + await tester.pumpAndSettle(); + expect(stubDeps.browser.currentPage.value?.url, 'https://example.com/a.html'); + await tester.sendKeyDownEvent(LogicalKeyboardKey.bracketRight); + await tester.pumpAndSettle(); + expect(stubDeps.browser.currentPage.value?.url, 'https://example.com/b.html'); + await tester.sendKeyUpEvent(LogicalKeyboardKey.metaLeft); + await tester.sendKeyUpEvent(LogicalKeyboardKey.bracketRight); + + // Ctrl+L hands the focus to the address field, with its text selected. + await tester.sendKeyDownEvent(LogicalKeyboardKey.controlLeft); + await tester.sendKeyDownEvent(LogicalKeyboardKey.keyL); + await tester.pump(); + final field = tester.widget(find.byType(TextField).first); + expect(field.focusNode!.hasFocus, isTrue); + + // The invariant check runs before test tear-downs, so reset by hand. + debugDefaultTargetPlatformOverride = null; + }); + testWidgets('bookmarks can be searched and removed from their own page', (tester) async { await pumpApp(tester); diff --git a/app/test/reader_widget_test.dart b/app/test/reader_widget_test.dart index 47a9fd3..8f264db 100644 --- a/app/test/reader_widget_test.dart +++ b/app/test/reader_widget_test.dart @@ -1,4 +1,6 @@ import 'dart:convert' show base64; + +import 'package:flutter/services.dart' show LogicalKeyboardKey; import 'dart:io' show File; import 'dart:typed_data'; @@ -80,6 +82,135 @@ void main() { expect(opened, 'https://example.com/next.html'); }); + testWidgets('Tab walks the links on a page', (tester) async { + final page = MewsPage( + url: 'https://example.com/index.html', + lang: 'en', + direction: TextDirection.ltr, + variant: MewsVariant.plain, + specVersion: '0.1', + entries: const [], + body: const [ + ParagraphBlock([MewsText('Read on for more.')]), + ParagraphBlock([ + MewsLink('https://example.com/first.html', [MewsText('the first page')]), + ]), + ParagraphBlock([ + MewsLink('https://example.com/second.html', [MewsText('the second page')]), + ]), + ], + ); + + final opened = []; + await tester.pumpWidget( + MaterialApp( + home: StringsScope( + strings: await _strings(), + child: MewsPageView( + page: page, + palette: paletteFor( + pageVariant: MewsVariant.plain, readerVariant: 'site', dark: false), + fontFamily: 'Atkinson Hyperlegible', + fontScale: 1.0, + visitedUrls: const {}, + onLinkTap: opened.add, + onFormSubmit: (_, _) {}, + ), + ), + ), + ); + + // Pressing the page puts the keyboard focus on it. + final body = tester.getRect(find.text('Read on for more.')); + await tester.tapAt(body.center); + await tester.pump(); + + // Tab highlights each link in reading order, and Enter opens the one + // highlighted. + Future press(LogicalKeyboardKey key) async { + await tester.sendKeyDownEvent(key); + await tester.pump(); + await tester.sendKeyUpEvent(key); + await tester.pump(); + } + + await press(LogicalKeyboardKey.tab); + await press(LogicalKeyboardKey.enter); + expect(opened, ['https://example.com/first.html']); + + await press(LogicalKeyboardKey.tab); + await press(LogicalKeyboardKey.enter); + expect(opened, ['https://example.com/first.html', 'https://example.com/second.html']); + + // Shift+Tab walks back, and Escape puts the highlight away, so Enter + // after it opens nothing. + await tester.sendKeyDownEvent(LogicalKeyboardKey.shiftLeft); + await tester.sendKeyDownEvent(LogicalKeyboardKey.tab); + await tester.pump(); + await tester.sendKeyUpEvent(LogicalKeyboardKey.tab); + await tester.sendKeyUpEvent(LogicalKeyboardKey.shiftLeft); + await press(LogicalKeyboardKey.enter); + expect(opened, ['https://example.com/first.html', 'https://example.com/second.html', + 'https://example.com/first.html']); + + await press(LogicalKeyboardKey.escape); + await press(LogicalKeyboardKey.enter); + expect(opened, hasLength(3)); + }); + + testWidgets('the scroll follows the Tab walk', (tester) async { + final page = MewsPage( + url: 'https://example.com/index.html', + lang: 'en', + direction: TextDirection.ltr, + variant: MewsVariant.plain, + specVersion: '0.1', + entries: const [], + body: [ + // Enough prose to push the links below the fold. + for (var i = 0; i < 40; i++) + ParagraphBlock([MewsText('Paragraph $i of the tall page.')]), + const ParagraphBlock([ + MewsLink('https://example.com/deep.html', [MewsText('a link down below')]), + ]), + ], + ); + + final scroll = ScrollController(); + await tester.pumpWidget( + MaterialApp( + home: StringsScope( + strings: await _strings(), + child: MewsPageView( + page: page, + palette: paletteFor( + pageVariant: MewsVariant.plain, readerVariant: 'site', dark: false), + fontFamily: 'Atkinson Hyperlegible', + fontScale: 1.0, + visitedUrls: const {}, + onLinkTap: (_) {}, + onFormSubmit: (_, _) {}, + scrollController: scroll, + ), + ), + ), + ); + await tester.pumpAndSettle(); + expect(scroll.offset, 0); + + // Pressing the page puts the keyboard focus on it, and the walk carries + // the viewport along to whichever link is highlighted. + final body = tester.getRect(find.text('Paragraph 0 of the tall page.')); + await tester.tapAt(body.center); + await tester.pump(); + await tester.sendKeyDownEvent(LogicalKeyboardKey.tab); + await tester.pump(); + await tester.sendKeyUpEvent(LogicalKeyboardKey.tab); + await tester.pumpAndSettle(); + + expect(scroll.offset, greaterThan(0)); + }); + testWidgets('an image on a capsule arrives over the protocol', (tester) async { // A 1x1 PNG, small enough to spell out. final png = Uint8List.fromList(base64.decode(