From 9443edfe140ec636e27ce8d0ea831776628de3ec Mon Sep 17 00:00:00 2001 From: CaptainFrito Date: Thu, 29 Jan 2026 13:54:24 +0700 Subject: [PATCH 1/2] feat: UI themes, Lyra --- lib/Epub/Epub.cpp | 25 +- lib/Epub/Epub.h | 4 +- lib/GfxRenderer/GfxRenderer.cpp | 292 +++++--- lib/GfxRenderer/GfxRenderer.h | 29 +- lib/Xtc/Xtc.cpp | 18 +- lib/Xtc/Xtc.h | 4 +- src/CrossPointSettings.cpp | 5 +- src/CrossPointSettings.h | 5 + src/RecentBooksStore.h | 5 + src/ScreenComponents.cpp | 146 ---- src/ScreenComponents.h | 42 -- src/activities/boot_sleep/SleepActivity.cpp | 17 +- src/activities/boot_sleep/SleepActivity.h | 1 - .../browser/OpdsBookBrowserActivity.cpp | 12 +- src/activities/home/HomeActivity.cpp | 530 ++++---------- src/activities/home/HomeActivity.h | 28 +- src/activities/home/MyLibraryActivity.cpp | 280 +++----- src/activities/home/MyLibraryActivity.h | 25 +- .../network/CalibreConnectActivity.cpp | 8 +- .../network/CrossPointWebServerActivity.cpp | 3 +- .../network/NetworkModeSelectionActivity.cpp | 3 +- .../network/WifiSelectionActivity.cpp | 11 +- src/activities/reader/EpubReaderActivity.cpp | 56 +- .../EpubReaderChapterSelectionActivity.cpp | 3 +- .../reader/KOReaderSyncActivity.cpp | 11 +- src/activities/reader/TxtReaderActivity.cpp | 12 +- .../XtcReaderChapterSelectionActivity.cpp | 3 +- .../settings/CalibreSettingsActivity.cpp | 3 +- .../settings/CategorySettingsActivity.cpp | 193 ------ .../settings/CategorySettingsActivity.h | 70 -- .../settings/ClearCacheActivity.cpp | 7 +- .../settings/KOReaderAuthActivity.cpp | 5 +- .../settings/KOReaderSettingsActivity.cpp | 3 +- src/activities/settings/OtaUpdateActivity.cpp | 3 +- src/activities/settings/SettingsActivity.cpp | 198 ++++-- src/activities/settings/SettingsActivity.h | 36 +- src/activities/util/KeyboardEntryActivity.cpp | 5 +- src/components/UITheme.cpp | 132 ++++ src/components/UITheme.h | 94 +++ src/components/themes/BaseTheme.cpp | 646 ++++++++++++++++++ src/components/themes/BaseTheme.h | 67 ++ src/components/themes/lyra/LyraTheme.cpp | 334 +++++++++ src/components/themes/lyra/LyraTheme.h | 54 ++ src/main.cpp | 5 +- 44 files changed, 2105 insertions(+), 1328 deletions(-) delete mode 100644 src/ScreenComponents.cpp delete mode 100644 src/ScreenComponents.h delete mode 100644 src/activities/settings/CategorySettingsActivity.cpp delete mode 100644 src/activities/settings/CategorySettingsActivity.h create mode 100644 src/components/UITheme.cpp create mode 100644 src/components/UITheme.h create mode 100644 src/components/themes/BaseTheme.cpp create mode 100644 src/components/themes/BaseTheme.h create mode 100644 src/components/themes/lyra/LyraTheme.cpp create mode 100644 src/components/themes/lyra/LyraTheme.h diff --git a/lib/Epub/Epub.cpp b/lib/Epub/Epub.cpp index 7559e3b3..b3e50c38 100644 --- a/lib/Epub/Epub.cpp +++ b/lib/Epub/Epub.cpp @@ -428,11 +428,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; } @@ -444,11 +444,8 @@ bool Epub::generateThumbBmp() const { const auto coverImageHref = bookMetadataCache->coreMetadata.coverItemHref; if (coverImageHref.empty()) { Serial.printf("[%lu] [EBP] No known cover image for thumbnail\n", millis()); - return false; - } - - if (coverImageHref.substr(coverImageHref.length() - 4) == ".jpg" || - coverImageHref.substr(coverImageHref.length() - 5) == ".jpeg") { + } else if (coverImageHref.substr(coverImageHref.length() - 4) == ".jpg" || + coverImageHref.substr(coverImageHref.length() - 5) == ".jpeg") { Serial.printf("[%lu] [EBP] Generating thumb BMP from JPG cover image\n", millis()); const auto coverJpgTempPath = getCachePath() + "/.cover.jpg"; @@ -464,14 +461,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(); @@ -480,7 +477,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"); @@ -489,6 +486,10 @@ bool Epub::generateThumbBmp() const { Serial.printf("[%lu] [EBP] Cover image is not a JPG, skipping thumbnail\n", millis()); } + // Write an empty bmp file to avoid generation attempts in the future + FsFile thumbBmp; + SdMan.openFileForWrite("EBP", getThumbBmpPath(height), thumbBmp); + thumbBmp.close(); return false; } diff --git a/lib/Epub/Epub.h b/lib/Epub/Epub.h index 7a21efd5..663258fd 100644 --- a/lib/Epub/Epub.h +++ b/lib/Epub/Epub.h @@ -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; diff --git a/lib/GfxRenderer/GfxRenderer.cpp b/lib/GfxRenderer/GfxRenderer.cpp index fa1c61c6..f8a5ab8b 100644 --- a/lib/GfxRenderer/GfxRenderer.cpp +++ b/lib/GfxRenderer/GfxRenderer.cpp @@ -130,6 +130,12 @@ void GfxRenderer::drawLine(int x1, int y1, int x2, int y2, const bool state) con } } +void GfxRenderer::drawLine(int x1, int y1, int x2, int y2, const int lineWidth, const bool state) const { + for (int i = 0; i < lineWidth; i++) { + drawLine(x1, y1 + i, x2, y2 + i, state); + } +} + void GfxRenderer::drawRect(const int x, const int y, const int width, const int height, const bool state) const { drawLine(x, y, x + width - 1, y, state); drawLine(x + width - 1, y, x + width - 1, y + height - 1, state); @@ -137,12 +143,215 @@ void GfxRenderer::drawRect(const int x, const int y, const int width, const int drawLine(x, y, x, y + height - 1, state); } +// Border is inside the rectangle +void GfxRenderer::drawRect(const int x, const int y, const int width, const int height, const int lineWidth, + const bool state) const { + for (int i = 0; i < lineWidth; i++) { + drawLine(x + i, y + i, x + width - i, y + i, state); + drawLine(x + width - i, y + i, x + width - i, y + height - i, state); + drawLine(x + width - i, y + height - i, x + i, y + height - i, state); + drawLine(x + i, y + height - i, x + i, y + i, state); + } +} + +void GfxRenderer::drawArc(const int maxRadius, const int cx, const int cy, const int xDir, const int yDir, + const int lineWidth, const bool state) const { + const int stroke = std::min(lineWidth, maxRadius); + const int innerRadius = std::max(maxRadius - stroke, 0); + const int outerRadiusSq = maxRadius * maxRadius; + const int innerRadiusSq = innerRadius * innerRadius; + for (int dy = 0; dy <= maxRadius; ++dy) { + for (int dx = 0; dx <= maxRadius; ++dx) { + const int distSq = dx * dx + dy * dy; + if (distSq > outerRadiusSq || distSq < innerRadiusSq) { + continue; + } + const int px = cx + xDir * dx; + const int py = cy + yDir * dy; + drawPixel(px, py, state); + } + } +}; + +// Border is inside the rectangle, rounded corners +void GfxRenderer::drawRoundedRect(const int x, const int y, const int width, const int height, const int lineWidth, + const int cornerRadius, bool state) const { + drawRoundedRect(x, y, width, height, lineWidth, cornerRadius, true, true, true, true, state); +} + +// Border is inside the rectangle, rounded corners +void GfxRenderer::drawRoundedRect(const int x, const int y, const int width, const int height, const int lineWidth, + const int cornerRadius, bool roundTopLeft, bool roundTopRight, bool roundBottomLeft, + bool roundBottomRight, bool state) const { + if (lineWidth <= 0 || width <= 0 || height <= 0) { + return; + } + + const int maxRadius = std::min({cornerRadius, width / 2, height / 2}); + if (maxRadius <= 0) { + drawRect(x, y, width, height, lineWidth, state); + return; + } + + const int stroke = std::min(lineWidth, maxRadius); + const int right = x + width - 1; + const int bottom = y + height - 1; + + const int horizontalWidth = width - 2 * maxRadius; + if (horizontalWidth > 0) { + if (roundTopLeft || roundTopRight) { + fillRect(x + maxRadius, y, horizontalWidth, stroke, state); + } + if (roundBottomLeft || roundBottomRight) { + fillRect(x + maxRadius, bottom - stroke + 1, horizontalWidth, stroke, state); + } + } + + const int verticalHeight = height - 2 * maxRadius; + if (verticalHeight > 0) { + if (roundTopLeft || roundBottomLeft) { + fillRect(x, y + maxRadius, stroke, verticalHeight, state); + } + if (roundTopRight || roundBottomRight) { + fillRect(right - stroke + 1, y + maxRadius, stroke, verticalHeight, state); + } + } + + if (roundTopLeft) { + drawArc(maxRadius, x + maxRadius, y + maxRadius, -1, -1, lineWidth, state); + } + if (roundTopRight) { + drawArc(maxRadius, right - maxRadius, y + maxRadius, 1, -1, lineWidth, state); + } + if (roundBottomRight) { + drawArc(maxRadius, right - maxRadius, bottom - maxRadius, 1, 1, lineWidth, state); + } + if (roundBottomLeft) { + drawArc(maxRadius, x + maxRadius, bottom - maxRadius, -1, 1, lineWidth, state); + } +} + void GfxRenderer::fillRect(const int x, const int y, const int width, const int height, const bool state) const { for (int fillY = y; fillY < y + height; fillY++) { drawLine(x, fillY, x + width - 1, fillY, state); } } +static constexpr uint8_t bayer4x4[4][4] = { + {0, 8, 2, 10}, + {12, 4, 14, 6}, + {3, 11, 1, 9}, + {15, 7, 13, 5}, +}; +static constexpr int matrixSize = 4; +static constexpr int matrixLevels = matrixSize * matrixSize; + +void GfxRenderer::drawPixelDither(const int x, const int y, Color color) const { + if (color == COLOR_CLEAR) { + } else if (color == COLOR_BLACK) { + drawPixel(x, y, true); + } else if (color == COLOR_WHITE) { + drawPixel(x, y, false); + } else { + // Use dithering + const int greyLevel = static_cast(color) - 1; // 0-15 + const int normalizedGrey = (greyLevel * 255) / (matrixLevels - 1); + const int clampedGrey = std::max(0, std::min(normalizedGrey, 255)); + const int threshold = (clampedGrey * (matrixLevels + 1)) / 256; + + const int matrixX = x & (matrixSize - 1); + const int matrixY = y & (matrixSize - 1); + const uint8_t patternValue = bayer4x4[matrixY][matrixX]; + const bool black = patternValue < threshold; + drawPixel(x, y, black); + } +} + +// Use Bayer matrix 4x4 dithering to fill the rectangle with a grey level +void GfxRenderer::fillRectDither(const int x, const int y, const int width, const int height, Color color) const { + if (color == COLOR_CLEAR) { + } else if (color == COLOR_BLACK) { + fillRect(x, y, width, height, true); + } else if (color == COLOR_WHITE) { + fillRect(x, y, width, height, false); + } else { + for (int fillY = y; fillY < y + height; fillY++) { + for (int fillX = x; fillX < x + width; fillX++) { + drawPixelDither(fillX, fillY, color); + } + } + } +} + +void GfxRenderer::fillArc(const int maxRadius, const int cx, const int cy, const int xDir, const int yDir, + Color color) const { + const int radiusSq = maxRadius * maxRadius; + for (int dy = 0; dy <= maxRadius; ++dy) { + for (int dx = 0; dx <= maxRadius; ++dx) { + const int distSq = dx * dx + dy * dy; + const int px = cx + xDir * dx; + const int py = cy + yDir * dy; + if (distSq <= radiusSq) { + drawPixelDither(px, py, color); + } + } + } +} + +void GfxRenderer::fillRoundedRect(const int x, const int y, const int width, const int height, const int cornerRadius, + const Color color) const { + fillRoundedRect(x, y, width, height, cornerRadius, true, true, true, true, color); +} + +void GfxRenderer::fillRoundedRect(const int x, const int y, const int width, const int height, const int cornerRadius, + bool roundTopLeft, bool roundTopRight, bool roundBottomLeft, bool roundBottomRight, + const Color color) const { + if (width <= 0 || height <= 0) { + return; + } + + const int maxRadius = std::min({cornerRadius, width / 2, height / 2}); + if (maxRadius <= 0) { + fillRectDither(x, y, width, height, color); + return; + } + + const int horizontalWidth = width - 2 * maxRadius; + if (horizontalWidth > 0) { + fillRectDither(x + maxRadius, y, horizontalWidth, height, color); + } + + const int verticalHeight = height - 2 * maxRadius; + if (verticalHeight > 0) { + fillRectDither(x, y + maxRadius, maxRadius, verticalHeight, color); + fillRectDither(x + width - maxRadius, y + maxRadius, maxRadius, verticalHeight, color); + } + + if (roundTopLeft) { + fillArc(maxRadius, x + maxRadius, y + maxRadius, -1, -1, color); + } else { + fillRectDither(x, y, maxRadius, maxRadius, color); + } + + if (roundTopRight) { + fillArc(maxRadius, x + width - maxRadius - 1, y + maxRadius, 1, -1, color); + } else { + fillRectDither(x + width - maxRadius, y, maxRadius, maxRadius, color); + } + + if (roundBottomRight) { + fillArc(maxRadius, x + width - maxRadius - 1, y + height - maxRadius - 1, 1, 1, color); + } else { + fillRectDither(x + width - maxRadius, y + height - maxRadius, maxRadius, maxRadius, color); + } + + if (roundBottomLeft) { + fillArc(maxRadius, x + maxRadius, y + height - maxRadius - 1, -1, 1, color); + } else { + fillRectDither(x, y + height - maxRadius, maxRadius, maxRadius, color); + } +} + void GfxRenderer::drawImage(const uint8_t bitmap[], const int x, const int y, const int width, const int height) const { int rotatedX = 0; int rotatedY = 0; @@ -166,6 +375,10 @@ void GfxRenderer::drawImage(const uint8_t bitmap[], const int x, const int y, co display.drawImage(bitmap, rotatedX, rotatedY, width, height); } +void GfxRenderer::drawIcon(const uint8_t bitmap[], const int x, const int y, const int width, const int height) const { + display.drawImage(bitmap, y, getScreenWidth() - width - x, height, width); +} + void GfxRenderer::drawBitmap(const Bitmap& bitmap, const int x, const int y, const int maxWidth, const int maxHeight, const float cropX, const float cropY) const { // For 1-bit bitmaps, use optimized 1-bit rendering path (no crop support for 1-bit) @@ -480,85 +693,6 @@ int GfxRenderer::getLineHeight(const int fontId) const { return fontMap.at(fontId).getData(EpdFontFamily::REGULAR)->advanceY; } -void GfxRenderer::drawButtonHints(const int fontId, const char* btn1, const char* btn2, const char* btn3, - const char* btn4) { - const Orientation orig_orientation = getOrientation(); - setOrientation(Orientation::Portrait); - - const int pageHeight = getScreenHeight(); - constexpr int buttonWidth = 106; - constexpr int buttonHeight = 40; - constexpr int buttonY = 40; // Distance from bottom - constexpr int textYOffset = 7; // Distance from top of button to text baseline - constexpr int buttonPositions[] = {25, 130, 245, 350}; - const char* labels[] = {btn1, btn2, btn3, btn4}; - - for (int i = 0; i < 4; i++) { - // Only draw if the label is non-empty - if (labels[i] != nullptr && labels[i][0] != '\0') { - const int x = buttonPositions[i]; - fillRect(x, pageHeight - buttonY, buttonWidth, buttonHeight, false); - drawRect(x, pageHeight - buttonY, buttonWidth, buttonHeight); - const int textWidth = getTextWidth(fontId, labels[i]); - const int textX = x + (buttonWidth - 1 - textWidth) / 2; - drawText(fontId, textX, pageHeight - buttonY + textYOffset, labels[i]); - } - } - - setOrientation(orig_orientation); -} - -void GfxRenderer::drawSideButtonHints(const int fontId, const char* topBtn, const char* bottomBtn) const { - const int screenWidth = getScreenWidth(); - constexpr int buttonWidth = 40; // Width on screen (height when rotated) - constexpr int buttonHeight = 80; // Height on screen (width when rotated) - constexpr int buttonX = 5; // Distance from right edge - // Position for the button group - buttons share a border so they're adjacent - constexpr int topButtonY = 345; // Top button position - - const char* labels[] = {topBtn, bottomBtn}; - - // Draw the shared border for both buttons as one unit - const int x = screenWidth - buttonX - buttonWidth; - - // Draw top button outline (3 sides, bottom open) - if (topBtn != nullptr && topBtn[0] != '\0') { - drawLine(x, topButtonY, x + buttonWidth - 1, topButtonY); // Top - drawLine(x, topButtonY, x, topButtonY + buttonHeight - 1); // Left - drawLine(x + buttonWidth - 1, topButtonY, x + buttonWidth - 1, topButtonY + buttonHeight - 1); // Right - } - - // Draw shared middle border - if ((topBtn != nullptr && topBtn[0] != '\0') || (bottomBtn != nullptr && bottomBtn[0] != '\0')) { - drawLine(x, topButtonY + buttonHeight, x + buttonWidth - 1, topButtonY + buttonHeight); // Shared border - } - - // Draw bottom button outline (3 sides, top is shared) - if (bottomBtn != nullptr && bottomBtn[0] != '\0') { - drawLine(x, topButtonY + buttonHeight, x, topButtonY + 2 * buttonHeight - 1); // Left - drawLine(x + buttonWidth - 1, topButtonY + buttonHeight, x + buttonWidth - 1, - topButtonY + 2 * buttonHeight - 1); // Right - drawLine(x, topButtonY + 2 * buttonHeight - 1, x + buttonWidth - 1, topButtonY + 2 * buttonHeight - 1); // Bottom - } - - // Draw text for each button - for (int i = 0; i < 2; i++) { - if (labels[i] != nullptr && labels[i][0] != '\0') { - const int y = topButtonY + i * buttonHeight; - - // Draw rotated text centered in the button - const int textWidth = getTextWidth(fontId, labels[i]); - const int textHeight = getTextHeight(fontId); - - // Center the rotated text in the button - const int textX = x + (buttonWidth - textHeight) / 2; - const int textY = y + (buttonHeight + textWidth) / 2; - - drawTextRotated90CW(fontId, textX, textY, labels[i]); - } - } -} - int GfxRenderer::getTextHeight(const int fontId) const { if (fontMap.count(fontId) == 0) { Serial.printf("[%lu] [GFX] Font %d not found\n", millis(), fontId); diff --git a/lib/GfxRenderer/GfxRenderer.h b/lib/GfxRenderer/GfxRenderer.h index 733975f4..43fb2a60 100644 --- a/lib/GfxRenderer/GfxRenderer.h +++ b/lib/GfxRenderer/GfxRenderer.h @@ -7,6 +7,16 @@ #include "Bitmap.h" +// Color representation: uint8_t mapped to 4x4 Bayer matrix dithering levels +// 0 = transparent, 1-16 = gray levels (white to black) +using Color = uint8_t; + +constexpr Color COLOR_CLEAR = 0x00; +constexpr Color COLOR_WHITE = 0x01; +constexpr Color COLOR_LIGHT_GRAY = 0x05; +constexpr Color COLOR_DARK_GRAY = 0x0A; +constexpr Color COLOR_BLACK = 0x10; + class GfxRenderer { public: enum RenderMode { BW, GRAYSCALE_LSB, GRAYSCALE_MSB }; @@ -34,6 +44,8 @@ class GfxRenderer { EpdFontFamily::Style style) const; void freeBwBufferChunks(); void rotateCoordinates(int x, int y, int* rotatedX, int* rotatedY) const; + void drawPixelDither(int x, int y, Color color) const; + void fillArc(int maxRadius, int cx, int cy, int xDir, int yDir, Color color) const; public: explicit GfxRenderer(HalDisplay& halDisplay) : display(halDisplay), renderMode(BW), orientation(Portrait) {} @@ -63,9 +75,20 @@ class GfxRenderer { // Drawing void drawPixel(int x, int y, bool state = true) 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; void drawRect(int x, int y, int width, int height, bool state = true) const; + void drawRect(int x, int y, int width, int height, int lineWidth, bool state) const; + void drawRoundedRect(int x, int y, int width, int height, int lineWidth, int cornerRadius, bool state) const; + void drawRoundedRect(int x, int y, int width, int height, int lineWidth, int cornerRadius, bool roundTopLeft, + bool roundTopRight, bool roundBottomLeft, bool roundBottomRight, bool state) const; void fillRect(int x, int y, int width, int height, bool state = true) const; + void fillRectDither(int x, int y, int width, int height, Color color) const; + void fillRoundedRect(int x, int y, int width, int height, int cornerRadius, Color color) const; + void fillRoundedRect(int x, int y, int width, int height, int cornerRadius, bool roundTopLeft, bool roundTopRight, + bool roundBottomLeft, bool roundBottomRight, Color color) const; void drawImage(const uint8_t bitmap[], int x, int y, int width, int height) const; + void drawIcon(const uint8_t bitmap[], int x, int y, int width, int height) const; void drawBitmap(const Bitmap& bitmap, int x, int y, int maxWidth, int maxHeight, float cropX = 0, float cropY = 0) const; void drawBitmap1Bit(const Bitmap& bitmap, int x, int y, int maxWidth, int maxHeight) const; @@ -83,17 +106,11 @@ class GfxRenderer { std::string truncatedText(int fontId, const char* text, int maxWidth, EpdFontFamily::Style style = EpdFontFamily::REGULAR) const; - // UI Components - void drawButtonHints(int fontId, const char* btn1, const char* btn2, const char* btn3, const char* btn4); - void drawSideButtonHints(int fontId, const char* topBtn, const char* bottomBtn) const; - - private: // Helper for drawing rotated text (90 degrees clockwise, for side buttons) void drawTextRotated90CW(int fontId, int x, int y, const char* text, bool black = true, EpdFontFamily::Style style = EpdFontFamily::REGULAR) const; int getTextHeight(int fontId) const; - public: // Grayscale functions void setRenderMode(const RenderMode mode) { this->renderMode = mode; } void copyGrayscaleLsbBuffers() const; diff --git a/lib/Xtc/Xtc.cpp b/lib/Xtc/Xtc.cpp index 7850d934..904e37dc 100644 --- a/lib/Xtc/Xtc.cpp +++ b/lib/Xtc/Xtc.cpp @@ -301,11 +301,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; } @@ -333,8 +333,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(THUMB_TARGET_WIDTH) / pageInfo.width; @@ -348,7 +348,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)); @@ -359,7 +359,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; } @@ -393,7 +393,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; @@ -558,7 +558,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; } diff --git a/lib/Xtc/Xtc.h b/lib/Xtc/Xtc.h index c8d9a040..47b4b8ed 100644 --- a/lib/Xtc/Xtc.h +++ b/lib/Xtc/Xtc.h @@ -64,8 +64,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; diff --git a/src/CrossPointSettings.cpp b/src/CrossPointSettings.cpp index 232c7c57..30fc49ae 100644 --- a/src/CrossPointSettings.cpp +++ b/src/CrossPointSettings.cpp @@ -22,7 +22,7 @@ void readAndValidate(FsFile& file, uint8_t& member, const uint8_t maxValue) { namespace { constexpr uint8_t SETTINGS_FILE_VERSION = 1; // Increment this when adding new persisted settings fields -constexpr uint8_t SETTINGS_COUNT = 23; +constexpr uint8_t SETTINGS_COUNT = 24; constexpr char SETTINGS_FILE[] = "/.crosspoint/settings.bin"; } // namespace @@ -61,6 +61,7 @@ bool CrossPointSettings::saveToFile() const { serialization::writeString(outputFile, std::string(opdsPassword)); serialization::writePod(outputFile, sleepScreenCoverFilter); // New fields added at end for backward compatibility + serialization::writePod(outputFile, uiTheme); outputFile.close(); Serial.printf("[%lu] [CPS] Settings saved to file\n", millis()); @@ -149,6 +150,8 @@ bool CrossPointSettings::loadFromFile() { readAndValidate(inputFile, sleepScreenCoverFilter, SLEEP_SCREEN_COVER_FILTER_COUNT); if (++settingsRead >= fileSettingsCount) break; // New fields added at end for backward compatibility + serialization::readPod(inputFile, uiTheme); + if (++settingsRead >= fileSettingsCount) break; } while (false); inputFile.close(); diff --git a/src/CrossPointSettings.h b/src/CrossPointSettings.h index c450d348..d52959fc 100644 --- a/src/CrossPointSettings.h +++ b/src/CrossPointSettings.h @@ -97,6 +97,9 @@ class CrossPointSettings { // Hide battery percentage enum HIDE_BATTERY_PERCENTAGE { HIDE_NEVER = 0, HIDE_READER = 1, HIDE_ALWAYS = 2, HIDE_BATTERY_PERCENTAGE_COUNT }; + // UI Theme + enum UI_THEME { CLASSIC = 0, LYRA = 1 }; + // Sleep screen settings uint8_t sleepScreen = DARK; // Sleep screen cover mode settings @@ -137,6 +140,8 @@ class CrossPointSettings { uint8_t hideBatteryPercentage = HIDE_NEVER; // Long-press chapter skip on side buttons uint8_t longPressChapterSkip = 1; + // UI Theme + uint8_t uiTheme = CLASSIC; ~CrossPointSettings() = default; diff --git a/src/RecentBooksStore.h b/src/RecentBooksStore.h index 7b87f1e0..86eff272 100644 --- a/src/RecentBooksStore.h +++ b/src/RecentBooksStore.h @@ -10,6 +10,11 @@ struct RecentBook { bool operator==(const RecentBook& other) const { return path == other.path; } }; +struct RecentBookWithCover { + RecentBook book; + std::string coverBmpPath; +}; + class RecentBooksStore { // Static instance static RecentBooksStore instance; diff --git a/src/ScreenComponents.cpp b/src/ScreenComponents.cpp deleted file mode 100644 index ef47dfc5..00000000 --- a/src/ScreenComponents.cpp +++ /dev/null @@ -1,146 +0,0 @@ -#include "ScreenComponents.h" - -#include - -#include -#include - -#include "Battery.h" -#include "fontIds.h" - -void ScreenComponents::drawBattery(const GfxRenderer& renderer, const int left, const int top, - const bool showPercentage) { - // Left aligned battery icon and percentage - const uint16_t percentage = battery.readPercentage(); - const auto percentageText = showPercentage ? std::to_string(percentage) + "%" : ""; - renderer.drawText(SMALL_FONT_ID, left + 20, top, percentageText.c_str()); - - // 1 column on left, 2 columns on right, 5 columns of battery body - constexpr int batteryWidth = 15; - constexpr int batteryHeight = 12; - const int x = left; - const int y = top + 6; - - // Top line - renderer.drawLine(x + 1, y, x + batteryWidth - 3, y); - // Bottom line - renderer.drawLine(x + 1, y + batteryHeight - 1, x + batteryWidth - 3, y + batteryHeight - 1); - // Left line - renderer.drawLine(x, y + 1, x, y + batteryHeight - 2); - // Battery end - renderer.drawLine(x + batteryWidth - 2, y + 1, x + batteryWidth - 2, y + batteryHeight - 2); - renderer.drawPixel(x + batteryWidth - 1, y + 3); - renderer.drawPixel(x + batteryWidth - 1, y + batteryHeight - 4); - renderer.drawLine(x + batteryWidth - 0, y + 4, x + batteryWidth - 0, y + batteryHeight - 5); - - // The +1 is to round up, so that we always fill at least one pixel - int filledWidth = percentage * (batteryWidth - 5) / 100 + 1; - if (filledWidth > batteryWidth - 5) { - filledWidth = batteryWidth - 5; // Ensure we don't overflow - } - - renderer.fillRect(x + 2, y + 2, filledWidth, batteryHeight - 4); -} - -void ScreenComponents::drawBookProgressBar(const GfxRenderer& renderer, const size_t bookProgress) { - int vieweableMarginTop, vieweableMarginRight, vieweableMarginBottom, vieweableMarginLeft; - renderer.getOrientedViewableTRBL(&vieweableMarginTop, &vieweableMarginRight, &vieweableMarginBottom, - &vieweableMarginLeft); - - const int progressBarMaxWidth = renderer.getScreenWidth() - vieweableMarginLeft - vieweableMarginRight; - const int progressBarY = renderer.getScreenHeight() - vieweableMarginBottom - BOOK_PROGRESS_BAR_HEIGHT; - const int barWidth = progressBarMaxWidth * bookProgress / 100; - renderer.fillRect(vieweableMarginLeft, progressBarY, barWidth, BOOK_PROGRESS_BAR_HEIGHT, true); -} - -int ScreenComponents::drawTabBar(const GfxRenderer& renderer, const int y, const std::vector& tabs) { - constexpr int tabPadding = 20; // Horizontal padding between tabs - constexpr int leftMargin = 20; // Left margin for first tab - constexpr int underlineHeight = 2; // Height of selection underline - constexpr int underlineGap = 4; // Gap between text and underline - - const int lineHeight = renderer.getLineHeight(UI_12_FONT_ID); - const int tabBarHeight = lineHeight + underlineGap + underlineHeight; - - int currentX = leftMargin; - - for (const auto& tab : tabs) { - const int textWidth = - renderer.getTextWidth(UI_12_FONT_ID, tab.label, tab.selected ? EpdFontFamily::BOLD : EpdFontFamily::REGULAR); - - // Draw tab label - renderer.drawText(UI_12_FONT_ID, currentX, y, tab.label, true, - tab.selected ? EpdFontFamily::BOLD : EpdFontFamily::REGULAR); - - // Draw underline for selected tab - if (tab.selected) { - renderer.fillRect(currentX, y + lineHeight + underlineGap, textWidth, underlineHeight); - } - - currentX += textWidth + tabPadding; - } - - return tabBarHeight; -} - -void ScreenComponents::drawScrollIndicator(const GfxRenderer& renderer, const int currentPage, const int totalPages, - const int contentTop, const int contentHeight) { - if (totalPages <= 1) { - return; // No need for indicator if only one page - } - - const int screenWidth = renderer.getScreenWidth(); - constexpr int indicatorWidth = 20; - constexpr int arrowSize = 6; - constexpr int margin = 15; // Offset from right edge - - const int centerX = screenWidth - indicatorWidth / 2 - margin; - const int indicatorTop = contentTop + 60; // Offset to avoid overlapping side button hints - const int indicatorBottom = contentTop + contentHeight - 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 page fraction in the middle (e.g., "1/3") - const std::string pageText = std::to_string(currentPage) + "/" + std::to_string(totalPages); - const int textWidth = renderer.getTextWidth(SMALL_FONT_ID, pageText.c_str()); - const int textX = centerX - textWidth / 2; - const int textY = (indicatorTop + indicatorBottom) / 2 - renderer.getLineHeight(SMALL_FONT_ID) / 2; - - renderer.drawText(SMALL_FONT_ID, textX, textY, pageText.c_str()); -} - -void ScreenComponents::drawProgressBar(const GfxRenderer& renderer, const int x, const int y, const int width, - const int height, const size_t current, const size_t total) { - if (total == 0) { - return; - } - - // Use 64-bit arithmetic to avoid overflow for large files - const int percent = static_cast((static_cast(current) * 100) / total); - - // Draw outline - renderer.drawRect(x, y, width, height); - - // Draw filled portion - const int fillWidth = (width - 4) * percent / 100; - if (fillWidth > 0) { - renderer.fillRect(x + 2, y + 2, fillWidth, height - 4); - } - - // Draw percentage text centered below bar - const std::string percentText = std::to_string(percent) + "%"; - renderer.drawCenteredText(UI_10_FONT_ID, y + height + 15, percentText.c_str()); -} diff --git a/src/ScreenComponents.h b/src/ScreenComponents.h deleted file mode 100644 index 15403f60..00000000 --- a/src/ScreenComponents.h +++ /dev/null @@ -1,42 +0,0 @@ -#pragma once - -#include -#include -#include - -class GfxRenderer; - -struct TabInfo { - const char* label; - bool selected; -}; - -class ScreenComponents { - public: - static const int BOOK_PROGRESS_BAR_HEIGHT = 4; - - static void drawBattery(const GfxRenderer& renderer, int left, int top, bool showPercentage = true); - static void drawBookProgressBar(const GfxRenderer& renderer, size_t bookProgress); - - // Draw a horizontal tab bar with underline indicator for selected tab - // Returns the height of the tab bar (for positioning content below) - static int drawTabBar(const GfxRenderer& renderer, int y, const std::vector& tabs); - - // Draw a scroll/page indicator on the right side of the screen - // Shows up/down arrows and current page fraction (e.g., "1/3") - static void drawScrollIndicator(const GfxRenderer& renderer, int currentPage, int totalPages, int contentTop, - int contentHeight); - - /** - * Draw a progress bar with percentage text. - * @param renderer The graphics renderer - * @param x Left position of the bar - * @param y Top position of the bar - * @param width Width of the bar - * @param height Height of the bar - * @param current Current progress value - * @param total Total value for 100% progress - */ - static void drawProgressBar(const GfxRenderer& renderer, int x, int y, int width, int height, size_t current, - size_t total); -}; diff --git a/src/activities/boot_sleep/SleepActivity.cpp b/src/activities/boot_sleep/SleepActivity.cpp index aace2095..e18fc48f 100644 --- a/src/activities/boot_sleep/SleepActivity.cpp +++ b/src/activities/boot_sleep/SleepActivity.cpp @@ -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"); diff --git a/src/activities/boot_sleep/SleepActivity.h b/src/activities/boot_sleep/SleepActivity.h index 283220ce..87df8ba1 100644 --- a/src/activities/boot_sleep/SleepActivity.h +++ b/src/activities/boot_sleep/SleepActivity.h @@ -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; diff --git a/src/activities/browser/OpdsBookBrowserActivity.cpp b/src/activities/browser/OpdsBookBrowserActivity.cpp index 2bde74de..6d98dbc4 100644 --- a/src/activities/browser/OpdsBookBrowserActivity.cpp +++ b/src/activities/browser/OpdsBookBrowserActivity.cpp @@ -8,8 +8,8 @@ #include "CrossPointSettings.h" #include "MappedInputManager.h" -#include "ScreenComponents.h" #include "activities/network/WifiSelectionActivity.h" +#include "components/UITheme.h" #include "fontIds.h" #include "network/HttpDownloader.h" #include "util/StringUtils.h" @@ -176,7 +176,7 @@ void OpdsBookBrowserActivity::render() const { if (state == BrowserState::CHECK_WIFI) { renderer.drawCenteredText(UI_10_FONT_ID, pageHeight / 2, statusMessage.c_str()); const auto labels = mappedInput.mapLabels("« Back", "", "", ""); - renderer.drawButtonHints(UI_10_FONT_ID, labels.btn1, labels.btn2, labels.btn3, labels.btn4); + UITheme::drawButtonHints(renderer, labels.btn1, labels.btn2, labels.btn3, labels.btn4); renderer.displayBuffer(); return; } @@ -184,7 +184,7 @@ void OpdsBookBrowserActivity::render() const { if (state == BrowserState::LOADING) { renderer.drawCenteredText(UI_10_FONT_ID, pageHeight / 2, statusMessage.c_str()); const auto labels = mappedInput.mapLabels("« Back", "", "", ""); - renderer.drawButtonHints(UI_10_FONT_ID, labels.btn1, labels.btn2, labels.btn3, labels.btn4); + UITheme::drawButtonHints(renderer, labels.btn1, labels.btn2, labels.btn3, labels.btn4); renderer.displayBuffer(); return; } @@ -193,7 +193,7 @@ void OpdsBookBrowserActivity::render() const { renderer.drawCenteredText(UI_10_FONT_ID, pageHeight / 2 - 20, "Error:"); renderer.drawCenteredText(UI_10_FONT_ID, pageHeight / 2 + 10, errorMessage.c_str()); const auto labels = mappedInput.mapLabels("« Back", "Retry", "", ""); - renderer.drawButtonHints(UI_10_FONT_ID, labels.btn1, labels.btn2, labels.btn3, labels.btn4); + UITheme::drawButtonHints(renderer, labels.btn1, labels.btn2, labels.btn3, labels.btn4); renderer.displayBuffer(); return; } @@ -206,7 +206,7 @@ void OpdsBookBrowserActivity::render() const { constexpr int barHeight = 20; constexpr int barX = 50; const int barY = pageHeight / 2 + 20; - ScreenComponents::drawProgressBar(renderer, barX, barY, barWidth, barHeight, downloadProgress, downloadTotal); + UITheme::drawProgressBar(renderer, Rect{barX, barY, barWidth, barHeight}, downloadProgress, downloadTotal); } renderer.displayBuffer(); return; @@ -219,7 +219,7 @@ void OpdsBookBrowserActivity::render() const { confirmLabel = "Download"; } const auto labels = mappedInput.mapLabels("« Back", confirmLabel, "", ""); - renderer.drawButtonHints(UI_10_FONT_ID, labels.btn1, labels.btn2, labels.btn3, labels.btn4); + UITheme::drawButtonHints(renderer, labels.btn1, labels.btn2, labels.btn3, labels.btn4); if (entries.empty()) { renderer.drawCenteredText(UI_10_FONT_ID, pageHeight / 2, "No entries found"); diff --git a/src/activities/home/HomeActivity.cpp b/src/activities/home/HomeActivity.cpp index 58b29505..0c63dd72 100644 --- a/src/activities/home/HomeActivity.cpp +++ b/src/activities/home/HomeActivity.cpp @@ -13,7 +13,8 @@ #include "CrossPointSettings.h" #include "CrossPointState.h" #include "MappedInputManager.h" -#include "ScreenComponents.h" +#include "RecentBooksStore.h" +#include "components/UITheme.h" #include "fontIds.h" #include "util/StringUtils.h" @@ -24,11 +25,113 @@ void HomeActivity::taskTrampoline(void* param) { int HomeActivity::getMenuItemCount() const { int count = 3; // My Library, File transfer, Settings - if (hasContinueReading) count++; - if (hasOpdsUrl) count++; + if (!recentBooks.empty()) { + count += recentBooks.size(); + } + if (hasOpdsUrl) { + count++; + } return count; } +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(books.size()), maxBooks)); + + int progress = 0; + bool loadingPopupDisplayed = false; + for (const RecentBook& book : books) { + const std::string& path = book.path; + + // Limit to maximum number of recent books + if (recentBooks.size() >= maxBooks) { + break; + } + + // Skip if file no longer exists + if (!SdMan.exists(path.c_str())) { + continue; + } + + std::string coverBmpPath = ""; + std::string lastBookFileName = ""; + const size_t lastSlash = path.find_last_of('/'); + if (lastSlash != std::string::npos) { + 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(lastBookFileName, ".epub")) { + Epub epub(path, "/.crosspoint"); + epub.load(false); + // if (!epub.getTitle().empty()) { + // lastBookTitle = std::string(epub.getTitle()); + // } + // if (!epub.getAuthor().empty()) { + // lastBookAuthor = std::string(epub.getAuthor()); + // } + // Try to generate thumbnail image for Continue Reading card + 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(lastBookFileName, ".xtch") || + StringUtils::checkFileExtension(lastBookFileName, ".xtc")) { + // Handle XTC file + Xtc xtc(path, "/.crosspoint"); + if (xtc.load()) { + // if (!xtc.getTitle().empty()) { + // lastBookTitle = std::string(xtc.getTitle()); + // } + // Try to generate thumbnail image for Continue Reading card + 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 = ""; + } + } + } + + // 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(RecentBookWithCover{book, coverBmpPath}); + progress++; + } + + Serial.printf("Recent books loaded: %d\n", recentBooks.size()); + recentsLoaded = true; + recentsLoading = false; + updateRequired = true; +} + void HomeActivity::onEnter() { Activity::onEnter(); @@ -40,62 +143,13 @@ void HomeActivity::onEnter() { // Check if OPDS browser URL is configured hasOpdsUrl = strlen(SETTINGS.opdsServerUrl) > 0; - if (hasContinueReading) { - // Extract filename from path for display - lastBookTitle = APP_STATE.openEpubPath; - const size_t lastSlash = lastBookTitle.find_last_of('/'); - if (lastSlash != std::string::npos) { - lastBookTitle = lastBookTitle.substr(lastSlash + 1); - } - - // If epub, try to load the metadata for title/author and cover - if (StringUtils::checkFileExtension(lastBookTitle, ".epub")) { - Epub epub(APP_STATE.openEpubPath, "/.crosspoint"); - epub.load(false); - if (!epub.getTitle().empty()) { - lastBookTitle = std::string(epub.getTitle()); - } - if (!epub.getAuthor().empty()) { - lastBookAuthor = std::string(epub.getAuthor()); - } - // Try to generate thumbnail image for Continue Reading card - if (epub.generateThumbBmp()) { - coverBmpPath = epub.getThumbBmpPath(); - hasCoverImage = true; - } - } else if (StringUtils::checkFileExtension(lastBookTitle, ".xtch") || - StringUtils::checkFileExtension(lastBookTitle, ".xtc")) { - // Handle XTC file - Xtc xtc(APP_STATE.openEpubPath, "/.crosspoint"); - if (xtc.load()) { - if (!xtc.getTitle().empty()) { - lastBookTitle = std::string(xtc.getTitle()); - } - if (!xtc.getAuthor().empty()) { - lastBookAuthor = std::string(xtc.getAuthor()); - } - // Try to generate thumbnail image for Continue Reading card - if (xtc.generateThumbBmp()) { - coverBmpPath = xtc.getThumbBmpPath(); - hasCoverImage = true; - } - } - // 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); - } - } - } - selectorIndex = 0; // Trigger first update updateRequired = true; xTaskCreate(&HomeActivity::taskTrampoline, "HomeActivityTask", - 4096, // Stack size (increased for cover image rendering) + 8192, // Stack size this, // Parameters 1, // Priority &displayTaskHandle // Task handle @@ -171,21 +225,21 @@ void HomeActivity::loop() { if (mappedInput.wasReleased(MappedInputManager::Button::Confirm)) { // Calculate dynamic indices based on which options are available int idx = 0; - const int continueIdx = hasContinueReading ? idx++ : -1; + int menuSelectedIndex = selectorIndex - static_cast(recentBooks.size()); const int myLibraryIdx = idx++; const int opdsLibraryIdx = hasOpdsUrl ? idx++ : -1; const int fileTransferIdx = idx++; const int settingsIdx = idx; - if (selectorIndex == continueIdx) { - onContinueReading(); - } else if (selectorIndex == myLibraryIdx) { + if (selectorIndex < recentBooks.size()) { + onSelectBook(recentBooks[selectorIndex].book.path, MyLibraryActivity::Tab::Recent); + } else if (menuSelectedIndex == myLibraryIdx) { onMyLibraryOpen(); - } else if (selectorIndex == opdsLibraryIdx) { + } else if (menuSelectedIndex == opdsLibraryIdx) { onOpdsBrowserOpen(); - } else if (selectorIndex == fileTransferIdx) { + } else if (menuSelectedIndex == fileTransferIdx) { onFileTransferOpen(); - } else if (selectorIndex == settingsIdx) { + } else if (menuSelectedIndex == settingsIdx) { onSettingsOpen(); } } else if (prevPressed) { @@ -210,350 +264,52 @@ void HomeActivity::displayTaskLoop() { } void HomeActivity::render() { - // If we have a stored cover buffer, restore it instead of clearing - const bool bufferRestored = coverBufferStored && restoreCoverBuffer(); - if (!bufferRestored) { - renderer.clearScreen(); - } - + auto metrics = UITheme::getMetrics(); const auto pageWidth = renderer.getScreenWidth(); const auto pageHeight = renderer.getScreenHeight(); - constexpr int margin = 20; - constexpr int bottomMargin = 60; - - // --- Top "book" card for the current title (selectorIndex == 0) --- - const int bookWidth = pageWidth / 2; - const int bookHeight = pageHeight / 2; - const int bookX = (pageWidth - bookWidth) / 2; - constexpr int bookY = 30; - const bool bookSelected = hasContinueReading && selectorIndex == 0; - - // Bookmark dimensions (used in multiple places) - const int bookmarkWidth = bookWidth / 8; - const int bookmarkHeight = bookHeight / 5; - const int bookmarkX = bookX + bookWidth - bookmarkWidth - 10; - const int bookmarkY = bookY + 5; - - // Draw book card regardless, fill with message based on `hasContinueReading` - { - // Draw cover image as background if available (inside the box) - // Only load from SD on first render, then use stored buffer - if (hasContinueReading && hasCoverImage && !coverBmpPath.empty() && !coverRendered) { - // First time: load cover from SD and render - FsFile file; - if (SdMan.openFileForRead("HOME", coverBmpPath, file)) { - Bitmap bitmap(file); - if (bitmap.parseHeaders() == BmpReaderError::Ok) { - // Calculate position to center image within the book card - int coverX, coverY; - - if (bitmap.getWidth() > bookWidth || bitmap.getHeight() > bookHeight) { - const float imgRatio = static_cast(bitmap.getWidth()) / static_cast(bitmap.getHeight()); - const float boxRatio = static_cast(bookWidth) / static_cast(bookHeight); - - if (imgRatio > boxRatio) { - coverX = bookX; - coverY = bookY + (bookHeight - static_cast(bookWidth / imgRatio)) / 2; - } else { - coverX = bookX + (bookWidth - static_cast(bookHeight * imgRatio)) / 2; - coverY = bookY; - } - } else { - coverX = bookX + (bookWidth - bitmap.getWidth()) / 2; - coverY = bookY + (bookHeight - bitmap.getHeight()) / 2; - } - - // Draw the cover image centered within the book card - renderer.drawBitmap(bitmap, coverX, coverY, bookWidth, bookHeight); - - // Draw border around the card - renderer.drawRect(bookX, bookY, bookWidth, bookHeight); - - // No bookmark ribbon when cover is shown - it would just cover the art - - // Store the buffer with cover image for fast navigation - coverBufferStored = storeCoverBuffer(); - coverRendered = true; - - // First render: if selected, draw selection indicators now - if (bookSelected) { - renderer.drawRect(bookX + 1, bookY + 1, bookWidth - 2, bookHeight - 2); - renderer.drawRect(bookX + 2, bookY + 2, bookWidth - 4, bookHeight - 4); - } - } - file.close(); - } - } else if (!bufferRestored && !coverRendered) { - // No cover image: draw border or fill, plus bookmark as visual flair - if (bookSelected) { - renderer.fillRect(bookX, bookY, bookWidth, bookHeight); - } else { - renderer.drawRect(bookX, bookY, bookWidth, bookHeight); - } - - // Draw bookmark ribbon when no cover image (visual decoration) - if (hasContinueReading) { - const int notchDepth = bookmarkHeight / 3; - const int centerX = bookmarkX + bookmarkWidth / 2; - - const int xPoints[5] = { - bookmarkX, // top-left - bookmarkX + bookmarkWidth, // top-right - bookmarkX + bookmarkWidth, // bottom-right - centerX, // center notch point - bookmarkX // bottom-left - }; - const int yPoints[5] = { - bookmarkY, // top-left - bookmarkY, // top-right - bookmarkY + bookmarkHeight, // bottom-right - bookmarkY + bookmarkHeight - notchDepth, // center notch point - bookmarkY + bookmarkHeight // bottom-left - }; - - // Draw bookmark ribbon (inverted if selected) - renderer.fillPolygon(xPoints, yPoints, 5, !bookSelected); - } - } - - // If buffer was restored, draw selection indicators if needed - if (bufferRestored && bookSelected && coverRendered) { - // Draw selection border (no bookmark inversion needed since cover has no bookmark) - renderer.drawRect(bookX + 1, bookY + 1, bookWidth - 2, bookHeight - 2); - renderer.drawRect(bookX + 2, bookY + 2, bookWidth - 4, bookHeight - 4); - } else if (!coverRendered && !bufferRestored) { - // Selection border already handled above in the no-cover case - } + bool bufferRestored = coverBufferStored && restoreCoverBuffer(); + if (!firstRenderDone || (recentsLoaded && !recentsDisplayed)) { + renderer.clearScreen(); } + UITheme::drawHeader(renderer, Rect{0, metrics.topPadding, pageWidth, metrics.homeTopPadding}, nullptr); + if (hasContinueReading) { - // Invert text colors based on selection state: - // - With cover: selected = white text on black box, unselected = black text on white box - // - Without cover: selected = white text on black card, unselected = black text on white card - - // Split into words (avoid stringstream to keep this light on the MCU) - std::vector words; - words.reserve(8); - size_t pos = 0; - while (pos < lastBookTitle.size()) { - while (pos < lastBookTitle.size() && lastBookTitle[pos] == ' ') { - ++pos; - } - if (pos >= lastBookTitle.size()) { - break; - } - const size_t start = pos; - while (pos < lastBookTitle.size() && lastBookTitle[pos] != ' ') { - ++pos; - } - words.emplace_back(lastBookTitle.substr(start, pos - start)); + 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, metrics.homeCoverHeight, popupCallbacks); } - - std::vector lines; - std::string currentLine; - // Extra padding inside the card so text doesn't hug the border - const int maxLineWidth = bookWidth - 40; - const int spaceWidth = renderer.getSpaceWidth(UI_12_FONT_ID); - - for (auto& i : words) { - // If we just hit the line limit (3), stop processing words - if (lines.size() >= 3) { - // Limit to 3 lines - // Still have words left, so add ellipsis to last line - lines.back().append("..."); - - while (!lines.back().empty() && renderer.getTextWidth(UI_12_FONT_ID, lines.back().c_str()) > maxLineWidth) { - // Remove "..." first, then remove one UTF-8 char, then add "..." back - lines.back().resize(lines.back().size() - 3); // Remove "..." - StringUtils::utf8RemoveLastChar(lines.back()); - lines.back().append("..."); - } - break; - } - - int wordWidth = renderer.getTextWidth(UI_12_FONT_ID, i.c_str()); - while (wordWidth > maxLineWidth && !i.empty()) { - // Word itself is too long, trim it (UTF-8 safe) - StringUtils::utf8RemoveLastChar(i); - // Check if we have room for ellipsis - std::string withEllipsis = i + "..."; - wordWidth = renderer.getTextWidth(UI_12_FONT_ID, withEllipsis.c_str()); - if (wordWidth <= maxLineWidth) { - i = withEllipsis; - break; - } - } - - int newLineWidth = renderer.getTextWidth(UI_12_FONT_ID, currentLine.c_str()); - if (newLineWidth > 0) { - newLineWidth += spaceWidth; - } - newLineWidth += wordWidth; - - if (newLineWidth > maxLineWidth && !currentLine.empty()) { - // New line too long, push old line - lines.push_back(currentLine); - currentLine = i; - } else { - currentLine.append(" ").append(i); - } - } - - // If lower than the line limit, push remaining words - if (!currentLine.empty() && lines.size() < 3) { - lines.push_back(currentLine); - } - - // Book title text - int totalTextHeight = renderer.getLineHeight(UI_12_FONT_ID) * static_cast(lines.size()); - if (!lastBookAuthor.empty()) { - totalTextHeight += renderer.getLineHeight(UI_10_FONT_ID) * 3 / 2; - } - - // Vertically center the title block within the card - int titleYStart = bookY + (bookHeight - totalTextHeight) / 2; - - // If cover image was rendered, draw box behind title and author - if (coverRendered) { - constexpr int boxPadding = 8; - // Calculate the max text width for the box - int maxTextWidth = 0; - for (const auto& line : lines) { - const int lineWidth = renderer.getTextWidth(UI_12_FONT_ID, line.c_str()); - if (lineWidth > maxTextWidth) { - maxTextWidth = lineWidth; - } - } - if (!lastBookAuthor.empty()) { - std::string trimmedAuthor = lastBookAuthor; - while (renderer.getTextWidth(UI_10_FONT_ID, trimmedAuthor.c_str()) > maxLineWidth && !trimmedAuthor.empty()) { - StringUtils::utf8RemoveLastChar(trimmedAuthor); - } - if (renderer.getTextWidth(UI_10_FONT_ID, trimmedAuthor.c_str()) < - renderer.getTextWidth(UI_10_FONT_ID, lastBookAuthor.c_str())) { - trimmedAuthor.append("..."); - } - const int authorWidth = renderer.getTextWidth(UI_10_FONT_ID, trimmedAuthor.c_str()); - if (authorWidth > maxTextWidth) { - maxTextWidth = authorWidth; - } - } - - const int boxWidth = maxTextWidth + boxPadding * 2; - const int boxHeight = totalTextHeight + boxPadding * 2; - const int boxX = (pageWidth - boxWidth) / 2; - const int boxY = titleYStart - boxPadding; - - // Draw box (inverted when selected: black box instead of white) - renderer.fillRect(boxX, boxY, boxWidth, boxHeight, bookSelected); - // Draw border around the box (inverted when selected: white border instead of black) - renderer.drawRect(boxX, boxY, boxWidth, boxHeight, !bookSelected); - } - - for (const auto& line : lines) { - renderer.drawCenteredText(UI_12_FONT_ID, titleYStart, line.c_str(), !bookSelected); - titleYStart += renderer.getLineHeight(UI_12_FONT_ID); - } - - if (!lastBookAuthor.empty()) { - titleYStart += renderer.getLineHeight(UI_10_FONT_ID) / 2; - std::string trimmedAuthor = lastBookAuthor; - // Trim author if too long (UTF-8 safe) - bool wasTrimmed = false; - while (renderer.getTextWidth(UI_10_FONT_ID, trimmedAuthor.c_str()) > maxLineWidth && !trimmedAuthor.empty()) { - StringUtils::utf8RemoveLastChar(trimmedAuthor); - wasTrimmed = true; - } - if (wasTrimmed && !trimmedAuthor.empty()) { - // Make room for ellipsis - while (renderer.getTextWidth(UI_10_FONT_ID, (trimmedAuthor + "...").c_str()) > maxLineWidth && - !trimmedAuthor.empty()) { - StringUtils::utf8RemoveLastChar(trimmedAuthor); - } - trimmedAuthor.append("..."); - } - renderer.drawCenteredText(UI_10_FONT_ID, titleYStart, trimmedAuthor.c_str(), !bookSelected); - } - - // "Continue Reading" label at the bottom - const int continueY = bookY + bookHeight - renderer.getLineHeight(UI_10_FONT_ID) * 3 / 2; - if (coverRendered) { - // Draw box behind "Continue Reading" text (inverted when selected: black box instead of white) - const char* continueText = "Continue Reading"; - const int continueTextWidth = renderer.getTextWidth(UI_10_FONT_ID, continueText); - constexpr int continuePadding = 6; - const int continueBoxWidth = continueTextWidth + continuePadding * 2; - const int continueBoxHeight = renderer.getLineHeight(UI_10_FONT_ID) + continuePadding; - const int continueBoxX = (pageWidth - continueBoxWidth) / 2; - const int continueBoxY = continueY - continuePadding / 2; - renderer.fillRect(continueBoxX, continueBoxY, continueBoxWidth, continueBoxHeight, bookSelected); - renderer.drawRect(continueBoxX, continueBoxY, continueBoxWidth, continueBoxHeight, !bookSelected); - renderer.drawCenteredText(UI_10_FONT_ID, continueY, continueText, !bookSelected); - } else { - renderer.drawCenteredText(UI_10_FONT_ID, continueY, "Continue Reading", !bookSelected); - } - } else { - // No book to continue reading - const int y = - bookY + (bookHeight - renderer.getLineHeight(UI_12_FONT_ID) - renderer.getLineHeight(UI_10_FONT_ID)) / 2; - renderer.drawCenteredText(UI_12_FONT_ID, y, "No open book"); - renderer.drawCenteredText(UI_10_FONT_ID, y + renderer.getLineHeight(UI_12_FONT_ID), "Start reading below"); } - // --- Bottom menu tiles --- // Build menu items dynamically - std::vector menuItems = {"My Library", "File Transfer", "Settings"}; + std::vector menuItems = {"Browse Files", "File Transfer", "Settings"}; if (hasOpdsUrl) { // Insert OPDS Browser after My Library menuItems.insert(menuItems.begin() + 1, "OPDS Browser"); } - const int menuTileWidth = pageWidth - 2 * margin; - constexpr int menuTileHeight = 45; - constexpr int menuSpacing = 8; - const int totalMenuHeight = - static_cast(menuItems.size()) * menuTileHeight + (static_cast(menuItems.size()) - 1) * menuSpacing; - - int menuStartY = bookY + bookHeight + 15; - // Ensure we don't collide with the bottom button legend - const int maxMenuStartY = pageHeight - bottomMargin - totalMenuHeight - margin; - if (menuStartY > maxMenuStartY) { - menuStartY = maxMenuStartY; - } - - for (size_t i = 0; i < menuItems.size(); ++i) { - const int overallIndex = static_cast(i) + (hasContinueReading ? 1 : 0); - constexpr int tileX = margin; - const int tileY = menuStartY + static_cast(i) * (menuTileHeight + menuSpacing); - const bool selected = selectorIndex == overallIndex; - - if (selected) { - renderer.fillRect(tileX, tileY, menuTileWidth, menuTileHeight); - } else { - renderer.drawRect(tileX, tileY, menuTileWidth, menuTileHeight); - } - - const char* label = menuItems[i]; - const int textWidth = renderer.getTextWidth(UI_10_FONT_ID, label); - const int textX = tileX + (menuTileWidth - textWidth) / 2; - const int lineHeight = renderer.getLineHeight(UI_10_FONT_ID); - const int textY = tileY + (menuTileHeight - lineHeight) / 2; // vertically centered assuming y is top of text - - // Invert text when the tile is selected, to contrast with the filled background - renderer.drawText(UI_10_FONT_ID, textX, textY, label, !selected); - } + UITheme::drawButtonMenu( + renderer, + Rect{0, metrics.homeTopPadding + metrics.homeCoverHeight + metrics.verticalSpacing, pageWidth, + pageHeight - (metrics.headerHeight + metrics.homeTopPadding + metrics.verticalSpacing * 2 + + metrics.buttonHintsHeight)}, + static_cast(menuItems.size()), selectorIndex - recentBooks.size(), + [&menuItems](int index) { return std::string(menuItems[index]); }, false, nullptr); const auto labels = mappedInput.mapLabels("", "Select", "Up", "Down"); - renderer.drawButtonHints(UI_10_FONT_ID, labels.btn1, labels.btn2, labels.btn3, labels.btn4); - - const bool showBatteryPercentage = - SETTINGS.hideBatteryPercentage != CrossPointSettings::HIDE_BATTERY_PERCENTAGE::HIDE_ALWAYS; - // get percentage so we can align text properly - const uint16_t percentage = battery.readPercentage(); - const auto percentageText = showBatteryPercentage ? std::to_string(percentage) + "%" : ""; - const auto batteryX = pageWidth - 25 - renderer.getTextWidth(SMALL_FONT_ID, percentageText.c_str()); - ScreenComponents::drawBattery(renderer, batteryX, 10, showBatteryPercentage); + UITheme::drawButtonHints(renderer, labels.btn1, labels.btn2, labels.btn3, labels.btn4); renderer.displayBuffer(); + + if (!firstRenderDone) { + firstRenderDone = true; + updateRequired = true; + } } diff --git a/src/activities/home/HomeActivity.h b/src/activities/home/HomeActivity.h index 52963514..0edba2c6 100644 --- a/src/activities/home/HomeActivity.h +++ b/src/activities/home/HomeActivity.h @@ -4,8 +4,13 @@ #include #include +#include #include "../Activity.h" +#include "./MyLibraryActivity.h" + +struct RecentBookWithCover; +struct PopupCallbacks; class HomeActivity final : public Activity { TaskHandle_t displayTaskHandle = nullptr; @@ -13,15 +18,16 @@ class HomeActivity final : public Activity { int selectorIndex = 0; bool updateRequired = false; bool hasContinueReading = false; + bool recentsLoading = false; + bool recentsLoaded = false; + bool recentsDisplayed = false; + bool firstRenderDone = false; bool hasOpdsUrl = false; - bool hasCoverImage = false; bool coverRendered = false; // Track if cover has been rendered once bool coverBufferStored = false; // Track if cover buffer is stored uint8_t* coverBuffer = nullptr; // HomeActivity's own buffer for cover image - std::string lastBookTitle; - std::string lastBookAuthor; - std::string coverBmpPath; - const std::function onContinueReading; + std::vector recentBooks; + const std::function onSelectBook; const std::function onMyLibraryOpen; const std::function onSettingsOpen; const std::function onFileTransferOpen; @@ -34,14 +40,16 @@ class HomeActivity final : public Activity { 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(GfxRenderer& renderer, MappedInputManager& mappedInput, - const std::function& onContinueReading, const std::function& onMyLibraryOpen, - const std::function& onSettingsOpen, const std::function& onFileTransferOpen, - const std::function& onOpdsBrowserOpen) + explicit HomeActivity( + GfxRenderer& renderer, MappedInputManager& mappedInput, + const std::function& onSelectBook, + const std::function& onMyLibraryOpen, const std::function& onSettingsOpen, + const std::function& onFileTransferOpen, const std::function& onOpdsBrowserOpen) : Activity("Home", renderer, mappedInput), - onContinueReading(onContinueReading), + onSelectBook(onSelectBook), onMyLibraryOpen(onMyLibraryOpen), onSettingsOpen(onSettingsOpen), onFileTransferOpen(onFileTransferOpen), diff --git a/src/activities/home/MyLibraryActivity.cpp b/src/activities/home/MyLibraryActivity.cpp index 29c6ea73..f211d044 100644 --- a/src/activities/home/MyLibraryActivity.cpp +++ b/src/activities/home/MyLibraryActivity.cpp @@ -3,26 +3,16 @@ #include #include -#include - #include "MappedInputManager.h" #include "RecentBooksStore.h" -#include "ScreenComponents.h" +#include "components/UITheme.h" #include "fontIds.h" #include "util/StringUtils.h" namespace { -// Layout constants -constexpr int TAB_BAR_Y = 15; -constexpr int CONTENT_START_Y = 60; -constexpr int LINE_HEIGHT = 30; -constexpr int RECENTS_LINE_HEIGHT = 65; // Increased for two-line items -constexpr int LEFT_MARGIN = 20; -constexpr int RIGHT_MARGIN = 40; // Extra space for scroll indicator - -// Timing thresholds constexpr int SKIP_PAGE_MS = 700; constexpr unsigned long GO_HOME_MS = 1000; +} // namespace void sortFileList(std::vector& strs) { std::sort(begin(strs), end(strs), [](const std::string& str1, const std::string& str2) { @@ -33,36 +23,10 @@ void sortFileList(std::vector& strs) { [](const char& char1, const char& char2) { return tolower(char1) < tolower(char2); }); }); } -} // namespace -int MyLibraryActivity::getPageItems() const { - const int screenHeight = renderer.getScreenHeight(); - const int bottomBarHeight = 60; // Space for button hints - const int availableHeight = screenHeight - CONTENT_START_Y - bottomBarHeight; - int items = availableHeight / LINE_HEIGHT; - if (items < 1) { - items = 1; - } - return items; -} - -int MyLibraryActivity::getCurrentItemCount() const { - if (currentTab == Tab::Recent) { - return static_cast(recentBooks.size()); - } - return static_cast(files.size()); -} - -int MyLibraryActivity::getTotalPages() const { - const int itemCount = getCurrentItemCount(); - const int pageItems = getPageItems(); - if (itemCount == 0) return 1; - return (itemCount + pageItems - 1) / pageItems; -} - -int MyLibraryActivity::getCurrentPage() const { - const int pageItems = getPageItems(); - return selectorIndex / pageItems + 1; +void MyLibraryActivity::taskTrampoline(void* param) { + auto* self = static_cast(param); + self->displayTaskLoop(); } void MyLibraryActivity::loadRecentBooks() { @@ -114,18 +78,6 @@ void MyLibraryActivity::loadFiles() { sortFileList(files); } -size_t MyLibraryActivity::findEntry(const std::string& name) const { - for (size_t i = 0; i < files.size(); i++) { - if (files[i] == name) return i; - } - return 0; -} - -void MyLibraryActivity::taskTrampoline(void* param) { - auto* self = static_cast(param); - self->displayTaskLoop(); -} - void MyLibraryActivity::onEnter() { Activity::onEnter(); @@ -139,7 +91,7 @@ void MyLibraryActivity::onEnter() { updateRequired = true; xTaskCreate(&MyLibraryActivity::taskTrampoline, "MyLibraryActivityTask", - 4096, // Stack size (increased for epub metadata loading) + 4096, // Stack size this, // Parameters 1, // Priority &displayTaskHandle // Task handle @@ -149,8 +101,7 @@ void MyLibraryActivity::onEnter() { void MyLibraryActivity::onExit() { Activity::onExit(); - // Wait until not rendering to delete task to avoid killing mid-instruction to - // EPD + // Wait until not rendering to delete task to avoid killing mid-instruction to EPD xSemaphoreTake(renderingMutex, portMAX_DELAY); if (displayTaskHandle) { vTaskDelete(displayTaskHandle); @@ -163,10 +114,7 @@ void MyLibraryActivity::onExit() { } void MyLibraryActivity::loop() { - const int itemCount = getCurrentItemCount(); - const int pageItems = getPageItems(); - - // Long press BACK (1s+) in Files tab goes to root folder + // Long press BACK (1s+) goes to root folder if (currentTab == Tab::Files && mappedInput.isPressed(MappedInputManager::Button::Back) && mappedInput.getHeldTime() >= GO_HOME_MS) { if (basepath != "/") { @@ -178,92 +126,85 @@ void MyLibraryActivity::loop() { return; } - const bool upReleased = mappedInput.wasReleased(MappedInputManager::Button::Up); - const bool downReleased = mappedInput.wasReleased(MappedInputManager::Button::Down); - const bool leftReleased = mappedInput.wasReleased(MappedInputManager::Button::Left); - const bool rightReleased = mappedInput.wasReleased(MappedInputManager::Button::Right); + const bool upReleased = mappedInput.wasReleased(MappedInputManager::Button::Left); + const bool downReleased = mappedInput.wasReleased(MappedInputManager::Button::Right); + const bool leftReleased = mappedInput.wasReleased(MappedInputManager::Button::Up); + const bool rightReleased = mappedInput.wasReleased(MappedInputManager::Button::Down); const bool skipPage = mappedInput.getHeldTime() > SKIP_PAGE_MS; + const int pageItems = UITheme::getNumberOfItemsPerPage(renderer, true, true, true); - // Confirm button - open selected item if (mappedInput.wasReleased(MappedInputManager::Button::Confirm)) { if (currentTab == Tab::Recent) { if (!recentBooks.empty() && selectorIndex < static_cast(recentBooks.size())) { + Serial.printf("Selected recent book: %s\n", recentBooks[selectorIndex].path.c_str()); onSelectBook(recentBooks[selectorIndex].path, currentTab); + return; } } else { - // Files tab - if (!files.empty() && selectorIndex < static_cast(files.size())) { - if (basepath.back() != '/') basepath += "/"; - if (files[selectorIndex].back() == '/') { - // Enter directory - basepath += files[selectorIndex].substr(0, files[selectorIndex].length() - 1); - loadFiles(); - selectorIndex = 0; - updateRequired = true; - } else { - // Open file - onSelectBook(basepath + files[selectorIndex], currentTab); - } + if (files.empty()) { + return; + } + + if (basepath.back() != '/') basepath += "/"; + if (files[selectorIndex].back() == '/') { + basepath += files[selectorIndex].substr(0, files[selectorIndex].length() - 1); + loadFiles(); + selectorIndex = 0; + updateRequired = true; + } else { + onSelectBook(basepath + files[selectorIndex], currentTab); + return; } } - return; } - // Back button if (mappedInput.wasReleased(MappedInputManager::Button::Back)) { + // Short press: go up one directory, or go home if at root if (mappedInput.getHeldTime() < GO_HOME_MS) { if (currentTab == Tab::Files && basepath != "/") { - // Go up one directory, remembering the directory we came from const std::string oldPath = basepath; + basepath.replace(basepath.find_last_of('/'), std::string::npos, ""); if (basepath.empty()) basepath = "/"; loadFiles(); - // Select the directory we just came from const auto pos = oldPath.find_last_of('/'); const std::string dirName = oldPath.substr(pos + 1) + "/"; - selectorIndex = static_cast(findEntry(dirName)); + selectorIndex = findEntry(dirName); updateRequired = true; } else { - // Go home onGoHome(); } } - return; } // Tab switching: Left/Right always control tabs - if (leftReleased && currentTab == Tab::Files) { - currentTab = Tab::Recent; - selectorIndex = 0; - updateRequired = true; - return; - } - if (rightReleased && currentTab == Tab::Recent) { - currentTab = Tab::Files; - selectorIndex = 0; - updateRequired = true; - return; - } - - // Navigation: Up/Down moves through items only - const bool prevReleased = upReleased; - const bool nextReleased = downReleased; - - if (prevReleased && itemCount > 0) { - if (skipPage) { - selectorIndex = ((selectorIndex / pageItems - 1) * pageItems + itemCount) % itemCount; + if (leftReleased || rightReleased) { + if (currentTab == Tab::Files) { + currentTab = Tab::Recent; } else { - selectorIndex = (selectorIndex + itemCount - 1) % itemCount; + currentTab = Tab::Files; + } + selectorIndex = 0; + updateRequired = true; + return; + } + + int listSize = (currentTab == Tab::Recent) ? static_cast(recentBooks.size()) : static_cast(files.size()); + if (upReleased) { + if (skipPage) { + selectorIndex = ((selectorIndex / pageItems - 1) * pageItems + listSize) % listSize; + } else { + selectorIndex = (selectorIndex + listSize - 1) % listSize; } updateRequired = true; - } else if (nextReleased && itemCount > 0) { + } else if (downReleased) { if (skipPage) { - selectorIndex = ((selectorIndex / pageItems + 1) * pageItems) % itemCount; + selectorIndex = ((selectorIndex / pageItems + 1) * pageItems) % listSize; } else { - selectorIndex = (selectorIndex + 1) % itemCount; + selectorIndex = (selectorIndex + 1) % listSize; } updateRequired = true; } @@ -284,100 +225,47 @@ void MyLibraryActivity::displayTaskLoop() { void MyLibraryActivity::render() const { renderer.clearScreen(); - // Draw tab bar - std::vector tabs = {{"Recent", currentTab == Tab::Recent}, {"Files", currentTab == Tab::Files}}; - ScreenComponents::drawTabBar(renderer, TAB_BAR_Y, tabs); + const auto pageWidth = renderer.getScreenWidth(); + const auto pageHeight = renderer.getScreenHeight(); + auto metrics = UITheme::getMetrics(); - // Draw content based on current tab + auto folderName = basepath == "/" ? "SD card" : basepath.substr(basepath.rfind('/') + 1).c_str(); + UITheme::drawHeader(renderer, Rect{0, metrics.topPadding, pageWidth, metrics.headerHeight}, folderName); + + UITheme::drawTabBar(renderer, Rect{0, metrics.topPadding + metrics.headerHeight, pageWidth, metrics.tabBarHeight}, + {{"Recent", currentTab == Tab::Recent}, {"Files", currentTab == Tab::Files}}); + + const int contentTop = metrics.topPadding + metrics.headerHeight + metrics.tabBarHeight + metrics.verticalSpacing; + const int contentHeight = pageHeight - contentTop - metrics.buttonHintsHeight - metrics.verticalSpacing * 2; if (currentTab == Tab::Recent) { - renderRecentTab(); + // Recent tab + if (recentBooks.empty()) { + renderer.drawText(UI_10_FONT_ID, metrics.contentSidePadding, contentTop + 20, "No recent books"); + } else { + UITheme::drawList( + renderer, Rect{0, contentTop, pageWidth, contentHeight}, recentBooks.size(), selectorIndex, + [this](int index) { return recentBooks[index].title; }, false, nullptr, false, nullptr); + } } else { - renderFilesTab(); + if (files.empty()) { + renderer.drawText(UI_10_FONT_ID, metrics.contentSidePadding, contentTop + 20, "No books found"); + } else { + UITheme::drawList( + renderer, Rect{0, contentTop, pageWidth, contentHeight}, files.size(), selectorIndex, + [this](int index) { return files[index]; }, false, nullptr, false, nullptr); + } } - // Draw scroll indicator - const int screenHeight = renderer.getScreenHeight(); - const int contentHeight = screenHeight - CONTENT_START_Y - 60; // 60 for bottom bar - ScreenComponents::drawScrollIndicator(renderer, getCurrentPage(), getTotalPages(), CONTENT_START_Y, contentHeight); - - // Draw side button hints (up/down navigation on right side) - // Note: text is rotated 90° CW, so ">" appears as "^" and "<" appears as "v" - renderer.drawSideButtonHints(UI_10_FONT_ID, ">", "<"); - - // Draw bottom button hints - const auto labels = mappedInput.mapLabels("« Back", "Open", "<", ">"); - renderer.drawButtonHints(UI_10_FONT_ID, labels.btn1, labels.btn2, labels.btn3, labels.btn4); + // Help text + const auto labels = mappedInput.mapLabels("« Home", "Open", "Up", "Down"); + UITheme::drawButtonHints(renderer, labels.btn1, labels.btn2, labels.btn3, labels.btn4); + UITheme::drawSideButtonHints(renderer, "^", "v"); renderer.displayBuffer(); } -void MyLibraryActivity::renderRecentTab() const { - const auto pageWidth = renderer.getScreenWidth(); - const int pageItems = getPageItems(); - const int bookCount = static_cast(recentBooks.size()); - - if (bookCount == 0) { - renderer.drawText(UI_10_FONT_ID, LEFT_MARGIN, CONTENT_START_Y, "No recent books"); - return; - } - - const auto pageStartIndex = selectorIndex / pageItems * pageItems; - - // Draw selection highlight - renderer.fillRect(0, CONTENT_START_Y + (selectorIndex % pageItems) * RECENTS_LINE_HEIGHT - 2, - pageWidth - RIGHT_MARGIN, RECENTS_LINE_HEIGHT); - - // Draw items - for (int i = pageStartIndex; i < bookCount && i < pageStartIndex + pageItems; i++) { - const auto& book = recentBooks[i]; - const int y = CONTENT_START_Y + (i % pageItems) * RECENTS_LINE_HEIGHT; - - // Line 1: Title - std::string title = book.title; - if (title.empty()) { - // Fallback for older entries or files without metadata - title = book.path; - const size_t lastSlash = title.find_last_of('/'); - if (lastSlash != std::string::npos) { - title = title.substr(lastSlash + 1); - } - const size_t dot = title.find_last_of('.'); - if (dot != std::string::npos) { - title.resize(dot); - } - } - auto truncatedTitle = renderer.truncatedText(UI_12_FONT_ID, title.c_str(), pageWidth - LEFT_MARGIN - RIGHT_MARGIN); - renderer.drawText(UI_12_FONT_ID, LEFT_MARGIN, y + 2, truncatedTitle.c_str(), i != selectorIndex); - - // Line 2: Author - if (!book.author.empty()) { - auto truncatedAuthor = - renderer.truncatedText(UI_10_FONT_ID, book.author.c_str(), pageWidth - LEFT_MARGIN - RIGHT_MARGIN); - renderer.drawText(UI_10_FONT_ID, LEFT_MARGIN, y + 32, truncatedAuthor.c_str(), i != selectorIndex); - } - } -} - -void MyLibraryActivity::renderFilesTab() const { - const auto pageWidth = renderer.getScreenWidth(); - const int pageItems = getPageItems(); - const int fileCount = static_cast(files.size()); - - if (fileCount == 0) { - renderer.drawText(UI_10_FONT_ID, LEFT_MARGIN, CONTENT_START_Y, "No books found"); - return; - } - - const auto pageStartIndex = selectorIndex / pageItems * pageItems; - - // Draw selection highlight - renderer.fillRect(0, CONTENT_START_Y + (selectorIndex % pageItems) * LINE_HEIGHT - 2, pageWidth - RIGHT_MARGIN, - LINE_HEIGHT); - - // Draw items - for (int i = pageStartIndex; i < fileCount && i < pageStartIndex + pageItems; i++) { - auto item = renderer.truncatedText(UI_10_FONT_ID, files[i].c_str(), pageWidth - LEFT_MARGIN - RIGHT_MARGIN); - renderer.drawText(UI_10_FONT_ID, LEFT_MARGIN, CONTENT_START_Y + (i % pageItems) * LINE_HEIGHT, item.c_str(), - i != selectorIndex); - } +size_t MyLibraryActivity::findEntry(const std::string& name) const { + for (size_t i = 0; i < files.size(); i++) + if (files[i] == name) return i; + return 0; } diff --git a/src/activities/home/MyLibraryActivity.h b/src/activities/home/MyLibraryActivity.h index 39a27ed7..142cc071 100644 --- a/src/activities/home/MyLibraryActivity.h +++ b/src/activities/home/MyLibraryActivity.h @@ -19,7 +19,7 @@ class MyLibraryActivity final : public Activity { SemaphoreHandle_t renderingMutex = nullptr; Tab currentTab = Tab::Recent; - int selectorIndex = 0; + size_t selectorIndex = 0; bool updateRequired = false; // Recent tab state @@ -30,37 +30,28 @@ class MyLibraryActivity final : public Activity { std::vector files; // Callbacks - const std::function onGoHome; const std::function onSelectBook; + const std::function onGoHome; - // Number of items that fit on a page - int getPageItems() const; - int getCurrentItemCount() const; - int getTotalPages() const; - int getCurrentPage() const; + static void taskTrampoline(void* param); + [[noreturn]] void displayTaskLoop(); + void render() const; // Data loading void loadRecentBooks(); void loadFiles(); size_t findEntry(const std::string& name) const; - // Rendering - static void taskTrampoline(void* param); - [[noreturn]] void displayTaskLoop(); - void render() const; - void renderRecentTab() const; - void renderFilesTab() const; - public: explicit MyLibraryActivity(GfxRenderer& renderer, MappedInputManager& mappedInput, const std::function& onGoHome, const std::function& onSelectBook, Tab initialTab = Tab::Recent, std::string initialPath = "/") : Activity("MyLibrary", renderer, mappedInput), - currentTab(initialTab), basepath(initialPath.empty() ? "/" : std::move(initialPath)), - onGoHome(onGoHome), - onSelectBook(onSelectBook) {} + currentTab(initialTab), + onSelectBook(onSelectBook), + onGoHome(onGoHome) {} void onEnter() override; void onExit() override; void loop() override; diff --git a/src/activities/network/CalibreConnectActivity.cpp b/src/activities/network/CalibreConnectActivity.cpp index 8aa60c40..db90a247 100644 --- a/src/activities/network/CalibreConnectActivity.cpp +++ b/src/activities/network/CalibreConnectActivity.cpp @@ -6,8 +6,8 @@ #include #include "MappedInputManager.h" -#include "ScreenComponents.h" #include "WifiSelectionActivity.h" +#include "components/UITheme.h" #include "fontIds.h" namespace { @@ -258,8 +258,8 @@ void CalibreConnectActivity::renderServerRunning() const { constexpr int barWidth = 300; constexpr int barHeight = 16; constexpr int barX = (480 - barWidth) / 2; - ScreenComponents::drawProgressBar(renderer, barX, y + 22, barWidth, barHeight, lastProgressReceived, - lastProgressTotal); + UITheme::drawProgressBar(renderer, Rect{barX, y + 22, barWidth, barHeight}, lastProgressReceived, + lastProgressTotal); y += 40; } @@ -272,5 +272,5 @@ void CalibreConnectActivity::renderServerRunning() const { } const auto labels = mappedInput.mapLabels("« Exit", "", "", ""); - renderer.drawButtonHints(UI_10_FONT_ID, labels.btn1, labels.btn2, labels.btn3, labels.btn4); + UITheme::drawButtonHints(renderer, labels.btn1, labels.btn2, labels.btn3, labels.btn4); } diff --git a/src/activities/network/CrossPointWebServerActivity.cpp b/src/activities/network/CrossPointWebServerActivity.cpp index c6af1497..22b7889c 100644 --- a/src/activities/network/CrossPointWebServerActivity.cpp +++ b/src/activities/network/CrossPointWebServerActivity.cpp @@ -13,6 +13,7 @@ #include "NetworkModeSelectionActivity.h" #include "WifiSelectionActivity.h" #include "activities/network/CalibreConnectActivity.h" +#include "components/UITheme.h" #include "fontIds.h" namespace { @@ -479,5 +480,5 @@ void CrossPointWebServerActivity::renderServerRunning() const { } const auto labels = mappedInput.mapLabels("« Exit", "", "", ""); - renderer.drawButtonHints(UI_10_FONT_ID, labels.btn1, labels.btn2, labels.btn3, labels.btn4); + UITheme::drawButtonHints(renderer, labels.btn1, labels.btn2, labels.btn3, labels.btn4); } diff --git a/src/activities/network/NetworkModeSelectionActivity.cpp b/src/activities/network/NetworkModeSelectionActivity.cpp index 50767084..581e9d9d 100644 --- a/src/activities/network/NetworkModeSelectionActivity.cpp +++ b/src/activities/network/NetworkModeSelectionActivity.cpp @@ -3,6 +3,7 @@ #include #include "MappedInputManager.h" +#include "components/UITheme.h" #include "fontIds.h" namespace { @@ -131,7 +132,7 @@ void NetworkModeSelectionActivity::render() const { // Draw help text at bottom const auto labels = mappedInput.mapLabels("« Back", "Select", "", ""); - renderer.drawButtonHints(UI_10_FONT_ID, labels.btn1, labels.btn2, labels.btn3, labels.btn4); + UITheme::drawButtonHints(renderer, labels.btn1, labels.btn2, labels.btn3, labels.btn4); renderer.displayBuffer(); } diff --git a/src/activities/network/WifiSelectionActivity.cpp b/src/activities/network/WifiSelectionActivity.cpp index 5c45223b..eadcb7c4 100644 --- a/src/activities/network/WifiSelectionActivity.cpp +++ b/src/activities/network/WifiSelectionActivity.cpp @@ -8,6 +8,7 @@ #include "MappedInputManager.h" #include "WifiCredentialStore.h" #include "activities/util/KeyboardEntryActivity.h" +#include "components/UITheme.h" #include "fontIds.h" void WifiSelectionActivity::taskTrampoline(void* param) { @@ -586,7 +587,7 @@ void WifiSelectionActivity::renderNetworkList() const { // Draw help text renderer.drawText(SMALL_FONT_ID, 20, pageHeight - 75, "* = Encrypted | + = Saved"); const auto labels = mappedInput.mapLabels("« Back", "Connect", "", ""); - renderer.drawButtonHints(UI_10_FONT_ID, labels.btn1, labels.btn2, labels.btn3, labels.btn4); + UITheme::drawButtonHints(renderer, labels.btn1, labels.btn2, labels.btn3, labels.btn4); } void WifiSelectionActivity::renderConnecting() const { @@ -625,7 +626,7 @@ void WifiSelectionActivity::renderConnected() const { // Use centralized button hints const auto labels = mappedInput.mapLabels("", "Continue", "", ""); - renderer.drawButtonHints(UI_10_FONT_ID, labels.btn1, labels.btn2, labels.btn3, labels.btn4); + UITheme::drawButtonHints(renderer, labels.btn1, labels.btn2, labels.btn3, labels.btn4); } void WifiSelectionActivity::renderSavePrompt() const { @@ -667,7 +668,7 @@ void WifiSelectionActivity::renderSavePrompt() const { // Use centralized button hints const auto labels = mappedInput.mapLabels("« Skip", "Select", "Left", "Right"); - renderer.drawButtonHints(UI_10_FONT_ID, labels.btn1, labels.btn2, labels.btn3, labels.btn4); + UITheme::drawButtonHints(renderer, labels.btn1, labels.btn2, labels.btn3, labels.btn4); } void WifiSelectionActivity::renderConnectionFailed() const { @@ -680,7 +681,7 @@ void WifiSelectionActivity::renderConnectionFailed() const { // Use centralized button hints const auto labels = mappedInput.mapLabels("« Back", "Continue", "", ""); - renderer.drawButtonHints(UI_10_FONT_ID, labels.btn1, labels.btn2, labels.btn3, labels.btn4); + UITheme::drawButtonHints(renderer, labels.btn1, labels.btn2, labels.btn3, labels.btn4); } void WifiSelectionActivity::renderForgetPrompt() const { @@ -722,5 +723,5 @@ void WifiSelectionActivity::renderForgetPrompt() const { // Use centralized button hints const auto labels = mappedInput.mapLabels("« Back", "Select", "Left", "Right"); - renderer.drawButtonHints(UI_10_FONT_ID, labels.btn1, labels.btn2, labels.btn3, labels.btn4); + UITheme::drawButtonHints(renderer, labels.btn1, labels.btn2, labels.btn3, labels.btn4); } diff --git a/src/activities/reader/EpubReaderActivity.cpp b/src/activities/reader/EpubReaderActivity.cpp index 58668c68..aa8a428b 100644 --- a/src/activities/reader/EpubReaderActivity.cpp +++ b/src/activities/reader/EpubReaderActivity.cpp @@ -10,7 +10,7 @@ #include "EpubReaderChapterSelectionActivity.h" #include "MappedInputManager.h" #include "RecentBooksStore.h" -#include "ScreenComponents.h" +#include "components/UITheme.h" #include "fontIds.h" namespace { @@ -286,13 +286,15 @@ void EpubReaderActivity::renderScreen() { orientedMarginRight += SETTINGS.screenMargin; orientedMarginBottom += SETTINGS.screenMargin; + auto metrics = UITheme::getMetrics(); + // Add status bar margin if (SETTINGS.statusBar != CrossPointSettings::STATUS_BAR_MODE::NONE) { // Add additional margin for status bar if progress bar is shown const bool showProgressBar = SETTINGS.statusBar == CrossPointSettings::STATUS_BAR_MODE::FULL_WITH_PROGRESS_BAR || SETTINGS.statusBar == CrossPointSettings::STATUS_BAR_MODE::ONLY_PROGRESS_BAR; orientedMarginBottom += statusBarMargin - SETTINGS.screenMargin + - (showProgressBar ? (ScreenComponents::BOOK_PROGRESS_BAR_HEIGHT + progressBarMarginTop) : 0); + (showProgressBar ? (metrics.bookProgressBarHeight + progressBarMarginTop) : 0); } if (!section) { @@ -308,49 +310,12 @@ void EpubReaderActivity::renderScreen() { viewportHeight, SETTINGS.hyphenationEnabled)) { Serial.printf("[%lu] [ERS] Cache not found, building...\n", millis()); - // Progress bar dimensions - constexpr int barWidth = 200; - constexpr int barHeight = 10; - constexpr int boxMargin = 20; - const int textWidth = renderer.getTextWidth(UI_12_FONT_ID, "Indexing..."); - const int boxWidthWithBar = (barWidth > textWidth ? barWidth : textWidth) + boxMargin * 2; - const int boxWidthNoBar = textWidth + boxMargin * 2; - const int boxHeightWithBar = renderer.getLineHeight(UI_12_FONT_ID) + barHeight + boxMargin * 3; - const int boxHeightNoBar = renderer.getLineHeight(UI_12_FONT_ID) + boxMargin * 2; - const int boxXWithBar = (renderer.getScreenWidth() - boxWidthWithBar) / 2; - const int boxXNoBar = (renderer.getScreenWidth() - boxWidthNoBar) / 2; - constexpr int boxY = 50; - const int barX = boxXWithBar + (boxWidthWithBar - barWidth) / 2; - const int barY = boxY + renderer.getLineHeight(UI_12_FONT_ID) + boxMargin * 2; - - // Always show "Indexing..." text first - { - renderer.fillRect(boxXNoBar, boxY, boxWidthNoBar, boxHeightNoBar, false); - renderer.drawText(UI_12_FONT_ID, boxXNoBar + boxMargin, boxY + boxMargin, "Indexing..."); - renderer.drawRect(boxXNoBar + 5, boxY + 5, boxWidthNoBar - 10, boxHeightNoBar - 10); - renderer.displayBuffer(); - pagesUntilFullRefresh = 0; - } - - // Setup callback - only called for chapters >= 50KB, redraws with progress bar - auto progressSetup = [this, boxXWithBar, boxWidthWithBar, boxHeightWithBar, barX, barY] { - renderer.fillRect(boxXWithBar, boxY, boxWidthWithBar, boxHeightWithBar, false); - renderer.drawText(UI_12_FONT_ID, boxXWithBar + boxMargin, boxY + boxMargin, "Indexing..."); - renderer.drawRect(boxXWithBar + 5, boxY + 5, boxWidthWithBar - 10, boxHeightWithBar - 10); - renderer.drawRect(barX, barY, barWidth, barHeight); - renderer.displayBuffer(); - }; - - // Progress callback to update progress bar - auto progressCallback = [this, barX, barY, barWidth, barHeight](int progress) { - const int fillWidth = (barWidth - 2) * progress / 100; - renderer.fillRect(barX + 1, barY + 1, fillWidth, barHeight - 2, true); - renderer.displayBuffer(HalDisplay::FAST_REFRESH); - }; + auto popupCallbacks = UITheme::drawPopupWithProgress(renderer, "Indexing..."); if (!section->createSectionFile(SETTINGS.getReaderFontId(), SETTINGS.getReaderLineCompression(), SETTINGS.extraParagraphSpacing, SETTINGS.paragraphAlignment, viewportWidth, - viewportHeight, SETTINGS.hyphenationEnabled, progressSetup, progressCallback)) { + viewportHeight, SETTINGS.hyphenationEnabled, popupCallbacks.setup, + popupCallbacks.update)) { Serial.printf("[%lu] [ERS] Failed to persist page data to SD\n", millis()); section.reset(); return; @@ -463,6 +428,8 @@ void EpubReaderActivity::renderContents(std::unique_ptr page, const int or void EpubReaderActivity::renderStatusBar(const int orientedMarginRight, const int orientedMarginBottom, const int orientedMarginLeft) const { + auto metrics = UITheme::getMetrics(); + // determine visible status bar elements const bool showProgressPercentage = SETTINGS.statusBar == CrossPointSettings::STATUS_BAR_MODE::FULL; const bool showProgressBar = SETTINGS.statusBar == CrossPointSettings::STATUS_BAR_MODE::FULL_WITH_PROGRESS_BAR || @@ -506,11 +473,12 @@ void EpubReaderActivity::renderStatusBar(const int orientedMarginRight, const in if (showProgressBar) { // Draw progress bar at the very bottom of the screen, from edge to edge of viewable area - ScreenComponents::drawBookProgressBar(renderer, static_cast(bookProgress)); + UITheme::drawBookProgressBar(renderer, static_cast(bookProgress)); } if (showBattery) { - ScreenComponents::drawBattery(renderer, orientedMarginLeft + 1, textY, showBatteryPercentage); + UITheme::drawBattery(renderer, Rect{orientedMarginLeft + 1, textY, metrics.batteryWidth, metrics.batteryHeight}, + showBatteryPercentage); } if (showChapterTitle) { diff --git a/src/activities/reader/EpubReaderChapterSelectionActivity.cpp b/src/activities/reader/EpubReaderChapterSelectionActivity.cpp index 1b35e143..ef328c96 100644 --- a/src/activities/reader/EpubReaderChapterSelectionActivity.cpp +++ b/src/activities/reader/EpubReaderChapterSelectionActivity.cpp @@ -5,6 +5,7 @@ #include "KOReaderCredentialStore.h" #include "KOReaderSyncActivity.h" #include "MappedInputManager.h" +#include "components/UITheme.h" #include "fontIds.h" namespace { @@ -209,7 +210,7 @@ void EpubReaderChapterSelectionActivity::renderScreen() { } const auto labels = mappedInput.mapLabels("« Back", "Select", "Up", "Down"); - renderer.drawButtonHints(UI_10_FONT_ID, labels.btn1, labels.btn2, labels.btn3, labels.btn4); + UITheme::drawButtonHints(renderer, labels.btn1, labels.btn2, labels.btn3, labels.btn4); renderer.displayBuffer(); } diff --git a/src/activities/reader/KOReaderSyncActivity.cpp b/src/activities/reader/KOReaderSyncActivity.cpp index 4a85f23d..b732169f 100644 --- a/src/activities/reader/KOReaderSyncActivity.cpp +++ b/src/activities/reader/KOReaderSyncActivity.cpp @@ -8,6 +8,7 @@ #include "KOReaderDocumentId.h" #include "MappedInputManager.h" #include "activities/network/WifiSelectionActivity.h" +#include "components/UITheme.h" #include "fontIds.h" namespace { @@ -266,7 +267,7 @@ void KOReaderSyncActivity::render() { renderer.drawCenteredText(UI_10_FONT_ID, 320, "Set up KOReader account in Settings"); const auto labels = mappedInput.mapLabels("Back", "", "", ""); - renderer.drawButtonHints(UI_10_FONT_ID, labels.btn1, labels.btn2, labels.btn3, labels.btn4); + UITheme::drawButtonHints(renderer, labels.btn1, labels.btn2, labels.btn3, labels.btn4); renderer.displayBuffer(); return; } @@ -339,7 +340,7 @@ void KOReaderSyncActivity::render() { renderer.drawText(UI_10_FONT_ID, 20, optionY + optionHeight * 2, "Cancel", selectedOption != 2); const auto labels = mappedInput.mapLabels("", "Select", "", ""); - renderer.drawButtonHints(UI_10_FONT_ID, labels.btn1, labels.btn2, labels.btn3, labels.btn4); + UITheme::drawButtonHints(renderer, labels.btn1, labels.btn2, labels.btn3, labels.btn4); renderer.displayBuffer(); return; } @@ -349,7 +350,7 @@ void KOReaderSyncActivity::render() { renderer.drawCenteredText(UI_10_FONT_ID, 320, "Upload current position?"); const auto labels = mappedInput.mapLabels("Cancel", "Upload", "", ""); - renderer.drawButtonHints(UI_10_FONT_ID, labels.btn1, labels.btn2, labels.btn3, labels.btn4); + UITheme::drawButtonHints(renderer, labels.btn1, labels.btn2, labels.btn3, labels.btn4); renderer.displayBuffer(); return; } @@ -358,7 +359,7 @@ void KOReaderSyncActivity::render() { renderer.drawCenteredText(UI_10_FONT_ID, 300, "Progress uploaded!", true, EpdFontFamily::BOLD); const auto labels = mappedInput.mapLabels("Back", "", "", ""); - renderer.drawButtonHints(UI_10_FONT_ID, labels.btn1, labels.btn2, labels.btn3, labels.btn4); + UITheme::drawButtonHints(renderer, labels.btn1, labels.btn2, labels.btn3, labels.btn4); renderer.displayBuffer(); return; } @@ -368,7 +369,7 @@ void KOReaderSyncActivity::render() { renderer.drawCenteredText(UI_10_FONT_ID, 320, statusMessage.c_str()); const auto labels = mappedInput.mapLabels("Back", "", "", ""); - renderer.drawButtonHints(UI_10_FONT_ID, labels.btn1, labels.btn2, labels.btn3, labels.btn4); + UITheme::drawButtonHints(renderer, labels.btn1, labels.btn2, labels.btn3, labels.btn4); renderer.displayBuffer(); return; } diff --git a/src/activities/reader/TxtReaderActivity.cpp b/src/activities/reader/TxtReaderActivity.cpp index e9303de3..e38b549e 100644 --- a/src/activities/reader/TxtReaderActivity.cpp +++ b/src/activities/reader/TxtReaderActivity.cpp @@ -9,7 +9,7 @@ #include "CrossPointState.h" #include "MappedInputManager.h" #include "RecentBooksStore.h" -#include "ScreenComponents.h" +#include "components/UITheme.h" #include "fontIds.h" namespace { @@ -168,13 +168,15 @@ void TxtReaderActivity::initializeReader() { orientedMarginRight += cachedScreenMargin; orientedMarginBottom += cachedScreenMargin; + auto metrics = UITheme::getMetrics(); + // Add status bar margin if (SETTINGS.statusBar != CrossPointSettings::STATUS_BAR_MODE::NONE) { // Add additional margin for status bar if progress bar is shown const bool showProgressBar = SETTINGS.statusBar == CrossPointSettings::STATUS_BAR_MODE::FULL_WITH_PROGRESS_BAR || SETTINGS.statusBar == CrossPointSettings::STATUS_BAR_MODE::ONLY_PROGRESS_BAR; orientedMarginBottom += statusBarMargin - cachedScreenMargin + - (showProgressBar ? (ScreenComponents::BOOK_PROGRESS_BAR_HEIGHT + progressBarMarginTop) : 0); + (showProgressBar ? (metrics.bookProgressBarHeight + progressBarMarginTop) : 0); } viewportWidth = renderer.getScreenWidth() - orientedMarginLeft - orientedMarginRight; @@ -530,6 +532,7 @@ void TxtReaderActivity::renderStatusBar(const int orientedMarginRight, const int const bool showBatteryPercentage = SETTINGS.hideBatteryPercentage == CrossPointSettings::HIDE_BATTERY_PERCENTAGE::HIDE_NEVER; + auto metrics = UITheme::getMetrics(); const auto screenHeight = renderer.getScreenHeight(); const auto textY = screenHeight - orientedMarginBottom - 4; int progressTextWidth = 0; @@ -551,11 +554,12 @@ void TxtReaderActivity::renderStatusBar(const int orientedMarginRight, const int if (showProgressBar) { // Draw progress bar at the very bottom of the screen, from edge to edge of viewable area - ScreenComponents::drawBookProgressBar(renderer, static_cast(progress)); + UITheme::drawBookProgressBar(renderer, static_cast(progress)); } if (showBattery) { - ScreenComponents::drawBattery(renderer, orientedMarginLeft, textY, showBatteryPercentage); + UITheme::drawBattery(renderer, Rect{orientedMarginLeft, textY, metrics.batteryWidth, metrics.batteryHeight}, + showBatteryPercentage); } if (showTitle) { diff --git a/src/activities/reader/XtcReaderChapterSelectionActivity.cpp b/src/activities/reader/XtcReaderChapterSelectionActivity.cpp index b2cfecaa..88288b2a 100644 --- a/src/activities/reader/XtcReaderChapterSelectionActivity.cpp +++ b/src/activities/reader/XtcReaderChapterSelectionActivity.cpp @@ -3,6 +3,7 @@ #include #include "MappedInputManager.h" +#include "components/UITheme.h" #include "fontIds.h" namespace { @@ -150,7 +151,7 @@ void XtcReaderChapterSelectionActivity::renderScreen() { } const auto labels = mappedInput.mapLabels("« Back", "Select", "Up", "Down"); - renderer.drawButtonHints(UI_10_FONT_ID, labels.btn1, labels.btn2, labels.btn3, labels.btn4); + UITheme::drawButtonHints(renderer, labels.btn1, labels.btn2, labels.btn3, labels.btn4); renderer.displayBuffer(); } diff --git a/src/activities/settings/CalibreSettingsActivity.cpp b/src/activities/settings/CalibreSettingsActivity.cpp index d1df9d0e..be9a1d76 100644 --- a/src/activities/settings/CalibreSettingsActivity.cpp +++ b/src/activities/settings/CalibreSettingsActivity.cpp @@ -7,6 +7,7 @@ #include "CrossPointSettings.h" #include "MappedInputManager.h" #include "activities/util/KeyboardEntryActivity.h" +#include "components/UITheme.h" #include "fontIds.h" namespace { @@ -183,7 +184,7 @@ void CalibreSettingsActivity::render() { // Draw button hints const auto labels = mappedInput.mapLabels("« Back", "Select", "", ""); - renderer.drawButtonHints(UI_10_FONT_ID, labels.btn1, labels.btn2, labels.btn3, labels.btn4); + UITheme::drawButtonHints(renderer, labels.btn1, labels.btn2, labels.btn3, labels.btn4); renderer.displayBuffer(); } diff --git a/src/activities/settings/CategorySettingsActivity.cpp b/src/activities/settings/CategorySettingsActivity.cpp deleted file mode 100644 index 7fd5ef5f..00000000 --- a/src/activities/settings/CategorySettingsActivity.cpp +++ /dev/null @@ -1,193 +0,0 @@ -#include "CategorySettingsActivity.h" - -#include -#include - -#include - -#include "CalibreSettingsActivity.h" -#include "ClearCacheActivity.h" -#include "CrossPointSettings.h" -#include "KOReaderSettingsActivity.h" -#include "MappedInputManager.h" -#include "OtaUpdateActivity.h" -#include "fontIds.h" - -void CategorySettingsActivity::taskTrampoline(void* param) { - auto* self = static_cast(param); - self->displayTaskLoop(); -} - -void CategorySettingsActivity::onEnter() { - Activity::onEnter(); - renderingMutex = xSemaphoreCreateMutex(); - - selectedSettingIndex = 0; - updateRequired = true; - - xTaskCreate(&CategorySettingsActivity::taskTrampoline, "CategorySettingsActivityTask", 4096, this, 1, - &displayTaskHandle); -} - -void CategorySettingsActivity::onExit() { - ActivityWithSubactivity::onExit(); - - // Wait until not rendering to delete task to avoid killing mid-instruction to EPD - xSemaphoreTake(renderingMutex, portMAX_DELAY); - if (displayTaskHandle) { - vTaskDelete(displayTaskHandle); - displayTaskHandle = nullptr; - } - vSemaphoreDelete(renderingMutex); - renderingMutex = nullptr; -} - -void CategorySettingsActivity::loop() { - if (subActivity) { - subActivity->loop(); - return; - } - - // Handle actions with early return - if (mappedInput.wasPressed(MappedInputManager::Button::Confirm)) { - toggleCurrentSetting(); - updateRequired = true; - return; - } - - if (mappedInput.wasPressed(MappedInputManager::Button::Back)) { - SETTINGS.saveToFile(); - onGoBack(); - return; - } - - // Handle navigation - if (mappedInput.wasPressed(MappedInputManager::Button::Up) || - mappedInput.wasPressed(MappedInputManager::Button::Left)) { - selectedSettingIndex = (selectedSettingIndex > 0) ? (selectedSettingIndex - 1) : (settingsCount - 1); - updateRequired = true; - } else if (mappedInput.wasPressed(MappedInputManager::Button::Down) || - mappedInput.wasPressed(MappedInputManager::Button::Right)) { - selectedSettingIndex = (selectedSettingIndex < settingsCount - 1) ? (selectedSettingIndex + 1) : 0; - updateRequired = true; - } -} - -void CategorySettingsActivity::toggleCurrentSetting() { - if (selectedSettingIndex < 0 || selectedSettingIndex >= settingsCount) { - return; - } - - const auto& setting = settingsList[selectedSettingIndex]; - - if (setting.type == SettingType::TOGGLE && setting.valuePtr != nullptr) { - // Toggle the boolean value using the member pointer - const bool currentValue = SETTINGS.*(setting.valuePtr); - SETTINGS.*(setting.valuePtr) = !currentValue; - } else if (setting.type == SettingType::ENUM && setting.valuePtr != nullptr) { - const uint8_t currentValue = SETTINGS.*(setting.valuePtr); - SETTINGS.*(setting.valuePtr) = (currentValue + 1) % static_cast(setting.enumValues.size()); - } else if (setting.type == SettingType::VALUE && setting.valuePtr != nullptr) { - const int8_t currentValue = SETTINGS.*(setting.valuePtr); - if (currentValue + setting.valueRange.step > setting.valueRange.max) { - SETTINGS.*(setting.valuePtr) = setting.valueRange.min; - } else { - SETTINGS.*(setting.valuePtr) = currentValue + setting.valueRange.step; - } - } else if (setting.type == SettingType::ACTION) { - if (strcmp(setting.name, "KOReader Sync") == 0) { - xSemaphoreTake(renderingMutex, portMAX_DELAY); - exitActivity(); - enterNewActivity(new KOReaderSettingsActivity(renderer, mappedInput, [this] { - exitActivity(); - updateRequired = true; - })); - xSemaphoreGive(renderingMutex); - } else if (strcmp(setting.name, "OPDS Browser") == 0) { - xSemaphoreTake(renderingMutex, portMAX_DELAY); - exitActivity(); - enterNewActivity(new CalibreSettingsActivity(renderer, mappedInput, [this] { - exitActivity(); - updateRequired = true; - })); - xSemaphoreGive(renderingMutex); - } else if (strcmp(setting.name, "Clear Cache") == 0) { - xSemaphoreTake(renderingMutex, portMAX_DELAY); - exitActivity(); - enterNewActivity(new ClearCacheActivity(renderer, mappedInput, [this] { - exitActivity(); - updateRequired = true; - })); - xSemaphoreGive(renderingMutex); - } else if (strcmp(setting.name, "Check for updates") == 0) { - xSemaphoreTake(renderingMutex, portMAX_DELAY); - exitActivity(); - enterNewActivity(new OtaUpdateActivity(renderer, mappedInput, [this] { - exitActivity(); - updateRequired = true; - })); - xSemaphoreGive(renderingMutex); - } - } else { - return; - } - - SETTINGS.saveToFile(); -} - -void CategorySettingsActivity::displayTaskLoop() { - while (true) { - if (updateRequired && !subActivity) { - updateRequired = false; - xSemaphoreTake(renderingMutex, portMAX_DELAY); - render(); - xSemaphoreGive(renderingMutex); - } - vTaskDelay(10 / portTICK_PERIOD_MS); - } -} - -void CategorySettingsActivity::render() const { - renderer.clearScreen(); - - const auto pageWidth = renderer.getScreenWidth(); - const auto pageHeight = renderer.getScreenHeight(); - - renderer.drawCenteredText(UI_12_FONT_ID, 15, categoryName, true, EpdFontFamily::BOLD); - - // Draw selection highlight - renderer.fillRect(0, 60 + selectedSettingIndex * 30 - 2, pageWidth - 1, 30); - - // Draw all settings - for (int i = 0; i < settingsCount; i++) { - const int settingY = 60 + i * 30; // 30 pixels between settings - const bool isSelected = (i == selectedSettingIndex); - - // Draw setting name - renderer.drawText(UI_10_FONT_ID, 20, settingY, settingsList[i].name, !isSelected); - - // Draw value based on setting type - std::string valueText; - if (settingsList[i].type == SettingType::TOGGLE && settingsList[i].valuePtr != nullptr) { - const bool value = SETTINGS.*(settingsList[i].valuePtr); - valueText = value ? "ON" : "OFF"; - } else if (settingsList[i].type == SettingType::ENUM && settingsList[i].valuePtr != nullptr) { - const uint8_t value = SETTINGS.*(settingsList[i].valuePtr); - valueText = settingsList[i].enumValues[value]; - } else if (settingsList[i].type == SettingType::VALUE && settingsList[i].valuePtr != nullptr) { - valueText = std::to_string(SETTINGS.*(settingsList[i].valuePtr)); - } - if (!valueText.empty()) { - const auto width = renderer.getTextWidth(UI_10_FONT_ID, valueText.c_str()); - renderer.drawText(UI_10_FONT_ID, pageWidth - 20 - width, settingY, valueText.c_str(), !isSelected); - } - } - - renderer.drawText(SMALL_FONT_ID, pageWidth - 20 - renderer.getTextWidth(SMALL_FONT_ID, CROSSPOINT_VERSION), - pageHeight - 60, CROSSPOINT_VERSION); - - const auto labels = mappedInput.mapLabels("« Back", "Toggle", "", ""); - renderer.drawButtonHints(UI_10_FONT_ID, labels.btn1, labels.btn2, labels.btn3, labels.btn4); - - renderer.displayBuffer(); -} diff --git a/src/activities/settings/CategorySettingsActivity.h b/src/activities/settings/CategorySettingsActivity.h deleted file mode 100644 index a7d1f0ce..00000000 --- a/src/activities/settings/CategorySettingsActivity.h +++ /dev/null @@ -1,70 +0,0 @@ -#pragma once -#include -#include -#include - -#include -#include -#include - -#include "activities/ActivityWithSubactivity.h" - -class CrossPointSettings; - -enum class SettingType { TOGGLE, ENUM, ACTION, VALUE }; - -struct SettingInfo { - const char* name; - SettingType type; - uint8_t CrossPointSettings::* valuePtr; - std::vector enumValues; - - struct ValueRange { - uint8_t min; - uint8_t max; - uint8_t step; - }; - ValueRange valueRange; - - static SettingInfo Toggle(const char* name, uint8_t CrossPointSettings::* ptr) { - return {name, SettingType::TOGGLE, ptr}; - } - - static SettingInfo Enum(const char* name, uint8_t CrossPointSettings::* ptr, std::vector values) { - return {name, SettingType::ENUM, ptr, std::move(values)}; - } - - static SettingInfo Action(const char* name) { return {name, SettingType::ACTION, nullptr}; } - - static SettingInfo Value(const char* name, uint8_t CrossPointSettings::* ptr, const ValueRange valueRange) { - return {name, SettingType::VALUE, ptr, {}, valueRange}; - } -}; - -class CategorySettingsActivity final : public ActivityWithSubactivity { - TaskHandle_t displayTaskHandle = nullptr; - SemaphoreHandle_t renderingMutex = nullptr; - bool updateRequired = false; - int selectedSettingIndex = 0; - const char* categoryName; - const SettingInfo* settingsList; - int settingsCount; - const std::function onGoBack; - - static void taskTrampoline(void* param); - [[noreturn]] void displayTaskLoop(); - void render() const; - void toggleCurrentSetting(); - - public: - CategorySettingsActivity(GfxRenderer& renderer, MappedInputManager& mappedInput, const char* categoryName, - const SettingInfo* settingsList, int settingsCount, const std::function& onGoBack) - : ActivityWithSubactivity("CategorySettings", renderer, mappedInput), - categoryName(categoryName), - settingsList(settingsList), - settingsCount(settingsCount), - onGoBack(onGoBack) {} - void onEnter() override; - void onExit() override; - void loop() override; -}; diff --git a/src/activities/settings/ClearCacheActivity.cpp b/src/activities/settings/ClearCacheActivity.cpp index 1e10c14b..4ba27af0 100644 --- a/src/activities/settings/ClearCacheActivity.cpp +++ b/src/activities/settings/ClearCacheActivity.cpp @@ -5,6 +5,7 @@ #include #include "MappedInputManager.h" +#include "components/UITheme.h" #include "fontIds.h" void ClearCacheActivity::taskTrampoline(void* param) { @@ -66,7 +67,7 @@ void ClearCacheActivity::render() { renderer.drawCenteredText(UI_10_FONT_ID, pageHeight / 2 + 30, "when opened again.", true); const auto labels = mappedInput.mapLabels("« Cancel", "Clear", "", ""); - renderer.drawButtonHints(UI_10_FONT_ID, labels.btn1, labels.btn2, labels.btn3, labels.btn4); + UITheme::drawButtonHints(renderer, labels.btn1, labels.btn2, labels.btn3, labels.btn4); renderer.displayBuffer(); return; } @@ -86,7 +87,7 @@ void ClearCacheActivity::render() { renderer.drawCenteredText(UI_10_FONT_ID, pageHeight / 2 + 10, resultText.c_str()); const auto labels = mappedInput.mapLabels("« Back", "", "", ""); - renderer.drawButtonHints(UI_10_FONT_ID, labels.btn1, labels.btn2, labels.btn3, labels.btn4); + UITheme::drawButtonHints(renderer, labels.btn1, labels.btn2, labels.btn3, labels.btn4); renderer.displayBuffer(); return; } @@ -96,7 +97,7 @@ void ClearCacheActivity::render() { renderer.drawCenteredText(UI_10_FONT_ID, pageHeight / 2 + 10, "Check serial output for details"); const auto labels = mappedInput.mapLabels("« Back", "", "", ""); - renderer.drawButtonHints(UI_10_FONT_ID, labels.btn1, labels.btn2, labels.btn3, labels.btn4); + UITheme::drawButtonHints(renderer, labels.btn1, labels.btn2, labels.btn3, labels.btn4); renderer.displayBuffer(); return; } diff --git a/src/activities/settings/KOReaderAuthActivity.cpp b/src/activities/settings/KOReaderAuthActivity.cpp index 8681812f..8b27abe9 100644 --- a/src/activities/settings/KOReaderAuthActivity.cpp +++ b/src/activities/settings/KOReaderAuthActivity.cpp @@ -7,6 +7,7 @@ #include "KOReaderSyncClient.h" #include "MappedInputManager.h" #include "activities/network/WifiSelectionActivity.h" +#include "components/UITheme.h" #include "fontIds.h" void KOReaderAuthActivity::taskTrampoline(void* param) { @@ -136,7 +137,7 @@ void KOReaderAuthActivity::render() { renderer.drawCenteredText(UI_10_FONT_ID, 320, "KOReader sync is ready to use"); const auto labels = mappedInput.mapLabels("Done", "", "", ""); - renderer.drawButtonHints(UI_10_FONT_ID, labels.btn1, labels.btn2, labels.btn3, labels.btn4); + UITheme::drawButtonHints(renderer, labels.btn1, labels.btn2, labels.btn3, labels.btn4); renderer.displayBuffer(); return; } @@ -146,7 +147,7 @@ void KOReaderAuthActivity::render() { renderer.drawCenteredText(UI_10_FONT_ID, 320, errorMessage.c_str()); const auto labels = mappedInput.mapLabels("Back", "", "", ""); - renderer.drawButtonHints(UI_10_FONT_ID, labels.btn1, labels.btn2, labels.btn3, labels.btn4); + UITheme::drawButtonHints(renderer, labels.btn1, labels.btn2, labels.btn3, labels.btn4); renderer.displayBuffer(); return; } diff --git a/src/activities/settings/KOReaderSettingsActivity.cpp b/src/activities/settings/KOReaderSettingsActivity.cpp index 71003433..4cbf993a 100644 --- a/src/activities/settings/KOReaderSettingsActivity.cpp +++ b/src/activities/settings/KOReaderSettingsActivity.cpp @@ -8,6 +8,7 @@ #include "KOReaderCredentialStore.h" #include "MappedInputManager.h" #include "activities/util/KeyboardEntryActivity.h" +#include "components/UITheme.h" #include "fontIds.h" namespace { @@ -207,7 +208,7 @@ void KOReaderSettingsActivity::render() { // Draw button hints const auto labels = mappedInput.mapLabels("« Back", "Select", "", ""); - renderer.drawButtonHints(UI_10_FONT_ID, labels.btn1, labels.btn2, labels.btn3, labels.btn4); + UITheme::drawButtonHints(renderer, labels.btn1, labels.btn2, labels.btn3, labels.btn4); renderer.displayBuffer(); } diff --git a/src/activities/settings/OtaUpdateActivity.cpp b/src/activities/settings/OtaUpdateActivity.cpp index 86dcf2ac..5fac7004 100644 --- a/src/activities/settings/OtaUpdateActivity.cpp +++ b/src/activities/settings/OtaUpdateActivity.cpp @@ -5,6 +5,7 @@ #include "MappedInputManager.h" #include "activities/network/WifiSelectionActivity.h" +#include "components/UITheme.h" #include "fontIds.h" #include "network/OtaUpdater.h" @@ -142,7 +143,7 @@ void OtaUpdateActivity::render() { renderer.drawText(UI_10_FONT_ID, 20, 270, ("New Version: " + updater.getLatestVersion()).c_str()); const auto labels = mappedInput.mapLabels("Cancel", "Update", "", ""); - renderer.drawButtonHints(UI_10_FONT_ID, labels.btn1, labels.btn2, labels.btn3, labels.btn4); + UITheme::drawButtonHints(renderer, labels.btn1, labels.btn2, labels.btn3, labels.btn4); renderer.displayBuffer(); return; } diff --git a/src/activities/settings/SettingsActivity.cpp b/src/activities/settings/SettingsActivity.cpp index 7316db05..c3910d9f 100644 --- a/src/activities/settings/SettingsActivity.cpp +++ b/src/activities/settings/SettingsActivity.cpp @@ -3,15 +3,19 @@ #include #include -#include "CategorySettingsActivity.h" +#include "CalibreSettingsActivity.h" +#include "ClearCacheActivity.h" #include "CrossPointSettings.h" +#include "KOReaderSettingsActivity.h" #include "MappedInputManager.h" +#include "OtaUpdateActivity.h" +#include "components/UITheme.h" #include "fontIds.h" const char* SettingsActivity::categoryNames[categoryCount] = {"Display", "Reader", "Controls", "System"}; namespace { -constexpr int displaySettingsCount = 6; +constexpr int displaySettingsCount = 7; const SettingInfo displaySettings[displaySettingsCount] = { // Should match with SLEEP_SCREEN_MODE SettingInfo::Enum("Sleep Screen", &CrossPointSettings::sleepScreen, {"Dark", "Light", "Custom", "Cover", "None"}), @@ -22,7 +26,9 @@ const SettingInfo displaySettings[displaySettingsCount] = { {"None", "No Progress", "Full w/ Percentage", "Full w/ Progress Bar", "Progress Bar"}), SettingInfo::Enum("Hide Battery %", &CrossPointSettings::hideBatteryPercentage, {"Never", "In Reader", "Always"}), SettingInfo::Enum("Refresh Frequency", &CrossPointSettings::refreshFrequency, - {"1 page", "5 pages", "10 pages", "15 pages", "30 pages"})}; + {"1 page", "5 pages", "10 pages", "15 pages", "30 pages"}), + SettingInfo::Enum("UI Theme", &CrossPointSettings::uiTheme, {"Classic", "Lyra"}), +}; constexpr int readerSettingsCount = 9; const SettingInfo readerSettings[readerSettingsCount] = { @@ -67,6 +73,11 @@ void SettingsActivity::onEnter() { // Reset selection to first category selectedCategoryIndex = 0; + selectedSettingIndex = 0; + + // Initialize with first category (Display) + settingsList = displaySettings; + settingsCount = displaySettingsCount; // Trigger first update updateRequired = true; @@ -90,6 +101,8 @@ void SettingsActivity::onExit() { } vSemaphoreDelete(renderingMutex); renderingMutex = nullptr; + + UITheme::initialize(); // Re-apply theme in case it was changed } void SettingsActivity::loop() { @@ -98,9 +111,10 @@ void SettingsActivity::loop() { return; } - // Handle category selection + // Handle actions with early return if (mappedInput.wasPressed(MappedInputManager::Button::Confirm)) { - enterCategory(selectedCategoryIndex); + toggleCurrentSetting(); + updateRequired = true; return; } @@ -110,56 +124,108 @@ void SettingsActivity::loop() { return; } + bool hasChangedCategory = false; + // Handle navigation - if (mappedInput.wasPressed(MappedInputManager::Button::Up) || - mappedInput.wasPressed(MappedInputManager::Button::Left)) { - // Move selection up (with wrap-around) + if (mappedInput.wasPressed(MappedInputManager::Button::Left)) { + selectedSettingIndex = (selectedSettingIndex > 0) ? (selectedSettingIndex - 1) : (settingsCount - 1); + updateRequired = true; + } else if (mappedInput.wasPressed(MappedInputManager::Button::Right)) { + selectedSettingIndex = (selectedSettingIndex < settingsCount - 1) ? (selectedSettingIndex + 1) : 0; + updateRequired = true; + } else if (mappedInput.wasPressed(MappedInputManager::Button::Up)) { + hasChangedCategory = true; selectedCategoryIndex = (selectedCategoryIndex > 0) ? (selectedCategoryIndex - 1) : (categoryCount - 1); updateRequired = true; - } else if (mappedInput.wasPressed(MappedInputManager::Button::Down) || - mappedInput.wasPressed(MappedInputManager::Button::Right)) { - // Move selection down (with wrap around) + } else if (mappedInput.wasPressed(MappedInputManager::Button::Down)) { + hasChangedCategory = true; selectedCategoryIndex = (selectedCategoryIndex < categoryCount - 1) ? (selectedCategoryIndex + 1) : 0; updateRequired = true; } + + if (hasChangedCategory) { + selectedSettingIndex = 0; + switch (selectedCategoryIndex) { + case 0: // Display + settingsList = displaySettings; + settingsCount = displaySettingsCount; + break; + case 1: // Reader + settingsList = readerSettings; + settingsCount = readerSettingsCount; + break; + case 2: // Controls + settingsList = controlsSettings; + settingsCount = controlsSettingsCount; + break; + case 3: // System + settingsList = systemSettings; + settingsCount = systemSettingsCount; + break; + } + } } -void SettingsActivity::enterCategory(int categoryIndex) { - if (categoryIndex < 0 || categoryIndex >= categoryCount) { +void SettingsActivity::toggleCurrentSetting() { + if (selectedSettingIndex < 0 || selectedSettingIndex >= settingsCount) { return; } - xSemaphoreTake(renderingMutex, portMAX_DELAY); - exitActivity(); + const auto& setting = settingsList[selectedSettingIndex]; - const SettingInfo* settingsList = nullptr; - int settingsCount = 0; - - switch (categoryIndex) { - case 0: // Display - settingsList = displaySettings; - settingsCount = displaySettingsCount; - break; - case 1: // Reader - settingsList = readerSettings; - settingsCount = readerSettingsCount; - break; - case 2: // Controls - settingsList = controlsSettings; - settingsCount = controlsSettingsCount; - break; - case 3: // System - settingsList = systemSettings; - settingsCount = systemSettingsCount; - break; + if (setting.type == SettingType::TOGGLE && setting.valuePtr != nullptr) { + // Toggle the boolean value using the member pointer + const bool currentValue = SETTINGS.*(setting.valuePtr); + SETTINGS.*(setting.valuePtr) = !currentValue; + } else if (setting.type == SettingType::ENUM && setting.valuePtr != nullptr) { + const uint8_t currentValue = SETTINGS.*(setting.valuePtr); + SETTINGS.*(setting.valuePtr) = (currentValue + 1) % static_cast(setting.enumValues.size()); + } else if (setting.type == SettingType::VALUE && setting.valuePtr != nullptr) { + const int8_t currentValue = SETTINGS.*(setting.valuePtr); + if (currentValue + setting.valueRange.step > setting.valueRange.max) { + SETTINGS.*(setting.valuePtr) = setting.valueRange.min; + } else { + SETTINGS.*(setting.valuePtr) = currentValue + setting.valueRange.step; + } + } else if (setting.type == SettingType::ACTION) { + if (strcmp(setting.name, "KOReader Sync") == 0) { + xSemaphoreTake(renderingMutex, portMAX_DELAY); + exitActivity(); + enterNewActivity(new KOReaderSettingsActivity(renderer, mappedInput, [this] { + exitActivity(); + updateRequired = true; + })); + xSemaphoreGive(renderingMutex); + } else if (strcmp(setting.name, "OPDS Browser") == 0) { + xSemaphoreTake(renderingMutex, portMAX_DELAY); + exitActivity(); + enterNewActivity(new CalibreSettingsActivity(renderer, mappedInput, [this] { + exitActivity(); + updateRequired = true; + })); + xSemaphoreGive(renderingMutex); + } else if (strcmp(setting.name, "Clear Cache") == 0) { + xSemaphoreTake(renderingMutex, portMAX_DELAY); + exitActivity(); + enterNewActivity(new ClearCacheActivity(renderer, mappedInput, [this] { + exitActivity(); + updateRequired = true; + })); + xSemaphoreGive(renderingMutex); + } else if (strcmp(setting.name, "Check for updates") == 0) { + xSemaphoreTake(renderingMutex, portMAX_DELAY); + exitActivity(); + enterNewActivity(new OtaUpdateActivity(renderer, mappedInput, [this] { + exitActivity(); + updateRequired = true; + })); + xSemaphoreGive(renderingMutex); + } + } else { + return; } - enterNewActivity(new CategorySettingsActivity(renderer, mappedInput, categoryNames[categoryIndex], settingsList, - settingsCount, [this] { - exitActivity(); - updateRequired = true; - })); - xSemaphoreGive(renderingMutex); + SETTINGS.saveToFile(); } void SettingsActivity::displayTaskLoop() { @@ -180,27 +246,49 @@ void SettingsActivity::render() const { const auto pageWidth = renderer.getScreenWidth(); const auto pageHeight = renderer.getScreenHeight(); - // Draw header - renderer.drawCenteredText(UI_12_FONT_ID, 15, "Settings", true, EpdFontFamily::BOLD); + auto metrics = UITheme::getMetrics(); - // Draw selection - renderer.fillRect(0, 60 + selectedCategoryIndex * 30 - 2, pageWidth - 1, 30); + UITheme::drawHeader(renderer, Rect{0, metrics.topPadding, pageWidth, metrics.headerHeight}, "Settings"); - // Draw all categories + std::vector tabs; + tabs.reserve(categoryCount); for (int i = 0; i < categoryCount; i++) { - const int categoryY = 60 + i * 30; // 30 pixels between categories - - // Draw category name - renderer.drawText(UI_10_FONT_ID, 20, categoryY, categoryNames[i], i != selectedCategoryIndex); + tabs.push_back({categoryNames[i], selectedCategoryIndex == i}); } + UITheme::drawTabBar(renderer, Rect{0, metrics.topPadding + metrics.headerHeight, pageWidth, metrics.tabBarHeight}, + tabs); - // Draw version text above button hints - renderer.drawText(SMALL_FONT_ID, pageWidth - 20 - renderer.getTextWidth(SMALL_FONT_ID, CROSSPOINT_VERSION), - pageHeight - 60, CROSSPOINT_VERSION); + UITheme::drawList( + renderer, + Rect{0, metrics.topPadding + metrics.headerHeight + metrics.tabBarHeight + metrics.verticalSpacing, pageWidth, + pageHeight - (metrics.topPadding + metrics.headerHeight + metrics.tabBarHeight + metrics.buttonHintsHeight + + metrics.verticalSpacing * 2)}, + settingsCount, selectedSettingIndex, [this](int index) { return std::string(settingsList[index].name); }, false, + nullptr, true, + [this](int i) { + const auto& setting = settingsList[i]; + std::string valueText = ""; + if (settingsList[i].type == SettingType::TOGGLE && settingsList[i].valuePtr != nullptr) { + const bool value = SETTINGS.*(settingsList[i].valuePtr); + valueText = value ? "ON" : "OFF"; + } else if (settingsList[i].type == SettingType::ENUM && settingsList[i].valuePtr != nullptr) { + const uint8_t value = SETTINGS.*(settingsList[i].valuePtr); + valueText = settingsList[i].enumValues[value]; + } else if (settingsList[i].type == SettingType::VALUE && settingsList[i].valuePtr != nullptr) { + valueText = std::to_string(SETTINGS.*(settingsList[i].valuePtr)); + } + return valueText; + }); + + // Draw version text + renderer.drawText(SMALL_FONT_ID, + pageWidth - metrics.versionTextRightX - renderer.getTextWidth(SMALL_FONT_ID, CROSSPOINT_VERSION), + metrics.versionTextY, CROSSPOINT_VERSION); // Draw help text - const auto labels = mappedInput.mapLabels("« Back", "Select", "", ""); - renderer.drawButtonHints(UI_10_FONT_ID, labels.btn1, labels.btn2, labels.btn3, labels.btn4); + const auto labels = mappedInput.mapLabels("« Back", "Select", "Up", "Down"); + UITheme::drawButtonHints(renderer, labels.btn1, labels.btn2, labels.btn3, labels.btn4); + UITheme::drawSideButtonHints(renderer, "^", "v"); // Always use standard refresh for settings screen renderer.displayBuffer(); diff --git a/src/activities/settings/SettingsActivity.h b/src/activities/settings/SettingsActivity.h index 821dda42..54eb8ba4 100644 --- a/src/activities/settings/SettingsActivity.h +++ b/src/activities/settings/SettingsActivity.h @@ -10,13 +10,46 @@ #include "activities/ActivityWithSubactivity.h" class CrossPointSettings; -struct SettingInfo; + +enum class SettingType { TOGGLE, ENUM, ACTION, VALUE }; + +struct SettingInfo { + const char* name; + SettingType type; + uint8_t CrossPointSettings::* valuePtr; + std::vector enumValues; + + struct ValueRange { + uint8_t min; + uint8_t max; + uint8_t step; + }; + ValueRange valueRange; + + static SettingInfo Toggle(const char* name, uint8_t CrossPointSettings::* ptr) { + return {name, SettingType::TOGGLE, ptr}; + } + + static SettingInfo Enum(const char* name, uint8_t CrossPointSettings::* ptr, std::vector values) { + return {name, SettingType::ENUM, ptr, std::move(values)}; + } + + static SettingInfo Action(const char* name) { return {name, SettingType::ACTION, nullptr}; } + + static SettingInfo Value(const char* name, uint8_t CrossPointSettings::* ptr, const ValueRange valueRange) { + return {name, SettingType::VALUE, ptr, {}, valueRange}; + } +}; class SettingsActivity final : public ActivityWithSubactivity { TaskHandle_t displayTaskHandle = nullptr; SemaphoreHandle_t renderingMutex = nullptr; bool updateRequired = false; int selectedCategoryIndex = 0; // Currently selected category + int selectedSettingIndex = 0; + int settingsCount = 0; + const SettingInfo* settingsList = nullptr; + const std::function onGoHome; static constexpr int categoryCount = 4; @@ -26,6 +59,7 @@ class SettingsActivity final : public ActivityWithSubactivity { [[noreturn]] void displayTaskLoop(); void render() const; void enterCategory(int categoryIndex); + void toggleCurrentSetting(); public: explicit SettingsActivity(GfxRenderer& renderer, MappedInputManager& mappedInput, diff --git a/src/activities/util/KeyboardEntryActivity.cpp b/src/activities/util/KeyboardEntryActivity.cpp index 3a6befac..2b148813 100644 --- a/src/activities/util/KeyboardEntryActivity.cpp +++ b/src/activities/util/KeyboardEntryActivity.cpp @@ -1,6 +1,7 @@ #include "KeyboardEntryActivity.h" #include "MappedInputManager.h" +#include "components/UITheme.h" #include "fontIds.h" // Keyboard layouts - lowercase @@ -354,10 +355,10 @@ void KeyboardEntryActivity::render() const { // Draw help text const auto labels = mappedInput.mapLabels("« Back", "Select", "Left", "Right"); - renderer.drawButtonHints(UI_10_FONT_ID, labels.btn1, labels.btn2, labels.btn3, labels.btn4); + UITheme::drawButtonHints(renderer, labels.btn1, labels.btn2, labels.btn3, labels.btn4); // Draw side button hints for Up/Down navigation - renderer.drawSideButtonHints(UI_10_FONT_ID, "Up", "Down"); + UITheme::drawSideButtonHints(renderer, "Up", "Down"); renderer.displayBuffer(); } diff --git a/src/components/UITheme.cpp b/src/components/UITheme.cpp new file mode 100644 index 00000000..8c790654 --- /dev/null +++ b/src/components/UITheme.cpp @@ -0,0 +1,132 @@ +#include "UITheme.h" + +#include + +#include + +#include "RecentBooksStore.h" +#include "components/themes/BaseTheme.h" +#include "components/themes/lyra/LyraTheme.h" + +std::unique_ptr currentTheme = nullptr; +const ThemeMetrics* UITheme::currentMetrics = &BaseMetrics::values; + +// Initialize theme based on settings +void UITheme::initialize() { + auto themeType = static_cast(SETTINGS.uiTheme); + setTheme(themeType); +} + +void UITheme::setTheme(CrossPointSettings::UI_THEME type) { + switch (type) { + case CrossPointSettings::UI_THEME::CLASSIC: + Serial.printf("[%lu] [UI] Using Classic theme\n", millis()); + currentTheme = std::unique_ptr(new BaseTheme()); + currentMetrics = &BaseMetrics::values; + break; + case CrossPointSettings::UI_THEME::LYRA: + Serial.printf("[%lu] [UI] Using Lyra theme\n", millis()); + currentTheme = std::unique_ptr(new LyraTheme()); + currentMetrics = &LyraMetrics::values; + break; + } +} + +int UITheme::getNumberOfItemsPerPage(const GfxRenderer& renderer, bool hasHeader, bool hasTabBar, bool hasButtonHints) { + const ThemeMetrics& metrics = UITheme::getMetrics(); + int reservedHeight = metrics.topPadding; + if (hasHeader) { + reservedHeight += metrics.headerHeight; + } + if (hasTabBar) { + reservedHeight += metrics.tabBarHeight + metrics.verticalSpacing; + } + if (hasButtonHints) { + reservedHeight += metrics.verticalSpacing + metrics.buttonHintsHeight; + } + const int availableHeight = renderer.getScreenHeight() - reservedHeight; + return availableHeight / metrics.listRowHeight; +} + +// Forward all component methods to the current theme +void UITheme::drawProgressBar(const GfxRenderer& renderer, Rect rect, size_t current, size_t total) { + if (currentTheme != nullptr) { + currentTheme->drawProgressBar(renderer, rect, current, total); + } +} + +void UITheme::drawBattery(const GfxRenderer& renderer, Rect rect, bool showPercentage) { + if (currentTheme != nullptr) { + currentTheme->drawBattery(renderer, rect, showPercentage); + } +} + +void UITheme::drawButtonHints(const GfxRenderer& renderer, const char* btn1, const char* btn2, const char* btn3, + const char* btn4) { + if (currentTheme != nullptr) { + currentTheme->drawButtonHints(renderer, btn1, btn2, btn3, btn4); + } +} + +void UITheme::drawSideButtonHints(const GfxRenderer& renderer, const char* topBtn, const char* bottomBtn) { + if (currentTheme != nullptr) { + currentTheme->drawSideButtonHints(renderer, topBtn, bottomBtn); + } +} + +void UITheme::drawList(const GfxRenderer& renderer, Rect rect, int itemCount, int selectedIndex, + const std::function& rowTitle, bool hasIcon, + const std::function& rowIcon, bool hasValue, + const std::function& rowValue) { + if (currentTheme != nullptr) { + currentTheme->drawList(renderer, rect, itemCount, selectedIndex, rowTitle, hasIcon, rowIcon, hasValue, rowValue); + } +} + +void UITheme::drawHeader(const GfxRenderer& renderer, Rect rect, const char* title) { + if (currentTheme != nullptr) { + currentTheme->drawHeader(renderer, rect, title); + } +} + +void UITheme::drawTabBar(const GfxRenderer& renderer, const Rect rect, const std::vector& tabs) { + if (currentTheme != nullptr) { + currentTheme->drawTabBar(renderer, rect, tabs); + } +} + +void UITheme::drawRecentBookCover(GfxRenderer& renderer, Rect rect, const std::vector& recentBooks, + const int selectorIndex, bool& coverRendered, bool& coverBufferStored, + bool& bufferRestored, std::function storeCoverBuffer) { + if (currentTheme != nullptr) { + currentTheme->drawRecentBookCover(renderer, rect, recentBooks, selectorIndex, coverRendered, coverBufferStored, + bufferRestored, storeCoverBuffer); + } +} + +void UITheme::drawButtonMenu(GfxRenderer& renderer, Rect rect, int buttonCount, int selectedIndex, + const std::function& buttonLabel, bool hasIcon, + const std::function& rowIcon) { + if (currentTheme != nullptr) { + currentTheme->drawButtonMenu(renderer, rect, buttonCount, selectedIndex, buttonLabel, hasIcon, rowIcon); + } +} + +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); + } + return PopupCallbacks{}; +} + +void UITheme::drawBookProgressBar(const GfxRenderer& renderer, const size_t bookProgress) { + if (currentTheme != nullptr) { + currentTheme->drawBookProgressBar(renderer, bookProgress); + } +} \ No newline at end of file diff --git a/src/components/UITheme.h b/src/components/UITheme.h new file mode 100644 index 00000000..cc43ffb9 --- /dev/null +++ b/src/components/UITheme.h @@ -0,0 +1,94 @@ +#pragma once + +#include +#include + +#include "CrossPointSettings.h" + +class GfxRenderer; +struct RecentBookWithCover; + +struct Rect { + int x; + int y; + int width; + int height; + + // Constructor for explicit initialization + explicit Rect(int x = 0, int y = 0, int width = 0, int height = 0) : x(x), y(y), width(width), height(height) {} +}; + +struct TabInfo { + const char* label; + bool selected; +}; + +struct ThemeMetrics { + int batteryWidth; + int batteryHeight; + + int topPadding; + int batteryBarHeight; + int headerHeight; + int verticalSpacing; + + int contentSidePadding; + int listRowHeight; + int menuRowHeight; + int menuSpacing; + + int tabSpacing; + int tabBarHeight; + + int scrollBarWidth; + int scrollBarRightOffset; + + int homeTopPadding; + int homeCoverHeight; + int homeRecentBooksCount; + + int buttonHintsHeight; + int sideButtonHintsWidth; + + int versionTextRightX; + int versionTextY; + + int bookProgressBarHeight; +}; + +struct PopupCallbacks { + std::function setup; + std::function update; +}; + +class UITheme { + private: + static const ThemeMetrics* currentMetrics; + + public: + static void initialize(); + static void setTheme(CrossPointSettings::UI_THEME type); + static const ThemeMetrics& getMetrics() { return *currentMetrics; } + static int getNumberOfItemsPerPage(const GfxRenderer& renderer, bool hasHeader, bool hasTabBar, bool hasButtonHints); + + static void drawProgressBar(const GfxRenderer& renderer, Rect rect, size_t current, size_t total); + static void drawBattery(const GfxRenderer& renderer, Rect rect, bool showPercentage = true); + static void drawButtonHints(const GfxRenderer& renderer, const char* btn1, const char* btn2, const char* btn3, + const char* btn4); + static void drawSideButtonHints(const GfxRenderer& renderer, const char* topBtn, const char* bottomBtn); + static void drawList(const GfxRenderer& renderer, Rect rect, int itemCount, int selectedIndex, + const std::function& rowTitle, bool hasIcon, + const std::function& rowIcon, bool hasValue, + const std::function& rowValue); + static void drawHeader(const GfxRenderer& renderer, Rect rect, const char* title); + static void drawTabBar(const GfxRenderer& renderer, Rect rect, const std::vector& tabs); + static void drawRecentBookCover(GfxRenderer& renderer, Rect rect, const std::vector& recentBooks, + const int selectorIndex, bool& coverRendered, bool& coverBufferStored, + bool& bufferRestored, std::function storeCoverBuffer); + static void drawButtonMenu(GfxRenderer& renderer, Rect rect, int buttonCount, int selectedIndex, + const std::function& buttonLabel, bool hasIcon, + const std::function& rowIcon); + static void drawPopup(GfxRenderer& renderer, const char* message); + static PopupCallbacks drawPopupWithProgress(GfxRenderer& renderer, const std::string& title); + static void drawBookProgressBar(const GfxRenderer& renderer, const size_t bookProgress); +}; \ No newline at end of file diff --git a/src/components/themes/BaseTheme.cpp b/src/components/themes/BaseTheme.cpp new file mode 100644 index 00000000..31ad738e --- /dev/null +++ b/src/components/themes/BaseTheme.cpp @@ -0,0 +1,646 @@ +#include "BaseTheme.h" + +#include +#include + +#include +#include + +#include "Battery.h" +#include "RecentBooksStore.h" +#include "fontIds.h" +#include "util/StringUtils.h" + +// Internal constants +namespace { +constexpr int batteryPercentSpacing = 4; +constexpr int homeMenuMargin = 20; +constexpr int homeMarginTop = 30; +} // namespace + +void BaseTheme::drawBattery(const GfxRenderer& renderer, Rect rect, const bool showPercentage) { + // Left aligned battery icon and percentage + // TODO refactor this so the percentage doesnt change after we position it + const uint16_t percentage = battery.readPercentage(); + if (showPercentage) { + const auto percentageText = std::to_string(percentage) + "%"; + renderer.drawText(SMALL_FONT_ID, rect.x + batteryPercentSpacing + BaseMetrics::values.batteryWidth, rect.y, + percentageText.c_str()); + } + // 1 column on left, 2 columns on right, 5 columns of battery body + const int x = rect.x; + const int y = rect.y + 6; + const int battWidth = BaseMetrics::values.batteryWidth; + + // Top line + renderer.drawLine(x + 1, y, x + battWidth - 3, y); + // Bottom line + renderer.drawLine(x + 1, y + rect.height - 1, x + battWidth - 3, y + rect.height - 1); + // Left line + renderer.drawLine(x, y + 1, x, y + rect.height - 2); + // Battery end + renderer.drawLine(x + battWidth - 2, y + 1, x + battWidth - 2, y + rect.height - 2); + renderer.drawPixel(x + battWidth - 1, y + 3); + renderer.drawPixel(x + battWidth - 1, y + rect.height - 4); + renderer.drawLine(x + battWidth - 0, y + 4, x + battWidth - 0, y + rect.height - 5); + + // The +1 is to round up, so that we always fill at least one pixel + int filledWidth = percentage * (rect.width - 5) / 100 + 1; + if (filledWidth > rect.width - 5) { + filledWidth = rect.width - 5; // Ensure we don't overflow + } + + renderer.fillRect(x + 2, y + 2, filledWidth, rect.height - 4); +} + +void BaseTheme::drawProgressBar(const GfxRenderer& renderer, Rect rect, const size_t current, const size_t total) { + if (total == 0) { + return; + } + + // Use 64-bit arithmetic to avoid overflow for large files + const int percent = static_cast((static_cast(current) * 100) / total); + + // Draw outline + renderer.drawRect(rect.x, rect.y, rect.width, rect.height); + + // Draw filled portion + const int fillWidth = (rect.width - 4) * percent / 100; + if (fillWidth > 0) { + renderer.fillRect(rect.x + 2, rect.y + 2, fillWidth, rect.height - 4); + } + + // Draw percentage text centered below bar + const std::string percentText = std::to_string(percent) + "%"; + renderer.drawCenteredText(UI_10_FONT_ID, rect.y + rect.height + 15, percentText.c_str()); +} + +void BaseTheme::drawButtonHints(const GfxRenderer& renderer, const char* btn1, const char* btn2, const char* btn3, + const char* btn4) { + // TODO: Fix rotated hints + // const GfxRenderer::Orientation orig_orientation = renderer.getOrientation(); + // renderer.setOrientation(GfxRenderer::Orientation::Portrait); + + const int pageHeight = renderer.getScreenHeight(); + constexpr int buttonWidth = 106; + constexpr int buttonHeight = BaseMetrics::values.buttonHintsHeight; + constexpr int buttonY = BaseMetrics::values.buttonHintsHeight; // Distance from bottom + constexpr int textYOffset = 7; // Distance from top of button to text baseline + constexpr int buttonPositions[] = {25, 130, 245, 350}; + const char* labels[] = {btn1, btn2, btn3, btn4}; + + for (int i = 0; i < 4; i++) { + // Only draw if the label is non-empty + if (labels[i] != nullptr && labels[i][0] != '\0') { + const int x = buttonPositions[i]; + renderer.fillRect(x, pageHeight - buttonY, buttonWidth, buttonHeight, false); + renderer.drawRect(x, pageHeight - buttonY, buttonWidth, buttonHeight); + const int textWidth = renderer.getTextWidth(UI_10_FONT_ID, labels[i]); + const int textX = x + (buttonWidth - 1 - textWidth) / 2; + renderer.drawText(UI_10_FONT_ID, textX, pageHeight - buttonY + textYOffset, labels[i]); + } + } + + // renderer.setOrientation(orig_orientation); +} + +void BaseTheme::drawSideButtonHints(const GfxRenderer& renderer, const char* topBtn, const char* bottomBtn) { + const int screenWidth = renderer.getScreenWidth(); + 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 + + const char* labels[] = {topBtn, bottomBtn}; + + // Draw the shared border for both buttons as one unit + const int x = screenWidth - buttonX - buttonWidth; + + // Draw top button outline (3 sides, bottom open) + if (topBtn != nullptr && topBtn[0] != '\0') { + renderer.drawLine(x, topButtonY, x + buttonWidth - 1, topButtonY); // Top + renderer.drawLine(x, topButtonY, x, topButtonY + buttonHeight - 1); // Left + renderer.drawLine(x + buttonWidth - 1, topButtonY, x + buttonWidth - 1, topButtonY + buttonHeight - 1); // Right + } + + // Draw shared middle border + if ((topBtn != nullptr && topBtn[0] != '\0') || (bottomBtn != nullptr && bottomBtn[0] != '\0')) { + renderer.drawLine(x, topButtonY + buttonHeight, x + buttonWidth - 1, topButtonY + buttonHeight); // Shared border + } + + // Draw bottom button outline (3 sides, top is shared) + if (bottomBtn != nullptr && bottomBtn[0] != '\0') { + renderer.drawLine(x, topButtonY + buttonHeight, x, topButtonY + 2 * buttonHeight - 1); // Left + renderer.drawLine(x + buttonWidth - 1, topButtonY + buttonHeight, x + buttonWidth - 1, + topButtonY + 2 * buttonHeight - 1); // Right + renderer.drawLine(x, topButtonY + 2 * buttonHeight - 1, x + buttonWidth - 1, + topButtonY + 2 * buttonHeight - 1); // Bottom + } + + // Draw text for each button + for (int i = 0; i < 2; i++) { + if (labels[i] != nullptr && labels[i][0] != '\0') { + const int y = topButtonY + i * buttonHeight; + + // Draw rotated text centered in the button + const int textWidth = renderer.getTextWidth(SMALL_FONT_ID, labels[i]); + const int textHeight = renderer.getTextHeight(SMALL_FONT_ID); + + // Center the rotated text in the button + const int textX = x + (buttonWidth - textHeight) / 2; + const int textY = y + (buttonHeight + textWidth) / 2; + + renderer.drawTextRotated90CW(SMALL_FONT_ID, textX, textY, labels[i]); + } + } +} + +void BaseTheme::drawList(const GfxRenderer& renderer, Rect rect, int itemCount, int selectedIndex, + const std::function& rowTitle, bool hasIcon, + const std::function& rowIcon, bool hasValue, + const std::function& rowValue) { + int pageItems = rect.height / BaseMetrics::values.listRowHeight; + const int rowHeight = BaseMetrics::values.listRowHeight; + + const int totalPages = (itemCount + pageItems - 1) / pageItems; + if (totalPages > 1) { + 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 - BaseMetrics::values.sideButtonHintsWidth - 5; + renderer.fillRect(0, rect.y + selectedIndex % pageItems * rowHeight - 2, contentWidth, rowHeight); + // Draw all items + const auto pageStartIndex = selectedIndex / pageItems * pageItems; + for (int i = pageStartIndex; i < itemCount && i < pageStartIndex + pageItems; i++) { + const int itemY = rect.y + (i % pageItems) * rowHeight; + + // Draw name + auto itemName = rowTitle(i); + auto item = renderer.truncatedText(UI_10_FONT_ID, itemName.c_str(), + contentWidth - BaseMetrics::values.contentSidePadding * 2 - + (hasValue ? 60 : 0)); // TODO truncate according to value width? + renderer.drawText(UI_10_FONT_ID, rect.x + BaseMetrics::values.contentSidePadding, itemY, item.c_str(), + i != selectedIndex); + + if (hasValue) { + // Draw value + std::string valueText = rowValue(i); + const auto textWidth = renderer.getTextWidth(UI_10_FONT_ID, valueText.c_str()); + renderer.drawText(UI_10_FONT_ID, contentWidth - BaseMetrics::values.contentSidePadding - textWidth, itemY, + valueText.c_str(), i != selectedIndex); + } + } +} + +void BaseTheme::drawHeader(const GfxRenderer& renderer, Rect rect, const char* title) { + const bool showBatteryPercentage = + SETTINGS.hideBatteryPercentage != CrossPointSettings::HIDE_BATTERY_PERCENTAGE::HIDE_ALWAYS; + int batteryX = rect.x + rect.width - BaseMetrics::values.contentSidePadding - BaseMetrics::values.batteryWidth; + if (showBatteryPercentage) { + const uint16_t percentage = battery.readPercentage(); + const auto percentageText = std::to_string(percentage) + "%"; + batteryX -= renderer.getTextWidth(SMALL_FONT_ID, percentageText.c_str()); + } + drawBattery(renderer, Rect{batteryX, rect.y + 5, BaseMetrics::values.batteryWidth, BaseMetrics::values.batteryHeight}, + showBatteryPercentage); + + if (title) { + renderer.drawCenteredText(UI_12_FONT_ID, rect.y + 5, title, true, EpdFontFamily::BOLD); + } +} + +void BaseTheme::drawTabBar(const GfxRenderer& renderer, const Rect rect, const std::vector& tabs) { + constexpr int underlineHeight = 2; // Height of selection underline + constexpr int underlineGap = 4; // Gap between text and underline + + const int lineHeight = renderer.getLineHeight(UI_12_FONT_ID); + + int currentX = rect.x + BaseMetrics::values.contentSidePadding; + + for (const auto& tab : tabs) { + const int textWidth = + renderer.getTextWidth(UI_12_FONT_ID, tab.label, tab.selected ? EpdFontFamily::BOLD : EpdFontFamily::REGULAR); + + // Draw tab label + renderer.drawText(UI_12_FONT_ID, currentX, rect.y, tab.label, true, + tab.selected ? EpdFontFamily::BOLD : EpdFontFamily::REGULAR); + + // Draw underline for selected tab + if (tab.selected) { + renderer.fillRect(currentX, rect.y + lineHeight + underlineGap, textWidth, underlineHeight); + } + + currentX += textWidth + BaseMetrics::values.tabSpacing; + } +} + +// Draw the "Recent Book" cover card on the home screen +// TODO: Refactor method to make it cleaner, split into smaller methods +void BaseTheme::drawRecentBookCover(GfxRenderer& renderer, Rect rect, + const std::vector& recentBooks, const int selectorIndex, + bool& coverRendered, bool& coverBufferStored, bool& bufferRestored, + std::function storeCoverBuffer) { + // --- Top "book" card for the current title (selectorIndex == 0) --- + const int bookWidth = rect.width / 2; + const int bookHeight = rect.height; + const int bookX = (rect.width - bookWidth) / 2; + const int bookY = rect.y; + const bool hasContinueReading = !recentBooks.empty(); + const bool bookSelected = hasContinueReading && selectorIndex == 0; + + // Bookmark dimensions (used in multiple places) + const int bookmarkWidth = bookWidth / 8; + const int bookmarkHeight = bookHeight / 5; + const int bookmarkX = bookX + bookWidth - bookmarkWidth - 10; + const int bookmarkY = bookY + 5; + + // Draw book card regardless, fill with message based on `hasContinueReading` + { + // Draw cover image as background if available (inside the box) + // Only load from SD on first render, then use stored buffer + if (hasContinueReading && !recentBooks[0].coverBmpPath.empty() && !coverRendered) { + const std::string& coverBmpPath = recentBooks[0].coverBmpPath; + + // First time: load cover from SD and render + FsFile file; + if (SdMan.openFileForRead("HOME", coverBmpPath, file)) { + Bitmap bitmap(file); + if (bitmap.parseHeaders() == BmpReaderError::Ok) { + // Calculate position to center image within the book card + int coverX, coverY; + + if (bitmap.getWidth() > bookWidth || bitmap.getHeight() > bookHeight) { + const float imgRatio = static_cast(bitmap.getWidth()) / static_cast(bitmap.getHeight()); + const float boxRatio = static_cast(bookWidth) / static_cast(bookHeight); + + if (imgRatio > boxRatio) { + coverX = bookX; + coverY = bookY + (bookHeight - static_cast(bookWidth / imgRatio)) / 2; + } else { + coverX = bookX + (bookWidth - static_cast(bookHeight * imgRatio)) / 2; + coverY = bookY; + } + } else { + coverX = bookX + (bookWidth - bitmap.getWidth()) / 2; + coverY = bookY + (bookHeight - bitmap.getHeight()) / 2; + } + + // Draw the cover image centered within the book card + renderer.drawBitmap(bitmap, coverX, coverY, bookWidth, bookHeight); + + // Draw border around the card + renderer.drawRect(bookX, bookY, bookWidth, bookHeight); + + // No bookmark ribbon when cover is shown - it would just cover the art + + // Store the buffer with cover image for fast navigation + coverBufferStored = storeCoverBuffer(); + coverRendered = true; + + // First render: if selected, draw selection indicators now + if (bookSelected) { + renderer.drawRect(bookX + 1, bookY + 1, bookWidth - 2, bookHeight - 2); + renderer.drawRect(bookX + 2, bookY + 2, bookWidth - 4, bookHeight - 4); + } + } + file.close(); + } + } else if (!bufferRestored && !coverRendered) { + // No cover image: draw border or fill, plus bookmark as visual flair + if (bookSelected) { + renderer.fillRect(bookX, bookY, bookWidth, bookHeight); + } else { + renderer.drawRect(bookX, bookY, bookWidth, bookHeight); + } + + // Draw bookmark ribbon when no cover image (visual decoration) + if (hasContinueReading) { + const int notchDepth = bookmarkHeight / 3; + const int centerX = bookmarkX + bookmarkWidth / 2; + + const int xPoints[5] = { + bookmarkX, // top-left + bookmarkX + bookmarkWidth, // top-right + bookmarkX + bookmarkWidth, // bottom-right + centerX, // center notch point + bookmarkX // bottom-left + }; + const int yPoints[5] = { + bookmarkY, // top-left + bookmarkY, // top-right + bookmarkY + bookmarkHeight, // bottom-right + bookmarkY + bookmarkHeight - notchDepth, // center notch point + bookmarkY + bookmarkHeight // bottom-left + }; + + // Draw bookmark ribbon (inverted if selected) + renderer.fillPolygon(xPoints, yPoints, 5, !bookSelected); + } + } + + // If buffer was restored, draw selection indicators if needed + if (bufferRestored && bookSelected && coverRendered) { + // Draw selection border (no bookmark inversion needed since cover has no bookmark) + renderer.drawRect(bookX + 1, bookY + 1, bookWidth - 2, bookHeight - 2); + renderer.drawRect(bookX + 2, bookY + 2, bookWidth - 4, bookHeight - 4); + } else if (!coverRendered && !bufferRestored) { + // Selection border already handled above in the no-cover case + } + } + + if (hasContinueReading) { + const std::string& lastBookTitle = recentBooks[0].book.title; + const std::string& lastBookAuthor = recentBooks[0].book.author; + + // Invert text colors based on selection state: + // - With cover: selected = white text on black box, unselected = black text on white box + // - Without cover: selected = white text on black card, unselected = black text on white card + + // Split into words (avoid stringstream to keep this light on the MCU) + std::vector words; + words.reserve(8); + size_t pos = 0; + while (pos < lastBookTitle.size()) { + while (pos < lastBookTitle.size() && lastBookTitle[pos] == ' ') { + ++pos; + } + if (pos >= lastBookTitle.size()) { + break; + } + const size_t start = pos; + while (pos < lastBookTitle.size() && lastBookTitle[pos] != ' ') { + ++pos; + } + words.emplace_back(lastBookTitle.substr(start, pos - start)); + } + + std::vector lines; + std::string currentLine; + // Extra padding inside the card so text doesn't hug the border + const int maxLineWidth = bookWidth - 40; + const int spaceWidth = renderer.getSpaceWidth(UI_12_FONT_ID); + + for (auto& i : words) { + // If we just hit the line limit (3), stop processing words + if (lines.size() >= 3) { + // Limit to 3 lines + // Still have words left, so add ellipsis to last line + lines.back().append("..."); + + while (!lines.back().empty() && renderer.getTextWidth(UI_12_FONT_ID, lines.back().c_str()) > maxLineWidth) { + // Remove "..." first, then remove one UTF-8 char, then add "..." back + lines.back().resize(lines.back().size() - 3); // Remove "..." + StringUtils::utf8RemoveLastChar(lines.back()); + lines.back().append("..."); + } + break; + } + + int wordWidth = renderer.getTextWidth(UI_12_FONT_ID, i.c_str()); + while (wordWidth > maxLineWidth && !i.empty()) { + // Word itself is too long, trim it (UTF-8 safe) + StringUtils::utf8RemoveLastChar(i); + // Check if we have room for ellipsis + std::string withEllipsis = i + "..."; + wordWidth = renderer.getTextWidth(UI_12_FONT_ID, withEllipsis.c_str()); + if (wordWidth <= maxLineWidth) { + i = withEllipsis; + break; + } + } + + int newLineWidth = renderer.getTextWidth(UI_12_FONT_ID, currentLine.c_str()); + if (newLineWidth > 0) { + newLineWidth += spaceWidth; + } + newLineWidth += wordWidth; + + if (newLineWidth > maxLineWidth && !currentLine.empty()) { + // New line too long, push old line + lines.push_back(currentLine); + currentLine = i; + } else { + currentLine.append(" ").append(i); + } + } + + // If lower than the line limit, push remaining words + if (!currentLine.empty() && lines.size() < 3) { + lines.push_back(currentLine); + } + + // Book title text + int totalTextHeight = renderer.getLineHeight(UI_12_FONT_ID) * static_cast(lines.size()); + if (!lastBookAuthor.empty()) { + totalTextHeight += renderer.getLineHeight(UI_10_FONT_ID) * 3 / 2; + } + + // Vertically center the title block within the card + int titleYStart = bookY + (bookHeight - totalTextHeight) / 2; + + // If cover image was rendered, draw box behind title and author + if (coverRendered) { + constexpr int boxPadding = 8; + // Calculate the max text width for the box + int maxTextWidth = 0; + for (const auto& line : lines) { + const int lineWidth = renderer.getTextWidth(UI_12_FONT_ID, line.c_str()); + if (lineWidth > maxTextWidth) { + maxTextWidth = lineWidth; + } + } + if (!lastBookAuthor.empty()) { + std::string trimmedAuthor = lastBookAuthor; + while (renderer.getTextWidth(UI_10_FONT_ID, trimmedAuthor.c_str()) > maxLineWidth && !trimmedAuthor.empty()) { + StringUtils::utf8RemoveLastChar(trimmedAuthor); + } + if (renderer.getTextWidth(UI_10_FONT_ID, trimmedAuthor.c_str()) < + renderer.getTextWidth(UI_10_FONT_ID, lastBookAuthor.c_str())) { + trimmedAuthor.append("..."); + } + const int authorWidth = renderer.getTextWidth(UI_10_FONT_ID, trimmedAuthor.c_str()); + if (authorWidth > maxTextWidth) { + maxTextWidth = authorWidth; + } + } + + const int boxWidth = maxTextWidth + boxPadding * 2; + const int boxHeight = totalTextHeight + boxPadding * 2; + const int boxX = (rect.width - boxWidth) / 2; + const int boxY = titleYStart - boxPadding; + + // Draw box (inverted when selected: black box instead of white) + renderer.fillRect(boxX, boxY, boxWidth, boxHeight, bookSelected); + // Draw border around the box (inverted when selected: white border instead of black) + renderer.drawRect(boxX, boxY, boxWidth, boxHeight, !bookSelected); + } + + for (const auto& line : lines) { + renderer.drawCenteredText(UI_12_FONT_ID, titleYStart, line.c_str(), !bookSelected); + titleYStart += renderer.getLineHeight(UI_12_FONT_ID); + } + + if (!lastBookAuthor.empty()) { + titleYStart += renderer.getLineHeight(UI_10_FONT_ID) / 2; + std::string trimmedAuthor = lastBookAuthor; + // Trim author if too long (UTF-8 safe) + bool wasTrimmed = false; + while (renderer.getTextWidth(UI_10_FONT_ID, trimmedAuthor.c_str()) > maxLineWidth && !trimmedAuthor.empty()) { + StringUtils::utf8RemoveLastChar(trimmedAuthor); + wasTrimmed = true; + } + if (wasTrimmed && !trimmedAuthor.empty()) { + // Make room for ellipsis + while (renderer.getTextWidth(UI_10_FONT_ID, (trimmedAuthor + "...").c_str()) > maxLineWidth && + !trimmedAuthor.empty()) { + StringUtils::utf8RemoveLastChar(trimmedAuthor); + } + trimmedAuthor.append("..."); + } + renderer.drawCenteredText(UI_10_FONT_ID, titleYStart, trimmedAuthor.c_str(), !bookSelected); + } + + // "Continue Reading" label at the bottom + const int continueY = bookY + bookHeight - renderer.getLineHeight(UI_10_FONT_ID) * 3 / 2; + if (coverRendered) { + // Draw box behind "Continue Reading" text (inverted when selected: black box instead of white) + const char* continueText = "Continue Reading"; + const int continueTextWidth = renderer.getTextWidth(UI_10_FONT_ID, continueText); + constexpr int continuePadding = 6; + const int continueBoxWidth = continueTextWidth + continuePadding * 2; + const int continueBoxHeight = renderer.getLineHeight(UI_10_FONT_ID) + continuePadding; + const int continueBoxX = (rect.width - continueBoxWidth) / 2; + const int continueBoxY = continueY - continuePadding / 2; + renderer.fillRect(continueBoxX, continueBoxY, continueBoxWidth, continueBoxHeight, bookSelected); + renderer.drawRect(continueBoxX, continueBoxY, continueBoxWidth, continueBoxHeight, !bookSelected); + renderer.drawCenteredText(UI_10_FONT_ID, continueY, continueText, !bookSelected); + } else { + renderer.drawCenteredText(UI_10_FONT_ID, continueY, "Continue Reading", !bookSelected); + } + } else { + // No book to continue reading + const int y = + bookY + (bookHeight - renderer.getLineHeight(UI_12_FONT_ID) - renderer.getLineHeight(UI_10_FONT_ID)) / 2; + renderer.drawCenteredText(UI_12_FONT_ID, y, "No open book"); + renderer.drawCenteredText(UI_10_FONT_ID, y + renderer.getLineHeight(UI_12_FONT_ID), "Start reading below"); + } +} + +void BaseTheme::drawButtonMenu(GfxRenderer& renderer, Rect rect, int buttonCount, int selectedIndex, + const std::function& buttonLabel, bool hasIcon, + const std::function& rowIcon) { + for (int i = 0; i < buttonCount; ++i) { + const int tileY = BaseMetrics::values.verticalSpacing + rect.y + + static_cast(i) * (BaseMetrics::values.menuRowHeight + BaseMetrics::values.menuSpacing); + + const bool selected = selectedIndex == i; + + if (selected) { + renderer.fillRect(rect.x + BaseMetrics::values.contentSidePadding, tileY, + rect.width - BaseMetrics::values.contentSidePadding * 2, BaseMetrics::values.menuRowHeight); + } else { + renderer.drawRect(rect.x + BaseMetrics::values.contentSidePadding, tileY, + rect.width - BaseMetrics::values.contentSidePadding * 2, BaseMetrics::values.menuRowHeight); + } + + const char* label = buttonLabel(i).c_str(); + const int textWidth = renderer.getTextWidth(UI_10_FONT_ID, label); + const int textX = rect.x + (rect.width - textWidth) / 2; + const int lineHeight = renderer.getLineHeight(UI_10_FONT_ID); + const int textY = + tileY + (BaseMetrics::values.menuRowHeight - lineHeight) / 2; // vertically centered assuming y is top of text + // Invert text when the tile is selected, to contrast with the filled background + renderer.drawText(UI_10_FONT_ID, textX, textY, label, selectedIndex != i); + } +} + +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; + constexpr int barHeight = 10; + constexpr int boxMargin = 20; + const int textWidth = renderer.getTextWidth(UI_12_FONT_ID, title.c_str()); + const int boxWidthWithBar = (barWidth > textWidth ? barWidth : textWidth) + boxMargin * 2; + const int boxWidthNoBar = textWidth + boxMargin * 2; + const int boxHeightWithBar = renderer.getLineHeight(UI_12_FONT_ID) + barHeight + boxMargin * 3; + const int boxHeightNoBar = renderer.getLineHeight(UI_12_FONT_ID) + boxMargin * 2; + const int boxXWithBar = (renderer.getScreenWidth() - boxWidthWithBar) / 2; + const int boxXNoBar = (renderer.getScreenWidth() - boxWidthNoBar) / 2; + constexpr int boxY = 50; + const int barX = boxXWithBar + (boxWidthWithBar - barWidth) / 2; + const int barY = boxY + renderer.getLineHeight(UI_12_FONT_ID) + boxMargin * 2; + + // Always show title text first + renderer.fillRect(boxXNoBar, boxY, boxWidthNoBar, boxHeightNoBar, false); + renderer.drawText(UI_12_FONT_ID, boxXNoBar + boxMargin, boxY + boxMargin, title.c_str()); + renderer.drawRect(boxXNoBar + 5, boxY + 5, boxWidthNoBar - 10, boxHeightNoBar - 10); + renderer.displayBuffer(); + + // Setup callback - only called for chapters >= 50KB, redraws with progress bar + auto setup = [this, &renderer, boxXWithBar, boxWidthWithBar, boxHeightWithBar, boxMargin, title, barX, barY, barWidth, + barHeight, boxY] { + renderer.fillRect(boxXWithBar, boxY, boxWidthWithBar, boxHeightWithBar, false); + renderer.drawText(UI_12_FONT_ID, boxXWithBar + boxMargin, boxY + boxMargin, title.c_str()); + renderer.drawRect(boxXWithBar + 5, boxY + 5, boxWidthWithBar - 10, boxHeightWithBar - 10); + renderer.drawRect(barX, barY, barWidth, barHeight); + renderer.displayBuffer(); + }; + + // Progress callback to update progress bar + auto update = [this, &renderer, barX, barY, barWidth, barHeight](int progress) { + const int fillWidth = (barWidth - 2) * progress / 100; + renderer.fillRect(barX + 1, barY + 1, fillWidth, barHeight - 2, true); + renderer.displayBuffer(HalDisplay::FAST_REFRESH); + }; + + return {setup, update}; +} + +void BaseTheme::drawBookProgressBar(const GfxRenderer& renderer, const size_t bookProgress) { + int vieweableMarginTop, vieweableMarginRight, vieweableMarginBottom, vieweableMarginLeft; + renderer.getOrientedViewableTRBL(&vieweableMarginTop, &vieweableMarginRight, &vieweableMarginBottom, + &vieweableMarginLeft); + + const int progressBarMaxWidth = renderer.getScreenWidth() - vieweableMarginLeft - vieweableMarginRight; + const int progressBarY = + renderer.getScreenHeight() - vieweableMarginBottom - BaseMetrics::values.bookProgressBarHeight; + const int barWidth = progressBarMaxWidth * bookProgress / 100; + renderer.fillRect(vieweableMarginLeft, progressBarY, barWidth, BaseMetrics::values.bookProgressBarHeight, true); +} diff --git a/src/components/themes/BaseTheme.h b/src/components/themes/BaseTheme.h new file mode 100644 index 00000000..7ab2a96e --- /dev/null +++ b/src/components/themes/BaseTheme.h @@ -0,0 +1,67 @@ +#pragma once + +#include +#include +#include + +#include "components/UITheme.h" + +class GfxRenderer; +struct RecentBookInfo; + +// Default theme implementation (Classic Theme) +// Additional themes can inherit from this and override methods as needed + +namespace BaseMetrics { +constexpr ThemeMetrics values = {.batteryWidth = 15, + .batteryHeight = 12, + .topPadding = 5, + .batteryBarHeight = 20, + .headerHeight = 45, + .verticalSpacing = 10, + .contentSidePadding = 20, + .listRowHeight = 30, + .menuRowHeight = 45, + .menuSpacing = 8, + .tabSpacing = 10, + .tabBarHeight = 50, + .scrollBarWidth = 4, + .scrollBarRightOffset = 5, + .homeTopPadding = 20, + .homeCoverHeight = 400, + .homeRecentBooksCount = 1, + .buttonHintsHeight = 40, + .sideButtonHintsWidth = 30, + .versionTextRightX = 20, + .versionTextY = 738, + .bookProgressBarHeight = 4}; +} + +class BaseTheme { + public: + virtual ~BaseTheme() = default; + + // Component drawing methods + virtual void drawProgressBar(const GfxRenderer& renderer, Rect rect, size_t current, size_t total); + virtual void drawBattery(const GfxRenderer& renderer, Rect rect, bool showPercentage = true); + virtual void drawButtonHints(const GfxRenderer& renderer, const char* btn1, const char* btn2, const char* btn3, + const char* btn4); + virtual void drawSideButtonHints(const GfxRenderer& renderer, const char* topBtn, const char* bottomBtn); + virtual void drawList(const GfxRenderer& renderer, Rect rect, int itemCount, int selectedIndex, + const std::function& rowTitle, bool hasIcon, + const std::function& rowIcon, bool hasValue, + const std::function& rowValue); + + virtual void drawHeader(const GfxRenderer& renderer, Rect rect, const char* title); + virtual void drawTabBar(const GfxRenderer& renderer, Rect rect, const std::vector& tabs); + virtual void drawRecentBookCover(GfxRenderer& renderer, Rect rect, + const std::vector& recentBooks, const int selectorIndex, + bool& coverRendered, bool& coverBufferStored, bool& bufferRestored, + std::function storeCoverBuffer); + virtual void drawButtonMenu(GfxRenderer& renderer, Rect rect, int buttonCount, int selectedIndex, + const std::function& buttonLabel, bool hasIcon, + const std::function& rowIcon); + virtual void drawPopup(GfxRenderer& renderer, const char* message); + virtual PopupCallbacks drawPopupWithProgress(GfxRenderer& renderer, const std::string& title); + virtual void drawBookProgressBar(const GfxRenderer& renderer, const size_t bookProgress); +}; \ No newline at end of file diff --git a/src/components/themes/lyra/LyraTheme.cpp b/src/components/themes/lyra/LyraTheme.cpp new file mode 100644 index 00000000..e9a0c078 --- /dev/null +++ b/src/components/themes/lyra/LyraTheme.cpp @@ -0,0 +1,334 @@ +#include "LyraTheme.h" + +#include +#include + +#include +#include + +#include "Battery.h" +#include "RecentBooksStore.h" +#include "fontIds.h" +#include "util/StringUtils.h" + +// Internal constants +namespace { +constexpr int batteryPercentSpacing = 4; +constexpr int hPaddingInSelection = 8; +constexpr int cornerRadius = 6; +constexpr int topHintButtonY = 345; +} // namespace + +void LyraTheme::drawBattery(const GfxRenderer& renderer, Rect rect, const bool showPercentage) { + // Left aligned battery icon and percentage + const uint16_t percentage = battery.readPercentage(); + if (showPercentage) { + const auto percentageText = std::to_string(percentage) + "%"; + renderer.drawText(SMALL_FONT_ID, rect.x + batteryPercentSpacing + LyraMetrics::values.batteryWidth, rect.y, + percentageText.c_str()); + } + // 1 column on left, 2 columns on right, 5 columns of battery body + const int x = rect.x; + const int y = rect.y + 6; + const int battWidth = LyraMetrics::values.batteryWidth; + + // Top line + renderer.drawLine(x + 1, y, x + battWidth - 3, y); + // Bottom line + renderer.drawLine(x + 1, y + rect.height - 1, x + battWidth - 3, y + rect.height - 1); + // Left line + renderer.drawLine(x, y + 1, x, y + rect.height - 2); + // Battery end + renderer.drawLine(x + battWidth - 2, y + 1, x + battWidth - 2, y + rect.height - 2); + renderer.drawPixel(x + battWidth - 1, y + 3); + renderer.drawPixel(x + battWidth - 1, y + rect.height - 4); + renderer.drawLine(x + battWidth - 0, y + 4, x + battWidth - 0, y + rect.height - 5); + + // Draw bars + if (percentage > 10) { + renderer.fillRect(x + 2, y + 2, 3, rect.height - 4); + } + if (percentage > 40) { + renderer.fillRect(x + 6, y + 2, 3, rect.height - 4); + } + if (percentage > 70) { + renderer.fillRect(x + 10, y + 2, 3, rect.height - 4); + } +} + +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; + if (showBatteryPercentage) { + const uint16_t percentage = battery.readPercentage(); + const auto percentageText = std::to_string(percentage) + "%"; + batteryX -= renderer.getTextWidth(SMALL_FONT_ID, percentageText.c_str()); + } + drawBattery(renderer, + Rect{batteryX, rect.y + 10, LyraMetrics::values.batteryWidth, LyraMetrics::values.batteryHeight}, + showBatteryPercentage); + + if (title) { + renderer.drawText(UI_12_FONT_ID, rect.x + LyraMetrics::values.contentSidePadding, + rect.y + LyraMetrics::values.batteryBarHeight + 3, title, true, EpdFontFamily::BOLD); + renderer.drawLine(rect.x, rect.y + rect.height - 3, rect.x + rect.width, rect.y + rect.height - 3, 3, true); + } +} + +void LyraTheme::drawTabBar(const GfxRenderer& renderer, Rect rect, const std::vector& tabs) { + int currentX = rect.x + LyraMetrics::values.contentSidePadding; + + for (const auto& tab : tabs) { + const int textWidth = renderer.getTextWidth(UI_10_FONT_ID, tab.label, EpdFontFamily::REGULAR); + + // Draw tab label + renderer.drawText(UI_10_FONT_ID, currentX, rect.y + 6, tab.label, true, EpdFontFamily::REGULAR); + + // Draw underline for selected tab + if (tab.selected) { + renderer.drawLine(currentX, rect.y + rect.height - 2, currentX + textWidth, rect.y + rect.height - 2, true); + } + + currentX += textWidth + LyraMetrics::values.tabSpacing; + } + + renderer.drawLine(rect.x, rect.y + rect.height - 1, rect.x + rect.width, rect.y + rect.height - 1, true); +} + +void LyraTheme::drawList(const GfxRenderer& renderer, Rect rect, int itemCount, int selectedIndex, + const std::function& rowTitle, bool hasIcon, + const std::function& rowIcon, bool hasValue, + const std::function& rowValue) { + int pageItems = rect.height / LyraMetrics::values.listRowHeight; + const int rowHeight = LyraMetrics::values.listRowHeight; + + const int totalPages = (itemCount + pageItems - 1) / pageItems; + if (totalPages > 1) { + const int scrollAreaHeight = topHintButtonY - rect.y - LyraMetrics::values.verticalSpacing; + + // Draw scroll bar + const int scrollBarHeight = (scrollAreaHeight * pageItems) / itemCount; + const int currentPage = selectedIndex / pageItems; + const int scrollBarY = rect.y + ((scrollAreaHeight - scrollBarHeight) * currentPage) / (totalPages - 1); + const int scrollBarX = rect.x + rect.width - LyraMetrics::values.scrollBarRightOffset; + renderer.drawLine(scrollBarX, rect.y, scrollBarX, rect.y + scrollAreaHeight, true); + renderer.fillRect(scrollBarX - LyraMetrics::values.scrollBarWidth, scrollBarY, LyraMetrics::values.scrollBarWidth, + scrollBarHeight, true); + } + + // Draw selection + int contentWidth = + rect.width - + (totalPages > 1 ? (LyraMetrics::values.scrollBarWidth + LyraMetrics::values.scrollBarRightOffset) : 1); + renderer.fillRoundedRect(LyraMetrics::values.contentSidePadding, rect.y + selectedIndex % pageItems * rowHeight, + contentWidth - LyraMetrics::values.contentSidePadding * 2, rowHeight, cornerRadius, + COLOR_LIGHT_GRAY); + + // Draw all items + const auto pageStartIndex = selectedIndex / pageItems * pageItems; + for (int i = pageStartIndex; i < itemCount && i < pageStartIndex + pageItems; i++) { + const int itemY = rect.y + (i % pageItems) * rowHeight; + + // Draw name + auto itemName = rowTitle(i); + auto item = + renderer.truncatedText(UI_10_FONT_ID, itemName.c_str(), + contentWidth - LyraMetrics::values.contentSidePadding * 2 - hPaddingInSelection * 2 - + (hasValue ? 60 : 0)); // TODO truncate according to value width? + renderer.drawText(UI_10_FONT_ID, rect.x + LyraMetrics::values.contentSidePadding + hPaddingInSelection * 2, + itemY + 6, item.c_str(), true); + + if (hasValue) { + // Draw value + std::string valueText = rowValue(i); + if (!valueText.empty()) { + const auto textWidth = renderer.getTextWidth(UI_10_FONT_ID, valueText.c_str()); + + if (i == selectedIndex) { + renderer.fillRoundedRect( + contentWidth - LyraMetrics::values.contentSidePadding - hPaddingInSelection * 2 - textWidth, itemY, + textWidth + hPaddingInSelection * 2, rowHeight, cornerRadius, COLOR_BLACK); + } + + renderer.drawText(UI_10_FONT_ID, + contentWidth - LyraMetrics::values.contentSidePadding - hPaddingInSelection - textWidth, + itemY + 6, valueText.c_str(), i != selectedIndex); + } + } + } +} + +void LyraTheme::drawButtonHints(const GfxRenderer& renderer, const char* btn1, const char* btn2, const char* btn3, + const char* btn4) { + // TODO: Fix rotated hints + // const GfxRenderer::Orientation orig_orientation = renderer.getOrientation(); + // renderer.setOrientation(GfxRenderer::Orientation::Portrait); + + const int pageHeight = renderer.getScreenHeight(); + constexpr int buttonWidth = 80; + constexpr int buttonHeight = LyraMetrics::values.buttonHintsHeight; + constexpr int buttonY = LyraMetrics::values.buttonHintsHeight; // Distance from bottom + constexpr int textYOffset = 7; // Distance from top of button to text baseline + constexpr int buttonPositions[] = {68, 156, 244, 332}; + const char* labels[] = {btn1, btn2, btn3, btn4}; + + for (int i = 0; i < 4; i++) { + // Only draw if the label is non-empty + if (labels[i] != nullptr && labels[i][0] != '\0') { + const int x = buttonPositions[i]; + renderer.fillRect(x, pageHeight - buttonY, buttonWidth, buttonHeight, false); + renderer.drawRoundedRect(x, pageHeight - buttonY, buttonWidth, buttonHeight, 1, cornerRadius, true, true, false, + false, true); + const int textWidth = renderer.getTextWidth(SMALL_FONT_ID, labels[i]); + const int textX = x + (buttonWidth - 1 - textWidth) / 2; + renderer.drawText(SMALL_FONT_ID, textX, pageHeight - buttonY + textYOffset, labels[i]); + } + } + + // renderer.setOrientation(orig_orientation); +} + +void LyraTheme::drawSideButtonHints(const GfxRenderer& renderer, const char* topBtn, const char* bottomBtn) { + const int screenWidth = renderer.getScreenWidth(); + constexpr int buttonWidth = LyraMetrics::values.sideButtonHintsWidth; // Width on screen (height when rotated) + constexpr int buttonHeight = 80; // Height on screen (width when rotated) + // Position for the button group - buttons share a border so they're adjacent + + const char* labels[] = {topBtn, bottomBtn}; + + // Draw the shared border for both buttons as one unit + const int x = screenWidth - buttonWidth; + + // Draw top button outline + if (topBtn != nullptr && topBtn[0] != '\0') { + renderer.drawRoundedRect(x, topHintButtonY, buttonWidth, buttonHeight, 1, cornerRadius, true, false, true, false, + true); + } + + // Draw bottom button outline + if (bottomBtn != nullptr && bottomBtn[0] != '\0') { + renderer.drawRoundedRect(x, topHintButtonY + buttonHeight + 5, buttonWidth, buttonHeight, 1, cornerRadius, true, + false, true, false, true); + } + + // Draw text for each button + for (int i = 0; i < 2; i++) { + if (labels[i] != nullptr && labels[i][0] != '\0') { + const int y = topHintButtonY + (i * buttonHeight + 5); + + // Draw rotated text centered in the button + const int textWidth = renderer.getTextWidth(SMALL_FONT_ID, labels[i]); + + renderer.drawTextRotated90CW(SMALL_FONT_ID, x, y + (buttonHeight + textWidth) / 2, labels[i]); + } + } +} + +void LyraTheme::drawRecentBookCover(GfxRenderer& renderer, Rect rect, + const std::vector& recentBooks, const int selectorIndex, + bool& coverRendered, bool& coverBufferStored, bool& bufferRestored, + std::function storeCoverBuffer) { + const int tileWidth = (rect.width - 2 * LyraMetrics::values.contentSidePadding) / 3; + const int tileHeight = rect.height; + const int bookTitleHeight = 53; + const int tileY = rect.y; + const bool hasContinueReading = !recentBooks.empty(); + + // Draw book card regardless, fill with message based on `hasContinueReading` + // Draw cover image as background if available (inside the box) + // Only load from SD on first render, then use stored buffer + if (hasContinueReading) { + if (!coverRendered) { + for (int i = 0; i < std::min(static_cast(recentBooks.size()), LyraMetrics::values.homeRecentBooksCount); + i++) { + const std::string& coverBmpPath = recentBooks[i].coverBmpPath; + + if (coverBmpPath.empty()) { + continue; + } + + int tileX = LyraMetrics::values.contentSidePadding + tileWidth * i; + + // First time: load cover from SD and render + FsFile file; + if (SdMan.openFileForRead("HOME", coverBmpPath, file)) { + Bitmap bitmap(file); + if (bitmap.parseHeaders() == BmpReaderError::Ok) { + renderer.drawBitmap(bitmap, tileX + hPaddingInSelection, tileY + hPaddingInSelection, + tileWidth - 2 * hPaddingInSelection, + tileHeight - bookTitleHeight - hPaddingInSelection); + } + file.close(); + } + } + + coverBufferStored = storeCoverBuffer(); + coverRendered = true; + } + + for (int i = 0; i < std::min(static_cast(recentBooks.size()), LyraMetrics::values.homeRecentBooksCount); i++) { + bool bookSelected = (selectorIndex == i); + + int tileX = LyraMetrics::values.contentSidePadding + tileWidth * i; + auto title = + renderer.truncatedText(UI_10_FONT_ID, recentBooks[i].book.title.c_str(), tileWidth - 2 * hPaddingInSelection); + + if (bookSelected) { + // Draw selection box + renderer.fillRoundedRect(tileX, tileY, tileWidth, hPaddingInSelection, cornerRadius, true, true, false, false, + COLOR_LIGHT_GRAY); + renderer.fillRectDither(tileX, tileY + hPaddingInSelection, hPaddingInSelection, + tileHeight - hPaddingInSelection, COLOR_LIGHT_GRAY); + renderer.fillRectDither(tileX + tileWidth - hPaddingInSelection, tileY + hPaddingInSelection, + hPaddingInSelection, tileHeight - hPaddingInSelection, COLOR_LIGHT_GRAY); + renderer.fillRoundedRect(tileX, tileY + tileHeight + hPaddingInSelection - bookTitleHeight, tileWidth, + bookTitleHeight, cornerRadius, false, false, true, true, COLOR_LIGHT_GRAY); + } + renderer.drawText(UI_10_FONT_ID, tileX + hPaddingInSelection, + tileY + tileHeight - bookTitleHeight + hPaddingInSelection + 5, title.c_str(), true); + } + } +} + +void LyraTheme::drawButtonMenu(GfxRenderer& renderer, Rect rect, int buttonCount, int selectedIndex, + const std::function& buttonLabel, bool hasIcon, + const std::function& rowIcon) { + for (int i = 0; i < buttonCount; ++i) { + int tileWidth = (rect.width - LyraMetrics::values.contentSidePadding * 2 - LyraMetrics::values.menuSpacing) / 2; + Rect tileRect = + Rect{rect.x + LyraMetrics::values.contentSidePadding + (LyraMetrics::values.menuSpacing + tileWidth) * (i % 2), + rect.y + static_cast(i / 2) * (LyraMetrics::values.menuRowHeight + LyraMetrics::values.menuSpacing), + tileWidth, LyraMetrics::values.menuRowHeight}; + + const bool selected = selectedIndex == i; + + if (selected) { + renderer.fillRoundedRect(tileRect.x, tileRect.y, tileRect.width, tileRect.height, cornerRadius, COLOR_LIGHT_GRAY); + } + + const char* label = buttonLabel(i).c_str(); + const int textX = tileRect.x + 16; + const int lineHeight = renderer.getLineHeight(UI_12_FONT_ID); + const int textY = tileRect.y + (LyraMetrics::values.menuRowHeight - lineHeight) / 2; + + // 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::REGULAR); + 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::REGULAR); + renderer.displayBuffer(); +} \ No newline at end of file diff --git a/src/components/themes/lyra/LyraTheme.h b/src/components/themes/lyra/LyraTheme.h new file mode 100644 index 00000000..22fad551 --- /dev/null +++ b/src/components/themes/lyra/LyraTheme.h @@ -0,0 +1,54 @@ +#pragma once + +#include "components/themes/BaseTheme.h" + +class GfxRenderer; + +// Lyra theme metrics (zero runtime cost) +namespace LyraMetrics { +constexpr ThemeMetrics values = {.batteryWidth = 16, + .batteryHeight = 12, + .topPadding = 5, + .batteryBarHeight = 40, + .headerHeight = 84, + .verticalSpacing = 16, + .contentSidePadding = 20, + .listRowHeight = 40, + .menuRowHeight = 64, + .menuSpacing = 8, + .tabSpacing = 20, + .tabBarHeight = 40, + .scrollBarWidth = 4, + .scrollBarRightOffset = 5, + .homeTopPadding = 56, + .homeCoverHeight = 266, + .homeRecentBooksCount = 3, + .buttonHintsHeight = 40, + .sideButtonHintsWidth = 19, + .versionTextRightX = 20, + .versionTextY = 55, + .bookProgressBarHeight = 4}; +} + +class LyraTheme : public BaseTheme { + public: + // Component drawing methods + // void drawProgressBar(const GfxRenderer& renderer, Rect rect, size_t current, size_t total) override; + void drawBattery(const GfxRenderer& renderer, Rect rect, bool showPercentage = true) override; + void drawHeader(const GfxRenderer& renderer, Rect rect, const char* title) override; + void drawTabBar(const GfxRenderer& renderer, Rect rect, const std::vector& tabs) override; + void drawList(const GfxRenderer& renderer, Rect rect, int itemCount, int selectedIndex, + const std::function& rowTitle, bool hasIcon, + const std::function& rowIcon, bool hasValue, + const std::function& rowValue) override; + void drawButtonHints(const GfxRenderer& renderer, const char* btn1, const char* btn2, const char* btn3, + const char* btn4) override; + void drawSideButtonHints(const GfxRenderer& renderer, const char* topBtn, const char* bottomBtn) override; + void drawButtonMenu(GfxRenderer& renderer, Rect rect, int buttonCount, int selectedIndex, + const std::function& buttonLabel, bool hasIcon, + const std::function& rowIcon) override; + void drawRecentBookCover(GfxRenderer& renderer, Rect rect, const std::vector& recentBooks, + const int selectorIndex, bool& coverRendered, bool& coverBufferStored, bool& bufferRestored, + std::function storeCoverBuffer) override; + void drawPopup(GfxRenderer& renderer, const char* message) override; +}; diff --git a/src/main.cpp b/src/main.cpp index 2308f0a2..d6e301df 100644 --- a/src/main.cpp +++ b/src/main.cpp @@ -24,6 +24,7 @@ #include "activities/reader/ReaderActivity.h" #include "activities/settings/SettingsActivity.h" #include "activities/util/FullScreenMessageActivity.h" +#include "components/UITheme.h" #include "fontIds.h" HalDisplay display; @@ -209,7 +210,6 @@ void onGoToReader(const std::string& initialEpubPath, MyLibraryActivity::Tab fro enterNewActivity( new ReaderActivity(renderer, mappedInputManager, initialEpubPath, fromTab, onGoHome, onGoToMyLibraryWithTab)); } -void onContinueReading() { onGoToReader(APP_STATE.openEpubPath, MyLibraryActivity::Tab::Recent); } void onGoToFileTransfer() { exitActivity(); @@ -238,7 +238,7 @@ void onGoToBrowser() { void onGoHome() { exitActivity(); - enterNewActivity(new HomeActivity(renderer, mappedInputManager, onContinueReading, onGoToMyLibrary, onGoToSettings, + enterNewActivity(new HomeActivity(renderer, mappedInputManager, onGoToReader, onGoToMyLibrary, onGoToSettings, onGoToFileTransfer, onGoToBrowser)); } @@ -293,6 +293,7 @@ void setup() { SETTINGS.loadFromFile(); KOREADER_STORE.loadFromFile(); + UITheme::initialize(); if (gpio.isWakeupByPowerButton()) { // For normal wakeups, verify power button press duration From 2b33d92a0111db4e2c4a6a04fe4ac8282834bca5 Mon Sep 17 00:00:00 2001 From: CaptainFrito Date: Thu, 29 Jan 2026 17:08:33 +0700 Subject: [PATCH 2/2] Updated tabs controls --- src/activities/home/MyLibraryActivity.cpp | 2 +- src/activities/settings/SettingsActivity.cpp | 33 ++++++++++------- src/components/UITheme.cpp | 4 +- src/components/UITheme.h | 2 +- src/components/themes/BaseTheme.cpp | 20 ++++++---- src/components/themes/BaseTheme.h | 2 +- src/components/themes/lyra/LyraTheme.cpp | 39 ++++++++++++++------ src/components/themes/lyra/LyraTheme.h | 4 +- 8 files changed, 67 insertions(+), 39 deletions(-) diff --git a/src/activities/home/MyLibraryActivity.cpp b/src/activities/home/MyLibraryActivity.cpp index f211d044..2dcacc22 100644 --- a/src/activities/home/MyLibraryActivity.cpp +++ b/src/activities/home/MyLibraryActivity.cpp @@ -233,7 +233,7 @@ void MyLibraryActivity::render() const { UITheme::drawHeader(renderer, Rect{0, metrics.topPadding, pageWidth, metrics.headerHeight}, folderName); UITheme::drawTabBar(renderer, Rect{0, metrics.topPadding + metrics.headerHeight, pageWidth, metrics.tabBarHeight}, - {{"Recent", currentTab == Tab::Recent}, {"Files", currentTab == Tab::Files}}); + {{"Recent", currentTab == Tab::Recent}, {"Files", currentTab == Tab::Files}}, false); const int contentTop = metrics.topPadding + metrics.headerHeight + metrics.tabBarHeight + metrics.verticalSpacing; const int contentHeight = pageHeight - contentTop - metrics.buttonHintsHeight - metrics.verticalSpacing * 2; diff --git a/src/activities/settings/SettingsActivity.cpp b/src/activities/settings/SettingsActivity.cpp index c3910d9f..206388aa 100644 --- a/src/activities/settings/SettingsActivity.cpp +++ b/src/activities/settings/SettingsActivity.cpp @@ -110,12 +110,19 @@ void SettingsActivity::loop() { subActivity->loop(); return; } - + bool hasChangedCategory = false; + // Handle actions with early return if (mappedInput.wasPressed(MappedInputManager::Button::Confirm)) { - toggleCurrentSetting(); - updateRequired = true; - return; + if (selectedSettingIndex == 0) { + selectedCategoryIndex = (selectedCategoryIndex < categoryCount - 1) ? (selectedCategoryIndex + 1) : 0; + hasChangedCategory = true; + updateRequired = true; + } else { + toggleCurrentSetting(); + updateRequired = true; + return; + } } if (mappedInput.wasPressed(MappedInputManager::Button::Back)) { @@ -124,14 +131,13 @@ void SettingsActivity::loop() { return; } - bool hasChangedCategory = false; // Handle navigation if (mappedInput.wasPressed(MappedInputManager::Button::Left)) { - selectedSettingIndex = (selectedSettingIndex > 0) ? (selectedSettingIndex - 1) : (settingsCount - 1); + selectedSettingIndex = (selectedSettingIndex > 0) ? (selectedSettingIndex - 1) : (settingsCount); updateRequired = true; } else if (mappedInput.wasPressed(MappedInputManager::Button::Right)) { - selectedSettingIndex = (selectedSettingIndex < settingsCount - 1) ? (selectedSettingIndex + 1) : 0; + selectedSettingIndex = (selectedSettingIndex < settingsCount) ? (selectedSettingIndex + 1) : 0; updateRequired = true; } else if (mappedInput.wasPressed(MappedInputManager::Button::Up)) { hasChangedCategory = true; @@ -144,7 +150,7 @@ void SettingsActivity::loop() { } if (hasChangedCategory) { - selectedSettingIndex = 0; + selectedSettingIndex = (selectedSettingIndex == 0) ? 0 : 1; switch (selectedCategoryIndex) { case 0: // Display settingsList = displaySettings; @@ -167,11 +173,12 @@ void SettingsActivity::loop() { } void SettingsActivity::toggleCurrentSetting() { - if (selectedSettingIndex < 0 || selectedSettingIndex >= settingsCount) { + int selectedSetting = selectedSettingIndex - 1; + if (selectedSetting < 0 || selectedSetting >= settingsCount) { return; } - const auto& setting = settingsList[selectedSettingIndex]; + const auto& setting = settingsList[selectedSetting]; if (setting.type == SettingType::TOGGLE && setting.valuePtr != nullptr) { // Toggle the boolean value using the member pointer @@ -256,14 +263,14 @@ void SettingsActivity::render() const { tabs.push_back({categoryNames[i], selectedCategoryIndex == i}); } UITheme::drawTabBar(renderer, Rect{0, metrics.topPadding + metrics.headerHeight, pageWidth, metrics.tabBarHeight}, - tabs); + tabs, selectedSettingIndex == 0); UITheme::drawList( renderer, Rect{0, metrics.topPadding + metrics.headerHeight + metrics.tabBarHeight + metrics.verticalSpacing, pageWidth, pageHeight - (metrics.topPadding + metrics.headerHeight + metrics.tabBarHeight + metrics.buttonHintsHeight + metrics.verticalSpacing * 2)}, - settingsCount, selectedSettingIndex, [this](int index) { return std::string(settingsList[index].name); }, false, + settingsCount, selectedSettingIndex - 1, [this](int index) { return std::string(settingsList[index].name); }, false, nullptr, true, [this](int i) { const auto& setting = settingsList[i]; @@ -286,7 +293,7 @@ void SettingsActivity::render() const { metrics.versionTextY, CROSSPOINT_VERSION); // Draw help text - const auto labels = mappedInput.mapLabels("« Back", "Select", "Up", "Down"); + const auto labels = mappedInput.mapLabels("« Back", selectedSettingIndex == 0 ? "Tab >" : "Toggle", "Up", "Down"); UITheme::drawButtonHints(renderer, labels.btn1, labels.btn2, labels.btn3, labels.btn4); UITheme::drawSideButtonHints(renderer, "^", "v"); diff --git a/src/components/UITheme.cpp b/src/components/UITheme.cpp index 8c790654..dac4de6f 100644 --- a/src/components/UITheme.cpp +++ b/src/components/UITheme.cpp @@ -89,9 +89,9 @@ void UITheme::drawHeader(const GfxRenderer& renderer, Rect rect, const char* tit } } -void UITheme::drawTabBar(const GfxRenderer& renderer, const Rect rect, const std::vector& tabs) { +void UITheme::drawTabBar(const GfxRenderer& renderer, const Rect rect, const std::vector& tabs, bool selected) { if (currentTheme != nullptr) { - currentTheme->drawTabBar(renderer, rect, tabs); + currentTheme->drawTabBar(renderer, rect, tabs, selected); } } diff --git a/src/components/UITheme.h b/src/components/UITheme.h index cc43ffb9..1790cb58 100644 --- a/src/components/UITheme.h +++ b/src/components/UITheme.h @@ -81,7 +81,7 @@ class UITheme { const std::function& rowIcon, bool hasValue, const std::function& rowValue); static void drawHeader(const GfxRenderer& renderer, Rect rect, const char* title); - static void drawTabBar(const GfxRenderer& renderer, Rect rect, const std::vector& tabs); + static void drawTabBar(const GfxRenderer& renderer, Rect rect, const std::vector& tabs, bool selected); static void drawRecentBookCover(GfxRenderer& renderer, Rect rect, const std::vector& recentBooks, const int selectorIndex, bool& coverRendered, bool& coverBufferStored, bool& bufferRestored, std::function storeCoverBuffer); diff --git a/src/components/themes/BaseTheme.cpp b/src/components/themes/BaseTheme.cpp index 31ad738e..84b33c65 100644 --- a/src/components/themes/BaseTheme.cpp +++ b/src/components/themes/BaseTheme.cpp @@ -191,7 +191,9 @@ void BaseTheme::drawList(const GfxRenderer& renderer, Rect rect, int itemCount, // Draw selection int contentWidth = rect.width - BaseMetrics::values.sideButtonHintsWidth - 5; - renderer.fillRect(0, rect.y + selectedIndex % pageItems * rowHeight - 2, contentWidth, rowHeight); + if (selectedIndex >= 0) { + renderer.fillRect(0, rect.y + selectedIndex % pageItems * rowHeight - 2, contentWidth, rowHeight); + } // Draw all items const auto pageStartIndex = selectedIndex / pageItems * pageItems; for (int i = pageStartIndex; i < itemCount && i < pageStartIndex + pageItems; i++) { @@ -232,7 +234,7 @@ void BaseTheme::drawHeader(const GfxRenderer& renderer, Rect rect, const char* t } } -void BaseTheme::drawTabBar(const GfxRenderer& renderer, const Rect rect, const std::vector& tabs) { +void BaseTheme::drawTabBar(const GfxRenderer& renderer, const Rect rect, const std::vector& tabs, bool selected) { constexpr int underlineHeight = 2; // Height of selection underline constexpr int underlineGap = 4; // Gap between text and underline @@ -244,15 +246,19 @@ void BaseTheme::drawTabBar(const GfxRenderer& renderer, const Rect rect, const s const int textWidth = renderer.getTextWidth(UI_12_FONT_ID, tab.label, tab.selected ? EpdFontFamily::BOLD : EpdFontFamily::REGULAR); - // Draw tab label - renderer.drawText(UI_12_FONT_ID, currentX, rect.y, tab.label, true, - tab.selected ? EpdFontFamily::BOLD : EpdFontFamily::REGULAR); - // Draw underline for selected tab if (tab.selected) { - renderer.fillRect(currentX, rect.y + lineHeight + underlineGap, textWidth, underlineHeight); + if (selected) { + renderer.fillRect(currentX - 3, rect.y, textWidth + 6, lineHeight + underlineGap); + } else { + renderer.fillRect(currentX, rect.y + lineHeight + underlineGap, textWidth, underlineHeight); + } } + // Draw tab label + renderer.drawText(UI_12_FONT_ID, currentX, rect.y, tab.label, !(tab.selected && selected), + tab.selected ? EpdFontFamily::BOLD : EpdFontFamily::REGULAR); + currentX += textWidth + BaseMetrics::values.tabSpacing; } } diff --git a/src/components/themes/BaseTheme.h b/src/components/themes/BaseTheme.h index 7ab2a96e..8a739372 100644 --- a/src/components/themes/BaseTheme.h +++ b/src/components/themes/BaseTheme.h @@ -53,7 +53,7 @@ class BaseTheme { const std::function& rowValue); virtual void drawHeader(const GfxRenderer& renderer, Rect rect, const char* title); - virtual void drawTabBar(const GfxRenderer& renderer, Rect rect, const std::vector& tabs); + virtual void drawTabBar(const GfxRenderer& renderer, Rect rect, const std::vector& tabs, bool selected); virtual void drawRecentBookCover(GfxRenderer& renderer, Rect rect, const std::vector& recentBooks, const int selectorIndex, bool& coverRendered, bool& coverBufferStored, bool& bufferRestored, diff --git a/src/components/themes/lyra/LyraTheme.cpp b/src/components/themes/lyra/LyraTheme.cpp index e9a0c078..a726fc6b 100644 --- a/src/components/themes/lyra/LyraTheme.cpp +++ b/src/components/themes/lyra/LyraTheme.cpp @@ -78,21 +78,30 @@ void LyraTheme::drawHeader(const GfxRenderer& renderer, Rect rect, const char* t } } -void LyraTheme::drawTabBar(const GfxRenderer& renderer, Rect rect, const std::vector& tabs) { +void LyraTheme::drawTabBar(const GfxRenderer& renderer, Rect rect, const std::vector& tabs, bool selected) { int currentX = rect.x + LyraMetrics::values.contentSidePadding; + if (selected) { + renderer.fillRectDither(rect.x, rect.y, rect.width, rect.height, COLOR_LIGHT_GRAY); + } + for (const auto& tab : tabs) { const int textWidth = renderer.getTextWidth(UI_10_FONT_ID, tab.label, EpdFontFamily::REGULAR); - // Draw tab label - renderer.drawText(UI_10_FONT_ID, currentX, rect.y + 6, tab.label, true, EpdFontFamily::REGULAR); - - // Draw underline for selected tab if (tab.selected) { - renderer.drawLine(currentX, rect.y + rect.height - 2, currentX + textWidth, rect.y + rect.height - 2, true); + if (selected) { + renderer.fillRoundedRect(currentX, rect.y + 1, textWidth + 2 * hPaddingInSelection, rect.height - 4, + cornerRadius, COLOR_BLACK); + } else { + renderer.fillRectDither(currentX, rect.y, textWidth + 2 * hPaddingInSelection, rect.height - 3, COLOR_LIGHT_GRAY); + renderer.drawLine(currentX, rect.y + rect.height - 3, currentX + textWidth + 2 * hPaddingInSelection, rect.y + rect.height - 3, 2, true); + } } - currentX += textWidth + LyraMetrics::values.tabSpacing; + renderer.drawText(UI_10_FONT_ID, currentX + hPaddingInSelection, rect.y + 6, tab.label, !(tab.selected && selected), EpdFontFamily::REGULAR); + + + currentX += textWidth + LyraMetrics::values.tabSpacing + 2 * hPaddingInSelection; } renderer.drawLine(rect.x, rect.y + rect.height - 1, rect.x + rect.width, rect.y + rect.height - 1, true); @@ -123,9 +132,11 @@ void LyraTheme::drawList(const GfxRenderer& renderer, Rect rect, int itemCount, int contentWidth = rect.width - (totalPages > 1 ? (LyraMetrics::values.scrollBarWidth + LyraMetrics::values.scrollBarRightOffset) : 1); - renderer.fillRoundedRect(LyraMetrics::values.contentSidePadding, rect.y + selectedIndex % pageItems * rowHeight, + if (selectedIndex >= 0) { + renderer.fillRoundedRect(LyraMetrics::values.contentSidePadding, rect.y + selectedIndex % pageItems * rowHeight, contentWidth - LyraMetrics::values.contentSidePadding * 2, rowHeight, cornerRadius, COLOR_LIGHT_GRAY); + } // Draw all items const auto pageStartIndex = selectedIndex / pageItems * pageItems; @@ -169,22 +180,26 @@ void LyraTheme::drawButtonHints(const GfxRenderer& renderer, const char* btn1, c const int pageHeight = renderer.getScreenHeight(); constexpr int buttonWidth = 80; + constexpr int smallButtonHeight = 15; constexpr int buttonHeight = LyraMetrics::values.buttonHintsHeight; constexpr int buttonY = LyraMetrics::values.buttonHintsHeight; // Distance from bottom constexpr int textYOffset = 7; // Distance from top of button to text baseline - constexpr int buttonPositions[] = {68, 156, 244, 332}; + constexpr int buttonPositions[] = {58, 146, 254, 342}; const char* labels[] = {btn1, btn2, btn3, btn4}; for (int i = 0; i < 4; i++) { // Only draw if the label is non-empty + const int x = buttonPositions[i]; + renderer.fillRect(x, pageHeight - buttonY, buttonWidth, buttonHeight, false); if (labels[i] != nullptr && labels[i][0] != '\0') { - const int x = buttonPositions[i]; - renderer.fillRect(x, pageHeight - buttonY, buttonWidth, buttonHeight, false); renderer.drawRoundedRect(x, pageHeight - buttonY, buttonWidth, buttonHeight, 1, cornerRadius, true, true, false, false, true); const int textWidth = renderer.getTextWidth(SMALL_FONT_ID, labels[i]); const int textX = x + (buttonWidth - 1 - textWidth) / 2; renderer.drawText(SMALL_FONT_ID, textX, pageHeight - buttonY + textYOffset, labels[i]); + } else { + renderer.drawRoundedRect(x, pageHeight - smallButtonHeight, buttonWidth, smallButtonHeight, 1, cornerRadius, true, true, false, + false, true); } } @@ -194,7 +209,7 @@ void LyraTheme::drawButtonHints(const GfxRenderer& renderer, const char* btn1, c void LyraTheme::drawSideButtonHints(const GfxRenderer& renderer, const char* topBtn, const char* bottomBtn) { const int screenWidth = renderer.getScreenWidth(); constexpr int buttonWidth = LyraMetrics::values.sideButtonHintsWidth; // Width on screen (height when rotated) - constexpr int buttonHeight = 80; // Height on screen (width when rotated) + constexpr int buttonHeight = 78; // Height on screen (width when rotated) // Position for the button group - buttons share a border so they're adjacent const char* labels[] = {topBtn, bottomBtn}; diff --git a/src/components/themes/lyra/LyraTheme.h b/src/components/themes/lyra/LyraTheme.h index 22fad551..3fad82e4 100644 --- a/src/components/themes/lyra/LyraTheme.h +++ b/src/components/themes/lyra/LyraTheme.h @@ -16,7 +16,7 @@ constexpr ThemeMetrics values = {.batteryWidth = 16, .listRowHeight = 40, .menuRowHeight = 64, .menuSpacing = 8, - .tabSpacing = 20, + .tabSpacing = 8, .tabBarHeight = 40, .scrollBarWidth = 4, .scrollBarRightOffset = 5, @@ -36,7 +36,7 @@ class LyraTheme : public BaseTheme { // void drawProgressBar(const GfxRenderer& renderer, Rect rect, size_t current, size_t total) override; void drawBattery(const GfxRenderer& renderer, Rect rect, bool showPercentage = true) override; void drawHeader(const GfxRenderer& renderer, Rect rect, const char* title) override; - void drawTabBar(const GfxRenderer& renderer, Rect rect, const std::vector& tabs) override; + void drawTabBar(const GfxRenderer& renderer, Rect rect, const std::vector& tabs, bool selected) override; void drawList(const GfxRenderer& renderer, Rect rect, int itemCount, int selectedIndex, const std::function& rowTitle, bool hasIcon, const std::function& rowIcon, bool hasValue,