import 'dart:async' show Timer; import 'dart:typed_data'; import 'package:flutter/services.dart' show HardwareKeyboard, KeyDownEvent, KeyRepeatEvent, KeyUpEvent, 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'; import '../../application/fonts.dart'; import '../../mews/document.dart'; import '../../mews/html.dart' show htmlSpecVersion; import 'palettes.dart'; /// How long the reader has to stop typing before a search box narrows its /// page. Long enough that a word is one query rather than one per letter. const searchTypingPause = Duration(milliseconds: 250); /// Renders a parsed [MewsPage] with the client's own presentation, following /// the measurements of the default stylesheet mews-0.1.css: a 40em text /// column, 1.5 line height, and the block margins authors expect. class MewsPageView extends StatefulWidget { const MewsPageView({ required this.page, required this.palette, required this.fontFamily, required this.fontScale, required this.visitedUrls, required this.onLinkTap, required this.onFormSubmit, this.onFormChange, this.fetchImageBytes, this.scrollController, this.onSaveQuotation, this.ownPage = false, this.focusNode, this.autofocus = false, this.onBack, this.onForward, this.onHub, super.key, }); final MewsPage page; final MewsPalette palette; final String fontFamily; final double fontScale; final Set visitedUrls; final void Function(String url) onLinkTap; final void Function(FormBlock form, Map values) onFormSubmit; /// Called as the reader types, so a search box can narrow its page without /// being submitted. Null on a page whose forms only answer when submitted. final void Function(FormBlock form, Map values)? onFormChange; /// The bytes of an image living on a protocol page, where no address exists /// that the platform could load from. Null when the page holds none. final Future Function(String url)? fetchImageBytes; final ScrollController? scrollController; /// Called with text the reader marked and chose to keep as a quotation. /// Null on a view that cannot keep one, such as a swipe peek. final void Function(String text)? onSaveQuotation; /// Whether this is one of the client's own pages. Those are built from the /// same blocks, but they are interface rather than an article, so they are /// laid out denser and wider, rows are divided instead of bulleted, and /// 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(); } class _MewsPageViewState extends State { final _recognizers = {}; /// Images already asked for, so a rebuild shows the same one arriving /// 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; /// Forms of the page, counted in reading order as it is built. A web page /// can carry the same form twice — a search box in both a desktop and a /// mobile header — so the action alone does not identify one. int _formCounter = 0; final _linkHrefs = []; final _linkSpans = []; 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. The D-pad keys are /// handled by [_onDpadKey]. KeyEventResult _onPageKey(FocusNode node, KeyEvent event) { 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) { 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.escape && _focusedLink >= 0) { setState(() => _focusedLink = -1); return KeyEventResult.handled; } 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); 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() { // 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(); } void _disposeRecognizers() { for (final recognizer in _recognizers.values) { recognizer.dispose(); } _recognizers.clear(); } @override Widget build(BuildContext context) { final strings = StringsScope.of(context); final base = 17.0 * widget.fontScale; // body font-size: 106% final palette = widget.palette; _linkHrefs.clear(); _linkSpans.clear(); _stops.clear(); _linkCounter = 0; _formCounter = 0; final blocks = [ // A converted web page says so: what it shows is only the part of it // the Mews subset reaches. if (widget.page.specVersion == htmlSpecVersion) _notice(strings.t('error-simplified-view'), base, palette) else 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 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 and to keep; forms manage // their own selection. The toolbar offers to keep the marked // text as a quotation, so the selection's text is carried here. child: SelectionArea( onSelectionChanged: (content) => _markedText = content?.plainText, contextMenuBuilder: (context, selectableRegion) => _selectionToolbar(context, selectableRegion), 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, ), ), ), ], ), ), ), ), ), ); } /// The text of the selection now on the page, if any, so the toolbar can /// offer to keep it. String? _markedText; /// The selection toolbar: the platform's own buttons, plus the choice to /// keep the marked text as a quotation when a place to keep it is given. Widget _selectionToolbar( BuildContext context, SelectableRegionState selectableRegion) { final items = selectableRegion.contextMenuButtonItems; final marked = _markedText?.trim(); if (widget.onSaveQuotation != null && marked != null && marked.isNotEmpty) { items.insert( 0, ContextMenuButtonItem( type: ContextMenuButtonType.custom, label: StringsScope.of(context).t('quotations-save'), onPressed: () { widget.onSaveQuotation!(marked); selectableRegion.hideToolbar(); selectableRegion.clearSelection(); }, ), ); } return AdaptiveTextSelectionToolbar.buttonItems( anchors: selectableRegion.contextMenuAnchors, buttonItems: items, ); } /// A table, every row padded out to the width of the widest. Flutter's /// Table requires each row to hold the same number of cells, and a web /// page's rows are routinely ragged. A colspan is not honored, so a cell /// that spans several columns simply sits in the first of them. Widget _table( List rows, String? caption, double base, TextStyle foreground, ) { final palette = widget.palette; var width = 0; for (final row in rows) { if (row.cells.length > width) width = row.cells.length; } // Nothing to draw, and a Table needs at least one column. if (width == 0) return const SizedBox.shrink(); return 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 (var i = 0; i < width; i++) TableCell( child: Padding( padding: EdgeInsets.symmetric( horizontal: base * 0.6, vertical: base * 0.3, ), child: Text.rich( TextSpan( children: i < row.cells.length ? _inline(row.cells[i].inline) : const [], ), style: foreground.copyWith( fontWeight: i < row.cells.length && row.cells[i].header ? FontWeight.bold : null, ), ), ), ), ], ), ], ), ], ), ); } 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, ), ), ); // Blocks ------------------------------------------------------------------- Widget _block(BuildContext context, MewsBlock block, double base) { final palette = widget.palette; final foreground = TextStyle( color: palette.foreground, fontFamily: widget.fontFamily, fontFamilyFallback: fallbackFor(widget.fontFamily), fontSize: base, height: 1.5, ); 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, ), ), ), ParagraphBlock(:final inline) => Padding( 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), ), ), 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), ], ), ), 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, ), ), ), ), RuleBlock() => Padding( 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) => _table(rows, caption, base, foreground), ImageBlock(:final src, :final alt, :final allowed) => Padding( padding: EdgeInsets.only(bottom: base), child: allowed ? _image(src, alt, base) : _imageFallback(alt, base), ), FormBlock() => Padding( padding: EdgeInsets.only(bottom: base), // Keyed by where it sits and what it submits to: a page rebuilt as // 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: 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. Widget _image(String src, String alt, double base) { final scheme = Uri.tryParse(src)?.scheme; if (scheme != 'gemini' && scheme != 'spartan') { return Image.network( src, fit: BoxFit.scaleDown, errorBuilder: (_, _, _) => _imageFallback(alt, base, unavailable: true), ); } final loader = widget.fetchImageBytes; if (loader == null) return _imageFallback(alt, base, unavailable: true); return FutureBuilder( future: _images.putIfAbsent(src, () { final arriving = loader(src); arriving.then((bytes) { if (bytes == null) _images.remove(src); }); return arriving; }), 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), ), ); } 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'; return Container( width: double.infinity, padding: EdgeInsets.all(base * 0.75), decoration: BoxDecoration( border: Border.all(color: palette.border), color: palette.preBackground, ), child: Text( message, style: TextStyle(color: palette.foreground, fontSize: base * 0.9), ), ); } /// 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, ]) { final palette = widget.palette; return [ for (final node in inline) switch (node) { 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 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; _stops.add(_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 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 // 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 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 (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); } 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 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; var cursor = start; for (final child in children) { search(child, cursor); cursor += child.toPlainText().length; } } search(root, 0); return found; } TapGestureRecognizer _recognizer(MewsLink link) { return _recognizers.putIfAbsent(link, () { return TapGestureRecognizer()..onTap = () => widget.onLinkTap(link.href); }); } } /// A form rendered from the page model. Submitting sends the field values to /// the browser controller; reset restores the author's initial values. class _MewsForm extends StatefulWidget { const _MewsForm({ required this.form, required this.foreground, required this.onFormSubmit, this.onFormChange, this.ownPage = false, }); final FormBlock form; final TextStyle foreground; final void Function(FormBlock form, Map values) onFormSubmit; final void Function(FormBlock form, Map values)? onFormChange; /// On our own pages a form is a search box or a one-field prompt, so it sits /// on one line instead of filling the column like a page's own form. final bool ownPage; @override State<_MewsForm> createState() => _MewsFormState(); } class _MewsFormState extends State<_MewsForm> { final _controllers = {}; final _values = {}; /// Waits out [searchTypingPause] before the page is narrowed. Timer? _typing; /// A page that narrows as the reader types is rebuilt under the box they are /// typing in, and the rebuilt box carries what they typed, so the text is /// left alone. When the page changes it itself, as clearing a search does, /// the box follows. @override void didUpdateWidget(_MewsForm oldWidget) { super.didUpdateWidget(oldWidget); final wasValues = _modelValues(oldWidget.form); _modelValues(widget.form).forEach((name, value) { if (wasValues[name] == value) return; final controller = _controllers[name]; if (controller == null || controller.text == value) return; controller.value = TextEditingValue( text: value, selection: TextSelection.collapsed(offset: value.length), ); _values[name] = value; }); } Map _modelValues(FormBlock form) => { for (final field in form.fields) if (field case MewsTextField(:final name, :final value)) name: value, }; @override void dispose() { _typing?.cancel(); for (final controller in _controllers.values) { controller.dispose(); } super.dispose(); } @override Widget build(BuildContext context) { final strings = StringsScope.of(context); final form = widget.form; 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), ), ); if (widget.ownPage) { final inputs = form.fields.where((f) => f is! MewsSubmitField).toList(); return Form( child: Row( crossAxisAlignment: CrossAxisAlignment.center, children: [ for (final field in inputs) if (field is! MewsHiddenField) Expanded(child: _field(field, style)) else _field(field, style), for (final field in form.fields.whereType()) Padding( padding: const EdgeInsetsDirectional.only(start: 8), child: button(field), ), ], ), ); } return Form( child: Column( crossAxisAlignment: CrossAxisAlignment.stretch, children: [ for (final field in form.fields) _field(field, style), for (final field in form.fields.whereType()) Padding( padding: const EdgeInsets.only(top: 8), child: button(field), ), ], ), ); } Widget _field(MewsFormField field, TextStyle style) { Widget control = switch (field) { MewsTextField( :final name, :final password, :final value, :final maxLength, ) => TextFormField( controller: _controllers.putIfAbsent( name, () => TextEditingController( text: _values.putIfAbsent(name, () => value), ), ), obscureText: password, maxLength: maxLength, style: style, decoration: InputDecoration(labelText: field.label, isDense: true), onChanged: (text) => _changed(widget.form, name, text), ), MewsTextAreaField(:final name, :final value, :final rows) => TextFormField( controller: _controllers.putIfAbsent( 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( 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), ), ), 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(), }; return control; } void _submit(FormBlock form) { // Submitting says the reader is done typing, so it stands in for the // narrowing they were waiting on. _typing?.cancel(); widget.onFormSubmit(form, _valuesOf(form)); } /// Reports what is in the form once the reader pauses, so a search box /// narrows its page without being submitted. void _changed(FormBlock form, String name, String text) { _values[name] = text; final report = widget.onFormChange; if (report == null) return; _typing?.cancel(); _typing = Timer(searchTypingPause, () => report(form, _valuesOf(form))); } Map _valuesOf(FormBlock form) { final values = {}; for (final field in form.fields) { switch (field) { case MewsTextField(:final name): values[name] = _controllers[name]?.text ?? ''; case MewsTextAreaField(:final name): values[name] = _controllers[name]?.text ?? ''; case MewsCheckboxField(:final name, :final value): if (_values[name] == 'on') values[name] = value; case MewsRadioField(:final name, :final value): if (_values[name] == value) values[name] = value; case MewsSelectField(:final name): values[name] = _values[name] ?? ''; case MewsHiddenField(:final name, :final value): values[name] = value; case MewsSubmitField(): break; } } return values; } void _reset(FormBlock form) { setState(() { _values.clear(); for (final controller in _controllers.values) { controller.clear(); } // Restore the author's initial text field values. for (final field in form.fields) { switch (field) { case MewsTextField(:final name, :final value): _controllers[name]?.text = value; case MewsTextAreaField(:final name, :final value): _controllers[name]?.text = value; default: break; } } }); } }