feat: Add per-book letterbox fill override

Introduce BookSettings utility for per-book settings stored in
the book's cache directory (book_settings.bin). Add "Letterbox Fill"
option to the EPUB reader menu that cycles Default/Dithered/Solid/None.
At sleep time, the per-book override is loaded and takes precedence
over the global setting for all book types (EPUB, XTC, TXT).

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
cottongin
2026-02-13 16:07:38 -05:00
parent 0c71e0b13f
commit 6aa0b865c2
7 changed files with 167 additions and 8 deletions

View File

@@ -12,6 +12,7 @@
#include "CrossPointSettings.h" #include "CrossPointSettings.h"
#include "CrossPointState.h" #include "CrossPointState.h"
#include "util/BookSettings.h"
#include "components/UITheme.h" #include "components/UITheme.h"
#include "fontIds.h" #include "fontIds.h"
#include "images/Logo120.h" #include "images/Logo120.h"
@@ -450,7 +451,8 @@ void SleepActivity::renderDefaultSleepScreen() const {
renderer.displayBuffer(HalDisplay::HALF_REFRESH); renderer.displayBuffer(HalDisplay::HALF_REFRESH);
} }
void SleepActivity::renderBitmapSleepScreen(const Bitmap& bitmap, const std::string& edgeCachePath) const { void SleepActivity::renderBitmapSleepScreen(const Bitmap& bitmap, const std::string& edgeCachePath,
uint8_t fillModeOverride) const {
int x, y; int x, y;
const auto pageWidth = renderer.getScreenWidth(); const auto pageWidth = renderer.getScreenWidth();
const auto pageHeight = renderer.getScreenHeight(); const auto pageHeight = renderer.getScreenHeight();
@@ -494,8 +496,11 @@ void SleepActivity::renderBitmapSleepScreen(const Bitmap& bitmap, const std::str
const float scale = const float scale =
std::min(static_cast<float>(pageWidth) / effectiveWidth, static_cast<float>(pageHeight) / effectiveHeight); std::min(static_cast<float>(pageWidth) / effectiveWidth, static_cast<float>(pageHeight) / effectiveHeight);
// Determine letterbox fill settings // Determine letterbox fill settings (per-book override takes precedence)
const uint8_t fillMode = SETTINGS.sleepScreenLetterboxFill; 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); const bool wantFill = (fillMode != CrossPointSettings::SLEEP_SCREEN_LETTERBOX_FILL::LETTERBOX_NONE);
static const char* fillModeNames[] = {"dithered", "solid", "none"}; static const char* fillModeNames[] = {"dithered", "solid", "none"};
@@ -582,6 +587,7 @@ void SleepActivity::renderCoverSleepScreen() const {
} }
std::string coverBmpPath; std::string coverBmpPath;
std::string bookCachePath;
bool cropped = SETTINGS.sleepScreenCoverMode == CrossPointSettings::SLEEP_SCREEN_COVER_MODE::CROP; bool cropped = SETTINGS.sleepScreenCoverMode == CrossPointSettings::SLEEP_SCREEN_COVER_MODE::CROP;
// Check if the current book is XTC, TXT, or EPUB // Check if the current book is XTC, TXT, or EPUB
@@ -600,6 +606,7 @@ void SleepActivity::renderCoverSleepScreen() const {
} }
coverBmpPath = lastXtc.getCoverBmpPath(); coverBmpPath = lastXtc.getCoverBmpPath();
bookCachePath = lastXtc.getCachePath();
} else if (StringUtils::checkFileExtension(APP_STATE.openEpubPath, ".txt")) { } else if (StringUtils::checkFileExtension(APP_STATE.openEpubPath, ".txt")) {
// Handle TXT file - looks for cover image in the same folder // Handle TXT file - looks for cover image in the same folder
Txt lastTxt(APP_STATE.openEpubPath, "/.crosspoint"); Txt lastTxt(APP_STATE.openEpubPath, "/.crosspoint");
@@ -614,6 +621,7 @@ void SleepActivity::renderCoverSleepScreen() const {
} }
coverBmpPath = lastTxt.getCoverBmpPath(); coverBmpPath = lastTxt.getCoverBmpPath();
bookCachePath = lastTxt.getCachePath();
} else if (StringUtils::checkFileExtension(APP_STATE.openEpubPath, ".epub")) { } else if (StringUtils::checkFileExtension(APP_STATE.openEpubPath, ".epub")) {
// Handle EPUB file // Handle EPUB file
Epub lastEpub(APP_STATE.openEpubPath, "/.crosspoint"); Epub lastEpub(APP_STATE.openEpubPath, "/.crosspoint");
@@ -629,10 +637,18 @@ void SleepActivity::renderCoverSleepScreen() const {
} }
coverBmpPath = lastEpub.getCoverBmpPath(cropped); coverBmpPath = lastEpub.getCoverBmpPath(cropped);
bookCachePath = lastEpub.getCachePath();
} else { } else {
return (this->*renderNoCoverSleepScreen)(); return (this->*renderNoCoverSleepScreen)();
} }
// Load per-book letterbox fill override (falls back to global if not set)
uint8_t fillModeOverride = BookSettings::USE_GLOBAL;
if (!bookCachePath.empty()) {
auto bookSettings = BookSettings::load(bookCachePath);
fillModeOverride = bookSettings.letterboxFillOverride;
}
FsFile file; FsFile file;
if (Storage.openFileForRead("SLP", coverBmpPath, file)) { if (Storage.openFileForRead("SLP", coverBmpPath, file)) {
Bitmap bitmap(file); Bitmap bitmap(file);
@@ -644,7 +660,7 @@ void SleepActivity::renderCoverSleepScreen() const {
if (dotPos != std::string::npos) { if (dotPos != std::string::npos) {
edgeCachePath = coverBmpPath.substr(0, dotPos) + "_edges.bin"; edgeCachePath = coverBmpPath.substr(0, dotPos) + "_edges.bin";
} }
renderBitmapSleepScreen(bitmap, edgeCachePath); renderBitmapSleepScreen(bitmap, edgeCachePath, fillModeOverride);
return; return;
} }
} }

View File

@@ -16,6 +16,8 @@ class SleepActivity final : public Activity {
void renderDefaultSleepScreen() const; void renderDefaultSleepScreen() const;
void renderCustomSleepScreen() const; void renderCustomSleepScreen() const;
void renderCoverSleepScreen() const; void renderCoverSleepScreen() const;
void renderBitmapSleepScreen(const Bitmap& bitmap, const std::string& edgeCachePath = "") const; // fillModeOverride: 0xFF = use global setting, otherwise a SLEEP_SCREEN_LETTERBOX_FILL value.
void renderBitmapSleepScreen(const Bitmap& bitmap, const std::string& edgeCachePath = "",
uint8_t fillModeOverride = 0xFF) const;
void renderBlankSleepScreen() const; void renderBlankSleepScreen() const;
}; };

View File

@@ -242,7 +242,7 @@ void EpubReaderActivity::loop() {
exitActivity(); exitActivity();
enterNewActivity(new EpubReaderMenuActivity( enterNewActivity(new EpubReaderMenuActivity(
this->renderer, this->mappedInput, epub->getTitle(), currentPage, totalPages, bookProgressPercent, this->renderer, this->mappedInput, epub->getTitle(), currentPage, totalPages, bookProgressPercent,
SETTINGS.orientation, hasDictionary, isBookmarked, SETTINGS.orientation, hasDictionary, isBookmarked, epub->getCachePath(),
[this](const uint8_t orientation) { onReaderMenuBack(orientation); }, [this](const uint8_t orientation) { onReaderMenuBack(orientation); },
[this](EpubReaderMenuActivity::MenuAction action) { onReaderMenuConfirm(action); })); [this](EpubReaderMenuActivity::MenuAction action) { onReaderMenuConfirm(action); }));
xSemaphoreGive(renderingMutex); xSemaphoreGive(renderingMutex);
@@ -776,6 +776,10 @@ void EpubReaderActivity::onReaderMenuConfirm(EpubReaderMenuActivity::MenuAction
} }
break; break;
} }
// Handled locally in the menu activity (cycle on Confirm, never dispatched here)
case EpubReaderMenuActivity::MenuAction::ROTATE_SCREEN:
case EpubReaderMenuActivity::MenuAction::LETTERBOX_FILL:
break;
} }
} }

View File

@@ -68,6 +68,14 @@ void EpubReaderMenuActivity::loop() {
updateRequired = true; updateRequired = true;
return; return;
} }
if (selectedAction == MenuAction::LETTERBOX_FILL) {
// Cycle through: Default -> Dithered -> Solid -> None -> Default ...
int idx = (letterboxFillToIndex() + 1) % LETTERBOX_FILL_OPTION_COUNT;
pendingLetterboxFill = indexToLetterboxFill(idx);
saveLetterboxFill();
updateRequired = true;
return;
}
// 1. Capture the callback and action locally // 1. Capture the callback and action locally
auto actionCallback = onAction; auto actionCallback = onAction;
@@ -139,6 +147,12 @@ void EpubReaderMenuActivity::renderScreen() {
const auto width = renderer.getTextWidth(UI_10_FONT_ID, value); const auto width = renderer.getTextWidth(UI_10_FONT_ID, value);
renderer.drawText(UI_10_FONT_ID, contentX + contentWidth - 20 - width, displayY, value, !isSelected); renderer.drawText(UI_10_FONT_ID, contentX + contentWidth - 20 - width, displayY, value, !isSelected);
} }
if (menuItems[i].action == MenuAction::LETTERBOX_FILL) {
// Render current letterbox fill value on the right edge of the content area.
const auto value = letterboxFillLabels[letterboxFillToIndex()];
const auto width = renderer.getTextWidth(UI_10_FONT_ID, value);
renderer.drawText(UI_10_FONT_ID, contentX + contentWidth - 20 - width, displayY, value, !isSelected);
}
} }
// Footer / Hints // Footer / Hints

View File

