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

View File

@@ -16,6 +16,8 @@ class SleepActivity final : public Activity {
void renderDefaultSleepScreen() const;
void renderCustomSleepScreen() 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;
};

View File

@@ -242,7 +242,7 @@ void EpubReaderActivity::loop() {
exitActivity();
enterNewActivity(new EpubReaderMenuActivity(
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](EpubReaderMenuActivity::MenuAction action) { onReaderMenuConfirm(action); }));
xSemaphoreGive(renderingMutex);
@@ -776,6 +776,10 @@ void EpubReaderActivity::onReaderMenuConfirm(EpubReaderMenuActivity::MenuAction
}
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;
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
auto actionCallback = onAction;
@@ -139,6 +147,12 @@ void EpubReaderMenuActivity::renderScreen() {
const auto width = renderer.getTextWidth(UI_10_FONT_ID, value);
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

View File

@@ -9,6 +9,7 @@
#include <vector>
#include "../ActivityWithSubactivity.h"
#include "util/BookSettings.h"
#include "util/ButtonNavigator.h"
class EpubReaderMenuActivity final : public ActivityWithSubactivity {
@@ -20,6 +21,7 @@ class EpubReaderMenuActivity final : public ActivityWithSubactivity {
LOOKUP,
LOOKED_UP_WORDS,
ROTATE_SCREEN,
LETTERBOX_FILL,
SELECT_CHAPTER,
GO_TO_BOOKMARK,
GO_TO_PERCENT,
@@ -32,18 +34,23 @@ class EpubReaderMenuActivity final : public ActivityWithSubactivity {
explicit EpubReaderMenuActivity(GfxRenderer& renderer, MappedInputManager& mappedInput, const std::string& title,
const int currentPage, const int totalPages, const int bookProgressPercent,
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(MenuAction)>& onAction)
: ActivityWithSubactivity("EpubReaderMenu", renderer, mappedInput),
menuItems(buildMenuItems(hasDictionary, isBookmarked)),
title(title),
pendingOrientation(currentOrientation),
bookCachePath(bookCachePath),
currentPage(currentPage),
totalPages(totalPages),
bookProgressPercent(bookProgressPercent),
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 onExit() override;
@@ -65,6 +72,11 @@ class EpubReaderMenuActivity final : public ActivityWithSubactivity {
std::string title = "Reader Menu";
uint8_t pendingOrientation = 0;
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 totalPages = 0;
int bookProgressPercent = 0;
@@ -72,6 +84,25 @@ class EpubReaderMenuActivity final : public ActivityWithSubactivity {
const std::function<void(uint8_t)> onBack;
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) {
std::vector<MenuItem> items;
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::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::GO_TO_BOOKMARK, "Go to Bookmark"});
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);
};