the-mews/app/lib/presentation/reader/mews_page_view.dart
2026-10-13 11:28:06 +03:00

1274 lines
44 KiB
Dart

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<String> visitedUrls;
final void Function(String url) onLinkTap;
final void Function(FormBlock form, Map<String, String> 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<String, String> 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<Uint8List?> 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<MewsPageView> createState() => _MewsPageViewState();
}
class _MewsPageViewState extends State<MewsPageView> {
final _recognizers = <MewsLink, TapGestureRecognizer>{};
/// 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 = <String, Future<Uint8List?>>{};
/// 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 = <String>[];
final _linkSpans = <TextSpan>[];
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 = <FocusNode>[];
/// 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 = <Object>[];
/// 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<EditableText>();
/// 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<Object> _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<MewsTableRow> 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 <InlineSpan>[],
),
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<Uint8List?>(
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<InlineSpan> _inline(
List<MewsInline> 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<MewsInline> 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<int> _offsetsWithin(InlineSpan root, InlineSpan target) {
final found = <int>[];
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<String, String> values) onFormSubmit;
final void Function(FormBlock form, Map<String, String> 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 = <String, TextEditingController>{};
final _values = <String, String>{};
/// 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<String, String> _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<MewsSubmitField>())
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<MewsSubmitField>())
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<String>(
groupValue: _values.putIfAbsent(name, () => checked ? value : ''),
onChanged: (value) => setState(() => _values[name] = value ?? ''),
child: RadioListTile<String>(
value: value,
title: field.label == null
? null
: Text(field.label!, style: style),
),
),
MewsSelectField(:final name, :final options) =>
options.isEmpty
? const SizedBox.shrink()
: DropdownButtonFormField<String>(
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<String, String> _valuesOf(FormBlock form) {
final values = <String, String>{};
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;
}
}
});
}
}