@@ -9,6 +9,7 @@
#include <vector> #include <vector>
#include "../ActivityWithSubactivity.h" #include "../ActivityWithSubactivity.h"
#include "util/BookSettings.h"
#include "util/ButtonNavigator.h" #include "util/ButtonNavigator.h"
class EpubReaderMenuActivity final : public ActivityWithSubactivity { class EpubReaderMenuActivity final : public ActivityWithSubactivity {
@@ -20,6 +21,7 @@ class EpubReaderMenuActivity final : public ActivityWithSubactivity {
LOOKUP, LOOKUP,
LOOKED_UP_WORDS, LOOKED_UP_WORDS,
ROTATE_SCREEN, ROTATE_SCREEN,
LETTERBOX_FILL,
SELECT_CHAPTER, SELECT_CHAPTER,
GO_TO_BOOKMARK, GO_TO_BOOKMARK,
GO_TO_PERCENT, GO_TO_PERCENT,
@@ -32,18 +34,23 @@ class EpubReaderMenuActivity final : public ActivityWithSubactivity {
explicit EpubReaderMenuActivity(GfxRenderer& renderer, MappedInputManager& mappedInput, const std::string& title, explicit EpubReaderMenuActivity(GfxRenderer& renderer, MappedInputManager& mappedInput, const std::string& title,
const int currentPage, const int totalPages, const int bookProgressPercent, const int currentPage, const int totalPages, const int bookProgressPercent,
const uint8_t currentOrientation, const bool hasDictionary, const uint8_t currentOrientation, const bool hasDictionary,
const bool isBookmarked, const bool isBookmarked, const std::string& bookCachePath,
const std::function<void(uint8_t)>& onBack, const std::function<void(uint8_t)>& onBack,
const std::function<void(MenuAction)>& onAction) const std::function<void(MenuAction)>& onAction)
: ActivityWithSubactivity("EpubReaderMenu", renderer, mappedInput), : ActivityWithSubactivity("EpubReaderMenu", renderer, mappedInput),
menuItems(buildMenuItems(hasDictionary, isBookmarked)), menuItems(buildMenuItems(hasDictionary, isBookmarked)),
title(title), title(title),
pendingOrientation(currentOrientation), pendingOrientation(currentOrientation),
bookCachePath(bookCachePath),
currentPage(currentPage), currentPage(currentPage),
totalPages(totalPages), totalPages(totalPages),
bookProgressPercent(bookProgressPercent), bookProgressPercent(bookProgressPercent),
onBack(onBack), onBack(onBack),
onAction(onAction) {} onAction(onAction) {
// Load per-book settings to initialize the letterbox fill override
auto bookSettings = BookSettings::load(bookCachePath);
pendingLetterboxFill = bookSettings.letterboxFillOverride;
}
void onEnter() override; void onEnter() override;
void onExit() override; void onExit() override;
@@ -65,6 +72,11 @@ class EpubReaderMenuActivity final : public ActivityWithSubactivity {
std::string title = "Reader Menu"; std::string title = "Reader Menu";
uint8_t pendingOrientation = 0; uint8_t pendingOrientation = 0;
const std::vector<const char*> orientationLabels = {"Portrait", "Landscape CW", "Inverted", "Landscape CCW"}; const std::vector<const char*> orientationLabels = {"Portrait", "Landscape CW", "Inverted", "Landscape CCW"};
std::string bookCachePath;
// Letterbox fill override: 0xFF = Default (use global), 0 = Dithered, 1 = Solid, 2 = None
uint8_t pendingLetterboxFill = BookSettings::USE_GLOBAL;
static constexpr int LETTERBOX_FILL_OPTION_COUNT = 4; // Default + 3 modes
const std::vector<const char*> letterboxFillLabels = {"Default", "Dithered", "Solid", "None"};
int currentPage = 0; int currentPage = 0;
int totalPages = 0; int totalPages = 0;
int bookProgressPercent = 0; int bookProgressPercent = 0;
@@ -72,6 +84,25 @@ class EpubReaderMenuActivity final : public ActivityWithSubactivity {
const std::function<void(uint8_t)> onBack; const std::function<void(uint8_t)> onBack;
const std::function<void(MenuAction)> onAction; const std::function<void(MenuAction)> onAction;
// Map the internal override value to an index into letterboxFillLabels.
int letterboxFillToIndex() const {
if (pendingLetterboxFill == BookSettings::USE_GLOBAL) return 0; // "Default"
return pendingLetterboxFill + 1; // 0->1 (Dithered), 1->2 (Solid), 2->3 (None)
}
// Map an index from letterboxFillLabels back to an override value.
static uint8_t indexToLetterboxFill(int index) {
if (index == 0) return BookSettings::USE_GLOBAL;
return static_cast<uint8_t>(index - 1);
}
// Save the current letterbox fill override to the book's settings file.
void saveLetterboxFill() const {
auto bookSettings = BookSettings::load(bookCachePath);
bookSettings.letterboxFillOverride = pendingLetterboxFill;
BookSettings::save(bookCachePath, bookSettings);
}
static std::vector<MenuItem> buildMenuItems(bool hasDictionary, bool isBookmarked) { static std::vector<MenuItem> buildMenuItems(bool hasDictionary, bool isBookmarked) {
std::vector<MenuItem> items; std::vector<MenuItem> items;
if (isBookmarked) { if (isBookmarked) {
@@ -84,6 +115,7 @@ class EpubReaderMenuActivity final : public ActivityWithSubactivity {
items.push_back({MenuAction::LOOKED_UP_WORDS, "Lookup Word History"}); items.push_back({MenuAction::LOOKED_UP_WORDS, "Lookup Word History"});
} }
items.push_back({MenuAction::ROTATE_SCREEN, "Reading Orientation"}); items.push_back({MenuAction::ROTATE_SCREEN, "Reading Orientation"});
items.push_back({MenuAction::LETTERBOX_FILL, "Letterbox Fill"});
items.push_back({MenuAction::SELECT_CHAPTER, "Table of Contents"}); items.push_back({MenuAction::SELECT_CHAPTER, "Table of Contents"});
items.push_back({MenuAction::GO_TO_BOOKMARK, "Go to Bookmark"}); items.push_back({MenuAction::GO_TO_BOOKMARK, "Go to Bookmark"});
items.push_back({MenuAction::GO_TO_PERCENT, "Go to %"}); items.push_back({MenuAction::GO_TO_PERCENT, "Go to %"});

60
src/util/BookSettings.cpp Normal file
View File

@@ -0,0 +1,60 @@
#include "BookSettings.h"
#include <HalStorage.h>
#include <HardwareSerial.h>
#include <Serialization.h>
namespace {
constexpr uint8_t BOOK_SETTINGS_VERSION = 1;
constexpr uint8_t BOOK_SETTINGS_COUNT = 1; // Number of persisted fields
} // namespace
std::string BookSettings::filePath(const std::string& cachePath) { return cachePath + "/book_settings.bin"; }
BookSettings BookSettings::load(const std::string& cachePath) {
BookSettings settings;
FsFile f;
if (!Storage.openFileForRead("BST", filePath(cachePath), f)) {
return settings;
}
uint8_t version;
serialization::readPod(f, version);
if (version != BOOK_SETTINGS_VERSION) {
f.close();
return settings;
}
uint8_t fieldCount;
serialization::readPod(f, fieldCount);
// Read fields that exist (supports older files with fewer fields)
uint8_t fieldsRead = 0;
do {
serialization::readPod(f, settings.letterboxFillOverride);
if (++fieldsRead >= fieldCount) break;
// New fields added here for forward compatibility
} while (false);
f.close();
Serial.printf("[%lu] [BST] Loaded book settings from %s (letterboxFill=%d)\n", millis(), filePath(cachePath).c_str(),
settings.letterboxFillOverride);
return settings;
}
bool BookSettings::save(const std::string& cachePath, const BookSettings& settings) {
FsFile f;
if (!Storage.openFileForWrite("BST", filePath(cachePath), f)) {
Serial.printf("[%lu] [BST] Could not save book settings!\n", millis());
return false;
}
serialization::writePod(f, BOOK_SETTINGS_VERSION);
serialization::writePod(f, BOOK_SETTINGS_COUNT);
serialization::writePod(f, settings.letterboxFillOverride);
// New fields added here
f.close();
Serial.printf("[%lu] [BST] Saved book settings to %s\n", millis(), filePath(cachePath).c_str());
return true;
}

31
src/util/BookSettings.h Normal file
View File

@@ -0,0 +1,31 @@
#pragma once
#include <cstdint>
#include <string>
#include "CrossPointSettings.h"
// Per-book settings stored in the book's cache directory.
// Fields default to sentinel values (0xFF) meaning "use global setting".
class BookSettings {
public:
// 0xFF = use global default; otherwise one of SLEEP_SCREEN_LETTERBOX_FILL values (0-2).
uint8_t letterboxFillOverride = USE_GLOBAL;
static constexpr uint8_t USE_GLOBAL = 0xFF;
// Returns the effective letterbox fill mode: the per-book override if set,
// otherwise the global setting from CrossPointSettings.
uint8_t getEffectiveLetterboxFill() const {
if (letterboxFillOverride != USE_GLOBAL &&
letterboxFillOverride < CrossPointSettings::SLEEP_SCREEN_LETTERBOX_FILL_COUNT) {
return letterboxFillOverride;
}
return SETTINGS.sleepScreenLetterboxFill;
}
static BookSettings load(const std::string& cachePath);
static bool save(const std::string& cachePath, const BookSettings& settings);
private:
static std::string filePath(const std::string& cachePath);
};