feat: narrow a library page as the reader types in its search box
This commit is contained in:
parent
9d9f09ebc7
commit
95602ac623
5 changed files with 157 additions and 8 deletions
|
|
@ -254,6 +254,27 @@ class BrowserController {
|
|||
await _loadCurrent();
|
||||
}
|
||||
|
||||
/// Narrows the open page to what the reader has typed in a search box. The
|
||||
/// query rides in the address as it does when the box is submitted, but it
|
||||
/// replaces the step of the walk they are standing on instead of adding
|
||||
/// one, so a search of eight letters is one step back and not eight.
|
||||
///
|
||||
/// Only one of our own pages narrows this way. A form on a fetched page is
|
||||
/// sent when it is submitted, never while it is being filled in, and so is
|
||||
/// a form that changes something rather than going somewhere.
|
||||
Future<void> narrowWhileTyping(String action, Map<String, String> fields) async {
|
||||
if (!MewsUrls.isInternal(action) || MewsUrls.isAction(action)) return;
|
||||
if (_stackIndex < 0) return;
|
||||
final url = MewsUrls.page(Uri.parse(action), fields);
|
||||
if (url == _stack[_stackIndex]) return;
|
||||
// The page the reader was going to next belonged to the search they have
|
||||
// now changed, so it is dropped as it is on any other navigation.
|
||||
_stack.removeRange(_stackIndex + 1, _stack.length);
|
||||
_stack[_stackIndex] = url;
|
||||
_updateNeighbors();
|
||||
await _loadCurrent();
|
||||
}
|
||||
|
||||
Future<void> submitForm(String action, String method, Map<String, String> fields) async {
|
||||
// A form on one of our own pages is a navigation or an action, never a
|
||||
// network request; the fetch client cannot speak the mews scheme.
|
||||
|
|
|
|||
|
|
@ -1,3 +1,5 @@
|
|||
import 'dart:async' show Timer;
|
||||
|
||||
import 'dart:typed_data';
|
||||
|
||||
import 'package:flutter/services.dart'
|
||||
|
|
@ -15,6 +17,10 @@ import '../../application/fonts.dart';
|
|||
import '../../mews/document.dart';
|
||||
import 'palettes.dart';
|
||||
|
||||
/// How long the reader has to stop typing before a search box narrows its
|
||||
/// page. Long enough that a word is one query rather than one per letter.
|
||||
const searchTypingPause = Duration(milliseconds: 250);
|
||||
|
||||
/// Renders a parsed [MewsPage] with the client's own presentation, following
|
||||
/// the measurements of the default stylesheet mews-0.1.css: a 40em text
|
||||
/// column, 1.5 line height, and the block margins authors expect.
|
||||
|
|
@ -27,6 +33,7 @@ class MewsPageView extends StatefulWidget {
|
|||
required this.visitedUrls,
|
||||
required this.onLinkTap,
|
||||
required this.onFormSubmit,
|
||||
this.onFormChange,
|
||||
this.fetchImageBytes,
|
||||
this.scrollController,
|
||||
this.ownPage = false,
|
||||
|
|
@ -41,6 +48,10 @@ class MewsPageView extends StatefulWidget {
|
|||
final void Function(String url) onLinkTap;
|
||||
final void Function(FormBlock form, Map<String, String> values) onFormSubmit;
|
||||
|
||||
/// Called as the reader types, so a search box can narrow its page without
|
||||
/// being submitted. Null on a page whose forms only answer when submitted.
|
||||
final void Function(FormBlock form, Map<String, String> values)? onFormChange;
|
||||
|
||||
/// The bytes of an image living on a protocol page, where no address exists
|
||||
/// that the platform could load from. Null when the page holds none.
|
||||
final Future<Uint8List?> Function(String url)? fetchImageBytes;
|
||||
|
|
@ -412,10 +423,15 @@ class _MewsPageViewState extends State<MewsPageView> {
|
|||
),
|
||||
FormBlock() => Padding(
|
||||
padding: EdgeInsets.only(bottom: base),
|
||||
// Keyed by what it submits to: a page rebuilt as 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.
|
||||
key: ValueKey('form:${block.action}'),
|
||||
child: _MewsForm(
|
||||
form: block,
|
||||
foreground: foreground,
|
||||
onFormSubmit: widget.onFormSubmit,
|
||||
onFormChange: widget.onFormChange,
|
||||
ownPage: widget.ownPage,
|
||||
),
|
||||
),
|
||||
|
|
@ -669,12 +685,14 @@ class _MewsForm extends StatefulWidget {
|
|||
required this.form,
|
||||
required this.foreground,
|
||||
required this.onFormSubmit,
|
||||
this.onFormChange,
|
||||
this.ownPage = false,
|
||||
});
|
||||
|
||||
final FormBlock form;
|
||||
final TextStyle foreground;
|
||||
final void Function(FormBlock form, Map<String, String> values) onFormSubmit;
|
||||
final void Function(FormBlock form, Map<String, String> values)? onFormChange;
|
||||
|
||||
/// On our own pages a form is a search box or a one-field prompt, so it sits
|
||||
/// on one line instead of filling the column like a page's own form.
|
||||
|
|
@ -688,8 +706,37 @@ class _MewsFormState extends State<_MewsForm> {
|
|||
final _controllers = <String, TextEditingController>{};
|
||||
final _values = <String, String>{};
|
||||
|
||||
/// Waits out [searchTypingPause] before the page is narrowed.
|
||||
Timer? _typing;
|
||||
|
||||
/// A page that narrows as the reader types is rebuilt under the box they are
|
||||
/// typing in, and the rebuilt box carries what they typed, so the text is
|
||||
/// left alone. When the page changes it itself, as clearing a search does,
|
||||
/// the box follows.
|
||||
@override
|
||||
void didUpdateWidget(_MewsForm oldWidget) {
|
||||
super.didUpdateWidget(oldWidget);
|
||||
final wasValues = _modelValues(oldWidget.form);
|
||||
_modelValues(widget.form).forEach((name, value) {
|
||||
if (wasValues[name] == value) return;
|
||||
final controller = _controllers[name];
|
||||
if (controller == null || controller.text == value) return;
|
||||
controller.value = TextEditingValue(
|
||||
text: value,
|
||||
selection: TextSelection.collapsed(offset: value.length),
|
||||
);
|
||||
_values[name] = value;
|
||||
});
|
||||
}
|
||||
|
||||
Map<String, String> _modelValues(FormBlock form) => {
|
||||
for (final field in form.fields)
|
||||
if (field case MewsTextField(:final name, :final value)) name: value,
|
||||
};
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
_typing?.cancel();
|
||||
for (final controller in _controllers.values) {
|
||||
controller.dispose();
|
||||
}
|
||||
|
|
@ -768,6 +815,7 @@ class _MewsFormState extends State<_MewsForm> {
|
|||
maxLength: maxLength,
|
||||
style: style,
|
||||
decoration: InputDecoration(labelText: field.label, isDense: true),
|
||||
onChanged: (text) => _changed(widget.form, name, text),
|
||||
),
|
||||
MewsTextAreaField(:final name, :final value, :final rows) =>
|
||||
TextFormField(
|
||||
|
|
@ -832,6 +880,23 @@ class _MewsFormState extends State<_MewsForm> {
|
|||
}
|
||||
|
||||
void _submit(FormBlock form) {
|
||||
// Submitting says the reader is done typing, so it stands in for the
|
||||
// narrowing they were waiting on.
|
||||
_typing?.cancel();
|
||||
widget.onFormSubmit(form, _valuesOf(form));
|
||||
}
|
||||
|
||||
/// Reports what is in the form once the reader pauses, so a search box
|
||||
/// narrows its page without being submitted.
|
||||
void _changed(FormBlock form, String name, String text) {
|
||||
_values[name] = text;
|
||||
final report = widget.onFormChange;
|
||||
if (report == null) return;
|
||||
_typing?.cancel();
|
||||
_typing = Timer(searchTypingPause, () => report(form, _valuesOf(form)));
|
||||
}
|
||||
|
||||
Map<String, String> _valuesOf(FormBlock form) {
|
||||
final values = <String, String>{};
|
||||
for (final field in form.fields) {
|
||||
switch (field) {
|
||||
|
|
@ -851,7 +916,7 @@ class _MewsFormState extends State<_MewsForm> {
|
|||
break;
|
||||
}
|
||||
}
|
||||
widget.onFormSubmit(form, values);
|
||||
return values;
|
||||
}
|
||||
|
||||
void _reset(FormBlock form) {
|
||||
|
|
|
|||
|
|
@ -242,7 +242,10 @@ class _ReaderScreenState extends State<ReaderScreen> {
|
|||
url: page.url,
|
||||
);
|
||||
return MewsPageView(
|
||||
key: ValueKey(page.url),
|
||||
// Our own pages carry their view state in the address, so the key
|
||||
// leaves the query out: narrowing a list as the reader types rebuilds
|
||||
// the page without taking the search box out from under them.
|
||||
key: ValueKey(_viewKey(page.url)),
|
||||
page: page,
|
||||
ownPage: MewsUrls.isInternal(page.url),
|
||||
palette: palette,
|
||||
|
|
@ -252,12 +255,23 @@ class _ReaderScreenState extends State<ReaderScreen> {
|
|||
onLinkTap: (url) => deps.browser.open(url),
|
||||
onFormSubmit: (form, values) =>
|
||||
deps.browser.submitForm(form.action, form.method, values),
|
||||
onFormChange: (form, values) =>
|
||||
deps.browser.narrowWhileTyping(form.action, values),
|
||||
fetchImageBytes: deps.browser.fetchImageBytes,
|
||||
scrollController: _scroll,
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
/// What identifies the open page to the widget tree. One of our own pages
|
||||
/// is identified by its path alone, so a change to its query string, such
|
||||
/// as a search being narrowed, rebuilds the page in place instead of
|
||||
/// building a new one.
|
||||
String _viewKey(String url) {
|
||||
if (!MewsUrls.isInternal(url)) return url;
|
||||
return Uri.parse(url).path;
|
||||
}
|
||||
|
||||
/// Renders a neighboring page for the swipe peek, non-interactive: it is a
|
||||
/// look ahead, and committing the navigation brings the real thing.
|
||||
Widget? _neighborView(MewsPage? page) {
|
||||
|
|
|
|||
|
|
@ -16,6 +16,7 @@ import 'package:mews/mews/parser.dart';
|
|||
import 'package:mews/application/pages/mews_urls.dart';
|
||||
import 'package:mews/presentation/app.dart';
|
||||
import 'package:mews/presentation/reader/address_bar.dart';
|
||||
import 'package:mews/presentation/reader/mews_page_view.dart' show searchTypingPause;
|
||||
import 'package:mews/presentation/reader/swipe_navigator.dart';
|
||||
|
||||
import 'fake_fetch.dart';
|
||||
|
|
@ -51,6 +52,9 @@ String _page(String title, String body) => '''
|
|||
</html>
|
||||
''';
|
||||
|
||||
/// Long enough for a search box to have narrowed its page.
|
||||
final pastTypingPause = searchTypingPause + const Duration(milliseconds: 50);
|
||||
|
||||
void main() {
|
||||
late MewsDatabase db;
|
||||
late AppDependencies deps;
|
||||
|
|
@ -663,19 +667,64 @@ void main() {
|
|||
expect(find.textContaining('Signal bars'), findsOneWidget);
|
||||
|
||||
// Removing one stays on the page and adds no walk history entry. What the
|
||||
// reader has typed survives, because the renderer matches form state by
|
||||
// position and the search box keeps its place in the page.
|
||||
await tester.enterText(find.byType(TextFormField), 'half typed');
|
||||
// reader has typed survives, because the renderer matches the search box
|
||||
// of the rebuilt page with the one they are typing in. The search here
|
||||
// matches both addresses, so the list is still the whole of it.
|
||||
await tester.enterText(find.byType(TextFormField), 'exam');
|
||||
await tester.pump(pastTypingPause);
|
||||
await tester.pumpAndSettle();
|
||||
final stepsBack = deps.browser.canGoBack;
|
||||
await tapRun(tester, 'Remove bookmark');
|
||||
expect(find.widgetWithText(TextFormField, 'half typed'), findsOneWidget);
|
||||
expect(deps.browser.currentPage.value?.url, MewsUrls.bookmarks);
|
||||
expect(find.widgetWithText(TextFormField, 'exam'), findsOneWidget);
|
||||
expect(deps.browser.currentPage.value?.url, 'mews:bookmarks?q=exam');
|
||||
expect(deps.browser.canGoBack, stepsBack);
|
||||
expect(find.textContaining('Retro phones'), findsNothing);
|
||||
expect(find.textContaining('Signal bars'), findsOneWidget);
|
||||
expect(deps.bookmarks.isBookmarked('https://a.example/retro.html'), isFalse);
|
||||
});
|
||||
|
||||
testWidgets('a search box narrows its page as the reader types',
|
||||
(tester) async {
|
||||
await pumpApp(tester);
|
||||
await deps.bookmarks.load();
|
||||
await deps.bookmarks.toggle('https://a.example/retro.html', 'Retro phones');
|
||||
await deps.bookmarks.toggle('https://b.example/signal.html', 'Signal bars');
|
||||
|
||||
await deps.browser.open(MewsUrls.bookmarks);
|
||||
await tester.pumpAndSettle();
|
||||
final stepsBack = deps.browser.canGoBack;
|
||||
|
||||
// Typing narrows the list without the box being submitted, and the query
|
||||
// lands in the address as it does on a submit.
|
||||
await tester.enterText(find.byType(TextFormField), 'retro');
|
||||
await tester.pump(pastTypingPause);
|
||||
await tester.pumpAndSettle();
|
||||
expect(deps.browser.currentPage.value?.url, 'mews:bookmarks?q=retro');
|
||||
expect(find.textContaining('Retro phones'), findsOneWidget);
|
||||
expect(find.textContaining('Signal bars'), findsNothing);
|
||||
|
||||
// The box keeps what was typed, and the cursor with it, so the next
|
||||
// letter carries on from where the reader left off.
|
||||
expect(find.widgetWithText(TextFormField, 'retro'), findsOneWidget);
|
||||
await tester.enterText(find.byType(TextFormField), 'signal');
|
||||
await tester.pump(pastTypingPause);
|
||||
await tester.pumpAndSettle();
|
||||
expect(deps.browser.currentPage.value?.url, 'mews:bookmarks?q=signal');
|
||||
expect(find.textContaining('Signal bars'), findsOneWidget);
|
||||
|
||||
// A narrowed search replaces the step of the walk the reader stands on,
|
||||
// so two searches are not two steps back.
|
||||
expect(deps.browser.canGoBack, stepsBack);
|
||||
|
||||
// Clearing the search empties the box too, since the page set it.
|
||||
await tapRun(tester, 'Clear search');
|
||||
await tester.pumpAndSettle();
|
||||
expect(deps.browser.currentPage.value?.url, MewsUrls.bookmarks);
|
||||
expect(find.widgetWithText(TextFormField, 'signal'), findsNothing);
|
||||
expect(find.textContaining('Retro phones'), findsOneWidget);
|
||||
expect(find.textContaining('Signal bars'), findsOneWidget);
|
||||
});
|
||||
|
||||
testWidgets('tags are made by using them and filter the list',
|
||||
(tester) async {
|
||||
await pumpApp(tester);
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue