Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
37 changes: 31 additions & 6 deletions docs/architecture.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -61,7 +69,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
Expand Down Expand Up @@ -90,9 +100,22 @@ 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. 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.
Expand Down Expand Up @@ -124,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<void>`; the reader pushes
Expand Down
2 changes: 2 additions & 0 deletions lib/src/controller/reader_controller.dart
Original file line number Diff line number Diff line change
Expand Up @@ -152,6 +152,8 @@ final class ReaderController extends ChangeNotifier {
);
}

void cancelPendingSeek() => _engine?.cancelPendingSeek();

Future<void> goNext() async {
await _navigate(ReaderNavigationCause.turn, (engine) => engine.goNext());
}
Expand Down
6 changes: 5 additions & 1 deletion lib/src/domain/reader_capabilities.dart
Original file line number Diff line number Diff line change
Expand Up @@ -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) =>
Expand All @@ -18,13 +20,15 @@ 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(
supportsPagination,
supportsScrolling,
supportsTextCustomization,
supportsColumnMode,
supportsPageTurnAnimation,
);
}
8 changes: 7 additions & 1 deletion lib/src/domain/reader_preferences.dart
Original file line number Diff line number Diff line change
Expand Up @@ -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);
Expand All @@ -32,6 +33,7 @@ final class ReaderPreferences {
final Brightness brightness;
final ReaderLayoutMode layoutMode;
final ReaderColumnMode columnMode;
final bool pageTurnAnimation;

ReaderPreferences copyWith({
Object? fontFamily = _unsetFontFamily,
Expand All @@ -45,6 +47,7 @@ final class ReaderPreferences {
Brightness? brightness,
ReaderLayoutMode? layoutMode,
ReaderColumnMode? columnMode,
bool? pageTurnAnimation,
}) {
return ReaderPreferences(
fontFamily: identical(fontFamily, _unsetFontFamily)
Expand All @@ -60,6 +63,7 @@ final class ReaderPreferences {
brightness: brightness ?? this.brightness,
layoutMode: layoutMode ?? this.layoutMode,
columnMode: columnMode ?? this.columnMode,
pageTurnAnimation: pageTurnAnimation ?? this.pageTurnAnimation,
);
}

Expand All @@ -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(
Expand All @@ -92,5 +97,6 @@ final class ReaderPreferences {
brightness,
layoutMode,
columnMode,
pageTurnAnimation,
);
}
82 changes: 71 additions & 11 deletions lib/src/engine/epub/epub_reader_engine.dart
Original file line number Diff line number Diff line change
Expand Up @@ -50,13 +50,15 @@ final class EpubReaderEngine extends ReaderEngine {
Future<void> Function(
EpubReaderLocator target,
List<EpubContentBlock> blocks,
bool Function() cancelled,
)?
prepareLocation;
Future<void> _turnQueue = Future<void>.value();
int _contentLength = 0;
int _restorationRevision = 0;
int _contentRevision = 0;
int _navigationGeneration = 0;
_EpubSeek? _activeSeek;
bool _disposed = false;
int _visiblePage = 1;
int _pageCount = 0;
Expand Down Expand Up @@ -88,6 +90,7 @@ final class EpubReaderEngine extends ReaderEngine {
supportsScrolling: true,
supportsTextCustomization: true,
supportsColumnMode: true,
supportsPageTurnAnimation: true,
);
@override
ReaderSnapshot snapshot = const ReaderIdleSnapshot();
Expand All @@ -105,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 {
Expand Down Expand Up @@ -255,7 +259,12 @@ final class EpubReaderEngine extends ReaderEngine {
}

@override
Future<void> goTo(ReaderLocator locator) async {
Future<void> goTo(ReaderLocator locator) {
cancelPendingSeek();
return _goTo(locator);
}

Future<void> _goTo(ReaderLocator locator, {_EpubSeek? seek}) async {
if (locator is! EpubReaderLocator ||
locator.spineIndex >= _chapterCount ||
_worker == null) {
Expand All @@ -274,17 +283,18 @@ 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 [];
_viewportReady = false;
_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,
Expand All @@ -294,13 +304,35 @@ final class EpubReaderEngine extends ReaderEngine {
locator.textOffset,
),
EpubContentBlocks(chapter['blocks'] as List),
seek,
);
_requireCurrent(generation);
_setChapter(chapter, locator);
}
_publish();
}

Future<void> _prepare(
EpubReaderLocator target,
List<EpubContentBlock> 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<void> goToProgress(double progress) async {
if (!progress.isFinite || progress < 0 || progress > 1) {
Expand All @@ -316,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
Expand Down Expand Up @@ -525,6 +568,7 @@ final class EpubReaderEngine extends ReaderEngine {
backgroundColor: _preferences.backgroundColor,
foregroundColor: _preferences.foregroundColor,
brightness: _preferences.brightness,
pageTurnAnimation: _preferences.pageTurnAnimation,
) !=
_preferences;
_preferences = preferences;
Expand Down Expand Up @@ -576,6 +620,7 @@ final class EpubReaderEngine extends ReaderEngine {
@override
void dispose() {
if (_disposed) return;
cancelPendingSeek();
_disposed = true;
_navigationGeneration++;
_openingWorker?.dispose();
Expand All @@ -589,3 +634,18 @@ final class EpubReaderEngine extends ReaderEngine {
super.dispose();
}
}

final class _EpubSeek {
final _cancellation = Completer<void>();
bool get cancelled => _cancellation.isCompleted;
void cancel() => _cancellation.complete();

Future<T> wait<T>(Future<T> operation) => Future.any([
operation,
_cancellation.future.then<T>((_) => throw const _CancelledSeek()),
]);
}

final class _CancelledSeek implements Exception {
const _CancelledSeek();
}
Loading
Loading