feat: add keyboard shortcuts and Tab link traversal on pointer hosts
This commit is contained in:
parent
fa7aa5557c
commit
56db479df0
6 changed files with 798 additions and 272 deletions
|
|
@ -11,7 +11,11 @@ import '../app.dart';
|
|||
/// subscribe (only when the page offers something to subscribe to) and
|
||||
/// bookmark the current page.
|
||||
class AddressBar extends StatefulWidget {
|
||||
const AddressBar({super.key});
|
||||
const AddressBar({this.focusNode, super.key});
|
||||
|
||||
/// The reader can ask for the field's focus from outside, for a keyboard
|
||||
/// shortcut. When null the bar owns its own node.
|
||||
final FocusNode? focusNode;
|
||||
|
||||
@override
|
||||
State<AddressBar> createState() => _AddressBarState();
|
||||
|
|
@ -19,7 +23,7 @@ class AddressBar extends StatefulWidget {
|
|||
|
||||
class _AddressBarState extends State<AddressBar> {
|
||||
final _controller = TextEditingController();
|
||||
final _focus = FocusNode();
|
||||
late final FocusNode _focus = widget.focusNode ?? FocusNode();
|
||||
bool _focused = false;
|
||||
|
||||
@override
|
||||
|
|
@ -40,7 +44,8 @@ class _AddressBarState extends State<AddressBar> {
|
|||
|
||||
@override
|
||||
void dispose() {
|
||||
_focus.dispose();
|
||||
// A focus node handed in from outside belongs to whoever handed it in.
|
||||
if (widget.focusNode == null) _focus.dispose();
|
||||
_controller.dispose();
|
||||
super.dispose();
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,8 +1,13 @@
|
|||
import 'dart:typed_data';
|
||||
|
||||
import 'package:flutter/services.dart'
|
||||
show HardwareKeyboard, KeyDownEvent, LogicalKeyboardKey;
|
||||
|
||||
import 'dart:ui' as ui show TextDirection;
|
||||
|
||||
import 'package:flutter/gestures.dart';
|
||||
import 'package:flutter/rendering.dart'
|
||||
show RenderAbstractViewport, RenderObject, RenderParagraph;
|
||||
import 'package:flutter/material.dart';
|
||||
|
||||
import '../../l10n/strings.dart';
|
||||
|
|
@ -58,16 +63,64 @@ class _MewsPageViewState extends State<MewsPageView> {
|
|||
/// 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;
|
||||
final _linkHrefs = <String>[];
|
||||
final _linkSpans = <TextSpan>[];
|
||||
final _region = FocusNode();
|
||||
|
||||
/// Walks the keyboard through the page's links: Tab steps forward,
|
||||
/// Shift+Tab back, Enter opens the highlighted link, and Escape puts the
|
||||
/// highlight away. A Tab at either end leaves the walk to the focus
|
||||
/// traversal, so the chrome comes and goes as usual.
|
||||
KeyEventResult _onPageKey(FocusNode node, KeyEvent event) {
|
||||
if (event is! KeyDownEvent) return KeyEventResult.ignored;
|
||||
final key = event.logicalKey;
|
||||
if (key == LogicalKeyboardKey.tab) {
|
||||
final forward = !HardwareKeyboard.instance.isShiftPressed;
|
||||
if (_focusedLink < 0) {
|
||||
if (!forward || _linkCount == 0) return KeyEventResult.ignored;
|
||||
_focusLink(0);
|
||||
return KeyEventResult.handled;
|
||||
}
|
||||
final next = _focusedLink + (forward ? 1 : -1);
|
||||
if (next < 0 || next >= _linkCount) {
|
||||
setState(() => _focusedLink = -1);
|
||||
return KeyEventResult.ignored;
|
||||
}
|
||||
_focusLink(next);
|
||||
return KeyEventResult.handled;
|
||||
}
|
||||
if (key == LogicalKeyboardKey.enter ||
|
||||
key == LogicalKeyboardKey.numpadEnter) {
|
||||
if (_focusedLink < 0) return KeyEventResult.ignored;
|
||||
widget.onLinkTap(_linkHrefs[_focusedLink]);
|
||||
return KeyEventResult.handled;
|
||||
}
|
||||
if (key == LogicalKeyboardKey.escape && _focusedLink >= 0) {
|
||||
setState(() => _focusedLink = -1);
|
||||
return KeyEventResult.handled;
|
||||
}
|
||||
return KeyEventResult.ignored;
|
||||
}
|
||||
|
||||
@override
|
||||
void didUpdateWidget(MewsPageView oldWidget) {
|
||||
super.didUpdateWidget(oldWidget);
|
||||
if (oldWidget.page != widget.page) {
|
||||
_disposeRecognizers();
|
||||
// The links walked by the keyboard belong to the page, so a new page
|
||||
// starts with none highlighted.
|
||||
_focusedLink = -1;
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
_region.dispose();
|
||||
_disposeRecognizers();
|
||||
super.dispose();
|
||||
}
|
||||
|
|
@ -85,55 +138,70 @@ class _MewsPageViewState extends State<MewsPageView> {
|
|||
final base = 17.0 * widget.fontScale; // body font-size: 106%
|
||||
final palette = widget.palette;
|
||||
|
||||
_linkHrefs.clear();
|
||||
_linkSpans.clear();
|
||||
_linkCounter = 0;
|
||||
final blocks = [
|
||||
if (!widget.page.isMews)
|
||||
_notice(strings.t('error-non-mews'), base, palette),
|
||||
for (final block in widget.page.body) _block(context, block, base),
|
||||
];
|
||||
_linkCount = _linkHrefs.length;
|
||||
|
||||
return Directionality(
|
||||
textDirection: widget.page.direction == TextDirection.rtl
|
||||
? ui.TextDirection.rtl
|
||||
: ui.TextDirection.ltr,
|
||||
// Page text is the reader's to copy; forms manage their own selection.
|
||||
child: SelectionArea(
|
||||
child: ColoredBox(
|
||||
color: palette.background,
|
||||
child: ListView(
|
||||
controller: widget.scrollController,
|
||||
padding: EdgeInsets.all(base),
|
||||
children: [
|
||||
Center(
|
||||
child: ConstrainedBox(
|
||||
// max-width: 40em for an article; our own pages are lists, so
|
||||
// they get more room before they stop growing.
|
||||
constraints:
|
||||
BoxConstraints(maxWidth: (widget.ownPage ? 56 : 40) * base),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||
children: blocks,
|
||||
),
|
||||
return Focus(
|
||||
focusNode: _region,
|
||||
onKeyEvent: _onPageKey,
|
||||
// Pressing the page puts the keyboard focus on it, the way clicking a
|
||||
// document body does in a browser; a Listener watches without joining
|
||||
// the gesture arena, so links and scrolling keep their own taps.
|
||||
child: Listener(
|
||||
onPointerDown: (_) => _region.requestFocus(),
|
||||
child: Directionality(
|
||||
textDirection: widget.page.direction == TextDirection.rtl
|
||||
? ui.TextDirection.rtl
|
||||
: ui.TextDirection.ltr,
|
||||
// Page text is the reader's to copy; forms manage their own selection.
|
||||
child: SelectionArea(
|
||||
child: ColoredBox(
|
||||
color: palette.background,
|
||||
child: ListView(
|
||||
controller: widget.scrollController,
|
||||
padding: EdgeInsets.all(base),
|
||||
children: [
|
||||
Center(
|
||||
child: ConstrainedBox(
|
||||
// max-width: 40em for an article; our own pages are lists, so
|
||||
// they get more room before they stop growing.
|
||||
constraints: BoxConstraints(
|
||||
maxWidth: (widget.ownPage ? 56 : 40) * base,
|
||||
),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||
children: blocks,
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _notice(String text, double base, MewsPalette palette) => Padding(
|
||||
padding: EdgeInsets.only(bottom: base),
|
||||
child: Text(
|
||||
text,
|
||||
style: TextStyle(
|
||||
color: palette.foreground.withValues(alpha: 0.7),
|
||||
fontFamily: widget.fontFamily,
|
||||
fontFamilyFallback: fallbackFor(widget.fontFamily),
|
||||
fontSize: base * 0.85,
|
||||
),
|
||||
),
|
||||
);
|
||||
padding: EdgeInsets.only(bottom: base),
|
||||
child: Text(
|
||||
text,
|
||||
style: TextStyle(
|
||||
color: palette.foreground.withValues(alpha: 0.7),
|
||||
fontFamily: widget.fontFamily,
|
||||
fontFamilyFallback: fallbackFor(widget.fontFamily),
|
||||
fontSize: base * 0.85,
|
||||
),
|
||||
),
|
||||
);
|
||||
|
||||
// Blocks -------------------------------------------------------------------
|
||||
|
||||
|
|
@ -149,191 +217,208 @@ class _MewsPageViewState extends State<MewsPageView> {
|
|||
|
||||
return switch (block) {
|
||||
HeadingBlock(:final level, :final inline) => Padding(
|
||||
padding: EdgeInsets.only(
|
||||
top: level == 1
|
||||
? base * 0.5
|
||||
: (widget.ownPage ? base : base * 1.6),
|
||||
bottom: base * 0.5,
|
||||
),
|
||||
child: Text.rich(
|
||||
TextSpan(children: _inline(inline)),
|
||||
style: foreground.copyWith(
|
||||
fontWeight: FontWeight.bold,
|
||||
height: 1.25,
|
||||
fontSize: base * const [1.6, 1.3, 1.1, 1.0, 1.0, 1.0][level - 1],
|
||||
// A section label on our own pages, not a document heading.
|
||||
color: widget.ownPage && level > 1
|
||||
? palette.foreground.withValues(alpha: 0.7)
|
||||
: null,
|
||||
letterSpacing: widget.ownPage && level > 1 ? 0.4 : null,
|
||||
),
|
||||
),
|
||||
padding: EdgeInsets.only(
|
||||
top: level == 1 ? base * 0.5 : (widget.ownPage ? base : base * 1.6),
|
||||
bottom: base * 0.5,
|
||||
),
|
||||
child: Text.rich(
|
||||
TextSpan(children: _inline(inline)),
|
||||
style: foreground.copyWith(
|
||||
fontWeight: FontWeight.bold,
|
||||
height: 1.25,
|
||||
fontSize: base * const [1.6, 1.3, 1.1, 1.0, 1.0, 1.0][level - 1],
|
||||
// A section label on our own pages, not a document heading.
|
||||
color: widget.ownPage && level > 1
|
||||
? palette.foreground.withValues(alpha: 0.7)
|
||||
: null,
|
||||
letterSpacing: widget.ownPage && level > 1 ? 0.4 : null,
|
||||
),
|
||||
),
|
||||
),
|
||||
ParagraphBlock(:final inline) => Padding(
|
||||
padding: EdgeInsets.only(bottom: widget.ownPage ? base * 0.35 : base),
|
||||
child: Text.rich(TextSpan(children: _inline(inline)), style: foreground),
|
||||
),
|
||||
padding: EdgeInsets.only(bottom: widget.ownPage ? base * 0.35 : base),
|
||||
child: Text.rich(
|
||||
TextSpan(children: _inline(inline)),
|
||||
style: foreground,
|
||||
),
|
||||
),
|
||||
AddressBlock(:final inline) => Padding(
|
||||
padding: EdgeInsets.only(bottom: base),
|
||||
child: Text.rich(
|
||||
TextSpan(children: _inline(inline)),
|
||||
style: foreground.copyWith(fontStyle: FontStyle.italic),
|
||||
),
|
||||
),
|
||||
padding: EdgeInsets.only(bottom: base),
|
||||
child: Text.rich(
|
||||
TextSpan(children: _inline(inline)),
|
||||
style: foreground.copyWith(fontStyle: FontStyle.italic),
|
||||
),
|
||||
),
|
||||
QuoteBlock(:final children) => Container(
|
||||
margin: EdgeInsets.only(bottom: base),
|
||||
decoration: BoxDecoration(
|
||||
border: BorderDirectional(start: BorderSide(color: palette.border, width: 3)),
|
||||
),
|
||||
padding: EdgeInsetsDirectional.only(start: base),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||
children: [for (final child in children) _block(context, child, base)],
|
||||
),
|
||||
margin: EdgeInsets.only(bottom: base),
|
||||
decoration: BoxDecoration(
|
||||
border: BorderDirectional(
|
||||
start: BorderSide(color: palette.border, width: 3),
|
||||
),
|
||||
),
|
||||
padding: EdgeInsetsDirectional.only(start: base),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||
children: [
|
||||
for (final child in children) _block(context, child, base),
|
||||
],
|
||||
),
|
||||
),
|
||||
PreBlock(:final text) => Container(
|
||||
margin: EdgeInsets.only(bottom: base),
|
||||
color: palette.preBackground,
|
||||
padding: EdgeInsets.all(base * 0.75),
|
||||
child: SingleChildScrollView(
|
||||
scrollDirection: Axis.horizontal,
|
||||
child: Text(
|
||||
text,
|
||||
style: foreground.copyWith(
|
||||
fontFamily: 'monospace',
|
||||
fontSize: base * 0.9,
|
||||
),
|
||||
),
|
||||
margin: EdgeInsets.only(bottom: base),
|
||||
color: palette.preBackground,
|
||||
padding: EdgeInsets.all(base * 0.75),
|
||||
child: SingleChildScrollView(
|
||||
scrollDirection: Axis.horizontal,
|
||||
child: Text(
|
||||
text,
|
||||
style: foreground.copyWith(
|
||||
fontFamily: 'monospace',
|
||||
fontSize: base * 0.9,
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
RuleBlock() => Padding(
|
||||
padding: EdgeInsets.symmetric(
|
||||
vertical: widget.ownPage ? base * 0.6 : base * 2),
|
||||
child: Divider(color: palette.border, height: 1),
|
||||
),
|
||||
padding: EdgeInsets.symmetric(
|
||||
vertical: widget.ownPage ? base * 0.6 : base * 2,
|
||||
),
|
||||
child: Divider(color: palette.border, height: 1),
|
||||
),
|
||||
// Rows on our own pages: separated by a line rather than marked with a
|
||||
// bullet, so a list of bookmarks or settings reads as a list of rows.
|
||||
ListBlock(:final items) when widget.ownPage => Padding(
|
||||
padding: EdgeInsets.only(bottom: base * 0.35),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||
children: [
|
||||
for (var i = 0; i < items.length; i++) ...[
|
||||
if (i > 0) Divider(color: palette.border, height: 1),
|
||||
Padding(
|
||||
padding: EdgeInsets.only(top: base * 0.45),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||
children: [
|
||||
for (final child in items[i].blocks)
|
||||
_block(context, child, base),
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
],
|
||||
),
|
||||
),
|
||||
ListBlock(:final ordered, :final start, :final items) => Padding(
|
||||
padding: EdgeInsets.only(bottom: base),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||
children: [
|
||||
for (var i = 0; i < items.length; i++)
|
||||
Padding(
|
||||
padding: EdgeInsets.only(bottom: base * 0.25),
|
||||
child: Row(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
SizedBox(
|
||||
width: base * 1.5,
|
||||
child: Text(
|
||||
ordered ? '${start + i}.' : '\u2022',
|
||||
style: foreground,
|
||||
),
|
||||
),
|
||||
Expanded(
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||
children: [
|
||||
for (final child in items[i].blocks) _block(context, child, base),
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
DefinitionListBlock(:final items) => Padding(
|
||||
padding: EdgeInsets.only(bottom: base),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||
children: [
|
||||
for (final item in items) ...[
|
||||
Text.rich(
|
||||
TextSpan(children: _inline(item.term)),
|
||||
style: foreground.copyWith(fontWeight: FontWeight.bold),
|
||||
),
|
||||
Padding(
|
||||
padding: EdgeInsetsDirectional.only(start: base * 1.5, bottom: base * 0.5),
|
||||
child: Text.rich(TextSpan(children: _inline(item.definition)), style: foreground),
|
||||
),
|
||||
],
|
||||
],
|
||||
),
|
||||
),
|
||||
TableBlock(:final rows, :final caption) => Padding(
|
||||
padding: EdgeInsets.only(bottom: base),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||
children: [
|
||||
if (caption != null)
|
||||
Text(caption,
|
||||
style: foreground.copyWith(fontWeight: FontWeight.bold)),
|
||||
Table(
|
||||
border: TableBorder.all(color: palette.border),
|
||||
defaultVerticalAlignment: TableCellVerticalAlignment.top,
|
||||
padding: EdgeInsets.only(bottom: base * 0.35),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||
children: [
|
||||
for (var i = 0; i < items.length; i++) ...[
|
||||
if (i > 0) Divider(color: palette.border, height: 1),
|
||||
Padding(
|
||||
padding: EdgeInsets.only(top: base * 0.45),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||
children: [
|
||||
for (final row in rows)
|
||||
TableRow(
|
||||
children: [
|
||||
for (final cell in row.cells)
|
||||
TableCell(
|
||||
child: Padding(
|
||||
padding: EdgeInsets.symmetric(
|
||||
horizontal: base * 0.6,
|
||||
vertical: base * 0.3,
|
||||
),
|
||||
child: Text.rich(
|
||||
TextSpan(children: _inline(cell.inline)),
|
||||
style: foreground.copyWith(
|
||||
fontWeight: cell.header ? FontWeight.bold : null,
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
for (final child in items[i].blocks)
|
||||
_block(context, child, base),
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
],
|
||||
),
|
||||
),
|
||||
ListBlock(:final ordered, :final start, :final items) => Padding(
|
||||
padding: EdgeInsets.only(bottom: base),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||
children: [
|
||||
for (var i = 0; i < items.length; i++)
|
||||
Padding(
|
||||
padding: EdgeInsets.only(bottom: base * 0.25),
|
||||
child: Row(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
SizedBox(
|
||||
width: base * 1.5,
|
||||
child: Text(
|
||||
ordered ? '${start + i}.' : '\u2022',
|
||||
style: foreground,
|
||||
),
|
||||
),
|
||||
Expanded(
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||
children: [
|
||||
for (final child in items[i].blocks)
|
||||
_block(context, child, base),
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
DefinitionListBlock(:final items) => Padding(
|
||||
padding: EdgeInsets.only(bottom: base),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||
children: [
|
||||
for (final item in items) ...[
|
||||
Text.rich(
|
||||
TextSpan(children: _inline(item.term)),
|
||||
style: foreground.copyWith(fontWeight: FontWeight.bold),
|
||||
),
|
||||
Padding(
|
||||
padding: EdgeInsetsDirectional.only(
|
||||
start: base * 1.5,
|
||||
bottom: base * 0.5,
|
||||
),
|
||||
child: Text.rich(
|
||||
TextSpan(children: _inline(item.definition)),
|
||||
style: foreground,
|
||||
),
|
||||
),
|
||||
],
|
||||
],
|
||||
),
|
||||
),
|
||||
TableBlock(:final rows, :final caption) => Padding(
|
||||
padding: EdgeInsets.only(bottom: base),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||
children: [
|
||||
if (caption != null)
|
||||
Text(
|
||||
caption,
|
||||
style: foreground.copyWith(fontWeight: FontWeight.bold),
|
||||
),
|
||||
Table(
|
||||
border: TableBorder.all(color: palette.border),
|
||||
defaultVerticalAlignment: TableCellVerticalAlignment.top,
|
||||
children: [
|
||||
for (final row in rows)
|
||||
TableRow(
|
||||
children: [
|
||||
for (final cell in row.cells)
|
||||
TableCell(
|
||||
child: Padding(
|
||||
padding: EdgeInsets.symmetric(
|
||||
horizontal: base * 0.6,
|
||||
vertical: base * 0.3,
|
||||
),
|
||||
child: Text.rich(
|
||||
TextSpan(children: _inline(cell.inline)),
|
||||
style: foreground.copyWith(
|
||||
fontWeight: cell.header
|
||||
? FontWeight.bold
|
||||
: null,
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
ImageBlock(:final src, :final alt, :final allowed) => Padding(
|
||||
padding: EdgeInsets.only(bottom: base),
|
||||
child: allowed ? _image(src, alt, base) : _imageFallback(alt, base),
|
||||
),
|
||||
padding: EdgeInsets.only(bottom: base),
|
||||
child: allowed ? _image(src, alt, base) : _imageFallback(alt, base),
|
||||
),
|
||||
FormBlock() => Padding(
|
||||
padding: EdgeInsets.only(bottom: base),
|
||||
child: _MewsForm(
|
||||
form: block,
|
||||
foreground: foreground,
|
||||
onFormSubmit: widget.onFormSubmit,
|
||||
ownPage: widget.ownPage,
|
||||
),
|
||||
),
|
||||
padding: EdgeInsets.only(bottom: base),
|
||||
child: _MewsForm(
|
||||
form: block,
|
||||
foreground: foreground,
|
||||
onFormSubmit: widget.onFormSubmit,
|
||||
ownPage: widget.ownPage,
|
||||
),
|
||||
),
|
||||
};
|
||||
}
|
||||
|
||||
|
|
@ -359,23 +444,29 @@ class _MewsPageViewState extends State<MewsPageView> {
|
|||
});
|
||||
return arriving;
|
||||
}),
|
||||
builder: (context, snapshot) => snapshot.connectionState != ConnectionState.done
|
||||
builder: (context, snapshot) =>
|
||||
snapshot.connectionState != ConnectionState.done
|
||||
? const SizedBox.shrink()
|
||||
: snapshot.data == null
|
||||
? _imageFallback(alt, base, unavailable: true)
|
||||
: Image.memory(
|
||||
snapshot.data!,
|
||||
fit: BoxFit.scaleDown,
|
||||
errorBuilder: (_, _, _) => _imageFallback(alt, base, unavailable: true),
|
||||
),
|
||||
? _imageFallback(alt, base, unavailable: true)
|
||||
: Image.memory(
|
||||
snapshot.data!,
|
||||
fit: BoxFit.scaleDown,
|
||||
errorBuilder: (_, _, _) =>
|
||||
_imageFallback(alt, base, unavailable: true),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _imageFallback(String alt, double base, {bool unavailable = false}) {
|
||||
final palette = widget.palette;
|
||||
final strings = StringsScope.of(context);
|
||||
final messageId = unavailable ? 'error-image-unavailable' : 'error-image-blocked';
|
||||
final message = alt.isEmpty ? strings.t(messageId) : '${strings.t(messageId)} $alt';
|
||||
final messageId = unavailable
|
||||
? 'error-image-unavailable'
|
||||
: 'error-image-blocked';
|
||||
final message = alt.isEmpty
|
||||
? strings.t(messageId)
|
||||
: '${strings.t(messageId)} $alt';
|
||||
return Container(
|
||||
width: double.infinity,
|
||||
padding: EdgeInsets.all(base * 0.75),
|
||||
|
|
@ -393,41 +484,177 @@ class _MewsPageViewState extends State<MewsPageView> {
|
|||
/// 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]) {
|
||||
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),
|
||||
MewsText(:final text) => TextSpan(
|
||||
text: text,
|
||||
recognizer: linkRecognizer,
|
||||
),
|
||||
MewsLineBreak() => const TextSpan(text: '\n'),
|
||||
MewsEmphasis(:final style, :final children) => TextSpan(
|
||||
style: switch (style) {
|
||||
EmphasisStyle.strong => const TextStyle(fontWeight: FontWeight.bold),
|
||||
EmphasisStyle.emphasis when widget.ownPage => TextStyle(
|
||||
color: palette.foreground.withValues(alpha: 0.65),
|
||||
fontSize: 15.0 * widget.fontScale,
|
||||
),
|
||||
EmphasisStyle.emphasis => const TextStyle(fontStyle: FontStyle.italic),
|
||||
EmphasisStyle.term => const TextStyle(fontStyle: FontStyle.italic),
|
||||
EmphasisStyle.quote => const TextStyle(fontStyle: FontStyle.italic),
|
||||
_ => const TextStyle(fontFamily: 'monospace'),
|
||||
},
|
||||
children: _inline(children, linkRecognizer)),
|
||||
MewsLink(:final href, :final children) => TextSpan(
|
||||
style: TextStyle(
|
||||
color: widget.visitedUrls.contains(href) ? palette.visited : palette.link,
|
||||
// Underlines mark links in prose; on our own pages they are
|
||||
// controls, so weight carries them instead.
|
||||
decoration:
|
||||
widget.ownPage ? TextDecoration.none : TextDecoration.underline,
|
||||
fontWeight: widget.ownPage ? FontWeight.w500 : null,
|
||||
style: switch (style) {
|
||||
EmphasisStyle.strong => const TextStyle(
|
||||
fontWeight: FontWeight.bold,
|
||||
),
|
||||
recognizer: _recognizer(node),
|
||||
children: _inline(children, _recognizer(node))),
|
||||
EmphasisStyle.emphasis when widget.ownPage => TextStyle(
|
||||
color: palette.foreground.withValues(alpha: 0.65),
|
||||
fontSize: 15.0 * widget.fontScale,
|
||||
),
|
||||
EmphasisStyle.emphasis => const TextStyle(
|
||||
fontStyle: FontStyle.italic,
|
||||
),
|
||||
EmphasisStyle.term => const TextStyle(
|
||||
fontStyle: FontStyle.italic,
|
||||
),
|
||||
EmphasisStyle.quote => const TextStyle(
|
||||
fontStyle: FontStyle.italic,
|
||||
),
|
||||
_ => const TextStyle(fontFamily: 'monospace'),
|
||||
},
|
||||
children: _inline(children, linkRecognizer),
|
||||
),
|
||||
MewsLink(:final children) => _linkSpan(node, children, palette),
|
||||
},
|
||||
];
|
||||
}
|
||||
|
||||
/// One link, in its place in the page's walk. Underlines mark links in
|
||||
/// prose; on our own pages they are controls, so weight carries them
|
||||
/// instead. The keyboard highlight underlines and tints whichever link
|
||||
/// Tab has landed on, wherever it is.
|
||||
TextSpan _linkSpan(
|
||||
MewsLink link,
|
||||
List<MewsInline> children,
|
||||
MewsPalette palette,
|
||||
) {
|
||||
final focused = _focusedLink == _linkCounter;
|
||||
_linkCounter++;
|
||||
_linkHrefs.add(link.href);
|
||||
return _recordedLink(
|
||||
TextSpan(
|
||||
style: TextStyle(
|
||||
color: widget.visitedUrls.contains(link.href)
|
||||
? palette.visited
|
||||
: palette.link,
|
||||
decoration: focused || !widget.ownPage
|
||||
? TextDecoration.underline
|
||||
: TextDecoration.none,
|
||||
fontWeight: widget.ownPage ? FontWeight.w500 : null,
|
||||
backgroundColor: focused
|
||||
? palette.link.withValues(alpha: 0.14)
|
||||
: null,
|
||||
),
|
||||
recognizer: _recognizer(link),
|
||||
children: _inline(children, _recognizer(link)),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
/// Records a built link span, so the walk can find where it landed.
|
||||
TextSpan _recordedLink(TextSpan span) {
|
||||
_linkSpans.add(span);
|
||||
return span;
|
||||
}
|
||||
|
||||
/// Moves the keyboard highlight and scrolls the page to keep the link in
|
||||
/// view, the way focus traversal carries the viewport along.
|
||||
void _focusLink(int index) {
|
||||
setState(() => _focusedLink = index);
|
||||
if (index >= 0) {
|
||||
WidgetsBinding.instance.addPostFrameCallback((_) => _revealFocusedLink());
|
||||
}
|
||||
}
|
||||
|
||||
void _revealFocusedLink() {
|
||||
if (!mounted || _focusedLink < 0) return;
|
||||
final controller = widget.scrollController;
|
||||
if (controller == null || !controller.hasClients) return;
|
||||
final span = _linkSpans[_focusedLink];
|
||||
|
||||
// The highlighted link is glyphs inside one of the page's paragraphs;
|
||||
// the walk finds which one, and where the link sits in it.
|
||||
var paragraph = renderParagraphOf(context.findRenderObject(), span);
|
||||
if (paragraph == null) return;
|
||||
final offset = _offsetWithin(paragraph.text, span);
|
||||
if (offset == null) return;
|
||||
final boxes = paragraph.getBoxesForSelection(
|
||||
TextSelection(
|
||||
baseOffset: offset,
|
||||
extentOffset: offset + span.toPlainText().length,
|
||||
),
|
||||
);
|
||||
if (boxes.isEmpty) return;
|
||||
// A link wrapped across lines shows every line of it, not just the first.
|
||||
final rect = Rect.fromLTRB(
|
||||
boxes.first.left,
|
||||
boxes.first.top,
|
||||
boxes.last.right,
|
||||
boxes.last.bottom,
|
||||
);
|
||||
|
||||
// The boxes are in the paragraph's own coordinates, which is what the
|
||||
// viewport asks for when revealing; the offset it answers with is where
|
||||
// to scroll to, and the walk rides there smoothly.
|
||||
final viewport = RenderAbstractViewport.of(paragraph);
|
||||
final reveal = viewport.getOffsetToReveal(paragraph, 0.5, rect: rect.inflate(8));
|
||||
final position = controller.position;
|
||||
final target = reveal.offset
|
||||
.clamp(position.minScrollExtent, position.maxScrollExtent)
|
||||
.toDouble();
|
||||
position.animateTo(
|
||||
target,
|
||||
duration: const Duration(milliseconds: 200),
|
||||
curve: Curves.easeOut,
|
||||
);
|
||||
}
|
||||
|
||||
/// The rendered paragraph holding [span], searched among [root]'s
|
||||
/// descendants, or null when the page did not render one.
|
||||
RenderParagraph? renderParagraphOf(RenderObject? root, TextSpan span) {
|
||||
if (root == null) return null;
|
||||
RenderParagraph? found;
|
||||
void walk(RenderObject node) {
|
||||
if (found != null) return;
|
||||
if (node is RenderParagraph && _offsetWithin(node.text, span) != null) {
|
||||
found = node;
|
||||
return;
|
||||
}
|
||||
node.visitChildren(walk);
|
||||
}
|
||||
|
||||
walk(root);
|
||||
return found;
|
||||
}
|
||||
|
||||
/// Where [target] begins in the text of [root], counting characters down
|
||||
/// the span tree, or null when it is not among [root]'s descendants.
|
||||
/// Where [target] begins in the text of [root], counting characters down
|
||||
/// the span tree, or null when it is not among [root]'s descendants.
|
||||
/// TextSpan.visitChildren walks whole subtrees, so this walks the children
|
||||
/// itself: one level per step, one character count per sibling.
|
||||
int? _offsetWithin(InlineSpan root, InlineSpan target) {
|
||||
int? search(InlineSpan span, int start) {
|
||||
if (identical(span, target)) return start;
|
||||
final children = span is TextSpan ? span.children : null;
|
||||
if (children == null) return null;
|
||||
var cursor = start;
|
||||
for (final child in children) {
|
||||
final result = search(child, cursor);
|
||||
if (result != null) return result;
|
||||
cursor += child.toPlainText().length;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
return search(root, 0);
|
||||
}
|
||||
|
||||
TapGestureRecognizer _recognizer(MewsLink link) {
|
||||
return _recognizers.putIfAbsent(link, () {
|
||||
return TapGestureRecognizer()..onTap = () => widget.onLinkTap(link.href);
|
||||
|
|
@ -476,15 +703,15 @@ class _MewsFormState extends State<_MewsForm> {
|
|||
final style = widget.foreground;
|
||||
|
||||
Widget button(MewsSubmitField field) => FilledButton(
|
||||
onPressed: () => field.reset ? _reset(form) : _submit(form),
|
||||
child: Text(
|
||||
field.label ??
|
||||
(field.reset
|
||||
? strings.t('form-reset-default')
|
||||
: strings.t('form-submit-default')),
|
||||
style: TextStyle(fontFamily: style.fontFamily),
|
||||
),
|
||||
);
|
||||
onPressed: () => field.reset ? _reset(form) : _submit(form),
|
||||
child: Text(
|
||||
field.label ??
|
||||
(field.reset
|
||||
? strings.t('form-reset-default')
|
||||
: strings.t('form-submit-default')),
|
||||
style: TextStyle(fontFamily: style.fontFamily),
|
||||
),
|
||||
);
|
||||
|
||||
if (widget.ownPage) {
|
||||
final inputs = form.fields.where((f) => f is! MewsSubmitField).toList();
|
||||
|
|
@ -524,47 +751,80 @@ class _MewsFormState extends State<_MewsForm> {
|
|||
|
||||
Widget _field(MewsFormField field, TextStyle style) {
|
||||
Widget control = switch (field) {
|
||||
MewsTextField(:final name, :final password, :final value, :final maxLength) => TextFormField(
|
||||
MewsTextField(
|
||||
:final name,
|
||||
:final password,
|
||||
:final value,
|
||||
:final maxLength,
|
||||
) =>
|
||||
TextFormField(
|
||||
controller: _controllers.putIfAbsent(
|
||||
name, () => TextEditingController(text: _values.putIfAbsent(name, () => value))),
|
||||
name,
|
||||
() => TextEditingController(
|
||||
text: _values.putIfAbsent(name, () => value),
|
||||
),
|
||||
),
|
||||
obscureText: password,
|
||||
maxLength: maxLength,
|
||||
style: style,
|
||||
decoration: InputDecoration(labelText: field.label, isDense: true),
|
||||
),
|
||||
MewsTextAreaField(:final name, :final value, :final rows) => TextFormField(
|
||||
MewsTextAreaField(:final name, :final value, :final rows) =>
|
||||
TextFormField(
|
||||
controller: _controllers.putIfAbsent(
|
||||
name, () => TextEditingController(text: _values.putIfAbsent(name, () => value))),
|
||||
name,
|
||||
() => TextEditingController(
|
||||
text: _values.putIfAbsent(name, () => value),
|
||||
),
|
||||
),
|
||||
minLines: rows,
|
||||
maxLines: rows,
|
||||
style: style,
|
||||
decoration: InputDecoration(labelText: field.label, isDense: true),
|
||||
),
|
||||
MewsCheckboxField(:final name, :final checked) => CheckboxListTile(
|
||||
title: field.label == null ? null : Text(field.label!, style: style),
|
||||
value: _values.putIfAbsent(name, () => checked ? 'on' : '') == 'on',
|
||||
onChanged: (value) => setState(() => _values[name] = value == true ? 'on' : ''),
|
||||
),
|
||||
MewsRadioField(:final name, :final value, :final checked) => RadioGroup<String>(
|
||||
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),
|
||||
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),
|
||||
),
|
||||
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(),
|
||||
};
|
||||
|
|
|
|||
|
|
@ -1,5 +1,8 @@
|
|||
import 'package:flutter/foundation.dart'
|
||||
show defaultTargetPlatform, TargetPlatform;
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter/rendering.dart' show ScrollDirection;
|
||||
import 'package:flutter/services.dart' show LogicalKeyboardKey;
|
||||
import 'package:signals/signals_flutter.dart' show SignalBuilder;
|
||||
|
||||
import '../../application/controllers/browser_controller.dart'
|
||||
|
|
@ -32,13 +35,22 @@ class ReaderScreen extends StatefulWidget {
|
|||
|
||||
class _ReaderScreenState extends State<ReaderScreen> {
|
||||
final _scroll = ScrollController();
|
||||
final _addressFocus = FocusNode();
|
||||
|
||||
/// The address bar is on screen from the start, hidden while the reader
|
||||
/// scrolls down, back on any upward scroll.
|
||||
/// scrolls down, back on any upward scroll. A pointer host with a keyboard
|
||||
/// has room to spare and keys to reach the bar, so it never hides.
|
||||
bool _chromeVisible = true;
|
||||
bool _showBrokenText = false;
|
||||
final _checkedSubscriptions = <String>{};
|
||||
|
||||
/// Touch devices navigate with gestures; the others are pointer hosts
|
||||
/// whose readers reach the chrome with the mouse and the keyboard.
|
||||
static bool get _isTouch => switch (defaultTargetPlatform) {
|
||||
TargetPlatform.android || TargetPlatform.iOS => true,
|
||||
_ => false,
|
||||
};
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
|
|
@ -49,10 +61,14 @@ class _ReaderScreenState extends State<ReaderScreen> {
|
|||
void dispose() {
|
||||
_scroll.removeListener(_onScroll);
|
||||
_scroll.dispose();
|
||||
_addressFocus.dispose();
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
void _onScroll() {
|
||||
// On a pointer host the bar stays on screen, so scrolling changes
|
||||
// nothing about it.
|
||||
if (!_isTouch) return;
|
||||
if (!_scroll.hasClients) return;
|
||||
final position = _scroll.position;
|
||||
|
||||
|
|
@ -70,7 +86,7 @@ class _ReaderScreenState extends State<ReaderScreen> {
|
|||
Widget build(BuildContext context) {
|
||||
final deps = AppScope.of(context).deps;
|
||||
|
||||
return Scaffold(
|
||||
final reader = Scaffold(
|
||||
body: Column(
|
||||
children: [
|
||||
AnimatedSlide(
|
||||
|
|
@ -82,7 +98,7 @@ class _ReaderScreenState extends State<ReaderScreen> {
|
|||
curve: Curves.easeOut,
|
||||
alignment: Alignment.topCenter,
|
||||
child: _chromeVisible
|
||||
? const AddressBar()
|
||||
? AddressBar(focusNode: _addressFocus)
|
||||
: const SizedBox(width: double.infinity),
|
||||
),
|
||||
),
|
||||
|
|
@ -134,6 +150,43 @@ class _ReaderScreenState extends State<ReaderScreen> {
|
|||
],
|
||||
),
|
||||
);
|
||||
|
||||
// A pointer host drives the reader from the keyboard as well: the bar
|
||||
// takes an address (Ctrl or Cmd+L, which selects what is in it), the page
|
||||
// reloads (Ctrl or Cmd+R), and the walk goes back and forward
|
||||
// (Alt+Left/Right, or Cmd+Left/Right and Cmd+[ and Cmd+] on a Mac).
|
||||
// Touch hosts navigate with gestures instead.
|
||||
if (_isTouch) return reader;
|
||||
return CallbackShortcuts(
|
||||
bindings: {
|
||||
const SingleActivator(LogicalKeyboardKey.keyL, control: true):
|
||||
_addressFocus.requestFocus,
|
||||
const SingleActivator(LogicalKeyboardKey.keyL, meta: true):
|
||||
_addressFocus.requestFocus,
|
||||
const SingleActivator(LogicalKeyboardKey.keyR, control: true):
|
||||
deps.browser.reload,
|
||||
const SingleActivator(LogicalKeyboardKey.keyR, meta: true):
|
||||
deps.browser.reload,
|
||||
const SingleActivator(LogicalKeyboardKey.arrowLeft, alt: true):
|
||||
deps.browser.back,
|
||||
const SingleActivator(LogicalKeyboardKey.arrowRight, alt: true):
|
||||
deps.browser.forward,
|
||||
// The same walk, with the keys a Mac reader reaches for.
|
||||
const SingleActivator(LogicalKeyboardKey.arrowLeft, meta: true):
|
||||
deps.browser.back,
|
||||
const SingleActivator(LogicalKeyboardKey.arrowRight, meta: true):
|
||||
deps.browser.forward,
|
||||
const SingleActivator(LogicalKeyboardKey.bracketLeft, meta: true):
|
||||
deps.browser.back,
|
||||
const SingleActivator(LogicalKeyboardKey.bracketRight, meta: true):
|
||||
deps.browser.forward,
|
||||
},
|
||||
// Key events reach only the focused branch, and a session starts with
|
||||
// nothing focused; this node gives them a place to arrive. Tapping the
|
||||
// address field or a control moves the focus deeper in, where the
|
||||
// shortcuts still apply.
|
||||
child: Focus(autofocus: true, skipTraversal: true, child: reader),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _body(BuildContext context, MewsPage? page) {
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue