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
|
|
@ -7,7 +7,7 @@ import 'package:flutter/gestures.dart'
|
|||
import 'package:url_launcher/url_launcher.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter/rendering.dart' show ScrollDirection;
|
||||
import 'package:flutter/services.dart' show LogicalKeyboardKey;
|
||||
import 'package:flutter/services.dart' show KeyDownEvent, LogicalKeyboardKey;
|
||||
import 'package:signals/signals_flutter.dart' show SignalBuilder;
|
||||
|
||||
import '../../application/controllers/browser_controller.dart'
|
||||
|
|
@ -40,6 +40,12 @@ class ReaderScreen extends StatefulWidget {
|
|||
class _ReaderScreenState extends State<ReaderScreen> {
|
||||
final _scroll = ScrollController();
|
||||
final _addressFocus = FocusNode();
|
||||
final _pageFocus = FocusNode();
|
||||
|
||||
/// Around the error pages, which have no page view to take the focus; and
|
||||
/// the error whose page last had its first control focused.
|
||||
final _errorFocus = FocusNode(canRequestFocus: false, skipTraversal: true);
|
||||
Object? _focusedError;
|
||||
|
||||
/// The address bar is on screen from the start, hidden while the reader
|
||||
/// scrolls down, back on any upward scroll. A pointer host with a keyboard
|
||||
|
|
@ -80,6 +86,8 @@ class _ReaderScreenState extends State<ReaderScreen> {
|
|||
_scroll.removeListener(_onScroll);
|
||||
_scroll.dispose();
|
||||
_addressFocus.dispose();
|
||||
_pageFocus.dispose();
|
||||
_errorFocus.dispose();
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
|
|
@ -144,6 +152,51 @@ class _ReaderScreenState extends State<ReaderScreen> {
|
|||
action();
|
||||
};
|
||||
|
||||
/// The D-pad keys the page leaves to the reader. On a touch host the Menu
|
||||
/// key shows the chrome with the focus in the address field, and hides it
|
||||
/// with the focus back on the page. The address field keeps the arrows for
|
||||
/// its cursor, so Down returns to the page, and Left or Right at the edge
|
||||
/// of the text moves on to the bar's buttons.
|
||||
KeyEventResult _onReaderKey(FocusNode node, KeyEvent event) {
|
||||
if (event is! KeyDownEvent) return KeyEventResult.ignored;
|
||||
final key = event.logicalKey;
|
||||
|
||||
if (key == LogicalKeyboardKey.contextMenu && _isTouch) {
|
||||
final show = !_chromeVisible;
|
||||
setState(() => _chromeVisible = show);
|
||||
if (show) {
|
||||
// The field is built with the chrome, a frame from now.
|
||||
WidgetsBinding.instance
|
||||
.addPostFrameCallback((_) => _addressFocus.requestFocus());
|
||||
} else {
|
||||
_pageFocus.requestFocus();
|
||||
}
|
||||
return KeyEventResult.handled;
|
||||
}
|
||||
|
||||
if (!_addressFocus.hasPrimaryFocus) return KeyEventResult.ignored;
|
||||
if (key == LogicalKeyboardKey.arrowDown) {
|
||||
_pageFocus.requestFocus();
|
||||
return KeyEventResult.handled;
|
||||
}
|
||||
final direction = switch (key) {
|
||||
LogicalKeyboardKey.arrowLeft => TraversalDirection.left,
|
||||
LogicalKeyboardKey.arrowRight => TraversalDirection.right,
|
||||
_ => null,
|
||||
};
|
||||
if (direction == null) return KeyEventResult.ignored;
|
||||
final text = (_addressFocus.context!
|
||||
.findAncestorWidgetOfExactType<EditableText>())!
|
||||
.controller
|
||||
.value;
|
||||
final atEdge = text.selection.isCollapsed &&
|
||||
text.selection.baseOffset ==
|
||||
(direction == TraversalDirection.right ? text.text.length : 0);
|
||||
if (!atEdge) return KeyEventResult.ignored;
|
||||
_addressFocus.focusInDirection(direction);
|
||||
return KeyEventResult.handled;
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final deps = AppScope.of(context).deps;
|
||||
|
|
@ -236,8 +289,19 @@ class _ReaderScreenState extends State<ReaderScreen> {
|
|||
// 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;
|
||||
// Touch hosts navigate with gestures, or with a D-pad, instead.
|
||||
//
|
||||
// Key events reach only the focused branch, and a session starts with
|
||||
// nothing focused; this node gives them a place to arrive until the page
|
||||
// takes the focus. Tapping the address field or a control moves the
|
||||
// focus deeper in, where the shortcuts still apply.
|
||||
final keyed = Focus(
|
||||
autofocus: true,
|
||||
skipTraversal: true,
|
||||
onKeyEvent: _onReaderKey,
|
||||
child: reader,
|
||||
);
|
||||
if (_isTouch) return keyed;
|
||||
return CallbackShortcuts(
|
||||
bindings: {
|
||||
const SingleActivator(LogicalKeyboardKey.keyL, control: true):
|
||||
|
|
@ -262,11 +326,7 @@ class _ReaderScreenState extends State<ReaderScreen> {
|
|||
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),
|
||||
child: keyed,
|
||||
);
|
||||
}
|
||||
|
||||
|
|
@ -276,7 +336,8 @@ class _ReaderScreenState extends State<ReaderScreen> {
|
|||
if (page == null) {
|
||||
return SignalBuilder(builder: (context) {
|
||||
final error = deps.browser.error.value;
|
||||
return switch (error) {
|
||||
_focusErrorPage(error);
|
||||
final body = switch (error) {
|
||||
final BrokenPage broken => _errorPage(context, broken),
|
||||
LoadFailed() => _errorPage(context, null),
|
||||
// A protocol status carries the site's own words for what went
|
||||
|
|
@ -299,9 +360,11 @@ class _ReaderScreenState extends State<ReaderScreen> {
|
|||
CertificateChanged() => _certificateErrorPage(context),
|
||||
null => _emptyPage(context),
|
||||
};
|
||||
return Focus(focusNode: _errorFocus, child: body);
|
||||
});
|
||||
}
|
||||
|
||||
_focusedError = null;
|
||||
_checkSubscription(context, page);
|
||||
|
||||
return _pageView(context, page);
|
||||
|
|
@ -369,6 +432,11 @@ class _ReaderScreenState extends State<ReaderScreen> {
|
|||
},
|
||||
fetchImageBytes: deps.browser.fetchImageBytes,
|
||||
scrollController: _scroll,
|
||||
focusNode: _pageFocus,
|
||||
autofocus: true,
|
||||
onBack: deps.browser.back,
|
||||
onForward: deps.browser.forward,
|
||||
onHub: () => deps.browser.open(MewsUrls.hub),
|
||||
);
|
||||
});
|
||||
}
|
||||
|
|
@ -626,6 +694,19 @@ class _ReaderScreenState extends State<ReaderScreen> {
|
|||
);
|
||||
}
|
||||
|
||||
/// Puts the focus on the first control of an error page as it appears, so a
|
||||
/// D-pad has somewhere to start. A reader typing in the address field keeps
|
||||
/// the focus there.
|
||||
void _focusErrorPage(Object? error) {
|
||||
if (identical(error, _focusedError)) return;
|
||||
_focusedError = error;
|
||||
if (error == null) return;
|
||||
WidgetsBinding.instance.addPostFrameCallback((_) {
|
||||
if (!mounted || _focusInPageField()) return;
|
||||
_errorFocus.traversalDescendants.firstOrNull?.requestFocus();
|
||||
});
|
||||
}
|
||||
|
||||
/// Records the posts of a subscribed page once per page load, so what is new
|
||||
/// shows up on the subscriptions and posts pages (spec 6.1). The controller
|
||||
/// no-ops for a page the reader is not subscribed to.
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue