From 95602ac623fa63b586d2278b94cece2d55e00117 Mon Sep 17 00:00:00 2001 From: randogoth Date: Sun, 11 Oct 2026 22:56:56 +0300 Subject: [PATCH] feat: narrow a library page as the reader types in its search box --- SPEC.md | 2 +- .../controllers/browser_controller.dart | 21 ++++++ .../presentation/reader/mews_page_view.dart | 67 ++++++++++++++++++- .../presentation/reader/reader_screen.dart | 16 ++++- app/test/app_widget_test.dart | 59 ++++++++++++++-- 5 files changed, 157 insertions(+), 8 deletions(-) diff --git a/SPEC.md b/SPEC.md index 3447027..cee0897 100644 --- a/SPEC.md +++ b/SPEC.md @@ -86,7 +86,7 @@ The v1 client presents one reader screen: - A site that publishes through an Atom feed instead of dated links is read through the feed (spec 6.2). Dated links are the normative mechanism, so the feed is only asked for when the page itself listed nothing, and a feed on another site is refused. Posts are keyed by address, so a post listed both ways is stored once, under the title the page gave it. - Subscribed sites are checked once in the background at launch, and whenever the reader asks. Sites are checked one after another rather than at once, and a site that cannot be reached keeps the posts already held and is still recorded as checked, so one site being down never stops the rest. - The reader can tag bookmarks and sites. A tag exists only while something carries it, so adding a tag and making one are the same act; `mews:tags` lists every tag in use and what carries it, and following a tag narrows the list it belongs to. There is no renaming and no empty tag. -- Each library page carries a search box that narrows it by title or address, and each row carries its own actions: remove a bookmark, leave a site or check it now, forget a page. History lists one row per address, headed by the most recent visit, rather than repeating a page once per visit. Clearing history takes a second step on a page the reader can simply leave, since it cannot be undone. +- Each library page carries a search box that narrows it by title or address as the reader types, a quarter second after they stop. The query rides in the address as any other view state does, but it replaces the step of the walk they are standing on rather than adding one, so a search of eight letters is one step back and not eight; the box keeps what they typed as the page rebuilds under them, and follows the page when the page sets it, as clearing the search does. Submitting the box still works, for a reader who would rather say when to search. Each row carries its own actions: remove a bookmark, leave a site or check it now, forget a page. History lists one row per address, headed by the most recent visit, rather than repeating a page once per visit. Clearing history takes a second step on a page the reader can simply leave, since it cannot be undone. - A change is a link too: following `mews:do/` applies it and rerenders the page in place without adding a history entry. Only a link followed from one of the client's own pages is honored, so a fetched page cannot clear history or change a setting on the reader's behalf (spec 8.1). A form on one of these pages is routed the same way rather than sent to the network. - Our own pages are built from the same blocks as a page but are interface, not an article, so the renderer lays them out differently: wider before they stop growing, denser, rows divided by a line instead of bulleted, section headings quieter, a note under a row set smaller and dimmer, links carried by weight rather than an underline, and a one-field form on a single line. - The interface ships in English, German, Spanish, French, Hebrew, Japanese, Portuguese and Russian. It follows the device's language by default and the reader can choose another on the settings page, where each language is named in its own words so it can be found from an interface they cannot read. A message a language has not translated falls back to English rather than disappearing. diff --git a/app/lib/application/controllers/browser_controller.dart b/app/lib/application/controllers/browser_controller.dart index f524f0d..447f84a 100644 --- a/app/lib/application/controllers/browser_controller.dart +++ b/app/lib/application/controllers/browser_controller.dart @@ -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 narrowWhileTyping(String action, Map 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 submitForm(String action, String method, Map 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. diff --git a/app/lib/presentation/reader/mews_page_view.dart b/app/lib/presentation/reader/mews_page_view.dart index 3443172..d1ab85e 100644 --- a/app/lib/presentation/reader/mews_page_view.dart +++ b/app/lib/presentation/reader/mews_page_view.dart @@ -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 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 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 Function(String url)? fetchImageBytes; @@ -412,10 +423,15 @@ class _MewsPageViewState extends State { ), 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 values) onFormSubmit; + final void Function(FormBlock form, Map 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 = {}; final _values = {}; + /// 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 _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 _valuesOf(FormBlock form) { final values = {}; 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) { diff --git a/app/lib/presentation/reader/reader_screen.dart b/app/lib/presentation/reader/reader_screen.dart index c9d8c1e..83a36dc 100644 --- a/app/lib/presentation/reader/reader_screen.dart +++ b/app/lib/presentation/reader/reader_screen.dart @@ -242,7 +242,10 @@ class _ReaderScreenState extends State { 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 { 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) { diff --git a/app/test/app_widget_test.dart b/app/test/app_widget_test.dart index 8257ae2..ad26f76 100644 --- a/app/test/app_widget_test.dart +++ b/app/test/app_widget_test.dart @@ -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) => ''' '''; +/// 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);