diff --git a/lib/Epub/Epub.cpp b/lib/Epub/Epub.cpp index 7559e3b3..644fceed 100644 --- a/lib/Epub/Epub.cpp +++ b/lib/Epub/Epub.cpp @@ -428,11 +428,12 @@ bool Epub::generateCoverBmp(bool cropped) const { return false; } -std::string Epub::getThumbBmpPath() const { return cachePath + "/thumb.bmp"; } +std::string Epub::getThumbBmpPath() const { return cachePath + "/thumb_[HEIGHT].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 +445,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 +462,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 +478,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 +487,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..f4e99a23 100644 --- a/lib/Epub/Epub.h +++ b/lib/Epub/Epub.h @@ -48,7 +48,8 @@ class Epub { 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 b5aa7710..c06698db 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 + 1, y, horizontalWidth - 2, height, color); + } + + const int verticalHeight = height - 2 * maxRadius - 2; + if (verticalHeight > 0) { + fillRectDither(x, y + maxRadius + 1, maxRadius + 1, verticalHeight, color); + fillRectDither(x + width - maxRadius - 1, y + maxRadius + 1, maxRadius + 1, verticalHeight, color); + } + + if (roundTopLeft) { + fillArc(maxRadius, x + maxRadius, y + maxRadius, -1, -1, color); + } else { + fillRectDither(x, y, maxRadius + 1, maxRadius + 1, color); + } + + if (roundTopRight) { + fillArc(maxRadius, x + width - maxRadius - 1, y + maxRadius, 1, -1, color); + } else { + fillRectDither(x + width - maxRadius - 1, y, maxRadius + 1, maxRadius + 1, color); + } + + if (roundBottomRight) { + fillArc(maxRadius, x + width - maxRadius - 1, y + height - maxRadius - 1, 1, 1, color); + } else { + fillRectDither(x + width - maxRadius - 1, y + height - maxRadius - 1, maxRadius + 1, maxRadius + 1, color); + } + + if (roundBottomLeft) { + fillArc(maxRadius, x + maxRadius, y + height - maxRadius - 1, -1, 1, color); + } else { + fillRectDither(x, y + height - maxRadius - 1, maxRadius + 1, maxRadius + 1, 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) @@ -488,85 +701,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 86ddc8fc..26d5f25b 100644 --- a/lib/GfxRenderer/GfxRenderer.h +++ b/lib/GfxRenderer/GfxRenderer.h @@ -7,6 +7,10 @@ #include "Bitmap.h" +// Color representation: uint8_t mapped to 4x4 Bayer matrix dithering levels +// 0 = transparent, 1-16 = gray levels (white to black) +enum Color : uint8_t { Clear = 0x00, White = 0x01, LightGray = 0x05, DarkGray = 0x0A, Black = 0x10 }; + class GfxRenderer { public: enum RenderMode { BW, GRAYSCALE_LSB, GRAYSCALE_MSB }; @@ -34,6 +38,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 +69,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 +100,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..05f6651d 100644 --- a/lib/Xtc/Xtc.cpp +++ b/lib/Xtc/Xtc.cpp @@ -301,11 +301,12 @@ bool Xtc::generateCoverBmp() const { return true; } -std::string Xtc::getThumbBmpPath() const { return cachePath + "/thumb.bmp"; } +std::string Xtc::getThumbBmpPath() const { return cachePath + "/thumb_[HEIGHT].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 +334,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 +349,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 +360,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 +394,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 +559,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..9b75f586 100644 --- a/lib/Xtc/Xtc.h +++ b/lib/Xtc/Xtc.h @@ -65,7 +65,8 @@ class Xtc { 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.cpp b/src/RecentBooksStore.cpp index 5932de36..cc28231c 100644 --- a/src/RecentBooksStore.cpp +++ b/src/RecentBooksStore.cpp @@ -7,14 +7,15 @@ #include namespace { -constexpr uint8_t RECENT_BOOKS_FILE_VERSION = 2; +constexpr uint8_t RECENT_BOOKS_FILE_VERSION = 3; constexpr char RECENT_BOOKS_FILE[] = "/.crosspoint/recent.bin"; constexpr int MAX_RECENT_BOOKS = 10; } // namespace RecentBooksStore RecentBooksStore::instance; -void RecentBooksStore::addBook(const std::string& path, const std::string& title, const std::string& author) { +void RecentBooksStore::addBook(const std::string& path, const std::string& title, const std::string& author, + const std::string& coverBmpPath) { // Remove existing entry if present auto it = std::find_if(recentBooks.begin(), recentBooks.end(), [&](const RecentBook& book) { return book.path == path; }); @@ -23,7 +24,7 @@ void RecentBooksStore::addBook(const std::string& path, const std::string& title } // Add to front - recentBooks.insert(recentBooks.begin(), {path, title, author}); + recentBooks.insert(recentBooks.begin(), {path, title, author, coverBmpPath}); // Trim to max size if (recentBooks.size() > MAX_RECENT_BOOKS) { @@ -50,6 +51,7 @@ bool RecentBooksStore::saveToFile() const { serialization::writeString(outputFile, book.path); serialization::writeString(outputFile, book.title); serialization::writeString(outputFile, book.author); + serialization::writeString(outputFile, book.coverBmpPath); } outputFile.close(); @@ -77,7 +79,23 @@ bool RecentBooksStore::loadFromFile() { serialization::readString(inputFile, path); // Title and author will be empty, they will be filled when the book is // opened again - recentBooks.push_back({path, "", ""}); + std::string fileName = path.substr(path.rfind('/') + 1); + recentBooks.push_back({path, fileName, "", "-"}); + } + } else if (version == 2) { + // Old version, just read paths, titles and authors + uint8_t count; + serialization::readPod(inputFile, count); + recentBooks.clear(); + recentBooks.reserve(count); + for (uint8_t i = 0; i < count; i++) { + std::string path, title, author; + serialization::readString(inputFile, path); + serialization::readString(inputFile, title); + serialization::readString(inputFile, author); + // Title and author will be empty, they will be filled when the book is + // opened again + recentBooks.push_back({path, title, author, "-"}); } } else { Serial.printf("[%lu] [RBS] Deserialization failed: Unknown version %u\n", millis(), version); @@ -92,11 +110,12 @@ bool RecentBooksStore::loadFromFile() { recentBooks.reserve(count); for (uint8_t i = 0; i < count; i++) { - std::string path, title, author; + std::string path, title, author, coverBmpPath; serialization::readString(inputFile, path); serialization::readString(inputFile, title); serialization::readString(inputFile, author); - recentBooks.push_back({path, title, author}); + serialization::readString(inputFile, coverBmpPath); + recentBooks.push_back({path, title, author, coverBmpPath}); } } diff --git a/src/RecentBooksStore.h b/src/RecentBooksStore.h index 7b87f1e0..16b7a8e2 100644 --- a/src/RecentBooksStore.h +++ b/src/RecentBooksStore.h @@ -6,6 +6,7 @@ struct RecentBook { std::string path; std::string title; std::string author; + std::string coverBmpPath; bool operator==(const RecentBook& other) const { return path == other.path; } }; @@ -23,7 +24,8 @@ class RecentBooksStore { static RecentBooksStore& getInstance() { return instance; } // Add a book to the recent list (moves to front if already exists) - void addBook(const std::string& path, const std::string& title, const std::string& author); + void addBook(const std::string& path, const std::string& title, const std::string& author, + const std::string& coverBmpPath); // Get the list of recent books (most recent first) const std::vector& getBooks() const { return recentBooks; } diff --git a/src/ScreenComponents.cpp b/src/ScreenComponents.cpp deleted file mode 100644 index 72f7faf0..00000000 --- a/src/ScreenComponents.cpp +++ /dev/null @@ -1,178 +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); -} - -ScreenComponents::PopupLayout ScreenComponents::drawPopup(const GfxRenderer& renderer, const char* message) { - constexpr int margin = 15; - constexpr int y = 60; - const int textWidth = renderer.getTextWidth(UI_12_FONT_ID, message, EpdFontFamily::BOLD); - const int textHeight = renderer.getLineHeight(UI_12_FONT_ID); - const int w = textWidth + margin * 2; - const int h = textHeight + margin * 2; - const int x = (renderer.getScreenWidth() - w) / 2; - - renderer.fillRect(x - 2, y - 2, w + 4, h + 4, true); // frame thickness 2 - renderer.fillRect(x, y, w, h, false); - - const int textX = x + (w - textWidth) / 2; - const int textY = y + margin - 2; - renderer.drawText(UI_12_FONT_ID, textX, textY, message, true, EpdFontFamily::BOLD); - renderer.displayBuffer(); - return {x, y, w, h}; -} - -void ScreenComponents::fillPopupProgress(const GfxRenderer& renderer, const PopupLayout& layout, const int progress) { - constexpr int barHeight = 4; - const int barWidth = layout.width - 30; // twice the margin in drawPopup to match text width - const int barX = layout.x + (layout.width - barWidth) / 2; - const int barY = layout.y + layout.height - 10; - - int fillWidth = barWidth * progress / 100; - - renderer.fillRect(barX, barY, fillWidth, barHeight, true); - - renderer.displayBuffer(HalDisplay::FAST_REFRESH); -} - -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 78ed5920..00000000 --- a/src/ScreenComponents.h +++ /dev/null @@ -1,53 +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; - - struct PopupLayout { - int x; - int y; - int width; - int height; - }; - - static void drawBattery(const GfxRenderer& renderer, int left, int top, bool showPercentage = true); - static void drawBookProgressBar(const GfxRenderer& renderer, size_t bookProgress); - - static PopupLayout drawPopup(const GfxRenderer& renderer, const char* message); - - static void fillPopupProgress(const GfxRenderer& renderer, const PopupLayout& layout, int progress); - - // 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 7ffc5851..2e8042ad 100644 --- a/src/activities/boot_sleep/SleepActivity.cpp +++ b/src/activities/boot_sleep/SleepActivity.cpp @@ -8,15 +8,14 @@ #include "CrossPointSettings.h" #include "CrossPointState.h" -#include "ScreenComponents.h" +#include "components/UITheme.h" #include "fontIds.h" #include "images/CrossLarge.h" #include "util/StringUtils.h" void SleepActivity::onEnter() { Activity::onEnter(); - - ScreenComponents::drawPopup(renderer, "Entering Sleep..."); + GUI.drawPopup(renderer, "Entering Sleep..."); if (SETTINGS.sleepScreen == CrossPointSettings::SLEEP_SCREEN_MODE::BLANK) { return renderBlankSleepScreen(); diff --git a/src/activities/browser/OpdsBookBrowserActivity.cpp b/src/activities/browser/OpdsBookBrowserActivity.cpp index 2bde74de..f33bda04 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); + GUI.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); + GUI.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); + GUI.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); + GUI.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); + GUI.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 678af7cb..ed198357 100644 --- a/src/activities/home/HomeActivity.cpp +++ b/src/activities/home/HomeActivity.cpp @@ -14,7 +14,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,12 +25,109 @@ void HomeActivity::taskTrampoline(void* param) { } int HomeActivity::getMenuItemCount() const { - int count = 3; // My Library, File transfer, Settings - if (hasContinueReading) count++; - if (hasOpdsUrl) count++; + int count = 4; // My Library, Recents, File transfer, Settings + if (!recentBooks.empty()) { + count += recentBooks.size(); + } + if (hasOpdsUrl) { + count++; + } return count; } +void HomeActivity::loadRecentBooks(int maxBooks, int coverHeight) { + recentsLoading = true; + bool showingLoading = false; + Rect popupRect; + + recentBooks.clear(); + const auto& books = RECENT_BOOKS.getBooks(); + recentBooks.reserve(std::min(static_cast(books.size()), maxBooks)); + + bool mustReloadRecents = false; // We're migrating from an older version of recent books + int progress = 0; + for (const RecentBook& book : books) { + bool mustLoadCover = false; + + // Limit to maximum number of recent books + if (recentBooks.size() >= maxBooks) { + break; + } + + // Skip if file no longer exists + if (!SdMan.exists(book.path.c_str())) { + continue; + } + + if (book.coverBmpPath == "-") { + mustReloadRecents = true; + } else { + std::string coverPath = UITheme::getCoverThumbPath(book.coverBmpPath, coverHeight); + if (!SdMan.exists(coverPath.c_str())) { + mustLoadCover = true; + } + } + + if (mustReloadRecents || mustLoadCover) { + std::string lastBookFileName = ""; + const size_t lastSlash = book.path.find_last_of('/'); + if (lastSlash != std::string::npos) { + lastBookFileName = book.path.substr(lastSlash + 1); + } + + Serial.printf("Loading recent book: %s\n", book.path.c_str()); + + // If epub, try to load the metadata for title/author and cover + if (StringUtils::checkFileExtension(lastBookFileName, ".epub")) { + Epub epub(book.path, "/.crosspoint"); + epub.load(false); + + // Try to generate thumbnail image for Continue Reading card + if (!showingLoading) { + showingLoading = true; + popupRect = GUI.drawPopup(renderer, "Loading..."); + } + GUI.fillPopupProgress(renderer, popupRect, progress * (100 / maxBooks)); + epub.generateThumbBmp(coverHeight); + + if (mustReloadRecents) { + // Update recent book entry with title/author/cover path + RECENT_BOOKS.addBook(book.path, epub.getTitle(), epub.getAuthor(), epub.getThumbBmpPath()); + recentBooks.push_back({book.path, epub.getTitle(), epub.getAuthor(), epub.getThumbBmpPath()}); + } + } else if (StringUtils::checkFileExtension(lastBookFileName, ".xtch") || + StringUtils::checkFileExtension(lastBookFileName, ".xtc")) { + // Handle XTC file + Xtc xtc(book.path, "/.crosspoint"); + if (xtc.load()) { + // Try to generate thumbnail image for Continue Reading card + if (!showingLoading) { + showingLoading = true; + popupRect = GUI.drawPopup(renderer, "Loading..."); + } + GUI.fillPopupProgress(renderer, popupRect, progress * (100 / maxBooks)); + xtc.generateThumbBmp(coverHeight); + + if (mustReloadRecents) { + // Update recent book entry with title/author/cover path + RECENT_BOOKS.addBook(book.path, xtc.getTitle(), xtc.getAuthor(), xtc.getThumbBmpPath()); + recentBooks.push_back({book.path, xtc.getTitle(), xtc.getAuthor(), xtc.getThumbBmpPath()}); + } + } + } + } + + if (!mustReloadRecents) { + recentBooks.push_back(book); + } + progress++; + } + + recentsLoaded = true; + recentsLoading = false; + updateRequired = true; +} + void HomeActivity::onEnter() { Activity::onEnter(); @@ -41,62 +139,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 @@ -172,21 +221,24 @@ 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 recentsIdx = 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].path); + } else if (menuSelectedIndex == myLibraryIdx) { onMyLibraryOpen(); - } else if (selectorIndex == opdsLibraryIdx) { + } else if (menuSelectedIndex == recentsIdx) { + onRecentsOpen(); + } 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) { @@ -211,350 +263,51 @@ 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::getInstance().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(); } + GUI.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; + GUI.drawRecentBookCover(renderer, Rect{0, metrics.homeTopPadding, pageWidth, metrics.homeCoverTileHeight}, + recentBooks, selectorIndex, coverRendered, coverBufferStored, bufferRestored, + std::bind(&HomeActivity::storeCoverBuffer, this)); + } else if (!recentsLoading && firstRenderDone) { + recentsLoading = true; + loadRecentBooks(metrics.homeRecentBooksCount, metrics.homeCoverHeight); } - - 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 "..." - 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) - 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()) { - 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()) { - 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()) { - 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", "Recents", "File Transfer", "Settings"}; if (hasOpdsUrl) { // Insert OPDS Browser after My Library - menuItems.insert(menuItems.begin() + 1, "OPDS Browser"); + menuItems.insert(menuItems.begin() + 2, "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); - } + GUI.drawButtonMenu( + renderer, + Rect{0, metrics.homeTopPadding + metrics.homeCoverTileHeight + 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); + GUI.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..f27a8f93 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 RecentBook; +struct Rect; class HomeActivity final : public Activity { TaskHandle_t displayTaskHandle = nullptr; @@ -13,16 +18,18 @@ 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 onRecentsOpen; const std::function onSettingsOpen; const std::function onFileTransferOpen; const std::function onOpdsBrowserOpen; @@ -34,15 +41,18 @@ 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); public: explicit HomeActivity(GfxRenderer& renderer, MappedInputManager& mappedInput, - const std::function& onContinueReading, const std::function& onMyLibraryOpen, + const std::function& onSelectBook, + const std::function& onMyLibraryOpen, const std::function& onRecentsOpen, const std::function& onSettingsOpen, const std::function& onFileTransferOpen, const std::function& onOpdsBrowserOpen) : Activity("Home", renderer, mappedInput), - onContinueReading(onContinueReading), + onSelectBook(onSelectBook), onMyLibraryOpen(onMyLibraryOpen), + onRecentsOpen(onRecentsOpen), onSettingsOpen(onSettingsOpen), onFileTransferOpen(onFileTransferOpen), onOpdsBrowserOpen(onOpdsBrowserOpen) {} diff --git a/src/activities/home/MyLibraryActivity.cpp b/src/activities/home/MyLibraryActivity.cpp index 29c6ea73..5628994b 100644 --- a/src/activities/home/MyLibraryActivity.cpp +++ b/src/activities/home/MyLibraryActivity.cpp @@ -3,26 +3,15 @@ #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,50 +22,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::loadRecentBooks() { - recentBooks.clear(); - const auto& books = RECENT_BOOKS.getBooks(); - recentBooks.reserve(books.size()); - - for (const auto& book : books) { - // Skip if file no longer exists - if (!SdMan.exists(book.path.c_str())) { - continue; - } - recentBooks.push_back(book); - } +void MyLibraryActivity::taskTrampoline(void* param) { + auto* self = static_cast(param); + self->displayTaskLoop(); } void MyLibraryActivity::loadFiles() { @@ -114,32 +63,18 @@ 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(); renderingMutex = xSemaphoreCreateMutex(); - // Load data for both tabs - loadRecentBooks(); loadFiles(); selectorIndex = 0; 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 +84,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,107 +97,76 @@ void MyLibraryActivity::onExit() { } void MyLibraryActivity::loop() { - const int itemCount = getCurrentItemCount(); - const int pageItems = getPageItems(); + // Long press BACK (1s+) goes to root folder + if (mappedInput.isPressed(MappedInputManager::Button::Back) && mappedInput.getHeldTime() >= GO_HOME_MS && + basepath != "/") { + basepath = "/"; + loadFiles(); + selectorIndex = 0; + updateRequired = true; + return; + } - // Long press BACK (1s+) in Files tab goes to root folder - if (currentTab == Tab::Files && mappedInput.isPressed(MappedInputManager::Button::Back) && - mappedInput.getHeldTime() >= GO_HOME_MS) { - if (basepath != "/") { - basepath = "/"; + const bool upReleased = mappedInput.wasReleased(MappedInputManager::Button::Left) || + mappedInput.wasReleased(MappedInputManager::Button::Up); + ; + const bool downReleased = mappedInput.wasReleased(MappedInputManager::Button::Right) || + mappedInput.wasReleased(MappedInputManager::Button::Down); + + const bool skipPage = mappedInput.getHeldTime() > SKIP_PAGE_MS; + const int pageItems = UITheme::getInstance().getNumberOfItemsPerPage(renderer, true, false, true, true); + + if (mappedInput.wasReleased(MappedInputManager::Button::Confirm)) { + 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; - } - 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 skipPage = mappedInput.getHeldTime() > SKIP_PAGE_MS; - - // Confirm button - open selected item - if (mappedInput.wasReleased(MappedInputManager::Button::Confirm)) { - if (currentTab == Tab::Recent) { - if (!recentBooks.empty() && selectorIndex < static_cast(recentBooks.size())) { - onSelectBook(recentBooks[selectorIndex].path, currentTab); - } } 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); - } - } + onSelectBook(basepath + files[selectorIndex]); + 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 + if (basepath != "/") { 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) { + int listSize = static_cast(files.size()); + if (upReleased) { if (skipPage) { - selectorIndex = ((selectorIndex / pageItems - 1) * pageItems + itemCount) % itemCount; + selectorIndex = ((selectorIndex / pageItems - 1) * pageItems + listSize) % listSize; } else { - selectorIndex = (selectorIndex + itemCount - 1) % itemCount; + 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 +187,32 @@ 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::getInstance().getMetrics(); - // Draw content based on current tab - if (currentTab == Tab::Recent) { - renderRecentTab(); + auto folderName = basepath == "/" ? "SD card" : basepath.substr(basepath.rfind('/') + 1).c_str(); + GUI.drawHeader(renderer, Rect{0, metrics.topPadding, pageWidth, metrics.headerHeight}, folderName); + + const int contentTop = metrics.topPadding + metrics.headerHeight + metrics.verticalSpacing; + const int contentHeight = pageHeight - contentTop - metrics.buttonHintsHeight - metrics.verticalSpacing * 2; + if (files.empty()) { + renderer.drawText(UI_10_FONT_ID, metrics.contentSidePadding, contentTop + 20, "No books found"); } else { - renderFilesTab(); + GUI.drawList( + renderer, Rect{0, contentTop, pageWidth, contentHeight}, files.size(), selectorIndex, + [this](int index) { return files[index]; }, false, nullptr, 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"); + GUI.drawButtonHints(renderer, labels.btn1, labels.btn2, labels.btn3, labels.btn4); 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..70e9e29c 100644 --- a/src/activities/home/MyLibraryActivity.h +++ b/src/activities/home/MyLibraryActivity.h @@ -8,59 +8,40 @@ #include #include "../Activity.h" -#include "RecentBooksStore.h" class MyLibraryActivity final : public Activity { - public: - enum class Tab { Recent, Files }; - private: TaskHandle_t displayTaskHandle = nullptr; SemaphoreHandle_t renderingMutex = nullptr; - Tab currentTab = Tab::Recent; - int selectorIndex = 0; + size_t selectorIndex = 0; bool updateRequired = false; - // Recent tab state - std::vector recentBooks; - - // Files tab state (from FileSelectionActivity) + // Files state std::string basepath = "/"; std::vector files; // Callbacks + const std::function onSelectBook; const std::function onGoHome; - const std::function onSelectBook; - // Number of items that fit on a page - int getPageItems() const; - int getCurrentItemCount() const; - int getTotalPages() const; - int getCurrentPage() 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; + + // Data loading + void loadFiles(); + size_t findEntry(const std::string& name) const; public: explicit MyLibraryActivity(GfxRenderer& renderer, MappedInputManager& mappedInput, const std::function& onGoHome, - const std::function& onSelectBook, - Tab initialTab = Tab::Recent, std::string initialPath = "/") + const std::function& onSelectBook, + std::string initialPath = "/") : Activity("MyLibrary", renderer, mappedInput), - currentTab(initialTab), basepath(initialPath.empty() ? "/" : std::move(initialPath)), - onGoHome(onGoHome), - onSelectBook(onSelectBook) {} + onSelectBook(onSelectBook), + onGoHome(onGoHome) {} void onEnter() override; void onExit() override; void loop() override; diff --git a/src/activities/home/RecentBooksActivity.cpp b/src/activities/home/RecentBooksActivity.cpp new file mode 100644 index 00000000..c47a67d0 --- /dev/null +++ b/src/activities/home/RecentBooksActivity.cpp @@ -0,0 +1,149 @@ +#include "RecentBooksActivity.h" + +#include +#include + +#include "MappedInputManager.h" +#include "RecentBooksStore.h" +#include "components/UITheme.h" +#include "fontIds.h" +#include "util/StringUtils.h" + +namespace { +constexpr int SKIP_PAGE_MS = 700; +constexpr unsigned long GO_HOME_MS = 1000; +} // namespace + +void RecentBooksActivity::taskTrampoline(void* param) { + auto* self = static_cast(param); + self->displayTaskLoop(); +} + +void RecentBooksActivity::loadRecentBooks() { + recentBooks.clear(); + const auto& books = RECENT_BOOKS.getBooks(); + recentBooks.reserve(books.size()); + + for (const auto& book : books) { + // Skip if file no longer exists + if (!SdMan.exists(book.path.c_str())) { + continue; + } + recentBooks.push_back(book); + } +} + +void RecentBooksActivity::onEnter() { + Activity::onEnter(); + + renderingMutex = xSemaphoreCreateMutex(); + + // Load data + loadRecentBooks(); + + selectorIndex = 0; + updateRequired = true; + + xTaskCreate(&RecentBooksActivity::taskTrampoline, "RecentBooksActivityTask", + 4096, // Stack size + this, // Parameters + 1, // Priority + &displayTaskHandle // Task handle + ); +} + +void RecentBooksActivity::onExit() { + Activity::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; + + recentBooks.clear(); +} + +void RecentBooksActivity::loop() { + const bool upReleased = mappedInput.wasReleased(MappedInputManager::Button::Left) || + mappedInput.wasReleased(MappedInputManager::Button::Up); + ; + const bool downReleased = mappedInput.wasReleased(MappedInputManager::Button::Right) || + mappedInput.wasReleased(MappedInputManager::Button::Down); + + const bool skipPage = mappedInput.getHeldTime() > SKIP_PAGE_MS; + const int pageItems = UITheme::getInstance().getNumberOfItemsPerPage(renderer, true, false, true, true); + + if (mappedInput.wasReleased(MappedInputManager::Button::Confirm)) { + if (!recentBooks.empty() && selectorIndex < static_cast(recentBooks.size())) { + Serial.printf("Selected recent book: %s\n", recentBooks[selectorIndex].path.c_str()); + onSelectBook(recentBooks[selectorIndex].path); + return; + } + } + + if (mappedInput.wasReleased(MappedInputManager::Button::Back)) { + onGoHome(); + } + + int listSize = static_cast(recentBooks.size()); + if (upReleased) { + if (skipPage) { + selectorIndex = ((selectorIndex / pageItems - 1) * pageItems + listSize) % listSize; + } else { + selectorIndex = (selectorIndex + listSize - 1) % listSize; + } + updateRequired = true; + } else if (downReleased) { + if (skipPage) { + selectorIndex = ((selectorIndex / pageItems + 1) * pageItems) % listSize; + } else { + selectorIndex = (selectorIndex + 1) % listSize; + } + updateRequired = true; + } +} + +void RecentBooksActivity::displayTaskLoop() { + while (true) { + if (updateRequired) { + updateRequired = false; + xSemaphoreTake(renderingMutex, portMAX_DELAY); + render(); + xSemaphoreGive(renderingMutex); + } + vTaskDelay(10 / portTICK_PERIOD_MS); + } +} + +void RecentBooksActivity::render() const { + renderer.clearScreen(); + + const auto pageWidth = renderer.getScreenWidth(); + const auto pageHeight = renderer.getScreenHeight(); + auto metrics = UITheme::getInstance().getMetrics(); + + GUI.drawHeader(renderer, Rect{0, metrics.topPadding, pageWidth, metrics.headerHeight}, "Recent Books"); + + const int contentTop = metrics.topPadding + metrics.headerHeight + metrics.verticalSpacing; + const int contentHeight = pageHeight - contentTop - metrics.buttonHintsHeight - metrics.verticalSpacing * 2; + + // Recent tab + if (recentBooks.empty()) { + renderer.drawText(UI_10_FONT_ID, metrics.contentSidePadding, contentTop + 20, "No recent books"); + } else { + GUI.drawList( + renderer, Rect{0, contentTop, pageWidth, contentHeight}, recentBooks.size(), selectorIndex, + [this](int index) { return recentBooks[index].title; }, true, + [this](int index) { return recentBooks[index].author; }, false, nullptr, false, nullptr); + } + + // Help text + const auto labels = mappedInput.mapLabels("« Home", "Open", "Up", "Down"); + GUI.drawButtonHints(renderer, labels.btn1, labels.btn2, labels.btn3, labels.btn4); + + renderer.displayBuffer(); +} diff --git a/src/activities/home/RecentBooksActivity.h b/src/activities/home/RecentBooksActivity.h new file mode 100644 index 00000000..4490aeac --- /dev/null +++ b/src/activities/home/RecentBooksActivity.h @@ -0,0 +1,43 @@ +#pragma once +#include +#include +#include + +#include +#include +#include + +#include "../Activity.h" +#include "RecentBooksStore.h" + +class RecentBooksActivity final : public Activity { + private: + TaskHandle_t displayTaskHandle = nullptr; + SemaphoreHandle_t renderingMutex = nullptr; + + size_t selectorIndex = 0; + bool updateRequired = false; + + // Recent tab state + std::vector recentBooks; + + // Callbacks + const std::function onSelectBook; + const std::function onGoHome; + + static void taskTrampoline(void* param); + [[noreturn]] void displayTaskLoop(); + void render() const; + + // Data loading + void loadRecentBooks(); + + public: + explicit RecentBooksActivity(GfxRenderer& renderer, MappedInputManager& mappedInput, + const std::function& onGoHome, + const std::function& onSelectBook) + : Activity("RecentBooks", renderer, mappedInput), 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..c7f636d6 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,7 @@ 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); + GUI.drawProgressBar(renderer, Rect{barX, y + 22, barWidth, barHeight}, lastProgressReceived, lastProgressTotal); y += 40; } @@ -272,5 +271,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); + GUI.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..0338d825 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); + GUI.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..e6713ea0 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); + GUI.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 8bf83a93..e940dbb2 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); + GUI.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); + GUI.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); + GUI.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); + GUI.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); + GUI.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 5ccfb4fe..cefd69f9 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 { @@ -85,7 +85,7 @@ void EpubReaderActivity::onEnter() { // Save current epub as last opened epub and add to recent books APP_STATE.openEpubPath = epub->getPath(); APP_STATE.saveToFile(); - RECENT_BOOKS.addBook(epub->getPath(), epub->getTitle(), epub->getAuthor()); + RECENT_BOOKS.addBook(epub->getPath(), epub->getTitle(), epub->getAuthor(), epub->getThumbBmpPath()); // Trigger first update updateRequired = true; @@ -347,13 +347,15 @@ void EpubReaderActivity::renderScreen() { orientedMarginRight += SETTINGS.screenMargin; orientedMarginBottom += SETTINGS.screenMargin; + auto metrics = UITheme::getInstance().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) { @@ -369,7 +371,7 @@ void EpubReaderActivity::renderScreen() { viewportHeight, SETTINGS.hyphenationEnabled)) { Serial.printf("[%lu] [ERS] Cache not found, building...\n", millis()); - const auto popupFn = [this]() { ScreenComponents::drawPopup(renderer, "Indexing..."); }; + const auto popupFn = [this]() { GUI.drawPopup(renderer, "Indexing..."); }; if (!section->createSectionFile(SETTINGS.getReaderFontId(), SETTINGS.getReaderLineCompression(), SETTINGS.extraParagraphSpacing, SETTINGS.paragraphAlignment, viewportWidth, @@ -491,6 +493,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::getInstance().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 || @@ -534,11 +538,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)); + GUI.drawBookProgressBar(renderer, static_cast(bookProgress)); } if (showBattery) { - ScreenComponents::drawBattery(renderer, orientedMarginLeft + 1, textY, showBatteryPercentage); + GUI.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 a77b37d0..eb95af67 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() { // Skip button hints in landscape CW mode (they overlap content) if (renderer.getOrientation() != GfxRenderer::LandscapeClockwise) { const auto labels = mappedInput.mapLabels("« Back", "Select", "Up", "Down"); - renderer.drawButtonHints(UI_10_FONT_ID, labels.btn1, labels.btn2, labels.btn3, labels.btn4); + GUI.drawButtonHints(renderer, labels.btn1, labels.btn2, labels.btn3, labels.btn4); } renderer.displayBuffer(); diff --git a/src/activities/reader/EpubReaderMenuActivity.cpp b/src/activities/reader/EpubReaderMenuActivity.cpp index 5ce4881d..f36af3f2 100644 --- a/src/activities/reader/EpubReaderMenuActivity.cpp +++ b/src/activities/reader/EpubReaderMenuActivity.cpp @@ -2,6 +2,7 @@ #include +#include "components/UITheme.h" #include "fontIds.h" void EpubReaderMenuActivity::onEnter() { @@ -97,7 +98,7 @@ void EpubReaderMenuActivity::renderScreen() { // Footer / Hints const auto labels = mappedInput.mapLabels("« Back", "Select", "Up", "Down"); - renderer.drawButtonHints(UI_10_FONT_ID, labels.btn1, labels.btn2, labels.btn3, labels.btn4); + GUI.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..8846e319 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); + GUI.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); + GUI.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); + GUI.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); + GUI.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); + GUI.drawButtonHints(renderer, labels.btn1, labels.btn2, labels.btn3, labels.btn4); renderer.displayBuffer(); return; } diff --git a/src/activities/reader/ReaderActivity.cpp b/src/activities/reader/ReaderActivity.cpp index 04240b3c..0b004f34 100644 --- a/src/activities/reader/ReaderActivity.cpp +++ b/src/activities/reader/ReaderActivity.cpp @@ -74,7 +74,7 @@ std::unique_ptr ReaderActivity::loadTxt(const std::string& path) { void ReaderActivity::goToLibrary(const std::string& fromBookPath) { // If coming from a book, start in that book's folder; otherwise start from root const auto initialPath = fromBookPath.empty() ? "/" : extractFolderPath(fromBookPath); - onGoToLibrary(initialPath, libraryTab); + onGoToLibrary(initialPath); } void ReaderActivity::onGoToEpubReader(std::unique_ptr epub) { diff --git a/src/activities/reader/ReaderActivity.h b/src/activities/reader/ReaderActivity.h index ab74878f..6ecd6f34 100644 --- a/src/activities/reader/ReaderActivity.h +++ b/src/activities/reader/ReaderActivity.h @@ -10,10 +10,9 @@ class Txt; class ReaderActivity final : public ActivityWithSubactivity { std::string initialBookPath; - std::string currentBookPath; // Track current book path for navigation - MyLibraryActivity::Tab libraryTab; // Track which tab to return to + std::string currentBookPath; // Track current book path for navigation const std::function onGoBack; - const std::function onGoToLibrary; + const std::function onGoToLibrary; static std::unique_ptr loadEpub(const std::string& path); static std::unique_ptr loadXtc(const std::string& path); static std::unique_ptr loadTxt(const std::string& path); @@ -28,11 +27,10 @@ class ReaderActivity final : public ActivityWithSubactivity { public: explicit ReaderActivity(GfxRenderer& renderer, MappedInputManager& mappedInput, std::string initialBookPath, - MyLibraryActivity::Tab libraryTab, const std::function& onGoBack, - const std::function& onGoToLibrary) + const std::function& onGoBack, + const std::function& onGoToLibrary) : ActivityWithSubactivity("Reader", renderer, mappedInput), initialBookPath(std::move(initialBookPath)), - libraryTab(libraryTab), onGoBack(onGoBack), onGoToLibrary(onGoToLibrary) {} void onEnter() override; diff --git a/src/activities/reader/TxtReaderActivity.cpp b/src/activities/reader/TxtReaderActivity.cpp index eb1a9eef..4de12d18 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 { @@ -58,9 +58,11 @@ void TxtReaderActivity::onEnter() { txt->setupCacheDir(); // Save current txt as last opened file and add to recent books - APP_STATE.openEpubPath = txt->getPath(); + auto filePath = txt->getPath(); + auto fileName = filePath.substr(filePath.rfind('/') + 1); + APP_STATE.openEpubPath = filePath; APP_STATE.saveToFile(); - RECENT_BOOKS.addBook(txt->getPath(), "", ""); + RECENT_BOOKS.addBook(filePath, fileName, "", ""); // Trigger first update updateRequired = true; @@ -168,13 +170,15 @@ void TxtReaderActivity::initializeReader() { orientedMarginRight += cachedScreenMargin; orientedMarginBottom += cachedScreenMargin; + auto metrics = UITheme::getInstance().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; @@ -210,7 +214,7 @@ void TxtReaderActivity::buildPageIndex() { Serial.printf("[%lu] [TRS] Building page index for %zu bytes...\n", millis(), fileSize); - ScreenComponents::drawPopup(renderer, "Indexing..."); + GUI.drawPopup(renderer, "Indexing..."); while (offset < fileSize) { std::vector tempLines; @@ -498,6 +502,7 @@ void TxtReaderActivity::renderStatusBar(const int orientedMarginRight, const int const bool showBatteryPercentage = SETTINGS.hideBatteryPercentage == CrossPointSettings::HIDE_BATTERY_PERCENTAGE::HIDE_NEVER; + auto metrics = UITheme::getInstance().getMetrics(); const auto screenHeight = renderer.getScreenHeight(); const auto textY = screenHeight - orientedMarginBottom - 4; int progressTextWidth = 0; @@ -519,11 +524,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)); + GUI.drawBookProgressBar(renderer, static_cast(progress)); } if (showBattery) { - ScreenComponents::drawBattery(renderer, orientedMarginLeft, textY, showBatteryPercentage); + GUI.drawBattery(renderer, Rect{orientedMarginLeft, textY, metrics.batteryWidth, metrics.batteryHeight}, + showBatteryPercentage); } if (showTitle) { diff --git a/src/activities/reader/XtcReaderActivity.cpp b/src/activities/reader/XtcReaderActivity.cpp index f579abcd..a7350c16 100644 --- a/src/activities/reader/XtcReaderActivity.cpp +++ b/src/activities/reader/XtcReaderActivity.cpp @@ -16,6 +16,7 @@ #include "MappedInputManager.h" #include "RecentBooksStore.h" #include "XtcReaderChapterSelectionActivity.h" +#include "components/UITheme.h" #include "fontIds.h" namespace { @@ -45,7 +46,7 @@ void XtcReaderActivity::onEnter() { // Save current XTC as last opened book and add to recent books APP_STATE.openEpubPath = xtc->getPath(); APP_STATE.saveToFile(); - RECENT_BOOKS.addBook(xtc->getPath(), xtc->getTitle(), xtc->getAuthor()); + RECENT_BOOKS.addBook(xtc->getPath(), xtc->getTitle(), xtc->getAuthor(), xtc->getThumbBmpPath()); // Trigger first update updateRequired = true; diff --git a/src/activities/reader/XtcReaderChapterSelectionActivity.cpp b/src/activities/reader/XtcReaderChapterSelectionActivity.cpp index ad806a30..76465cc2 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 { @@ -152,7 +153,7 @@ void XtcReaderChapterSelectionActivity::renderScreen() { // Skip button hints in landscape CW mode (they overlap content) if (renderer.getOrientation() != GfxRenderer::LandscapeClockwise) { const auto labels = mappedInput.mapLabels("« Back", "Select", "Up", "Down"); - renderer.drawButtonHints(UI_10_FONT_ID, labels.btn1, labels.btn2, labels.btn3, labels.btn4); + GUI.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..86a1a070 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); + GUI.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..f00472b1 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); + GUI.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); + GUI.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); + GUI.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..78d6ec84 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); + GUI.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); + GUI.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..278ce7cd 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); + GUI.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..c6f16a78 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); + GUI.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..3ad1c60c 100644 --- a/src/activities/settings/SettingsActivity.cpp +++ b/src/activities/settings/SettingsActivity.cpp @@ -3,15 +3,20 @@ #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 changeTabsMs = 700; +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 +27,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 +74,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 +102,8 @@ void SettingsActivity::onExit() { } vSemaphoreDelete(renderingMutex); renderingMutex = nullptr; + + UITheme::getInstance().reload(); // Re-apply theme in case it was changed } void SettingsActivity::loop() { @@ -97,11 +111,19 @@ void SettingsActivity::loop() { subActivity->loop(); return; } + bool hasChangedCategory = false; - // Handle category selection + // Handle actions with early return if (mappedInput.wasPressed(MappedInputManager::Button::Confirm)) { - enterCategory(selectedCategoryIndex); - 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)) { @@ -110,56 +132,113 @@ void SettingsActivity::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 changeTab = mappedInput.getHeldTime() > changeTabsMs; + // Handle navigation - if (mappedInput.wasPressed(MappedInputManager::Button::Up) || - mappedInput.wasPressed(MappedInputManager::Button::Left)) { - // Move selection up (with wrap-around) + if (upReleased && changeTab) { + 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 (downReleased && changeTab) { + hasChangedCategory = true; selectedCategoryIndex = (selectedCategoryIndex < categoryCount - 1) ? (selectedCategoryIndex + 1) : 0; updateRequired = true; + } else if (upReleased || leftReleased) { + selectedSettingIndex = (selectedSettingIndex > 0) ? (selectedSettingIndex - 1) : (settingsCount); + updateRequired = true; + } else if (rightReleased || downReleased) { + selectedSettingIndex = (selectedSettingIndex < settingsCount) ? (selectedSettingIndex + 1) : 0; + updateRequired = true; + } + + if (hasChangedCategory) { + selectedSettingIndex = (selectedSettingIndex == 0) ? 0 : 1; + 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() { + int selectedSetting = selectedSettingIndex - 1; + if (selectedSetting < 0 || selectedSetting >= settingsCount) { return; } - xSemaphoreTake(renderingMutex, portMAX_DELAY); - exitActivity(); + const auto& setting = settingsList[selectedSetting]; - 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 +259,48 @@ 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::getInstance().getMetrics(); - // Draw selection - renderer.fillRect(0, 60 + selectedCategoryIndex * 30 - 2, pageWidth - 1, 30); + GUI.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}); } + GUI.drawTabBar(renderer, Rect{0, metrics.topPadding + metrics.headerHeight, pageWidth, metrics.tabBarHeight}, tabs, + selectedSettingIndex == 0); - // Draw version text above button hints - renderer.drawText(SMALL_FONT_ID, pageWidth - 20 - renderer.getTextWidth(SMALL_FONT_ID, CROSSPOINT_VERSION), - pageHeight - 60, CROSSPOINT_VERSION); + GUI.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 - 1, [this](int index) { return std::string(settingsList[index].name); }, + false, nullptr, 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", "Toggle", "Up", "Down"); + GUI.drawButtonHints(renderer, labels.btn1, labels.btn2, labels.btn3, labels.btn4); // 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..19b5c00d 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); + GUI.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"); + GUI.drawSideButtonHints(renderer, "Up", "Down"); renderer.displayBuffer(); } diff --git a/src/components/UITheme.cpp b/src/components/UITheme.cpp new file mode 100644 index 00000000..45dfefde --- /dev/null +++ b/src/components/UITheme.cpp @@ -0,0 +1,62 @@ +#include "UITheme.h" + +#include + +#include + +#include "RecentBooksStore.h" +#include "components/themes/BaseTheme.h" +#include "components/themes/lyra/LyraTheme.h" + +UITheme UITheme::instance; + +UITheme::UITheme() { + auto themeType = static_cast(SETTINGS.uiTheme); + setTheme(themeType); +} + +void UITheme::reload() { + 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 = new BaseTheme(); + currentMetrics = &BaseMetrics::values; + break; + case CrossPointSettings::UI_THEME::LYRA: + Serial.printf("[%lu] [UI] Using Lyra theme\n", millis()); + currentTheme = new LyraTheme(); + currentMetrics = &LyraMetrics::values; + break; + } +} + +int UITheme::getNumberOfItemsPerPage(const GfxRenderer& renderer, bool hasHeader, bool hasTabBar, bool hasButtonHints, + bool hasSubtitle) { + const ThemeMetrics& metrics = UITheme::getInstance().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; + int rowHeight = hasSubtitle ? metrics.listWithSubtitleRowHeight : metrics.listRowHeight; + return availableHeight / rowHeight; +} + +std::string UITheme::getCoverThumbPath(std::string coverBmpPath, int coverHeight) { + size_t pos = coverBmpPath.find("[HEIGHT]", 0); + if (pos != std::string::npos) { + coverBmpPath.replace(pos, 8, std::to_string(coverHeight)); + } + return coverBmpPath; +} diff --git a/src/components/UITheme.h b/src/components/UITheme.h new file mode 100644 index 00000000..0a30223b --- /dev/null +++ b/src/components/UITheme.h @@ -0,0 +1,31 @@ +#pragma once + +#include +#include + +#include "CrossPointSettings.h" +#include "components/themes/BaseTheme.h" + +class UITheme { + // Static instance + static UITheme instance; + + public: + UITheme(); + static UITheme& getInstance() { return instance; } + + const ThemeMetrics& getMetrics() { return *currentMetrics; } + const BaseTheme& getTheme() { return *currentTheme; } + void reload(); + void setTheme(CrossPointSettings::UI_THEME type); + static int getNumberOfItemsPerPage(const GfxRenderer& renderer, bool hasHeader, bool hasTabBar, bool hasButtonHints, + bool hasSubtitle); + static std::string getCoverThumbPath(std::string coverBmpPath, int coverHeight); + + private: + const ThemeMetrics* currentMetrics; + const BaseTheme* currentTheme; +}; + +// Helper macro to access current theme +#define GUI UITheme::getInstance().getTheme() diff --git a/src/components/themes/BaseTheme.cpp b/src/components/themes/BaseTheme.cpp new file mode 100644 index 00000000..dd632671 --- /dev/null +++ b/src/components/themes/BaseTheme.cpp @@ -0,0 +1,642 @@ +#include "BaseTheme.h" + +#include +#include +#include + +#include +#include + +#include "Battery.h" +#include "RecentBooksStore.h" +#include "components/UITheme.h" +#include "fontIds.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) const { + // 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) const { + 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(GfxRenderer& renderer, const char* btn1, const char* btn2, const char* btn3, + const char* btn4) const { + 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 { + 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 hasSubtitle, + const std::function& rowSubtitle, bool hasIcon, + const std::function& rowIcon, bool hasValue, + const std::function& rowValue) const { + int rowHeight = hasSubtitle ? BaseMetrics::values.listWithSubtitleRowHeight : BaseMetrics::values.listRowHeight; + int pageItems = rect.height / rowHeight; + + 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; // 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 - 5; + if (selectedIndex >= 0) { + renderer.fillRect(0, rect.y + selectedIndex % pageItems * rowHeight - 2, rect.width, 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; + int textWidth = contentWidth - BaseMetrics::values.contentSidePadding * 2 - (hasValue ? 60 : 0); + + // Draw name + auto itemName = rowTitle(i); + auto font = hasSubtitle ? UI_12_FONT_ID : UI_10_FONT_ID; + auto item = renderer.truncatedText(font, itemName.c_str(), textWidth); + renderer.drawText(font, rect.x + BaseMetrics::values.contentSidePadding, itemY, item.c_str(), i != selectedIndex); + + if (hasSubtitle) { + // Draw subtitle + std::string subtitleText = rowSubtitle(i); + auto subtitle = renderer.truncatedText(UI_10_FONT_ID, subtitleText.c_str(), textWidth); + renderer.drawText(UI_10_FONT_ID, rect.x + BaseMetrics::values.contentSidePadding, itemY + 30, subtitle.c_str(), + i != selectedIndex); + } + + if (hasValue) { + // Draw value + std::string valueText = rowValue(i); + const auto valueTextWidth = renderer.getTextWidth(UI_10_FONT_ID, valueText.c_str()); + renderer.drawText(UI_10_FONT_ID, rect.x + contentWidth - BaseMetrics::values.contentSidePadding - valueTextWidth, + itemY, valueText.c_str(), i != selectedIndex); + } + } +} + +void BaseTheme::drawHeader(const GfxRenderer& renderer, Rect rect, const char* title) const { + 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) { + int padding = rect.width - batteryX; + auto truncatedTitle = renderer.truncatedText(UI_12_FONT_ID, title, + rect.width - padding * 2 - BaseMetrics::values.contentSidePadding * 2, + EpdFontFamily::BOLD); + renderer.drawCenteredText(UI_12_FONT_ID, rect.y + 5, truncatedTitle.c_str(), true, EpdFontFamily::BOLD); + } +} + +void BaseTheme::drawTabBar(const GfxRenderer& renderer, const Rect rect, const std::vector& tabs, + bool selected) const { + 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 underline for selected tab + if (tab.selected) { + 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; + } +} + +// 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) const { + // --- 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 = + UITheme::getCoverThumbPath(recentBooks[0].coverBmpPath, BaseMetrics::values.homeCoverHeight); + + // 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].title; + const std::string& lastBookAuthor = recentBooks[0].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 "..." + 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) + 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()) { + 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()) { + 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()) { + 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) const { + 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); + } +} + +Rect BaseTheme::drawPopup(const GfxRenderer& renderer, const char* message) const { + constexpr int margin = 15; + constexpr int y = 60; + const int textWidth = renderer.getTextWidth(UI_12_FONT_ID, message, EpdFontFamily::BOLD); + const int textHeight = renderer.getLineHeight(UI_12_FONT_ID); + const int w = textWidth + margin * 2; + const int h = textHeight + margin * 2; + const int x = (renderer.getScreenWidth() - w) / 2; + + renderer.fillRect(x - 2, y - 2, w + 4, h + 4, true); // frame thickness 2 + renderer.fillRect(x, y, w, h, false); + + const int textX = x + (w - textWidth) / 2; + const int textY = y + margin - 2; + renderer.drawText(UI_12_FONT_ID, textX, textY, message, true, EpdFontFamily::BOLD); + renderer.displayBuffer(); + return Rect{x, y, w, h}; +} + +void BaseTheme::fillPopupProgress(const GfxRenderer& renderer, const Rect& layout, const int progress) const { + constexpr int barHeight = 4; + const int barWidth = layout.width - 30; // twice the margin in drawPopup to match text width + const int barX = layout.x + (layout.width - barWidth) / 2; + const int barY = layout.y + layout.height - 10; + + int fillWidth = barWidth * progress / 100; + + renderer.fillRect(barX, barY, fillWidth, barHeight, true); + + renderer.displayBuffer(HalDisplay::FAST_REFRESH); +} + +void BaseTheme::drawBookProgressBar(const GfxRenderer& renderer, const size_t bookProgress) const { + 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..43b684d0 --- /dev/null +++ b/src/components/themes/BaseTheme.h @@ -0,0 +1,118 @@ +#pragma once + +#include +#include +#include +#include + +class GfxRenderer; +struct RecentBook; + +struct Rect { + int x; + int y; + int width; + int height; + + 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 listWithSubtitleRowHeight; + int menuRowHeight; + int menuSpacing; + + int tabSpacing; + int tabBarHeight; + + int scrollBarWidth; + int scrollBarRightOffset; + + int homeTopPadding; + int homeCoverHeight; + int homeCoverTileHeight; + int homeRecentBooksCount; + + int buttonHintsHeight; + int sideButtonHintsWidth; + + int versionTextRightX; + int versionTextY; + + int bookProgressBarHeight; +}; + +// 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, + .listWithSubtitleRowHeight = 65, + .menuRowHeight = 45, + .menuSpacing = 8, + .tabSpacing = 10, + .tabBarHeight = 50, + .scrollBarWidth = 4, + .scrollBarRightOffset = 5, + .homeTopPadding = 20, + .homeCoverHeight = 400, + .homeCoverTileHeight = 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) const; + virtual void drawBattery(const GfxRenderer& renderer, Rect rect, bool showPercentage = true) const; + virtual void drawButtonHints(GfxRenderer& renderer, const char* btn1, const char* btn2, const char* btn3, + const char* btn4) const; + virtual void drawSideButtonHints(const GfxRenderer& renderer, const char* topBtn, const char* bottomBtn) const; + virtual void drawList(const GfxRenderer& renderer, Rect rect, int itemCount, int selectedIndex, + const std::function& rowTitle, bool hasSubtitle, + const std::function& rowSubtitle, bool hasIcon, + const std::function& rowIcon, bool hasValue, + const std::function& rowValue) const; + + virtual void drawHeader(const GfxRenderer& renderer, Rect rect, const char* title) const; + virtual void drawTabBar(const GfxRenderer& renderer, Rect rect, const std::vector& tabs, + bool selected) const; + virtual void drawRecentBookCover(GfxRenderer& renderer, Rect rect, const std::vector& recentBooks, + const int selectorIndex, bool& coverRendered, bool& coverBufferStored, + bool& bufferRestored, std::function storeCoverBuffer) const; + virtual void drawButtonMenu(GfxRenderer& renderer, Rect rect, int buttonCount, int selectedIndex, + const std::function& buttonLabel, bool hasIcon, + const std::function& rowIcon) const; + virtual Rect drawPopup(const GfxRenderer& renderer, const char* message) const; + virtual void fillPopupProgress(const GfxRenderer& renderer, const Rect& layout, const int progress) const; + virtual void drawBookProgressBar(const GfxRenderer& renderer, const size_t bookProgress) const; +}; \ 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..1ce4cdbe --- /dev/null +++ b/src/components/themes/lyra/LyraTheme.cpp @@ -0,0 +1,374 @@ +#include "LyraTheme.h" + +#include +#include + +#include +#include + +#include "Battery.h" +#include "RecentBooksStore.h" +#include "components/UITheme.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) const { + // 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) const { + 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) { + auto truncatedTitle = renderer.truncatedText( + UI_12_FONT_ID, title, rect.width - LyraMetrics::values.contentSidePadding * 2, EpdFontFamily::BOLD); + renderer.drawText(UI_12_FONT_ID, rect.x + LyraMetrics::values.contentSidePadding, + rect.y + LyraMetrics::values.batteryBarHeight + 3, truncatedTitle.c_str(), 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, + bool selected) const { + int currentX = rect.x + LyraMetrics::values.contentSidePadding; + + if (selected) { + renderer.fillRectDither(rect.x, rect.y, rect.width, rect.height, Color::LightGray); + } + + for (const auto& tab : tabs) { + const int textWidth = renderer.getTextWidth(UI_10_FONT_ID, tab.label, EpdFontFamily::REGULAR); + + if (tab.selected) { + 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::LightGray); + renderer.drawLine(currentX, rect.y + rect.height - 3, currentX + textWidth + 2 * hPaddingInSelection, + rect.y + rect.height - 3, 2, true); + } + } + + 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); +} + +void LyraTheme::drawList(const GfxRenderer& renderer, Rect rect, int itemCount, int selectedIndex, + const std::function& rowTitle, bool hasSubtitle, + const std::function& rowSubtitle, bool hasIcon, + const std::function& rowIcon, bool hasValue, + const std::function& rowValue) const { + int rowHeight = hasSubtitle ? LyraMetrics::values.listWithSubtitleRowHeight : LyraMetrics::values.listRowHeight; + int pageItems = rect.height / rowHeight; + + const int totalPages = (itemCount + pageItems - 1) / pageItems; + if (totalPages > 1) { + const int scrollAreaHeight = rect.height; + + // 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); + if (selectedIndex >= 0) { + renderer.fillRoundedRect(LyraMetrics::values.contentSidePadding, rect.y + selectedIndex % pageItems * rowHeight, + contentWidth - LyraMetrics::values.contentSidePadding * 2, rowHeight, cornerRadius, + Color::LightGray); + } + + // 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 + int textWidth = contentWidth - LyraMetrics::values.contentSidePadding * 2 - hPaddingInSelection * 2 - + (hasValue ? 60 : 0); // TODO truncate according to value width? + auto itemName = rowTitle(i); + auto item = renderer.truncatedText(UI_10_FONT_ID, itemName.c_str(), textWidth); + renderer.drawText(UI_10_FONT_ID, rect.x + LyraMetrics::values.contentSidePadding + hPaddingInSelection * 2, + itemY + 6, item.c_str(), true); + + if (hasSubtitle) { + // Draw subtitle + std::string subtitleText = rowSubtitle(i); + auto subtitle = renderer.truncatedText(SMALL_FONT_ID, subtitleText.c_str(), textWidth); + renderer.drawText(SMALL_FONT_ID, rect.x + LyraMetrics::values.contentSidePadding + hPaddingInSelection * 2, + itemY + 30, subtitle.c_str(), true); + } + + if (hasValue) { + // Draw value + std::string valueText = rowValue(i); + if (!valueText.empty()) { + const auto valueTextWidth = renderer.getTextWidth(UI_10_FONT_ID, valueText.c_str()); + + if (i == selectedIndex) { + renderer.fillRoundedRect( + contentWidth - LyraMetrics::values.contentSidePadding - hPaddingInSelection * 2 - valueTextWidth, itemY, + valueTextWidth + hPaddingInSelection * 2, rowHeight, cornerRadius, Color::Black); + } + + renderer.drawText(UI_10_FONT_ID, + contentWidth - LyraMetrics::values.contentSidePadding - hPaddingInSelection - valueTextWidth, + itemY + 6, valueText.c_str(), i != selectedIndex); + } + } + } +} + +void LyraTheme::drawButtonHints(GfxRenderer& renderer, const char* btn1, const char* btn2, const char* btn3, + const char* btn4) const { + const GfxRenderer::Orientation orig_orientation = renderer.getOrientation(); + renderer.setOrientation(GfxRenderer::Orientation::Portrait); + + 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[] = {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') { + 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); + } + } + + renderer.setOrientation(orig_orientation); +} + +void LyraTheme::drawSideButtonHints(const GfxRenderer& renderer, const char* topBtn, const char* bottomBtn) const { + const int screenWidth = renderer.getScreenWidth(); + constexpr int buttonWidth = LyraMetrics::values.sideButtonHintsWidth; // Width on screen (height 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}; + + // 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 { + const int tileWidth = (rect.width - 2 * LyraMetrics::values.contentSidePadding) / 3; + const int tileHeight = rect.height; + const int bookTitleHeight = tileHeight - LyraMetrics::values.homeCoverHeight - hPaddingInSelection; + 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++) { + std::string coverPath = recentBooks[i].coverBmpPath; + if (coverPath.empty()) { + continue; + } + + const std::string coverBmpPath = UITheme::getCoverThumbPath(coverPath, LyraMetrics::values.homeCoverHeight); + + 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) { + float coverHeight = static_cast(bitmap.getHeight()); + float coverWidth = static_cast(bitmap.getWidth()); + float ratio = coverWidth / coverHeight; + const float tileRatio = static_cast(tileWidth - 2 * hPaddingInSelection) / + static_cast(LyraMetrics::values.homeCoverHeight); + float cropX = 1.0f - (tileRatio / ratio); + + renderer.drawBitmap(bitmap, tileX + hPaddingInSelection, tileY + hPaddingInSelection, + tileWidth - 2 * hPaddingInSelection, LyraMetrics::values.homeCoverHeight, cropX); + } + 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].title.c_str(), tileWidth - 2 * hPaddingInSelection); + + if (bookSelected) { + // Draw selection box + renderer.fillRoundedRect(tileX, tileY, tileWidth, hPaddingInSelection, cornerRadius, true, true, false, false, + Color::LightGray); + renderer.fillRectDither(tileX, tileY + hPaddingInSelection, hPaddingInSelection, + LyraMetrics::values.homeCoverHeight, Color::LightGray); + renderer.fillRectDither(tileX + tileWidth - hPaddingInSelection, tileY + hPaddingInSelection, + hPaddingInSelection, LyraMetrics::values.homeCoverHeight, Color::LightGray); + renderer.fillRoundedRect(tileX, tileY + LyraMetrics::values.homeCoverHeight + hPaddingInSelection, tileWidth, + bookTitleHeight, cornerRadius, false, false, true, true, Color::LightGray); + } + 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) const { + 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::LightGray); + } + + 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); + } +} + +Rect LyraTheme::drawPopup(const GfxRenderer& renderer, const char* message) const { + constexpr int margin = 15; + constexpr int y = 60; + const int textWidth = renderer.getTextWidth(UI_12_FONT_ID, message, EpdFontFamily::REGULAR); + const int textHeight = renderer.getLineHeight(UI_12_FONT_ID); + const int w = textWidth + margin * 2; + const int h = textHeight + margin * 2; + const int x = (renderer.getScreenWidth() - w) / 2; + + renderer.fillRect(x - 5, y - 5, w + 10, h + 10, false); + renderer.drawRect(x, y, w, h, true); + + const int textX = x + (w - textWidth) / 2; + const int textY = y + margin - 2; + renderer.drawText(UI_12_FONT_ID, textX, textY, message, true, EpdFontFamily::REGULAR); + renderer.displayBuffer(); + return Rect{x, y, w, h}; +} \ 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..f2e28970 --- /dev/null +++ b/src/components/themes/lyra/LyraTheme.h @@ -0,0 +1,58 @@ +#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, + .listWithSubtitleRowHeight = 60, + .menuRowHeight = 64, + .menuSpacing = 8, + .tabSpacing = 8, + .tabBarHeight = 40, + .scrollBarWidth = 4, + .scrollBarRightOffset = 5, + .homeTopPadding = 56, + .homeCoverHeight = 226, + .homeCoverTileHeight = 287, + .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) const override; + void drawHeader(const GfxRenderer& renderer, Rect rect, const char* title) const override; + void drawTabBar(const GfxRenderer& renderer, Rect rect, const std::vector& tabs, + bool selected) const override; + void drawList(const GfxRenderer& renderer, Rect rect, int itemCount, int selectedIndex, + const std::function& rowTitle, bool hasSubtitle, + const std::function& rowSubtitle, bool hasIcon, + const std::function& rowIcon, bool hasValue, + const std::function& rowValue) const override; + void drawButtonHints(GfxRenderer& renderer, const char* btn1, const char* btn2, const char* btn3, + const char* btn4) const override; + void drawSideButtonHints(const GfxRenderer& renderer, const char* topBtn, const char* bottomBtn) const override; + void drawButtonMenu(GfxRenderer& renderer, Rect rect, int buttonCount, int selectedIndex, + const std::function& buttonLabel, bool hasIcon, + const std::function& rowIcon) const override; + void drawRecentBookCover(GfxRenderer& renderer, Rect rect, const std::vector& recentBooks, + const int selectorIndex, bool& coverRendered, bool& coverBufferStored, bool& bufferRestored, + std::function storeCoverBuffer) const override; + Rect drawPopup(const GfxRenderer& renderer, const char* message) const override; +}; diff --git a/src/main.cpp b/src/main.cpp index 89c4e13c..5b118fad 100644 --- a/src/main.cpp +++ b/src/main.cpp @@ -20,10 +20,12 @@ #include "activities/browser/OpdsBookBrowserActivity.h" #include "activities/home/HomeActivity.h" #include "activities/home/MyLibraryActivity.h" +#include "activities/home/RecentBooksActivity.h" #include "activities/network/CrossPointWebServerActivity.h" #include "activities/reader/ReaderActivity.h" #include "activities/settings/SettingsActivity.h" #include "activities/util/FullScreenMessageActivity.h" +#include "components/UITheme.h" #include "fontIds.h" HalDisplay display; @@ -203,13 +205,13 @@ void enterDeepSleep() { } void onGoHome(); -void onGoToMyLibraryWithTab(const std::string& path, MyLibraryActivity::Tab tab); -void onGoToReader(const std::string& initialEpubPath, MyLibraryActivity::Tab fromTab) { +void onGoToMyLibraryWithPath(const std::string& path); +void onGoToRecentBooks(); +void onGoToReader(const std::string& initialEpubPath) { exitActivity(); enterNewActivity( - new ReaderActivity(renderer, mappedInputManager, initialEpubPath, fromTab, onGoHome, onGoToMyLibraryWithTab)); + new ReaderActivity(renderer, mappedInputManager, initialEpubPath, onGoHome, onGoToMyLibraryWithPath)); } -void onContinueReading() { onGoToReader(APP_STATE.openEpubPath, MyLibraryActivity::Tab::Recent); } void onGoToFileTransfer() { exitActivity(); @@ -226,9 +228,14 @@ void onGoToMyLibrary() { enterNewActivity(new MyLibraryActivity(renderer, mappedInputManager, onGoHome, onGoToReader)); } -void onGoToMyLibraryWithTab(const std::string& path, MyLibraryActivity::Tab tab) { +void onGoToRecentBooks() { exitActivity(); - enterNewActivity(new MyLibraryActivity(renderer, mappedInputManager, onGoHome, onGoToReader, tab, path)); + enterNewActivity(new RecentBooksActivity(renderer, mappedInputManager, onGoHome, onGoToReader)); +} + +void onGoToMyLibraryWithPath(const std::string& path) { + exitActivity(); + enterNewActivity(new MyLibraryActivity(renderer, mappedInputManager, onGoHome, onGoToReader, path)); } void onGoToBrowser() { @@ -238,8 +245,8 @@ void onGoToBrowser() { void onGoHome() { exitActivity(); - enterNewActivity(new HomeActivity(renderer, mappedInputManager, onContinueReading, onGoToMyLibrary, onGoToSettings, - onGoToFileTransfer, onGoToBrowser)); + enterNewActivity(new HomeActivity(renderer, mappedInputManager, onGoToReader, onGoToMyLibrary, onGoToRecentBooks, + onGoToSettings, onGoToFileTransfer, onGoToBrowser)); } void setupDisplayAndFonts() { @@ -293,6 +300,7 @@ void setup() { SETTINGS.loadFromFile(); KOREADER_STORE.loadFromFile(); + UITheme::getInstance().reload(); switch (gpio.getWakeupReason()) { case HalGPIO::WakeupReason::PowerButton: @@ -330,7 +338,7 @@ void setup() { const auto path = APP_STATE.openEpubPath; APP_STATE.openEpubPath = ""; APP_STATE.saveToFile(); - onGoToReader(path, MyLibraryActivity::Tab::Recent); + onGoToReader(path); } // Ensure we're not still holding the power button before leaving setup