From 95ea8ea0a33a2b16dde7e56be384119b1cb246cb Mon Sep 17 00:00:00 2001 From: Karolis Strazdas Date: Thu, 29 Oct 2026 21:12:59 +0200 Subject: [PATCH 1/7] fix: unify reader loading and allow instant page turns --- docs/architecture.md | 7 +- lib/src/domain/reader_capabilities.dart | 6 +- lib/src/domain/reader_preferences.dart | 8 +- lib/src/engine/epub/epub_reader_engine.dart | 2 + lib/src/engine/epub/epub_viewport.dart | 77 ++++++----- lib/src/presentation/papyrus_reader.dart | 17 ++- lib/src/presentation/reader_loading.dart | 36 ++++++ .../presentation/reader_settings_panel.dart | 14 ++ lib/src/presentation/reader_ui_builders.dart | 2 + test/presentation/epub_page_turn_test.dart | 67 +++++++++- test/presentation/epub_selection_test.dart | 12 +- .../reader_loading_motion_test.dart | 122 ++++++++++++++++++ tool/browser/smoke.mjs | 9 ++ 13 files changed, 327 insertions(+), 52 deletions(-) create mode 100644 lib/src/presentation/reader_loading.dart create mode 100644 test/presentation/reader_loading_motion_test.dart diff --git a/docs/architecture.md b/docs/architecture.md index e7f462e..39aabb8 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -61,7 +61,9 @@ keeps readable content and offers retry. Coverage describes committed viewports. The viewport owns page and scroll controllers. Paginated commands use the same slide duration within and across spine items. At a boundary it retains the outgoing spread, animates to the prepared destination, then commits reading coverage and -the visible locator. Reduced motion skips the animation. Rapid commands wait for +the visible locator. The page-turn animation preference and reduced motion both +skip the animation, including drag-following and snap transitions. Touch swipes +still turn one page on release. Changing motion does not invalidate layout. Rapid commands wait for each transition to finish. Spine indices identify content and saved positions, independently of any table-of-contents chapter divisions. Single-column content has a maximum line width; spreads collapse on narrow or highly scaled viewports. Scrolling uses the @@ -92,7 +94,8 @@ independence without exposing PDFium details to the shell or host persistence. The shell retains the content widget during position updates, serializes user commands and keeps error views escapable. Initial opening uses a centered spinner without toolbar chrome; hosts can supply a loading builder to match their file-fetch -loader. Reader appearance controls the +loader. Initial EPUB pagination uses the same builder, host theme, centered +position and background, without a separate preparation label. Reader appearance controls the chrome brightness as well as page colors. Wide panels overlay the reading area; compact panels fit available height and insets. A distinct surface color and outlined page-facing edges separate panels from book content without shadows. diff --git a/lib/src/domain/reader_capabilities.dart b/lib/src/domain/reader_capabilities.dart index e47ad83..8a4cba0 100644 --- a/lib/src/domain/reader_capabilities.dart +++ b/lib/src/domain/reader_capabilities.dart @@ -4,12 +4,14 @@ final class ReaderCapabilities { this.supportsScrolling = false, this.supportsTextCustomization = false, this.supportsColumnMode = false, + this.supportsPageTurnAnimation = false, }); final bool supportsPagination; final bool supportsScrolling; final bool supportsTextCustomization; final bool supportsColumnMode; + final bool supportsPageTurnAnimation; @override bool operator ==(Object other) => @@ -18,7 +20,8 @@ final class ReaderCapabilities { supportsPagination == other.supportsPagination && supportsScrolling == other.supportsScrolling && supportsTextCustomization == other.supportsTextCustomization && - supportsColumnMode == other.supportsColumnMode; + supportsColumnMode == other.supportsColumnMode && + supportsPageTurnAnimation == other.supportsPageTurnAnimation; @override int get hashCode => Object.hash( @@ -26,5 +29,6 @@ final class ReaderCapabilities { supportsScrolling, supportsTextCustomization, supportsColumnMode, + supportsPageTurnAnimation, ); } diff --git a/lib/src/domain/reader_preferences.dart b/lib/src/domain/reader_preferences.dart index b82ef7f..b2eb928 100644 --- a/lib/src/domain/reader_preferences.dart +++ b/lib/src/domain/reader_preferences.dart @@ -17,6 +17,7 @@ final class ReaderPreferences { this.brightness = Brightness.light, this.layoutMode = ReaderLayoutMode.paginated, this.columnMode = ReaderColumnMode.single, + this.pageTurnAnimation = true, }) : assert(fontSize > 0), assert(lineHeight > 0), assert(paragraphSpacing >= 0); @@ -32,6 +33,7 @@ final class ReaderPreferences { final Brightness brightness; final ReaderLayoutMode layoutMode; final ReaderColumnMode columnMode; + final bool pageTurnAnimation; ReaderPreferences copyWith({ Object? fontFamily = _unsetFontFamily, @@ -45,6 +47,7 @@ final class ReaderPreferences { Brightness? brightness, ReaderLayoutMode? layoutMode, ReaderColumnMode? columnMode, + bool? pageTurnAnimation, }) { return ReaderPreferences( fontFamily: identical(fontFamily, _unsetFontFamily) @@ -60,6 +63,7 @@ final class ReaderPreferences { brightness: brightness ?? this.brightness, layoutMode: layoutMode ?? this.layoutMode, columnMode: columnMode ?? this.columnMode, + pageTurnAnimation: pageTurnAnimation ?? this.pageTurnAnimation, ); } @@ -77,7 +81,8 @@ final class ReaderPreferences { foregroundColor == other.foregroundColor && brightness == other.brightness && layoutMode == other.layoutMode && - columnMode == other.columnMode; + columnMode == other.columnMode && + pageTurnAnimation == other.pageTurnAnimation; @override int get hashCode => Object.hash( @@ -92,5 +97,6 @@ final class ReaderPreferences { brightness, layoutMode, columnMode, + pageTurnAnimation, ); } diff --git a/lib/src/engine/epub/epub_reader_engine.dart b/lib/src/engine/epub/epub_reader_engine.dart index 6b3d683..92b3820 100644 --- a/lib/src/engine/epub/epub_reader_engine.dart +++ b/lib/src/engine/epub/epub_reader_engine.dart @@ -88,6 +88,7 @@ final class EpubReaderEngine extends ReaderEngine { supportsScrolling: true, supportsTextCustomization: true, supportsColumnMode: true, + supportsPageTurnAnimation: true, ); @override ReaderSnapshot snapshot = const ReaderIdleSnapshot(); @@ -525,6 +526,7 @@ final class EpubReaderEngine extends ReaderEngine { backgroundColor: _preferences.backgroundColor, foregroundColor: _preferences.foregroundColor, brightness: _preferences.brightness, + pageTurnAnimation: _preferences.pageTurnAnimation, ) != _preferences; _preferences = preferences; diff --git a/lib/src/engine/epub/epub_viewport.dart b/lib/src/engine/epub/epub_viewport.dart index 6735515..8d7a39c 100644 --- a/lib/src/engine/epub/epub_viewport.dart +++ b/lib/src/engine/epub/epub_viewport.dart @@ -9,6 +9,7 @@ import '../../domain/reader_locator.dart'; import '../../domain/reader_types.dart'; import '../../domain/reader_preferences.dart'; import '../../presentation/reader_interaction_scope.dart'; +import '../../presentation/reader_loading.dart'; import '../../presentation/reader_swipe_tracker.dart'; import '../../presentation/reader_highlight_interaction.dart'; import '../../presentation/reader_text_toolbar.dart'; @@ -50,6 +51,10 @@ final class _EpubViewportState extends State { int _pageIndex = 0; int _columns = 1; final _swipe = ReaderSwipeTracker(); + bool get _animateTurns => + widget.engine.preferences.pageTurnAnimation && + !(MediaQuery.maybeOf(context)?.disableAnimations ?? false); + int _startPage = 0; double _startScroll = 0; bool _restoring = false; @@ -446,8 +451,7 @@ final class _EpubViewportState extends State { if (next < 0 || next >= count) { final index = widget.engine.locator.spineIndex + direction; if (index < 0 || index >= widget.engine.chapterCount) return false; - final animate = - !(MediaQuery.maybeOf(context)?.disableAnimations ?? false); + final animate = _animateTurns; final from = _pageIndex * _columns; final outgoing = _pages.sublist( from, @@ -478,7 +482,7 @@ final class _EpubViewportState extends State { return true; } - if (MediaQuery.maybeOf(context)?.disableAnimations ?? false) { + if (!_animateTurns) { controller.jumpToPage(next); } else { await controller.animateToPage( @@ -807,7 +811,10 @@ final class _EpubViewportState extends State { ); } return ColoredBox( - color: preferences.backgroundColor, + color: _pages.isEmpty + ? ReaderLoadingScope.maybeOf(context)?.backgroundColor ?? + preferences.backgroundColor + : preferences.backgroundColor, child: LayoutBuilder( builder: (context, constraints) { final size = Size( @@ -901,22 +908,26 @@ final class _EpubViewportState extends State { _layoutReady!.complete(); } _busy = false; - return Center( - child: Column( - mainAxisSize: MainAxisSize.min, - children: [ - Text( - 'Could not prepare these pages.', - style: TextStyle( - color: preferences.foregroundColor, + return ColoredBox( + color: preferences.backgroundColor, + child: Center( + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + Text( + 'Could not prepare these pages.', + style: TextStyle( + color: preferences.foregroundColor, + ), ), - ), - const SizedBox(height: 12), - FilledButton( - onPressed: () => setState(() => _layoutKey = null), - child: const Text('Try again'), - ), - ], + const SizedBox(height: 12), + FilledButton( + onPressed: () => + setState(() => _layoutKey = null), + child: const Text('Try again'), + ), + ], + ), ), ); } @@ -926,23 +937,7 @@ final class _EpubViewportState extends State { snapshot.data!.isNotEmpty; if (!ready && _pages.isEmpty) { - return Center( - child: Column( - mainAxisSize: MainAxisSize.min, - children: [ - CircularProgressIndicator( - color: preferences.foregroundColor, - ), - const SizedBox(height: 12), - Text( - 'Preparing pages…', - style: TextStyle( - color: preferences.foregroundColor, - ), - ), - ], - ), - ); + return const ReaderLoading(); } if (ready) { _displayPreferences = preferences; @@ -996,6 +991,9 @@ final class _EpubViewportState extends State { content = PageView.builder( key: ValueKey(_pagesController), controller: _pagesController, + physics: _animateTurns + ? null + : const NeverScrollableScrollPhysics(), onPageChanged: _publishPage, itemCount: outgoing == null ? (pages.length / columns).ceil() @@ -1063,12 +1061,13 @@ final class _EpubViewportState extends State { } } else { if (drag < 0 && - _startPage == - (pages.length / columns).ceil() - 1) { + (!_animateTurns || + _startPage == + (pages.length / columns).ceil() - 1)) { _requestTurn(1); } - if (drag > 0 && _startPage == 0) { + if (drag > 0 && (!_animateTurns || _startPage == 0)) { _requestTurn(-1); } } diff --git a/lib/src/presentation/papyrus_reader.dart b/lib/src/presentation/papyrus_reader.dart index e6fac19..82232fa 100644 --- a/lib/src/presentation/papyrus_reader.dart +++ b/lib/src/presentation/papyrus_reader.dart @@ -21,6 +21,7 @@ import 'reader_controls.dart'; import 'reader_surface.dart'; import 'reader_contents_panel.dart'; import 'reader_settings_panel.dart'; +import 'reader_loading.dart'; import 'reader_theme_data.dart'; import 'reader_ui_builders.dart'; import '../domain/reader_selection.dart'; @@ -369,7 +370,7 @@ final class _PapyrusReaderState extends State { data: readerTheme, child: Builder( builder: (context) => Material( - color: snapshot.status == ReaderStatus.ready + color: snapshot.status == ReaderStatus.ready && snapshot.contentReady ? snapshot.preferences.backgroundColor : theme.surfaceColor, child: SafeArea( @@ -380,7 +381,19 @@ final class _PapyrusReaderState extends State { _buildError(context, snapshot, theme), theme, ), - ReaderStatus.ready => _buildReady(context, snapshot, theme), + ReaderStatus.ready => ReaderLoadingScope( + backgroundColor: theme.surfaceColor, + builder: (context) => Theme( + data: readerTheme, + child: Builder( + builder: (context) => ColoredBox( + color: theme.surfaceColor, + child: _buildLoading(context, snapshot, theme), + ), + ), + ), + child: _buildReady(context, snapshot, theme), + ), }, ), ), diff --git a/lib/src/presentation/reader_loading.dart b/lib/src/presentation/reader_loading.dart new file mode 100644 index 0000000..8407e08 --- /dev/null +++ b/lib/src/presentation/reader_loading.dart @@ -0,0 +1,36 @@ +import 'package:flutter/material.dart'; + +/// Keeps engine preparation visually consistent with the host's opening state. +final class ReaderLoadingScope extends InheritedWidget { + const ReaderLoadingScope({ + required this.builder, + required this.backgroundColor, + required super.child, + super.key, + }); + + final WidgetBuilder builder; + final Color backgroundColor; + + static ReaderLoadingScope? maybeOf(BuildContext context) => + context.dependOnInheritedWidgetOfExactType(); + + @override + bool updateShouldNotify(ReaderLoadingScope oldWidget) => + builder != oldWidget.builder || + backgroundColor != oldWidget.backgroundColor; +} + +final class ReaderLoading extends StatelessWidget { + const ReaderLoading({super.key}); + + @override + Widget build(BuildContext context) { + final scope = context + .dependOnInheritedWidgetOfExactType(); + return scope?.builder(context) ?? + const Center( + child: CircularProgressIndicator(semanticsLabel: 'Opening document'), + ); + } +} diff --git a/lib/src/presentation/reader_settings_panel.dart b/lib/src/presentation/reader_settings_panel.dart index bfbec71..d13f7fa 100644 --- a/lib/src/presentation/reader_settings_panel.dart +++ b/lib/src/presentation/reader_settings_panel.dart @@ -146,6 +146,20 @@ final class ReaderSettingsPanel extends StatelessWidget { : null, ), ], + if (capabilities.supportsPageTurnAnimation && + preferences.layoutMode == ReaderLayoutMode.paginated) + SwitchListTile( + contentPadding: EdgeInsets.zero, + title: const Text('Page turn animation'), + subtitle: const Text( + 'Slide between pages. Turn off for instant page changes.', + ), + value: preferences.pageTurnAnimation, + onChanged: enabled + ? (value) => + _update(preferences.copyWith(pageTurnAnimation: value)) + : null, + ), if (capabilities.supportsColumnMode) ...[ const SizedBox(height: 12), DropdownButtonFormField( diff --git a/lib/src/presentation/reader_ui_builders.dart b/lib/src/presentation/reader_ui_builders.dart index b200fbe..6881f9b 100644 --- a/lib/src/presentation/reader_ui_builders.dart +++ b/lib/src/presentation/reader_ui_builders.dart @@ -133,6 +133,8 @@ final class ReaderUiBuilders { final ReaderPanelRouteBuilder? compactPanelRoute; final ReaderPanelBuilder? tableOfContents; final ReaderPanelBuilder? settings; + + /// Used while opening the document and preparing its first readable pages. final ReaderStateBuilder? loading; final ReaderStateBuilder? error; final ReaderStateBuilder? empty; diff --git a/test/presentation/epub_page_turn_test.dart b/test/presentation/epub_page_turn_test.dart index 6266aad..9dc8258 100644 --- a/test/presentation/epub_page_turn_test.dart +++ b/test/presentation/epub_page_turn_test.dart @@ -6,9 +6,66 @@ import '../support/in_process_epub_worker.dart'; import '../support/synthetic_epub.dart'; void main() { - for (final reducedMotion in [false, true]) { + testWidgets('instant turns do not slide on commands or touch drags', ( + tester, + ) async { + final engine = testEpubEngine(); + await engine.load( + ReaderDocument( + id: 'instant', + format: ReaderFormat.epub, + loadBytes: () async => syntheticEpub(longChapter: true), + ), + preferences: const ReaderPreferences(), + ); + await tester.pumpWidget( + MaterialApp(home: Builder(builder: engine.buildViewport)), + ); + await tester.pumpAndSettle(); + await pumpReaderCommand(tester, engine.goNext()); + final controller = tester + .widget(find.byType(PageView)) + .controller!; + final location = engine.locator; + final revision = engine.restorationRevision; + await engine.updatePreferences( + engine.preferences.copyWith(pageTurnAnimation: false), + ); + await tester.pump(); + expect(engine.restorationRevision, revision); + expect(engine.locator, location); + expect( + tester.widget(find.byType(PageView)).controller, + same(controller), + ); + final next = engine.goNext(); + await tester.pump(); + expect(controller.page, 2); + await next; + final gesture = await tester.startGesture( + tester.getCenter(find.byType(PageView)), + ); + await gesture.moveBy(const Offset(-150, 0)); + await tester.pump(const Duration(milliseconds: 16)); + expect(controller.page, 2, reason: 'No sliding while a finger is down'); + await gesture.up(); + await tester.pump(); + expect(controller.page, 3); + await tester.pumpAndSettle(); + await tester.drag(find.byType(PageView), const Offset(150, 0)); + await tester.pump(); + expect(controller.page, 2); + await tester.pumpAndSettle(); + expect(tester.takeException(), isNull); + }); + + for (final (reducedMotion, pageTurnAnimation) in [ + (false, true), + (true, true), + (false, false), + ]) { testWidgets( - 'boundary turns preserve the committed location until visible, reduced motion $reducedMotion', + 'boundary turns preserve the committed location until visible, reduced motion $reducedMotion, animation $pageTurnAnimation', (tester) async { final engine = testEpubEngine(); await engine.load( @@ -18,7 +75,7 @@ void main() { loadBytes: () async => syntheticEpub(firstChapterBody: '

First page.

'), ), - preferences: const ReaderPreferences(), + preferences: ReaderPreferences(pageTurnAnimation: pageTurnAnimation), ); await tester.pumpWidget( MaterialApp( @@ -36,7 +93,7 @@ void main() { await tester.pump(const Duration(milliseconds: 16)); } - if (!reducedMotion) { + if (!reducedMotion && pageTurnAnimation) { final controller = tester .widget(find.byType(PageView)) .controller!; @@ -55,7 +112,7 @@ void main() { await tester.pump(const Duration(milliseconds: 16)); } - if (!reducedMotion) { + if (!reducedMotion && pageTurnAnimation) { final controller = tester .widget(find.byType(PageView)) .controller!; diff --git a/test/presentation/epub_selection_test.dart b/test/presentation/epub_selection_test.dart index d4e5469..e1615e1 100644 --- a/test/presentation/epub_selection_test.dart +++ b/test/presentation/epub_selection_test.dart @@ -9,9 +9,14 @@ import '../support/in_process_epub_worker.dart'; import '../support/synthetic_epub.dart'; void main() { - for (final kind in [PointerDeviceKind.mouse, PointerDeviceKind.touch]) { + for (final (kind, animation) in [ + (PointerDeviceKind.mouse, true), + (PointerDeviceKind.touch, true), + (PointerDeviceKind.mouse, false), + (PointerDeviceKind.touch, false), + ]) { testWidgets( - 'selecting text at a chapter boundary does not turn the page ($kind)', + 'selecting text at a chapter boundary does not turn the page ($kind, animation $animation)', (tester) async { tester.view.devicePixelRatio = 1; tester.view.physicalSize = const Size(390, 844); @@ -27,6 +32,9 @@ void main() { : TargetPlatform.linux, ), home: PapyrusReader( + initialPreferences: ReaderPreferences( + pageTurnAnimation: animation, + ), onAnnotate: (selection) => saved = selection, document: ReaderDocument( id: 'selection', diff --git a/test/presentation/reader_loading_motion_test.dart b/test/presentation/reader_loading_motion_test.dart new file mode 100644 index 0000000..3dc6ae1 --- /dev/null +++ b/test/presentation/reader_loading_motion_test.dart @@ -0,0 +1,122 @@ +import 'dart:async'; + +import 'package:flutter/material.dart'; +import 'package:flutter/services.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:papyrus_reader/papyrus_reader.dart'; + +import '../support/in_process_epub_worker.dart'; +import '../support/synthetic_epub.dart'; + +void main() { + testWidgets( + 'host loader stays consistent through opening and initial pagination', + (tester) async { + final gate = Completer(); + final engine = testEpubEngine(); + final phases = {}; + const loaderKey = ValueKey('host-loader'); + await tester.pumpWidget( + MaterialApp( + home: PapyrusReader( + registry: ReaderEngineRegistry([ + ReaderEngineRegistration( + formats: const {ReaderFormat.epub}, + factory: () => engine, + ), + ]), + document: ReaderDocument( + id: 'loading', + format: ReaderFormat.epub, + loadBytes: () async { + await gate.future; + return syntheticEpub(longChapter: true); + }, + ), + initialPreferences: const ReaderPreferences( + brightness: Brightness.dark, + backgroundColor: Colors.black, + foregroundColor: Colors.white, + ), + builders: ReaderUiBuilders( + loading: (context, state) { + phases.add(state.snapshot.status); + expect(Theme.of(context).brightness, Brightness.light); + return const Center( + child: CircularProgressIndicator(key: loaderKey), + ); + }, + ), + ), + ), + ); + expect(find.byKey(loaderKey), findsOneWidget); + final center = tester.getCenter(find.byKey(loaderKey)); + gate.complete(); + + for ( + var frame = 0; + frame < 100 && !engine.snapshot.contentReady; + frame++ + ) { + await tester.pump(const Duration(milliseconds: 16)); + expect(find.text('Preparing pages…'), findsNothing); + if (find.byType(CircularProgressIndicator).evaluate().isNotEmpty) { + expect(find.byType(CircularProgressIndicator), findsOneWidget); + expect(find.byKey(loaderKey), findsOneWidget); + expect(tester.getCenter(find.byKey(loaderKey)), center); + } + } + + await tester.pumpAndSettle(); + expect(phases, containsAll([ReaderStatus.loading, ReaderStatus.ready])); + expect(engine.snapshot.contentReady, isTrue); + expect(find.byType(CircularProgressIndicator), findsNothing); + expect(find.byType(PageView), findsOneWidget); + expect(tester.takeException(), isNull); + }, + ); + + testWidgets('reading settings update animation without moving the page', ( + tester, + ) async { + final engine = testEpubEngine(); + final changes = []; + await tester.pumpWidget( + MaterialApp( + home: PapyrusReader( + registry: ReaderEngineRegistry([ + ReaderEngineRegistration( + formats: const {ReaderFormat.epub}, + factory: () => engine, + ), + ]), + document: ReaderDocument( + id: 'settings', + format: ReaderFormat.epub, + loadBytes: () async => syntheticEpub(longChapter: true), + ), + onPreferencesChanged: changes.add, + ), + ), + ); + await tester.pumpAndSettle(); + await pumpReaderCommand(tester, engine.goNext()); + final location = engine.locator; + await tester.sendKeyEvent(LogicalKeyboardKey.escape); + await tester.pumpAndSettle(); + await tester.tap(find.byTooltip('Reading settings')); + await tester.pumpAndSettle(); + final toggle = find.widgetWithText(SwitchListTile, 'Page turn animation'); + await tester.ensureVisible(toggle); + await tester.tap(toggle); + await tester.pumpAndSettle(); + expect(engine.preferences.pageTurnAnimation, isFalse); + expect(changes.last.pageTurnAnimation, isFalse); + expect(engine.locator, location); + await tester.tap(toggle); + await tester.pumpAndSettle(); + expect(engine.preferences.pageTurnAnimation, isTrue); + expect(tester.takeException(), isNull); + }); +} diff --git a/tool/browser/smoke.mjs b/tool/browser/smoke.mjs index 8e717e5..4a81595 100644 --- a/tool/browser/smoke.mjs +++ b/tool/browser/smoke.mjs @@ -174,6 +174,15 @@ try { const afterWheel = await paragraph.boundingBox(); assert.ok(beforeWheel && afterWheel && Math.abs(beforeWheel.y - afterWheel.y) < 1, 'A normal paginated column must fit without hidden vertical overflow'); + await openSettings(); + const animation = page.getByRole('switch', { name: /Page turn animation/ }); + await animation.click(); + await animation.waitForFunction(element => element.getAttribute('aria-checked') === 'false'); + await settle(); + await screenshot('desktop-instant-turns'); + await animation.click(); + await animation.waitForFunction(element => element.getAttribute('aria-checked') === 'true'); + await closeSettings(); await screenshot('desktop'); await hideControls(); const focusedParagraph = await paragraph.boundingBox(); From 01e50dd9142cf79244c3e5b1b8b83f5b0d6ea684 Mon Sep 17 00:00:00 2001 From: Karolis Strazdas Date: Thu, 29 Oct 2026 21:30:22 +0200 Subject: [PATCH 2/7] fix: simplify page turn animation setting to dropdown --- .../presentation/reader_settings_panel.dart | 28 +++++++++++++------ .../reader_loading_motion_test.dart | 12 +++++--- tool/browser/smoke.mjs | 8 ++---- 3 files changed, 29 insertions(+), 19 deletions(-) diff --git a/lib/src/presentation/reader_settings_panel.dart b/lib/src/presentation/reader_settings_panel.dart index d13f7fa..63916e4 100644 --- a/lib/src/presentation/reader_settings_panel.dart +++ b/lib/src/presentation/reader_settings_panel.dart @@ -147,19 +147,29 @@ final class ReaderSettingsPanel extends StatelessWidget { ), ], if (capabilities.supportsPageTurnAnimation && - preferences.layoutMode == ReaderLayoutMode.paginated) - SwitchListTile( - contentPadding: EdgeInsets.zero, - title: const Text('Page turn animation'), - subtitle: const Text( - 'Slide between pages. Turn off for instant page changes.', + preferences.layoutMode == ReaderLayoutMode.paginated) ...[ + const SizedBox(height: 12), + DropdownButtonFormField( + key: ValueKey(('reader-animation', preferences.pageTurnAnimation)), + initialValue: preferences.pageTurnAnimation, + isExpanded: true, + decoration: const InputDecoration( + labelText: 'Page turn animation', + border: OutlineInputBorder(), ), - value: preferences.pageTurnAnimation, + items: const [ + DropdownMenuItem(value: true, child: Text('Slide')), + DropdownMenuItem(value: false, child: Text('None')), + ], onChanged: enabled - ? (value) => - _update(preferences.copyWith(pageTurnAnimation: value)) + ? (value) { + if (value != null) { + _update(preferences.copyWith(pageTurnAnimation: value)); + } + } : null, ), + ], if (capabilities.supportsColumnMode) ...[ const SizedBox(height: 12), DropdownButtonFormField( diff --git a/test/presentation/reader_loading_motion_test.dart b/test/presentation/reader_loading_motion_test.dart index 3dc6ae1..64b8ca8 100644 --- a/test/presentation/reader_loading_motion_test.dart +++ b/test/presentation/reader_loading_motion_test.dart @@ -107,14 +107,18 @@ void main() { await tester.pumpAndSettle(); await tester.tap(find.byTooltip('Reading settings')); await tester.pumpAndSettle(); - final toggle = find.widgetWithText(SwitchListTile, 'Page turn animation'); - await tester.ensureVisible(toggle); - await tester.tap(toggle); + final animation = find.byType(DropdownButtonFormField); + await tester.ensureVisible(animation); + await tester.tap(animation); + await tester.pumpAndSettle(); + await tester.tap(find.text('None').last); await tester.pumpAndSettle(); expect(engine.preferences.pageTurnAnimation, isFalse); expect(changes.last.pageTurnAnimation, isFalse); expect(engine.locator, location); - await tester.tap(toggle); + await tester.tap(animation); + await tester.pumpAndSettle(); + await tester.tap(find.text('Slide').last); await tester.pumpAndSettle(); expect(engine.preferences.pageTurnAnimation, isTrue); expect(tester.takeException(), isNull); diff --git a/tool/browser/smoke.mjs b/tool/browser/smoke.mjs index 4a81595..9a71c15 100644 --- a/tool/browser/smoke.mjs +++ b/tool/browser/smoke.mjs @@ -175,13 +175,9 @@ try { assert.ok(beforeWheel && afterWheel && Math.abs(beforeWheel.y - afterWheel.y) < 1, 'A normal paginated column must fit without hidden vertical overflow'); await openSettings(); - const animation = page.getByRole('switch', { name: /Page turn animation/ }); - await animation.click(); - await animation.waitForFunction(element => element.getAttribute('aria-checked') === 'false'); - await settle(); + await select('Slide', 'None'); await screenshot('desktop-instant-turns'); - await animation.click(); - await animation.waitForFunction(element => element.getAttribute('aria-checked') === 'true'); + await select('None', 'Slide'); await closeSettings(); await screenshot('desktop'); await hideControls(); From 9f2f12783695916506f011f9b834ff0d1ae82db5 Mon Sep 17 00:00:00 2001 From: Karolis Strazdas Date: Thu, 29 Oct 2026 22:16:21 +0200 Subject: [PATCH 3/7] fix: preload nearby EPUB pages in both directions --- docs/architecture.md | 12 +- lib/src/engine/epub/epub_viewport.dart | 148 ++++++++++++++------- test/presentation/epub_page_turn_test.dart | 2 +- test/presentation/epub_prefetch_test.dart | 128 ++++++++++++++++++ 4 files changed, 242 insertions(+), 48 deletions(-) create mode 100644 test/presentation/epub_prefetch_test.dart diff --git a/docs/architecture.md b/docs/architecture.md index 39aabb8..ab03b72 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -37,10 +37,18 @@ terminate their sessions on disposal. The browser must permit `worker-src blob:` Flutter's `TextPainter` measures styled runs at the actual column width, text scale and direction. Pagination cuts at line boundaries, keeps run styles and uses content offsets for restoration. Work yields between bounded batches. -Paginated reading consumes a resumable page stream, preparing a few spreads ahead -instead of waiting for an entire spine item. Worker blocks are converted lazily, +Paginated reading consumes a resumable page stream. Opening prepares the visible +spread, then a yielding background task keeps five spreads ahead ready, plus a +coverage boundary. A turn to a prepared spread never waits for an additional page. +Worker blocks are converted lazily, including image decoding. Adjacent spine items share in-flight loading and page cursors with foreground navigation, so turning does not restart prefetch work. +Forward and backward neighbor preparation run independently. The previous item's +tail is prepared when within three spreads of its boundary, while already measured +pages remain available for reversing direction within an item. Nearby image decoding +is warmed five spreads ahead and three behind using Flutter's image cache. Exact +page counting starts after the current buffer is ready and cannot block preparation +in either direction. The viewport retains at most three layout cursors for the current geometry. Document, font, viewport or typography changes dispose them. Only visible pages are built. Already measured pages remain available for backward turns. The location diff --git a/lib/src/engine/epub/epub_viewport.dart b/lib/src/engine/epub/epub_viewport.dart index 8d7a39c..9cd5c15 100644 --- a/lib/src/engine/epub/epub_viewport.dart +++ b/lib/src/engine/epub/epub_viewport.dart @@ -40,6 +40,9 @@ final class _EpubViewportState extends State { List? _turnPages; int _turnDirection = 0; int? _turnSpine; + static const _spreadsAhead = 5; + static const _spreadsBehind = 3; + final Set _warmingImages = {}; static const _turnDuration = Duration(milliseconds: 220); Timer? _prefetchTimer; int _prefetchGeneration = 0; @@ -440,8 +443,12 @@ final class _EpubViewportState extends State { final next = _pageIndex + direction; final layout = _layoutCache[_layoutKey]; - if (next >= 0 && layout != null && !layout.complete) { - await layout.ensure(count: (next + 1) * _columns + 1); + final requiredPages = (next + 1) * _columns; + if (next >= 0 && + layout != null && + !layout.complete && + layout.pages.length < requiredPages) { + await layout.ensure(count: requiredPages); if (!mounted || !identical(layout, _layoutCache[_layoutKey])) return true; setState(() {}); } @@ -658,7 +665,7 @@ final class _EpubViewportState extends State { try { await layout.ensure( - count: _columns * 3 + 1, + count: prefetch == null ? _columns : (_spreadsAhead + 1) * _columns + 1, offset: target?.textOffset, toEnd: toEnd || @@ -674,7 +681,8 @@ final class _EpubViewportState extends State { (page) => page.offset <= offset, ); await layout.ensure( - count: ((targetPage ~/ _columns) + 2) * _columns + 1, + count: ((targetPage ~/ _columns) + 1) * _columns, + cancelled: () => prefetch != null && prefetch != _prefetchGeneration, ); } } catch (_) { @@ -718,6 +726,74 @@ final class _EpubViewportState extends State { ); } + bool _prefetchCancelled(int generation) => + !mounted || generation != _prefetchGeneration; + + void _warmImages(List pages, int first, int end) { + final limit = math.min(end, pages.length); + for (var index = math.max(0, first); index < limit; index++) { + for (final fragment in pages[index].fragments) { + final bytes = fragment.block.image; + if (bytes == null) continue; + final image = MemoryImage(bytes); + if (PaintingBinding.instance.imageCache.containsKey(image) || + !_warmingImages.add(image)) { + continue; + } + unawaited( + precacheImage( + image, + context, + onError: (_, _) {}, + ).whenComplete(() => _warmingImages.remove(image)), + ); + } + } + } + + Future _countInBackground(EpubPageLayout layout, int generation) async { + try { + // Let nearby-page preparation start before spending time on exact totals. + await layout.yieldWork(); + if (_prefetchCancelled(generation)) return; + await layout.countPages(cancelled: () => _prefetchCancelled(generation)); + if (!_prefetchCancelled(generation)) _publishPageCount(layout); + } catch (_) { + // Keep the estimated total when speculative counting fails. + } + } + + Future _prefetchNeighbor( + int index, + int direction, + int generation, + ) async { + final neighbor = index + direction; + if (neighbor < 0 || neighbor >= widget.engine.chapterCount) return; + try { + final chapter = await widget.engine.chapterContent(neighbor); + if (_prefetchCancelled(generation)) return; + final pages = await _measure( + neighbor, + EpubContentBlocks(chapter['blocks'] as List), + toEnd: direction < 0, + prefetch: generation, + ); + if (_prefetchCancelled(generation)) return; + _warmImages( + pages, + direction < 0 ? pages.length - _spreadsBehind * _columns : 0, + direction < 0 ? pages.length : _spreadsAhead * _columns, + ); + final layout = _layoutCache[(neighbor, _geometryKey)]; + if (direction > 0 && layout != null) { + await _countInBackground(layout, generation); + } + } catch (_) { + // One unreadable or slow neighbor must not block the other direction. + } + } + void _schedulePrefetch() { _prefetchTimer?.cancel(); final generation = ++_prefetchGeneration; @@ -725,49 +801,31 @@ final class _EpubViewportState extends State { _prefetchTimer = Timer(Duration.zero, () async { try { final current = _layoutCache[_layoutKey]; - if (current != null) { - final previousCount = current.pages.length; - final wasComplete = current.complete; - await current.ensure( - count: (_pageIndex + 4) * _columns + 1, - cancelled: () => generation != _prefetchGeneration, - ); - if (!mounted || generation != _prefetchGeneration) return; - if (previousCount != current.pages.length || - wasComplete != current.complete) { - setState(() {}); - } - _publishPageCount(current); - } - - for (final neighbor in [index + 1, index - 1]) { - if (!mounted || generation != _prefetchGeneration) return; - if (neighbor < 0 || neighbor >= widget.engine.chapterCount) continue; - try { - final chapter = await widget.engine.chapterContent(neighbor); - if (!mounted || generation != _prefetchGeneration) return; - await _measure( - neighbor, - EpubContentBlocks(chapter['blocks'] as List), - toEnd: neighbor < index, - prefetch: generation, - ); - } catch (_) { - // An unreadable neighbor must not prevent counting the current pages. - } + if (current == null) return; + final previousCount = current.pages.length; + final wasComplete = current.complete; + await current.ensure( + count: (_pageIndex + _spreadsAhead + 1) * _columns + 1, + cancelled: () => _prefetchCancelled(generation), + ); + if (_prefetchCancelled(generation)) return; + if (previousCount != current.pages.length || + wasComplete != current.complete) { + setState(() {}); } + _publishPageCount(current); + _warmImages( + current.pages, + (_pageIndex - _spreadsBehind) * _columns, + (_pageIndex + _spreadsAhead + 1) * _columns, + ); - final geometry = _geometryKey; - for (final item in [index, index + 1]) { - if (!mounted || generation != _prefetchGeneration) return; - final layout = _layoutCache[(item, geometry)]; - if (layout == null) continue; - await layout.countPages( - cancelled: () => generation != _prefetchGeneration, - ); - if (!mounted || generation != _prefetchGeneration) return; - _publishPageCount(layout); - } + await Future.wait([ + _prefetchNeighbor(index, 1, generation), + if (_pageIndex < _spreadsBehind) + _prefetchNeighbor(index, -1, generation), + _countInBackground(current, generation), + ]); } catch (_) { // Foreground navigation surfaces errors. Speculation preserves the page. } diff --git a/test/presentation/epub_page_turn_test.dart b/test/presentation/epub_page_turn_test.dart index 9dc8258..5133d35 100644 --- a/test/presentation/epub_page_turn_test.dart +++ b/test/presentation/epub_page_turn_test.dart @@ -317,7 +317,7 @@ void main() { .widget(find.byType(PageView)) .childrenDelegate .estimatedChildCount, - lessThanOrEqualTo(5), + lessThanOrEqualTo(7), ); for (var i = 0; i < 8; i++) { diff --git a/test/presentation/epub_prefetch_test.dart b/test/presentation/epub_prefetch_test.dart new file mode 100644 index 0000000..ccce7a7 --- /dev/null +++ b/test/presentation/epub_prefetch_test.dart @@ -0,0 +1,128 @@ +import 'dart:async'; + +import 'package:flutter/material.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:papyrus_reader/papyrus_reader.dart'; +import 'package:papyrus_reader/src/engine/epub/worker/epub_worker.dart'; + +import '../support/in_process_epub_worker.dart'; +import '../support/synthetic_epub.dart'; + +void main() { + for (final blocked in [0, 2]) { + testWidgets( + 'a slow neighbor $blocked does not block preloading the other direction', + (tester) async { + final worker = _GatedNeighbor(blocked); + final engine = EpubReaderEngine(workerFactory: () async => worker); + addTearDown(engine.dispose); + await engine.load( + ReaderDocument( + id: 'neighbors', + format: ReaderFormat.epub, + loadBytes: () async => syntheticEpub(), + ), + initialLocator: EpubReaderLocator( + spineIndex: 1, + localProgression: 0, + totalProgression: .5, + ), + preferences: const ReaderPreferences(pageTurnAnimation: false), + ); + await tester.pumpWidget( + MaterialApp(home: Builder(builder: engine.buildViewport)), + ); + await tester.pumpAndSettle(); + expect(worker.reads[0], 1); + expect(worker.reads[2], 1); + expect(engine.locator.spineIndex, 1); + final destination = blocked == 0 ? 2 : 0; + await pumpReaderCommand( + tester, + blocked == 0 ? engine.goNext() : engine.goPrevious(), + ); + expect(engine.locator.spineIndex, destination); + expect( + worker.reads[destination], + 1, + reason: 'Use the already prepared neighbor', + ); + worker.gate.complete(); + await tester.pumpAndSettle(); + expect(tester.takeException(), isNull); + }, + ); + } + + testWidgets( + 'keeps five forward turns ready and retains pages for reversing direction', + (tester) async { + final engine = testEpubEngine(); + await engine.load( + ReaderDocument( + id: 'buffered', + format: ReaderFormat.epub, + loadBytes: () async => syntheticEpub( + singleSpine: true, + firstChapterBody: List.filled( + 1000, + '

A long uninterrupted book keeps nearby pages ready.

', + ).join(), + ), + ), + preferences: const ReaderPreferences(pageTurnAnimation: false), + ); + await tester.pumpWidget( + MaterialApp(home: Builder(builder: engine.buildViewport)), + ); + await tester.pumpAndSettle(); + final view = tester.widget(find.byType(PageView)); + expect( + view.childrenDelegate.estimatedChildCount, + greaterThanOrEqualTo(6), + ); + expect( + view.childrenDelegate.estimatedChildCount, + lessThan(12), + reason: 'Do not paginate the whole book to turn', + ); + final start = engine.locator.textOffset; + for (var page = 1; page <= 5; page++) { + final turn = engine.goNext(); + await tester.pump(); + expect(view.controller!.page, page); + await turn; + } + for (var page = 4; page >= 0; page--) { + final turn = engine.goPrevious(); + await tester.pump(); + expect(view.controller!.page, page); + await turn; + } + expect(engine.locator.textOffset, start); + await tester.pumpAndSettle(); + expect(tester.takeException(), isNull); + }, + ); +} + +final class _GatedNeighbor implements EpubWorker { + _GatedNeighbor(this.blocked); + final int blocked; + final delegate = InProcessEpubWorker(); + final gate = Completer(); + final reads = {}; + + @override + Future> request(String command, Object? argument) async { + if (command == 'chapter') { + final index = argument as int; + reads.update(index, (count) => count + 1, ifAbsent: () => 1); + if (index == blocked) await gate.future; + } + return delegate.request(command, argument); + } + + @override + void dispose() => delegate.dispose(); +} From dc5ea75298644ae7e613616c7aa31e4292c5ffb7 Mon Sep 17 00:00:00 2001 From: Karolis Strazdas Date: Thu, 29 Oct 2026 22:22:03 +0200 Subject: [PATCH 4/7] fix: publish prepared pages before immediate turns --- lib/src/engine/epub/epub_viewport.dart | 15 ++++++++--- test/presentation/epub_prefetch_test.dart | 33 +++++++++++++++++++++++ 2 files changed, 45 insertions(+), 3 deletions(-) diff --git a/lib/src/engine/epub/epub_viewport.dart b/lib/src/engine/epub/epub_viewport.dart index 9cd5c15..69dadae 100644 --- a/lib/src/engine/epub/epub_viewport.dart +++ b/lib/src/engine/epub/epub_viewport.dart @@ -52,6 +52,7 @@ final class _EpubViewportState extends State { bool _showLoading = false; int _restoration = -1; int _pageIndex = 0; + int _pageViewCount = 0; int _columns = 1; final _swipe = ReaderSwipeTracker(); bool get _animateTurns => @@ -483,6 +484,13 @@ final class _EpubViewportState extends State { return true; } + if (next >= _pageViewCount) { + // A prepared page may not yet be included in the PageView's scroll extent. + setState(() {}); + await WidgetsBinding.instance.endOfFrame; + if (!mounted || !identical(layout, _layoutCache[_layoutKey])) return true; + } + final controller = _pagesController; if (!controller.hasClients) { @@ -1046,6 +1054,9 @@ final class _EpubViewportState extends State { ); } else { final outgoing = _turnPages; + _pageViewCount = outgoing == null + ? (pages.length / columns).ceil() + : 2; content = PageView.builder( key: ValueKey(_pagesController), controller: _pagesController, @@ -1053,9 +1064,7 @@ final class _EpubViewportState extends State { ? null : const NeverScrollableScrollPhysics(), onPageChanged: _publishPage, - itemCount: outgoing == null - ? (pages.length / columns).ceil() - : 2, + itemCount: _pageViewCount, itemBuilder: (context, index) { if (outgoing != null) { final oldPage = index == (_turnDirection > 0 ? 0 : 1); diff --git a/test/presentation/epub_prefetch_test.dart b/test/presentation/epub_prefetch_test.dart index ccce7a7..dbeee3e 100644 --- a/test/presentation/epub_prefetch_test.dart +++ b/test/presentation/epub_prefetch_test.dart @@ -9,6 +9,39 @@ import '../support/in_process_epub_worker.dart'; import '../support/synthetic_epub.dart'; void main() { + testWidgets( + 'an immediate turn can use a page prepared before the viewport grows', + (tester) async { + final engine = testEpubEngine(); + await engine.load( + ReaderDocument( + id: 'early-turn', + format: ReaderFormat.epub, + loadBytes: () async => syntheticEpub(longChapter: true), + ), + preferences: const ReaderPreferences(pageTurnAnimation: false), + ); + await tester.pumpWidget( + MaterialApp(home: Builder(builder: engine.buildViewport)), + ); + for ( + var frame = 0; + frame < 100 && !engine.snapshot.contentReady; + frame++ + ) { + await tester.pump(const Duration(milliseconds: 1)); + } + expect(engine.snapshot.contentReady, isTrue); + final before = engine.locator.textOffset!; + await pumpReaderCommand(tester, engine.goNext()); + expect( + tester.widget(find.byType(PageView)).controller!.page, + 1, + ); + expect(engine.locator.textOffset, greaterThan(before)); + }, + ); + for (final blocked in [0, 2]) { testWidgets( 'a slow neighbor $blocked does not block preloading the other direction', From ee7737deb9b579199e812d14c580bc1a957f6344 Mon Sep 17 00:00:00 2001 From: Karolis Strazdas Date: Thu, 29 Oct 2026 23:30:14 +0200 Subject: [PATCH 5/7] fix: let the latest slider seek supersede obsolete navigation --- docs/architecture.md | 14 ++- lib/src/controller/reader_controller.dart | 2 + lib/src/engine/epub/epub_reader_engine.dart | 80 ++++++++++++-- lib/src/engine/epub/epub_viewport.dart | 22 +++- lib/src/engine/reader_engine.dart | 4 + lib/src/presentation/papyrus_reader.dart | 71 +++++++++++-- lib/src/presentation/reader_controls.dart | 3 + test/engine/epub_seek_test.dart | 95 +++++++++++++++++ test/presentation/epub_seek_test.dart | 101 ++++++++++++++++++ test/presentation/papyrus_reader_test.dart | 109 ++++++++++++++++++++ tool/browser/smoke.mjs | 11 ++ 11 files changed, 488 insertions(+), 24 deletions(-) create mode 100644 test/engine/epub_seek_test.dart create mode 100644 test/presentation/epub_seek_test.dart diff --git a/docs/architecture.md b/docs/architecture.md index ab03b72..67f450b 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -100,7 +100,19 @@ independence without exposing PDFium details to the shell or host persistence. ## Shell The shell retains the content widget during position updates, serializes user -commands and keeps error views escapable. Initial opening uses a centered spinner +commands and keeps error views escapable. Progress-slider dragging previews locally +and commits on release. New drags and releases supersede earlier slider requests, +so queued intermediate destinations are skipped and stale failures cannot replace +the latest result. Ordinary page-turn commands retain their order. Background +snapshots cannot move the thumb during a drag or a pending seek. + +EPUB progress seeks have cancellation signals. Cancellation releases the navigation +queue without waiting for an obsolete chapter request, and layout demand stops at +the next yield while retaining reusable pages. Late results cannot commit a locator. +Existing worker parsing can finish into the content cache, rather than restarting +the document worker. The destination viewport replenishes the nearby-page buffer. +Engines may implement `cancelPendingSeek`; engines without cooperative cancellation +finish their active operation before the shell executes the latest queued seek. Initial opening uses a centered spinner without toolbar chrome; hosts can supply a loading builder to match their file-fetch loader. Initial EPUB pagination uses the same builder, host theme, centered position and background, without a separate preparation label. Reader appearance controls the diff --git a/lib/src/controller/reader_controller.dart b/lib/src/controller/reader_controller.dart index 0ed94b5..32bb317 100644 --- a/lib/src/controller/reader_controller.dart +++ b/lib/src/controller/reader_controller.dart @@ -152,6 +152,8 @@ final class ReaderController extends ChangeNotifier { ); } + void cancelPendingSeek() => _engine?.cancelPendingSeek(); + Future goNext() async { await _navigate(ReaderNavigationCause.turn, (engine) => engine.goNext()); } diff --git a/lib/src/engine/epub/epub_reader_engine.dart b/lib/src/engine/epub/epub_reader_engine.dart index 92b3820..7a59521 100644 --- a/lib/src/engine/epub/epub_reader_engine.dart +++ b/lib/src/engine/epub/epub_reader_engine.dart @@ -50,6 +50,7 @@ final class EpubReaderEngine extends ReaderEngine { Future Function( EpubReaderLocator target, List blocks, + bool Function() cancelled, )? prepareLocation; Future _turnQueue = Future.value(); @@ -57,6 +58,7 @@ final class EpubReaderEngine extends ReaderEngine { int _restorationRevision = 0; int _contentRevision = 0; int _navigationGeneration = 0; + _EpubSeek? _activeSeek; bool _disposed = false; int _visiblePage = 1; int _pageCount = 0; @@ -106,6 +108,7 @@ final class EpubReaderEngine extends ReaderEngine { 'The EPUB document or initial locator has the wrong format.', ); } + cancelPendingSeek(); final generation = ++_navigationGeneration; EpubWorker? candidate; try { @@ -256,7 +259,12 @@ final class EpubReaderEngine extends ReaderEngine { } @override - Future goTo(ReaderLocator locator) async { + Future goTo(ReaderLocator locator) { + cancelPendingSeek(); + return _goTo(locator); + } + + Future _goTo(ReaderLocator locator, {_EpubSeek? seek}) async { if (locator is! EpubReaderLocator || locator.spineIndex >= _chapterCount || _worker == null) { @@ -275,7 +283,7 @@ final class EpubReaderEngine extends ReaderEngine { local: locator.localProgression, textOffset: offset?.clamp(0, _contentLength), ); - await prepareLocation?.call(target, _blocks); + await _prepare(target, _blocks, seek); _requireCurrent(generation); _locator = target; _coverage = const []; @@ -283,9 +291,10 @@ final class EpubReaderEngine extends ReaderEngine { _atEnd = false; _restorationRevision++; } else { - final chapter = await chapterContent(locator.spineIndex); + final request = chapterContent(locator.spineIndex); + final chapter = await (seek?.wait(request) ?? request); _requireCurrent(generation); - await prepareLocation?.call( + await _prepare( _locatorFor( locator.spineIndex, local: locator.localProgression, @@ -295,6 +304,7 @@ final class EpubReaderEngine extends ReaderEngine { locator.textOffset, ), EpubContentBlocks(chapter['blocks'] as List), + seek, ); _requireCurrent(generation); _setChapter(chapter, locator); @@ -302,6 +312,27 @@ final class EpubReaderEngine extends ReaderEngine { _publish(); } + Future _prepare( + EpubReaderLocator target, + List blocks, + _EpubSeek? seek, + ) async { + final preparation = prepareLocation?.call( + target, + blocks, + () => seek?.cancelled ?? false, + ); + if (preparation != null) await (seek?.wait(preparation) ?? preparation); + } + + @override + void cancelPendingSeek() { + final seek = _activeSeek; + if (seek == null || seek.cancelled) return; + _navigationGeneration++; + seek.cancel(); + } + @override Future goToProgress(double progress) async { if (!progress.isFinite || progress < 0 || progress > 1) { @@ -317,14 +348,25 @@ final class EpubReaderEngine extends ReaderEngine { 'No EPUB document is loaded.', ); } + cancelPendingSeek(); + final seek = _EpubSeek(); + _activeSeek = seek; final position = _progressIndex!.position(progress); - await goTo( - _locatorFor( - position.chapter, - local: progress == 1 ? 1 : 0, - textOffset: position.offset, - ), - ); + try { + await _goTo( + _locatorFor( + position.chapter, + local: progress == 1 ? 1 : 0, + textOffset: position.offset, + ), + seek: seek, + ); + } catch (_) { + // A cancelled request must not report a late preparation failure. + if (!seek.cancelled) rethrow; + } finally { + if (identical(_activeSeek, seek)) _activeSeek = null; + } } @override @@ -578,6 +620,7 @@ final class EpubReaderEngine extends ReaderEngine { @override void dispose() { if (_disposed) return; + cancelPendingSeek(); _disposed = true; _navigationGeneration++; _openingWorker?.dispose(); @@ -591,3 +634,18 @@ final class EpubReaderEngine extends ReaderEngine { super.dispose(); } } + +final class _EpubSeek { + final _cancellation = Completer(); + bool get cancelled => _cancellation.isCompleted; + void cancel() => _cancellation.complete(); + + Future wait(Future operation) => Future.any([ + operation, + _cancellation.future.then((_) => throw const _CancelledSeek()), + ]); +} + +final class _CancelledSeek implements Exception { + const _CancelledSeek(); +} diff --git a/lib/src/engine/epub/epub_viewport.dart b/lib/src/engine/epub/epub_viewport.dart index 69dadae..8a49623 100644 --- a/lib/src/engine/epub/epub_viewport.dart +++ b/lib/src/engine/epub/epub_viewport.dart @@ -642,9 +642,15 @@ final class _EpubViewportState extends State { EpubReaderLocator? target, bool toEnd = false, int? prefetch, + bool Function()? cancelled, }) async { final geometry = _geometryKey; final key = (index, geometry); + bool obsolete() => + !mounted || + geometry != _geometryKey || + (cancelled?.call() ?? false) || + (prefetch != null && prefetch != _prefetchGeneration); final layout = _layoutCache.putIfAbsent(key, () { final contentSize = _contentSize; final preferences = widget.engine.preferences; @@ -681,7 +687,7 @@ final class _EpubViewportState extends State { (target != null && target.textOffset == null && target.localProgression > 0), - cancelled: () => prefetch != null && prefetch != _prefetchGeneration, + cancelled: obsolete, ); final offset = target?.textOffset; if (offset != null && !layout.complete) { @@ -690,7 +696,7 @@ final class _EpubViewportState extends State { ); await layout.ensure( count: ((targetPage ~/ _columns) + 1) * _columns, - cancelled: () => prefetch != null && prefetch != _prefetchGeneration, + cancelled: obsolete, ); } } catch (_) { @@ -705,18 +711,26 @@ final class _EpubViewportState extends State { Future _prepareLocation( EpubReaderLocator target, List blocks, + bool Function() cancelled, ) async { if (widget.engine.renderer != null && widget.engine.preferences.layoutMode == ReaderLayoutMode.scroll) { return; } + _prefetchGeneration++; + _prefetchTimer?.cancel(); // A resize can supersede measurement while navigation is waiting. Object? geometry; do { geometry = _geometryKey; - await _measure(target.spineIndex, blocks, target: target); - } while (mounted && geometry != _geometryKey); + await _measure( + target.spineIndex, + blocks, + target: target, + cancelled: cancelled, + ); + } while (mounted && !cancelled() && geometry != _geometryKey); } void _publishPageCount(EpubPageLayout layout) { diff --git a/lib/src/engine/reader_engine.dart b/lib/src/engine/reader_engine.dart index 9a9df80..b98bf38 100644 --- a/lib/src/engine/reader_engine.dart +++ b/lib/src/engine/reader_engine.dart @@ -26,6 +26,10 @@ abstract class ReaderEngine extends ChangeNotifier { Future goToProgress(double progress); + /// Stop a pending progress seek when supported. Engines that cannot cancel + /// finish the active operation before the shell runs the latest queued seek. + void cancelPendingSeek() {} + Future goNext(); Future goPrevious(); diff --git a/lib/src/presentation/papyrus_reader.dart b/lib/src/presentation/papyrus_reader.dart index 82232fa..89061d1 100644 --- a/lib/src/presentation/papyrus_reader.dart +++ b/lib/src/presentation/papyrus_reader.dart @@ -80,6 +80,10 @@ final class _PapyrusReaderState extends State { _ReaderPanel _panel = _ReaderPanel.none; bool _controlsVisible = false; double? _dragProgress; + bool _draggingProgress = false; + int _seekRevision = 0; + int? _pendingSeekRevision; + int? _activeSeekRevision; int _loadGeneration = 0; bool _loadedDependencies = false; Future _commandQueue = Future.value(); @@ -219,6 +223,11 @@ final class _PapyrusReaderState extends State { } void _invalidateCommands({bool notify = true}) { + _controller.cancelPendingSeek(); + _seekRevision++; + _pendingSeekRevision = null; + _activeSeekRevision = null; + _draggingProgress = false; _commandGeneration++; _commandQueue = Future.value(); _pendingCommands = 0; @@ -275,7 +284,9 @@ final class _PapyrusReaderState extends State { final next = _controller.snapshot; _observedSnapshot = next; - if (next.contentReady) { + if (next.contentReady && + !_draggingProgress && + _pendingSeekRevision == null) { _dragProgress = null; } @@ -597,8 +608,11 @@ final class _PapyrusReaderState extends State { _dragProgress ?? _progressOf(snapshot.locator), locationLabel: snapshot.locationLabel, - onProgressChanged: (value) => - setState(() => _dragProgress = value), + onProgressChangeStart: _startProgressDrag, + onProgressChanged: (value) => setState(() { + _draggingProgress = true; + _dragProgress = value; + }), onProgressChangeEnd: _goToProgress, onPrevious: _goPrevious, onNext: _goNext, @@ -1091,11 +1105,44 @@ final class _PapyrusReaderState extends State { }; } + void _startProgressDrag(double progress) { + _seekRevision++; + _pendingSeekRevision = null; + if (_activeSeekRevision != null) _controller.cancelPendingSeek(); + setState(() { + _draggingProgress = true; + _dragProgress = progress; + }); + } + void _goToProgress(double progress) { - setState(() => _dragProgress = progress); + final revision = ++_seekRevision; + if (_activeSeekRevision != null) _controller.cancelPendingSeek(); + setState(() { + _draggingProgress = false; + _pendingSeekRevision = revision; + _dragProgress = progress; + }); _enqueueCommand( - (controller) => controller.goToProgress(progress), + (controller) async { + _activeSeekRevision = revision; + _pendingSeekRevision = revision; + try { + await controller.goToProgress(progress); + } finally { + if (_activeSeekRevision == revision) _activeSeekRevision = null; + if (mounted && revision == _seekRevision) { + setState(() { + _pendingSeekRevision = null; + if (!_draggingProgress && controller.snapshot.contentReady) { + _dragProgress = null; + } + }); + } + } + }, context: 'while changing reader progress', + isCurrent: () => revision == _seekRevision, ); } @@ -1119,6 +1166,7 @@ final class _PapyrusReaderState extends State { Future Function(ReaderController controller) operation, { required String context, bool expectedBoundary = false, + bool Function()? isCurrent, }) { if (!mounted) { return; @@ -1144,14 +1192,18 @@ final class _PapyrusReaderState extends State { setState(() {}); final scheduled = _commandQueue.then((_) async { - if (!mounted || generation != _commandGeneration) { + if (!mounted || + generation != _commandGeneration || + !(isCurrent?.call() ?? true)) { return; } try { await operation(controller); } catch (error, stackTrace) { - if (!mounted || generation != _commandGeneration) { + if (!mounted || + generation != _commandGeneration || + !(isCurrent?.call() ?? true)) { return; } if (expectedBoundary && @@ -1164,6 +1216,7 @@ final class _PapyrusReaderState extends State { operation, context: context, expectedBoundary: expectedBoundary, + isCurrent: isCurrent, ); _handleCommandError(error, stackTrace, context); } @@ -1181,9 +1234,11 @@ final class _PapyrusReaderState extends State { } _commandRevision.value++; setState(() {}); - if (restoreReadingFocus) { + if (restoreReadingFocus && (isCurrent?.call() ?? true)) { WidgetsBinding.instance.addPostFrameCallback((_) { if (mounted && + !_draggingProgress && + (isCurrent?.call() ?? true) && generation == _commandGeneration && _panel == _ReaderPanel.none && _compactPanelRoute == null) { diff --git a/lib/src/presentation/reader_controls.dart b/lib/src/presentation/reader_controls.dart index 024383d..dc4f3fd 100644 --- a/lib/src/presentation/reader_controls.dart +++ b/lib/src/presentation/reader_controls.dart @@ -164,6 +164,7 @@ final class ReaderProgressControls extends StatelessWidget { const ReaderProgressControls({ super.key, required this.progress, + required this.onProgressChangeStart, required this.onProgressChanged, required this.onProgressChangeEnd, required this.onPrevious, @@ -174,6 +175,7 @@ final class ReaderProgressControls extends StatelessWidget { }); final double progress; + final ValueChanged onProgressChangeStart; final ValueChanged onProgressChanged; final ValueChanged onProgressChangeEnd; final VoidCallback onPrevious; @@ -207,6 +209,7 @@ final class ReaderProgressControls extends StatelessWidget { ), child: Slider( value: normalized, + onChangeStart: enabled ? onProgressChangeStart : null, onChanged: enabled ? onProgressChanged : null, onChangeEnd: enabled ? onProgressChangeEnd : null, ), diff --git a/test/engine/epub_seek_test.dart b/test/engine/epub_seek_test.dart new file mode 100644 index 0000000..1524d29 --- /dev/null +++ b/test/engine/epub_seek_test.dart @@ -0,0 +1,95 @@ +import 'dart:async'; + +import 'package:flutter_test/flutter_test.dart'; +import 'package:papyrus_reader/papyrus_reader.dart'; +import 'package:papyrus_reader/src/engine/epub/worker/epub_worker.dart'; + +import '../support/in_process_epub_worker.dart'; +import '../support/synthetic_epub.dart'; + +void main() { + for (final lateError in [false, true]) { + test( + 'latest seek does not wait for an obsolete chapter read, late error $lateError', + () async { + final worker = _SeekWorker(); + final engine = EpubReaderEngine(workerFactory: () async => worker); + addTearDown(engine.dispose); + await engine.load(_document(), preferences: const ReaderPreferences()); + final visited = []; + engine.addListener(() => visited.add(engine.locator.spineIndex)); + final old = engine.goToProgress(.7); + await worker.started.future; + final latest = engine.goToProgress(1); + await Future.wait([old, latest]).timeout(const Duration(seconds: 2)); + expect(engine.locator.spineIndex, 2); + expect(visited, isNot(contains(1))); + if (lateError) { + worker.gate.completeError(StateError('Obsolete chapter read failed')); + } else { + worker.gate.complete(); + } + await Future.delayed(Duration.zero); + expect(engine.locator.spineIndex, 2); + expect(visited, isNot(contains(1))); + }, + ); + } + + test( + 'superseding a seek cancels its layout demand without waiting for it', + () async { + final engine = EpubReaderEngine( + workerFactory: () async => InProcessEpubWorker(), + ); + addTearDown(engine.dispose); + await engine.load(_document(), preferences: const ReaderPreferences()); + final started = Completer(); + final gate = Completer(); + late bool Function() cancelled; + engine.prepareLocation = (target, blocks, isCancelled) async { + if (target.spineIndex == 1) { + cancelled = isCancelled; + started.complete(); + await gate.future; + } + }; + final old = engine.goToProgress(.7); + await started.future; + final latest = engine.goToProgress(1); + await Future.wait([old, latest]).timeout(const Duration(seconds: 2)); + expect(cancelled(), isTrue); + expect(engine.locator.spineIndex, 2); + gate.complete(); + await Future.delayed(Duration.zero); + expect(engine.locator.spineIndex, 2); + }, + ); +} + +ReaderDocument _document() => ReaderDocument( + id: 'seeking', + format: ReaderFormat.epub, + loadBytes: () async => syntheticEpub( + firstChapterBody: '

${List.filled(300, 'First').join(' ')}

', + secondChapterBody: '

${List.filled(300, 'Later').join(' ')}

', + ), +); + +final class _SeekWorker implements EpubWorker { + final delegate = InProcessEpubWorker(); + final started = Completer(); + final gate = Completer(); + + @override + Future> request(String command, Object? argument) async { + if (command == 'chapter' && argument == 1) { + if (!started.isCompleted) started.complete(); + await gate.future; + } + return delegate.request(command, argument); + } + + @override + void dispose() => delegate.dispose(); +} diff --git a/test/presentation/epub_seek_test.dart b/test/presentation/epub_seek_test.dart new file mode 100644 index 0000000..69068cc --- /dev/null +++ b/test/presentation/epub_seek_test.dart @@ -0,0 +1,101 @@ +import 'dart:async'; + +import 'package:flutter/material.dart'; +import 'package:flutter/services.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:papyrus_reader/papyrus_reader.dart'; +import 'package:papyrus_reader/src/engine/epub/worker/epub_worker.dart'; + +import '../support/in_process_epub_worker.dart'; +import '../support/synthetic_epub.dart'; + +void main() { + testWidgets( + 'rapid scrubbing displays and persists only the latest prepared destination', + (tester) async { + final worker = _SlowChapterWorker(); + final engine = EpubReaderEngine(workerFactory: () async => worker); + final controller = ReaderController( + registry: ReaderEngineRegistry([ + ReaderEngineRegistration( + formats: const {ReaderFormat.epub}, + factory: () => engine, + ), + ]), + ); + addTearDown(controller.dispose); + final saved = []; + await tester.pumpWidget( + MaterialApp( + home: PapyrusReader( + controller: controller, + document: ReaderDocument( + id: 'scrub', + format: ReaderFormat.epub, + loadBytes: () async => syntheticEpub( + firstChapterBody: + '

${List.filled(300, 'First').join(' ')}

', + secondChapterBody: + '

${List.filled(300, 'Later').join(' ')}

', + ), + ), + initialPreferences: const ReaderPreferences( + pageTurnAnimation: false, + ), + onLocatorChanged: (locator) => + saved.add(locator as EpubReaderLocator), + ), + ), + ); + await tester.pumpAndSettle(); + await tester.sendKeyEvent(LogicalKeyboardKey.escape); + await tester.pumpAndSettle(); + saved.clear(); + Slider slider() => tester.widget(find.byType(Slider).first); + slider().onChangeEnd!(.7); + await tester.pump(); + expect(worker.blocked, isTrue); + + slider().onChangeStart!(.7); + slider().onChanged!(.3); + await tester.pumpAndSettle(); + expect(slider().value, .3); + expect(engine.locator.spineIndex, 0); + expect(saved, isEmpty); + + slider().onChangeEnd!(.3); + slider().onChangeEnd!(1); + await tester.pumpAndSettle(); + expect(worker.gate.isCompleted, isFalse); + expect(controller.snapshot.contentReady, isTrue); + expect(engine.locator.spineIndex, 2); + expect(find.text('Chapter Three'), findsOneWidget); + expect(saved, isNotEmpty); + expect(saved.every((locator) => locator.spineIndex == 2), isTrue); + final position = engine.locator; + worker.gate.complete(); + await tester.pumpAndSettle(); + expect(engine.locator, position); + expect(saved.every((locator) => locator.spineIndex == 2), isTrue); + expect(tester.takeException(), isNull); + }, + ); +} + +final class _SlowChapterWorker implements EpubWorker { + final delegate = InProcessEpubWorker(); + final gate = Completer(); + bool blocked = false; + + @override + Future> request(String command, Object? argument) async { + if (command == 'chapter' && argument == 1) { + blocked = true; + await gate.future; + } + return delegate.request(command, argument); + } + + @override + void dispose() => delegate.dispose(); +} diff --git a/test/presentation/papyrus_reader_test.dart b/test/presentation/papyrus_reader_test.dart index e359408..f722344 100644 --- a/test/presentation/papyrus_reader_test.dart +++ b/test/presentation/papyrus_reader_test.dart @@ -1070,6 +1070,106 @@ void main() { expect(find.text('Reading mode'), findsOneWidget); }); + testWidgets( + 'slider releases coalesce and keep the latest preview while waiting', + (tester) async { + final gate = Completer(); + final engine = _UiReaderEngine(progressGates: [gate]); + final controller = controllerFor([engine]); + addTearDown(controller.dispose); + await pumpReader( + tester, + document: document('Seek'), + controller: controller, + ); + await tester.pumpAndSettle(); + Slider slider() => tester.widget(find.byType(Slider).first); + slider().onChangeEnd!(.2); + await tester.pump(); + slider().onChangeEnd!(.4); + slider().onChangeEnd!(.9); + await tester.pump(); + expect(engine.goToProgressCalls, [.2]); + engine._publishLocator( + EpubReaderLocator( + spineIndex: 0, + localProgression: 0, + totalProgression: 0, + ), + ); + await tester.pump(); + expect(slider().value, .9); + gate.complete(); + await tester.pumpAndSettle(); + expect(engine.goToProgressCalls, [.2, .9]); + expect(slider().value, .9); + expect(tester.takeException(), isNull); + }, + ); + + testWidgets('background updates do not move the thumb during a drag', ( + tester, + ) async { + final engine = _UiReaderEngine(); + final controller = controllerFor([engine]); + addTearDown(controller.dispose); + await pumpReader( + tester, + document: document('Drag'), + controller: controller, + ); + await tester.pumpAndSettle(); + Slider slider() => tester.widget(find.byType(Slider).first); + slider().onChangeStart!(0); + slider().onChanged!(.8); + engine._publishLocator( + EpubReaderLocator( + spineIndex: 0, + localProgression: 0, + totalProgression: 0, + ), + ); + await tester.pump(); + expect(slider().value, .8); + expect(engine.goToProgressCalls, isEmpty); + slider().onChangeEnd!(.8); + await tester.pumpAndSettle(); + expect(engine.goToProgressCalls, [.8]); + }); + + testWidgets('obsolete seek errors do not replace the latest result', ( + tester, + ) async { + final gate = Completer(); + final engine = _UiReaderEngine( + progressGates: [gate], + progressFailures: [ + const ReaderException( + ReaderErrorCode.invalidDocument, + 'Obsolete seek failure', + ), + ], + ); + final controller = controllerFor([engine]); + addTearDown(controller.dispose); + await pumpReader( + tester, + document: document('Failure'), + controller: controller, + ); + await tester.pumpAndSettle(); + final slider = tester.widget(find.byType(Slider).first); + slider.onChangeEnd!(.2); + await tester.pump(); + slider.onChangeEnd!(.9); + gate.complete(); + await tester.pumpAndSettle(); + expect(engine.goToProgressCalls, [.2, .9]); + expect(find.text('Obsolete seek failure'), findsNothing); + expect(tester.widget(find.byType(Slider).first).value, .9); + expect(tester.takeException(), isNull); + }); + testWidgets('slider, buttons, and keyboard delegate navigation', ( tester, ) async { @@ -1607,6 +1707,8 @@ final class _UiReaderEngine extends ReaderEngine { this.loadGate, this.loadFailure, this.nextGates = const [], + this.progressGates = const [], + this.progressFailures = const [], this.nextFailure, this.previousFailure, this.preferenceFailure, @@ -1622,6 +1724,8 @@ final class _UiReaderEngine extends ReaderEngine { final Widget Function(BuildContext, ReaderSnapshot)? viewportBuilder; final ReaderException? loadFailure; final List> nextGates; + final List> progressGates; + final List progressFailures; final ReaderException? nextFailure; final ReaderException? previousFailure; final ReaderException? preferenceFailure; @@ -1717,7 +1821,12 @@ final class _UiReaderEngine extends ReaderEngine { @override Future goToProgress(double progress) async { + final call = goToProgressCalls.length; goToProgressCalls.add(progress); + if (call < progressGates.length) await progressGates[call].future; + if (call < progressFailures.length && progressFailures[call] != null) { + throw progressFailures[call]!; + } _publishLocator( EpubReaderLocator( spineIndex: 0, diff --git a/tool/browser/smoke.mjs b/tool/browser/smoke.mjs index 9a71c15..a8e8996 100644 --- a/tool/browser/smoke.mjs +++ b/tool/browser/smoke.mjs @@ -221,6 +221,17 @@ try { }; await openFile(resolve(reader, 'example/assets/reading_lab.epub')); await showInitialControls(page.getByText('At the Gate', { exact: true }).first()); + const progressSlider = page.getByRole('slider').last(); + const progressBounds = await progressSlider.boundingBox(); + assert.ok(progressBounds, 'Reading progress must remain interactive'); + const progressY = progressBounds.y + progressBounds.height / 2; + for (const fraction of [0.8, 0.15, 0.9, 1]) { + await page.mouse.move(progressBounds.x + progressBounds.width / 2, progressY); + await page.mouse.down(); + await page.mouse.move(progressBounds.x + progressBounds.width * fraction, progressY, { steps: 3 }); + await page.mouse.up(); + } + await page.waitForFunction(() => document.querySelector('[aria-label^="Chapter 2 of 2"]')); await backToLibrary(); const invalid = resolve(temp, 'invalid.epub'); await writeFile(invalid, Buffer.from([1, 2, 3])); From c7ce2d62ec6590f0d11467d8b13383ea86915ca5 Mon Sep 17 00:00:00 2001 From: Karolis Strazdas Date: Thu, 29 Oct 2026 23:41:04 +0200 Subject: [PATCH 6/7] Expose shared panel header to host builders --- docs/architecture.md | 4 +++- lib/src/presentation/papyrus_reader.dart | 3 +++ lib/src/presentation/reader_ui_builders.dart | 6 ++++++ 3 files changed, 12 insertions(+), 1 deletion(-) diff --git a/docs/architecture.md b/docs/architecture.md index 67f450b..8f4ad3e 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -147,7 +147,9 @@ The presentation layer keeps session, keyboard and viewport coordination in `papyrus_reader.dart`. Toolbar/progress controls, contents, settings and the panel header live in separate presentation modules. These modules are implementation details and are not exported by the package entry point. Public builder contexts -and custom-engine integration remain in the shell's existing public API. +and custom-engine integration remain in the shell's existing public API. Custom +panels can call `ReaderPanelContext.buildHeader(context, title)` to reuse the +standard title, divider, and close button with the reader's chrome theme. Hosts can supply `ReaderUiBuilders.compactPanelRoute` to own compact panel presentation. The factory returns an **unpushed** `Route`; the reader pushes diff --git a/lib/src/presentation/papyrus_reader.dart b/lib/src/presentation/papyrus_reader.dart index 89061d1..1e34594 100644 --- a/lib/src/presentation/papyrus_reader.dart +++ b/lib/src/presentation/papyrus_reader.dart @@ -21,6 +21,7 @@ import 'reader_controls.dart'; import 'reader_surface.dart'; import 'reader_contents_panel.dart'; import 'reader_settings_panel.dart'; +import 'reader_panel_header.dart'; import 'reader_loading.dart'; import 'reader_theme_data.dart'; import 'reader_ui_builders.dart'; @@ -1043,6 +1044,8 @@ final class _PapyrusReaderState extends State { close: close, isBusy: _isCommandBusy, showHeader: showHeader, + buildHeader: (context, title) => + ReaderPanelHeader(title: title, close: close, theme: theme), navigate: (locator) { close(); _enqueueCommand( diff --git a/lib/src/presentation/reader_ui_builders.dart b/lib/src/presentation/reader_ui_builders.dart index 6881f9b..39960ea 100644 --- a/lib/src/presentation/reader_ui_builders.dart +++ b/lib/src/presentation/reader_ui_builders.dart @@ -9,6 +9,8 @@ typedef ReaderToolbarBuilder = Widget Function(BuildContext context, ReaderToolbarContext state); typedef ReaderPanelBuilder = Widget Function(BuildContext context, ReaderPanelContext state); +typedef ReaderPanelHeaderBuilder = + Widget Function(BuildContext context, String title); typedef ReaderStateBuilder = Widget Function(BuildContext context, ReaderStateContext state); typedef ReaderViewportBuilder = @@ -74,11 +76,15 @@ final class ReaderPanelContext { this.isBusy = false, this.showHeader = true, this.buildContents, + this.buildHeader, this.navigate, }); final bool showHeader; final WidgetBuilder? buildContents; + + /// Builds the standard panel header with the reader's chrome and close action. + final ReaderPanelHeaderBuilder? buildHeader; final ValueChanged? navigate; final ReaderSnapshot snapshot; final ReaderController controller; From c9f566fde4de3bfd1dce6df1fd6d17bae6aaf114 Mon Sep 17 00:00:00 2001 From: Karolis Strazdas Date: Thu, 29 Oct 2026 23:45:47 +0200 Subject: [PATCH 7/7] fix: retain page inset colors during reader preparation --- lib/src/presentation/papyrus_reader.dart | 7 +- .../reader_loading_motion_test.dart | 121 ++++++++++++++++++ 2 files changed, 127 insertions(+), 1 deletion(-) diff --git a/lib/src/presentation/papyrus_reader.dart b/lib/src/presentation/papyrus_reader.dart index 1e34594..d9e149b 100644 --- a/lib/src/presentation/papyrus_reader.dart +++ b/lib/src/presentation/papyrus_reader.dart @@ -78,6 +78,7 @@ final class _PapyrusReaderState extends State { late ReaderController _controller; late bool _ownsController; ReaderSnapshot? _observedSnapshot; + bool _hasReadableContent = false; _ReaderPanel _panel = _ReaderPanel.none; bool _controlsVisible = false; double? _dragProgress; @@ -201,6 +202,7 @@ final class _PapyrusReaderState extends State { widget.initialPreferences ?? const ReaderPreferences(), ); _observedSnapshot = _controller.snapshot; + _hasReadableContent = _controller.snapshot.contentReady; _controller.addListener(_controllerChanged); } @@ -284,6 +286,9 @@ final class _PapyrusReaderState extends State { final previous = _observedSnapshot; final next = _controller.snapshot; _observedSnapshot = next; + _hasReadableContent = + next.status == ReaderStatus.ready && + (_hasReadableContent || next.contentReady); if (next.contentReady && !_draggingProgress && @@ -382,7 +387,7 @@ final class _PapyrusReaderState extends State { data: readerTheme, child: Builder( builder: (context) => Material( - color: snapshot.status == ReaderStatus.ready && snapshot.contentReady + color: _hasReadableContent ? snapshot.preferences.backgroundColor : theme.surfaceColor, child: SafeArea( diff --git a/test/presentation/reader_loading_motion_test.dart b/test/presentation/reader_loading_motion_test.dart index 64b8ca8..27ebd2c 100644 --- a/test/presentation/reader_loading_motion_test.dart +++ b/test/presentation/reader_loading_motion_test.dart @@ -77,6 +77,127 @@ void main() { }, ); + testWidgets('safe-area color stays with readable pages until a new load', ( + tester, + ) async { + tester.view.padding = const FakeViewPadding(top: 24, bottom: 20); + addTearDown(tester.view.resetPadding); + final engines = []; + final controller = ReaderController( + initialPreferences: const ReaderPreferences( + brightness: Brightness.dark, + backgroundColor: Colors.black, + foregroundColor: Colors.white, + ), + registry: ReaderEngineRegistry([ + ReaderEngineRegistration( + formats: const {ReaderFormat.epub}, + factory: () { + final engine = testEpubEngine(); + engines.add(engine); + return engine; + }, + ), + ]), + ); + addTearDown(controller.dispose); + await tester.pumpWidget( + MaterialApp( + home: PapyrusReader( + controller: controller, + document: ReaderDocument( + id: 'insets', + format: ReaderFormat.epub, + loadBytes: () async => syntheticEpub(longChapter: true), + ), + ), + ), + ); + + Color? insetColor() => tester + .widget( + find + .descendant( + of: find.byType(PapyrusReader), + matching: find.byType(Material), + ) + .first, + ) + .color; + + final hostColor = Theme.of( + tester.element(find.byType(PapyrusReader)), + ).colorScheme.surface; + expect(insetColor(), hostColor); + await tester.pumpAndSettle(); + final engine = engines.single; + expect(engine.snapshot.contentReady, isTrue); + expect(insetColor(), Colors.black); + + engine.viewportPreparing(); + expect(engine.snapshot.contentReady, isFalse); + await tester.pump(); + expect(insetColor(), Colors.black); + await tester.pumpAndSettle(); + + await controller.updatePreferences( + controller.preferences.copyWith(fontSize: 28), + ); + expect(controller.snapshot.contentReady, isFalse); + await tester.pump(); + expect(insetColor(), Colors.black); + await tester.pumpAndSettle(); + + var finished = false; + final turn = controller + .goTo( + EpubReaderLocator( + spineIndex: 1, + localProgression: 0, + totalProgression: .9, + ), + ) + .whenComplete(() => finished = true); + for (var frame = 0; frame < 100 && !finished; frame++) { + await tester.pump(const Duration(milliseconds: 16)); + expect(insetColor(), Colors.black); + } + expect(finished, isTrue); + await turn; + await tester.pumpAndSettle(); + expect(engine.locator.spineIndex, 1); + + final gate = Completer(); + final load = controller.load( + ReaderDocument( + id: 'replacement', + format: ReaderFormat.epub, + loadBytes: () async { + await gate.future; + return syntheticEpub(longChapter: true); + }, + ), + ); + await tester.pump(); + expect(insetColor(), hostColor); + gate.complete(); + for ( + var frame = 0; + frame < 100 && !controller.snapshot.contentReady; + frame++ + ) { + await tester.pump(const Duration(milliseconds: 16)); + if (!controller.snapshot.contentReady) { + expect(insetColor(), hostColor); + } + } + await load; + await tester.pumpAndSettle(); + expect(controller.snapshot.contentReady, isTrue); + expect(insetColor(), Colors.black); + expect(tester.takeException(), isNull); + }); + testWidgets('reading settings update animation without moving the page', ( tester, ) async {