feat: drive the reader with a D-pad
This commit is contained in:
parent
7b2ddc0249
commit
f386ef2c5b
5 changed files with 600 additions and 84 deletions
|
|
@ -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) {
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue