diff --git a/lib/Epub/Epub.cpp b/lib/Epub/Epub.cpp index b48d7ea..dd2378a 100644 --- a/lib/Epub/Epub.cpp +++ b/lib/Epub/Epub.cpp @@ -263,10 +263,13 @@ const std::string& Epub::getTitle() const { } std::string Epub::getCoverBmpPath() const { return cachePath + "/cover.bmp"; } +std::string Epub::getThumbBmpPath() const { return cachePath + "/thumb.bmp"; } + +bool Epub::generateCoverBmp(bool thumb) const { + std::string path = thumb ? getThumbBmpPath() : getCoverBmpPath(); -bool Epub::generateCoverBmp() const { // Already generated, return true - if (SD.exists(getCoverBmpPath().c_str())) { + if (SD.exists(path.c_str())) { return true; } @@ -298,11 +301,11 @@ bool Epub::generateCoverBmp() const { } File coverBmp; - if (!FsHelpers::openFileForWrite("EBP", getCoverBmpPath(), coverBmp)) { + if (!FsHelpers::openFileForWrite("EBP", path, coverBmp)) { coverJpg.close(); return false; } - const bool success = JpegToBmpConverter::jpegFileToBmpStream(coverJpg, coverBmp); + const bool success = JpegToBmpConverter::jpegFileToBmpStream(coverJpg, coverBmp, thumb ? 1 : 2, thumb ? 90 : 480, thumb ? 120 : 800); coverJpg.close(); coverBmp.close(); SD.remove(coverJpgTempPath.c_str()); diff --git a/lib/Epub/Epub.h b/lib/Epub/Epub.h index acdd32c..40549c4 100644 --- a/lib/Epub/Epub.h +++ b/lib/Epub/Epub.h @@ -39,8 +39,9 @@ class Epub { const std::string& getCachePath() const; const std::string& getPath() const; const std::string& getTitle() const; + std::string getThumbBmpPath() const; std::string getCoverBmpPath() const; - bool generateCoverBmp() const; + bool generateCoverBmp(bool thumb) 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 bcd8808..f078972 100644 --- a/lib/GfxRenderer/GfxRenderer.cpp +++ b/lib/GfxRenderer/GfxRenderer.cpp @@ -80,6 +80,74 @@ void GfxRenderer::drawText(const int fontId, const int x, const int y, const cha } } +void GfxRenderer::drawTextInBox(const int fontId, const int x, const int y, const int w, const int h, const char* text, const bool centered, const bool black, const EpdFontStyle style) const { + const int lineHeight = getLineHeight(fontId); + const int spaceWidth = getSpaceWidth(fontId); + int xpos = x; + int ypos = y + lineHeight; + if (centered) { + int textWidth = getTextWidth(fontId, text, style); + if (textWidth < w) { + // Center if text on single line + xpos = x + (w - textWidth) / 2; + } + } + + // cannot draw a NULL / empty string + if (text == nullptr || *text == '\0') { + return; + } + + if (fontMap.count(fontId) == 0) { + Serial.printf("[%lu] [GFX] Font %d not found\n", millis(), fontId); + return; + } + const auto font = fontMap.at(fontId); + + // no printable characters + if (!font.hasPrintableChars(text, style)) { + return; + } + + uint32_t cp; + int ellipsisWidth = 0; + while ((cp = utf8NextCodepoint(reinterpret_cast(&text)))) { + const int charWidth = getTextWidth(fontId, reinterpret_cast(&cp), style); + if (xpos + charWidth + ellipsisWidth > x + w) { + if (ellipsisWidth > 0) { + // Draw ellipsis and exit + int dotX = xpos; + renderChar(font, '.', &dotX, &ypos, black, style); + dotX += spaceWidth/3; + renderChar(font, '.', &dotX, &ypos, black, style); + dotX += spaceWidth/3; + renderChar(font, '.', &dotX, &ypos, black, style); + break; + } else { + // TODO center when more than one line + // if (centered) { + // int textWidth = getTextWidth(fontId, text, style); + // if (textWidth < w) { + // xpos = x + (w - textWidth) / 2; + // } + // } + xpos = x; + ypos += lineHeight; + if (h > 0 && ypos - y > h) { + // Overflowing box height + break; + } + if (h > 0 && ypos + lineHeight - y > h) { + // Last line, prepare ellipsis + ellipsisWidth = spaceWidth * 4; + } + } + } + + renderChar(font, cp, &xpos, &ypos, black, style); + } +} + void GfxRenderer::drawLine(int x1, int y1, int x2, int y2, const bool state) const { if (x1 == x2) { if (y2 < y1) { @@ -101,6 +169,12 @@ void GfxRenderer::drawLine(int x1, int y1, int x2, int y2, const bool state) con } } +void GfxRenderer::drawLine(int x1, int y1, int x2, int y2, const int lineWidth, const bool state) const { + for (int i = 0; i < lineWidth; i++) { + drawLine(x1, y1 + i, x2, y2 + i, state); + } +} + void GfxRenderer::drawRect(const int x, const int y, const int width, const int height, const bool state) const { drawLine(x, y, x + width - 1, y, state); drawLine(x + width - 1, y, x + width - 1, y + height - 1, state); @@ -108,17 +182,132 @@ void GfxRenderer::drawRect(const int x, const int y, const int width, const int drawLine(x, y, x, y + height - 1, state); } +// Border is inside the rectangle +void GfxRenderer::drawRect(const int x, const int y, const int width, const int height, const int lineWidth, const bool state) const { + for (int i = 0; i < lineWidth; i++) { + drawLine(x + i, y + i, x + width - i, y + i, state); + drawLine(x + width - i, y + i, x + width - i, y + height - i, state); + drawLine(x + width - i, y + height - i, x + i, y + height - i, state); + drawLine(x + i, y + height - i, x + i, y + i, state); + } +} + +void GfxRenderer::drawArc(const int maxRadius, const int cx, const int cy, const int xDir, const int yDir, const int lineWidth, const bool state) const { + const int stroke = std::min(lineWidth, maxRadius); + const int innerRadius = std::max(maxRadius - stroke, 0); + const int outerRadiusSq = maxRadius * maxRadius; + const int innerRadiusSq = innerRadius * innerRadius; + for (int dy = 0; dy <= maxRadius; ++dy) { + for (int dx = 0; dx <= maxRadius; ++dx) { + const int distSq = dx * dx + dy * dy; + if (distSq > outerRadiusSq || distSq < innerRadiusSq) { + continue; + } + const int px = cx + xDir * dx; + const int py = cy + yDir * dy; + drawPixel(px, py, state); + } + } +}; + +// Border is inside the rectangle, rounded corners +void GfxRenderer::drawRoundedRect(const int x, const int y, const int width, const int height, const int lineWidth, const int cornerRadius, const bool state) const { + if (lineWidth <= 0 || width <= 0 || height <= 0) { + return; + } + + const int maxRadius = std::min({cornerRadius, width / 2, height / 2}); + if (maxRadius <= 0) { + drawRect(x, y, width, height, lineWidth, state); + return; + } + + const int stroke = std::min(lineWidth, maxRadius); + const int right = x + width - 1; + const int bottom = y + height - 1; + + const int horizontalWidth = width - 2 * maxRadius; + if (horizontalWidth > 0) { + fillRect(x + maxRadius, y, horizontalWidth, stroke, state); + fillRect(x + maxRadius, bottom - stroke + 1, horizontalWidth, stroke, state); + } + + const int verticalHeight = height - 2 * maxRadius; + if (verticalHeight > 0) { + fillRect(x, y + maxRadius, stroke, verticalHeight, state); + fillRect(right - stroke + 1, y + maxRadius, stroke, verticalHeight, state); + } + + drawArc(maxRadius, x + maxRadius, y + maxRadius, -1, -1, lineWidth, state); // TL + drawArc(maxRadius, right - maxRadius, y + maxRadius, 1, -1, lineWidth, state); // TR + drawArc(maxRadius, right - maxRadius, bottom - maxRadius, 1, 1, lineWidth, state); // BR + drawArc(maxRadius, x + maxRadius, bottom - maxRadius, -1, 1, lineWidth, state); // BL +} + void GfxRenderer::fillRect(const int x, const int y, const int width, const int height, const bool state) const { for (int fillY = y; fillY < y + height; fillY++) { drawLine(x, fillY, x + width - 1, fillY, state); } } +// Use Bayer matrix 4x4 dithering to fill the rectangle with a grey level - 0 white to 15 black +void GfxRenderer::fillRectGrey(const int x, const int y, const int width, const int height, const int greyLevel) const { + static constexpr uint8_t bayer4x4[4][4] = { + {0, 8, 2, 10}, + {12, 4, 14, 6}, + {3, 11, 1, 9}, + {15, 7, 13, 5}, + }; + static constexpr int matrixSize = 4; + static constexpr int matrixLevels = matrixSize * matrixSize; + + const int normalizedGrey = (greyLevel * 255) / (matrixLevels - 1); + const int clampedGrey = std::max(0, std::min(normalizedGrey, 255)); + const int threshold = (clampedGrey * (matrixLevels + 1)) / 256; + + for (int dy = 0; dy < height; ++dy) { + const int screenY = y + dy; + const int matrixY = screenY & (matrixSize - 1); + for (int dx = 0; dx < width; ++dx) { + const int screenX = x + dx; + const int matrixX = screenX & (matrixSize - 1); + const uint8_t patternValue = bayer4x4[matrixY][matrixX]; + const bool black = patternValue < threshold; + drawPixel(screenX, screenY, black); + } + } +} + +// Color -1 white, 0 clear, 1 black +void GfxRenderer::fillArc(const int maxRadius, const int cx, const int cy, const int xDir, const int yDir, const int insideColor, const int outsideColor) const { + const int radiusSq = maxRadius * maxRadius; + for (int dy = 0; dy <= maxRadius; ++dy) { + for (int dx = 0; dx <= maxRadius; ++dx) { + const int distSq = dx * dx + dy * dy; + const int px = cx + xDir * dx; + const int py = cy + yDir * dy; + if (distSq > radiusSq) { + if (outsideColor != 0) { + drawPixel(px, py, outsideColor == 1); + } + } else { + if (insideColor != 0) { + drawPixel(px, py, insideColor == 1); + } + } + } + } +}; + void GfxRenderer::drawImage(const uint8_t bitmap[], const int x, const int y, const int width, const int height) const { // Flip X and Y for portrait mode einkDisplay.drawImage(bitmap, y, x, height, width); } +void GfxRenderer::drawIcon(const uint8_t bitmap[], const int x, const int y, const int width, const int height) const { + einkDisplay.drawImage(bitmap, y, getScreenWidth() - width - x, height, width); +} + void GfxRenderer::drawBitmap(const Bitmap& bitmap, const int x, const int y, const int maxWidth, const int maxHeight) const { float scale = 1.0f; diff --git a/lib/GfxRenderer/GfxRenderer.h b/lib/GfxRenderer/GfxRenderer.h index 00a525d..32ba525 100644 --- a/lib/GfxRenderer/GfxRenderer.h +++ b/lib/GfxRenderer/GfxRenderer.h @@ -45,15 +45,23 @@ class GfxRenderer { // Drawing void drawPixel(int x, int y, bool state = true) const; void drawLine(int x1, int y1, int x2, int y2, bool state = true) const; + void drawLine(int x1, int y1, int x2, int y2, int lineWidth, bool state = true) const; void drawRect(int x, int y, int width, int height, bool state = true) const; + void drawRect(int x, int y, int width, int height, int lineWidth, bool state) const; + void drawArc(int maxRadius, int cx, int cy, int xDir, int yDir, int lineWidth, bool state) const; + void drawRoundedRect(int x, int y, int width, int height, int lineWidth, int cornerRadius, bool state) const; void fillRect(int x, int y, int width, int height, bool state = true) const; + void fillRectGrey(int x, int y, int width, int height, int greyLevel) const; + void fillArc(int maxRadius, int cx, int cy, int xDir, int yDir, int insideColor, int outsideColor) const; void drawImage(const uint8_t bitmap[], int x, int y, int width, int height) const; + void drawIcon(const uint8_t bitmap[], int x, int y, int width, int height) const; void drawBitmap(const Bitmap& bitmap, int x, int y, int maxWidth, int maxHeight) const; // Text int getTextWidth(int fontId, const char* text, EpdFontStyle style = REGULAR) const; void drawCenteredText(int fontId, int y, const char* text, bool black = true, EpdFontStyle style = REGULAR) const; void drawText(int fontId, int x, int y, const char* text, bool black = true, EpdFontStyle style = REGULAR) const; + void drawTextInBox(int fontId, int x, int y, int w, int h, const char* text, bool centered, bool black = true, EpdFontStyle style = REGULAR) const; int getSpaceWidth(int fontId) const; int getLineHeight(int fontId) const; diff --git a/lib/JpegToBmpConverter/JpegToBmpConverter.cpp b/lib/JpegToBmpConverter/JpegToBmpConverter.cpp index 0a19701..69c4f7a 100644 --- a/lib/JpegToBmpConverter/JpegToBmpConverter.cpp +++ b/lib/JpegToBmpConverter/JpegToBmpConverter.cpp @@ -16,7 +16,6 @@ struct JpegReadContext { // ============================================================================ // IMAGE PROCESSING OPTIONS - Toggle these to test different configurations // ============================================================================ -constexpr bool USE_8BIT_OUTPUT = false; // true: 8-bit grayscale (no quantization), false: 2-bit (4 levels) // Dithering method selection (only one should be true, or all false for simple quantization): constexpr bool USE_ATKINSON = true; // Atkinson dithering (cleaner than F-S, less error diffusion) constexpr bool USE_FLOYD_STEINBERG = false; // Floyd-Steinberg error diffusion (can cause "worm" artifacts) @@ -74,16 +73,41 @@ static inline int adjustPixel(int gray) { return gray; } -// Simple quantization without dithering - just divide into 4 levels -static inline uint8_t quantizeSimple(int gray) { +// Quantize a brightness-adjusted gray value into evenly spaced levels +static inline uint8_t quantizeAdjustedSimple(int gray, int levelCount) { + if (levelCount <= 1) return 0; + if (gray < 0) gray = 0; + if (gray > 255) gray = 255; + int level = (gray * levelCount) >> 8; // Divide by 256 + if (level >= levelCount) level = levelCount - 1; + return static_cast(level); +} + +// Quantize adjusted gray and also return the reconstructed 0-255 value +static inline uint8_t quantizeAdjustedWithValue(int gray, int levelCount, int& quantizedValue) { + if (levelCount <= 1) { + quantizedValue = 0; + return 0; + } + if (gray < 0) gray = 0; + if (gray > 255) gray = 255; + int level = (gray * levelCount) >> 8; + if (level >= levelCount) level = levelCount - 1; + const int denom = levelCount - 1; + quantizedValue = denom > 0 ? (level * 255) / denom : 0; + return static_cast(level); +} + +// Simple quantization without dithering - divide into 2^bits levels +static inline uint8_t quantizeSimple(int gray, int levelCount) { gray = adjustPixel(gray); - // Simple 2-bit quantization: 0-63=0, 64-127=1, 128-191=2, 192-255=3 - return static_cast(gray >> 6); + return quantizeAdjustedSimple(gray, levelCount); } // Hash-based noise dithering - survives downsampling without moiré artifacts // Uses integer hash to generate pseudo-random threshold per pixel -static inline uint8_t quantizeNoise(int gray, int x, int y) { +static inline uint8_t quantizeNoise(int gray, int x, int y, int levelCount) { + if (levelCount <= 1) return 0; gray = adjustPixel(gray); // Generate noise threshold using integer hash (no regular pattern to alias) @@ -91,24 +115,23 @@ static inline uint8_t quantizeNoise(int gray, int x, int y) { hash = (hash ^ (hash >> 13)) * 1274126177u; const int threshold = static_cast(hash >> 24); // 0-255 - // Map gray (0-255) to 4 levels with dithering - const int scaled = gray * 3; - - if (scaled < 255) { - return (scaled + threshold >= 255) ? 1 : 0; - } else if (scaled < 510) { - return ((scaled - 255) + threshold >= 255) ? 2 : 1; - } else { - return ((scaled - 510) + threshold >= 255) ? 3 : 2; + // Map gray (0-255) to N levels with dithering + const int scaled = gray * levelCount; + int level = scaled >> 8; + if (level >= levelCount) level = levelCount - 1; + const int remainder = scaled & 0xFF; + if (level < levelCount - 1 && remainder + threshold >= 256) { + level++; } + return static_cast(level); } // Main quantization function - selects between methods based on config -static inline uint8_t quantize(int gray, int x, int y) { +static inline uint8_t quantize(int gray, int x, int y, int levelCount) { if (USE_NOISE_DITHERING) { - return quantizeNoise(gray, x, y); + return quantizeNoise(gray, x, y, levelCount); } else { - return quantizeSimple(gray); + return quantizeSimple(gray, levelCount); } } @@ -120,7 +143,7 @@ static inline uint8_t quantize(int gray, int x, int y) { // Less error buildup = fewer artifacts than Floyd-Steinberg class AtkinsonDitherer { public: - AtkinsonDitherer(int width) : width(width) { + AtkinsonDitherer(int width, int levelCount) : width(width), levelCount(levelCount) { errorRow0 = new int16_t[width + 4](); // Current row errorRow1 = new int16_t[width + 4](); // Next row errorRow2 = new int16_t[width + 4](); // Row after next @@ -142,21 +165,8 @@ class AtkinsonDitherer { if (adjusted > 255) adjusted = 255; // Quantize to 4 levels - uint8_t quantized; - int quantizedValue; - if (adjusted < 43) { - quantized = 0; - quantizedValue = 0; - } else if (adjusted < 128) { - quantized = 1; - quantizedValue = 85; - } else if (adjusted < 213) { - quantized = 2; - quantizedValue = 170; - } else { - quantized = 3; - quantizedValue = 255; - } + int quantizedValue = 0; + uint8_t quantized = quantizeAdjustedWithValue(adjusted, levelCount, quantizedValue); // Calculate error (only distribute 6/8 = 75%) int error = (adjusted - quantizedValue) >> 3; // error/8 @@ -188,6 +198,7 @@ class AtkinsonDitherer { private: int width; + int levelCount; int16_t* errorRow0; int16_t* errorRow1; int16_t* errorRow2; @@ -203,7 +214,7 @@ class AtkinsonDitherer { // 7/16 X class FloydSteinbergDitherer { public: - FloydSteinbergDitherer(int width) : width(width), rowCount(0) { + FloydSteinbergDitherer(int width, int levelCount) : width(width), levelCount(levelCount), rowCount(0) { errorCurRow = new int16_t[width + 2](); // +2 for boundary handling errorNextRow = new int16_t[width + 2](); } @@ -216,6 +227,7 @@ class FloydSteinbergDitherer { // Process a single pixel and return quantized 2-bit value // x is the logical x position (0 to width-1), direction handled internally uint8_t processPixel(int gray, int x, bool reverseDirection) { + gray = adjustPixel(gray); // Add accumulated error to this pixel int adjusted = gray + errorCurRow[x + 1]; @@ -223,22 +235,9 @@ class FloydSteinbergDitherer { if (adjusted < 0) adjusted = 0; if (adjusted > 255) adjusted = 255; - // Quantize to 4 levels (0, 85, 170, 255) - uint8_t quantized; - int quantizedValue; - if (adjusted < 43) { - quantized = 0; - quantizedValue = 0; - } else if (adjusted < 128) { - quantized = 1; - quantizedValue = 85; - } else if (adjusted < 213) { - quantized = 2; - quantizedValue = 170; - } else { - quantized = 3; - quantizedValue = 255; - } + // Quantize to the requested level count + int quantizedValue = 0; + uint8_t quantized = quantizeAdjustedWithValue(adjusted, levelCount, quantizedValue); // Calculate error int error = adjusted - quantizedValue; @@ -292,6 +291,7 @@ class FloydSteinbergDitherer { private: int width; + int levelCount; int rowCount; int16_t* errorCurRow; int16_t* errorNextRow; @@ -316,12 +316,38 @@ inline void write32Signed(Print& out, const int32_t value) { out.write((value >> 24) & 0xFF); } +inline void writeIndexedPixel(uint8_t* rowBuffer, int x, int bitsPerPixel, uint8_t value) { + const int bitPos = x * bitsPerPixel; + const int byteIndex = bitPos >> 3; + const int bitOffset = 8 - bitsPerPixel - (bitPos & 7); + rowBuffer[byteIndex] |= static_cast(value << bitOffset); +} + +int getBytesPerRow(int width, int bitsPerPixel) { + if (bitsPerPixel == 8) { + return (width + 3) / 4 * 4; // 8 bits per pixel, padded + } else if (bitsPerPixel == 2) { + return (width * 2 + 31) / 32 * 4; // 2 bits per pixel, round up + } + return (width + 31) / 32 * 4; // 1 bit per pixel, round up +} + +int getColorsUsed(int bitsPerPixel) { + if (bitsPerPixel == 8) { + return 256; + } else if (bitsPerPixel == 2) { + return 4; + } + return 2; +} + // Helper function: Write BMP header with 8-bit grayscale (256 levels) -void writeBmpHeader8bit(Print& bmpOut, const int width, const int height) { +void writeBmpHeader(Print& bmpOut, const int width, const int height, int bitsPerPixel) { // Calculate row padding (each row must be multiple of 4 bytes) - const int bytesPerRow = (width + 3) / 4 * 4; // 8 bits per pixel, padded + const int bytesPerRow = getBytesPerRow(width, bitsPerPixel); + const int colorsUsed = getColorsUsed(bitsPerPixel); + const int paletteSize = colorsUsed * 4; // Size of color palette const int imageSize = bytesPerRow * height; - const uint32_t paletteSize = 256 * 4; // 256 colors * 4 bytes (BGRA) const uint32_t fileSize = 14 + 40 + paletteSize + imageSize; // BMP File Header (14 bytes) @@ -336,60 +362,45 @@ void writeBmpHeader8bit(Print& bmpOut, const int width, const int height) { write32Signed(bmpOut, width); write32Signed(bmpOut, -height); // Negative height = top-down bitmap write16(bmpOut, 1); // Color planes - write16(bmpOut, 8); // Bits per pixel (8 bits) + write16(bmpOut, bitsPerPixel); // Bits per pixel (8 bits) write32(bmpOut, 0); // BI_RGB (no compression) write32(bmpOut, imageSize); write32(bmpOut, 2835); // xPixelsPerMeter (72 DPI) write32(bmpOut, 2835); // yPixelsPerMeter (72 DPI) - write32(bmpOut, 256); // colorsUsed - write32(bmpOut, 256); // colorsImportant + write32(bmpOut, colorsUsed); // colorsUsed + write32(bmpOut, colorsUsed); // colorsImportant - // Color Palette (256 grayscale entries x 4 bytes = 1024 bytes) - for (int i = 0; i < 256; i++) { - bmpOut.write(static_cast(i)); // Blue - bmpOut.write(static_cast(i)); // Green - bmpOut.write(static_cast(i)); // Red - bmpOut.write(static_cast(0)); // Reserved - } -} - -// Helper function: Write BMP header with 2-bit color depth -void JpegToBmpConverter::writeBmpHeader(Print& bmpOut, const int width, const int height) { - // Calculate row padding (each row must be multiple of 4 bytes) - const int bytesPerRow = (width * 2 + 31) / 32 * 4; // 2 bits per pixel, round up - const int imageSize = bytesPerRow * height; - const uint32_t fileSize = 70 + imageSize; // 14 (file header) + 40 (DIB header) + 16 (palette) + image - - // BMP File Header (14 bytes) - bmpOut.write('B'); - bmpOut.write('M'); - write32(bmpOut, fileSize); // File size - write32(bmpOut, 0); // Reserved - write32(bmpOut, 70); // Offset to pixel data - - // DIB Header (BITMAPINFOHEADER - 40 bytes) - write32(bmpOut, 40); - write32Signed(bmpOut, width); - write32Signed(bmpOut, -height); // Negative height = top-down bitmap - write16(bmpOut, 1); // Color planes - write16(bmpOut, 2); // Bits per pixel (2 bits) - write32(bmpOut, 0); // BI_RGB (no compression) - write32(bmpOut, imageSize); - write32(bmpOut, 2835); // xPixelsPerMeter (72 DPI) - write32(bmpOut, 2835); // yPixelsPerMeter (72 DPI) - write32(bmpOut, 4); // colorsUsed - write32(bmpOut, 4); // colorsImportant - - // Color Palette (4 colors x 4 bytes = 16 bytes) - // Format: Blue, Green, Red, Reserved (BGRA) - uint8_t palette[16] = { - 0x00, 0x00, 0x00, 0x00, // Color 0: Black - 0x55, 0x55, 0x55, 0x00, // Color 1: Dark gray (85) - 0xAA, 0xAA, 0xAA, 0x00, // Color 2: Light gray (170) - 0xFF, 0xFF, 0xFF, 0x00 // Color 3: White - }; - for (const uint8_t i : palette) { - bmpOut.write(i); + if (bitsPerPixel == 8) { + // Color Palette (256 grayscale entries x 4 bytes = 1024 bytes) + for (int i = 0; i < 256; i++) { + bmpOut.write(static_cast(i)); // Blue + bmpOut.write(static_cast(i)); // Green + bmpOut.write(static_cast(i)); // Red + bmpOut.write(static_cast(0)); // Reserved + } + return; + } else if (bitsPerPixel == 2) { + // Color Palette (4 colors x 4 bytes = 16 bytes) + // Format: Blue, Green, Red, Reserved (BGRA) + uint8_t palette[16] = { + 0x00, 0x00, 0x00, 0x00, // Color 0: Black + 0x55, 0x55, 0x55, 0x00, // Color 1: Dark gray (85) + 0xAA, 0xAA, 0xAA, 0x00, // Color 2: Light gray (170) + 0xFF, 0xFF, 0xFF, 0x00 // Color 3: White + }; + for (const uint8_t i : palette) { + bmpOut.write(i); + } + } else { + // Color Palette (2 colors x 4 bytes = 8 bytes) + // Format: Blue, Green, Red, Reserved (BGRA) + uint8_t palette[8] = { + 0x00, 0x00, 0x00, 0x00, // Color 0: Black + 0xFF, 0xFF, 0xFF, 0x00 // Color 1: White + }; + for (const uint8_t i : palette) { + bmpOut.write(i); + } } } @@ -425,10 +436,19 @@ unsigned char JpegToBmpConverter::jpegReadCallback(unsigned char* pBuf, const un return 0; // Success } -// Core function: Convert JPEG file to 2-bit BMP bool JpegToBmpConverter::jpegFileToBmpStream(File& jpegFile, Print& bmpOut) { + return jpegFileToBmpStream(jpegFile, bmpOut, 2, TARGET_MAX_WIDTH, TARGET_MAX_HEIGHT); +} + +// Core function: Convert JPEG file to BMP +bool JpegToBmpConverter::jpegFileToBmpStream(File& jpegFile, Print& bmpOut, int bitsPerPixel, int targetWidth, int targetHeight) { Serial.printf("[%lu] [JPG] Converting JPEG to BMP\n", millis()); + if (bitsPerPixel != 1 && bitsPerPixel != 2 && bitsPerPixel != 8) { + Serial.printf("[%lu] [JPG] Unsupported bitsPerPixel: %d\n", millis(), bitsPerPixel); + return false; + } + // Setup context for picojpeg callback JpegReadContext context = {.file = jpegFile, .bufferPos = 0, .bufferFilled = 0}; @@ -462,10 +482,10 @@ bool JpegToBmpConverter::jpegFileToBmpStream(File& jpegFile, Print& bmpOut) { uint32_t scaleY_fp = 65536; bool needsScaling = false; - if (USE_PRESCALE && (imageInfo.m_width > TARGET_MAX_WIDTH || imageInfo.m_height > TARGET_MAX_HEIGHT)) { + if (USE_PRESCALE && (imageInfo.m_width > targetWidth || imageInfo.m_height > targetHeight)) { // Calculate scale to fit within target dimensions while maintaining aspect ratio - const float scaleToFitWidth = static_cast(TARGET_MAX_WIDTH) / imageInfo.m_width; - const float scaleToFitHeight = static_cast(TARGET_MAX_HEIGHT) / imageInfo.m_height; + const float scaleToFitWidth = static_cast(targetWidth) / imageInfo.m_width; + const float scaleToFitHeight = static_cast(targetHeight) / imageInfo.m_height; const float scale = (scaleToFitWidth < scaleToFitHeight) ? scaleToFitWidth : scaleToFitHeight; outWidth = static_cast(imageInfo.m_width * scale); @@ -482,19 +502,15 @@ bool JpegToBmpConverter::jpegFileToBmpStream(File& jpegFile, Print& bmpOut) { needsScaling = true; Serial.printf("[%lu] [JPG] Pre-scaling %dx%d -> %dx%d (fit to %dx%d)\n", millis(), imageInfo.m_width, - imageInfo.m_height, outWidth, outHeight, TARGET_MAX_WIDTH, TARGET_MAX_HEIGHT); + imageInfo.m_height, outWidth, outHeight, targetWidth, targetHeight); } // Write BMP header with output dimensions - int bytesPerRow; - if (USE_8BIT_OUTPUT) { - writeBmpHeader8bit(bmpOut, outWidth, outHeight); - bytesPerRow = (outWidth + 3) / 4 * 4; - } else { - writeBmpHeader(bmpOut, outWidth, outHeight); - bytesPerRow = (outWidth * 2 + 31) / 32 * 4; - } - + writeBmpHeader(bmpOut, outWidth, outHeight, bitsPerPixel); + const int bytesPerRow = getBytesPerRow(outWidth, bitsPerPixel); + const int levelCount = 1 << bitsPerPixel; + const bool indexedOutput = bitsPerPixel != 8; + // Allocate row buffer auto* rowBuffer = static_cast(malloc(bytesPerRow)); if (!rowBuffer) { @@ -522,15 +538,15 @@ bool JpegToBmpConverter::jpegFileToBmpStream(File& jpegFile, Print& bmpOut) { return false; } - // Create ditherer if enabled (only for 2-bit output) + // Create ditherer if enabled (only for indexed output) // Use OUTPUT dimensions for dithering (after prescaling) AtkinsonDitherer* atkinsonDitherer = nullptr; FloydSteinbergDitherer* fsDitherer = nullptr; - if (!USE_8BIT_OUTPUT) { + if (indexedOutput) { if (USE_ATKINSON) { - atkinsonDitherer = new AtkinsonDitherer(outWidth); + atkinsonDitherer = new AtkinsonDitherer(outWidth, levelCount); } else if (USE_FLOYD_STEINBERG) { - fsDitherer = new FloydSteinbergDitherer(outWidth); + fsDitherer = new FloydSteinbergDitherer(outWidth, levelCount); } } @@ -612,7 +628,7 @@ bool JpegToBmpConverter::jpegFileToBmpStream(File& jpegFile, Print& bmpOut) { // No scaling - direct output (1:1 mapping) memset(rowBuffer, 0, bytesPerRow); - if (USE_8BIT_OUTPUT) { + if (!indexedOutput) { for (int x = 0; x < outWidth; x++) { const uint8_t gray = mcuRowBuffer[bufferY * imageInfo.m_width + x]; rowBuffer[x] = adjustPixel(gray); @@ -620,17 +636,15 @@ bool JpegToBmpConverter::jpegFileToBmpStream(File& jpegFile, Print& bmpOut) { } else { for (int x = 0; x < outWidth; x++) { const uint8_t gray = mcuRowBuffer[bufferY * imageInfo.m_width + x]; - uint8_t twoBit; + uint8_t indexedValue; if (atkinsonDitherer) { - twoBit = atkinsonDitherer->processPixel(gray, x); + indexedValue = atkinsonDitherer->processPixel(gray, x); } else if (fsDitherer) { - twoBit = fsDitherer->processPixel(gray, x, fsDitherer->isReverseRow()); + indexedValue = fsDitherer->processPixel(gray, x, fsDitherer->isReverseRow()); } else { - twoBit = quantize(gray, x, y); + indexedValue = quantize(gray, x, y, levelCount); } - const int byteIndex = (x * 2) / 8; - const int bitOffset = 6 - ((x * 2) % 8); - rowBuffer[byteIndex] |= (twoBit << bitOffset); + writeIndexedPixel(rowBuffer, x, bitsPerPixel, indexedValue); } if (atkinsonDitherer) atkinsonDitherer->nextRow(); @@ -675,7 +689,7 @@ bool JpegToBmpConverter::jpegFileToBmpStream(File& jpegFile, Print& bmpOut) { if (srcY_fp >= nextOutY_srcStart && currentOutY < outHeight) { memset(rowBuffer, 0, bytesPerRow); - if (USE_8BIT_OUTPUT) { + if (!indexedOutput) { for (int x = 0; x < outWidth; x++) { const uint8_t gray = (rowCount[x] > 0) ? (rowAccum[x] / rowCount[x]) : 0; rowBuffer[x] = adjustPixel(gray); @@ -683,17 +697,15 @@ bool JpegToBmpConverter::jpegFileToBmpStream(File& jpegFile, Print& bmpOut) { } else { for (int x = 0; x < outWidth; x++) { const uint8_t gray = (rowCount[x] > 0) ? (rowAccum[x] / rowCount[x]) : 0; - uint8_t twoBit; + uint8_t indexedValue; if (atkinsonDitherer) { - twoBit = atkinsonDitherer->processPixel(gray, x); + indexedValue = atkinsonDitherer->processPixel(gray, x); } else if (fsDitherer) { - twoBit = fsDitherer->processPixel(gray, x, fsDitherer->isReverseRow()); + indexedValue = fsDitherer->processPixel(gray, x, fsDitherer->isReverseRow()); } else { - twoBit = quantize(gray, x, currentOutY); + indexedValue = quantize(gray, x, currentOutY, levelCount); } - const int byteIndex = (x * 2) / 8; - const int bitOffset = 6 - ((x * 2) % 8); - rowBuffer[byteIndex] |= (twoBit << bitOffset); + writeIndexedPixel(rowBuffer, x, bitsPerPixel, indexedValue); } if (atkinsonDitherer) atkinsonDitherer->nextRow(); diff --git a/lib/JpegToBmpConverter/JpegToBmpConverter.h b/lib/JpegToBmpConverter/JpegToBmpConverter.h index 1cb76e5..21e8772 100644 --- a/lib/JpegToBmpConverter/JpegToBmpConverter.h +++ b/lib/JpegToBmpConverter/JpegToBmpConverter.h @@ -5,11 +5,11 @@ class ZipFile; class JpegToBmpConverter { - static void writeBmpHeader(Print& bmpOut, int width, int height); // [COMMENTED OUT] static uint8_t grayscaleTo2Bit(uint8_t grayscale, int x, int y); static unsigned char jpegReadCallback(unsigned char* pBuf, unsigned char buf_size, unsigned char* pBytes_actually_read, void* pCallback_data); public: static bool jpegFileToBmpStream(File& jpegFile, Print& bmpOut); + static bool jpegFileToBmpStream(File& jpegFile, Print& bmpOut, int bitsPerPixel, int targetWidth, int targetHeight); }; diff --git a/src/CrossPointSettings.h b/src/CrossPointSettings.h index ab591be..c6f1b28 100644 --- a/src/CrossPointSettings.h +++ b/src/CrossPointSettings.h @@ -29,6 +29,9 @@ class CrossPointSettings { uint8_t extraParagraphSpacing = 1; // Duration of the power button press uint8_t shortPwrBtn = 0; + // UI Theme + enum UI_THEME { LIST = 0, GRID = 1 }; + uint8_t uiTheme = GRID; ~CrossPointSettings() = default; diff --git a/src/activities/boot_sleep/SleepActivity.cpp b/src/activities/boot_sleep/SleepActivity.cpp index 4bc70f5..9addfce 100644 --- a/src/activities/boot_sleep/SleepActivity.cpp +++ b/src/activities/boot_sleep/SleepActivity.cpp @@ -182,7 +182,7 @@ void SleepActivity::renderCoverSleepScreen() const { return renderDefaultSleepScreen(); } - if (!lastEpub.generateCoverBmp()) { + if (!lastEpub.generateCoverBmp(false)) { Serial.println("[SLP] Failed to generate cover bmp"); return renderDefaultSleepScreen(); } diff --git a/src/activities/home/GridBrowserActivity.cpp b/src/activities/home/GridBrowserActivity.cpp new file mode 100644 index 0000000..07ac334 --- /dev/null +++ b/src/activities/home/GridBrowserActivity.cpp @@ -0,0 +1,309 @@ +#include "GridBrowserActivity.h" + +#include +#include +#include +#include + +#include "config.h" +#include "../../images/FolderIcon.h" +#include "../util/Window.h" + +namespace { +constexpr int PAGE_ITEMS = 9; +constexpr int SKIP_PAGE_MS = 700; +constexpr int TILE_W = 135; +constexpr int TILE_H = 200; +constexpr int TILE_PADDING = 5; +constexpr int THUMB_W = 90; +constexpr int THUMB_H = 120; +constexpr int TILE_TEXT_H = 60; +constexpr int GRID_OFFSET_LEFT = 37; +constexpr int GRID_OFFSET_TOP = 125; +} // namespace + +inline int min(const int a, const int b) { return a < b ? a : b; } + +void GridBrowserActivity::sortFileList(std::vector& strs) { + std::sort(begin(strs), end(strs), [](const FileInfo& f1, const FileInfo& f2) { + if (f1.type == F_DIRECTORY && f2.type != F_DIRECTORY) return true; + if (f1.type != F_DIRECTORY && f2.type == F_DIRECTORY) return false; + return lexicographical_compare( + begin(f1.name), end(f1.name), begin(f2.name), end(f2.name), + [](const char& char1, const char& char2) { return tolower(char1) < tolower(char2); }); + }); +} + +void GridBrowserActivity::displayTaskTrampoline(void* param) { + auto* self = static_cast(param); + self->displayTaskLoop(); +} + +void GridBrowserActivity::loadThumbsTaskTrampoline(void* param) { + auto* self = static_cast(param); + self->loadThumbsTaskLoop(); +} + +void GridBrowserActivity::loadThumbsTaskLoop() { + while (true) { + if (thumbsLoadingRequired) { + xSemaphoreTake(loadThumbsMutex, portMAX_DELAY); + loadThumbs(); + xSemaphoreGive(loadThumbsMutex); + thumbsLoadingRequired = false; + } + vTaskDelay(10 / portTICK_PERIOD_MS); + } +} + +void GridBrowserActivity::loadThumbs() { + int thumbsCount = min(PAGE_ITEMS, files.size() - page * PAGE_ITEMS); + for (int i = 0; i < thumbsCount; i++) { + const auto file = files[i + page * PAGE_ITEMS]; + if (file.type == F_EPUB) { + if (file.thumbPath.empty()) { + Serial.printf("[%lu] Loading thumb for epub: %s\n", millis(), file.name.c_str()); + std::string thumbPath = loadEpubThumb(basepath + "/" + file.name); + if (!thumbPath.empty()) { + files[i + page * PAGE_ITEMS].thumbPath = thumbPath; + } + renderRequired = true; + taskYIELD(); + } + } + } +} + +std::string GridBrowserActivity::loadEpubThumb(std::string path) { + File file; + Epub epubFile(path, "/.crosspoint"); + if (!epubFile.load()) { + Serial.printf("[%lu] Failed to load epub: %s\n", millis(), path.c_str()); + return ""; + } + if (!epubFile.generateCoverBmp(true)) { + Serial.printf("[%lu] Failed to generate epub thumb\n", millis()); + return ""; + } + std::string thumbPath = epubFile.getThumbBmpPath(); + Serial.printf("[%lu] epub has thumb at %s\n", millis(), thumbPath.c_str()); + return thumbPath; +} + +void GridBrowserActivity::loadFiles() { + files.clear(); + selectorIndex = 0; + previousSelectorIndex = -1; + page = 0; + auto root = SD.open(basepath.c_str()); + int count = 0; + for (File file = root.openNextFile(); file; file = root.openNextFile()) { + const std::string filename = std::string(file.name()); + if (filename.empty() || filename[0] == '.') { + file.close(); + continue; + } + + if (file.isDirectory()) { + files.emplace_back(FileInfo{ filename, filename, F_DIRECTORY, "" }); + } else { + FileType type = F_FILE; + size_t dot = filename.find_first_of('.'); + std::string basename = filename; + if (dot != std::string::npos) { + std::string ext = filename.substr(dot); + basename = filename.substr(0, dot); + // lowercase ext for case-insensitive compare + std::transform(ext.begin(), ext.end(), ext.begin(), [](unsigned char c){ return std::tolower(c); }); + if (ext == ".epub") { + type = F_EPUB; + } else if (ext == ".bmp") { + type = F_BMP; + } + } + if (type != F_FILE) { + files.emplace_back(FileInfo{ filename, basename, type, "" }); + } + } + file.close(); + count++; + } + root.close(); + GridBrowserActivity::sortFileList(files); +} + +void GridBrowserActivity::onEnter() { + renderingMutex = xSemaphoreCreateMutex(); + loadThumbsMutex = xSemaphoreCreateMutex(); + + page = 0; + loadFiles(); + onPageChanged(); + + xTaskCreate(&GridBrowserActivity::displayTaskTrampoline, "GridFileBrowserTask", + 8192, // Stack size + this, // Parameters + 2, // Priority + &displayTaskHandle // Task handle + ); + xTaskCreate(&GridBrowserActivity::loadThumbsTaskTrampoline, "LoadThumbsTask", + 8192, // Stack size + this, // Parameters + 1, // Priority + &loadThumbsTaskHandle // Task handle + ); +} + +void GridBrowserActivity::onExit() { + Activity::onExit(); + + // Wait until not rendering to delete task to avoid killing mid-instruction to EPD + xSemaphoreTake(renderingMutex, portMAX_DELAY); + if (displayTaskHandle) { + vTaskDelete(displayTaskHandle); + displayTaskHandle = nullptr; + } + vSemaphoreDelete(renderingMutex); + renderingMutex = nullptr; + + if (loadThumbsTaskHandle) { + vTaskDelete(loadThumbsTaskHandle); + loadThumbsTaskHandle = nullptr; + } + vSemaphoreDelete(loadThumbsMutex); + loadThumbsMutex = nullptr; + + files.clear(); +} + +void GridBrowserActivity::onPageChanged() { + selectorIndex = 0; + previousSelectorIndex = -1; + renderRequired = true; + thumbsLoadingRequired = true; +} + +void GridBrowserActivity::loop() { + const bool prevReleased = inputManager.wasReleased(InputManager::BTN_UP) || inputManager.wasReleased(InputManager::BTN_LEFT); + const bool nextReleased = inputManager.wasReleased(InputManager::BTN_DOWN) || inputManager.wasReleased(InputManager::BTN_RIGHT); + const bool skipPage = inputManager.getHeldTime() > SKIP_PAGE_MS; + const int selected = selectorIndex + page * PAGE_ITEMS; + + if (inputManager.wasPressed(InputManager::BTN_CONFIRM)) { + if (files.empty()) { + return; + } + + if (basepath.back() != '/') { + basepath += "/"; + } + if (files[selected].type == F_DIRECTORY) { + // open subfolder + basepath += files[selected].name; + loadFiles(); + onPageChanged(); + } else { + onSelect(basepath + files[selected].name); + } + } else if (inputManager.wasPressed(InputManager::BTN_BACK)) { + if (basepath != "/") { + basepath.resize(basepath.rfind('/')); + if (basepath.empty()) basepath = "/"; + loadFiles(); + onPageChanged(); + } else { + // At root level, go back home + onGoHome(); + } + } else if (prevReleased) { + previousSelectorIndex = selectorIndex; + if (selectorIndex == 0 || skipPage) { + if (page > 0) { + page--; + onPageChanged(); + } + } else { + selectorIndex--; + updateRequired = true; + } + } else if (nextReleased) { + previousSelectorIndex = selectorIndex; + if (selectorIndex == min(PAGE_ITEMS, files.size() - page * PAGE_ITEMS) - 1 || skipPage) { + if (page < files.size() / PAGE_ITEMS) { + page++; + onPageChanged(); + } + } else { + selectorIndex++; + updateRequired = true; + } + } +} + +void GridBrowserActivity::displayTaskLoop() { + while (true) { + if (renderRequired || updateRequired) { + bool didRequireRender = renderRequired; + renderRequired = false; + updateRequired = false; + xSemaphoreTake(renderingMutex, portMAX_DELAY); + render(didRequireRender); + xSemaphoreGive(renderingMutex); + } + vTaskDelay(10 / portTICK_PERIOD_MS); + } +} + +void GridBrowserActivity::render(bool clear) const { + if (clear) { + renderer.clearScreen(); + auto folderName = basepath == "/" ? "SD card" : basepath.substr(basepath.rfind('/') + 1).c_str(); + drawFullscreenWindowFrame(renderer, folderName); + } + + if (!files.empty()) { + for (size_t i = 0; i < min(PAGE_ITEMS, files.size() - page * PAGE_ITEMS); i++) { + const auto file = files[i + page * PAGE_ITEMS]; + + const int16_t tileX = GRID_OFFSET_LEFT + i % 3 * TILE_W; + const int16_t tileY = GRID_OFFSET_TOP + i / 3 * TILE_H; + + if (file.type == F_DIRECTORY) { + constexpr int iconOffsetX = (TILE_W - FOLDERICON_WIDTH) / 2; + constexpr int iconOffsetY = (TILE_H - TILE_TEXT_H - FOLDERICON_HEIGHT) / 2; + renderer.drawIcon(FolderIcon, tileX + iconOffsetX, tileY + iconOffsetY, FOLDERICON_WIDTH, FOLDERICON_HEIGHT); + } + + if (!file.thumbPath.empty()) { + Serial.printf("Rendering file thumb: %s\n", file.thumbPath.c_str()); + File bmpFile = SD.open(file.thumbPath.c_str()); + if (bmpFile) { + Bitmap bitmap(bmpFile); + if (bitmap.parseHeaders() == BmpReaderError::Ok) { + constexpr int thumbOffsetX = (TILE_W - THUMB_W) / 2; + constexpr int thumbOffsetY = (TILE_H - TILE_TEXT_H - THUMB_H) / 2; + renderer.drawBitmap(bitmap, tileX + thumbOffsetX, tileY + thumbOffsetY, THUMB_W, THUMB_H); + } + } + } + + renderer.drawTextInBox(UI_FONT_ID, tileX + TILE_PADDING, tileY + TILE_H - TILE_TEXT_H, TILE_W - 2 * TILE_PADDING, TILE_TEXT_H, file.basename.c_str(), true); + } + + update(false); + renderer.displayBuffer(); + } +} + +void GridBrowserActivity::drawSelectionRectangle(int tileIndex, bool black) const { + renderer.drawRoundedRect(GRID_OFFSET_LEFT + tileIndex % 3 * TILE_W, GRID_OFFSET_TOP + tileIndex / 3 * TILE_H, TILE_W, TILE_H, 2, 5, black); +} + +void GridBrowserActivity::update(bool render) const { + // Redraw only changed tiles + // renderer.clearScreen(); + if (previousSelectorIndex >= 0) { + drawSelectionRectangle(previousSelectorIndex, false); + } + drawSelectionRectangle(selectorIndex, true); +} \ No newline at end of file diff --git a/src/activities/home/GridBrowserActivity.h b/src/activities/home/GridBrowserActivity.h new file mode 100644 index 0000000..6343663 --- /dev/null +++ b/src/activities/home/GridBrowserActivity.h @@ -0,0 +1,69 @@ +#pragma once +#include +#include +#include + +#include +#include +#include + +#include "../Activity.h" + +enum FileType { + F_DIRECTORY = 0, + F_EPUB, + F_TXT, + F_BMP, + F_FILE +}; + +struct FileInfo { + std::string name; + std::string basename; + FileType type; + std::string thumbPath; +}; + +class GridBrowserActivity final : public Activity { + TaskHandle_t displayTaskHandle = nullptr; + SemaphoreHandle_t renderingMutex = nullptr; + TaskHandle_t loadThumbsTaskHandle = nullptr; + SemaphoreHandle_t loadThumbsMutex = nullptr; + std::string basepath = "/"; + std::vector files; + int selectorIndex = 0; + int previousSelectorIndex = -1; + int page = 0; + bool updateRequired = false; + bool renderRequired = false; + bool thumbsLoadingRequired = false; + const std::function onSelect; + const std::function onGoHome; + + static void displayTaskTrampoline(void* param); + [[noreturn]] void displayTaskLoop(); + static void loadThumbsTaskTrampoline(void* param); + void render(bool clear) const; + void update(bool render) const; + void loadFiles(); + void drawSelectionRectangle(int tileIndex, bool black) const; + std::string loadEpubThumb(std::string path); + void loadThumbsTaskLoop(); + void loadThumbs(); + void onPageChanged(); + + public: + explicit GridBrowserActivity(GfxRenderer& renderer, InputManager& inputManager, + const std::function& onSelect, + const std::function& onGoHome, + std::string initialPath = "/") + : Activity("FileSelection", renderer, inputManager), + onSelect(onSelect), + onGoHome(onGoHome), + basepath(initialPath.empty() ? "/" : std::move(initialPath)) {} + void onEnter() override; + void onExit() override; + void loop() override; + private: + static void sortFileList(std::vector& strs); +}; diff --git a/src/activities/reader/ReaderActivity.cpp b/src/activities/reader/ReaderActivity.cpp index 519a33a..54927bf 100644 --- a/src/activities/reader/ReaderActivity.cpp +++ b/src/activities/reader/ReaderActivity.cpp @@ -5,7 +5,9 @@ #include "Epub.h" #include "EpubReaderActivity.h" #include "FileSelectionActivity.h" +#include "../home/GridBrowserActivity.h" #include "activities/util/FullScreenMessageActivity.h" +#include "../../CrossPointSettings.h" std::string ReaderActivity::extractFolderPath(const std::string& filePath) { const auto lastSlash = filePath.find_last_of('/'); @@ -51,8 +53,13 @@ void ReaderActivity::onGoToFileSelection(const std::string& fromEpubPath) { exitActivity(); // If coming from a book, start in that book's folder; otherwise start from root const auto initialPath = fromEpubPath.empty() ? "/" : extractFolderPath(fromEpubPath); - enterNewActivity(new FileSelectionActivity( - renderer, inputManager, [this](const std::string& path) { onSelectEpubFile(path); }, onGoBack, initialPath)); + if (SETTINGS.uiTheme == CrossPointSettings::GRID) { + enterNewActivity(new GridBrowserActivity( + renderer, inputManager, [this](const std::string& path) { onSelectEpubFile(path); }, onGoBack, initialPath)); + } else { + enterNewActivity(new FileSelectionActivity( + renderer, inputManager, [this](const std::string& path) { onSelectEpubFile(path); }, onGoBack, initialPath)); + } } void ReaderActivity::onGoToEpubReader(std::unique_ptr epub) { diff --git a/src/activities/settings/SettingsActivity.cpp b/src/activities/settings/SettingsActivity.cpp index b71a877..3d453a4 100644 --- a/src/activities/settings/SettingsActivity.cpp +++ b/src/activities/settings/SettingsActivity.cpp @@ -9,14 +9,15 @@ // Define the static settings list namespace { -constexpr int settingsCount = 5; +constexpr int settingsCount = 6; const SettingInfo settingsList[settingsCount] = { // Should match with SLEEP_SCREEN_MODE {"Sleep Screen", SettingType::ENUM, &CrossPointSettings::sleepScreen, {"Dark", "Light", "Custom", "Cover"}}, {"Status Bar", SettingType::ENUM, &CrossPointSettings::statusBar, {"None", "No Progress", "Full"}}, {"Extra Paragraph Spacing", SettingType::TOGGLE, &CrossPointSettings::extraParagraphSpacing, {}}, {"Short Power Button Click", SettingType::TOGGLE, &CrossPointSettings::shortPwrBtn, {}}, - {"Check for updates", SettingType::ACTION, nullptr, {}}, + {"UI Theme", SettingType::ENUM, &CrossPointSettings::uiTheme, {"List", "Grid"}}, + {"Check for updates", SettingType::ACTION, nullptr, {}} }; } // namespace diff --git a/src/activities/util/Window.cpp b/src/activities/util/Window.cpp new file mode 100644 index 0000000..c701d4d --- /dev/null +++ b/src/activities/util/Window.cpp @@ -0,0 +1,77 @@ +#include "./Window.h" +#include "Battery.h" +#include "config.h" + +namespace { +constexpr int windowCornerRadius = 16; +constexpr int windowBorderWidth = 2; +constexpr int fullscreenWindowMargin = 20; +constexpr int windowHeaderHeight = 50; +constexpr int statusBarHeight = 50; +constexpr int batteryWidth = 15; +constexpr int batteryHeight = 10; +} // namespace + +void drawWindowFrame(GfxRenderer& renderer, int xMargin, int y, int height, bool hasShadow, const char* title) { + const int windowWidth = GfxRenderer::getScreenWidth() - 2 * xMargin; + + if (title) { // Header background + renderer.fillRectGrey(xMargin, y, windowWidth, windowHeaderHeight, 5); + renderer.fillArc(windowCornerRadius, xMargin + windowCornerRadius, y + windowCornerRadius, -1, -1, 0, -1); // TL + renderer.fillArc(windowCornerRadius, windowWidth + xMargin - windowCornerRadius, y + windowCornerRadius, 1, -1, 0, -1); // TR + } + + renderer.drawRoundedRect(xMargin, y, windowWidth, height, windowBorderWidth, windowCornerRadius, true); + + if (hasShadow) { + renderer.drawLine(windowWidth + xMargin, y + windowCornerRadius + 2, windowWidth + xMargin, y + height - windowCornerRadius, windowBorderWidth, true); + renderer.drawLine(xMargin + windowCornerRadius + 2, y + height, windowWidth + xMargin - windowCornerRadius, y + height, windowBorderWidth, true); + renderer.drawArc(windowCornerRadius + windowBorderWidth, windowWidth + xMargin - 1 - windowCornerRadius, y + height - 1 - windowCornerRadius, 1, 1, windowBorderWidth, true); + renderer.drawPixel(xMargin + windowCornerRadius + 1, y + height, true); + } + + if (title) { // Header + const int titleWidth = renderer.getTextWidth(UI_FONT_ID, title); + const int titleX = (GfxRenderer::getScreenWidth() - titleWidth) / 2; + const int titleY = y + 10; + renderer.drawText(UI_FONT_ID, titleX, titleY, title, true, REGULAR); + renderer.drawLine(xMargin, y + windowHeaderHeight, windowWidth + xMargin, y + windowHeaderHeight, windowBorderWidth, true); + } +} + +void drawFullscreenWindowFrame(GfxRenderer& renderer, const char* title) { + drawStatusBar(renderer); + drawWindowFrame(renderer, fullscreenWindowMargin, statusBarHeight, GfxRenderer::getScreenHeight() - fullscreenWindowMargin - statusBarHeight, true, title); +} + +void drawStatusBar(GfxRenderer& renderer) { + constexpr auto textY = 18; + + // Left aligned battery icon and percentage + const uint16_t percentage = battery.readPercentage(); + const auto percentageText = std::to_string(percentage) + "%"; + renderer.drawText(SMALL_FONT_ID, fullscreenWindowMargin + batteryWidth + 5, textY, percentageText.c_str()); + + // 1 column on left, 2 columns on right, 5 columns of battery body + constexpr int x = fullscreenWindowMargin; + constexpr int y = textY + 5; + + // Top line + renderer.drawLine(x, y, x + batteryWidth - 4, y); + // Bottom line + renderer.drawLine(x, y + batteryHeight - 1, x + batteryWidth - 4, y + batteryHeight - 1); + // Left line + renderer.drawLine(x, y, x, y + batteryHeight - 1); + // Battery end + renderer.drawLine(x + batteryWidth - 4, y, x + batteryWidth - 4, y + batteryHeight - 1); + renderer.drawLine(x + batteryWidth - 3, y + 2, x + batteryWidth - 1, y + 2); + renderer.drawLine(x + batteryWidth - 3, y + batteryHeight - 3, x + batteryWidth - 1, y + batteryHeight - 3); + renderer.drawLine(x + batteryWidth - 1, y + 2, x + batteryWidth - 1, y + batteryHeight - 3); + + // The +1 is to round up, so that we always fill at least one pixel + int filledWidth = percentage * (batteryWidth - 5) / 100 + 1; + if (filledWidth > batteryWidth - 5) { + filledWidth = batteryWidth - 5; // Ensure we don't overflow + } + renderer.fillRect(x + 1, y + 1, filledWidth, batteryHeight - 2); +} \ No newline at end of file diff --git a/src/activities/util/Window.h b/src/activities/util/Window.h new file mode 100644 index 0000000..7315322 --- /dev/null +++ b/src/activities/util/Window.h @@ -0,0 +1,6 @@ +#pragma once +#include + +void drawWindowFrame(GfxRenderer& renderer, int xMargin, int y, int height, bool hasShadow, const char* title); +void drawFullscreenWindowFrame(GfxRenderer& renderer, const char* title); +void drawStatusBar(GfxRenderer& renderer); \ No newline at end of file diff --git a/src/images/FolderIcon.h b/src/images/FolderIcon.h new file mode 100644 index 0000000..a4e6ef0 --- /dev/null +++ b/src/images/FolderIcon.h @@ -0,0 +1,58 @@ +#pragma once +#include + +#define FOLDERICON_WIDTH 80 +#define FOLDERICON_HEIGHT 80 + +static const uint8_t FolderIcon[] = { + 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, + 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, + 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xE0, 0x00, 0x00, 0x00, 0x00, + 0x0F, 0xFF, 0xFF, 0xFF, 0xFF, 0xC0, 0x00, 0x00, 0x00, 0x00, 0x07, 0xFF, 0xFF, 0xFF, 0xFE, 0x00, + 0x00, 0x00, 0x00, 0x00, 0x03, 0xFF, 0xFF, 0xFF, 0xFC, 0x00, 0x00, 0x00, 0x00, 0x00, 0x01, 0xFF, + 0xFF, 0xFF, 0xF8, 0xFF, 0x3F, 0xFF, 0xFF, 0xFE, 0x01, 0xFF, 0xFF, 0xFF, 0xF1, 0xFF, 0x3F, 0xFF, + 0xFF, 0xFF, 0x00, 0xFF, 0xFF, 0xFF, 0xF3, 0xFF, 0x3F, 0xFF, 0xFF, 0xFF, 0x80, 0xFF, 0xFF, 0xFF, + 0xE7, 0xFF, 0x3F, 0xFF, 0xFF, 0xFF, 0x80, 0xFF, 0xFF, 0xFF, 0xE7, 0xFF, 0x3F, 0xFF, 0xFF, 0xFF, + 0xC0, 0xFF, 0xFF, 0xFF, 0xE7, 0xFF, 0x3F, 0xFF, 0xFF, 0xFF, 0xC0, 0xFF, 0xFF, 0xFF, 0xE7, 0xFF, + 0x3F, 0xFF, 0xFF, 0xFF, 0xC0, 0xFF, 0xFF, 0xFF, 0xE7, 0xFF, 0x3F, 0xFF, 0xFF, 0xFF, 0xC0, 0xFF, + 0xFF, 0xFF, 0xE7, 0xFF, 0x3F, 0xFF, 0xFF, 0xFF, 0xC0, 0xFF, 0xFF, 0xFF, 0xE7, 0xFF, 0x3F, 0xFF, + 0xFF, 0xFF, 0xC0, 0xFF, 0xFF, 0xFF, 0xE7, 0xFF, 0x3F, 0xFF, 0xFF, 0xFF, 0xC0, 0xFF, 0xFF, 0xFF, + 0xE7, 0xFF, 0x3F, 0xFF, 0xFF, 0xFF, 0xC0, 0xFF, 0xFF, 0xFF, 0xE7, 0xFF, 0x3F, 0xFF, 0xFF, 0xFF, + 0xC0, 0xFF, 0xFF, 0xFF, 0xE7, 0xFF, 0x3F, 0xFF, 0xFF, 0xFF, 0xC0, 0xFF, 0xFF, 0xFF, 0xE7, 0xFF, + 0x3F, 0xFF, 0xFF, 0xFF, 0xC0, 0xFF, 0xFF, 0xFF, 0xE7, 0xFF, 0x3F, 0xFF, 0xFF, 0xFF, 0xC0, 0xFF, + 0xFF, 0xFF, 0xE7, 0xFF, 0x3F, 0xFF, 0xFF, 0xFF, 0xC0, 0xFF, 0xFF, 0xFF, 0xE7, 0xFF, 0x3F, 0xFF, + 0xFF, 0xFF, 0xC0, 0xFF, 0xFF, 0xFF, 0xE7, 0xFF, 0x3F, 0xFF, 0xFF, 0xFF, 0xC0, 0xFF, 0xFF, 0xFF, + 0xE7, 0xFF, 0x3F, 0xFF, 0xFF, 0xFF, 0xC0, 0xFF, 0xFF, 0xFF, 0xE7, 0xFF, 0x3F, 0xFF, 0xFF, 0xFF, + 0xC0, 0xFF, 0xFF, 0xFF, 0xE7, 0xFF, 0x3F, 0xFF, 0xFF, 0xFF, 0xC0, 0xFF, 0xFF, 0xFF, 0xE7, 0xFF, + 0x3F, 0xFF, 0xFF, 0xFF, 0xC0, 0xFF, 0xFF, 0xFF, 0xE7, 0xFF, 0x3F, 0xFF, 0xFF, 0xFF, 0xC0, 0xFF, + 0xFF, 0xFF, 0xE7, 0xFF, 0x3F, 0xFF, 0xFF, 0xFF, 0xC0, 0xFF, 0xFF, 0xFF, 0xE7, 0xFF, 0x3F, 0xFF, + 0xFF, 0xFF, 0xC0, 0xFF, 0xFF, 0xFF, 0xE7, 0xFF, 0x3F, 0xFF, 0xFF, 0xFF, 0xC0, 0xFF, 0xFF, 0xFF, + 0xE7, 0xFF, 0x3F, 0xFF, 0xFF, 0xFF, 0xC0, 0xFF, 0xFF, 0xFF, 0xE7, 0xFF, 0x3F, 0xFF, 0xFF, 0xFF, + 0xC0, 0xFF, 0xFF, 0xFF, 0xE7, 0xFF, 0x3F, 0xFF, 0xFF, 0xFF, 0xC0, 0xFF, 0xFF, 0xFF, 0xE7, 0xFF, + 0x3F, 0xFF, 0xFF, 0xFF, 0xC0, 0xFF, 0xFF, 0xFF, 0xE7, 0xFF, 0x3F, 0xFF, 0xFF, 0xFF, 0xC0, 0xFF, + 0xFF, 0xFF, 0xE7, 0xFF, 0x3F, 0xFF, 0xFF, 0xFF, 0xC0, 0xFF, 0xFF, 0xFF, 0xE7, 0xFF, 0x3F, 0xFF, + 0xFF, 0xFF, 0xC0, 0xFF, 0xFF, 0xFF, 0xCF, 0xFF, 0x3F, 0xFF, 0xFF, 0xFF, 0xC0, 0xFF, 0xFF, 0xFF, + 0x8F, 0xFF, 0x3F, 0xFF, 0xFF, 0xFF, 0xC0, 0xFF, 0xFF, 0xFF, 0x1F, 0xFF, 0x3F, 0xFF, 0xFF, 0xFF, + 0xC0, 0xFF, 0xFF, 0xFE, 0x1F, 0xFF, 0x3F, 0xFF, 0xFF, 0xFF, 0xC0, 0xFF, 0xFF, 0xF8, 0x7F, 0xFF, + 0x3F, 0xFF, 0xFF, 0xFF, 0xC0, 0xFF, 0xFF, 0xF0, 0xFF, 0xFF, 0x3F, 0xFF, 0xFF, 0xFF, 0xC0, 0xFF, + 0xFF, 0xE3, 0xFF, 0xFF, 0x3F, 0xFF, 0xFF, 0xFF, 0xC0, 0xFF, 0xFF, 0xC7, 0xFF, 0xFF, 0x3F, 0xFF, + 0xFF, 0xFF, 0xC0, 0xFF, 0xFF, 0xCF, 0xFF, 0xFF, 0x3F, 0xFF, 0xFF, 0xFF, 0xC0, 0xFF, 0xFF, 0x9F, + 0xFF, 0xFF, 0x3F, 0xFF, 0xFF, 0xFF, 0xC0, 0xFF, 0xFF, 0x9F, 0xFF, 0xFF, 0x3F, 0xFF, 0xFF, 0xFF, + 0xC0, 0xFF, 0xFF, 0x9F, 0xFF, 0xFF, 0x3F, 0xFF, 0xFF, 0xFF, 0xC0, 0xFF, 0xFF, 0x9F, 0xFF, 0xFF, + 0x3F, 0xFF, 0xFF, 0xFF, 0xC0, 0xFF, 0xFF, 0x9F, 0xFF, 0xFF, 0x3F, 0xFF, 0xFF, 0xFF, 0xC0, 0xFF, + 0xFF, 0x9F, 0xFF, 0xFF, 0x3F, 0xFF, 0xFF, 0xFF, 0xC0, 0xFF, 0xFF, 0x9F, 0xFF, 0xFF, 0x3F, 0xFF, + 0xFF, 0xFF, 0xC0, 0xFF, 0xFF, 0x9F, 0xFF, 0xFF, 0x3F, 0xFF, 0xFF, 0xFF, 0xC0, 0xFF, 0xFF, 0x9F, + 0xFF, 0xFF, 0x3F, 0xFF, 0xFF, 0xFF, 0xC0, 0xFF, 0xFF, 0x9F, 0xFF, 0xFF, 0x3F, 0xFF, 0xFF, 0xFF, + 0xC0, 0xFF, 0xFF, 0x9F, 0xFF, 0xFF, 0x3F, 0xFF, 0xFF, 0xFF, 0xC0, 0xFF, 0xFF, 0x9F, 0xFF, 0xFF, + 0x3F, 0xFF, 0xFF, 0xFF, 0xC0, 0xFF, 0xFF, 0x9F, 0xFF, 0xFF, 0x3F, 0xFF, 0xFF, 0xFF, 0xC0, 0xFF, + 0xFF, 0x9F, 0xFF, 0xFF, 0x3F, 0xFF, 0xFF, 0xFF, 0xC0, 0xFF, 0xFF, 0x9F, 0xFF, 0xFF, 0x3F, 0xFF, + 0xFF, 0xFF, 0xC0, 0xFF, 0xFF, 0x9F, 0xFF, 0xFF, 0x3F, 0xFF, 0xFF, 0xFF, 0xC0, 0xFF, 0xFF, 0x9F, + 0xFF, 0xFF, 0x3F, 0xFF, 0xFF, 0xFF, 0x81, 0xFF, 0xFF, 0x9F, 0xFF, 0xFF, 0x3F, 0xFF, 0xFF, 0xFF, + 0x81, 0xFF, 0xFF, 0x8F, 0xFF, 0xFF, 0x3F, 0xFF, 0xFF, 0xFF, 0x83, 0xFF, 0xFF, 0xC7, 0xFF, 0xFF, + 0x3F, 0xFF, 0xFF, 0xFF, 0x07, 0xFF, 0xFF, 0xE3, 0xFF, 0xFF, 0x3F, 0xFF, 0xFF, 0xFE, 0x0F, 0xFF, + 0xFF, 0xF0, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x7F, 0xFF, 0xFF, 0xF8, 0x00, 0x00, 0x00, 0x00, + 0x00, 0x00, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, + 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, + 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, + 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF +};