mod: Phase 2c-e — GfxRenderer, themes, SleepActivity, SettingsActivity, platformio

- Add drawPixelGray to GfxRenderer for letterbox fill rendering
- Add PRERENDER_THUMB_HEIGHTS to UITheme for placeholder cover generation
- Add [env:mod] build environment to platformio.ini
- Implement sleep screen letterbox fill (solid/dithered) with edge
  caching in SleepActivity, including placeholder cover fallback
- Add Clock settings category to SettingsActivity with timezone,
  NTP sync, and set-time actions; replace CalibreSettings with
  OpdsServerListActivity; add DynamicEnum rendering support
- Add long-press book management to RecentBooksActivity

Made-with: Cursor
This commit is contained in:
cottongin
2026-03-07 15:52:46 -05:00
parent d1ee45592e
commit 30473c27d3
10 changed files with 485 additions and 30 deletions

View File

@@ -180,6 +180,16 @@ void GfxRenderer::drawPixel(const int x, const int y, const bool state) const {
}
}
void GfxRenderer::drawPixelGray(const int x, const int y, const uint8_t val2bit) const {
if (renderMode == BW && val2bit < 3) {
drawPixel(x, y);
} else if (renderMode == GRAYSCALE_MSB && (val2bit == 1 || val2bit == 2)) {
drawPixel(x, y, false);
} else if (renderMode == GRAYSCALE_LSB && val2bit == 1) {
drawPixel(x, y, false);
}
}
int GfxRenderer::getTextWidth(const int fontId, const char* text, const EpdFontFamily::Style style) const {
const auto fontIt = fontMap.find(fontId);
if (fontIt == fontMap.end()) {

View File

@@ -83,6 +83,7 @@ class GfxRenderer {
// Drawing
void drawPixel(int x, int y, bool state = true) const;
void drawPixelGray(int x, int y, uint8_t val2bit) const;
void drawLine(int x1, int y1, int x2, int y2, bool state = true) const;
void drawLine(int x1, int y1, int x2, int y2, int lineWidth, bool state) const;
void drawArc(int maxRadius, int cx, int cy, int xDir, int yDir, int lineWidth, bool state) const;

View File

@@ -72,6 +72,22 @@ build_flags =
-DLOG_LEVEL=2 ; Set log level to debug for development builds
[env:mod]
extends = base
extra_scripts =
${base.extra_scripts}
pre:scripts/inject_mod_version.py
build_flags =
${base.build_flags}
-DOMIT_OPENDYSLEXIC
-DOMIT_HYPH_DE
-DOMIT_HYPH_ES
-DOMIT_HYPH_FR
-DOMIT_HYPH_IT
-DOMIT_HYPH_RU
-DENABLE_SERIAL_LOG
-DLOG_LEVEL=2 ; Set log level to debug for mod builds
[env:gh_release]
extends = base
build_flags =

View File

@@ -5,14 +5,251 @@
#include <GfxRenderer.h>
#include <HalStorage.h>
#include <I18n.h>
#include <Logging.h>
#include <PlaceholderCoverGenerator.h>
#include <Serialization.h>
#include <Txt.h>
#include <Xtc.h>
#include <algorithm>
#include <cmath>
#include "CrossPointSettings.h"
#include "CrossPointState.h"
#include "components/UITheme.h"
#include "fontIds.h"
#include "images/Logo120.h"
#include "util/BookSettings.h"
#define USE_SLEEP_DOUBLE_FAST_REFRESH 1
namespace {
constexpr int EDGE_SAMPLE_DEPTH = 20;
constexpr uint8_t val2bitToGray(uint8_t val2bit) { return val2bit * 85; }
struct LetterboxFillData {
uint8_t avgA = 128;
uint8_t avgB = 128;
int letterboxA = 0;
int letterboxB = 0;
bool horizontal = false;
bool valid = false;
};
uint8_t snapToEinkLevel(uint8_t gray) {
if (gray < 43) return 0;
if (gray < 128) return 85;
if (gray < 213) return 170;
return 255;
}
// clang-format off
constexpr uint8_t BAYER_4X4[4][4] = {
{ 0, 128, 32, 160},
{192, 64, 224, 96},
{ 48, 176, 16, 144},
{240, 112, 208, 80}
};
// clang-format on
uint8_t quantizeBayerDither(int gray, int x, int y) {
const int threshold = BAYER_4X4[y & 3][x & 3];
const int scaled = gray * 3;
if (scaled < 255) return (scaled + threshold >= 255) ? 1 : 0;
if (scaled < 510) return ((scaled - 255) + threshold >= 255) ? 2 : 1;
return ((scaled - 510) + threshold >= 255) ? 3 : 2;
}
constexpr uint8_t EDGE_CACHE_VERSION = 2;
bool loadEdgeCache(const std::string& path, int screenWidth, int screenHeight, LetterboxFillData& data) {
FsFile file;
if (!Storage.openFileForRead("SLP", path, file)) return false;
uint8_t version;
serialization::readPod(file, version);
if (version != EDGE_CACHE_VERSION) {
file.close();
return false;
}
uint16_t cachedW, cachedH;
serialization::readPod(file, cachedW);
serialization::readPod(file, cachedH);
if (cachedW != static_cast<uint16_t>(screenWidth) || cachedH != static_cast<uint16_t>(screenHeight)) {
file.close();
return false;
}
uint8_t horizontal;
serialization::readPod(file, horizontal);
data.horizontal = (horizontal != 0);
serialization::readPod(file, data.avgA);
serialization::readPod(file, data.avgB);
int16_t lbA, lbB;
serialization::readPod(file, lbA);
serialization::readPod(file, lbB);
data.letterboxA = lbA;
data.letterboxB = lbB;
file.close();
data.valid = true;
return true;
}
bool saveEdgeCache(const std::string& path, int screenWidth, int screenHeight, const LetterboxFillData& data) {
if (!data.valid) return false;
FsFile file;
if (!Storage.openFileForWrite("SLP", path, file)) return false;
serialization::writePod(file, EDGE_CACHE_VERSION);
serialization::writePod(file, static_cast<uint16_t>(screenWidth));
serialization::writePod(file, static_cast<uint16_t>(screenHeight));
serialization::writePod(file, static_cast<uint8_t>(data.horizontal ? 1 : 0));
serialization::writePod(file, data.avgA);
serialization::writePod(file, data.avgB);
serialization::writePod(file, static_cast<int16_t>(data.letterboxA));
serialization::writePod(file, static_cast<int16_t>(data.letterboxB));
file.close();
return true;
}
LetterboxFillData computeEdgeAverages(const Bitmap& bitmap, int imgX, int imgY, int pageWidth, int pageHeight,
float scale, float cropX, float cropY) {
LetterboxFillData data;
const int cropPixX = static_cast<int>(std::floor(bitmap.getWidth() * cropX / 2.0f));
const int cropPixY = static_cast<int>(std::floor(bitmap.getHeight() * cropY / 2.0f));
const int visibleWidth = bitmap.getWidth() - 2 * cropPixX;
const int visibleHeight = bitmap.getHeight() - 2 * cropPixY;
if (visibleWidth <= 0 || visibleHeight <= 0) return data;
const int outputRowSize = (bitmap.getWidth() + 3) / 4;
auto* outputRow = static_cast<uint8_t*>(malloc(outputRowSize));
auto* rowBytes = static_cast<uint8_t*>(malloc(bitmap.getRowBytes()));
if (!outputRow || !rowBytes) {
free(outputRow);
free(rowBytes);
return data;
}
if (imgY > 0) {
data.horizontal = true;
const int scaledHeight = static_cast<int>(std::round(static_cast<float>(visibleHeight) * scale));
data.letterboxA = imgY;
data.letterboxB = pageHeight - imgY - scaledHeight;
if (data.letterboxB < 0) data.letterboxB = 0;
const int sampleRows = std::min(EDGE_SAMPLE_DEPTH, visibleHeight);
uint64_t sumTop = 0, sumBot = 0;
int countTop = 0, countBot = 0;
for (int bmpY = 0; bmpY < bitmap.getHeight(); bmpY++) {
if (bitmap.readNextRow(outputRow, rowBytes) != BmpReaderError::Ok) break;
const int logicalY = bitmap.isTopDown() ? bmpY : bitmap.getHeight() - 1 - bmpY;
if (logicalY < cropPixY || logicalY >= bitmap.getHeight() - cropPixY) continue;
const int outY = logicalY - cropPixY;
const bool inTop = (outY < sampleRows);
const bool inBot = (outY >= visibleHeight - sampleRows);
if (!inTop && !inBot) continue;
for (int bmpX = cropPixX; bmpX < bitmap.getWidth() - cropPixX; bmpX++) {
const uint8_t val = (outputRow[bmpX / 4] >> (6 - ((bmpX * 2) % 8))) & 0x3;
const uint8_t gray = val2bitToGray(val);
if (inTop) { sumTop += gray; countTop++; }
if (inBot) { sumBot += gray; countBot++; }
}
}
data.avgA = countTop > 0 ? static_cast<uint8_t>(sumTop / countTop) : 128;
data.avgB = countBot > 0 ? static_cast<uint8_t>(sumBot / countBot) : 128;
data.valid = true;
} else if (imgX > 0) {
data.horizontal = false;
const int scaledWidth = static_cast<int>(std::round(static_cast<float>(visibleWidth) * scale));
data.letterboxA = imgX;
data.letterboxB = pageWidth - imgX - scaledWidth;
if (data.letterboxB < 0) data.letterboxB = 0;
const int sampleCols = std::min(EDGE_SAMPLE_DEPTH, visibleWidth);
uint64_t sumLeft = 0, sumRight = 0;
int countLeft = 0, countRight = 0;
for (int bmpY = 0; bmpY < bitmap.getHeight(); bmpY++) {
if (bitmap.readNextRow(outputRow, rowBytes) != BmpReaderError::Ok) break;
const int logicalY = bitmap.isTopDown() ? bmpY : bitmap.getHeight() - 1 - bmpY;
if (logicalY < cropPixY || logicalY >= bitmap.getHeight() - cropPixY) continue;
for (int bmpX = cropPixX; bmpX < cropPixX + sampleCols; bmpX++) {
const uint8_t val = (outputRow[bmpX / 4] >> (6 - ((bmpX * 2) % 8))) & 0x3;
sumLeft += val2bitToGray(val);
countLeft++;
}
for (int bmpX = bitmap.getWidth() - cropPixX - sampleCols; bmpX < bitmap.getWidth() - cropPixX; bmpX++) {
const uint8_t val = (outputRow[bmpX / 4] >> (6 - ((bmpX * 2) % 8))) & 0x3;
sumRight += val2bitToGray(val);
countRight++;
}
}
data.avgA = countLeft > 0 ? static_cast<uint8_t>(sumLeft / countLeft) : 128;
data.avgB = countRight > 0 ? static_cast<uint8_t>(sumRight / countRight) : 128;
data.valid = true;
}
bitmap.rewindToData();
free(outputRow);
free(rowBytes);
return data;
}
void drawLetterboxFill(GfxRenderer& renderer, const LetterboxFillData& data, uint8_t fillMode) {
if (!data.valid) return;
const bool isSolid = (fillMode == CrossPointSettings::SLEEP_SCREEN_LETTERBOX_FILL::LETTERBOX_SOLID);
const uint8_t levelA = isSolid ? snapToEinkLevel(data.avgA) / 85 : 0;
const uint8_t levelB = isSolid ? snapToEinkLevel(data.avgB) / 85 : 0;
if (data.horizontal) {
if (data.letterboxA > 0) {
for (int y = 0; y < data.letterboxA; y++)
for (int x = 0; x < renderer.getScreenWidth(); x++) {
const uint8_t lv = isSolid ? levelA : quantizeBayerDither(data.avgA, x, y);
renderer.drawPixelGray(x, y, lv);
}
}
if (data.letterboxB > 0) {
const int start = renderer.getScreenHeight() - data.letterboxB;
for (int y = start; y < renderer.getScreenHeight(); y++)
for (int x = 0; x < renderer.getScreenWidth(); x++) {
const uint8_t lv = isSolid ? levelB : quantizeBayerDither(data.avgB, x, y);
renderer.drawPixelGray(x, y, lv);
}
}
} else {
if (data.letterboxA > 0) {
for (int x = 0; x < data.letterboxA; x++)
for (int y = 0; y < renderer.getScreenHeight(); y++) {
const uint8_t lv = isSolid ? levelA : quantizeBayerDither(data.avgA, x, y);
renderer.drawPixelGray(x, y, lv);
}
}
if (data.letterboxB > 0) {
const int start = renderer.getScreenWidth() - data.letterboxB;
for (int x = start; x < renderer.getScreenWidth(); x++)
for (int y = 0; y < renderer.getScreenHeight(); y++) {
const uint8_t lv = isSolid ? levelB : quantizeBayerDither(data.avgB, x, y);
renderer.drawPixelGray(x, y, lv);
}
}
}
}
} // namespace
void SleepActivity::onEnter() {
Activity::onEnter();
@@ -137,7 +374,8 @@ void SleepActivity::renderDefaultSleepScreen() const {
renderer.displayBuffer(HalDisplay::HALF_REFRESH);
}
void SleepActivity::renderBitmapSleepScreen(const Bitmap& bitmap) const {
void SleepActivity::renderBitmapSleepScreen(const Bitmap& bitmap, const std::string& edgeCachePath,
uint8_t fillModeOverride) const {
int x, y;
const auto pageWidth = renderer.getScreenWidth();
const auto pageHeight = renderer.getScreenHeight();
@@ -145,62 +383,99 @@ void SleepActivity::renderBitmapSleepScreen(const Bitmap& bitmap) const {
LOG_DBG("SLP", "bitmap %d x %d, screen %d x %d", bitmap.getWidth(), bitmap.getHeight(), pageWidth, pageHeight);
if (bitmap.getWidth() > pageWidth || bitmap.getHeight() > pageHeight) {
// image will scale, make sure placement is right
float ratio = static_cast<float>(bitmap.getWidth()) / static_cast<float>(bitmap.getHeight());
const float screenRatio = static_cast<float>(pageWidth) / static_cast<float>(pageHeight);
LOG_DBG("SLP", "bitmap ratio: %f, screen ratio: %f", ratio, screenRatio);
if (ratio > screenRatio) {
// image wider than viewport ratio, scaled down image needs to be centered vertically
if (SETTINGS.sleepScreenCoverMode == CrossPointSettings::SLEEP_SCREEN_COVER_MODE::CROP) {
cropX = 1.0f - (screenRatio / ratio);
LOG_DBG("SLP", "Cropping bitmap x: %f", cropX);
ratio = (1.0f - cropX) * static_cast<float>(bitmap.getWidth()) / static_cast<float>(bitmap.getHeight());
}
x = 0;
y = std::round((static_cast<float>(pageHeight) - static_cast<float>(pageWidth) / ratio) / 2);
LOG_DBG("SLP", "Centering with ratio %f to y=%d", ratio, y);
} else {
// image taller than viewport ratio, scaled down image needs to be centered horizontally
if (SETTINGS.sleepScreenCoverMode == CrossPointSettings::SLEEP_SCREEN_COVER_MODE::CROP) {
cropY = 1.0f - (ratio / screenRatio);
LOG_DBG("SLP", "Cropping bitmap y: %f", cropY);
ratio = static_cast<float>(bitmap.getWidth()) / ((1.0f - cropY) * static_cast<float>(bitmap.getHeight()));
}
x = std::round((static_cast<float>(pageWidth) - static_cast<float>(pageHeight) * ratio) / 2);
y = 0;
LOG_DBG("SLP", "Centering with ratio %f to x=%d", ratio, x);
}
} else {
// center the image
x = (pageWidth - bitmap.getWidth()) / 2;
y = (pageHeight - bitmap.getHeight()) / 2;
}
LOG_DBG("SLP", "drawing to %d x %d", x, y);
const float effectiveWidth = (1.0f - cropX) * bitmap.getWidth();
const float effectiveHeight = (1.0f - cropY) * bitmap.getHeight();
const float scale =
std::min(static_cast<float>(pageWidth) / effectiveWidth, static_cast<float>(pageHeight) / effectiveHeight);
const uint8_t fillMode = (fillModeOverride != BookSettings::USE_GLOBAL &&
fillModeOverride < CrossPointSettings::SLEEP_SCREEN_LETTERBOX_FILL_COUNT)
? fillModeOverride
: SETTINGS.sleepScreenLetterboxFill;
const bool wantFill = (fillMode != CrossPointSettings::SLEEP_SCREEN_LETTERBOX_FILL::LETTERBOX_NONE);
LetterboxFillData fillData;
const bool hasLetterbox = (x > 0 || y > 0);
if (hasLetterbox && wantFill) {
bool cacheLoaded = false;
if (!edgeCachePath.empty()) {
cacheLoaded = loadEdgeCache(edgeCachePath, pageWidth, pageHeight, fillData);
}
if (!cacheLoaded) {
fillData = computeEdgeAverages(bitmap, x, y, pageWidth, pageHeight, scale, cropX, cropY);
if (fillData.valid && !edgeCachePath.empty()) {
saveEdgeCache(edgeCachePath, pageWidth, pageHeight, fillData);
}
}
}
renderer.clearScreen();
const bool hasGreyscale = bitmap.hasGreyscale() &&
SETTINGS.sleepScreenCoverFilter == CrossPointSettings::SLEEP_SCREEN_COVER_FILTER::NO_FILTER;
const bool isInverted =
SETTINGS.sleepScreenCoverFilter == CrossPointSettings::SLEEP_SCREEN_COVER_FILTER::INVERTED_BLACK_AND_WHITE;
renderer.drawBitmap(bitmap, x, y, pageWidth, pageHeight, cropX, cropY);
#if USE_SLEEP_DOUBLE_FAST_REFRESH
const bool useDoubleFast =
fillData.valid && fillMode == CrossPointSettings::SLEEP_SCREEN_LETTERBOX_FILL::LETTERBOX_DITHERED;
#else
const bool useDoubleFast = false;
#endif
if (SETTINGS.sleepScreenCoverFilter == CrossPointSettings::SLEEP_SCREEN_COVER_FILTER::INVERTED_BLACK_AND_WHITE) {
renderer.invertScreen();
if (useDoubleFast) {
renderer.clearScreen();
renderer.displayBuffer(HalDisplay::FAST_REFRESH);
if (fillData.valid) drawLetterboxFill(renderer, fillData, fillMode);
renderer.drawBitmap(bitmap, x, y, pageWidth, pageHeight, cropX, cropY);
if (isInverted) renderer.invertScreen();
renderer.displayBuffer(HalDisplay::FAST_REFRESH);
} else {
if (fillData.valid) drawLetterboxFill(renderer, fillData, fillMode);
renderer.drawBitmap(bitmap, x, y, pageWidth, pageHeight, cropX, cropY);
if (isInverted) renderer.invertScreen();
renderer.displayBuffer(HalDisplay::HALF_REFRESH);
}
renderer.displayBuffer(HalDisplay::HALF_REFRESH);
if (hasGreyscale) {
bitmap.rewindToData();
renderer.clearScreen(0x00);
renderer.setRenderMode(GfxRenderer::GRAYSCALE_LSB);
if (fillData.valid) drawLetterboxFill(renderer, fillData, fillMode);
renderer.drawBitmap(bitmap, x, y, pageWidth, pageHeight, cropX, cropY);
renderer.copyGrayscaleLsbBuffers();
bitmap.rewindToData();
renderer.clearScreen(0x00);
renderer.setRenderMode(GfxRenderer::GRAYSCALE_MSB);
if (fillData.valid) drawLetterboxFill(renderer, fillData, fillMode);
renderer.drawBitmap(bitmap, x, y, pageWidth, pageHeight, cropX, cropY);
renderer.copyGrayscaleMsbBuffers();
@@ -225,11 +500,10 @@ void SleepActivity::renderCoverSleepScreen() const {
}
std::string coverBmpPath;
std::string bookCachePath;
bool cropped = SETTINGS.sleepScreenCoverMode == CrossPointSettings::SLEEP_SCREEN_COVER_MODE::CROP;
// Check if the current book is XTC, TXT, or EPUB
if (FsHelpers::hasXtcExtension(APP_STATE.openEpubPath)) {
// Handle XTC file
Xtc lastXtc(APP_STATE.openEpubPath, "/.crosspoint");
if (!lastXtc.load()) {
LOG_ERR("SLP", "Failed to load last XTC");
@@ -237,13 +511,18 @@ void SleepActivity::renderCoverSleepScreen() const {
}
if (!lastXtc.generateCoverBmp()) {
LOG_DBG("SLP", "XTC cover generation failed, trying placeholder");
PlaceholderCoverGenerator::generate(lastXtc.getCoverBmpPath(), lastXtc.getTitle(), lastXtc.getAuthor(), 480, 800);
}
if (!Storage.exists(lastXtc.getCoverBmpPath().c_str())) {
LOG_ERR("SLP", "Failed to generate XTC cover bmp");
return (this->*renderNoCoverSleepScreen)();
}
coverBmpPath = lastXtc.getCoverBmpPath();
bookCachePath = lastXtc.getCachePath();
} else if (FsHelpers::hasTxtExtension(APP_STATE.openEpubPath)) {
// Handle TXT file - looks for cover image in the same folder
Txt lastTxt(APP_STATE.openEpubPath, "/.crosspoint");
if (!lastTxt.load()) {
LOG_ERR("SLP", "Failed to load last TXT");
@@ -251,36 +530,58 @@ void SleepActivity::renderCoverSleepScreen() const {
}
if (!lastTxt.generateCoverBmp()) {
LOG_DBG("SLP", "TXT cover generation failed, trying placeholder");
PlaceholderCoverGenerator::generate(lastTxt.getCoverBmpPath(), lastTxt.getTitle(), "", 480, 800);
}
if (!Storage.exists(lastTxt.getCoverBmpPath().c_str())) {
LOG_ERR("SLP", "No cover image found for TXT file");
return (this->*renderNoCoverSleepScreen)();
}
coverBmpPath = lastTxt.getCoverBmpPath();
bookCachePath = lastTxt.getCachePath();
} else if (FsHelpers::hasEpubExtension(APP_STATE.openEpubPath)) {
// Handle EPUB file
Epub lastEpub(APP_STATE.openEpubPath, "/.crosspoint");
// Skip loading css since we only need metadata here
if (!lastEpub.load(true, true)) {
LOG_ERR("SLP", "Failed to load last epub");
return (this->*renderNoCoverSleepScreen)();
}
if (!lastEpub.generateCoverBmp(cropped)) {
LOG_DBG("SLP", "EPUB cover generation failed, trying placeholder");
PlaceholderCoverGenerator::generate(lastEpub.getCoverBmpPath(cropped), lastEpub.getTitle(),
lastEpub.getAuthor(), 480, 800);
}
if (!Storage.exists(lastEpub.getCoverBmpPath(cropped).c_str())) {
LOG_ERR("SLP", "Failed to generate cover bmp");
return (this->*renderNoCoverSleepScreen)();
}
coverBmpPath = lastEpub.getCoverBmpPath(cropped);
bookCachePath = lastEpub.getCachePath();
} else {
return (this->*renderNoCoverSleepScreen)();
}
uint8_t fillModeOverride = BookSettings::USE_GLOBAL;
if (!bookCachePath.empty()) {
auto bookSettings = BookSettings::load(bookCachePath);
fillModeOverride = bookSettings.letterboxFillOverride;
}
FsFile file;
if (Storage.openFileForRead("SLP", coverBmpPath, file)) {
Bitmap bitmap(file);
if (bitmap.parseHeaders() == BmpReaderError::Ok) {
LOG_DBG("SLP", "Rendering sleep cover: %s", coverBmpPath.c_str());
renderBitmapSleepScreen(bitmap);
std::string edgeCachePath;
const auto dotPos = coverBmpPath.rfind(".bmp");
if (dotPos != std::string::npos) {
edgeCachePath = coverBmpPath.substr(0, dotPos) + "_edges.bin";
}
renderBitmapSleepScreen(bitmap, edgeCachePath, fillModeOverride);
file.close();
return;
}

View File

@@ -1,4 +1,7 @@
#pragma once
#include <string>
#include "../Activity.h"
class Bitmap;
@@ -13,6 +16,7 @@ class SleepActivity final : public Activity {
void renderDefaultSleepScreen() const;
void renderCustomSleepScreen() const;
void renderCoverSleepScreen() const;
void renderBitmapSleepScreen(const Bitmap& bitmap) const;
void renderBitmapSleepScreen(const Bitmap& bitmap, const std::string& edgeCachePath = "",
uint8_t fillModeOverride = 0xFF) const;
void renderBlankSleepScreen() const;
};

View File

@@ -6,10 +6,13 @@
#include <algorithm>
#include "BookManageMenuActivity.h"
#include "MappedInputManager.h"
#include "RecentBooksStore.h"
#include "activities/ActivityResult.h"
#include "components/UITheme.h"
#include "fontIds.h"
#include "util/BookManager.h"
namespace {
constexpr unsigned long GO_HOME_MS = 1000;
@@ -44,11 +47,64 @@ void RecentBooksActivity::onExit() {
recentBooks.clear();
}
void RecentBooksActivity::openManageMenu(const std::string& bookPath) {
const bool isArchived = BookManager::isArchived(bookPath);
const std::string capturedPath = bookPath;
startActivityForResult(
std::make_unique<BookManageMenuActivity>(renderer, mappedInput, capturedPath, isArchived, false),
[this, capturedPath](const ActivityResult& result) {
if (result.isCancelled) {
requestUpdate();
return;
}
const auto& menuResult = std::get<MenuResult>(result.data);
auto action = static_cast<BookManageMenuActivity::Action>(menuResult.action);
bool success = false;
switch (action) {
case BookManageMenuActivity::Action::ARCHIVE:
success = BookManager::archiveBook(capturedPath);
break;
case BookManageMenuActivity::Action::UNARCHIVE:
success = BookManager::unarchiveBook(capturedPath);
break;
case BookManageMenuActivity::Action::DELETE:
success = BookManager::deleteBook(capturedPath);
break;
case BookManageMenuActivity::Action::DELETE_CACHE:
success = BookManager::deleteBookCache(capturedPath);
break;
case BookManageMenuActivity::Action::REINDEX:
success = BookManager::reindexBook(capturedPath, false);
break;
case BookManageMenuActivity::Action::REINDEX_FULL:
success = BookManager::reindexBook(capturedPath, true);
break;
}
{
RenderLock lock(*this);
GUI.drawPopup(renderer, success ? tr(STR_DONE) : tr(STR_ACTION_FAILED));
}
loadRecentBooks();
selectorIndex = 0;
requestUpdate();
});
}
void RecentBooksActivity::loop() {
const int pageItems = UITheme::getInstance().getNumberOfItemsPerPage(renderer, true, false, true, true);
if (mappedInput.wasReleased(MappedInputManager::Button::Confirm)) {
if (mappedInput.isPressed(MappedInputManager::Button::Confirm) && mappedInput.getHeldTime() >= LONG_PRESS_MS) {
if (!recentBooks.empty() && selectorIndex < static_cast<int>(recentBooks.size())) {
ignoreNextConfirmRelease = true;
openManageMenu(recentBooks[selectorIndex].path);
return;
}
}
if (mappedInput.wasReleased(MappedInputManager::Button::Confirm)) {
if (ignoreNextConfirmRelease) {
ignoreNextConfirmRelease = false;
} else if (!recentBooks.empty() && selectorIndex < static_cast<int>(recentBooks.size())) {
LOG_DBG("RBA", "Selected recent book: %s", recentBooks[selectorIndex].path.c_str());
onSelectBook(recentBooks[selectorIndex].path);
return;

View File

@@ -18,8 +18,11 @@ class RecentBooksActivity final : public Activity {
// Recent tab state
std::vector<RecentBook> recentBooks;
// Data loading
bool ignoreNextConfirmRelease = false;
static constexpr unsigned long LONG_PRESS_MS = 700;
void loadRecentBooks();
void openManageMenu(const std::string& bookPath);
public:
explicit RecentBooksActivity(GfxRenderer& renderer, MappedInputManager& mappedInput)

View File

@@ -3,14 +3,21 @@
#include <GfxRenderer.h>
#include <Logging.h>
#include <algorithm>
#include <cstdlib>
#include <ctime>
#include "ButtonRemapActivity.h"
#include "CalibreSettingsActivity.h"
#include "ClearCacheActivity.h"
#include "CrossPointSettings.h"
#include "KOReaderSettingsActivity.h"
#include "LanguageSelectActivity.h"
#include "MappedInputManager.h"
#include "NtpSyncActivity.h"
#include "OpdsServerListActivity.h"
#include "OtaUpdateActivity.h"
#include "SetTimeActivity.h"
#include "SetTimezoneOffsetActivity.h"
#include "SettingsList.h"
#include "StatusBarSettingsActivity.h"
#include "activities/network/WifiSelectionActivity.h"
@@ -18,13 +25,15 @@
#include "fontIds.h"
const StrId SettingsActivity::categoryNames[categoryCount] = {StrId::STR_CAT_DISPLAY, StrId::STR_CAT_READER,
StrId::STR_CAT_CONTROLS, StrId::STR_CAT_SYSTEM};
StrId::STR_CAT_CONTROLS, StrId::STR_CAT_SYSTEM,
StrId::STR_CAT_CLOCK};
void SettingsActivity::onEnter() {
Activity::onEnter();
// Build per-category vectors from the shared settings list
displaySettings.clear();
clockSettings.clear();
readerSettings.clear();
controlsSettings.clear();
systemSettings.clear();
@@ -33,6 +42,8 @@ void SettingsActivity::onEnter() {
if (setting.category == StrId::STR_NONE_OPT) continue;
if (setting.category == StrId::STR_CAT_DISPLAY) {
displaySettings.push_back(setting);
} else if (setting.category == StrId::STR_CAT_CLOCK) {
clockSettings.push_back(setting);
} else if (setting.category == StrId::STR_CAT_READER) {
readerSettings.push_back(setting);
} else if (setting.category == StrId::STR_CAT_CONTROLS) {
@@ -40,10 +51,10 @@ void SettingsActivity::onEnter() {
} else if (setting.category == StrId::STR_CAT_SYSTEM) {
systemSettings.push_back(setting);
}
// Web-only categories (KOReader Sync, OPDS Browser) are skipped for device UI
}
// Append device-only ACTION items
rebuildClockActions();
controlsSettings.insert(controlsSettings.begin(),
SettingInfo::Action(StrId::STR_REMAP_FRONT_BUTTONS, SettingAction::RemapFrontButtons));
systemSettings.push_back(SettingInfo::Action(StrId::STR_WIFI_NETWORKS, SettingAction::Network));
@@ -132,6 +143,9 @@ void SettingsActivity::loop() {
case 3:
currentSettings = &systemSettings;
break;
case 4:
currentSettings = &clockSettings;
break;
}
settingsCount = static_cast<int>(currentSettings->size());
}
@@ -173,7 +187,7 @@ void SettingsActivity::toggleCurrentSetting() {
startActivityForResult(std::make_unique<KOReaderSettingsActivity>(renderer, mappedInput), resultHandler);
break;
case SettingAction::OPDSBrowser:
startActivityForResult(std::make_unique<CalibreSettingsActivity>(renderer, mappedInput), resultHandler);
startActivityForResult(std::make_unique<OpdsServerListActivity>(renderer, mappedInput, false), resultHandler);
break;
case SettingAction::Network:
startActivityForResult(std::make_unique<WifiSelectionActivity>(renderer, mappedInput, false), resultHandler);
@@ -187,16 +201,51 @@ void SettingsActivity::toggleCurrentSetting() {
case SettingAction::Language:
startActivityForResult(std::make_unique<LanguageSelectActivity>(renderer, mappedInput), resultHandler);
break;
case SettingAction::SetTime:
startActivityForResult(std::make_unique<SetTimeActivity>(renderer, mappedInput), resultHandler);
break;
case SettingAction::SetTimezoneOffset:
startActivityForResult(std::make_unique<SetTimezoneOffsetActivity>(renderer, mappedInput), resultHandler);
break;
case SettingAction::SyncClock:
startActivityForResult(std::make_unique<NtpSyncActivity>(renderer, mappedInput), resultHandler);
break;
case SettingAction::None:
// Do nothing
break;
}
return; // Results will be handled in the result handler, so we can return early here
return;
} else if (setting.type == SettingType::ENUM && setting.valueGetter && setting.valueSetter) {
const uint8_t currentValue = setting.valueGetter();
setting.valueSetter((currentValue + 1) % static_cast<uint8_t>(setting.enumValues.size()));
} else {
return;
}
SETTINGS.saveToFile();
setenv("TZ", SETTINGS.getTimezonePosixStr(), 1);
tzset();
rebuildClockActions();
}
void SettingsActivity::rebuildClockActions() {
clockSettings.erase(std::remove_if(clockSettings.begin(), clockSettings.end(),
[](const SettingInfo& s) { return s.type == SettingType::ACTION; }),
clockSettings.end());
clockSettings.push_back(SettingInfo::Action(StrId::STR_SYNC_CLOCK, SettingAction::SyncClock));
clockSettings.push_back(SettingInfo::Action(StrId::STR_SET_TIME, SettingAction::SetTime));
if (SETTINGS.timezone == CrossPointSettings::TZ_CUSTOM) {
clockSettings.push_back(SettingInfo::Action(StrId::STR_SET_UTC_OFFSET, SettingAction::SetTimezoneOffset));
}
if (currentSettings == &clockSettings) {
settingsCount = static_cast<int>(clockSettings.size());
if (selectedSettingIndex > settingsCount) {
selectedSettingIndex = settingsCount;
}
}
}
void SettingsActivity::render(RenderLock&&) {
@@ -235,6 +284,11 @@ void SettingsActivity::render(RenderLock&&) {
} else if (setting.type == SettingType::ENUM && setting.valuePtr != nullptr) {
const uint8_t value = SETTINGS.*(setting.valuePtr);
valueText = I18N.get(setting.enumValues[value]);
} else if (setting.type == SettingType::ENUM && setting.valueGetter) {
const uint8_t value = setting.valueGetter();
if (value < setting.enumValues.size()) {
valueText = I18N.get(setting.enumValues[value]);
}
} else if (setting.type == SettingType::VALUE && setting.valuePtr != nullptr) {
valueText = std::to_string(SETTINGS.*(setting.valuePtr));
}

View File

@@ -21,6 +21,9 @@ enum class SettingAction {
ClearCache,
CheckForUpdates,
Language,
SetTime,
SetTimezoneOffset,
SyncClock,
};
struct SettingInfo {
@@ -148,16 +151,18 @@ class SettingsActivity final : public Activity {
// Per-category settings derived from shared list + device-only actions
std::vector<SettingInfo> displaySettings;
std::vector<SettingInfo> clockSettings;
std::vector<SettingInfo> readerSettings;
std::vector<SettingInfo> controlsSettings;
std::vector<SettingInfo> systemSettings;
const std::vector<SettingInfo>* currentSettings = nullptr;
static constexpr int categoryCount = 4;
static constexpr int categoryCount = 5;
static const StrId categoryNames[categoryCount];
void enterCategory(int categoryIndex);
void toggleCurrentSetting();
void rebuildClockActions();
public:
explicit SettingsActivity(GfxRenderer& renderer, MappedInputManager& mappedInput)

View File

@@ -30,5 +30,10 @@ class UITheme {
std::unique_ptr<BaseTheme> currentTheme;
};
// Known theme thumbnail heights to prerender when opening a book for the first time.
// Correspond to homeCoverHeight values across all themes (Lyra=226, Base=400).
static constexpr int PRERENDER_THUMB_HEIGHTS[] = {226, 400};
static constexpr int PRERENDER_THUMB_HEIGHTS_COUNT = 2;
// Helper macro to access current theme
#define GUI UITheme::getInstance().getTheme()