feat: narrow a library page as the reader types in its search box

This commit is contained in:
randogoth 2026-10-11 22:56:56 +03:00
parent 9d9f09ebc7
commit 95602ac623
5 changed files with 157 additions and 8 deletions

View file

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

View file

@ -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) {

View file

@ -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) {