This commit is contained in:
Brackyt 2026-01-25 23:33:32 +00:00 committed by GitHub
commit 1e1669914a
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
26 changed files with 5056 additions and 525 deletions

View File

@ -3,43 +3,88 @@
#include <cstdlib>
#include <cstring>
#include "BitmapHelpers.h"
// ============================================================================
// IMAGE PROCESSING OPTIONS - Toggle these to test different configurations
// ============================================================================
// Note: For cover images, dithering is done in JpegToBmpConverter.cpp
// This file handles BMP reading - use simple quantization to avoid double-dithering
constexpr bool USE_ATKINSON = true; // Use Atkinson dithering instead of Floyd-Steinberg
// IMAGE PROCESSING OPTIONS
// ============================================================================
constexpr bool USE_ATKINSON = true;
Bitmap::~Bitmap() {
delete[] errorCurRow;
delete[] errorNextRow;
delete atkinsonDitherer;
delete fsDitherer;
}
uint16_t Bitmap::readLE16(FsFile& f) {
const int c0 = f.read();
const int c1 = f.read();
const auto b0 = static_cast<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 +96,25 @@ const char* Bitmap::errorToString(BmpReaderError err) {
case BmpReaderError::SeekStartFailed:
return "SeekStartFailed";
case BmpReaderError::NotBMP:
return "NotBMP (missing 'BM')";
return "NotBMP";
case BmpReaderError::DIBTooSmall:
return "DIBTooSmall (<40 bytes)";
return "DIBTooSmall";
case BmpReaderError::BadPlanes:
return "BadPlanes (!= 1)";
return "BadPlanes";
case BmpReaderError::UnsupportedBpp:
return "UnsupportedBpp (expected 1, 2, 8, 24, or 32)";
return "UnsupportedBpp";
case BmpReaderError::UnsupportedCompression:
return "UnsupportedCompression (expected BI_RGB or BI_BITFIELDS for 32bpp)";
return "UnsupportedCompression";
case BmpReaderError::BadDimensions:
return "BadDimensions";
case BmpReaderError::ImageTooLarge:
return "ImageTooLarge (max 2048x3072)";
return "ImageTooLarge";
case BmpReaderError::PaletteTooLarge:
return "PaletteTooLarge";
case BmpReaderError::SeekPixelDataFailed:
return "SeekPixelDataFailed";
case BmpReaderError::BufferTooSmall:
return "BufferTooSmall";
case BmpReaderError::OomRowBuffer:
return "OomRowBuffer";
case BmpReaderError::ShortReadRow:
@ -81,67 +124,85 @@ const char* Bitmap::errorToString(BmpReaderError err) {
}
BmpReaderError Bitmap::parseHeaders() {
if (!file) return BmpReaderError::FileInvalid;
if (!file.seek(0)) return BmpReaderError::SeekStartFailed;
if (!file && !memoryBuffer) return BmpReaderError::FileInvalid;
if (!seekSet(0)) return BmpReaderError::SeekStartFailed;
// --- BMP FILE HEADER ---
const uint16_t bfType = readLE16(file);
const uint16_t bfType = readLE16();
if (bfType != 0x4D42) return BmpReaderError::NotBMP;
file.seekCur(8);
bfOffBits = readLE32(file);
seekCur(8);
bfOffBits = readLE32();
// --- DIB HEADER ---
const uint32_t biSize = readLE32(file);
const uint32_t biSize = readLE32();
if (biSize < 40) return BmpReaderError::DIBTooSmall;
width = static_cast<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 uint16_t planes = readLE16();
bpp = readLE16();
const uint32_t comp = readLE32();
const bool validBpp = bpp == 1 || bpp == 2 || bpp == 8 || bpp == 24 || bpp == 32;
if (planes != 1) return BmpReaderError::BadPlanes;
if (!validBpp) return BmpReaderError::UnsupportedBpp;
// Allow BI_RGB (0) for all, and BI_BITFIELDS (3) for 32bpp which is common for BGRA masks.
if (!(comp == 0 || (bpp == 32 && comp == 3))) return BmpReaderError::UnsupportedCompression;
file.seekCur(12); // biSizeImage, biXPelsPerMeter, biYPelsPerMeter
const uint32_t colorsUsed = readLE32(file);
seekCur(12);
const uint32_t colorsUsed = readLE32();
if (colorsUsed > 256u) return BmpReaderError::PaletteTooLarge;
file.seekCur(4); // biClrImportant
seekCur(4);
// Robustness Fix: Skip extended header bytes (V4/V5)
if (biSize > 40) {
seekCur(biSize - 40);
}
if (width <= 0 || height <= 0) return BmpReaderError::BadDimensions;
// Safety limits to prevent memory issues on ESP32
constexpr int MAX_IMAGE_WIDTH = 2048;
constexpr int MAX_IMAGE_HEIGHT = 3072;
if (width > MAX_IMAGE_WIDTH || height > MAX_IMAGE_HEIGHT) {
return BmpReaderError::ImageTooLarge;
}
// Pre-calculate Row Bytes to avoid doing this every row
rowBytes = (width * bpp + 31) / 32 * 4;
for (int i = 0; i < 256; i++) paletteLum[i] = static_cast<uint8_t>(i);
if (colorsUsed > 0) {
for (uint32_t i = 0; i < colorsUsed; i++) {
// Initialize safe default palette
if (bpp == 1) {
// For 1-bit, default to Black(0) and White(1)
paletteLum[0] = 0;
paletteLum[1] = 255;
} else if (bpp <= 8) {
int maxIdx = (1 << bpp) - 1;
for (int i = 0; i <= maxIdx; i++) {
paletteLum[i] = (i * 255) / maxIdx;
}
} else {
for (int i = 0; i < 256; i++) paletteLum[i] = static_cast<uint8_t>(i);
}
// If indexed color (<=8bpp), we MUST load the palette.
// The palette is located AFTER the DIB header.
if (bpp <= 8) {
// Explicit seek to palette start
if (!seekSet(14 + biSize)) return BmpReaderError::SeekStartFailed;
uint32_t colorsToRead = colorsUsed;
if (colorsToRead == 0) colorsToRead = 1 << bpp;
if (colorsToRead > 256) colorsToRead = 256;
for (uint32_t i = 0; i < colorsToRead; i++) {
uint8_t rgb[4];
file.read(rgb, 4); // Read B, G, R, Reserved in one go
if (readBytes(rgb, 4) != 4) break;
paletteLum[i] = (77u * rgb[2] + 150u * rgb[1] + 29u * rgb[0]) >> 8;
}
}
if (!file.seek(bfOffBits)) {
return BmpReaderError::SeekPixelDataFailed;
}
if (!seekSet(bfOffBits)) return BmpReaderError::SeekPixelDataFailed;
// Create ditherer if enabled (only for 2-bit output)
// Use OUTPUT dimensions for dithering (after prescaling)
if (bpp > 2 && dithering) {
if (USE_ATKINSON) {
atkinsonDitherer = new AtkinsonDitherer(width);
@ -153,31 +214,25 @@ BmpReaderError Bitmap::parseHeaders() {
return BmpReaderError::Ok;
}
// packed 2bpp output, 0 = black, 1 = dark gray, 2 = light gray, 3 = white
BmpReaderError Bitmap::readNextRow(uint8_t* data, uint8_t* rowBuffer) const {
// Note: rowBuffer should be pre-allocated by the caller to size 'rowBytes'
if (file.read(rowBuffer, rowBytes) != rowBytes) return BmpReaderError::ShortReadRow;
if (readBytes(rowBuffer, rowBytes) != (size_t)rowBytes) return BmpReaderError::ShortReadRow;
prevRowY += 1;
uint8_t* outPtr = data;
uint8_t currentOutByte = 0;
int bitShift = 6;
int currentX = 0;
// Helper lambda to pack 2bpp color into the output stream
auto packPixel = [&](const uint8_t lum) {
uint8_t color;
if (atkinsonDitherer) {
color = atkinsonDitherer->processPixel(adjustPixel(lum), currentX);
color = atkinsonDitherer->processPixel(lum, currentX);
} else if (fsDitherer) {
color = fsDitherer->processPixel(adjustPixel(lum), currentX);
color = fsDitherer->processPixel(lum, currentX);
} else {
if (bpp > 2) {
// Simple quantization or noise dithering
color = quantize(adjustPixel(lum), currentX, prevRowY);
} else {
// do not quantize 2bpp image
color = static_cast<uint8_t>(lum >> 6);
}
}
@ -192,13 +247,18 @@ BmpReaderError Bitmap::readNextRow(uint8_t* data, uint8_t* rowBuffer) const {
currentX++;
};
uint8_t lum;
switch (bpp) {
case 32: {
const uint8_t* p = rowBuffer;
for (int x = 0; x < width; x++) {
lum = (77u * p[2] + 150u * p[1] + 29u * p[0]) >> 8;
uint8_t lum; // Declare lum here
// Handle Alpha channel (byte 3). If transparent (<128), treat as White.
// This fixes 32-bit icons appearing as black squares on white backgrounds.
if (p[3] < 128) {
lum = 255;
} else {
lum = (77u * p[2] + 150u * p[1] + 29u * p[0]) >> 8;
}
packPixel(lum);
p += 4;
}
@ -207,32 +267,27 @@ BmpReaderError Bitmap::readNextRow(uint8_t* data, uint8_t* rowBuffer) const {
case 24: {
const uint8_t* p = rowBuffer;
for (int x = 0; x < width; x++) {
lum = (77u * p[2] + 150u * p[1] + 29u * p[0]) >> 8;
uint8_t lum = (77u * p[2] + 150u * p[1] + 29u * p[0]) >> 8;
packPixel(lum);
p += 3;
}
break;
}
case 8: {
for (int x = 0; x < width; x++) {
packPixel(paletteLum[rowBuffer[x]]);
}
for (int x = 0; x < width; x++) packPixel(paletteLum[rowBuffer[x]]);
break;
}
case 2: {
for (int x = 0; x < width; x++) {
lum = paletteLum[(rowBuffer[x >> 2] >> (6 - ((x & 3) * 2))) & 0x03];
uint8_t lum = paletteLum[(rowBuffer[x >> 2] >> (6 - ((x & 3) * 2))) & 0x03];
packPixel(lum);
}
break;
}
case 1: {
for (int x = 0; x < width; x++) {
// Get palette index (0 or 1) from bit at position x
const uint8_t palIndex = (rowBuffer[x >> 3] & (0x80 >> (x & 7))) ? 1 : 0;
// Use palette lookup for proper black/white mapping
lum = paletteLum[palIndex];
packPixel(lum);
packPixel(paletteLum[palIndex]);
}
break;
}
@ -245,20 +300,13 @@ BmpReaderError Bitmap::readNextRow(uint8_t* data, uint8_t* rowBuffer) const {
else if (fsDitherer)
fsDitherer->nextRow();
// Flush remaining bits if width is not a multiple of 4
if (bitShift != 6) *outPtr = currentOutByte;
return BmpReaderError::Ok;
}
BmpReaderError Bitmap::rewindToData() const {
if (!file.seek(bfOffBits)) {
return BmpReaderError::SeekPixelDataFailed;
}
// Reset dithering when rewinding
if (!seekSet(bfOffBits)) return BmpReaderError::SeekPixelDataFailed;
if (fsDitherer) fsDitherer->reset();
if (atkinsonDitherer) atkinsonDitherer->reset();
return BmpReaderError::Ok;
}

View File

@ -32,11 +32,16 @@ class Bitmap {
public:
static const char* errorToString(BmpReaderError err);
explicit Bitmap(FsFile& file, bool dithering = false) : file(file), dithering(dithering) {}
explicit Bitmap(FsFile& file, bool dithering = false) : file(&file), dithering(dithering) {}
explicit Bitmap(const uint8_t* buffer, size_t size, bool dithering = false)
: file(nullptr), memoryBuffer(buffer), memorySize(size), dithering(dithering) {}
~Bitmap();
BmpReaderError parseHeaders();
BmpReaderError readNextRow(uint8_t* data, uint8_t* rowBuffer) const;
BmpReaderError rewindToData() const;
// Getters
int getWidth() const { return width; }
int getHeight() const { return height; }
bool isTopDown() const { return topDown; }
@ -46,10 +51,21 @@ class Bitmap {
uint16_t getBpp() const { return bpp; }
private:
static uint16_t readLE16(FsFile& f);
static uint32_t readLE32(FsFile& f);
// Internal IO helpers
int readByte() const;
size_t readBytes(void* buf, size_t count) const;
bool seekSet(uint32_t pos) const;
bool seekCur(int32_t offset) const; // Only needed for skip?
uint16_t readLE16();
uint32_t readLE32();
// Source (one is valid)
FsFile* file = nullptr;
const uint8_t* memoryBuffer = nullptr;
size_t memorySize = 0;
mutable size_t bufferPos = 0;
FsFile& file;
bool dithering = false;
int width = 0;
int height = 0;

File diff suppressed because it is too large Load Diff

View File

@ -14,9 +14,11 @@ class GfxRenderer {
// Logical screen orientation from the perspective of callers
enum Orientation {
Portrait, // 480x800 logical coordinates (current default)
LandscapeClockwise, // 800x480 logical coordinates, rotated 180° (swap top/bottom)
LandscapeClockwise, // 800x480 logical coordinates, rotated 180° (swap
// top/bottom)
PortraitInverted, // 480x800 logical coordinates, inverted
LandscapeCounterClockwise // 800x480 logical coordinates, native panel orientation
LandscapeCounterClockwise // 800x480 logical coordinates, native panel
// orientation
};
private:
@ -47,7 +49,8 @@ class GfxRenderer {
// Setup
void insertFont(int fontId, EpdFontFamily font);
// Orientation control (affects logical width/height and coordinate transforms)
// Orientation control (affects logical width/height and coordinate
// transforms)
void setOrientation(const Orientation o) { orientation = o; }
Orientation getOrientation() const { return orientation; }
@ -62,15 +65,31 @@ class GfxRenderer {
// Drawing
void drawPixel(int x, int y, bool state = true) const;
bool readPixel(int x, int y) const; // Returns true if pixel is black
void drawLine(int x1, int y1, int x2, int y2, bool state = true) const;
void drawRect(int x, int y, int width, int height, bool state = true) const;
void fillRect(int x, int y, int width, int height, bool state = true) const;
void fillRectDithered(int x, int y, int width, int height, uint8_t grayLevel) const;
void drawRoundedRect(int x, int y, int width, int height, int radius, bool state = true) const;
void fillRoundedRect(int x, int y, int width, int height, int radius, bool state = true) const;
void fillRoundedRectDithered(int x, int y, int width, int height, int radius, uint8_t grayLevel) const;
void drawImage(const uint8_t bitmap[], int x, int y, int width, int height) const;
void drawBitmap(const Bitmap& bitmap, int x, int y, int maxWidth, int maxHeight, float cropX = 0,
float cropY = 0) const;
void drawBitmap1Bit(const Bitmap& bitmap, int x, int y, int maxWidth, int maxHeight) const;
void drawBitmap1Bit(const Bitmap& bitmap, const int x, const int y, const int maxWidth, const int maxHeight) const;
void drawTransparentBitmap(const Bitmap& bitmap, const int x, const int y, const int w, const int h) const;
void drawRoundedBitmap(const Bitmap& bitmap, const int x, const int y, const int w, const int h,
const int radius) const;
void draw2BitImage(const uint8_t data[], int x, int y, int w, int h) const;
void fillPolygon(const int* xPoints, const int* yPoints, int numPoints, bool state = true) const;
// Region caching - copies a rectangular region to/from a buffer
// Returns allocated buffer on success, nullptr on failure. Caller owns the
// memory.
uint8_t* captureRegion(int x, int y, int width, int height, size_t* outSize) const;
// Restores a previously captured region. Buffer must match dimensions.
void restoreRegion(const uint8_t* buffer, int x, int y, int width, int height) const;
// Text
int getTextWidth(int fontId, const char* text, EpdFontFamily::Style style = EpdFontFamily::REGULAR) const;
void drawCenteredText(int fontId, int y, const char* text, bool black = true,

View File

@ -0,0 +1,585 @@
#pragma once
#include <Bitmap.h>
#include <SDCardManager.h>
#include <vector>
#include "ThemeContext.h"
#include "ThemeTypes.h"
#include "UIElement.h"
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
int padding = 0; // Inner padding for children
int borderRadius = 0; // Corner radius (for future rounded rect support)
public:
explicit Container(const std::string& id) : UIElement(id), bgColorExpr(Expression::parse("0xFF")) {}
virtual ~Container() {
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 setPadding(int p) {
padding = p;
markDirty();
}
int getPadding() const { return padding; }
void setBorderRadius(int r) {
borderRadius = r;
markDirty();
}
int getBorderRadius() const { return borderRadius; }
void layout(const ThemeContext& context, int parentX, int parentY, int parentW, int parentH) override {
UIElement::layout(context, parentX, parentY, parentW, parentH);
// Children are laid out with padding offset
int childX = absX + padding;
int childY = absY + padding;
int childW = absW - 2 * padding;
int childH = absH - 2 * padding;
for (auto child : children) {
child->layout(context, childX, childY, childW, childH);
}
}
void markDirty() override {
UIElement::markDirty();
for (auto child : children) {
child->markDirty();
}
}
void draw(const GfxRenderer& renderer, const ThemeContext& context) override {
if (!isVisible(context)) return;
if (hasBg) {
std::string colStr = context.evaluatestring(bgColorExpr);
uint8_t color = Color::parse(colStr).value;
// Use dithered fill for grayscale values, solid fill for black/white
// Use rounded rect if borderRadius > 0
if (color == 0x00) {
if (borderRadius > 0) {
renderer.fillRoundedRect(absX, absY, absW, absH, borderRadius, true);
} else {
renderer.fillRect(absX, absY, absW, absH, true);
}
} else if (color >= 0xF0) {
if (borderRadius > 0) {
renderer.fillRoundedRect(absX, absY, absW, absH, borderRadius, false);
} else {
renderer.fillRect(absX, absY, absW, absH, false);
}
} else {
if (borderRadius > 0) {
renderer.fillRoundedRectDithered(absX, absY, absW, absH, borderRadius, color);
} else {
renderer.fillRectDithered(absX, absY, absW, absH, color);
}
}
}
// Handle dynamic border expression
bool drawBorder = border;
if (hasBorderExpr()) {
drawBorder = context.evaluateBool(borderExpr.rawExpr);
}
if (drawBorder) {
if (borderRadius > 0) {
renderer.drawRoundedRect(absX, absY, absW, absH, borderRadius, true);
} else {
renderer.drawRect(absX, absY, absW, absH, true);
}
}
for (auto child : children) {
child->draw(renderer, context);
}
markClean();
}
};
// --- Rectangle ---
class Rectangle : public UIElement {
bool fill = false;
Expression fillExpr; // Dynamic fill based on expression
Expression colorExpr;
public:
explicit 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:
explicit 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);
// Split text into lines based on width
std::vector<std::string> lines;
if (absW > 0 && textWidth > absW && maxLines > 1) {
// Logic to wrap text
std::string remaining = finalText;
while (!remaining.empty() && (int)lines.size() < maxLines) {
// If it fits, add entire line
if (renderer.getTextWidth(fontId, remaining.c_str()) <= absW) {
lines.push_back(remaining);
break;
}
// Binary search for cut point
int len = remaining.length();
int cut = len;
// Find split point
// Optimistic start: approximate chars that fit
int avgCharWidth = renderer.getTextWidth(fontId, "a");
if (avgCharWidth < 1) avgCharWidth = 8;
int approxChars = absW / avgCharWidth;
if (approxChars < 1) approxChars = 1;
if (approxChars >= len) approxChars = len - 1;
// Refine from approxChars
int w = renderer.getTextWidth(fontId, remaining.substr(0, approxChars).c_str());
if (w < absW) {
// Grow
for (int i = approxChars; i <= len; i++) {
if (renderer.getTextWidth(fontId, remaining.substr(0, i).c_str()) > absW) {
cut = i - 1;
break;
}
cut = i;
}
} else {
// Shrink
for (int i = approxChars; i > 0; i--) {
if (renderer.getTextWidth(fontId, remaining.substr(0, i).c_str()) <= absW) {
cut = i;
break;
}
}
}
// Find last space before cut
if (cut < (int)remaining.length()) {
int space = -1;
for (int i = cut; i > 0; i--) {
if (remaining[i] == ' ') {
space = i;
break;
}
}
if (space != -1) cut = space;
}
std::string line = remaining.substr(0, cut);
// If we're at the last allowed line but still have more text
if ((int)lines.size() == maxLines - 1 && cut < (int)remaining.length()) {
if (ellipsis) {
line = renderer.truncatedText(fontId, remaining.c_str(), absW);
}
lines.push_back(line);
break;
}
lines.push_back(line);
// Advance
if (cut < (int)remaining.length()) {
// Skip the space if check
if (remaining[cut] == ' ') cut++;
remaining = remaining.substr(cut);
} else {
remaining = "";
}
}
} else {
// Single line handling (truncate if needed)
if (ellipsis && textWidth > absW && absW > 0) {
finalText = renderer.truncatedText(fontId, finalText.c_str(), absW);
}
lines.push_back(finalText);
}
// Draw lines
int totalTextHeight = lines.size() * lineHeight;
int startY = absY;
// Vertical centering
if (absH > 0 && totalTextHeight < absH) {
startY = absY + (absH - totalTextHeight) / 2;
}
for (size_t i = 0; i < lines.size(); i++) {
int lineWidth = renderer.getTextWidth(fontId, lines[i].c_str());
int drawX = absX;
if (alignment == Alignment::Center && absW > 0) {
drawX = absX + (absW - lineWidth) / 2;
} else if (alignment == Alignment::Right && absW > 0) {
drawX = absX + absW - lineWidth;
}
renderer.drawText(fontId, drawX, startY + i * lineHeight, lines[i].c_str(), black);
}
markClean();
}
};
// --- BitmapElement ---
class BitmapElement : public UIElement {
Expression srcExpr;
bool scaleToFit = true;
bool preserveAspect = true;
int borderRadius = 0;
public:
explicit BitmapElement(const std::string& id) : UIElement(id) {
cacheable = true; // Bitmaps benefit from caching
}
ElementType getType() const override { return ElementType::Bitmap; }
void setSrc(const std::string& src) {
srcExpr = Expression::parse(src);
invalidateCache();
}
void setScaleToFit(bool scale) {
scaleToFit = scale;
invalidateCache();
}
void setPreserveAspect(bool preserve) {
preserveAspect = preserve;
invalidateCache();
}
void setBorderRadius(int r) {
borderRadius = r;
// Radius doesn't affect cache key unless we baked it in (we don't currently),
// but we should redraw.
markDirty();
}
void draw(const GfxRenderer& renderer, const ThemeContext& context) override;
};
// --- ProgressBar ---
class ProgressBar : public UIElement {
Expression valueExpr; // Current value (0-100 or 0-max)
Expression maxExpr; // Max value (default 100)
Expression fgColorExpr; // Foreground color
Expression bgColorExpr; // Background color
bool showBorder = true;
int borderWidth = 1;
public:
explicit ProgressBar(const std::string& id)
: UIElement(id),
valueExpr(Expression::parse("0")),
maxExpr(Expression::parse("100")),
fgColorExpr(Expression::parse("0x00")), // Black fill
bgColorExpr(Expression::parse("0xFF")) // White background
{}
ElementType getType() const override { return ElementType::ProgressBar; }
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:
explicit 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();
}
};
// --- BatteryIcon ---
class BatteryIcon : public UIElement {
Expression valueExpr;
Expression colorExpr;
public:
explicit BatteryIcon(const std::string& id)
: UIElement(id), valueExpr(Expression::parse("0")), colorExpr(Expression::parse("0x00")) {
// Black by default
}
ElementType getType() const override { return ElementType::BatteryIcon; }
void setValue(const std::string& expr) {
valueExpr = Expression::parse(expr);
markDirty();
}
void setColor(const std::string& expr) {
colorExpr = Expression::parse(expr);
markDirty();
}
void draw(const GfxRenderer& renderer, const ThemeContext& context) override {
if (!isVisible(context)) return;
std::string valStr = context.evaluatestring(valueExpr);
int percentage = valStr.empty() ? 0 : std::stoi(valStr);
std::string colStr = context.evaluatestring(colorExpr);
uint8_t color = Color::parse(colStr).value;
bool black = (color == 0x00);
constexpr int batteryWidth = 15;
constexpr int batteryHeight = 12;
int x = absX;
int y = absY;
if (absW > batteryWidth) x += (absW - batteryWidth) / 2;
if (absH > batteryHeight) y += (absH - batteryHeight) / 2;
renderer.drawLine(x + 1, y, x + batteryWidth - 3, y, black);
renderer.drawLine(x + 1, y + batteryHeight - 1, x + batteryWidth - 3, y + batteryHeight - 1, black);
renderer.drawLine(x, y + 1, x, y + batteryHeight - 2, black);
renderer.drawLine(x + batteryWidth - 2, y + 1, x + batteryWidth - 2, y + batteryHeight - 2, black);
renderer.drawPixel(x + batteryWidth - 1, y + 3, black);
renderer.drawPixel(x + batteryWidth - 1, y + batteryHeight - 4, black);
renderer.drawLine(x + batteryWidth - 0, y + 4, x + batteryWidth - 0, y + batteryHeight - 5, black);
if (percentage > 0) {
int filledWidth = percentage * (batteryWidth - 5) / 100 + 1;
if (filledWidth > batteryWidth - 5) {
filledWidth = batteryWidth - 5;
}
renderer.fillRect(x + 2, y + 2, filledWidth, batteryHeight - 4, black);
}
markClean();
}
};
} // namespace ThemeEngine

View File

@ -0,0 +1,303 @@
#pragma once
// Default theme - matches the original CrossPoint Reader look
// This is embedded in the firmware as a fallback
namespace ThemeEngine {
// Use static function for C++14 ODR compatibility
static const char* getDefaultThemeIni() {
static const char* theme = R"INI(
; ============================================
; DEFAULT THEME - Original CrossPoint Reader
; ============================================
; Screen: 480x800
; Layout: Centered book card + vertical menu list
[Global]
FontUI12 = UI_12
FontUI10 = UI_10
NavBookCount = 1
; ============================================
; HOME SCREEN
; ============================================
[Home]
Type = Container
X = 0
Y = 0
Width = 480
Height = 800
BgColor = white
; --- Battery (top right) ---
[BatteryWrapper]
Parent = Home
Type = Container
X = 400
Y = 10
Width = 80
Height = 20
[BatteryIcon]
Parent = BatteryWrapper
Type = BatteryIcon
X = 0
Y = 5
Width = 15
Height = 20
Value = {BatteryPercent}
Color = black
[BatteryText]
Parent = BatteryWrapper
Type = Label
Font = Small
Text = {BatteryPercent}%
X = 22
Y = 0
Width = 50
Height = 20
Align = Left
Visible = {ShowBatteryPercent}
; --- Book Card (centered) ---
; Original: 240x400 at (120, 30)
[BookCard]
Parent = Home
Type = Container
X = 120
Y = 30
Width = 240
Height = 400
Border = true
BgColor = {IsBookSelected ? "black" : "white"}
Visible = {HasBook}
; Bookmark ribbon decoration (when no cover)
[BookmarkRibbon]
Parent = BookCard
Type = Container
X = 200
Y = 5
Width = 30
Height = 60
BgColor = {IsBookSelected ? "white" : "black"}
Visible = {!HasCover}
[BookmarkNotch]
Parent = BookmarkRibbon
Type = Container
X = 10
Y = 45
Width = 10
Height = 15
BgColor = {IsBookSelected ? "black" : "white"}
; Title centered in card
[BookCover]
Parent = BookCard
Type = Bitmap
X = 0
Y = 0
Width = 240
Height = 400
Src = {BookCoverPath}
ScaleToFit = true
PreserveAspect = true
Visible = {HasCover}
; White box for text overlay
[InfoBox]
Parent = BookCard
Type = Container
X = 20
Y = 120
Width = 200
Height = 150
BgColor = white
Border = true
[BookTitle]
Parent = InfoBox
Type = Label
Font = UI_12
Text = {BookTitle}
X = 10
Y = 10
Width = 180
Height = 80
Color = black
Align = center
Ellipsis = true
MaxLines = 3
[BookAuthor]
Parent = InfoBox
Type = Label
Font = UI_10
Text = {BookAuthor}
X = 10
Y = 100
Width = 180
Height = 40
Color = black
Align = center
Ellipsis = true
; "Continue Reading" at bottom of card
[ContinueLabel]
Parent = BookCard
Type = Label
Font = UI_10
Text = Continue Reading
X = 20
Y = 365
Width = 200
Height = 25
Color = {IsBookSelected ? "white" : "black"}
Align = center
Visible = {HasBook}
; --- No Book Message ---
[NoBookCard]
Parent = Home
Type = Container
X = 120
Y = 30
Width = 240
Height = 400
Border = true
Visible = {!HasBook}
[NoBookTitle]
Parent = NoBookCard
Type = Label
Font = UI_12
Text = No open book
X = 20
Y = 175
Width = 200
Height = 25
Align = center
[NoBookSubtitle]
Parent = NoBookCard
Type = Label
Font = UI_10
Text = Start reading below
X = 20
Y = 205
Width = 200
Height = 25
Align = center
; --- Menu List ---
; Original: margin=20, tileWidth=440, tileHeight=45, spacing=8
; menuStartY = 30 + 400 + 15 = 445
[MenuList]
Parent = Home
Type = List
Source = MainMenu
ItemTemplate = MenuItem
X = 20
Y = 445
Width = 440
Height = 280
Direction = Vertical
ItemHeight = 45
Spacing = 8
; --- Menu Item Template ---
[MenuItem]
Type = Container
Width = 440
Height = 45
BgColor = {Item.Selected ? "black" : "white"}
Border = true
[MenuItemLabel]
Parent = MenuItem
Type = Label
Font = UI_10
Text = {Item.Title}
X = 0
Y = 0
Width = 440
Height = 45
Color = {Item.Selected ? "white" : "black"}
Align = center
; --- Button Hints (bottom) ---
; Original: 4 buttons at [25, 130, 245, 350], width=106, height=40
; Y = pageHeight - 40 = 760
[HintBtn2]
Parent = Home
Type = Container
X = 130
Y = 760
Width = 106
Height = 40
BgColor = white
Border = true
[HintBtn2Label]
Parent = HintBtn2
Type = Label
Font = UI_10
Text = Confirm
X = 0
Y = 0
Width = 106
Height = 40
Align = center
[HintBtn3]
Parent = Home
Type = Container
X = 245
Y = 760
Width = 106
Height = 40
BgColor = white
Border = true
[HintBtn3Label]
Parent = HintBtn3
Type = Label
Font = UI_10
Text = Up
X = 0
Y = 0
Width = 106
Height = 40
Align = center
[HintBtn4]
Parent = Home
Type = Container
X = 350
Y = 760
Width = 106
Height = 40
BgColor = white
Border = true
[HintBtn4Label]
Parent = HintBtn4
Type = Label
Font = UI_10
Text = Down
X = 0
Y = 0
Width = 106
Height = 40
Align = center
)INI";
return theme;
}
} // namespace ThemeEngine

View File

@ -0,0 +1,38 @@
#pragma once
#include <map>
#include <string>
#include <vector>
// Forward declaration for FS file or stream if needed,
// but for now we'll take a string buffer or filename to keep it generic?
// Or better, depend on FS.h to read files directly.
#ifdef FILE_READ
#undef FILE_READ
#endif
#ifdef FILE_WRITE
#undef FILE_WRITE
#endif
#include <FS.h>
namespace ThemeEngine {
struct IniSection {
std::string name;
std::map<std::string, std::string> properties;
};
class IniParser {
public:
// Parse a stream (File, Serial, etc.)
static std::map<std::string, std::map<std::string, std::string>> parse(Stream& stream);
// Parse a string buffer (useful for testing)
static std::map<std::string, std::map<std::string, std::string>> parseString(const std::string& content);
private:
static void trim(std::string& s);
};
} // namespace ThemeEngine

View File

@ -0,0 +1,528 @@
#pragma once
#include <vector>
#include "BasicElements.h"
#include "ThemeContext.h"
#include "ThemeTypes.h"
#include "UIElement.h"
namespace ThemeEngine {
// --- HStack: Horizontal Stack Layout ---
// Children are arranged horizontally with optional spacing
class HStack : public Container {
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

View File

@ -0,0 +1,143 @@
#pragma once
#include <map>
#include <vector>
#include "BasicElements.h"
#include "UIElement.h"
namespace ThemeEngine {
// --- List ---
// Supports vertical, horizontal, and grid layouts
class List : public Container {
public:
enum class Direction { Vertical, Horizontal };
enum class LayoutMode { List, Grid };
private:
std::string source; // Data source name (e.g., "MainMenu", "FileList")
std::string itemTemplateId; // ID of the template element
int itemWidth = 0; // Explicit item width (0 = auto)
int itemHeight = 0; // Explicit item height (0 = auto from template)
int scrollOffset = 0; // Scroll position for long lists
int visibleItems = -1; // Max visible items (-1 = auto)
int spacing = 0; // Gap between items
int columns = 1; // Number of columns (for grid mode)
Direction direction = Direction::Vertical;
LayoutMode layoutMode = LayoutMode::List;
// Template element reference (resolved after loading)
UIElement* itemTemplate = nullptr;
public:
List(const std::string& id) : Container(id) {}
ElementType getType() const override { return ElementType::List; }
void setSource(const std::string& s) {
source = s;
markDirty();
}
const std::string& getSource() const { return source; }
void setItemTemplateId(const std::string& id) {
itemTemplateId = id;
markDirty();
}
void setItemTemplate(UIElement* elem) {
itemTemplate = elem;
markDirty();
}
UIElement* getItemTemplate() const { return itemTemplate; }
void setItemWidth(int w) {
itemWidth = w;
markDirty();
}
void setItemHeight(int h) {
itemHeight = h;
markDirty();
}
int getItemHeight() const {
if (itemHeight > 0) return itemHeight;
if (itemTemplate) return itemTemplate->getAbsH() > 0 ? itemTemplate->getAbsH() : 45;
return 45;
}
int getItemWidth() const {
if (itemWidth > 0) return itemWidth;
if (itemTemplate) return itemTemplate->getAbsW() > 0 ? itemTemplate->getAbsW() : 100;
return 100;
}
void setScrollOffset(int offset) {
scrollOffset = offset;
markDirty();
}
int getScrollOffset() const { return scrollOffset; }
void setVisibleItems(int count) {
visibleItems = count;
markDirty();
}
void setSpacing(int s) {
spacing = s;
markDirty();
}
void setColumns(int c) {
columns = c > 0 ? c : 1;
if (columns > 1) layoutMode = LayoutMode::Grid;
markDirty();
}
void setDirection(Direction d) {
direction = d;
markDirty();
}
void setDirectionFromString(const std::string& dir) {
if (dir == "Horizontal" || dir == "horizontal" || dir == "row") {
direction = Direction::Horizontal;
} else {
direction = Direction::Vertical;
}
markDirty();
}
void setLayoutMode(LayoutMode m) {
layoutMode = m;
markDirty();
}
// Resolve template reference from element map
void resolveTemplate(const std::map<std::string, UIElement*>& elements) {
if (elements.count(itemTemplateId)) {
itemTemplate = elements.at(itemTemplateId);
}
}
void layout(const ThemeContext& context, int parentX, int parentY, int parentW, int parentH) override {
// Layout self first (bounds)
UIElement::layout(context, parentX, parentY, parentW, parentH);
// Pre-layout the template once with list's dimensions to get item sizes
// Pass absH so percentage heights in the template work correctly
if (itemTemplate && itemHeight == 0) {
itemTemplate->layout(context, absX, absY, absW, absH);
}
}
// Draw is implemented in BasicElements.cpp
void draw(const GfxRenderer& renderer, const ThemeContext& context) override;
};
} // namespace ThemeEngine

View File

@ -0,0 +1,348 @@
#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:
explicit ThemeContext(const ThemeContext* parent = nullptr) : parent(parent) {}
void setString(const std::string& key, const std::string& value) { strings[key] = value; }
void setInt(const std::string& key, int value) { ints[key] = value; }
void setBool(const std::string& key, bool value) { bools[key] = value; }
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

View File

@ -0,0 +1,122 @@
#pragma once
#include <GfxRenderer.h>
#include <map>
#include <string>
#include <vector>
#include "BasicElements.h"
#include "IniParser.h"
#include "ThemeContext.h"
namespace ThemeEngine {
struct ProcessedAsset {
std::vector<uint8_t> data;
int w, h;
GfxRenderer::Orientation orientation;
};
// Screen render cache - stores full screen state for quick restore
struct ScreenCache {
uint8_t* buffer = nullptr;
size_t bufferSize = 0;
std::string screenName;
uint32_t contextHash = 0; // Hash of context data to detect changes
bool valid = false;
~ScreenCache() {
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;
int navBookCount = 1; // Number of navigable book slots (from theme [Global] section)
std::map<std::string, int> fontMap;
// Screen-level caching for fast redraw
std::map<std::string, ScreenCache> screenCaches;
bool useCaching = true;
// Track which elements are data-dependent vs static
std::map<std::string, bool> elementDependsOnData;
// Factory and property methods
UIElement* createElement(const std::string& id, const std::string& type);
void applyProperties(UIElement* elem, const std::map<std::string, std::string>& props);
public:
static ThemeManager& get() {
static ThemeManager instance;
return instance;
}
// Initialize defaults (fonts, etc.)
void begin();
// Register a font ID mapping (e.g. "UI_12" -> 0)
void registerFont(const std::string& name, int id);
// Theme loading
void loadTheme(const std::string& themeName);
void unloadTheme();
// Get current theme name
const std::string& getCurrentTheme() const { return currentThemeName; }
// Get number of navigable book slots (from theme config, default 1)
int getNavBookCount() const { return navBookCount; }
// Render a screen
void renderScreen(const std::string& screenName, const GfxRenderer& renderer, const ThemeContext& context);
// Render with dirty tracking (only redraws changed regions)
void renderScreenOptimized(const std::string& screenName, const GfxRenderer& renderer, const ThemeContext& context,
const ThemeContext* prevContext = nullptr);
// Invalidate all caches (call when theme changes or screen switches)
void invalidateAllCaches();
// Invalidate specific screen cache
void invalidateScreenCache(const std::string& screenName);
// Enable/disable caching
void setCachingEnabled(bool enabled) { useCaching = enabled; }
bool isCachingEnabled() const { return useCaching; }
// Asset path resolution
std::string getAssetPath(const std::string& assetName);
// Asset caching
const std::vector<uint8_t>* getCachedAsset(const std::string& path);
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

View File

@ -0,0 +1,76 @@
#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
explicit Color(uint8_t v) : value(v) {}
Color() : value(0) {}
static Color parse(const std::string& str) {
if (str.empty()) return Color(0);
if (str == "black") return Color(0x00);
if (str == "white") return Color(0xFF);
if (str == "gray" || str == "grey") return Color(0x80);
if (str.size() > 2 && str.substr(0, 2) == "0x") {
return Color((uint8_t)std::strtol(str.c_str(), nullptr, 16));
}
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

View File

@ -0,0 +1,193 @@
#pragma once
#include <GfxRenderer.h>
#include <string>
#include <vector>
#include "ThemeContext.h"
#include "ThemeTypes.h"
namespace ThemeEngine {
class Container; // Forward declaration
class UIElement {
public:
int getAbsX() const { return absX; }
int getAbsY() const { return absY; }
int getAbsW() const { return absW; }
int getAbsH() const { return absH; }
const std::string& getId() const { return id; }
protected:
std::string id;
Dimension x, y, width, height;
Expression visibleExpr;
bool visibleExprIsStatic = true; // True if visibility doesn't depend on data
// Recomputed every layout pass
int absX = 0, absY = 0, absW = 0, absH = 0;
// 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,
BatteryIcon,
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

View File

@ -0,0 +1,262 @@
#include "BasicElements.h"
#include <GfxRenderer.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;
}
// Resolve simplified or relative paths
if (path.find('/') == std::string::npos || (path.length() > 0 && path[0] != '/')) {
path = ThemeManager::get().getAssetPath(path);
}
// 1. Check if we have a cached 1-bit render
const ProcessedAsset* processed = ThemeManager::get().getProcessedAsset(path, renderer.getOrientation(), absW, absH);
if (processed && processed->w == absW && processed->h == absH) {
const int rowBytes = (absW + 7) / 8;
for (int y = 0; y < absH; y++) {
const uint8_t* srcRow = processed->data.data() + y * rowBytes;
for (int x = 0; x < absW; x++) {
// Cached 1-bit data: 0=Black, 1=White
bool isBlack = !(srcRow[x / 8] & (1 << (7 - (x % 8))));
// Draw opaque (true=black, false=white)
renderer.drawPixel(absX + x, absY + y, isBlack);
}
}
markClean();
return;
}
bool drawSuccess = false;
// 2. Try Streaming (Absolute paths, large images)
if (path.length() > 0 && path[0] == '/') {
FsFile file;
if (SdMan.openFileForRead("HOME", path, file)) {
Bitmap bmp(file, true); // (file, dithering=true)
if (bmp.parseHeaders() == BmpReaderError::Ok) {
// Center logic
int drawX = absX;
int drawY = absY;
if (bmp.getWidth() < absW) drawX += (absW - bmp.getWidth()) / 2;
if (bmp.getHeight() < absH) drawY += (absH - bmp.getHeight()) / 2;
if (borderRadius > 0) {
renderer.drawRoundedBitmap(bmp, drawX, drawY, absW, absH, borderRadius);
} else {
renderer.drawBitmap(bmp, drawX, drawY, absW, absH);
}
drawSuccess = true;
}
file.close();
}
}
// 3. Fallback to RAM Cache (Standard method)
if (!drawSuccess) {
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) {
int drawX = absX;
int drawY = absY;
if (bmp.getWidth() < absW) drawX += (absW - bmp.getWidth()) / 2;
if (bmp.getHeight() < absH) drawY += (absH - bmp.getHeight()) / 2;
if (borderRadius > 0) {
renderer.drawRoundedBitmap(bmp, drawX, drawY, absW, absH, borderRadius);
} else {
renderer.drawBitmap(bmp, drawX, drawY, absW, absH);
}
drawSuccess = true;
}
}
}
// 4. Cache result if successful
if (drawSuccess) {
ProcessedAsset asset;
asset.w = absW;
asset.h = absH;
asset.orientation = renderer.getOrientation();
const int rowBytes = (absW + 7) / 8;
asset.data.resize(rowBytes * absH, 0xFF); // Initialize to 0xFF (White)
for (int y = 0; y < absH; y++) {
uint8_t* dstRow = asset.data.data() + y * rowBytes;
for (int x = 0; x < absW; x++) {
// Read precise pixel state from framebuffer
bool isBlack = renderer.readPixel(absX + x, absY + y);
if (isBlack) {
// Clear bit for black (0)
dstRow[x / 8] &= ~(1 << (7 - (x % 8)));
}
}
}
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

View File

@ -0,0 +1,102 @@
#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

View File

@ -0,0 +1,176 @@
#include "LayoutElements.h"
#include <Bitmap.h>
#include "ThemeManager.h"
namespace ThemeEngine {
// Built-in icon drawing
// These are simple geometric representations of common icons
void Icon::draw(const GfxRenderer& renderer, const ThemeContext& context) {
if (!isVisible(context)) {
markClean();
return;
}
std::string iconName = context.evaluatestring(srcExpr);
if (iconName.empty()) {
markClean();
return;
}
std::string colStr = context.evaluatestring(colorExpr);
uint8_t color = Color::parse(colStr).value;
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;
// 1. Try to load as a theme asset (exact match or .bmp extension)
std::string path = iconName;
bool isPath = iconName.find('/') != std::string::npos || iconName.find('.') != std::string::npos;
std::string assetPath = path;
if (!isPath) {
assetPath = ThemeManager::get().getAssetPath(iconName + ".bmp");
} else if (path[0] != '/') {
assetPath = ThemeManager::get().getAssetPath(iconName);
}
const std::vector<uint8_t>* data = ThemeManager::get().getCachedAsset(assetPath);
if (data && !data->empty()) {
Bitmap bmp(data->data(), data->size());
if (bmp.parseHeaders() == BmpReaderError::Ok) {
renderer.drawTransparentBitmap(bmp, absX, absY, w, h);
markClean();
return;
}
}
// 2. Built-in icons (simple geometric shapes) as fallback
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

View File

@ -0,0 +1,572 @@
#include "ThemeManager.h"
#include <SDCardManager.h>
#include <algorithm>
#include <map>
#include <vector>
#include "DefaultTheme.h"
#include "LayoutElements.h"
#include "ListElement.h"
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 root
std::string rootPath = "/themes/" + currentThemeName + "/" + assetName;
if (SdMan.exists(rootPath.c_str())) return rootPath;
// Fallback to assets/ subfolder
return "/themes/" + currentThemeName + "/assets/" + assetName;
}
UIElement* ThemeManager::createElement(const std::string& id, const std::string& type) {
// Basic elements
if (type == "Container") return new Container(id);
if (type == "Rectangle") return new Rectangle(id);
if (type == "Label") return new Label(id);
if (type == "Bitmap") return new BitmapElement(id);
if (type == "List") return new List(id);
if (type == "ProgressBar") return new ProgressBar(id);
if (type == "Divider") return new Divider(id);
// Layout elements
if (type == "HStack") return new HStack(id);
if (type == "VStack") return new VStack(id);
if (type == "Grid") return new Grid(id);
// Advanced elements
if (type == "Badge") return new Badge(id);
if (type == "Toggle") return new Toggle(id);
if (type == "TabBar") return new TabBar(id);
if (type == "Icon") return new Icon(id);
if (type == "ScrollIndicator") return new ScrollIndicator(id);
if (type == "BatteryIcon") return new BatteryIcon(id);
return nullptr;
}
void ThemeManager::applyProperties(UIElement* elem, const std::map<std::string, std::string>& props) {
const auto elemType = elem->getType();
for (const auto& kv : props) {
const std::string& key = kv.first;
const std::string& val = kv.second;
// ========== Common properties ==========
if (key == "X")
elem->setX(Dimension::parse(val));
else if (key == "Y")
elem->setY(Dimension::parse(val));
else if (key == "Width")
elem->setWidth(Dimension::parse(val));
else if (key == "Height")
elem->setHeight(Dimension::parse(val));
else if (key == "Visible")
elem->setVisibleExpr(val);
else if (key == "Cacheable")
elem->setCacheable(val == "true" || val == "1");
// ========== Rectangle properties ==========
else if (key == "Fill") {
if (elemType == UIElement::ElementType::Rectangle) {
auto rect = static_cast<Rectangle*>(elem);
if (val.find('{') != std::string::npos) {
rect->setFillExpr(val);
} else {
rect->setFill(val == "true" || val == "1");
}
}
} else if (key == "Color") {
if (elemType == UIElement::ElementType::Rectangle) {
static_cast<Rectangle*>(elem)->setColorExpr(val);
} else if (elemType == UIElement::ElementType::Container || elemType == UIElement::ElementType::HStack ||
elemType == UIElement::ElementType::VStack || elemType == UIElement::ElementType::Grid ||
elemType == UIElement::ElementType::TabBar) {
static_cast<Container*>(elem)->setBackgroundColorExpr(val);
} else if (elemType == UIElement::ElementType::Label) {
static_cast<Label*>(elem)->setColorExpr(val);
} else if (elemType == UIElement::ElementType::Divider) {
static_cast<Divider*>(elem)->setColorExpr(val);
} else if (elemType == UIElement::ElementType::Icon) {
static_cast<Icon*>(elem)->setColorExpr(val);
} else if (elemType == UIElement::ElementType::BatteryIcon) {
static_cast<BatteryIcon*>(elem)->setColor(val);
}
}
// ========== Container properties ==========
else if (key == "Border") {
if (auto c = elem->asContainer()) {
if (val.find('{') != std::string::npos) {
c->setBorderExpr(val);
} else {
c->setBorder(val == "true" || val == "1" || val == "yes");
}
}
} else if (key == "Padding") {
if (elemType == UIElement::ElementType::Container || elemType == UIElement::ElementType::HStack ||
elemType == UIElement::ElementType::VStack || elemType == UIElement::ElementType::Grid) {
static_cast<Container*>(elem)->setPadding(std::stoi(val));
} else if (elemType == UIElement::ElementType::TabBar) {
static_cast<TabBar*>(elem)->setPadding(std::stoi(val));
}
} else if (key == "BorderRadius") {
if (elemType == UIElement::ElementType::Container || elemType == UIElement::ElementType::HStack ||
elemType == UIElement::ElementType::VStack || elemType == UIElement::ElementType::Grid) {
static_cast<Container*>(elem)->setBorderRadius(std::stoi(val));
} else if (elemType == UIElement::ElementType::Bitmap) {
static_cast<BitmapElement*>(elem)->setBorderRadius(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 (elemType == UIElement::ElementType::BatteryIcon) {
static_cast<BatteryIcon*>(elem)->setValue(val);
}
} else if (key == "Max") {
if (elemType == UIElement::ElementType::ProgressBar) {
static_cast<ProgressBar*>(elem)->setMax(val);
}
} else if (key == "FgColor") {
if (elemType == UIElement::ElementType::ProgressBar) {
static_cast<ProgressBar*>(elem)->setFgColor(val);
} else if (elemType == UIElement::ElementType::Badge) {
static_cast<Badge*>(elem)->setFgColor(val);
}
} else if (key == "BgColor") {
if (elemType == UIElement::ElementType::ProgressBar) {
static_cast<ProgressBar*>(elem)->setBgColor(val);
} else if (elemType == UIElement::ElementType::Badge) {
static_cast<Badge*>(elem)->setBgColor(val);
} else if (elemType == UIElement::ElementType::Container || elemType == UIElement::ElementType::HStack ||
elemType == UIElement::ElementType::VStack || elemType == UIElement::ElementType::Grid) {
static_cast<Container*>(elem)->setBackgroundColorExpr(val);
}
} else if (key == "ShowBorder") {
if (elemType == UIElement::ElementType::ProgressBar) {
static_cast<ProgressBar*>(elem)->setShowBorder(val == "true" || val == "1");
}
}
// ========== Divider properties ==========
else if (key == "Horizontal") {
if (elemType == UIElement::ElementType::Divider) {
static_cast<Divider*>(elem)->setHorizontal(val == "true" || val == "1");
}
} else if (key == "Thickness") {
if (elemType == UIElement::ElementType::Divider) {
static_cast<Divider*>(elem)->setThickness(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;
std::map<std::string, std::map<std::string, std::string>> sections;
if (themeName == "Default" || themeName.empty()) {
std::string path = "/themes/Default/theme.ini";
if (SdMan.exists(path.c_str())) {
FsFile file;
if (SdMan.openFileForRead("Theme", path, file)) {
sections = IniParser::parse(file);
file.close();
Serial.println("[ThemeManager] Loaded Default theme from SD card");
}
} else {
Serial.println("[ThemeManager] Using embedded Default theme");
sections = IniParser::parseString(getDefaultThemeIni());
}
currentThemeName = "Default";
} else {
std::string path = "/themes/" + themeName + "/theme.ini";
Serial.printf("[ThemeManager] Checking path: %s\n", path.c_str());
if (!SdMan.exists(path.c_str())) {
Serial.printf("[ThemeManager] Theme %s not found, using Default\n", themeName.c_str());
sections = IniParser::parseString(getDefaultThemeIni());
currentThemeName = "Default";
} else {
FsFile file;
if (SdMan.openFileForRead("Theme", path, file)) {
Serial.printf("[ThemeManager] Parsing theme file...\n");
sections = IniParser::parse(file);
file.close();
Serial.printf("[ThemeManager] Parsed %d sections from %s\n", (int)sections.size(), themeName.c_str());
} else {
Serial.printf("[ThemeManager] Failed to open %s, using Default\n", path.c_str());
sections = IniParser::parseString(getDefaultThemeIni());
currentThemeName = "Default";
}
}
}
// Read theme configuration from [Global] section
navBookCount = 1; // Default
if (sections.count("Global")) {
const auto& global = sections.at("Global");
if (global.count("NavBookCount")) {
navBookCount = std::stoi(global.at("NavBookCount"));
if (navBookCount < 1) navBookCount = 1;
if (navBookCount > 10) navBookCount = 10; // Reasonable max
}
}
// 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

View File

@ -14,7 +14,7 @@ CrossPointSettings CrossPointSettings::instance;
namespace {
constexpr uint8_t SETTINGS_FILE_VERSION = 1;
// Increment this when adding new persisted settings fields
constexpr uint8_t SETTINGS_COUNT = 20;
constexpr uint8_t SETTINGS_COUNT = 21;
constexpr char SETTINGS_FILE[] = "/.crosspoint/settings.bin";
} // namespace
@ -49,6 +49,7 @@ bool CrossPointSettings::saveToFile() const {
serialization::writePod(outputFile, hideBatteryPercentage);
serialization::writePod(outputFile, longPressChapterSkip);
serialization::writePod(outputFile, hyphenationEnabled);
serialization::writeString(outputFile, std::string(themeName));
outputFile.close();
Serial.printf("[%lu] [CPS] Settings saved to file\n", millis());
@ -120,6 +121,13 @@ bool CrossPointSettings::loadFromFile() {
if (++settingsRead >= fileSettingsCount) break;
serialization::readPod(inputFile, hyphenationEnabled);
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;
} while (false);
inputFile.close();

View File

@ -70,7 +70,8 @@ class CrossPointSettings {
// Short power button click behaviour
uint8_t shortPwrBtn = IGNORE;
// EPUB reading orientation settings
// 0 = portrait (default), 1 = landscape clockwise, 2 = inverted, 3 = landscape counter-clockwise
// 0 = portrait (default), 1 = landscape clockwise, 2 = inverted, 3 =
// landscape counter-clockwise
uint8_t orientation = PORTRAIT;
// Button layouts
uint8_t frontButtonLayout = BACK_CONFIRM_LEFT_RIGHT;
@ -94,6 +95,7 @@ class CrossPointSettings {
uint8_t hideBatteryPercentage = HIDE_NEVER;
// Long-press chapter skip on side buttons
uint8_t longPressChapterSkip = 1;
char themeName[64] = "Default";
~CrossPointSettings() = default;

View File

@ -4,6 +4,7 @@
#include <Epub.h>
#include <GfxRenderer.h>
#include <SDCardManager.h>
#include <ThemeManager.h>
#include <Xtc.h>
#include <cstring>
@ -13,6 +14,7 @@
#include "CrossPointSettings.h"
#include "CrossPointState.h"
#include "MappedInputManager.h"
#include "RecentBooksStore.h"
#include "ScreenComponents.h"
#include "fontIds.h"
#include "util/StringUtils.h"
@ -23,9 +25,8 @@ void HomeActivity::taskTrampoline(void* param) {
}
int HomeActivity::getMenuItemCount() const {
int count = 3; // My Library, File transfer, Settings
if (hasContinueReading) count++;
if (hasOpdsUrl) count++;
int count = 3; // Browse Files, File Transfer, Settings
if (hasOpdsUrl) count++; // + Calibre Library
return count;
}
@ -34,6 +35,12 @@ void HomeActivity::onEnter() {
renderingMutex = xSemaphoreCreateMutex();
// Reset render and selection state
coverRendered = false;
coverBufferStored = false;
freeCoverBuffer();
selectorIndex = 0; // Start at first item (first book if any, else first menu)
// Check if we have a book to continue reading
hasContinueReading = !APP_STATE.openEpubPath.empty() && SdMan.exists(APP_STATE.openEpubPath.c_str());
@ -87,6 +94,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;
@ -99,10 +112,92 @@ 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;
info.path = bookPath; // Store the full path
// 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);
@ -134,21 +229,6 @@ bool HomeActivity::storeCoverBuffer() {
return true;
}
bool HomeActivity::restoreCoverBuffer() {
if (!coverBuffer) {
return false;
}
uint8_t* frameBuffer = renderer.getFrameBuffer();
if (!frameBuffer) {
return false;
}
const size_t bufferSize = GfxRenderer::getBufferSize();
memcpy(frameBuffer, coverBuffer, bufferSize);
return true;
}
void HomeActivity::freeCoverBuffer() {
if (coverBuffer) {
free(coverBuffer);
@ -162,34 +242,47 @@ void HomeActivity::loop() {
mappedInput.wasPressed(MappedInputManager::Button::Left);
const bool nextPressed = mappedInput.wasPressed(MappedInputManager::Button::Down) ||
mappedInput.wasPressed(MappedInputManager::Button::Right);
const bool confirmPressed = mappedInput.wasReleased(MappedInputManager::Button::Confirm);
// Navigation uses theme-configured book slots (limited by actual books available)
const int maxBooks = static_cast<int>(cachedRecentBooks.size());
const int themeBookCount = ThemeEngine::ThemeManager::get().getNavBookCount();
const int navBookCount = std::min(themeBookCount, maxBooks);
const int menuCount = getMenuItemCount();
const int totalCount = navBookCount + menuCount;
if (mappedInput.wasReleased(MappedInputManager::Button::Confirm)) {
// Calculate dynamic indices based on which options are available
int idx = 0;
const int continueIdx = hasContinueReading ? idx++ : -1;
const int myLibraryIdx = idx++;
const int opdsLibraryIdx = hasOpdsUrl ? idx++ : -1;
const int fileTransferIdx = idx++;
const int settingsIdx = idx;
if (selectorIndex == continueIdx) {
if (confirmPressed) {
if (selectorIndex < navBookCount && selectorIndex < maxBooks) {
// Book selected - open the selected book
APP_STATE.openEpubPath = cachedRecentBooks[selectorIndex].path;
onContinueReading();
} else if (selectorIndex == myLibraryIdx) {
onMyLibraryOpen();
} else if (selectorIndex == opdsLibraryIdx) {
onOpdsBrowserOpen();
} else if (selectorIndex == fileTransferIdx) {
onFileTransferOpen();
} else if (selectorIndex == settingsIdx) {
onSettingsOpen();
} else {
// Menu item selected
const int menuIdx = selectorIndex - navBookCount;
int idx = 0;
const int myLibraryIdx = idx++;
const int opdsLibraryIdx = hasOpdsUrl ? idx++ : -1;
const int fileTransferIdx = idx++;
const int settingsIdx = idx;
if (menuIdx == myLibraryIdx) {
onMyLibraryOpen();
} else if (menuIdx == opdsLibraryIdx) {
onOpdsBrowserOpen();
} else if (menuIdx == fileTransferIdx) {
onFileTransferOpen();
} else if (menuIdx == settingsIdx) {
onSettingsOpen();
}
}
} else if (prevPressed) {
selectorIndex = (selectorIndex + menuCount - 1) % menuCount;
return;
}
if (prevPressed) {
selectorIndex = (selectorIndex + totalCount - 1) % totalCount;
updateRequired = true;
} else if (nextPressed) {
selectorIndex = (selectorIndex + 1) % menuCount;
selectorIndex = (selectorIndex + 1) % totalCount;
updateRequired = true;
}
}
@ -207,350 +300,176 @@ void HomeActivity::displayTaskLoop() {
}
void HomeActivity::render() {
// If we have a stored cover buffer, restore it instead of clearing
const bool bufferRestored = coverBufferStored && restoreCoverBuffer();
if (!bufferRestored) {
renderer.clearScreen();
// Battery check logic (only update every 60 seconds)
const uint32_t now = millis();
const bool needBatteryUpdate = (now - lastBatteryCheck > 60000) || (lastBatteryCheck == 0);
if (needBatteryUpdate) {
cachedBatteryLevel = battery.readPercentage();
lastBatteryCheck = now;
}
const auto pageWidth = renderer.getScreenWidth();
const auto pageHeight = renderer.getScreenHeight();
// Always clear screen - ThemeEngine handles caching internally
renderer.clearScreen();
constexpr int margin = 20;
constexpr int bottomMargin = 60;
ThemeEngine::ThemeContext context;
// --- Top "book" card for the current title (selectorIndex == 0) ---
const int bookWidth = pageWidth / 2;
const int bookHeight = pageHeight / 2;
const int bookX = (pageWidth - bookWidth) / 2;
constexpr int bookY = 30;
const bool bookSelected = hasContinueReading && selectorIndex == 0;
// --- Bind Global Data ---
context.setString("BatteryPercent", std::to_string(cachedBatteryLevel));
context.setBool("ShowBatteryPercent",
SETTINGS.hideBatteryPercentage != CrossPointSettings::HIDE_BATTERY_PERCENTAGE::HIDE_ALWAYS);
// Bookmark dimensions (used in multiple places)
const int bookmarkWidth = bookWidth / 8;
const int bookmarkHeight = bookHeight / 5;
const int bookmarkX = bookX + bookWidth - bookmarkWidth - 10;
const int bookmarkY = bookY + 5;
// --- Navigation counts (must match loop()) ---
const int recentCount = static_cast<int>(cachedRecentBooks.size());
const int themeBookCount = ThemeEngine::ThemeManager::get().getNavBookCount();
const int navBookCount = std::min(themeBookCount, recentCount);
const bool isBookSelected = selectorIndex < navBookCount;
// Draw book card regardless, fill with message based on `hasContinueReading`
{
// Draw cover image as background if available (inside the box)
// Only load from SD on first render, then use stored buffer
if (hasContinueReading && hasCoverImage && !coverBmpPath.empty() && !coverRendered) {
// First time: load cover from SD and render
FsFile file;
if (SdMan.openFileForRead("HOME", coverBmpPath, file)) {
Bitmap bitmap(file);
if (bitmap.parseHeaders() == BmpReaderError::Ok) {
// Calculate position to center image within the book card
int coverX, coverY;
// --- Recent Books Data ---
context.setBool("HasRecentBooks", recentCount > 0);
context.setInt("RecentBooks.Count", recentCount);
context.setInt("SelectedBookIndex", isBookSelected ? selectorIndex : -1);
if (bitmap.getWidth() > bookWidth || bitmap.getHeight() > bookHeight) {
const float imgRatio = static_cast<float>(bitmap.getWidth()) / static_cast<float>(bitmap.getHeight());
const float boxRatio = static_cast<float>(bookWidth) / static_cast<float>(bookHeight);
for (int i = 0; i < recentCount; i++) {
const auto& book = cachedRecentBooks[i];
std::string prefix = "RecentBooks." + std::to_string(i) + ".";
if (imgRatio > boxRatio) {
coverX = bookX;
coverY = bookY + (bookHeight - static_cast<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;
}
// Draw the cover image centered within the book card
renderer.drawBitmap(bitmap, coverX, coverY, bookWidth, bookHeight);
// Draw border around the card
renderer.drawRect(bookX, bookY, bookWidth, bookHeight);
// No bookmark ribbon when cover is shown - it would just cover the art
// Store the buffer with cover image for fast navigation
coverBufferStored = storeCoverBuffer();
coverRendered = true;
// First render: if selected, draw selection indicators now
if (bookSelected) {
renderer.drawRect(bookX + 1, bookY + 1, bookWidth - 2, bookHeight - 2);
renderer.drawRect(bookX + 2, bookY + 2, bookWidth - 4, bookHeight - 4);
}
}
file.close();
}
} else if (!bufferRestored && !coverRendered) {
// No cover image: draw border or fill, plus bookmark as visual flair
if (bookSelected) {
renderer.fillRect(bookX, bookY, bookWidth, bookHeight);
} else {
renderer.drawRect(bookX, bookY, bookWidth, bookHeight);
}
// Draw bookmark ribbon when no cover image (visual decoration)
if (hasContinueReading) {
const int notchDepth = bookmarkHeight / 3;
const int centerX = bookmarkX + bookmarkWidth / 2;
const int xPoints[5] = {
bookmarkX, // top-left
bookmarkX + bookmarkWidth, // top-right
bookmarkX + bookmarkWidth, // bottom-right
centerX, // center notch point
bookmarkX // bottom-left
};
const int yPoints[5] = {
bookmarkY, // top-left
bookmarkY, // top-right
bookmarkY + bookmarkHeight, // bottom-right
bookmarkY + bookmarkHeight - notchDepth, // center notch point
bookmarkY + bookmarkHeight // bottom-left
};
// Draw bookmark ribbon (inverted if selected)
renderer.fillPolygon(xPoints, yPoints, 5, !bookSelected);
}
}
// If buffer was restored, draw selection indicators if needed
if (bufferRestored && bookSelected && coverRendered) {
// Draw selection border (no bookmark inversion needed since cover has no bookmark)
renderer.drawRect(bookX + 1, bookY + 1, bookWidth - 2, bookHeight - 2);
renderer.drawRect(bookX + 2, bookY + 2, bookWidth - 4, bookHeight - 4);
} else if (!coverRendered && !bufferRestored) {
// Selection border already handled above in the no-cover case
}
context.setString(prefix + "Title", book.title);
context.setString(prefix + "Image", book.coverPath);
context.setString(prefix + "Progress", std::to_string(book.progressPercent));
// Book is selected if selectorIndex matches
context.setBool(prefix + "Selected", selectorIndex == i);
}
// --- Book Card Data (for themes with single book) ---
context.setBool("IsBookSelected", isBookSelected);
context.setBool("HasBook", hasContinueReading);
context.setString("BookTitle", lastBookTitle);
context.setString("BookAuthor", lastBookAuthor);
context.setString("BookCoverPath", coverBmpPath);
context.setBool("HasCover", hasContinueReading && hasCoverImage && !coverBmpPath.empty());
context.setBool("ShowInfoBox", true);
// Default values
std::string chapterTitle = "";
std::string currentPageStr = "-";
std::string totalPagesStr = "-";
int progressPercent = 0;
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
if (StringUtils::checkFileExtension(APP_STATE.openEpubPath, ".epub")) {
Epub epub(APP_STATE.openEpubPath, "/.crosspoint");
epub.load(false);
// 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));
}
// 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();
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);
currentPageStr = std::to_string(spineIndex + 1); // Display 1-based
totalPagesStr = std::to_string(spineCount);
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("...");
if (spineCount > 0) {
progressPercent = (spineIndex * 100) / spineCount;
}
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("...");
// Resolve Chapter Title
auto spineEntry = epub.getSpineItem(spineIndex);
if (spineEntry.tocIndex != -1) {
auto tocEntry = epub.getTocItem(spineEntry.tocIndex);
chapterTitle = tocEntry.title;
}
}
break;
f.close();
}
} else if (StringUtils::checkFileExtension(APP_STATE.openEpubPath, ".xtc") ||
StringUtils::checkFileExtension(APP_STATE.openEpubPath, ".xtch")) {
Xtc xtc(APP_STATE.openEpubPath, "/.crosspoint");
if (xtc.load()) {
// 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();
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;
currentPageStr = std::to_string(currentPage + 1); // 1-based
totalPagesStr = std::to_string(totalPages);
if (totalPages > 0) {
progressPercent = (currentPage * 100) / totalPages;
}
chapterTitle = "Page " + currentPageStr;
}
f.close();
}
}
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"};
context.setString("BookChapter", chapterTitle);
context.setString("BookCurrentPage", currentPageStr);
context.setString("BookTotalPages", totalPagesStr);
context.setInt("BookProgressPercent", progressPercent);
context.setString("BookProgressPercentStr", std::to_string(progressPercent));
// --- Main Menu Data ---
// Menu items start after the book slot
const int menuStartIdx = navBookCount;
int idx = 0;
const int myLibraryIdx = menuStartIdx + idx++;
const int opdsLibraryIdx = hasOpdsUrl ? menuStartIdx + idx++ : -1;
const int fileTransferIdx = menuStartIdx + idx++;
const int settingsIdx = menuStartIdx + idx;
std::vector<std::string> menuLabels;
std::vector<std::string> menuIcons;
std::vector<bool> menuSelected;
menuLabels.push_back("Browse Files");
menuIcons.push_back("folder");
menuSelected.push_back(selectorIndex == myLibraryIdx);
if (hasOpdsUrl) {
// Insert Calibre Library after My Library
menuItems.insert(menuItems.begin() + 1, "Calibre Library");
menuLabels.push_back("Calibre Library");
menuIcons.push_back("library");
menuSelected.push_back(selectorIndex == opdsLibraryIdx);
}
const int menuTileWidth = pageWidth - 2 * margin;
constexpr int menuTileHeight = 45;
constexpr int menuSpacing = 8;
const int totalMenuHeight =
static_cast<int>(menuItems.size()) * menuTileHeight + (static_cast<int>(menuItems.size()) - 1) * menuSpacing;
menuLabels.push_back("File Transfer");
menuIcons.push_back("transfer");
menuSelected.push_back(selectorIndex == fileTransferIdx);
int menuStartY = bookY + bookHeight + 15;
// Ensure we don't collide with the bottom button legend
const int maxMenuStartY = pageHeight - bottomMargin - totalMenuHeight - margin;
if (menuStartY > maxMenuStartY) {
menuStartY = maxMenuStartY;
menuLabels.push_back("Settings");
menuIcons.push_back("settings");
menuSelected.push_back(selectorIndex == settingsIdx);
context.setInt("MainMenu.Count", menuLabels.size());
for (size_t i = 0; i < menuLabels.size(); ++i) {
std::string prefix = "MainMenu." + std::to_string(i) + ".";
context.setString(prefix + "Title", menuLabels[i]);
context.setString(prefix + "Icon", menuIcons[i]);
context.setBool(prefix + "Selected", menuSelected[i]);
}
for (size_t i = 0; i < menuItems.size(); ++i) {
const int overallIndex = static_cast<int>(i) + (hasContinueReading ? 1 : 0);
constexpr int tileX = margin;
const int tileY = menuStartY + static_cast<int>(i) * (menuTileHeight + menuSpacing);
const bool selected = selectorIndex == overallIndex;
// --- Render via ThemeEngine ---
const uint32_t renderStart = millis();
ThemeEngine::ThemeManager::get().renderScreen("Home", renderer, context);
const uint32_t renderTime = millis() - renderStart;
Serial.printf("[HOME] ThemeEngine render took %lums\n", renderTime);
if (selected) {
renderer.fillRect(tileX, tileY, menuTileWidth, menuTileHeight);
} else {
renderer.drawRect(tileX, tileY, menuTileWidth, menuTileHeight);
}
const char* label = menuItems[i];
const int textWidth = renderer.getTextWidth(UI_10_FONT_ID, label);
const int textX = tileX + (menuTileWidth - textWidth) / 2;
const int lineHeight = renderer.getLineHeight(UI_10_FONT_ID);
const int textY = tileY + (menuTileHeight - lineHeight) / 2; // vertically centered assuming y is top of text
// Invert text when the tile is selected, to contrast with the filled background
renderer.drawText(UI_10_FONT_ID, textX, textY, label, !selected);
// After first full render, store the framebuffer for fast subsequent updates
if (!coverRendered) {
coverBufferStored = storeCoverBuffer();
coverRendered = true;
}
const auto labels = mappedInput.mapLabels("", "Select", "Up", "Down");
renderer.drawButtonHints(UI_10_FONT_ID, labels.btn1, labels.btn2, labels.btn3, labels.btn4);
const bool showBatteryPercentage =
SETTINGS.hideBatteryPercentage != CrossPointSettings::HIDE_BATTERY_PERCENTAGE::HIDE_ALWAYS;
// get percentage so we can align text properly
const uint16_t percentage = battery.readPercentage();
const auto percentageText = showBatteryPercentage ? std::to_string(percentage) + "%" : "";
const auto batteryX = pageWidth - 25 - renderer.getTextWidth(SMALL_FONT_ID, percentageText.c_str());
ScreenComponents::drawBattery(renderer, batteryX, 10, showBatteryPercentage);
const uint32_t displayStart = millis();
renderer.displayBuffer();
Serial.printf("[HOME] Display buffer took %lums\n", millis() - displayStart);
}

View File

@ -4,13 +4,23 @@
#include <freertos/task.h>
#include <functional>
#include <string>
#include <vector>
#include "../Activity.h"
// Cached data for a recent book
struct CachedBookInfo {
std::string path; // Full path to the book file
std::string title;
std::string coverPath;
int progressPercent = 0;
};
class HomeActivity final : public Activity {
TaskHandle_t displayTaskHandle = nullptr;
SemaphoreHandle_t renderingMutex = nullptr;
int selectorIndex = 0;
int selectorIndex = 0; // Unified index: 0..bookCount-1 = books, bookCount+ = menu
bool updateRequired = false;
bool hasContinueReading = false;
bool hasOpdsUrl = false;
@ -21,6 +31,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;
@ -31,9 +46,10 @@ class HomeActivity final : public Activity {
[[noreturn]] void displayTaskLoop();
void render();
int getMenuItemCount() const;
bool storeCoverBuffer(); // Store frame buffer for cover image
bool restoreCoverBuffer(); // Restore frame buffer from stored cover
void freeCoverBuffer(); // Free the stored cover buffer
bool storeCoverBuffer(); // Store frame buffer for cover image
void freeCoverBuffer(); // Free the stored cover buffer
void loadRecentBooksData(); // Load and cache recent books data
public:
explicit HomeActivity(GfxRenderer& renderer, MappedInputManager& mappedInput,

View File

@ -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();
@ -32,7 +37,8 @@ void CategorySettingsActivity::onEnter() {
void CategorySettingsActivity::onExit() {
ActivityWithSubactivity::onExit();
// Wait until not rendering to delete task to avoid killing mid-instruction to EPD
// Wait until not rendering to delete task to avoid killing mid-instruction to
// EPD
xSemaphoreTake(renderingMutex, portMAX_DELAY);
if (displayTaskHandle) {
vTaskDelete(displayTaskHandle);
@ -127,6 +133,14 @@ void CategorySettingsActivity::toggleCurrentSetting() {
updateRequired = true;
}));
xSemaphoreGive(renderingMutex);
} else if (strcmp(setting.name, "Theme") == 0) {
xSemaphoreTake(renderingMutex, portMAX_DELAY);
exitActivity();
enterNewActivity(new ThemeSelectionActivity(renderer, mappedInput, [this] {
exitActivity();
updateRequired = true;
}));
xSemaphoreGive(renderingMutex);
}
} else {
return;

View File

@ -11,7 +11,7 @@
const char* SettingsActivity::categoryNames[categoryCount] = {"Display", "Reader", "Controls", "System"};
namespace {
constexpr int displaySettingsCount = 5;
constexpr int displaySettingsCount = 6;
const SettingInfo displaySettings[displaySettingsCount] = {
// Should match with SLEEP_SCREEN_MODE
SettingInfo::Enum("Sleep Screen", &CrossPointSettings::sleepScreen, {"Dark", "Light", "Custom", "Cover", "None"}),
@ -19,7 +19,8 @@ const SettingInfo displaySettings[displaySettingsCount] = {
SettingInfo::Enum("Status Bar", &CrossPointSettings::statusBar, {"None", "No Progress", "Full"}),
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] = {
@ -78,7 +79,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);

View File

@ -0,0 +1,183 @@
#include "ThemeSelectionActivity.h"
#include <GfxRenderer.h>
#include <SDCardManager.h>
#include <esp_system.h>
#include <cstring>
#include "CrossPointSettings.h"
#include "MappedInputManager.h"
#include "fontIds.h"
void ThemeSelectionActivity::taskTrampoline(void* param) {
auto* self = static_cast<ThemeSelectionActivity*>(param);
self->displayTaskLoop();
}
void ThemeSelectionActivity::onEnter() {
Activity::onEnter();
renderingMutex = xSemaphoreCreateMutex();
// Load themes
themeNames.clear();
// Always add Default
themeNames.push_back("Default");
FsFile root = SdMan.open("/themes");
if (root.isDirectory()) {
FsFile file;
while (file.openNext(&root, O_RDONLY)) {
if (file.isDirectory()) {
char name[256];
file.getName(name, sizeof(name));
// Skip hidden folders and "Default" if scans it (already added)
if (name[0] != '.' && std::string(name) != "Default") {
themeNames.push_back(name);
}
}
file.close();
}
}
root.close();
// Find current selection
std::string current = SETTINGS.themeName;
selectedIndex = 0;
for (size_t i = 0; i < themeNames.size(); i++) {
if (themeNames[i] == current) {
selectedIndex = i;
break;
}
}
updateRequired = true;
xTaskCreate(&ThemeSelectionActivity::taskTrampoline, "ThemeSelTask", 4096, this, 1, &displayTaskHandle);
}
void ThemeSelectionActivity::onExit() {
Activity::onExit();
xSemaphoreTake(renderingMutex, portMAX_DELAY);
if (displayTaskHandle) {
vTaskDelete(displayTaskHandle);
displayTaskHandle = nullptr;
}
vSemaphoreDelete(renderingMutex);
renderingMutex = nullptr;
}
void ThemeSelectionActivity::loop() {
if (mappedInput.wasPressed(MappedInputManager::Button::Confirm)) {
if (selectedIndex >= 0 && selectedIndex < themeNames.size()) {
std::string selected = themeNames[selectedIndex];
// Only reboot if theme actually changed
if (selected != std::string(SETTINGS.themeName)) {
strncpy(SETTINGS.themeName, selected.c_str(), sizeof(SETTINGS.themeName) - 1);
SETTINGS.themeName[sizeof(SETTINGS.themeName) - 1] = '\0';
SETTINGS.saveToFile();
// Show reboot message
renderer.clearScreen();
renderer.drawCenteredText(UI_12_FONT_ID, renderer.getScreenHeight() / 2 - 20, "Applying theme...", true);
renderer.drawCenteredText(UI_10_FONT_ID, renderer.getScreenHeight() / 2 + 10, "Device will restart", true);
renderer.displayBuffer();
// Small delay to ensure display updates
vTaskDelay(500 / portTICK_PERIOD_MS);
esp_restart();
return;
}
}
onGoBack();
return;
}
if (mappedInput.wasPressed(MappedInputManager::Button::Back)) {
onGoBack();
return;
}
if (mappedInput.wasPressed(MappedInputManager::Button::Up) ||
mappedInput.wasPressed(MappedInputManager::Button::Left)) {
selectedIndex = (selectedIndex > 0) ? (selectedIndex - 1) : (themeNames.size() - 1);
updateRequired = true;
} else if (mappedInput.wasPressed(MappedInputManager::Button::Down) ||
mappedInput.wasPressed(MappedInputManager::Button::Right)) {
selectedIndex = (selectedIndex < themeNames.size() - 1) ? (selectedIndex + 1) : 0;
updateRequired = true;
}
}
void ThemeSelectionActivity::displayTaskLoop() {
while (true) {
if (updateRequired) {
updateRequired = false;
xSemaphoreTake(renderingMutex, portMAX_DELAY);
render();
xSemaphoreGive(renderingMutex);
}
vTaskDelay(10 / portTICK_PERIOD_MS);
}
}
void ThemeSelectionActivity::render() const {
renderer.clearScreen();
const auto pageWidth = renderer.getScreenWidth();
const auto pageHeight = renderer.getScreenHeight();
// Header
renderer.drawCenteredText(UI_12_FONT_ID, 15, "Select Theme", true, EpdFontFamily::BOLD);
// Layout constants
const int entryHeight = 30;
const int startY = 60;
const int maxVisible = (pageHeight - startY - 40) / entryHeight;
// Viewport calculation
int startIdx = 0;
if (themeNames.size() > maxVisible) {
if (selectedIndex >= maxVisible / 2) {
startIdx = selectedIndex - maxVisible / 2;
}
if (startIdx + maxVisible > themeNames.size()) {
startIdx = themeNames.size() - maxVisible;
}
}
// Draw Highlight
int visibleIndex = selectedIndex - startIdx;
if (visibleIndex >= 0 && visibleIndex < maxVisible) {
renderer.fillRect(0, startY + visibleIndex * entryHeight - 2, pageWidth - 1, entryHeight);
}
// Draw List
for (int i = 0; i < maxVisible && (startIdx + i) < themeNames.size(); i++) {
int idx = startIdx + i;
int y = startY + i * entryHeight;
bool isSelected = (idx == selectedIndex);
std::string displayName = themeNames[idx];
if (themeNames[idx] == std::string(SETTINGS.themeName)) {
displayName = "* " + displayName;
}
renderer.drawText(UI_10_FONT_ID, 20, y, displayName.c_str(), !isSelected);
}
// Scrollbar if needed
if (themeNames.size() > maxVisible) {
int barHeight = pageHeight - startY - 40;
int thumbHeight = barHeight * maxVisible / themeNames.size();
int thumbY = startY + (barHeight - thumbHeight) * startIdx / (themeNames.size() - maxVisible);
renderer.fillRect(pageWidth - 5, startY, 2, barHeight, 0);
renderer.fillRect(pageWidth - 7, thumbY, 6, thumbHeight, 1);
}
const auto labels = mappedInput.mapLabels("Cancel", "Select", "", "");
renderer.drawButtonHints(UI_10_FONT_ID, labels.btn1, labels.btn2, labels.btn3, labels.btn4);
renderer.displayBuffer();
}

View File

@ -0,0 +1,30 @@
#pragma once
#include <freertos/FreeRTOS.h>
#include <freertos/semphr.h>
#include <freertos/task.h>
#include <functional>
#include <string>
#include <vector>
#include "activities/Activity.h"
class ThemeSelectionActivity final : public Activity {
TaskHandle_t displayTaskHandle = nullptr;
SemaphoreHandle_t renderingMutex = nullptr;
bool updateRequired = false;
int selectedIndex = 0;
std::vector<std::string> themeNames;
const std::function<void()> onGoBack;
static void taskTrampoline(void* param);
[[noreturn]] void displayTaskLoop();
void render() const;
public:
ThemeSelectionActivity(GfxRenderer& renderer, MappedInputManager& mappedInput, const std::function<void()>& onGoBack)
: Activity("ThemeSelection", renderer, mappedInput), onGoBack(onGoBack) {}
void onEnter() override;
void onExit() override;
void loop() override;
};

View File

@ -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"
@ -153,12 +154,14 @@ void enterNewActivity(Activity* activity) {
// Verify long press on wake-up from deep sleep
void verifyWakeupLongPress() {
// Give the user up to 1000ms to start holding the power button, and must hold for SETTINGS.getPowerButtonDuration()
// Give the user up to 1000ms to start holding the power button, and must hold
// for SETTINGS.getPowerButtonDuration()
const auto start = millis();
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;
@ -166,7 +169,8 @@ void verifyWakeupLongPress() {
inputManager.update();
// Verify the user has actually pressed
while (!inputManager.isPressed(InputManager::BTN_POWER) && millis() - start < 1000) {
delay(10); // only wait 10ms each iteration to not delay too much in case of short configured duration.
delay(10); // only wait 10ms each iteration to not delay too much in case of
// short configured duration.
inputManager.update();
}
@ -206,7 +210,8 @@ void enterDeepSleep() {
Serial.printf("[%lu] [ ] Power button press calibration value: %lu ms\n", millis(), t2 - t1);
Serial.printf("[%lu] [ ] Entering deep sleep.\n", millis());
esp_deep_sleep_enable_gpio_wakeup(1ULL << InputManager::POWER_BUTTON_PIN, ESP_GPIO_WAKEUP_GPIO_LOW);
// Ensure that the power button has been released to avoid immediately turning back on if you're holding it
// Ensure that the power button has been released to avoid immediately turning
// back on if you're holding it
waitForPowerRelease();
// Enter Deep Sleep
esp_deep_sleep_start();
@ -288,6 +293,8 @@ bool isWakeupAfterFlashing() {
return isUsbConnected() && (wakeupCause == ESP_SLEEP_WAKEUP_UNDEFINED) && (resetReason == ESP_RST_UNKNOWN);
}
bool isSoftwareRestart() { return esp_reset_reason() == ESP_RST_SW; }
void setup() {
t1 = millis();
@ -322,16 +329,23 @@ void setup() {
SETTINGS.loadFromFile();
KOREADER_STORE.loadFromFile();
if (!isWakeupAfterFlashing()) {
// For normal wakeups (not immediately after flashing), verify long press
if (!isWakeupAfterFlashing() && !isSoftwareRestart()) {
// For normal wakeups (not immediately after flashing or software restart), verify long press
verifyWakeupLongPress();
}
// First serial output only here to avoid timing inconsistencies for power button press duration verification
// First serial output only here to avoid timing inconsistencies for power
// button press duration verification
Serial.printf("[%lu] [ ] Starting CrossPoint version " CROSSPOINT_VERSION "\n", millis());
setupDisplayAndFonts();
ThemeEngine::ThemeManager::get().begin();
ThemeEngine::ThemeManager::get().registerFont("UI_12", UI_12_FONT_ID);
ThemeEngine::ThemeManager::get().registerFont("UI_10", UI_10_FONT_ID);
ThemeEngine::ThemeManager::get().registerFont("Small", SMALL_FONT_ID);
ThemeEngine::ThemeManager::get().loadTheme(SETTINGS.themeName);
exitActivity();
enterNewActivity(new BootActivity(renderer, mappedInputManager));
@ -341,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;
@ -366,7 +381,8 @@ void loop() {
lastMemPrint = millis();
}
// Check for any user activity (button press or release) or active background work
// Check for any user activity (button press or release) or active background
// work
static unsigned long lastActivityTime = millis();
if (inputManager.wasAnyPressed() || inputManager.wasAnyReleased() ||
(currentActivity && currentActivity->preventAutoSleep())) {
@ -404,8 +420,8 @@ void loop() {
}
// Add delay at the end of the loop to prevent tight spinning
// When an activity requests skip loop delay (e.g., webserver running), use yield() for faster response
// Otherwise, use longer delay to save power
// When an activity requests skip loop delay (e.g., webserver running), use
// yield() for faster response Otherwise, use longer delay to save power
if (currentActivity && currentActivity->skipLoopDelay()) {
yield(); // Give FreeRTOS a chance to run tasks, but return immediately
} else {