Compare commits

..

2 Commits

Author SHA1 Message Date
CaptainFrito
bf145817eb
Merge 5d8178cf9d into 3ce11f14ce 2026-01-25 07:51:24 +00:00
CaptainFrito
5d8178cf9d feat: UI themes, Lyra 2026-01-25 14:51:06 +07:00
14 changed files with 141 additions and 83 deletions

View File

@ -419,11 +419,11 @@ bool Epub::generateCoverBmp(bool cropped) const {
return false;
}
std::string Epub::getThumbBmpPath() const { return cachePath + "/thumb.bmp"; }
std::string Epub::getThumbBmpPath(int height) const { return cachePath + "/thumb_" + std::to_string(height) + ".bmp"; }
bool Epub::generateThumbBmp() const {
bool Epub::generateThumbBmp(int height) const {
// Already generated, return true
if (SdMan.exists(getThumbBmpPath().c_str())) {
if (SdMan.exists(getThumbBmpPath(height).c_str())) {
return true;
}
@ -455,14 +455,14 @@ bool Epub::generateThumbBmp() const {
}
FsFile thumbBmp;
if (!SdMan.openFileForWrite("EBP", getThumbBmpPath(), thumbBmp)) {
if (!SdMan.openFileForWrite("EBP", getThumbBmpPath(height), thumbBmp)) {
coverJpg.close();
return false;
}
// Use smaller target size for Continue Reading card (half of screen: 240x400)
// Generate 1-bit BMP for fast home screen rendering (no gray passes needed)
constexpr int THUMB_TARGET_WIDTH = 240;
constexpr int THUMB_TARGET_HEIGHT = 400;
int THUMB_TARGET_WIDTH = height * 0.6;
int THUMB_TARGET_HEIGHT = height;
const bool success = JpegToBmpConverter::jpegFileTo1BitBmpStreamWithSize(coverJpg, thumbBmp, THUMB_TARGET_WIDTH,
THUMB_TARGET_HEIGHT);
coverJpg.close();
@ -471,7 +471,7 @@ bool Epub::generateThumbBmp() const {
if (!success) {
Serial.printf("[%lu] [EBP] Failed to generate thumb BMP from JPG cover image\n", millis());
SdMan.remove(getThumbBmpPath().c_str());
SdMan.remove(getThumbBmpPath(height).c_str());
}
Serial.printf("[%lu] [EBP] Generated thumb BMP from JPG cover image, success: %s\n", millis(),
success ? "yes" : "no");

View File

@ -47,8 +47,8 @@ class Epub {
const std::string& getLanguage() const;
std::string getCoverBmpPath(bool cropped = false) const;
bool generateCoverBmp(bool cropped = false) const;
std::string getThumbBmpPath() const;
bool generateThumbBmp() const;
std::string getThumbBmpPath(int height) const;
bool generateThumbBmp(int height) const;
uint8_t* readItemContentsToBytes(const std::string& itemHref, size_t* size = nullptr,
bool trailingNullByte = false) const;
bool readItemContentsToStream(const std::string& itemHref, Print& out, size_t chunkSize) const;

View File

@ -293,11 +293,11 @@ bool Xtc::generateCoverBmp() const {
return true;
}
std::string Xtc::getThumbBmpPath() const { return cachePath + "/thumb.bmp"; }
std::string Xtc::getThumbBmpPath(int height) const { return cachePath + "/thumb_" + std::to_string(height) + ".bmp"; }
bool Xtc::generateThumbBmp() const {
bool Xtc::generateThumbBmp(int height) const {
// Already generated
if (SdMan.exists(getThumbBmpPath().c_str())) {
if (SdMan.exists(getThumbBmpPath(height).c_str())) {
return true;
}
@ -325,8 +325,8 @@ bool Xtc::generateThumbBmp() const {
const uint8_t bitDepth = parser->getBitDepth();
// Calculate target dimensions for thumbnail (fit within 240x400 Continue Reading card)
constexpr int THUMB_TARGET_WIDTH = 240;
constexpr int THUMB_TARGET_HEIGHT = 400;
int THUMB_TARGET_WIDTH = height * 0.6;
int THUMB_TARGET_HEIGHT = height;
// Calculate scale factor
float scaleX = static_cast<float>(THUMB_TARGET_WIDTH) / pageInfo.width;
@ -340,7 +340,7 @@ bool Xtc::generateThumbBmp() const {
if (generateCoverBmp()) {
FsFile src, dst;
if (SdMan.openFileForRead("XTC", getCoverBmpPath(), src)) {
if (SdMan.openFileForWrite("XTC", getThumbBmpPath(), dst)) {
if (SdMan.openFileForWrite("XTC", getThumbBmpPath(height), dst)) {
uint8_t buffer[512];
while (src.available()) {
size_t bytesRead = src.read(buffer, sizeof(buffer));
@ -351,7 +351,7 @@ bool Xtc::generateThumbBmp() const {
src.close();
}
Serial.printf("[%lu] [XTC] Copied cover to thumb (no scaling needed)\n", millis());
return SdMan.exists(getThumbBmpPath().c_str());
return SdMan.exists(getThumbBmpPath(height).c_str());
}
return false;
}
@ -385,7 +385,7 @@ bool Xtc::generateThumbBmp() const {
// Create thumbnail BMP file - use 1-bit format for fast home screen rendering (no gray passes)
FsFile thumbBmp;
if (!SdMan.openFileForWrite("XTC", getThumbBmpPath(), thumbBmp)) {
if (!SdMan.openFileForWrite("XTC", getThumbBmpPath(height), thumbBmp)) {
Serial.printf("[%lu] [XTC] Failed to create thumb BMP file\n", millis());
free(pageBuffer);
return false;
@ -550,7 +550,7 @@ bool Xtc::generateThumbBmp() const {
free(pageBuffer);
Serial.printf("[%lu] [XTC] Generated thumb BMP (%dx%d): %s\n", millis(), thumbWidth, thumbHeight,
getThumbBmpPath().c_str());
getThumbBmpPath(height).c_str());
return true;
}

View File

@ -63,8 +63,8 @@ class Xtc {
std::string getCoverBmpPath() const;
bool generateCoverBmp() const;
// Thumbnail support (for Continue Reading card)
std::string getThumbBmpPath() const;
bool generateThumbBmp() const;
std::string getThumbBmpPath(int height) const;
bool generateThumbBmp(int height) const;
// Page access
uint32_t getPageCount() const;

View File

@ -8,13 +8,14 @@
#include "CrossPointSettings.h"
#include "CrossPointState.h"
#include "components/UITheme.h"
#include "fontIds.h"
#include "images/CrossLarge.h"
#include "util/StringUtils.h"
void SleepActivity::onEnter() {
Activity::onEnter();
renderPopup("Entering Sleep...");
UITheme::drawPopup(renderer, "Entering Sleep...");
if (SETTINGS.sleepScreen == CrossPointSettings::SLEEP_SCREEN_MODE::BLANK) {
return renderBlankSleepScreen();
@ -31,20 +32,6 @@ void SleepActivity::onEnter() {
renderDefaultSleepScreen();
}
void SleepActivity::renderPopup(const char* message) const {
const int textWidth = renderer.getTextWidth(UI_12_FONT_ID, message, EpdFontFamily::BOLD);
constexpr int margin = 20;
const int x = (renderer.getScreenWidth() - textWidth - margin * 2) / 2;
constexpr int y = 117;
const int w = textWidth + margin * 2;
const int h = renderer.getLineHeight(UI_12_FONT_ID) + margin * 2;
// renderer.clearScreen();
renderer.fillRect(x - 5, y - 5, w + 10, h + 10, true);
renderer.fillRect(x + 5, y + 5, w - 10, h - 10, false);
renderer.drawText(UI_12_FONT_ID, x + margin, y + margin, message, true, EpdFontFamily::BOLD);
renderer.displayBuffer();
}
void SleepActivity::renderCustomSleepScreen() const {
// Check if we have a /sleep directory
auto dir = SdMan.open("/sleep");

View File

@ -10,7 +10,6 @@ class SleepActivity final : public Activity {
void onEnter() override;
private:
void renderPopup(const char* message) const;
void renderDefaultSleepScreen() const;
void renderCustomSleepScreen() const;
void renderCoverSleepScreen() const;

View File

@ -34,20 +34,16 @@ int HomeActivity::getMenuItemCount() const {
return count;
}
void HomeActivity::loadRecentBooks(int maxBooks) { //}, PopupCallbacks& popupCallbacks) {
void HomeActivity::loadRecentBooks(int maxBooks, int coverHeight, PopupCallbacks& popupCallbacks) {
recentsLoading = true;
recentBooks.clear();
const auto& books = RECENT_BOOKS.getBooks();
recentBooks.reserve(std::min(static_cast<int>(books.size()), maxBooks));
// if (books.size() > 0) {
// popupCallbacks.setup();
// }
int progress = 0;
bool loadingPopupDisplayed = false;
for (const auto& path : books) {
// popupCallbacks.update(recentBooks.size() * 30); // TODO improve progress calculation
// Limit to maximum number of recent books
if (recentBooks.size() >= maxBooks) {
break;
@ -61,15 +57,16 @@ void HomeActivity::loadRecentBooks(int maxBooks) { //}, PopupCallbacks& popupCa
std::string lastBookTitle = "";
std::string lastBookAuthor = "";
std::string coverBmpPath = "";
std::string lastBookFileName = "";
const size_t lastSlash = path.find_last_of('/');
if (lastSlash != std::string::npos) {
lastBookTitle = path.substr(lastSlash + 1);
lastBookFileName = path.substr(lastSlash + 1);
}
Serial.printf("Loading recent book: %s\n", path.c_str());
// If epub, try to load the metadata for title/author and cover
if (StringUtils::checkFileExtension(lastBookTitle, ".epub")) {
if (StringUtils::checkFileExtension(lastBookFileName, ".epub")) {
Epub epub(path, "/.crosspoint");
epub.load(false);
if (!epub.getTitle().empty()) {
@ -79,11 +76,20 @@ void HomeActivity::loadRecentBooks(int maxBooks) { //}, PopupCallbacks& popupCa
lastBookAuthor = std::string(epub.getAuthor());
}
// Try to generate thumbnail image for Continue Reading card
if (epub.generateThumbBmp()) {
coverBmpPath = epub.getThumbBmpPath();
coverBmpPath = epub.getThumbBmpPath(coverHeight);
if (!SdMan.exists(coverBmpPath.c_str())) {
if (loadingPopupDisplayed) {
popupCallbacks.update(progress * 30);
} else {
popupCallbacks.setup();
loadingPopupDisplayed = true;
}
if (!epub.generateThumbBmp(coverHeight)) {
coverBmpPath = "";
}
}
} else if (StringUtils::checkFileExtension(lastBookTitle, ".xtch") ||
StringUtils::checkFileExtension(lastBookTitle, ".xtc")) {
} else if (StringUtils::checkFileExtension(lastBookFileName, ".xtch") ||
StringUtils::checkFileExtension(lastBookFileName, ".xtc")) {
// Handle XTC file
Xtc xtc(path, "/.crosspoint");
if (xtc.load()) {
@ -91,19 +97,33 @@ void HomeActivity::loadRecentBooks(int maxBooks) { //}, PopupCallbacks& popupCa
lastBookTitle = std::string(xtc.getTitle());
}
// Try to generate thumbnail image for Continue Reading card
if (xtc.generateThumbBmp()) {
coverBmpPath = xtc.getThumbBmpPath();
coverBmpPath = xtc.getThumbBmpPath(coverHeight);
if (!SdMan.exists(coverBmpPath.c_str())) {
if (loadingPopupDisplayed) {
popupCallbacks.update(progress * 30);
} else {
popupCallbacks.setup();
loadingPopupDisplayed = true;
}
if (!xtc.generateThumbBmp(coverHeight)) {
coverBmpPath = "";
}
}
}
// Remove extension from title if we don't have metadata
if (StringUtils::checkFileExtension(lastBookTitle, ".xtch")) {
lastBookTitle.resize(lastBookTitle.length() - 5);
} else if (StringUtils::checkFileExtension(lastBookTitle, ".xtc")) {
lastBookTitle.resize(lastBookTitle.length() - 4);
if (lastBookTitle.empty()) {
// Remove extension from title if we don't have metadata
if (StringUtils::checkFileExtension(lastBookFileName, ".xtch")) {
lastBookFileName.resize(lastBookFileName.length() - 5);
} else if (StringUtils::checkFileExtension(lastBookFileName, ".xtc")) {
lastBookFileName.resize(lastBookFileName.length() - 4);
}
lastBookTitle = lastBookFileName;
}
}
recentBooks.push_back(RecentBookInfo{lastBookTitle, lastBookAuthor, coverBmpPath, path});
progress++;
}
Serial.printf("Recent books loaded: %d\n", recentBooks.size());
@ -245,26 +265,26 @@ void HomeActivity::displayTaskLoop() {
void HomeActivity::render() {
auto metrics = UITheme::getMetrics();
bool bufferRestored = coverBufferStored && restoreCoverBuffer();
if (!bufferRestored || !firstRenderDone) {
renderer.clearScreen();
}
const auto pageWidth = renderer.getScreenWidth();
const auto pageHeight = renderer.getScreenHeight();
bool bufferRestored = coverBufferStored && restoreCoverBuffer();
if (!firstRenderDone || (recentsLoaded && !recentsDisplayed)) {
renderer.clearScreen();
}
UITheme::drawHeader(renderer, Rect{0, metrics.topPadding, pageWidth, metrics.homeTopPadding}, nullptr);
if (hasContinueReading) {
if (recentsLoaded) {
recentsDisplayed = true;
UITheme::drawRecentBookCover(renderer, Rect{0, metrics.homeTopPadding, pageWidth, metrics.homeCoverHeight},
recentBooks, selectorIndex, coverRendered, coverBufferStored, bufferRestored,
std::bind(&HomeActivity::storeCoverBuffer, this));
} else if (!recentsLoading && firstRenderDone) {
recentsLoading = true;
// PopupCallbacks popupCallbacks = UITheme::drawPopupWithProgress(renderer, "Loading...");
loadRecentBooks(metrics.homeRecentBooksCount); //, popupCallbacks);
PopupCallbacks popupCallbacks = UITheme::drawPopupWithProgress(renderer, "Loading...");
loadRecentBooks(metrics.homeRecentBooksCount, metrics.homeCoverHeight, popupCallbacks);
}
}

View File

@ -20,6 +20,7 @@ class HomeActivity final : public Activity {
bool hasContinueReading = false;
bool recentsLoading = false;
bool recentsLoaded = false;
bool recentsDisplayed = false;
bool firstRenderDone = false;
bool hasOpdsUrl = false;
bool coverRendered = false; // Track if cover has been rendered once
@ -36,10 +37,10 @@ class HomeActivity final : public Activity {
[[noreturn]] void displayTaskLoop();
void render();
int getMenuItemCount() const;
bool storeCoverBuffer(); // Store frame buffer for cover image
bool restoreCoverBuffer(); // Restore frame buffer from stored cover
void freeCoverBuffer(); // Free the stored cover buffer
void loadRecentBooks(int maxBooks); //, PopupCallbacks& popupCallbacks);
bool storeCoverBuffer(); // Store frame buffer for cover image
bool restoreCoverBuffer(); // Restore frame buffer from stored cover
void freeCoverBuffer(); // Free the stored cover buffer
void loadRecentBooks(int maxBooks, int coverHeight, PopupCallbacks& popupCallbacks);
public:
explicit HomeActivity(

View File

@ -112,6 +112,12 @@ void UITheme::drawButtonMenu(GfxRenderer& renderer, Rect rect, int buttonCount,
}
}
void UITheme::drawPopup(GfxRenderer& renderer, const char* message) {
if (currentTheme != nullptr) {
currentTheme->drawPopup(renderer, message);
}
}
PopupCallbacks UITheme::drawPopupWithProgress(GfxRenderer& renderer, const std::string& title) {
if (currentTheme != nullptr) {
return currentTheme->drawPopupWithProgress(renderer, title);

View File

@ -86,5 +86,6 @@ class UITheme {
static void drawButtonMenu(GfxRenderer& renderer, Rect rect, int buttonCount, int selectedIndex,
const std::function<std::string(int index)>& buttonLabel, bool hasIcon,
const std::function<std::string(int index)>& rowIcon);
static void drawPopup(GfxRenderer& renderer, const char* message);
static PopupCallbacks drawPopupWithProgress(GfxRenderer& renderer, const std::string& title);
};

View File

@ -106,9 +106,9 @@ void BaseTheme::drawButtonHints(const GfxRenderer& renderer, const char* btn1, c
void BaseTheme::drawSideButtonHints(const GfxRenderer& renderer, const char* topBtn, const char* bottomBtn) {
const int screenWidth = renderer.getScreenWidth();
constexpr int buttonWidth = BaseMetrics::values.buttonHintsHeight; // Width on screen (height when rotated)
constexpr int buttonHeight = 80; // Height on screen (width when rotated)
constexpr int buttonX = 5; // Distance from right edge
constexpr int buttonWidth = BaseMetrics::values.sideButtonHintsWidth; // Width on screen (height when rotated)
constexpr int buttonHeight = 80; // Height on screen (width when rotated)
constexpr int buttonX = 4; // Distance from right edge
// Position for the button group - buttons share a border so they're adjacent
constexpr int topButtonY = 345; // Top button position
@ -165,20 +165,32 @@ void BaseTheme::drawList(const GfxRenderer& renderer, Rect rect, int itemCount,
const int totalPages = (itemCount + pageItems - 1) / pageItems;
if (totalPages > 1) {
// Draw scroll bar
const int scrollBarHeight = (rect.height * pageItems) / itemCount;
const int currentPage = selectedIndex / pageItems;
const int scrollBarY = rect.y + ((rect.height - scrollBarHeight) * currentPage) / (totalPages - 1);
const int scrollBarX = rect.x + rect.width - BaseMetrics::values.scrollBarRightOffset;
renderer.drawLine(scrollBarX, rect.y, scrollBarX, rect.y + rect.height, true);
renderer.fillRect(scrollBarX - BaseMetrics::values.scrollBarWidth, scrollBarY, BaseMetrics::values.scrollBarWidth,
scrollBarHeight, true);
constexpr int indicatorWidth = 20;
constexpr int arrowSize = 6;
constexpr int margin = 15; // Offset from right edge
const int centerX = rect.x + rect.width - indicatorWidth / 2 - margin;
const int indicatorTop = rect.y + 60; // Offset to avoid overlapping side button hints
const int indicatorBottom = rect.y + rect.height - 30;
// Draw up arrow at top (^) - narrow point at top, wide base at bottom
for (int i = 0; i < arrowSize; ++i) {
const int lineWidth = 1 + i * 2;
const int startX = centerX - i;
renderer.drawLine(startX, indicatorTop + i, startX + lineWidth - 1, indicatorTop + i);
}
// Draw down arrow at bottom (v) - wide base at top, narrow point at bottom
for (int i = 0; i < arrowSize; ++i) {
const int lineWidth = 1 + (arrowSize - 1 - i) * 2;
const int startX = centerX - (arrowSize - 1 - i);
renderer.drawLine(startX, indicatorBottom - arrowSize + 1 + i, startX + lineWidth - 1,
indicatorBottom - arrowSize + 1 + i);
}
}
// Draw selection
int contentWidth =
rect.width -
(totalPages > 1 ? (BaseMetrics::values.scrollBarWidth + BaseMetrics::values.scrollBarRightOffset) : 1);
int contentWidth = rect.width - BaseMetrics::values.sideButtonHintsWidth - 5;
renderer.fillRect(0, rect.y + selectedIndex % pageItems * rowHeight - 2, contentWidth, rowHeight);
// Draw all items
const auto pageStartIndex = selectedIndex / pageItems * pageItems;
@ -564,6 +576,20 @@ void BaseTheme::drawButtonMenu(GfxRenderer& renderer, Rect rect, int buttonCount
}
}
void BaseTheme::drawPopup(GfxRenderer& renderer, const char* message) {
const int textWidth = renderer.getTextWidth(UI_12_FONT_ID, message, EpdFontFamily::BOLD);
constexpr int margin = 20;
const int x = (renderer.getScreenWidth() - textWidth - margin * 2) / 2;
constexpr int y = 117;
const int w = textWidth + margin * 2;
const int h = renderer.getLineHeight(UI_12_FONT_ID) + margin * 2;
// renderer.clearScreen();
renderer.fillRect(x - 5, y - 5, w + 10, h + 10, true);
renderer.fillRect(x + 5, y + 5, w - 10, h - 10, false);
renderer.drawText(UI_12_FONT_ID, x + margin, y + margin, message, true, EpdFontFamily::BOLD);
renderer.displayBuffer();
}
PopupCallbacks BaseTheme::drawPopupWithProgress(GfxRenderer& renderer, const std::string& title) {
// Progress bar dimensions
constexpr int barWidth = 200;

View File

@ -31,7 +31,7 @@ constexpr ThemeMetrics values = {.batteryWidth = 15,
.homeCoverHeight = 400,
.homeRecentBooksCount = 1,
.buttonHintsHeight = 40,
.sideButtonHintsWidth = 20,
.sideButtonHintsWidth = 30,
.versionTextRightX = 20,
.versionTextY = 738};
}
@ -59,5 +59,6 @@ class BaseTheme {
virtual void drawButtonMenu(GfxRenderer& renderer, Rect rect, int buttonCount, int selectedIndex,
const std::function<std::string(int index)>& buttonLabel, bool hasIcon,
const std::function<std::string(int index)>& rowIcon);
virtual void drawPopup(GfxRenderer& renderer, const char* message);
virtual PopupCallbacks drawPopupWithProgress(GfxRenderer& renderer, const std::string& title);
};

View File

@ -57,6 +57,8 @@ void LyraTheme::drawBattery(const GfxRenderer& renderer, Rect rect, const bool s
}
void LyraTheme::drawHeader(const GfxRenderer& renderer, Rect rect, const char* title) {
renderer.fillRect(rect.x, rect.y, rect.width, rect.height, false);
const bool showBatteryPercentage =
SETTINGS.hideBatteryPercentage != CrossPointSettings::HIDE_BATTERY_PERCENTAGE::HIDE_ALWAYS;
int batteryX = rect.x + rect.width - LyraMetrics::values.contentSidePadding - LyraMetrics::values.batteryWidth;
@ -314,4 +316,18 @@ void LyraTheme::drawButtonMenu(GfxRenderer& renderer, Rect rect, int buttonCount
// Invert text when the tile is selected, to contrast with the filled background
renderer.drawText(UI_12_FONT_ID, textX, textY, label, true);
}
}
void LyraTheme::drawPopup(GfxRenderer& renderer, const char* message) {
const int textWidth = renderer.getTextWidth(UI_12_FONT_ID, message, EpdFontFamily::BOLD);
constexpr int margin = 20;
const int x = (renderer.getScreenWidth() - textWidth - margin * 2) / 2;
constexpr int y = 117;
const int w = textWidth + margin * 2;
const int h = renderer.getLineHeight(UI_12_FONT_ID) + margin * 2;
renderer.fillRect(x - 5, y - 5, w + 10, h + 10, false);
renderer.drawRect(x + 5, y + 5, w - 10, h - 10, true);
renderer.drawText(UI_12_FONT_ID, x + margin, y + margin, message, true, EpdFontFamily::BOLD);
renderer.displayBuffer();
}

View File

@ -49,4 +49,5 @@ class LyraTheme : public BaseTheme {
void drawRecentBookCover(GfxRenderer& renderer, Rect rect, const std::vector<RecentBookInfo>& recentBooks,
const int selectorIndex, bool& coverRendered, bool& coverBufferStored, bool& bufferRestored,
std::function<bool()> storeCoverBuffer) override;
void drawPopup(GfxRenderer& renderer, const char* message) override;
};