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
|
|
@ -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) {
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue