feat: drive the reader with a D-pad

This commit is contained in:
randogoth 2026-10-12 17:59:14 +03:00
parent 7b2ddc0249
commit f386ef2c5b
5 changed files with 600 additions and 84 deletions

View file

@ -3,7 +3,12 @@ import 'dart:async' show Timer;
import 'dart:typed_data';
import 'package:flutter/services.dart'
show HardwareKeyboard, KeyDownEvent, LogicalKeyboardKey;
show
HardwareKeyboard,
KeyDownEvent,
KeyRepeatEvent,
KeyUpEvent,
LogicalKeyboardKey;
import 'dart:ui' as ui show TextDirection;
@ -39,6 +44,11 @@ class MewsPageView extends StatefulWidget {
this.scrollController,
this.onSaveQuotation,
this.ownPage = false,
this.focusNode,
this.autofocus = false,
this.onBack,
this.onForward,
this.onHub,
super.key,
});
@ -69,6 +79,20 @@ class MewsPageView extends StatefulWidget {
/// links read as controls rather than prose.
final bool ownPage;
/// The page's keyboard focus, so the reader can hand it back from the
/// chrome. When null the page owns its own node.
final FocusNode? focusNode;
/// Whether the page takes the keyboard focus when it appears, so a device
/// driven by a D-pad alone has somewhere for its keys to arrive.
final bool autofocus;
/// What holding the D-pad left, right or center does. Null on a view the
/// reader does not navigate from, such as a swipe peek.
final VoidCallback? onBack;
final VoidCallback? onForward;
final VoidCallback? onHub;
@override
State<MewsPageView> createState() => _MewsPageViewState();
}
@ -92,15 +116,43 @@ class _MewsPageViewState extends State<MewsPageView> {
int _formCounter = 0;
final _linkHrefs = <String>[];
final _linkSpans = <TextSpan>[];
final _region = FocusNode();
late final FocusNode _region = widget.focusNode ?? FocusNode();
/// One node around each form, by its place among the page's forms, so the
/// D-pad walk can find the form's controls. Kept across builds, since the
/// controls stay attached to them.
final _formNodes = <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.
/// traversal, so the chrome comes and goes as usual. The D-pad keys are
/// handled by [_onDpadKey].
KeyEventResult _onPageKey(FocusNode node, KeyEvent event) {
if (event is! KeyDownEvent) return KeyEventResult.ignored;
final key = event.logicalKey;
if (_dpadKeys.contains(key) && !_modified()) return _onDpadKey(event);
if (event is! KeyDownEvent) return KeyEventResult.ignored;
if (key == LogicalKeyboardKey.tab) {
final forward = !HardwareKeyboard.instance.isShiftPressed;
if (_focusedLink < 0) {
@ -116,12 +168,6 @@ class _MewsPageViewState extends State<MewsPageView> {
_focusLink(next);
return KeyEventResult.handled;
}
if (key == LogicalKeyboardKey.enter ||
key == LogicalKeyboardKey.numpadEnter) {
if (_focusedLink < 0) return KeyEventResult.ignored;
widget.onLinkTap(_linkHrefs[_focusedLink]);
return KeyEventResult.handled;
}
if (key == LogicalKeyboardKey.escape && _focusedLink >= 0) {
setState(() => _focusedLink = -1);
return KeyEventResult.handled;
@ -129,6 +175,184 @@ class _MewsPageViewState extends State<MewsPageView> {
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);
@ -142,7 +366,11 @@ class _MewsPageViewState extends State<MewsPageView> {
@override
void dispose() {
_region.dispose();
// A focus node handed in from outside belongs to whoever handed it in.
if (widget.focusNode == null) _region.dispose();
for (final node in _formNodes) {
node.dispose();
}
_disposeRecognizers();
super.dispose();
}
@ -162,6 +390,7 @@ class _MewsPageViewState extends State<MewsPageView> {
_linkHrefs.clear();
_linkSpans.clear();
_stops.clear();
_linkCounter = 0;
_formCounter = 0;
final blocks = [
@ -506,18 +735,32 @@ class _MewsPageViewState extends State<MewsPageView> {
// the reader types in this box is matched up with the box they are
// typing in, so the text and the cursor stay where they are. The
// position keeps two forms with one action apart.
key: ValueKey('form:${_formCounter++}:${block.action}'),
child: _MewsForm(
form: block,
foreground: foreground,
onFormSubmit: widget.onFormSubmit,
onFormChange: widget.onFormChange,
ownPage: widget.ownPage,
key: ValueKey('form:$_formCounter:${block.action}'),
child: Focus(
focusNode: _formNode(),
canRequestFocus: false,
skipTraversal: true,
child: _MewsForm(
form: block,
foreground: foreground,
onFormSubmit: widget.onFormSubmit,
onFormChange: widget.onFormChange,
ownPage: widget.ownPage,
),
),
),
};
}
/// The node around the next form built, recorded as a stop of the walk.
FocusNode _formNode() {
final index = _formCounter++;
if (index == _formNodes.length) _formNodes.add(FocusNode());
final node = _formNodes[index];
_stops.add(node);
return node;
}
/// An allowed image, from the platform on the web or from the protocol's
/// own bytes on a capsule. A blocked or unreachable image falls back to its
/// alt text, the way a missing image does.
@ -630,6 +873,7 @@ class _MewsPageViewState extends State<MewsPageView> {
MewsPalette palette,
) {
final focused = _focusedLink == _linkCounter;
_stops.add(_linkCounter);
_linkCounter++;
_linkHrefs.add(link.href);
return _recordedLink(
@ -671,28 +915,9 @@ class _MewsPageViewState extends State<MewsPageView> {
if (!mounted || _focusedLink < 0) return;
final controller = widget.scrollController;
if (controller == null || !controller.hasClients) return;
final span = _linkSpans[_focusedLink];
// The highlighted link is glyphs inside one of the page's paragraphs;
// the walk finds which one, and where the link sits in it.
var paragraph = renderParagraphOf(context.findRenderObject(), span);
if (paragraph == null) return;
final offset = _offsetWithin(paragraph.text, span);
if (offset == null) return;
final boxes = paragraph.getBoxesForSelection(
TextSelection(
baseOffset: offset,
extentOffset: offset + span.toPlainText().length,
),
);
if (boxes.isEmpty) return;
// A link wrapped across lines shows every line of it, not just the first.
final rect = Rect.fromLTRB(
boxes.first.left,
boxes.first.top,
boxes.last.right,
boxes.last.bottom,
);
final box = _linkBox(_focusedLink);
if (box == null) return;
final (:paragraph, :rect) = box;
// The boxes are in the paragraph's own coordinates, which is what the
// viewport asks for when revealing; the offset it answers with is where
@ -710,45 +935,73 @@ class _MewsPageViewState extends State<MewsPageView> {
);
}
/// The rendered paragraph holding [span], searched among [root]'s
/// descendants, or null when the page did not render one.
RenderParagraph? renderParagraphOf(RenderObject? root, TextSpan span) {
if (root == null) return null;
RenderParagraph? found;
/// The paragraph showing link [index], and where the link sits in it.
({RenderParagraph paragraph, Rect rect})? _linkBox(int index) {
final span = _linkSpans[index];
// A link is glyphs inside one of the page's paragraphs; the walk finds
// which one, and where the link sits in it. A paragraph rebuilt with
// equal text keeps its old spans, so links are matched by equality, and
// a link equal to earlier ones is found as their next occurrence.
var skip = _linkSpans.take(index).where((other) => other == span).length;
RenderParagraph? paragraph;
var offset = 0;
void walk(RenderObject node) {
if (found != null) return;
if (node is RenderParagraph && _offsetWithin(node.text, span) != null) {
found = node;
return;
if (paragraph != null) return;
if (node is RenderParagraph) {
for (final at in _offsetsWithin(node.text, span)) {
if (skip-- > 0) continue;
paragraph = node;
offset = at;
return;
}
}
node.visitChildren(walk);
}
walk(root);
return found;
final root = context.findRenderObject();
if (root != null) walk(root);
final found = paragraph;
if (found == null) return null;
final boxes = found.getBoxesForSelection(
TextSelection(
baseOffset: offset,
extentOffset: offset + span.toPlainText().length,
),
);
if (boxes.isEmpty) return null;
// A link wrapped across lines covers every line of it, not just the first.
final rect = Rect.fromLTRB(
boxes.first.left,
boxes.first.top,
boxes.last.right,
boxes.last.bottom,
);
return (paragraph: found, rect: rect);
}
/// Where [target] begins in the text of [root], counting characters down
/// the span tree, or null when it is not among [root]'s descendants.
/// Where [target] begins in the text of [root], counting characters down
/// the span tree, or null when it is not among [root]'s descendants.
/// TextSpan.visitChildren walks whole subtrees, so this walks the children
/// itself: one level per step, one character count per sibling.
int? _offsetWithin(InlineSpan root, InlineSpan target) {
int? search(InlineSpan span, int start) {
if (identical(span, target)) return start;
/// Where each span equal to [target] begins in the text of [root],
/// counting characters down the span tree. TextSpan.visitChildren walks
/// whole subtrees, so this walks the children itself: one level per step,
/// one character count per sibling.
List<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 null;
if (children == null) return;
var cursor = start;
for (final child in children) {
final result = search(child, cursor);
if (result != null) return result;
search(child, cursor);
cursor += child.toPlainText().length;
}
return null;
}
return search(root, 0);
search(root, 0);
return found;
}
TapGestureRecognizer _recognizer(MewsLink link) {