diff --git a/lib/Epub/Epub.cpp b/lib/Epub/Epub.cpp index d3299836..f98b02eb 100644 --- a/lib/Epub/Epub.cpp +++ b/lib/Epub/Epub.cpp @@ -810,6 +810,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 @@ -902,6 +905,79 @@ 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 { + const auto coverTempPath = getCachePath() + "/.cover"; + FsFile coverTemp; + if (!Storage.openFileForWrite("EBP", coverTempPath, coverTemp)) return false; + if (!readItemContentsToStream(coverImageHref, coverTemp, 1024)) { + coverTemp.close(); + Storage.remove(coverTempPath.c_str()); + return false; + } + coverTemp.close(); + + if (!Storage.openFileForRead("EBP", coverTempPath, coverTemp)) { + Storage.remove(coverTempPath.c_str()); + return false; + } + + const auto detectedFormat = detectCoverImageFormat(coverTemp); + bool success = false; + FsFile thumbBmp; + if (!Storage.openFileForWrite("EBP", getThumbBmpPath(width, height), thumbBmp)) { + coverTemp.close(); + Storage.remove(coverTempPath.c_str()); + return false; + } + + if (detectedFormat == CoverImageFormat::Jpeg) { + LOG_DBG("EBP", "Generating %dx%d thumb BMP from JPEG cover image", width, height); + success = JpegToBmpConverter::jpegFileTo1BitBmpStreamWithSize(coverTemp, thumbBmp, width, height); + } else if (detectedFormat == CoverImageFormat::Png) { + LOG_DBG("EBP", "Generating %dx%d thumb BMP from PNG cover image", width, height); + success = PngToBmpConverter::pngFileTo1BitBmpStreamWithSize(coverTemp, thumbBmp, width, height); + } else { + LOG_DBG("EBP", "Cover image format unknown, attempting JPEG then PNG: %s", coverImageHref.c_str()); + success = JpegToBmpConverter::jpegFileTo1BitBmpStreamWithSize(coverTemp, thumbBmp, width, height); + if (!success) { + thumbBmp.close(); + Storage.remove(getThumbBmpPath(width, height).c_str()); + if (!Storage.openFileForWrite("EBP", getThumbBmpPath(width, height), thumbBmp)) { + coverTemp.close(); + Storage.remove(coverTempPath.c_str()); + return false; + } + coverTemp.seek(0); + success = PngToBmpConverter::pngFileTo1BitBmpStreamWithSize(coverTemp, thumbBmp, width, height); + } + } + + coverTemp.close(); + thumbBmp.close(); + Storage.remove(coverTempPath.c_str()); + if (!success) Storage.remove(getThumbBmpPath(width, height).c_str()); + LOG_DBG("EBP", "Generated %dx%d thumb BMP from cover image, success: %s", width, height, success ? "yes" : "no"); + return success; + } + + // 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..5697fc57 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; @@ -1495,8 +1550,6 @@ void GfxRenderer::drawBitmap(const Bitmap& bitmap, const int x, const int y, con bool isScaled = false; int cropPixX = std::floor(bitmap.getWidth() * cropX / 2.0f); int cropPixY = std::floor(bitmap.getHeight() * cropY / 2.0f); - LOG_DBG("GFX", "Cropping %dx%d by %dx%d pix, is %s", bitmap.getWidth(), bitmap.getHeight(), cropPixX, cropPixY, - bitmap.isTopDown() ? "top-down" : "bottom-up"); const float croppedWidth = (1.0f - cropX) * static_cast(bitmap.getWidth()); const float croppedHeight = (1.0f - cropY) * static_cast(bitmap.getHeight()); @@ -1518,7 +1571,6 @@ void GfxRenderer::drawBitmap(const Bitmap& bitmap, const int x, const int y, con scale = fitScale; isScaled = true; } - LOG_DBG("GFX", "Scaling by %f - %s", scale, isScaled ? "scaled" : "not scaled"); // Calculate output row size (2 bits per pixel, packed into bytes) // IMPORTANT: Use int, not uint8_t, to avoid overflow for images > 1020 pixels wide @@ -1595,13 +1647,19 @@ void GfxRenderer::drawBitmap1Bit(const Bitmap& bitmap, const int x, const int y, const int maxHeight) const { float scale = 1.0f; bool isScaled = false; - if (maxWidth > 0 && bitmap.getWidth() > maxWidth) { - scale = static_cast(maxWidth) / static_cast(bitmap.getWidth()); - isScaled = true; + if (maxWidth > 0) { + const float s = static_cast(maxWidth) / static_cast(bitmap.getWidth()); + if (s != 1.0f) { + scale = s; + isScaled = true; + } } - if (maxHeight > 0 && bitmap.getHeight() > maxHeight) { - scale = std::min(scale, static_cast(maxHeight) / static_cast(bitmap.getHeight())); - isScaled = true; + if (maxHeight > 0) { + const float s = static_cast(maxHeight) / static_cast(bitmap.getHeight()); + if (s < scale || (scale == 1.0f && s != 1.0f)) { + scale = s; + isScaled = (scale != 1.0f); + } } // For 1-bit BMP, output is still 2-bit packed (for consistency with readNextRow) 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/I18n/translations/english.yaml b/lib/I18n/translations/english.yaml index ba744332..0dbe665e 100644 --- a/lib/I18n/translations/english.yaml +++ b/lib/I18n/translations/english.yaml @@ -326,6 +326,7 @@ STR_UI_THEME: "UI Theme" STR_THEME_CLASSIC: "Classic" STR_THEME_LYRA: "Lyra" STR_THEME_LYRA_EXTENDED: "Lyra Extended" +STR_THEME_LYRA_CAROUSEL: "Lyra Carousel" STR_SUNLIGHT_FADING_FIX: "Sunlight Fading Fix" STR_REMAP_FRONT_BUTTONS: "Remap Front Buttons" STR_OPDS_BROWSER: "OPDS Browser" diff --git a/lib/Xtc/Xtc.cpp b/lib/Xtc/Xtc.cpp index a1f8f8a6..5ccf5bf2 100644 --- a/lib/Xtc/Xtc.cpp +++ b/lib/Xtc/Xtc.cpp @@ -262,62 +262,63 @@ 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; - } + const std::string destPath = getThumbBmpPath(height); + if (Storage.exists(destPath.c_str())) return true; + const int width = static_cast(height * 0.6f); + if (!generateThumbBmp(width, height)) return false; + const std::string srcPath = getThumbBmpPath(width, height); + Storage.rename(srcPath.c_str(), destPath.c_str()); + return Storage.exists(destPath.c_str()); +} + +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 +326,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 +338,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 +345,27 @@ bool Xtc::generateThumbBmp(int height) const { return false; } - // Create thumbnail BMP file - use 1-bit format for fast home screen rendering (no gray passes) + const std::string thumbPath = getThumbBmpPath(width, height); FsFile thumbBmp; - if (!Storage.openFileForWrite("XTC", getThumbBmpPath(height), thumbBmp)) { - LOG_DBG("XTC", "Failed to create thumb BMP file"); + if (!Storage.openFileForWrite("XTC", thumbPath, 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(); + Storage.remove(thumbPath.c_str()); 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 +373,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 +382,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 +389,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 68e091f7..a3de775b 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 ab6e22f2..495ebfd7 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..f8c97d97 100644 --- a/src/activities/home/HomeActivity.cpp +++ b/src/activities/home/HomeActivity.cpp @@ -6,11 +6,15 @@ #include #include #include +#include +#include +#include #include #include #include #include +#include #include #include "CrossPointSettings.h" @@ -19,10 +23,43 @@ #include "MappedInputManager.h" #include "OpdsServerStore.h" #include "RecentBooksStore.h" +#include "activities/reader/ReaderActivity.h" #include "components/UITheme.h" #include "fontIds.h" namespace { +// Convert a sidecar JPG/PNG cover to a 1-bit BMP in the cache and return the BMP path, or "" on failure. +// fileName is the basename of the output file (without directory), e.g. "340x540.bmp" or "400.bmp". +std::string convertSidecarToBmp(const std::string& bookPath, const std::string& sidecarPath, int width, int height, + const std::string& fileName) { + const std::string cacheDir = "/.crosspoint/sidecar_" + std::to_string(std::hash{}(bookPath)); + Storage.mkdir(cacheDir.c_str()); + const std::string bmpPath = cacheDir + "/" + fileName; + if (Storage.exists(bmpPath.c_str())) return bmpPath; + + FsFile src; + if (!Storage.openFileForRead("HOME", sidecarPath, src)) return ""; + FsFile dst; + if (!Storage.openFileForWrite("HOME", bmpPath, dst)) { + src.close(); + return ""; + } + + bool ok = false; + if (FsHelpers::hasJpgExtension(sidecarPath)) { + ok = JpegToBmpConverter::jpegFileTo1BitBmpStreamWithSize(src, dst, width, height); + } else if (FsHelpers::hasPngExtension(sidecarPath)) { + ok = PngToBmpConverter::pngFileTo1BitBmpStreamWithSize(src, dst, width, height); + } + src.close(); + dst.close(); + if (!ok) { + Storage.remove(bmpPath.c_str()); + return ""; + } + return bmpPath; +} + 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; @@ -139,6 +176,24 @@ void HomeActivity::loadRecentBooks(int maxBooks) { continue; } + // Check for a sidecar cover — takes priority over embedded cover. + // Also catches books registered before sidecar support (empty coverBmpPath). + const std::string sidecar = ReaderActivity::sidecarCoverPath(book.path); + if (!sidecar.empty()) { + const bool sidecarAlreadyStored = + book.coverBmpPath == sidecar || book.coverBmpPath.find("sidecar_") != std::string::npos; + LOG_DBG("HOME", "Sidecar for %s: stored=%s alreadyStored=%d", book.path.c_str(), book.coverBmpPath.c_str(), + sidecarAlreadyStored ? 1 : 0); + if (!sidecarAlreadyStored) { + LOG_DBG("HOME", "Updating coverBmpPath to sidecar: %s", sidecar.c_str()); + RECENT_BOOKS.updateBook(book.path, book.title, book.author, book.series, sidecar); + RecentBook updated = book; + updated.coverBmpPath = sidecar; + recentBooks.push_back(updated); + continue; + } + } + recentBooks.push_back(book); } } @@ -146,34 +201,90 @@ void HomeActivity::loadRecentBooks(int maxBooks) { void HomeActivity::loadRecentCovers(int coverHeight) { recentsLoading = true; + const auto thumbSizes = GUI.getCoverThumbSizes(coverHeight); + for (; nextRecentCoverIndex < recentBooks.size(); nextRecentCoverIndex++) { RecentBook& book = recentBooks[nextRecentCoverIndex]; if (!book.coverBmpPath.empty()) { - std::string coverPath = UITheme::getCoverThumbPath(book.coverBmpPath, coverHeight); - if (!Storage.exists(coverPath.c_str())) { - // If epub, try to load the metadata for title/author and cover - if (FsHelpers::hasEpubExtension(book.path)) { - Epub epub(book.path, "/.crosspoint"); - // Skip loading css since we only need metadata here - epub.load(false, true); + // Sidecar covers (JPG/PNG paths stored directly) must be converted to BMP thumbnails + // and the stored coverBmpPath updated to the cache path with [WIDTH]x[HEIGHT] placeholder. + const bool isSidecar = + FsHelpers::hasJpgExtension(book.coverBmpPath) || FsHelpers::hasPngExtension(book.coverBmpPath); + if (isSidecar) { + LOG_DBG("HOME", "Converting sidecar %s for book %s", book.coverBmpPath.c_str(), book.path.c_str()); + if (!Storage.exists(book.coverBmpPath.c_str())) { + LOG_ERR("HOME", "Sidecar file missing: %s", book.coverBmpPath.c_str()); + RECENT_BOOKS.updateBook(book.path, book.title, book.author, book.series, ""); + book.coverBmpPath = ""; + } else { + // Free the carousel frame cache before converting — PNG/JPEG decode needs ~42 KB + // contiguous heap, which won't be available while the 48 KB frame buffer is held. + // The cache will be rebuilt on the next render. + UITheme::getInstance().getMutableTheme().invalidateFrameCache(); - // Try to generate thumbnail image for Continue Reading card - bool success = epub.generateThumbBmp(coverHeight); - if (!success) { - RECENT_BOOKS.updateBook(book.path, book.title, book.author, book.series, ""); - book.coverBmpPath = ""; + const std::string cacheBase = "/.crosspoint/sidecar_" + std::to_string(std::hash{}(book.path)); + const std::string placeholder = cacheBase + "/[HEIGHT].bmp"; + bool success = true; + if (!thumbSizes.empty()) { + for (const auto& sz : thumbSizes) { + const std::string name = std::to_string(sz.first) + "x" + std::to_string(sz.second) + ".bmp"; + if (convertSidecarToBmp(book.path, book.coverBmpPath, sz.first, sz.second, name).empty()) { + success = false; + break; + } + } + } else { + const int w = coverHeight * 6 / 10; + const std::string name = std::to_string(coverHeight) + ".bmp"; + if (convertSidecarToBmp(book.path, book.coverBmpPath, w, coverHeight, name).empty()) success = false; + } + if (success) { + LOG_DBG("HOME", "Sidecar converted, placeholder: %s", placeholder.c_str()); + RECENT_BOOKS.updateBook(book.path, book.title, book.author, book.series, placeholder); + book.coverBmpPath = placeholder; + } else { + LOG_ERR("HOME", "Failed to convert sidecar cover for %s", book.path.c_str()); + // Don't permanently clear the path on failure — keep the raw sidecar path + // so the next home visit can retry (e.g. after more memory becomes available). } coverRendered = false; nextRecentCoverIndex++; recentsLoading = false; requestUpdate(); return; - } else if (FsHelpers::hasXtcExtension(book.path)) { - // Handle XTC file - Xtc xtc(book.path, "/.crosspoint"); - if (xtc.load()) { - // Try to generate thumbnail image for Continue Reading card - bool success = xtc.generateThumbBmp(coverHeight); + } + } + + if (!book.coverBmpPath.empty()) { + if (!thumbSizes.empty()) { + // Theme uses WxH thumbnails — check which are missing and generate + bool anyMissing = false; + for (const auto& sz : thumbSizes) { + const std::string path = UITheme::getCoverThumbPath(book.coverBmpPath, sz.first, sz.second); + if (!Storage.exists(path.c_str())) { + anyMissing = true; + break; + } + } + + if (anyMissing) { + bool success = true; + if (FsHelpers::hasEpubExtension(book.path)) { + Epub epub(book.path, "/.crosspoint"); + epub.load(false, true); + for (const auto& sz : thumbSizes) { + const std::string path = UITheme::getCoverThumbPath(book.coverBmpPath, sz.first, sz.second); + if (!Storage.exists(path.c_str())) success = epub.generateThumbBmp(sz.first, sz.second) && success; + } + } else if (FsHelpers::hasXtcExtension(book.path)) { + Xtc xtc(book.path, "/.crosspoint"); + if (xtc.load()) { + for (const auto& sz : thumbSizes) { + const std::string path = UITheme::getCoverThumbPath(book.coverBmpPath, sz.first, sz.second); + if (!Storage.exists(path.c_str())) success = xtc.generateThumbBmp(sz.first, sz.second) && success; + } + } + } if (!success) { RECENT_BOOKS.updateBook(book.path, book.title, book.author, book.series, ""); book.coverBmpPath = ""; @@ -184,8 +295,40 @@ void HomeActivity::loadRecentCovers(int coverHeight) { requestUpdate(); return; } + } else { + std::string coverPath = UITheme::getCoverThumbPath(book.coverBmpPath, coverHeight); + if (!Storage.exists(coverPath.c_str())) { + if (FsHelpers::hasEpubExtension(book.path)) { + Epub epub(book.path, "/.crosspoint"); + epub.load(false, true); + bool success = epub.generateThumbBmp(coverHeight); + if (!success) { + RECENT_BOOKS.updateBook(book.path, book.title, book.author, book.series, ""); + book.coverBmpPath = ""; + } + coverRendered = false; + nextRecentCoverIndex++; + recentsLoading = false; + requestUpdate(); + return; + } else if (FsHelpers::hasXtcExtension(book.path)) { + Xtc xtc(book.path, "/.crosspoint"); + if (xtc.load()) { + bool success = xtc.generateThumbBmp(coverHeight); + if (!success) { + RECENT_BOOKS.updateBook(book.path, book.title, book.author, book.series, ""); + book.coverBmpPath = ""; + } + coverRendered = false; + nextRecentCoverIndex++; + recentsLoading = false; + requestUpdate(); + return; + } + } + } } - } + } // if (!book.coverBmpPath.empty()) after sidecar check } } @@ -241,9 +384,8 @@ void HomeActivity::onEnter() { void HomeActivity::onExit() { Activity::onExit(); - - // Free the stored cover buffer if any freeCoverBuffer(); + UITheme::getInstance().getMutableTheme().invalidateFrameCache(); } bool HomeActivity::storeCoverBuffer() { @@ -292,27 +434,70 @@ 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 = (GUI.getHomeNavigation() == HomeNavigation::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) { @@ -330,21 +515,43 @@ void HomeActivity::render(RenderLock&&) { const auto& metrics = UITheme::getInstance().getMetrics(); const Rect contentRect = UITheme::getContentRect(renderer, true, false); + if (menuEntriesDirty) { + rebuildMenuEntries(); + } + + const int menuCount = static_cast(menuEntries.size()); + const bool isCarousel = (GUI.getHomeNavigation() == HomeNavigation::Carousel); + + // Fast path: theme owns its own pre-rendered frame cache + if (isCarousel) { + const auto carouselLabels = mappedInput.mapLabels("", tr(STR_SELECT), tr(STR_DIR_LEFT), tr(STR_DIR_RIGHT)); + const bool handled = GUI.tryFastHomeRender( + renderer, recentBooks, selectorIndex, menuCount, + [this](int index) { return std::string(I18N.get(menuEntries[index].label)); }, + [this](int index) { return menuEntries[index].icon; }, carouselLabels.btn1, carouselLabels.btn2, + carouselLabels.btn3, carouselLabels.btn4); + if (handled) { + if (!firstRenderDone) { + firstRenderDone = true; + requestUpdate(); + } else if (!recentsLoaded && !recentsLoading) { + recentsLoading = true; + loadRecentCovers(metrics.homeCoverHeight); + } + return; + } + } + renderer.clearScreen(); bool bufferRestored = coverBufferStored && restoreCoverBuffer(); GUI.drawHeader(renderer, Rect{contentRect.x, metrics.topPadding, contentRect.width, metrics.homeTopPadding}, nullptr); - if (menuEntriesDirty) { - rebuildMenuEntries(); - } - const int totalItems = static_cast(recentBooks.size() + menuEntries.size()); if (selectorIndex >= totalItems) { selectorIndex = std::max(0, totalItems - 1); } - const int menuCount = static_cast(menuEntries.size()); const HomeScreenLayout layout = computeHomeScreenLayout(metrics, contentRect.height, menuCount); GUI.drawRecentBookCover(renderer, @@ -360,7 +567,8 @@ void HomeActivity::render(RenderLock&&) { [this](int index) { return std::string(I18N.get(menuEntries[index].label)); }, [this](int index) { return menuEntries[index].icon; }); - const auto labels = mappedInput.mapLabels("", tr(STR_SELECT), tr(STR_DIR_UP), tr(STR_DIR_DOWN)); + const auto labels = isCarousel ? mappedInput.mapLabels("", tr(STR_SELECT), tr(STR_DIR_LEFT), tr(STR_DIR_RIGHT)) + : mappedInput.mapLabels("", tr(STR_SELECT), tr(STR_DIR_UP), tr(STR_DIR_DOWN)); GUI.drawButtonHints(renderer, labels.btn1, labels.btn2, labels.btn3, labels.btn4); renderer.displayBuffer(); @@ -368,6 +576,9 @@ void HomeActivity::render(RenderLock&&) { if (!firstRenderDone) { firstRenderDone = true; requestUpdate(); + } else if (!recentsLoaded && !recentsLoading) { + recentsLoading = true; + loadRecentCovers(getHomeCoverRenderHeight(computeHomeScreenLayout(metrics, contentRect.height, menuCount))); } } diff --git a/src/activities/home/HomeActivity.h b/src/activities/home/HomeActivity.h index c41d1506..a161d4ec 100644 --- a/src/activities/home/HomeActivity.h +++ b/src/activities/home/HomeActivity.h @@ -32,20 +32,22 @@ 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; bool hasOpdsServers = false; - bool coverRendered = false; // Track if cover has been rendered once - bool coverBufferStored = false; // Track if cover buffer is stored + bool coverRendered = false; + bool coverBufferStored = false; size_t nextRecentCoverIndex = 0; - uint8_t* coverBuffer = nullptr; // HomeActivity's own buffer for cover image + uint8_t* coverBuffer = nullptr; + std::vector recentBooks; std::vector menuEntries; bool menuEntriesDirty = true; - std::string focusBookPath; // book path to re-select on first render, if present in recents - int focusSelectorIndex = -1; // fallback combined-selector index when focusBookPath doesn't match + std::string focusBookPath; + int focusSelectorIndex = -1; void onSelectBook(const std::string& path); void dispatchMenuAction(MenuAction action); diff --git a/src/activities/reader/EpubReaderActivity.cpp b/src/activities/reader/EpubReaderActivity.cpp index 5ed20603..4fec8af5 100644 --- a/src/activities/reader/EpubReaderActivity.cpp +++ b/src/activities/reader/EpubReaderActivity.cpp @@ -29,6 +29,7 @@ #include "KOReaderCredentialStore.h" #include "MappedInputManager.h" #include "QrDisplayActivity.h" +#include "ReaderActivity.h" #include "ReaderUtils.h" #include "RecentBooksStore.h" #include "SdCardFontGlobals.h" @@ -207,7 +208,9 @@ void EpubReaderActivity::onEnter() { if (!series.empty() && !epub->getSeriesIndex().empty()) { series += " #" + epub->getSeriesIndex(); } - RECENT_BOOKS.addBook(epub->getPath(), epub->getTitle(), epub->getAuthor(), series, epub->getThumbBmpPath()); + const std::string epubSidecar = ReaderActivity::sidecarCoverPath(epub->getPath()); + const std::string epubCover = epubSidecar.empty() ? epub->getThumbBmpPath() : epubSidecar; + RECENT_BOOKS.addBook(epub->getPath(), epub->getTitle(), epub->getAuthor(), series, epubCover); const RecentBook currentBook = RECENT_BOOKS.getBookByPath(epub->getPath()); bookEmbeddedStyleOverride = currentBook.embeddedStyleOverride; bookImageRenderingOverride = currentBook.imageRenderingOverride; @@ -241,6 +244,7 @@ void EpubReaderActivity::onExit() { APP_STATE.readerActivityLoadCount = 0; APP_STATE.saveToFile(); section.reset(); + UITheme::getInstance().getMutableTheme().onBookWillClose(epub ? epub->getPath() : "", epub.get(), nullptr, nullptr); epub.reset(); currentPageFootnotes.clear(); currentPageFootnotes.shrink_to_fit(); diff --git a/src/activities/reader/MdReaderActivity.cpp b/src/activities/reader/MdReaderActivity.cpp index 3aa172be..81118c93 100644 --- a/src/activities/reader/MdReaderActivity.cpp +++ b/src/activities/reader/MdReaderActivity.cpp @@ -15,6 +15,7 @@ #include "CrossPointState.h" #include "MappedInputManager.h" #include "MdReaderTocSelectionActivity.h" +#include "ReaderActivity.h" #include "ReaderUtils.h" #include "RecentBooksStore.h" #include "components/UITheme.h" @@ -52,7 +53,7 @@ void MdReaderActivity::onEnter() { auto fileName = filePath.substr(filePath.rfind('/') + 1); APP_STATE.openEpubPath = filePath; APP_STATE.saveToFile(); - RECENT_BOOKS.addBook(filePath, fileName, "", "", ""); + RECENT_BOOKS.addBook(filePath, fileName, "", "", ReaderActivity::sidecarCoverPath(filePath)); requestUpdate(); } @@ -219,6 +220,7 @@ void MdReaderActivity::onExit() { currentPageLines.clear(); APP_STATE.readerActivityLoadCount = 0; APP_STATE.saveToFile(); + UITheme::getInstance().getMutableTheme().onBookWillClose(txt ? txt->getPath() : "", nullptr, nullptr, txt.get()); txt.reset(); } diff --git a/src/activities/reader/ReaderActivity.cpp b/src/activities/reader/ReaderActivity.cpp index 0a7a045e..0d12c201 100644 --- a/src/activities/reader/ReaderActivity.cpp +++ b/src/activities/reader/ReaderActivity.cpp @@ -56,6 +56,21 @@ bool ReaderActivity::isImageFile(const std::string& path) { return FsHelpers::hasBmpExtension(path) || FsHelpers::hasJpgExtension(path) || FsHelpers::hasPngExtension(path); } +std::string ReaderActivity::sidecarCoverPath(const std::string& bookPath) { + const auto sep = bookPath.find_last_of("/\\"); + const auto dot = bookPath.rfind('.'); + if (dot == std::string::npos || (sep != std::string::npos && dot < sep)) return ""; + const std::string base = bookPath.substr(0, dot); + for (const char* ext : {".jpg", ".jpeg", ".png", ".bmp"}) { + const std::string candidate = base + ext; + if (Storage.exists(candidate.c_str())) { + LOG_DBG("SIDECAR", "Found sidecar cover: %s", candidate.c_str()); + return candidate; + } + } + return ""; +} + std::unique_ptr ReaderActivity::loadEpub(const std::string& path) { if (!Storage.exists(path.c_str())) { LOG_ERR("READER", "File does not exist: %s", path.c_str()); diff --git a/src/activities/reader/ReaderActivity.h b/src/activities/reader/ReaderActivity.h index 0292901f..bc24c94a 100644 --- a/src/activities/reader/ReaderActivity.h +++ b/src/activities/reader/ReaderActivity.h @@ -30,6 +30,8 @@ class ReaderActivity final : public Activity { void onGoBack(); public: + static std::string sidecarCoverPath(const std::string& bookPath); + explicit ReaderActivity(GfxRenderer& renderer, MappedInputManager& mappedInput, std::string initialBookPath) : Activity("Reader", renderer, mappedInput), initialBookPath(std::move(initialBookPath)) {} void onEnter() override; diff --git a/src/activities/reader/TxtReaderActivity.cpp b/src/activities/reader/TxtReaderActivity.cpp index 0222a8cc..de670db3 100644 --- a/src/activities/reader/TxtReaderActivity.cpp +++ b/src/activities/reader/TxtReaderActivity.cpp @@ -13,6 +13,7 @@ #include "CrossPointState.h" #include "GlobalBookmarkIndex.h" #include "MappedInputManager.h" +#include "ReaderActivity.h" #include "ReaderUtils.h" #include "RecentBooksStore.h" #include "StarredPagesActivity.h" @@ -112,7 +113,7 @@ void TxtReaderActivity::onEnter() { auto fileName = filePath.substr(filePath.rfind('/') + 1); APP_STATE.openEpubPath = filePath; APP_STATE.saveToFile(); - RECENT_BOOKS.addBook(filePath, fileName, "", "", ""); + RECENT_BOOKS.addBook(filePath, fileName, "", "", ReaderActivity::sidecarCoverPath(filePath)); // Trigger first update requestUpdate(); @@ -134,6 +135,7 @@ void TxtReaderActivity::onExit() { currentPageLines.clear(); APP_STATE.readerActivityLoadCount = 0; APP_STATE.saveToFile(); + UITheme::getInstance().getMutableTheme().onBookWillClose(txt ? txt->getPath() : "", nullptr, nullptr, txt.get()); txt.reset(); } diff --git a/src/activities/reader/XtcReaderActivity.cpp b/src/activities/reader/XtcReaderActivity.cpp index f384339b..43795b3e 100644 --- a/src/activities/reader/XtcReaderActivity.cpp +++ b/src/activities/reader/XtcReaderActivity.cpp @@ -17,9 +17,11 @@ #include "CrossPointSettings.h" #include "CrossPointState.h" #include "MappedInputManager.h" +#include "ReaderActivity.h" #include "ReaderUtils.h" #include "RecentBooksStore.h" #include "XtcReaderChapterSelectionActivity.h" +#include "components/UITheme.h" #include "fontIds.h" void XtcReaderActivity::onEnter() { @@ -41,7 +43,9 @@ 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(), "", xtc->getThumbBmpPath()); + const std::string xtcSidecar = ReaderActivity::sidecarCoverPath(xtc->getPath()); + const std::string xtcCover = xtcSidecar.empty() ? xtc->getThumbBmpPath() : xtcSidecar; + RECENT_BOOKS.addBook(xtc->getPath(), xtc->getTitle(), xtc->getAuthor(), "", xtcCover); // Trigger first update requestUpdate(); @@ -52,6 +56,8 @@ void XtcReaderActivity::onExit() { APP_STATE.readerActivityLoadCount = 0; APP_STATE.saveToFile(); + + UITheme::getInstance().getMutableTheme().onBookWillClose(xtc ? xtc->getPath() : "", nullptr, xtc.get(), nullptr); xtc.reset(); } 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..10b42635 100644 --- a/src/components/UITheme.h +++ b/src/components/UITheme.h @@ -18,6 +18,7 @@ class UITheme { const ThemeMetrics& getMetrics() const { return *currentMetrics; } const BaseTheme& getTheme() const { return *currentTheme; } + BaseTheme& getMutableTheme() { return *currentTheme; } void reload(); void setTheme(CrossPointSettings::UI_THEME type); static int getNumberOfItemsPerPage(const GfxRenderer& renderer, bool hasHeader, bool hasTabBar, bool hasButtonHints, @@ -60,6 +61,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..76415592 100644 --- a/src/components/themes/BaseTheme.h +++ b/src/components/themes/BaseTheme.h @@ -4,9 +4,13 @@ #include #include #include +#include #include class GfxRenderer; +class Epub; +class Txt; +class Xtc; struct RecentBook; struct Rect { @@ -48,6 +52,8 @@ struct ThemeMetrics { int homeCoverHeight; int homeCoverTileHeight; int homeRecentBooksCount; + bool homeContinueReadingInMenu = false; + int homeMenuTopOffset = 0; int buttonHintsHeight; int sideButtonHintsWidth; @@ -67,12 +73,15 @@ struct ThemeMetrics { int keyboardVerticalOffset; int keyboardTextFieldWidthPercent; int keyboardWidthPercent; + int keyboardKeyCornerRadius = 0; }; enum UIIcon { Folder, Text, Image, Book, File, Recent, Settings, Transfer, Library, Wifi, Hotspot, Weather }; enum class KeyboardKeyType { Normal, Shift, Mode, Reveal, Space, Del, Ok, Disabled }; +enum class HomeNavigation { Linear, Carousel }; + // Default theme implementation (Classic Theme) // Additional themes can inherit from this and override methods as needed @@ -160,7 +169,35 @@ class BaseTheme { bool inactiveSelection = false) const; virtual bool showsFileIcons() const { return false; } - // Shared constants and helpers for battery drawing (used by all themes) + // ---- Home screen navigation / rendering contract ---- + + // Return Carousel to opt in to left/right book navigation and tryFastHomeRender(). + virtual HomeNavigation getHomeNavigation() const { return HomeNavigation::Linear; } + + // Return the cover thumbnail sizes this theme needs for its home screen. + // HomeActivity calls generateThumbBmp() for each pair. Empty = use height-only path. + virtual std::vector> getCoverThumbSizes(int coverHeight) const { return {}; } + + // Attempt a full home-screen render from pre-cached state. + // Return true if the theme handled the render (HomeActivity must not draw anything else). + // Return false to fall through to the standard slow-path render in HomeActivity. + virtual bool tryFastHomeRender(GfxRenderer& renderer, const std::vector& recentBooks, int selectorIndex, + int menuCount, const std::function& menuLabel, + const std::function& menuIcon, const char* hintBtn1, const char* hintBtn2, + const char* hintBtn3, const char* hintBtn4) const { + return false; + } + + // Called by readers just before releasing the book object. Themes that cache + // cover thumbnails can generate them here while the book is still loaded. + // Only one of epub/xtc/txt will be non-null depending on the reader. + virtual void onBookWillClose(const std::string& path, Epub* epub, Xtc* xtc, Txt* txt) {} + + // Called when HomeActivity exits. Themes that hold heap-allocated render caches + // should free them here so the memory is available to child activities. + virtual void invalidateFrameCache() {} + + // ---- Shared constants and helpers for battery drawing (used by all themes) ---- static constexpr int batteryPercentSpacing = 4; static void drawBatteryOutline(const GfxRenderer& renderer, int x, int y, int battWidth, int rectHeight); static void drawBatteryLightningBolt(const GfxRenderer& renderer, int boltX, int boltY); diff --git a/src/components/themes/lyra/Lyra3CoversTheme.cpp b/src/components/themes/lyra/Lyra3CoversTheme.cpp index 5e8671bf..06f5f230 100644 --- a/src/components/themes/lyra/Lyra3CoversTheme.cpp +++ b/src/components/themes/lyra/Lyra3CoversTheme.cpp @@ -52,7 +52,7 @@ void Lyra3CoversTheme::drawRecentBookCover(GfxRenderer& renderer, Rect rect, con const float ratio = bitmapWidth / bitmapHeight; const float tileRatio = static_cast(tileWidth - 2 * hPaddingInSelection) / static_cast(coverHeight); - const float cropX = 1.0f - (tileRatio / ratio); + const float cropX = std::max(0.0f, 1.0f - (tileRatio / ratio)); renderer.drawBitmap(bitmap, tileX + hPaddingInSelection, tileY + hPaddingInSelection, tileWidth - 2 * hPaddingInSelection, coverHeight, cropX); diff --git a/src/components/themes/lyra/LyraCarouselTheme.cpp b/src/components/themes/lyra/LyraCarouselTheme.cpp new file mode 100644 index 00000000..771142b0 --- /dev/null +++ b/src/components/themes/lyra/LyraCarouselTheme.cpp @@ -0,0 +1,662 @@ +#include "LyraCarouselTheme.h" + +#include +#include +#include +#include +#include +#include +#include +#include +#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/weather32.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; + case UIIcon::Weather: + return Weather32Icon; + case UIIcon::Wifi: + return WifiIcon; + case UIIcon::Hotspot: + return HotspotIcon; + default: + return nullptr; + } +} +// --------------------------------------------------------------------------- +// Static frame cache — survives HomeActivity re-creation so that returning to +// home after settings doesn't re-read covers from SD. +// Freed explicitly via invalidateFrameCache() before entering the reader. +// --------------------------------------------------------------------------- +constexpr int kFrameCount = 1; +uint8_t* gCachedFrames[kFrameCount] = {}; +int gCachedFrameBookIdx[kFrameCount] = {-1}; +int gCachedFrameCount = 0; +std::string gCacheKey; + +int findFrameSlot(int bookIdx) { + for (int i = 0; i < kFrameCount; ++i) { + if (gCachedFrameBookIdx[i] == bookIdx && gCachedFrames[i] != nullptr) return i; + } + return -1; +} + +void freeFrameCache() { + for (int i = 0; i < kFrameCount; ++i) { + if (gCachedFrames[i]) { + free(gCachedFrames[i]); + gCachedFrames[i] = nullptr; + } + gCachedFrameBookIdx[i] = -1; + } + gCachedFrameCount = 0; + gCacheKey.clear(); +} +} // namespace + +// --------------------------------------------------------------------------- +// Static helpers +// --------------------------------------------------------------------------- +void LyraCarouselTheme::setPreRenderIndex(int idx) { lastCarouselSelectorIndex = idx; } + +void LyraCarouselTheme::invalidateFrameCache() { freeFrameCache(); } + +void LyraCarouselTheme::onBookWillClose(const std::string& /*path*/, Epub* epub, Xtc* xtc, Txt* /*txt*/) { + if (epub) { + epub->generateThumbBmp(kCenterCoverW, kCenterCoverH); + epub->generateThumbBmp(kSideCoverW, kSideCoverH); + } + if (xtc) { + xtc->generateThumbBmp(kCenterCoverW, kCenterCoverH); + xtc->generateThumbBmp(kSideCoverW, kSideCoverH); + } + // txt files have no cover image — nothing to generate + invalidateFrameCache(); +} + +// --------------------------------------------------------------------------- +// tryFastHomeRender — pre-renders carousel frames and composites them +// --------------------------------------------------------------------------- +namespace { +void renderOneCarouselFrame(GfxRenderer& renderer, const std::vector& recentBooks, int bookIdx, int slotIdx, + const ThemeMetrics& metrics) { + if (!gCachedFrames[slotIdx]) return; + const int pageWidth = renderer.getScreenWidth(); + const int bookCount = static_cast(recentBooks.size()); + bool d1 = false, d2 = false, d3 = false; + + lastCarouselSelectorIndex = bookIdx; + renderer.clearScreen(); + UITheme::getInstance().getTheme().drawRecentBookCover( + renderer, Rect{0, metrics.homeTopPadding, pageWidth, metrics.homeCoverTileHeight}, recentBooks, bookCount, d1, d2, + d3, []() { return true; }); + + memcpy(gCachedFrames[slotIdx], renderer.getFrameBuffer(), renderer.getBufferSize()); + gCachedFrameBookIdx[slotIdx] = bookIdx; +} + +void updateSlidingWindow(GfxRenderer& renderer, const std::vector& recentBooks, int centerIdx, + const ThemeMetrics& metrics) { + const int bookCount = static_cast(recentBooks.size()); + if (bookCount <= kFrameCount || gCachedFrameCount == 0) 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, maxDist = -1; + for (int i = 0; i < kFrameCount; ++i) { + if (!gCachedFrames[i]) continue; + const int b = gCachedFrameBookIdx[i]; + if (b == centerIdx || (hasPrev && b == prevIdx) || (hasNext && b == nextIdx)) continue; + const int diff = std::abs(b - centerIdx); + const int dist = std::min(diff, bookCount - diff); + if (dist > maxDist) { + maxDist = dist; + evictSlot = i; + } + } + if (evictSlot >= 0) renderOneCarouselFrame(renderer, recentBooks, missingIdx, evictSlot, metrics); +} +} // namespace + +bool LyraCarouselTheme::tryFastHomeRender(GfxRenderer& renderer, const std::vector& recentBooks, + int selectorIndex, int menuCount, + const std::function& menuLabel, + const std::function& menuIcon, const char* hintBtn1, + const char* hintBtn2, const char* hintBtn3, const char* hintBtn4) const { + const int bookCount = static_cast(recentBooks.size()); + if (bookCount == 0) return false; + + const auto& metrics = UITheme::getInstance().getMetrics(); + const int pageWidth = renderer.getScreenWidth(); + const int pageHeight = renderer.getScreenHeight(); + const size_t bufferSize = renderer.getBufferSize(); + uint8_t* frameBuffer = renderer.getFrameBuffer(); + if (!frameBuffer) return false; + + // Build cache key from book paths + std::string newKey; + newKey.reserve(128); + for (const auto& b : recentBooks) { + newKey += b.path; + newKey += '\0'; + } + + if (newKey != gCacheKey || gCachedFrameCount == 0) { + // Free old cache and allocate fresh frames + freeFrameCache(); + const int frameCount = std::min(bookCount, kFrameCount); + for (int i = 0; i < frameCount; ++i) { + gCachedFrames[i] = static_cast(malloc(bufferSize)); + if (!gCachedFrames[i]) { + LOG_ERR("CAROUSEL", "tryFastHomeRender: malloc failed for frame %d", i); + freeFrameCache(); + return false; + } + } + // Render only the initially-selected frame; neighbours are filled lazily. + const int initialIdx = (selectorIndex < bookCount) ? selectorIndex : 0; + renderOneCarouselFrame(renderer, recentBooks, initialIdx, 0, metrics); + gCachedFrameCount = frameCount; + gCacheKey = newKey; + } + + const bool inCarouselRow = (selectorIndex < bookCount); + const int centerIdx = + inCarouselRow ? selectorIndex : (lastCarouselSelectorIndex >= 0 ? lastCarouselSelectorIndex : 0); + int slotIdx = findFrameSlot(centerIdx); + if (slotIdx < 0) { + slotIdx = 0; + } + if (!gCachedFrames[slotIdx]) { + gCachedFrames[slotIdx] = static_cast(malloc(bufferSize)); + if (!gCachedFrames[slotIdx]) { + LOG_ERR("CAROUSEL", "tryFastHomeRender: malloc failed for frame %d", slotIdx); + return false; + } + } + if (findFrameSlot(centerIdx) < 0 || gCachedFrameBookIdx[slotIdx] != centerIdx) { + renderOneCarouselFrame(renderer, recentBooks, centerIdx, slotIdx, metrics); + } + + memcpy(frameBuffer, gCachedFrames[slotIdx], bufferSize); + UITheme::getInstance().getTheme().drawHeader(renderer, Rect{0, metrics.topPadding, pageWidth, metrics.homeTopPadding}, + nullptr); + + // Overlay the selection border when carousel row is active + if (inCarouselRow) { + const int screenW = renderer.getScreenWidth(); + const int centerTileY = metrics.homeTopPadding + kCoverTopPad; + const int centerX = (screenW - kCenterCoverMaxW) / 2; + renderer.drawRoundedRect(centerX, centerTileY, kCenterCoverMaxW, kCenterCoverMaxH, kSelectionLineW, kCornerRadius, + true); + } + + // Menu row + const int menuIdx = inCarouselRow ? -1 : (selectorIndex - bookCount); + UITheme::getInstance().getTheme().drawButtonMenu( + renderer, + Rect{0, metrics.homeTopPadding + metrics.homeCoverTileHeight + metrics.verticalSpacing, pageWidth, + pageHeight - (metrics.headerHeight + metrics.homeTopPadding + metrics.verticalSpacing * 2 + + metrics.buttonHintsHeight)}, + menuCount, menuIdx, menuLabel, menuIcon); + + // Button hints + UITheme::getInstance().getTheme().drawButtonHints(renderer, hintBtn1, hintBtn2, hintBtn3, hintBtn4); + + renderer.displayBuffer(); + updateSlidingWindow(renderer, recentBooks, centerIdx, metrics); + return 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]; + // Side tiles may extend off-screen — only round corners that are on-screen. + const bool roundLeft = (x >= 0); + const bool roundRight = (x + maxW <= screenW); + 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); + // Clear only the pixels outside the arc in each corner. + // The arc centre for the top-left corner is (x+r, y+r). A pixel at + // (x+dx, y+dy) is outside the arc when its distance from that centre + // exceeds r, i.e. (r-1-dx)²+(r-1-dy)² > (r-1)². + for (int dy = 0; dy < kCornerRadius; ++dy) { + for (int dx = 0; dx < kCornerRadius; ++dx) { + const int ex = kCornerRadius - 1 - dx; + const int ey = kCornerRadius - 1 - dy; + if (ex * ex + ey * ey > (kCornerRadius - 1) * (kCornerRadius - 1)) { + if (roundLeft) { + renderer.drawPixel(x + dx, y + dy, false); // top-left + renderer.drawPixel(x + dx, y + maxH - 1 - dy, false); // bottom-left + } + if (roundRight) { + renderer.drawPixel(x + maxW - 1 - dx, y + dy, false); // top-right + renderer.drawPixel(x + maxW - 1 - dx, y + maxH - 1 - dy, false); // bottom-right + } + } + } + } + renderer.drawRoundedRect(x, y, maxW, maxH, kThinOutlineW, kCornerRadius, roundLeft, roundRight, roundLeft, + roundRight, true); + hasCover = true; + } + file.close(); + } + } + if (!hasCover) { + renderer.drawRoundedRect(x, y, maxW, maxH, 1, kCornerRadius, roundLeft, roundRight, roundLeft, roundRight, true); + renderer.fillRoundedRect(x, y + maxH / 3, maxW, 2 * maxH / 3, kCornerRadius, /*roundTopLeft=*/false, + /*roundTopRight=*/false, /*roundBottomLeft=*/roundLeft, /*roundBottomRight=*/roundRight, + Color::Black); + renderer.drawIcon(CoverIcon, x + maxW / 2 - 16, y + 8, 32, 32); + } + return true; + }; + + if (!coverRendered) { + lastCarouselSelectorIndex = centerIdx; + + // Clear from the top of the tile down through the author/title text area. + // Use absolute coordinates so the clear covers the text regardless of what + // rect.height HomeActivity computed (it may be smaller than homeCoverTileHeight). + const int textAreaBottom = centerTileY + kCenterCoverMaxH // bottom of centre cover + + 8 + kDotSize // dots + + 6 + renderer.getLineHeight(kTitleFontId) // author line + + 2 + renderer.getLineHeight(kTitleFontId) // title line + + 4; // small margin + renderer.fillRect(rect.x, rect.y, rect.width, textAreaBottom - rect.y, 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)) { + const bool rl = (leftX >= 0), rr = (leftX + kSideCoverMaxW <= screenW); + renderer.drawRoundedRect(leftX, sideTileY, kSideCoverMaxW, kSideCoverMaxH, 1, kCornerRadius, rl, rr, rl, rr, + true); + } + } + if (bookCount >= 2) { + if (drawCover(nextIdx, rightX, sideTileY, kSideCoverMaxW, kSideCoverMaxH)) { + const bool rl = (rightX >= 0), rr = (rightX + kSideCoverMaxW <= screenW); + renderer.drawRoundedRect(rightX, sideTileY, kSideCoverMaxW, kSideCoverMaxH, 1, kCornerRadius, rl, rr, rl, rr, + 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; + + const int screenW = renderer.getScreenWidth(); + const int tileH = kMenuIconPad + kMenuIconSize + kMenuIconPad; + // 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; + + // How many icons fit side-by-side? Each needs at least (kMenuIconSize + 2*kHighlightPad). + const int minTileW = kMenuIconSize + 2 * kHighlightPad; + const int visibleCount = std::min(buttonCount, screenW / minTileW); + const int tileW = screenW / visibleCount; + + // Sliding window: keep selectedIndex centred when we can't show all icons. + int firstVisible = 0; + if (selectedIndex >= 0 && visibleCount < buttonCount) { + firstVisible = selectedIndex - visibleCount / 2; + firstVisible = std::max(0, std::min(firstVisible, buttonCount - visibleCount)); + } + + // Draw the selected item's label in the header area so the user knows what they're about to open. + if (selectedIndex >= 0 && buttonLabel != nullptr) { + const auto& metrics = UITheme::getInstance().getMetrics(); + const std::string label = buttonLabel(selectedIndex); + const int labelY = metrics.topPadding + 5; // same y as battery / clock + const int labelW = renderer.getTextWidth(UI_12_FONT_ID, label.c_str(), EpdFontFamily::BOLD); + const int labelX = (screenW - labelW) / 2; + renderer.drawText(UI_12_FONT_ID, labelX, labelY, label.c_str(), true, EpdFontFamily::BOLD); + } + + for (int slot = 0; slot < visibleCount; ++slot) { + const int i = firstVisible + slot; + if (i >= buttonCount) break; + + const int tileX = slot * 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); + } + } + + // Overflow indicators: small filled square at left/right edge when icons are clipped + if (visibleCount < buttonCount) { + constexpr int kArrowDotR = 3; + const int arrowY = rowY + tileH / 2; + if (firstVisible > 0 && slot == 0) + renderer.fillRect(2, arrowY - kArrowDotR, kArrowDotR * 2, kArrowDotR * 2, true); + if (firstVisible + visibleCount < buttonCount && slot == visibleCount - 1) + renderer.fillRect(screenW - 2 - kArrowDotR * 2, arrowY - kArrowDotR, kArrowDotR * 2, kArrowDotR * 2, true); + } + } +} + +// --------------------------------------------------------------------------- +// 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 — skip if selected item is a separator + const bool selectedIsSeparator = (selectedIndex >= 0 && selectedIndex < itemCount && rowTitle != nullptr && + UITheme::isSeparatorTitle(rowTitle(selectedIndex))); + if (selectedIndex >= 0 && !selectedIsSeparator) { + 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); + if (UITheme::isSeparatorTitle(itemName)) { + itemName = UITheme::stripSeparatorTitle(itemName); + drawListSeparator(renderer, + Rect{rect.x + LyraCarouselMetrics::values.contentSidePadding, itemY, + contentWidth - LyraCarouselMetrics::values.contentSidePadding * 2, rowHeight}, + textX, rowTextWidth, itemName); + continue; + } + 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..63019665 --- /dev/null +++ b/src/components/themes/lyra/LyraCarouselTheme.h @@ -0,0 +1,90 @@ + +#pragma once + +#include +#include + +#include "components/themes/lyra/LyraTheme.h" + +class GfxRenderer; +class Epub; +class Txt; +class Xtc; + +// 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); + + HomeNavigation getHomeNavigation() const override { return HomeNavigation::Carousel; } + std::vector> getCoverThumbSizes(int /*coverHeight*/) const override { + return {{kCenterCoverW, kCenterCoverH}, {kSideCoverW, kSideCoverH}}; + } + bool tryFastHomeRender(GfxRenderer& renderer, const std::vector& recentBooks, int selectorIndex, + int menuCount, const std::function& menuLabel, + const std::function& menuIcon, const char* hintBtn1, const char* hintBtn2, + const char* hintBtn3, const char* hintBtn4) const override; + void onBookWillClose(const std::string& path, Epub* epub, Xtc* xtc, Txt* txt) override; + void invalidateFrameCache() 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; + void drawButtonMenu(GfxRenderer& renderer, Rect rect, int buttonCount, int selectedIndex, + const std::function& buttonLabel, + const std::function& rowIcon) 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); };