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

@ -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.