This commit is contained in:
jpirnay
2026-03-13 21:24:04 +01:00
83 changed files with 104973 additions and 120228 deletions
+1 -1
View File
@@ -63,7 +63,7 @@ bool CrossPointState::loadFromBinaryFile() {
if (version >= 2) {
serialization::readPod(inputFile, lastSleepImage);
} else {
lastSleepImage = 0;
lastSleepImage = UINT8_MAX;
}
if (version >= 3) {
+2 -1
View File
@@ -1,4 +1,5 @@
#pragma once
#include <cstdint>
#include <iosfwd>
#include <string>
@@ -8,7 +9,7 @@ class CrossPointState {
public:
std::string openEpubPath;
uint8_t lastSleepImage;
uint8_t lastSleepImage = UINT8_MAX; // UINT8_MAX = unset sentinel
uint8_t readerActivityLoadCount = 0;
bool lastSleepFromReader = false;
~CrossPointState() = default;
+1 -1
View File
@@ -87,7 +87,7 @@ bool JsonSettingsIO::loadState(CrossPointState& s, const char* json) {
}
s.openEpubPath = doc["openEpubPath"] | std::string("");
s.lastSleepImage = doc["lastSleepImage"] | (uint8_t)0;
s.lastSleepImage = doc["lastSleepImage"] | (uint8_t)UINT8_MAX;
s.readerActivityLoadCount = doc["readerActivityLoadCount"] | (uint8_t)0;
s.lastSleepFromReader = doc["lastSleepFromReader"] | false;
return true;
+1 -1
View File
@@ -231,7 +231,7 @@ void SleepActivity::renderCustomSleepScreen() const {
// Generate a random number between 1 and numFiles
auto randomFileIndex = random(numFiles);
// If we picked the same image as last time, reroll
while (numFiles > 1 && randomFileIndex == APP_STATE.lastSleepImage) {
while (numFiles > 1 && APP_STATE.lastSleepImage != UINT8_MAX && randomFileIndex == APP_STATE.lastSleepImage) {
randomFileIndex = random(numFiles);
}
APP_STATE.lastSleepImage = randomFileIndex;
+56 -49
View File
@@ -2,11 +2,13 @@
#include <Epub/Page.h>
#include <Epub/blocks/TextBlock.h>
#include <FontCacheManager.h>
#include <FsHelpers.h>
#include <GfxRenderer.h>
#include <HalStorage.h>
#include <I18n.h>
#include <Logging.h>
#include <esp_system.h>
#include <memory>
@@ -19,6 +21,7 @@
#include "KOReaderSyncActivity.h"
#include "MappedInputManager.h"
#include "QrDisplayActivity.h"
#include "ReaderUtils.h"
#include "RecentBooksStore.h"
#include "components/UITheme.h"
#include "fontIds.h"
@@ -27,7 +30,6 @@
namespace {
// pagesPerRefresh now comes from SETTINGS.getRefreshFrequency()
constexpr unsigned long skipChapterMs = 700;
constexpr unsigned long goHomeMs = 1000;
// pages per minute, first item is 1 to prevent division by zero if accessed
const std::vector<int> PAGE_TURN_LABELS = {1, 1, 3, 6, 12};
@@ -41,27 +43,6 @@ int clampPercent(int percent) {
return percent;
}
// Apply the logical reader orientation to the renderer.
// This centralizes orientation mapping so we don't duplicate switch logic elsewhere.
void applyReaderOrientation(GfxRenderer& renderer, const uint8_t orientation) {
switch (orientation) {
case CrossPointSettings::ORIENTATION::PORTRAIT:
renderer.setOrientation(GfxRenderer::Orientation::Portrait);
break;
case CrossPointSettings::ORIENTATION::LANDSCAPE_CW:
renderer.setOrientation(GfxRenderer::Orientation::LandscapeClockwise);
break;
case CrossPointSettings::ORIENTATION::INVERTED:
renderer.setOrientation(GfxRenderer::Orientation::PortraitInverted);
break;
case CrossPointSettings::ORIENTATION::LANDSCAPE_CCW:
renderer.setOrientation(GfxRenderer::Orientation::LandscapeCounterClockwise);
break;
default:
break;
}
}
} // namespace
void EpubReaderActivity::onEnter() {
@@ -73,7 +54,7 @@ void EpubReaderActivity::onEnter() {
// Configure screen orientation based on settings
// NOTE: This affects layout math and must be applied before any render calls.
applyReaderOrientation(renderer, SETTINGS.orientation);
ReaderUtils::applyOrientation(renderer, SETTINGS.orientation);
epub->setupCacheDir();
@@ -183,13 +164,14 @@ void EpubReaderActivity::loop() {
}
// Long press BACK (1s+) goes to file selection
if (mappedInput.isPressed(MappedInputManager::Button::Back) && mappedInput.getHeldTime() >= goHomeMs) {
if (mappedInput.isPressed(MappedInputManager::Button::Back) && mappedInput.getHeldTime() >= ReaderUtils::GO_HOME_MS) {
activityManager.goToFileBrowser(epub ? epub->getPath() : "");
return;
}
// Short press BACK goes directly to home (or restores position if viewing footnote)
if (mappedInput.wasReleased(MappedInputManager::Button::Back) && mappedInput.getHeldTime() < goHomeMs) {
if (mappedInput.wasReleased(MappedInputManager::Button::Back) &&
mappedInput.getHeldTime() < ReaderUtils::GO_HOME_MS) {
if (footnoteDepth > 0) {
restoreSavedPosition();
return;
@@ -198,20 +180,7 @@ void EpubReaderActivity::loop() {
return;
}
// When long-press chapter skip is disabled, turn pages on press instead of release.
const bool usePressForPageTurn = !SETTINGS.longPressChapterSkip;
const bool prevTriggered = usePressForPageTurn ? (mappedInput.wasPressed(MappedInputManager::Button::PageBack) ||
mappedInput.wasPressed(MappedInputManager::Button::Left))
: (mappedInput.wasReleased(MappedInputManager::Button::PageBack) ||
mappedInput.wasReleased(MappedInputManager::Button::Left));
const bool powerPageTurn = SETTINGS.shortPwrBtn == CrossPointSettings::SHORT_PWRBTN::PAGE_TURN &&
mappedInput.wasReleased(MappedInputManager::Button::Power);
const bool nextTriggered = usePressForPageTurn
? (mappedInput.wasPressed(MappedInputManager::Button::PageForward) || powerPageTurn ||
mappedInput.wasPressed(MappedInputManager::Button::Right))
: (mappedInput.wasReleased(MappedInputManager::Button::PageForward) || powerPageTurn ||
mappedInput.wasReleased(MappedInputManager::Button::Right));
auto [prevTriggered, nextTriggered] = ReaderUtils::detectPageTurn(mappedInput);
if (!prevTriggered && !nextTriggered) {
return;
}
@@ -459,7 +428,7 @@ void EpubReaderActivity::applyOrientation(const uint8_t orientation) {
SETTINGS.saveToFile();
// Update renderer orientation to match the new logical coordinate system.
applyReaderOrientation(renderer, SETTINGS.orientation);
ReaderUtils::applyOrientation(renderer, SETTINGS.orientation);
// Reset section to force re-layout in the new orientation.
section.reset();
@@ -669,7 +638,6 @@ void EpubReaderActivity::render(RenderLock&& lock) {
const auto start = millis();
renderContents(std::move(p), orientedMarginTop, orientedMarginRight, orientedMarginBottom, orientedMarginLeft);
LOG_DBG("ERS", "Rendered page in %dms", millis() - start);
renderer.clearFontCache();
}
saveProgress(currentSpineIndex, section->currentPage, section->pageCount);
@@ -699,11 +667,30 @@ void EpubReaderActivity::saveProgress(int spineIndex, int currentPage, int pageC
void EpubReaderActivity::renderContents(std::unique_ptr<Page> page, const int orientedMarginTop,
const int orientedMarginRight, const int orientedMarginBottom,
const int orientedMarginLeft) {
const auto t0 = millis();
auto* fcm = renderer.getFontCacheManager();
fcm->resetStats();
// Font prewarm: scan pass accumulates text, then prewarm, then real render
const uint32_t heapBefore = esp_get_free_heap_size();
auto scope = fcm->createPrewarmScope();
page->render(renderer, SETTINGS.getReaderFontId(), orientedMarginLeft, orientedMarginTop); // scan pass
scope.endScanAndPrewarm();
const uint32_t heapAfter = esp_get_free_heap_size();
fcm->logStats("prewarm");
const auto tPrewarm = millis();
LOG_DBG("ERS", "Heap: before=%lu after=%lu delta=%ld", heapBefore, heapAfter,
(int32_t)heapAfter - (int32_t)heapBefore);
// Force special handling for pages with images when anti-aliasing is on
bool imagePageWithAA = page->hasImages() && SETTINGS.textAntiAliasing;
page->render(renderer, SETTINGS.getReaderFontId(), orientedMarginLeft, orientedMarginTop);
renderStatusBar();
fcm->logStats("bw_render");
const auto tBwRender = millis();
if (imagePageWithAA) {
// Double FAST_REFRESH with selective image blanking (pablohc's technique):
// HALF_REFRESH sets particles too firmly for the grayscale LUT to adjust.
@@ -723,16 +710,14 @@ void EpubReaderActivity::renderContents(std::unique_ptr<Page> page, const int or
renderer.displayBuffer(HalDisplay::HALF_REFRESH);
}
// Double FAST_REFRESH handles ghosting for image pages; don't count toward full refresh cadence
} else if (pagesUntilFullRefresh <= 1) {
renderer.displayBuffer(HalDisplay::HALF_REFRESH);
pagesUntilFullRefresh = SETTINGS.getRefreshFrequency();
} else {
renderer.displayBuffer();
pagesUntilFullRefresh--;
ReaderUtils::displayWithRefreshCycle(renderer, pagesUntilFullRefresh);
}
const auto tDisplay = millis();
// Save bw buffer to reset buffer state after grayscale data sync
renderer.storeBwBuffer();
const auto tBwStore = millis();
// grayscale rendering
// TODO: Only do this if font supports it
@@ -741,20 +726,42 @@ void EpubReaderActivity::renderContents(std::unique_ptr<Page> page, const int or
renderer.setRenderMode(GfxRenderer::GRAYSCALE_LSB);
page->render(renderer, SETTINGS.getReaderFontId(), orientedMarginLeft, orientedMarginTop);
renderer.copyGrayscaleLsbBuffers();
const auto tGrayLsb = millis();
// Render and copy to MSB buffer
renderer.clearScreen(0x00);
renderer.setRenderMode(GfxRenderer::GRAYSCALE_MSB);
page->render(renderer, SETTINGS.getReaderFontId(), orientedMarginLeft, orientedMarginTop);
renderer.copyGrayscaleMsbBuffers();
const auto tGrayMsb = millis();
// display grayscale part
renderer.displayGrayBuffer();
const auto tGrayDisplay = millis();
renderer.setRenderMode(GfxRenderer::BW);
}
fcm->logStats("gray");
// restore the bw data
renderer.restoreBwBuffer();
// restore the bw data
renderer.restoreBwBuffer();
const auto tBwRestore = millis();
const auto tEnd = millis();
LOG_DBG("ERS",
"Page render: prewarm=%lums bw_render=%lums display=%lums bw_store=%lums "
"gray_lsb=%lums gray_msb=%lums gray_display=%lums bw_restore=%lums total=%lums",
tPrewarm - t0, tBwRender - tPrewarm, tDisplay - tBwRender, tBwStore - tDisplay, tGrayLsb - tBwStore,
tGrayMsb - tGrayLsb, tGrayDisplay - tGrayMsb, tBwRestore - tGrayDisplay, tEnd - t0);
} else {
// restore the bw data
renderer.restoreBwBuffer();
const auto tBwRestore = millis();
const auto tEnd = millis();
LOG_DBG("ERS",
"Page render: prewarm=%lums bw_render=%lums display=%lums bw_store=%lums bw_restore=%lums total=%lums",
tPrewarm - t0, tBwRender - tPrewarm, tDisplay - tBwRender, tBwStore - tDisplay, tBwRestore - tBwStore,
tEnd - t0);
}
}
void EpubReaderActivity::renderStatusBar() const {
+89
View File
@@ -0,0 +1,89 @@
#pragma once
#include <CrossPointSettings.h>
#include <GfxRenderer.h>
#include <Logging.h>
#include "MappedInputManager.h"
namespace ReaderUtils {
constexpr unsigned long GO_HOME_MS = 1000;
inline void applyOrientation(GfxRenderer& renderer, const uint8_t orientation) {
switch (orientation) {
case CrossPointSettings::ORIENTATION::PORTRAIT:
renderer.setOrientation(GfxRenderer::Orientation::Portrait);
break;
case CrossPointSettings::ORIENTATION::LANDSCAPE_CW:
renderer.setOrientation(GfxRenderer::Orientation::LandscapeClockwise);
break;
case CrossPointSettings::ORIENTATION::INVERTED:
renderer.setOrientation(GfxRenderer::Orientation::PortraitInverted);
break;
case CrossPointSettings::ORIENTATION::LANDSCAPE_CCW:
renderer.setOrientation(GfxRenderer::Orientation::LandscapeCounterClockwise);
break;
default:
break;
}
}
struct PageTurnResult {
bool prev;
bool next;
};
inline PageTurnResult detectPageTurn(const MappedInputManager& input) {
const bool usePress = !SETTINGS.longPressChapterSkip;
const bool prev = usePress ? (input.wasPressed(MappedInputManager::Button::PageBack) ||
input.wasPressed(MappedInputManager::Button::Left))
: (input.wasReleased(MappedInputManager::Button::PageBack) ||
input.wasReleased(MappedInputManager::Button::Left));
const bool powerTurn = SETTINGS.shortPwrBtn == CrossPointSettings::SHORT_PWRBTN::PAGE_TURN &&
input.wasReleased(MappedInputManager::Button::Power);
const bool next = usePress ? (input.wasPressed(MappedInputManager::Button::PageForward) || powerTurn ||
input.wasPressed(MappedInputManager::Button::Right))
: (input.wasReleased(MappedInputManager::Button::PageForward) || powerTurn ||
input.wasReleased(MappedInputManager::Button::Right));
return {prev, next};
}
inline void displayWithRefreshCycle(const GfxRenderer& renderer, int& pagesUntilFullRefresh) {
if (pagesUntilFullRefresh <= 1) {
renderer.displayBuffer(HalDisplay::HALF_REFRESH);
pagesUntilFullRefresh = SETTINGS.getRefreshFrequency();
} else {
renderer.displayBuffer();
pagesUntilFullRefresh--;
}
}
// Grayscale anti-aliasing pass. Renders content twice (LSB + MSB) to build
// the grayscale buffer. Only the content callback is re-rendered — status bars
// and other overlays should be drawn before calling this.
// Kept as a template to avoid std::function overhead; instantiated once per reader type.
template <typename RenderFn>
void renderAntiAliased(GfxRenderer& renderer, RenderFn&& renderFn) {
if (!renderer.storeBwBuffer()) {
LOG_ERR("READER", "Failed to store BW buffer for anti-aliasing");
return;
}
renderer.clearScreen(0x00);
renderer.setRenderMode(GfxRenderer::GRAYSCALE_LSB);
renderFn();
renderer.copyGrayscaleLsbBuffers();
renderer.clearScreen(0x00);
renderer.setRenderMode(GfxRenderer::GRAYSCALE_MSB);
renderFn();
renderer.copyGrayscaleMsbBuffers();
renderer.displayGrayBuffer();
renderer.setRenderMode(GfxRenderer::BW);
renderer.restoreBwBuffer();
}
} // namespace ReaderUtils
+17 -63
View File
@@ -1,5 +1,6 @@
#include "TxtReaderActivity.h"
#include <FontCacheManager.h>
#include <GfxRenderer.h>
#include <HalStorage.h>
#include <I18n.h>
@@ -9,14 +10,13 @@
#include "CrossPointSettings.h"
#include "CrossPointState.h"
#include "MappedInputManager.h"
#include "ReaderUtils.h"
#include "RecentBooksStore.h"
#include "components/UITheme.h"
#include "fontIds.h"
namespace {
constexpr unsigned long goHomeMs = 1000;
constexpr size_t CHUNK_SIZE = 8 * 1024; // 8KB chunk for reading
// Cache file magic and version
constexpr uint32_t CACHE_MAGIC = 0x54585449; // "TXTI"
constexpr uint8_t CACHE_VERSION = 2; // Increment when cache format changes
@@ -88,23 +88,7 @@ void TxtReaderActivity::onEnter() {
return;
}
// Configure screen orientation based on settings
switch (SETTINGS.orientation) {
case CrossPointSettings::ORIENTATION::PORTRAIT:
renderer.setOrientation(GfxRenderer::Orientation::Portrait);
break;
case CrossPointSettings::ORIENTATION::LANDSCAPE_CW:
renderer.setOrientation(GfxRenderer::Orientation::LandscapeClockwise);
break;
case CrossPointSettings::ORIENTATION::INVERTED:
renderer.setOrientation(GfxRenderer::Orientation::PortraitInverted);
break;
case CrossPointSettings::ORIENTATION::LANDSCAPE_CCW:
renderer.setOrientation(GfxRenderer::Orientation::LandscapeCounterClockwise);
break;
default:
break;
}
ReaderUtils::applyOrientation(renderer, SETTINGS.orientation);
txt->setupCacheDir();
@@ -134,31 +118,19 @@ void TxtReaderActivity::onExit() {
void TxtReaderActivity::loop() {
// Long press BACK (1s+) goes to file selection
if (mappedInput.isPressed(MappedInputManager::Button::Back) && mappedInput.getHeldTime() >= goHomeMs) {
if (mappedInput.isPressed(MappedInputManager::Button::Back) && mappedInput.getHeldTime() >= ReaderUtils::GO_HOME_MS) {
activityManager.goToFileBrowser(txt ? txt->getPath() : "");
return;
}
// Short press BACK goes directly to home
if (mappedInput.wasReleased(MappedInputManager::Button::Back) && mappedInput.getHeldTime() < goHomeMs) {
if (mappedInput.wasReleased(MappedInputManager::Button::Back) &&
mappedInput.getHeldTime() < ReaderUtils::GO_HOME_MS) {
onGoHome();
return;
}
// When long-press chapter skip is disabled, turn pages on press instead of release.
const bool usePressForPageTurn = !SETTINGS.longPressChapterSkip;
const bool prevTriggered = usePressForPageTurn ? (mappedInput.wasPressed(MappedInputManager::Button::PageBack) ||
mappedInput.wasPressed(MappedInputManager::Button::Left))
: (mappedInput.wasReleased(MappedInputManager::Button::PageBack) ||
mappedInput.wasReleased(MappedInputManager::Button::Left));
const bool powerPageTurn = SETTINGS.shortPwrBtn == CrossPointSettings::SHORT_PWRBTN::PAGE_TURN &&
mappedInput.wasReleased(MappedInputManager::Button::Power);
const bool nextTriggered = usePressForPageTurn
? (mappedInput.wasPressed(MappedInputManager::Button::PageForward) || powerPageTurn ||
mappedInput.wasPressed(MappedInputManager::Button::Right))
: (mappedInput.wasReleased(MappedInputManager::Button::PageForward) || powerPageTurn ||
mappedInput.wasReleased(MappedInputManager::Button::Right));
auto [prevTriggered, nextTriggered] = ReaderUtils::detectPageTurn(mappedInput);
if (!prevTriggered && !nextTriggered) {
return;
}
@@ -316,7 +288,6 @@ void TxtReaderActivity::render(RenderLock&&) {
renderer.clearScreen();
renderPage();
renderer.clearFontCache();
// Save progress
saveProgress();
@@ -361,39 +332,22 @@ void TxtReaderActivity::renderPage() {
}
};
// First pass: BW rendering
// Font prewarm: scan pass accumulates text, then prewarm, then real render
auto* fcm = renderer.getFontCacheManager();
auto scope = fcm->createPrewarmScope();
renderLines(); // scan pass — text accumulated, no drawing
scope.endScanAndPrewarm();
// BW rendering
renderLines();
renderStatusBar();
if (pagesUntilFullRefresh <= 1) {
renderer.displayBuffer(HalDisplay::HALF_REFRESH);
pagesUntilFullRefresh = SETTINGS.getRefreshFrequency();
} else {
renderer.displayBuffer();
pagesUntilFullRefresh--;
}
ReaderUtils::displayWithRefreshCycle(renderer, pagesUntilFullRefresh);
// Grayscale rendering pass (for anti-aliased fonts)
if (SETTINGS.textAntiAliasing) {
// Save BW buffer for restoration after grayscale pass
renderer.storeBwBuffer();
renderer.clearScreen(0x00);
renderer.setRenderMode(GfxRenderer::GRAYSCALE_LSB);
renderLines();
renderer.copyGrayscaleLsbBuffers();
renderer.clearScreen(0x00);
renderer.setRenderMode(GfxRenderer::GRAYSCALE_MSB);
renderLines();
renderer.copyGrayscaleMsbBuffers();
renderer.displayGrayBuffer();
renderer.setRenderMode(GfxRenderer::BW);
// Restore BW buffer
renderer.restoreBwBuffer();
ReaderUtils::renderAntiAliased(renderer, [&renderLines]() { renderLines(); });
}
// scope destructor clears font cache via FontCacheManager
}
void TxtReaderActivity::renderStatusBar() const {
+7 -2
View File
@@ -89,8 +89,13 @@ void SettingsActivity::loop() {
}
if (mappedInput.wasPressed(MappedInputManager::Button::Back)) {
SETTINGS.saveToFile();
onGoHome();
if (selectedSettingIndex > 0) {
selectedSettingIndex = 0;
requestUpdate();
} else {
SETTINGS.saveToFile();
onGoHome();
}
return;
}
+4 -1
View File
@@ -1,5 +1,6 @@
#include <Arduino.h>
#include <Epub.h>
#include <FontCacheManager.h>
#include <FontDecompressor.h>
#include <GfxRenderer.h>
#include <HalDisplay.h>
@@ -32,6 +33,7 @@ MappedInputManager mappedInputManager(gpio);
GfxRenderer renderer(display);
ActivityManager activityManager(renderer, mappedInputManager);
FontDecompressor fontDecompressor;
FontCacheManager fontCacheManager(renderer.getFontMap());
// Fonts
EpdFont bookerly14RegularFont(&bookerly_14_regular);
@@ -203,7 +205,8 @@ void setupDisplayAndFonts() {
if (!fontDecompressor.init()) {
LOG_ERR("MAIN", "Font decompressor init failed");
}
renderer.setFontDecompressor(&fontDecompressor);
fontCacheManager.setFontDecompressor(&fontDecompressor);
renderer.setFontCacheManager(&fontCacheManager);
renderer.insertFont(BOOKERLY_14_FONT_ID, bookerly14FontFamily);
#ifndef OMIT_FONTS
renderer.insertFont(BOOKERLY_12_FONT_ID, bookerly12FontFamily);
+14 -2
View File
@@ -1,5 +1,6 @@
#include "QrUtils.h"
#include <Utf8.h>
#include <qrcode.h>
#include <algorithm>
@@ -12,7 +13,18 @@ void QrUtils::drawQrCode(const GfxRenderer& renderer, const Rect& bounds, const
// Version 4 holds ~114 bytes, Version 10 ~395, Version 20 ~1066, up to 40
// qrcode.h max version is 40.
// Formula: approx version = size / 26 + 1 (very rough estimate, better to find best fit)
const size_t len = textPayload.length();
size_t len = textPayload.length();
// Truncate to max QR capacity at a UTF-8 safe boundary to avoid splitting multi-byte sequences
static constexpr size_t MAX_QR_CAPACITY = 2953; // Version 40, ECC_LOW, byte mode
std::string truncated;
const char* payload = textPayload.c_str();
if (len > MAX_QR_CAPACITY) {
len = utf8SafeTruncateBuffer(textPayload.c_str(), static_cast<int>(MAX_QR_CAPACITY));
truncated = textPayload.substr(0, len);
payload = truncated.c_str();
}
int version = 4;
if (len > 114) version = 10;
if (len > 395) version = 20;
@@ -25,7 +37,7 @@ void QrUtils::drawQrCode(const GfxRenderer& renderer, const Rect& bounds, const
QRCode qrcode;
// Initialize the QR code. We use ECC_LOW for max capacity.
int8_t res = qrcode_initText(&qrcode, qrcodeBytes.get(), version, ECC_LOW, textPayload.c_str());
int8_t res = qrcode_initText(&qrcode, qrcodeBytes.get(), version, ECC_LOW, payload);
if (res == 0) {
// Determine the optimal pixel size.