diff --git a/lib/GfxRenderer/Bitmap.cpp b/lib/GfxRenderer/Bitmap.cpp index ad25ffcd..0b8a3e65 100644 --- a/lib/GfxRenderer/Bitmap.cpp +++ b/lib/GfxRenderer/Bitmap.cpp @@ -1,147 +1,195 @@ #include "Bitmap.h" - +#include "BitmapHelpers.h" #include #include // ============================================================================ -// 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(); - - 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); - - return static_cast(b0) | (static_cast(b1) << 8) | (static_cast(b2) << 16) | - (static_cast(b3) << 24); +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 char* Bitmap::errorToString(BmpReaderError err) { +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; +} + +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) { switch (err) { - case BmpReaderError::Ok: - return "Ok"; - case BmpReaderError::FileInvalid: - return "FileInvalid"; - case BmpReaderError::SeekStartFailed: - return "SeekStartFailed"; - case BmpReaderError::NotBMP: - return "NotBMP (missing 'BM')"; - case BmpReaderError::DIBTooSmall: - return "DIBTooSmall (<40 bytes)"; - case BmpReaderError::BadPlanes: - return "BadPlanes (!= 1)"; - case BmpReaderError::UnsupportedBpp: - return "UnsupportedBpp (expected 1, 2, 8, 24, or 32)"; - case BmpReaderError::UnsupportedCompression: - return "UnsupportedCompression (expected BI_RGB or BI_BITFIELDS for 32bpp)"; - case BmpReaderError::BadDimensions: - return "BadDimensions"; - case BmpReaderError::ImageTooLarge: - return "ImageTooLarge (max 2048x3072)"; - case BmpReaderError::PaletteTooLarge: - return "PaletteTooLarge"; - - case BmpReaderError::SeekPixelDataFailed: - return "SeekPixelDataFailed"; - case BmpReaderError::BufferTooSmall: - return "BufferTooSmall"; - - case BmpReaderError::OomRowBuffer: - return "OomRowBuffer"; - case BmpReaderError::ShortReadRow: - return "ShortReadRow"; + case BmpReaderError::Ok: + return "Ok"; + case BmpReaderError::FileInvalid: + return "FileInvalid"; + case BmpReaderError::SeekStartFailed: + return "SeekStartFailed"; + case BmpReaderError::NotBMP: + return "NotBMP"; + case BmpReaderError::DIBTooSmall: + return "DIBTooSmall"; + case BmpReaderError::BadPlanes: + return "BadPlanes"; + case BmpReaderError::UnsupportedBpp: + return "UnsupportedBpp"; + case BmpReaderError::UnsupportedCompression: + return "UnsupportedCompression"; + case BmpReaderError::BadDimensions: + return "BadDimensions"; + case BmpReaderError::ImageTooLarge: + 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: + return "ShortReadRow"; } return "Unknown"; } 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); - if (bfType != 0x4D42) return BmpReaderError::NotBMP; + 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); - if (biSize < 40) return BmpReaderError::DIBTooSmall; + 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 bool validBpp = bpp == 1 || bpp == 2 || bpp == 8 || bpp == 24 || bpp == 32; + 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; + if (planes != 1) + return BmpReaderError::BadPlanes; + if (!validBpp) + return BmpReaderError::UnsupportedBpp; + if (!(comp == 0 || (bpp == 32 && comp == 3))) + return BmpReaderError::UnsupportedCompression; - file.seekCur(12); // biSizeImage, biXPelsPerMeter, biYPelsPerMeter - const uint32_t colorsUsed = readLE32(file); - if (colorsUsed > 256u) return BmpReaderError::PaletteTooLarge; - file.seekCur(4); // biClrImportant + seekCur(12); + const uint32_t colorsUsed = readLE32(); + if (colorsUsed > 256u) + return BmpReaderError::PaletteTooLarge; + seekCur(4); - if (width <= 0 || height <= 0) return BmpReaderError::BadDimensions; + 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); + for (int i = 0; i < 256; i++) + paletteLum[i] = static_cast(i); if (colorsUsed > 0) { for (uint32_t i = 0; i < colorsUsed; i++) { uint8_t rgb[4]; - file.read(rgb, 4); // Read B, G, R, Reserved in one go + readBytes(rgb, 4); paletteLum[i] = (77u * rgb[2] + 150u * rgb[1] + 29u * rgb[0]) >> 8; } } - if (!file.seek(bfOffBits)) { + 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 +201,26 @@ 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; +BmpReaderError Bitmap::readNextRow(uint8_t *data, uint8_t *rowBuffer) const { + if (readBytes(rowBuffer, rowBytes) != (size_t)rowBytes) + return BmpReaderError::ShortReadRow; prevRowY += 1; - - uint8_t* outPtr = data; + 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,52 +235,47 @@ 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; - packPixel(lum); - p += 4; - } - break; + case 32: { + const uint8_t *p = rowBuffer; + for (int x = 0; x < width; x++) { + uint8_t lum = (77u * p[2] + 150u * p[1] + 29u * p[0]) >> 8; + packPixel(lum); + p += 4; } - 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; - packPixel(lum); - p += 3; - } - break; + break; + } + case 24: { + const uint8_t *p = rowBuffer; + for (int x = 0; x < width; x++) { + uint8_t lum = (77u * p[2] + 150u * p[1] + 29u * p[0]) >> 8; + packPixel(lum); + p += 3; } - case 8: { - for (int x = 0; x < width; x++) { - packPixel(paletteLum[rowBuffer[x]]); - } - break; + break; + } + case 8: { + for (int x = 0; x < width; x++) + packPixel(paletteLum[rowBuffer[x]]); + break; + } + case 2: { + for (int x = 0; x < width; x++) { + uint8_t lum = + paletteLum[(rowBuffer[x >> 2] >> (6 - ((x & 3) * 2))) & 0x03]; + packPixel(lum); } - case 2: { - for (int x = 0; x < width; x++) { - lum = paletteLum[(rowBuffer[x >> 2] >> (6 - ((x & 3) * 2))) & 0x03]; - packPixel(lum); - } - break; + break; + } + case 1: { + for (int x = 0; x < width; x++) { + const uint8_t palIndex = (rowBuffer[x >> 3] & (0x80 >> (x & 7))) ? 1 : 0; + packPixel(paletteLum[palIndex]); } - 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); - } - break; - } - default: - return BmpReaderError::UnsupportedBpp; + break; + } + default: + return BmpReaderError::UnsupportedBpp; } if (atkinsonDitherer) @@ -245,20 +283,17 @@ 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; - + if (bitShift != 6) + *outPtr = currentOutByte; return BmpReaderError::Ok; } BmpReaderError Bitmap::rewindToData() const { - if (!file.seek(bfOffBits)) { + if (!seekSet(bfOffBits)) return BmpReaderError::SeekPixelDataFailed; - } - - // Reset dithering when rewinding - if (fsDitherer) fsDitherer->reset(); - if (atkinsonDitherer) atkinsonDitherer->reset(); - + 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..595c8d30 100644 --- a/lib/GfxRenderer/Bitmap.h +++ b/lib/GfxRenderer/Bitmap.h @@ -29,14 +29,21 @@ enum class BmpReaderError : uint8_t { }; class Bitmap { - public: - static const char* errorToString(BmpReaderError err); +public: + static const char *errorToString(BmpReaderError err); + + 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) {} - explicit Bitmap(FsFile& file, bool dithering = false) : file(file), dithering(dithering) {} ~Bitmap(); BmpReaderError parseHeaders(); - BmpReaderError readNextRow(uint8_t* data, uint8_t* rowBuffer) const; + 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; } @@ -45,11 +52,22 @@ class Bitmap { bool is1Bit() const { return bpp == 1; } uint16_t getBpp() const { return bpp; } - private: - static uint16_t readLE16(FsFile& f); - static uint32_t readLE32(FsFile& f); +private: + // 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; @@ -60,10 +78,10 @@ class Bitmap { uint8_t paletteLum[256] = {}; // Floyd-Steinberg dithering state (mutable for const methods) - mutable int16_t* errorCurRow = nullptr; - mutable int16_t* errorNextRow = nullptr; - mutable int prevRowY = -1; // Track row progression for error propagation + mutable int16_t *errorCurRow = nullptr; + mutable int16_t *errorNextRow = nullptr; + mutable int prevRowY = -1; // Track row progression for error propagation - mutable AtkinsonDitherer* atkinsonDitherer = nullptr; - mutable FloydSteinbergDitherer* fsDitherer = nullptr; + mutable AtkinsonDitherer *atkinsonDitherer = nullptr; + mutable FloydSteinbergDitherer *fsDitherer = nullptr; }; diff --git a/lib/GfxRenderer/GfxRenderer.cpp b/lib/GfxRenderer/GfxRenderer.cpp index fa1c61c6..a194f740 100644 --- a/lib/GfxRenderer/GfxRenderer.cpp +++ b/lib/GfxRenderer/GfxRenderer.cpp @@ -1,10 +1,14 @@ #include "GfxRenderer.h" #include +#include -void GfxRenderer::insertFont(const int fontId, EpdFontFamily font) { fontMap.insert({fontId, font}); } +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 { +void GfxRenderer::rotateCoordinates(const int x, const int y, int *rotatedX, + int *rotatedY) const { switch (orientation) { case Portrait: { // Logical portrait (480x800) → panel (800x480) @@ -59,13 +63,14 @@ void GfxRenderer::drawPixel(const int x, const int y, const bool state) const { const uint8_t bitPosition = 7 - (rotatedX % 8); // MSB first if (state) { - frameBuffer[byteIndex] &= ~(1 << bitPosition); // Clear bit + frameBuffer[byteIndex] &= ~(1 << bitPosition); // Clear bit } else { - frameBuffer[byteIndex] |= 1 << bitPosition; // Set bit + frameBuffer[byteIndex] |= 1 << bitPosition; // Set bit } } -int GfxRenderer::getTextWidth(const int fontId, const char* text, const EpdFontFamily::Style style) const { +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); return 0; @@ -76,13 +81,15 @@ int GfxRenderer::getTextWidth(const int fontId, const char* text, const EpdFontF return w; } -void GfxRenderer::drawCenteredText(const int fontId, const int y, const char* text, const bool black, +void GfxRenderer::drawCenteredText(const int fontId, const int y, + const char *text, const bool black, const EpdFontFamily::Style style) const { const int x = (getScreenWidth() - getTextWidth(fontId, text, style)) / 2; drawText(fontId, x, y, text, black, style); } -void GfxRenderer::drawText(const int fontId, const int x, const int y, const char* text, const bool black, +void GfxRenderer::drawText(const int fontId, const int x, const int y, + const char *text, const bool black, const EpdFontFamily::Style style) const { const int yPos = y + getFontAscenderSize(fontId); int xpos = x; @@ -104,42 +111,150 @@ void GfxRenderer::drawText(const int fontId, const int x, const int y, const cha } uint32_t cp; - while ((cp = utf8NextCodepoint(reinterpret_cast(&text)))) { + while ((cp = utf8NextCodepoint(reinterpret_cast(&text)))) { renderChar(font, cp, &xpos, &yPos, black, style); } } -void GfxRenderer::drawLine(int x1, int y1, int x2, int y2, const bool state) const { - if (x1 == x2) { - if (y2 < y1) { - std::swap(y1, y2); +void GfxRenderer::drawLine(int x1, int y1, int x2, int y2, + const bool state) const { + // 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()); } } -void GfxRenderer::drawRect(const int x, const int y, const int width, const int height, const bool state) const { +void GfxRenderer::drawRect(const int x, const int y, const int width, + const int height, const bool state) const { drawLine(x, y, x + width - 1, y, state); drawLine(x + width - 1, y, x + width - 1, y + height - 1, state); drawLine(x + width - 1, y + height - 1, x, y + height - 1, state); drawLine(x, y, x, y + height - 1, state); } -void GfxRenderer::fillRect(const int x, const int y, const int width, const int height, const bool state) const { - for (int fillY = y; fillY < y + height; fillY++) { - drawLine(x, fillY, x + width - 1, fillY, state); +void GfxRenderer::fillRect(const int x, const int y, const int width, + const int height, const bool state) const { + uint8_t *frameBuffer = einkDisplay.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 = EInkDisplay::DISPLAY_HEIGHT - 1 - sx; + const uint16_t byteIndex = + physY * EInkDisplay::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 = EInkDisplay::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 * EInkDisplay::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 * EInkDisplay::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); } } @@ -166,9 +281,11 @@ void GfxRenderer::drawImage(const uint8_t bitmap[], const int x, const int y, co display.drawImage(bitmap, rotatedX, rotatedY, width, height); } -void GfxRenderer::drawBitmap(const Bitmap& bitmap, const int x, const int y, const int maxWidth, const int maxHeight, +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,46 +295,50 @@ 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()); + scale = static_cast(maxWidth) / + static_cast((1.0f - cropX) * bitmap.getWidth()); isScaled = true; } if (maxHeight > 0 && (1.0f - cropY) * bitmap.getHeight() > maxHeight) { - scale = std::min(scale, static_cast(maxHeight) / static_cast((1.0f - cropY) * bitmap.getHeight())); + 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())); + 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()); + Serial.printf("[%lu] [GFX] !! Failed to allocate BMP row buffers\n", + millis()); free(outputRow); free(rowBytes); return; } 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. - int screenY = -cropPixY + (bitmap.isTopDown() ? bmpY : bitmap.getHeight() - 1 - 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. + int screenY = + -cropPixY + (bitmap.isTopDown() ? bmpY : bitmap.getHeight() - 1 - bmpY); if (isScaled) { screenY = std::floor(screenY * scale); } - screenY += y; // the offset should not be scaled + screenY += y; // the offset should not be scaled if (screenY >= getScreenHeight()) { break; } if (bitmap.readNextRow(outputRow, rowBytes) != BmpReaderError::Ok) { - Serial.printf("[%lu] [GFX] Failed to read row %d from bitmap\n", millis(), bmpY); + Serial.printf("[%lu] [GFX] Failed to read row %d from bitmap\n", millis(), + bmpY); free(outputRow); free(rowBytes); return; @@ -237,7 +358,7 @@ void GfxRenderer::drawBitmap(const Bitmap& bitmap, const int x, const int y, con if (isScaled) { screenX = std::floor(screenX * scale); } - screenX += x; // the offset should not be scaled + screenX += x; // the offset should not be scaled if (screenX >= getScreenWidth()) { break; } @@ -261,26 +382,170 @@ void GfxRenderer::drawBitmap(const Bitmap& bitmap, const int x, const int y, con free(rowBytes); } -void GfxRenderer::drawBitmap1Bit(const Bitmap& bitmap, const int x, const int y, const int maxWidth, +void GfxRenderer::draw2BitImage(const uint8_t data[], int x, int y, int w, + int h) const { + uint8_t *frameBuffer = einkDisplay.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 < 3 means black pixel in 2-bit representation + if (val < 3) { + // In Portrait: physical Y = DISPLAY_HEIGHT - 1 - screenX + const int physY = EInkDisplay::DISPLAY_HEIGHT - 1 - screenX; + const uint16_t byteIndex = + physY * EInkDisplay::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 = EInkDisplay::DISPLAY_WIDTH - 1 - screenY; + const int physY = screenX; + const uint16_t byteIndex = + physY * EInkDisplay::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 = EInkDisplay::DISPLAY_WIDTH - 1 - screenX; + physY = EInkDisplay::DISPLAY_HEIGHT - 1 - screenY; + } else { + physX = screenX; + physY = screenY; + } + const uint16_t byteIndex = + physY * EInkDisplay::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; bool isScaled = false; if (maxWidth > 0 && bitmap.getWidth() > maxWidth) { - scale = static_cast(maxWidth) / static_cast(bitmap.getWidth()); + scale = + static_cast(maxWidth) / static_cast(bitmap.getWidth()); isScaled = true; } if (maxHeight > 0 && bitmap.getHeight() > maxHeight) { - scale = std::min(scale, static_cast(maxHeight) / static_cast(bitmap.getHeight())); + scale = std::min(scale, static_cast(maxHeight) / + static_cast(bitmap.getHeight())); 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())); + auto *outputRow = static_cast(malloc(outputRowSize)); + auto *rowBytes = static_cast(malloc(bitmap.getRowBytes())); if (!outputRow || !rowBytes) { - Serial.printf("[%lu] [GFX] !! Failed to allocate 1-bit BMP row buffers\n", millis()); + Serial.printf("[%lu] [GFX] !! Failed to allocate 1-bit BMP row buffers\n", + millis()); free(outputRow); free(rowBytes); return; @@ -289,24 +554,29 @@ void GfxRenderer::drawBitmap1Bit(const Bitmap& bitmap, const int x, const int y, for (int bmpY = 0; bmpY < bitmap.getHeight(); bmpY++) { // Read rows sequentially using readNextRow if (bitmap.readNextRow(outputRow, rowBytes) != BmpReaderError::Ok) { - Serial.printf("[%lu] [GFX] Failed to read row %d from 1-bit bitmap\n", millis(), bmpY); + Serial.printf("[%lu] [GFX] Failed to read row %d from 1-bit bitmap\n", + millis(), bmpY); free(outputRow); free(rowBytes); return; } // Calculate screen Y based on whether BMP is top-down or bottom-up - const int bmpYOffset = bitmap.isTopDown() ? bmpY : bitmap.getHeight() - 1 - bmpY; - int screenY = y + (isScaled ? static_cast(std::floor(bmpYOffset * scale)) : bmpYOffset); + const int bmpYOffset = + bitmap.isTopDown() ? bmpY : bitmap.getHeight() - 1 - bmpY; + int screenY = + y + (isScaled ? static_cast(std::floor(bmpYOffset * scale)) + : bmpYOffset); if (screenY >= getScreenHeight()) { - continue; // Continue reading to keep row counter in sync + continue; // Continue reading to keep row counter in sync } if (screenY < 0) { continue; } for (int bmpX = 0; bmpX < bitmap.getWidth(); bmpX++) { - int screenX = x + (isScaled ? static_cast(std::floor(bmpX * scale)) : bmpX); + int screenX = + x + (isScaled ? static_cast(std::floor(bmpX * scale)) : bmpX); if (screenX >= getScreenWidth()) { break; } @@ -330,24 +600,31 @@ void GfxRenderer::drawBitmap1Bit(const Bitmap& bitmap, const int x, const int y, free(rowBytes); } -void GfxRenderer::fillPolygon(const int* xPoints, const int* yPoints, int numPoints, bool state) const { - if (numPoints < 3) return; +void GfxRenderer::fillPolygon(const int *xPoints, const int *yPoints, + int numPoints, bool state) const { + if (numPoints < 3) + return; // Find bounding box int minY = yPoints[0], maxY = yPoints[0]; for (int i = 1; i < numPoints; i++) { - if (yPoints[i] < minY) minY = yPoints[i]; - if (yPoints[i] > maxY) maxY = yPoints[i]; + if (yPoints[i] < minY) + minY = yPoints[i]; + if (yPoints[i] > maxY) + maxY = yPoints[i]; } // Clip to screen - if (minY < 0) minY = 0; - if (maxY >= getScreenHeight()) maxY = getScreenHeight() - 1; + if (minY < 0) + minY = 0; + if (maxY >= getScreenHeight()) + maxY = getScreenHeight() - 1; // Allocate node buffer for scanline algorithm - auto* nodeX = static_cast(malloc(numPoints * sizeof(int))); + auto *nodeX = static_cast(malloc(numPoints * sizeof(int))); if (!nodeX) { - Serial.printf("[%lu] [GFX] !! Failed to allocate polygon node buffer\n", millis()); + Serial.printf("[%lu] [GFX] !! Failed to allocate polygon node buffer\n", + millis()); return; } @@ -358,11 +635,13 @@ void GfxRenderer::fillPolygon(const int* xPoints, const int* yPoints, int numPoi // Find all intersection points with edges int j = numPoints - 1; for (int i = 0; i < numPoints; i++) { - if ((yPoints[i] < scanY && yPoints[j] >= scanY) || (yPoints[j] < scanY && yPoints[i] >= scanY)) { + if ((yPoints[i] < scanY && yPoints[j] >= scanY) || + (yPoints[j] < scanY && yPoints[i] >= scanY)) { // Calculate X intersection using fixed-point to avoid float int dy = yPoints[j] - yPoints[i]; if (dy != 0) { - nodeX[nodes++] = xPoints[i] + (scanY - yPoints[i]) * (xPoints[j] - xPoints[i]) / dy; + nodeX[nodes++] = xPoints[i] + (scanY - yPoints[i]) * + (xPoints[j] - xPoints[i]) / dy; } } j = i; @@ -385,8 +664,10 @@ void GfxRenderer::fillPolygon(const int* xPoints, const int* yPoints, int numPoi int endX = nodeX[i + 1]; // Clip to screen - if (startX < 0) startX = 0; - if (endX >= getScreenWidth()) endX = getScreenWidth() - 1; + if (startX < 0) + startX = 0; + if (endX >= getScreenWidth()) + endX = getScreenWidth() - 1; // Draw horizontal line for (int x = startX; x <= endX; x++) { @@ -398,6 +679,124 @@ 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 { @@ -413,7 +812,8 @@ void GfxRenderer::invertScreen() const { void GfxRenderer::displayBuffer(const HalDisplay::RefreshMode refreshMode) const { display.displayBuffer(refreshMode); } -std::string GfxRenderer::truncatedText(const int fontId, const char* text, const int maxWidth, +std::string GfxRenderer::truncatedText(const int fontId, const char *text, + const int maxWidth, const EpdFontFamily::Style style) const { std::string item = text; int itemWidth = getTextWidth(fontId, item.c_str(), style); @@ -424,7 +824,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: @@ -480,18 +881,19 @@ int GfxRenderer::getLineHeight(const int fontId) const { return fontMap.at(fontId).getData(EpdFontFamily::REGULAR)->advanceY; } -void GfxRenderer::drawButtonHints(const int fontId, const char* btn1, const char* btn2, const char* btn3, - const char* btn4) { +void GfxRenderer::drawButtonHints(const int fontId, const char *btn1, + const char *btn2, const char *btn3, + const char *btn4) { const Orientation orig_orientation = getOrientation(); setOrientation(Orientation::Portrait); const int pageHeight = getScreenHeight(); constexpr int buttonWidth = 106; constexpr int buttonHeight = 40; - constexpr int buttonY = 40; // Distance from bottom - constexpr int textYOffset = 7; // Distance from top of button to text baseline + constexpr int buttonY = 40; // Distance from bottom + constexpr int textYOffset = 7; // Distance from top of button to text baseline constexpr int buttonPositions[] = {25, 130, 245, 350}; - const char* labels[] = {btn1, btn2, btn3, btn4}; + const char *labels[] = {btn1, btn2, btn3, btn4}; for (int i = 0; i < 4; i++) { // Only draw if the label is non-empty @@ -508,37 +910,44 @@ void GfxRenderer::drawButtonHints(const int fontId, const char* btn1, const char setOrientation(orig_orientation); } -void GfxRenderer::drawSideButtonHints(const int fontId, const char* topBtn, const char* bottomBtn) const { +void GfxRenderer::drawSideButtonHints(const int fontId, const char *topBtn, + const char *bottomBtn) const { const int screenWidth = getScreenWidth(); - constexpr int buttonWidth = 40; // Width on screen (height when rotated) - constexpr int buttonHeight = 80; // Height on screen (width when rotated) - constexpr int buttonX = 5; // Distance from right edge + constexpr int buttonWidth = 40; // Width on screen (height when rotated) + constexpr int buttonHeight = 80; // Height on screen (width when rotated) + constexpr int buttonX = 5; // Distance from right edge // Position for the button group - buttons share a border so they're adjacent - constexpr int topButtonY = 345; // Top button position + constexpr int topButtonY = 345; // Top button position - const char* labels[] = {topBtn, bottomBtn}; + const char *labels[] = {topBtn, bottomBtn}; // Draw the shared border for both buttons as one unit const int x = screenWidth - buttonX - buttonWidth; // Draw top button outline (3 sides, bottom open) if (topBtn != nullptr && topBtn[0] != '\0') { - drawLine(x, topButtonY, x + buttonWidth - 1, topButtonY); // Top - drawLine(x, topButtonY, x, topButtonY + buttonHeight - 1); // Left - drawLine(x + buttonWidth - 1, topButtonY, x + buttonWidth - 1, topButtonY + buttonHeight - 1); // Right + 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 + if ((topBtn != nullptr && topBtn[0] != '\0') || + (bottomBtn != nullptr && bottomBtn[0] != '\0')) { + drawLine(x, topButtonY + buttonHeight, x + buttonWidth - 1, + topButtonY + buttonHeight); // Shared border } // Draw bottom button outline (3 sides, top is shared) if (bottomBtn != nullptr && bottomBtn[0] != '\0') { - drawLine(x, topButtonY + buttonHeight, x, topButtonY + 2 * buttonHeight - 1); // Left - drawLine(x + buttonWidth - 1, topButtonY + buttonHeight, x + buttonWidth - 1, - topButtonY + 2 * buttonHeight - 1); // Right - drawLine(x, topButtonY + 2 * buttonHeight - 1, x + buttonWidth - 1, topButtonY + 2 * buttonHeight - 1); // Bottom + drawLine(x, topButtonY + buttonHeight, x, + topButtonY + 2 * buttonHeight - 1); // Left + drawLine(x + buttonWidth - 1, topButtonY + buttonHeight, + x + buttonWidth - 1, + topButtonY + 2 * buttonHeight - 1); // Right + drawLine(x, topButtonY + 2 * buttonHeight - 1, x + buttonWidth - 1, + topButtonY + 2 * buttonHeight - 1); // Bottom } // Draw text for each button @@ -567,7 +976,9 @@ int GfxRenderer::getTextHeight(const int fontId) const { return fontMap.at(fontId).getData(EpdFontFamily::REGULAR)->ascender; } -void GfxRenderer::drawTextRotated90CW(const int fontId, const int x, const int y, const char* text, const bool black, +void GfxRenderer::drawTextRotated90CW(const int fontId, const int x, + const int y, const char *text, + const bool black, const EpdFontFamily::Style style) const { // Cannot draw a NULL / empty string if (text == nullptr || *text == '\0') { @@ -589,11 +1000,11 @@ void GfxRenderer::drawTextRotated90CW(const int fontId, const int x, const int y // Original (glyphX, glyphY) -> Rotated (glyphY, -glyphX) // Text reads from bottom to top - int yPos = y; // Current Y position (decreases as we draw characters) + int yPos = y; // Current Y position (decreases as we draw characters) uint32_t cp; - while ((cp = utf8NextCodepoint(reinterpret_cast(&text)))) { - const EpdGlyph* glyph = font.getGlyph(cp, style); + while ((cp = utf8NextCodepoint(reinterpret_cast(&text)))) { + const EpdGlyph *glyph = font.getGlyph(cp, style); if (!glyph) { glyph = font.getGlyph(REPLACEMENT_GLYPH, style); } @@ -608,7 +1019,7 @@ void GfxRenderer::drawTextRotated90CW(const int fontId, const int x, const int y const int left = glyph->left; const int top = glyph->top; - const uint8_t* bitmap = &font.getData(style)->bitmap[offset]; + const uint8_t *bitmap = &font.getData(style)->bitmap[offset]; if (bitmap != nullptr) { for (int glyphY = 0; glyphY < height; glyphY++) { @@ -618,7 +1029,8 @@ void GfxRenderer::drawTextRotated90CW(const int fontId, const int x, const int y // 90° clockwise rotation transformation: // screenX = x + (ascender - top + glyphY) // screenY = yPos - (left + glyphX) - const int screenX = x + (font.getData(style)->ascender - top + glyphY); + const int screenX = + x + (font.getData(style)->ascender - top + glyphY); const int screenY = yPos - left - glyphX; if (is2Bit) { @@ -628,7 +1040,8 @@ void GfxRenderer::drawTextRotated90CW(const int fontId, const int x, const int y if (renderMode == BW && bmpVal < 3) { drawPixel(screenX, screenY, black); - } else if (renderMode == GRAYSCALE_MSB && (bmpVal == 1 || bmpVal == 2)) { + } else if (renderMode == GRAYSCALE_MSB && + (bmpVal == 1 || bmpVal == 2)) { drawPixel(screenX, screenY, false); } else if (renderMode == GRAYSCALE_LSB && bmpVal == 1) { drawPixel(screenX, screenY, false); @@ -664,7 +1077,7 @@ void GfxRenderer::copyGrayscaleMsbBuffers() const { display.copyGrayscaleMsbBuff void GfxRenderer::displayGrayBuffer() const { display.displayGrayBuffer(); } void GfxRenderer::freeBwBufferChunks() { - for (auto& bwBufferChunk : bwBufferChunks) { + for (auto &bwBufferChunk : bwBufferChunks) { if (bwBufferChunk) { free(bwBufferChunk); bwBufferChunk = nullptr; @@ -674,9 +1087,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,18 +1103,20 @@ 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", + 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; } const size_t offset = i * BW_BUFFER_CHUNK_SIZE; - bwBufferChunks[i] = static_cast(malloc(BW_BUFFER_CHUNK_SIZE)); + bwBufferChunks[i] = static_cast(malloc(BW_BUFFER_CHUNK_SIZE)); if (!bwBufferChunks[i]) { - Serial.printf("[%lu] [GFX] !! Failed to allocate BW buffer chunk %zu (%zu bytes)\n", millis(), i, - BW_BUFFER_CHUNK_SIZE); + Serial.printf( + "[%lu] [GFX] !! Failed to allocate BW buffer chunk %zu (%zu bytes)\n", + millis(), i, BW_BUFFER_CHUNK_SIZE); // Free previously allocated chunks freeBwBufferChunks(); return false; @@ -709,20 +1125,20 @@ bool GfxRenderer::storeBwBuffer() { memcpy(bwBufferChunks[i], frameBuffer + offset, BW_BUFFER_CHUNK_SIZE); } - Serial.printf("[%lu] [GFX] Stored BW buffer in %zu chunks (%zu bytes each)\n", millis(), BW_BUFFER_NUM_CHUNKS, - BW_BUFFER_CHUNK_SIZE); + Serial.printf("[%lu] [GFX] Stored BW buffer in %zu chunks (%zu bytes each)\n", + millis(), BW_BUFFER_NUM_CHUNKS, BW_BUFFER_CHUNK_SIZE); return true; } /** - * 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 bool missingChunks = false; - for (const auto& bwBufferChunk : bwBufferChunks) { + for (const auto &bwBufferChunk : bwBufferChunks) { if (!bwBufferChunk) { missingChunks = true; break; @@ -736,7 +1152,8 @@ void GfxRenderer::restoreBwBuffer() { uint8_t* frameBuffer = display.getFrameBuffer(); if (!frameBuffer) { - Serial.printf("[%lu] [GFX] !! No framebuffer in restoreBwBuffer\n", millis()); + Serial.printf("[%lu] [GFX] !! No framebuffer in restoreBwBuffer\n", + millis()); freeBwBufferChunks(); return; } @@ -744,7 +1161,9 @@ void GfxRenderer::restoreBwBuffer() { for (size_t i = 0; i < BW_BUFFER_NUM_CHUNKS; i++) { // Check if chunk is missing if (!bwBufferChunks[i]) { - Serial.printf("[%lu] [GFX] !! BW buffer chunks not stored - this is likely a bug\n", millis()); + Serial.printf( + "[%lu] [GFX] !! BW buffer chunks not stored - this is likely a bug\n", + millis()); freeBwBufferChunks(); return; } @@ -770,9 +1189,10 @@ void GfxRenderer::cleanupGrayscaleWithFrameBuffer() const { } } -void GfxRenderer::renderChar(const EpdFontFamily& fontFamily, const uint32_t cp, int* x, const int* y, - const bool pixelState, const EpdFontFamily::Style style) const { - const EpdGlyph* glyph = fontFamily.getGlyph(cp, style); +void GfxRenderer::renderChar(const EpdFontFamily &fontFamily, const uint32_t cp, + int *x, const int *y, const bool pixelState, + const EpdFontFamily::Style style) const { + const EpdGlyph *glyph = fontFamily.getGlyph(cp, style); if (!glyph) { glyph = fontFamily.getGlyph(REPLACEMENT_GLYPH, style); } @@ -789,7 +1209,7 @@ void GfxRenderer::renderChar(const EpdFontFamily& fontFamily, const uint32_t cp, const uint8_t height = glyph->height; const int left = glyph->left; - const uint8_t* bitmap = nullptr; + const uint8_t *bitmap = nullptr; bitmap = &fontFamily.getData(style)->bitmap[offset]; if (bitmap != nullptr) { @@ -802,17 +1222,20 @@ 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 + } 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 drawPixel(screenX, screenY, false); } else if (renderMode == GRAYSCALE_LSB && bmpVal == 1) { // Dark gray @@ -833,31 +1256,32 @@ void GfxRenderer::renderChar(const EpdFontFamily& fontFamily, const uint32_t cp, *x += glyph->advanceX; } -void GfxRenderer::getOrientedViewableTRBL(int* outTop, int* outRight, int* outBottom, int* outLeft) const { +void GfxRenderer::getOrientedViewableTRBL(int *outTop, int *outRight, + int *outBottom, int *outLeft) const { switch (orientation) { - case Portrait: - *outTop = VIEWABLE_MARGIN_TOP; - *outRight = VIEWABLE_MARGIN_RIGHT; - *outBottom = VIEWABLE_MARGIN_BOTTOM; - *outLeft = VIEWABLE_MARGIN_LEFT; - break; - case LandscapeClockwise: - *outTop = VIEWABLE_MARGIN_LEFT; - *outRight = VIEWABLE_MARGIN_TOP; - *outBottom = VIEWABLE_MARGIN_RIGHT; - *outLeft = VIEWABLE_MARGIN_BOTTOM; - break; - case PortraitInverted: - *outTop = VIEWABLE_MARGIN_BOTTOM; - *outRight = VIEWABLE_MARGIN_LEFT; - *outBottom = VIEWABLE_MARGIN_TOP; - *outLeft = VIEWABLE_MARGIN_RIGHT; - break; - case LandscapeCounterClockwise: - *outTop = VIEWABLE_MARGIN_RIGHT; - *outRight = VIEWABLE_MARGIN_BOTTOM; - *outBottom = VIEWABLE_MARGIN_LEFT; - *outLeft = VIEWABLE_MARGIN_TOP; - break; + case Portrait: + *outTop = VIEWABLE_MARGIN_TOP; + *outRight = VIEWABLE_MARGIN_RIGHT; + *outBottom = VIEWABLE_MARGIN_BOTTOM; + *outLeft = VIEWABLE_MARGIN_LEFT; + break; + case LandscapeClockwise: + *outTop = VIEWABLE_MARGIN_LEFT; + *outRight = VIEWABLE_MARGIN_TOP; + *outBottom = VIEWABLE_MARGIN_RIGHT; + *outLeft = VIEWABLE_MARGIN_BOTTOM; + break; + case PortraitInverted: + *outTop = VIEWABLE_MARGIN_BOTTOM; + *outRight = VIEWABLE_MARGIN_LEFT; + *outBottom = VIEWABLE_MARGIN_TOP; + *outLeft = VIEWABLE_MARGIN_RIGHT; + break; + case LandscapeCounterClockwise: + *outTop = VIEWABLE_MARGIN_RIGHT; + *outRight = VIEWABLE_MARGIN_BOTTOM; + *outBottom = VIEWABLE_MARGIN_LEFT; + *outLeft = VIEWABLE_MARGIN_TOP; + break; } } diff --git a/lib/GfxRenderer/GfxRenderer.h b/lib/GfxRenderer/GfxRenderer.h index 733975f4..6518c1db 100644 --- a/lib/GfxRenderer/GfxRenderer.h +++ b/lib/GfxRenderer/GfxRenderer.h @@ -8,15 +8,17 @@ #include "Bitmap.h" class GfxRenderer { - public: +public: enum RenderMode { BW, GRAYSCALE_LSB, GRAYSCALE_MSB }; // 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) - PortraitInverted, // 480x800 logical coordinates, inverted - LandscapeCounterClockwise // 800x480 logical coordinates, native panel orientation + Portrait, // 480x800 logical coordinates (current default) + LandscapeClockwise, // 800x480 logical coordinates, rotated 180° (swap + // top/bottom) + PortraitInverted, // 480x800 logical coordinates, inverted + LandscapeCounterClockwise // 800x480 logical coordinates, native panel + // orientation }; private: @@ -28,12 +30,13 @@ class GfxRenderer { HalDisplay& display; RenderMode renderMode; Orientation orientation; - uint8_t* bwBufferChunks[BW_BUFFER_NUM_CHUNKS] = {nullptr}; + uint8_t *bwBufferChunks[BW_BUFFER_NUM_CHUNKS] = {nullptr}; std::map fontMap; - void renderChar(const EpdFontFamily& fontFamily, uint32_t cp, int* x, const int* y, bool pixelState, + void renderChar(const EpdFontFamily &fontFamily, uint32_t cp, int *x, + const int *y, bool pixelState, EpdFontFamily::Style style) const; void freeBwBufferChunks(); - void rotateCoordinates(int x, int y, int* rotatedX, int* rotatedY) const; + void rotateCoordinates(int x, int y, int *rotatedX, int *rotatedY) const; public: explicit GfxRenderer(HalDisplay& halDisplay) : display(halDisplay), renderMode(BW), orientation(Portrait) {} @@ -47,7 +50,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; } @@ -65,47 +69,67 @@ class GfxRenderer { 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 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 fillPolygon(const int* xPoints, const int* yPoints, int numPoints, bool state = true) 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 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, - EpdFontFamily::Style style = EpdFontFamily::REGULAR) const; - void drawText(int fontId, int x, int y, const char* text, bool black = true, + 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, + EpdFontFamily::Style style = EpdFontFamily::REGULAR) const; + void drawText(int fontId, int x, int y, const char *text, bool black = true, EpdFontFamily::Style style = EpdFontFamily::REGULAR) const; int getSpaceWidth(int fontId) const; int getFontAscenderSize(int fontId) const; int getLineHeight(int fontId) const; - std::string truncatedText(int fontId, const char* text, int maxWidth, - EpdFontFamily::Style style = EpdFontFamily::REGULAR) const; + std::string + truncatedText(int fontId, const char *text, int maxWidth, + EpdFontFamily::Style style = EpdFontFamily::REGULAR) const; // UI Components - void drawButtonHints(int fontId, const char* btn1, const char* btn2, const char* btn3, const char* btn4); - void drawSideButtonHints(int fontId, const char* topBtn, const char* bottomBtn) const; + void drawButtonHints(int fontId, const char *btn1, const char *btn2, + const char *btn3, const char *btn4); + void drawSideButtonHints(int fontId, const char *topBtn, + const char *bottomBtn) const; - private: +private: // Helper for drawing rotated text (90 degrees clockwise, for side buttons) - void drawTextRotated90CW(int fontId, int x, int y, const char* text, bool black = true, - EpdFontFamily::Style style = EpdFontFamily::REGULAR) const; + void drawTextRotated90CW( + int fontId, int x, int y, const char *text, bool black = true, + EpdFontFamily::Style style = EpdFontFamily::REGULAR) const; int getTextHeight(int fontId) const; - public: +public: // Grayscale functions void setRenderMode(const RenderMode mode) { this->renderMode = mode; } void copyGrayscaleLsbBuffers() const; void copyGrayscaleMsbBuffers() const; void displayGrayBuffer() const; - bool storeBwBuffer(); // Returns true if buffer was stored successfully - void restoreBwBuffer(); // Restore and free the stored buffer + bool storeBwBuffer(); // Returns true if buffer was stored successfully + void restoreBwBuffer(); // Restore and free the stored buffer void cleanupGrayscaleWithFrameBuffer() const; // Low level functions - uint8_t* getFrameBuffer() const; + uint8_t *getFrameBuffer() const; static size_t getBufferSize(); void grayscaleRevert() const; - void getOrientedViewableTRBL(int* outTop, int* outRight, int* outBottom, int* outLeft) const; + void getOrientedViewableTRBL(int *outTop, int *outRight, int *outBottom, + int *outLeft) const; }; diff --git a/lib/ThemeEngine/include/BasicElements.h b/lib/ThemeEngine/include/BasicElements.h new file mode 100644 index 00000000..fe1784f2 --- /dev/null +++ b/lib/ThemeEngine/include/BasicElements.h @@ -0,0 +1,404 @@ +#pragma once + +#include "ThemeContext.h" +#include "ThemeTypes.h" +#include "UIElement.h" +#include +#include +#include + +namespace ThemeEngine { + +// --- Container --- +class Container : public UIElement { +protected: + std::vector children; + Expression bgColorExpr; + bool hasBg = false; + bool border = false; + Expression borderExpr; // Dynamic border based on expression + +public: + Container(const std::string &id) : UIElement(id) { + bgColorExpr = Expression::parse("0xFF"); + } + virtual ~Container() { + for (auto child : children) + delete child; + } + + Container *asContainer() override { return this; } + + ElementType getType() const override { return ElementType::Container; } + + void addChild(UIElement *child) { children.push_back(child); } + + 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 layout(const ThemeContext &context, int parentX, int parentY, + int parentW, int parentH) override { + UIElement::layout(context, parentX, parentY, parentW, parentH); + for (auto child : children) { + child->layout(context, absX, absY, absW, absH); + } + } + + void markDirty() override { + UIElement::markDirty(); + for (auto child : children) { + child->markDirty(); + } + } + + void draw(const GfxRenderer &renderer, const ThemeContext &context) override { + if (!isVisible(context)) + return; + + if (hasBg) { + std::string colStr = context.evaluatestring(bgColorExpr); + uint8_t color = Color::parse(colStr).value; + renderer.fillRect(absX, absY, absW, absH, color == 0x00); + } + + // Handle dynamic border expression + bool drawBorder = border; + if (hasBorderExpr()) { + drawBorder = context.evaluateBool(borderExpr.rawExpr); + } + + if (drawBorder) { + renderer.drawRect(absX, absY, absW, absH, true); + } + + for (auto child : children) { + child->draw(renderer, context); + } + + markClean(); + } +}; + +// --- Rectangle --- +class Rectangle : public UIElement { + bool fill = false; + Expression fillExpr; // Dynamic fill based on expression + Expression colorExpr; + +public: + Rectangle(const std::string &id) : UIElement(id) { + colorExpr = Expression::parse("0x00"); + } + ElementType getType() const override { return ElementType::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 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); + + bool shouldFill = fill; + if (!fillExpr.empty()) { + shouldFill = context.evaluateBool(fillExpr.rawExpr); + } + + if (shouldFill) { + renderer.fillRect(absX, absY, absW, absH, black); + } else { + renderer.drawRect(absX, absY, absW, absH, black); + } + + markClean(); + } +}; + +// --- 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: + Label(const std::string &id) : UIElement(id) { + colorExpr = Expression::parse("0x00"); + } + ElementType getType() const override { return ElementType::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 { + if (!isVisible(context)) + return; + + std::string finalText = context.evaluatestring(textExpr); + if (finalText.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, finalText.c_str()); + int lineHeight = renderer.getLineHeight(fontId); + + // Truncate if needed + if (ellipsis && textWidth > absW && absW > 0) { + finalText = renderer.truncatedText(fontId, finalText.c_str(), absW); + textWidth = renderer.getTextWidth(fontId, finalText.c_str()); + } + + int drawX = absX; + int drawY = absY; + + // Vertical centering + if (absH > 0 && lineHeight > 0) { + drawY = absY + (absH - lineHeight) / 2; + } + + // Horizontal alignment + if (alignment == Alignment::Center && absW > 0) { + drawX = absX + (absW - textWidth) / 2; + } else if (alignment == Alignment::Right && absW > 0) { + drawX = absX + absW - textWidth; + } + + // Bounds check + if (drawX + textWidth > renderer.getScreenWidth()) { + markClean(); + return; + } + + renderer.drawText(fontId, drawX, drawY, finalText.c_str(), black); + markClean(); + } +}; + +// --- BitmapElement --- +class BitmapElement : public UIElement { + Expression srcExpr; + bool scaleToFit = true; + bool preserveAspect = true; + +public: + BitmapElement(const std::string &id) : UIElement(id) { + cacheable = true; // Bitmaps benefit from caching + } + ElementType getType() const override { return ElementType::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 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: + 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; } + + 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 = valStr.empty() ? 0 : std::stoi(valStr); + int maxVal = maxStr.empty() ? 100 : std::stoi(maxStr); + 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: + Divider(const std::string &id) : UIElement(id) { + colorExpr = Expression::parse("0x00"); + } + + ElementType getType() const override { return ElementType::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(); + } +}; + +} // namespace ThemeEngine diff --git a/lib/ThemeEngine/include/DefaultTheme.h b/lib/ThemeEngine/include/DefaultTheme.h new file mode 100644 index 00000000..fe4d820d --- /dev/null +++ b/lib/ThemeEngine/include/DefaultTheme.h @@ -0,0 +1,367 @@ +#pragma once + +constexpr const char *DEFAULT_THEME_INI = R"( +; ============================================ +; Default Theme for CrossPoint Reader +; ============================================ + +[Global] +FontUI12 = UI_12 +FontUI10 = UI_10 + +; ============================================ +; HOME SCREEN +; ============================================ + +[Home] +Type = Container +X = 0 +Y = 0 +Width = 100% +Height = 100% +Color = white + +; --- Status Bar --- +[StatusBar] +Parent = Home +Type = Container +X = 0 +Y = 10 +Width = 100% +Height = 24 + +[BatteryIcon] +Parent = StatusBar +Type = Icon +Src = battery +X = 400 +Width = 28 +Height = 18 +Color = black + +[BatteryLabel] +Parent = StatusBar +Type = Label +Font = UI_10 +Text = {BatteryPercent}% +X = 432 +Width = 48 +Height = 20 + +; --- Recent Books Section --- +[RecentBooksSection] +Parent = Home +Type = Container +X = 0 +Y = 45 +Width = 100% +Height = 260 +Visible = {HasRecentBooks} + +[RecentBooksList] +Parent = RecentBooksSection +Type = List +Source = RecentBooks +ItemTemplate = RecentBookItem +X = 15 +Y = 0 +Width = 450 +Height = 260 +Direction = Horizontal +ItemWidth = 145 +Spacing = 10 + +; --- Recent Book Item Template --- +[RecentBookItem] +Type = Container +Width = 145 +Height = 250 + +[BookCoverContainer] +Parent = RecentBookItem +Type = Container +X = 0 +Y = 0 +Width = 145 +Height = 195 +Border = {Item.Selected} + +[BookCoverImage] +Parent = BookCoverContainer +Type = Bitmap +X = 2 +Y = 2 +Width = 141 +Height = 191 +Src = {Item.Image} +Cacheable = true + +[BookProgressBadge] +Parent = BookCoverContainer +Type = Badge +X = 5 +Y = 168 +Text = {Item.Progress}% +Font = UI_10 +BgColor = black +FgColor = white +PaddingH = 6 +PaddingV = 2 + +[BookTitleLabel] +Parent = RecentBookItem +Type = Label +Font = UI_10 +Text = {Item.Title} +X = 0 +Y = 200 +Width = 145 +Height = 40 +Ellipsis = true + +; --- No Recent Books State --- +[EmptyBooksMessage] +Parent = Home +Type = Label +Font = UI_12 +Text = No recent books +Centered = true +X = 0 +Y = 100 +Width = 480 +Height = 30 +Visible = {!HasRecentBooks} + +[EmptyBooksSub] +Parent = Home +Type = Label +Font = UI_10 +Text = Open a book to start reading +Centered = true +X = 0 +Y = 130 +Width = 480 +Height = 30 +Visible = {!HasRecentBooks} + +; --- Main Menu (2-column grid) --- +[MainMenuList] +Parent = Home +Type = List +Source = MainMenu +ItemTemplate = MainMenuItem +X = 15 +Y = 330 +Width = 450 +Height = 350 +Columns = 2 +ItemHeight = 70 +Spacing = 20 + +; --- Menu Item Template --- +[MainMenuItem] +Type = HStack +Width = 210 +Height = 65 +Spacing = 12 +CenterVertical = true +Border = {Item.Selected} + +[MenuItemIcon] +Parent = MainMenuItem +Type = Icon +Src = {Item.Icon} +Width = 36 +Height = 36 +Color = black + +[MenuItemLabel] +Parent = MainMenuItem +Type = Label +Font = UI_12 +Text = {Item.Title} +Width = 150 +Height = 40 +Color = black + +; --- Bottom Hint Bar --- +[HintBar] +Parent = Home +Type = HStack +X = 60 +Y = 760 +Width = 360 +Height = 30 +Spacing = 80 +CenterVertical = true + +[HintSelect] +Parent = HintBar +Type = Icon +Src = check +Width = 24 +Height = 24 + +[HintUp] +Parent = HintBar +Type = Icon +Src = up +Width = 24 +Height = 24 + +[HintDown] +Parent = HintBar +Type = Icon +Src = down +Width = 24 +Height = 24 + +; ============================================ +; SETTINGS SCREEN +; ============================================ + +[Settings] +Type = Container +X = 0 +Y = 0 +Width = 100% +Height = 100% +Color = white + +[SettingsTitle] +Parent = Settings +Type = Label +Font = UI_12 +Text = Settings +X = 15 +Y = 15 +Width = 200 +Height = 30 + +[SettingsTabBar] +Parent = Settings +Type = TabBar +X = 0 +Y = 50 +Width = 100% +Height = 40 +Selected = {SelectedTab} +IndicatorHeight = 3 +ShowIndicator = true + +[TabReading] +Parent = SettingsTabBar +Type = Label +Font = UI_10 +Text = Reading +Centered = true +Height = 35 + +[TabControls] +Parent = SettingsTabBar +Type = Label +Font = UI_10 +Text = Controls +Centered = true +Height = 35 + +[TabDisplay] +Parent = SettingsTabBar +Type = Label +Font = UI_10 +Text = Display +Centered = true +Height = 35 + +[TabSystem] +Parent = SettingsTabBar +Type = Label +Font = UI_10 +Text = System +Centered = true +Height = 35 + +[SettingsList] +Parent = Settings +Type = List +Source = SettingsItems +ItemTemplate = SettingsItem +X = 0 +Y = 95 +Width = 450 +Height = 650 +ItemHeight = 50 +Spacing = 0 + +[SettingsScrollIndicator] +Parent = Settings +Type = ScrollIndicator +X = 460 +Y = 100 +Width = 15 +Height = 640 +Position = {ScrollPosition} +Total = {TotalItems} +VisibleCount = {VisibleItems} +TrackWidth = 4 + +; --- Settings Item Template --- +[SettingsItem] +Type = Container +Width = 450 +Height = 48 +Border = false + +[SettingsItemBg] +Parent = SettingsItem +Type = Rectangle +X = 0 +Y = 0 +Width = 450 +Height = 45 +Fill = {Item.Selected} +Color = black + +[SettingsItemLabel] +Parent = SettingsItem +Type = Label +Font = UI_10 +Text = {Item.Title} +X = 15 +Y = 0 +Width = 250 +Height = 45 +Color = {Item.Selected ? white : black} + +[SettingsItemValue] +Parent = SettingsItem +Type = Label +Font = UI_10 +Text = {Item.Value} +X = 270 +Y = 0 +Width = 120 +Height = 45 +Align = Right +Color = {Item.Selected ? white : black} + +[SettingsItemToggle] +Parent = SettingsItem +Type = Toggle +X = 390 +Y = 8 +Width = 50 +Height = 30 +Value = {Item.ToggleValue} +Visible = {Item.HasToggle} + +[SettingsItemDivider] +Parent = SettingsItem +Type = Divider +X = 15 +Y = 46 +Width = 420 +Height = 1 +Horizontal = true +Color = 0x80 +)"; diff --git a/lib/ThemeEngine/include/IniParser.h b/lib/ThemeEngine/include/IniParser.h new file mode 100644 index 00000000..5e27f6ed --- /dev/null +++ b/lib/ThemeEngine/include/IniParser.h @@ -0,0 +1,40 @@ +#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..337c3a00 --- /dev/null +++ b/lib/ThemeEngine/include/LayoutElements.h @@ -0,0 +1,543 @@ +#pragma once + +#include "BasicElements.h" +#include "ThemeContext.h" +#include "ThemeTypes.h" +#include "UIElement.h" +#include + +namespace ThemeEngine { + +// --- HStack: Horizontal Stack Layout --- +// Children are arranged horizontally with optional spacing +class HStack : public Container { + int spacing = 0; // Gap between children + int padding = 0; // Internal padding + bool centerVertical = false; + +public: + HStack(const std::string &id) : Container(id) {} + ElementType getType() const override { return ElementType::HStack; } + + void setSpacing(int s) { + spacing = s; + markDirty(); + } + void setPadding(int p) { + padding = p; + markDirty(); + } + void setCenterVertical(bool c) { + centerVertical = c; + 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(); + + // Re-layout with proper position + int childY = absY + padding; + if (centerVertical && childH < availableH) { + childY = absY + padding + (availableH - childH) / 2; + } + + 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 { + int spacing = 0; + int padding = 0; + bool centerHorizontal = false; + +public: + VStack(const std::string &id) : Container(id) {} + ElementType getType() const override { return ElementType::VStack; } + + void setSpacing(int s) { + spacing = s; + markDirty(); + } + void setPadding(int p) { + padding = p; + markDirty(); + } + void setCenterHorizontal(bool c) { + centerHorizontal = c; + 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(); + + int childX = absX + padding; + if (centerHorizontal && childW < availableW) { + childX = absX + padding + (availableW - childW) / 2; + } + + 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; } + + 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; + + int availableW = absW - 2 * padding - (columns - 1) * colSpacing; + int cellW = availableW / columns; + 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 >= columns) { + 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 cornerRadius = 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; } + + 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 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 + int textW = renderer.getTextWidth(fontId, text.c_str()); + int textH = renderer.getLineHeight(fontId); + int badgeW = textW + 2 * paddingH; + int badgeH = textH + 2 * paddingV; + + // Use absX, absY as position, but we may auto-size + if (absW == 0) + absW = badgeW; + if (absH == 0) + absH = badgeH; + + // Draw background + std::string bgStr = context.evaluatestring(bgColorExpr); + uint8_t bgColor = Color::parse(bgStr).value; + renderer.fillRect(absX, absY, absW, absH, bgColor == 0x00); + + // Draw border for contrast + renderer.drawRect(absX, absY, absW, absH, bgColor != 0x00); + + // Draw text centered + std::string fgStr = context.evaluatestring(fgColorExpr); + uint8_t fgColor = Color::parse(fgStr).value; + int textX = absX + (absW - textW) / 2; + int textY = absY + (absH - textH) / 2; + renderer.drawText(fontId, textX, textY, text.c_str(), fgColor == 0x00); + + markClean(); + } +}; + +// --- Toggle: On/Off Switch --- +class Toggle : public UIElement { + Expression valueExpr; // Boolean expression + Expression onColorExpr; + Expression offColorExpr; + int trackWidth = 44; + int trackHeight = 24; + int knobSize = 20; + +public: + Toggle(const std::string &id) : UIElement(id) { + valueExpr = Expression::parse("false"); + onColorExpr = Expression::parse("0x00"); // Black when on + offColorExpr = Expression::parse("0xFF"); // White when off + } + + ElementType getType() const override { return ElementType::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 setTrackWidth(int w) { + trackWidth = w; + markDirty(); + } + void setTrackHeight(int h) { + trackHeight = h; + markDirty(); + } + void setKnobSize(int s) { + knobSize = s; + markDirty(); + } + + void draw(const GfxRenderer &renderer, const ThemeContext &context) override { + if (!isVisible(context)) + return; + + bool isOn = context.evaluateBool(valueExpr.rawExpr); + + // Get colors + std::string colorStr = + isOn ? context.evaluatestring(onColorExpr) : context.evaluatestring(offColorExpr); + uint8_t trackColor = Color::parse(colorStr).value; + + // Draw track + int trackX = absX; + int trackY = absY + (absH - trackHeight) / 2; + renderer.fillRect(trackX, trackY, trackWidth, trackHeight, trackColor == 0x00); + renderer.drawRect(trackX, trackY, trackWidth, trackHeight, true); + + // Draw knob + int knobMargin = (trackHeight - knobSize) / 2; + int knobX = isOn ? (trackX + trackWidth - knobSize - knobMargin) + : (trackX + knobMargin); + int knobY = trackY + knobMargin; + + // Knob is opposite color of track + renderer.fillRect(knobX, knobY, knobSize, knobSize, trackColor != 0x00); + 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; } + + 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 = 0; + if (!selStr.empty()) { + // Try to parse as number + try { + selectedIdx = std::stoi(selStr); + } catch (...) { + selectedIdx = 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); + } + } + + // Draw bottom border + renderer.drawLine(absX, absY + absH - 1, absX + absW - 1, absY + absH - 1, 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; } + + 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; } + + 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 = posStr.empty() ? 0 : std::stof(posStr); + int total = totalStr.empty() ? 1 : std::stoi(totalStr); + int visible = visStr.empty() ? 1 : std::stoi(visStr); + + 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..2bb9e42e --- /dev/null +++ b/lib/ThemeEngine/include/ListElement.h @@ -0,0 +1,147 @@ +#pragma once + +#include "BasicElements.h" +#include "UIElement.h" +#include +#include + +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; } + + 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 parent dimensions to get its height + if (itemTemplate && itemHeight == 0) { + itemTemplate->layout(context, absX, absY, absW, 0); + } + } + + // 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..42c40361 --- /dev/null +++ b/lib/ThemeEngine/include/ThemeContext.h @@ -0,0 +1,382 @@ +#pragma once + +#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(); + } + +public: + 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; } + + 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 ""; + } + + // 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 = expr.substr(1, expr.size() - 2); + } + + // Handle negation + if (!expr.empty() && expr[0] == '!') { + return !evaluateBool(expr.substr(1)); + } + + // Handle parentheses + if (!expr.empty() && expr[0] == '(') { + int depth = 1; + size_t closePos = 1; + while (closePos < expr.length() && depth > 0) { + if (expr[closePos] == '(') + depth++; + if (expr[closePos] == ')') + depth--; + closePos++; + } + if (closePos <= expr.length()) { + std::string inner = expr.substr(1, closePos - 2); + std::string rest = trim(expr.substr(closePos)); + bool innerResult = evaluateBool(inner); + + // Check for && or || + if (rest.length() >= 2 && rest.substr(0, 2) == "&&") { + return innerResult && evaluateBool(rest.substr(2)); + } + if (rest.length() >= 2 && rest.substr(0, 2) == "||") { + return innerResult || evaluateBool(rest.substr(2)); + } + return innerResult; + } + } + + // Handle && and || (lowest precedence) + size_t andPos = expr.find("&&"); + size_t orPos = expr.find("||"); + + // Process || first (lower precedence than &&) + if (orPos != std::string::npos && + (andPos == std::string::npos || orPos < andPos)) { + return evaluateBool(expr.substr(0, orPos)) || + evaluateBool(expr.substr(orPos + 2)); + } + if (andPos != std::string::npos) { + return evaluateBool(expr.substr(0, andPos)) && + evaluateBool(expr.substr(andPos + 2)); + } + + // Handle comparisons + size_t eqPos = expr.find("=="); + if (eqPos != std::string::npos) { + std::string left = trim(expr.substr(0, eqPos)); + std::string right = trim(expr.substr(eqPos + 2)); + return compareValues(left, right) == 0; + } + + size_t nePos = expr.find("!="); + if (nePos != std::string::npos) { + std::string left = trim(expr.substr(0, nePos)); + std::string right = trim(expr.substr(nePos + 2)); + return compareValues(left, right) != 0; + } + + size_t gePos = expr.find(">="); + if (gePos != std::string::npos) { + std::string left = trim(expr.substr(0, gePos)); + std::string right = trim(expr.substr(gePos + 2)); + return compareValues(left, right) >= 0; + } + + size_t lePos = expr.find("<="); + if (lePos != std::string::npos) { + std::string left = trim(expr.substr(0, lePos)); + std::string right = trim(expr.substr(lePos + 2)); + return compareValues(left, right) <= 0; + } + + size_t gtPos = expr.find('>'); + if (gtPos != std::string::npos) { + std::string left = trim(expr.substr(0, gtPos)); + std::string right = trim(expr.substr(gtPos + 1)); + return compareValues(left, right) > 0; + } + + size_t ltPos = expr.find('<'); + if (ltPos != std::string::npos) { + std::string left = trim(expr.substr(0, ltPos)); + std::string right = trim(expr.substr(ltPos + 1)); + return compareValues(left, right) < 0; + } + + // Simple variable lookup + return getBool(expr, false); + } + + // 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) && isNumber(rightVal)) { + int l = std::stoi(leftVal); + int r = std::stoi(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 (v.size() > 2 && v[0] == '0' && (v[1] == 'x' || v[1] == 'X')) { + 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") { + return v; + } + + // Try to look up as variable + if (hasKey(v)) { + return getAnyAsString(v); + } + + // 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..3ddac3aa --- /dev/null +++ b/lib/ThemeEngine/include/ThemeManager.h @@ -0,0 +1,124 @@ +#pragma once + +#include "BasicElements.h" +#include "IniParser.h" +#include "ThemeContext.h" +#include +#include +#include +#include + +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() { + if (buffer) { + free(buffer); + buffer = nullptr; + } + } + + void invalidate() { valid = false; } +}; + +class ThemeManager { +private: + std::map elements; // All elements by ID + std::string currentThemeName; + 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; } + + // 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); + 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..554c582f --- /dev/null +++ b/lib/ThemeEngine/include/ThemeTypes.h @@ -0,0 +1,85 @@ +#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); + + if (str.back() == '%') { + return Dimension(std::stoi(str.substr(0, str.length() - 1)), + DimensionUnit::PERCENT); + } + return Dimension(std::stoi(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 + + 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)); + } + return Color((uint8_t)std::stoi(str)); + } +}; + +// 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..db2393c4 --- /dev/null +++ b/lib/ThemeEngine/include/UIElement.h @@ -0,0 +1,204 @@ +#pragma once + +#include "ThemeContext.h" +#include "ThemeTypes.h" +#include +#include +#include + +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; + + // 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; + } + + 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) { + 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, + ScrollIndicator + }; + + virtual ElementType getType() const { return ElementType::Base; } + + 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..8ea71a36 --- /dev/null +++ b/lib/ThemeEngine/src/BasicElements.cpp @@ -0,0 +1,228 @@ +#include "BasicElements.h" +#include "Bitmap.h" +#include "ListElement.h" +#include "ThemeManager.h" +#include "ThemeTypes.h" + +namespace ThemeEngine { + +// --- 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; + } + + // 1. Try Processed Cache in ThemeManager (keyed by path + target dimensions) + const ProcessedAsset *processed = + ThemeManager::get().getProcessedAsset(path, renderer.getOrientation(), absW, absH); + if (processed && processed->w == absW && processed->h == absH) { + renderer.draw2BitImage(processed->data.data(), absX, absY, absW, absH); + markClean(); + return; + } + + // 2. Try raw asset cache, then process and cache + const std::vector *data = ThemeManager::get().getCachedAsset(path); + if (!data || data->empty()) { + markClean(); + return; + } + + Bitmap bmp(data->data(), data->size()); + if (bmp.parseHeaders() != BmpReaderError::Ok) { + markClean(); + return; + } + + // Draw the bitmap (handles scaling internally) + renderer.drawBitmap(bmp, absX, absY, absW, absH); + + // After drawing, capture the rendered region and cache it for next time + ProcessedAsset asset; + asset.w = absW; + asset.h = absH; + asset.orientation = renderer.getOrientation(); + + // Capture the rendered region from framebuffer + uint8_t *frameBuffer = renderer.getFrameBuffer(); + if (frameBuffer) { + const int screenW = renderer.getScreenWidth(); + const int bytesPerRow = (absW + 3) / 4; + asset.data.resize(bytesPerRow * absH); + + for (int y = 0; y < absH; y++) { + int srcOffset = ((absY + y) * screenW + absX) / 4; + int dstOffset = y * bytesPerRow; + // Copy 2-bit packed pixels + for (int x = 0; x < absW; x++) { + int sx = absX + x; + int srcByteIdx = ((absY + y) * screenW + sx) / 4; + int srcBitIdx = (sx % 4) * 2; + int dstByteIdx = dstOffset + x / 4; + int dstBitIdx = (x % 4) * 2; + + uint8_t pixel = (frameBuffer[srcByteIdx] >> (6 - srcBitIdx)) & 0x03; + asset.data[dstByteIdx] &= ~(0x03 << (6 - dstBitIdx)); + asset.data[dstByteIdx] |= (pixel << (6 - dstBitIdx)); + } + } + + 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(); + + // 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 + if (layoutMode == LayoutMode::Grid && columns > 1) { + int totalSpacing = (columns - 1) * spacing; + itemW = (absW - totalSpacing) / columns; + } + + for (int i = 0; i < count; ++i) { + // Create item context with scoped variables + ThemeContext itemContext(&context); + std::string prefix = source + "." + std::to_string(i) + "."; + + // Standard list item variables + itemContext.setString("Item.Title", context.getString(prefix + "Title")); + itemContext.setString("Item.Value", context.getString(prefix + "Value")); + itemContext.setBool("Item.Selected", context.getBool(prefix + "Selected")); + 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); + + // 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; + } + + // 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; + } + + // Create item context with scoped variables + ThemeContext itemContext(&context); + std::string prefix = source + "." + std::to_string(i) + "."; + + itemContext.setString("Item.Title", context.getString(prefix + "Title")); + itemContext.setString("Item.Value", context.getString(prefix + "Value")); + itemContext.setBool("Item.Selected", context.getBool(prefix + "Selected")); + 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); + + // 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..3d44cf54 --- /dev/null +++ b/lib/ThemeEngine/src/IniParser.cpp @@ -0,0 +1,104 @@ +#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; + // stream check not strictly possible like file, can rely on available() + + std::string currentSection = ""; + String line; // Use Arduino String for easy file reading, then convert to + // std::string + + while (stream.available()) { + line = stream.readStringUntil('\n'); + std::string sLine = line.c_str(); + trim(sLine); + + if (sLine.empty() || sLine[0] == ';' || sLine[0] == '#') { + continue; // Skip comments and empty lines + } + + 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..5fd04d5a --- /dev/null +++ b/lib/ThemeEngine/src/LayoutElements.cpp @@ -0,0 +1,173 @@ +#include "LayoutElements.h" +#include "ThemeManager.h" +#include + +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; + bool black = (color == 0x00); + + // Use absW/absH if set, otherwise use iconSize + int w = absW > 0 ? absW : iconSize; + int h = absH > 0 ? absH : iconSize; + int cx = absX + w / 2; + int cy = absY + h / 2; + + // Check if it's a path to a BMP file + if (iconName.find('/') != std::string::npos || + iconName.find('.') != std::string::npos) { + // Try to load as bitmap + std::string path = iconName; + if (path[0] != '/') { + path = ThemeManager::get().getAssetPath(iconName); + } + + const std::vector *data = ThemeManager::get().getCachedAsset(path); + if (data && !data->empty()) { + Bitmap bmp(data->data(), data->size()); + if (bmp.parseHeaders() == BmpReaderError::Ok) { + renderer.drawBitmap(bmp, absX, absY, w, h); + markClean(); + return; + } + } + } + + // Built-in icons (simple geometric shapes) + 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 = absX + (w - bw) / 2; + int by = absY + (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 = absX + (w - fw) / 2; + int fy = absY + (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, absY, t, r - ir, black); + renderer.fillRect(cx - t / 2, cy + r, t, r - ir, black); + renderer.fillRect(absX, 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 = absX + 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 = absX + (w - dw) / 2; + int dy = absY + (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 = absX + (w - bw) / 2; + int by = absY + (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 + int x1 = absX + w / 4; + int y1 = cy; + int x2 = cx; + int y2 = absY + h * 3 / 4; + int x3 = absX + w * 3 / 4; + int y3 = absY + 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 { + // Unknown icon - draw placeholder + renderer.drawRect(absX, absY, w, h, black); + renderer.drawLine(absX, absY, absX + w - 1, absY + h - 1, black); + renderer.drawLine(absX + w - 1, absY, absX, absY + 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..c18fec94 --- /dev/null +++ b/lib/ThemeEngine/src/ThemeManager.cpp @@ -0,0 +1,582 @@ +#include "ThemeManager.h" +#include "DefaultTheme.h" +#include "LayoutElements.h" +#include "ListElement.h" +#include +#include +#include +#include + +namespace ThemeEngine { + +void ThemeManager::begin() { + // Default fonts or setup +} + +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 assets + 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); + + 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