Merge remote-tracking branch 'origin/develop' into feat-bluetooth

This commit is contained in:
Justin Mitchell
2026-07-14 04:20:50 -04:00
118 changed files with 22879 additions and 6746 deletions
+147 -58
View File
@@ -206,6 +206,8 @@ void EpubReaderActivity::onEnter() {
return;
}
ImageBlock::clearSessionRenderFailures();
// Configure screen orientation based on settings
// NOTE: This affects layout math and must be applied before any render calls.
ReaderUtils::applyOrientation(renderer, SETTINGS.orientation);
@@ -339,14 +341,43 @@ void EpubReaderActivity::loop() {
return;
}
// Lazily resume a partial's extension build once the reader nears its watermark. Far from
// it the rebuild is all cost (whole-chapter re-layout from page 0) and no benefit this
// session, so reopening a partial deliberately does NOT start it (see the deferral in
// render()); crossing this margin is the signal that the reader will actually need pages
// past the watermark soon. Uses the last render's viewport so pagination matches the
// partial being extended.
if (section && !section->isBuilding() && section->isPartial() && !RenderLock::peek() && buildViewportWidth > 0 &&
!partialRebuildStartFailed &&
section->currentPage + PARTIAL_REBUILD_START_MARGIN >= static_cast<int>(section->pageCount)) {
RenderLock lock;
if (!section->startBuild(SETTINGS.getReaderFontId(), SETTINGS.getReaderLineCompression(),
SETTINGS.extraParagraphSpacing, SETTINGS.paragraphAlignment, buildViewportWidth,
buildViewportHeight, SETTINGS.hyphenationEnabled, SETTINGS.embeddedStyle,
SETTINGS.imageRendering, SETTINGS.focusReadingEnabled)) {
// Not fatal: the partial keeps serving its pages; crossing the watermark falls back to
// the blocking extension in render(). Don't retry every tick.
partialRebuildStartFailed = true;
LOG_ERR("ERS", "Failed to start deferred partial extension build");
} else {
LOG_DBG("ERS", "Reader near partial watermark (%d/%d), resuming extension build", section->currentPage,
section->pageCount);
}
}
// Drive any in-progress incremental section build forward, off the page-turn critical path,
// but only within a small window ahead of the reader: an unbounded build monopolized the
// RenderLock and locked out page turns. The build follows the reader instead, and instant
// reopen comes from suspendBuild() persisting the laid-out pages as a partial on exit.
// Skip while the render mutex is busy so we never delay a pending render; re-check
// isBuilding() under the lock since render() may have just finished it.
// While extending a partial (rebuild from a previous session), pageCount is pinned at the
// partial's watermark until the build catches up, so the window check would wrongly read
// "far enough ahead" and stall the build at 0 pages -- then the first turn past the
// watermark re-parses the whole chapter synchronously. Keep ticking until it finalizes.
if (section && section->isBuilding() && !RenderLock::peek() &&
static_cast<int>(section->pageCount) < section->currentPage + BUILD_WINDOW_AHEAD && buildTickHeapGate()) {
(section->isPartial() || static_cast<int>(section->pageCount) < section->currentPage + BUILD_WINDOW_AHEAD) &&
buildTickHeapGate()) {
RenderLock lock;
// Re-check under the lock: render() (which also holds the RenderLock) may have finalized the
// build between the outer isBuilding() check and acquiring the lock here, in which case
@@ -1043,11 +1074,17 @@ void EpubReaderActivity::render(RenderLock&& lock) {
const uint16_t viewportWidth = renderer.getScreenWidth() - orientedMarginLeft - orientedMarginRight;
const uint16_t viewportHeight = renderer.getScreenHeight() - orientedMarginTop - orientedMarginBottom;
// Capture for loop()'s lazy partial-extension start (must match this render's layout params).
buildViewportWidth = viewportWidth;
buildViewportHeight = viewportHeight;
if (!section) {
const auto filepath = epub->getSpineItem(currentSpineIndex).href;
LOG_DBG("ERS", "Loading file: %s, index: %d", filepath.c_str(), currentSpineIndex);
section = std::unique_ptr<Section>(new Section(epub, currentSpineIndex, renderer));
// Fresh section, fresh chance: a failed lazy extension start in a previous
// section must not suppress watermark-triggered rebuilds for this one.
partialRebuildStartFailed = false;
// A finalized cache serves every page as-is. A partial cache (suspended build from a
// previous session) serves its pages instantly too, but a build must still run to lay
@@ -1128,12 +1165,14 @@ void EpubReaderActivity::render(RenderLock&& lock) {
// The popup's own refresh is a plain FAST, so force the page that replaces it onto the HALF
// ghost-cleanup path -- otherwise the "INDEXING" text ghosts under the rendered page.
pagesUntilFullRefresh = 1;
// No popup redraws while the framebuffer is lent to the build below;
// the panel holds the popup displayed above (e-ink is persistent).
const auto popupFn = [this]() {
if (renderer.hasFrameBuffer()) {
GUI.drawPopup(renderer, tr(STR_INDEXING));
}
if (renderer.hasFrameBuffer()) GUI.drawPopup(renderer, tr(STR_INDEXING));
};
buildLoan.release();
// Lend the framebuffer's 48 KB to the blocking full build; restored
// (white) at scope exit, and the page render below redraws everything.
GfxRenderer::FrameBufferLoan loan(renderer);
const auto buildSection = [&]() {
return section->createSectionFile(SETTINGS.getReaderFontId(), SETTINGS.getReaderLineCompression(),
SETTINGS.extraParagraphSpacing, SETTINGS.paragraphAlignment, viewportWidth,
@@ -1148,9 +1187,11 @@ void EpubReaderActivity::render(RenderLock&& lock) {
silentRestartDefrag();
LOG_ERR("ERS", "Failed to persist page data to SD");
section.reset();
loan.end(); // restore before anything draws
showBuildError();
return;
}
loan.end();
} else {
// Lay out just enough to show the landing page; loop() builds the rest behind it. Show the
// indexing popup up front only when the build will actually be slow: a large spine (its
@@ -1158,64 +1199,84 @@ void EpubReaderActivity::render(RenderLock&& lock) {
// a deep resume/jump that must lay out many pages to reach the landing page. Tiny sections
// build in a blink and stay popup-free.
const int target = pendingPageJump.has_value() ? *pendingPageJump : (nextPageNumber < 0 ? 0 : nextPageNumber);
const size_t spineBytes = epub->getCumulativeSpineItemSize(currentSpineIndex) -
(currentSpineIndex > 0 ? epub->getCumulativeSpineItemSize(currentSpineIndex - 1) : 0);
// Popup only when the build will actually be slow: a big spine whose HTML still needs
// inflating (the multi-second cost), or a deep page target. A reopen with cached HTML builds
// fast, so no popup -- that's what made an already-indexed book look like it was reindexing.
// A partial cache that already covers the target page shows it instantly: never popup.
const bool willInflate = !section->hasHtmlCache();
const bool anchorJump = !pendingAnchor.empty();
bool showPopup;
if (anchorJump) {
// An anchor jump's cost is bounded by the anchor's page, not `target`. An anchor already
// in the on-disk map (partial or finalized cache) lands instantly: no popup. Otherwise it
// lies beyond the indexed watermark and the build may lay out the whole spine to find it,
// so gate on spine size alone -- laying out a big spine takes seconds even with cached
// HTML. Ordinary chapter-top TOC jumps resolve on page 0 and stay popup-free.
showPopup = !section->findAnchor(pendingAnchor).has_value() && spineBytes > BUILD_POPUP_BYTE_THRESHOLD;
// Landing well inside a partial: the page (or anchor, via the on-disk map) is already
// servable, so don't restart the extension build now -- it re-lays out the WHOLE chapter
// from page 0 (minutes of background CPU + SD writes on a giant spine), pure waste when
// the reader never nears the watermark this session. loop() starts it lazily once the
// reader is within PARTIAL_REBUILD_START_MARGIN pages of the watermark.
if (section->isPartial() &&
(anchorJump ? section->getPageForAnchor(pendingAnchor).has_value()
: target + PARTIAL_REBUILD_START_MARGIN < static_cast<int>(section->pageCount))) {
LOG_DBG("ERS", "Partial covers target %d of %d; deferring extension build", target, section->pageCount);
} else {
const bool targetAvailable = target < static_cast<int>(section->pageCount);
showPopup = !targetAvailable &&
((spineBytes > BUILD_POPUP_BYTE_THRESHOLD && willInflate) || target > BUILD_POPUP_PAGE_THRESHOLD);
}
if (showPopup) {
GUI.drawPopup(renderer, tr(STR_INDEXING));
// HALF-clear the popup when the page replaces it, else "INDEXING" ghosts under the page.
pagesUntilFullRefresh = 1;
}
buildLoan.release();
// startBuild does the zip inflate (the big contiguous allocation), so it gets
// the BLE free-and-retry fallback too; it cleans up fully on failure, making a
// retry safe.
const auto beginBuild = [&]() {
return section->startBuild(SETTINGS.getReaderFontId(), SETTINGS.getReaderLineCompression(),
SETTINGS.extraParagraphSpacing, SETTINGS.paragraphAlignment, viewportWidth,
viewportHeight, SETTINGS.hyphenationEnabled, SETTINGS.embeddedStyle,
SETTINGS.imageRendering, SETTINGS.focusReadingEnabled);
};
bool started = !heapTooLow && beginBuild();
if (!started && SETTINGS.bluetoothEnabled) {
started = retryWithBleFreed(beginBuild);
}
if (!started) {
silentRestartDefrag();
LOG_ERR("ERS", "Failed to start section build");
section.reset();
showBuildError();
return;
}
while (!section->isBuildComplete() &&
(anchorJump ? !section->findAnchor(pendingAnchor) : static_cast<int>(section->pageCount) <= target)) {
// Anchor jump: build until the anchor's page is laid out (usually page 0), checking a
// partial's on-disk anchor map too so an already-indexed anchor resolves immediately.
// Otherwise: build until the target page exists. loop() builds the rest behind it.
if (!section->buildSomeMore(BUILD_PAGES_PER_CHUNK)) {
LOG_ERR("ERS", "Failed during incremental section build");
const size_t spineBytes =
epub->getCumulativeSpineItemSize(currentSpineIndex) -
(currentSpineIndex > 0 ? epub->getCumulativeSpineItemSize(currentSpineIndex - 1) : 0);
// Popup only when the build will actually be slow: a big spine whose HTML still needs
// inflating (the multi-second cost), or a deep page target. A reopen with cached HTML builds
// fast, so no popup -- that's what made an already-indexed book look like it was reindexing.
// A partial cache that already covers the target page shows it instantly: never popup.
const bool willInflate = !section->hasHtmlCache();
bool showPopup;
if (anchorJump) {
// An anchor jump's cost is bounded by the anchor's page, not `target`. An anchor already
// in the on-disk map (partial or finalized cache) lands instantly: no popup. Otherwise it
// lies beyond the indexed watermark and the build may lay out the whole spine to find it,
// so gate on spine size alone -- laying out a big spine takes seconds even with cached
// HTML. Ordinary chapter-top TOC jumps resolve on page 0 and stay popup-free.
showPopup = !section->findAnchor(pendingAnchor).has_value() && spineBytes > BUILD_POPUP_BYTE_THRESHOLD;
} else {
const bool targetAvailable = target < static_cast<int>(section->pageCount);
showPopup = !targetAvailable && ((spineBytes > BUILD_POPUP_BYTE_THRESHOLD && willInflate) ||
target > BUILD_POPUP_PAGE_THRESHOLD);
}
if (showPopup) {
GUI.drawPopup(renderer, tr(STR_INDEXING));
// HALF-clear the popup when the page replaces it, else "INDEXING" ghosts under the page.
pagesUntilFullRefresh = 1;
}
// Lend the framebuffer's 48 KB to the blocking pre-render burst
// (startBuild inflates the whole spine HTML — the memory peak). The
// background buildSomeMore chunks in loop() do NOT get the loan: they
// deliberately interleave with page renders. Restored before render.
GfxRenderer::FrameBufferLoan loan(renderer);
// startBuild does the zip inflate (the big contiguous allocation), so it gets
// the BLE free-and-retry fallback too; it cleans up fully on failure, making a
// retry safe.
const auto beginBuild = [&]() {
return section->startBuild(SETTINGS.getReaderFontId(), SETTINGS.getReaderLineCompression(),
SETTINGS.extraParagraphSpacing, SETTINGS.paragraphAlignment, viewportWidth,
viewportHeight, SETTINGS.hyphenationEnabled, SETTINGS.embeddedStyle,
SETTINGS.imageRendering, SETTINGS.focusReadingEnabled);
};
bool started = !heapTooLow && beginBuild();
if (!started && SETTINGS.bluetoothEnabled) {
started = retryWithBleFreed(beginBuild);
}
if (!started) {
silentRestartDefrag();
LOG_ERR("ERS", "Failed to start section build");
section.reset();
loan.end(); // restore before anything draws (showBuildError renders a popup)
showBuildError();
return;
}
while (!section->isBuildComplete() &&
(anchorJump ? !section->findAnchor(pendingAnchor) : static_cast<int>(section->pageCount) <= target)) {
// Anchor jump: build until the anchor's page is laid out (usually page 0), checking a
// partial's on-disk anchor map too so an already-indexed anchor resolves immediately.
// Otherwise: build until the target page exists. loop() builds the rest behind it.
if (!section->buildSomeMore(BUILD_PAGES_PER_CHUNK)) {
LOG_ERR("ERS", "Failed during incremental section build");
section.reset();
loan.end(); // restore before anything draws (showBuildError renders a popup)
showBuildError();
return;
}
}
loan.end();
}
}
} else {
@@ -1258,6 +1319,16 @@ void EpubReaderActivity::render(RenderLock&& lock) {
// Extend the build to the requested page if needed (for partials and in-progress builds).
// This runs every render, so it covers both the first page and any forward turn that gets
// ahead of the background builder; pages already built do no work here.
//
// Crossing a partial's watermark before the extension rebuild has caught up means a
// synchronous wait spanning the remaining prefix re-layout -- potentially tens of
// seconds on a giant spine. Show the indexing popup so it isn't a silent freeze
// (the page that replaces it takes the HALF ghost-cleanup path). Ordinary window
// catch-ups on a non-partial build are a page or two and stay popup-free.
if (section->isPartial() && section->currentPage >= static_cast<int>(section->pageCount)) {
GUI.drawPopup(renderer, tr(STR_INDEXING));
pagesUntilFullRefresh = 1;
}
while (section->isPartial() && section->currentPage >= static_cast<int>(section->pageCount)) {
// Start a build to extend a partial toward the requested page.
buildLoan.release();
@@ -1387,7 +1458,17 @@ void EpubReaderActivity::render(RenderLock&& lock) {
// preceding lines show (mini rebuilds, kern reloads, BLE churn).
LOG_DBG("MEM", "post-render: free=%u maxAlloc=%u", (unsigned)ESP.getFreeHeap(), (unsigned)ESP.getMaxAllocHeap());
}
saveProgress(currentSpineIndex, section->currentPage, section->estimatedTotalPages());
// Only persist when the position actually changed. render() also runs on menu,
// bookmark and screenshot re-renders, and writeAtomic is several FAT ops for 6 bytes.
// Every real page turn changes currentPage, so progress durability is unaffected.
if (currentSpineIndex != lastSavedSpineIndex || section->currentPage != lastSavedPage ||
section->pageCount != lastSavedPageCount) {
if (saveProgress(currentSpineIndex, section->currentPage, section->estimatedTotalPages())) {
lastSavedSpineIndex = currentSpineIndex;
lastSavedPage = section->currentPage;
lastSavedPageCount = section->estimatedTotalPages();
}
}
showPendingSyncSaveError();
@@ -1442,6 +1523,7 @@ void EpubReaderActivity::renderContents(std::unique_ptr<Page> page, const int or
const auto tPrewarm = millis();
const bool pageHasImages = page->hasImages();
const bool pageHasImagesNeedingDecode = pageHasImages && page->hasImagesNeedingDecode();
const bool needsTextGrayscale = SETTINGS.textAntiAliasing;
const bool needsAnyGrayscale = needsTextGrayscale || pageHasImages;
auto renderGrayscalePass = [&]() {
@@ -1452,6 +1534,13 @@ void EpubReaderActivity::renderContents(std::unique_ptr<Page> page, const int or
}
};
if (pageHasImagesNeedingDecode) {
page->renderWithImagePlaceholders(renderer, fontId, orientedMarginLeft, orientedMarginTop);
renderStatusBar();
renderer.displayBuffer(HalDisplay::FAST_REFRESH);
renderer.clearScreen();
}
page->render(renderer, fontId, orientedMarginLeft, orientedMarginTop);
renderStatusBar();
const auto tBwRender = millis();