feat: add keyboard shortcuts and Tab link traversal on pointer hosts

This commit is contained in:
randogoth 2026-10-11 22:32:44 +03:00
parent fa7aa5557c
commit 56db479df0
6 changed files with 798 additions and 272 deletions

View file

@ -1,4 +1,6 @@
import 'dart:convert' show base64;
import 'package:flutter/services.dart' show LogicalKeyboardKey;
import 'dart:io' show File;
import 'dart:typed_data';
@ -80,6 +82,135 @@ void main() {
expect(opened, 'https://example.com/next.html');
});
testWidgets('Tab walks the links on a page', (tester) async {
final page = MewsPage(
url: 'https://example.com/index.html',
lang: 'en',
direction: TextDirection.ltr,
variant: MewsVariant.plain,
specVersion: '0.1',
entries: const [],
body: const [
ParagraphBlock([MewsText('Read on for more.')]),
ParagraphBlock([
MewsLink('https://example.com/first.html', [MewsText('the first page')]),
]),
ParagraphBlock([
MewsLink('https://example.com/second.html', [MewsText('the second page')]),
]),
],
);
final opened = <String>[];
await tester.pumpWidget(
MaterialApp(
home: StringsScope(
strings: await _strings(),
child: MewsPageView(
page: page,
palette: paletteFor(
pageVariant: MewsVariant.plain, readerVariant: 'site', dark: false),
fontFamily: 'Atkinson Hyperlegible',
fontScale: 1.0,
visitedUrls: const {},
onLinkTap: opened.add,
onFormSubmit: (_, _) {},
),
),
),
);
// Pressing the page puts the keyboard focus on it.
final body = tester.getRect(find.text('Read on for more.'));
await tester.tapAt(body.center);
await tester.pump();
// Tab highlights each link in reading order, and Enter opens the one
// highlighted.
Future<void> press(LogicalKeyboardKey key) async {
await tester.sendKeyDownEvent(key);
await tester.pump();
await tester.sendKeyUpEvent(key);
await tester.pump();
}
await press(LogicalKeyboardKey.tab);
await press(LogicalKeyboardKey.enter);
expect(opened, ['https://example.com/first.html']);
await press(LogicalKeyboardKey.tab);
await press(LogicalKeyboardKey.enter);
expect(opened, ['https://example.com/first.html', 'https://example.com/second.html']);
// Shift+Tab walks back, and Escape puts the highlight away, so Enter
// after it opens nothing.
await tester.sendKeyDownEvent(LogicalKeyboardKey.shiftLeft);
await tester.sendKeyDownEvent(LogicalKeyboardKey.tab);
await tester.pump();
await tester.sendKeyUpEvent(LogicalKeyboardKey.tab);
await tester.sendKeyUpEvent(LogicalKeyboardKey.shiftLeft);
await press(LogicalKeyboardKey.enter);
expect(opened, ['https://example.com/first.html', 'https://example.com/second.html',
'https://example.com/first.html']);
await press(LogicalKeyboardKey.escape);
await press(LogicalKeyboardKey.enter);
expect(opened, hasLength(3));
});
testWidgets('the scroll follows the Tab walk', (tester) async {
final page = MewsPage(
url: 'https://example.com/index.html',
lang: 'en',
direction: TextDirection.ltr,
variant: MewsVariant.plain,
specVersion: '0.1',
entries: const [],
body: [
// Enough prose to push the links below the fold.
for (var i = 0; i < 40; i++)
ParagraphBlock([MewsText('Paragraph $i of the tall page.')]),
const ParagraphBlock([
MewsLink('https://example.com/deep.html', [MewsText('a link down below')]),
]),
],
);
final scroll = ScrollController();
await tester.pumpWidget(
MaterialApp(
home: StringsScope(
strings: await _strings(),
child: MewsPageView(
page: page,
palette: paletteFor(
pageVariant: MewsVariant.plain, readerVariant: 'site', dark: false),
fontFamily: 'Atkinson Hyperlegible',
fontScale: 1.0,
visitedUrls: const {},
onLinkTap: (_) {},
onFormSubmit: (_, _) {},
scrollController: scroll,
),
),
),
);
await tester.pumpAndSettle();
expect(scroll.offset, 0);
// Pressing the page puts the keyboard focus on it, and the walk carries
// the viewport along to whichever link is highlighted.
final body = tester.getRect(find.text('Paragraph 0 of the tall page.'));
await tester.tapAt(body.center);
await tester.pump();
await tester.sendKeyDownEvent(LogicalKeyboardKey.tab);
await tester.pump();
await tester.sendKeyUpEvent(LogicalKeyboardKey.tab);
await tester.pumpAndSettle();
expect(scroll.offset, greaterThan(0));
});
testWidgets('an image on a capsule arrives over the protocol', (tester) async {
// A 1x1 PNG, small enough to spell out.
final png = Uint8List.fromList(base64.decode(