diff --git a/lib/Epub/Epub.cpp b/lib/Epub/Epub.cpp index f4fdcf3c..1dba55c5 100644 --- a/lib/Epub/Epub.cpp +++ b/lib/Epub/Epub.cpp @@ -816,6 +816,9 @@ bool Epub::generateCoverBmp(bool cropped) const { 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"; } +std::string Epub::getThumbBmpPath(int width, int height) const { + return cachePath + "/thumb_" + std::to_string(width) + "x" + std::to_string(height) + ".bmp"; +} bool Epub::generateThumbBmp(int height) const { // Already generated, return true @@ -908,6 +911,66 @@ bool Epub::generateThumbBmp(int height) const { return false; } +bool Epub::generateThumbBmp(int width, int height) const { + if (Storage.exists(getThumbBmpPath(width, height).c_str())) return true; + + if (!bookMetadataCache || !bookMetadataCache->isLoaded()) { + LOG_ERR("EBP", "Cannot generate thumb BMP, cache not loaded"); + return false; + } + + const auto coverImageHref = bookMetadataCache->coreMetadata.coverItemHref; + if (coverImageHref.empty()) { + LOG_DBG("EBP", "No known cover image for thumbnail"); + } else if (FsHelpers::hasJpgExtension(coverImageHref)) { + const auto coverJpgTempPath = getCachePath() + "/.cover.jpg"; + FsFile coverJpg; + if (!Storage.openFileForWrite("EBP", coverJpgTempPath, coverJpg)) return false; + readItemContentsToStream(coverImageHref, coverJpg, 1024); + coverJpg.close(); + if (!Storage.openFileForRead("EBP", coverJpgTempPath, coverJpg)) return false; + FsFile thumbBmp; + if (!Storage.openFileForWrite("EBP", getThumbBmpPath(width, height), thumbBmp)) { + coverJpg.close(); + return false; + } + const bool success = JpegToBmpConverter::jpegFileTo1BitBmpStreamWithSize(coverJpg, thumbBmp, width, height); + coverJpg.close(); + thumbBmp.close(); + Storage.remove(coverJpgTempPath.c_str()); + if (!success) Storage.remove(getThumbBmpPath(width, height).c_str()); + LOG_DBG("EBP", "Generated %dx%d thumb BMP from JPG, success: %s", width, height, success ? "yes" : "no"); + return success; + } else if (FsHelpers::hasPngExtension(coverImageHref)) { + const auto coverPngTempPath = getCachePath() + "/.cover.png"; + FsFile coverPng; + if (!Storage.openFileForWrite("EBP", coverPngTempPath, coverPng)) return false; + readItemContentsToStream(coverImageHref, coverPng, 1024); + coverPng.close(); + if (!Storage.openFileForRead("EBP", coverPngTempPath, coverPng)) return false; + FsFile thumbBmp; + if (!Storage.openFileForWrite("EBP", getThumbBmpPath(width, height), thumbBmp)) { + coverPng.close(); + return false; + } + const bool success = PngToBmpConverter::pngFileTo1BitBmpStreamWithSize(coverPng, thumbBmp, width, height); + coverPng.close(); + thumbBmp.close(); + Storage.remove(coverPngTempPath.c_str()); + if (!success) Storage.remove(getThumbBmpPath(width, height).c_str()); + LOG_DBG("EBP", "Generated %dx%d thumb BMP from PNG, success: %s", width, height, success ? "yes" : "no"); + return success; + } else { + LOG_ERR("EBP", "Cover image is not a supported format, skipping thumbnail"); + } + + // Write empty sentinel to avoid repeated generation attempts + FsFile thumbBmp; + Storage.openFileForWrite("EBP", getThumbBmpPath(width, height), thumbBmp); + thumbBmp.close(); + return false; +} + uint8_t* Epub::readItemContentsToBytes(const std::string& itemHref, size_t* size, const bool trailingNullByte) const { if (itemHref.empty()) { LOG_DBG("EBP", "Failed to read item, empty href"); diff --git a/lib/Epub/Epub.h b/lib/Epub/Epub.h index d127f2f2..15b77d77 100644 --- a/lib/Epub/Epub.h +++ b/lib/Epub/Epub.h @@ -62,7 +62,9 @@ class Epub { bool generateCoverBmp(bool cropped = false) const; std::string getThumbBmpPath() const; std::string getThumbBmpPath(int height) const; + std::string getThumbBmpPath(int width, int height) const; bool generateThumbBmp(int height) const; + bool generateThumbBmp(int width, 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 06f0c2db..31dd431a 100644 --- a/lib/GfxRenderer/GfxRenderer.cpp +++ b/lib/GfxRenderer/GfxRenderer.cpp @@ -1482,6 +1482,61 @@ void GfxRenderer::drawIcon(const uint8_t bitmap[], const int x, const int y, con display.drawImageTransparent(bitmap, y, getScreenWidth() - width - x, height, width); } +void GfxRenderer::drawIconInverted(const uint8_t bitmap[], const int x, const int y, const int width, + const int height) const { + // Portrait-mode coordinate transform (x↔y swap), matching drawIcon. + // OR with ~srcByte sets framebuffer bits to 1 (white) wherever the icon + // bitmap is 0 (black) — produces a white icon on a black background. + const int physX = y; + const int physY = getScreenWidth() - width - x; + const int imgW = height; // dimensions swapped by portrait transform + const int imgH = width; + const int srcStride = (imgW + 7) / 8; + + if (physX + imgW <= 0 || physX >= static_cast(panelWidthBytes) * 8) return; + if (physY + imgH <= 0 || physY >= static_cast(panelHeight)) return; + + const int baseByte = (physX >= 0) ? (physX >> 3) : -(((-physX) + 7) >> 3); + const int bitShift = ((physX % 8) + 8) % 8; + + const int trail = srcStride * 8 - imgW; + const uint8_t trailMask = static_cast(0xFF << trail); + const int lastCol = srcStride - 1; + + for (int row = 0; row < imgH; ++row) { + const int destY = physY + row; + if (destY < 0 || destY >= static_cast(panelHeight)) continue; + const int rowBase = destY * static_cast(panelWidthBytes); + const int srcOffset = row * srcStride; + + if (bitShift == 0) { + for (int col = 0; col < srcStride; ++col) { + const int dst = baseByte + col; + if (dst < 0) continue; + if (dst >= static_cast(panelWidthBytes)) break; + uint8_t inv = ~bitmap[srcOffset + col]; + if (col == lastCol && trail > 0) inv &= trailMask; + frameBuffer[rowBase + dst] |= inv; + } + } else { + const int rsh = bitShift; + const int lsh = 8 - bitShift; + for (int col = 0; col < srcStride; ++col) { + uint8_t inv = ~bitmap[srcOffset + col]; + if (col == lastCol && trail > 0) inv &= trailMask; + const int dstHi = baseByte + col; + const int dstLo = dstHi + 1; + if (dstHi >= 0 && dstHi < static_cast(panelWidthBytes)) { + frameBuffer[rowBase + dstHi] |= static_cast(inv >> rsh); + } + if (dstLo >= 0 && dstLo < static_cast(panelWidthBytes)) { + frameBuffer[rowBase + dstLo] |= static_cast(inv << lsh); + } + } + } + } +} + 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 { if (fontCacheManager_ && fontCacheManager_->isScanning()) return; diff --git a/lib/GfxRenderer/GfxRenderer.h b/lib/GfxRenderer/GfxRenderer.h index 76a536fe..0a277dfa 100644 --- a/lib/GfxRenderer/GfxRenderer.h +++ b/lib/GfxRenderer/GfxRenderer.h @@ -155,6 +155,7 @@ class GfxRenderer { 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 drawIconInverted(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; diff --git a/lib/Xtc/Xtc.cpp b/lib/Xtc/Xtc.cpp index a1f8f8a6..c01e68ed 100644 --- a/lib/Xtc/Xtc.cpp +++ b/lib/Xtc/Xtc.cpp @@ -262,62 +262,55 @@ bool Xtc::generateCoverBmp() const { 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"; } +std::string Xtc::getThumbBmpPath(int width, int height) const { + return cachePath + "/thumb_" + std::to_string(width) + "x" + std::to_string(height) + ".bmp"; +} -bool Xtc::generateThumbBmp(int height) const { - // Already generated - if (Storage.exists(getThumbBmpPath(height).c_str())) { - return true; - } +bool Xtc::generateThumbBmp(int height) const { return generateThumbBmp(height * 0.6, height); } + +bool Xtc::generateThumbBmp(int width, int height) const { + if (Storage.exists(getThumbBmpPath(width, height).c_str())) return true; if (!loaded || !parser) { LOG_ERR("XTC", "Cannot generate thumb BMP, file not loaded"); return false; } - if (parser->getPageCount() == 0) { LOG_ERR("XTC", "No pages in XTC file"); return false; } - // Setup cache directory setupCacheDir(); - // Get first page info for cover xtc::PageInfo pageInfo; if (!parser->getPageInfo(0, pageInfo)) { LOG_DBG("XTC", "Failed to get first page info"); return false; } - // Get bit depth const uint8_t bitDepth = parser->getBitDepth(); + const int THUMB_TARGET_WIDTH = width; + const int THUMB_TARGET_HEIGHT = height; - // Calculate target dimensions for thumbnail (fit within 240x400 Continue Reading card) - int THUMB_TARGET_WIDTH = height * 0.6; - int THUMB_TARGET_HEIGHT = height; - - // Calculate scale factor float scaleX = static_cast(THUMB_TARGET_WIDTH) / pageInfo.width; float scaleY = static_cast(THUMB_TARGET_HEIGHT) / pageInfo.height; - float scale = (scaleX > scaleY) ? scaleX : scaleY; // for cropping + float scale = (scaleX > scaleY) ? scaleX : scaleY; - // Only scale down, never up if (scale >= 1.0f) { - // Page is already small enough, just use cover.bmp - // Copy cover.bmp to thumb.bmp if (generateCoverBmp()) { FsFile src, dst; if (Storage.openFileForRead("XTC", getCoverBmpPath(), src)) { - if (Storage.openFileForWrite("XTC", getThumbBmpPath(height), dst)) { + if (Storage.openFileForWrite("XTC", getThumbBmpPath(width, height), dst)) { uint8_t buffer[512]; while (src.available()) { size_t bytesRead = src.read(buffer, sizeof(buffer)); dst.write(buffer, bytesRead); } + dst.close(); } + src.close(); } - LOG_DBG("XTC", "Copied cover to thumb (no scaling needed)"); - return Storage.exists(getThumbBmpPath(height).c_str()); + return Storage.exists(getThumbBmpPath(width, height).c_str()); } return false; } @@ -325,10 +318,6 @@ bool Xtc::generateThumbBmp(int height) const { uint16_t thumbWidth = static_cast(pageInfo.width * scale); uint16_t thumbHeight = static_cast(pageInfo.height * scale); - LOG_DBG("XTC", "Generating thumb BMP: %dx%d -> %dx%d (scale: %.3f)", pageInfo.width, pageInfo.height, thumbWidth, - thumbHeight, scale); - - // Allocate buffer for page data size_t bitmapSize; if (bitDepth == 2) { bitmapSize = ((static_cast(pageInfo.width) * pageInfo.height + 7) / 8) * 2; @@ -341,7 +330,6 @@ bool Xtc::generateThumbBmp(int height) const { return false; } - // Load first page (cover) size_t bytesRead = const_cast(parser.get())->loadPage(0, pageBuffer, bitmapSize); if (bytesRead == 0) { LOG_ERR("XTC", "Failed to load cover page for thumb"); @@ -349,32 +337,25 @@ bool Xtc::generateThumbBmp(int height) const { return false; } - // Create thumbnail BMP file - use 1-bit format for fast home screen rendering (no gray passes) FsFile thumbBmp; - if (!Storage.openFileForWrite("XTC", getThumbBmpPath(height), thumbBmp)) { - LOG_DBG("XTC", "Failed to create thumb BMP file"); + if (!Storage.openFileForWrite("XTC", getThumbBmpPath(width, height), thumbBmp)) { free(pageBuffer); return false; } - // Write 1-bit BMP header (top-down row order) + const uint32_t rowSize = (thumbWidth + 31) / 32 * 4; BmpHeader bmpHeader; createBmpHeader(&bmpHeader, thumbWidth, thumbHeight, BmpRowOrder::TopDown); - thumbBmp.write(reinterpret_cast(&bmpHeader), sizeof(bmpHeader)); + thumbBmp.write(reinterpret_cast(&bmpHeader), sizeof(BmpHeader)); - const uint32_t rowSize = (thumbWidth + 31) / 32 * 4; - - // Allocate row buffer for 1-bit output uint8_t* rowBuffer = static_cast(malloc(rowSize)); if (!rowBuffer) { free(pageBuffer); + thumbBmp.close(); return false; } - // Fixed-point scale factor (16.16) uint32_t scaleInv_fp = static_cast(65536.0f / scale); - - // Pre-calculate plane info for 2-bit mode const size_t planeSize = (bitDepth == 2) ? ((static_cast(pageInfo.width) * pageInfo.height + 7) / 8) : 0; const uint8_t* plane1 = (bitDepth == 2) ? pageBuffer : nullptr; const uint8_t* plane2 = (bitDepth == 2) ? pageBuffer + planeSize : nullptr; @@ -382,9 +363,7 @@ bool Xtc::generateThumbBmp(int height) const { const size_t srcRowBytes = (bitDepth == 1) ? ((pageInfo.width + 7) / 8) : 0; for (uint16_t dstY = 0; dstY < thumbHeight; dstY++) { - memset(rowBuffer, 0xFF, rowSize); // Start with all white (bit 1) - - // Calculate source Y range with bounds checking + memset(rowBuffer, 0xFF, rowSize); uint32_t srcYStart = (static_cast(dstY) * scaleInv_fp) >> 16; uint32_t srcYEnd = (static_cast(dstY + 1) * scaleInv_fp) >> 16; if (srcYStart >= pageInfo.height) srcYStart = pageInfo.height - 1; @@ -393,7 +372,6 @@ bool Xtc::generateThumbBmp(int height) const { if (srcYEnd > pageInfo.height) srcYEnd = pageInfo.height; for (uint16_t dstX = 0; dstX < thumbWidth; dstX++) { - // Calculate source X range with bounds checking uint32_t srcXStart = (static_cast(dstX) * scaleInv_fp) >> 16; uint32_t srcXEnd = (static_cast(dstX + 1) * scaleInv_fp) >> 16; if (srcXStart >= pageInfo.width) srcXStart = pageInfo.width - 1; @@ -401,82 +379,56 @@ bool Xtc::generateThumbBmp(int height) const { if (srcXEnd <= srcXStart) srcXEnd = srcXStart + 1; if (srcXEnd > pageInfo.width) srcXEnd = pageInfo.width; - // Area averaging: sum grayscale values (0-255 range) - uint32_t graySum = 0; - uint32_t totalCount = 0; - + uint32_t graySum = 0, totalCount = 0; for (uint32_t srcY = srcYStart; srcY < srcYEnd && srcY < pageInfo.height; srcY++) { for (uint32_t srcX = srcXStart; srcX < srcXEnd && srcX < pageInfo.width; srcX++) { - uint8_t grayValue = 255; // Default: white - + uint8_t grayValue = 255; if (bitDepth == 2) { - // XTH 2-bit mode: pixel value 0-3 - // Bounds check for column index if (srcX < pageInfo.width) { const size_t colIndex = pageInfo.width - 1 - srcX; const size_t byteInCol = srcY / 8; const size_t bitInByte = 7 - (srcY % 8); const size_t byteOffset = colIndex * colBytes + byteInCol; - // Bounds check for buffer access if (byteOffset < planeSize) { const uint8_t bit1 = (plane1[byteOffset] >> bitInByte) & 1; const uint8_t bit2 = (plane2[byteOffset] >> bitInByte) & 1; - const uint8_t pixelValue = (bit1 << 1) | bit2; - // Convert 2-bit (0-3) to grayscale: 0=black, 3=white - // pixelValue: 0=white, 1=light gray, 2=dark gray, 3=black (XTC polarity) - grayValue = (3 - pixelValue) * 85; // 0->255, 1->170, 2->85, 3->0 + grayValue = (3 - ((bit1 << 1) | bit2)) * 85; } } } else { - // 1-bit mode const size_t byteIdx = srcY * srcRowBytes + srcX / 8; const size_t bitIdx = 7 - (srcX % 8); - // Bounds check for buffer access if (byteIdx < bitmapSize) { - const uint8_t pixelBit = (pageBuffer[byteIdx] >> bitIdx) & 1; - // XTC 1-bit polarity: 0=black, 1=white (same as BMP palette) - grayValue = pixelBit ? 255 : 0; + grayValue = ((pageBuffer[byteIdx] >> bitIdx) & 1) ? 255 : 0; } } - graySum += grayValue; totalCount++; } } - // Calculate average grayscale and quantize to 1-bit with noise dithering uint8_t avgGray = (totalCount > 0) ? static_cast(graySum / totalCount) : 255; - - // Hash-based noise dithering for 1-bit output uint32_t hash = static_cast(dstX) * 374761393u + static_cast(dstY) * 668265263u; hash = (hash ^ (hash >> 13)) * 1274126177u; - const int threshold = static_cast(hash >> 24); // 0-255 - const int adjustedThreshold = 128 + ((threshold - 128) / 2); // Range: 64-192 - - // Quantize to 1-bit: 0=black, 1=white + const int threshold = static_cast(hash >> 24); + const int adjustedThreshold = 128 + ((threshold - 128) / 2); uint8_t oneBit = (avgGray >= adjustedThreshold) ? 1 : 0; - - // Pack 1-bit value into row buffer (MSB first, 8 pixels per byte) const size_t byteIndex = dstX / 8; const size_t bitOffset = 7 - (dstX % 8); - // Bounds check for row buffer access if (byteIndex < rowSize) { - if (oneBit) { - rowBuffer[byteIndex] |= (1 << bitOffset); // Set bit for white - } else { - rowBuffer[byteIndex] &= ~(1 << bitOffset); // Clear bit for black - } + if (oneBit) + rowBuffer[byteIndex] |= (1 << bitOffset); + else + rowBuffer[byteIndex] &= ~(1 << bitOffset); } } - - // Write row (already padded to 4-byte boundary by rowSize) thumbBmp.write(rowBuffer, rowSize); } free(rowBuffer); + thumbBmp.close(); free(pageBuffer); - - LOG_DBG("XTC", "Generated thumb BMP (%dx%d): %s", thumbWidth, thumbHeight, getThumbBmpPath(height).c_str()); + LOG_DBG("XTC", "Generated thumb BMP (%dx%d): %s", thumbWidth, thumbHeight, getThumbBmpPath(width, height).c_str()); return true; } diff --git a/lib/Xtc/Xtc.h b/lib/Xtc/Xtc.h index 93f911fc..adf6e53b 100644 --- a/lib/Xtc/Xtc.h +++ b/lib/Xtc/Xtc.h @@ -66,7 +66,9 @@ class Xtc { // Thumbnail support (for Continue Reading card) std::string getThumbBmpPath() const; std::string getThumbBmpPath(int height) const; + std::string getThumbBmpPath(int width, int height) const; bool generateThumbBmp(int height) const; + bool generateThumbBmp(int width, int height) const; // Page access uint32_t getPageCount() const; diff --git a/src/CrossPointSettings.h b/src/CrossPointSettings.h index 1dfe164f..181c8dfb 100644 --- a/src/CrossPointSettings.h +++ b/src/CrossPointSettings.h @@ -132,7 +132,7 @@ class CrossPointSettings { 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, LYRA_3_COVERS = 2 }; + enum UI_THEME { CLASSIC = 0, LYRA = 1, LYRA_3_COVERS = 2, LYRA_CAROUSEL = 3 }; // Image rendering in EPUB reader enum IMAGE_RENDERING { IMAGES_DISPLAY = 0, IMAGES_PLACEHOLDER = 1, IMAGES_SUPPRESS = 2, IMAGE_RENDERING_COUNT }; diff --git a/src/SettingsList.h b/src/SettingsList.h index 33410c06..8f4a29aa 100644 --- a/src/SettingsList.h +++ b/src/SettingsList.h @@ -79,8 +79,9 @@ inline const std::vector list = { SettingInfo::Toggle(StrId::STR_SUNLIGHT_FADING_FIX, &CrossPointSettings::fadingFix, "fadingFix", StrId::STR_CAT_DISPLAY), SettingInfo::Enum(StrId::STR_UI_THEME, &CrossPointSettings::uiTheme, - {StrId::STR_THEME_CLASSIC, StrId::STR_THEME_LYRA, StrId::STR_THEME_LYRA_EXTENDED}, "uiTheme", - StrId::STR_CAT_DISPLAY), + {StrId::STR_THEME_CLASSIC, StrId::STR_THEME_LYRA, StrId::STR_THEME_LYRA_EXTENDED, + StrId::STR_THEME_LYRA_CAROUSEL}, + "uiTheme", StrId::STR_CAT_DISPLAY), // --- Reader --- // General reader settings diff --git a/src/activities/home/HomeActivity.cpp b/src/activities/home/HomeActivity.cpp index 858341a8..70e06d4b 100644 --- a/src/activities/home/HomeActivity.cpp +++ b/src/activities/home/HomeActivity.cpp @@ -11,6 +11,7 @@ #include #include +#include #include #include "CrossPointSettings.h" @@ -20,9 +21,39 @@ #include "OpdsServerStore.h" #include "RecentBooksStore.h" #include "components/UITheme.h" +#include "components/themes/lyra/LyraCarouselTheme.h" #include "fontIds.h" +// --------------------------------------------------------------------------- +// Static carousel frame cache — survives HomeActivity re-creation so that +// returning to home (e.g. after settings) doesn't re-read covers from SD. +// Freed explicitly in onSelectBook() before entering the reader. +// --------------------------------------------------------------------------- namespace { +uint8_t* gCachedFrames[HomeActivity::kCarouselFrameCount] = {}; +int gCachedFrameBookIdx[HomeActivity::kCarouselFrameCount] = {-1, -1, -1}; +int gCachedFrameCount = 0; +std::string gCacheKey; + +int findFrameSlot(int bookIdx) { + for (int i = 0; i < HomeActivity::kCarouselFrameCount; ++i) { + if (gCachedFrameBookIdx[i] == bookIdx && gCachedFrames[i] != nullptr) return i; + } + return -1; +} + +void invalidateCarouselCache() { + for (int i = 0; i < HomeActivity::kCarouselFrameCount; ++i) { + if (gCachedFrames[i]) { + free(gCachedFrames[i]); + gCachedFrames[i] = nullptr; + } + gCachedFrameBookIdx[i] = -1; + } + gCachedFrameCount = 0; + gCacheKey.clear(); +} + constexpr int CLASSIC_MIN_RECENT_TILE_HEIGHT = 280; constexpr int LYRA_MIN_RECENT_TILE_HEIGHT = 170; constexpr int LYRA_3_COVERS_MIN_RECENT_TILE_HEIGHT = 200; @@ -199,6 +230,7 @@ void HomeActivity::onEnter() { hasOpdsServers = OPDS_STORE.hasServers(); selectorIndex = 0; + carouselFramesReady = false; recentsLoading = false; recentsLoaded = false; firstRenderDone = false; @@ -212,6 +244,11 @@ void HomeActivity::onEnter() { recentsLoaded = true; } + // Pre-render carousel frames before first display so the fast path is ready. + if (static_cast(SETTINGS.uiTheme) == CrossPointSettings::UI_THEME::LYRA_CAROUSEL) { + preRenderCarouselFrames(); + } + // Apply focus: book path takes priority, else combined selector index (covers // "return to the menu entry I was on"). bool focused = false; @@ -242,8 +279,9 @@ void HomeActivity::onEnter() { void HomeActivity::onExit() { Activity::onExit(); - // Free the stored cover buffer if any freeCoverBuffer(); + invalidateCarouselCache(); + freeCarouselFrames(); } bool HomeActivity::storeCoverBuffer() { @@ -288,31 +326,188 @@ void HomeActivity::freeCoverBuffer() { coverBufferStored = false; } +void HomeActivity::freeCarouselFrames() { + // Instance pointers are aliases into the static cache — do not free here. + for (int i = 0; i < kCarouselFrameCount; ++i) carouselFrames[i] = nullptr; + carouselFramesReady = false; +} + +void HomeActivity::preRenderCarouselFrames() { + const int bookCount = static_cast(recentBooks.size()); + if (bookCount == 0) return; + + // Build cache key from book paths in order + std::string newKey; + newKey.reserve(128); + for (const auto& b : recentBooks) { + newKey += b.path; + newKey += '\0'; + } + + // Cache hit: same books in same order — reuse without any SD reads + if (newKey == gCacheKey && gCachedFrameCount > 0) { + for (int i = 0; i < gCachedFrameCount; ++i) carouselFrames[i] = gCachedFrames[i]; + carouselFramesReady = true; + coverRendered = false; + coverBufferStored = false; + return; + } + + // Cache miss: free old cache and re-render + invalidateCarouselCache(); + + if (!renderer.getFrameBuffer()) return; + + const size_t bufferSize = renderer.getBufferSize(); + freeCoverBuffer(); // reclaim 48KB before allocating frames + + const int frameCount = std::min(bookCount, kCarouselFrameCount); + for (int i = 0; i < frameCount; ++i) { + gCachedFrames[i] = static_cast(malloc(bufferSize)); + if (!gCachedFrames[i]) { + LOG_ERR("HOME", "preRenderCarouselFrames: malloc failed for frame %d", i); + invalidateCarouselCache(); + return; + } + } + + // Render only the currently-selected cover. Adjacent frames are populated + // lazily by updateSlidingWindowCache() after the first paint completes. + const int selectedBookIdx = (selectorIndex < bookCount) ? selectorIndex : lastCarouselBookIndex; + const int initialBookIdx = (selectedBookIdx >= 0 && selectedBookIdx < bookCount) ? selectedBookIdx : 0; + renderCarouselFrame(initialBookIdx, 0); + + gCachedFrameCount = frameCount; + gCacheKey = newKey; + carouselFramesReady = true; + coverRendered = false; + coverBufferStored = false; +} + +void HomeActivity::renderCarouselFrame(int bookIdx, int slotIdx) { + uint8_t* frameBuffer = renderer.getFrameBuffer(); + if (!frameBuffer || !gCachedFrames[slotIdx]) return; + + const auto& metrics = UITheme::getInstance().getMetrics(); + const int pageWidth = renderer.getScreenWidth(); + const int bookCount = static_cast(recentBooks.size()); + bool dummy1 = false, dummy2 = false, dummy3 = false; + + LyraCarouselTheme::setPreRenderIndex(bookIdx); + renderer.clearScreen(); + GUI.drawHeader(renderer, Rect{0, metrics.topPadding, pageWidth, metrics.homeTopPadding}, nullptr); + GUI.drawRecentBookCover(renderer, Rect{0, metrics.homeTopPadding, pageWidth, metrics.homeCoverTileHeight}, + recentBooks, bookCount, dummy1, dummy2, dummy3, []() { return true; }); + + memcpy(gCachedFrames[slotIdx], frameBuffer, renderer.getBufferSize()); + gCachedFrameBookIdx[slotIdx] = bookIdx; + carouselFrames[slotIdx] = gCachedFrames[slotIdx]; +} + +void HomeActivity::updateSlidingWindowCache(int centerIdx, int bookCount) { + if (bookCount <= kCarouselFrameCount || !carouselFramesReady) return; + + const int prevIdx = (centerIdx + bookCount - 1) % bookCount; + const int nextIdx = (centerIdx + 1) % bookCount; + + const bool hasPrev = findFrameSlot(prevIdx) >= 0; + const bool hasNext = findFrameSlot(nextIdx) >= 0; + if (hasPrev && hasNext) return; + + const int missingIdx = !hasPrev ? prevIdx : nextIdx; + + int evictSlot = -1; + int maxDist = -1; + for (int i = 0; i < kCarouselFrameCount; ++i) { + if (!gCachedFrames[i]) continue; + const int bookInSlot = gCachedFrameBookIdx[i]; + if (bookInSlot == centerIdx) continue; + if (hasPrev && bookInSlot == prevIdx) continue; + if (hasNext && bookInSlot == nextIdx) continue; + const int diff = std::abs(bookInSlot - centerIdx); + const int dist = std::min(diff, bookCount - diff); + if (dist > maxDist) { + maxDist = dist; + evictSlot = i; + } + } + + if (evictSlot >= 0) { + LOG_DBG("HOME", "carousel: evict slot %d (book %d) -> book %d", evictSlot, gCachedFrameBookIdx[evictSlot], + missingIdx); + renderCarouselFrame(missingIdx, evictSlot); + } +} + void HomeActivity::loop() { if (menuEntriesDirty) { rebuildMenuEntries(); } - const int totalItems = static_cast(recentBooks.size() + menuEntries.size()); - if (firstRenderDone && !recentsLoaded && !recentsLoading) { - const auto& metrics = UITheme::getInstance().getMetrics(); - const Rect contentRect = UITheme::getContentRect(renderer, true, false); - const HomeScreenLayout layout = - computeHomeScreenLayout(metrics, contentRect.height, static_cast(menuEntries.size())); - loadRecentCovers(getHomeCoverRenderHeight(layout)); - return; + const bool isCarousel = + static_cast(SETTINGS.uiTheme) == CrossPointSettings::UI_THEME::LYRA_CAROUSEL; + + if (isCarousel) { + const int bookCount = static_cast(recentBooks.size()); + const int menuItemCount = static_cast(menuEntries.size()); + const bool inCarouselRow = (selectorIndex < bookCount); + const int menuIdx = inCarouselRow ? 0 : (selectorIndex - bookCount); + + if (mappedInput.wasPressed(MappedInputManager::Button::Right)) { + if (inCarouselRow && bookCount > 0) + selectorIndex = (selectorIndex + 1) % bookCount; + else if (!inCarouselRow) + selectorIndex = bookCount + (menuIdx + 1) % menuItemCount; + requestUpdate(); + } + if (mappedInput.wasPressed(MappedInputManager::Button::Left)) { + if (inCarouselRow && bookCount > 0) + selectorIndex = (selectorIndex + bookCount - 1) % bookCount; + else if (!inCarouselRow) + selectorIndex = bookCount + (menuIdx + menuItemCount - 1) % menuItemCount; + requestUpdate(); + } + if (mappedInput.wasPressed(MappedInputManager::Button::Down)) { + if (inCarouselRow) { + lastCarouselBookIndex = selectorIndex; + selectorIndex = bookCount; + } else { + selectorIndex = lastCarouselBookIndex; + } + requestUpdate(); + } + if (mappedInput.wasPressed(MappedInputManager::Button::Up)) { + if (inCarouselRow) { + lastCarouselBookIndex = selectorIndex; + selectorIndex = bookCount; + } else { + selectorIndex = lastCarouselBookIndex; + } + requestUpdate(); + } + } else { + const int totalItems = static_cast(recentBooks.size() + menuEntries.size()); + + if (firstRenderDone && !recentsLoaded && !recentsLoading) { + const auto& metrics = UITheme::getInstance().getMetrics(); + const Rect contentRect = UITheme::getContentRect(renderer, true, false); + const HomeScreenLayout layout = + computeHomeScreenLayout(metrics, contentRect.height, static_cast(menuEntries.size())); + loadRecentCovers(getHomeCoverRenderHeight(layout)); + return; + } + + buttonNavigator.onNext([this, totalItems] { + selectorIndex = ButtonNavigator::nextIndex(selectorIndex, totalItems); + requestUpdate(); + }); + + buttonNavigator.onPrevious([this, totalItems] { + selectorIndex = ButtonNavigator::previousIndex(selectorIndex, totalItems); + requestUpdate(); + }); } - buttonNavigator.onNext([this, totalItems] { - selectorIndex = ButtonNavigator::nextIndex(selectorIndex, totalItems); - requestUpdate(); - }); - - buttonNavigator.onPrevious([this, totalItems] { - selectorIndex = ButtonNavigator::previousIndex(selectorIndex, totalItems); - requestUpdate(); - }); - if (mappedInput.wasReleased(MappedInputManager::Button::Confirm)) { const int recentsCount = static_cast(recentBooks.size()); if (selectorIndex < recentsCount) { diff --git a/src/activities/home/HomeActivity.h b/src/activities/home/HomeActivity.h index c41d1506..a33d6227 100644 --- a/src/activities/home/HomeActivity.h +++ b/src/activities/home/HomeActivity.h @@ -13,6 +13,8 @@ struct Rect; class HomeActivity final : public Activity { public: + static constexpr int kCarouselFrameCount = 3; + enum class MenuAction { FileBrowser, Recents, @@ -32,6 +34,7 @@ class HomeActivity final : public Activity { ButtonNavigator buttonNavigator; int selectorIndex = 0; + int lastCarouselBookIndex = 0; // remembered position when leaving carousel row bool recentsLoading = false; bool recentsLoaded = false; bool firstRenderDone = false; @@ -40,6 +43,10 @@ class HomeActivity final : public Activity { bool coverBufferStored = false; // Track if cover buffer is stored size_t nextRecentCoverIndex = 0; uint8_t* coverBuffer = nullptr; // HomeActivity's own buffer for cover image + + uint8_t* carouselFrames[kCarouselFrameCount] = {nullptr, nullptr, nullptr}; + bool carouselFramesReady = false; + std::vector recentBooks; std::vector menuEntries; bool menuEntriesDirty = true; @@ -54,6 +61,10 @@ class HomeActivity final : public Activity { bool storeCoverBuffer(); bool restoreCoverBuffer(); void freeCoverBuffer(); + void preRenderCarouselFrames(); + void freeCarouselFrames(); + void renderCarouselFrame(int bookIdx, int slotIdx); + void updateSlidingWindowCache(int centerIdx, int bookCount); void loadRecentBooks(int maxBooks); void loadRecentCovers(int coverHeight); diff --git a/src/components/UITheme.cpp b/src/components/UITheme.cpp index 5c3dfe03..93ed81c8 100644 --- a/src/components/UITheme.cpp +++ b/src/components/UITheme.cpp @@ -13,6 +13,7 @@ #include "RecentBooksStore.h" #include "components/themes/BaseTheme.h" #include "components/themes/lyra/Lyra3CoversTheme.h" +#include "components/themes/lyra/LyraCarouselTheme.h" #include "components/themes/lyra/LyraTheme.h" namespace { @@ -66,6 +67,16 @@ void UITheme::setTheme(CrossPointSettings::UI_THEME type) { currentTheme = std::make_unique(); currentMetrics = &Lyra3CoversMetrics::values; break; + case CrossPointSettings::UI_THEME::LYRA_CAROUSEL: + LOG_DBG("UI", "Using Lyra Carousel theme"); + currentTheme = std::make_unique(); + currentMetrics = &LyraCarouselMetrics::values; + break; + default: + LOG_ERR("UI", "Unknown theme %d, falling back to Classic", static_cast(type)); + currentTheme = std::make_unique(); + currentMetrics = &BaseMetrics::values; + break; } } @@ -161,6 +172,14 @@ std::string UITheme::getCoverThumbPath(std::string coverBmpPath, int coverHeight return coverBmpPath; } +std::string UITheme::getCoverThumbPath(std::string coverBmpPath, int width, int height) { + size_t pos = coverBmpPath.find("[HEIGHT]", 0); + if (pos != std::string::npos) { + coverBmpPath.replace(pos, 8, std::to_string(width) + "x" + std::to_string(height)); + } + return coverBmpPath; +} + UIIcon UITheme::getFileIcon(const std::string& filename) { if (filename.back() == '/') { return Folder; diff --git a/src/components/UITheme.h b/src/components/UITheme.h index da015093..6cd03473 100644 --- a/src/components/UITheme.h +++ b/src/components/UITheme.h @@ -60,6 +60,7 @@ class UITheme { // The mapping to logical edges is orientation-dependent. static Rect getContentRect(const GfxRenderer& renderer, bool hasBottomHints, bool hasSideHints); static std::string getCoverThumbPath(std::string coverBmpPath, int coverHeight); + static std::string getCoverThumbPath(std::string coverBmpPath, int width, int height); static UIIcon getFileIcon(const std::string& filename); static int getStatusBarTopHeight(bool forceStatusItems = false); static int getStatusBarBottomHeight(bool forceStatusItems = false); diff --git a/src/components/themes/BaseTheme.h b/src/components/themes/BaseTheme.h index a9923287..dd116ad3 100644 --- a/src/components/themes/BaseTheme.h +++ b/src/components/themes/BaseTheme.h @@ -159,6 +159,7 @@ class BaseTheme { const char* secondaryLabel = nullptr, KeyboardKeyType keyType = KeyboardKeyType::Normal, bool inactiveSelection = false) const; virtual bool showsFileIcons() const { return false; } + virtual void drawCarouselBorder(GfxRenderer& renderer, Rect coverRect, bool inCarouselRow) const {} // Shared constants and helpers for battery drawing (used by all themes) static constexpr int batteryPercentSpacing = 4; diff --git a/src/components/themes/lyra/LyraCarouselTheme.cpp b/src/components/themes/lyra/LyraCarouselTheme.cpp new file mode 100644 index 00000000..83052e3a --- /dev/null +++ b/src/components/themes/lyra/LyraCarouselTheme.cpp @@ -0,0 +1,394 @@ +#include "LyraCarouselTheme.h" + +#include +#include +#include + +#include +#include + +#include "RecentBooksStore.h" +#include "components/UITheme.h" +#include "components/icons/book.h" +#include "components/icons/book24.h" +#include "components/icons/cover.h" +#include "components/icons/file24.h" +#include "components/icons/folder.h" +#include "components/icons/folder24.h" +#include "components/icons/hotspot.h" +#include "components/icons/image24.h" +#include "components/icons/library.h" +#include "components/icons/recent.h" +#include "components/icons/settings2.h" +#include "components/icons/text24.h" +#include "components/icons/transfer.h" +#include "components/icons/wifi.h" +#include "fontIds.h" + +namespace { +// Cover layout — centre cover dominates, sides slide kOverlap px behind it +constexpr int kCenterCoverMaxW = LyraCarouselTheme::kCenterCoverW; +constexpr int kCenterCoverMaxH = LyraCarouselTheme::kCenterCoverH; +constexpr int kSideCoverMaxW = LyraCarouselTheme::kSideCoverW; +constexpr int kSideCoverMaxH = LyraCarouselTheme::kSideCoverH; +constexpr int kOverlap = 60; +constexpr int kCoverTopPad = 10; + +constexpr int kTitleFontId = UI_12_FONT_ID; +constexpr int kDotSize = 8; // px square dot +constexpr int kDotGap = 6; // px between dots + +constexpr int kCornerRadius = 6; +constexpr int kThinOutlineW = 1; // always-visible outline around centre cover +constexpr int kSelectionLineW = 3; // thicker outline when centre cover is selected +constexpr int kCenterOutlineW = 4; // white ring around centre cover + +// Icon row — icons are 32×32 bitmaps; drawIcon does NOT scale +constexpr int kMenuIconSize = 32; // must match actual bitmap dimensions +constexpr int kMenuIconPad = 14; // symmetric vertical padding → tile height = 60 +constexpr int kHighlightPad = 12; // horizontal padding around the icon on each side +// Row is anchored to the bottom of the screen, just above button hints +constexpr int kButtonHintsH = LyraCarouselMetrics::values.buttonHintsHeight; + +int lastCarouselSelectorIndex = -1; + +const uint8_t* iconBitmapFor(UIIcon icon) { + switch (icon) { + case UIIcon::Folder: + return FolderIcon; + case UIIcon::Recent: + return RecentIcon; + case UIIcon::Transfer: + return TransferIcon; + case UIIcon::Settings: + return Settings2Icon; + case UIIcon::Book: + return BookIcon; + case UIIcon::Library: + return LibraryIcon; + default: + return nullptr; + } +} +} // namespace + +// --------------------------------------------------------------------------- +// Static helpers +// --------------------------------------------------------------------------- +void LyraCarouselTheme::setPreRenderIndex(int idx) { lastCarouselSelectorIndex = idx; } + +void LyraCarouselTheme::drawCarouselBorder(GfxRenderer& renderer, Rect coverRect, bool inCarouselRow) const { + if (!inCarouselRow) return; + const int screenW = renderer.getScreenWidth(); + const int centerX = (screenW - kCenterCoverMaxW) / 2; + const int centerTileY = coverRect.y + kCoverTopPad; + renderer.drawRoundedRect(centerX, centerTileY, kCenterCoverMaxW, kCenterCoverMaxH, kSelectionLineW, kCornerRadius, + true); +} + +// --------------------------------------------------------------------------- +// Carousel cover strip +// --------------------------------------------------------------------------- +void LyraCarouselTheme::drawRecentBookCover(GfxRenderer& renderer, Rect rect, + const std::vector& recentBooks, const int selectorIndex, + bool& coverRendered, bool& coverBufferStored, bool& bufferRestored, + std::function storeCoverBuffer) const { + if (recentBooks.empty()) { + drawEmptyRecents(renderer, rect); + return; + } + + const int bookCount = static_cast(recentBooks.size()); + // When navigating the icon row, keep showing the last carousel position — + // falling back to 0 on first use (lastCarouselSelectorIndex == -1). + const bool inCarouselRow = (selectorIndex < bookCount); + int centerIdx = inCarouselRow ? selectorIndex : (lastCarouselSelectorIndex >= 0 ? lastCarouselSelectorIndex : 0); + + if (centerIdx >= bookCount) { + centerIdx = bookCount - 1; + coverRendered = false; + coverBufferStored = false; + } + + // cppcheck-suppress knownConditionTrueFalse + // Reachable as false when navigating the icon row with a previously-set + // lastCarouselSelectorIndex; cppcheck only models the inCarouselRow=true path. + if (centerIdx != lastCarouselSelectorIndex) { + coverRendered = false; + coverBufferStored = false; + } + + const int screenW = renderer.getScreenWidth(); + const int centerTileY = rect.y + kCoverTopPad; + const int sideTileY = centerTileY + (kCenterCoverMaxH - kSideCoverMaxH) / 2; + + const int centerX = (screenW - kCenterCoverMaxW) / 2; + const int leftX = centerX - kSideCoverMaxW + kOverlap; + const int rightX = centerX + kCenterCoverMaxW - kOverlap; + + // Returns true if a book exists at bookIdx (cover image or placeholder drawn). + // Returns false only when the slot has no book — caller skips the border too. + auto drawCover = [&](int bookIdx, int x, int y, int maxW, int maxH) -> bool { + if (bookIdx < 0 || bookIdx >= bookCount) return false; + const RecentBook& book = recentBooks[bookIdx]; + bool hasCover = false; + if (!book.coverBmpPath.empty()) { + const std::string thumbPath = UITheme::getCoverThumbPath(book.coverBmpPath, maxW, maxH); + FsFile file; + if (Storage.openFileForRead("HOME", thumbPath, file)) { + Bitmap bitmap(file); + if (bitmap.parseHeaders() == BmpReaderError::Ok) { + // Height always fills the tile. Only crop horizontally if the cover is + // wider than the tile; narrow covers get white space on the sides. + const float bmpRatio = static_cast(bitmap.getWidth()) / static_cast(bitmap.getHeight()); + const float tileRatio = static_cast(maxW) / static_cast(maxH); + const float cropX = (bmpRatio > tileRatio) ? (1.0f - tileRatio / bmpRatio) : 0.0f; + renderer.drawBitmap(bitmap, x, y, maxW, maxH, cropX, 0.0f); + renderer.maskRoundedRectOutsideCorners(x, y, maxW, maxH, kCornerRadius, Color::White); + hasCover = true; + } + file.close(); + } + } + if (!hasCover) { + renderer.drawRoundedRect(x, y, maxW, maxH, 1, kCornerRadius, true); + renderer.fillRoundedRect(x, y + maxH / 3, maxW, 2 * maxH / 3, kCornerRadius, /*roundTopLeft=*/false, + /*roundTopRight=*/false, /*roundBottomLeft=*/true, /*roundBottomRight=*/true, + Color::Black); + renderer.drawIcon(CoverIcon, x + maxW / 2 - 16, y + 8, 32, 32); + } + return true; + }; + + if (!coverRendered) { + lastCarouselSelectorIndex = centerIdx; + + // Clear the entire cover tile to white so stale pixels from old positions + // don't persist (drawBitmap only sets black pixels, never clears). + renderer.fillRect(rect.x, rect.y, rect.width, rect.height, false); + + // Sides first so centre renders on top. + // Left side only when there are 3+ books; right side when there are 2+ books. + // Border only drawn if a cover image was actually rendered (no placeholders). + const int prevIdx = (centerIdx + bookCount - 1) % bookCount; + const int nextIdx = (centerIdx + 1) % bookCount; + if (bookCount >= 3) { + if (drawCover(prevIdx, leftX, sideTileY, kSideCoverMaxW, kSideCoverMaxH)) + renderer.drawRoundedRect(leftX, sideTileY, kSideCoverMaxW, kSideCoverMaxH, 1, kCornerRadius, true); + } + if (bookCount >= 2) { + if (drawCover(nextIdx, rightX, sideTileY, kSideCoverMaxW, kSideCoverMaxH)) + renderer.drawRoundedRect(rightX, sideTileY, kSideCoverMaxW, kSideCoverMaxH, 1, kCornerRadius, true); + } + + // Clear a white outline ring around the centre cover, then draw the cover + // inside it. The white ring always separates the centre from the sides. + renderer.fillRect(centerX - kCenterOutlineW, centerTileY - kCenterOutlineW, kCenterCoverMaxW + 2 * kCenterOutlineW, + kCenterCoverMaxH + 2 * kCenterOutlineW, false); + drawCover(centerIdx, centerX, centerTileY, kCenterCoverMaxW, kCenterCoverMaxH); + + // Dots — centred over the cover tile, count = actual book count + const int dotsY = centerTileY + kCenterCoverMaxH + 8; + const int totalDotsW = bookCount * kDotSize + (bookCount - 1) * kDotGap; + int dotX = centerX + (kCenterCoverMaxW - totalDotsW) / 2; + for (int i = 0; i < bookCount; ++i) { + if (i == centerIdx) + renderer.fillRect(dotX, dotsY, kDotSize, kDotSize, true); + else + renderer.drawRect(dotX, dotsY, kDotSize, kDotSize, true); + dotX += kDotSize + kDotGap; + } + + // Author then title below dots + const int authorY = dotsY + kDotSize + 6; + const std::string authorTrunc = + renderer.truncatedText(kTitleFontId, recentBooks[centerIdx].author.c_str(), kCenterCoverMaxW); + const int authorW = renderer.getTextWidth(kTitleFontId, authorTrunc.c_str()); + renderer.drawText(kTitleFontId, centerX + (kCenterCoverMaxW - authorW) / 2, authorY, authorTrunc.c_str(), true); + + const int titleY = authorY + renderer.getLineHeight(kTitleFontId) + 2; + const std::string titleTrunc = + renderer.truncatedText(kTitleFontId, recentBooks[centerIdx].title.c_str(), kCenterCoverMaxW); + const int titleW = renderer.getTextWidth(kTitleFontId, titleTrunc.c_str()); + renderer.drawText(kTitleFontId, centerX + (kCenterCoverMaxW - titleW) / 2, titleY, titleTrunc.c_str(), true); + + coverBufferStored = storeCoverBuffer(); + coverRendered = coverBufferStored; + } + + // Always outline the centre cover at its own edge (white ring sits outside the black line); + // thicker when the carousel row is active + const int outlineW = inCarouselRow ? kSelectionLineW : kThinOutlineW; + renderer.drawRoundedRect(centerX, centerTileY, kCenterCoverMaxW, kCenterCoverMaxH, outlineW, kCornerRadius, true); +} + +// --------------------------------------------------------------------------- +// Horizontal icon-only menu row — anchored to bottom of screen +// --------------------------------------------------------------------------- +void LyraCarouselTheme::drawButtonMenu(GfxRenderer& renderer, Rect rect, int buttonCount, int selectedIndex, + const std::function& buttonLabel, + const std::function& rowIcon) const { + if (buttonCount <= 0) return; + (void)buttonLabel; + + const int tileH = kMenuIconPad + kMenuIconSize + kMenuIconPad; + const int tileW = renderer.getScreenWidth() / buttonCount; + // Anchor row just above button hints, ignoring rect.y which may be off-screen + // for large cover tiles + const int rowY = renderer.getScreenHeight() - kButtonHintsH - tileH; + + for (int i = 0; i < buttonCount; ++i) { + const int tileX = i * tileW; + const int iconX = tileX + (tileW - kMenuIconSize) / 2; + const int iconY = rowY + kMenuIconPad; + + const bool selected = (selectedIndex == i); + if (selected) { + const int highlightSize = kMenuIconSize + 2 * kHighlightPad; + const int highlightY = rowY + (tileH - highlightSize) / 2; + renderer.fillRoundedRect(iconX - kHighlightPad, highlightY, highlightSize, highlightSize, kCornerRadius, + Color::Black); + } + + if (rowIcon != nullptr) { + const uint8_t* bmp = iconBitmapFor(rowIcon(i)); + if (bmp != nullptr) { + if (selected) + renderer.drawIconInverted(bmp, iconX, iconY, kMenuIconSize, kMenuIconSize); + else + renderer.drawIcon(bmp, iconX, iconY, kMenuIconSize, kMenuIconSize); + } + } + } +} + +// --------------------------------------------------------------------------- +// List — solid black highlight, inverted text and icons on selected row +// --------------------------------------------------------------------------- +void LyraCarouselTheme::drawList(const GfxRenderer& renderer, Rect rect, int itemCount, int selectedIndex, + const std::function& rowTitle, + const std::function& rowSubtitle, + const std::function& rowIcon, + const std::function& rowValue, bool highlightValue) const { + constexpr int hPad = 8; + constexpr int listIconSz = 24; + constexpr int mainMenuIconSz = 32; + constexpr int maxValWidth = 200; + constexpr int cornerRadius = 6; + + const int rowHeight = (rowSubtitle != nullptr) ? LyraCarouselMetrics::values.listWithSubtitleRowHeight + : LyraCarouselMetrics::values.listRowHeight; + const int pageItems = rect.height / rowHeight; + if (pageItems <= 0 || itemCount <= 0) return; + const int totalPages = (itemCount + pageItems - 1) / pageItems; + + if (totalPages > 1) { + const int scrollAreaHeight = rect.height; + 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 - LyraCarouselMetrics::values.scrollBarRightOffset; + renderer.drawLine(scrollBarX, rect.y, scrollBarX, rect.y + scrollAreaHeight, true); + renderer.fillRect(scrollBarX - LyraCarouselMetrics::values.scrollBarWidth, scrollBarY, + LyraCarouselMetrics::values.scrollBarWidth, scrollBarHeight, true); + } + + int contentWidth = + rect.width - + (totalPages > 1 ? (LyraCarouselMetrics::values.scrollBarWidth + LyraCarouselMetrics::values.scrollBarRightOffset) + : 1); + + // Solid black highlight bar + if (selectedIndex >= 0) { + renderer.fillRoundedRect( + rect.x + LyraCarouselMetrics::values.contentSidePadding, rect.y + selectedIndex % pageItems * rowHeight, + contentWidth - LyraCarouselMetrics::values.contentSidePadding * 2, rowHeight, kCornerRadius, Color::Black); + } + + int textX = rect.x + LyraCarouselMetrics::values.contentSidePadding + hPad; + int textWidth = contentWidth - LyraCarouselMetrics::values.contentSidePadding * 2 - hPad * 2; + int iconSize = 0; + if (rowIcon != nullptr) { + iconSize = (rowSubtitle != nullptr) ? mainMenuIconSz : listIconSz; + textX += iconSize + hPad; + textWidth -= iconSize + hPad; + } + + const auto pageStartIndex = selectedIndex / pageItems * pageItems; + const int iconY = (rowSubtitle != nullptr) ? 16 : 10; + for (int i = pageStartIndex; i < itemCount && i < pageStartIndex + pageItems; i++) { + const int itemY = rect.y + (i % pageItems) * rowHeight; + const bool sel = (i == selectedIndex); + int rowTextWidth = textWidth; + + int valueWidth = 0; + std::string valueText; + if (rowValue != nullptr) { + valueText = rowValue(i); + valueText = renderer.truncatedText(UI_10_FONT_ID, valueText.c_str(), maxValWidth); + valueWidth = renderer.getTextWidth(UI_10_FONT_ID, valueText.c_str()) + hPad; + rowTextWidth -= valueWidth; + } + + auto itemName = rowTitle(i); + auto item = renderer.truncatedText(UI_10_FONT_ID, itemName.c_str(), rowTextWidth); + renderer.drawText(UI_10_FONT_ID, textX, itemY + 7, item.c_str(), !sel); + + if (rowIcon != nullptr) { + const uint8_t* iconBitmap = iconForName(rowIcon(i), iconSize); + if (iconBitmap != nullptr) { + const int ix = rect.x + LyraCarouselMetrics::values.contentSidePadding + hPad; + if (sel) + renderer.drawIconInverted(iconBitmap, ix, itemY + iconY, iconSize, iconSize); + else + renderer.drawIcon(iconBitmap, ix, itemY + iconY, iconSize, iconSize); + } + } + + if (rowSubtitle != nullptr) { + std::string subtitleText = rowSubtitle(i); + auto subtitle = renderer.truncatedText(SMALL_FONT_ID, subtitleText.c_str(), rowTextWidth); + renderer.drawText(SMALL_FONT_ID, textX, itemY + 30, subtitle.c_str(), !sel); + } + + if (!valueText.empty()) { + if (sel && highlightValue) { + renderer.fillRoundedRect( + rect.x + contentWidth - LyraCarouselMetrics::values.contentSidePadding - hPad - valueWidth, itemY, + valueWidth + hPad, rowHeight, cornerRadius, Color::Black); + } + renderer.drawText(UI_10_FONT_ID, + rect.x + contentWidth - LyraCarouselMetrics::values.contentSidePadding - valueWidth, itemY + 6, + valueText.c_str(), !sel); + } + } +} + +// --------------------------------------------------------------------------- +// Tab bar — solid black background + solid black active tab, inverted text +// --------------------------------------------------------------------------- +void LyraCarouselTheme::drawTabBar(const GfxRenderer& renderer, Rect rect, const std::vector& tabs, + bool selected) const { + constexpr int hPad = 8; + int currentX = rect.x + LyraCarouselMetrics::values.contentSidePadding; + + 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 * hPad, rect.height - 4, kCornerRadius, + Color::Black); + } else { + renderer.drawRoundedRect(currentX, rect.y, textWidth + 2 * hPad, rect.height - 3, 1, kCornerRadius, true); + } + } + + renderer.drawText(UI_10_FONT_ID, currentX + hPad, rect.y + 6, tab.label, !(tab.selected && selected), + EpdFontFamily::REGULAR); + + currentX += textWidth + LyraCarouselMetrics::values.tabSpacing + 2 * hPad; + } + + renderer.drawLine(rect.x, rect.y + rect.height - 1, rect.x + rect.width - 1, rect.y + rect.height - 1, true); +} diff --git a/src/components/themes/lyra/LyraCarouselTheme.h b/src/components/themes/lyra/LyraCarouselTheme.h new file mode 100644 index 00000000..7cd60ce7 --- /dev/null +++ b/src/components/themes/lyra/LyraCarouselTheme.h @@ -0,0 +1,73 @@ + +#pragma once + +#include "components/themes/lyra/LyraTheme.h" + +class GfxRenderer; + +// Lyra Carousel theme metrics (zero runtime cost) +namespace LyraCarouselMetrics { +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 = 600, + .homeCoverTileHeight = 660, + .homeRecentBooksCount = 5, + .homeContinueReadingInMenu = false, + .homeMenuTopOffset = 16, + .buttonHintsHeight = 40, + .sideButtonHintsWidth = 30, + .progressBarHeight = 16, + .progressBarMarginTop = 1, + .statusBarHorizontalMargin = 5, + .statusBarVerticalMargin = 19, + .keyboardKeyWidth = 31, + .keyboardKeyHeight = 50, + .keyboardKeySpacing = 0, + .keyboardBottomKeyHeight = 35, + .keyboardBottomKeySpacing = 5, + .keyboardBottomAligned = true, + .keyboardCenteredText = true, + .keyboardVerticalOffset = -7, + .keyboardTextFieldWidthPercent = 85, + .keyboardWidthPercent = 90, + .keyboardKeyCornerRadius = 6}; +} + +class LyraCarouselTheme : public LyraTheme { + public: + // Exact pixel dimensions for each carousel slot — used for exact-size thumbnail generation + static constexpr int kCenterCoverW = 340; + static constexpr int kCenterCoverH = LyraCarouselMetrics::values.homeCoverHeight - 60; // 540 + static constexpr int kSideCoverW = 200; + static constexpr int kSideCoverH = LyraCarouselMetrics::values.homeCoverHeight - 210; // 390 + + static void setPreRenderIndex(int idx); + void drawRecentBookCover(GfxRenderer& renderer, Rect rect, const std::vector& recentBooks, + const int selectorIndex, bool& coverRendered, bool& coverBufferStored, bool& bufferRestored, + std::function storeCoverBuffer) const override; + void drawButtonMenu(GfxRenderer& renderer, Rect rect, int buttonCount, int selectedIndex, + const std::function& buttonLabel, + const std::function& rowIcon) const override; + void drawCarouselBorder(GfxRenderer& renderer, Rect coverRect, bool inCarouselRow) const override; + void drawList(const GfxRenderer& renderer, Rect rect, int itemCount, int selectedIndex, + const std::function& rowTitle, + const std::function& rowSubtitle, + const std::function& rowIcon, const std::function& rowValue, + bool highlightValue) const override; + void drawTabBar(const GfxRenderer& renderer, Rect rect, const std::vector& tabs, + bool selected) const override; +}; diff --git a/src/components/themes/lyra/LyraTheme.cpp b/src/components/themes/lyra/LyraTheme.cpp index 347a1589..9ee2f037 100644 --- a/src/components/themes/lyra/LyraTheme.cpp +++ b/src/components/themes/lyra/LyraTheme.cpp @@ -81,7 +81,9 @@ void drawLyraBatteryIcon(const GfxRenderer& renderer, int x, int y, int battWidt } } -const uint8_t* iconForName(UIIcon icon, int size) { +} // namespace + +const uint8_t* LyraTheme::iconForName(UIIcon icon, int size) { if (size == 24) { switch (icon) { case UIIcon::Folder: @@ -125,7 +127,6 @@ const uint8_t* iconForName(UIIcon icon, int size) { } return nullptr; } -} // namespace // Reads the overall progress percent stored as the last byte of progress.bin. // The cache path is derived from the book path alone (no epub/xtc/txt loading needed). diff --git a/src/components/themes/lyra/LyraTheme.h b/src/components/themes/lyra/LyraTheme.h index 31de32e2..758ad84b 100644 --- a/src/components/themes/lyra/LyraTheme.h +++ b/src/components/themes/lyra/LyraTheme.h @@ -81,4 +81,5 @@ class LyraTheme : public BaseTheme { protected: static int getRecentBookProgressPercent(const RecentBook& book); static void drawProgressBadge(const GfxRenderer& renderer, Rect anchorRect, int progressPercent); + static const uint8_t* iconForName(UIIcon icon, int size); };