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