diff --git a/lib/GfxRenderer/Bitmap.cpp b/lib/GfxRenderer/Bitmap.cpp index ad25ffcd..4f7173a2 100644 --- a/lib/GfxRenderer/Bitmap.cpp +++ b/lib/GfxRenderer/Bitmap.cpp @@ -3,43 +3,88 @@ #include #include +#include "BitmapHelpers.h" + // ============================================================================ -// IMAGE PROCESSING OPTIONS - Toggle these to test different configurations -// ============================================================================ -// Note: For cover images, dithering is done in JpegToBmpConverter.cpp -// This file handles BMP reading - use simple quantization to avoid double-dithering -constexpr bool USE_ATKINSON = true; // Use Atkinson dithering instead of Floyd-Steinberg +// IMAGE PROCESSING OPTIONS // ============================================================================ +constexpr bool USE_ATKINSON = true; Bitmap::~Bitmap() { delete[] errorCurRow; delete[] errorNextRow; - delete atkinsonDitherer; delete fsDitherer; } -uint16_t Bitmap::readLE16(FsFile& f) { - const int c0 = f.read(); - const int c1 = f.read(); - const auto b0 = static_cast(c0 < 0 ? 0 : c0); - const auto b1 = static_cast(c1 < 0 ? 0 : c1); - return static_cast(b0) | (static_cast(b1) << 8); +// =================================== +// IO Helpers +// =================================== + +int Bitmap::readByte() const { + if (file && *file) { + return file->read(); + } else if (memoryBuffer) { + if (bufferPos < memorySize) { + return memoryBuffer[bufferPos++]; + } + return -1; + } + return -1; } -uint32_t Bitmap::readLE32(FsFile& f) { - const int c0 = f.read(); - const int c1 = f.read(); - const int c2 = f.read(); - const int c3 = f.read(); +size_t Bitmap::readBytes(void* buf, size_t count) const { + if (file && *file) { + return file->read(buf, count); + } else if (memoryBuffer) { + size_t available = memorySize - bufferPos; + if (count > available) count = available; + memcpy(buf, memoryBuffer + bufferPos, count); + bufferPos += count; + return count; + } + return 0; +} - const auto b0 = static_cast(c0 < 0 ? 0 : c0); - const auto b1 = static_cast(c1 < 0 ? 0 : c1); - const auto b2 = static_cast(c2 < 0 ? 0 : c2); - const auto b3 = static_cast(c3 < 0 ? 0 : c3); +bool Bitmap::seekSet(uint32_t pos) const { + if (file && *file) { + return file->seek(pos); + } else if (memoryBuffer) { + if (pos <= memorySize) { + bufferPos = pos; + return true; + } + return false; + } + return false; +} - return static_cast(b0) | (static_cast(b1) << 8) | (static_cast(b2) << 16) | - (static_cast(b3) << 24); +bool Bitmap::seekCur(int32_t offset) const { + if (file && *file) { + return file->seekCur(offset); + } else if (memoryBuffer) { + if (bufferPos + offset <= memorySize) { + bufferPos += offset; + return true; + } + return false; + } + return false; +} + +uint16_t Bitmap::readLE16() { + const int c0 = readByte(); + const int c1 = readByte(); + return static_cast(c0 & 0xFF) | (static_cast(c1 & 0xFF) << 8); +} + +uint32_t Bitmap::readLE32() { + const int c0 = readByte(); + const int c1 = readByte(); + const int c2 = readByte(); + const int c3 = readByte(); + return static_cast(c0 & 0xFF) | (static_cast(c1 & 0xFF) << 8) | + (static_cast(c2 & 0xFF) << 16) | (static_cast(c3 & 0xFF) << 24); } const char* Bitmap::errorToString(BmpReaderError err) { @@ -51,27 +96,25 @@ const char* Bitmap::errorToString(BmpReaderError err) { case BmpReaderError::SeekStartFailed: return "SeekStartFailed"; case BmpReaderError::NotBMP: - return "NotBMP (missing 'BM')"; + return "NotBMP"; case BmpReaderError::DIBTooSmall: - return "DIBTooSmall (<40 bytes)"; + return "DIBTooSmall"; case BmpReaderError::BadPlanes: - return "BadPlanes (!= 1)"; + return "BadPlanes"; case BmpReaderError::UnsupportedBpp: - return "UnsupportedBpp (expected 1, 2, 8, 24, or 32)"; + return "UnsupportedBpp"; case BmpReaderError::UnsupportedCompression: - return "UnsupportedCompression (expected BI_RGB or BI_BITFIELDS for 32bpp)"; + return "UnsupportedCompression"; case BmpReaderError::BadDimensions: return "BadDimensions"; case BmpReaderError::ImageTooLarge: - return "ImageTooLarge (max 2048x3072)"; + return "ImageTooLarge"; case BmpReaderError::PaletteTooLarge: return "PaletteTooLarge"; - case BmpReaderError::SeekPixelDataFailed: return "SeekPixelDataFailed"; case BmpReaderError::BufferTooSmall: return "BufferTooSmall"; - case BmpReaderError::OomRowBuffer: return "OomRowBuffer"; case BmpReaderError::ShortReadRow: @@ -81,67 +124,85 @@ const char* Bitmap::errorToString(BmpReaderError err) { } BmpReaderError Bitmap::parseHeaders() { - if (!file) return BmpReaderError::FileInvalid; - if (!file.seek(0)) return BmpReaderError::SeekStartFailed; + if (!file && !memoryBuffer) return BmpReaderError::FileInvalid; + if (!seekSet(0)) return BmpReaderError::SeekStartFailed; - // --- BMP FILE HEADER --- - const uint16_t bfType = readLE16(file); + const uint16_t bfType = readLE16(); if (bfType != 0x4D42) return BmpReaderError::NotBMP; - file.seekCur(8); - bfOffBits = readLE32(file); + seekCur(8); + bfOffBits = readLE32(); - // --- DIB HEADER --- - const uint32_t biSize = readLE32(file); + const uint32_t biSize = readLE32(); if (biSize < 40) return BmpReaderError::DIBTooSmall; - width = static_cast(readLE32(file)); - const auto rawHeight = static_cast(readLE32(file)); + width = static_cast(readLE32()); + const auto rawHeight = static_cast(readLE32()); topDown = rawHeight < 0; height = topDown ? -rawHeight : rawHeight; - const uint16_t planes = readLE16(file); - bpp = readLE16(file); - const uint32_t comp = readLE32(file); + const uint16_t planes = readLE16(); + bpp = readLE16(); + const uint32_t comp = readLE32(); const bool validBpp = bpp == 1 || bpp == 2 || bpp == 8 || bpp == 24 || bpp == 32; if (planes != 1) return BmpReaderError::BadPlanes; if (!validBpp) return BmpReaderError::UnsupportedBpp; - // Allow BI_RGB (0) for all, and BI_BITFIELDS (3) for 32bpp which is common for BGRA masks. if (!(comp == 0 || (bpp == 32 && comp == 3))) return BmpReaderError::UnsupportedCompression; - file.seekCur(12); // biSizeImage, biXPelsPerMeter, biYPelsPerMeter - const uint32_t colorsUsed = readLE32(file); + seekCur(12); + const uint32_t colorsUsed = readLE32(); if (colorsUsed > 256u) return BmpReaderError::PaletteTooLarge; - file.seekCur(4); // biClrImportant + seekCur(4); + + // Robustness Fix: Skip extended header bytes (V4/V5) + if (biSize > 40) { + seekCur(biSize - 40); + } if (width <= 0 || height <= 0) return BmpReaderError::BadDimensions; - // Safety limits to prevent memory issues on ESP32 constexpr int MAX_IMAGE_WIDTH = 2048; constexpr int MAX_IMAGE_HEIGHT = 3072; if (width > MAX_IMAGE_WIDTH || height > MAX_IMAGE_HEIGHT) { return BmpReaderError::ImageTooLarge; } - // Pre-calculate Row Bytes to avoid doing this every row rowBytes = (width * bpp + 31) / 32 * 4; - for (int i = 0; i < 256; i++) paletteLum[i] = static_cast(i); - if (colorsUsed > 0) { - for (uint32_t i = 0; i < colorsUsed; i++) { + // Initialize safe default palette + if (bpp == 1) { + // For 1-bit, default to Black(0) and White(1) + paletteLum[0] = 0; + paletteLum[1] = 255; + } else if (bpp <= 8) { + int maxIdx = (1 << bpp) - 1; + for (int i = 0; i <= maxIdx; i++) { + paletteLum[i] = (i * 255) / maxIdx; + } + } else { + for (int i = 0; i < 256; i++) paletteLum[i] = static_cast(i); + } + + // If indexed color (<=8bpp), we MUST load the palette. + // The palette is located AFTER the DIB header. + if (bpp <= 8) { + // Explicit seek to palette start + if (!seekSet(14 + biSize)) return BmpReaderError::SeekStartFailed; + + uint32_t colorsToRead = colorsUsed; + if (colorsToRead == 0) colorsToRead = 1 << bpp; + if (colorsToRead > 256) colorsToRead = 256; + + for (uint32_t i = 0; i < colorsToRead; i++) { uint8_t rgb[4]; - file.read(rgb, 4); // Read B, G, R, Reserved in one go + if (readBytes(rgb, 4) != 4) break; paletteLum[i] = (77u * rgb[2] + 150u * rgb[1] + 29u * rgb[0]) >> 8; } } - if (!file.seek(bfOffBits)) { - return BmpReaderError::SeekPixelDataFailed; - } + if (!seekSet(bfOffBits)) return BmpReaderError::SeekPixelDataFailed; - // Create ditherer if enabled (only for 2-bit output) - // Use OUTPUT dimensions for dithering (after prescaling) if (bpp > 2 && dithering) { if (USE_ATKINSON) { atkinsonDitherer = new AtkinsonDitherer(width); @@ -153,31 +214,25 @@ BmpReaderError Bitmap::parseHeaders() { return BmpReaderError::Ok; } -// packed 2bpp output, 0 = black, 1 = dark gray, 2 = light gray, 3 = white BmpReaderError Bitmap::readNextRow(uint8_t* data, uint8_t* rowBuffer) const { - // Note: rowBuffer should be pre-allocated by the caller to size 'rowBytes' - if (file.read(rowBuffer, rowBytes) != rowBytes) return BmpReaderError::ShortReadRow; + if (readBytes(rowBuffer, rowBytes) != (size_t)rowBytes) return BmpReaderError::ShortReadRow; prevRowY += 1; - uint8_t* outPtr = data; uint8_t currentOutByte = 0; int bitShift = 6; int currentX = 0; - // Helper lambda to pack 2bpp color into the output stream auto packPixel = [&](const uint8_t lum) { uint8_t color; if (atkinsonDitherer) { - color = atkinsonDitherer->processPixel(adjustPixel(lum), currentX); + color = atkinsonDitherer->processPixel(lum, currentX); } else if (fsDitherer) { - color = fsDitherer->processPixel(adjustPixel(lum), currentX); + color = fsDitherer->processPixel(lum, currentX); } else { if (bpp > 2) { - // Simple quantization or noise dithering color = quantize(adjustPixel(lum), currentX, prevRowY); } else { - // do not quantize 2bpp image color = static_cast(lum >> 6); } } @@ -192,13 +247,18 @@ BmpReaderError Bitmap::readNextRow(uint8_t* data, uint8_t* rowBuffer) const { currentX++; }; - uint8_t lum; - switch (bpp) { case 32: { const uint8_t* p = rowBuffer; for (int x = 0; x < width; x++) { - lum = (77u * p[2] + 150u * p[1] + 29u * p[0]) >> 8; + uint8_t lum; // Declare lum here + // Handle Alpha channel (byte 3). If transparent (<128), treat as White. + // This fixes 32-bit icons appearing as black squares on white backgrounds. + if (p[3] < 128) { + lum = 255; + } else { + lum = (77u * p[2] + 150u * p[1] + 29u * p[0]) >> 8; + } packPixel(lum); p += 4; } @@ -207,32 +267,27 @@ BmpReaderError Bitmap::readNextRow(uint8_t* data, uint8_t* rowBuffer) const { case 24: { const uint8_t* p = rowBuffer; for (int x = 0; x < width; x++) { - lum = (77u * p[2] + 150u * p[1] + 29u * p[0]) >> 8; + uint8_t lum = (77u * p[2] + 150u * p[1] + 29u * p[0]) >> 8; packPixel(lum); p += 3; } break; } case 8: { - for (int x = 0; x < width; x++) { - packPixel(paletteLum[rowBuffer[x]]); - } + for (int x = 0; x < width; x++) packPixel(paletteLum[rowBuffer[x]]); break; } case 2: { for (int x = 0; x < width; x++) { - lum = paletteLum[(rowBuffer[x >> 2] >> (6 - ((x & 3) * 2))) & 0x03]; + uint8_t lum = paletteLum[(rowBuffer[x >> 2] >> (6 - ((x & 3) * 2))) & 0x03]; packPixel(lum); } break; } case 1: { for (int x = 0; x < width; x++) { - // Get palette index (0 or 1) from bit at position x const uint8_t palIndex = (rowBuffer[x >> 3] & (0x80 >> (x & 7))) ? 1 : 0; - // Use palette lookup for proper black/white mapping - lum = paletteLum[palIndex]; - packPixel(lum); + packPixel(paletteLum[palIndex]); } break; } @@ -245,20 +300,13 @@ BmpReaderError Bitmap::readNextRow(uint8_t* data, uint8_t* rowBuffer) const { else if (fsDitherer) fsDitherer->nextRow(); - // Flush remaining bits if width is not a multiple of 4 if (bitShift != 6) *outPtr = currentOutByte; - return BmpReaderError::Ok; } BmpReaderError Bitmap::rewindToData() const { - if (!file.seek(bfOffBits)) { - return BmpReaderError::SeekPixelDataFailed; - } - - // Reset dithering when rewinding + if (!seekSet(bfOffBits)) return BmpReaderError::SeekPixelDataFailed; if (fsDitherer) fsDitherer->reset(); if (atkinsonDitherer) atkinsonDitherer->reset(); - return BmpReaderError::Ok; } diff --git a/lib/GfxRenderer/Bitmap.h b/lib/GfxRenderer/Bitmap.h index 544869c1..41ee0509 100644 --- a/lib/GfxRenderer/Bitmap.h +++ b/lib/GfxRenderer/Bitmap.h @@ -32,11 +32,16 @@ class Bitmap { public: static const char* errorToString(BmpReaderError err); - explicit Bitmap(FsFile& file, bool dithering = false) : file(file), dithering(dithering) {} + explicit Bitmap(FsFile& file, bool dithering = false) : file(&file), dithering(dithering) {} + explicit Bitmap(const uint8_t* buffer, size_t size, bool dithering = false) + : file(nullptr), memoryBuffer(buffer), memorySize(size), dithering(dithering) {} + ~Bitmap(); BmpReaderError parseHeaders(); BmpReaderError readNextRow(uint8_t* data, uint8_t* rowBuffer) const; BmpReaderError rewindToData() const; + + // Getters int getWidth() const { return width; } int getHeight() const { return height; } bool isTopDown() const { return topDown; } @@ -46,10 +51,21 @@ class Bitmap { uint16_t getBpp() const { return bpp; } private: - static uint16_t readLE16(FsFile& f); - static uint32_t readLE32(FsFile& f); + // Internal IO helpers + int readByte() const; + size_t readBytes(void* buf, size_t count) const; + bool seekSet(uint32_t pos) const; + bool seekCur(int32_t offset) const; // Only needed for skip? + + uint16_t readLE16(); + uint32_t readLE32(); + + // Source (one is valid) + FsFile* file = nullptr; + const uint8_t* memoryBuffer = nullptr; + size_t memorySize = 0; + mutable size_t bufferPos = 0; - FsFile& file; bool dithering = false; int width = 0; int height = 0; diff --git a/lib/GfxRenderer/GfxRenderer.cpp b/lib/GfxRenderer/GfxRenderer.cpp index fa1c61c6..1b5efff8 100644 --- a/lib/GfxRenderer/GfxRenderer.cpp +++ b/lib/GfxRenderer/GfxRenderer.cpp @@ -2,6 +2,8 @@ #include +#include + void GfxRenderer::insertFont(const int fontId, EpdFontFamily font) { fontMap.insert({fontId, font}); } void GfxRenderer::rotateCoordinates(const int x, const int y, int* rotatedX, int* rotatedY) const { @@ -65,6 +67,28 @@ void GfxRenderer::drawPixel(const int x, const int y, const bool state) const { } } +bool GfxRenderer::readPixel(const int x, const int y) const { + uint8_t* frameBuffer = display.getFrameBuffer(); + if (!frameBuffer) { + return false; + } + + int rotatedX = 0; + int rotatedY = 0; + rotateCoordinates(x, y, &rotatedX, &rotatedY); + + // Bounds checking against physical panel dimensions + if (rotatedX < 0 || rotatedX >= HalDisplay::DISPLAY_WIDTH || rotatedY < 0 || rotatedY >= HalDisplay::DISPLAY_HEIGHT) { + return false; + } + + const uint16_t byteIndex = rotatedY * HalDisplay::DISPLAY_WIDTH_BYTES + (rotatedX / 8); + const uint8_t bitPosition = 7 - (rotatedX % 8); + + // Bit cleared = black, bit set = white + return !(frameBuffer[byteIndex] & (1 << bitPosition)); +} + int GfxRenderer::getTextWidth(const int fontId, const char* text, const EpdFontFamily::Style style) const { if (fontMap.count(fontId) == 0) { Serial.printf("[%lu] [GFX] Font %d not found\n", millis(), fontId); @@ -110,23 +134,27 @@ void GfxRenderer::drawText(const int fontId, const int x, const int y, const cha } void GfxRenderer::drawLine(int x1, int y1, int x2, int y2, const bool state) const { - if (x1 == x2) { - if (y2 < y1) { - std::swap(y1, y2); + // Bresenham's line algorithm + int dx = abs(x2 - x1); + int dy = abs(y2 - y1); + int sx = (x1 < x2) ? 1 : -1; + int sy = (y1 < y2) ? 1 : -1; + int err = dx - dy; + + while (true) { + drawPixel(x1, y1, state); + + if (x1 == x2 && y1 == y2) break; + + int e2 = 2 * err; + if (e2 > -dy) { + err -= dy; + x1 += sx; } - for (int y = y1; y <= y2; y++) { - drawPixel(x1, y, state); + if (e2 < dx) { + err += dx; + y1 += sy; } - } else if (y1 == y2) { - if (x2 < x1) { - std::swap(x1, x2); - } - for (int x = x1; x <= x2; x++) { - drawPixel(x, y1, state); - } - } else { - // TODO: Implement - Serial.printf("[%lu] [GFX] Line drawing not supported\n", millis()); } } @@ -138,8 +166,309 @@ void GfxRenderer::drawRect(const int x, const int y, const int width, const int } 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); + uint8_t* frameBuffer = display.getFrameBuffer(); + if (!frameBuffer) { + return; + } + + const int screenWidth = getScreenWidth(); + const int screenHeight = getScreenHeight(); + + // Clip to screen bounds + const int x1 = std::max(0, x); + const int y1 = std::max(0, y); + const int x2 = std::min(screenWidth - 1, x + width - 1); + const int y2 = std::min(screenHeight - 1, y + height - 1); + + if (x1 > x2 || y1 > y2) return; + + // Optimized path for Portrait mode (most common) + if (orientation == Portrait) { + for (int sy = y1; sy <= y2; sy++) { + // In Portrait: logical (x, y) -> physical (y, DISPLAY_HEIGHT - 1 - x) + const int physX = sy; + const uint8_t physXByte = physX / 8; + const uint8_t physXBit = 7 - (physX % 8); + const uint8_t mask = 1 << physXBit; + + for (int sx = x1; sx <= x2; sx++) { + const int physY = HalDisplay::DISPLAY_HEIGHT - 1 - sx; + const uint16_t byteIndex = physY * HalDisplay::DISPLAY_WIDTH_BYTES + physXByte; + + if (state) { + frameBuffer[byteIndex] &= ~mask; // Black + } else { + frameBuffer[byteIndex] |= mask; // White + } + } + } + return; + } + + // Optimized path for PortraitInverted + if (orientation == PortraitInverted) { + for (int sy = y1; sy <= y2; sy++) { + const int physX = HalDisplay::DISPLAY_WIDTH - 1 - sy; + const uint8_t physXByte = physX / 8; + const uint8_t physXBit = 7 - (physX % 8); + const uint8_t mask = 1 << physXBit; + + for (int sx = x1; sx <= x2; sx++) { + const int physY = sx; + const uint16_t byteIndex = physY * HalDisplay::DISPLAY_WIDTH_BYTES + physXByte; + + if (state) { + frameBuffer[byteIndex] &= ~mask; + } else { + frameBuffer[byteIndex] |= mask; + } + } + } + return; + } + + // Optimized horizontal line fill for Landscape modes + if (orientation == LandscapeCounterClockwise) { + for (int sy = y1; sy <= y2; sy++) { + const int physY = sy; + const uint16_t rowOffset = physY * HalDisplay::DISPLAY_WIDTH_BYTES; + + // Fill full bytes where possible + const int physX1 = x1; + const int physX2 = x2; + const int byteStart = physX1 / 8; + const int byteEnd = physX2 / 8; + + for (int bx = byteStart; bx <= byteEnd; bx++) { + uint8_t mask = 0xFF; + + // Mask out bits before start on first byte + if (bx == byteStart) { + const int startBit = physX1 % 8; + mask &= (0xFF >> startBit); + } + // Mask out bits after end on last byte + if (bx == byteEnd) { + const int endBit = physX2 % 8; + mask &= (0xFF << (7 - endBit)); + } + + if (state) { + frameBuffer[rowOffset + bx] &= ~mask; + } else { + frameBuffer[rowOffset + bx] |= mask; + } + } + } + return; + } + + // Fallback for LandscapeClockwise and any other cases + for (int fillY = y1; fillY <= y2; fillY++) { + drawLine(x1, fillY, x2, fillY, state); + } +} + +void GfxRenderer::fillRectDithered(const int x, const int y, const int width, const int height, + const uint8_t grayLevel) const { + // Simulate grayscale using dithering patterns + // 0x00 = black, 0xFF = white, values in between = dithered + + if (grayLevel == 0x00) { + fillRect(x, y, width, height, true); // Solid black + return; + } + if (grayLevel >= 0xF0) { + fillRect(x, y, width, height, false); // Solid white + return; + } + + // Use ordered dithering (Bayer matrix 2x2) + const int screenWidth = getScreenWidth(); + const int screenHeight = getScreenHeight(); + + const int x1 = std::max(0, x); + const int y1 = std::max(0, y); + const int x2 = std::min(screenWidth - 1, x + width - 1); + const int y2 = std::min(screenHeight - 1, y + height - 1); + + if (x1 > x2 || y1 > y2) return; + + int threshold = (grayLevel * 4) / 255; + + for (int sy = y1; sy <= y2; sy++) { + for (int sx = x1; sx <= x2; sx++) { + int bayerValue; + int px = sx % 2; + int py = sy % 2; + if (px == 0 && py == 0) + bayerValue = 0; + else if (px == 1 && py == 0) + bayerValue = 2; + else if (px == 0 && py == 1) + bayerValue = 3; + else + bayerValue = 1; + + bool isBlack = bayerValue >= threshold; + drawPixel(sx, sy, isBlack); + } + } +} + +void GfxRenderer::drawRoundedRect(const int x, const int y, const int width, const int height, const int radius, + const bool state) const { + if (radius <= 0) { + drawRect(x, y, width, height, state); + return; + } + + int r = std::min(radius, std::min(width / 2, height / 2)); + int cx, cy; + int px = 0, py = r; + int d = 1 - r; + + while (px <= py) { + cx = x + r; + cy = y + r; + drawPixel(cx - py, cy - px, state); + drawPixel(cx - px, cy - py, state); + + cx = x + width - 1 - r; + cy = y + r; + drawPixel(cx + py, cy - px, state); + drawPixel(cx + px, cy - py, state); + + cx = x + r; + cy = y + height - 1 - r; + drawPixel(cx - py, cy + px, state); + drawPixel(cx - px, cy + py, state); + + cx = x + width - 1 - r; + cy = y + height - 1 - r; + drawPixel(cx + py, cy + px, state); + drawPixel(cx + px, cy + py, state); + + if (d < 0) { + d += 2 * px + 3; + } else { + d += 2 * (px - py) + 5; + py--; + } + px++; + } + + drawLine(x + r, y, x + width - 1 - r, y, state); + drawLine(x + r, y + height - 1, x + width - 1 - r, y + height - 1, state); + drawLine(x, y + r, x, y + height - 1 - r, state); + drawLine(x + width - 1, y + r, x + width - 1, y + height - 1 - r, state); +} + +void GfxRenderer::fillRoundedRect(const int x, const int y, const int width, const int height, const int radius, + const bool state) const { + if (radius <= 0) { + fillRect(x, y, width, height, state); + return; + } + + int r = std::min(radius, std::min(width / 2, height / 2)); + fillRect(x + r, y, width - 2 * r, height, state); + fillRect(x, y + r, r, height - 2 * r, state); + fillRect(x + width - r, y + r, r, height - 2 * r, state); + + int px = 0, py = r; + int d = 1 - r; + + while (px <= py) { + drawLine(x + r - py, y + r - px, x + r, y + r - px, state); + drawLine(x + width - 1 - r, y + r - px, x + width - 1 - r + py, y + r - px, state); + drawLine(x + r - px, y + r - py, x + r, y + r - py, state); + drawLine(x + width - 1 - r, y + r - py, x + width - 1 - r + px, y + r - py, state); + + drawLine(x + r - py, y + height - 1 - r + px, x + r, y + height - 1 - r + px, state); + drawLine(x + width - 1 - r, y + height - 1 - r + px, x + width - 1 - r + py, y + height - 1 - r + px, state); + drawLine(x + r - px, y + height - 1 - r + py, x + r, y + height - 1 - r + py, state); + drawLine(x + width - 1 - r, y + height - 1 - r + py, x + width - 1 - r + px, y + height - 1 - r + py, state); + + if (d < 0) { + d += 2 * px + 3; + } else { + d += 2 * (px - py) + 5; + py--; + } + px++; + } +} + +void GfxRenderer::fillRoundedRectDithered(const int x, const int y, const int width, const int height, const int radius, + const uint8_t grayLevel) const { + if (grayLevel == 0x00) { + fillRoundedRect(x, y, width, height, radius, true); + return; + } + if (grayLevel >= 0xF0) { + fillRoundedRect(x, y, width, height, radius, false); + return; + } + + int r = std::min(radius, std::min(width / 2, height / 2)); + if (r <= 0) { + fillRectDithered(x, y, width, height, grayLevel); + return; + } + + const int screenWidth = getScreenWidth(); + const int screenHeight = getScreenHeight(); + int threshold = (grayLevel * 4) / 255; + + auto isInside = [&](int px, int py) -> bool { + if (px < x + r && py < y + r) { + int dx = px - (x + r); + int dy = py - (y + r); + return (dx * dx + dy * dy) <= r * r; + } + if (px >= x + width - r && py < y + r) { + int dx = px - (x + width - 1 - r); + int dy = py - (y + r); + return (dx * dx + dy * dy) <= r * r; + } + if (px < x + r && py >= y + height - r) { + int dx = px - (x + r); + int dy = py - (y + height - 1 - r); + return (dx * dx + dy * dy) <= r * r; + } + if (px >= x + width - r && py >= y + height - r) { + int dx = px - (x + width - 1 - r); + int dy = py - (y + height - 1 - r); + return (dx * dx + dy * dy) <= r * r; + } + return px >= x && px < x + width && py >= y && py < y + height; + }; + + const int x1 = std::max(0, x); + const int y1 = std::max(0, y); + const int x2 = std::min(screenWidth - 1, x + width - 1); + const int y2 = std::min(screenHeight - 1, y + height - 1); + + for (int sy = y1; sy <= y2; sy++) { + for (int sx = x1; sx <= x2; sx++) { + if (!isInside(sx, sy)) continue; + + int bayerValue; + int bx = sx % 2; + int by = sy % 2; + if (bx == 0 && by == 0) + bayerValue = 0; + else if (bx == 1 && by == 0) + bayerValue = 2; + else if (bx == 0 && by == 1) + bayerValue = 3; + else + bayerValue = 1; + + bool isBlack = bayerValue >= threshold; + drawPixel(sx, sy, isBlack); + } } } @@ -168,7 +497,8 @@ void GfxRenderer::drawImage(const uint8_t bitmap[], const int x, const int y, co void GfxRenderer::drawBitmap(const Bitmap& bitmap, const int x, const int y, const int maxWidth, const int maxHeight, const float cropX, const float cropY) const { - // For 1-bit bitmaps, use optimized 1-bit rendering path (no crop support for 1-bit) + // For 1-bit bitmaps, use optimized 1-bit rendering path (no crop support for + // 1-bit) if (bitmap.is1Bit() && cropX == 0.0f && cropY == 0.0f) { drawBitmap1Bit(bitmap, x, y, maxWidth, maxHeight); return; @@ -178,8 +508,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); - Serial.printf("[%lu] [GFX] Cropping %dx%d by %dx%d pix, is %s\n", millis(), bitmap.getWidth(), bitmap.getHeight(), - cropPixX, cropPixY, bitmap.isTopDown() ? "top-down" : "bottom-up"); if (maxWidth > 0 && (1.0f - cropX) * bitmap.getWidth() > maxWidth) { scale = static_cast(maxWidth) / static_cast((1.0f - cropX) * bitmap.getWidth()); @@ -189,10 +517,10 @@ void GfxRenderer::drawBitmap(const Bitmap& bitmap, const int x, const int y, con scale = std::min(scale, static_cast(maxHeight) / static_cast((1.0f - cropY) * bitmap.getHeight())); isScaled = true; } - Serial.printf("[%lu] [GFX] Scaling by %f - %s\n", millis(), 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 + // IMPORTANT: Use int, not uint8_t, to avoid overflow for images > 1020 pixels + // wide const int outputRowSize = (bitmap.getWidth() + 3) / 4; auto* outputRow = static_cast(malloc(outputRowSize)); auto* rowBytes = static_cast(malloc(bitmap.getRowBytes())); @@ -205,8 +533,8 @@ void GfxRenderer::drawBitmap(const Bitmap& bitmap, const int x, const int y, con } for (int bmpY = 0; bmpY < (bitmap.getHeight() - cropPixY); bmpY++) { - // The BMP's (0, 0) is the bottom-left corner (if the height is positive, top-left if negative). - // Screen's (0, 0) is the top-left corner. + // The BMP's (0, 0) is the bottom-left corner (if the height is positive, + // top-left if negative). Screen's (0, 0) is the top-left corner. int screenY = -cropPixY + (bitmap.isTopDown() ? bmpY : bitmap.getHeight() - 1 - bmpY); if (isScaled) { screenY = std::floor(screenY * scale); @@ -247,8 +575,14 @@ void GfxRenderer::drawBitmap(const Bitmap& bitmap, const int x, const int y, con const uint8_t val = outputRow[bmpX / 4] >> (6 - ((bmpX * 2) % 8)) & 0x3; - if (renderMode == BW && val < 3) { - drawPixel(screenX, screenY); + // In BW mode: + // 0 = Black (< 45) + // 1 = Dark Gray (< 70) + // 2 = Light Gray (< 140) + // 3 = White + // Draw black for val < 2, and white for val >= 2 (Opaque) + if (renderMode == BW) { + drawPixel(screenX, screenY, val < 2); } else if (renderMode == GRAYSCALE_MSB && (val == 1 || val == 2)) { drawPixel(screenX, screenY, false); } else if (renderMode == GRAYSCALE_LSB && val == 1) { @@ -261,6 +595,132 @@ void GfxRenderer::drawBitmap(const Bitmap& bitmap, const int x, const int y, con free(rowBytes); } +void GfxRenderer::draw2BitImage(const uint8_t data[], int x, int y, int w, int h) const { + uint8_t* frameBuffer = display.getFrameBuffer(); + if (!frameBuffer) { + return; + } + + const int screenWidth = getScreenWidth(); + const int screenHeight = getScreenHeight(); + + // Pre-compute row byte width for 2-bit packed data (4 pixels per byte) + const int srcRowBytes = (w + 3) / 4; + + // Optimized path for Portrait mode with BW rendering (most common case) + // In Portrait: logical (x, y) -> physical (y, DISPLAY_HEIGHT - 1 - x) + if (orientation == Portrait && renderMode == BW) { + for (int row = 0; row < h; row++) { + const int screenY = y + row; + if (screenY < 0 || screenY >= screenHeight) continue; + + // In Portrait, screenY maps to physical X coordinate + const int physX = screenY; + const uint8_t* srcRow = data + row * srcRowBytes; + + for (int col = 0; col < w; col++) { + const int screenX = x + col; + if (screenX < 0 || screenX >= screenWidth) continue; + + // Extract 2-bit value (4 pixels per byte) + const uint8_t val = (srcRow[col / 4] >> (6 - ((col % 4) * 2))) & 0x3; + + // val < 2 means black pixel in 2-bit representation (0=Black, 1=DarkGray) + // 2=LightGray, 3=White -> Treat as White + if (val < 2) { + // In Portrait: physical Y = DISPLAY_HEIGHT - 1 - screenX + const int physY = HalDisplay::DISPLAY_HEIGHT - 1 - screenX; + const uint16_t byteIndex = physY * HalDisplay::DISPLAY_WIDTH_BYTES + (physX / 8); + const uint8_t bitPosition = 7 - (physX % 8); + frameBuffer[byteIndex] &= ~(1 << bitPosition); // Clear bit = black + } + } + } + return; + } + + // Optimized path for PortraitInverted mode with BW rendering + if (orientation == PortraitInverted && renderMode == BW) { + for (int row = 0; row < h; row++) { + const int screenY = y + row; + if (screenY < 0 || screenY >= screenHeight) continue; + + const uint8_t* srcRow = data + row * srcRowBytes; + + for (int col = 0; col < w; col++) { + const int screenX = x + col; + if (screenX < 0 || screenX >= screenWidth) continue; + + const uint8_t val = (srcRow[col / 4] >> (6 - ((col % 4) * 2))) & 0x3; + + if (val < 3) { + // PortraitInverted: physical X = DISPLAY_WIDTH - 1 - screenY + // physical Y = screenX + const int physX = HalDisplay::DISPLAY_WIDTH - 1 - screenY; + const int physY = screenX; + const uint16_t byteIndex = physY * HalDisplay::DISPLAY_WIDTH_BYTES + (physX / 8); + const uint8_t bitPosition = 7 - (physX % 8); + frameBuffer[byteIndex] &= ~(1 << bitPosition); + } + } + } + return; + } + + // Optimized path for Landscape modes with BW rendering + if ((orientation == LandscapeClockwise || orientation == LandscapeCounterClockwise) && renderMode == BW) { + for (int row = 0; row < h; row++) { + const int screenY = y + row; + if (screenY < 0 || screenY >= screenHeight) continue; + + const uint8_t* srcRow = data + row * srcRowBytes; + + for (int col = 0; col < w; col++) { + const int screenX = x + col; + if (screenX < 0 || screenX >= screenWidth) continue; + + const uint8_t val = (srcRow[col / 4] >> (6 - ((col % 4) * 2))) & 0x3; + + if (val < 3) { + int physX, physY; + if (orientation == LandscapeClockwise) { + physX = HalDisplay::DISPLAY_WIDTH - 1 - screenX; + physY = HalDisplay::DISPLAY_HEIGHT - 1 - screenY; + } else { + physX = screenX; + physY = screenY; + } + const uint16_t byteIndex = physY * HalDisplay::DISPLAY_WIDTH_BYTES + (physX / 8); + const uint8_t bitPosition = 7 - (physX % 8); + frameBuffer[byteIndex] &= ~(1 << bitPosition); + } + } + } + return; + } + + // Fallback: generic path for grayscale modes + for (int row = 0; row < h; row++) { + const int screenY = y + row; + if (screenY < 0 || screenY >= screenHeight) continue; + + const uint8_t* srcRow = data + row * srcRowBytes; + + for (int col = 0; col < w; col++) { + const int screenX = x + col; + if (screenX < 0 || screenX >= screenWidth) continue; + + const uint8_t val = (srcRow[col / 4] >> (6 - ((col % 4) * 2))) & 0x3; + + if (renderMode == GRAYSCALE_MSB && (val == 1 || val == 2)) { + drawPixel(screenX, screenY, false); + } else if (renderMode == GRAYSCALE_LSB && val == 1) { + drawPixel(screenX, screenY, false); + } + } + } +} + void GfxRenderer::drawBitmap1Bit(const Bitmap& bitmap, const int x, const int y, const int maxWidth, const int maxHeight) const { float scale = 1.0f; @@ -274,7 +734,8 @@ void GfxRenderer::drawBitmap1Bit(const Bitmap& bitmap, const int x, const int y, isScaled = true; } - // For 1-bit BMP, output is still 2-bit packed (for consistency with readNextRow) + // For 1-bit BMP, output is still 2-bit packed (for consistency with + // readNextRow) const int outputRowSize = (bitmap.getWidth() + 3) / 4; auto* outputRow = static_cast(malloc(outputRowSize)); auto* rowBytes = static_cast(malloc(bitmap.getRowBytes())); @@ -318,10 +779,8 @@ void GfxRenderer::drawBitmap1Bit(const Bitmap& bitmap, const int x, const int y, const uint8_t val = outputRow[bmpX / 4] >> (6 - ((bmpX * 2) % 8)) & 0x3; // For 1-bit source: 0 or 1 -> map to black (0,1,2) or white (3) - // val < 3 means black pixel (draw it) - if (val < 3) { - drawPixel(screenX, screenY, true); - } + // Draw black if val < 3, Draw white if val == 3 (Opaque) + drawPixel(screenX, screenY, val < 3); // White pixels (val == 3) are not drawn (leave background) } } @@ -330,6 +789,193 @@ void GfxRenderer::drawBitmap1Bit(const Bitmap& bitmap, const int x, const int y, free(rowBytes); } +void GfxRenderer::drawTransparentBitmap(const Bitmap& bitmap, const int x, const int y, const int w, + const int h) const { + // Similar to drawBitmap1Bit but strictly skips 1s (white) in the source 1-bit data + // The Bitmap reader returns 2-bit packed data where 0-2=Black and 3=White for 1-bit sources + + float scale = 1.0f; + bool isScaled = false; + if (w > 0) { + scale = static_cast(w) / static_cast(bitmap.getWidth()); + isScaled = true; + } + if (h > 0) { + scale = std::min(scale, static_cast(h) / static_cast(bitmap.getHeight())); + isScaled = true; + } + + const int outputRowSize = (bitmap.getWidth() + 3) / 4; + auto* outputRow = static_cast(malloc(outputRowSize)); + auto* rowBytes = static_cast(malloc(bitmap.getRowBytes())); + + if (!outputRow || !rowBytes) { + Serial.printf("[%lu] [GFX] !! Failed to allocate BMP row buffers\n", millis()); + free(outputRow); + free(rowBytes); + return; + } + + for (int bmpY = 0; bmpY < bitmap.getHeight(); bmpY++) { + if (bitmap.readNextRow(outputRow, rowBytes) != BmpReaderError::Ok) { + free(outputRow); + free(rowBytes); + return; + } + + const int bmpYOffset = bitmap.isTopDown() ? bmpY : bitmap.getHeight() - 1 - bmpY; + + // Calculate target Y span + int startY = y + static_cast(std::floor(bmpYOffset * scale)); + int endY = y + static_cast(std::floor((bmpYOffset + 1) * scale)); + + // Clamp to screen + if (startY < 0) startY = 0; + if (endY > getScreenHeight()) endY = getScreenHeight(); + if (startY >= endY) continue; + + for (int bmpX = 0; bmpX < bitmap.getWidth(); bmpX++) { + // Calculate target X span + int startX = x + static_cast(std::floor(bmpX * scale)); + int endX = x + static_cast(std::floor((bmpX + 1) * scale)); + + if (startX < 0) startX = 0; + if (endX > getScreenWidth()) endX = getScreenWidth(); + if (startX >= endX) continue; + + // Extract 2-bit value (0=Black, 3=White for 1-bit BMP) + const uint8_t val = outputRow[bmpX / 4] >> (6 - ((bmpX * 2) % 8)) & 0x3; + + if (val < 3) { + for (int sy = startY; sy < endY; sy++) { + for (int sx = startX; sx < endX; sx++) { + drawPixel(sx, sy, true); // Black + } + } + } + } + } + + free(outputRow); + free(rowBytes); +} + +void GfxRenderer::drawRoundedBitmap(const Bitmap& bitmap, const int x, const int y, const int w, const int h, + const int radius) const { + if (radius <= 0) { + drawBitmap(bitmap, x, y, w, h); + return; + } + + float scale = 1.0f; + bool isScaled = false; + if (w > 0) { + scale = static_cast(w) / static_cast(bitmap.getWidth()); + isScaled = true; + } + if (h > 0) { + scale = std::min(scale, static_cast(h) / static_cast(bitmap.getHeight())); + isScaled = true; + } + + // Pre-calculate squared radius for containment checks + const int r2 = radius * radius; + + // Lambda to check if a pixel is inside the rounded rect + // We use relative coordinates (px, py) from the top-left of the destination rect + auto isVisible = [&](int px, int py) -> bool { + // Top-left + if (px < radius && py < radius) { + int dx = radius - px; + int dy = radius - py; + return (dx * dx + dy * dy) <= r2; + } + // Top-right + if (px >= w - radius && py < radius) { + int dx = px - (w - 1 - radius); + int dy = radius - py; + return (dx * dx + dy * dy) <= r2; + } + // Bottom-left + if (px < radius && py >= h - radius) { + int dx = radius - px; + int dy = py - (h - 1 - radius); + return (dx * dx + dy * dy) <= r2; + } + // Bottom-right + if (px >= w - radius && py >= h - radius) { + int dx = px - (w - 1 - radius); + int dy = py - (h - 1 - radius); + return (dx * dx + dy * dy) <= r2; + } + return true; // Safe center area + }; + + const int outputRowSize = (bitmap.getWidth() + 3) / 4; + auto* outputRow = static_cast(malloc(outputRowSize)); + auto* rowBytes = static_cast(malloc(bitmap.getRowBytes())); + + if (!outputRow || !rowBytes) { + Serial.printf("[%lu] [GFX] !! Failed to allocate BMP row buffers\n", millis()); + free(outputRow); + free(rowBytes); + return; + } + + for (int bmpY = 0; bmpY < bitmap.getHeight(); bmpY++) { + if (bitmap.readNextRow(outputRow, rowBytes) != BmpReaderError::Ok) { + free(outputRow); + free(rowBytes); + return; + } + + const int bmpYOffset = bitmap.isTopDown() ? bmpY : bitmap.getHeight() - 1 - bmpY; + + // Calculate target Y span + int startY = y + static_cast(std::floor(bmpYOffset * scale)); + int endY = y + static_cast(std::floor((bmpYOffset + 1) * scale)); + + if (startY < 0) startY = 0; + if (endY > getScreenHeight()) endY = getScreenHeight(); + if (startY >= endY) continue; + + for (int bmpX = 0; bmpX < bitmap.getWidth(); bmpX++) { + int startX = x + static_cast(std::floor(bmpX * scale)); + int endX = x + static_cast(std::floor((bmpX + 1) * scale)); + + if (startX < 0) startX = 0; + if (endX > getScreenWidth()) endX = getScreenWidth(); + if (startX >= endX) continue; + + const uint8_t val = outputRow[bmpX / 4] >> (6 - ((bmpX * 2) % 8)) & 0x3; + bool pixelBlack = false; + + if (renderMode == BW) { + pixelBlack = (val < 2); + } else if (renderMode == GRAYSCALE_MSB) { + pixelBlack = (val < 3); // Draw all non-white as black for icons/covers + } else if (renderMode == GRAYSCALE_LSB) { + pixelBlack = (val == 0); + } + + if (pixelBlack) { + for (int sy = startY; sy < endY; sy++) { + int relY = sy - y; + for (int sx = startX; sx < endX; sx++) { + int relX = sx - x; + if (isVisible(relX, relY)) { + drawPixel(sx, sy, true); + } + } + } + } + } + } + + free(outputRow); + free(rowBytes); +} + void GfxRenderer::fillPolygon(const int* xPoints, const int* yPoints, int numPoints, bool state) const { if (numPoints < 3) return; @@ -398,6 +1044,130 @@ void GfxRenderer::fillPolygon(const int* xPoints, const int* yPoints, int numPoi free(nodeX); } +uint8_t* GfxRenderer::captureRegion(int x, int y, int width, int height, size_t* outSize) const { + uint8_t* frameBuffer = display.getFrameBuffer(); + if (!frameBuffer || width <= 0 || height <= 0) { + if (outSize) *outSize = 0; + return nullptr; + } + + // Clip to screen bounds + const int screenWidth = getScreenWidth(); + const int screenHeight = getScreenHeight(); + if (x < 0) { + width += x; + x = 0; + } + if (y < 0) { + height += y; + y = 0; + } + if (x + width > screenWidth) width = screenWidth - x; + if (y + height > screenHeight) height = screenHeight - y; + + if (width <= 0 || height <= 0) { + if (outSize) *outSize = 0; + return nullptr; + } + + // Pack as 1-bit: ceil(width/8) bytes per row + const size_t rowBytes = (width + 7) / 8; + const size_t bufferSize = rowBytes * height + 4 * sizeof(int); // +header + uint8_t* buffer = static_cast(malloc(bufferSize)); + if (!buffer) { + if (outSize) *outSize = 0; + return nullptr; + } + + // Store dimensions in header + int* header = reinterpret_cast(buffer); + header[0] = x; + header[1] = y; + header[2] = width; + header[3] = height; + uint8_t* data = buffer + 4 * sizeof(int); + + // Extract pixels - this is orientation-dependent + for (int row = 0; row < height; row++) { + const int screenY = y + row; + uint8_t* destRow = data + row * rowBytes; + memset(destRow, 0xFF, rowBytes); // Start with white + + for (int col = 0; col < width; col++) { + const int screenX = x + col; + + // Get physical coordinates + int physX, physY; + rotateCoordinates(screenX, screenY, &physX, &physY); + + // Read pixel from framebuffer + const uint16_t byteIndex = physY * HalDisplay::DISPLAY_WIDTH_BYTES + (physX / 8); + const uint8_t bitPosition = 7 - (physX % 8); + const bool isBlack = !(frameBuffer[byteIndex] & (1 << bitPosition)); + + // Store in destination + if (isBlack) { + destRow[col / 8] &= ~(1 << (7 - (col % 8))); + } + } + } + + if (outSize) *outSize = bufferSize; + return buffer; +} + +void GfxRenderer::restoreRegion(const uint8_t* buffer, int x, int y, int width, int height) const { + uint8_t* frameBuffer = display.getFrameBuffer(); + if (!frameBuffer || !buffer || width <= 0 || height <= 0) { + return; + } + + const size_t rowBytes = (width + 7) / 8; + const uint8_t* data = buffer + 4 * sizeof(int); // Skip header + + // Optimized path for Portrait mode + if (orientation == Portrait) { + for (int row = 0; row < height; row++) { + const int screenY = y + row; + if (screenY < 0 || screenY >= getScreenHeight()) continue; + + const uint8_t* srcRow = data + row * rowBytes; + const int physX = screenY; + const uint8_t physXByte = physX / 8; + const uint8_t physXBit = 7 - (physX % 8); + const uint8_t mask = 1 << physXBit; + + for (int col = 0; col < width; col++) { + const int screenX = x + col; + if (screenX < 0 || screenX >= getScreenWidth()) continue; + + const bool isBlack = !(srcRow[col / 8] & (1 << (7 - (col % 8)))); + const int physY = HalDisplay::DISPLAY_HEIGHT - 1 - screenX; + const uint16_t byteIndex = physY * HalDisplay::DISPLAY_WIDTH_BYTES + physXByte; + + if (isBlack) { + frameBuffer[byteIndex] &= ~mask; + } else { + frameBuffer[byteIndex] |= mask; + } + } + } + return; + } + + // Generic fallback using drawPixel + for (int row = 0; row < height; row++) { + const int screenY = y + row; + const uint8_t* srcRow = data + row * rowBytes; + + for (int col = 0; col < width; col++) { + const int screenX = x + col; + const bool isBlack = !(srcRow[col / 8] & (1 << (7 - (col % 8)))); + drawPixel(screenX, screenY, isBlack); + } + } +} + void GfxRenderer::clearScreen(const uint8_t color) const { display.clearScreen(color); } void GfxRenderer::invertScreen() const { @@ -424,7 +1194,8 @@ std::string GfxRenderer::truncatedText(const int fontId, const char* text, const return item; } -// Note: Internal driver treats screen in command orientation; this library exposes a logical orientation +// Note: Internal driver treats screen in command orientation; this library +// exposes a logical orientation int GfxRenderer::getScreenWidth() const { switch (orientation) { case Portrait: @@ -523,22 +1294,26 @@ void GfxRenderer::drawSideButtonHints(const int fontId, const char* topBtn, cons // Draw top button outline (3 sides, bottom open) if (topBtn != nullptr && topBtn[0] != '\0') { - drawLine(x, topButtonY, x + buttonWidth - 1, topButtonY); // Top - drawLine(x, topButtonY, x, topButtonY + buttonHeight - 1); // Left - drawLine(x + buttonWidth - 1, topButtonY, x + buttonWidth - 1, topButtonY + buttonHeight - 1); // Right + drawLine(x, topButtonY, x + buttonWidth - 1, topButtonY); // Top + drawLine(x, topButtonY, x, topButtonY + buttonHeight - 1); // Left + drawLine(x + buttonWidth - 1, topButtonY, x + buttonWidth - 1, + topButtonY + buttonHeight - 1); // Right } // Draw shared middle border if ((topBtn != nullptr && topBtn[0] != '\0') || (bottomBtn != nullptr && bottomBtn[0] != '\0')) { - drawLine(x, topButtonY + buttonHeight, x + buttonWidth - 1, topButtonY + buttonHeight); // Shared border + drawLine(x, topButtonY + buttonHeight, x + buttonWidth - 1, + topButtonY + buttonHeight); // Shared border } // Draw bottom button outline (3 sides, top is shared) if (bottomBtn != nullptr && bottomBtn[0] != '\0') { - drawLine(x, topButtonY + buttonHeight, x, topButtonY + 2 * buttonHeight - 1); // Left + drawLine(x, topButtonY + buttonHeight, x, + topButtonY + 2 * buttonHeight - 1); // Left drawLine(x + buttonWidth - 1, topButtonY + buttonHeight, x + buttonWidth - 1, - topButtonY + 2 * buttonHeight - 1); // Right - drawLine(x, topButtonY + 2 * buttonHeight - 1, x + buttonWidth - 1, topButtonY + 2 * buttonHeight - 1); // Bottom + topButtonY + 2 * buttonHeight - 1); // Right + drawLine(x, topButtonY + 2 * buttonHeight - 1, x + buttonWidth - 1, + topButtonY + 2 * buttonHeight - 1); // Bottom } // Draw text for each button @@ -674,9 +1449,10 @@ void GfxRenderer::freeBwBufferChunks() { /** * This should be called before grayscale buffers are populated. - * A `restoreBwBuffer` call should always follow the grayscale render if this method was called. - * Uses chunked allocation to avoid needing 48KB of contiguous memory. - * Returns true if buffer was stored successfully, false if allocation failed. + * A `restoreBwBuffer` call should always follow the grayscale render if this + * method was called. Uses chunked allocation to avoid needing 48KB of + * contiguous memory. Returns true if buffer was stored successfully, false if + * allocation failed. */ bool GfxRenderer::storeBwBuffer() { const uint8_t* frameBuffer = display.getFrameBuffer(); @@ -689,8 +1465,10 @@ bool GfxRenderer::storeBwBuffer() { for (size_t i = 0; i < BW_BUFFER_NUM_CHUNKS; i++) { // Check if any chunks are already allocated if (bwBufferChunks[i]) { - Serial.printf("[%lu] [GFX] !! BW buffer chunk %zu already stored - this is likely a bug, freeing chunk\n", - millis(), i); + Serial.printf( + "[%lu] [GFX] !! BW buffer chunk %zu already stored - this " + "is likely a bug, freeing chunk\n", + millis(), i); free(bwBufferChunks[i]); bwBufferChunks[i] = nullptr; } @@ -715,9 +1493,9 @@ bool GfxRenderer::storeBwBuffer() { } /** - * This can only be called if `storeBwBuffer` was called prior to the grayscale render. - * It should be called to restore the BW buffer state after grayscale rendering is complete. - * Uses chunked restoration to match chunked storage. + * This can only be called if `storeBwBuffer` was called prior to the grayscale + * render. It should be called to restore the BW buffer state after grayscale + * rendering is complete. Uses chunked restoration to match chunked storage. */ void GfxRenderer::restoreBwBuffer() { // Check if any all chunks are allocated @@ -802,17 +1580,19 @@ void GfxRenderer::renderChar(const EpdFontFamily& fontFamily, const uint32_t cp, if (is2Bit) { const uint8_t byte = bitmap[pixelPosition / 4]; const uint8_t bit_index = (3 - pixelPosition % 4) * 2; - // the direct bit from the font is 0 -> white, 1 -> light gray, 2 -> dark gray, 3 -> black - // we swap this to better match the way images and screen think about colors: - // 0 -> black, 1 -> dark grey, 2 -> light grey, 3 -> white + // the direct bit from the font is 0 -> white, 1 -> light gray, 2 -> + // dark gray, 3 -> black we swap this to better match the way images + // and screen think about colors: 0 -> black, 1 -> dark grey, 2 -> + // light grey, 3 -> white const uint8_t bmpVal = 3 - (byte >> bit_index) & 0x3; if (renderMode == BW && bmpVal < 3) { // Black (also paints over the grays in BW mode) drawPixel(screenX, screenY, pixelState); } else if (renderMode == GRAYSCALE_MSB && (bmpVal == 1 || bmpVal == 2)) { - // Light gray (also mark the MSB if it's going to be a dark gray too) - // We have to flag pixels in reverse for the gray buffers, as 0 leave alone, 1 update + // Light gray (also mark the MSB if it's going to be a dark gray + // too) We have to flag pixels in reverse for the gray buffers, as 0 + // leave alone, 1 update drawPixel(screenX, screenY, false); } else if (renderMode == GRAYSCALE_LSB && bmpVal == 1) { // Dark gray diff --git a/lib/GfxRenderer/GfxRenderer.h b/lib/GfxRenderer/GfxRenderer.h index 86ddc8fc..20772636 100644 --- a/lib/GfxRenderer/GfxRenderer.h +++ b/lib/GfxRenderer/GfxRenderer.h @@ -14,9 +14,11 @@ class GfxRenderer { // Logical screen orientation from the perspective of callers enum Orientation { Portrait, // 480x800 logical coordinates (current default) - LandscapeClockwise, // 800x480 logical coordinates, rotated 180° (swap top/bottom) + LandscapeClockwise, // 800x480 logical coordinates, rotated 180° (swap + // top/bottom) PortraitInverted, // 480x800 logical coordinates, inverted - LandscapeCounterClockwise // 800x480 logical coordinates, native panel orientation + LandscapeCounterClockwise // 800x480 logical coordinates, native panel + // orientation }; private: @@ -47,7 +49,8 @@ class GfxRenderer { // Setup void insertFont(int fontId, EpdFontFamily font); - // Orientation control (affects logical width/height and coordinate transforms) + // Orientation control (affects logical width/height and coordinate + // transforms) void setOrientation(const Orientation o) { orientation = o; } Orientation getOrientation() const { return orientation; } @@ -62,15 +65,30 @@ class GfxRenderer { // Drawing void drawPixel(int x, int y, bool state = true) const; + bool readPixel(int x, int y) const; // Returns true if pixel is black void drawLine(int x1, int y1, int x2, int y2, bool state = true) const; void drawRect(int x, int y, int width, int height, bool state = true) const; void fillRect(int x, int y, int width, int height, bool state = true) const; + void fillRectDithered(int x, int y, int width, int height, uint8_t grayLevel) const; + void drawRoundedRect(int x, int y, int width, int height, int radius, bool state = true) const; + void fillRoundedRect(int x, int y, int width, int height, int radius, bool state = true) const; + void fillRoundedRectDithered(int x, int y, int width, int height, int radius, uint8_t grayLevel) const; void drawImage(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; + void drawTransparentBitmap(const Bitmap& bitmap, int x, int y, int w, int h) const; + void drawRoundedBitmap(const Bitmap& bitmap, int x, int y, int w, int h, int radius) const; + void draw2BitImage(const uint8_t data[], int x, int y, int w, int h) const; void fillPolygon(const int* xPoints, const int* yPoints, int numPoints, bool state = true) const; + // Region caching - copies a rectangular region to/from a buffer + // Returns allocated buffer on success, nullptr on failure. Caller owns the + // memory. + uint8_t* captureRegion(int x, int y, int width, int height, size_t* outSize) const; + // Restores a previously captured region. Buffer must match dimensions. + void restoreRegion(const uint8_t* buffer, int x, int y, int width, int height) const; + // Text int getTextWidth(int fontId, const char* text, EpdFontFamily::Style style = EpdFontFamily::REGULAR) const; void drawCenteredText(int fontId, int y, const char* text, bool black = true, diff --git a/lib/ThemeEngine/include/BasicElements.h b/lib/ThemeEngine/include/BasicElements.h new file mode 100644 index 00000000..8d32adc5 --- /dev/null +++ b/lib/ThemeEngine/include/BasicElements.h @@ -0,0 +1,422 @@ +#pragma once + +#include +#include + +#include + +#include "ThemeContext.h" +#include "ThemeTypes.h" +#include "UIElement.h" + +namespace ThemeEngine { + +// Safe integer parsing (no exceptions) +inline int parseIntSafe(const std::string& s, int defaultVal = 0) { + if (s.empty()) return defaultVal; + char* end; + long val = strtol(s.c_str(), &end, 10); + return (end != s.c_str()) ? static_cast(val) : defaultVal; +} + +// Safe float parsing (no exceptions) +inline float parseFloatSafe(const std::string& s, float defaultVal = 0.0f) { + if (s.empty()) return defaultVal; + char* end; + float val = strtof(s.c_str(), &end); + return (end != s.c_str()) ? val : defaultVal; +} + +// --- Container --- +class Container : public UIElement { + protected: + std::vector children; + Expression bgColorExpr; + bool hasBg = false; + bool border = false; + Expression borderExpr; // Dynamic border based on expression + int padding = 0; // Inner padding for children + int borderRadius = 0; // Corner radius (for future rounded rect support) + + public: + explicit Container(const std::string& id) : UIElement(id), bgColorExpr(Expression::parse("0xFF")) {} + virtual ~Container() = default; + + Container* asContainer() override { return this; } + + ElementType getType() const override { return ElementType::Container; } + const char* getTypeName() const override { return "Container"; } + + void addChild(UIElement* child) { children.push_back(child); } + + void clearChildren() { children.clear(); } + + const std::vector& getChildren() const { return children; } + + void setBackgroundColorExpr(const std::string& expr) { + bgColorExpr = Expression::parse(expr); + hasBg = true; + markDirty(); + } + + void setBorder(bool enable) { + border = enable; + markDirty(); + } + + void setBorderExpr(const std::string& expr) { + borderExpr = Expression::parse(expr); + markDirty(); + } + + bool hasBorderExpr() const { return !borderExpr.empty(); } + + void setPadding(int p) { + padding = p; + markDirty(); + } + + int getPadding() const { return padding; } + + void setBorderRadius(int r) { + borderRadius = r; + markDirty(); + } + + int getBorderRadius() const { return borderRadius; } + + void layout(const ThemeContext& context, int parentX, int parentY, int parentW, int parentH) override { + UIElement::layout(context, parentX, parentY, parentW, parentH); + // Children are laid out with padding offset + int childX = absX + padding; + int childY = absY + padding; + int childW = absW - 2 * padding; + int childH = absH - 2 * padding; + for (auto child : children) { + child->layout(context, childX, childY, childW, childH); + } + } + + void markDirty() override { + UIElement::markDirty(); + for (auto child : children) { + child->markDirty(); + } + } + + void draw(const GfxRenderer& renderer, const ThemeContext& context) override; +}; + +// --- Rectangle --- +class Rectangle : public UIElement { + bool fill = false; + Expression fillExpr; // Dynamic fill based on expression + Expression colorExpr; + int borderRadius = 0; + + public: + explicit Rectangle(const std::string& id) : UIElement(id), colorExpr(Expression::parse("0x00")) {} + ElementType getType() const override { return ElementType::Rectangle; } + const char* getTypeName() const override { return "Rectangle"; } + + void setFill(bool f) { + fill = f; + markDirty(); + } + + void setFillExpr(const std::string& expr) { + fillExpr = Expression::parse(expr); + markDirty(); + } + + void setColorExpr(const std::string& c) { + colorExpr = Expression::parse(c); + markDirty(); + } + + void setBorderRadius(int r) { + borderRadius = r; + markDirty(); + } + + void draw(const GfxRenderer& renderer, const ThemeContext& context) override; +}; + +// --- Label --- +class Label : public UIElement { + public: + enum class Alignment { Left, Center, Right }; + + private: + Expression textExpr; + int fontId = 0; + Alignment alignment = Alignment::Left; + Expression colorExpr; + int maxLines = 1; // For multi-line support + bool ellipsis = true; // Truncate with ... if too long + + public: + explicit Label(const std::string& id) : UIElement(id), colorExpr(Expression::parse("0x00")) {} + ElementType getType() const override { return ElementType::Label; } + const char* getTypeName() const override { return "Label"; } + + void setText(const std::string& expr) { + textExpr = Expression::parse(expr); + markDirty(); + } + void setFont(int fid) { + fontId = fid; + markDirty(); + } + void setAlignment(Alignment a) { + alignment = a; + markDirty(); + } + void setCentered(bool c) { + alignment = c ? Alignment::Center : Alignment::Left; + markDirty(); + } + void setColorExpr(const std::string& c) { + colorExpr = Expression::parse(c); + markDirty(); + } + void setMaxLines(int lines) { + maxLines = lines; + markDirty(); + } + void setEllipsis(bool e) { + ellipsis = e; + markDirty(); + } + + void draw(const GfxRenderer& renderer, const ThemeContext& context) override; +}; + +// --- BitmapElement --- +class BitmapElement : public UIElement { + Expression srcExpr; + bool scaleToFit = true; + bool preserveAspect = true; + int borderRadius = 0; + + public: + explicit BitmapElement(const std::string& id) : UIElement(id) { + cacheable = true; // Bitmaps benefit from caching + } + ElementType getType() const override { return ElementType::Bitmap; } + const char* getTypeName() const override { return "Bitmap"; } + + void setSrc(const std::string& src) { + srcExpr = Expression::parse(src); + invalidateCache(); + } + + void setScaleToFit(bool scale) { + scaleToFit = scale; + invalidateCache(); + } + + void setPreserveAspect(bool preserve) { + preserveAspect = preserve; + invalidateCache(); + } + + void setBorderRadius(int r) { + borderRadius = r; + // Radius doesn't affect cache key unless we baked it in (we don't currently), + // but we should redraw. + markDirty(); + } + + void draw(const GfxRenderer& renderer, const ThemeContext& context) override; +}; + +// --- ProgressBar --- +class ProgressBar : public UIElement { + Expression valueExpr; // Current value (0-100 or 0-max) + Expression maxExpr; // Max value (default 100) + Expression fgColorExpr; // Foreground color + Expression bgColorExpr; // Background color + bool showBorder = true; + int borderWidth = 1; + + public: + explicit ProgressBar(const std::string& id) + : UIElement(id), + valueExpr(Expression::parse("0")), + maxExpr(Expression::parse("100")), + fgColorExpr(Expression::parse("0x00")), // Black fill + bgColorExpr(Expression::parse("0xFF")) // White background + {} + + ElementType getType() const override { return ElementType::ProgressBar; } + const char* getTypeName() const override { return "ProgressBar"; } + + void setValue(const std::string& expr) { + valueExpr = Expression::parse(expr); + markDirty(); + } + void setMax(const std::string& expr) { + maxExpr = Expression::parse(expr); + markDirty(); + } + void setFgColor(const std::string& expr) { + fgColorExpr = Expression::parse(expr); + markDirty(); + } + void setBgColor(const std::string& expr) { + bgColorExpr = Expression::parse(expr); + markDirty(); + } + void setShowBorder(bool show) { + showBorder = show; + markDirty(); + } + + void draw(const GfxRenderer& renderer, const ThemeContext& context) override { + if (!isVisible(context)) return; + + std::string valStr = context.evaluatestring(valueExpr); + std::string maxStr = context.evaluatestring(maxExpr); + + int value = parseIntSafe(valStr, 0); + int maxVal = parseIntSafe(maxStr, 100); + if (maxVal <= 0) maxVal = 100; + + float ratio = static_cast(value) / static_cast(maxVal); + if (ratio < 0) ratio = 0; + if (ratio > 1) ratio = 1; + + // Draw background + std::string bgStr = context.evaluatestring(bgColorExpr); + uint8_t bgColor = Color::parse(bgStr).value; + renderer.fillRect(absX, absY, absW, absH, bgColor == 0x00); + + // Draw filled portion + int fillWidth = static_cast(absW * ratio); + if (fillWidth > 0) { + std::string fgStr = context.evaluatestring(fgColorExpr); + uint8_t fgColor = Color::parse(fgStr).value; + renderer.fillRect(absX, absY, fillWidth, absH, fgColor == 0x00); + } + + // Draw border + if (showBorder) { + renderer.drawRect(absX, absY, absW, absH, true); + } + + markClean(); + } +}; + +// --- Divider (horizontal or vertical line) --- +class Divider : public UIElement { + Expression colorExpr; + bool horizontal = true; + int thickness = 1; + + public: + explicit Divider(const std::string& id) : UIElement(id), colorExpr(Expression::parse("0x00")) {} + + ElementType getType() const override { return ElementType::Divider; } + const char* getTypeName() const override { return "Divider"; } + + void setColorExpr(const std::string& expr) { + colorExpr = Expression::parse(expr); + markDirty(); + } + void setHorizontal(bool h) { + horizontal = h; + markDirty(); + } + void setThickness(int t) { + thickness = t; + markDirty(); + } + + void draw(const GfxRenderer& renderer, const ThemeContext& context) override { + if (!isVisible(context)) return; + + std::string colStr = context.evaluatestring(colorExpr); + uint8_t color = Color::parse(colStr).value; + bool black = (color == 0x00); + + if (horizontal) { + for (int i = 0; i < thickness && i < absH; i++) { + renderer.drawLine(absX, absY + i, absX + absW - 1, absY + i, black); + } + } else { + for (int i = 0; i < thickness && i < absW; i++) { + renderer.drawLine(absX + i, absY, absX + i, absY + absH - 1, black); + } + } + + markClean(); + } +}; + +// --- BatteryIcon --- +class BatteryIcon : public UIElement { + Expression valueExpr; + Expression colorExpr; + + public: + explicit BatteryIcon(const std::string& id) + : UIElement(id), valueExpr(Expression::parse("0")), colorExpr(Expression::parse("0x00")) { + // Black by default + } + + ElementType getType() const override { return ElementType::BatteryIcon; } + const char* getTypeName() const override { return "BatteryIcon"; } + + void setValue(const std::string& expr) { + valueExpr = Expression::parse(expr); + markDirty(); + } + + void setColor(const std::string& expr) { + colorExpr = Expression::parse(expr); + markDirty(); + } + + void draw(const GfxRenderer& renderer, const ThemeContext& context) override { + if (!isVisible(context)) return; + + std::string valStr = context.evaluatestring(valueExpr); + int percentage = parseIntSafe(valStr, 0); + + std::string colStr = context.evaluatestring(colorExpr); + uint8_t color = Color::parse(colStr).value; + bool black = (color == 0x00); + + constexpr int batteryWidth = 15; + constexpr int batteryHeight = 12; + + int x = absX; + int y = absY; + + if (absW > batteryWidth) x += (absW - batteryWidth) / 2; + if (absH > batteryHeight) y += (absH - batteryHeight) / 2; + + renderer.drawLine(x + 1, y, x + batteryWidth - 3, y, black); + renderer.drawLine(x + 1, y + batteryHeight - 1, x + batteryWidth - 3, y + batteryHeight - 1, black); + renderer.drawLine(x, y + 1, x, y + batteryHeight - 2, black); + renderer.drawLine(x + batteryWidth - 2, y + 1, x + batteryWidth - 2, y + batteryHeight - 2, black); + + renderer.drawPixel(x + batteryWidth - 1, y + 3, black); + renderer.drawPixel(x + batteryWidth - 1, y + batteryHeight - 4, black); + renderer.drawLine(x + batteryWidth - 0, y + 4, x + batteryWidth - 0, y + batteryHeight - 5, black); + + if (percentage > 0) { + int filledWidth = percentage * (batteryWidth - 5) / 100 + 1; + if (filledWidth > batteryWidth - 5) { + filledWidth = batteryWidth - 5; + } + renderer.fillRect(x + 2, y + 2, filledWidth, batteryHeight - 4, black); + } + + markClean(); + } +}; + +} // namespace ThemeEngine diff --git a/lib/ThemeEngine/include/DefaultTheme.h b/lib/ThemeEngine/include/DefaultTheme.h new file mode 100644 index 00000000..3263b041 --- /dev/null +++ b/lib/ThemeEngine/include/DefaultTheme.h @@ -0,0 +1,303 @@ +#pragma once + +// Default theme - matches the original CrossPoint Reader look +// This is embedded in the firmware as a fallback + +namespace ThemeEngine { + +// Use static function for C++14 ODR compatibility +static const char* getDefaultThemeIni() { + static const char* theme = R"INI( +; ============================================ +; DEFAULT THEME - Original CrossPoint Reader +; ============================================ +; Screen: 480x800 +; Layout: Centered book card + vertical menu list + +[Global] +FontUI12 = UI_12 +FontUI10 = UI_10 +NavBookCount = 1 + +; ============================================ +; HOME SCREEN +; ============================================ + +[Home] +Type = Container +X = 0 +Y = 0 +Width = 480 +Height = 800 +BgColor = white + +; --- Battery (top right) --- +[BatteryWrapper] +Parent = Home +Type = Container +X = 400 +Y = 10 +Width = 80 +Height = 20 + +[BatteryIcon] +Parent = BatteryWrapper +Type = BatteryIcon +X = 0 +Y = 5 +Width = 15 +Height = 20 +Value = {BatteryPercent} +Color = black + +[BatteryText] +Parent = BatteryWrapper +Type = Label +Font = Small +Text = {BatteryPercent}% +X = 22 +Y = 0 +Width = 50 +Height = 20 +Align = Left +Visible = {ShowBatteryPercent} + +; --- Book Card (centered) --- +; Original: 240x400 at (120, 30) +[BookCard] +Parent = Home +Type = Container +X = 120 +Y = 30 +Width = 240 +Height = 400 +Border = true +BgColor = {IsBookSelected ? "black" : "white"} +Visible = {HasBook} + +; Bookmark ribbon decoration (when no cover) +[BookmarkRibbon] +Parent = BookCard +Type = Container +X = 200 +Y = 5 +Width = 30 +Height = 60 +BgColor = {IsBookSelected ? "white" : "black"} +Visible = {!HasCover} + +[BookmarkNotch] +Parent = BookmarkRibbon +Type = Container +X = 10 +Y = 45 +Width = 10 +Height = 15 +BgColor = {IsBookSelected ? "black" : "white"} + +; Title centered in card +[BookCover] +Parent = BookCard +Type = Bitmap +X = 0 +Y = 0 +Width = 240 +Height = 400 +Src = {BookCoverPath} +ScaleToFit = true +PreserveAspect = true +Visible = {HasCover} + +; White box for text overlay +[InfoBox] +Parent = BookCard +Type = Container +X = 20 +Y = 120 +Width = 200 +Height = 150 +BgColor = white +Border = true + +[BookTitle] +Parent = InfoBox +Type = Label +Font = UI_12 +Text = {BookTitle} +X = 10 +Y = 10 +Width = 180 +Height = 80 +Color = black +Align = center +Ellipsis = true +MaxLines = 3 + +[BookAuthor] +Parent = InfoBox +Type = Label +Font = UI_10 +Text = {BookAuthor} +X = 10 +Y = 100 +Width = 180 +Height = 40 +Color = black +Align = center +Ellipsis = true + +; "Continue Reading" at bottom of card +[ContinueLabel] +Parent = BookCard +Type = Label +Font = UI_10 +Text = Continue Reading +X = 20 +Y = 365 +Width = 200 +Height = 25 +Color = {IsBookSelected ? "white" : "black"} +Align = center +Visible = {HasBook} + +; --- No Book Message --- +[NoBookCard] +Parent = Home +Type = Container +X = 120 +Y = 30 +Width = 240 +Height = 400 +Border = true +Visible = {!HasBook} + +[NoBookTitle] +Parent = NoBookCard +Type = Label +Font = UI_12 +Text = No open book +X = 20 +Y = 175 +Width = 200 +Height = 25 +Align = center + +[NoBookSubtitle] +Parent = NoBookCard +Type = Label +Font = UI_10 +Text = Start reading below +X = 20 +Y = 205 +Width = 200 +Height = 25 +Align = center + +; --- Menu List --- +; Original: margin=20, tileWidth=440, tileHeight=45, spacing=8 +; menuStartY = 30 + 400 + 15 = 445 +[MenuList] +Parent = Home +Type = List +Source = MainMenu +ItemTemplate = MenuItem +X = 20 +Y = 445 +Width = 440 +Height = 280 +Direction = Vertical +ItemHeight = 45 +Spacing = 8 + +; --- Menu Item Template --- +[MenuItem] +Type = Container +Width = 440 +Height = 45 +BgColor = {Item.Selected ? "black" : "white"} +Border = true + +[MenuItemLabel] +Parent = MenuItem +Type = Label +Font = UI_10 +Text = {Item.Title} +X = 0 +Y = 0 +Width = 440 +Height = 45 +Color = {Item.Selected ? "white" : "black"} +Align = center + +; --- Button Hints (bottom) --- +; Original: 4 buttons at [25, 130, 245, 350], width=106, height=40 +; Y = pageHeight - 40 = 760 + +[HintBtn2] +Parent = Home +Type = Container +X = 130 +Y = 760 +Width = 106 +Height = 40 +BgColor = white +Border = true + +[HintBtn2Label] +Parent = HintBtn2 +Type = Label +Font = UI_10 +Text = Confirm +X = 0 +Y = 0 +Width = 106 +Height = 40 +Align = center + +[HintBtn3] +Parent = Home +Type = Container +X = 245 +Y = 760 +Width = 106 +Height = 40 +BgColor = white +Border = true + +[HintBtn3Label] +Parent = HintBtn3 +Type = Label +Font = UI_10 +Text = Up +X = 0 +Y = 0 +Width = 106 +Height = 40 +Align = center + +[HintBtn4] +Parent = Home +Type = Container +X = 350 +Y = 760 +Width = 106 +Height = 40 +BgColor = white +Border = true + +[HintBtn4Label] +Parent = HintBtn4 +Type = Label +Font = UI_10 +Text = Down +X = 0 +Y = 0 +Width = 106 +Height = 40 +Align = center + +)INI"; + return theme; +} + +} // namespace ThemeEngine diff --git a/lib/ThemeEngine/include/IniParser.h b/lib/ThemeEngine/include/IniParser.h new file mode 100644 index 00000000..3f7321b4 --- /dev/null +++ b/lib/ThemeEngine/include/IniParser.h @@ -0,0 +1,38 @@ +#pragma once + +#include +#include +#include + +// Forward declaration for FS file or stream if needed, +// but for now we'll take a string buffer or filename to keep it generic? +// Or better, depend on FS.h to read files directly. + +#ifdef FILE_READ +#undef FILE_READ +#endif +#ifdef FILE_WRITE +#undef FILE_WRITE +#endif +#include + +namespace ThemeEngine { + +struct IniSection { + std::string name; + std::map properties; +}; + +class IniParser { + public: + // Parse a stream (File, Serial, etc.) + static std::map> parse(Stream& stream); + + // Parse a string buffer (useful for testing) + static std::map> parseString(const std::string& content); + + private: + static void trim(std::string& s); +}; + +} // namespace ThemeEngine diff --git a/lib/ThemeEngine/include/LayoutElements.h b/lib/ThemeEngine/include/LayoutElements.h new file mode 100644 index 00000000..53841430 --- /dev/null +++ b/lib/ThemeEngine/include/LayoutElements.h @@ -0,0 +1,721 @@ +#pragma once + +#include + +#include "BasicElements.h" +#include "ThemeContext.h" +#include "ThemeTypes.h" +#include "UIElement.h" + +namespace ThemeEngine { + +// --- HStack: Horizontal Stack Layout --- +// Children are arranged horizontally with optional spacing +class HStack : public Container { + public: + enum class VAlign { Top, Center, Bottom }; + + private: + int spacing = 0; // Gap between children + int padding = 0; // Internal padding + VAlign vAlign = VAlign::Top; + + public: + HStack(const std::string& id) : Container(id) {} + ElementType getType() const override { return ElementType::HStack; } + const char* getTypeName() const override { return "HStack"; } + + void setSpacing(int s) { + spacing = s; + markDirty(); + } + void setPadding(int p) { + padding = p; + markDirty(); + } + void setVAlign(VAlign a) { + vAlign = a; + markDirty(); + } + void setVAlignFromString(const std::string& s) { + if (s == "center" || s == "Center") { + vAlign = VAlign::Center; + } else if (s == "bottom" || s == "Bottom") { + vAlign = VAlign::Bottom; + } else { + vAlign = VAlign::Top; + } + markDirty(); + } + + void layout(const ThemeContext& context, int parentX, int parentY, int parentW, int parentH) override { + UIElement::layout(context, parentX, parentY, parentW, parentH); + + int currentX = absX + padding; + int availableH = absH - 2 * padding; + int availableW = absW - 2 * padding; + + for (auto child : children) { + // Let child calculate its preferred size first + // Pass large parent bounds to avoid clamping issues during size calculation + child->layout(context, currentX, absY + padding, availableW, availableH); + int childW = child->getAbsW(); + int childH = child->getAbsH(); + + // Extract child's own Y offset (from first layout pass) + int childYOffset = child->getAbsY() - (absY + padding); + + // Calculate base position based on vertical alignment + int childY = absY + padding; + if (childH < availableH) { + switch (vAlign) { + case VAlign::Center: + childY = absY + padding + (availableH - childH) / 2; + break; + case VAlign::Bottom: + childY = absY + padding + (availableH - childH); + break; + case VAlign::Top: + default: + childY = absY + padding; + break; + } + } + + // Add child's own Y offset to the calculated position + childY += childYOffset; + + // Only do second layout pass if position changed from first pass + int firstPassY = child->getAbsY(); + if (childY != firstPassY) { + child->layout(context, currentX, childY, childW, childH); + } + currentX += childW + spacing; + availableW -= (childW + spacing); + if (availableW < 0) availableW = 0; + } + } +}; + +// --- VStack: Vertical Stack Layout --- +// Children are arranged vertically with optional spacing +class VStack : public Container { + public: + enum class HAlign { Left, Center, Right }; + + private: + int spacing = 0; + int padding = 0; + HAlign hAlign = HAlign::Left; + + public: + VStack(const std::string& id) : Container(id) {} + ElementType getType() const override { return ElementType::VStack; } + const char* getTypeName() const override { return "VStack"; } + + void setSpacing(int s) { + spacing = s; + markDirty(); + } + void setPadding(int p) { + padding = p; + markDirty(); + } + void setHAlign(HAlign a) { + hAlign = a; + markDirty(); + } + void setHAlignFromString(const std::string& s) { + if (s == "center" || s == "Center") { + hAlign = HAlign::Center; + } else if (s == "right" || s == "Right") { + hAlign = HAlign::Right; + } else { + hAlign = HAlign::Left; + } + markDirty(); + } + + void layout(const ThemeContext& context, int parentX, int parentY, int parentW, int parentH) override { + UIElement::layout(context, parentX, parentY, parentW, parentH); + + int currentY = absY + padding; + int availableW = absW - 2 * padding; + int availableH = absH - 2 * padding; + + for (auto child : children) { + // Pass large parent bounds to avoid clamping issues during size calculation + child->layout(context, absX + padding, currentY, availableW, availableH); + int childW = child->getAbsW(); + int childH = child->getAbsH(); + + // Extract child's own X offset (from first layout pass) + int childXOffset = child->getAbsX() - (absX + padding); + + // Calculate base position based on horizontal alignment + int childX = absX + padding; + if (childW < availableW) { + switch (hAlign) { + case HAlign::Center: + childX = absX + padding + (availableW - childW) / 2; + break; + case HAlign::Right: + childX = absX + padding + (availableW - childW); + break; + case HAlign::Left: + default: + childX = absX + padding; + break; + } + } + + // Add child's own X offset to the calculated position + childX += childXOffset; + + // Only do second layout pass if position changed from first pass + int firstPassX = child->getAbsX(); + if (childX != firstPassX) { + child->layout(context, childX, currentY, childW, childH); + } + currentY += childH + spacing; + availableH -= (childH + spacing); + if (availableH < 0) availableH = 0; + } + } +}; + +// --- Grid: Grid Layout --- +// Children arranged in a grid with specified columns +class Grid : public Container { + int columns = 2; + int rowSpacing = 10; + int colSpacing = 10; + int padding = 0; + + public: + Grid(const std::string& id) : Container(id) {} + ElementType getType() const override { return ElementType::Grid; } + const char* getTypeName() const override { return "Grid"; } + + void setColumns(int c) { + columns = c > 0 ? c : 1; + markDirty(); + } + void setRowSpacing(int s) { + rowSpacing = s; + markDirty(); + } + void setColSpacing(int s) { + colSpacing = s; + markDirty(); + } + void setPadding(int p) { + padding = p; + markDirty(); + } + + void layout(const ThemeContext& context, int parentX, int parentY, int parentW, int parentH) override { + UIElement::layout(context, parentX, parentY, parentW, parentH); + + if (children.empty()) return; + + // Guard against division by zero + int cols = columns > 0 ? columns : 1; + int availableW = absW - 2 * padding - (cols - 1) * colSpacing; + int cellW = availableW / cols; + int availableH = absH - 2 * padding; + + int row = 0, col = 0; + int currentY = absY + padding; + int maxRowHeight = 0; + + for (auto child : children) { + int cellX = absX + padding + col * (cellW + colSpacing); + + // Pass cell dimensions to avoid clamping issues + child->layout(context, cellX, currentY, cellW, availableH); + int childH = child->getAbsH(); + if (childH > maxRowHeight) maxRowHeight = childH; + + col++; + if (col >= cols) { + col = 0; + row++; + currentY += maxRowHeight + rowSpacing; + availableH -= (maxRowHeight + rowSpacing); + if (availableH < 0) availableH = 0; + maxRowHeight = 0; + } + } + } +}; + +// --- Badge: Small overlay text/indicator --- +class Badge : public UIElement { + Expression textExpr; + Expression bgColorExpr; + Expression fgColorExpr; + int fontId = 0; + int paddingH = 8; // Horizontal padding + int paddingV = 4; // Vertical padding + int borderRadius = 0; + + public: + Badge(const std::string& id) : UIElement(id) { + bgColorExpr = Expression::parse("0x00"); // Black background + fgColorExpr = Expression::parse("0xFF"); // White text + } + + ElementType getType() const override { return ElementType::Badge; } + const char* getTypeName() const override { return "Badge"; } + + void setText(const std::string& expr) { + textExpr = Expression::parse(expr); + markDirty(); + } + void setBgColor(const std::string& expr) { + bgColorExpr = Expression::parse(expr); + markDirty(); + } + void setFgColor(const std::string& expr) { + fgColorExpr = Expression::parse(expr); + markDirty(); + } + void setFont(int fid) { + fontId = fid; + markDirty(); + } + void setPaddingH(int p) { + paddingH = p; + markDirty(); + } + void setPaddingV(int p) { + paddingV = p; + markDirty(); + } + + void setBorderRadius(int r) { + borderRadius = r; + markDirty(); + } + + void draw(const GfxRenderer& renderer, const ThemeContext& context) override { + if (!isVisible(context)) return; + + std::string text = context.evaluatestring(textExpr); + if (text.empty()) { + markClean(); + return; + } + + // Calculate badge size based on text content - always auto-sizes + int textW = renderer.getTextWidth(fontId, text.c_str()); + int textH = renderer.getLineHeight(fontId); + int badgeW = textW + 2 * paddingH; + int badgeH = textH + 2 * paddingV; + + // Badge always auto-sizes to content + int drawW = badgeW; + int drawH = badgeH; + + // Position the badge within its container + // If absW/absH are set, use them as bounding box for alignment + int drawX = absX; + int drawY = absY; + + // Right-align badge within bounding box if width is specified + if (absW > 0 && absW > drawW) { + drawX = absX + absW - drawW; + } + // Vertically center badge within bounding box if height is specified + if (absH > 0 && absH > drawH) { + drawY = absY + (absH - drawH) / 2; + } + + // Draw background + std::string bgStr = context.evaluatestring(bgColorExpr); + uint8_t bgColor = Color::parse(bgStr).value; + if (borderRadius > 0) { + if (bgColor == 0x00) { + renderer.fillRoundedRect(drawX, drawY, drawW, drawH, borderRadius, true); + } else if (bgColor >= 0xF0) { + renderer.fillRoundedRect(drawX, drawY, drawW, drawH, borderRadius, false); + } else { + renderer.fillRoundedRectDithered(drawX, drawY, drawW, drawH, borderRadius, bgColor); + } + } else { + renderer.fillRect(drawX, drawY, drawW, drawH, bgColor == 0x00); + } + + // Draw border for contrast (only if not black background) + if (bgColor != 0x00) { + if (borderRadius > 0) { + renderer.drawRoundedRect(drawX, drawY, drawW, drawH, borderRadius, true); + } else { + renderer.drawRect(drawX, drawY, drawW, drawH, true); + } + } + + // Draw text centered within the badge + std::string fgStr = context.evaluatestring(fgColorExpr); + uint8_t fgColor = Color::parse(fgStr).value; + int textX = drawX + paddingH; + int textY = drawY + paddingV; + renderer.drawText(fontId, textX, textY, text.c_str(), fgColor == 0x00); + + markClean(); + } +}; + +// --- Toggle: On/Off Switch --- +// Fully themable toggle with track and knob +// Supports rounded or square appearance based on BorderRadius +class Toggle : public UIElement { + Expression valueExpr; // Boolean expression for on/off state + Expression onColorExpr; // Track color when ON + Expression offColorExpr; // Track color when OFF + Expression knobColorExpr; // Knob color (optional, defaults to opposite of track) + int trackWidth = 44; + int trackHeight = 24; + int knobSize = 20; + int borderRadius = 0; // 0 = square, >0 = rounded (use trackHeight/2 for pill shape) + int knobRadius = 0; // Knob corner radius + + public: + Toggle(const std::string& id) : UIElement(id) { + valueExpr = Expression::parse("false"); + onColorExpr = Expression::parse("0x00"); // Black when on + offColorExpr = Expression::parse("0xCC"); // Light gray when off + } + + ElementType getType() const override { return ElementType::Toggle; } + const char* getTypeName() const override { return "Toggle"; } + + void setValue(const std::string& expr) { + valueExpr = Expression::parse(expr); + markDirty(); + } + void setOnColor(const std::string& expr) { + onColorExpr = Expression::parse(expr); + markDirty(); + } + void setOffColor(const std::string& expr) { + offColorExpr = Expression::parse(expr); + markDirty(); + } + void setKnobColor(const std::string& expr) { + knobColorExpr = Expression::parse(expr); + markDirty(); + } + void setTrackWidth(int w) { + trackWidth = w; + markDirty(); + } + void setTrackHeight(int h) { + trackHeight = h; + markDirty(); + } + void setKnobSize(int s) { + knobSize = s; + markDirty(); + } + void setBorderRadius(int r) { + borderRadius = r; + markDirty(); + } + void setKnobRadius(int r) { + knobRadius = r; + markDirty(); + } + + void draw(const GfxRenderer& renderer, const ThemeContext& context) override { + if (!isVisible(context)) return; + + // Evaluate the value - handle simple variable references directly + bool isOn = false; + std::string rawExpr = valueExpr.rawExpr; + + // If it's a simple {variable} reference, resolve it directly + if (rawExpr.size() > 2 && rawExpr.front() == '{' && rawExpr.back() == '}') { + std::string varName = rawExpr.substr(1, rawExpr.size() - 2); + // Trim whitespace + size_t start = varName.find_first_not_of(" \t"); + size_t end = varName.find_last_not_of(" \t"); + if (start != std::string::npos) { + varName = varName.substr(start, end - start + 1); + } + isOn = context.getAnyAsBool(varName, false); + } else { + isOn = context.evaluateBool(rawExpr); + } + + // Get track color based on state + std::string colorStr = isOn ? context.evaluatestring(onColorExpr) : context.evaluatestring(offColorExpr); + uint8_t trackColor = Color::parse(colorStr).value; + + // Calculate track position (centered vertically in bounding box) + int trackX = absX; + int trackY = absY + (absH - trackHeight) / 2; + + // Calculate effective border radius (capped at half height for pill shape) + int effectiveRadius = borderRadius; + if (effectiveRadius > trackHeight / 2) { + effectiveRadius = trackHeight / 2; + } + + // Draw track + if (effectiveRadius > 0) { + // Rounded track + if (trackColor == 0x00) { + renderer.fillRoundedRect(trackX, trackY, trackWidth, trackHeight, effectiveRadius, true); + } else if (trackColor >= 0xF0) { + renderer.fillRoundedRect(trackX, trackY, trackWidth, trackHeight, effectiveRadius, false); + renderer.drawRoundedRect(trackX, trackY, trackWidth, trackHeight, effectiveRadius, true); + } else { + renderer.fillRoundedRectDithered(trackX, trackY, trackWidth, trackHeight, effectiveRadius, trackColor); + renderer.drawRoundedRect(trackX, trackY, trackWidth, trackHeight, effectiveRadius, true); + } + } else { + // Square track + if (trackColor == 0x00) { + renderer.fillRect(trackX, trackY, trackWidth, trackHeight, true); + } else if (trackColor >= 0xF0) { + renderer.fillRect(trackX, trackY, trackWidth, trackHeight, false); + renderer.drawRect(trackX, trackY, trackWidth, trackHeight, true); + } else { + renderer.fillRectDithered(trackX, trackY, trackWidth, trackHeight, trackColor); + renderer.drawRect(trackX, trackY, trackWidth, trackHeight, true); + } + } + + // Calculate knob position + int knobMargin = (trackHeight - knobSize) / 2; + int knobX = isOn ? (trackX + trackWidth - knobSize - knobMargin) : (trackX + knobMargin); + int knobY = trackY + knobMargin; + + // Determine knob color + bool knobBlack; + if (!knobColorExpr.empty()) { + std::string knobStr = context.evaluatestring(knobColorExpr); + uint8_t knobColor = Color::parse(knobStr).value; + knobBlack = (knobColor == 0x00); + } else { + // Default: knob is opposite color of track + knobBlack = (trackColor >= 0x80); + } + + // Calculate effective knob radius + int effectiveKnobRadius = knobRadius; + if (effectiveKnobRadius > knobSize / 2) { + effectiveKnobRadius = knobSize / 2; + } + + // Draw knob + if (effectiveKnobRadius > 0) { + renderer.fillRoundedRect(knobX, knobY, knobSize, knobSize, effectiveKnobRadius, knobBlack); + if (!knobBlack) { + renderer.drawRoundedRect(knobX, knobY, knobSize, knobSize, effectiveKnobRadius, true); + } + } else { + renderer.fillRect(knobX, knobY, knobSize, knobSize, knobBlack); + if (!knobBlack) { + renderer.drawRect(knobX, knobY, knobSize, knobSize, true); + } + } + + markClean(); + } +}; + +// --- TabBar: Horizontal tab selection --- +class TabBar : public Container { + Expression selectedExpr; // Currently selected tab index or name + int tabSpacing = 0; + int padding = 0; + int indicatorHeight = 3; + bool showIndicator = true; + + public: + TabBar(const std::string& id) : Container(id) {} + ElementType getType() const override { return ElementType::TabBar; } + const char* getTypeName() const override { return "TabBar"; } + + void setSelected(const std::string& expr) { + selectedExpr = Expression::parse(expr); + markDirty(); + } + void setTabSpacing(int s) { + tabSpacing = s; + markDirty(); + } + void setPadding(int p) { + padding = p; + markDirty(); + } + void setIndicatorHeight(int h) { + indicatorHeight = h; + markDirty(); + } + void setShowIndicator(bool show) { + showIndicator = show; + markDirty(); + } + + void layout(const ThemeContext& context, int parentX, int parentY, int parentW, int parentH) override { + UIElement::layout(context, parentX, parentY, parentW, parentH); + + if (children.empty()) return; + + // Distribute tabs evenly + int numTabs = children.size(); + int totalSpacing = (numTabs - 1) * tabSpacing; + int availableW = absW - 2 * padding - totalSpacing; + int tabW = availableW / numTabs; + int currentX = absX + padding; + + for (size_t i = 0; i < children.size(); i++) { + children[i]->layout(context, currentX, absY, tabW, absH - indicatorHeight); + currentX += tabW + tabSpacing; + } + } + + void draw(const GfxRenderer& renderer, const ThemeContext& context) override { + if (!isVisible(context)) return; + + // Draw background if set + if (hasBg) { + std::string colStr = context.evaluatestring(bgColorExpr); + uint8_t color = Color::parse(colStr).value; + renderer.fillRect(absX, absY, absW, absH, color == 0x00); + } + + // Draw children (tab labels) + for (auto child : children) { + child->draw(renderer, context); + } + + // Draw selection indicator + if (showIndicator && !children.empty()) { + std::string selStr = context.evaluatestring(selectedExpr); + int selectedIdx = parseIntSafe(selStr, 0); + + if (selectedIdx >= 0 && selectedIdx < static_cast(children.size())) { + UIElement* tab = children[selectedIdx]; + int indX = tab->getAbsX(); + int indY = absY + absH - indicatorHeight; + int indW = tab->getAbsW(); + renderer.fillRect(indX, indY, indW, indicatorHeight, true); + } + } + + markClean(); + } +}; + +// --- Icon: Small symbolic image --- +// Can be a built-in icon name or a path to a BMP +class Icon : public UIElement { + Expression srcExpr; // Icon name or path + Expression colorExpr; + int iconSize = 24; + + // Built-in icon names and their simple representations + // In a real implementation, these would be actual bitmap data + + public: + Icon(const std::string& id) : UIElement(id) { + colorExpr = Expression::parse("0x00"); // Black by default + } + + ElementType getType() const override { return ElementType::Icon; } + const char* getTypeName() const override { return "Icon"; } + + void setSrc(const std::string& expr) { + srcExpr = Expression::parse(expr); + markDirty(); + } + void setColorExpr(const std::string& expr) { + colorExpr = Expression::parse(expr); + markDirty(); + } + void setIconSize(int s) { + iconSize = s; + markDirty(); + } + + void draw(const GfxRenderer& renderer, const ThemeContext& context) override; +}; + +// --- ScrollIndicator: Visual scroll position --- +class ScrollIndicator : public UIElement { + Expression positionExpr; // 0.0 to 1.0 + Expression totalExpr; // Total items + Expression visibleExpr; // Visible items + int trackWidth = 4; + + public: + ScrollIndicator(const std::string& id) : UIElement(id) { + positionExpr = Expression::parse("0"); + totalExpr = Expression::parse("1"); + visibleExpr = Expression::parse("1"); + } + + ElementType getType() const override { return ElementType::ScrollIndicator; } + const char* getTypeName() const override { return "ScrollIndicator"; } + + void setPosition(const std::string& expr) { + positionExpr = Expression::parse(expr); + markDirty(); + } + void setTotal(const std::string& expr) { + totalExpr = Expression::parse(expr); + markDirty(); + } + void setVisibleCount(const std::string& expr) { + visibleExpr = Expression::parse(expr); + markDirty(); + } + void setTrackWidth(int w) { + trackWidth = w; + markDirty(); + } + + void draw(const GfxRenderer& renderer, const ThemeContext& context) override { + if (!isVisible(context)) return; + + // Get values + std::string posStr = context.evaluatestring(positionExpr); + std::string totalStr = context.evaluatestring(totalExpr); + std::string visStr = context.evaluatestring(visibleExpr); + + float position = parseFloatSafe(posStr, 0.0f); + int total = parseIntSafe(totalStr, 1); + int visible = parseIntSafe(visStr, 1); + + if (total <= visible) { + // No need to show scrollbar + markClean(); + return; + } + + // Draw track + int trackX = absX + (absW - trackWidth) / 2; + renderer.drawRect(trackX, absY, trackWidth, absH, true); + + // Calculate thumb size and position + float ratio = static_cast(visible) / static_cast(total); + int thumbH = static_cast(absH * ratio); + if (thumbH < 20) thumbH = 20; // Minimum thumb size + + int maxScroll = total - visible; + float scrollRatio = maxScroll > 0 ? position / maxScroll : 0; + int thumbY = absY + static_cast((absH - thumbH) * scrollRatio); + + // Draw thumb + renderer.fillRect(trackX, thumbY, trackWidth, thumbH, true); + + markClean(); + } +}; + +} // namespace ThemeEngine diff --git a/lib/ThemeEngine/include/ListElement.h b/lib/ThemeEngine/include/ListElement.h new file mode 100644 index 00000000..c86852d5 --- /dev/null +++ b/lib/ThemeEngine/include/ListElement.h @@ -0,0 +1,144 @@ +#pragma once + +#include +#include + +#include "BasicElements.h" +#include "UIElement.h" + +namespace ThemeEngine { + +// --- List --- +// Supports vertical, horizontal, and grid layouts +class List : public Container { + public: + enum class Direction { Vertical, Horizontal }; + enum class LayoutMode { List, Grid }; + + private: + std::string source; // Data source name (e.g., "MainMenu", "FileList") + std::string itemTemplateId; // ID of the template element + int itemWidth = 0; // Explicit item width (0 = auto) + int itemHeight = 0; // Explicit item height (0 = auto from template) + int scrollOffset = 0; // Scroll position for long lists + int visibleItems = -1; // Max visible items (-1 = auto) + int spacing = 0; // Gap between items + int columns = 1; // Number of columns (for grid mode) + Direction direction = Direction::Vertical; + LayoutMode layoutMode = LayoutMode::List; + + // Template element reference (resolved after loading) + UIElement* itemTemplate = nullptr; + + public: + List(const std::string& id) : Container(id) {} + + ElementType getType() const override { return ElementType::List; } + const char* getTypeName() const override { return "List"; } + + void setSource(const std::string& s) { + source = s; + markDirty(); + } + + const std::string& getSource() const { return source; } + + void setItemTemplateId(const std::string& id) { + itemTemplateId = id; + markDirty(); + } + + void setItemTemplate(UIElement* elem) { + itemTemplate = elem; + markDirty(); + } + + UIElement* getItemTemplate() const { return itemTemplate; } + + void setItemWidth(int w) { + itemWidth = w; + markDirty(); + } + + void setItemHeight(int h) { + itemHeight = h; + markDirty(); + } + + int getItemHeight() const { + if (itemHeight > 0) return itemHeight; + if (itemTemplate) return itemTemplate->getAbsH() > 0 ? itemTemplate->getAbsH() : 45; + return 45; + } + + int getItemWidth() const { + if (itemWidth > 0) return itemWidth; + if (itemTemplate) return itemTemplate->getAbsW() > 0 ? itemTemplate->getAbsW() : 100; + return 100; + } + + void setScrollOffset(int offset) { + scrollOffset = offset; + markDirty(); + } + + int getScrollOffset() const { return scrollOffset; } + + void setVisibleItems(int count) { + visibleItems = count; + markDirty(); + } + + void setSpacing(int s) { + spacing = s; + markDirty(); + } + + void setColumns(int c) { + columns = c > 0 ? c : 1; + if (columns > 1) layoutMode = LayoutMode::Grid; + markDirty(); + } + + void setDirection(Direction d) { + direction = d; + markDirty(); + } + + void setDirectionFromString(const std::string& dir) { + if (dir == "Horizontal" || dir == "horizontal" || dir == "row") { + direction = Direction::Horizontal; + } else { + direction = Direction::Vertical; + } + markDirty(); + } + + void setLayoutMode(LayoutMode m) { + layoutMode = m; + markDirty(); + } + + // Resolve template reference from element map + void resolveTemplate(const std::map& elements) { + if (elements.count(itemTemplateId)) { + itemTemplate = elements.at(itemTemplateId); + } + } + + void layout(const ThemeContext& context, int parentX, int parentY, int parentW, int parentH) override { + // Layout self first (bounds) + UIElement::layout(context, parentX, parentY, parentW, parentH); + + // Pre-layout the template once with list's dimensions to get item sizes + // Pass absH so percentage heights in the template work correctly + if (itemTemplate && itemHeight == 0) { + itemTemplate->layout(context, absX, absY, absW, absH); + } + } + + // Draw is implemented in BasicElements.cpp + void draw(const GfxRenderer& renderer, const ThemeContext& context) override; +}; + +} // namespace ThemeEngine diff --git a/lib/ThemeEngine/include/ThemeContext.h b/lib/ThemeEngine/include/ThemeContext.h new file mode 100644 index 00000000..8db01212 --- /dev/null +++ b/lib/ThemeEngine/include/ThemeContext.h @@ -0,0 +1,564 @@ +#pragma once + +#include +#include +#include +#include +#include +#include + +namespace ThemeEngine { + +// Token types for expression parsing +struct ExpressionToken { + enum Type { LITERAL, VARIABLE }; + Type type; + std::string value; // Literal text or variable name +}; + +// Pre-parsed expression for efficient repeated evaluation +struct Expression { + std::vector tokens; + std::string rawExpr; // Original expression string for complex evaluation + + bool empty() const { return tokens.empty() && rawExpr.empty(); } + + static Expression parse(const std::string& str) { + Expression expr; + expr.rawExpr = str; + + if (str.empty()) return expr; + + size_t start = 0; + while (start < str.length()) { + size_t open = str.find('{', start); + if (open == std::string::npos) { + // Remaining literal + expr.tokens.push_back({ExpressionToken::LITERAL, str.substr(start)}); + break; + } + + if (open > start) { + // Literal before variable + expr.tokens.push_back({ExpressionToken::LITERAL, str.substr(start, open - start)}); + } + + size_t close = str.find('}', open); + if (close == std::string::npos) { + // Broken brace, treat as literal + expr.tokens.push_back({ExpressionToken::LITERAL, str.substr(open)}); + break; + } + + // Variable + expr.tokens.push_back({ExpressionToken::VARIABLE, str.substr(open + 1, close - open - 1)}); + start = close + 1; + } + return expr; + } +}; + +class ThemeContext { + private: + std::map strings; + std::map ints; + std::map bools; + + const ThemeContext* parent = nullptr; + + // Helper to trim whitespace + static std::string trim(const std::string& s) { + size_t start = s.find_first_not_of(" \t\n\r"); + if (start == std::string::npos) return ""; + size_t end = s.find_last_not_of(" \t\n\r"); + return s.substr(start, end - start + 1); + } + + // Helper to check if string is a number + static bool isNumber(const std::string& s) { + if (s.empty()) return false; + size_t start = (s[0] == '-') ? 1 : 0; + for (size_t i = start; i < s.length(); i++) { + if (!isdigit(s[i])) return false; + } + return start < s.length(); + } + + // Helper to check if string is a hex number (0x..) + static bool isHexNumber(const std::string& s) { + if (s.size() < 3) return false; + if (!(s[0] == '0' && (s[1] == 'x' || s[1] == 'X'))) return false; + for (size_t i = 2; i < s.length(); i++) { + char c = s[i]; + if (!((c >= '0' && c <= '9') || (c >= 'a' && c <= 'f') || (c >= 'A' && c <= 'F'))) return false; + } + return true; + } + + static int parseInt(const std::string& s) { + if (isHexNumber(s)) { + return static_cast(std::strtol(s.c_str(), nullptr, 16)); + } + if (isNumber(s)) { + return static_cast(std::strtol(s.c_str(), nullptr, 10)); + } + return 0; + } + + static bool coerceBool(const std::string& s) { + std::string v = trim(s); + if (v.empty()) return false; + if (v == "true" || v == "1") return true; + if (v == "false" || v == "0") return false; + if (isHexNumber(v) || isNumber(v)) return parseInt(v) != 0; + return true; + } + + public: + explicit ThemeContext(const ThemeContext* parent = nullptr) : parent(parent) {} + + void setString(const std::string& key, const std::string& value) { strings[key] = value; } + void setInt(const std::string& key, int value) { ints[key] = value; } + void setBool(const std::string& key, bool value) { bools[key] = value; } + + // Helper to populate list data efficiently + void setListItem(const std::string& listName, int index, const std::string& property, const std::string& value) { + strings[listName + "." + std::to_string(index) + "." + property] = value; + } + void setListItem(const std::string& listName, int index, const std::string& property, int value) { + ints[listName + "." + std::to_string(index) + "." + property] = value; + } + void setListItem(const std::string& listName, int index, const std::string& property, bool value) { + bools[listName + "." + std::to_string(index) + "." + property] = value; + } + void setListItem(const std::string& listName, int index, const std::string& property, const char* value) { + strings[listName + "." + std::to_string(index) + "." + property] = value; + } + + std::string getString(const std::string& key, const std::string& defaultValue = "") const { + auto it = strings.find(key); + if (it != strings.end()) return it->second; + if (parent) return parent->getString(key, defaultValue); + return defaultValue; + } + + int getInt(const std::string& key, int defaultValue = 0) const { + auto it = ints.find(key); + if (it != ints.end()) return it->second; + if (parent) return parent->getInt(key, defaultValue); + return defaultValue; + } + + bool getBool(const std::string& key, bool defaultValue = false) const { + auto it = bools.find(key); + if (it != bools.end()) return it->second; + if (parent) return parent->getBool(key, defaultValue); + return defaultValue; + } + + bool hasKey(const std::string& key) const { + if (strings.count(key) || ints.count(key) || bools.count(key)) return true; + if (parent) return parent->hasKey(key); + return false; + } + + // Get any value as string + std::string getAnyAsString(const std::string& key) const { + // Check strings first + auto sit = strings.find(key); + if (sit != strings.end()) return sit->second; + + // Check ints + auto iit = ints.find(key); + if (iit != ints.end()) return std::to_string(iit->second); + + // Check bools + auto bit = bools.find(key); + if (bit != bools.end()) return bit->second ? "true" : "false"; + + // Check parent + if (parent) return parent->getAnyAsString(key); + + return ""; + } + + bool getAnyAsBool(const std::string& key, bool defaultValue = false) const { + auto bit = bools.find(key); + if (bit != bools.end()) return bit->second; + + auto iit = ints.find(key); + if (iit != ints.end()) return iit->second != 0; + + auto sit = strings.find(key); + if (sit != strings.end()) return coerceBool(sit->second); + + if (parent) return parent->getAnyAsBool(key, defaultValue); + return defaultValue; + } + + int getAnyAsInt(const std::string& key, int defaultValue = 0) const { + auto iit = ints.find(key); + if (iit != ints.end()) return iit->second; + + auto bit = bools.find(key); + if (bit != bools.end()) return bit->second ? 1 : 0; + + auto sit = strings.find(key); + if (sit != strings.end()) return parseInt(sit->second); + + if (parent) return parent->getAnyAsInt(key, defaultValue); + return defaultValue; + } + + // Evaluate a complex boolean expression + // Supports: !, &&, ||, ==, !=, <, >, <=, >=, parentheses + bool evaluateBool(const std::string& expression) const { + std::string expr = trim(expression); + if (expr.empty()) return false; + + // Handle literal true/false + if (expr == "true" || expr == "1") return true; + if (expr == "false" || expr == "0") return false; + + // Handle {var} wrapper + if (expr.size() > 2 && expr.front() == '{' && expr.back() == '}') { + expr = trim(expr.substr(1, expr.size() - 2)); + } + + struct Token { + enum Type { Identifier, Number, String, Op, LParen, RParen, End }; + Type type; + std::string text; + }; + + struct Tokenizer { + const std::string& s; + size_t pos = 0; + Token peeked{Token::End, ""}; + bool hasPeek = false; + + explicit Tokenizer(const std::string& input) : s(input) {} + + static std::string trimCopy(const std::string& in) { + size_t start = in.find_first_not_of(" \t\n\r"); + if (start == std::string::npos) return ""; + size_t end = in.find_last_not_of(" \t\n\r"); + return in.substr(start, end - start + 1); + } + + void skipWs() { + while (pos < s.size() && (s[pos] == ' ' || s[pos] == '\t' || s[pos] == '\n' || s[pos] == '\r')) { + pos++; + } + } + + Token readToken() { + skipWs(); + if (pos >= s.size()) return {Token::End, ""}; + char c = s[pos]; + + if (c == '(') { + pos++; + return {Token::LParen, "("}; + } + if (c == ')') { + pos++; + return {Token::RParen, ")"}; + } + + if (c == '{') { + size_t end = s.find('}', pos + 1); + std::string inner; + if (end == std::string::npos) { + inner = s.substr(pos + 1); + pos = s.size(); + } else { + inner = s.substr(pos + 1, end - pos - 1); + pos = end + 1; + } + return {Token::Identifier, trimCopy(inner)}; + } + + if (c == '"' || c == '\'') { + char quote = c; + pos++; + std::string out; + while (pos < s.size()) { + char ch = s[pos++]; + if (ch == '\\' && pos < s.size()) { + out.push_back(s[pos++]); + continue; + } + if (ch == quote) break; + out.push_back(ch); + } + return {Token::String, out}; + } + + // Operators + if (pos + 1 < s.size()) { + std::string two = s.substr(pos, 2); + if (two == "&&" || two == "||" || two == "==" || two == "!=" || two == "<=" || two == ">=") { + pos += 2; + return {Token::Op, two}; + } + } + if (c == '!' || c == '<' || c == '>') { + pos++; + return {Token::Op, std::string(1, c)}; + } + + // Number (decimal or hex) + if (isdigit(c) || (c == '-' && pos + 1 < s.size() && isdigit(s[pos + 1]))) { + size_t start = pos; + pos++; + if (pos + 1 < s.size() && s[start] == '0' && (s[pos] == 'x' || s[pos] == 'X')) { + pos++; // consume x + while (pos < s.size() && isxdigit(s[pos])) pos++; + } else { + while (pos < s.size() && isdigit(s[pos])) pos++; + } + return {Token::Number, s.substr(start, pos - start)}; + } + + // Identifier + if (isalpha(c) || c == '_' || c == '.') { + size_t start = pos; + pos++; + while (pos < s.size()) { + char ch = s[pos]; + if (isalnum(ch) || ch == '_' || ch == '.') { + pos++; + continue; + } + break; + } + return {Token::Identifier, s.substr(start, pos - start)}; + } + + // Unknown char, skip + pos++; + return readToken(); + } + + Token next() { + if (hasPeek) { + hasPeek = false; + return peeked; + } + return readToken(); + } + + Token peek() { + if (!hasPeek) { + peeked = readToken(); + hasPeek = true; + } + return peeked; + } + }; + + Tokenizer tz(expr); + + std::function parseOr; + std::function parseAnd; + std::function parseNot; + std::function parseComparison; + std::function parseValue; + + parseValue = [&]() -> std::string { + Token t = tz.next(); + if (t.type == Token::LParen) { + bool inner = parseOr(); + Token close = tz.next(); + if (close.type != Token::RParen) { + // best-effort: no-op + } + return inner ? "true" : "false"; + } + if (t.type == Token::String) { + return "'" + t.text + "'"; + } + if (t.type == Token::Number) { + return t.text; + } + if (t.type == Token::Identifier) { + return t.text; + } + return ""; + }; + + auto isComparisonOp = [](const Token& t) { + if (t.type != Token::Op) return false; + return t.text == "==" || t.text == "!=" || t.text == "<" || t.text == ">" || t.text == "<=" || t.text == ">="; + }; + + parseComparison = [&]() -> bool { + std::string left = parseValue(); + Token op = tz.peek(); + if (isComparisonOp(op)) { + tz.next(); + std::string right = parseValue(); + int cmp = compareValues(left, right); + if (op.text == "==") return cmp == 0; + if (op.text == "!=") return cmp != 0; + if (op.text == "<") return cmp < 0; + if (op.text == ">") return cmp > 0; + if (op.text == "<=") return cmp <= 0; + if (op.text == ">=") return cmp >= 0; + return false; + } + return coerceBool(resolveValue(left)); + }; + + parseNot = [&]() -> bool { + Token t = tz.peek(); + if (t.type == Token::Op && t.text == "!") { + tz.next(); + return !parseNot(); + } + return parseComparison(); + }; + + parseAnd = [&]() -> bool { + bool value = parseNot(); + while (true) { + Token t = tz.peek(); + if (t.type == Token::Op && t.text == "&&") { + tz.next(); + value = value && parseNot(); + continue; + } + break; + } + return value; + }; + + parseOr = [&]() -> bool { + bool value = parseAnd(); + while (true) { + Token t = tz.peek(); + if (t.type == Token::Op && t.text == "||") { + tz.next(); + value = value || parseAnd(); + continue; + } + break; + } + return value; + }; + + return parseOr(); + } + + // Compare two values (handles variables, numbers, strings) + int compareValues(const std::string& left, const std::string& right) const { + std::string leftVal = resolveValue(left); + std::string rightVal = resolveValue(right); + + // Try numeric comparison + if ((isNumber(leftVal) || isHexNumber(leftVal)) && (isNumber(rightVal) || isHexNumber(rightVal))) { + int l = parseInt(leftVal); + int r = parseInt(rightVal); + return (l < r) ? -1 : (l > r) ? 1 : 0; + } + + // String comparison + return leftVal.compare(rightVal); + } + + // Resolve a value (variable name -> value, or literal) + std::string resolveValue(const std::string& val) const { + std::string v = trim(val); + + // Remove quotes for string literals + if (v.size() >= 2 && v.front() == '"' && v.back() == '"') { + return v.substr(1, v.size() - 2); + } + if (v.size() >= 2 && v.front() == '\'' && v.back() == '\'') { + return v.substr(1, v.size() - 2); + } + + // If it's a number, return as-is + if (isNumber(v)) return v; + + // Check for hex color literals (0x00, 0xFF, etc.) + if (isHexNumber(v)) { + return v; + } + + // Check for known color names - return as-is + if (v == "black" || v == "white" || v == "gray" || v == "grey") { + return v; + } + + // Check for boolean literals + if (v == "true" || v == "false" || v == "1" || v == "0") { + return v; + } + + // Try to look up as variable + std::string varName = v; + if (varName.size() >= 2 && varName.front() == '{' && varName.back() == '}') { + varName = trim(varName.substr(1, varName.size() - 2)); + } + + if (hasKey(varName)) { + return getAnyAsString(varName); + } + + // Return as literal if not found as variable + return v; + } + + // Evaluate a string expression with variable substitution + std::string evaluatestring(const Expression& expr) const { + if (expr.empty()) return ""; + + std::string result; + for (const auto& token : expr.tokens) { + if (token.type == ExpressionToken::LITERAL) { + result += token.value; + } else { + // Variable lookup - check for comparison expressions inside + std::string varName = token.value; + + // If the variable contains comparison operators, evaluate as condition + if (varName.find("==") != std::string::npos || varName.find("!=") != std::string::npos || + varName.find("&&") != std::string::npos || varName.find("||") != std::string::npos) { + result += evaluateBool(varName) ? "true" : "false"; + continue; + } + + // Handle ternary: condition ? trueVal : falseVal + size_t qPos = varName.find('?'); + if (qPos != std::string::npos) { + size_t cPos = varName.find(':', qPos); + if (cPos != std::string::npos) { + std::string condition = trim(varName.substr(0, qPos)); + std::string trueVal = trim(varName.substr(qPos + 1, cPos - qPos - 1)); + std::string falseVal = trim(varName.substr(cPos + 1)); + + bool condResult = evaluateBool(condition); + result += resolveValue(condResult ? trueVal : falseVal); + continue; + } + } + + // Normal variable lookup + std::string strVal = getAnyAsString(varName); + result += strVal; + } + } + return result; + } + + // Legacy method for backward compatibility + std::string evaluateString(const std::string& expression) const { + if (expression.empty()) return ""; + Expression expr = Expression::parse(expression); + return evaluatestring(expr); + } +}; + +} // namespace ThemeEngine diff --git a/lib/ThemeEngine/include/ThemeManager.h b/lib/ThemeEngine/include/ThemeManager.h new file mode 100644 index 00000000..f028c72d --- /dev/null +++ b/lib/ThemeEngine/include/ThemeManager.h @@ -0,0 +1,155 @@ +#pragma once + +#include + +#include +#include +#include + +#include "BasicElements.h" +#include "IniParser.h" +#include "ThemeContext.h" + +namespace ThemeEngine { + +struct ProcessedAsset { + std::vector data; + int w, h; + GfxRenderer::Orientation orientation; +}; + +// Screen render cache - stores full screen state for quick restore +struct ScreenCache { + uint8_t* buffer = nullptr; + size_t bufferSize = 0; + std::string screenName; + uint32_t contextHash = 0; // Hash of context data to detect changes + bool valid = false; + + ScreenCache() = default; + ~ScreenCache() { + if (buffer) { + free(buffer); + buffer = nullptr; + } + } + + // Prevent double-free from copy + ScreenCache(const ScreenCache&) = delete; + ScreenCache& operator=(const ScreenCache&) = delete; + + // Allow move + ScreenCache(ScreenCache&& other) noexcept + : buffer(other.buffer), + bufferSize(other.bufferSize), + screenName(std::move(other.screenName)), + contextHash(other.contextHash), + valid(other.valid) { + other.buffer = nullptr; + other.bufferSize = 0; + other.valid = false; + } + + ScreenCache& operator=(ScreenCache&& other) noexcept { + if (this != &other) { + if (buffer) free(buffer); + buffer = other.buffer; + bufferSize = other.bufferSize; + screenName = std::move(other.screenName); + contextHash = other.contextHash; + valid = other.valid; + other.buffer = nullptr; + other.bufferSize = 0; + other.valid = false; + } + return *this; + } + + void invalidate() { valid = false; } +}; + +class ThemeManager { + private: + std::map elements; // All elements by ID + std::string currentThemeName; + int navBookCount = 1; // Number of navigable book slots (from theme [Global] section) + std::map fontMap; + + // Screen-level caching for fast redraw + std::map screenCaches; + bool useCaching = true; + + // Track which elements are data-dependent vs static + std::map elementDependsOnData; + + // Factory and property methods + UIElement* createElement(const std::string& id, const std::string& type); + void applyProperties(UIElement* elem, const std::map& props); + + public: + static ThemeManager& get() { + static ThemeManager instance; + return instance; + } + + // Initialize defaults (fonts, etc.) + void begin(); + + // Register a font ID mapping (e.g. "UI_12" -> 0) + void registerFont(const std::string& name, int id); + + // Theme loading + void loadTheme(const std::string& themeName); + void unloadTheme(); + + // Get current theme name + const std::string& getCurrentTheme() const { return currentThemeName; } + + // Get number of navigable book slots (from theme config, default 1) + int getNavBookCount() const { return navBookCount; } + + // Render a screen + void renderScreen(const std::string& screenName, const GfxRenderer& renderer, const ThemeContext& context); + + // Render with dirty tracking (only redraws changed regions) + void renderScreenOptimized(const std::string& screenName, const GfxRenderer& renderer, const ThemeContext& context, + const ThemeContext* prevContext = nullptr); + + // Invalidate all caches (call when theme changes or screen switches) + void invalidateAllCaches(); + + // Invalidate specific screen cache + void invalidateScreenCache(const std::string& screenName); + + // Enable/disable caching + void setCachingEnabled(bool enabled) { useCaching = enabled; } + bool isCachingEnabled() const { return useCaching; } + + // Asset path resolution + std::string getAssetPath(const std::string& assetName); + + // Asset caching + const std::vector* getCachedAsset(const std::string& path); + void cacheAsset(const std::string& path, std::vector&& data); + const ProcessedAsset* getProcessedAsset(const std::string& path, GfxRenderer::Orientation orientation, + int targetW = 0, int targetH = 0); + void cacheProcessedAsset(const std::string& path, const ProcessedAsset& asset, int targetW = 0, int targetH = 0); + + // Clear asset caches (for memory management) + void clearAssetCaches(); + + // Get element by ID (useful for direct manipulation) + UIElement* getElement(const std::string& id) { + auto it = elements.find(id); + return it != elements.end() ? it->second : nullptr; + } + + private: + std::map> assetCache; + std::map processedCache; + + // Compute a simple hash of context data for cache invalidation + uint32_t computeContextHash(const ThemeContext& context, const std::string& screenName); +}; + +} // namespace ThemeEngine diff --git a/lib/ThemeEngine/include/ThemeTypes.h b/lib/ThemeEngine/include/ThemeTypes.h new file mode 100644 index 00000000..60724b65 --- /dev/null +++ b/lib/ThemeEngine/include/ThemeTypes.h @@ -0,0 +1,84 @@ +#pragma once + +#include +#include + +namespace ThemeEngine { + +enum class DimensionUnit { PIXELS, PERCENT, UNKNOWN }; + +struct Dimension { + int value; + DimensionUnit unit; + + Dimension(int v, DimensionUnit u) : value(v), unit(u) {} + Dimension() : value(0), unit(DimensionUnit::PIXELS) {} + + static Dimension parse(const std::string& str) { + if (str.empty()) return Dimension(0, DimensionUnit::PIXELS); + + auto safeParseInt = [](const std::string& s) { + char* end = nullptr; + long v = std::strtol(s.c_str(), &end, 10); + if (!end || end == s.c_str()) return 0; + return static_cast(v); + }; + + if (str.back() == '%') { + return Dimension(safeParseInt(str.substr(0, str.length() - 1)), DimensionUnit::PERCENT); + } + return Dimension(safeParseInt(str), DimensionUnit::PIXELS); + } + + int resolve(int parentSize) const { + if (unit == DimensionUnit::PERCENT) { + return (parentSize * value) / 100; + } + return value; + } +}; + +struct Color { + uint8_t value; // For E-Ink: 0 (Black) to 255 (White), or simplified palette + + explicit Color(uint8_t v) : value(v) {} + Color() : value(0) {} + + static Color parse(const std::string& str) { + if (str.empty()) return Color(0); + if (str == "black") return Color(0x00); + if (str == "white") return Color(0xFF); + if (str == "gray" || str == "grey") return Color(0x80); + if (str.size() > 2 && str.substr(0, 2) == "0x") { + return Color((uint8_t)std::strtol(str.c_str(), nullptr, 16)); + } + // Safe fallback using strtol (returns 0 on error, no exception) + return Color((uint8_t)std::strtol(str.c_str(), nullptr, 10)); + } +}; + +// Rect structure for dirty regions +struct Rect { + int x, y, w, h; + + Rect() : x(0), y(0), w(0), h(0) {} + Rect(int x, int y, int w, int h) : x(x), y(y), w(w), h(h) {} + + bool isEmpty() const { return w <= 0 || h <= 0; } + + bool intersects(const Rect& other) const { + return !(x + w <= other.x || other.x + other.w <= x || y + h <= other.y || other.y + other.h <= y); + } + + Rect unite(const Rect& other) const { + if (isEmpty()) return other; + if (other.isEmpty()) return *this; + int nx = std::min(x, other.x); + int ny = std::min(y, other.y); + int nx2 = std::max(x + w, other.x + other.w); + int ny2 = std::max(y + h, other.y + other.h); + return Rect(nx, ny, nx2 - nx, ny2 - ny); + } +}; + +} // namespace ThemeEngine diff --git a/lib/ThemeEngine/include/UIElement.h b/lib/ThemeEngine/include/UIElement.h new file mode 100644 index 00000000..be7db4b8 --- /dev/null +++ b/lib/ThemeEngine/include/UIElement.h @@ -0,0 +1,211 @@ +#pragma once + +#include + +#include +#include + +#include "ThemeContext.h" +#include "ThemeTypes.h" + +namespace ThemeEngine { + +class Container; // Forward declaration + +class UIElement { + public: + int getAbsX() const { return absX; } + int getAbsY() const { return absY; } + int getAbsW() const { return absW; } + int getAbsH() const { return absH; } + const std::string& getId() const { return id; } + + protected: + std::string id; + Dimension x, y, width, height; + Expression visibleExpr; + bool visibleExprIsStatic = true; // True if visibility doesn't depend on data + + // Recomputed every layout pass + int absX = 0, absY = 0, absW = 0, absH = 0; + + // Layout caching - track last params to skip redundant layout + int lastParentX = -1, lastParentY = -1, lastParentW = -1, lastParentH = -1; + bool layoutValid = false; + + // Caching support + bool cacheable = false; // Set true for expensive elements like bitmaps + bool cacheValid = false; // Is the cached render still valid? + uint8_t* cachedRender = nullptr; + size_t cachedRenderSize = 0; + int cachedX = 0, cachedY = 0, cachedW = 0, cachedH = 0; + + // Dirty tracking + bool dirty = true; // Needs redraw + + bool isVisible(const ThemeContext& context) const { + if (visibleExpr.empty()) return true; + return context.evaluateBool(visibleExpr.rawExpr); + } + + public: + UIElement(const std::string& id) : id(id), visibleExpr(Expression::parse("true")) {} + + virtual ~UIElement() { + if (cachedRender) { + free(cachedRender); + cachedRender = nullptr; + } + } + + void setX(Dimension val) { + x = val; + markDirty(); + } + void setY(Dimension val) { + y = val; + markDirty(); + } + void setWidth(Dimension val) { + width = val; + markDirty(); + } + void setHeight(Dimension val) { + height = val; + markDirty(); + } + void setVisibleExpr(const std::string& expr) { + visibleExpr = Expression::parse(expr); + // Check if expression contains variables + visibleExprIsStatic = + (expr == "true" || expr == "false" || expr == "1" || expr == "0" || expr.find('{') == std::string::npos); + markDirty(); + } + + void setCacheable(bool val) { cacheable = val; } + bool isCacheable() const { return cacheable; } + + virtual void markDirty() { + dirty = true; + cacheValid = false; + layoutValid = false; + } + + void markClean() { dirty = false; } + bool isDirty() const { return dirty; } + + // Invalidate cache (called when dependent data changes) + void invalidateCache() { + cacheValid = false; + dirty = true; + } + + // Calculate absolute position based on parent + virtual void layout(const ThemeContext& context, int parentX, int parentY, int parentW, int parentH) { + // Skip layout if params unchanged and layout is still valid + if (layoutValid && parentX == lastParentX && parentY == lastParentY && parentW == lastParentW && + parentH == lastParentH) { + return; + } + + lastParentX = parentX; + lastParentY = parentY; + lastParentW = parentW; + lastParentH = parentH; + layoutValid = true; + + int newX = parentX + x.resolve(parentW); + int newY = parentY + y.resolve(parentH); + int newW = width.resolve(parentW); + int newH = height.resolve(parentH); + + // Clamp to parent bounds + if (newX >= parentX + parentW) newX = parentX + parentW - 1; + if (newY >= parentY + parentH) newY = parentY + parentH - 1; + + int maxX = parentX + parentW; + int maxY = parentY + parentH; + + if (newX + newW > maxX) newW = maxX - newX; + if (newY + newH > maxY) newH = maxY - newY; + + if (newW < 0) newW = 0; + if (newH < 0) newH = 0; + + // Check if position changed + if (newX != absX || newY != absY || newW != absW || newH != absH) { + absX = newX; + absY = newY; + absW = newW; + absH = newH; + markDirty(); + } + } + + virtual Container* asContainer() { return nullptr; } + + enum class ElementType { + Base, + Container, + Rectangle, + Label, + Bitmap, + List, + ProgressBar, + Divider, + // Layout elements + HStack, + VStack, + Grid, + // Advanced elements + Badge, + Toggle, + TabBar, + Icon, + BatteryIcon, + ScrollIndicator + }; + + virtual ElementType getType() const { return ElementType::Base; } + virtual const char* getTypeName() const { return "UIElement"; } + + int getLayoutHeight() const { return absH; } + int getLayoutWidth() const { return absW; } + + // Get bounding rect for this element + Rect getBounds() const { return Rect(absX, absY, absW, absH); } + + // Main draw method - handles caching automatically + virtual void draw(const GfxRenderer& renderer, const ThemeContext& context) = 0; + + protected: + // Cache the rendered output + bool cacheRender(const GfxRenderer& renderer) { + if (cachedRender) { + free(cachedRender); + cachedRender = nullptr; + } + + cachedRender = renderer.captureRegion(absX, absY, absW, absH, &cachedRenderSize); + if (cachedRender) { + cachedX = absX; + cachedY = absY; + cachedW = absW; + cachedH = absH; + cacheValid = true; + return true; + } + return false; + } + + // Restore from cache + bool restoreFromCache(const GfxRenderer& renderer) const { + if (!cacheValid || !cachedRender) return false; + if (absX != cachedX || absY != cachedY || absW != cachedW || absH != cachedH) return false; + + renderer.restoreRegion(cachedRender, absX, absY, absW, absH); + return true; + } +}; + +} // namespace ThemeEngine diff --git a/lib/ThemeEngine/src/BasicElements.cpp b/lib/ThemeEngine/src/BasicElements.cpp new file mode 100644 index 00000000..d1f0b679 --- /dev/null +++ b/lib/ThemeEngine/src/BasicElements.cpp @@ -0,0 +1,500 @@ +#include "BasicElements.h" + +#include + +#include "Bitmap.h" +#include "ListElement.h" +#include "ThemeManager.h" +#include "ThemeTypes.h" + +namespace ThemeEngine { + +// --- Container --- +void Container::draw(const GfxRenderer& renderer, const ThemeContext& context) { + if (!isVisible(context)) return; + + if (hasBg) { + std::string colStr = context.evaluatestring(bgColorExpr); + uint8_t color = Color::parse(colStr).value; + // Use dithered fill for grayscale values, solid fill for black/white + // Use rounded rect if borderRadius > 0 + if (color == 0x00) { + if (borderRadius > 0) { + renderer.fillRoundedRect(absX, absY, absW, absH, borderRadius, true); + } else { + renderer.fillRect(absX, absY, absW, absH, true); + } + } else if (color >= 0xF0) { + if (borderRadius > 0) { + renderer.fillRoundedRect(absX, absY, absW, absH, borderRadius, false); + } else { + renderer.fillRect(absX, absY, absW, absH, false); + } + } else { + if (borderRadius > 0) { + renderer.fillRoundedRectDithered(absX, absY, absW, absH, borderRadius, color); + } else { + renderer.fillRectDithered(absX, absY, absW, absH, color); + } + } + } + + // Handle dynamic border expression + bool drawBorder = border; + if (hasBorderExpr()) { + drawBorder = context.evaluateBool(borderExpr.rawExpr); + } + + if (drawBorder) { + if (borderRadius > 0) { + renderer.drawRoundedRect(absX, absY, absW, absH, borderRadius, true); + } else { + renderer.drawRect(absX, absY, absW, absH, true); + } + } + + for (auto child : children) { + child->draw(renderer, context); + } + + markClean(); +} + +// --- Rectangle --- +void Rectangle::draw(const GfxRenderer& renderer, const ThemeContext& context) { + if (!isVisible(context)) return; + + std::string colStr = context.evaluatestring(colorExpr); + uint8_t color = Color::parse(colStr).value; + + bool shouldFill = fill; + if (!fillExpr.empty()) { + shouldFill = context.evaluateBool(fillExpr.rawExpr); + } + + if (shouldFill) { + // Use dithered fill for grayscale values, solid fill for black/white + // Use rounded rect if borderRadius > 0 + if (color == 0x00) { + if (borderRadius > 0) { + renderer.fillRoundedRect(absX, absY, absW, absH, borderRadius, true); + } else { + renderer.fillRect(absX, absY, absW, absH, true); + } + } else if (color >= 0xF0) { + if (borderRadius > 0) { + renderer.fillRoundedRect(absX, absY, absW, absH, borderRadius, false); + } else { + renderer.fillRect(absX, absY, absW, absH, false); + } + } else { + if (borderRadius > 0) { + renderer.fillRoundedRectDithered(absX, absY, absW, absH, borderRadius, color); + } else { + renderer.fillRectDithered(absX, absY, absW, absH, color); + } + } + } else { + // Draw border + bool black = (color == 0x00); + if (borderRadius > 0) { + renderer.drawRoundedRect(absX, absY, absW, absH, borderRadius, black); + } else { + renderer.drawRect(absX, absY, absW, absH, black); + } + } + + markClean(); +} + +// --- Label --- +void Label::draw(const GfxRenderer& renderer, const ThemeContext& context) { + if (!isVisible(context)) return; + + std::string finalStr = context.evaluatestring(textExpr); + + if (finalStr.empty()) { + markClean(); + return; + } + + std::string colStr = context.evaluatestring(colorExpr); + uint8_t color = Color::parse(colStr).value; + bool black = (color == 0x00); + + int textWidth = renderer.getTextWidth(fontId, finalStr.c_str()); + int lineHeight = renderer.getLineHeight(fontId); + + std::vector lines; + lines.reserve(maxLines); // Pre-allocate to avoid reallocations + if (absW > 0 && textWidth > absW && maxLines > 1) { + // Logic to wrap text + std::string remaining = finalStr; + while (!remaining.empty() && (int)lines.size() < maxLines) { + // If it fits, add entire line + if (renderer.getTextWidth(fontId, remaining.c_str()) <= absW) { + lines.push_back(remaining); + break; + } + + // Binary search for maximum characters that fit (O(log n) instead of O(n)) + int len = remaining.length(); + int lo = 1, hi = len; + while (lo < hi) { + int mid = (lo + hi + 1) / 2; + if (renderer.getTextWidth(fontId, remaining.substr(0, mid).c_str()) <= absW) { + lo = mid; + } else { + hi = mid - 1; + } + } + int cut = lo; + + // Find last space before cut + if (cut < (int)remaining.length()) { + int space = -1; + for (int i = cut; i > 0; i--) { + if (remaining[i] == ' ') { + space = i; + break; + } + } + if (space != -1) cut = space; + } + + std::string line = remaining.substr(0, cut); + + // If we're at the last allowed line but still have more text + if ((int)lines.size() == maxLines - 1 && cut < (int)remaining.length()) { + if (ellipsis) { + line = renderer.truncatedText(fontId, remaining.c_str(), absW); + } + lines.push_back(line); + break; + } + + lines.push_back(line); + // Advance + if (cut < (int)remaining.length()) { + // Skip the space if check + if (remaining[cut] == ' ') cut++; + remaining = remaining.substr(cut); + } else { + remaining = ""; + } + } + } else { + // Single line handling (truncate if needed) + if (ellipsis && textWidth > absW && absW > 0) { + finalStr = renderer.truncatedText(fontId, finalStr.c_str(), absW); + } + lines.push_back(finalStr); + } + + // Draw lines + int totalTextHeight = lines.size() * lineHeight; + int startY = absY; + + // Vertical centering + if (absH > 0 && totalTextHeight < absH) { + startY = absY + (absH - totalTextHeight) / 2; + } + + for (size_t i = 0; i < lines.size(); i++) { + int lineWidth = renderer.getTextWidth(fontId, lines[i].c_str()); + int drawX = absX; + + if (alignment == Alignment::Center && absW > 0) { + drawX = absX + (absW - lineWidth) / 2; + } else if (alignment == Alignment::Right && absW > 0) { + drawX = absX + absW - lineWidth; + } + + renderer.drawText(fontId, drawX, startY + i * lineHeight, lines[i].c_str(), black); + } + + markClean(); +} + +// --- BitmapElement --- +void BitmapElement::draw(const GfxRenderer& renderer, const ThemeContext& context) { + if (!isVisible(context)) { + markClean(); + return; + } + + std::string path = context.evaluatestring(srcExpr); + if (path.empty()) { + markClean(); + return; + } + + if (path.find('/') == std::string::npos || (path.length() > 0 && path[0] != '/')) { + path = ThemeManager::get().getAssetPath(path); + } + + // Fast path: use cached 1-bit render + const ProcessedAsset* processed = ThemeManager::get().getProcessedAsset(path, renderer.getOrientation(), absW, absH); + if (processed && processed->w == absW && processed->h == absH) { + renderer.restoreRegion(processed->data.data(), absX, absY, absW, absH); + markClean(); + return; + } + + // Helper to draw bitmap with centering and optional rounded corners + auto drawBmp = [&](Bitmap& bmp) { + int drawX = absX; + int drawY = absY; + if (bmp.getWidth() < absW) drawX += (absW - bmp.getWidth()) / 2; + if (bmp.getHeight() < absH) drawY += (absH - bmp.getHeight()) / 2; + if (borderRadius > 0) { + renderer.drawRoundedBitmap(bmp, drawX, drawY, absW, absH, borderRadius); + } else { + renderer.drawBitmap(bmp, drawX, drawY, absW, absH); + } + }; + + bool drawSuccess = false; + + // Try RAM cache first + const std::vector* cachedData = ThemeManager::get().getCachedAsset(path); + if (cachedData && !cachedData->empty()) { + Bitmap bmp(cachedData->data(), cachedData->size()); + if (bmp.parseHeaders() == BmpReaderError::Ok) { + drawBmp(bmp); + drawSuccess = true; + } + } + + // Fallback: load from SD card + if (!drawSuccess && path.length() > 0 && path[0] == '/') { + FsFile file; + if (SdMan.openFileForRead("HOME", path, file)) { + size_t fileSize = file.size(); + if (fileSize > 0 && fileSize < 100000) { + std::vector fileData(fileSize); + if (file.read(fileData.data(), fileSize) == fileSize) { + ThemeManager::get().cacheAsset(path, std::move(fileData)); + const std::vector* newCachedData = ThemeManager::get().getCachedAsset(path); + if (newCachedData && !newCachedData->empty()) { + Bitmap bmp(newCachedData->data(), newCachedData->size()); + if (bmp.parseHeaders() == BmpReaderError::Ok) { + drawBmp(bmp); + drawSuccess = true; + } + } + } + } else { + Bitmap bmp(file, true); + if (bmp.parseHeaders() == BmpReaderError::Ok) { + drawBmp(bmp); + drawSuccess = true; + } + } + file.close(); + } + } + + // Cache rendered result for fast subsequent draws using captureRegion + if (drawSuccess && absW * absH <= 40000) { + size_t capturedSize = 0; + uint8_t* captured = renderer.captureRegion(absX, absY, absW, absH, &capturedSize); + if (captured && capturedSize > 0) { + ProcessedAsset asset; + asset.w = absW; + asset.h = absH; + asset.orientation = renderer.getOrientation(); + asset.data.assign(captured, captured + capturedSize); + free(captured); + ThemeManager::get().cacheProcessedAsset(path, asset, absW, absH); + } + } + + markClean(); +} + +// --- List --- +void List::draw(const GfxRenderer& renderer, const ThemeContext& context) { + if (!isVisible(context)) { + markClean(); + return; + } + + // Draw background + if (hasBg) { + std::string colStr = context.evaluatestring(bgColorExpr); + uint8_t color = Color::parse(colStr).value; + renderer.fillRect(absX, absY, absW, absH, color == 0x00); + } + if (border) { + renderer.drawRect(absX, absY, absW, absH, true); + } + + if (!itemTemplate) { + markClean(); + return; + } + + int count = context.getInt(source + ".Count"); + if (count <= 0) { + markClean(); + return; + } + + // Get item dimensions + int itemW = getItemWidth(); + int itemH = getItemHeight(); + + // Pre-allocate string buffers to avoid repeated allocations + std::string prefix; + prefix.reserve(source.length() + 16); + std::string key; + key.reserve(source.length() + 32); + char numBuf[12]; + + // Handle different layout modes + if (direction == Direction::Horizontal || layoutMode == LayoutMode::Grid) { + // Horizontal or Grid layout + int col = 0; + int row = 0; + int currentX = absX; + int currentY = absY; + + // For grid, calculate item width based on columns only if not explicitly set + if (layoutMode == LayoutMode::Grid && columns > 1 && itemWidth == 0) { + int totalSpacing = (columns - 1) * spacing; + itemW = (absW - totalSpacing) / columns; + } + + for (int i = 0; i < count; ++i) { + // Build prefix efficiently: "source.i." + prefix.clear(); + prefix += source; + prefix += '.'; + snprintf(numBuf, sizeof(numBuf), "%d", i); + prefix += numBuf; + prefix += '.'; + + // Create item context with scoped variables + ThemeContext itemContext(&context); + + // Standard list item variables - include all properties for full flexibility + std::string nameVal = context.getString(prefix + "Name"); + itemContext.setString("Item.Name", nameVal); + itemContext.setString("Item.Title", context.getString(prefix + "Title")); + itemContext.setString("Item.Value", context.getAnyAsString(prefix + "Value")); + itemContext.setString("Item.Type", context.getString(prefix + "Type")); + itemContext.setString("Item.ValueLabel", context.getString(prefix + "ValueLabel")); + itemContext.setString("Item.BgColor", context.getString(prefix + "BgColor")); + itemContext.setBool("Item.Selected", context.getBool(prefix + "Selected")); + itemContext.setBool("Item.Value", context.getBool(prefix + "Value")); + itemContext.setString("Item.Icon", context.getString(prefix + "Icon")); + itemContext.setString("Item.Image", context.getString(prefix + "Image")); + itemContext.setString("Item.Progress", context.getString(prefix + "Progress")); + + // Viewport check + if (direction == Direction::Horizontal) { + if (currentX + itemW < absX) { + currentX += itemW + spacing; + continue; + } + if (currentX > absX + absW) break; + } else { + // Grid mode + if (currentY + itemH < absY) { + col++; + if (col >= columns) { + col = 0; + row++; + currentY += itemH + spacing; + } + currentX = absX + col * (itemW + spacing); + continue; + } + if (currentY > absY + absH) break; + } + itemContext.setInt("Item.Index", i); + itemContext.setInt("Item.Count", count); + // ValueIndex may not exist for all item types, so check first + if (context.hasKey(prefix + "ValueIndex")) { + itemContext.setInt("Item.ValueIndex", context.getInt(prefix + "ValueIndex")); + } + + // Layout and draw + itemTemplate->layout(itemContext, currentX, currentY, itemW, itemH); + itemTemplate->draw(renderer, itemContext); + + if (layoutMode == LayoutMode::Grid && columns > 1) { + col++; + if (col >= columns) { + col = 0; + row++; + currentX = absX; + currentY += itemH + spacing; + } else { + currentX += itemW + spacing; + } + } else { + // Horizontal list + currentX += itemW + spacing; + } + } + } else { + // Vertical list (default) + int currentY = absY; + int viewportBottom = absY + absH; + + for (int i = 0; i < count; ++i) { + // Skip items above viewport + if (currentY + itemH < absY) { + currentY += itemH + spacing; + continue; + } + // Stop if below viewport + if (currentY > viewportBottom) { + break; + } + + // Build prefix efficiently: "source.i." + prefix.clear(); + prefix += source; + prefix += '.'; + snprintf(numBuf, sizeof(numBuf), "%d", i); + prefix += numBuf; + prefix += '.'; + + // Create item context with scoped variables + ThemeContext itemContext(&context); + + // Standard list item variables - include all properties for full flexibility + std::string nameVal = context.getString(prefix + "Name"); + itemContext.setString("Item.Name", nameVal); + itemContext.setString("Item.Title", context.getString(prefix + "Title")); + itemContext.setString("Item.Value", context.getAnyAsString(prefix + "Value")); + itemContext.setString("Item.Type", context.getString(prefix + "Type")); + itemContext.setString("Item.ValueLabel", context.getString(prefix + "ValueLabel")); + itemContext.setString("Item.BgColor", context.getString(prefix + "BgColor")); + itemContext.setBool("Item.Selected", context.getBool(prefix + "Selected")); + itemContext.setBool("Item.Value", context.getBool(prefix + "Value")); + itemContext.setString("Item.Icon", context.getString(prefix + "Icon")); + itemContext.setString("Item.Image", context.getString(prefix + "Image")); + itemContext.setString("Item.Progress", context.getString(prefix + "Progress")); + itemContext.setInt("Item.Index", i); + itemContext.setInt("Item.Count", count); + // ValueIndex may not exist for all item types, so check first + if (context.hasKey(prefix + "ValueIndex")) { + itemContext.setInt("Item.ValueIndex", context.getInt(prefix + "ValueIndex")); + } + + // Layout and draw the template for this item + itemTemplate->layout(itemContext, absX, currentY, absW, itemH); + itemTemplate->draw(renderer, itemContext); + + currentY += itemH + spacing; + } + } + + markClean(); +} + +} // namespace ThemeEngine diff --git a/lib/ThemeEngine/src/IniParser.cpp b/lib/ThemeEngine/src/IniParser.cpp new file mode 100644 index 00000000..6a920a78 --- /dev/null +++ b/lib/ThemeEngine/src/IniParser.cpp @@ -0,0 +1,99 @@ +#include "IniParser.h" + +#include + +namespace ThemeEngine { + +void IniParser::trim(std::string& s) { + if (s.empty()) return; + + // Trim left + size_t first = s.find_first_not_of(" \t\n\r"); + if (first == std::string::npos) { + s.clear(); + return; + } + + // Trim right + size_t last = s.find_last_not_of(" \t\n\r"); + s = s.substr(first, (last - first + 1)); +} + +std::map> IniParser::parse(Stream& stream) { + std::map> sections; + std::string currentSection; + String line; + + while (stream.available()) { + line = stream.readStringUntil('\n'); + std::string sLine = line.c_str(); + trim(sLine); + + if (sLine.empty() || sLine[0] == ';' || sLine[0] == '#') { + continue; + } + + if (sLine.front() == '[' && sLine.back() == ']') { + currentSection = sLine.substr(1, sLine.size() - 2); + trim(currentSection); + } else { + size_t eqPos = sLine.find('='); + if (eqPos != std::string::npos) { + std::string key = sLine.substr(0, eqPos); + std::string value = sLine.substr(eqPos + 1); + trim(key); + trim(value); + + // Remove quotes if present + if (value.size() >= 2 && value.front() == '"' && value.back() == '"') { + value = value.substr(1, value.size() - 2); + } + + if (!currentSection.empty()) { + sections[currentSection][key] = value; + } + } + } + } + return sections; +} + +std::map> IniParser::parseString(const std::string& content) { + std::map> sections; + std::stringstream ss(content); + std::string line; + std::string currentSection; + + while (std::getline(ss, line)) { + trim(line); + + if (line.empty() || line[0] == ';' || line[0] == '#') { + continue; + } + + if (line.front() == '[' && line.back() == ']') { + currentSection = line.substr(1, line.size() - 2); + trim(currentSection); + } else { + size_t eqPos = line.find('='); + if (eqPos != std::string::npos) { + std::string key = line.substr(0, eqPos); + std::string value = line.substr(eqPos + 1); + trim(key); + trim(value); + + // Remove quotes if present + if (value.size() >= 2 && value.front() == '"' && value.back() == '"') { + value = value.substr(1, value.size() - 2); + } + + if (!currentSection.empty()) { + sections[currentSection][key] = value; + } + } + } + } + return sections; +} + +} // namespace ThemeEngine diff --git a/lib/ThemeEngine/src/LayoutElements.cpp b/lib/ThemeEngine/src/LayoutElements.cpp new file mode 100644 index 00000000..938ac013 --- /dev/null +++ b/lib/ThemeEngine/src/LayoutElements.cpp @@ -0,0 +1,194 @@ +#include "LayoutElements.h" + +#include + +#include "ThemeManager.h" + +namespace ThemeEngine { + +// Built-in icon drawing +// These are simple geometric representations of common icons +void Icon::draw(const GfxRenderer& renderer, const ThemeContext& context) { + if (!isVisible(context)) { + markClean(); + return; + } + + std::string iconName = context.evaluatestring(srcExpr); + if (iconName.empty()) { + markClean(); + return; + } + + std::string colStr = context.evaluatestring(colorExpr); + uint8_t color = Color::parse(colStr).value; + // Draw as black if color is dark (< 0x80), as white if light + // This allows grayscale colors to render visibly + bool black = (color < 0x80); + + // iconSize determines the actual drawn icon size + // absW/absH determine the bounding box for centering + int drawSize = iconSize; + int boundW = absW > 0 ? absW : iconSize; + int boundH = absH > 0 ? absH : iconSize; + + // Center the icon within its bounding box + int iconX = absX + (boundW - drawSize) / 2; + int iconY = absY + (boundH - drawSize) / 2; + int w = drawSize; + int h = drawSize; + int cx = iconX + w / 2; + int cy = iconY + h / 2; + + // 1. Try to load as a theme asset (exact match or .bmp extension) + std::string path = iconName; + bool isPath = iconName.find('/') != std::string::npos || iconName.find('.') != std::string::npos; + + std::string assetPath = path; + if (!isPath) { + assetPath = ThemeManager::get().getAssetPath(iconName + ".bmp"); + } else if (path[0] != '/') { + assetPath = ThemeManager::get().getAssetPath(iconName); + } + + const std::vector* data = ThemeManager::get().getCachedAsset(assetPath); + if (data && !data->empty()) { + Bitmap bmp(data->data(), data->size()); + if (bmp.parseHeaders() == BmpReaderError::Ok) { + renderer.drawTransparentBitmap(bmp, iconX, iconY, w, h); + markClean(); + return; + } + } + + // 2. Built-in icons (simple geometric shapes) as fallback + // All icons use iconX, iconY, w, h, cx, cy for proper centering + if (iconName == "heart" || iconName == "favorite") { + // Simple heart shape approximation + int s = w / 4; + renderer.fillRect(cx - s, cy - s / 2, s * 2, s, black); + renderer.fillRect(cx - s * 3 / 2, cy - s, s, s, black); + renderer.fillRect(cx + s / 2, cy - s, s, s, black); + // Bottom point + for (int i = 0; i < s; i++) { + renderer.drawLine(cx - s + i, cy + i, cx + s - i, cy + i, black); + } + } else if (iconName == "book" || iconName == "books") { + // Book icon + int bw = w * 2 / 3; + int bh = h * 3 / 4; + int bx = iconX + (w - bw) / 2; + int by = iconY + (h - bh) / 2; + renderer.drawRect(bx, by, bw, bh, black); + renderer.drawLine(bx + bw / 3, by, bx + bw / 3, by + bh - 1, black); + // Pages + renderer.drawLine(bx + 2, by + bh / 4, bx + bw / 3 - 2, by + bh / 4, black); + renderer.drawLine(bx + 2, by + bh / 2, bx + bw / 3 - 2, by + bh / 2, black); + } else if (iconName == "folder" || iconName == "files") { + // Folder icon + int fw = w * 3 / 4; + int fh = h * 2 / 3; + int fx = iconX + (w - fw) / 2; + int fy = iconY + (h - fh) / 2; + // Tab + renderer.fillRect(fx, fy, fw / 3, fh / 6, black); + // Body + renderer.drawRect(fx, fy + fh / 6, fw, fh - fh / 6, black); + } else if (iconName == "settings" || iconName == "gear") { + // Gear icon - simplified as circle with notches + int r = w / 3; + // Draw circle approximation + renderer.drawRect(cx - r, cy - r, r * 2, r * 2, black); + // Inner circle + int ir = r / 2; + renderer.drawRect(cx - ir, cy - ir, ir * 2, ir * 2, black); + // Teeth + int t = r / 3; + renderer.fillRect(cx - t / 2, iconY, t, r - ir, black); + renderer.fillRect(cx - t / 2, cy + r, t, r - ir, black); + renderer.fillRect(iconX, cy - t / 2, r - ir, t, black); + renderer.fillRect(cx + r, cy - t / 2, r - ir, t, black); + } else if (iconName == "transfer" || iconName == "arrow" || iconName == "send") { + // Arrow pointing right + int aw = w / 2; + int ah = h / 3; + int ax = iconX + w / 4; + int ay = cy - ah / 2; + // Shaft + renderer.fillRect(ax, ay, aw, ah, black); + // Arrow head + for (int i = 0; i < ah; i++) { + renderer.drawLine(ax + aw, cy - ah + i, ax + aw + ah - i, cy, black); + renderer.drawLine(ax + aw, cy + ah - i, ax + aw + ah - i, cy, black); + } + } else if (iconName == "library" || iconName == "device") { + // Device/tablet icon + int dw = w * 2 / 3; + int dh = h * 3 / 4; + int dx = iconX + (w - dw) / 2; + int dy = iconY + (h - dh) / 2; + renderer.drawRect(dx, dy, dw, dh, black); + // Screen + renderer.drawRect(dx + 2, dy + 2, dw - 4, dh - 8, black); + // Home button + renderer.fillRect(dx + dw / 2 - 2, dy + dh - 5, 4, 2, black); + } else if (iconName == "battery") { + // Battery icon + int bw = w * 3 / 4; + int bh = h / 2; + int bx = iconX + (w - bw) / 2; + int by = iconY + (h - bh) / 2; + renderer.drawRect(bx, by, bw - 3, bh, black); + renderer.fillRect(bx + bw - 3, by + bh / 4, 3, bh / 2, black); + } else if (iconName == "check" || iconName == "checkmark") { + // Checkmark - use iconX/iconY for proper centering + int x1 = iconX + w / 4; + int y1 = cy; + int x2 = cx; + int y2 = iconY + h * 3 / 4; + int x3 = iconX + w * 3 / 4; + int y3 = iconY + h / 4; + renderer.drawLine(x1, y1, x2, y2, black); + renderer.drawLine(x2, y2, x3, y3, black); + // Thicken + renderer.drawLine(x1, y1 + 1, x2, y2 + 1, black); + renderer.drawLine(x2, y2 + 1, x3, y3 + 1, black); + } else if (iconName == "back" || iconName == "left") { + // Left arrow + int s = w / 3; + for (int i = 0; i < s; i++) { + renderer.drawLine(cx - s + i, cy, cx, cy - s + i, black); + renderer.drawLine(cx - s + i, cy, cx, cy + s - i, black); + } + } else if (iconName == "up") { + // Up arrow + int s = h / 3; + for (int i = 0; i < s; i++) { + renderer.drawLine(cx, cy - s + i, cx - s + i, cy, black); + renderer.drawLine(cx, cy - s + i, cx + s - i, cy, black); + } + } else if (iconName == "down") { + // Down arrow + int s = h / 3; + for (int i = 0; i < s; i++) { + renderer.drawLine(cx, cy + s - i, cx - s + i, cy, black); + renderer.drawLine(cx, cy + s - i, cx + s - i, cy, black); + } + } else if (iconName == "right") { + // Right arrow + int s = w / 3; + for (int i = 0; i < s; i++) { + renderer.drawLine(cx + s - i, cy, cx, cy - s + i, black); + renderer.drawLine(cx + s - i, cy, cx, cy + s - i, black); + } + } else { + // Unknown icon - draw placeholder + renderer.drawRect(iconX, iconY, w, h, black); + renderer.drawLine(iconX, iconY, iconX + w - 1, iconY + h - 1, black); + renderer.drawLine(iconX + w - 1, iconY, iconX, iconY + h - 1, black); + } + + markClean(); +} + +} // namespace ThemeEngine diff --git a/lib/ThemeEngine/src/ThemeManager.cpp b/lib/ThemeEngine/src/ThemeManager.cpp new file mode 100644 index 00000000..a3ba9314 --- /dev/null +++ b/lib/ThemeEngine/src/ThemeManager.cpp @@ -0,0 +1,623 @@ +#include "ThemeManager.h" + +#include + +#include +#include +#include +#include + +#include "DefaultTheme.h" +#include "LayoutElements.h" +#include "ListElement.h" + +namespace ThemeEngine { + +void ThemeManager::begin() {} + +void ThemeManager::registerFont(const std::string& name, int id) { fontMap[name] = id; } + +std::string ThemeManager::getAssetPath(const std::string& assetName) { + // Check if absolute path + if (!assetName.empty() && assetName[0] == '/') return assetName; + + // Otherwise relative to theme root + std::string rootPath = "/themes/" + currentThemeName + "/" + assetName; + if (SdMan.exists(rootPath.c_str())) return rootPath; + + // Fallback to assets/ subfolder + return "/themes/" + currentThemeName + "/assets/" + assetName; +} + +UIElement* ThemeManager::createElement(const std::string& id, const std::string& type) { + // Basic elements + if (type == "Container") return new Container(id); + if (type == "Rectangle") return new Rectangle(id); + if (type == "Label") return new Label(id); + if (type == "Bitmap") return new BitmapElement(id); + if (type == "List") return new List(id); + if (type == "ProgressBar") return new ProgressBar(id); + if (type == "Divider") return new Divider(id); + + // Layout elements + if (type == "HStack") return new HStack(id); + if (type == "VStack") return new VStack(id); + if (type == "Grid") return new Grid(id); + + // Advanced elements + if (type == "Badge") return new Badge(id); + if (type == "Toggle") return new Toggle(id); + if (type == "TabBar") return new TabBar(id); + if (type == "Icon") return new Icon(id); + if (type == "ScrollIndicator") return new ScrollIndicator(id); + if (type == "BatteryIcon") return new BatteryIcon(id); + + return nullptr; +} + +void ThemeManager::applyProperties(UIElement* elem, const std::map& props) { + const auto elemType = elem->getType(); + + for (const auto& kv : props) { + const std::string& key = kv.first; + const std::string& val = kv.second; + + // Common properties + if (key == "X") + elem->setX(Dimension::parse(val)); + else if (key == "Y") + elem->setY(Dimension::parse(val)); + else if (key == "Width") + elem->setWidth(Dimension::parse(val)); + else if (key == "Height") + elem->setHeight(Dimension::parse(val)); + else if (key == "Visible") + elem->setVisibleExpr(val); + else if (key == "Cacheable") + elem->setCacheable(val == "true" || val == "1"); + + // Rectangle properties + else if (key == "Fill") { + if (elemType == UIElement::ElementType::Rectangle) { + auto rect = static_cast(elem); + if (val.find('{') != std::string::npos) { + rect->setFillExpr(val); + } else { + rect->setFill(val == "true" || val == "1"); + } + } + } else if (key == "Color") { + if (elemType == UIElement::ElementType::Rectangle) { + static_cast(elem)->setColorExpr(val); + } else if (elemType == UIElement::ElementType::Container || elemType == UIElement::ElementType::HStack || + elemType == UIElement::ElementType::VStack || elemType == UIElement::ElementType::Grid || + elemType == UIElement::ElementType::TabBar) { + static_cast(elem)->setBackgroundColorExpr(val); + } else if (elemType == UIElement::ElementType::Label) { + static_cast(elem)->setColorExpr(val); + } else if (elemType == UIElement::ElementType::Divider) { + static_cast(elem)->setColorExpr(val); + } else if (elemType == UIElement::ElementType::Icon) { + static_cast(elem)->setColorExpr(val); + } else if (elemType == UIElement::ElementType::BatteryIcon) { + static_cast(elem)->setColor(val); + } + } + + // Container properties + else if (key == "Border") { + if (auto c = elem->asContainer()) { + if (val.find('{') != std::string::npos) { + c->setBorderExpr(val); + } else { + c->setBorder(val == "true" || val == "1" || val == "yes"); + } + } + } else if (key == "Padding") { + if (elemType == UIElement::ElementType::Container || elemType == UIElement::ElementType::HStack || + elemType == UIElement::ElementType::VStack || elemType == UIElement::ElementType::Grid) { + static_cast(elem)->setPadding(parseIntSafe(val)); + } else if (elemType == UIElement::ElementType::TabBar) { + static_cast(elem)->setPadding(parseIntSafe(val)); + } + } else if (key == "BorderRadius") { + if (elemType == UIElement::ElementType::Container || elemType == UIElement::ElementType::HStack || + elemType == UIElement::ElementType::VStack || elemType == UIElement::ElementType::Grid) { + static_cast(elem)->setBorderRadius(parseIntSafe(val)); + } else if (elemType == UIElement::ElementType::Bitmap) { + static_cast(elem)->setBorderRadius(parseIntSafe(val)); + } else if (elemType == UIElement::ElementType::Rectangle) { + static_cast(elem)->setBorderRadius(parseIntSafe(val)); + } else if (elemType == UIElement::ElementType::Badge) { + static_cast(elem)->setBorderRadius(parseIntSafe(val)); + } else if (elemType == UIElement::ElementType::Toggle) { + static_cast(elem)->setBorderRadius(parseIntSafe(val)); + } + } else if (key == "Spacing") { + if (elemType == UIElement::ElementType::HStack) { + static_cast(elem)->setSpacing(parseIntSafe(val)); + } else if (elemType == UIElement::ElementType::VStack) { + static_cast(elem)->setSpacing(parseIntSafe(val)); + } else if (elemType == UIElement::ElementType::List) { + static_cast(elem)->setSpacing(parseIntSafe(val)); + } + } else if (key == "VAlign") { + if (elemType == UIElement::ElementType::HStack) { + static_cast(elem)->setVAlignFromString(val); + } + } else if (key == "HAlign") { + if (elemType == UIElement::ElementType::VStack) { + static_cast(elem)->setHAlignFromString(val); + } + } + + // Label properties + else if (key == "Text") { + if (elemType == UIElement::ElementType::Label) { + static_cast(elem)->setText(val); + } else if (elemType == UIElement::ElementType::Badge) { + static_cast(elem)->setText(val); + } + } else if (key == "Font") { + if (elemType == UIElement::ElementType::Label) { + if (fontMap.count(val)) { + static_cast(elem)->setFont(fontMap[val]); + } + } else if (elemType == UIElement::ElementType::Badge) { + if (fontMap.count(val)) { + static_cast(elem)->setFont(fontMap[val]); + } + } + } else if (key == "Centered") { + if (elemType == UIElement::ElementType::Label) { + static_cast(elem)->setCentered(val == "true" || val == "1"); + } + } else if (key == "Align") { + if (elemType == UIElement::ElementType::Label) { + Label::Alignment align = Label::Alignment::Left; + if (val == "Center" || val == "center") align = Label::Alignment::Center; + if (val == "Right" || val == "right") align = Label::Alignment::Right; + static_cast(elem)->setAlignment(align); + } + } else if (key == "MaxLines") { + if (elemType == UIElement::ElementType::Label) { + static_cast(elem)->setMaxLines(parseIntSafe(val)); + } + } else if (key == "Ellipsis") { + if (elemType == UIElement::ElementType::Label) { + static_cast(elem)->setEllipsis(val == "true" || val == "1"); + } + } + + // Bitmap/Icon properties + else if (key == "Src") { + if (elemType == UIElement::ElementType::Bitmap) { + auto b = static_cast(elem); + if (val.find('{') == std::string::npos && val.find('/') == std::string::npos) { + b->setSrc(getAssetPath(val)); + } else { + b->setSrc(val); + } + } else if (elemType == UIElement::ElementType::Icon) { + static_cast(elem)->setSrc(val); + } + } else if (key == "ScaleToFit") { + if (elemType == UIElement::ElementType::Bitmap) { + static_cast(elem)->setScaleToFit(val == "true" || val == "1"); + } + } else if (key == "PreserveAspect") { + if (elemType == UIElement::ElementType::Bitmap) { + static_cast(elem)->setPreserveAspect(val == "true" || val == "1"); + } + } else if (key == "IconSize") { + if (elemType == UIElement::ElementType::Icon) { + static_cast(elem)->setIconSize(parseIntSafe(val)); + } + } + + // List properties + else if (key == "Source") { + if (elemType == UIElement::ElementType::List) { + static_cast(elem)->setSource(val); + } + } else if (key == "ItemTemplate") { + if (elemType == UIElement::ElementType::List) { + static_cast(elem)->setItemTemplateId(val); + } + } else if (key == "ItemHeight") { + if (elemType == UIElement::ElementType::List) { + static_cast(elem)->setItemHeight(parseIntSafe(val)); + } + } else if (key == "ItemWidth") { + if (elemType == UIElement::ElementType::List) { + static_cast(elem)->setItemWidth(parseIntSafe(val)); + } + } else if (key == "Direction") { + if (elemType == UIElement::ElementType::List) { + static_cast(elem)->setDirectionFromString(val); + } + } else if (key == "Columns") { + if (elemType == UIElement::ElementType::List) { + static_cast(elem)->setColumns(parseIntSafe(val)); + } else if (elemType == UIElement::ElementType::Grid) { + static_cast(elem)->setColumns(parseIntSafe(val)); + } + } else if (key == "RowSpacing") { + if (elemType == UIElement::ElementType::Grid) { + static_cast(elem)->setRowSpacing(parseIntSafe(val)); + } + } else if (key == "ColSpacing") { + if (elemType == UIElement::ElementType::Grid) { + static_cast(elem)->setColSpacing(parseIntSafe(val)); + } + } + + // ProgressBar properties + else if (key == "Value") { + if (elemType == UIElement::ElementType::ProgressBar) { + static_cast(elem)->setValue(val); + } else if (elemType == UIElement::ElementType::Toggle) { + static_cast(elem)->setValue(val); + } else if (elemType == UIElement::ElementType::BatteryIcon) { + static_cast(elem)->setValue(val); + } + } else if (key == "Max") { + if (elemType == UIElement::ElementType::ProgressBar) { + static_cast(elem)->setMax(val); + } + } else if (key == "FgColor") { + if (elemType == UIElement::ElementType::ProgressBar) { + static_cast(elem)->setFgColor(val); + } else if (elemType == UIElement::ElementType::Badge) { + static_cast(elem)->setFgColor(val); + } + } else if (key == "BgColor") { + if (elemType == UIElement::ElementType::ProgressBar) { + static_cast(elem)->setBgColor(val); + } else if (elemType == UIElement::ElementType::Badge) { + static_cast(elem)->setBgColor(val); + } else if (elemType == UIElement::ElementType::Container || elemType == UIElement::ElementType::HStack || + elemType == UIElement::ElementType::VStack || elemType == UIElement::ElementType::Grid) { + static_cast(elem)->setBackgroundColorExpr(val); + } + } else if (key == "ShowBorder") { + if (elemType == UIElement::ElementType::ProgressBar) { + static_cast(elem)->setShowBorder(val == "true" || val == "1"); + } + } + + // Divider properties + else if (key == "Horizontal") { + if (elemType == UIElement::ElementType::Divider) { + static_cast(elem)->setHorizontal(val == "true" || val == "1"); + } + } else if (key == "Thickness") { + if (elemType == UIElement::ElementType::Divider) { + static_cast(elem)->setThickness(parseIntSafe(val)); + } + } + + // Toggle properties + else if (key == "OnColor") { + if (elemType == UIElement::ElementType::Toggle) { + static_cast(elem)->setOnColor(val); + } + } else if (key == "OffColor") { + if (elemType == UIElement::ElementType::Toggle) { + static_cast(elem)->setOffColor(val); + } + } else if (key == "KnobColor") { + if (elemType == UIElement::ElementType::Toggle) { + static_cast(elem)->setKnobColor(val); + } + } else if (key == "TrackWidth") { + if (elemType == UIElement::ElementType::Toggle) { + static_cast(elem)->setTrackWidth(parseIntSafe(val)); + } else if (elemType == UIElement::ElementType::ScrollIndicator) { + static_cast(elem)->setTrackWidth(parseIntSafe(val)); + } + } else if (key == "TrackHeight") { + if (elemType == UIElement::ElementType::Toggle) { + static_cast(elem)->setTrackHeight(parseIntSafe(val)); + } + } else if (key == "KnobSize") { + if (elemType == UIElement::ElementType::Toggle) { + static_cast(elem)->setKnobSize(parseIntSafe(val)); + } + } else if (key == "KnobRadius") { + if (elemType == UIElement::ElementType::Toggle) { + static_cast(elem)->setKnobRadius(parseIntSafe(val)); + } + } + + // TabBar properties + else if (key == "Selected") { + if (elemType == UIElement::ElementType::TabBar) { + static_cast(elem)->setSelected(val); + } + } else if (key == "TabSpacing") { + if (elemType == UIElement::ElementType::TabBar) { + static_cast(elem)->setTabSpacing(parseIntSafe(val)); + } + } else if (key == "IndicatorHeight") { + if (elemType == UIElement::ElementType::TabBar) { + static_cast(elem)->setIndicatorHeight(parseIntSafe(val)); + } + } else if (key == "ShowIndicator") { + if (elemType == UIElement::ElementType::TabBar) { + static_cast(elem)->setShowIndicator(val == "true" || val == "1"); + } + } + + // ScrollIndicator properties + else if (key == "Position") { + if (elemType == UIElement::ElementType::ScrollIndicator) { + static_cast(elem)->setPosition(val); + } + } else if (key == "Total") { + if (elemType == UIElement::ElementType::ScrollIndicator) { + static_cast(elem)->setTotal(val); + } + } else if (key == "VisibleCount") { + if (elemType == UIElement::ElementType::ScrollIndicator) { + static_cast(elem)->setVisibleCount(val); + } + } + + // Badge properties + else if (key == "PaddingH") { + if (elemType == UIElement::ElementType::Badge) { + static_cast(elem)->setPaddingH(parseIntSafe(val)); + } + } else if (key == "PaddingV") { + if (elemType == UIElement::ElementType::Badge) { + static_cast(elem)->setPaddingV(parseIntSafe(val)); + } + } + } +} + +const std::vector* ThemeManager::getCachedAsset(const std::string& path) { + if (assetCache.count(path)) { + return &assetCache.at(path); + } + + if (!SdMan.exists(path.c_str())) return nullptr; + + FsFile file; + if (SdMan.openFileForRead("ThemeCache", path, file)) { + size_t size = file.size(); + auto& buf = assetCache[path]; + buf.resize(size); + file.read(buf.data(), size); + file.close(); + return &buf; + } + return nullptr; +} + +void ThemeManager::cacheAsset(const std::string& path, std::vector&& data) { + assetCache[path] = std::move(data); +} + +const ProcessedAsset* ThemeManager::getProcessedAsset(const std::string& path, GfxRenderer::Orientation orientation, + int targetW, int targetH) { + std::string cacheKey = path; + if (targetW > 0 && targetH > 0) { + cacheKey += ":" + std::to_string(targetW) + "x" + std::to_string(targetH); + } + + if (processedCache.count(cacheKey)) { + const auto& asset = processedCache.at(cacheKey); + if (asset.orientation == orientation) { + return &asset; + } + } + return nullptr; +} + +void ThemeManager::cacheProcessedAsset(const std::string& path, const ProcessedAsset& asset, int targetW, int targetH) { + std::string cacheKey = path; + if (targetW > 0 && targetH > 0) { + cacheKey += ":" + std::to_string(targetW) + "x" + std::to_string(targetH); + } + processedCache[cacheKey] = asset; +} + +void ThemeManager::clearAssetCaches() { + assetCache.clear(); + processedCache.clear(); +} + +void ThemeManager::unloadTheme() { + for (auto& kv : elements) { + delete kv.second; + } + elements.clear(); + clearAssetCaches(); + invalidateAllCaches(); +} + +void ThemeManager::invalidateAllCaches() { + for (auto& kv : screenCaches) { + kv.second.invalidate(); + } +} + +void ThemeManager::invalidateScreenCache(const std::string& screenName) { + if (screenCaches.count(screenName)) { + screenCaches[screenName].invalidate(); + } +} + +uint32_t ThemeManager::computeContextHash(const ThemeContext& context, const std::string& screenName) { + uint32_t hash = 2166136261u; + for (char c : screenName) { + hash ^= static_cast(c); + hash *= 16777619u; + } + return hash; +} + +void ThemeManager::loadTheme(const std::string& themeName) { + unloadTheme(); + currentThemeName = themeName; + + std::map> sections; + + if (themeName == "Default" || themeName.empty()) { + std::string path = "/themes/Default/theme.ini"; + if (SdMan.exists(path.c_str())) { + FsFile file; + if (SdMan.openFileForRead("Theme", path, file)) { + sections = IniParser::parse(file); + file.close(); + } + } else { + sections = IniParser::parseString(getDefaultThemeIni()); + } + currentThemeName = "Default"; + } else { + std::string path = "/themes/" + themeName + "/theme.ini"; + + if (!SdMan.exists(path.c_str())) { + sections = IniParser::parseString(getDefaultThemeIni()); + currentThemeName = "Default"; + } else { + FsFile file; + if (SdMan.openFileForRead("Theme", path, file)) { + sections = IniParser::parse(file); + file.close(); + } else { + sections = IniParser::parseString(getDefaultThemeIni()); + currentThemeName = "Default"; + } + } + } + + // Read theme configuration from [Global] section + navBookCount = 1; + if (sections.count("Global")) { + const auto& global = sections.at("Global"); + if (global.count("NavBookCount")) { + navBookCount = parseIntSafe(global.at("NavBookCount")); + if (navBookCount < 1) navBookCount = 1; + if (navBookCount > 10) navBookCount = 10; + } + } + + // Pass 1: Create elements + for (const auto& sec : sections) { + const std::string& id = sec.first; + const std::map& props = sec.second; + + if (id == "Global") continue; + + auto it = props.find("Type"); + if (it == props.end()) continue; + + const std::string& type = it->second; + if (type.empty()) continue; + + UIElement* elem = createElement(id, type); + if (elem) { + elements[id] = elem; + } + } + + // Pass 2: Apply properties and wire parent relationships + std::vector lists; + for (const auto& sec : sections) { + const std::string& id = sec.first; + if (id == "Global") continue; + if (elements.find(id) == elements.end()) continue; + + UIElement* elem = elements[id]; + applyProperties(elem, sec.second); + + if (elem->getType() == UIElement::ElementType::List) { + lists.push_back(static_cast(elem)); + } + + // Wire parent relationship (fallback if Children not specified) + if (sec.second.count("Parent")) { + const std::string& parentId = sec.second.at("Parent"); + if (elements.count(parentId)) { + UIElement* parent = elements[parentId]; + if (auto c = parent->asContainer()) { + const auto& children = c->getChildren(); + if (std::find(children.begin(), children.end(), elem) == children.end()) { + c->addChild(elem); + } + } + } + } + + // Children property - explicit ordering + if (sec.second.count("Children")) { + if (auto c = elem->asContainer()) { + c->clearChildren(); + + std::string s = sec.second.at("Children"); + size_t pos = 0; + + auto processChild = [&](const std::string& childName) { + std::string childId = childName; + size_t start = childId.find_first_not_of(" "); + size_t end = childId.find_last_not_of(" "); + if (start == std::string::npos) return; + childId = childId.substr(start, end - start + 1); + + if (elements.count(childId)) { + c->addChild(elements[childId]); + } + }; + + while ((pos = s.find(',')) != std::string::npos) { + processChild(s.substr(0, pos)); + s.erase(0, pos + 1); + } + processChild(s); + } + } + } + + // Pass 3: Resolve list templates + for (auto* l : lists) { + l->resolveTemplate(elements); + } +} + +void ThemeManager::renderScreen(const std::string& screenName, const GfxRenderer& renderer, + const ThemeContext& context) { + if (elements.count(screenName) == 0) { + return; + } + + UIElement* root = elements[screenName]; + root->layout(context, 0, 0, renderer.getScreenWidth(), renderer.getScreenHeight()); + root->draw(renderer, context); +} + +void ThemeManager::renderScreenOptimized(const std::string& screenName, const GfxRenderer& renderer, + const ThemeContext& context, const ThemeContext* prevContext) { + if (elements.count(screenName) == 0) { + return; + } + + UIElement* root = elements[screenName]; + + // Layout uses internal caching - will skip if params unchanged + root->layout(context, 0, 0, renderer.getScreenWidth(), renderer.getScreenHeight()); + + // If no previous context provided, do full draw + if (!prevContext) { + root->draw(renderer, context); + return; + } + + // Draw elements - dirty tracking is handled internally by each element + root->draw(renderer, context); +} + +} // namespace ThemeEngine diff --git a/src/CrossPointSettings.cpp b/src/CrossPointSettings.cpp index 232c7c57..c3858c47 100644 --- a/src/CrossPointSettings.cpp +++ b/src/CrossPointSettings.cpp @@ -22,7 +22,7 @@ void readAndValidate(FsFile& file, uint8_t& member, const uint8_t maxValue) { namespace { constexpr uint8_t SETTINGS_FILE_VERSION = 1; // Increment this when adding new persisted settings fields -constexpr uint8_t SETTINGS_COUNT = 23; +constexpr uint8_t SETTINGS_COUNT = 24; // 23 upstream + themeName constexpr char SETTINGS_FILE[] = "/.crosspoint/settings.bin"; } // namespace @@ -60,6 +60,7 @@ bool CrossPointSettings::saveToFile() const { serialization::writeString(outputFile, std::string(opdsUsername)); serialization::writeString(outputFile, std::string(opdsPassword)); serialization::writePod(outputFile, sleepScreenCoverFilter); + serialization::writeString(outputFile, std::string(themeName)); // New fields added at end for backward compatibility outputFile.close(); @@ -148,6 +149,13 @@ bool CrossPointSettings::loadFromFile() { if (++settingsRead >= fileSettingsCount) break; readAndValidate(inputFile, sleepScreenCoverFilter, SLEEP_SCREEN_COVER_FILTER_COUNT); if (++settingsRead >= fileSettingsCount) break; + { + std::string themeStr; + serialization::readString(inputFile, themeStr); + strncpy(themeName, themeStr.c_str(), sizeof(themeName) - 1); + themeName[sizeof(themeName) - 1] = '\0'; + } + if (++settingsRead >= fileSettingsCount) break; // New fields added at end for backward compatibility } while (false); diff --git a/src/CrossPointSettings.h b/src/CrossPointSettings.h index c450d348..e57651b9 100644 --- a/src/CrossPointSettings.h +++ b/src/CrossPointSettings.h @@ -137,6 +137,8 @@ class CrossPointSettings { uint8_t hideBatteryPercentage = HIDE_NEVER; // Long-press chapter skip on side buttons uint8_t longPressChapterSkip = 1; + // Theme name (theme-engine addition) + char themeName[64] = "Default"; ~CrossPointSettings() = default; diff --git a/src/activities/home/HomeActivity.cpp b/src/activities/home/HomeActivity.cpp index 58b29505..f54b25e3 100644 --- a/src/activities/home/HomeActivity.cpp +++ b/src/activities/home/HomeActivity.cpp @@ -4,8 +4,10 @@ #include #include #include +#include #include +#include #include #include @@ -13,6 +15,7 @@ #include "CrossPointSettings.h" #include "CrossPointState.h" #include "MappedInputManager.h" +#include "RecentBooksStore.h" #include "ScreenComponents.h" #include "fontIds.h" #include "util/StringUtils.h" @@ -23,9 +26,8 @@ void HomeActivity::taskTrampoline(void* param) { } int HomeActivity::getMenuItemCount() const { - int count = 3; // My Library, File transfer, Settings - if (hasContinueReading) count++; - if (hasOpdsUrl) count++; + int count = 3; // Browse Files, File Transfer, Settings + if (hasOpdsUrl) count++; // + Calibre Library return count; } @@ -34,62 +36,110 @@ void HomeActivity::onEnter() { renderingMutex = xSemaphoreCreateMutex(); + // Reset render and selection state + coverRendered = false; + coverBufferStored = false; + freeCoverBuffer(); + selectorIndex = 0; // Start at first item (first book if any, else first menu) + // Check if we have a book to continue reading hasContinueReading = !APP_STATE.openEpubPath.empty() && SdMan.exists(APP_STATE.openEpubPath.c_str()); // Check if OPDS browser URL is configured hasOpdsUrl = strlen(SETTINGS.opdsServerUrl) > 0; - if (hasContinueReading) { - // Extract filename from path for display - lastBookTitle = APP_STATE.openEpubPath; - const size_t lastSlash = lastBookTitle.find_last_of('/'); - if (lastSlash != std::string::npos) { - lastBookTitle = lastBookTitle.substr(lastSlash + 1); - } + // Load and cache recent books data FIRST (loads each book only once) + loadRecentBooksData(); - // If epub, try to load the metadata for title/author and cover - if (StringUtils::checkFileExtension(lastBookTitle, ".epub")) { - Epub epub(APP_STATE.openEpubPath, "/.crosspoint"); - epub.load(false); - if (!epub.getTitle().empty()) { - lastBookTitle = std::string(epub.getTitle()); + if (hasContinueReading) { + // Initialize defaults + cachedChapterTitle = ""; + cachedCurrentPage = "-"; + cachedTotalPages = "-"; + cachedProgressPercent = 0; + + // Check if current book is in recent books - use cached data instead of reloading + const auto& openPath = APP_STATE.openEpubPath; + auto it = std::find_if(cachedRecentBooks.begin(), cachedRecentBooks.end(), + [&openPath](const CachedBookInfo& book) { return book.path == openPath; }); + + if (it != cachedRecentBooks.end()) { + lastBookTitle = it->title; + coverBmpPath = it->coverPath; + hasCoverImage = !it->coverPath.empty(); + cachedProgressPercent = it->progressPercent; + } else { + // Book not in recent list, need to load it + lastBookTitle = APP_STATE.openEpubPath; + const size_t lastSlash = lastBookTitle.find_last_of('/'); + if (lastSlash != std::string::npos) { + lastBookTitle = lastBookTitle.substr(lastSlash + 1); } - if (!epub.getAuthor().empty()) { - lastBookAuthor = std::string(epub.getAuthor()); - } - // Try to generate thumbnail image for Continue Reading card - if (epub.generateThumbBmp()) { - coverBmpPath = epub.getThumbBmpPath(); - hasCoverImage = true; - } - } else if (StringUtils::checkFileExtension(lastBookTitle, ".xtch") || - StringUtils::checkFileExtension(lastBookTitle, ".xtc")) { - // Handle XTC file - Xtc xtc(APP_STATE.openEpubPath, "/.crosspoint"); - if (xtc.load()) { - if (!xtc.getTitle().empty()) { - lastBookTitle = std::string(xtc.getTitle()); - } - if (!xtc.getAuthor().empty()) { - lastBookAuthor = std::string(xtc.getAuthor()); - } - // Try to generate thumbnail image for Continue Reading card - if (xtc.generateThumbBmp()) { - coverBmpPath = xtc.getThumbBmpPath(); + + if (StringUtils::checkFileExtension(APP_STATE.openEpubPath, ".epub")) { + Epub epub(APP_STATE.openEpubPath, "/.crosspoint"); + epub.load(false); + if (!epub.getTitle().empty()) lastBookTitle = epub.getTitle(); + if (!epub.getAuthor().empty()) lastBookAuthor = epub.getAuthor(); + if (epub.generateThumbBmp()) { + coverBmpPath = epub.getThumbBmpPath(); hasCoverImage = true; } - } - // Remove extension from title if we don't have metadata - if (StringUtils::checkFileExtension(lastBookTitle, ".xtch")) { - lastBookTitle.resize(lastBookTitle.length() - 5); - } else if (StringUtils::checkFileExtension(lastBookTitle, ".xtc")) { - lastBookTitle.resize(lastBookTitle.length() - 4); + // Get progress info from the same loaded epub + FsFile f; + if (SdMan.openFileForRead("HOME", epub.getCachePath() + "/progress.bin", f)) { + uint8_t data[4]; + if (f.read(data, 4) == 4) { + int spineIndex = data[0] + (data[1] << 8); + int spineCount = epub.getSpineItemsCount(); + cachedCurrentPage = std::to_string(spineIndex + 1); + cachedTotalPages = std::to_string(spineCount); + if (spineCount > 0) cachedProgressPercent = (spineIndex * 100) / spineCount; + auto spineEntry = epub.getSpineItem(spineIndex); + if (spineEntry.tocIndex != -1) { + cachedChapterTitle = epub.getTocItem(spineEntry.tocIndex).title; + } + } + f.close(); + } + } else if (StringUtils::checkFileExtension(APP_STATE.openEpubPath, ".xtc") || + StringUtils::checkFileExtension(APP_STATE.openEpubPath, ".xtch")) { + Xtc xtc(APP_STATE.openEpubPath, "/.crosspoint"); + if (xtc.load()) { + if (!xtc.getTitle().empty()) lastBookTitle = xtc.getTitle(); + if (xtc.generateThumbBmp()) { + coverBmpPath = xtc.getThumbBmpPath(); + hasCoverImage = true; + } + // Get progress from same loaded xtc + FsFile f; + if (SdMan.openFileForRead("HOME", xtc.getCachePath() + "/progress.bin", f)) { + uint8_t data[4]; + if (f.read(data, 4) == 4) { + uint32_t currentPage = data[0] | (data[1] << 8) | (data[2] << 16) | (data[3] << 24); + uint32_t totalPages = xtc.getPageCount(); + cachedCurrentPage = std::to_string(currentPage + 1); + cachedTotalPages = std::to_string(totalPages); + if (totalPages > 0) cachedProgressPercent = (currentPage * 100) / totalPages; + cachedChapterTitle = "Page " + cachedCurrentPage; + } + f.close(); + } + } + // Remove extension from title if we don't have metadata + if (StringUtils::checkFileExtension(lastBookTitle, ".xtch")) { + lastBookTitle.resize(lastBookTitle.length() - 5); + } else if (StringUtils::checkFileExtension(lastBookTitle, ".xtc")) { + lastBookTitle.resize(lastBookTitle.length() - 4); + } } } } selectorIndex = 0; + lastBatteryCheck = 0; // Force update on first render + coverRendered = false; + coverBufferStored = false; // Trigger first update updateRequired = true; @@ -102,10 +152,97 @@ void HomeActivity::onEnter() { ); } +void HomeActivity::loadRecentBooksData() { + cachedRecentBooks.clear(); + + const auto& recentBooks = RECENT_BOOKS.getBooks(); + const int maxRecentBooks = 3; + int recentCount = std::min(static_cast(recentBooks.size()), maxRecentBooks); + + for (int i = 0; i < recentCount; i++) { + const RecentBook& recentBook = recentBooks[i]; + const std::string& bookPath = recentBook.path; + CachedBookInfo info; + info.path = bookPath; // Store the full path + + // Use title from RecentBook if available, otherwise extract from path + if (!recentBook.title.empty()) { + info.title = recentBook.title; + } else { + info.title = bookPath; + size_t lastSlash = info.title.find_last_of('/'); + if (lastSlash != std::string::npos) { + info.title = info.title.substr(lastSlash + 1); + } + size_t lastDot = info.title.find_last_of('.'); + if (lastDot != std::string::npos) { + info.title = info.title.substr(0, lastDot); + } + } + + if (StringUtils::checkFileExtension(bookPath, ".epub")) { + Epub epub(bookPath, "/.crosspoint"); + epub.load(false); + if (!epub.getTitle().empty()) { + info.title = epub.getTitle(); + } + if (epub.generateThumbBmp()) { + info.coverPath = epub.getThumbBmpPath(); + } + + // Read progress + FsFile f; + if (SdMan.openFileForRead("HOME", epub.getCachePath() + "/progress.bin", f)) { + uint8_t data[4]; + if (f.read(data, 4) == 4) { + int spineIndex = data[0] + (data[1] << 8); + int spineCount = epub.getSpineItemsCount(); + if (spineCount > 0) { + info.progressPercent = (spineIndex * 100) / spineCount; + } + } + f.close(); + } + } else if (StringUtils::checkFileExtension(bookPath, ".xtc") || + StringUtils::checkFileExtension(bookPath, ".xtch")) { + Xtc xtc(bookPath, "/.crosspoint"); + if (xtc.load()) { + if (!xtc.getTitle().empty()) { + info.title = xtc.getTitle(); + } + if (xtc.generateThumbBmp()) { + info.coverPath = xtc.getThumbBmpPath(); + } + + // Read progress + FsFile f; + if (SdMan.openFileForRead("HOME", xtc.getCachePath() + "/progress.bin", f)) { + uint8_t data[4]; + if (f.read(data, 4) == 4) { + uint32_t currentPage = data[0] | (data[1] << 8) | (data[2] << 16) | (data[3] << 24); + uint32_t totalPages = xtc.getPageCount(); + if (totalPages > 0) { + info.progressPercent = (currentPage * 100) / totalPages; + } + } + f.close(); + } + } + } + + Serial.printf("[HOME] Book %d: title='%s', cover='%s', progress=%d%%\n", i, info.title.c_str(), + info.coverPath.c_str(), info.progressPercent); + cachedRecentBooks.push_back(info); + } + + Serial.printf("[HOME] Loaded %d recent books\n", (int)cachedRecentBooks.size()); +} + void HomeActivity::onExit() { Activity::onExit(); - // Wait until not rendering to delete task to avoid killing mid-instruction to EPD + // Wait until not rendering to delete task to avoid killing mid-instruction to + // EPD xSemaphoreTake(renderingMutex, portMAX_DELAY); if (displayTaskHandle) { vTaskDelete(displayTaskHandle); @@ -116,6 +253,9 @@ void HomeActivity::onExit() { // Free the stored cover buffer if any freeCoverBuffer(); + + // Free ThemeEngine caches to ensure enough memory for reader's grayscale buffers + ThemeEngine::ThemeManager::get().clearAssetCaches(); } bool HomeActivity::storeCoverBuffer() { @@ -137,21 +277,6 @@ bool HomeActivity::storeCoverBuffer() { return true; } -bool HomeActivity::restoreCoverBuffer() { - if (!coverBuffer) { - return false; - } - - uint8_t* frameBuffer = renderer.getFrameBuffer(); - if (!frameBuffer) { - return false; - } - - const size_t bufferSize = GfxRenderer::getBufferSize(); - memcpy(frameBuffer, coverBuffer, bufferSize); - return true; -} - void HomeActivity::freeCoverBuffer() { if (coverBuffer) { free(coverBuffer); @@ -165,34 +290,47 @@ void HomeActivity::loop() { mappedInput.wasPressed(MappedInputManager::Button::Left); const bool nextPressed = mappedInput.wasPressed(MappedInputManager::Button::Down) || mappedInput.wasPressed(MappedInputManager::Button::Right); + const bool confirmPressed = mappedInput.wasReleased(MappedInputManager::Button::Confirm); + // Navigation uses theme-configured book slots (limited by actual books available) + const int maxBooks = static_cast(cachedRecentBooks.size()); + const int themeBookCount = ThemeEngine::ThemeManager::get().getNavBookCount(); + const int navBookCount = std::min(themeBookCount, maxBooks); const int menuCount = getMenuItemCount(); + const int totalCount = navBookCount + menuCount; - if (mappedInput.wasReleased(MappedInputManager::Button::Confirm)) { - // Calculate dynamic indices based on which options are available - int idx = 0; - const int continueIdx = hasContinueReading ? idx++ : -1; - const int myLibraryIdx = idx++; - const int opdsLibraryIdx = hasOpdsUrl ? idx++ : -1; - const int fileTransferIdx = idx++; - const int settingsIdx = idx; - - if (selectorIndex == continueIdx) { + if (confirmPressed) { + if (selectorIndex < navBookCount && selectorIndex < maxBooks) { + // Book selected - open the selected book + APP_STATE.openEpubPath = cachedRecentBooks[selectorIndex].path; onContinueReading(); - } else if (selectorIndex == myLibraryIdx) { - onMyLibraryOpen(); - } else if (selectorIndex == opdsLibraryIdx) { - onOpdsBrowserOpen(); - } else if (selectorIndex == fileTransferIdx) { - onFileTransferOpen(); - } else if (selectorIndex == settingsIdx) { - onSettingsOpen(); + } else { + // Menu item selected + const int menuIdx = selectorIndex - navBookCount; + int idx = 0; + const int myLibraryIdx = idx++; + const int opdsLibraryIdx = hasOpdsUrl ? idx++ : -1; + const int fileTransferIdx = idx++; + const int settingsIdx = idx; + + if (menuIdx == myLibraryIdx) { + onMyLibraryOpen(); + } else if (menuIdx == opdsLibraryIdx) { + onOpdsBrowserOpen(); + } else if (menuIdx == fileTransferIdx) { + onFileTransferOpen(); + } else if (menuIdx == settingsIdx) { + onSettingsOpen(); + } } - } else if (prevPressed) { - selectorIndex = (selectorIndex + menuCount - 1) % menuCount; + return; + } + + if (prevPressed) { + selectorIndex = (selectorIndex + totalCount - 1) % totalCount; updateRequired = true; } else if (nextPressed) { - selectorIndex = (selectorIndex + 1) % menuCount; + selectorIndex = (selectorIndex + 1) % totalCount; updateRequired = true; } } @@ -210,350 +348,115 @@ void HomeActivity::displayTaskLoop() { } void HomeActivity::render() { - // If we have a stored cover buffer, restore it instead of clearing - const bool bufferRestored = coverBufferStored && restoreCoverBuffer(); - if (!bufferRestored) { - renderer.clearScreen(); + // Battery check logic (only update every 60 seconds) + const uint32_t now = millis(); + const bool needBatteryUpdate = (now - lastBatteryCheck > 60000) || (lastBatteryCheck == 0); + if (needBatteryUpdate) { + cachedBatteryLevel = battery.readPercentage(); + lastBatteryCheck = now; } - const auto pageWidth = renderer.getScreenWidth(); - const auto pageHeight = renderer.getScreenHeight(); + // Always clear screen - required because parent containers draw backgrounds + renderer.clearScreen(); - constexpr int margin = 20; - constexpr int bottomMargin = 60; + ThemeEngine::ThemeContext context; - // --- Top "book" card for the current title (selectorIndex == 0) --- - const int bookWidth = pageWidth / 2; - const int bookHeight = pageHeight / 2; - const int bookX = (pageWidth - bookWidth) / 2; - constexpr int bookY = 30; - const bool bookSelected = hasContinueReading && selectorIndex == 0; + // --- Bind Global Data --- + context.setString("BatteryPercent", std::to_string(cachedBatteryLevel)); + context.setBool("ShowBatteryPercent", + SETTINGS.hideBatteryPercentage != CrossPointSettings::HIDE_BATTERY_PERCENTAGE::HIDE_ALWAYS); - // Bookmark dimensions (used in multiple places) - const int bookmarkWidth = bookWidth / 8; - const int bookmarkHeight = bookHeight / 5; - const int bookmarkX = bookX + bookWidth - bookmarkWidth - 10; - const int bookmarkY = bookY + 5; + // --- Navigation counts (must match loop()) --- + const int recentCount = static_cast(cachedRecentBooks.size()); + const int themeBookCount = ThemeEngine::ThemeManager::get().getNavBookCount(); + const int navBookCount = std::min(themeBookCount, recentCount); + const bool isBookSelected = selectorIndex < navBookCount; - // Draw book card regardless, fill with message based on `hasContinueReading` - { - // Draw cover image as background if available (inside the box) - // Only load from SD on first render, then use stored buffer - if (hasContinueReading && hasCoverImage && !coverBmpPath.empty() && !coverRendered) { - // First time: load cover from SD and render - FsFile file; - if (SdMan.openFileForRead("HOME", coverBmpPath, file)) { - Bitmap bitmap(file); - if (bitmap.parseHeaders() == BmpReaderError::Ok) { - // Calculate position to center image within the book card - int coverX, coverY; + // --- Recent Books Data --- + context.setBool("HasRecentBooks", recentCount > 0); + context.setInt("RecentBooks.Count", recentCount); + context.setInt("SelectedBookIndex", isBookSelected ? selectorIndex : -1); - if (bitmap.getWidth() > bookWidth || bitmap.getHeight() > bookHeight) { - const float imgRatio = static_cast(bitmap.getWidth()) / static_cast(bitmap.getHeight()); - const float boxRatio = static_cast(bookWidth) / static_cast(bookHeight); + for (int i = 0; i < recentCount; i++) { + const auto& book = cachedRecentBooks[i]; + std::string prefix = "RecentBooks." + std::to_string(i) + "."; - if (imgRatio > boxRatio) { - coverX = bookX; - coverY = bookY + (bookHeight - static_cast(bookWidth / imgRatio)) / 2; - } else { - coverX = bookX + (bookWidth - static_cast(bookHeight * imgRatio)) / 2; - coverY = bookY; - } - } else { - coverX = bookX + (bookWidth - bitmap.getWidth()) / 2; - coverY = bookY + (bookHeight - bitmap.getHeight()) / 2; - } - - // Draw the cover image centered within the book card - renderer.drawBitmap(bitmap, coverX, coverY, bookWidth, bookHeight); - - // Draw border around the card - renderer.drawRect(bookX, bookY, bookWidth, bookHeight); - - // No bookmark ribbon when cover is shown - it would just cover the art - - // Store the buffer with cover image for fast navigation - coverBufferStored = storeCoverBuffer(); - coverRendered = true; - - // First render: if selected, draw selection indicators now - if (bookSelected) { - renderer.drawRect(bookX + 1, bookY + 1, bookWidth - 2, bookHeight - 2); - renderer.drawRect(bookX + 2, bookY + 2, bookWidth - 4, bookHeight - 4); - } - } - file.close(); - } - } else if (!bufferRestored && !coverRendered) { - // No cover image: draw border or fill, plus bookmark as visual flair - if (bookSelected) { - renderer.fillRect(bookX, bookY, bookWidth, bookHeight); - } else { - renderer.drawRect(bookX, bookY, bookWidth, bookHeight); - } - - // Draw bookmark ribbon when no cover image (visual decoration) - if (hasContinueReading) { - const int notchDepth = bookmarkHeight / 3; - const int centerX = bookmarkX + bookmarkWidth / 2; - - const int xPoints[5] = { - bookmarkX, // top-left - bookmarkX + bookmarkWidth, // top-right - bookmarkX + bookmarkWidth, // bottom-right - centerX, // center notch point - bookmarkX // bottom-left - }; - const int yPoints[5] = { - bookmarkY, // top-left - bookmarkY, // top-right - bookmarkY + bookmarkHeight, // bottom-right - bookmarkY + bookmarkHeight - notchDepth, // center notch point - bookmarkY + bookmarkHeight // bottom-left - }; - - // Draw bookmark ribbon (inverted if selected) - renderer.fillPolygon(xPoints, yPoints, 5, !bookSelected); - } - } - - // If buffer was restored, draw selection indicators if needed - if (bufferRestored && bookSelected && coverRendered) { - // Draw selection border (no bookmark inversion needed since cover has no bookmark) - renderer.drawRect(bookX + 1, bookY + 1, bookWidth - 2, bookHeight - 2); - renderer.drawRect(bookX + 2, bookY + 2, bookWidth - 4, bookHeight - 4); - } else if (!coverRendered && !bufferRestored) { - // Selection border already handled above in the no-cover case - } + context.setString(prefix + "Title", book.title); + context.setString(prefix + "Image", book.coverPath); + context.setString(prefix + "Progress", std::to_string(book.progressPercent)); + // Book is selected if selectorIndex matches + context.setBool(prefix + "Selected", selectorIndex == i); } - if (hasContinueReading) { - // Invert text colors based on selection state: - // - With cover: selected = white text on black box, unselected = black text on white box - // - Without cover: selected = white text on black card, unselected = black text on white card + // --- Book Card Data (for themes with single book) --- + context.setBool("IsBookSelected", isBookSelected); + context.setBool("HasBook", hasContinueReading); + context.setString("BookTitle", lastBookTitle); + context.setString("BookAuthor", lastBookAuthor); + context.setString("BookCoverPath", coverBmpPath); + context.setBool("HasCover", hasContinueReading && hasCoverImage && !coverBmpPath.empty()); + context.setBool("ShowInfoBox", true); - // Split into words (avoid stringstream to keep this light on the MCU) - std::vector words; - words.reserve(8); - size_t pos = 0; - while (pos < lastBookTitle.size()) { - while (pos < lastBookTitle.size() && lastBookTitle[pos] == ' ') { - ++pos; - } - if (pos >= lastBookTitle.size()) { - break; - } - const size_t start = pos; - while (pos < lastBookTitle.size() && lastBookTitle[pos] != ' ') { - ++pos; - } - words.emplace_back(lastBookTitle.substr(start, pos - start)); - } + // Use cached values (loaded in onEnter, NOT every render) + context.setString("BookChapter", cachedChapterTitle); + context.setString("BookCurrentPage", cachedCurrentPage); + context.setString("BookTotalPages", cachedTotalPages); + context.setInt("BookProgressPercent", cachedProgressPercent); + context.setString("BookProgressPercentStr", std::to_string(cachedProgressPercent)); - std::vector lines; - std::string currentLine; - // Extra padding inside the card so text doesn't hug the border - const int maxLineWidth = bookWidth - 40; - const int spaceWidth = renderer.getSpaceWidth(UI_12_FONT_ID); + // --- Main Menu Data --- + // Menu items start after the book slot + const int menuStartIdx = navBookCount; - for (auto& i : words) { - // If we just hit the line limit (3), stop processing words - if (lines.size() >= 3) { - // Limit to 3 lines - // Still have words left, so add ellipsis to last line - lines.back().append("..."); + int idx = 0; + const int myLibraryIdx = menuStartIdx + idx++; + const int opdsLibraryIdx = hasOpdsUrl ? menuStartIdx + idx++ : -1; + const int fileTransferIdx = menuStartIdx + idx++; + const int settingsIdx = menuStartIdx + idx; - while (!lines.back().empty() && renderer.getTextWidth(UI_12_FONT_ID, lines.back().c_str()) > maxLineWidth) { - // Remove "..." first, then remove one UTF-8 char, then add "..." back - lines.back().resize(lines.back().size() - 3); // Remove "..." - StringUtils::utf8RemoveLastChar(lines.back()); - lines.back().append("..."); - } - break; - } + std::vector menuLabels; + std::vector menuIcons; + std::vector menuSelected; - int wordWidth = renderer.getTextWidth(UI_12_FONT_ID, i.c_str()); - while (wordWidth > maxLineWidth && !i.empty()) { - // Word itself is too long, trim it (UTF-8 safe) - StringUtils::utf8RemoveLastChar(i); - // Check if we have room for ellipsis - std::string withEllipsis = i + "..."; - wordWidth = renderer.getTextWidth(UI_12_FONT_ID, withEllipsis.c_str()); - if (wordWidth <= maxLineWidth) { - i = withEllipsis; - break; - } - } + menuLabels.push_back("Browse Files"); + menuIcons.push_back("folder"); + menuSelected.push_back(selectorIndex == myLibraryIdx); - int newLineWidth = renderer.getTextWidth(UI_12_FONT_ID, currentLine.c_str()); - if (newLineWidth > 0) { - newLineWidth += spaceWidth; - } - newLineWidth += wordWidth; - - if (newLineWidth > maxLineWidth && !currentLine.empty()) { - // New line too long, push old line - lines.push_back(currentLine); - currentLine = i; - } else { - currentLine.append(" ").append(i); - } - } - - // If lower than the line limit, push remaining words - if (!currentLine.empty() && lines.size() < 3) { - lines.push_back(currentLine); - } - - // Book title text - int totalTextHeight = renderer.getLineHeight(UI_12_FONT_ID) * static_cast(lines.size()); - if (!lastBookAuthor.empty()) { - totalTextHeight += renderer.getLineHeight(UI_10_FONT_ID) * 3 / 2; - } - - // Vertically center the title block within the card - int titleYStart = bookY + (bookHeight - totalTextHeight) / 2; - - // If cover image was rendered, draw box behind title and author - if (coverRendered) { - constexpr int boxPadding = 8; - // Calculate the max text width for the box - int maxTextWidth = 0; - for (const auto& line : lines) { - const int lineWidth = renderer.getTextWidth(UI_12_FONT_ID, line.c_str()); - if (lineWidth > maxTextWidth) { - maxTextWidth = lineWidth; - } - } - if (!lastBookAuthor.empty()) { - std::string trimmedAuthor = lastBookAuthor; - while (renderer.getTextWidth(UI_10_FONT_ID, trimmedAuthor.c_str()) > maxLineWidth && !trimmedAuthor.empty()) { - StringUtils::utf8RemoveLastChar(trimmedAuthor); - } - if (renderer.getTextWidth(UI_10_FONT_ID, trimmedAuthor.c_str()) < - renderer.getTextWidth(UI_10_FONT_ID, lastBookAuthor.c_str())) { - trimmedAuthor.append("..."); - } - const int authorWidth = renderer.getTextWidth(UI_10_FONT_ID, trimmedAuthor.c_str()); - if (authorWidth > maxTextWidth) { - maxTextWidth = authorWidth; - } - } - - const int boxWidth = maxTextWidth + boxPadding * 2; - const int boxHeight = totalTextHeight + boxPadding * 2; - const int boxX = (pageWidth - boxWidth) / 2; - const int boxY = titleYStart - boxPadding; - - // Draw box (inverted when selected: black box instead of white) - renderer.fillRect(boxX, boxY, boxWidth, boxHeight, bookSelected); - // Draw border around the box (inverted when selected: white border instead of black) - renderer.drawRect(boxX, boxY, boxWidth, boxHeight, !bookSelected); - } - - for (const auto& line : lines) { - renderer.drawCenteredText(UI_12_FONT_ID, titleYStart, line.c_str(), !bookSelected); - titleYStart += renderer.getLineHeight(UI_12_FONT_ID); - } - - if (!lastBookAuthor.empty()) { - titleYStart += renderer.getLineHeight(UI_10_FONT_ID) / 2; - std::string trimmedAuthor = lastBookAuthor; - // Trim author if too long (UTF-8 safe) - bool wasTrimmed = false; - while (renderer.getTextWidth(UI_10_FONT_ID, trimmedAuthor.c_str()) > maxLineWidth && !trimmedAuthor.empty()) { - StringUtils::utf8RemoveLastChar(trimmedAuthor); - wasTrimmed = true; - } - if (wasTrimmed && !trimmedAuthor.empty()) { - // Make room for ellipsis - while (renderer.getTextWidth(UI_10_FONT_ID, (trimmedAuthor + "...").c_str()) > maxLineWidth && - !trimmedAuthor.empty()) { - StringUtils::utf8RemoveLastChar(trimmedAuthor); - } - trimmedAuthor.append("..."); - } - renderer.drawCenteredText(UI_10_FONT_ID, titleYStart, trimmedAuthor.c_str(), !bookSelected); - } - - // "Continue Reading" label at the bottom - const int continueY = bookY + bookHeight - renderer.getLineHeight(UI_10_FONT_ID) * 3 / 2; - if (coverRendered) { - // Draw box behind "Continue Reading" text (inverted when selected: black box instead of white) - const char* continueText = "Continue Reading"; - const int continueTextWidth = renderer.getTextWidth(UI_10_FONT_ID, continueText); - constexpr int continuePadding = 6; - const int continueBoxWidth = continueTextWidth + continuePadding * 2; - const int continueBoxHeight = renderer.getLineHeight(UI_10_FONT_ID) + continuePadding; - const int continueBoxX = (pageWidth - continueBoxWidth) / 2; - const int continueBoxY = continueY - continuePadding / 2; - renderer.fillRect(continueBoxX, continueBoxY, continueBoxWidth, continueBoxHeight, bookSelected); - renderer.drawRect(continueBoxX, continueBoxY, continueBoxWidth, continueBoxHeight, !bookSelected); - renderer.drawCenteredText(UI_10_FONT_ID, continueY, continueText, !bookSelected); - } else { - renderer.drawCenteredText(UI_10_FONT_ID, continueY, "Continue Reading", !bookSelected); - } - } else { - // No book to continue reading - const int y = - bookY + (bookHeight - renderer.getLineHeight(UI_12_FONT_ID) - renderer.getLineHeight(UI_10_FONT_ID)) / 2; - renderer.drawCenteredText(UI_12_FONT_ID, y, "No open book"); - renderer.drawCenteredText(UI_10_FONT_ID, y + renderer.getLineHeight(UI_12_FONT_ID), "Start reading below"); - } - - // --- Bottom menu tiles --- - // Build menu items dynamically - std::vector menuItems = {"My Library", "File Transfer", "Settings"}; if (hasOpdsUrl) { - // Insert OPDS Browser after My Library - menuItems.insert(menuItems.begin() + 1, "OPDS Browser"); + menuLabels.push_back("OPDS Browser"); + menuIcons.push_back("library"); + menuSelected.push_back(selectorIndex == opdsLibraryIdx); } - const int menuTileWidth = pageWidth - 2 * margin; - constexpr int menuTileHeight = 45; - constexpr int menuSpacing = 8; - const int totalMenuHeight = - static_cast(menuItems.size()) * menuTileHeight + (static_cast(menuItems.size()) - 1) * menuSpacing; + menuLabels.push_back("File Transfer"); + menuIcons.push_back("transfer"); + menuSelected.push_back(selectorIndex == fileTransferIdx); - int menuStartY = bookY + bookHeight + 15; - // Ensure we don't collide with the bottom button legend - const int maxMenuStartY = pageHeight - bottomMargin - totalMenuHeight - margin; - if (menuStartY > maxMenuStartY) { - menuStartY = maxMenuStartY; + menuLabels.push_back("Settings"); + menuIcons.push_back("settings"); + menuSelected.push_back(selectorIndex == settingsIdx); + + context.setInt("MainMenu.Count", menuLabels.size()); + for (size_t i = 0; i < menuLabels.size(); ++i) { + std::string prefix = "MainMenu." + std::to_string(i) + "."; + context.setString(prefix + "Title", menuLabels[i]); + context.setString(prefix + "Icon", menuIcons[i]); + context.setBool(prefix + "Selected", menuSelected[i]); } - for (size_t i = 0; i < menuItems.size(); ++i) { - const int overallIndex = static_cast(i) + (hasContinueReading ? 1 : 0); - constexpr int tileX = margin; - const int tileY = menuStartY + static_cast(i) * (menuTileHeight + menuSpacing); - const bool selected = selectorIndex == overallIndex; + // --- Render via ThemeEngine --- + const uint32_t renderStart = millis(); + ThemeEngine::ThemeManager::get().renderScreen("Home", renderer, context); + const uint32_t renderTime = millis() - renderStart; + Serial.printf("[HOME] ThemeEngine render took %lums\n", renderTime); - if (selected) { - renderer.fillRect(tileX, tileY, menuTileWidth, menuTileHeight); - } else { - renderer.drawRect(tileX, tileY, menuTileWidth, menuTileHeight); - } - - const char* label = menuItems[i]; - const int textWidth = renderer.getTextWidth(UI_10_FONT_ID, label); - const int textX = tileX + (menuTileWidth - textWidth) / 2; - const int lineHeight = renderer.getLineHeight(UI_10_FONT_ID); - const int textY = tileY + (menuTileHeight - lineHeight) / 2; // vertically centered assuming y is top of text - - // Invert text when the tile is selected, to contrast with the filled background - renderer.drawText(UI_10_FONT_ID, textX, textY, label, !selected); + // After first full render, store the framebuffer for fast subsequent updates + if (!coverRendered) { + coverBufferStored = storeCoverBuffer(); + coverRendered = true; } - const auto labels = mappedInput.mapLabels("", "Select", "Up", "Down"); - renderer.drawButtonHints(UI_10_FONT_ID, labels.btn1, labels.btn2, labels.btn3, labels.btn4); - - const bool showBatteryPercentage = - SETTINGS.hideBatteryPercentage != CrossPointSettings::HIDE_BATTERY_PERCENTAGE::HIDE_ALWAYS; - // get percentage so we can align text properly - const uint16_t percentage = battery.readPercentage(); - const auto percentageText = showBatteryPercentage ? std::to_string(percentage) + "%" : ""; - const auto batteryX = pageWidth - 25 - renderer.getTextWidth(SMALL_FONT_ID, percentageText.c_str()); - ScreenComponents::drawBattery(renderer, batteryX, 10, showBatteryPercentage); - + const uint32_t displayStart = millis(); renderer.displayBuffer(); + Serial.printf("[HOME] Display buffer took %lums\n", millis() - displayStart); } diff --git a/src/activities/home/HomeActivity.h b/src/activities/home/HomeActivity.h index 52963514..052f651e 100644 --- a/src/activities/home/HomeActivity.h +++ b/src/activities/home/HomeActivity.h @@ -4,13 +4,23 @@ #include #include +#include +#include #include "../Activity.h" +// Cached data for a recent book +struct CachedBookInfo { + std::string path; // Full path to the book file + std::string title; + std::string coverPath; + int progressPercent = 0; +}; + class HomeActivity final : public Activity { TaskHandle_t displayTaskHandle = nullptr; SemaphoreHandle_t renderingMutex = nullptr; - int selectorIndex = 0; + int selectorIndex = 0; // Unified index: 0..bookCount-1 = books, bookCount+ = menu bool updateRequired = false; bool hasContinueReading = false; bool hasOpdsUrl = false; @@ -18,9 +28,22 @@ class HomeActivity final : public Activity { bool coverRendered = false; // Track if cover has been rendered once bool coverBufferStored = false; // Track if cover buffer is stored uint8_t* coverBuffer = nullptr; // HomeActivity's own buffer for cover image + bool needsFullRender = true; // Force full render (first time or after layout changes) + int lastSelectorIndex = -1; // Track selection for incremental updates std::string lastBookTitle; std::string lastBookAuthor; std::string coverBmpPath; + uint8_t cachedBatteryLevel = 0; + uint32_t lastBatteryCheck = 0; + + // Cached "continue reading" info (loaded once in onEnter, NOT every render!) + std::string cachedChapterTitle; + std::string cachedCurrentPage; + std::string cachedTotalPages; + int cachedProgressPercent = 0; + + // Cached recent books data (loaded once in onEnter) + std::vector cachedRecentBooks; const std::function onContinueReading; const std::function onMyLibraryOpen; const std::function onSettingsOpen; @@ -31,9 +54,10 @@ class HomeActivity final : public Activity { [[noreturn]] void displayTaskLoop(); void render(); int getMenuItemCount() const; - bool storeCoverBuffer(); // Store frame buffer for cover image - bool restoreCoverBuffer(); // Restore frame buffer from stored cover - void freeCoverBuffer(); // Free the stored cover buffer + bool storeCoverBuffer(); // Store frame buffer for cover image + + void freeCoverBuffer(); // Free the stored cover buffer + void loadRecentBooksData(); // Load and cache recent books data public: explicit HomeActivity(GfxRenderer& renderer, MappedInputManager& mappedInput, diff --git a/src/activities/settings/CategorySettingsActivity.cpp b/src/activities/settings/CategorySettingsActivity.cpp index 7fd5ef5f..9c6fa844 100644 --- a/src/activities/settings/CategorySettingsActivity.cpp +++ b/src/activities/settings/CategorySettingsActivity.cpp @@ -11,8 +11,13 @@ #include "KOReaderSettingsActivity.h" #include "MappedInputManager.h" #include "OtaUpdateActivity.h" +#include "ThemeSelectionActivity.h" #include "fontIds.h" +// ... (existing includes) + +// ... + void CategorySettingsActivity::taskTrampoline(void* param) { auto* self = static_cast(param); self->displayTaskLoop(); @@ -32,7 +37,8 @@ void CategorySettingsActivity::onEnter() { void CategorySettingsActivity::onExit() { ActivityWithSubactivity::onExit(); - // Wait until not rendering to delete task to avoid killing mid-instruction to EPD + // Wait until not rendering to delete task to avoid killing mid-instruction to + // EPD xSemaphoreTake(renderingMutex, portMAX_DELAY); if (displayTaskHandle) { vTaskDelete(displayTaskHandle); @@ -127,6 +133,14 @@ void CategorySettingsActivity::toggleCurrentSetting() { updateRequired = true; })); xSemaphoreGive(renderingMutex); + } else if (strcmp(setting.name, "Theme") == 0) { + xSemaphoreTake(renderingMutex, portMAX_DELAY); + exitActivity(); + enterNewActivity(new ThemeSelectionActivity(renderer, mappedInput, [this] { + exitActivity(); + updateRequired = true; + })); + xSemaphoreGive(renderingMutex); } } else { return; diff --git a/src/activities/settings/SettingsActivity.cpp b/src/activities/settings/SettingsActivity.cpp index 7316db05..18b933ce 100644 --- a/src/activities/settings/SettingsActivity.cpp +++ b/src/activities/settings/SettingsActivity.cpp @@ -1,19 +1,23 @@ #include "SettingsActivity.h" #include -#include +#include "Battery.h" +#include "CalibreSettingsActivity.h" #include "CategorySettingsActivity.h" +#include "ClearCacheActivity.h" #include "CrossPointSettings.h" +#include "KOReaderSettingsActivity.h" #include "MappedInputManager.h" +#include "OtaUpdateActivity.h" +#include "ThemeSelectionActivity.h" #include "fontIds.h" const char* SettingsActivity::categoryNames[categoryCount] = {"Display", "Reader", "Controls", "System"}; namespace { -constexpr int displaySettingsCount = 6; +constexpr int displaySettingsCount = 7; const SettingInfo displaySettings[displaySettingsCount] = { - // Should match with SLEEP_SCREEN_MODE SettingInfo::Enum("Sleep Screen", &CrossPointSettings::sleepScreen, {"Dark", "Light", "Custom", "Cover", "None"}), SettingInfo::Enum("Sleep Screen Cover Mode", &CrossPointSettings::sleepScreenCoverMode, {"Fit", "Crop"}), SettingInfo::Enum("Sleep Screen Cover Filter", &CrossPointSettings::sleepScreenCoverFilter, @@ -22,7 +26,8 @@ const SettingInfo displaySettings[displaySettingsCount] = { {"None", "No Progress", "Full w/ Percentage", "Full w/ Progress Bar", "Progress Bar"}), SettingInfo::Enum("Hide Battery %", &CrossPointSettings::hideBatteryPercentage, {"Never", "In Reader", "Always"}), SettingInfo::Enum("Refresh Frequency", &CrossPointSettings::refreshFrequency, - {"1 page", "5 pages", "10 pages", "15 pages", "30 pages"})}; + {"1 page", "5 pages", "10 pages", "15 pages", "30 pages"}), + SettingInfo::Action("Theme")}; constexpr int readerSettingsCount = 9; const SettingInfo readerSettings[readerSettingsCount] = { @@ -54,6 +59,55 @@ const SettingInfo systemSettings[systemSettingsCount] = { {"1 min", "5 min", "10 min", "15 min", "30 min"}), SettingInfo::Action("KOReader Sync"), SettingInfo::Action("OPDS Browser"), SettingInfo::Action("Clear Cache"), SettingInfo::Action("Check for updates")}; + +// All categories with their settings +struct CategoryData { + const char* name; + const SettingInfo* settings; + int count; +}; + +const CategoryData allCategories[4] = {{"Display", displaySettings, displaySettingsCount}, + {"Reader", readerSettings, readerSettingsCount}, + {"Controls", controlsSettings, controlsSettingsCount}, + {"System", systemSettings, systemSettingsCount}}; + +void updateContextForSetting(ThemeEngine::ThemeContext& ctx, const std::string& prefix, int i, const SettingInfo& info, + bool isSelected, bool fullUpdate) { + if (fullUpdate) { + ctx.setListItem(prefix, i, "Name", info.name); + ctx.setListItem(prefix, i, "Type", + info.type == SettingType::TOGGLE ? "Toggle" + : info.type == SettingType::ENUM ? "Enum" + : info.type == SettingType::ACTION ? "Action" + : info.type == SettingType::VALUE ? "Value" + : "Unknown"); + } + ctx.setListItem(prefix, i, "Selected", isSelected); + + // Values definitely need update + if (info.type == SettingType::TOGGLE && info.valuePtr) { + bool val = SETTINGS.*(info.valuePtr); + ctx.setListItem(prefix, i, "Value", val); + ctx.setListItem(prefix, i, "ValueLabel", val ? "On" : "Off"); + } else if (info.type == SettingType::ENUM && info.valuePtr) { + uint8_t val = SETTINGS.*(info.valuePtr); + if (val < info.enumValues.size()) { + ctx.setListItem(prefix, i, "Value", info.enumValues[val]); + ctx.setListItem(prefix, i, "ValueLabel", info.enumValues[val]); + ctx.setListItem(prefix, i, "ValueIndex", static_cast(val)); + } + } else if (info.type == SettingType::VALUE && info.valuePtr) { + int val = SETTINGS.*(info.valuePtr); + ctx.setListItem(prefix, i, "Value", val); + ctx.setListItem(prefix, i, "ValueLabel", std::to_string(val)); + } else if (info.type == SettingType::ACTION) { + if (fullUpdate) { + ctx.setListItem(prefix, i, "Value", ""); + ctx.setListItem(prefix, i, "ValueLabel", ""); + } + } +} } // namespace void SettingsActivity::taskTrampoline(void* param) { @@ -64,11 +118,14 @@ void SettingsActivity::taskTrampoline(void* param) { void SettingsActivity::onEnter() { Activity::onEnter(); renderingMutex = xSemaphoreCreateMutex(); - - // Reset selection to first category selectedCategoryIndex = 0; + selectedSettingIndex = 0; + + // For themed mode, provide all data upfront + if (ThemeEngine::ThemeManager::get().getElement("Settings")) { + updateThemeContext(true); // Full update + } - // Trigger first update updateRequired = true; xTaskCreate(&SettingsActivity::taskTrampoline, "SettingsActivityTask", @@ -82,7 +139,6 @@ void SettingsActivity::onEnter() { void SettingsActivity::onExit() { ActivityWithSubactivity::onExit(); - // Wait until not rendering to delete task to avoid killing mid-instruction to EPD xSemaphoreTake(renderingMutex, portMAX_DELAY); if (displayTaskHandle) { vTaskDelete(displayTaskHandle); @@ -93,14 +149,26 @@ void SettingsActivity::onExit() { } void SettingsActivity::loop() { + if (subActivityExitPending) { + subActivityExitPending = false; + exitActivity(); + updateThemeContext(true); + updateRequired = true; + } + if (subActivity) { subActivity->loop(); return; } - // Handle category selection + if (ThemeEngine::ThemeManager::get().getElement("Settings")) { + handleThemeInput(); + return; + } + + // Legacy mode if (mappedInput.wasPressed(MappedInputManager::Button::Confirm)) { - enterCategory(selectedCategoryIndex); + enterCategoryLegacy(selectedCategoryIndex); return; } @@ -110,7 +178,6 @@ void SettingsActivity::loop() { return; } - // Handle navigation if (mappedInput.wasPressed(MappedInputManager::Button::Up) || mappedInput.wasPressed(MappedInputManager::Button::Left)) { // Move selection up (with wrap-around) @@ -124,38 +191,14 @@ void SettingsActivity::loop() { } } -void SettingsActivity::enterCategory(int categoryIndex) { - if (categoryIndex < 0 || categoryIndex >= categoryCount) { - return; - } +void SettingsActivity::enterCategoryLegacy(int categoryIndex) { + if (categoryIndex < 0 || categoryIndex >= categoryCount) return; xSemaphoreTake(renderingMutex, portMAX_DELAY); exitActivity(); - - const SettingInfo* settingsList = nullptr; - int settingsCount = 0; - - switch (categoryIndex) { - case 0: // Display - settingsList = displaySettings; - settingsCount = displaySettingsCount; - break; - case 1: // Reader - settingsList = readerSettings; - settingsCount = readerSettingsCount; - break; - case 2: // Controls - settingsList = controlsSettings; - settingsCount = controlsSettingsCount; - break; - case 3: // System - settingsList = systemSettings; - settingsCount = systemSettingsCount; - break; - } - - enterNewActivity(new CategorySettingsActivity(renderer, mappedInput, categoryNames[categoryIndex], settingsList, - settingsCount, [this] { + enterNewActivity(new CategorySettingsActivity(renderer, mappedInput, allCategories[categoryIndex].name, + allCategories[categoryIndex].settings, + allCategories[categoryIndex].count, [this] { exitActivity(); updateRequired = true; })); @@ -166,9 +209,11 @@ void SettingsActivity::displayTaskLoop() { while (true) { if (updateRequired && !subActivity) { updateRequired = false; - xSemaphoreTake(renderingMutex, portMAX_DELAY); - render(); - xSemaphoreGive(renderingMutex); + + if (xSemaphoreTake(renderingMutex, portMAX_DELAY) == pdTRUE) { + render(); + xSemaphoreGive(renderingMutex); + } } vTaskDelay(10 / portTICK_PERIOD_MS); } @@ -177,6 +222,13 @@ void SettingsActivity::displayTaskLoop() { void SettingsActivity::render() const { renderer.clearScreen(); + if (ThemeEngine::ThemeManager::get().getElement("Settings")) { + ThemeEngine::ThemeManager::get().renderScreen("Settings", renderer, themeContext); + renderer.displayBuffer(); + return; + } + + // Legacy rendering const auto pageWidth = renderer.getScreenWidth(); const auto pageHeight = renderer.getScreenHeight(); @@ -186,7 +238,6 @@ void SettingsActivity::render() const { // Draw selection renderer.fillRect(0, 60 + selectedCategoryIndex * 30 - 2, pageWidth - 1, 30); - // Draw all categories for (int i = 0; i < categoryCount; i++) { const int categoryY = 60 + i * 30; // 30 pixels between categories @@ -198,10 +249,133 @@ void SettingsActivity::render() const { renderer.drawText(SMALL_FONT_ID, pageWidth - 20 - renderer.getTextWidth(SMALL_FONT_ID, CROSSPOINT_VERSION), pageHeight - 60, CROSSPOINT_VERSION); - // Draw help text const auto labels = mappedInput.mapLabels("« Back", "Select", "", ""); renderer.drawButtonHints(UI_10_FONT_ID, labels.btn1, labels.btn2, labels.btn3, labels.btn4); - // Always use standard refresh for settings screen renderer.displayBuffer(); } + +void SettingsActivity::updateThemeContext(bool fullUpdate) { + themeContext.setInt("System.Battery", battery.readPercentage()); + + // Categories + if (fullUpdate) { + themeContext.setInt("Categories.Count", categoryCount); + } + + themeContext.setInt("Categories.Selected", selectedCategoryIndex); + + for (int i = 0; i < categoryCount; i++) { + if (fullUpdate) { + themeContext.setListItem("Categories", i, "Name", allCategories[i].name); + themeContext.setListItem("Categories", i, "SettingsCount", allCategories[i].count); + } + themeContext.setListItem("Categories", i, "Selected", i == selectedCategoryIndex); + } + + // Provide ALL settings for ALL categories + // Format: Category0.Settings.0.Name, Category0.Settings.1.Name, etc. + for (int cat = 0; cat < categoryCount; cat++) { + std::string catPrefix = "Category" + std::to_string(cat) + ".Settings"; + for (int i = 0; i < allCategories[cat].count; i++) { + bool isSelected = (cat == selectedCategoryIndex && i == selectedSettingIndex); + updateContextForSetting(themeContext, catPrefix, i, allCategories[cat].settings[i], isSelected, fullUpdate); + } + } + + // Also provide current category's settings as "Settings" for simpler themes + if (fullUpdate) { + themeContext.setInt("Settings.Count", allCategories[selectedCategoryIndex].count); + } + + for (int i = 0; i < allCategories[selectedCategoryIndex].count; i++) { + updateContextForSetting(themeContext, "Settings", i, allCategories[selectedCategoryIndex].settings[i], + i == selectedSettingIndex, fullUpdate); + } +} + +void SettingsActivity::handleThemeInput() { + const int currentCategorySettingsCount = allCategories[selectedCategoryIndex].count; + + // Up/Down navigates settings within current category + if (mappedInput.wasPressed(MappedInputManager::Button::Up)) { + selectedSettingIndex = (selectedSettingIndex > 0) ? (selectedSettingIndex - 1) : (currentCategorySettingsCount - 1); + updateThemeContext(false); // Partial update + updateRequired = true; + return; + } + + if (mappedInput.wasPressed(MappedInputManager::Button::Down)) { + selectedSettingIndex = (selectedSettingIndex < currentCategorySettingsCount - 1) ? (selectedSettingIndex + 1) : 0; + updateThemeContext(false); // Partial update + updateRequired = true; + return; + } + + // Left/Right/PageBack/PageForward switches categories + if (mappedInput.wasPressed(MappedInputManager::Button::Left) || + mappedInput.wasPressed(MappedInputManager::Button::PageBack)) { + selectedCategoryIndex = (selectedCategoryIndex > 0) ? (selectedCategoryIndex - 1) : (categoryCount - 1); + selectedSettingIndex = 0; // Reset to first setting in new category + updateThemeContext(true); // Full update (category changed) + updateRequired = true; + return; + } + + if (mappedInput.wasPressed(MappedInputManager::Button::Right) || + mappedInput.wasPressed(MappedInputManager::Button::PageForward)) { + selectedCategoryIndex = (selectedCategoryIndex < categoryCount - 1) ? (selectedCategoryIndex + 1) : 0; + selectedSettingIndex = 0; + updateThemeContext(true); // Full update + updateRequired = true; + return; + } + + // Confirm toggles/activates current setting + if (mappedInput.wasPressed(MappedInputManager::Button::Confirm)) { + toggleCurrentSetting(); + updateThemeContext(false); // Values changed, partial update is enough (names don't change) + updateRequired = true; + return; + } + + // Back exits + if (mappedInput.wasPressed(MappedInputManager::Button::Back)) { + SETTINGS.saveToFile(); + onGoHome(); + } +} + +void SettingsActivity::toggleCurrentSetting() { + const auto& setting = allCategories[selectedCategoryIndex].settings[selectedSettingIndex]; + + if (setting.type == SettingType::TOGGLE && setting.valuePtr) { + bool currentVal = SETTINGS.*(setting.valuePtr); + SETTINGS.*(setting.valuePtr) = !currentVal; + } else if (setting.type == SettingType::ENUM && setting.valuePtr) { + uint8_t val = SETTINGS.*(setting.valuePtr); + SETTINGS.*(setting.valuePtr) = (val + 1) % static_cast(setting.enumValues.size()); + } else if (setting.type == SettingType::VALUE && setting.valuePtr) { + int8_t val = SETTINGS.*(setting.valuePtr); + if (val + setting.valueRange.step > setting.valueRange.max) { + SETTINGS.*(setting.valuePtr) = setting.valueRange.min; + } else { + SETTINGS.*(setting.valuePtr) = val + setting.valueRange.step; + } + } else if (setting.type == SettingType::ACTION) { + if (strcmp(setting.name, "KOReader Sync") == 0) { + enterNewActivity(new KOReaderSettingsActivity(renderer, mappedInput, [this] { subActivityExitPending = true; })); + } else if (strcmp(setting.name, "Calibre Settings") == 0) { + enterNewActivity(new CalibreSettingsActivity(renderer, mappedInput, [this] { subActivityExitPending = true; })); + } else if (strcmp(setting.name, "Clear Cache") == 0) { + enterNewActivity(new ClearCacheActivity(renderer, mappedInput, [this] { subActivityExitPending = true; })); + } else if (strcmp(setting.name, "Check for updates") == 0) { + enterNewActivity(new OtaUpdateActivity(renderer, mappedInput, [this] { subActivityExitPending = true; })); + } else if (strcmp(setting.name, "Theme") == 0) { + enterNewActivity(new ThemeSelectionActivity(renderer, mappedInput, [this] { subActivityExitPending = true; })); + } + vTaskDelay(50 / portTICK_PERIOD_MS); + } + + SETTINGS.saveToFile(); +} diff --git a/src/activities/settings/SettingsActivity.h b/src/activities/settings/SettingsActivity.h index 821dda42..ed5ee443 100644 --- a/src/activities/settings/SettingsActivity.h +++ b/src/activities/settings/SettingsActivity.h @@ -4,9 +4,9 @@ #include #include -#include -#include +#include "ThemeContext.h" +#include "ThemeManager.h" #include "activities/ActivityWithSubactivity.h" class CrossPointSettings; @@ -16,7 +16,9 @@ class SettingsActivity final : public ActivityWithSubactivity { TaskHandle_t displayTaskHandle = nullptr; SemaphoreHandle_t renderingMutex = nullptr; bool updateRequired = false; - int selectedCategoryIndex = 0; // Currently selected category + bool subActivityExitPending = false; + int selectedCategoryIndex = 0; + int selectedSettingIndex = 0; const std::function onGoHome; static constexpr int categoryCount = 4; @@ -25,7 +27,13 @@ class SettingsActivity final : public ActivityWithSubactivity { static void taskTrampoline(void* param); [[noreturn]] void displayTaskLoop(); void render() const; - void enterCategory(int categoryIndex); + void enterCategoryLegacy(int categoryIndex); + + // Theme support + ThemeEngine::ThemeContext themeContext; + void updateThemeContext(bool fullUpdate = false); + void handleThemeInput(); + void toggleCurrentSetting(); public: explicit SettingsActivity(GfxRenderer& renderer, MappedInputManager& mappedInput, diff --git a/src/activities/settings/ThemeSelectionActivity.cpp b/src/activities/settings/ThemeSelectionActivity.cpp new file mode 100644 index 00000000..0e3b7e5e --- /dev/null +++ b/src/activities/settings/ThemeSelectionActivity.cpp @@ -0,0 +1,183 @@ +#include "ThemeSelectionActivity.h" + +#include +#include +#include + +#include + +#include "CrossPointSettings.h" +#include "MappedInputManager.h" +#include "fontIds.h" + +void ThemeSelectionActivity::taskTrampoline(void* param) { + auto* self = static_cast(param); + self->displayTaskLoop(); +} + +void ThemeSelectionActivity::onEnter() { + Activity::onEnter(); + renderingMutex = xSemaphoreCreateMutex(); + + // Load themes + themeNames.clear(); + // Always add Default + themeNames.push_back("Default"); + + FsFile root = SdMan.open("/themes"); + if (root.isDirectory()) { + FsFile file; + while (file.openNext(&root, O_RDONLY)) { + if (file.isDirectory()) { + char name[256]; + file.getName(name, sizeof(name)); + // Skip hidden folders and "Default" if scans it (already added) + if (name[0] != '.' && std::string(name) != "Default") { + themeNames.push_back(name); + } + } + file.close(); + } + } + root.close(); + + // Find current selection + std::string current = SETTINGS.themeName; + selectedIndex = 0; + for (size_t i = 0; i < themeNames.size(); i++) { + if (themeNames[i] == current) { + selectedIndex = i; + break; + } + } + + updateRequired = true; + xTaskCreate(&ThemeSelectionActivity::taskTrampoline, "ThemeSelTask", 4096, this, 1, &displayTaskHandle); +} + +void ThemeSelectionActivity::onExit() { + Activity::onExit(); + + xSemaphoreTake(renderingMutex, portMAX_DELAY); + if (displayTaskHandle) { + vTaskDelete(displayTaskHandle); + displayTaskHandle = nullptr; + } + vSemaphoreDelete(renderingMutex); + renderingMutex = nullptr; +} + +void ThemeSelectionActivity::loop() { + if (mappedInput.wasPressed(MappedInputManager::Button::Confirm)) { + if (selectedIndex >= 0 && selectedIndex < themeNames.size()) { + std::string selected = themeNames[selectedIndex]; + + // Only reboot if theme actually changed + if (selected != std::string(SETTINGS.themeName)) { + strncpy(SETTINGS.themeName, selected.c_str(), sizeof(SETTINGS.themeName) - 1); + SETTINGS.themeName[sizeof(SETTINGS.themeName) - 1] = '\0'; + SETTINGS.saveToFile(); + + // Show reboot message + renderer.clearScreen(); + renderer.drawCenteredText(UI_12_FONT_ID, renderer.getScreenHeight() / 2 - 20, "Applying theme...", true); + renderer.drawCenteredText(UI_10_FONT_ID, renderer.getScreenHeight() / 2 + 10, "Device will restart", true); + renderer.displayBuffer(); + + // Small delay to ensure display updates + vTaskDelay(500 / portTICK_PERIOD_MS); + + esp_restart(); + return; + } + } + onGoBack(); + return; + } + + if (mappedInput.wasPressed(MappedInputManager::Button::Back)) { + onGoBack(); + return; + } + + if (mappedInput.wasPressed(MappedInputManager::Button::Up) || + mappedInput.wasPressed(MappedInputManager::Button::Left)) { + selectedIndex = (selectedIndex > 0) ? (selectedIndex - 1) : (themeNames.size() - 1); + updateRequired = true; + } else if (mappedInput.wasPressed(MappedInputManager::Button::Down) || + mappedInput.wasPressed(MappedInputManager::Button::Right)) { + selectedIndex = (selectedIndex < themeNames.size() - 1) ? (selectedIndex + 1) : 0; + updateRequired = true; + } +} + +void ThemeSelectionActivity::displayTaskLoop() { + while (true) { + if (updateRequired) { + updateRequired = false; + xSemaphoreTake(renderingMutex, portMAX_DELAY); + render(); + xSemaphoreGive(renderingMutex); + } + vTaskDelay(10 / portTICK_PERIOD_MS); + } +} + +void ThemeSelectionActivity::render() const { + renderer.clearScreen(); + + const auto pageWidth = renderer.getScreenWidth(); + const auto pageHeight = renderer.getScreenHeight(); + + // Header + renderer.drawCenteredText(UI_12_FONT_ID, 15, "Select Theme", true, EpdFontFamily::BOLD); + + // Layout constants + const int entryHeight = 30; + const int startY = 60; + const int maxVisible = (pageHeight - startY - 40) / entryHeight; + + // Viewport calculation + int startIdx = 0; + if (themeNames.size() > maxVisible) { + if (selectedIndex >= maxVisible / 2) { + startIdx = selectedIndex - maxVisible / 2; + } + if (startIdx + maxVisible > themeNames.size()) { + startIdx = themeNames.size() - maxVisible; + } + } + + // Draw Highlight + int visibleIndex = selectedIndex - startIdx; + if (visibleIndex >= 0 && visibleIndex < maxVisible) { + renderer.fillRect(0, startY + visibleIndex * entryHeight - 2, pageWidth - 1, entryHeight); + } + + // Draw List + for (int i = 0; i < maxVisible && (startIdx + i) < themeNames.size(); i++) { + int idx = startIdx + i; + int y = startY + i * entryHeight; + bool isSelected = (idx == selectedIndex); + + std::string displayName = themeNames[idx]; + if (themeNames[idx] == std::string(SETTINGS.themeName)) { + displayName = "* " + displayName; + } + renderer.drawText(UI_10_FONT_ID, 20, y, displayName.c_str(), !isSelected); + } + + // Scrollbar if needed + if (themeNames.size() > maxVisible) { + int barHeight = pageHeight - startY - 40; + int thumbHeight = barHeight * maxVisible / themeNames.size(); + int thumbY = startY + (barHeight - thumbHeight) * startIdx / (themeNames.size() - maxVisible); + renderer.fillRect(pageWidth - 5, startY, 2, barHeight, 0); + renderer.fillRect(pageWidth - 7, thumbY, 6, thumbHeight, 1); + } + + const auto labels = mappedInput.mapLabels("Cancel", "Select", "", ""); + renderer.drawButtonHints(UI_10_FONT_ID, labels.btn1, labels.btn2, labels.btn3, labels.btn4); + + renderer.displayBuffer(); +} diff --git a/src/activities/settings/ThemeSelectionActivity.h b/src/activities/settings/ThemeSelectionActivity.h new file mode 100644 index 00000000..888f9053 --- /dev/null +++ b/src/activities/settings/ThemeSelectionActivity.h @@ -0,0 +1,30 @@ +#pragma once +#include +#include +#include + +#include +#include +#include + +#include "activities/Activity.h" + +class ThemeSelectionActivity final : public Activity { + TaskHandle_t displayTaskHandle = nullptr; + SemaphoreHandle_t renderingMutex = nullptr; + bool updateRequired = false; + int selectedIndex = 0; + std::vector themeNames; + const std::function onGoBack; + + static void taskTrampoline(void* param); + [[noreturn]] void displayTaskLoop(); + void render() const; + + public: + ThemeSelectionActivity(GfxRenderer& renderer, MappedInputManager& mappedInput, const std::function& onGoBack) + : Activity("ThemeSelection", renderer, mappedInput), onGoBack(onGoBack) {} + void onEnter() override; + void onExit() override; + void loop() override; +}; diff --git a/src/main.cpp b/src/main.cpp index 89c4e13c..4f711bf3 100644 --- a/src/main.cpp +++ b/src/main.cpp @@ -15,6 +15,7 @@ #include "KOReaderCredentialStore.h" #include "MappedInputManager.h" #include "RecentBooksStore.h" +#include "ThemeManager.h" #include "activities/boot_sleep/BootActivity.h" #include "activities/boot_sleep/SleepActivity.h" #include "activities/browser/OpdsBookBrowserActivity.h" @@ -150,9 +151,10 @@ void verifyPowerButtonDuration() { // Give the user up to 1000ms to start holding the power button, and must hold for SETTINGS.getPowerButtonDuration() const auto start = millis(); bool abort = false; - // Subtract the current time, because inputManager only starts counting the HeldTime from the first update() - // This way, we remove the time we already took to reach here from the duration, - // assuming the button was held until now from millis()==0 (i.e. device start time). + // Subtract the current time, because inputManager only starts counting the + // HeldTime from the first update() This way, we remove the time we already + // took to reach here from the duration, assuming the button was held until + // now from millis()==0 (i.e. device start time). const uint16_t calibration = start; const uint16_t calibratedPressDuration = (calibration < SETTINGS.getPowerButtonDuration()) ? SETTINGS.getPowerButtonDuration() - calibration : 1; @@ -312,11 +314,18 @@ void setup() { break; } - // First serial output only here to avoid timing inconsistencies for power button press duration verification + // First serial output only here to avoid timing inconsistencies for power + // button press duration verification Serial.printf("[%lu] [ ] Starting CrossPoint version " CROSSPOINT_VERSION "\n", millis()); setupDisplayAndFonts(); + ThemeEngine::ThemeManager::get().begin(); + ThemeEngine::ThemeManager::get().registerFont("UI_12", UI_12_FONT_ID); + ThemeEngine::ThemeManager::get().registerFont("UI_10", UI_10_FONT_ID); + ThemeEngine::ThemeManager::get().registerFont("Small", SMALL_FONT_ID); + ThemeEngine::ThemeManager::get().loadTheme(SETTINGS.themeName); + exitActivity(); enterNewActivity(new BootActivity(renderer, mappedInputManager)); @@ -326,7 +335,8 @@ void setup() { if (APP_STATE.openEpubPath.empty()) { onGoHome(); } else { - // Clear app state to avoid getting into a boot loop if the epub doesn't load + // Clear app state to avoid getting into a boot loop if the epub doesn't + // load const auto path = APP_STATE.openEpubPath; APP_STATE.openEpubPath = ""; APP_STATE.saveToFile(); @@ -350,7 +360,8 @@ void loop() { lastMemPrint = millis(); } - // Check for any user activity (button press or release) or active background work + // Check for any user activity (button press or release) or active background + // work static unsigned long lastActivityTime = millis(); if (gpio.wasAnyPressed() || gpio.wasAnyReleased() || (currentActivity && currentActivity->preventAutoSleep())) { lastActivityTime = millis(); // Reset inactivity timer @@ -386,8 +397,8 @@ void loop() { } // Add delay at the end of the loop to prevent tight spinning - // When an activity requests skip loop delay (e.g., webserver running), use yield() for faster response - // Otherwise, use longer delay to save power + // When an activity requests skip loop delay (e.g., webserver running), use + // yield() for faster response Otherwise, use longer delay to save power if (currentActivity && currentActivity->skipLoopDelay()) { yield(); // Give FreeRTOS a chance to run tasks, but return immediately } else {