mirror of
https://github.com/daveallie/crosspoint-reader.git
synced 2026-02-04 22:57:50 +03:00
refactor: Enhance Bitmap handling and introduce ThemeEngine
- Refactored Bitmap class to improve memory management and streamline methods. - Introduced ThemeEngine with foundational classes for UI elements, layout management, and theme parsing. - Added support for dynamic themes and improved rendering capabilities in the HomeActivity and settings screens. This update lays the groundwork for a more flexible theming system, allowing for easier customization and management of UI elements across the application.
This commit is contained in:
parent
da4d3b5ea5
commit
374f1a1106
@ -1,45 +1,92 @@
|
||||
#include "Bitmap.h"
|
||||
|
||||
#include "BitmapHelpers.h"
|
||||
#include <cstdlib>
|
||||
#include <cstring>
|
||||
|
||||
// ============================================================================
|
||||
// IMAGE PROCESSING OPTIONS - Toggle these to test different configurations
|
||||
// ============================================================================
|
||||
// Note: For cover images, dithering is done in JpegToBmpConverter.cpp
|
||||
// This file handles BMP reading - use simple quantization to avoid double-dithering
|
||||
constexpr bool USE_ATKINSON = true; // Use Atkinson dithering instead of Floyd-Steinberg
|
||||
// IMAGE PROCESSING OPTIONS
|
||||
// ============================================================================
|
||||
constexpr bool USE_ATKINSON = true;
|
||||
|
||||
Bitmap::~Bitmap() {
|
||||
delete[] errorCurRow;
|
||||
delete[] errorNextRow;
|
||||
|
||||
delete atkinsonDitherer;
|
||||
delete fsDitherer;
|
||||
}
|
||||
|
||||
uint16_t Bitmap::readLE16(FsFile& f) {
|
||||
const int c0 = f.read();
|
||||
const int c1 = f.read();
|
||||
const auto b0 = static_cast<uint8_t>(c0 < 0 ? 0 : c0);
|
||||
const auto b1 = static_cast<uint8_t>(c1 < 0 ? 0 : c1);
|
||||
return static_cast<uint16_t>(b0) | (static_cast<uint16_t>(b1) << 8);
|
||||
// ===================================
|
||||
// IO Helpers
|
||||
// ===================================
|
||||
|
||||
int Bitmap::readByte() const {
|
||||
if (file && *file) {
|
||||
return file->read();
|
||||
} else if (memoryBuffer) {
|
||||
if (bufferPos < memorySize) {
|
||||
return memoryBuffer[bufferPos++];
|
||||
}
|
||||
return -1;
|
||||
}
|
||||
return -1;
|
||||
}
|
||||
|
||||
uint32_t Bitmap::readLE32(FsFile& f) {
|
||||
const int c0 = f.read();
|
||||
const int c1 = f.read();
|
||||
const int c2 = f.read();
|
||||
const int c3 = f.read();
|
||||
size_t Bitmap::readBytes(void *buf, size_t count) const {
|
||||
if (file && *file) {
|
||||
return file->read(buf, count);
|
||||
} else if (memoryBuffer) {
|
||||
size_t available = memorySize - bufferPos;
|
||||
if (count > available)
|
||||
count = available;
|
||||
memcpy(buf, memoryBuffer + bufferPos, count);
|
||||
bufferPos += count;
|
||||
return count;
|
||||
}
|
||||
return 0;
|
||||
}
|
||||
|
||||
const auto b0 = static_cast<uint8_t>(c0 < 0 ? 0 : c0);
|
||||
const auto b1 = static_cast<uint8_t>(c1 < 0 ? 0 : c1);
|
||||
const auto b2 = static_cast<uint8_t>(c2 < 0 ? 0 : c2);
|
||||
const auto b3 = static_cast<uint8_t>(c3 < 0 ? 0 : c3);
|
||||
bool Bitmap::seekSet(uint32_t pos) const {
|
||||
if (file && *file) {
|
||||
return file->seek(pos);
|
||||
} else if (memoryBuffer) {
|
||||
if (pos <= memorySize) {
|
||||
bufferPos = pos;
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
return static_cast<uint32_t>(b0) | (static_cast<uint32_t>(b1) << 8) | (static_cast<uint32_t>(b2) << 16) |
|
||||
(static_cast<uint32_t>(b3) << 24);
|
||||
bool Bitmap::seekCur(int32_t offset) const {
|
||||
if (file && *file) {
|
||||
return file->seekCur(offset);
|
||||
} else if (memoryBuffer) {
|
||||
if (bufferPos + offset <= memorySize) {
|
||||
bufferPos += offset;
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
uint16_t Bitmap::readLE16() {
|
||||
const int c0 = readByte();
|
||||
const int c1 = readByte();
|
||||
return static_cast<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) {
|
||||
@ -51,27 +98,25 @@ const char* Bitmap::errorToString(BmpReaderError err) {
|
||||
case BmpReaderError::SeekStartFailed:
|
||||
return "SeekStartFailed";
|
||||
case BmpReaderError::NotBMP:
|
||||
return "NotBMP (missing 'BM')";
|
||||
return "NotBMP";
|
||||
case BmpReaderError::DIBTooSmall:
|
||||
return "DIBTooSmall (<40 bytes)";
|
||||
return "DIBTooSmall";
|
||||
case BmpReaderError::BadPlanes:
|
||||
return "BadPlanes (!= 1)";
|
||||
return "BadPlanes";
|
||||
case BmpReaderError::UnsupportedBpp:
|
||||
return "UnsupportedBpp (expected 1, 2, 8, 24, or 32)";
|
||||
return "UnsupportedBpp";
|
||||
case BmpReaderError::UnsupportedCompression:
|
||||
return "UnsupportedCompression (expected BI_RGB or BI_BITFIELDS for 32bpp)";
|
||||
return "UnsupportedCompression";
|
||||
case BmpReaderError::BadDimensions:
|
||||
return "BadDimensions";
|
||||
case BmpReaderError::ImageTooLarge:
|
||||
return "ImageTooLarge (max 2048x3072)";
|
||||
return "ImageTooLarge";
|
||||
case BmpReaderError::PaletteTooLarge:
|
||||
return "PaletteTooLarge";
|
||||
|
||||
case BmpReaderError::SeekPixelDataFailed:
|
||||
return "SeekPixelDataFailed";
|
||||
case BmpReaderError::BufferTooSmall:
|
||||
return "BufferTooSmall";
|
||||
|
||||
case BmpReaderError::OomRowBuffer:
|
||||
return "OomRowBuffer";
|
||||
case BmpReaderError::ShortReadRow:
|
||||
@ -81,67 +126,70 @@ const char* Bitmap::errorToString(BmpReaderError err) {
|
||||
}
|
||||
|
||||
BmpReaderError Bitmap::parseHeaders() {
|
||||
if (!file) return BmpReaderError::FileInvalid;
|
||||
if (!file.seek(0)) return BmpReaderError::SeekStartFailed;
|
||||
if (!file && !memoryBuffer)
|
||||
return BmpReaderError::FileInvalid;
|
||||
if (!seekSet(0))
|
||||
return BmpReaderError::SeekStartFailed;
|
||||
|
||||
// --- BMP FILE HEADER ---
|
||||
const uint16_t bfType = readLE16(file);
|
||||
if (bfType != 0x4D42) return BmpReaderError::NotBMP;
|
||||
const uint16_t bfType = readLE16();
|
||||
if (bfType != 0x4D42)
|
||||
return BmpReaderError::NotBMP;
|
||||
|
||||
file.seekCur(8);
|
||||
bfOffBits = readLE32(file);
|
||||
seekCur(8);
|
||||
bfOffBits = readLE32();
|
||||
|
||||
// --- DIB HEADER ---
|
||||
const uint32_t biSize = readLE32(file);
|
||||
if (biSize < 40) return BmpReaderError::DIBTooSmall;
|
||||
const uint32_t biSize = readLE32();
|
||||
if (biSize < 40)
|
||||
return BmpReaderError::DIBTooSmall;
|
||||
|
||||
width = static_cast<int32_t>(readLE32(file));
|
||||
const auto rawHeight = static_cast<int32_t>(readLE32(file));
|
||||
width = static_cast<int32_t>(readLE32());
|
||||
const auto rawHeight = static_cast<int32_t>(readLE32());
|
||||
topDown = rawHeight < 0;
|
||||
height = topDown ? -rawHeight : rawHeight;
|
||||
|
||||
const uint16_t planes = readLE16(file);
|
||||
bpp = readLE16(file);
|
||||
const uint32_t comp = readLE32(file);
|
||||
const bool validBpp = bpp == 1 || bpp == 2 || bpp == 8 || bpp == 24 || bpp == 32;
|
||||
const uint16_t planes = readLE16();
|
||||
bpp = readLE16();
|
||||
const uint32_t comp = readLE32();
|
||||
const bool validBpp =
|
||||
bpp == 1 || bpp == 2 || bpp == 8 || bpp == 24 || bpp == 32;
|
||||
|
||||
if (planes != 1) return BmpReaderError::BadPlanes;
|
||||
if (!validBpp) return BmpReaderError::UnsupportedBpp;
|
||||
// Allow BI_RGB (0) for all, and BI_BITFIELDS (3) for 32bpp which is common for BGRA masks.
|
||||
if (!(comp == 0 || (bpp == 32 && comp == 3))) return BmpReaderError::UnsupportedCompression;
|
||||
if (planes != 1)
|
||||
return BmpReaderError::BadPlanes;
|
||||
if (!validBpp)
|
||||
return BmpReaderError::UnsupportedBpp;
|
||||
if (!(comp == 0 || (bpp == 32 && comp == 3)))
|
||||
return BmpReaderError::UnsupportedCompression;
|
||||
|
||||
file.seekCur(12); // biSizeImage, biXPelsPerMeter, biYPelsPerMeter
|
||||
const uint32_t colorsUsed = readLE32(file);
|
||||
if (colorsUsed > 256u) return BmpReaderError::PaletteTooLarge;
|
||||
file.seekCur(4); // biClrImportant
|
||||
seekCur(12);
|
||||
const uint32_t colorsUsed = readLE32();
|
||||
if (colorsUsed > 256u)
|
||||
return BmpReaderError::PaletteTooLarge;
|
||||
seekCur(4);
|
||||
|
||||
if (width <= 0 || height <= 0) return BmpReaderError::BadDimensions;
|
||||
if (width <= 0 || height <= 0)
|
||||
return BmpReaderError::BadDimensions;
|
||||
|
||||
// Safety limits to prevent memory issues on ESP32
|
||||
constexpr int MAX_IMAGE_WIDTH = 2048;
|
||||
constexpr int MAX_IMAGE_HEIGHT = 3072;
|
||||
if (width > MAX_IMAGE_WIDTH || height > MAX_IMAGE_HEIGHT) {
|
||||
return BmpReaderError::ImageTooLarge;
|
||||
}
|
||||
|
||||
// Pre-calculate Row Bytes to avoid doing this every row
|
||||
rowBytes = (width * bpp + 31) / 32 * 4;
|
||||
|
||||
for (int i = 0; i < 256; i++) paletteLum[i] = static_cast<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++) {
|
||||
uint8_t rgb[4];
|
||||
file.read(rgb, 4); // Read B, G, R, Reserved in one go
|
||||
readBytes(rgb, 4);
|
||||
paletteLum[i] = (77u * rgb[2] + 150u * rgb[1] + 29u * rgb[0]) >> 8;
|
||||
}
|
||||
}
|
||||
|
||||
if (!file.seek(bfOffBits)) {
|
||||
if (!seekSet(bfOffBits))
|
||||
return BmpReaderError::SeekPixelDataFailed;
|
||||
}
|
||||
|
||||
// Create ditherer if enabled (only for 2-bit output)
|
||||
// Use OUTPUT dimensions for dithering (after prescaling)
|
||||
if (bpp > 2 && dithering) {
|
||||
if (USE_ATKINSON) {
|
||||
atkinsonDitherer = new AtkinsonDitherer(width);
|
||||
@ -153,31 +201,26 @@ BmpReaderError Bitmap::parseHeaders() {
|
||||
return BmpReaderError::Ok;
|
||||
}
|
||||
|
||||
// packed 2bpp output, 0 = black, 1 = dark gray, 2 = light gray, 3 = white
|
||||
BmpReaderError Bitmap::readNextRow(uint8_t *data, uint8_t *rowBuffer) const {
|
||||
// Note: rowBuffer should be pre-allocated by the caller to size 'rowBytes'
|
||||
if (file.read(rowBuffer, rowBytes) != rowBytes) return BmpReaderError::ShortReadRow;
|
||||
if (readBytes(rowBuffer, rowBytes) != (size_t)rowBytes)
|
||||
return BmpReaderError::ShortReadRow;
|
||||
|
||||
prevRowY += 1;
|
||||
|
||||
uint8_t *outPtr = data;
|
||||
uint8_t currentOutByte = 0;
|
||||
int bitShift = 6;
|
||||
int currentX = 0;
|
||||
|
||||
// Helper lambda to pack 2bpp color into the output stream
|
||||
auto packPixel = [&](const uint8_t lum) {
|
||||
uint8_t color;
|
||||
if (atkinsonDitherer) {
|
||||
color = atkinsonDitherer->processPixel(adjustPixel(lum), currentX);
|
||||
color = atkinsonDitherer->processPixel(lum, currentX);
|
||||
} else if (fsDitherer) {
|
||||
color = fsDitherer->processPixel(adjustPixel(lum), currentX);
|
||||
color = fsDitherer->processPixel(lum, currentX);
|
||||
} else {
|
||||
if (bpp > 2) {
|
||||
// Simple quantization or noise dithering
|
||||
color = quantize(adjustPixel(lum), currentX, prevRowY);
|
||||
} else {
|
||||
// do not quantize 2bpp image
|
||||
color = static_cast<uint8_t>(lum >> 6);
|
||||
}
|
||||
}
|
||||
@ -192,13 +235,11 @@ BmpReaderError Bitmap::readNextRow(uint8_t* data, uint8_t* rowBuffer) const {
|
||||
currentX++;
|
||||
};
|
||||
|
||||
uint8_t lum;
|
||||
|
||||
switch (bpp) {
|
||||
case 32: {
|
||||
const uint8_t *p = rowBuffer;
|
||||
for (int x = 0; x < width; x++) {
|
||||
lum = (77u * p[2] + 150u * p[1] + 29u * p[0]) >> 8;
|
||||
uint8_t lum = (77u * p[2] + 150u * p[1] + 29u * p[0]) >> 8;
|
||||
packPixel(lum);
|
||||
p += 4;
|
||||
}
|
||||
@ -207,32 +248,29 @@ BmpReaderError Bitmap::readNextRow(uint8_t* data, uint8_t* rowBuffer) const {
|
||||
case 24: {
|
||||
const uint8_t *p = rowBuffer;
|
||||
for (int x = 0; x < width; x++) {
|
||||
lum = (77u * p[2] + 150u * p[1] + 29u * p[0]) >> 8;
|
||||
uint8_t lum = (77u * p[2] + 150u * p[1] + 29u * p[0]) >> 8;
|
||||
packPixel(lum);
|
||||
p += 3;
|
||||
}
|
||||
break;
|
||||
}
|
||||
case 8: {
|
||||
for (int x = 0; x < width; x++) {
|
||||
for (int x = 0; x < width; x++)
|
||||
packPixel(paletteLum[rowBuffer[x]]);
|
||||
}
|
||||
break;
|
||||
}
|
||||
case 2: {
|
||||
for (int x = 0; x < width; x++) {
|
||||
lum = paletteLum[(rowBuffer[x >> 2] >> (6 - ((x & 3) * 2))) & 0x03];
|
||||
uint8_t lum =
|
||||
paletteLum[(rowBuffer[x >> 2] >> (6 - ((x & 3) * 2))) & 0x03];
|
||||
packPixel(lum);
|
||||
}
|
||||
break;
|
||||
}
|
||||
case 1: {
|
||||
for (int x = 0; x < width; x++) {
|
||||
// Get palette index (0 or 1) from bit at position x
|
||||
const uint8_t palIndex = (rowBuffer[x >> 3] & (0x80 >> (x & 7))) ? 1 : 0;
|
||||
// Use palette lookup for proper black/white mapping
|
||||
lum = paletteLum[palIndex];
|
||||
packPixel(lum);
|
||||
packPixel(paletteLum[palIndex]);
|
||||
}
|
||||
break;
|
||||
}
|
||||
@ -245,20 +283,17 @@ BmpReaderError Bitmap::readNextRow(uint8_t* data, uint8_t* rowBuffer) const {
|
||||
else if (fsDitherer)
|
||||
fsDitherer->nextRow();
|
||||
|
||||
// Flush remaining bits if width is not a multiple of 4
|
||||
if (bitShift != 6) *outPtr = currentOutByte;
|
||||
|
||||
if (bitShift != 6)
|
||||
*outPtr = currentOutByte;
|
||||
return BmpReaderError::Ok;
|
||||
}
|
||||
|
||||
BmpReaderError Bitmap::rewindToData() const {
|
||||
if (!file.seek(bfOffBits)) {
|
||||
if (!seekSet(bfOffBits))
|
||||
return BmpReaderError::SeekPixelDataFailed;
|
||||
}
|
||||
|
||||
// Reset dithering when rewinding
|
||||
if (fsDitherer) fsDitherer->reset();
|
||||
if (atkinsonDitherer) atkinsonDitherer->reset();
|
||||
|
||||
if (fsDitherer)
|
||||
fsDitherer->reset();
|
||||
if (atkinsonDitherer)
|
||||
atkinsonDitherer->reset();
|
||||
return BmpReaderError::Ok;
|
||||
}
|
||||
|
||||
@ -32,11 +32,18 @@ class Bitmap {
|
||||
public:
|
||||
static const char *errorToString(BmpReaderError err);
|
||||
|
||||
explicit Bitmap(FsFile& file, bool dithering = false) : file(file), dithering(dithering) {}
|
||||
explicit Bitmap(FsFile &file, bool dithering = false)
|
||||
: file(&file), dithering(dithering) {}
|
||||
explicit Bitmap(const uint8_t *buffer, size_t size, bool dithering = false)
|
||||
: file(nullptr), memoryBuffer(buffer), memorySize(size),
|
||||
dithering(dithering) {}
|
||||
|
||||
~Bitmap();
|
||||
BmpReaderError parseHeaders();
|
||||
BmpReaderError readNextRow(uint8_t *data, uint8_t *rowBuffer) const;
|
||||
BmpReaderError rewindToData() const;
|
||||
|
||||
// Getters
|
||||
int getWidth() const { return width; }
|
||||
int getHeight() const { return height; }
|
||||
bool isTopDown() const { return topDown; }
|
||||
@ -46,10 +53,21 @@ class Bitmap {
|
||||
uint16_t getBpp() const { return bpp; }
|
||||
|
||||
private:
|
||||
static uint16_t readLE16(FsFile& f);
|
||||
static uint32_t readLE32(FsFile& f);
|
||||
// Internal IO helpers
|
||||
int readByte() const;
|
||||
size_t readBytes(void *buf, size_t count) const;
|
||||
bool seekSet(uint32_t pos) const;
|
||||
bool seekCur(int32_t offset) const; // Only needed for skip?
|
||||
|
||||
uint16_t readLE16();
|
||||
uint32_t readLE32();
|
||||
|
||||
// Source (one is valid)
|
||||
FsFile *file = nullptr;
|
||||
const uint8_t *memoryBuffer = nullptr;
|
||||
size_t memorySize = 0;
|
||||
mutable size_t bufferPos = 0;
|
||||
|
||||
FsFile& file;
|
||||
bool dithering = false;
|
||||
int width = 0;
|
||||
int height = 0;
|
||||
|
||||
@ -1,10 +1,14 @@
|
||||
#include "GfxRenderer.h"
|
||||
|
||||
#include <Utf8.h>
|
||||
#include <algorithm>
|
||||
|
||||
void GfxRenderer::insertFont(const int fontId, EpdFontFamily font) { fontMap.insert({fontId, font}); }
|
||||
void GfxRenderer::insertFont(const int fontId, EpdFontFamily font) {
|
||||
fontMap.insert({fontId, font});
|
||||
}
|
||||
|
||||
void GfxRenderer::rotateCoordinates(const int x, const int y, int* rotatedX, int* rotatedY) const {
|
||||
void GfxRenderer::rotateCoordinates(const int x, const int y, int *rotatedX,
|
||||
int *rotatedY) const {
|
||||
switch (orientation) {
|
||||
case Portrait: {
|
||||
// Logical portrait (480x800) → panel (800x480)
|
||||
@ -65,7 +69,8 @@ void GfxRenderer::drawPixel(const int x, const int y, const bool state) const {
|
||||
}
|
||||
}
|
||||
|
||||
int GfxRenderer::getTextWidth(const int fontId, const char* text, const EpdFontFamily::Style style) const {
|
||||
int GfxRenderer::getTextWidth(const int fontId, const char *text,
|
||||
const EpdFontFamily::Style style) const {
|
||||
if (fontMap.count(fontId) == 0) {
|
||||
Serial.printf("[%lu] [GFX] Font %d not found\n", millis(), fontId);
|
||||
return 0;
|
||||
@ -76,13 +81,15 @@ int GfxRenderer::getTextWidth(const int fontId, const char* text, const EpdFontF
|
||||
return w;
|
||||
}
|
||||
|
||||
void GfxRenderer::drawCenteredText(const int fontId, const int y, const char* text, const bool black,
|
||||
void GfxRenderer::drawCenteredText(const int fontId, const int y,
|
||||
const char *text, const bool black,
|
||||
const EpdFontFamily::Style style) const {
|
||||
const int x = (getScreenWidth() - getTextWidth(fontId, text, style)) / 2;
|
||||
drawText(fontId, x, y, text, black, style);
|
||||
}
|
||||
|
||||
void GfxRenderer::drawText(const int fontId, const int x, const int y, const char* text, const bool black,
|
||||
void GfxRenderer::drawText(const int fontId, const int x, const int y,
|
||||
const char *text, const bool black,
|
||||
const EpdFontFamily::Style style) const {
|
||||
const int yPos = y + getFontAscenderSize(fontId);
|
||||
int xpos = x;
|
||||
@ -109,37 +116,145 @@ void GfxRenderer::drawText(const int fontId, const int x, const int y, const cha
|
||||
}
|
||||
}
|
||||
|
||||
void GfxRenderer::drawLine(int x1, int y1, int x2, int y2, const bool state) const {
|
||||
if (x1 == x2) {
|
||||
if (y2 < y1) {
|
||||
std::swap(y1, y2);
|
||||
void GfxRenderer::drawLine(int x1, int y1, int x2, int y2,
|
||||
const bool state) const {
|
||||
// Bresenham's line algorithm
|
||||
int dx = abs(x2 - x1);
|
||||
int dy = abs(y2 - y1);
|
||||
int sx = (x1 < x2) ? 1 : -1;
|
||||
int sy = (y1 < y2) ? 1 : -1;
|
||||
int err = dx - dy;
|
||||
|
||||
while (true) {
|
||||
drawPixel(x1, y1, state);
|
||||
|
||||
if (x1 == x2 && y1 == y2) break;
|
||||
|
||||
int e2 = 2 * err;
|
||||
if (e2 > -dy) {
|
||||
err -= dy;
|
||||
x1 += sx;
|
||||
}
|
||||
for (int y = y1; y <= y2; y++) {
|
||||
drawPixel(x1, y, state);
|
||||
if (e2 < dx) {
|
||||
err += dx;
|
||||
y1 += sy;
|
||||
}
|
||||
} else if (y1 == y2) {
|
||||
if (x2 < x1) {
|
||||
std::swap(x1, x2);
|
||||
}
|
||||
for (int x = x1; x <= x2; x++) {
|
||||
drawPixel(x, y1, state);
|
||||
}
|
||||
} else {
|
||||
// TODO: Implement
|
||||
Serial.printf("[%lu] [GFX] Line drawing not supported\n", millis());
|
||||
}
|
||||
}
|
||||
|
||||
void GfxRenderer::drawRect(const int x, const int y, const int width, const int height, const bool state) const {
|
||||
void GfxRenderer::drawRect(const int x, const int y, const int width,
|
||||
const int height, const bool state) const {
|
||||
drawLine(x, y, x + width - 1, y, state);
|
||||
drawLine(x + width - 1, y, x + width - 1, y + height - 1, state);
|
||||
drawLine(x + width - 1, y + height - 1, x, y + height - 1, state);
|
||||
drawLine(x, y, x, y + height - 1, state);
|
||||
}
|
||||
|
||||
void GfxRenderer::fillRect(const int x, const int y, const int width, const int height, const bool state) const {
|
||||
for (int fillY = y; fillY < y + height; fillY++) {
|
||||
drawLine(x, fillY, x + width - 1, fillY, state);
|
||||
void GfxRenderer::fillRect(const int x, const int y, const int width,
|
||||
const int height, const bool state) const {
|
||||
uint8_t *frameBuffer = einkDisplay.getFrameBuffer();
|
||||
if (!frameBuffer) {
|
||||
return;
|
||||
}
|
||||
|
||||
const int screenWidth = getScreenWidth();
|
||||
const int screenHeight = getScreenHeight();
|
||||
|
||||
// Clip to screen bounds
|
||||
const int x1 = std::max(0, x);
|
||||
const int y1 = std::max(0, y);
|
||||
const int x2 = std::min(screenWidth - 1, x + width - 1);
|
||||
const int y2 = std::min(screenHeight - 1, y + height - 1);
|
||||
|
||||
if (x1 > x2 || y1 > y2)
|
||||
return;
|
||||
|
||||
// Optimized path for Portrait mode (most common)
|
||||
if (orientation == Portrait) {
|
||||
for (int sy = y1; sy <= y2; sy++) {
|
||||
// In Portrait: logical (x, y) -> physical (y, DISPLAY_HEIGHT - 1 - x)
|
||||
const int physX = sy;
|
||||
const uint8_t physXByte = physX / 8;
|
||||
const uint8_t physXBit = 7 - (physX % 8);
|
||||
const uint8_t mask = 1 << physXBit;
|
||||
|
||||
for (int sx = x1; sx <= x2; sx++) {
|
||||
const int physY = EInkDisplay::DISPLAY_HEIGHT - 1 - sx;
|
||||
const uint16_t byteIndex =
|
||||
physY * EInkDisplay::DISPLAY_WIDTH_BYTES + physXByte;
|
||||
|
||||
if (state) {
|
||||
frameBuffer[byteIndex] &= ~mask; // Black
|
||||
} else {
|
||||
frameBuffer[byteIndex] |= mask; // White
|
||||
}
|
||||
}
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
// Optimized path for PortraitInverted
|
||||
if (orientation == PortraitInverted) {
|
||||
for (int sy = y1; sy <= y2; sy++) {
|
||||
const int physX = EInkDisplay::DISPLAY_WIDTH - 1 - sy;
|
||||
const uint8_t physXByte = physX / 8;
|
||||
const uint8_t physXBit = 7 - (physX % 8);
|
||||
const uint8_t mask = 1 << physXBit;
|
||||
|
||||
for (int sx = x1; sx <= x2; sx++) {
|
||||
const int physY = sx;
|
||||
const uint16_t byteIndex =
|
||||
physY * EInkDisplay::DISPLAY_WIDTH_BYTES + physXByte;
|
||||
|
||||
if (state) {
|
||||
frameBuffer[byteIndex] &= ~mask;
|
||||
} else {
|
||||
frameBuffer[byteIndex] |= mask;
|
||||
}
|
||||
}
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
// Optimized horizontal line fill for Landscape modes
|
||||
if (orientation == LandscapeCounterClockwise) {
|
||||
for (int sy = y1; sy <= y2; sy++) {
|
||||
const int physY = sy;
|
||||
const uint16_t rowOffset = physY * EInkDisplay::DISPLAY_WIDTH_BYTES;
|
||||
|
||||
// Fill full bytes where possible
|
||||
const int physX1 = x1;
|
||||
const int physX2 = x2;
|
||||
const int byteStart = physX1 / 8;
|
||||
const int byteEnd = physX2 / 8;
|
||||
|
||||
for (int bx = byteStart; bx <= byteEnd; bx++) {
|
||||
uint8_t mask = 0xFF;
|
||||
|
||||
// Mask out bits before start on first byte
|
||||
if (bx == byteStart) {
|
||||
const int startBit = physX1 % 8;
|
||||
mask &= (0xFF >> startBit);
|
||||
}
|
||||
// Mask out bits after end on last byte
|
||||
if (bx == byteEnd) {
|
||||
const int endBit = physX2 % 8;
|
||||
mask &= (0xFF << (7 - endBit));
|
||||
}
|
||||
|
||||
if (state) {
|
||||
frameBuffer[rowOffset + bx] &= ~mask;
|
||||
} else {
|
||||
frameBuffer[rowOffset + bx] |= mask;
|
||||
}
|
||||
}
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
// Fallback for LandscapeClockwise and any other cases
|
||||
for (int fillY = y1; fillY <= y2; fillY++) {
|
||||
drawLine(x1, fillY, x2, fillY, state);
|
||||
}
|
||||
}
|
||||
|
||||
@ -166,9 +281,11 @@ void GfxRenderer::drawImage(const uint8_t bitmap[], const int x, const int y, co
|
||||
display.drawImage(bitmap, rotatedX, rotatedY, width, height);
|
||||
}
|
||||
|
||||
void GfxRenderer::drawBitmap(const Bitmap& bitmap, const int x, const int y, const int maxWidth, const int maxHeight,
|
||||
void GfxRenderer::drawBitmap(const Bitmap &bitmap, const int x, const int y,
|
||||
const int maxWidth, const int maxHeight,
|
||||
const float cropX, const float cropY) const {
|
||||
// For 1-bit bitmaps, use optimized 1-bit rendering path (no crop support for 1-bit)
|
||||
// For 1-bit bitmaps, use optimized 1-bit rendering path (no crop support for
|
||||
// 1-bit)
|
||||
if (bitmap.is1Bit() && cropX == 0.0f && cropY == 0.0f) {
|
||||
drawBitmap1Bit(bitmap, x, y, maxWidth, maxHeight);
|
||||
return;
|
||||
@ -178,36 +295,39 @@ void GfxRenderer::drawBitmap(const Bitmap& bitmap, const int x, const int y, con
|
||||
bool isScaled = false;
|
||||
int cropPixX = std::floor(bitmap.getWidth() * cropX / 2.0f);
|
||||
int cropPixY = std::floor(bitmap.getHeight() * cropY / 2.0f);
|
||||
Serial.printf("[%lu] [GFX] Cropping %dx%d by %dx%d pix, is %s\n", millis(), bitmap.getWidth(), bitmap.getHeight(),
|
||||
cropPixX, cropPixY, bitmap.isTopDown() ? "top-down" : "bottom-up");
|
||||
|
||||
if (maxWidth > 0 && (1.0f - cropX) * bitmap.getWidth() > maxWidth) {
|
||||
scale = static_cast<float>(maxWidth) / static_cast<float>((1.0f - cropX) * bitmap.getWidth());
|
||||
scale = static_cast<float>(maxWidth) /
|
||||
static_cast<float>((1.0f - cropX) * bitmap.getWidth());
|
||||
isScaled = true;
|
||||
}
|
||||
if (maxHeight > 0 && (1.0f - cropY) * bitmap.getHeight() > maxHeight) {
|
||||
scale = std::min(scale, static_cast<float>(maxHeight) / static_cast<float>((1.0f - cropY) * bitmap.getHeight()));
|
||||
scale = std::min(
|
||||
scale, static_cast<float>(maxHeight) /
|
||||
static_cast<float>((1.0f - cropY) * bitmap.getHeight()));
|
||||
isScaled = true;
|
||||
}
|
||||
Serial.printf("[%lu] [GFX] Scaling by %f - %s\n", millis(), scale, isScaled ? "scaled" : "not scaled");
|
||||
|
||||
// Calculate output row size (2 bits per pixel, packed into bytes)
|
||||
// IMPORTANT: Use int, not uint8_t, to avoid overflow for images > 1020 pixels wide
|
||||
// IMPORTANT: Use int, not uint8_t, to avoid overflow for images > 1020 pixels
|
||||
// wide
|
||||
const int outputRowSize = (bitmap.getWidth() + 3) / 4;
|
||||
auto *outputRow = static_cast<uint8_t *>(malloc(outputRowSize));
|
||||
auto *rowBytes = static_cast<uint8_t *>(malloc(bitmap.getRowBytes()));
|
||||
|
||||
if (!outputRow || !rowBytes) {
|
||||
Serial.printf("[%lu] [GFX] !! Failed to allocate BMP row buffers\n", millis());
|
||||
Serial.printf("[%lu] [GFX] !! Failed to allocate BMP row buffers\n",
|
||||
millis());
|
||||
free(outputRow);
|
||||
free(rowBytes);
|
||||
return;
|
||||
}
|
||||
|
||||
for (int bmpY = 0; bmpY < (bitmap.getHeight() - cropPixY); bmpY++) {
|
||||
// The BMP's (0, 0) is the bottom-left corner (if the height is positive, top-left if negative).
|
||||
// Screen's (0, 0) is the top-left corner.
|
||||
int screenY = -cropPixY + (bitmap.isTopDown() ? bmpY : bitmap.getHeight() - 1 - bmpY);
|
||||
// The BMP's (0, 0) is the bottom-left corner (if the height is positive,
|
||||
// top-left if negative). Screen's (0, 0) is the top-left corner.
|
||||
int screenY =
|
||||
-cropPixY + (bitmap.isTopDown() ? bmpY : bitmap.getHeight() - 1 - bmpY);
|
||||
if (isScaled) {
|
||||
screenY = std::floor(screenY * scale);
|
||||
}
|
||||
@ -217,7 +337,8 @@ void GfxRenderer::drawBitmap(const Bitmap& bitmap, const int x, const int y, con
|
||||
}
|
||||
|
||||
if (bitmap.readNextRow(outputRow, rowBytes) != BmpReaderError::Ok) {
|
||||
Serial.printf("[%lu] [GFX] Failed to read row %d from bitmap\n", millis(), bmpY);
|
||||
Serial.printf("[%lu] [GFX] Failed to read row %d from bitmap\n", millis(),
|
||||
bmpY);
|
||||
free(outputRow);
|
||||
free(rowBytes);
|
||||
return;
|
||||
@ -261,26 +382,170 @@ void GfxRenderer::drawBitmap(const Bitmap& bitmap, const int x, const int y, con
|
||||
free(rowBytes);
|
||||
}
|
||||
|
||||
void GfxRenderer::drawBitmap1Bit(const Bitmap& bitmap, const int x, const int y, const int maxWidth,
|
||||
void GfxRenderer::draw2BitImage(const uint8_t data[], int x, int y, int w,
|
||||
int h) const {
|
||||
uint8_t *frameBuffer = einkDisplay.getFrameBuffer();
|
||||
if (!frameBuffer) {
|
||||
return;
|
||||
}
|
||||
|
||||
const int screenWidth = getScreenWidth();
|
||||
const int screenHeight = getScreenHeight();
|
||||
|
||||
// Pre-compute row byte width for 2-bit packed data (4 pixels per byte)
|
||||
const int srcRowBytes = (w + 3) / 4;
|
||||
|
||||
// Optimized path for Portrait mode with BW rendering (most common case)
|
||||
// In Portrait: logical (x, y) -> physical (y, DISPLAY_HEIGHT - 1 - x)
|
||||
if (orientation == Portrait && renderMode == BW) {
|
||||
for (int row = 0; row < h; row++) {
|
||||
const int screenY = y + row;
|
||||
if (screenY < 0 || screenY >= screenHeight)
|
||||
continue;
|
||||
|
||||
// In Portrait, screenY maps to physical X coordinate
|
||||
const int physX = screenY;
|
||||
const uint8_t *srcRow = data + row * srcRowBytes;
|
||||
|
||||
for (int col = 0; col < w; col++) {
|
||||
const int screenX = x + col;
|
||||
if (screenX < 0 || screenX >= screenWidth)
|
||||
continue;
|
||||
|
||||
// Extract 2-bit value (4 pixels per byte)
|
||||
const uint8_t val = (srcRow[col / 4] >> (6 - ((col % 4) * 2))) & 0x3;
|
||||
|
||||
// val < 3 means black pixel in 2-bit representation
|
||||
if (val < 3) {
|
||||
// In Portrait: physical Y = DISPLAY_HEIGHT - 1 - screenX
|
||||
const int physY = EInkDisplay::DISPLAY_HEIGHT - 1 - screenX;
|
||||
const uint16_t byteIndex =
|
||||
physY * EInkDisplay::DISPLAY_WIDTH_BYTES + (physX / 8);
|
||||
const uint8_t bitPosition = 7 - (physX % 8);
|
||||
frameBuffer[byteIndex] &= ~(1 << bitPosition); // Clear bit = black
|
||||
}
|
||||
}
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
// Optimized path for PortraitInverted mode with BW rendering
|
||||
if (orientation == PortraitInverted && renderMode == BW) {
|
||||
for (int row = 0; row < h; row++) {
|
||||
const int screenY = y + row;
|
||||
if (screenY < 0 || screenY >= screenHeight)
|
||||
continue;
|
||||
|
||||
const uint8_t *srcRow = data + row * srcRowBytes;
|
||||
|
||||
for (int col = 0; col < w; col++) {
|
||||
const int screenX = x + col;
|
||||
if (screenX < 0 || screenX >= screenWidth)
|
||||
continue;
|
||||
|
||||
const uint8_t val = (srcRow[col / 4] >> (6 - ((col % 4) * 2))) & 0x3;
|
||||
|
||||
if (val < 3) {
|
||||
// PortraitInverted: physical X = DISPLAY_WIDTH - 1 - screenY
|
||||
// physical Y = screenX
|
||||
const int physX = EInkDisplay::DISPLAY_WIDTH - 1 - screenY;
|
||||
const int physY = screenX;
|
||||
const uint16_t byteIndex =
|
||||
physY * EInkDisplay::DISPLAY_WIDTH_BYTES + (physX / 8);
|
||||
const uint8_t bitPosition = 7 - (physX % 8);
|
||||
frameBuffer[byteIndex] &= ~(1 << bitPosition);
|
||||
}
|
||||
}
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
// Optimized path for Landscape modes with BW rendering
|
||||
if ((orientation == LandscapeClockwise ||
|
||||
orientation == LandscapeCounterClockwise) &&
|
||||
renderMode == BW) {
|
||||
for (int row = 0; row < h; row++) {
|
||||
const int screenY = y + row;
|
||||
if (screenY < 0 || screenY >= screenHeight)
|
||||
continue;
|
||||
|
||||
const uint8_t *srcRow = data + row * srcRowBytes;
|
||||
|
||||
for (int col = 0; col < w; col++) {
|
||||
const int screenX = x + col;
|
||||
if (screenX < 0 || screenX >= screenWidth)
|
||||
continue;
|
||||
|
||||
const uint8_t val = (srcRow[col / 4] >> (6 - ((col % 4) * 2))) & 0x3;
|
||||
|
||||
if (val < 3) {
|
||||
int physX, physY;
|
||||
if (orientation == LandscapeClockwise) {
|
||||
physX = EInkDisplay::DISPLAY_WIDTH - 1 - screenX;
|
||||
physY = EInkDisplay::DISPLAY_HEIGHT - 1 - screenY;
|
||||
} else {
|
||||
physX = screenX;
|
||||
physY = screenY;
|
||||
}
|
||||
const uint16_t byteIndex =
|
||||
physY * EInkDisplay::DISPLAY_WIDTH_BYTES + (physX / 8);
|
||||
const uint8_t bitPosition = 7 - (physX % 8);
|
||||
frameBuffer[byteIndex] &= ~(1 << bitPosition);
|
||||
}
|
||||
}
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
// Fallback: generic path for grayscale modes
|
||||
for (int row = 0; row < h; row++) {
|
||||
const int screenY = y + row;
|
||||
if (screenY < 0 || screenY >= screenHeight)
|
||||
continue;
|
||||
|
||||
const uint8_t *srcRow = data + row * srcRowBytes;
|
||||
|
||||
for (int col = 0; col < w; col++) {
|
||||
const int screenX = x + col;
|
||||
if (screenX < 0 || screenX >= screenWidth)
|
||||
continue;
|
||||
|
||||
const uint8_t val = (srcRow[col / 4] >> (6 - ((col % 4) * 2))) & 0x3;
|
||||
|
||||
if (renderMode == GRAYSCALE_MSB && (val == 1 || val == 2)) {
|
||||
drawPixel(screenX, screenY, false);
|
||||
} else if (renderMode == GRAYSCALE_LSB && val == 1) {
|
||||
drawPixel(screenX, screenY, false);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
void GfxRenderer::drawBitmap1Bit(const Bitmap &bitmap, const int x, const int y,
|
||||
const int maxWidth,
|
||||
const int maxHeight) const {
|
||||
float scale = 1.0f;
|
||||
bool isScaled = false;
|
||||
if (maxWidth > 0 && bitmap.getWidth() > maxWidth) {
|
||||
scale = static_cast<float>(maxWidth) / static_cast<float>(bitmap.getWidth());
|
||||
scale =
|
||||
static_cast<float>(maxWidth) / static_cast<float>(bitmap.getWidth());
|
||||
isScaled = true;
|
||||
}
|
||||
if (maxHeight > 0 && bitmap.getHeight() > maxHeight) {
|
||||
scale = std::min(scale, static_cast<float>(maxHeight) / static_cast<float>(bitmap.getHeight()));
|
||||
scale = std::min(scale, static_cast<float>(maxHeight) /
|
||||
static_cast<float>(bitmap.getHeight()));
|
||||
isScaled = true;
|
||||
}
|
||||
|
||||
// For 1-bit BMP, output is still 2-bit packed (for consistency with readNextRow)
|
||||
// For 1-bit BMP, output is still 2-bit packed (for consistency with
|
||||
// readNextRow)
|
||||
const int outputRowSize = (bitmap.getWidth() + 3) / 4;
|
||||
auto *outputRow = static_cast<uint8_t *>(malloc(outputRowSize));
|
||||
auto *rowBytes = static_cast<uint8_t *>(malloc(bitmap.getRowBytes()));
|
||||
|
||||
if (!outputRow || !rowBytes) {
|
||||
Serial.printf("[%lu] [GFX] !! Failed to allocate 1-bit BMP row buffers\n", millis());
|
||||
Serial.printf("[%lu] [GFX] !! Failed to allocate 1-bit BMP row buffers\n",
|
||||
millis());
|
||||
free(outputRow);
|
||||
free(rowBytes);
|
||||
return;
|
||||
@ -289,15 +554,19 @@ void GfxRenderer::drawBitmap1Bit(const Bitmap& bitmap, const int x, const int y,
|
||||
for (int bmpY = 0; bmpY < bitmap.getHeight(); bmpY++) {
|
||||
// Read rows sequentially using readNextRow
|
||||
if (bitmap.readNextRow(outputRow, rowBytes) != BmpReaderError::Ok) {
|
||||
Serial.printf("[%lu] [GFX] Failed to read row %d from 1-bit bitmap\n", millis(), bmpY);
|
||||
Serial.printf("[%lu] [GFX] Failed to read row %d from 1-bit bitmap\n",
|
||||
millis(), bmpY);
|
||||
free(outputRow);
|
||||
free(rowBytes);
|
||||
return;
|
||||
}
|
||||
|
||||
// Calculate screen Y based on whether BMP is top-down or bottom-up
|
||||
const int bmpYOffset = bitmap.isTopDown() ? bmpY : bitmap.getHeight() - 1 - bmpY;
|
||||
int screenY = y + (isScaled ? static_cast<int>(std::floor(bmpYOffset * scale)) : bmpYOffset);
|
||||
const int bmpYOffset =
|
||||
bitmap.isTopDown() ? bmpY : bitmap.getHeight() - 1 - bmpY;
|
||||
int screenY =
|
||||
y + (isScaled ? static_cast<int>(std::floor(bmpYOffset * scale))
|
||||
: bmpYOffset);
|
||||
if (screenY >= getScreenHeight()) {
|
||||
continue; // Continue reading to keep row counter in sync
|
||||
}
|
||||
@ -306,7 +575,8 @@ void GfxRenderer::drawBitmap1Bit(const Bitmap& bitmap, const int x, const int y,
|
||||
}
|
||||
|
||||
for (int bmpX = 0; bmpX < bitmap.getWidth(); bmpX++) {
|
||||
int screenX = x + (isScaled ? static_cast<int>(std::floor(bmpX * scale)) : bmpX);
|
||||
int screenX =
|
||||
x + (isScaled ? static_cast<int>(std::floor(bmpX * scale)) : bmpX);
|
||||
if (screenX >= getScreenWidth()) {
|
||||
break;
|
||||
}
|
||||
@ -330,24 +600,31 @@ void GfxRenderer::drawBitmap1Bit(const Bitmap& bitmap, const int x, const int y,
|
||||
free(rowBytes);
|
||||
}
|
||||
|
||||
void GfxRenderer::fillPolygon(const int* xPoints, const int* yPoints, int numPoints, bool state) const {
|
||||
if (numPoints < 3) return;
|
||||
void GfxRenderer::fillPolygon(const int *xPoints, const int *yPoints,
|
||||
int numPoints, bool state) const {
|
||||
if (numPoints < 3)
|
||||
return;
|
||||
|
||||
// Find bounding box
|
||||
int minY = yPoints[0], maxY = yPoints[0];
|
||||
for (int i = 1; i < numPoints; i++) {
|
||||
if (yPoints[i] < minY) minY = yPoints[i];
|
||||
if (yPoints[i] > maxY) maxY = yPoints[i];
|
||||
if (yPoints[i] < minY)
|
||||
minY = yPoints[i];
|
||||
if (yPoints[i] > maxY)
|
||||
maxY = yPoints[i];
|
||||
}
|
||||
|
||||
// Clip to screen
|
||||
if (minY < 0) minY = 0;
|
||||
if (maxY >= getScreenHeight()) maxY = getScreenHeight() - 1;
|
||||
if (minY < 0)
|
||||
minY = 0;
|
||||
if (maxY >= getScreenHeight())
|
||||
maxY = getScreenHeight() - 1;
|
||||
|
||||
// Allocate node buffer for scanline algorithm
|
||||
auto *nodeX = static_cast<int *>(malloc(numPoints * sizeof(int)));
|
||||
if (!nodeX) {
|
||||
Serial.printf("[%lu] [GFX] !! Failed to allocate polygon node buffer\n", millis());
|
||||
Serial.printf("[%lu] [GFX] !! Failed to allocate polygon node buffer\n",
|
||||
millis());
|
||||
return;
|
||||
}
|
||||
|
||||
@ -358,11 +635,13 @@ void GfxRenderer::fillPolygon(const int* xPoints, const int* yPoints, int numPoi
|
||||
// Find all intersection points with edges
|
||||
int j = numPoints - 1;
|
||||
for (int i = 0; i < numPoints; i++) {
|
||||
if ((yPoints[i] < scanY && yPoints[j] >= scanY) || (yPoints[j] < scanY && yPoints[i] >= scanY)) {
|
||||
if ((yPoints[i] < scanY && yPoints[j] >= scanY) ||
|
||||
(yPoints[j] < scanY && yPoints[i] >= scanY)) {
|
||||
// Calculate X intersection using fixed-point to avoid float
|
||||
int dy = yPoints[j] - yPoints[i];
|
||||
if (dy != 0) {
|
||||
nodeX[nodes++] = xPoints[i] + (scanY - yPoints[i]) * (xPoints[j] - xPoints[i]) / dy;
|
||||
nodeX[nodes++] = xPoints[i] + (scanY - yPoints[i]) *
|
||||
(xPoints[j] - xPoints[i]) / dy;
|
||||
}
|
||||
}
|
||||
j = i;
|
||||
@ -385,8 +664,10 @@ void GfxRenderer::fillPolygon(const int* xPoints, const int* yPoints, int numPoi
|
||||
int endX = nodeX[i + 1];
|
||||
|
||||
// Clip to screen
|
||||
if (startX < 0) startX = 0;
|
||||
if (endX >= getScreenWidth()) endX = getScreenWidth() - 1;
|
||||
if (startX < 0)
|
||||
startX = 0;
|
||||
if (endX >= getScreenWidth())
|
||||
endX = getScreenWidth() - 1;
|
||||
|
||||
// Draw horizontal line
|
||||
for (int x = startX; x <= endX; x++) {
|
||||
@ -398,6 +679,124 @@ void GfxRenderer::fillPolygon(const int* xPoints, const int* yPoints, int numPoi
|
||||
free(nodeX);
|
||||
}
|
||||
|
||||
uint8_t* GfxRenderer::captureRegion(int x, int y, int width, int height, size_t* outSize) const {
|
||||
uint8_t* frameBuffer = display.getFrameBuffer();
|
||||
if (!frameBuffer || width <= 0 || height <= 0) {
|
||||
if (outSize) *outSize = 0;
|
||||
return nullptr;
|
||||
}
|
||||
|
||||
// Clip to screen bounds
|
||||
const int screenWidth = getScreenWidth();
|
||||
const int screenHeight = getScreenHeight();
|
||||
if (x < 0) { width += x; x = 0; }
|
||||
if (y < 0) { height += y; y = 0; }
|
||||
if (x + width > screenWidth) width = screenWidth - x;
|
||||
if (y + height > screenHeight) height = screenHeight - y;
|
||||
|
||||
if (width <= 0 || height <= 0) {
|
||||
if (outSize) *outSize = 0;
|
||||
return nullptr;
|
||||
}
|
||||
|
||||
// Pack as 1-bit: ceil(width/8) bytes per row
|
||||
const size_t rowBytes = (width + 7) / 8;
|
||||
const size_t bufferSize = rowBytes * height + 4 * sizeof(int); // +header
|
||||
uint8_t* buffer = static_cast<uint8_t*>(malloc(bufferSize));
|
||||
if (!buffer) {
|
||||
if (outSize) *outSize = 0;
|
||||
return nullptr;
|
||||
}
|
||||
|
||||
// Store dimensions in header
|
||||
int* header = reinterpret_cast<int*>(buffer);
|
||||
header[0] = x;
|
||||
header[1] = y;
|
||||
header[2] = width;
|
||||
header[3] = height;
|
||||
uint8_t* data = buffer + 4 * sizeof(int);
|
||||
|
||||
// Extract pixels - this is orientation-dependent
|
||||
for (int row = 0; row < height; row++) {
|
||||
const int screenY = y + row;
|
||||
uint8_t* destRow = data + row * rowBytes;
|
||||
memset(destRow, 0xFF, rowBytes); // Start with white
|
||||
|
||||
for (int col = 0; col < width; col++) {
|
||||
const int screenX = x + col;
|
||||
|
||||
// Get physical coordinates
|
||||
int physX, physY;
|
||||
rotateCoordinates(screenX, screenY, &physX, &physY);
|
||||
|
||||
// Read pixel from framebuffer
|
||||
const uint16_t byteIndex = physY * HalDisplay::DISPLAY_WIDTH_BYTES + (physX / 8);
|
||||
const uint8_t bitPosition = 7 - (physX % 8);
|
||||
const bool isBlack = !(frameBuffer[byteIndex] & (1 << bitPosition));
|
||||
|
||||
// Store in destination
|
||||
if (isBlack) {
|
||||
destRow[col / 8] &= ~(1 << (7 - (col % 8)));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (outSize) *outSize = bufferSize;
|
||||
return buffer;
|
||||
}
|
||||
|
||||
void GfxRenderer::restoreRegion(const uint8_t* buffer, int x, int y, int width, int height) const {
|
||||
uint8_t* frameBuffer = display.getFrameBuffer();
|
||||
if (!frameBuffer || !buffer || width <= 0 || height <= 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
const size_t rowBytes = (width + 7) / 8;
|
||||
const uint8_t* data = buffer + 4 * sizeof(int); // Skip header
|
||||
|
||||
// Optimized path for Portrait mode
|
||||
if (orientation == Portrait) {
|
||||
for (int row = 0; row < height; row++) {
|
||||
const int screenY = y + row;
|
||||
if (screenY < 0 || screenY >= getScreenHeight()) continue;
|
||||
|
||||
const uint8_t* srcRow = data + row * rowBytes;
|
||||
const int physX = screenY;
|
||||
const uint8_t physXByte = physX / 8;
|
||||
const uint8_t physXBit = 7 - (physX % 8);
|
||||
const uint8_t mask = 1 << physXBit;
|
||||
|
||||
for (int col = 0; col < width; col++) {
|
||||
const int screenX = x + col;
|
||||
if (screenX < 0 || screenX >= getScreenWidth()) continue;
|
||||
|
||||
const bool isBlack = !(srcRow[col / 8] & (1 << (7 - (col % 8))));
|
||||
const int physY = HalDisplay::DISPLAY_HEIGHT - 1 - screenX;
|
||||
const uint16_t byteIndex = physY * HalDisplay::DISPLAY_WIDTH_BYTES + physXByte;
|
||||
|
||||
if (isBlack) {
|
||||
frameBuffer[byteIndex] &= ~mask;
|
||||
} else {
|
||||
frameBuffer[byteIndex] |= mask;
|
||||
}
|
||||
}
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
// Generic fallback using drawPixel
|
||||
for (int row = 0; row < height; row++) {
|
||||
const int screenY = y + row;
|
||||
const uint8_t* srcRow = data + row * rowBytes;
|
||||
|
||||
for (int col = 0; col < width; col++) {
|
||||
const int screenX = x + col;
|
||||
const bool isBlack = !(srcRow[col / 8] & (1 << (7 - (col % 8))));
|
||||
drawPixel(screenX, screenY, isBlack);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
void GfxRenderer::clearScreen(const uint8_t color) const { display.clearScreen(color); }
|
||||
|
||||
void GfxRenderer::invertScreen() const {
|
||||
@ -413,7 +812,8 @@ void GfxRenderer::invertScreen() const {
|
||||
|
||||
void GfxRenderer::displayBuffer(const HalDisplay::RefreshMode refreshMode) const { display.displayBuffer(refreshMode); }
|
||||
|
||||
std::string GfxRenderer::truncatedText(const int fontId, const char* text, const int maxWidth,
|
||||
std::string GfxRenderer::truncatedText(const int fontId, const char *text,
|
||||
const int maxWidth,
|
||||
const EpdFontFamily::Style style) const {
|
||||
std::string item = text;
|
||||
int itemWidth = getTextWidth(fontId, item.c_str(), style);
|
||||
@ -424,7 +824,8 @@ std::string GfxRenderer::truncatedText(const int fontId, const char* text, const
|
||||
return item;
|
||||
}
|
||||
|
||||
// Note: Internal driver treats screen in command orientation; this library exposes a logical orientation
|
||||
// Note: Internal driver treats screen in command orientation; this library
|
||||
// exposes a logical orientation
|
||||
int GfxRenderer::getScreenWidth() const {
|
||||
switch (orientation) {
|
||||
case Portrait:
|
||||
@ -480,7 +881,8 @@ int GfxRenderer::getLineHeight(const int fontId) const {
|
||||
return fontMap.at(fontId).getData(EpdFontFamily::REGULAR)->advanceY;
|
||||
}
|
||||
|
||||
void GfxRenderer::drawButtonHints(const int fontId, const char* btn1, const char* btn2, const char* btn3,
|
||||
void GfxRenderer::drawButtonHints(const int fontId, const char *btn1,
|
||||
const char *btn2, const char *btn3,
|
||||
const char *btn4) {
|
||||
const Orientation orig_orientation = getOrientation();
|
||||
setOrientation(Orientation::Portrait);
|
||||
@ -508,7 +910,8 @@ void GfxRenderer::drawButtonHints(const int fontId, const char* btn1, const char
|
||||
setOrientation(orig_orientation);
|
||||
}
|
||||
|
||||
void GfxRenderer::drawSideButtonHints(const int fontId, const char* topBtn, const char* bottomBtn) const {
|
||||
void GfxRenderer::drawSideButtonHints(const int fontId, const char *topBtn,
|
||||
const char *bottomBtn) const {
|
||||
const int screenWidth = getScreenWidth();
|
||||
constexpr int buttonWidth = 40; // Width on screen (height when rotated)
|
||||
constexpr int buttonHeight = 80; // Height on screen (width when rotated)
|
||||
@ -525,20 +928,26 @@ void GfxRenderer::drawSideButtonHints(const int fontId, const char* topBtn, cons
|
||||
if (topBtn != nullptr && topBtn[0] != '\0') {
|
||||
drawLine(x, topButtonY, x + buttonWidth - 1, topButtonY); // Top
|
||||
drawLine(x, topButtonY, x, topButtonY + buttonHeight - 1); // Left
|
||||
drawLine(x + buttonWidth - 1, topButtonY, x + buttonWidth - 1, topButtonY + buttonHeight - 1); // Right
|
||||
drawLine(x + buttonWidth - 1, topButtonY, x + buttonWidth - 1,
|
||||
topButtonY + buttonHeight - 1); // Right
|
||||
}
|
||||
|
||||
// Draw shared middle border
|
||||
if ((topBtn != nullptr && topBtn[0] != '\0') || (bottomBtn != nullptr && bottomBtn[0] != '\0')) {
|
||||
drawLine(x, topButtonY + buttonHeight, x + buttonWidth - 1, topButtonY + buttonHeight); // Shared border
|
||||
if ((topBtn != nullptr && topBtn[0] != '\0') ||
|
||||
(bottomBtn != nullptr && bottomBtn[0] != '\0')) {
|
||||
drawLine(x, topButtonY + buttonHeight, x + buttonWidth - 1,
|
||||
topButtonY + buttonHeight); // Shared border
|
||||
}
|
||||
|
||||
// Draw bottom button outline (3 sides, top is shared)
|
||||
if (bottomBtn != nullptr && bottomBtn[0] != '\0') {
|
||||
drawLine(x, topButtonY + buttonHeight, x, topButtonY + 2 * buttonHeight - 1); // Left
|
||||
drawLine(x + buttonWidth - 1, topButtonY + buttonHeight, x + buttonWidth - 1,
|
||||
drawLine(x, topButtonY + buttonHeight, x,
|
||||
topButtonY + 2 * buttonHeight - 1); // Left
|
||||
drawLine(x + buttonWidth - 1, topButtonY + buttonHeight,
|
||||
x + buttonWidth - 1,
|
||||
topButtonY + 2 * buttonHeight - 1); // Right
|
||||
drawLine(x, topButtonY + 2 * buttonHeight - 1, x + buttonWidth - 1, topButtonY + 2 * buttonHeight - 1); // Bottom
|
||||
drawLine(x, topButtonY + 2 * buttonHeight - 1, x + buttonWidth - 1,
|
||||
topButtonY + 2 * buttonHeight - 1); // Bottom
|
||||
}
|
||||
|
||||
// Draw text for each button
|
||||
@ -567,7 +976,9 @@ int GfxRenderer::getTextHeight(const int fontId) const {
|
||||
return fontMap.at(fontId).getData(EpdFontFamily::REGULAR)->ascender;
|
||||
}
|
||||
|
||||
void GfxRenderer::drawTextRotated90CW(const int fontId, const int x, const int y, const char* text, const bool black,
|
||||
void GfxRenderer::drawTextRotated90CW(const int fontId, const int x,
|
||||
const int y, const char *text,
|
||||
const bool black,
|
||||
const EpdFontFamily::Style style) const {
|
||||
// Cannot draw a NULL / empty string
|
||||
if (text == nullptr || *text == '\0') {
|
||||
@ -618,7 +1029,8 @@ void GfxRenderer::drawTextRotated90CW(const int fontId, const int x, const int y
|
||||
// 90° clockwise rotation transformation:
|
||||
// screenX = x + (ascender - top + glyphY)
|
||||
// screenY = yPos - (left + glyphX)
|
||||
const int screenX = x + (font.getData(style)->ascender - top + glyphY);
|
||||
const int screenX =
|
||||
x + (font.getData(style)->ascender - top + glyphY);
|
||||
const int screenY = yPos - left - glyphX;
|
||||
|
||||
if (is2Bit) {
|
||||
@ -628,7 +1040,8 @@ void GfxRenderer::drawTextRotated90CW(const int fontId, const int x, const int y
|
||||
|
||||
if (renderMode == BW && bmpVal < 3) {
|
||||
drawPixel(screenX, screenY, black);
|
||||
} else if (renderMode == GRAYSCALE_MSB && (bmpVal == 1 || bmpVal == 2)) {
|
||||
} else if (renderMode == GRAYSCALE_MSB &&
|
||||
(bmpVal == 1 || bmpVal == 2)) {
|
||||
drawPixel(screenX, screenY, false);
|
||||
} else if (renderMode == GRAYSCALE_LSB && bmpVal == 1) {
|
||||
drawPixel(screenX, screenY, false);
|
||||
@ -674,9 +1087,10 @@ void GfxRenderer::freeBwBufferChunks() {
|
||||
|
||||
/**
|
||||
* This should be called before grayscale buffers are populated.
|
||||
* A `restoreBwBuffer` call should always follow the grayscale render if this method was called.
|
||||
* Uses chunked allocation to avoid needing 48KB of contiguous memory.
|
||||
* Returns true if buffer was stored successfully, false if allocation failed.
|
||||
* A `restoreBwBuffer` call should always follow the grayscale render if this
|
||||
* method was called. Uses chunked allocation to avoid needing 48KB of
|
||||
* contiguous memory. Returns true if buffer was stored successfully, false if
|
||||
* allocation failed.
|
||||
*/
|
||||
bool GfxRenderer::storeBwBuffer() {
|
||||
const uint8_t* frameBuffer = display.getFrameBuffer();
|
||||
@ -689,7 +1103,8 @@ bool GfxRenderer::storeBwBuffer() {
|
||||
for (size_t i = 0; i < BW_BUFFER_NUM_CHUNKS; i++) {
|
||||
// Check if any chunks are already allocated
|
||||
if (bwBufferChunks[i]) {
|
||||
Serial.printf("[%lu] [GFX] !! BW buffer chunk %zu already stored - this is likely a bug, freeing chunk\n",
|
||||
Serial.printf("[%lu] [GFX] !! BW buffer chunk %zu already stored - this "
|
||||
"is likely a bug, freeing chunk\n",
|
||||
millis(), i);
|
||||
free(bwBufferChunks[i]);
|
||||
bwBufferChunks[i] = nullptr;
|
||||
@ -699,8 +1114,9 @@ bool GfxRenderer::storeBwBuffer() {
|
||||
bwBufferChunks[i] = static_cast<uint8_t *>(malloc(BW_BUFFER_CHUNK_SIZE));
|
||||
|
||||
if (!bwBufferChunks[i]) {
|
||||
Serial.printf("[%lu] [GFX] !! Failed to allocate BW buffer chunk %zu (%zu bytes)\n", millis(), i,
|
||||
BW_BUFFER_CHUNK_SIZE);
|
||||
Serial.printf(
|
||||
"[%lu] [GFX] !! Failed to allocate BW buffer chunk %zu (%zu bytes)\n",
|
||||
millis(), i, BW_BUFFER_CHUNK_SIZE);
|
||||
// Free previously allocated chunks
|
||||
freeBwBufferChunks();
|
||||
return false;
|
||||
@ -709,15 +1125,15 @@ bool GfxRenderer::storeBwBuffer() {
|
||||
memcpy(bwBufferChunks[i], frameBuffer + offset, BW_BUFFER_CHUNK_SIZE);
|
||||
}
|
||||
|
||||
Serial.printf("[%lu] [GFX] Stored BW buffer in %zu chunks (%zu bytes each)\n", millis(), BW_BUFFER_NUM_CHUNKS,
|
||||
BW_BUFFER_CHUNK_SIZE);
|
||||
Serial.printf("[%lu] [GFX] Stored BW buffer in %zu chunks (%zu bytes each)\n",
|
||||
millis(), BW_BUFFER_NUM_CHUNKS, BW_BUFFER_CHUNK_SIZE);
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
* This can only be called if `storeBwBuffer` was called prior to the grayscale render.
|
||||
* It should be called to restore the BW buffer state after grayscale rendering is complete.
|
||||
* Uses chunked restoration to match chunked storage.
|
||||
* This can only be called if `storeBwBuffer` was called prior to the grayscale
|
||||
* render. It should be called to restore the BW buffer state after grayscale
|
||||
* rendering is complete. Uses chunked restoration to match chunked storage.
|
||||
*/
|
||||
void GfxRenderer::restoreBwBuffer() {
|
||||
// Check if any all chunks are allocated
|
||||
@ -736,7 +1152,8 @@ void GfxRenderer::restoreBwBuffer() {
|
||||
|
||||
uint8_t* frameBuffer = display.getFrameBuffer();
|
||||
if (!frameBuffer) {
|
||||
Serial.printf("[%lu] [GFX] !! No framebuffer in restoreBwBuffer\n", millis());
|
||||
Serial.printf("[%lu] [GFX] !! No framebuffer in restoreBwBuffer\n",
|
||||
millis());
|
||||
freeBwBufferChunks();
|
||||
return;
|
||||
}
|
||||
@ -744,7 +1161,9 @@ void GfxRenderer::restoreBwBuffer() {
|
||||
for (size_t i = 0; i < BW_BUFFER_NUM_CHUNKS; i++) {
|
||||
// Check if chunk is missing
|
||||
if (!bwBufferChunks[i]) {
|
||||
Serial.printf("[%lu] [GFX] !! BW buffer chunks not stored - this is likely a bug\n", millis());
|
||||
Serial.printf(
|
||||
"[%lu] [GFX] !! BW buffer chunks not stored - this is likely a bug\n",
|
||||
millis());
|
||||
freeBwBufferChunks();
|
||||
return;
|
||||
}
|
||||
@ -770,8 +1189,9 @@ void GfxRenderer::cleanupGrayscaleWithFrameBuffer() const {
|
||||
}
|
||||
}
|
||||
|
||||
void GfxRenderer::renderChar(const EpdFontFamily& fontFamily, const uint32_t cp, int* x, const int* y,
|
||||
const bool pixelState, const EpdFontFamily::Style style) const {
|
||||
void GfxRenderer::renderChar(const EpdFontFamily &fontFamily, const uint32_t cp,
|
||||
int *x, const int *y, const bool pixelState,
|
||||
const EpdFontFamily::Style style) const {
|
||||
const EpdGlyph *glyph = fontFamily.getGlyph(cp, style);
|
||||
if (!glyph) {
|
||||
glyph = fontFamily.getGlyph(REPLACEMENT_GLYPH, style);
|
||||
@ -802,17 +1222,20 @@ void GfxRenderer::renderChar(const EpdFontFamily& fontFamily, const uint32_t cp,
|
||||
if (is2Bit) {
|
||||
const uint8_t byte = bitmap[pixelPosition / 4];
|
||||
const uint8_t bit_index = (3 - pixelPosition % 4) * 2;
|
||||
// the direct bit from the font is 0 -> white, 1 -> light gray, 2 -> dark gray, 3 -> black
|
||||
// we swap this to better match the way images and screen think about colors:
|
||||
// 0 -> black, 1 -> dark grey, 2 -> light grey, 3 -> white
|
||||
// the direct bit from the font is 0 -> white, 1 -> light gray, 2 ->
|
||||
// dark gray, 3 -> black we swap this to better match the way images
|
||||
// and screen think about colors: 0 -> black, 1 -> dark grey, 2 ->
|
||||
// light grey, 3 -> white
|
||||
const uint8_t bmpVal = 3 - (byte >> bit_index) & 0x3;
|
||||
|
||||
if (renderMode == BW && bmpVal < 3) {
|
||||
// Black (also paints over the grays in BW mode)
|
||||
drawPixel(screenX, screenY, pixelState);
|
||||
} else if (renderMode == GRAYSCALE_MSB && (bmpVal == 1 || bmpVal == 2)) {
|
||||
// Light gray (also mark the MSB if it's going to be a dark gray too)
|
||||
// We have to flag pixels in reverse for the gray buffers, as 0 leave alone, 1 update
|
||||
} else if (renderMode == GRAYSCALE_MSB &&
|
||||
(bmpVal == 1 || bmpVal == 2)) {
|
||||
// Light gray (also mark the MSB if it's going to be a dark gray
|
||||
// too) We have to flag pixels in reverse for the gray buffers, as 0
|
||||
// leave alone, 1 update
|
||||
drawPixel(screenX, screenY, false);
|
||||
} else if (renderMode == GRAYSCALE_LSB && bmpVal == 1) {
|
||||
// Dark gray
|
||||
@ -833,7 +1256,8 @@ void GfxRenderer::renderChar(const EpdFontFamily& fontFamily, const uint32_t cp,
|
||||
*x += glyph->advanceX;
|
||||
}
|
||||
|
||||
void GfxRenderer::getOrientedViewableTRBL(int* outTop, int* outRight, int* outBottom, int* outLeft) const {
|
||||
void GfxRenderer::getOrientedViewableTRBL(int *outTop, int *outRight,
|
||||
int *outBottom, int *outLeft) const {
|
||||
switch (orientation) {
|
||||
case Portrait:
|
||||
*outTop = VIEWABLE_MARGIN_TOP;
|
||||
|
||||
@ -14,9 +14,11 @@ class GfxRenderer {
|
||||
// Logical screen orientation from the perspective of callers
|
||||
enum Orientation {
|
||||
Portrait, // 480x800 logical coordinates (current default)
|
||||
LandscapeClockwise, // 800x480 logical coordinates, rotated 180° (swap top/bottom)
|
||||
LandscapeClockwise, // 800x480 logical coordinates, rotated 180° (swap
|
||||
// top/bottom)
|
||||
PortraitInverted, // 480x800 logical coordinates, inverted
|
||||
LandscapeCounterClockwise // 800x480 logical coordinates, native panel orientation
|
||||
LandscapeCounterClockwise // 800x480 logical coordinates, native panel
|
||||
// orientation
|
||||
};
|
||||
|
||||
private:
|
||||
@ -30,7 +32,8 @@ class GfxRenderer {
|
||||
Orientation orientation;
|
||||
uint8_t *bwBufferChunks[BW_BUFFER_NUM_CHUNKS] = {nullptr};
|
||||
std::map<int, EpdFontFamily> fontMap;
|
||||
void renderChar(const EpdFontFamily& fontFamily, uint32_t cp, int* x, const int* y, bool pixelState,
|
||||
void renderChar(const EpdFontFamily &fontFamily, uint32_t cp, int *x,
|
||||
const int *y, bool pixelState,
|
||||
EpdFontFamily::Style style) const;
|
||||
void freeBwBufferChunks();
|
||||
void rotateCoordinates(int x, int y, int *rotatedX, int *rotatedY) const;
|
||||
@ -47,7 +50,8 @@ class GfxRenderer {
|
||||
// Setup
|
||||
void insertFont(int fontId, EpdFontFamily font);
|
||||
|
||||
// Orientation control (affects logical width/height and coordinate transforms)
|
||||
// Orientation control (affects logical width/height and coordinate
|
||||
// transforms)
|
||||
void setOrientation(const Orientation o) { orientation = o; }
|
||||
Orientation getOrientation() const { return orientation; }
|
||||
|
||||
@ -65,31 +69,50 @@ class GfxRenderer {
|
||||
void drawLine(int x1, int y1, int x2, int y2, bool state = true) const;
|
||||
void drawRect(int x, int y, int width, int height, bool state = true) const;
|
||||
void fillRect(int x, int y, int width, int height, bool state = true) const;
|
||||
void drawImage(const uint8_t bitmap[], int x, int y, int width, int height) const;
|
||||
void drawBitmap(const Bitmap& bitmap, int x, int y, int maxWidth, int maxHeight, float cropX = 0,
|
||||
float cropY = 0) const;
|
||||
void drawBitmap1Bit(const Bitmap& bitmap, int x, int y, int maxWidth, int maxHeight) const;
|
||||
void fillPolygon(const int* xPoints, const int* yPoints, int numPoints, bool state = true) const;
|
||||
void drawImage(const uint8_t bitmap[], int x, int y, int width,
|
||||
int height) const;
|
||||
void drawBitmap(const Bitmap &bitmap, int x, int y, int maxWidth,
|
||||
int maxHeight, float cropX = 0, float cropY = 0) const;
|
||||
void drawBitmap1Bit(const Bitmap &bitmap, int x, int y, int maxWidth,
|
||||
int maxHeight) const;
|
||||
void draw2BitImage(const uint8_t data[], int x, int y, int w, int h) const;
|
||||
void fillPolygon(const int *xPoints, const int *yPoints, int numPoints,
|
||||
bool state = true) const;
|
||||
|
||||
// Region caching - copies a rectangular region to/from a buffer
|
||||
// Returns allocated buffer on success, nullptr on failure. Caller owns the
|
||||
// memory.
|
||||
uint8_t *captureRegion(int x, int y, int width, int height,
|
||||
size_t *outSize) const;
|
||||
// Restores a previously captured region. Buffer must match dimensions.
|
||||
void restoreRegion(const uint8_t *buffer, int x, int y, int width,
|
||||
int height) const;
|
||||
|
||||
// Text
|
||||
int getTextWidth(int fontId, const char* text, EpdFontFamily::Style style = EpdFontFamily::REGULAR) const;
|
||||
void drawCenteredText(int fontId, int y, const char* text, bool black = true,
|
||||
int getTextWidth(int fontId, const char *text,
|
||||
EpdFontFamily::Style style = EpdFontFamily::REGULAR) const;
|
||||
void
|
||||
drawCenteredText(int fontId, int y, const char *text, bool black = true,
|
||||
EpdFontFamily::Style style = EpdFontFamily::REGULAR) const;
|
||||
void drawText(int fontId, int x, int y, const char *text, bool black = true,
|
||||
EpdFontFamily::Style style = EpdFontFamily::REGULAR) const;
|
||||
int getSpaceWidth(int fontId) const;
|
||||
int getFontAscenderSize(int fontId) const;
|
||||
int getLineHeight(int fontId) const;
|
||||
std::string truncatedText(int fontId, const char* text, int maxWidth,
|
||||
std::string
|
||||
truncatedText(int fontId, const char *text, int maxWidth,
|
||||
EpdFontFamily::Style style = EpdFontFamily::REGULAR) const;
|
||||
|
||||
// UI Components
|
||||
void drawButtonHints(int fontId, const char* btn1, const char* btn2, const char* btn3, const char* btn4);
|
||||
void drawSideButtonHints(int fontId, const char* topBtn, const char* bottomBtn) const;
|
||||
void drawButtonHints(int fontId, const char *btn1, const char *btn2,
|
||||
const char *btn3, const char *btn4);
|
||||
void drawSideButtonHints(int fontId, const char *topBtn,
|
||||
const char *bottomBtn) const;
|
||||
|
||||
private:
|
||||
// Helper for drawing rotated text (90 degrees clockwise, for side buttons)
|
||||
void drawTextRotated90CW(int fontId, int x, int y, const char* text, bool black = true,
|
||||
void drawTextRotated90CW(
|
||||
int fontId, int x, int y, const char *text, bool black = true,
|
||||
EpdFontFamily::Style style = EpdFontFamily::REGULAR) const;
|
||||
int getTextHeight(int fontId) const;
|
||||
|
||||
@ -107,5 +130,6 @@ class GfxRenderer {
|
||||
uint8_t *getFrameBuffer() const;
|
||||
static size_t getBufferSize();
|
||||
void grayscaleRevert() const;
|
||||
void getOrientedViewableTRBL(int* outTop, int* outRight, int* outBottom, int* outLeft) const;
|
||||
void getOrientedViewableTRBL(int *outTop, int *outRight, int *outBottom,
|
||||
int *outLeft) const;
|
||||
};
|
||||
|
||||
404
lib/ThemeEngine/include/BasicElements.h
Normal file
404
lib/ThemeEngine/include/BasicElements.h
Normal file
@ -0,0 +1,404 @@
|
||||
#pragma once
|
||||
|
||||
#include "ThemeContext.h"
|
||||
#include "ThemeTypes.h"
|
||||
#include "UIElement.h"
|
||||
#include <Bitmap.h>
|
||||
#include <SDCardManager.h>
|
||||
#include <vector>
|
||||
|
||||
namespace ThemeEngine {
|
||||
|
||||
// --- 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
|
||||
|
||||
public:
|
||||
Container(const std::string &id) : UIElement(id) {
|
||||
bgColorExpr = Expression::parse("0xFF");
|
||||
}
|
||||
virtual ~Container() {
|
||||
for (auto child : children)
|
||||
delete child;
|
||||
}
|
||||
|
||||
Container *asContainer() override { return this; }
|
||||
|
||||
ElementType getType() const override { return ElementType::Container; }
|
||||
|
||||
void addChild(UIElement *child) { children.push_back(child); }
|
||||
|
||||
const std::vector<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 layout(const ThemeContext &context, int parentX, int parentY,
|
||||
int parentW, int parentH) override {
|
||||
UIElement::layout(context, parentX, parentY, parentW, parentH);
|
||||
for (auto child : children) {
|
||||
child->layout(context, absX, absY, absW, absH);
|
||||
}
|
||||
}
|
||||
|
||||
void markDirty() override {
|
||||
UIElement::markDirty();
|
||||
for (auto child : children) {
|
||||
child->markDirty();
|
||||
}
|
||||
}
|
||||
|
||||
void draw(const GfxRenderer &renderer, const ThemeContext &context) override {
|
||||
if (!isVisible(context))
|
||||
return;
|
||||
|
||||
if (hasBg) {
|
||||
std::string colStr = context.evaluatestring(bgColorExpr);
|
||||
uint8_t color = Color::parse(colStr).value;
|
||||
renderer.fillRect(absX, absY, absW, absH, color == 0x00);
|
||||
}
|
||||
|
||||
// Handle dynamic border expression
|
||||
bool drawBorder = border;
|
||||
if (hasBorderExpr()) {
|
||||
drawBorder = context.evaluateBool(borderExpr.rawExpr);
|
||||
}
|
||||
|
||||
if (drawBorder) {
|
||||
renderer.drawRect(absX, absY, absW, absH, true);
|
||||
}
|
||||
|
||||
for (auto child : children) {
|
||||
child->draw(renderer, context);
|
||||
}
|
||||
|
||||
markClean();
|
||||
}
|
||||
};
|
||||
|
||||
// --- Rectangle ---
|
||||
class Rectangle : public UIElement {
|
||||
bool fill = false;
|
||||
Expression fillExpr; // Dynamic fill based on expression
|
||||
Expression colorExpr;
|
||||
|
||||
public:
|
||||
Rectangle(const std::string &id) : UIElement(id) {
|
||||
colorExpr = Expression::parse("0x00");
|
||||
}
|
||||
ElementType getType() const override { return ElementType::Rectangle; }
|
||||
|
||||
void setFill(bool f) {
|
||||
fill = f;
|
||||
markDirty();
|
||||
}
|
||||
|
||||
void setFillExpr(const std::string &expr) {
|
||||
fillExpr = Expression::parse(expr);
|
||||
markDirty();
|
||||
}
|
||||
|
||||
void setColorExpr(const std::string &c) {
|
||||
colorExpr = Expression::parse(c);
|
||||
markDirty();
|
||||
}
|
||||
|
||||
void draw(const GfxRenderer &renderer, const ThemeContext &context) override {
|
||||
if (!isVisible(context))
|
||||
return;
|
||||
|
||||
std::string colStr = context.evaluatestring(colorExpr);
|
||||
uint8_t color = Color::parse(colStr).value;
|
||||
bool black = (color == 0x00);
|
||||
|
||||
bool shouldFill = fill;
|
||||
if (!fillExpr.empty()) {
|
||||
shouldFill = context.evaluateBool(fillExpr.rawExpr);
|
||||
}
|
||||
|
||||
if (shouldFill) {
|
||||
renderer.fillRect(absX, absY, absW, absH, black);
|
||||
} else {
|
||||
renderer.drawRect(absX, absY, absW, absH, black);
|
||||
}
|
||||
|
||||
markClean();
|
||||
}
|
||||
};
|
||||
|
||||
// --- Label ---
|
||||
class Label : public UIElement {
|
||||
public:
|
||||
enum class Alignment { Left, Center, Right };
|
||||
|
||||
private:
|
||||
Expression textExpr;
|
||||
int fontId = 0;
|
||||
Alignment alignment = Alignment::Left;
|
||||
Expression colorExpr;
|
||||
int maxLines = 1; // For multi-line support
|
||||
bool ellipsis = true; // Truncate with ... if too long
|
||||
|
||||
public:
|
||||
Label(const std::string &id) : UIElement(id) {
|
||||
colorExpr = Expression::parse("0x00");
|
||||
}
|
||||
ElementType getType() const override { return ElementType::Label; }
|
||||
|
||||
void setText(const std::string &expr) {
|
||||
textExpr = Expression::parse(expr);
|
||||
markDirty();
|
||||
}
|
||||
void setFont(int fid) {
|
||||
fontId = fid;
|
||||
markDirty();
|
||||
}
|
||||
void setAlignment(Alignment a) {
|
||||
alignment = a;
|
||||
markDirty();
|
||||
}
|
||||
void setCentered(bool c) {
|
||||
alignment = c ? Alignment::Center : Alignment::Left;
|
||||
markDirty();
|
||||
}
|
||||
void setColorExpr(const std::string &c) {
|
||||
colorExpr = Expression::parse(c);
|
||||
markDirty();
|
||||
}
|
||||
void setMaxLines(int lines) {
|
||||
maxLines = lines;
|
||||
markDirty();
|
||||
}
|
||||
void setEllipsis(bool e) {
|
||||
ellipsis = e;
|
||||
markDirty();
|
||||
}
|
||||
|
||||
void draw(const GfxRenderer &renderer, const ThemeContext &context) override {
|
||||
if (!isVisible(context))
|
||||
return;
|
||||
|
||||
std::string finalText = context.evaluatestring(textExpr);
|
||||
if (finalText.empty()) {
|
||||
markClean();
|
||||
return;
|
||||
}
|
||||
|
||||
std::string colStr = context.evaluatestring(colorExpr);
|
||||
uint8_t color = Color::parse(colStr).value;
|
||||
bool black = (color == 0x00);
|
||||
|
||||
int textWidth = renderer.getTextWidth(fontId, finalText.c_str());
|
||||
int lineHeight = renderer.getLineHeight(fontId);
|
||||
|
||||
// Truncate if needed
|
||||
if (ellipsis && textWidth > absW && absW > 0) {
|
||||
finalText = renderer.truncatedText(fontId, finalText.c_str(), absW);
|
||||
textWidth = renderer.getTextWidth(fontId, finalText.c_str());
|
||||
}
|
||||
|
||||
int drawX = absX;
|
||||
int drawY = absY;
|
||||
|
||||
// Vertical centering
|
||||
if (absH > 0 && lineHeight > 0) {
|
||||
drawY = absY + (absH - lineHeight) / 2;
|
||||
}
|
||||
|
||||
// Horizontal alignment
|
||||
if (alignment == Alignment::Center && absW > 0) {
|
||||
drawX = absX + (absW - textWidth) / 2;
|
||||
} else if (alignment == Alignment::Right && absW > 0) {
|
||||
drawX = absX + absW - textWidth;
|
||||
}
|
||||
|
||||
// Bounds check
|
||||
if (drawX + textWidth > renderer.getScreenWidth()) {
|
||||
markClean();
|
||||
return;
|
||||
}
|
||||
|
||||
renderer.drawText(fontId, drawX, drawY, finalText.c_str(), black);
|
||||
markClean();
|
||||
}
|
||||
};
|
||||
|
||||
// --- BitmapElement ---
|
||||
class BitmapElement : public UIElement {
|
||||
Expression srcExpr;
|
||||
bool scaleToFit = true;
|
||||
bool preserveAspect = true;
|
||||
|
||||
public:
|
||||
BitmapElement(const std::string &id) : UIElement(id) {
|
||||
cacheable = true; // Bitmaps benefit from caching
|
||||
}
|
||||
ElementType getType() const override { return ElementType::Bitmap; }
|
||||
|
||||
void setSrc(const std::string &src) {
|
||||
srcExpr = Expression::parse(src);
|
||||
invalidateCache();
|
||||
}
|
||||
|
||||
void setScaleToFit(bool scale) {
|
||||
scaleToFit = scale;
|
||||
invalidateCache();
|
||||
}
|
||||
|
||||
void setPreserveAspect(bool preserve) {
|
||||
preserveAspect = preserve;
|
||||
invalidateCache();
|
||||
}
|
||||
|
||||
void draw(const GfxRenderer &renderer, const ThemeContext &context) override;
|
||||
};
|
||||
|
||||
// --- ProgressBar ---
|
||||
class ProgressBar : public UIElement {
|
||||
Expression valueExpr; // Current value (0-100 or 0-max)
|
||||
Expression maxExpr; // Max value (default 100)
|
||||
Expression fgColorExpr; // Foreground color
|
||||
Expression bgColorExpr; // Background color
|
||||
bool showBorder = true;
|
||||
int borderWidth = 1;
|
||||
|
||||
public:
|
||||
ProgressBar(const std::string &id) : UIElement(id) {
|
||||
valueExpr = Expression::parse("0");
|
||||
maxExpr = Expression::parse("100");
|
||||
fgColorExpr = Expression::parse("0x00"); // Black fill
|
||||
bgColorExpr = Expression::parse("0xFF"); // White background
|
||||
}
|
||||
|
||||
ElementType getType() const override { return ElementType::ProgressBar; }
|
||||
|
||||
void setValue(const std::string &expr) {
|
||||
valueExpr = Expression::parse(expr);
|
||||
markDirty();
|
||||
}
|
||||
void setMax(const std::string &expr) {
|
||||
maxExpr = Expression::parse(expr);
|
||||
markDirty();
|
||||
}
|
||||
void setFgColor(const std::string &expr) {
|
||||
fgColorExpr = Expression::parse(expr);
|
||||
markDirty();
|
||||
}
|
||||
void setBgColor(const std::string &expr) {
|
||||
bgColorExpr = Expression::parse(expr);
|
||||
markDirty();
|
||||
}
|
||||
void setShowBorder(bool show) {
|
||||
showBorder = show;
|
||||
markDirty();
|
||||
}
|
||||
|
||||
void draw(const GfxRenderer &renderer, const ThemeContext &context) override {
|
||||
if (!isVisible(context))
|
||||
return;
|
||||
|
||||
std::string valStr = context.evaluatestring(valueExpr);
|
||||
std::string maxStr = context.evaluatestring(maxExpr);
|
||||
|
||||
int value = valStr.empty() ? 0 : std::stoi(valStr);
|
||||
int maxVal = maxStr.empty() ? 100 : std::stoi(maxStr);
|
||||
if (maxVal <= 0)
|
||||
maxVal = 100;
|
||||
|
||||
float ratio = static_cast<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:
|
||||
Divider(const std::string &id) : UIElement(id) {
|
||||
colorExpr = Expression::parse("0x00");
|
||||
}
|
||||
|
||||
ElementType getType() const override { return ElementType::Divider; }
|
||||
|
||||
void setColorExpr(const std::string &expr) {
|
||||
colorExpr = Expression::parse(expr);
|
||||
markDirty();
|
||||
}
|
||||
void setHorizontal(bool h) {
|
||||
horizontal = h;
|
||||
markDirty();
|
||||
}
|
||||
void setThickness(int t) {
|
||||
thickness = t;
|
||||
markDirty();
|
||||
}
|
||||
|
||||
void draw(const GfxRenderer &renderer, const ThemeContext &context) override {
|
||||
if (!isVisible(context))
|
||||
return;
|
||||
|
||||
std::string colStr = context.evaluatestring(colorExpr);
|
||||
uint8_t color = Color::parse(colStr).value;
|
||||
bool black = (color == 0x00);
|
||||
|
||||
if (horizontal) {
|
||||
for (int i = 0; i < thickness && i < absH; i++) {
|
||||
renderer.drawLine(absX, absY + i, absX + absW - 1, absY + i, black);
|
||||
}
|
||||
} else {
|
||||
for (int i = 0; i < thickness && i < absW; i++) {
|
||||
renderer.drawLine(absX + i, absY, absX + i, absY + absH - 1, black);
|
||||
}
|
||||
}
|
||||
|
||||
markClean();
|
||||
}
|
||||
};
|
||||
|
||||
} // namespace ThemeEngine
|
||||
367
lib/ThemeEngine/include/DefaultTheme.h
Normal file
367
lib/ThemeEngine/include/DefaultTheme.h
Normal file
@ -0,0 +1,367 @@
|
||||
#pragma once
|
||||
|
||||
constexpr const char *DEFAULT_THEME_INI = R"(
|
||||
; ============================================
|
||||
; Default Theme for CrossPoint Reader
|
||||
; ============================================
|
||||
|
||||
[Global]
|
||||
FontUI12 = UI_12
|
||||
FontUI10 = UI_10
|
||||
|
||||
; ============================================
|
||||
; HOME SCREEN
|
||||
; ============================================
|
||||
|
||||
[Home]
|
||||
Type = Container
|
||||
X = 0
|
||||
Y = 0
|
||||
Width = 100%
|
||||
Height = 100%
|
||||
Color = white
|
||||
|
||||
; --- Status Bar ---
|
||||
[StatusBar]
|
||||
Parent = Home
|
||||
Type = Container
|
||||
X = 0
|
||||
Y = 10
|
||||
Width = 100%
|
||||
Height = 24
|
||||
|
||||
[BatteryIcon]
|
||||
Parent = StatusBar
|
||||
Type = Icon
|
||||
Src = battery
|
||||
X = 400
|
||||
Width = 28
|
||||
Height = 18
|
||||
Color = black
|
||||
|
||||
[BatteryLabel]
|
||||
Parent = StatusBar
|
||||
Type = Label
|
||||
Font = UI_10
|
||||
Text = {BatteryPercent}%
|
||||
X = 432
|
||||
Width = 48
|
||||
Height = 20
|
||||
|
||||
; --- Recent Books Section ---
|
||||
[RecentBooksSection]
|
||||
Parent = Home
|
||||
Type = Container
|
||||
X = 0
|
||||
Y = 45
|
||||
Width = 100%
|
||||
Height = 260
|
||||
Visible = {HasRecentBooks}
|
||||
|
||||
[RecentBooksList]
|
||||
Parent = RecentBooksSection
|
||||
Type = List
|
||||
Source = RecentBooks
|
||||
ItemTemplate = RecentBookItem
|
||||
X = 15
|
||||
Y = 0
|
||||
Width = 450
|
||||
Height = 260
|
||||
Direction = Horizontal
|
||||
ItemWidth = 145
|
||||
Spacing = 10
|
||||
|
||||
; --- Recent Book Item Template ---
|
||||
[RecentBookItem]
|
||||
Type = Container
|
||||
Width = 145
|
||||
Height = 250
|
||||
|
||||
[BookCoverContainer]
|
||||
Parent = RecentBookItem
|
||||
Type = Container
|
||||
X = 0
|
||||
Y = 0
|
||||
Width = 145
|
||||
Height = 195
|
||||
Border = {Item.Selected}
|
||||
|
||||
[BookCoverImage]
|
||||
Parent = BookCoverContainer
|
||||
Type = Bitmap
|
||||
X = 2
|
||||
Y = 2
|
||||
Width = 141
|
||||
Height = 191
|
||||
Src = {Item.Image}
|
||||
Cacheable = true
|
||||
|
||||
[BookProgressBadge]
|
||||
Parent = BookCoverContainer
|
||||
Type = Badge
|
||||
X = 5
|
||||
Y = 168
|
||||
Text = {Item.Progress}%
|
||||
Font = UI_10
|
||||
BgColor = black
|
||||
FgColor = white
|
||||
PaddingH = 6
|
||||
PaddingV = 2
|
||||
|
||||
[BookTitleLabel]
|
||||
Parent = RecentBookItem
|
||||
Type = Label
|
||||
Font = UI_10
|
||||
Text = {Item.Title}
|
||||
X = 0
|
||||
Y = 200
|
||||
Width = 145
|
||||
Height = 40
|
||||
Ellipsis = true
|
||||
|
||||
; --- No Recent Books State ---
|
||||
[EmptyBooksMessage]
|
||||
Parent = Home
|
||||
Type = Label
|
||||
Font = UI_12
|
||||
Text = No recent books
|
||||
Centered = true
|
||||
X = 0
|
||||
Y = 100
|
||||
Width = 480
|
||||
Height = 30
|
||||
Visible = {!HasRecentBooks}
|
||||
|
||||
[EmptyBooksSub]
|
||||
Parent = Home
|
||||
Type = Label
|
||||
Font = UI_10
|
||||
Text = Open a book to start reading
|
||||
Centered = true
|
||||
X = 0
|
||||
Y = 130
|
||||
Width = 480
|
||||
Height = 30
|
||||
Visible = {!HasRecentBooks}
|
||||
|
||||
; --- Main Menu (2-column grid) ---
|
||||
[MainMenuList]
|
||||
Parent = Home
|
||||
Type = List
|
||||
Source = MainMenu
|
||||
ItemTemplate = MainMenuItem
|
||||
X = 15
|
||||
Y = 330
|
||||
Width = 450
|
||||
Height = 350
|
||||
Columns = 2
|
||||
ItemHeight = 70
|
||||
Spacing = 20
|
||||
|
||||
; --- Menu Item Template ---
|
||||
[MainMenuItem]
|
||||
Type = HStack
|
||||
Width = 210
|
||||
Height = 65
|
||||
Spacing = 12
|
||||
CenterVertical = true
|
||||
Border = {Item.Selected}
|
||||
|
||||
[MenuItemIcon]
|
||||
Parent = MainMenuItem
|
||||
Type = Icon
|
||||
Src = {Item.Icon}
|
||||
Width = 36
|
||||
Height = 36
|
||||
Color = black
|
||||
|
||||
[MenuItemLabel]
|
||||
Parent = MainMenuItem
|
||||
Type = Label
|
||||
Font = UI_12
|
||||
Text = {Item.Title}
|
||||
Width = 150
|
||||
Height = 40
|
||||
Color = black
|
||||
|
||||
; --- Bottom Hint Bar ---
|
||||
[HintBar]
|
||||
Parent = Home
|
||||
Type = HStack
|
||||
X = 60
|
||||
Y = 760
|
||||
Width = 360
|
||||
Height = 30
|
||||
Spacing = 80
|
||||
CenterVertical = true
|
||||
|
||||
[HintSelect]
|
||||
Parent = HintBar
|
||||
Type = Icon
|
||||
Src = check
|
||||
Width = 24
|
||||
Height = 24
|
||||
|
||||
[HintUp]
|
||||
Parent = HintBar
|
||||
Type = Icon
|
||||
Src = up
|
||||
Width = 24
|
||||
Height = 24
|
||||
|
||||
[HintDown]
|
||||
Parent = HintBar
|
||||
Type = Icon
|
||||
Src = down
|
||||
Width = 24
|
||||
Height = 24
|
||||
|
||||
; ============================================
|
||||
; SETTINGS SCREEN
|
||||
; ============================================
|
||||
|
||||
[Settings]
|
||||
Type = Container
|
||||
X = 0
|
||||
Y = 0
|
||||
Width = 100%
|
||||
Height = 100%
|
||||
Color = white
|
||||
|
||||
[SettingsTitle]
|
||||
Parent = Settings
|
||||
Type = Label
|
||||
Font = UI_12
|
||||
Text = Settings
|
||||
X = 15
|
||||
Y = 15
|
||||
Width = 200
|
||||
Height = 30
|
||||
|
||||
[SettingsTabBar]
|
||||
Parent = Settings
|
||||
Type = TabBar
|
||||
X = 0
|
||||
Y = 50
|
||||
Width = 100%
|
||||
Height = 40
|
||||
Selected = {SelectedTab}
|
||||
IndicatorHeight = 3
|
||||
ShowIndicator = true
|
||||
|
||||
[TabReading]
|
||||
Parent = SettingsTabBar
|
||||
Type = Label
|
||||
Font = UI_10
|
||||
Text = Reading
|
||||
Centered = true
|
||||
Height = 35
|
||||
|
||||
[TabControls]
|
||||
Parent = SettingsTabBar
|
||||
Type = Label
|
||||
Font = UI_10
|
||||
Text = Controls
|
||||
Centered = true
|
||||
Height = 35
|
||||
|
||||
[TabDisplay]
|
||||
Parent = SettingsTabBar
|
||||
Type = Label
|
||||
Font = UI_10
|
||||
Text = Display
|
||||
Centered = true
|
||||
Height = 35
|
||||
|
||||
[TabSystem]
|
||||
Parent = SettingsTabBar
|
||||
Type = Label
|
||||
Font = UI_10
|
||||
Text = System
|
||||
Centered = true
|
||||
Height = 35
|
||||
|
||||
[SettingsList]
|
||||
Parent = Settings
|
||||
Type = List
|
||||
Source = SettingsItems
|
||||
ItemTemplate = SettingsItem
|
||||
X = 0
|
||||
Y = 95
|
||||
Width = 450
|
||||
Height = 650
|
||||
ItemHeight = 50
|
||||
Spacing = 0
|
||||
|
||||
[SettingsScrollIndicator]
|
||||
Parent = Settings
|
||||
Type = ScrollIndicator
|
||||
X = 460
|
||||
Y = 100
|
||||
Width = 15
|
||||
Height = 640
|
||||
Position = {ScrollPosition}
|
||||
Total = {TotalItems}
|
||||
VisibleCount = {VisibleItems}
|
||||
TrackWidth = 4
|
||||
|
||||
; --- Settings Item Template ---
|
||||
[SettingsItem]
|
||||
Type = Container
|
||||
Width = 450
|
||||
Height = 48
|
||||
Border = false
|
||||
|
||||
[SettingsItemBg]
|
||||
Parent = SettingsItem
|
||||
Type = Rectangle
|
||||
X = 0
|
||||
Y = 0
|
||||
Width = 450
|
||||
Height = 45
|
||||
Fill = {Item.Selected}
|
||||
Color = black
|
||||
|
||||
[SettingsItemLabel]
|
||||
Parent = SettingsItem
|
||||
Type = Label
|
||||
Font = UI_10
|
||||
Text = {Item.Title}
|
||||
X = 15
|
||||
Y = 0
|
||||
Width = 250
|
||||
Height = 45
|
||||
Color = {Item.Selected ? white : black}
|
||||
|
||||
[SettingsItemValue]
|
||||
Parent = SettingsItem
|
||||
Type = Label
|
||||
Font = UI_10
|
||||
Text = {Item.Value}
|
||||
X = 270
|
||||
Y = 0
|
||||
Width = 120
|
||||
Height = 45
|
||||
Align = Right
|
||||
Color = {Item.Selected ? white : black}
|
||||
|
||||
[SettingsItemToggle]
|
||||
Parent = SettingsItem
|
||||
Type = Toggle
|
||||
X = 390
|
||||
Y = 8
|
||||
Width = 50
|
||||
Height = 30
|
||||
Value = {Item.ToggleValue}
|
||||
Visible = {Item.HasToggle}
|
||||
|
||||
[SettingsItemDivider]
|
||||
Parent = SettingsItem
|
||||
Type = Divider
|
||||
X = 15
|
||||
Y = 46
|
||||
Width = 420
|
||||
Height = 1
|
||||
Horizontal = true
|
||||
Color = 0x80
|
||||
)";
|
||||
40
lib/ThemeEngine/include/IniParser.h
Normal file
40
lib/ThemeEngine/include/IniParser.h
Normal file
@ -0,0 +1,40 @@
|
||||
#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
|
||||
543
lib/ThemeEngine/include/LayoutElements.h
Normal file
543
lib/ThemeEngine/include/LayoutElements.h
Normal file
@ -0,0 +1,543 @@
|
||||
#pragma once
|
||||
|
||||
#include "BasicElements.h"
|
||||
#include "ThemeContext.h"
|
||||
#include "ThemeTypes.h"
|
||||
#include "UIElement.h"
|
||||
#include <vector>
|
||||
|
||||
namespace ThemeEngine {
|
||||
|
||||
// --- HStack: Horizontal Stack Layout ---
|
||||
// Children are arranged horizontally with optional spacing
|
||||
class HStack : public Container {
|
||||
int spacing = 0; // Gap between children
|
||||
int padding = 0; // Internal padding
|
||||
bool centerVertical = false;
|
||||
|
||||
public:
|
||||
HStack(const std::string &id) : Container(id) {}
|
||||
ElementType getType() const override { return ElementType::HStack; }
|
||||
|
||||
void setSpacing(int s) {
|
||||
spacing = s;
|
||||
markDirty();
|
||||
}
|
||||
void setPadding(int p) {
|
||||
padding = p;
|
||||
markDirty();
|
||||
}
|
||||
void setCenterVertical(bool c) {
|
||||
centerVertical = c;
|
||||
markDirty();
|
||||
}
|
||||
|
||||
void layout(const ThemeContext &context, int parentX, int parentY,
|
||||
int parentW, int parentH) override {
|
||||
UIElement::layout(context, parentX, parentY, parentW, parentH);
|
||||
|
||||
int currentX = absX + padding;
|
||||
int availableH = absH - 2 * padding;
|
||||
int availableW = absW - 2 * padding;
|
||||
|
||||
for (auto child : children) {
|
||||
// Let child calculate its preferred size first
|
||||
// Pass large parent bounds to avoid clamping issues during size calculation
|
||||
child->layout(context, currentX, absY + padding, availableW, availableH);
|
||||
int childW = child->getAbsW();
|
||||
int childH = child->getAbsH();
|
||||
|
||||
// Re-layout with proper position
|
||||
int childY = absY + padding;
|
||||
if (centerVertical && childH < availableH) {
|
||||
childY = absY + padding + (availableH - childH) / 2;
|
||||
}
|
||||
|
||||
child->layout(context, currentX, childY, childW, childH);
|
||||
currentX += childW + spacing;
|
||||
availableW -= (childW + spacing);
|
||||
if (availableW < 0) availableW = 0;
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
// --- VStack: Vertical Stack Layout ---
|
||||
// Children are arranged vertically with optional spacing
|
||||
class VStack : public Container {
|
||||
int spacing = 0;
|
||||
int padding = 0;
|
||||
bool centerHorizontal = false;
|
||||
|
||||
public:
|
||||
VStack(const std::string &id) : Container(id) {}
|
||||
ElementType getType() const override { return ElementType::VStack; }
|
||||
|
||||
void setSpacing(int s) {
|
||||
spacing = s;
|
||||
markDirty();
|
||||
}
|
||||
void setPadding(int p) {
|
||||
padding = p;
|
||||
markDirty();
|
||||
}
|
||||
void setCenterHorizontal(bool c) {
|
||||
centerHorizontal = c;
|
||||
markDirty();
|
||||
}
|
||||
|
||||
void layout(const ThemeContext &context, int parentX, int parentY,
|
||||
int parentW, int parentH) override {
|
||||
UIElement::layout(context, parentX, parentY, parentW, parentH);
|
||||
|
||||
int currentY = absY + padding;
|
||||
int availableW = absW - 2 * padding;
|
||||
int availableH = absH - 2 * padding;
|
||||
|
||||
for (auto child : children) {
|
||||
// Pass large parent bounds to avoid clamping issues during size calculation
|
||||
child->layout(context, absX + padding, currentY, availableW, availableH);
|
||||
int childW = child->getAbsW();
|
||||
int childH = child->getAbsH();
|
||||
|
||||
int childX = absX + padding;
|
||||
if (centerHorizontal && childW < availableW) {
|
||||
childX = absX + padding + (availableW - childW) / 2;
|
||||
}
|
||||
|
||||
child->layout(context, childX, currentY, childW, childH);
|
||||
currentY += childH + spacing;
|
||||
availableH -= (childH + spacing);
|
||||
if (availableH < 0) availableH = 0;
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
// --- Grid: Grid Layout ---
|
||||
// Children arranged in a grid with specified columns
|
||||
class Grid : public Container {
|
||||
int columns = 2;
|
||||
int rowSpacing = 10;
|
||||
int colSpacing = 10;
|
||||
int padding = 0;
|
||||
|
||||
public:
|
||||
Grid(const std::string &id) : Container(id) {}
|
||||
ElementType getType() const override { return ElementType::Grid; }
|
||||
|
||||
void setColumns(int c) {
|
||||
columns = c > 0 ? c : 1;
|
||||
markDirty();
|
||||
}
|
||||
void setRowSpacing(int s) {
|
||||
rowSpacing = s;
|
||||
markDirty();
|
||||
}
|
||||
void setColSpacing(int s) {
|
||||
colSpacing = s;
|
||||
markDirty();
|
||||
}
|
||||
void setPadding(int p) {
|
||||
padding = p;
|
||||
markDirty();
|
||||
}
|
||||
|
||||
void layout(const ThemeContext &context, int parentX, int parentY,
|
||||
int parentW, int parentH) override {
|
||||
UIElement::layout(context, parentX, parentY, parentW, parentH);
|
||||
|
||||
if (children.empty())
|
||||
return;
|
||||
|
||||
int availableW = absW - 2 * padding - (columns - 1) * colSpacing;
|
||||
int cellW = availableW / columns;
|
||||
int availableH = absH - 2 * padding;
|
||||
|
||||
int row = 0, col = 0;
|
||||
int currentY = absY + padding;
|
||||
int maxRowHeight = 0;
|
||||
|
||||
for (auto child : children) {
|
||||
int cellX = absX + padding + col * (cellW + colSpacing);
|
||||
|
||||
// Pass cell dimensions to avoid clamping issues
|
||||
child->layout(context, cellX, currentY, cellW, availableH);
|
||||
int childH = child->getAbsH();
|
||||
if (childH > maxRowHeight)
|
||||
maxRowHeight = childH;
|
||||
|
||||
col++;
|
||||
if (col >= columns) {
|
||||
col = 0;
|
||||
row++;
|
||||
currentY += maxRowHeight + rowSpacing;
|
||||
availableH -= (maxRowHeight + rowSpacing);
|
||||
if (availableH < 0) availableH = 0;
|
||||
maxRowHeight = 0;
|
||||
}
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
// --- Badge: Small overlay text/indicator ---
|
||||
class Badge : public UIElement {
|
||||
Expression textExpr;
|
||||
Expression bgColorExpr;
|
||||
Expression fgColorExpr;
|
||||
int fontId = 0;
|
||||
int paddingH = 8; // Horizontal padding
|
||||
int paddingV = 4; // Vertical padding
|
||||
int cornerRadius = 0;
|
||||
|
||||
public:
|
||||
Badge(const std::string &id) : UIElement(id) {
|
||||
bgColorExpr = Expression::parse("0x00"); // Black background
|
||||
fgColorExpr = Expression::parse("0xFF"); // White text
|
||||
}
|
||||
|
||||
ElementType getType() const override { return ElementType::Badge; }
|
||||
|
||||
void setText(const std::string &expr) {
|
||||
textExpr = Expression::parse(expr);
|
||||
markDirty();
|
||||
}
|
||||
void setBgColor(const std::string &expr) {
|
||||
bgColorExpr = Expression::parse(expr);
|
||||
markDirty();
|
||||
}
|
||||
void setFgColor(const std::string &expr) {
|
||||
fgColorExpr = Expression::parse(expr);
|
||||
markDirty();
|
||||
}
|
||||
void setFont(int fid) {
|
||||
fontId = fid;
|
||||
markDirty();
|
||||
}
|
||||
void setPaddingH(int p) {
|
||||
paddingH = p;
|
||||
markDirty();
|
||||
}
|
||||
void setPaddingV(int p) {
|
||||
paddingV = p;
|
||||
markDirty();
|
||||
}
|
||||
|
||||
void draw(const GfxRenderer &renderer, const ThemeContext &context) override {
|
||||
if (!isVisible(context))
|
||||
return;
|
||||
|
||||
std::string text = context.evaluatestring(textExpr);
|
||||
if (text.empty()) {
|
||||
markClean();
|
||||
return;
|
||||
}
|
||||
|
||||
// Calculate badge size based on text
|
||||
int textW = renderer.getTextWidth(fontId, text.c_str());
|
||||
int textH = renderer.getLineHeight(fontId);
|
||||
int badgeW = textW + 2 * paddingH;
|
||||
int badgeH = textH + 2 * paddingV;
|
||||
|
||||
// Use absX, absY as position, but we may auto-size
|
||||
if (absW == 0)
|
||||
absW = badgeW;
|
||||
if (absH == 0)
|
||||
absH = badgeH;
|
||||
|
||||
// Draw background
|
||||
std::string bgStr = context.evaluatestring(bgColorExpr);
|
||||
uint8_t bgColor = Color::parse(bgStr).value;
|
||||
renderer.fillRect(absX, absY, absW, absH, bgColor == 0x00);
|
||||
|
||||
// Draw border for contrast
|
||||
renderer.drawRect(absX, absY, absW, absH, bgColor != 0x00);
|
||||
|
||||
// Draw text centered
|
||||
std::string fgStr = context.evaluatestring(fgColorExpr);
|
||||
uint8_t fgColor = Color::parse(fgStr).value;
|
||||
int textX = absX + (absW - textW) / 2;
|
||||
int textY = absY + (absH - textH) / 2;
|
||||
renderer.drawText(fontId, textX, textY, text.c_str(), fgColor == 0x00);
|
||||
|
||||
markClean();
|
||||
}
|
||||
};
|
||||
|
||||
// --- Toggle: On/Off Switch ---
|
||||
class Toggle : public UIElement {
|
||||
Expression valueExpr; // Boolean expression
|
||||
Expression onColorExpr;
|
||||
Expression offColorExpr;
|
||||
int trackWidth = 44;
|
||||
int trackHeight = 24;
|
||||
int knobSize = 20;
|
||||
|
||||
public:
|
||||
Toggle(const std::string &id) : UIElement(id) {
|
||||
valueExpr = Expression::parse("false");
|
||||
onColorExpr = Expression::parse("0x00"); // Black when on
|
||||
offColorExpr = Expression::parse("0xFF"); // White when off
|
||||
}
|
||||
|
||||
ElementType getType() const override { return ElementType::Toggle; }
|
||||
|
||||
void setValue(const std::string &expr) {
|
||||
valueExpr = Expression::parse(expr);
|
||||
markDirty();
|
||||
}
|
||||
void setOnColor(const std::string &expr) {
|
||||
onColorExpr = Expression::parse(expr);
|
||||
markDirty();
|
||||
}
|
||||
void setOffColor(const std::string &expr) {
|
||||
offColorExpr = Expression::parse(expr);
|
||||
markDirty();
|
||||
}
|
||||
void setTrackWidth(int w) {
|
||||
trackWidth = w;
|
||||
markDirty();
|
||||
}
|
||||
void setTrackHeight(int h) {
|
||||
trackHeight = h;
|
||||
markDirty();
|
||||
}
|
||||
void setKnobSize(int s) {
|
||||
knobSize = s;
|
||||
markDirty();
|
||||
}
|
||||
|
||||
void draw(const GfxRenderer &renderer, const ThemeContext &context) override {
|
||||
if (!isVisible(context))
|
||||
return;
|
||||
|
||||
bool isOn = context.evaluateBool(valueExpr.rawExpr);
|
||||
|
||||
// Get colors
|
||||
std::string colorStr =
|
||||
isOn ? context.evaluatestring(onColorExpr) : context.evaluatestring(offColorExpr);
|
||||
uint8_t trackColor = Color::parse(colorStr).value;
|
||||
|
||||
// Draw track
|
||||
int trackX = absX;
|
||||
int trackY = absY + (absH - trackHeight) / 2;
|
||||
renderer.fillRect(trackX, trackY, trackWidth, trackHeight, trackColor == 0x00);
|
||||
renderer.drawRect(trackX, trackY, trackWidth, trackHeight, true);
|
||||
|
||||
// Draw knob
|
||||
int knobMargin = (trackHeight - knobSize) / 2;
|
||||
int knobX = isOn ? (trackX + trackWidth - knobSize - knobMargin)
|
||||
: (trackX + knobMargin);
|
||||
int knobY = trackY + knobMargin;
|
||||
|
||||
// Knob is opposite color of track
|
||||
renderer.fillRect(knobX, knobY, knobSize, knobSize, trackColor != 0x00);
|
||||
renderer.drawRect(knobX, knobY, knobSize, knobSize, true);
|
||||
|
||||
markClean();
|
||||
}
|
||||
};
|
||||
|
||||
// --- TabBar: Horizontal tab selection ---
|
||||
class TabBar : public Container {
|
||||
Expression selectedExpr; // Currently selected tab index or name
|
||||
int tabSpacing = 0;
|
||||
int padding = 0;
|
||||
int indicatorHeight = 3;
|
||||
bool showIndicator = true;
|
||||
|
||||
public:
|
||||
TabBar(const std::string &id) : Container(id) {}
|
||||
ElementType getType() const override { return ElementType::TabBar; }
|
||||
|
||||
void setSelected(const std::string &expr) {
|
||||
selectedExpr = Expression::parse(expr);
|
||||
markDirty();
|
||||
}
|
||||
void setTabSpacing(int s) {
|
||||
tabSpacing = s;
|
||||
markDirty();
|
||||
}
|
||||
void setPadding(int p) {
|
||||
padding = p;
|
||||
markDirty();
|
||||
}
|
||||
void setIndicatorHeight(int h) {
|
||||
indicatorHeight = h;
|
||||
markDirty();
|
||||
}
|
||||
void setShowIndicator(bool show) {
|
||||
showIndicator = show;
|
||||
markDirty();
|
||||
}
|
||||
|
||||
void layout(const ThemeContext &context, int parentX, int parentY,
|
||||
int parentW, int parentH) override {
|
||||
UIElement::layout(context, parentX, parentY, parentW, parentH);
|
||||
|
||||
if (children.empty())
|
||||
return;
|
||||
|
||||
// Distribute tabs evenly
|
||||
int numTabs = children.size();
|
||||
int totalSpacing = (numTabs - 1) * tabSpacing;
|
||||
int availableW = absW - 2 * padding - totalSpacing;
|
||||
int tabW = availableW / numTabs;
|
||||
int currentX = absX + padding;
|
||||
|
||||
for (size_t i = 0; i < children.size(); i++) {
|
||||
children[i]->layout(context, currentX, absY, tabW, absH - indicatorHeight);
|
||||
currentX += tabW + tabSpacing;
|
||||
}
|
||||
}
|
||||
|
||||
void draw(const GfxRenderer &renderer, const ThemeContext &context) override {
|
||||
if (!isVisible(context))
|
||||
return;
|
||||
|
||||
// Draw background if set
|
||||
if (hasBg) {
|
||||
std::string colStr = context.evaluatestring(bgColorExpr);
|
||||
uint8_t color = Color::parse(colStr).value;
|
||||
renderer.fillRect(absX, absY, absW, absH, color == 0x00);
|
||||
}
|
||||
|
||||
// Draw children (tab labels)
|
||||
for (auto child : children) {
|
||||
child->draw(renderer, context);
|
||||
}
|
||||
|
||||
// Draw selection indicator
|
||||
if (showIndicator && !children.empty()) {
|
||||
std::string selStr = context.evaluatestring(selectedExpr);
|
||||
int selectedIdx = 0;
|
||||
if (!selStr.empty()) {
|
||||
// Try to parse as number
|
||||
try {
|
||||
selectedIdx = std::stoi(selStr);
|
||||
} catch (...) {
|
||||
selectedIdx = 0;
|
||||
}
|
||||
}
|
||||
|
||||
if (selectedIdx >= 0 && selectedIdx < static_cast<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);
|
||||
}
|
||||
}
|
||||
|
||||
// Draw bottom border
|
||||
renderer.drawLine(absX, absY + absH - 1, absX + absW - 1, absY + absH - 1, true);
|
||||
|
||||
markClean();
|
||||
}
|
||||
};
|
||||
|
||||
// --- Icon: Small symbolic image ---
|
||||
// Can be a built-in icon name or a path to a BMP
|
||||
class Icon : public UIElement {
|
||||
Expression srcExpr; // Icon name or path
|
||||
Expression colorExpr;
|
||||
int iconSize = 24;
|
||||
|
||||
// Built-in icon names and their simple representations
|
||||
// In a real implementation, these would be actual bitmap data
|
||||
|
||||
public:
|
||||
Icon(const std::string &id) : UIElement(id) {
|
||||
colorExpr = Expression::parse("0x00"); // Black by default
|
||||
}
|
||||
|
||||
ElementType getType() const override { return ElementType::Icon; }
|
||||
|
||||
void setSrc(const std::string &expr) {
|
||||
srcExpr = Expression::parse(expr);
|
||||
markDirty();
|
||||
}
|
||||
void setColorExpr(const std::string &expr) {
|
||||
colorExpr = Expression::parse(expr);
|
||||
markDirty();
|
||||
}
|
||||
void setIconSize(int s) {
|
||||
iconSize = s;
|
||||
markDirty();
|
||||
}
|
||||
|
||||
void draw(const GfxRenderer &renderer, const ThemeContext &context) override;
|
||||
};
|
||||
|
||||
// --- ScrollIndicator: Visual scroll position ---
|
||||
class ScrollIndicator : public UIElement {
|
||||
Expression positionExpr; // 0.0 to 1.0
|
||||
Expression totalExpr; // Total items
|
||||
Expression visibleExpr; // Visible items
|
||||
int trackWidth = 4;
|
||||
|
||||
public:
|
||||
ScrollIndicator(const std::string &id) : UIElement(id) {
|
||||
positionExpr = Expression::parse("0");
|
||||
totalExpr = Expression::parse("1");
|
||||
visibleExpr = Expression::parse("1");
|
||||
}
|
||||
|
||||
ElementType getType() const override { return ElementType::ScrollIndicator; }
|
||||
|
||||
void setPosition(const std::string &expr) {
|
||||
positionExpr = Expression::parse(expr);
|
||||
markDirty();
|
||||
}
|
||||
void setTotal(const std::string &expr) {
|
||||
totalExpr = Expression::parse(expr);
|
||||
markDirty();
|
||||
}
|
||||
void setVisibleCount(const std::string &expr) {
|
||||
visibleExpr = Expression::parse(expr);
|
||||
markDirty();
|
||||
}
|
||||
void setTrackWidth(int w) {
|
||||
trackWidth = w;
|
||||
markDirty();
|
||||
}
|
||||
|
||||
void draw(const GfxRenderer &renderer, const ThemeContext &context) override {
|
||||
if (!isVisible(context))
|
||||
return;
|
||||
|
||||
// Get values
|
||||
std::string posStr = context.evaluatestring(positionExpr);
|
||||
std::string totalStr = context.evaluatestring(totalExpr);
|
||||
std::string visStr = context.evaluatestring(visibleExpr);
|
||||
|
||||
float position = posStr.empty() ? 0 : std::stof(posStr);
|
||||
int total = totalStr.empty() ? 1 : std::stoi(totalStr);
|
||||
int visible = visStr.empty() ? 1 : std::stoi(visStr);
|
||||
|
||||
if (total <= visible) {
|
||||
// No need to show scrollbar
|
||||
markClean();
|
||||
return;
|
||||
}
|
||||
|
||||
// Draw track
|
||||
int trackX = absX + (absW - trackWidth) / 2;
|
||||
renderer.drawRect(trackX, absY, trackWidth, absH, true);
|
||||
|
||||
// Calculate thumb size and position
|
||||
float ratio = static_cast<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
|
||||
147
lib/ThemeEngine/include/ListElement.h
Normal file
147
lib/ThemeEngine/include/ListElement.h
Normal file
@ -0,0 +1,147 @@
|
||||
#pragma once
|
||||
|
||||
#include "BasicElements.h"
|
||||
#include "UIElement.h"
|
||||
#include <map>
|
||||
#include <vector>
|
||||
|
||||
namespace ThemeEngine {
|
||||
|
||||
// --- List ---
|
||||
// Supports vertical, horizontal, and grid layouts
|
||||
class List : public Container {
|
||||
public:
|
||||
enum class Direction { Vertical, Horizontal };
|
||||
enum class LayoutMode { List, Grid };
|
||||
|
||||
private:
|
||||
std::string source; // Data source name (e.g., "MainMenu", "FileList")
|
||||
std::string itemTemplateId; // ID of the template element
|
||||
int itemWidth = 0; // Explicit item width (0 = auto)
|
||||
int itemHeight = 0; // Explicit item height (0 = auto from template)
|
||||
int scrollOffset = 0; // Scroll position for long lists
|
||||
int visibleItems = -1; // Max visible items (-1 = auto)
|
||||
int spacing = 0; // Gap between items
|
||||
int columns = 1; // Number of columns (for grid mode)
|
||||
Direction direction = Direction::Vertical;
|
||||
LayoutMode layoutMode = LayoutMode::List;
|
||||
|
||||
// Template element reference (resolved after loading)
|
||||
UIElement *itemTemplate = nullptr;
|
||||
|
||||
public:
|
||||
List(const std::string &id) : Container(id) {}
|
||||
|
||||
ElementType getType() const override { return ElementType::List; }
|
||||
|
||||
void setSource(const std::string &s) {
|
||||
source = s;
|
||||
markDirty();
|
||||
}
|
||||
|
||||
const std::string &getSource() const { return source; }
|
||||
|
||||
void setItemTemplateId(const std::string &id) {
|
||||
itemTemplateId = id;
|
||||
markDirty();
|
||||
}
|
||||
|
||||
void setItemTemplate(UIElement *elem) {
|
||||
itemTemplate = elem;
|
||||
markDirty();
|
||||
}
|
||||
|
||||
UIElement *getItemTemplate() const { return itemTemplate; }
|
||||
|
||||
void setItemWidth(int w) {
|
||||
itemWidth = w;
|
||||
markDirty();
|
||||
}
|
||||
|
||||
void setItemHeight(int h) {
|
||||
itemHeight = h;
|
||||
markDirty();
|
||||
}
|
||||
|
||||
int getItemHeight() const {
|
||||
if (itemHeight > 0)
|
||||
return itemHeight;
|
||||
if (itemTemplate)
|
||||
return itemTemplate->getAbsH() > 0 ? itemTemplate->getAbsH() : 45;
|
||||
return 45;
|
||||
}
|
||||
|
||||
int getItemWidth() const {
|
||||
if (itemWidth > 0)
|
||||
return itemWidth;
|
||||
if (itemTemplate)
|
||||
return itemTemplate->getAbsW() > 0 ? itemTemplate->getAbsW() : 100;
|
||||
return 100;
|
||||
}
|
||||
|
||||
void setScrollOffset(int offset) {
|
||||
scrollOffset = offset;
|
||||
markDirty();
|
||||
}
|
||||
|
||||
int getScrollOffset() const { return scrollOffset; }
|
||||
|
||||
void setVisibleItems(int count) {
|
||||
visibleItems = count;
|
||||
markDirty();
|
||||
}
|
||||
|
||||
void setSpacing(int s) {
|
||||
spacing = s;
|
||||
markDirty();
|
||||
}
|
||||
|
||||
void setColumns(int c) {
|
||||
columns = c > 0 ? c : 1;
|
||||
if (columns > 1)
|
||||
layoutMode = LayoutMode::Grid;
|
||||
markDirty();
|
||||
}
|
||||
|
||||
void setDirection(Direction d) {
|
||||
direction = d;
|
||||
markDirty();
|
||||
}
|
||||
|
||||
void setDirectionFromString(const std::string &dir) {
|
||||
if (dir == "Horizontal" || dir == "horizontal" || dir == "row") {
|
||||
direction = Direction::Horizontal;
|
||||
} else {
|
||||
direction = Direction::Vertical;
|
||||
}
|
||||
markDirty();
|
||||
}
|
||||
|
||||
void setLayoutMode(LayoutMode m) {
|
||||
layoutMode = m;
|
||||
markDirty();
|
||||
}
|
||||
|
||||
// Resolve template reference from element map
|
||||
void resolveTemplate(const std::map<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 parent dimensions to get its height
|
||||
if (itemTemplate && itemHeight == 0) {
|
||||
itemTemplate->layout(context, absX, absY, absW, 0);
|
||||
}
|
||||
}
|
||||
|
||||
// Draw is implemented in BasicElements.cpp
|
||||
void draw(const GfxRenderer &renderer, const ThemeContext &context) override;
|
||||
};
|
||||
|
||||
} // namespace ThemeEngine
|
||||
382
lib/ThemeEngine/include/ThemeContext.h
Normal file
382
lib/ThemeEngine/include/ThemeContext.h
Normal file
@ -0,0 +1,382 @@
|
||||
#pragma once
|
||||
|
||||
#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();
|
||||
}
|
||||
|
||||
public:
|
||||
ThemeContext(const ThemeContext *parent = nullptr) : parent(parent) {}
|
||||
|
||||
void setString(const std::string &key, const std::string &value) {
|
||||
strings[key] = value;
|
||||
}
|
||||
void setInt(const std::string &key, int value) { ints[key] = value; }
|
||||
void setBool(const std::string &key, bool value) { bools[key] = value; }
|
||||
|
||||
std::string getString(const std::string &key,
|
||||
const std::string &defaultValue = "") const {
|
||||
auto it = strings.find(key);
|
||||
if (it != strings.end())
|
||||
return it->second;
|
||||
if (parent)
|
||||
return parent->getString(key, defaultValue);
|
||||
return defaultValue;
|
||||
}
|
||||
|
||||
int getInt(const std::string &key, int defaultValue = 0) const {
|
||||
auto it = ints.find(key);
|
||||
if (it != ints.end())
|
||||
return it->second;
|
||||
if (parent)
|
||||
return parent->getInt(key, defaultValue);
|
||||
return defaultValue;
|
||||
}
|
||||
|
||||
bool getBool(const std::string &key, bool defaultValue = false) const {
|
||||
auto it = bools.find(key);
|
||||
if (it != bools.end())
|
||||
return it->second;
|
||||
if (parent)
|
||||
return parent->getBool(key, defaultValue);
|
||||
return defaultValue;
|
||||
}
|
||||
|
||||
bool hasKey(const std::string &key) const {
|
||||
if (strings.count(key) || ints.count(key) || bools.count(key))
|
||||
return true;
|
||||
if (parent)
|
||||
return parent->hasKey(key);
|
||||
return false;
|
||||
}
|
||||
|
||||
// Get any value as string
|
||||
std::string getAnyAsString(const std::string &key) const {
|
||||
// Check strings first
|
||||
auto sit = strings.find(key);
|
||||
if (sit != strings.end())
|
||||
return sit->second;
|
||||
|
||||
// Check ints
|
||||
auto iit = ints.find(key);
|
||||
if (iit != ints.end())
|
||||
return std::to_string(iit->second);
|
||||
|
||||
// Check bools
|
||||
auto bit = bools.find(key);
|
||||
if (bit != bools.end())
|
||||
return bit->second ? "true" : "false";
|
||||
|
||||
// Check parent
|
||||
if (parent)
|
||||
return parent->getAnyAsString(key);
|
||||
|
||||
return "";
|
||||
}
|
||||
|
||||
// Evaluate a complex boolean expression
|
||||
// Supports: !, &&, ||, ==, !=, <, >, <=, >=, parentheses
|
||||
bool evaluateBool(const std::string &expression) const {
|
||||
std::string expr = trim(expression);
|
||||
if (expr.empty())
|
||||
return false;
|
||||
|
||||
// Handle literal true/false
|
||||
if (expr == "true" || expr == "1")
|
||||
return true;
|
||||
if (expr == "false" || expr == "0")
|
||||
return false;
|
||||
|
||||
// Handle {var} wrapper
|
||||
if (expr.size() > 2 && expr.front() == '{' && expr.back() == '}') {
|
||||
expr = expr.substr(1, expr.size() - 2);
|
||||
}
|
||||
|
||||
// Handle negation
|
||||
if (!expr.empty() && expr[0] == '!') {
|
||||
return !evaluateBool(expr.substr(1));
|
||||
}
|
||||
|
||||
// Handle parentheses
|
||||
if (!expr.empty() && expr[0] == '(') {
|
||||
int depth = 1;
|
||||
size_t closePos = 1;
|
||||
while (closePos < expr.length() && depth > 0) {
|
||||
if (expr[closePos] == '(')
|
||||
depth++;
|
||||
if (expr[closePos] == ')')
|
||||
depth--;
|
||||
closePos++;
|
||||
}
|
||||
if (closePos <= expr.length()) {
|
||||
std::string inner = expr.substr(1, closePos - 2);
|
||||
std::string rest = trim(expr.substr(closePos));
|
||||
bool innerResult = evaluateBool(inner);
|
||||
|
||||
// Check for && or ||
|
||||
if (rest.length() >= 2 && rest.substr(0, 2) == "&&") {
|
||||
return innerResult && evaluateBool(rest.substr(2));
|
||||
}
|
||||
if (rest.length() >= 2 && rest.substr(0, 2) == "||") {
|
||||
return innerResult || evaluateBool(rest.substr(2));
|
||||
}
|
||||
return innerResult;
|
||||
}
|
||||
}
|
||||
|
||||
// Handle && and || (lowest precedence)
|
||||
size_t andPos = expr.find("&&");
|
||||
size_t orPos = expr.find("||");
|
||||
|
||||
// Process || first (lower precedence than &&)
|
||||
if (orPos != std::string::npos &&
|
||||
(andPos == std::string::npos || orPos < andPos)) {
|
||||
return evaluateBool(expr.substr(0, orPos)) ||
|
||||
evaluateBool(expr.substr(orPos + 2));
|
||||
}
|
||||
if (andPos != std::string::npos) {
|
||||
return evaluateBool(expr.substr(0, andPos)) &&
|
||||
evaluateBool(expr.substr(andPos + 2));
|
||||
}
|
||||
|
||||
// Handle comparisons
|
||||
size_t eqPos = expr.find("==");
|
||||
if (eqPos != std::string::npos) {
|
||||
std::string left = trim(expr.substr(0, eqPos));
|
||||
std::string right = trim(expr.substr(eqPos + 2));
|
||||
return compareValues(left, right) == 0;
|
||||
}
|
||||
|
||||
size_t nePos = expr.find("!=");
|
||||
if (nePos != std::string::npos) {
|
||||
std::string left = trim(expr.substr(0, nePos));
|
||||
std::string right = trim(expr.substr(nePos + 2));
|
||||
return compareValues(left, right) != 0;
|
||||
}
|
||||
|
||||
size_t gePos = expr.find(">=");
|
||||
if (gePos != std::string::npos) {
|
||||
std::string left = trim(expr.substr(0, gePos));
|
||||
std::string right = trim(expr.substr(gePos + 2));
|
||||
return compareValues(left, right) >= 0;
|
||||
}
|
||||
|
||||
size_t lePos = expr.find("<=");
|
||||
if (lePos != std::string::npos) {
|
||||
std::string left = trim(expr.substr(0, lePos));
|
||||
std::string right = trim(expr.substr(lePos + 2));
|
||||
return compareValues(left, right) <= 0;
|
||||
}
|
||||
|
||||
size_t gtPos = expr.find('>');
|
||||
if (gtPos != std::string::npos) {
|
||||
std::string left = trim(expr.substr(0, gtPos));
|
||||
std::string right = trim(expr.substr(gtPos + 1));
|
||||
return compareValues(left, right) > 0;
|
||||
}
|
||||
|
||||
size_t ltPos = expr.find('<');
|
||||
if (ltPos != std::string::npos) {
|
||||
std::string left = trim(expr.substr(0, ltPos));
|
||||
std::string right = trim(expr.substr(ltPos + 1));
|
||||
return compareValues(left, right) < 0;
|
||||
}
|
||||
|
||||
// Simple variable lookup
|
||||
return getBool(expr, false);
|
||||
}
|
||||
|
||||
// Compare two values (handles variables, numbers, strings)
|
||||
int compareValues(const std::string &left, const std::string &right) const {
|
||||
std::string leftVal = resolveValue(left);
|
||||
std::string rightVal = resolveValue(right);
|
||||
|
||||
// Try numeric comparison
|
||||
if (isNumber(leftVal) && isNumber(rightVal)) {
|
||||
int l = std::stoi(leftVal);
|
||||
int r = std::stoi(rightVal);
|
||||
return (l < r) ? -1 : (l > r) ? 1 : 0;
|
||||
}
|
||||
|
||||
// String comparison
|
||||
return leftVal.compare(rightVal);
|
||||
}
|
||||
|
||||
// Resolve a value (variable name -> value, or literal)
|
||||
std::string resolveValue(const std::string &val) const {
|
||||
std::string v = trim(val);
|
||||
|
||||
// Remove quotes for string literals
|
||||
if (v.size() >= 2 && v.front() == '"' && v.back() == '"') {
|
||||
return v.substr(1, v.size() - 2);
|
||||
}
|
||||
if (v.size() >= 2 && v.front() == '\'' && v.back() == '\'') {
|
||||
return v.substr(1, v.size() - 2);
|
||||
}
|
||||
|
||||
// If it's a number, return as-is
|
||||
if (isNumber(v))
|
||||
return v;
|
||||
|
||||
// Check for hex color literals (0x00, 0xFF, etc.)
|
||||
if (v.size() > 2 && v[0] == '0' && (v[1] == 'x' || v[1] == 'X')) {
|
||||
return v;
|
||||
}
|
||||
|
||||
// Check for known color names - return as-is
|
||||
if (v == "black" || v == "white" || v == "gray" || v == "grey") {
|
||||
return v;
|
||||
}
|
||||
|
||||
// Check for boolean literals
|
||||
if (v == "true" || v == "false") {
|
||||
return v;
|
||||
}
|
||||
|
||||
// Try to look up as variable
|
||||
if (hasKey(v)) {
|
||||
return getAnyAsString(v);
|
||||
}
|
||||
|
||||
// Return as literal if not found as variable
|
||||
return v;
|
||||
}
|
||||
|
||||
// Evaluate a string expression with variable substitution
|
||||
std::string evaluatestring(const Expression &expr) const {
|
||||
if (expr.empty())
|
||||
return "";
|
||||
|
||||
std::string result;
|
||||
for (const auto &token : expr.tokens) {
|
||||
if (token.type == ExpressionToken::LITERAL) {
|
||||
result += token.value;
|
||||
} else {
|
||||
// Variable lookup - check for comparison expressions inside
|
||||
std::string varName = token.value;
|
||||
|
||||
// If the variable contains comparison operators, evaluate as condition
|
||||
if (varName.find("==") != std::string::npos ||
|
||||
varName.find("!=") != std::string::npos ||
|
||||
varName.find("&&") != std::string::npos ||
|
||||
varName.find("||") != std::string::npos) {
|
||||
result += evaluateBool(varName) ? "true" : "false";
|
||||
continue;
|
||||
}
|
||||
|
||||
// Handle ternary: condition ? trueVal : falseVal
|
||||
size_t qPos = varName.find('?');
|
||||
if (qPos != std::string::npos) {
|
||||
size_t cPos = varName.find(':', qPos);
|
||||
if (cPos != std::string::npos) {
|
||||
std::string condition = trim(varName.substr(0, qPos));
|
||||
std::string trueVal = trim(varName.substr(qPos + 1, cPos - qPos - 1));
|
||||
std::string falseVal = trim(varName.substr(cPos + 1));
|
||||
|
||||
bool condResult = evaluateBool(condition);
|
||||
result += resolveValue(condResult ? trueVal : falseVal);
|
||||
continue;
|
||||
}
|
||||
}
|
||||
|
||||
// Normal variable lookup
|
||||
std::string strVal = getAnyAsString(varName);
|
||||
result += strVal;
|
||||
}
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
// Legacy method for backward compatibility
|
||||
std::string evaluateString(const std::string &expression) const {
|
||||
if (expression.empty())
|
||||
return "";
|
||||
Expression expr = Expression::parse(expression);
|
||||
return evaluatestring(expr);
|
||||
}
|
||||
};
|
||||
|
||||
} // namespace ThemeEngine
|
||||
124
lib/ThemeEngine/include/ThemeManager.h
Normal file
124
lib/ThemeEngine/include/ThemeManager.h
Normal file
@ -0,0 +1,124 @@
|
||||
#pragma once
|
||||
|
||||
#include "BasicElements.h"
|
||||
#include "IniParser.h"
|
||||
#include "ThemeContext.h"
|
||||
#include <GfxRenderer.h>
|
||||
#include <map>
|
||||
#include <string>
|
||||
#include <vector>
|
||||
|
||||
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() {
|
||||
if (buffer) {
|
||||
free(buffer);
|
||||
buffer = nullptr;
|
||||
}
|
||||
}
|
||||
|
||||
void invalidate() { valid = false; }
|
||||
};
|
||||
|
||||
class ThemeManager {
|
||||
private:
|
||||
std::map<std::string, UIElement *> elements; // All elements by ID
|
||||
std::string currentThemeName;
|
||||
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; }
|
||||
|
||||
// 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);
|
||||
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
|
||||
85
lib/ThemeEngine/include/ThemeTypes.h
Normal file
85
lib/ThemeEngine/include/ThemeTypes.h
Normal file
@ -0,0 +1,85 @@
|
||||
#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);
|
||||
|
||||
if (str.back() == '%') {
|
||||
return Dimension(std::stoi(str.substr(0, str.length() - 1)),
|
||||
DimensionUnit::PERCENT);
|
||||
}
|
||||
return Dimension(std::stoi(str), DimensionUnit::PIXELS);
|
||||
}
|
||||
|
||||
int resolve(int parentSize) const {
|
||||
if (unit == DimensionUnit::PERCENT) {
|
||||
return (parentSize * value) / 100;
|
||||
}
|
||||
return value;
|
||||
}
|
||||
};
|
||||
|
||||
struct Color {
|
||||
uint8_t value; // For E-Ink: 0 (Black) to 255 (White), or simplified palette
|
||||
|
||||
Color(uint8_t v) : value(v) {}
|
||||
Color() : value(0) {}
|
||||
|
||||
static Color parse(const std::string &str) {
|
||||
if (str.empty())
|
||||
return Color(0);
|
||||
if (str == "black")
|
||||
return Color(0x00);
|
||||
if (str == "white")
|
||||
return Color(0xFF);
|
||||
if (str == "gray" || str == "grey")
|
||||
return Color(0x80);
|
||||
if (str.size() > 2 && str.substr(0, 2) == "0x") {
|
||||
return Color((uint8_t)std::strtol(str.c_str(), nullptr, 16));
|
||||
}
|
||||
return Color((uint8_t)std::stoi(str));
|
||||
}
|
||||
};
|
||||
|
||||
// Rect structure for dirty regions
|
||||
struct Rect {
|
||||
int x, y, w, h;
|
||||
|
||||
Rect() : x(0), y(0), w(0), h(0) {}
|
||||
Rect(int x, int y, int w, int h) : x(x), y(y), w(w), h(h) {}
|
||||
|
||||
bool isEmpty() const { return w <= 0 || h <= 0; }
|
||||
|
||||
bool intersects(const Rect &other) const {
|
||||
return !(x + w <= other.x || other.x + other.w <= x || y + h <= other.y ||
|
||||
other.y + other.h <= y);
|
||||
}
|
||||
|
||||
Rect unite(const Rect &other) const {
|
||||
if (isEmpty())
|
||||
return other;
|
||||
if (other.isEmpty())
|
||||
return *this;
|
||||
int nx = std::min(x, other.x);
|
||||
int ny = std::min(y, other.y);
|
||||
int nx2 = std::max(x + w, other.x + other.w);
|
||||
int ny2 = std::max(y + h, other.y + other.h);
|
||||
return Rect(nx, ny, nx2 - nx, ny2 - ny);
|
||||
}
|
||||
};
|
||||
|
||||
} // namespace ThemeEngine
|
||||
204
lib/ThemeEngine/include/UIElement.h
Normal file
204
lib/ThemeEngine/include/UIElement.h
Normal file
@ -0,0 +1,204 @@
|
||||
#pragma once
|
||||
|
||||
#include "ThemeContext.h"
|
||||
#include "ThemeTypes.h"
|
||||
#include <GfxRenderer.h>
|
||||
#include <string>
|
||||
#include <vector>
|
||||
|
||||
namespace ThemeEngine {
|
||||
|
||||
class Container; // Forward declaration
|
||||
|
||||
class UIElement {
|
||||
public:
|
||||
int getAbsX() const { return absX; }
|
||||
int getAbsY() const { return absY; }
|
||||
int getAbsW() const { return absW; }
|
||||
int getAbsH() const { return absH; }
|
||||
const std::string &getId() const { return id; }
|
||||
|
||||
protected:
|
||||
std::string id;
|
||||
Dimension x, y, width, height;
|
||||
Expression visibleExpr;
|
||||
bool visibleExprIsStatic = true; // True if visibility doesn't depend on data
|
||||
|
||||
// Recomputed every layout pass
|
||||
int absX = 0, absY = 0, absW = 0, absH = 0;
|
||||
|
||||
// Caching support
|
||||
bool cacheable = false; // Set true for expensive elements like bitmaps
|
||||
bool cacheValid = false; // Is the cached render still valid?
|
||||
uint8_t *cachedRender = nullptr;
|
||||
size_t cachedRenderSize = 0;
|
||||
int cachedX = 0, cachedY = 0, cachedW = 0, cachedH = 0;
|
||||
|
||||
// Dirty tracking
|
||||
bool dirty = true; // Needs redraw
|
||||
|
||||
bool isVisible(const ThemeContext &context) const {
|
||||
if (visibleExpr.empty())
|
||||
return true;
|
||||
return context.evaluateBool(visibleExpr.rawExpr);
|
||||
}
|
||||
|
||||
public:
|
||||
UIElement(const std::string &id) : id(id) {
|
||||
visibleExpr = Expression::parse("true");
|
||||
}
|
||||
|
||||
virtual ~UIElement() {
|
||||
if (cachedRender) {
|
||||
free(cachedRender);
|
||||
cachedRender = nullptr;
|
||||
}
|
||||
}
|
||||
|
||||
void setX(Dimension val) {
|
||||
x = val;
|
||||
markDirty();
|
||||
}
|
||||
void setY(Dimension val) {
|
||||
y = val;
|
||||
markDirty();
|
||||
}
|
||||
void setWidth(Dimension val) {
|
||||
width = val;
|
||||
markDirty();
|
||||
}
|
||||
void setHeight(Dimension val) {
|
||||
height = val;
|
||||
markDirty();
|
||||
}
|
||||
void setVisibleExpr(const std::string &expr) {
|
||||
visibleExpr = Expression::parse(expr);
|
||||
// Check if expression contains variables
|
||||
visibleExprIsStatic = (expr == "true" || expr == "false" || expr == "1" ||
|
||||
expr == "0" || expr.find('{') == std::string::npos);
|
||||
markDirty();
|
||||
}
|
||||
|
||||
void setCacheable(bool val) { cacheable = val; }
|
||||
bool isCacheable() const { return cacheable; }
|
||||
|
||||
virtual void markDirty() {
|
||||
dirty = true;
|
||||
cacheValid = false;
|
||||
}
|
||||
|
||||
void markClean() { dirty = false; }
|
||||
bool isDirty() const { return dirty; }
|
||||
|
||||
// Invalidate cache (called when dependent data changes)
|
||||
void invalidateCache() {
|
||||
cacheValid = false;
|
||||
dirty = true;
|
||||
}
|
||||
|
||||
// Calculate absolute position based on parent
|
||||
virtual void layout(const ThemeContext &context, int parentX, int parentY,
|
||||
int parentW, int parentH) {
|
||||
int newX = parentX + x.resolve(parentW);
|
||||
int newY = parentY + y.resolve(parentH);
|
||||
int newW = width.resolve(parentW);
|
||||
int newH = height.resolve(parentH);
|
||||
|
||||
// Clamp to parent bounds
|
||||
if (newX >= parentX + parentW)
|
||||
newX = parentX + parentW - 1;
|
||||
if (newY >= parentY + parentH)
|
||||
newY = parentY + parentH - 1;
|
||||
|
||||
int maxX = parentX + parentW;
|
||||
int maxY = parentY + parentH;
|
||||
|
||||
if (newX + newW > maxX)
|
||||
newW = maxX - newX;
|
||||
if (newY + newH > maxY)
|
||||
newH = maxY - newY;
|
||||
|
||||
if (newW < 0)
|
||||
newW = 0;
|
||||
if (newH < 0)
|
||||
newH = 0;
|
||||
|
||||
// Check if position changed
|
||||
if (newX != absX || newY != absY || newW != absW || newH != absH) {
|
||||
absX = newX;
|
||||
absY = newY;
|
||||
absW = newW;
|
||||
absH = newH;
|
||||
markDirty();
|
||||
}
|
||||
}
|
||||
|
||||
virtual Container *asContainer() { return nullptr; }
|
||||
|
||||
enum class ElementType {
|
||||
Base,
|
||||
Container,
|
||||
Rectangle,
|
||||
Label,
|
||||
Bitmap,
|
||||
List,
|
||||
ProgressBar,
|
||||
Divider,
|
||||
// Layout elements
|
||||
HStack,
|
||||
VStack,
|
||||
Grid,
|
||||
// Advanced elements
|
||||
Badge,
|
||||
Toggle,
|
||||
TabBar,
|
||||
Icon,
|
||||
ScrollIndicator
|
||||
};
|
||||
|
||||
virtual ElementType getType() const { return ElementType::Base; }
|
||||
|
||||
int getLayoutHeight() const { return absH; }
|
||||
int getLayoutWidth() const { return absW; }
|
||||
|
||||
// Get bounding rect for this element
|
||||
Rect getBounds() const { return Rect(absX, absY, absW, absH); }
|
||||
|
||||
// Main draw method - handles caching automatically
|
||||
virtual void draw(const GfxRenderer &renderer,
|
||||
const ThemeContext &context) = 0;
|
||||
|
||||
protected:
|
||||
// Cache the rendered output
|
||||
bool cacheRender(const GfxRenderer &renderer) {
|
||||
if (cachedRender) {
|
||||
free(cachedRender);
|
||||
cachedRender = nullptr;
|
||||
}
|
||||
|
||||
cachedRender = renderer.captureRegion(absX, absY, absW, absH, &cachedRenderSize);
|
||||
if (cachedRender) {
|
||||
cachedX = absX;
|
||||
cachedY = absY;
|
||||
cachedW = absW;
|
||||
cachedH = absH;
|
||||
cacheValid = true;
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
// Restore from cache
|
||||
bool restoreFromCache(const GfxRenderer &renderer) const {
|
||||
if (!cacheValid || !cachedRender)
|
||||
return false;
|
||||
if (absX != cachedX || absY != cachedY || absW != cachedW ||
|
||||
absH != cachedH)
|
||||
return false;
|
||||
|
||||
renderer.restoreRegion(cachedRender, absX, absY, absW, absH);
|
||||
return true;
|
||||
}
|
||||
};
|
||||
|
||||
} // namespace ThemeEngine
|
||||
228
lib/ThemeEngine/src/BasicElements.cpp
Normal file
228
lib/ThemeEngine/src/BasicElements.cpp
Normal file
@ -0,0 +1,228 @@
|
||||
#include "BasicElements.h"
|
||||
#include "Bitmap.h"
|
||||
#include "ListElement.h"
|
||||
#include "ThemeManager.h"
|
||||
#include "ThemeTypes.h"
|
||||
|
||||
namespace ThemeEngine {
|
||||
|
||||
// --- BitmapElement ---
|
||||
void BitmapElement::draw(const GfxRenderer &renderer,
|
||||
const ThemeContext &context) {
|
||||
if (!isVisible(context)) {
|
||||
markClean();
|
||||
return;
|
||||
}
|
||||
|
||||
std::string path = context.evaluatestring(srcExpr);
|
||||
if (path.empty()) {
|
||||
markClean();
|
||||
return;
|
||||
}
|
||||
|
||||
// 1. Try Processed Cache in ThemeManager (keyed by path + target dimensions)
|
||||
const ProcessedAsset *processed =
|
||||
ThemeManager::get().getProcessedAsset(path, renderer.getOrientation(), absW, absH);
|
||||
if (processed && processed->w == absW && processed->h == absH) {
|
||||
renderer.draw2BitImage(processed->data.data(), absX, absY, absW, absH);
|
||||
markClean();
|
||||
return;
|
||||
}
|
||||
|
||||
// 2. Try raw asset cache, then process and cache
|
||||
const std::vector<uint8_t> *data = ThemeManager::get().getCachedAsset(path);
|
||||
if (!data || data->empty()) {
|
||||
markClean();
|
||||
return;
|
||||
}
|
||||
|
||||
Bitmap bmp(data->data(), data->size());
|
||||
if (bmp.parseHeaders() != BmpReaderError::Ok) {
|
||||
markClean();
|
||||
return;
|
||||
}
|
||||
|
||||
// Draw the bitmap (handles scaling internally)
|
||||
renderer.drawBitmap(bmp, absX, absY, absW, absH);
|
||||
|
||||
// After drawing, capture the rendered region and cache it for next time
|
||||
ProcessedAsset asset;
|
||||
asset.w = absW;
|
||||
asset.h = absH;
|
||||
asset.orientation = renderer.getOrientation();
|
||||
|
||||
// Capture the rendered region from framebuffer
|
||||
uint8_t *frameBuffer = renderer.getFrameBuffer();
|
||||
if (frameBuffer) {
|
||||
const int screenW = renderer.getScreenWidth();
|
||||
const int bytesPerRow = (absW + 3) / 4;
|
||||
asset.data.resize(bytesPerRow * absH);
|
||||
|
||||
for (int y = 0; y < absH; y++) {
|
||||
int srcOffset = ((absY + y) * screenW + absX) / 4;
|
||||
int dstOffset = y * bytesPerRow;
|
||||
// Copy 2-bit packed pixels
|
||||
for (int x = 0; x < absW; x++) {
|
||||
int sx = absX + x;
|
||||
int srcByteIdx = ((absY + y) * screenW + sx) / 4;
|
||||
int srcBitIdx = (sx % 4) * 2;
|
||||
int dstByteIdx = dstOffset + x / 4;
|
||||
int dstBitIdx = (x % 4) * 2;
|
||||
|
||||
uint8_t pixel = (frameBuffer[srcByteIdx] >> (6 - srcBitIdx)) & 0x03;
|
||||
asset.data[dstByteIdx] &= ~(0x03 << (6 - dstBitIdx));
|
||||
asset.data[dstByteIdx] |= (pixel << (6 - dstBitIdx));
|
||||
}
|
||||
}
|
||||
|
||||
ThemeManager::get().cacheProcessedAsset(path, asset, absW, absH);
|
||||
}
|
||||
|
||||
markClean();
|
||||
}
|
||||
|
||||
// --- List ---
|
||||
void List::draw(const GfxRenderer &renderer, const ThemeContext &context) {
|
||||
if (!isVisible(context)) {
|
||||
markClean();
|
||||
return;
|
||||
}
|
||||
|
||||
// Draw background
|
||||
if (hasBg) {
|
||||
std::string colStr = context.evaluatestring(bgColorExpr);
|
||||
uint8_t color = Color::parse(colStr).value;
|
||||
renderer.fillRect(absX, absY, absW, absH, color == 0x00);
|
||||
}
|
||||
if (border) {
|
||||
renderer.drawRect(absX, absY, absW, absH, true);
|
||||
}
|
||||
|
||||
if (!itemTemplate) {
|
||||
markClean();
|
||||
return;
|
||||
}
|
||||
|
||||
int count = context.getInt(source + ".Count");
|
||||
if (count <= 0) {
|
||||
markClean();
|
||||
return;
|
||||
}
|
||||
|
||||
// Get item dimensions
|
||||
int itemW = getItemWidth();
|
||||
int itemH = getItemHeight();
|
||||
|
||||
// Handle different layout modes
|
||||
if (direction == Direction::Horizontal || layoutMode == LayoutMode::Grid) {
|
||||
// Horizontal or Grid layout
|
||||
int col = 0;
|
||||
int row = 0;
|
||||
int currentX = absX;
|
||||
int currentY = absY;
|
||||
|
||||
// For grid, calculate item width based on columns
|
||||
if (layoutMode == LayoutMode::Grid && columns > 1) {
|
||||
int totalSpacing = (columns - 1) * spacing;
|
||||
itemW = (absW - totalSpacing) / columns;
|
||||
}
|
||||
|
||||
for (int i = 0; i < count; ++i) {
|
||||
// Create item context with scoped variables
|
||||
ThemeContext itemContext(&context);
|
||||
std::string prefix = source + "." + std::to_string(i) + ".";
|
||||
|
||||
// Standard list item variables
|
||||
itemContext.setString("Item.Title", context.getString(prefix + "Title"));
|
||||
itemContext.setString("Item.Value", context.getString(prefix + "Value"));
|
||||
itemContext.setBool("Item.Selected", context.getBool(prefix + "Selected"));
|
||||
itemContext.setString("Item.Icon", context.getString(prefix + "Icon"));
|
||||
itemContext.setString("Item.Image", context.getString(prefix + "Image"));
|
||||
itemContext.setString("Item.Progress", context.getString(prefix + "Progress"));
|
||||
itemContext.setInt("Item.Index", i);
|
||||
itemContext.setInt("Item.Count", count);
|
||||
|
||||
// Viewport check
|
||||
if (direction == Direction::Horizontal) {
|
||||
if (currentX + itemW < absX) {
|
||||
currentX += itemW + spacing;
|
||||
continue;
|
||||
}
|
||||
if (currentX > absX + absW)
|
||||
break;
|
||||
} else {
|
||||
// Grid mode
|
||||
if (currentY + itemH < absY) {
|
||||
col++;
|
||||
if (col >= columns) {
|
||||
col = 0;
|
||||
row++;
|
||||
currentY += itemH + spacing;
|
||||
}
|
||||
currentX = absX + col * (itemW + spacing);
|
||||
continue;
|
||||
}
|
||||
if (currentY > absY + absH)
|
||||
break;
|
||||
}
|
||||
|
||||
// Layout and draw
|
||||
itemTemplate->layout(itemContext, currentX, currentY, itemW, itemH);
|
||||
itemTemplate->draw(renderer, itemContext);
|
||||
|
||||
if (layoutMode == LayoutMode::Grid && columns > 1) {
|
||||
col++;
|
||||
if (col >= columns) {
|
||||
col = 0;
|
||||
row++;
|
||||
currentX = absX;
|
||||
currentY += itemH + spacing;
|
||||
} else {
|
||||
currentX += itemW + spacing;
|
||||
}
|
||||
} else {
|
||||
// Horizontal list
|
||||
currentX += itemW + spacing;
|
||||
}
|
||||
}
|
||||
} else {
|
||||
// Vertical list (default)
|
||||
int currentY = absY;
|
||||
int viewportBottom = absY + absH;
|
||||
|
||||
for (int i = 0; i < count; ++i) {
|
||||
// Skip items above viewport
|
||||
if (currentY + itemH < absY) {
|
||||
currentY += itemH + spacing;
|
||||
continue;
|
||||
}
|
||||
// Stop if below viewport
|
||||
if (currentY > viewportBottom) {
|
||||
break;
|
||||
}
|
||||
|
||||
// Create item context with scoped variables
|
||||
ThemeContext itemContext(&context);
|
||||
std::string prefix = source + "." + std::to_string(i) + ".";
|
||||
|
||||
itemContext.setString("Item.Title", context.getString(prefix + "Title"));
|
||||
itemContext.setString("Item.Value", context.getString(prefix + "Value"));
|
||||
itemContext.setBool("Item.Selected", context.getBool(prefix + "Selected"));
|
||||
itemContext.setString("Item.Icon", context.getString(prefix + "Icon"));
|
||||
itemContext.setString("Item.Image", context.getString(prefix + "Image"));
|
||||
itemContext.setString("Item.Progress", context.getString(prefix + "Progress"));
|
||||
itemContext.setInt("Item.Index", i);
|
||||
itemContext.setInt("Item.Count", count);
|
||||
|
||||
// Layout and draw the template for this item
|
||||
itemTemplate->layout(itemContext, absX, currentY, absW, itemH);
|
||||
itemTemplate->draw(renderer, itemContext);
|
||||
|
||||
currentY += itemH + spacing;
|
||||
}
|
||||
}
|
||||
|
||||
markClean();
|
||||
}
|
||||
|
||||
} // namespace ThemeEngine
|
||||
104
lib/ThemeEngine/src/IniParser.cpp
Normal file
104
lib/ThemeEngine/src/IniParser.cpp
Normal file
@ -0,0 +1,104 @@
|
||||
#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;
|
||||
// stream check not strictly possible like file, can rely on available()
|
||||
|
||||
std::string currentSection = "";
|
||||
String line; // Use Arduino String for easy file reading, then convert to
|
||||
// std::string
|
||||
|
||||
while (stream.available()) {
|
||||
line = stream.readStringUntil('\n');
|
||||
std::string sLine = line.c_str();
|
||||
trim(sLine);
|
||||
|
||||
if (sLine.empty() || sLine[0] == ';' || sLine[0] == '#') {
|
||||
continue; // Skip comments and empty lines
|
||||
}
|
||||
|
||||
if (sLine.front() == '[' && sLine.back() == ']') {
|
||||
currentSection = sLine.substr(1, sLine.size() - 2);
|
||||
trim(currentSection);
|
||||
} else {
|
||||
size_t eqPos = sLine.find('=');
|
||||
if (eqPos != std::string::npos) {
|
||||
std::string key = sLine.substr(0, eqPos);
|
||||
std::string value = sLine.substr(eqPos + 1);
|
||||
trim(key);
|
||||
trim(value);
|
||||
|
||||
// Remove quotes if present
|
||||
if (value.size() >= 2 && value.front() == '"' && value.back() == '"') {
|
||||
value = value.substr(1, value.size() - 2);
|
||||
}
|
||||
|
||||
if (!currentSection.empty()) {
|
||||
sections[currentSection][key] = value;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
return sections;
|
||||
}
|
||||
|
||||
std::map<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
|
||||
173
lib/ThemeEngine/src/LayoutElements.cpp
Normal file
173
lib/ThemeEngine/src/LayoutElements.cpp
Normal file
@ -0,0 +1,173 @@
|
||||
#include "LayoutElements.h"
|
||||
#include "ThemeManager.h"
|
||||
#include <Bitmap.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;
|
||||
bool black = (color == 0x00);
|
||||
|
||||
// Use absW/absH if set, otherwise use iconSize
|
||||
int w = absW > 0 ? absW : iconSize;
|
||||
int h = absH > 0 ? absH : iconSize;
|
||||
int cx = absX + w / 2;
|
||||
int cy = absY + h / 2;
|
||||
|
||||
// Check if it's a path to a BMP file
|
||||
if (iconName.find('/') != std::string::npos ||
|
||||
iconName.find('.') != std::string::npos) {
|
||||
// Try to load as bitmap
|
||||
std::string path = iconName;
|
||||
if (path[0] != '/') {
|
||||
path = ThemeManager::get().getAssetPath(iconName);
|
||||
}
|
||||
|
||||
const std::vector<uint8_t> *data = ThemeManager::get().getCachedAsset(path);
|
||||
if (data && !data->empty()) {
|
||||
Bitmap bmp(data->data(), data->size());
|
||||
if (bmp.parseHeaders() == BmpReaderError::Ok) {
|
||||
renderer.drawBitmap(bmp, absX, absY, w, h);
|
||||
markClean();
|
||||
return;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Built-in icons (simple geometric shapes)
|
||||
if (iconName == "heart" || iconName == "favorite") {
|
||||
// Simple heart shape approximation
|
||||
int s = w / 4;
|
||||
renderer.fillRect(cx - s, cy - s / 2, s * 2, s, black);
|
||||
renderer.fillRect(cx - s * 3 / 2, cy - s, s, s, black);
|
||||
renderer.fillRect(cx + s / 2, cy - s, s, s, black);
|
||||
// Bottom point
|
||||
for (int i = 0; i < s; i++) {
|
||||
renderer.drawLine(cx - s + i, cy + i, cx + s - i, cy + i, black);
|
||||
}
|
||||
} else if (iconName == "book" || iconName == "books") {
|
||||
// Book icon
|
||||
int bw = w * 2 / 3;
|
||||
int bh = h * 3 / 4;
|
||||
int bx = absX + (w - bw) / 2;
|
||||
int by = absY + (h - bh) / 2;
|
||||
renderer.drawRect(bx, by, bw, bh, black);
|
||||
renderer.drawLine(bx + bw / 3, by, bx + bw / 3, by + bh - 1, black);
|
||||
// Pages
|
||||
renderer.drawLine(bx + 2, by + bh / 4, bx + bw / 3 - 2, by + bh / 4, black);
|
||||
renderer.drawLine(bx + 2, by + bh / 2, bx + bw / 3 - 2, by + bh / 2, black);
|
||||
} else if (iconName == "folder" || iconName == "files") {
|
||||
// Folder icon
|
||||
int fw = w * 3 / 4;
|
||||
int fh = h * 2 / 3;
|
||||
int fx = absX + (w - fw) / 2;
|
||||
int fy = absY + (h - fh) / 2;
|
||||
// Tab
|
||||
renderer.fillRect(fx, fy, fw / 3, fh / 6, black);
|
||||
// Body
|
||||
renderer.drawRect(fx, fy + fh / 6, fw, fh - fh / 6, black);
|
||||
} else if (iconName == "settings" || iconName == "gear") {
|
||||
// Gear icon - simplified as circle with notches
|
||||
int r = w / 3;
|
||||
// Draw circle approximation
|
||||
renderer.drawRect(cx - r, cy - r, r * 2, r * 2, black);
|
||||
// Inner circle
|
||||
int ir = r / 2;
|
||||
renderer.drawRect(cx - ir, cy - ir, ir * 2, ir * 2, black);
|
||||
// Teeth
|
||||
int t = r / 3;
|
||||
renderer.fillRect(cx - t / 2, absY, t, r - ir, black);
|
||||
renderer.fillRect(cx - t / 2, cy + r, t, r - ir, black);
|
||||
renderer.fillRect(absX, cy - t / 2, r - ir, t, black);
|
||||
renderer.fillRect(cx + r, cy - t / 2, r - ir, t, black);
|
||||
} else if (iconName == "transfer" || iconName == "arrow" || iconName == "send") {
|
||||
// Arrow pointing right
|
||||
int aw = w / 2;
|
||||
int ah = h / 3;
|
||||
int ax = absX + w / 4;
|
||||
int ay = cy - ah / 2;
|
||||
// Shaft
|
||||
renderer.fillRect(ax, ay, aw, ah, black);
|
||||
// Arrow head
|
||||
for (int i = 0; i < ah; i++) {
|
||||
renderer.drawLine(ax + aw, cy - ah + i, ax + aw + ah - i, cy, black);
|
||||
renderer.drawLine(ax + aw, cy + ah - i, ax + aw + ah - i, cy, black);
|
||||
}
|
||||
} else if (iconName == "library" || iconName == "device") {
|
||||
// Device/tablet icon
|
||||
int dw = w * 2 / 3;
|
||||
int dh = h * 3 / 4;
|
||||
int dx = absX + (w - dw) / 2;
|
||||
int dy = absY + (h - dh) / 2;
|
||||
renderer.drawRect(dx, dy, dw, dh, black);
|
||||
// Screen
|
||||
renderer.drawRect(dx + 2, dy + 2, dw - 4, dh - 8, black);
|
||||
// Home button
|
||||
renderer.fillRect(dx + dw / 2 - 2, dy + dh - 5, 4, 2, black);
|
||||
} else if (iconName == "battery") {
|
||||
// Battery icon
|
||||
int bw = w * 3 / 4;
|
||||
int bh = h / 2;
|
||||
int bx = absX + (w - bw) / 2;
|
||||
int by = absY + (h - bh) / 2;
|
||||
renderer.drawRect(bx, by, bw - 3, bh, black);
|
||||
renderer.fillRect(bx + bw - 3, by + bh / 4, 3, bh / 2, black);
|
||||
} else if (iconName == "check" || iconName == "checkmark") {
|
||||
// Checkmark
|
||||
int x1 = absX + w / 4;
|
||||
int y1 = cy;
|
||||
int x2 = cx;
|
||||
int y2 = absY + h * 3 / 4;
|
||||
int x3 = absX + w * 3 / 4;
|
||||
int y3 = absY + h / 4;
|
||||
renderer.drawLine(x1, y1, x2, y2, black);
|
||||
renderer.drawLine(x2, y2, x3, y3, black);
|
||||
// Thicken
|
||||
renderer.drawLine(x1, y1 + 1, x2, y2 + 1, black);
|
||||
renderer.drawLine(x2, y2 + 1, x3, y3 + 1, black);
|
||||
} else if (iconName == "back" || iconName == "left") {
|
||||
// Left arrow
|
||||
int s = w / 3;
|
||||
for (int i = 0; i < s; i++) {
|
||||
renderer.drawLine(cx - s + i, cy, cx, cy - s + i, black);
|
||||
renderer.drawLine(cx - s + i, cy, cx, cy + s - i, black);
|
||||
}
|
||||
} else if (iconName == "up") {
|
||||
// Up arrow
|
||||
int s = h / 3;
|
||||
for (int i = 0; i < s; i++) {
|
||||
renderer.drawLine(cx, cy - s + i, cx - s + i, cy, black);
|
||||
renderer.drawLine(cx, cy - s + i, cx + s - i, cy, black);
|
||||
}
|
||||
} else if (iconName == "down") {
|
||||
// Down arrow
|
||||
int s = h / 3;
|
||||
for (int i = 0; i < s; i++) {
|
||||
renderer.drawLine(cx, cy + s - i, cx - s + i, cy, black);
|
||||
renderer.drawLine(cx, cy + s - i, cx + s - i, cy, black);
|
||||
}
|
||||
} else {
|
||||
// Unknown icon - draw placeholder
|
||||
renderer.drawRect(absX, absY, w, h, black);
|
||||
renderer.drawLine(absX, absY, absX + w - 1, absY + h - 1, black);
|
||||
renderer.drawLine(absX + w - 1, absY, absX, absY + h - 1, black);
|
||||
}
|
||||
|
||||
markClean();
|
||||
}
|
||||
|
||||
} // namespace ThemeEngine
|
||||
582
lib/ThemeEngine/src/ThemeManager.cpp
Normal file
582
lib/ThemeEngine/src/ThemeManager.cpp
Normal file
@ -0,0 +1,582 @@
|
||||
#include "ThemeManager.h"
|
||||
#include "DefaultTheme.h"
|
||||
#include "LayoutElements.h"
|
||||
#include "ListElement.h"
|
||||
#include <SDCardManager.h>
|
||||
#include <algorithm>
|
||||
#include <map>
|
||||
#include <vector>
|
||||
|
||||
namespace ThemeEngine {
|
||||
|
||||
void ThemeManager::begin() {
|
||||
// Default fonts or setup
|
||||
}
|
||||
|
||||
void ThemeManager::registerFont(const std::string &name, int id) {
|
||||
fontMap[name] = id;
|
||||
}
|
||||
|
||||
std::string ThemeManager::getAssetPath(const std::string &assetName) {
|
||||
// Check if absolute path
|
||||
if (!assetName.empty() && assetName[0] == '/')
|
||||
return assetName;
|
||||
|
||||
// Otherwise relative to theme assets
|
||||
return "/themes/" + currentThemeName + "/assets/" + assetName;
|
||||
}
|
||||
|
||||
UIElement *ThemeManager::createElement(const std::string &id,
|
||||
const std::string &type) {
|
||||
// Basic elements
|
||||
if (type == "Container")
|
||||
return new Container(id);
|
||||
if (type == "Rectangle")
|
||||
return new Rectangle(id);
|
||||
if (type == "Label")
|
||||
return new Label(id);
|
||||
if (type == "Bitmap")
|
||||
return new BitmapElement(id);
|
||||
if (type == "List")
|
||||
return new List(id);
|
||||
if (type == "ProgressBar")
|
||||
return new ProgressBar(id);
|
||||
if (type == "Divider")
|
||||
return new Divider(id);
|
||||
|
||||
// Layout elements
|
||||
if (type == "HStack")
|
||||
return new HStack(id);
|
||||
if (type == "VStack")
|
||||
return new VStack(id);
|
||||
if (type == "Grid")
|
||||
return new Grid(id);
|
||||
|
||||
// Advanced elements
|
||||
if (type == "Badge")
|
||||
return new Badge(id);
|
||||
if (type == "Toggle")
|
||||
return new Toggle(id);
|
||||
if (type == "TabBar")
|
||||
return new TabBar(id);
|
||||
if (type == "Icon")
|
||||
return new Icon(id);
|
||||
if (type == "ScrollIndicator")
|
||||
return new ScrollIndicator(id);
|
||||
|
||||
return nullptr;
|
||||
}
|
||||
|
||||
void ThemeManager::applyProperties(
|
||||
UIElement *elem, const std::map<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);
|
||||
}
|
||||
}
|
||||
|
||||
// ========== 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::HStack) {
|
||||
static_cast<HStack *>(elem)->setPadding(std::stoi(val));
|
||||
} else if (elemType == UIElement::ElementType::VStack) {
|
||||
static_cast<VStack *>(elem)->setPadding(std::stoi(val));
|
||||
} else if (elemType == UIElement::ElementType::Grid) {
|
||||
static_cast<Grid *>(elem)->setPadding(std::stoi(val));
|
||||
} else if (elemType == UIElement::ElementType::TabBar) {
|
||||
static_cast<TabBar *>(elem)->setPadding(std::stoi(val));
|
||||
}
|
||||
} else if (key == "Spacing") {
|
||||
if (elemType == UIElement::ElementType::HStack) {
|
||||
static_cast<HStack *>(elem)->setSpacing(std::stoi(val));
|
||||
} else if (elemType == UIElement::ElementType::VStack) {
|
||||
static_cast<VStack *>(elem)->setSpacing(std::stoi(val));
|
||||
} else if (elemType == UIElement::ElementType::List) {
|
||||
static_cast<List *>(elem)->setSpacing(std::stoi(val));
|
||||
}
|
||||
} else if (key == "CenterVertical") {
|
||||
if (elemType == UIElement::ElementType::HStack) {
|
||||
static_cast<HStack *>(elem)->setCenterVertical(val == "true" || val == "1");
|
||||
}
|
||||
} else if (key == "CenterHorizontal") {
|
||||
if (elemType == UIElement::ElementType::VStack) {
|
||||
static_cast<VStack *>(elem)->setCenterHorizontal(val == "true" || val == "1");
|
||||
}
|
||||
}
|
||||
|
||||
// ========== 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(std::stoi(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(std::stoi(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(std::stoi(val));
|
||||
}
|
||||
} else if (key == "ItemWidth") {
|
||||
if (elemType == UIElement::ElementType::List) {
|
||||
static_cast<List *>(elem)->setItemWidth(std::stoi(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(std::stoi(val));
|
||||
} else if (elemType == UIElement::ElementType::Grid) {
|
||||
static_cast<Grid *>(elem)->setColumns(std::stoi(val));
|
||||
}
|
||||
} else if (key == "RowSpacing") {
|
||||
if (elemType == UIElement::ElementType::Grid) {
|
||||
static_cast<Grid *>(elem)->setRowSpacing(std::stoi(val));
|
||||
}
|
||||
} else if (key == "ColSpacing") {
|
||||
if (elemType == UIElement::ElementType::Grid) {
|
||||
static_cast<Grid *>(elem)->setColSpacing(std::stoi(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 (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 (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(std::stoi(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 == "TrackWidth") {
|
||||
if (elemType == UIElement::ElementType::Toggle) {
|
||||
static_cast<Toggle *>(elem)->setTrackWidth(std::stoi(val));
|
||||
} else if (elemType == UIElement::ElementType::ScrollIndicator) {
|
||||
static_cast<ScrollIndicator *>(elem)->setTrackWidth(std::stoi(val));
|
||||
}
|
||||
} else if (key == "TrackHeight") {
|
||||
if (elemType == UIElement::ElementType::Toggle) {
|
||||
static_cast<Toggle *>(elem)->setTrackHeight(std::stoi(val));
|
||||
}
|
||||
} else if (key == "KnobSize") {
|
||||
if (elemType == UIElement::ElementType::Toggle) {
|
||||
static_cast<Toggle *>(elem)->setKnobSize(std::stoi(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(std::stoi(val));
|
||||
}
|
||||
} else if (key == "IndicatorHeight") {
|
||||
if (elemType == UIElement::ElementType::TabBar) {
|
||||
static_cast<TabBar *>(elem)->setIndicatorHeight(std::stoi(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(std::stoi(val));
|
||||
}
|
||||
} else if (key == "PaddingV") {
|
||||
if (elemType == UIElement::ElementType::Badge) {
|
||||
static_cast<Badge *>(elem)->setPaddingV(std::stoi(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;
|
||||
}
|
||||
|
||||
const ProcessedAsset *
|
||||
ThemeManager::getProcessedAsset(const std::string &path,
|
||||
GfxRenderer::Orientation orientation,
|
||||
int targetW, int targetH) {
|
||||
// Include dimensions in cache key for scaled images
|
||||
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 it = elements.begin(); it != elements.end(); ++it) {
|
||||
delete it->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;
|
||||
Serial.printf("[ThemeManager] Loading theme: %s\n", themeName.c_str());
|
||||
|
||||
std::map<std::string, std::map<std::string, std::string>> sections;
|
||||
|
||||
if (themeName == "Default") {
|
||||
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();
|
||||
Serial.println("[ThemeManager] Loaded Default theme from SD card");
|
||||
}
|
||||
} else {
|
||||
Serial.println("[ThemeManager] Using embedded Default theme");
|
||||
sections = IniParser::parseString(DEFAULT_THEME_INI);
|
||||
}
|
||||
} else {
|
||||
std::string path = "/themes/" + themeName + "/theme.ini";
|
||||
FsFile file;
|
||||
if (SdMan.openFileForRead("Theme", path, file)) {
|
||||
sections = IniParser::parse(file);
|
||||
file.close();
|
||||
Serial.printf("[ThemeManager] Loaded theme: %s\n", themeName.c_str());
|
||||
} else {
|
||||
Serial.printf("[ThemeManager] ERR: Could not load theme %s, falling back "
|
||||
"to Default\n",
|
||||
themeName.c_str());
|
||||
sections = IniParser::parseString(DEFAULT_THEME_INI);
|
||||
currentThemeName = "Default";
|
||||
}
|
||||
}
|
||||
|
||||
// Pass 1: Creation
|
||||
for (const auto &sec : sections) {
|
||||
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;
|
||||
std::string type = it->second;
|
||||
if (type.empty())
|
||||
continue;
|
||||
|
||||
UIElement *elem = createElement(id, type);
|
||||
if (elem) {
|
||||
elements[id] = elem;
|
||||
}
|
||||
}
|
||||
|
||||
// Pass 2: Properties & Parent Wiring
|
||||
std::vector<List *> lists;
|
||||
for (const auto &sec : sections) {
|
||||
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));
|
||||
}
|
||||
|
||||
if (sec.second.count("Parent")) {
|
||||
std::string parentId = sec.second.at("Parent");
|
||||
if (elements.count(parentId)) {
|
||||
UIElement *parent = elements[parentId];
|
||||
if (auto c = parent->asContainer()) {
|
||||
c->addChild(elem);
|
||||
}
|
||||
} else {
|
||||
Serial.printf("[ThemeManager] WARN: Parent %s not found for %s\n",
|
||||
parentId.c_str(), id.c_str());
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Pass 3: Resolve List Templates
|
||||
for (auto l : lists) {
|
||||
l->resolveTemplate(elements);
|
||||
}
|
||||
|
||||
Serial.printf("[ThemeManager] Theme loaded with %d elements\n",
|
||||
(int)elements.size());
|
||||
}
|
||||
|
||||
void ThemeManager::renderScreen(const std::string &screenName,
|
||||
const GfxRenderer &renderer,
|
||||
const ThemeContext &context) {
|
||||
if (elements.count(screenName) == 0) {
|
||||
Serial.printf("[ThemeManager] Screen '%s' not found\n", screenName.c_str());
|
||||
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) {
|
||||
renderScreen(screenName, renderer, context);
|
||||
}
|
||||
|
||||
} // namespace ThemeEngine
|
||||
@ -22,7 +22,7 @@ void readAndValidate(FsFile& file, uint8_t& member, const uint8_t maxValue) {
|
||||
namespace {
|
||||
constexpr uint8_t SETTINGS_FILE_VERSION = 1;
|
||||
// Increment this when adding new persisted settings fields
|
||||
constexpr uint8_t SETTINGS_COUNT = 23;
|
||||
constexpr uint8_t SETTINGS_COUNT = 24; // 23 upstream + themeName
|
||||
constexpr char SETTINGS_FILE[] = "/.crosspoint/settings.bin";
|
||||
} // namespace
|
||||
|
||||
@ -60,6 +60,7 @@ bool CrossPointSettings::saveToFile() const {
|
||||
serialization::writeString(outputFile, std::string(opdsUsername));
|
||||
serialization::writeString(outputFile, std::string(opdsPassword));
|
||||
serialization::writePod(outputFile, sleepScreenCoverFilter);
|
||||
serialization::writeString(outputFile, std::string(themeName));
|
||||
// New fields added at end for backward compatibility
|
||||
outputFile.close();
|
||||
|
||||
@ -148,6 +149,13 @@ bool CrossPointSettings::loadFromFile() {
|
||||
if (++settingsRead >= fileSettingsCount) break;
|
||||
readAndValidate(inputFile, sleepScreenCoverFilter, SLEEP_SCREEN_COVER_FILTER_COUNT);
|
||||
if (++settingsRead >= fileSettingsCount) break;
|
||||
{
|
||||
std::string themeStr;
|
||||
serialization::readString(inputFile, themeStr);
|
||||
strncpy(themeName, themeStr.c_str(), sizeof(themeName) - 1);
|
||||
themeName[sizeof(themeName) - 1] = '\0';
|
||||
}
|
||||
if (++settingsRead >= fileSettingsCount) break;
|
||||
// New fields added at end for backward compatibility
|
||||
} while (false);
|
||||
|
||||
|
||||
@ -137,6 +137,8 @@ class CrossPointSettings {
|
||||
uint8_t hideBatteryPercentage = HIDE_NEVER;
|
||||
// Long-press chapter skip on side buttons
|
||||
uint8_t longPressChapterSkip = 1;
|
||||
// Theme name (theme-engine addition)
|
||||
char themeName[64] = "Default";
|
||||
|
||||
~CrossPointSettings() = default;
|
||||
|
||||
|
||||
@ -13,9 +13,11 @@
|
||||
#include "CrossPointSettings.h"
|
||||
#include "CrossPointState.h"
|
||||
#include "MappedInputManager.h"
|
||||
#include "RecentBooksStore.h"
|
||||
#include "ScreenComponents.h"
|
||||
#include "fontIds.h"
|
||||
#include "util/StringUtils.h"
|
||||
#include <ThemeManager.h>
|
||||
|
||||
void HomeActivity::taskTrampoline(void *param) {
|
||||
auto *self = static_cast<HomeActivity *>(param);
|
||||
@ -24,8 +26,10 @@ void HomeActivity::taskTrampoline(void* param) {
|
||||
|
||||
int HomeActivity::getMenuItemCount() const {
|
||||
int count = 3; // My Library, File transfer, Settings
|
||||
if (hasContinueReading) count++;
|
||||
if (hasOpdsUrl) count++;
|
||||
if (hasContinueReading)
|
||||
count++;
|
||||
if (hasOpdsUrl)
|
||||
count++;
|
||||
return count;
|
||||
}
|
||||
|
||||
@ -35,7 +39,8 @@ void HomeActivity::onEnter() {
|
||||
renderingMutex = xSemaphoreCreateMutex();
|
||||
|
||||
// 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
|
||||
hasOpdsUrl = strlen(SETTINGS.opdsServerUrl) > 0;
|
||||
@ -90,6 +95,12 @@ void HomeActivity::onEnter() {
|
||||
}
|
||||
|
||||
selectorIndex = 0;
|
||||
lastBatteryCheck = 0; // Force update on first render
|
||||
coverRendered = false;
|
||||
coverBufferStored = false;
|
||||
|
||||
// Load and cache recent books data (slow operation, do once)
|
||||
loadRecentBooksData();
|
||||
|
||||
// Trigger first update
|
||||
updateRequired = true;
|
||||
@ -102,10 +113,91 @@ 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 std::string &bookPath = recentBooks[i];
|
||||
CachedBookInfo info;
|
||||
|
||||
// Extract title from path
|
||||
info.title = bookPath;
|
||||
size_t lastSlash = info.title.find_last_of('/');
|
||||
if (lastSlash != std::string::npos) {
|
||||
info.title = info.title.substr(lastSlash + 1);
|
||||
}
|
||||
size_t lastDot = info.title.find_last_of('.');
|
||||
if (lastDot != std::string::npos) {
|
||||
info.title = info.title.substr(0, lastDot);
|
||||
}
|
||||
|
||||
if (StringUtils::checkFileExtension(bookPath, ".epub")) {
|
||||
Epub epub(bookPath, "/.crosspoint");
|
||||
epub.load(false);
|
||||
if (!epub.getTitle().empty()) {
|
||||
info.title = epub.getTitle();
|
||||
}
|
||||
if (epub.generateThumbBmp()) {
|
||||
info.coverPath = epub.getThumbBmpPath();
|
||||
}
|
||||
|
||||
// Read progress
|
||||
FsFile f;
|
||||
if (SdMan.openFileForRead("HOME", epub.getCachePath() + "/progress.bin", f)) {
|
||||
uint8_t data[4];
|
||||
if (f.read(data, 4) == 4) {
|
||||
int spineIndex = data[0] + (data[1] << 8);
|
||||
int spineCount = epub.getSpineItemsCount();
|
||||
if (spineCount > 0) {
|
||||
info.progressPercent = (spineIndex * 100) / spineCount;
|
||||
}
|
||||
}
|
||||
f.close();
|
||||
}
|
||||
} else if (StringUtils::checkFileExtension(bookPath, ".xtc") ||
|
||||
StringUtils::checkFileExtension(bookPath, ".xtch")) {
|
||||
Xtc xtc(bookPath, "/.crosspoint");
|
||||
if (xtc.load()) {
|
||||
if (!xtc.getTitle().empty()) {
|
||||
info.title = xtc.getTitle();
|
||||
}
|
||||
if (xtc.generateThumbBmp()) {
|
||||
info.coverPath = xtc.getThumbBmpPath();
|
||||
}
|
||||
|
||||
// Read progress
|
||||
FsFile f;
|
||||
if (SdMan.openFileForRead("HOME", xtc.getCachePath() + "/progress.bin", f)) {
|
||||
uint8_t data[4];
|
||||
if (f.read(data, 4) == 4) {
|
||||
uint32_t currentPage = data[0] | (data[1] << 8) | (data[2] << 16) | (data[3] << 24);
|
||||
uint32_t totalPages = xtc.getPageCount();
|
||||
if (totalPages > 0) {
|
||||
info.progressPercent = (currentPage * 100) / totalPages;
|
||||
}
|
||||
}
|
||||
f.close();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Serial.printf("[HOME] Book %d: title='%s', cover='%s', progress=%d%%\n",
|
||||
i, info.title.c_str(), info.coverPath.c_str(), info.progressPercent);
|
||||
cachedRecentBooks.push_back(info);
|
||||
}
|
||||
|
||||
Serial.printf("[HOME] Loaded %d recent books\n", (int)cachedRecentBooks.size());
|
||||
}
|
||||
|
||||
void HomeActivity::onExit() {
|
||||
Activity::onExit();
|
||||
|
||||
// Wait until not rendering to delete task to avoid killing mid-instruction to EPD
|
||||
// Wait until not rendering to delete task to avoid killing mid-instruction to
|
||||
// EPD
|
||||
xSemaphoreTake(renderingMutex, portMAX_DELAY);
|
||||
if (displayTaskHandle) {
|
||||
vTaskDelete(displayTaskHandle);
|
||||
@ -161,9 +253,11 @@ void HomeActivity::freeCoverBuffer() {
|
||||
}
|
||||
|
||||
void HomeActivity::loop() {
|
||||
const bool prevPressed = mappedInput.wasPressed(MappedInputManager::Button::Up) ||
|
||||
const bool prevPressed =
|
||||
mappedInput.wasPressed(MappedInputManager::Button::Up) ||
|
||||
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);
|
||||
|
||||
const int menuCount = getMenuItemCount();
|
||||
@ -210,350 +304,117 @@ void HomeActivity::displayTaskLoop() {
|
||||
}
|
||||
|
||||
void HomeActivity::render() {
|
||||
// If we have a stored cover buffer, restore it instead of clearing
|
||||
const bool bufferRestored = coverBufferStored && restoreCoverBuffer();
|
||||
if (!bufferRestored) {
|
||||
// Battery check logic (only update every 60 seconds)
|
||||
const uint32_t now = millis();
|
||||
const bool needBatteryUpdate =
|
||||
(now - lastBatteryCheck > 60000) || (lastBatteryCheck == 0);
|
||||
if (needBatteryUpdate) {
|
||||
cachedBatteryLevel = battery.readPercentage();
|
||||
lastBatteryCheck = now;
|
||||
}
|
||||
|
||||
// Optimization: If we have a cached framebuffer from a previous full render,
|
||||
// and only the selection changed (no battery update), restore it first.
|
||||
const bool canRestoreBuffer = coverBufferStored && coverRendered;
|
||||
const bool isSelectionOnlyChange = !needBatteryUpdate && canRestoreBuffer;
|
||||
|
||||
if (isSelectionOnlyChange) {
|
||||
restoreCoverBuffer();
|
||||
} else {
|
||||
renderer.clearScreen();
|
||||
}
|
||||
|
||||
const auto pageWidth = renderer.getScreenWidth();
|
||||
const auto pageHeight = renderer.getScreenHeight();
|
||||
ThemeEngine::ThemeContext context;
|
||||
|
||||
constexpr int margin = 20;
|
||||
constexpr int bottomMargin = 60;
|
||||
// --- Bind Global Data ---
|
||||
context.setString("BatteryPercent", std::to_string(cachedBatteryLevel));
|
||||
context.setBool("ShowBatteryPercent",
|
||||
SETTINGS.hideBatteryPercentage !=
|
||||
CrossPointSettings::HIDE_BATTERY_PERCENTAGE::HIDE_ALWAYS);
|
||||
|
||||
// --- Top "book" card for the current title (selectorIndex == 0) ---
|
||||
const int bookWidth = pageWidth / 2;
|
||||
const int bookHeight = pageHeight / 2;
|
||||
const int bookX = (pageWidth - bookWidth) / 2;
|
||||
constexpr int bookY = 30;
|
||||
const bool bookSelected = hasContinueReading && selectorIndex == 0;
|
||||
// --- Recent Books Data (use cached data for performance) ---
|
||||
int recentCount = static_cast<int>(cachedRecentBooks.size());
|
||||
|
||||
// Bookmark dimensions (used in multiple places)
|
||||
const int bookmarkWidth = bookWidth / 8;
|
||||
const int bookmarkHeight = bookHeight / 5;
|
||||
const int bookmarkX = bookX + bookWidth - bookmarkWidth - 10;
|
||||
const int bookmarkY = bookY + 5;
|
||||
context.setBool("HasRecentBooks", recentCount > 0);
|
||||
context.setInt("RecentBooks.Count", recentCount);
|
||||
|
||||
// Draw book card regardless, fill with message based on `hasContinueReading`
|
||||
{
|
||||
// Draw cover image as background if available (inside the box)
|
||||
// Only load from SD on first render, then use stored buffer
|
||||
if (hasContinueReading && hasCoverImage && !coverBmpPath.empty() && !coverRendered) {
|
||||
// First time: load cover from SD and render
|
||||
FsFile file;
|
||||
if (SdMan.openFileForRead("HOME", coverBmpPath, file)) {
|
||||
Bitmap bitmap(file);
|
||||
if (bitmap.parseHeaders() == BmpReaderError::Ok) {
|
||||
// Calculate position to center image within the book card
|
||||
int coverX, coverY;
|
||||
for (int i = 0; i < recentCount; i++) {
|
||||
const auto &book = cachedRecentBooks[i];
|
||||
std::string prefix = "RecentBooks." + std::to_string(i) + ".";
|
||||
|
||||
if (bitmap.getWidth() > bookWidth || bitmap.getHeight() > bookHeight) {
|
||||
const float imgRatio = static_cast<float>(bitmap.getWidth()) / static_cast<float>(bitmap.getHeight());
|
||||
const float boxRatio = static_cast<float>(bookWidth) / static_cast<float>(bookHeight);
|
||||
|
||||
if (imgRatio > boxRatio) {
|
||||
coverX = bookX;
|
||||
coverY = bookY + (bookHeight - static_cast<int>(bookWidth / imgRatio)) / 2;
|
||||
} else {
|
||||
coverX = bookX + (bookWidth - static_cast<int>(bookHeight * imgRatio)) / 2;
|
||||
coverY = bookY;
|
||||
}
|
||||
} else {
|
||||
coverX = bookX + (bookWidth - bitmap.getWidth()) / 2;
|
||||
coverY = bookY + (bookHeight - bitmap.getHeight()) / 2;
|
||||
context.setString(prefix + "Title", book.title);
|
||||
context.setString(prefix + "Image", book.coverPath);
|
||||
context.setString(prefix + "Progress", std::to_string(book.progressPercent));
|
||||
context.setBool(prefix + "Selected", false);
|
||||
}
|
||||
|
||||
// Draw the cover image centered within the book card
|
||||
renderer.drawBitmap(bitmap, coverX, coverY, bookWidth, bookHeight);
|
||||
// --- Book Card Data (for legacy theme) ---
|
||||
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);
|
||||
|
||||
// Draw border around the card
|
||||
renderer.drawRect(bookX, bookY, bookWidth, bookHeight);
|
||||
// --- Selection Logic ---
|
||||
int idx = 0;
|
||||
const int continueIdx = hasContinueReading ? idx++ : -1;
|
||||
const int myLibraryIdx = idx++;
|
||||
const int opdsLibraryIdx = hasOpdsUrl ? idx++ : -1;
|
||||
const int fileTransferIdx = idx++;
|
||||
const int settingsIdx = idx;
|
||||
|
||||
// No bookmark ribbon when cover is shown - it would just cover the art
|
||||
context.setBool("IsBookSelected", selectorIndex == continueIdx);
|
||||
|
||||
// Store the buffer with cover image for fast navigation
|
||||
// --- Main Menu Data ---
|
||||
std::vector<std::string> menuLabels;
|
||||
std::vector<std::string> menuIcons;
|
||||
std::vector<bool> menuSelected;
|
||||
|
||||
menuLabels.push_back("Books");
|
||||
menuIcons.push_back("book");
|
||||
menuSelected.push_back(selectorIndex == myLibraryIdx);
|
||||
|
||||
if (hasOpdsUrl) {
|
||||
menuLabels.push_back("OPDS Browser");
|
||||
menuIcons.push_back("library");
|
||||
menuSelected.push_back(selectorIndex == opdsLibraryIdx);
|
||||
}
|
||||
|
||||
menuLabels.push_back("Files");
|
||||
menuIcons.push_back("folder");
|
||||
menuSelected.push_back(selectorIndex == fileTransferIdx);
|
||||
|
||||
menuLabels.push_back("Transfer");
|
||||
menuIcons.push_back("transfer");
|
||||
menuSelected.push_back(false); // Separate from file transfer
|
||||
|
||||
menuLabels.push_back("Settings");
|
||||
menuIcons.push_back("settings");
|
||||
menuSelected.push_back(selectorIndex == settingsIdx);
|
||||
|
||||
context.setInt("MainMenu.Count", menuLabels.size());
|
||||
for (size_t i = 0; i < menuLabels.size(); ++i) {
|
||||
std::string prefix = "MainMenu." + std::to_string(i) + ".";
|
||||
context.setString(prefix + "Title", menuLabels[i]);
|
||||
context.setString(prefix + "Icon", menuIcons[i]);
|
||||
context.setBool(prefix + "Selected", menuSelected[i]);
|
||||
}
|
||||
|
||||
// --- 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();
|
||||
coverRendered = true;
|
||||
|
||||
// First render: if selected, draw selection indicators now
|
||||
if (bookSelected) {
|
||||
renderer.drawRect(bookX + 1, bookY + 1, bookWidth - 2, bookHeight - 2);
|
||||
renderer.drawRect(bookX + 2, bookY + 2, bookWidth - 4, bookHeight - 4);
|
||||
}
|
||||
}
|
||||
file.close();
|
||||
}
|
||||
} else if (!bufferRestored && !coverRendered) {
|
||||
// No cover image: draw border or fill, plus bookmark as visual flair
|
||||
if (bookSelected) {
|
||||
renderer.fillRect(bookX, bookY, bookWidth, bookHeight);
|
||||
} else {
|
||||
renderer.drawRect(bookX, bookY, bookWidth, bookHeight);
|
||||
}
|
||||
|
||||
// Draw bookmark ribbon when no cover image (visual decoration)
|
||||
if (hasContinueReading) {
|
||||
const int notchDepth = bookmarkHeight / 3;
|
||||
const int centerX = bookmarkX + bookmarkWidth / 2;
|
||||
|
||||
const int xPoints[5] = {
|
||||
bookmarkX, // top-left
|
||||
bookmarkX + bookmarkWidth, // top-right
|
||||
bookmarkX + bookmarkWidth, // bottom-right
|
||||
centerX, // center notch point
|
||||
bookmarkX // bottom-left
|
||||
};
|
||||
const int yPoints[5] = {
|
||||
bookmarkY, // top-left
|
||||
bookmarkY, // top-right
|
||||
bookmarkY + bookmarkHeight, // bottom-right
|
||||
bookmarkY + bookmarkHeight - notchDepth, // center notch point
|
||||
bookmarkY + bookmarkHeight // bottom-left
|
||||
};
|
||||
|
||||
// Draw bookmark ribbon (inverted if selected)
|
||||
renderer.fillPolygon(xPoints, yPoints, 5, !bookSelected);
|
||||
}
|
||||
}
|
||||
|
||||
// If buffer was restored, draw selection indicators if needed
|
||||
if (bufferRestored && bookSelected && coverRendered) {
|
||||
// Draw selection border (no bookmark inversion needed since cover has no bookmark)
|
||||
renderer.drawRect(bookX + 1, bookY + 1, bookWidth - 2, bookHeight - 2);
|
||||
renderer.drawRect(bookX + 2, bookY + 2, bookWidth - 4, bookHeight - 4);
|
||||
} else if (!coverRendered && !bufferRestored) {
|
||||
// Selection border already handled above in the no-cover case
|
||||
}
|
||||
}
|
||||
|
||||
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);
|
||||
|
||||
const uint32_t displayStart = millis();
|
||||
renderer.displayBuffer();
|
||||
Serial.printf("[HOME] Display buffer took %lums\n", millis() - displayStart);
|
||||
}
|
||||
|
||||
@ -4,9 +4,18 @@
|
||||
#include <freertos/task.h>
|
||||
|
||||
#include <functional>
|
||||
#include <string>
|
||||
#include <vector>
|
||||
|
||||
#include "../Activity.h"
|
||||
|
||||
// Cached data for a recent book
|
||||
struct CachedBookInfo {
|
||||
std::string title;
|
||||
std::string coverPath;
|
||||
int progressPercent = 0;
|
||||
};
|
||||
|
||||
class HomeActivity final : public Activity {
|
||||
TaskHandle_t displayTaskHandle = nullptr;
|
||||
SemaphoreHandle_t renderingMutex = nullptr;
|
||||
@ -21,6 +30,11 @@ class HomeActivity final : public Activity {
|
||||
std::string lastBookTitle;
|
||||
std::string lastBookAuthor;
|
||||
std::string coverBmpPath;
|
||||
uint8_t cachedBatteryLevel = 0;
|
||||
uint32_t lastBatteryCheck = 0;
|
||||
|
||||
// Cached recent books data (loaded once in onEnter)
|
||||
std::vector<CachedBookInfo> cachedRecentBooks;
|
||||
const std::function<void()> onContinueReading;
|
||||
const std::function<void()> onMyLibraryOpen;
|
||||
const std::function<void()> onSettingsOpen;
|
||||
@ -34,17 +48,18 @@ class HomeActivity final : public Activity {
|
||||
bool storeCoverBuffer(); // Store frame buffer for cover image
|
||||
bool restoreCoverBuffer(); // Restore frame buffer from stored cover
|
||||
void freeCoverBuffer(); // Free the stored cover buffer
|
||||
void loadRecentBooksData(); // Load and cache recent books data
|
||||
|
||||
public:
|
||||
explicit HomeActivity(GfxRenderer &renderer, MappedInputManager &mappedInput,
|
||||
const std::function<void()>& onContinueReading, const std::function<void()>& onMyLibraryOpen,
|
||||
const std::function<void()>& onSettingsOpen, const std::function<void()>& onFileTransferOpen,
|
||||
const std::function<void()> &onContinueReading,
|
||||
const std::function<void()> &onMyLibraryOpen,
|
||||
const std::function<void()> &onSettingsOpen,
|
||||
const std::function<void()> &onFileTransferOpen,
|
||||
const std::function<void()> &onOpdsBrowserOpen)
|
||||
: Activity("Home", renderer, mappedInput),
|
||||
onContinueReading(onContinueReading),
|
||||
onMyLibraryOpen(onMyLibraryOpen),
|
||||
onSettingsOpen(onSettingsOpen),
|
||||
onFileTransferOpen(onFileTransferOpen),
|
||||
onContinueReading(onContinueReading), onMyLibraryOpen(onMyLibraryOpen),
|
||||
onSettingsOpen(onSettingsOpen), onFileTransferOpen(onFileTransferOpen),
|
||||
onOpdsBrowserOpen(onOpdsBrowserOpen) {}
|
||||
void onEnter() override;
|
||||
void onExit() override;
|
||||
|
||||
254
src/activities/home/default.ini
Normal file
254
src/activities/home/default.ini
Normal file
@ -0,0 +1,254 @@
|
||||
; ============================================
|
||||
; Default Theme for CrossPoint Reader
|
||||
; ============================================
|
||||
;
|
||||
; ELEMENT TYPES:
|
||||
; Container - Basic container for grouping elements
|
||||
; HStack - Horizontal stack layout
|
||||
; VStack - Vertical stack layout
|
||||
; Grid - Grid layout with columns
|
||||
; Rectangle - Rectangle (filled or outlined)
|
||||
; Label - Text display
|
||||
; Bitmap - Image display (BMP files)
|
||||
; Icon - Built-in icons or small images
|
||||
; List - Repeating items (vertical, horizontal, or grid)
|
||||
; Badge - Small text overlay/tag
|
||||
; Toggle - On/off switch
|
||||
; TabBar - Tab selection bar
|
||||
; ProgressBar - Progress indicator
|
||||
; Divider - Horizontal/vertical line
|
||||
; ScrollIndicator - Scroll position indicator
|
||||
;
|
||||
; BUILT-IN ICONS:
|
||||
; heart, book, books, folder, files, settings, gear,
|
||||
; transfer, send, library, device, battery, check,
|
||||
; back, left, up, down
|
||||
;
|
||||
; EXPRESSIONS:
|
||||
; {Variable} - Variable substitution
|
||||
; {!Variable} - Boolean negation
|
||||
; {A && B} - Boolean AND
|
||||
; {A || B} - Boolean OR
|
||||
; {A == B} - Equality
|
||||
; {A != B} - Inequality
|
||||
; {A < B}, {A > B} - Comparisons
|
||||
; {Cond ? True : False} - Ternary
|
||||
;
|
||||
; DIMENSIONS:
|
||||
; 100 - Absolute pixels
|
||||
; 50% - Percentage of parent
|
||||
;
|
||||
; COLORS:
|
||||
; 0x00, black - Black
|
||||
; 0xFF, white - White
|
||||
|
||||
[Global]
|
||||
FontUI12 = UI_12
|
||||
FontUI10 = UI_10
|
||||
|
||||
; ============================================
|
||||
; HOME SCREEN
|
||||
; ============================================
|
||||
|
||||
[Home]
|
||||
Type = Container
|
||||
X = 0
|
||||
Y = 0
|
||||
Width = 100%
|
||||
Height = 100%
|
||||
Color = white
|
||||
|
||||
; --- Status Bar ---
|
||||
[StatusBar]
|
||||
Parent = Home
|
||||
Type = Container
|
||||
X = 0
|
||||
Y = 10
|
||||
Width = 100%
|
||||
Height = 24
|
||||
|
||||
[BatteryIcon]
|
||||
Parent = StatusBar
|
||||
Type = Icon
|
||||
Src = battery
|
||||
X = 400
|
||||
Width = 28
|
||||
Height = 18
|
||||
Color = black
|
||||
|
||||
[BatteryLabel]
|
||||
Parent = StatusBar
|
||||
Type = Label
|
||||
Font = UI_10
|
||||
Text = {BatteryPercent}%
|
||||
X = 432
|
||||
Width = 48
|
||||
Height = 20
|
||||
|
||||
; --- Recent Books Section ---
|
||||
[RecentBooksSection]
|
||||
Parent = Home
|
||||
Type = Container
|
||||
X = 0
|
||||
Y = 45
|
||||
Width = 100%
|
||||
Height = 260
|
||||
Visible = {HasRecentBooks}
|
||||
|
||||
[RecentBooksList]
|
||||
Parent = RecentBooksSection
|
||||
Type = List
|
||||
Source = RecentBooks
|
||||
ItemTemplate = RecentBookItem
|
||||
X = 15
|
||||
Y = 0
|
||||
Width = 450
|
||||
Height = 260
|
||||
Direction = Horizontal
|
||||
ItemWidth = 145
|
||||
Spacing = 10
|
||||
|
||||
; --- Recent Book Item Template ---
|
||||
[RecentBookItem]
|
||||
Type = Container
|
||||
Width = 145
|
||||
Height = 250
|
||||
|
||||
[BookCoverContainer]
|
||||
Parent = RecentBookItem
|
||||
Type = Container
|
||||
X = 0
|
||||
Y = 0
|
||||
Width = 145
|
||||
Height = 195
|
||||
Border = {Item.Selected}
|
||||
|
||||
[BookCoverImage]
|
||||
Parent = BookCoverContainer
|
||||
Type = Bitmap
|
||||
X = 2
|
||||
Y = 2
|
||||
Width = 141
|
||||
Height = 191
|
||||
Src = {Item.Image}
|
||||
Cacheable = true
|
||||
|
||||
[BookProgressBadge]
|
||||
Parent = BookCoverContainer
|
||||
Type = Badge
|
||||
X = 5
|
||||
Y = 168
|
||||
Text = {Item.Progress}%
|
||||
Font = UI_10
|
||||
BgColor = black
|
||||
FgColor = white
|
||||
PaddingH = 6
|
||||
PaddingV = 2
|
||||
|
||||
[BookTitleLabel]
|
||||
Parent = RecentBookItem
|
||||
Type = Label
|
||||
Font = UI_10
|
||||
Text = {Item.Title}
|
||||
X = 0
|
||||
Y = 200
|
||||
Width = 145
|
||||
Height = 40
|
||||
Ellipsis = true
|
||||
|
||||
; --- No Recent Books State ---
|
||||
[EmptyBooksMessage]
|
||||
Parent = Home
|
||||
Type = Label
|
||||
Font = UI_12
|
||||
Text = No recent books
|
||||
Centered = true
|
||||
X = 0
|
||||
Y = 100
|
||||
Width = 480
|
||||
Height = 30
|
||||
Visible = {!HasRecentBooks}
|
||||
|
||||
[EmptyBooksSub]
|
||||
Parent = Home
|
||||
Type = Label
|
||||
Font = UI_10
|
||||
Text = Open a book to start reading
|
||||
Centered = true
|
||||
X = 0
|
||||
Y = 130
|
||||
Width = 480
|
||||
Height = 30
|
||||
Visible = {!HasRecentBooks}
|
||||
|
||||
; --- Main Menu (2-column grid) ---
|
||||
[MainMenuList]
|
||||
Parent = Home
|
||||
Type = List
|
||||
Source = MainMenu
|
||||
ItemTemplate = MainMenuItem
|
||||
X = 15
|
||||
Y = 330
|
||||
Width = 450
|
||||
Height = 350
|
||||
Columns = 2
|
||||
ItemHeight = 70
|
||||
Spacing = 20
|
||||
|
||||
; --- Menu Item Template ---
|
||||
[MainMenuItem]
|
||||
Type = HStack
|
||||
Width = 210
|
||||
Height = 65
|
||||
Spacing = 12
|
||||
CenterVertical = true
|
||||
Border = {Item.Selected}
|
||||
|
||||
[MenuItemIcon]
|
||||
Parent = MainMenuItem
|
||||
Type = Icon
|
||||
Src = {Item.Icon}
|
||||
Width = 36
|
||||
Height = 36
|
||||
Color = black
|
||||
|
||||
[MenuItemLabel]
|
||||
Parent = MainMenuItem
|
||||
Type = Label
|
||||
Font = UI_12
|
||||
Text = {Item.Title}
|
||||
Width = 150
|
||||
Height = 40
|
||||
Color = black
|
||||
|
||||
; --- Bottom Hint Bar ---
|
||||
[HintBar]
|
||||
Parent = Home
|
||||
Type = HStack
|
||||
X = 60
|
||||
Y = 760
|
||||
Width = 360
|
||||
Height = 30
|
||||
Spacing = 80
|
||||
CenterVertical = true
|
||||
|
||||
[HintSelect]
|
||||
Parent = HintBar
|
||||
Type = Icon
|
||||
Src = check
|
||||
Width = 24
|
||||
Height = 24
|
||||
|
||||
[HintUp]
|
||||
Parent = HintBar
|
||||
Type = Icon
|
||||
Src = up
|
||||
Width = 24
|
||||
Height = 24
|
||||
|
||||
[HintDown]
|
||||
Parent = HintBar
|
||||
Type = Icon
|
||||
Src = down
|
||||
Width = 24
|
||||
Height = 24
|
||||
@ -11,8 +11,13 @@
|
||||
#include "KOReaderSettingsActivity.h"
|
||||
#include "MappedInputManager.h"
|
||||
#include "OtaUpdateActivity.h"
|
||||
#include "ThemeSelectionActivity.h"
|
||||
#include "fontIds.h"
|
||||
|
||||
// ... (existing includes)
|
||||
|
||||
// ...
|
||||
|
||||
void CategorySettingsActivity::taskTrampoline(void *param) {
|
||||
auto *self = static_cast<CategorySettingsActivity *>(param);
|
||||
self->displayTaskLoop();
|
||||
@ -25,14 +30,16 @@ void CategorySettingsActivity::onEnter() {
|
||||
selectedSettingIndex = 0;
|
||||
updateRequired = true;
|
||||
|
||||
xTaskCreate(&CategorySettingsActivity::taskTrampoline, "CategorySettingsActivityTask", 4096, this, 1,
|
||||
xTaskCreate(&CategorySettingsActivity::taskTrampoline,
|
||||
"CategorySettingsActivityTask", 4096, this, 1,
|
||||
&displayTaskHandle);
|
||||
}
|
||||
|
||||
void CategorySettingsActivity::onExit() {
|
||||
ActivityWithSubactivity::onExit();
|
||||
|
||||
// Wait until not rendering to delete task to avoid killing mid-instruction to EPD
|
||||
// Wait until not rendering to delete task to avoid killing mid-instruction to
|
||||
// EPD
|
||||
xSemaphoreTake(renderingMutex, portMAX_DELAY);
|
||||
if (displayTaskHandle) {
|
||||
vTaskDelete(displayTaskHandle);
|
||||
@ -64,11 +71,15 @@ void CategorySettingsActivity::loop() {
|
||||
// Handle navigation
|
||||
if (mappedInput.wasPressed(MappedInputManager::Button::Up) ||
|
||||
mappedInput.wasPressed(MappedInputManager::Button::Left)) {
|
||||
selectedSettingIndex = (selectedSettingIndex > 0) ? (selectedSettingIndex - 1) : (settingsCount - 1);
|
||||
selectedSettingIndex = (selectedSettingIndex > 0)
|
||||
? (selectedSettingIndex - 1)
|
||||
: (settingsCount - 1);
|
||||
updateRequired = true;
|
||||
} else if (mappedInput.wasPressed(MappedInputManager::Button::Down) ||
|
||||
mappedInput.wasPressed(MappedInputManager::Button::Right)) {
|
||||
selectedSettingIndex = (selectedSettingIndex < settingsCount - 1) ? (selectedSettingIndex + 1) : 0;
|
||||
selectedSettingIndex = (selectedSettingIndex < settingsCount - 1)
|
||||
? (selectedSettingIndex + 1)
|
||||
: 0;
|
||||
updateRequired = true;
|
||||
}
|
||||
}
|
||||
@ -86,8 +97,10 @@ void CategorySettingsActivity::toggleCurrentSetting() {
|
||||
SETTINGS.*(setting.valuePtr) = !currentValue;
|
||||
} else if (setting.type == SettingType::ENUM && setting.valuePtr != nullptr) {
|
||||
const uint8_t currentValue = SETTINGS.*(setting.valuePtr);
|
||||
SETTINGS.*(setting.valuePtr) = (currentValue + 1) % static_cast<uint8_t>(setting.enumValues.size());
|
||||
} else if (setting.type == SettingType::VALUE && setting.valuePtr != nullptr) {
|
||||
SETTINGS.*(setting.valuePtr) =
|
||||
(currentValue + 1) % static_cast<uint8_t>(setting.enumValues.size());
|
||||
} else if (setting.type == SettingType::VALUE &&
|
||||
setting.valuePtr != nullptr) {
|
||||
const int8_t currentValue = SETTINGS.*(setting.valuePtr);
|
||||
if (currentValue + setting.valueRange.step > setting.valueRange.max) {
|
||||
SETTINGS.*(setting.valuePtr) = setting.valueRange.min;
|
||||
@ -98,7 +111,8 @@ void CategorySettingsActivity::toggleCurrentSetting() {
|
||||
if (strcmp(setting.name, "KOReader Sync") == 0) {
|
||||
xSemaphoreTake(renderingMutex, portMAX_DELAY);
|
||||
exitActivity();
|
||||
enterNewActivity(new KOReaderSettingsActivity(renderer, mappedInput, [this] {
|
||||
enterNewActivity(
|
||||
new KOReaderSettingsActivity(renderer, mappedInput, [this] {
|
||||
exitActivity();
|
||||
updateRequired = true;
|
||||
}));
|
||||
@ -106,7 +120,8 @@ void CategorySettingsActivity::toggleCurrentSetting() {
|
||||
} else if (strcmp(setting.name, "OPDS Browser") == 0) {
|
||||
xSemaphoreTake(renderingMutex, portMAX_DELAY);
|
||||
exitActivity();
|
||||
enterNewActivity(new CalibreSettingsActivity(renderer, mappedInput, [this] {
|
||||
enterNewActivity(
|
||||
new CalibreSettingsActivity(renderer, mappedInput, [this] {
|
||||
exitActivity();
|
||||
updateRequired = true;
|
||||
}));
|
||||
@ -127,6 +142,15 @@ void CategorySettingsActivity::toggleCurrentSetting() {
|
||||
updateRequired = true;
|
||||
}));
|
||||
xSemaphoreGive(renderingMutex);
|
||||
} else if (strcmp(setting.name, "Theme") == 0) {
|
||||
xSemaphoreTake(renderingMutex, portMAX_DELAY);
|
||||
exitActivity();
|
||||
enterNewActivity(
|
||||
new ThemeSelectionActivity(renderer, mappedInput, [this] {
|
||||
exitActivity();
|
||||
updateRequired = true;
|
||||
}));
|
||||
xSemaphoreGive(renderingMutex);
|
||||
}
|
||||
} else {
|
||||
return;
|
||||
@ -153,7 +177,8 @@ void CategorySettingsActivity::render() const {
|
||||
const auto pageWidth = renderer.getScreenWidth();
|
||||
const auto pageHeight = renderer.getScreenHeight();
|
||||
|
||||
renderer.drawCenteredText(UI_12_FONT_ID, 15, categoryName, true, EpdFontFamily::BOLD);
|
||||
renderer.drawCenteredText(UI_12_FONT_ID, 15, categoryName, true,
|
||||
EpdFontFamily::BOLD);
|
||||
|
||||
// Draw selection highlight
|
||||
renderer.fillRect(0, 60 + selectedSettingIndex * 30 - 2, pageWidth - 1, 30);
|
||||
@ -164,30 +189,39 @@ void CategorySettingsActivity::render() const {
|
||||
const bool isSelected = (i == selectedSettingIndex);
|
||||
|
||||
// Draw setting name
|
||||
renderer.drawText(UI_10_FONT_ID, 20, settingY, settingsList[i].name, !isSelected);
|
||||
renderer.drawText(UI_10_FONT_ID, 20, settingY, settingsList[i].name,
|
||||
!isSelected);
|
||||
|
||||
// Draw value based on setting type
|
||||
std::string valueText;
|
||||
if (settingsList[i].type == SettingType::TOGGLE && settingsList[i].valuePtr != nullptr) {
|
||||
if (settingsList[i].type == SettingType::TOGGLE &&
|
||||
settingsList[i].valuePtr != nullptr) {
|
||||
const bool value = SETTINGS.*(settingsList[i].valuePtr);
|
||||
valueText = value ? "ON" : "OFF";
|
||||
} else if (settingsList[i].type == SettingType::ENUM && settingsList[i].valuePtr != nullptr) {
|
||||
} else if (settingsList[i].type == SettingType::ENUM &&
|
||||
settingsList[i].valuePtr != nullptr) {
|
||||
const uint8_t value = SETTINGS.*(settingsList[i].valuePtr);
|
||||
valueText = settingsList[i].enumValues[value];
|
||||
} else if (settingsList[i].type == SettingType::VALUE && settingsList[i].valuePtr != nullptr) {
|
||||
} else if (settingsList[i].type == SettingType::VALUE &&
|
||||
settingsList[i].valuePtr != nullptr) {
|
||||
valueText = std::to_string(SETTINGS.*(settingsList[i].valuePtr));
|
||||
}
|
||||
if (!valueText.empty()) {
|
||||
const auto width = renderer.getTextWidth(UI_10_FONT_ID, valueText.c_str());
|
||||
renderer.drawText(UI_10_FONT_ID, pageWidth - 20 - width, settingY, valueText.c_str(), !isSelected);
|
||||
const auto width =
|
||||
renderer.getTextWidth(UI_10_FONT_ID, valueText.c_str());
|
||||
renderer.drawText(UI_10_FONT_ID, pageWidth - 20 - width, settingY,
|
||||
valueText.c_str(), !isSelected);
|
||||
}
|
||||
}
|
||||
|
||||
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);
|
||||
|
||||
const auto labels = mappedInput.mapLabels("« Back", "Toggle", "", "");
|
||||
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);
|
||||
|
||||
renderer.displayBuffer();
|
||||
}
|
||||
|
||||
@ -8,10 +8,11 @@
|
||||
#include "MappedInputManager.h"
|
||||
#include "fontIds.h"
|
||||
|
||||
const char* SettingsActivity::categoryNames[categoryCount] = {"Display", "Reader", "Controls", "System"};
|
||||
const char *SettingsActivity::categoryNames[categoryCount] = {
|
||||
"Display", "Reader", "Controls", "System"};
|
||||
|
||||
namespace {
|
||||
constexpr int displaySettingsCount = 6;
|
||||
constexpr int displaySettingsCount = 7;
|
||||
const SettingInfo displaySettings[displaySettingsCount] = {
|
||||
// Should match with SLEEP_SCREEN_MODE
|
||||
SettingInfo::Enum("Sleep Screen", &CrossPointSettings::sleepScreen, {"Dark", "Light", "Custom", "Cover", "None"}),
|
||||
@ -22,21 +23,30 @@ const SettingInfo displaySettings[displaySettingsCount] = {
|
||||
{"None", "No Progress", "Full w/ Percentage", "Full w/ Progress Bar", "Progress Bar"}),
|
||||
SettingInfo::Enum("Hide Battery %", &CrossPointSettings::hideBatteryPercentage, {"Never", "In Reader", "Always"}),
|
||||
SettingInfo::Enum("Refresh Frequency", &CrossPointSettings::refreshFrequency,
|
||||
{"1 page", "5 pages", "10 pages", "15 pages", "30 pages"})};
|
||||
{"1 page", "5 pages", "10 pages", "15 pages", "30 pages"}),
|
||||
SettingInfo::Action("Theme")};
|
||||
|
||||
constexpr int readerSettingsCount = 9;
|
||||
const SettingInfo readerSettings[readerSettingsCount] = {
|
||||
SettingInfo::Enum("Font Family", &CrossPointSettings::fontFamily, {"Bookerly", "Noto Sans", "Open Dyslexic"}),
|
||||
SettingInfo::Enum("Font Size", &CrossPointSettings::fontSize, {"Small", "Medium", "Large", "X Large"}),
|
||||
SettingInfo::Enum("Line Spacing", &CrossPointSettings::lineSpacing, {"Tight", "Normal", "Wide"}),
|
||||
SettingInfo::Value("Screen Margin", &CrossPointSettings::screenMargin, {5, 40, 5}),
|
||||
SettingInfo::Enum("Paragraph Alignment", &CrossPointSettings::paragraphAlignment,
|
||||
SettingInfo::Enum("Font Family", &CrossPointSettings::fontFamily,
|
||||
{"Bookerly", "Noto Sans", "Open Dyslexic"}),
|
||||
SettingInfo::Enum("Font Size", &CrossPointSettings::fontSize,
|
||||
{"Small", "Medium", "Large", "X Large"}),
|
||||
SettingInfo::Enum("Line Spacing", &CrossPointSettings::lineSpacing,
|
||||
{"Tight", "Normal", "Wide"}),
|
||||
SettingInfo::Value("Screen Margin", &CrossPointSettings::screenMargin,
|
||||
{5, 40, 5}),
|
||||
SettingInfo::Enum("Paragraph Alignment",
|
||||
&CrossPointSettings::paragraphAlignment,
|
||||
{"Justify", "Left", "Center", "Right"}),
|
||||
SettingInfo::Toggle("Hyphenation", &CrossPointSettings::hyphenationEnabled),
|
||||
SettingInfo::Enum("Reading Orientation", &CrossPointSettings::orientation,
|
||||
SettingInfo::Enum(
|
||||
"Reading Orientation", &CrossPointSettings::orientation,
|
||||
{"Portrait", "Landscape CW", "Inverted", "Landscape CCW"}),
|
||||
SettingInfo::Toggle("Extra Paragraph Spacing", &CrossPointSettings::extraParagraphSpacing),
|
||||
SettingInfo::Toggle("Text Anti-Aliasing", &CrossPointSettings::textAntiAliasing)};
|
||||
SettingInfo::Toggle("Extra Paragraph Spacing",
|
||||
&CrossPointSettings::extraParagraphSpacing),
|
||||
SettingInfo::Toggle("Text Anti-Aliasing",
|
||||
&CrossPointSettings::textAntiAliasing)};
|
||||
|
||||
constexpr int controlsSettingsCount = 4;
|
||||
const SettingInfo controlsSettings[controlsSettingsCount] = {
|
||||
@ -45,8 +55,11 @@ const SettingInfo controlsSettings[controlsSettingsCount] = {
|
||||
{"Bck, Cnfrm, Lft, Rght", "Lft, Rght, Bck, Cnfrm", "Lft, Bck, Cnfrm, Rght", "Bck, Cnfrm, Rght, Lft"}),
|
||||
SettingInfo::Enum("Side Button Layout (reader)", &CrossPointSettings::sideButtonLayout,
|
||||
{"Prev, Next", "Next, Prev"}),
|
||||
SettingInfo::Toggle("Long-press Chapter Skip", &CrossPointSettings::longPressChapterSkip),
|
||||
SettingInfo::Enum("Short Power Button Click", &CrossPointSettings::shortPwrBtn, {"Ignore", "Sleep", "Page Turn"})};
|
||||
SettingInfo::Toggle("Long-press Chapter Skip",
|
||||
&CrossPointSettings::longPressChapterSkip),
|
||||
SettingInfo::Enum("Short Power Button Click",
|
||||
&CrossPointSettings::shortPwrBtn,
|
||||
{"Ignore", "Sleep", "Page Turn"})};
|
||||
|
||||
constexpr int systemSettingsCount = 5;
|
||||
const SettingInfo systemSettings[systemSettingsCount] = {
|
||||
@ -82,7 +95,8 @@ void SettingsActivity::onEnter() {
|
||||
void SettingsActivity::onExit() {
|
||||
ActivityWithSubactivity::onExit();
|
||||
|
||||
// Wait until not rendering to delete task to avoid killing mid-instruction to EPD
|
||||
// Wait until not rendering to delete task to avoid killing mid-instruction to
|
||||
// EPD
|
||||
xSemaphoreTake(renderingMutex, portMAX_DELAY);
|
||||
if (displayTaskHandle) {
|
||||
vTaskDelete(displayTaskHandle);
|
||||
@ -114,12 +128,16 @@ void SettingsActivity::loop() {
|
||||
if (mappedInput.wasPressed(MappedInputManager::Button::Up) ||
|
||||
mappedInput.wasPressed(MappedInputManager::Button::Left)) {
|
||||
// Move selection up (with wrap-around)
|
||||
selectedCategoryIndex = (selectedCategoryIndex > 0) ? (selectedCategoryIndex - 1) : (categoryCount - 1);
|
||||
selectedCategoryIndex = (selectedCategoryIndex > 0)
|
||||
? (selectedCategoryIndex - 1)
|
||||
: (categoryCount - 1);
|
||||
updateRequired = true;
|
||||
} else if (mappedInput.wasPressed(MappedInputManager::Button::Down) ||
|
||||
mappedInput.wasPressed(MappedInputManager::Button::Right)) {
|
||||
// Move selection down (with wrap around)
|
||||
selectedCategoryIndex = (selectedCategoryIndex < categoryCount - 1) ? (selectedCategoryIndex + 1) : 0;
|
||||
selectedCategoryIndex = (selectedCategoryIndex < categoryCount - 1)
|
||||
? (selectedCategoryIndex + 1)
|
||||
: 0;
|
||||
updateRequired = true;
|
||||
}
|
||||
}
|
||||
@ -154,7 +172,8 @@ void SettingsActivity::enterCategory(int categoryIndex) {
|
||||
break;
|
||||
}
|
||||
|
||||
enterNewActivity(new CategorySettingsActivity(renderer, mappedInput, categoryNames[categoryIndex], settingsList,
|
||||
enterNewActivity(new CategorySettingsActivity(
|
||||
renderer, mappedInput, categoryNames[categoryIndex], settingsList,
|
||||
settingsCount, [this] {
|
||||
exitActivity();
|
||||
updateRequired = true;
|
||||
@ -181,7 +200,8 @@ void SettingsActivity::render() const {
|
||||
const auto pageHeight = renderer.getScreenHeight();
|
||||
|
||||
// Draw header
|
||||
renderer.drawCenteredText(UI_12_FONT_ID, 15, "Settings", true, EpdFontFamily::BOLD);
|
||||
renderer.drawCenteredText(UI_12_FONT_ID, 15, "Settings", true,
|
||||
EpdFontFamily::BOLD);
|
||||
|
||||
// Draw selection
|
||||
renderer.fillRect(0, 60 + selectedCategoryIndex * 30 - 2, pageWidth - 1, 30);
|
||||
@ -191,16 +211,20 @@ void SettingsActivity::render() const {
|
||||
const int categoryY = 60 + i * 30; // 30 pixels between categories
|
||||
|
||||
// Draw category name
|
||||
renderer.drawText(UI_10_FONT_ID, 20, categoryY, categoryNames[i], i != selectedCategoryIndex);
|
||||
renderer.drawText(UI_10_FONT_ID, 20, categoryY, categoryNames[i],
|
||||
i != selectedCategoryIndex);
|
||||
}
|
||||
|
||||
// Draw version text above button hints
|
||||
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);
|
||||
|
||||
// Draw help text
|
||||
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();
|
||||
|
||||
180
src/activities/settings/ThemeSelectionActivity.cpp
Normal file
180
src/activities/settings/ThemeSelectionActivity.cpp
Normal file
@ -0,0 +1,180 @@
|
||||
#include "ThemeSelectionActivity.h"
|
||||
#include "CrossPointSettings.h"
|
||||
#include "MappedInputManager.h"
|
||||
#include "ThemeManager.h"
|
||||
#include "fontIds.h"
|
||||
#include <GfxRenderer.h>
|
||||
#include <SDCardManager.h>
|
||||
#include <cstring>
|
||||
|
||||
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];
|
||||
strncpy(SETTINGS.themeName, selected.c_str(),
|
||||
sizeof(SETTINGS.themeName) - 1);
|
||||
SETTINGS.themeName[sizeof(SETTINGS.themeName) - 1] = '\0';
|
||||
SETTINGS.saveToFile();
|
||||
|
||||
// Load the new theme immediately
|
||||
ThemeEngine::ThemeManager::get().loadTheme(selected);
|
||||
}
|
||||
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);
|
||||
|
||||
renderer.drawText(UI_10_FONT_ID, 20, y, themeNames[idx].c_str(),
|
||||
!isSelected);
|
||||
|
||||
// Mark current active theme if different from selected
|
||||
if (themeNames[idx] == std::string(SETTINGS.themeName)) {
|
||||
renderer.drawText(UI_10_FONT_ID, pageWidth - 80, y, "(Current)",
|
||||
!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); // Track logic? No just draw thumb
|
||||
renderer.fillRect(pageWidth - 7, thumbY, 6, thumbHeight, 1);
|
||||
}
|
||||
|
||||
// Hints
|
||||
const auto labels = mappedInput.mapLabels("Cancel", "Select", "", "");
|
||||
renderer.drawButtonHints(UI_10_FONT_ID, labels.btn1, labels.btn2, labels.btn3,
|
||||
labels.btn4);
|
||||
|
||||
renderer.displayBuffer();
|
||||
}
|
||||
29
src/activities/settings/ThemeSelectionActivity.h
Normal file
29
src/activities/settings/ThemeSelectionActivity.h
Normal file
@ -0,0 +1,29 @@
|
||||
#pragma once
|
||||
#include "activities/Activity.h"
|
||||
#include <freertos/FreeRTOS.h>
|
||||
#include <freertos/semphr.h>
|
||||
#include <freertos/task.h>
|
||||
#include <functional>
|
||||
#include <string>
|
||||
#include <vector>
|
||||
|
||||
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;
|
||||
};
|
||||
129
src/main.cpp
129
src/main.cpp
@ -15,6 +15,7 @@
|
||||
#include "KOReaderCredentialStore.h"
|
||||
#include "MappedInputManager.h"
|
||||
#include "RecentBooksStore.h"
|
||||
#include "ThemeManager.h"
|
||||
#include "activities/boot_sleep/BootActivity.h"
|
||||
#include "activities/boot_sleep/SleepActivity.h"
|
||||
#include "activities/browser/OpdsBookBrowserActivity.h"
|
||||
@ -37,76 +38,92 @@ EpdFont bookerly14RegularFont(&bookerly_14_regular);
|
||||
EpdFont bookerly14BoldFont(&bookerly_14_bold);
|
||||
EpdFont bookerly14ItalicFont(&bookerly_14_italic);
|
||||
EpdFont bookerly14BoldItalicFont(&bookerly_14_bolditalic);
|
||||
EpdFontFamily bookerly14FontFamily(&bookerly14RegularFont, &bookerly14BoldFont, &bookerly14ItalicFont,
|
||||
EpdFontFamily bookerly14FontFamily(&bookerly14RegularFont, &bookerly14BoldFont,
|
||||
&bookerly14ItalicFont,
|
||||
&bookerly14BoldItalicFont);
|
||||
#ifndef OMIT_FONTS
|
||||
EpdFont bookerly12RegularFont(&bookerly_12_regular);
|
||||
EpdFont bookerly12BoldFont(&bookerly_12_bold);
|
||||
EpdFont bookerly12ItalicFont(&bookerly_12_italic);
|
||||
EpdFont bookerly12BoldItalicFont(&bookerly_12_bolditalic);
|
||||
EpdFontFamily bookerly12FontFamily(&bookerly12RegularFont, &bookerly12BoldFont, &bookerly12ItalicFont,
|
||||
EpdFontFamily bookerly12FontFamily(&bookerly12RegularFont, &bookerly12BoldFont,
|
||||
&bookerly12ItalicFont,
|
||||
&bookerly12BoldItalicFont);
|
||||
EpdFont bookerly16RegularFont(&bookerly_16_regular);
|
||||
EpdFont bookerly16BoldFont(&bookerly_16_bold);
|
||||
EpdFont bookerly16ItalicFont(&bookerly_16_italic);
|
||||
EpdFont bookerly16BoldItalicFont(&bookerly_16_bolditalic);
|
||||
EpdFontFamily bookerly16FontFamily(&bookerly16RegularFont, &bookerly16BoldFont, &bookerly16ItalicFont,
|
||||
EpdFontFamily bookerly16FontFamily(&bookerly16RegularFont, &bookerly16BoldFont,
|
||||
&bookerly16ItalicFont,
|
||||
&bookerly16BoldItalicFont);
|
||||
EpdFont bookerly18RegularFont(&bookerly_18_regular);
|
||||
EpdFont bookerly18BoldFont(&bookerly_18_bold);
|
||||
EpdFont bookerly18ItalicFont(&bookerly_18_italic);
|
||||
EpdFont bookerly18BoldItalicFont(&bookerly_18_bolditalic);
|
||||
EpdFontFamily bookerly18FontFamily(&bookerly18RegularFont, &bookerly18BoldFont, &bookerly18ItalicFont,
|
||||
EpdFontFamily bookerly18FontFamily(&bookerly18RegularFont, &bookerly18BoldFont,
|
||||
&bookerly18ItalicFont,
|
||||
&bookerly18BoldItalicFont);
|
||||
|
||||
EpdFont notosans12RegularFont(¬osans_12_regular);
|
||||
EpdFont notosans12BoldFont(¬osans_12_bold);
|
||||
EpdFont notosans12ItalicFont(¬osans_12_italic);
|
||||
EpdFont notosans12BoldItalicFont(¬osans_12_bolditalic);
|
||||
EpdFontFamily notosans12FontFamily(¬osans12RegularFont, ¬osans12BoldFont, ¬osans12ItalicFont,
|
||||
EpdFontFamily notosans12FontFamily(¬osans12RegularFont, ¬osans12BoldFont,
|
||||
¬osans12ItalicFont,
|
||||
¬osans12BoldItalicFont);
|
||||
EpdFont notosans14RegularFont(¬osans_14_regular);
|
||||
EpdFont notosans14BoldFont(¬osans_14_bold);
|
||||
EpdFont notosans14ItalicFont(¬osans_14_italic);
|
||||
EpdFont notosans14BoldItalicFont(¬osans_14_bolditalic);
|
||||
EpdFontFamily notosans14FontFamily(¬osans14RegularFont, ¬osans14BoldFont, ¬osans14ItalicFont,
|
||||
EpdFontFamily notosans14FontFamily(¬osans14RegularFont, ¬osans14BoldFont,
|
||||
¬osans14ItalicFont,
|
||||
¬osans14BoldItalicFont);
|
||||
EpdFont notosans16RegularFont(¬osans_16_regular);
|
||||
EpdFont notosans16BoldFont(¬osans_16_bold);
|
||||
EpdFont notosans16ItalicFont(¬osans_16_italic);
|
||||
EpdFont notosans16BoldItalicFont(¬osans_16_bolditalic);
|
||||
EpdFontFamily notosans16FontFamily(¬osans16RegularFont, ¬osans16BoldFont, ¬osans16ItalicFont,
|
||||
EpdFontFamily notosans16FontFamily(¬osans16RegularFont, ¬osans16BoldFont,
|
||||
¬osans16ItalicFont,
|
||||
¬osans16BoldItalicFont);
|
||||
EpdFont notosans18RegularFont(¬osans_18_regular);
|
||||
EpdFont notosans18BoldFont(¬osans_18_bold);
|
||||
EpdFont notosans18ItalicFont(¬osans_18_italic);
|
||||
EpdFont notosans18BoldItalicFont(¬osans_18_bolditalic);
|
||||
EpdFontFamily notosans18FontFamily(¬osans18RegularFont, ¬osans18BoldFont, ¬osans18ItalicFont,
|
||||
EpdFontFamily notosans18FontFamily(¬osans18RegularFont, ¬osans18BoldFont,
|
||||
¬osans18ItalicFont,
|
||||
¬osans18BoldItalicFont);
|
||||
|
||||
EpdFont opendyslexic8RegularFont(&opendyslexic_8_regular);
|
||||
EpdFont opendyslexic8BoldFont(&opendyslexic_8_bold);
|
||||
EpdFont opendyslexic8ItalicFont(&opendyslexic_8_italic);
|
||||
EpdFont opendyslexic8BoldItalicFont(&opendyslexic_8_bolditalic);
|
||||
EpdFontFamily opendyslexic8FontFamily(&opendyslexic8RegularFont, &opendyslexic8BoldFont, &opendyslexic8ItalicFont,
|
||||
EpdFontFamily opendyslexic8FontFamily(&opendyslexic8RegularFont,
|
||||
&opendyslexic8BoldFont,
|
||||
&opendyslexic8ItalicFont,
|
||||
&opendyslexic8BoldItalicFont);
|
||||
EpdFont opendyslexic10RegularFont(&opendyslexic_10_regular);
|
||||
EpdFont opendyslexic10BoldFont(&opendyslexic_10_bold);
|
||||
EpdFont opendyslexic10ItalicFont(&opendyslexic_10_italic);
|
||||
EpdFont opendyslexic10BoldItalicFont(&opendyslexic_10_bolditalic);
|
||||
EpdFontFamily opendyslexic10FontFamily(&opendyslexic10RegularFont, &opendyslexic10BoldFont, &opendyslexic10ItalicFont,
|
||||
EpdFontFamily opendyslexic10FontFamily(&opendyslexic10RegularFont,
|
||||
&opendyslexic10BoldFont,
|
||||
&opendyslexic10ItalicFont,
|
||||
&opendyslexic10BoldItalicFont);
|
||||
EpdFont opendyslexic12RegularFont(&opendyslexic_12_regular);
|
||||
EpdFont opendyslexic12BoldFont(&opendyslexic_12_bold);
|
||||
EpdFont opendyslexic12ItalicFont(&opendyslexic_12_italic);
|
||||
EpdFont opendyslexic12BoldItalicFont(&opendyslexic_12_bolditalic);
|
||||
EpdFontFamily opendyslexic12FontFamily(&opendyslexic12RegularFont, &opendyslexic12BoldFont, &opendyslexic12ItalicFont,
|
||||
EpdFontFamily opendyslexic12FontFamily(&opendyslexic12RegularFont,
|
||||
&opendyslexic12BoldFont,
|
||||
&opendyslexic12ItalicFont,
|
||||
&opendyslexic12BoldItalicFont);
|
||||
EpdFont opendyslexic14RegularFont(&opendyslexic_14_regular);
|
||||
EpdFont opendyslexic14BoldFont(&opendyslexic_14_bold);
|
||||
EpdFont opendyslexic14ItalicFont(&opendyslexic_14_italic);
|
||||
EpdFont opendyslexic14BoldItalicFont(&opendyslexic_14_bolditalic);
|
||||
EpdFontFamily opendyslexic14FontFamily(&opendyslexic14RegularFont, &opendyslexic14BoldFont, &opendyslexic14ItalicFont,
|
||||
EpdFontFamily opendyslexic14FontFamily(&opendyslexic14RegularFont,
|
||||
&opendyslexic14BoldFont,
|
||||
&opendyslexic14ItalicFont,
|
||||
&opendyslexic14BoldItalicFont);
|
||||
#endif // OMIT_FONTS
|
||||
|
||||
@ -150,12 +167,15 @@ void verifyPowerButtonDuration() {
|
||||
// Give the user up to 1000ms to start holding the power button, and must hold for SETTINGS.getPowerButtonDuration()
|
||||
const auto start = millis();
|
||||
bool abort = false;
|
||||
// Subtract the current time, because inputManager only starts counting the HeldTime from the first update()
|
||||
// This way, we remove the time we already took to reach here from the duration,
|
||||
// assuming the button was held until now from millis()==0 (i.e. device start time).
|
||||
// Subtract the current time, because inputManager only starts counting the
|
||||
// HeldTime from the first update() This way, we remove the time we already
|
||||
// took to reach here from the duration, assuming the button was held until
|
||||
// now from millis()==0 (i.e. device start time).
|
||||
const uint16_t calibration = start;
|
||||
const uint16_t calibratedPressDuration =
|
||||
(calibration < SETTINGS.getPowerButtonDuration()) ? SETTINGS.getPowerButtonDuration() - calibration : 1;
|
||||
(calibration < SETTINGS.getPowerButtonDuration())
|
||||
? SETTINGS.getPowerButtonDuration() - calibration
|
||||
: 1;
|
||||
|
||||
gpio.update();
|
||||
// Needed because inputManager.isPressed() may take up to ~500ms to return the correct state
|
||||
@ -203,43 +223,55 @@ void enterDeepSleep() {
|
||||
}
|
||||
|
||||
void onGoHome();
|
||||
void onGoToMyLibraryWithTab(const std::string& path, MyLibraryActivity::Tab tab);
|
||||
void onGoToReader(const std::string& initialEpubPath, MyLibraryActivity::Tab fromTab) {
|
||||
void onGoToMyLibraryWithTab(const std::string &path,
|
||||
MyLibraryActivity::Tab tab);
|
||||
void onGoToReader(const std::string &initialEpubPath,
|
||||
MyLibraryActivity::Tab fromTab) {
|
||||
exitActivity();
|
||||
enterNewActivity(
|
||||
new ReaderActivity(renderer, mappedInputManager, initialEpubPath, fromTab, onGoHome, onGoToMyLibraryWithTab));
|
||||
enterNewActivity(new ReaderActivity(renderer, mappedInputManager,
|
||||
initialEpubPath, fromTab, onGoHome,
|
||||
onGoToMyLibraryWithTab));
|
||||
}
|
||||
void onContinueReading() {
|
||||
onGoToReader(APP_STATE.openEpubPath, MyLibraryActivity::Tab::Recent);
|
||||
}
|
||||
void onContinueReading() { onGoToReader(APP_STATE.openEpubPath, MyLibraryActivity::Tab::Recent); }
|
||||
|
||||
void onGoToFileTransfer() {
|
||||
exitActivity();
|
||||
enterNewActivity(new CrossPointWebServerActivity(renderer, mappedInputManager, onGoHome));
|
||||
enterNewActivity(
|
||||
new CrossPointWebServerActivity(renderer, mappedInputManager, onGoHome));
|
||||
}
|
||||
|
||||
void onGoToSettings() {
|
||||
exitActivity();
|
||||
enterNewActivity(new SettingsActivity(renderer, mappedInputManager, onGoHome));
|
||||
enterNewActivity(
|
||||
new SettingsActivity(renderer, mappedInputManager, onGoHome));
|
||||
}
|
||||
|
||||
void onGoToMyLibrary() {
|
||||
exitActivity();
|
||||
enterNewActivity(new MyLibraryActivity(renderer, mappedInputManager, onGoHome, onGoToReader));
|
||||
enterNewActivity(new MyLibraryActivity(renderer, mappedInputManager, onGoHome,
|
||||
onGoToReader));
|
||||
}
|
||||
|
||||
void onGoToMyLibraryWithTab(const std::string& path, MyLibraryActivity::Tab tab) {
|
||||
void onGoToMyLibraryWithTab(const std::string &path,
|
||||
MyLibraryActivity::Tab tab) {
|
||||
exitActivity();
|
||||
enterNewActivity(new MyLibraryActivity(renderer, mappedInputManager, onGoHome, onGoToReader, tab, path));
|
||||
enterNewActivity(new MyLibraryActivity(renderer, mappedInputManager, onGoHome,
|
||||
onGoToReader, tab, path));
|
||||
}
|
||||
|
||||
void onGoToBrowser() {
|
||||
exitActivity();
|
||||
enterNewActivity(new OpdsBookBrowserActivity(renderer, mappedInputManager, onGoHome));
|
||||
enterNewActivity(
|
||||
new OpdsBookBrowserActivity(renderer, mappedInputManager, onGoHome));
|
||||
}
|
||||
|
||||
void onGoHome() {
|
||||
exitActivity();
|
||||
enterNewActivity(new HomeActivity(renderer, mappedInputManager, onContinueReading, onGoToMyLibrary, onGoToSettings,
|
||||
onGoToFileTransfer, onGoToBrowser));
|
||||
enterNewActivity(new HomeActivity(
|
||||
renderer, mappedInputManager, onContinueReading, onGoToMyLibrary,
|
||||
onGoToSettings, onGoToFileTransfer, onGoToBrowser));
|
||||
}
|
||||
|
||||
void setupDisplayAndFonts() {
|
||||
@ -287,7 +319,8 @@ void setup() {
|
||||
Serial.printf("[%lu] [ ] SD card initialization failed\n", millis());
|
||||
setupDisplayAndFonts();
|
||||
exitActivity();
|
||||
enterNewActivity(new FullScreenMessageActivity(renderer, mappedInputManager, "SD card error", EpdFontFamily::BOLD));
|
||||
enterNewActivity(new FullScreenMessageActivity(
|
||||
renderer, mappedInputManager, "SD card error", EpdFontFamily::BOLD));
|
||||
return;
|
||||
}
|
||||
|
||||
@ -300,11 +333,19 @@ void setup() {
|
||||
verifyPowerButtonDuration();
|
||||
}
|
||||
|
||||
// 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());
|
||||
// First serial output only here to avoid timing inconsistencies for power
|
||||
// button press duration verification
|
||||
Serial.printf("[%lu] [ ] Starting CrossPoint version " CROSSPOINT_VERSION
|
||||
"\n",
|
||||
millis());
|
||||
|
||||
setupDisplayAndFonts();
|
||||
|
||||
ThemeEngine::ThemeManager::get().begin();
|
||||
ThemeEngine::ThemeManager::get().registerFont("UI_12", UI_12_FONT_ID);
|
||||
ThemeEngine::ThemeManager::get().registerFont("UI_10", UI_10_FONT_ID);
|
||||
ThemeEngine::ThemeManager::get().loadTheme(SETTINGS.themeName);
|
||||
|
||||
exitActivity();
|
||||
enterNewActivity(new BootActivity(renderer, mappedInputManager));
|
||||
|
||||
@ -314,7 +355,8 @@ void setup() {
|
||||
if (APP_STATE.openEpubPath.empty()) {
|
||||
onGoHome();
|
||||
} else {
|
||||
// Clear app state to avoid getting into a boot loop if the epub doesn't load
|
||||
// Clear app state to avoid getting into a boot loop if the epub doesn't
|
||||
// load
|
||||
const auto path = APP_STATE.openEpubPath;
|
||||
APP_STATE.openEpubPath = "";
|
||||
APP_STATE.lastSleepImage = 0;
|
||||
@ -334,12 +376,14 @@ void loop() {
|
||||
gpio.update();
|
||||
|
||||
if (Serial && millis() - lastMemPrint >= 10000) {
|
||||
Serial.printf("[%lu] [MEM] Free: %d bytes, Total: %d bytes, Min Free: %d bytes\n", millis(), ESP.getFreeHeap(),
|
||||
ESP.getHeapSize(), ESP.getMinFreeHeap());
|
||||
Serial.printf(
|
||||
"[%lu] [MEM] Free: %d bytes, Total: %d bytes, Min Free: %d bytes\n",
|
||||
millis(), ESP.getFreeHeap(), ESP.getHeapSize(), ESP.getMinFreeHeap());
|
||||
lastMemPrint = millis();
|
||||
}
|
||||
|
||||
// Check for any user activity (button press or release) or active background work
|
||||
// Check for any user activity (button press or release) or active background
|
||||
// work
|
||||
static unsigned long lastActivityTime = millis();
|
||||
if (gpio.wasAnyPressed() || gpio.wasAnyReleased() || (currentActivity && currentActivity->preventAutoSleep())) {
|
||||
lastActivityTime = millis(); // Reset inactivity timer
|
||||
@ -347,7 +391,9 @@ void loop() {
|
||||
|
||||
const unsigned long sleepTimeoutMs = SETTINGS.getSleepTimeoutMs();
|
||||
if (millis() - lastActivityTime >= sleepTimeoutMs) {
|
||||
Serial.printf("[%lu] [SLP] Auto-sleep triggered after %lu ms of inactivity\n", millis(), sleepTimeoutMs);
|
||||
Serial.printf(
|
||||
"[%lu] [SLP] Auto-sleep triggered after %lu ms of inactivity\n",
|
||||
millis(), sleepTimeoutMs);
|
||||
enterDeepSleep();
|
||||
// This should never be hit as `enterDeepSleep` calls esp_deep_sleep_start
|
||||
return;
|
||||
@ -369,14 +415,15 @@ void loop() {
|
||||
if (loopDuration > maxLoopDuration) {
|
||||
maxLoopDuration = loopDuration;
|
||||
if (maxLoopDuration > 50) {
|
||||
Serial.printf("[%lu] [LOOP] New max loop duration: %lu ms (activity: %lu ms)\n", millis(), maxLoopDuration,
|
||||
activityDuration);
|
||||
Serial.printf(
|
||||
"[%lu] [LOOP] New max loop duration: %lu ms (activity: %lu ms)\n",
|
||||
millis(), maxLoopDuration, activityDuration);
|
||||
}
|
||||
}
|
||||
|
||||
// Add delay at the end of the loop to prevent tight spinning
|
||||
// When an activity requests skip loop delay (e.g., webserver running), use yield() for faster response
|
||||
// Otherwise, use longer delay to save power
|
||||
// When an activity requests skip loop delay (e.g., webserver running), use
|
||||
// yield() for faster response Otherwise, use longer delay to save power
|
||||
if (currentActivity && currentActivity->skipLoopDelay()) {
|
||||
yield(); // Give FreeRTOS a chance to run tasks, but return immediately
|
||||
} else {
|
||||
|
||||
Loading…
Reference in New Issue
Block a user