feat: UI themes, Lyra

This commit is contained in:
CaptainFrito 2026-02-02 15:34:16 +07:00
parent e5c0ddc9fa
commit 1b15277c7e
50 changed files with 2254 additions and 1390 deletions

View File

@ -428,11 +428,12 @@ bool Epub::generateCoverBmp(bool cropped) const {
return false; return false;
} }
std::string Epub::getThumbBmpPath() const { return cachePath + "/thumb.bmp"; } std::string Epub::getThumbBmpPath() const { return cachePath + "/thumb_[HEIGHT].bmp"; }
std::string Epub::getThumbBmpPath(int height) const { return cachePath + "/thumb_" + std::to_string(height) + ".bmp"; }
bool Epub::generateThumbBmp() const { bool Epub::generateThumbBmp(int height) const {
// Already generated, return true // Already generated, return true
if (SdMan.exists(getThumbBmpPath().c_str())) { if (SdMan.exists(getThumbBmpPath(height).c_str())) {
return true; return true;
} }
@ -444,11 +445,8 @@ bool Epub::generateThumbBmp() const {
const auto coverImageHref = bookMetadataCache->coreMetadata.coverItemHref; const auto coverImageHref = bookMetadataCache->coreMetadata.coverItemHref;
if (coverImageHref.empty()) { if (coverImageHref.empty()) {
Serial.printf("[%lu] [EBP] No known cover image for thumbnail\n", millis()); Serial.printf("[%lu] [EBP] No known cover image for thumbnail\n", millis());
return false; } else if (coverImageHref.substr(coverImageHref.length() - 4) == ".jpg" ||
} coverImageHref.substr(coverImageHref.length() - 5) == ".jpeg") {
if (coverImageHref.substr(coverImageHref.length() - 4) == ".jpg" ||
coverImageHref.substr(coverImageHref.length() - 5) == ".jpeg") {
Serial.printf("[%lu] [EBP] Generating thumb BMP from JPG cover image\n", millis()); Serial.printf("[%lu] [EBP] Generating thumb BMP from JPG cover image\n", millis());
const auto coverJpgTempPath = getCachePath() + "/.cover.jpg"; const auto coverJpgTempPath = getCachePath() + "/.cover.jpg";
@ -464,14 +462,14 @@ bool Epub::generateThumbBmp() const {
} }
FsFile thumbBmp; FsFile thumbBmp;
if (!SdMan.openFileForWrite("EBP", getThumbBmpPath(), thumbBmp)) { if (!SdMan.openFileForWrite("EBP", getThumbBmpPath(height), thumbBmp)) {
coverJpg.close(); coverJpg.close();
return false; return false;
} }
// Use smaller target size for Continue Reading card (half of screen: 240x400) // Use smaller target size for Continue Reading card (half of screen: 240x400)
// Generate 1-bit BMP for fast home screen rendering (no gray passes needed) // Generate 1-bit BMP for fast home screen rendering (no gray passes needed)
constexpr int THUMB_TARGET_WIDTH = 240; int THUMB_TARGET_WIDTH = height * 0.6;
constexpr int THUMB_TARGET_HEIGHT = 400; int THUMB_TARGET_HEIGHT = height;
const bool success = JpegToBmpConverter::jpegFileTo1BitBmpStreamWithSize(coverJpg, thumbBmp, THUMB_TARGET_WIDTH, const bool success = JpegToBmpConverter::jpegFileTo1BitBmpStreamWithSize(coverJpg, thumbBmp, THUMB_TARGET_WIDTH,
THUMB_TARGET_HEIGHT); THUMB_TARGET_HEIGHT);
coverJpg.close(); coverJpg.close();
@ -480,7 +478,7 @@ bool Epub::generateThumbBmp() const {
if (!success) { if (!success) {
Serial.printf("[%lu] [EBP] Failed to generate thumb BMP from JPG cover image\n", millis()); Serial.printf("[%lu] [EBP] Failed to generate thumb BMP from JPG cover image\n", millis());
SdMan.remove(getThumbBmpPath().c_str()); SdMan.remove(getThumbBmpPath(height).c_str());
} }
Serial.printf("[%lu] [EBP] Generated thumb BMP from JPG cover image, success: %s\n", millis(), Serial.printf("[%lu] [EBP] Generated thumb BMP from JPG cover image, success: %s\n", millis(),
success ? "yes" : "no"); success ? "yes" : "no");
@ -489,6 +487,10 @@ bool Epub::generateThumbBmp() const {
Serial.printf("[%lu] [EBP] Cover image is not a JPG, skipping thumbnail\n", millis()); Serial.printf("[%lu] [EBP] Cover image is not a JPG, skipping thumbnail\n", millis());
} }
// Write an empty bmp file to avoid generation attempts in the future
FsFile thumbBmp;
SdMan.openFileForWrite("EBP", getThumbBmpPath(height), thumbBmp);
thumbBmp.close();
return false; return false;
} }

View File

@ -48,7 +48,8 @@ class Epub {
std::string getCoverBmpPath(bool cropped = false) const; std::string getCoverBmpPath(bool cropped = false) const;
bool generateCoverBmp(bool cropped = false) const; bool generateCoverBmp(bool cropped = false) const;
std::string getThumbBmpPath() const; std::string getThumbBmpPath() const;
bool generateThumbBmp() const; std::string getThumbBmpPath(int height) const;
bool generateThumbBmp(int height) const;
uint8_t* readItemContentsToBytes(const std::string& itemHref, size_t* size = nullptr, uint8_t* readItemContentsToBytes(const std::string& itemHref, size_t* size = nullptr,
bool trailingNullByte = false) const; bool trailingNullByte = false) const;
bool readItemContentsToStream(const std::string& itemHref, Print& out, size_t chunkSize) const; bool readItemContentsToStream(const std::string& itemHref, Print& out, size_t chunkSize) const;

View File

@ -130,6 +130,12 @@ void GfxRenderer::drawLine(int x1, int y1, int x2, int y2, const bool state) con
} }
} }
void GfxRenderer::drawLine(int x1, int y1, int x2, int y2, const int lineWidth, const bool state) const {
for (int i = 0; i < lineWidth; i++) {
drawLine(x1, y1 + i, x2, y2 + i, state);
}
}
void GfxRenderer::drawRect(const int x, const int y, const int width, const int height, const bool state) const { void GfxRenderer::drawRect(const int x, const int y, const int width, const int height, const bool state) const {
drawLine(x, y, x + width - 1, y, state); drawLine(x, y, x + width - 1, y, state);
drawLine(x + width - 1, y, x + width - 1, y + height - 1, state); drawLine(x + width - 1, y, x + width - 1, y + height - 1, state);
@ -137,12 +143,215 @@ void GfxRenderer::drawRect(const int x, const int y, const int width, const int
drawLine(x, y, x, y + height - 1, state); drawLine(x, y, x, y + height - 1, state);
} }
// Border is inside the rectangle
void GfxRenderer::drawRect(const int x, const int y, const int width, const int height, const int lineWidth,
const bool state) const {
for (int i = 0; i < lineWidth; i++) {
drawLine(x + i, y + i, x + width - i, y + i, state);
drawLine(x + width - i, y + i, x + width - i, y + height - i, state);
drawLine(x + width - i, y + height - i, x + i, y + height - i, state);
drawLine(x + i, y + height - i, x + i, y + i, state);
}
}
void GfxRenderer::drawArc(const int maxRadius, const int cx, const int cy, const int xDir, const int yDir,
const int lineWidth, const bool state) const {
const int stroke = std::min(lineWidth, maxRadius);
const int innerRadius = std::max(maxRadius - stroke, 0);
const int outerRadiusSq = maxRadius * maxRadius;
const int innerRadiusSq = innerRadius * innerRadius;
for (int dy = 0; dy <= maxRadius; ++dy) {
for (int dx = 0; dx <= maxRadius; ++dx) {
const int distSq = dx * dx + dy * dy;
if (distSq > outerRadiusSq || distSq < innerRadiusSq) {
continue;
}
const int px = cx + xDir * dx;
const int py = cy + yDir * dy;
drawPixel(px, py, state);
}
}
};
// Border is inside the rectangle, rounded corners
void GfxRenderer::drawRoundedRect(const int x, const int y, const int width, const int height, const int lineWidth,
const int cornerRadius, bool state) const {
drawRoundedRect(x, y, width, height, lineWidth, cornerRadius, true, true, true, true, state);
}
// Border is inside the rectangle, rounded corners
void GfxRenderer::drawRoundedRect(const int x, const int y, const int width, const int height, const int lineWidth,
const int cornerRadius, bool roundTopLeft, bool roundTopRight, bool roundBottomLeft,
bool roundBottomRight, bool state) const {
if (lineWidth <= 0 || width <= 0 || height <= 0) {
return;
}
const int maxRadius = std::min({cornerRadius, width / 2, height / 2});
if (maxRadius <= 0) {
drawRect(x, y, width, height, lineWidth, state);
return;
}
const int stroke = std::min(lineWidth, maxRadius);
const int right = x + width - 1;
const int bottom = y + height - 1;
const int horizontalWidth = width - 2 * maxRadius;
if (horizontalWidth > 0) {
if (roundTopLeft || roundTopRight) {
fillRect(x + maxRadius, y, horizontalWidth, stroke, state);
}
if (roundBottomLeft || roundBottomRight) {
fillRect(x + maxRadius, bottom - stroke + 1, horizontalWidth, stroke, state);
}
}
const int verticalHeight = height - 2 * maxRadius;
if (verticalHeight > 0) {
if (roundTopLeft || roundBottomLeft) {
fillRect(x, y + maxRadius, stroke, verticalHeight, state);
}
if (roundTopRight || roundBottomRight) {
fillRect(right - stroke + 1, y + maxRadius, stroke, verticalHeight, state);
}
}
if (roundTopLeft) {
drawArc(maxRadius, x + maxRadius, y + maxRadius, -1, -1, lineWidth, state);
}
if (roundTopRight) {
drawArc(maxRadius, right - maxRadius, y + maxRadius, 1, -1, lineWidth, state);
}
if (roundBottomRight) {
drawArc(maxRadius, right - maxRadius, bottom - maxRadius, 1, 1, lineWidth, state);
}
if (roundBottomLeft) {
drawArc(maxRadius, x + maxRadius, bottom - maxRadius, -1, 1, lineWidth, state);
}
}
void GfxRenderer::fillRect(const int x, const int y, const int width, const int height, const bool state) const { void GfxRenderer::fillRect(const int x, const int y, const int width, const int height, const bool state) const {
for (int fillY = y; fillY < y + height; fillY++) { for (int fillY = y; fillY < y + height; fillY++) {
drawLine(x, fillY, x + width - 1, fillY, state); drawLine(x, fillY, x + width - 1, fillY, state);
} }
} }
static constexpr uint8_t bayer4x4[4][4] = {
{0, 8, 2, 10},
{12, 4, 14, 6},
{3, 11, 1, 9},
{15, 7, 13, 5},
};
static constexpr int matrixSize = 4;
static constexpr int matrixLevels = matrixSize * matrixSize;
void GfxRenderer::drawPixelDither(const int x, const int y, Color color) const {
if (color == Color::Clear) {
} else if (color == Color::Black) {
drawPixel(x, y, true);
} else if (color == Color::White) {
drawPixel(x, y, false);
} else {
// Use dithering
const int greyLevel = static_cast<int>(color) - 1; // 0-15
const int normalizedGrey = (greyLevel * 255) / (matrixLevels - 1);
const int clampedGrey = std::max(0, std::min(normalizedGrey, 255));
const int threshold = (clampedGrey * (matrixLevels + 1)) / 256;
const int matrixX = x & (matrixSize - 1);
const int matrixY = y & (matrixSize - 1);
const uint8_t patternValue = bayer4x4[matrixY][matrixX];
const bool black = patternValue < threshold;
drawPixel(x, y, black);
}
}
// Use Bayer matrix 4x4 dithering to fill the rectangle with a grey level
void GfxRenderer::fillRectDither(const int x, const int y, const int width, const int height, Color color) const {
if (color == Color::Clear) {
} else if (color == Color::Black) {
fillRect(x, y, width, height, true);
} else if (color == Color::White) {
fillRect(x, y, width, height, false);
} else {
for (int fillY = y; fillY < y + height; fillY++) {
for (int fillX = x; fillX < x + width; fillX++) {
drawPixelDither(fillX, fillY, color);
}
}
}
}
void GfxRenderer::fillArc(const int maxRadius, const int cx, const int cy, const int xDir, const int yDir,
Color color) const {
const int radiusSq = maxRadius * maxRadius;
for (int dy = 0; dy <= maxRadius; ++dy) {
for (int dx = 0; dx <= maxRadius; ++dx) {
const int distSq = dx * dx + dy * dy;
const int px = cx + xDir * dx;
const int py = cy + yDir * dy;
if (distSq <= radiusSq) {
drawPixelDither(px, py, color);
}
}
}
}
void GfxRenderer::fillRoundedRect(const int x, const int y, const int width, const int height, const int cornerRadius,
const Color color) const {
fillRoundedRect(x, y, width, height, cornerRadius, true, true, true, true, color);
}
void GfxRenderer::fillRoundedRect(const int x, const int y, const int width, const int height, const int cornerRadius,
bool roundTopLeft, bool roundTopRight, bool roundBottomLeft, bool roundBottomRight,
const Color color) const {
if (width <= 0 || height <= 0) {
return;
}
const int maxRadius = std::min({cornerRadius, width / 2, height / 2});
if (maxRadius <= 0) {
fillRectDither(x, y, width, height, color);
return;
}
const int horizontalWidth = width - 2 * maxRadius;
if (horizontalWidth > 0) {
fillRectDither(x + maxRadius + 1, y, horizontalWidth - 2, height, color);
}
const int verticalHeight = height - 2 * maxRadius - 2;
if (verticalHeight > 0) {
fillRectDither(x, y + maxRadius + 1, maxRadius + 1, verticalHeight, color);
fillRectDither(x + width - maxRadius - 1, y + maxRadius + 1, maxRadius + 1, verticalHeight, color);
}
if (roundTopLeft) {
fillArc(maxRadius, x + maxRadius, y + maxRadius, -1, -1, color);
} else {
fillRectDither(x, y, maxRadius + 1, maxRadius + 1, color);
}
if (roundTopRight) {
fillArc(maxRadius, x + width - maxRadius - 1, y + maxRadius, 1, -1, color);
} else {
fillRectDither(x + width - maxRadius - 1, y, maxRadius + 1, maxRadius + 1, color);
}
if (roundBottomRight) {
fillArc(maxRadius, x + width - maxRadius - 1, y + height - maxRadius - 1, 1, 1, color);
} else {
fillRectDither(x + width - maxRadius - 1, y + height - maxRadius - 1, maxRadius + 1, maxRadius + 1, color);
}
if (roundBottomLeft) {
fillArc(maxRadius, x + maxRadius, y + height - maxRadius - 1, -1, 1, color);
} else {
fillRectDither(x, y + height - maxRadius - 1, maxRadius + 1, maxRadius + 1, color);
}
}
void GfxRenderer::drawImage(const uint8_t bitmap[], const int x, const int y, const int width, const int height) const { void GfxRenderer::drawImage(const uint8_t bitmap[], const int x, const int y, const int width, const int height) const {
int rotatedX = 0; int rotatedX = 0;
int rotatedY = 0; int rotatedY = 0;
@ -166,6 +375,10 @@ void GfxRenderer::drawImage(const uint8_t bitmap[], const int x, const int y, co
display.drawImage(bitmap, rotatedX, rotatedY, width, height); display.drawImage(bitmap, rotatedX, rotatedY, width, height);
} }
void GfxRenderer::drawIcon(const uint8_t bitmap[], const int x, const int y, const int width, const int height) const {
display.drawImage(bitmap, y, getScreenWidth() - width - x, height, width);
}
void GfxRenderer::drawBitmap(const Bitmap& bitmap, const int x, const int y, const int maxWidth, const int maxHeight, void GfxRenderer::drawBitmap(const Bitmap& bitmap, const int x, const int y, const int maxWidth, const int maxHeight,
const float cropX, const float cropY) const { const float cropX, const float cropY) const {
// For 1-bit bitmaps, use optimized 1-bit rendering path (no crop support for 1-bit) // For 1-bit bitmaps, use optimized 1-bit rendering path (no crop support for 1-bit)
@ -488,85 +701,6 @@ int GfxRenderer::getLineHeight(const int fontId) const {
return fontMap.at(fontId).getData(EpdFontFamily::REGULAR)->advanceY; return fontMap.at(fontId).getData(EpdFontFamily::REGULAR)->advanceY;
} }
void GfxRenderer::drawButtonHints(const int fontId, const char* btn1, const char* btn2, const char* btn3,
const char* btn4) {
const Orientation orig_orientation = getOrientation();
setOrientation(Orientation::Portrait);
const int pageHeight = getScreenHeight();
constexpr int buttonWidth = 106;
constexpr int buttonHeight = 40;
constexpr int buttonY = 40; // Distance from bottom
constexpr int textYOffset = 7; // Distance from top of button to text baseline
constexpr int buttonPositions[] = {25, 130, 245, 350};
const char* labels[] = {btn1, btn2, btn3, btn4};
for (int i = 0; i < 4; i++) {
// Only draw if the label is non-empty
if (labels[i] != nullptr && labels[i][0] != '\0') {
const int x = buttonPositions[i];
fillRect(x, pageHeight - buttonY, buttonWidth, buttonHeight, false);
drawRect(x, pageHeight - buttonY, buttonWidth, buttonHeight);
const int textWidth = getTextWidth(fontId, labels[i]);
const int textX = x + (buttonWidth - 1 - textWidth) / 2;
drawText(fontId, textX, pageHeight - buttonY + textYOffset, labels[i]);
}
}
setOrientation(orig_orientation);
}
void GfxRenderer::drawSideButtonHints(const int fontId, const char* topBtn, const char* bottomBtn) const {
const int screenWidth = getScreenWidth();
constexpr int buttonWidth = 40; // Width on screen (height when rotated)
constexpr int buttonHeight = 80; // Height on screen (width when rotated)
constexpr int buttonX = 5; // Distance from right edge
// Position for the button group - buttons share a border so they're adjacent
constexpr int topButtonY = 345; // Top button position
const char* labels[] = {topBtn, bottomBtn};
// Draw the shared border for both buttons as one unit
const int x = screenWidth - buttonX - buttonWidth;
// Draw top button outline (3 sides, bottom open)
if (topBtn != nullptr && topBtn[0] != '\0') {
drawLine(x, topButtonY, x + buttonWidth - 1, topButtonY); // Top
drawLine(x, topButtonY, x, topButtonY + buttonHeight - 1); // Left
drawLine(x + buttonWidth - 1, topButtonY, x + buttonWidth - 1, topButtonY + buttonHeight - 1); // Right
}
// Draw shared middle border
if ((topBtn != nullptr && topBtn[0] != '\0') || (bottomBtn != nullptr && bottomBtn[0] != '\0')) {
drawLine(x, topButtonY + buttonHeight, x + buttonWidth - 1, topButtonY + buttonHeight); // Shared border
}
// Draw bottom button outline (3 sides, top is shared)
if (bottomBtn != nullptr && bottomBtn[0] != '\0') {
drawLine(x, topButtonY + buttonHeight, x, topButtonY + 2 * buttonHeight - 1); // Left
drawLine(x + buttonWidth - 1, topButtonY + buttonHeight, x + buttonWidth - 1,
topButtonY + 2 * buttonHeight - 1); // Right
drawLine(x, topButtonY + 2 * buttonHeight - 1, x + buttonWidth - 1, topButtonY + 2 * buttonHeight - 1); // Bottom
}
// Draw text for each button
for (int i = 0; i < 2; i++) {
if (labels[i] != nullptr && labels[i][0] != '\0') {
const int y = topButtonY + i * buttonHeight;
// Draw rotated text centered in the button
const int textWidth = getTextWidth(fontId, labels[i]);
const int textHeight = getTextHeight(fontId);
// Center the rotated text in the button
const int textX = x + (buttonWidth - textHeight) / 2;
const int textY = y + (buttonHeight + textWidth) / 2;
drawTextRotated90CW(fontId, textX, textY, labels[i]);
}
}
}
int GfxRenderer::getTextHeight(const int fontId) const { int GfxRenderer::getTextHeight(const int fontId) const {
if (fontMap.count(fontId) == 0) { if (fontMap.count(fontId) == 0) {
Serial.printf("[%lu] [GFX] Font %d not found\n", millis(), fontId); Serial.printf("[%lu] [GFX] Font %d not found\n", millis(), fontId);

View File

@ -7,6 +7,10 @@
#include "Bitmap.h" #include "Bitmap.h"
// Color representation: uint8_t mapped to 4x4 Bayer matrix dithering levels
// 0 = transparent, 1-16 = gray levels (white to black)
enum Color : uint8_t { Clear = 0x00, White = 0x01, LightGray = 0x05, DarkGray = 0x0A, Black = 0x10 };
class GfxRenderer { class GfxRenderer {
public: public:
enum RenderMode { BW, GRAYSCALE_LSB, GRAYSCALE_MSB }; enum RenderMode { BW, GRAYSCALE_LSB, GRAYSCALE_MSB };
@ -34,6 +38,8 @@ class GfxRenderer {
EpdFontFamily::Style style) const; EpdFontFamily::Style style) const;
void freeBwBufferChunks(); void freeBwBufferChunks();
void rotateCoordinates(int x, int y, int* rotatedX, int* rotatedY) const; void rotateCoordinates(int x, int y, int* rotatedX, int* rotatedY) const;
void drawPixelDither(int x, int y, Color color) const;
void fillArc(int maxRadius, int cx, int cy, int xDir, int yDir, Color color) const;
public: public:
explicit GfxRenderer(HalDisplay& halDisplay) : display(halDisplay), renderMode(BW), orientation(Portrait) {} explicit GfxRenderer(HalDisplay& halDisplay) : display(halDisplay), renderMode(BW), orientation(Portrait) {}
@ -63,9 +69,20 @@ class GfxRenderer {
// Drawing // Drawing
void drawPixel(int x, int y, bool state = true) const; void drawPixel(int x, int y, bool state = true) const;
void drawLine(int x1, int y1, int x2, int y2, bool state = true) const; void drawLine(int x1, int y1, int x2, int y2, bool state = true) const;
void drawLine(int x1, int y1, int x2, int y2, int lineWidth, bool state) const;
void drawArc(int maxRadius, int cx, int cy, int xDir, int yDir, int lineWidth, bool state) const;
void drawRect(int x, int y, int width, int height, bool state = true) const; void drawRect(int x, int y, int width, int height, bool state = true) const;
void drawRect(int x, int y, int width, int height, int lineWidth, bool state) const;
void drawRoundedRect(int x, int y, int width, int height, int lineWidth, int cornerRadius, bool state) const;
void drawRoundedRect(int x, int y, int width, int height, int lineWidth, int cornerRadius, bool roundTopLeft,
bool roundTopRight, bool roundBottomLeft, bool roundBottomRight, bool state) const;
void fillRect(int x, int y, int width, int height, bool state = true) const; void fillRect(int x, int y, int width, int height, bool state = true) const;
void fillRectDither(int x, int y, int width, int height, Color color) const;
void fillRoundedRect(int x, int y, int width, int height, int cornerRadius, Color color) const;
void fillRoundedRect(int x, int y, int width, int height, int cornerRadius, bool roundTopLeft, bool roundTopRight,
bool roundBottomLeft, bool roundBottomRight, Color color) const;
void drawImage(const uint8_t bitmap[], int x, int y, int width, int height) const; void drawImage(const uint8_t bitmap[], int x, int y, int width, int height) const;
void drawIcon(const uint8_t bitmap[], int x, int y, int width, int height) const;
void drawBitmap(const Bitmap& bitmap, int x, int y, int maxWidth, int maxHeight, float cropX = 0, void drawBitmap(const Bitmap& bitmap, int x, int y, int maxWidth, int maxHeight, float cropX = 0,
float cropY = 0) const; float cropY = 0) const;
void drawBitmap1Bit(const Bitmap& bitmap, int x, int y, int maxWidth, int maxHeight) const; void drawBitmap1Bit(const Bitmap& bitmap, int x, int y, int maxWidth, int maxHeight) const;
@ -83,17 +100,11 @@ class GfxRenderer {
std::string truncatedText(int fontId, const char* text, int maxWidth, std::string truncatedText(int fontId, const char* text, int maxWidth,
EpdFontFamily::Style style = EpdFontFamily::REGULAR) const; EpdFontFamily::Style style = EpdFontFamily::REGULAR) const;
// UI Components
void drawButtonHints(int fontId, const char* btn1, const char* btn2, const char* btn3, const char* btn4);
void drawSideButtonHints(int fontId, const char* topBtn, const char* bottomBtn) const;
private:
// Helper for drawing rotated text (90 degrees clockwise, for side buttons) // Helper for drawing rotated text (90 degrees clockwise, for side buttons)
void drawTextRotated90CW(int fontId, int x, int y, const char* text, bool black = true, void drawTextRotated90CW(int fontId, int x, int y, const char* text, bool black = true,
EpdFontFamily::Style style = EpdFontFamily::REGULAR) const; EpdFontFamily::Style style = EpdFontFamily::REGULAR) const;
int getTextHeight(int fontId) const; int getTextHeight(int fontId) const;
public:
// Grayscale functions // Grayscale functions
void setRenderMode(const RenderMode mode) { this->renderMode = mode; } void setRenderMode(const RenderMode mode) { this->renderMode = mode; }
void copyGrayscaleLsbBuffers() const; void copyGrayscaleLsbBuffers() const;

View File

@ -301,11 +301,12 @@ bool Xtc::generateCoverBmp() const {
return true; return true;
} }
std::string Xtc::getThumbBmpPath() const { return cachePath + "/thumb.bmp"; } std::string Xtc::getThumbBmpPath() const { return cachePath + "/thumb_[HEIGHT].bmp"; }
std::string Xtc::getThumbBmpPath(int height) const { return cachePath + "/thumb_" + std::to_string(height) + ".bmp"; }
bool Xtc::generateThumbBmp() const { bool Xtc::generateThumbBmp(int height) const {
// Already generated // Already generated
if (SdMan.exists(getThumbBmpPath().c_str())) { if (SdMan.exists(getThumbBmpPath(height).c_str())) {
return true; return true;
} }
@ -333,8 +334,8 @@ bool Xtc::generateThumbBmp() const {
const uint8_t bitDepth = parser->getBitDepth(); const uint8_t bitDepth = parser->getBitDepth();
// Calculate target dimensions for thumbnail (fit within 240x400 Continue Reading card) // Calculate target dimensions for thumbnail (fit within 240x400 Continue Reading card)
constexpr int THUMB_TARGET_WIDTH = 240; int THUMB_TARGET_WIDTH = height * 0.6;
constexpr int THUMB_TARGET_HEIGHT = 400; int THUMB_TARGET_HEIGHT = height;
// Calculate scale factor // Calculate scale factor
float scaleX = static_cast<float>(THUMB_TARGET_WIDTH) / pageInfo.width; float scaleX = static_cast<float>(THUMB_TARGET_WIDTH) / pageInfo.width;
@ -348,7 +349,7 @@ bool Xtc::generateThumbBmp() const {
if (generateCoverBmp()) { if (generateCoverBmp()) {
FsFile src, dst; FsFile src, dst;
if (SdMan.openFileForRead("XTC", getCoverBmpPath(), src)) { if (SdMan.openFileForRead("XTC", getCoverBmpPath(), src)) {
if (SdMan.openFileForWrite("XTC", getThumbBmpPath(), dst)) { if (SdMan.openFileForWrite("XTC", getThumbBmpPath(height), dst)) {
uint8_t buffer[512]; uint8_t buffer[512];
while (src.available()) { while (src.available()) {
size_t bytesRead = src.read(buffer, sizeof(buffer)); size_t bytesRead = src.read(buffer, sizeof(buffer));
@ -359,7 +360,7 @@ bool Xtc::generateThumbBmp() const {
src.close(); src.close();
} }
Serial.printf("[%lu] [XTC] Copied cover to thumb (no scaling needed)\n", millis()); Serial.printf("[%lu] [XTC] Copied cover to thumb (no scaling needed)\n", millis());
return SdMan.exists(getThumbBmpPath().c_str()); return SdMan.exists(getThumbBmpPath(height).c_str());
} }
return false; return false;
} }
@ -393,7 +394,7 @@ bool Xtc::generateThumbBmp() const {
// Create thumbnail BMP file - use 1-bit format for fast home screen rendering (no gray passes) // Create thumbnail BMP file - use 1-bit format for fast home screen rendering (no gray passes)
FsFile thumbBmp; FsFile thumbBmp;
if (!SdMan.openFileForWrite("XTC", getThumbBmpPath(), thumbBmp)) { if (!SdMan.openFileForWrite("XTC", getThumbBmpPath(height), thumbBmp)) {
Serial.printf("[%lu] [XTC] Failed to create thumb BMP file\n", millis()); Serial.printf("[%lu] [XTC] Failed to create thumb BMP file\n", millis());
free(pageBuffer); free(pageBuffer);
return false; return false;
@ -558,7 +559,7 @@ bool Xtc::generateThumbBmp() const {
free(pageBuffer); free(pageBuffer);
Serial.printf("[%lu] [XTC] Generated thumb BMP (%dx%d): %s\n", millis(), thumbWidth, thumbHeight, Serial.printf("[%lu] [XTC] Generated thumb BMP (%dx%d): %s\n", millis(), thumbWidth, thumbHeight,
getThumbBmpPath().c_str()); getThumbBmpPath(height).c_str());
return true; return true;
} }

View File

@ -65,7 +65,8 @@ class Xtc {
bool generateCoverBmp() const; bool generateCoverBmp() const;
// Thumbnail support (for Continue Reading card) // Thumbnail support (for Continue Reading card)
std::string getThumbBmpPath() const; std::string getThumbBmpPath() const;
bool generateThumbBmp() const; std::string getThumbBmpPath(int height) const;
bool generateThumbBmp(int height) const;
// Page access // Page access
uint32_t getPageCount() const; uint32_t getPageCount() const;

View File

@ -22,7 +22,7 @@ void readAndValidate(FsFile& file, uint8_t& member, const uint8_t maxValue) {
namespace { namespace {
constexpr uint8_t SETTINGS_FILE_VERSION = 1; constexpr uint8_t SETTINGS_FILE_VERSION = 1;
// Increment this when adding new persisted settings fields // Increment this when adding new persisted settings fields
constexpr uint8_t SETTINGS_COUNT = 23; constexpr uint8_t SETTINGS_COUNT = 24;
constexpr char SETTINGS_FILE[] = "/.crosspoint/settings.bin"; constexpr char SETTINGS_FILE[] = "/.crosspoint/settings.bin";
} // namespace } // namespace
@ -61,6 +61,7 @@ bool CrossPointSettings::saveToFile() const {
serialization::writeString(outputFile, std::string(opdsPassword)); serialization::writeString(outputFile, std::string(opdsPassword));
serialization::writePod(outputFile, sleepScreenCoverFilter); serialization::writePod(outputFile, sleepScreenCoverFilter);
// New fields added at end for backward compatibility // New fields added at end for backward compatibility
serialization::writePod(outputFile, uiTheme);
outputFile.close(); outputFile.close();
Serial.printf("[%lu] [CPS] Settings saved to file\n", millis()); Serial.printf("[%lu] [CPS] Settings saved to file\n", millis());
@ -149,6 +150,8 @@ bool CrossPointSettings::loadFromFile() {
readAndValidate(inputFile, sleepScreenCoverFilter, SLEEP_SCREEN_COVER_FILTER_COUNT); readAndValidate(inputFile, sleepScreenCoverFilter, SLEEP_SCREEN_COVER_FILTER_COUNT);
if (++settingsRead >= fileSettingsCount) break; if (++settingsRead >= fileSettingsCount) break;
// New fields added at end for backward compatibility // New fields added at end for backward compatibility
serialization::readPod(inputFile, uiTheme);
if (++settingsRead >= fileSettingsCount) break;
} while (false); } while (false);
inputFile.close(); inputFile.close();

View File

@ -97,6 +97,9 @@ class CrossPointSettings {
// Hide battery percentage // Hide battery percentage
enum HIDE_BATTERY_PERCENTAGE { HIDE_NEVER = 0, HIDE_READER = 1, HIDE_ALWAYS = 2, HIDE_BATTERY_PERCENTAGE_COUNT }; enum HIDE_BATTERY_PERCENTAGE { HIDE_NEVER = 0, HIDE_READER = 1, HIDE_ALWAYS = 2, HIDE_BATTERY_PERCENTAGE_COUNT };
// UI Theme
enum UI_THEME { CLASSIC = 0, LYRA = 1 };
// Sleep screen settings // Sleep screen settings
uint8_t sleepScreen = DARK; uint8_t sleepScreen = DARK;
// Sleep screen cover mode settings // Sleep screen cover mode settings
@ -137,6 +140,8 @@ class CrossPointSettings {
uint8_t hideBatteryPercentage = HIDE_NEVER; uint8_t hideBatteryPercentage = HIDE_NEVER;
// Long-press chapter skip on side buttons // Long-press chapter skip on side buttons
uint8_t longPressChapterSkip = 1; uint8_t longPressChapterSkip = 1;
// UI Theme
uint8_t uiTheme = CLASSIC;
~CrossPointSettings() = default; ~CrossPointSettings() = default;

View File

@ -14,7 +14,8 @@ constexpr int MAX_RECENT_BOOKS = 10;
RecentBooksStore RecentBooksStore::instance; RecentBooksStore RecentBooksStore::instance;
void RecentBooksStore::addBook(const std::string& path, const std::string& title, const std::string& author) { void RecentBooksStore::addBook(const std::string& path, const std::string& title, const std::string& author,
const std::string& coverBmpPath) {
// Remove existing entry if present // Remove existing entry if present
auto it = auto it =
std::find_if(recentBooks.begin(), recentBooks.end(), [&](const RecentBook& book) { return book.path == path; }); std::find_if(recentBooks.begin(), recentBooks.end(), [&](const RecentBook& book) { return book.path == path; });
@ -23,7 +24,7 @@ void RecentBooksStore::addBook(const std::string& path, const std::string& title
} }
// Add to front // Add to front
recentBooks.insert(recentBooks.begin(), {path, title, author}); recentBooks.insert(recentBooks.begin(), {path, title, author, coverBmpPath});
// Trim to max size // Trim to max size
if (recentBooks.size() > MAX_RECENT_BOOKS) { if (recentBooks.size() > MAX_RECENT_BOOKS) {
@ -50,6 +51,7 @@ bool RecentBooksStore::saveToFile() const {
serialization::writeString(outputFile, book.path); serialization::writeString(outputFile, book.path);
serialization::writeString(outputFile, book.title); serialization::writeString(outputFile, book.title);
serialization::writeString(outputFile, book.author); serialization::writeString(outputFile, book.author);
serialization::writeString(outputFile, book.coverBmpPath);
} }
outputFile.close(); outputFile.close();
@ -77,7 +79,7 @@ bool RecentBooksStore::loadFromFile() {
serialization::readString(inputFile, path); serialization::readString(inputFile, path);
// Title and author will be empty, they will be filled when the book is // Title and author will be empty, they will be filled when the book is
// opened again // opened again
recentBooks.push_back({path, "", ""}); recentBooks.push_back({path, "", "", ""});
} }
} else { } else {
Serial.printf("[%lu] [RBS] Deserialization failed: Unknown version %u\n", millis(), version); Serial.printf("[%lu] [RBS] Deserialization failed: Unknown version %u\n", millis(), version);
@ -92,11 +94,12 @@ bool RecentBooksStore::loadFromFile() {
recentBooks.reserve(count); recentBooks.reserve(count);
for (uint8_t i = 0; i < count; i++) { for (uint8_t i = 0; i < count; i++) {
std::string path, title, author; std::string path, title, author, coverBmpPath;
serialization::readString(inputFile, path); serialization::readString(inputFile, path);
serialization::readString(inputFile, title); serialization::readString(inputFile, title);
serialization::readString(inputFile, author); serialization::readString(inputFile, author);
recentBooks.push_back({path, title, author}); serialization::readString(inputFile, coverBmpPath);
recentBooks.push_back({path, title, author, coverBmpPath});
} }
} }

View File

@ -6,6 +6,7 @@ struct RecentBook {
std::string path; std::string path;
std::string title; std::string title;
std::string author; std::string author;
std::string coverBmpPath;
bool operator==(const RecentBook& other) const { return path == other.path; } bool operator==(const RecentBook& other) const { return path == other.path; }
}; };
@ -23,7 +24,8 @@ class RecentBooksStore {
static RecentBooksStore& getInstance() { return instance; } static RecentBooksStore& getInstance() { return instance; }
// Add a book to the recent list (moves to front if already exists) // Add a book to the recent list (moves to front if already exists)
void addBook(const std::string& path, const std::string& title, const std::string& author); void addBook(const std::string& path, const std::string& title, const std::string& author,
const std::string& coverBmpPath);
// Get the list of recent books (most recent first) // Get the list of recent books (most recent first)
const std::vector<RecentBook>& getBooks() const { return recentBooks; } const std::vector<RecentBook>& getBooks() const { return recentBooks; }

View File

@ -1,178 +0,0 @@
#include "ScreenComponents.h"
#include <GfxRenderer.h>
#include <cstdint>
#include <string>
#include "Battery.h"
#include "fontIds.h"
void ScreenComponents::drawBattery(const GfxRenderer& renderer, const int left, const int top,
const bool showPercentage) {
// Left aligned battery icon and percentage
const uint16_t percentage = battery.readPercentage();
const auto percentageText = showPercentage ? std::to_string(percentage) + "%" : "";
renderer.drawText(SMALL_FONT_ID, left + 20, top, percentageText.c_str());
// 1 column on left, 2 columns on right, 5 columns of battery body
constexpr int batteryWidth = 15;
constexpr int batteryHeight = 12;
const int x = left;
const int y = top + 6;
// Top line
renderer.drawLine(x + 1, y, x + batteryWidth - 3, y);
// Bottom line
renderer.drawLine(x + 1, y + batteryHeight - 1, x + batteryWidth - 3, y + batteryHeight - 1);
// Left line
renderer.drawLine(x, y + 1, x, y + batteryHeight - 2);
// Battery end
renderer.drawLine(x + batteryWidth - 2, y + 1, x + batteryWidth - 2, y + batteryHeight - 2);
renderer.drawPixel(x + batteryWidth - 1, y + 3);
renderer.drawPixel(x + batteryWidth - 1, y + batteryHeight - 4);
renderer.drawLine(x + batteryWidth - 0, y + 4, x + batteryWidth - 0, y + batteryHeight - 5);
// The +1 is to round up, so that we always fill at least one pixel
int filledWidth = percentage * (batteryWidth - 5) / 100 + 1;
if (filledWidth > batteryWidth - 5) {
filledWidth = batteryWidth - 5; // Ensure we don't overflow
}
renderer.fillRect(x + 2, y + 2, filledWidth, batteryHeight - 4);
}
ScreenComponents::PopupLayout ScreenComponents::drawPopup(const GfxRenderer& renderer, const char* message) {
constexpr int margin = 15;
constexpr int y = 60;
const int textWidth = renderer.getTextWidth(UI_12_FONT_ID, message, EpdFontFamily::BOLD);
const int textHeight = renderer.getLineHeight(UI_12_FONT_ID);
const int w = textWidth + margin * 2;
const int h = textHeight + margin * 2;
const int x = (renderer.getScreenWidth() - w) / 2;
renderer.fillRect(x - 2, y - 2, w + 4, h + 4, true); // frame thickness 2
renderer.fillRect(x, y, w, h, false);
const int textX = x + (w - textWidth) / 2;
const int textY = y + margin - 2;
renderer.drawText(UI_12_FONT_ID, textX, textY, message, true, EpdFontFamily::BOLD);
renderer.displayBuffer();
return {x, y, w, h};
}
void ScreenComponents::fillPopupProgress(const GfxRenderer& renderer, const PopupLayout& layout, const int progress) {
constexpr int barHeight = 4;
const int barWidth = layout.width - 30; // twice the margin in drawPopup to match text width
const int barX = layout.x + (layout.width - barWidth) / 2;
const int barY = layout.y + layout.height - 10;
int fillWidth = barWidth * progress / 100;
renderer.fillRect(barX, barY, fillWidth, barHeight, true);
renderer.displayBuffer(HalDisplay::FAST_REFRESH);
}
void ScreenComponents::drawBookProgressBar(const GfxRenderer& renderer, const size_t bookProgress) {
int vieweableMarginTop, vieweableMarginRight, vieweableMarginBottom, vieweableMarginLeft;
renderer.getOrientedViewableTRBL(&vieweableMarginTop, &vieweableMarginRight, &vieweableMarginBottom,
&vieweableMarginLeft);
const int progressBarMaxWidth = renderer.getScreenWidth() - vieweableMarginLeft - vieweableMarginRight;
const int progressBarY = renderer.getScreenHeight() - vieweableMarginBottom - BOOK_PROGRESS_BAR_HEIGHT;
const int barWidth = progressBarMaxWidth * bookProgress / 100;
renderer.fillRect(vieweableMarginLeft, progressBarY, barWidth, BOOK_PROGRESS_BAR_HEIGHT, true);
}
int ScreenComponents::drawTabBar(const GfxRenderer& renderer, const int y, const std::vector<TabInfo>& tabs) {
constexpr int tabPadding = 20; // Horizontal padding between tabs
constexpr int leftMargin = 20; // Left margin for first tab
constexpr int underlineHeight = 2; // Height of selection underline
constexpr int underlineGap = 4; // Gap between text and underline
const int lineHeight = renderer.getLineHeight(UI_12_FONT_ID);
const int tabBarHeight = lineHeight + underlineGap + underlineHeight;
int currentX = leftMargin;
for (const auto& tab : tabs) {
const int textWidth =
renderer.getTextWidth(UI_12_FONT_ID, tab.label, tab.selected ? EpdFontFamily::BOLD : EpdFontFamily::REGULAR);
// Draw tab label
renderer.drawText(UI_12_FONT_ID, currentX, y, tab.label, true,
tab.selected ? EpdFontFamily::BOLD : EpdFontFamily::REGULAR);
// Draw underline for selected tab
if (tab.selected) {
renderer.fillRect(currentX, y + lineHeight + underlineGap, textWidth, underlineHeight);
}
currentX += textWidth + tabPadding;
}
return tabBarHeight;
}
void ScreenComponents::drawScrollIndicator(const GfxRenderer& renderer, const int currentPage, const int totalPages,
const int contentTop, const int contentHeight) {
if (totalPages <= 1) {
return; // No need for indicator if only one page
}
const int screenWidth = renderer.getScreenWidth();
constexpr int indicatorWidth = 20;
constexpr int arrowSize = 6;
constexpr int margin = 15; // Offset from right edge
const int centerX = screenWidth - indicatorWidth / 2 - margin;
const int indicatorTop = contentTop + 60; // Offset to avoid overlapping side button hints
const int indicatorBottom = contentTop + contentHeight - 30;
// Draw up arrow at top (^) - narrow point at top, wide base at bottom
for (int i = 0; i < arrowSize; ++i) {
const int lineWidth = 1 + i * 2;
const int startX = centerX - i;
renderer.drawLine(startX, indicatorTop + i, startX + lineWidth - 1, indicatorTop + i);
}
// Draw down arrow at bottom (v) - wide base at top, narrow point at bottom
for (int i = 0; i < arrowSize; ++i) {
const int lineWidth = 1 + (arrowSize - 1 - i) * 2;
const int startX = centerX - (arrowSize - 1 - i);
renderer.drawLine(startX, indicatorBottom - arrowSize + 1 + i, startX + lineWidth - 1,
indicatorBottom - arrowSize + 1 + i);
}
// Draw page fraction in the middle (e.g., "1/3")
const std::string pageText = std::to_string(currentPage) + "/" + std::to_string(totalPages);
const int textWidth = renderer.getTextWidth(SMALL_FONT_ID, pageText.c_str());
const int textX = centerX - textWidth / 2;
const int textY = (indicatorTop + indicatorBottom) / 2 - renderer.getLineHeight(SMALL_FONT_ID) / 2;
renderer.drawText(SMALL_FONT_ID, textX, textY, pageText.c_str());
}
void ScreenComponents::drawProgressBar(const GfxRenderer& renderer, const int x, const int y, const int width,
const int height, const size_t current, const size_t total) {
if (total == 0) {
return;
}
// Use 64-bit arithmetic to avoid overflow for large files
const int percent = static_cast<int>((static_cast<uint64_t>(current) * 100) / total);
// Draw outline
renderer.drawRect(x, y, width, height);
// Draw filled portion
const int fillWidth = (width - 4) * percent / 100;
if (fillWidth > 0) {
renderer.fillRect(x + 2, y + 2, fillWidth, height - 4);
}
// Draw percentage text centered below bar
const std::string percentText = std::to_string(percent) + "%";
renderer.drawCenteredText(UI_10_FONT_ID, y + height + 15, percentText.c_str());
}

View File

@ -1,53 +0,0 @@
#pragma once
#include <cstddef>
#include <cstdint>
#include <vector>
class GfxRenderer;
struct TabInfo {
const char* label;
bool selected;
};
class ScreenComponents {
public:
static const int BOOK_PROGRESS_BAR_HEIGHT = 4;
struct PopupLayout {
int x;
int y;
int width;
int height;
};
static void drawBattery(const GfxRenderer& renderer, int left, int top, bool showPercentage = true);
static void drawBookProgressBar(const GfxRenderer& renderer, size_t bookProgress);
static PopupLayout drawPopup(const GfxRenderer& renderer, const char* message);
static void fillPopupProgress(const GfxRenderer& renderer, const PopupLayout& layout, int progress);
// Draw a horizontal tab bar with underline indicator for selected tab
// Returns the height of the tab bar (for positioning content below)
static int drawTabBar(const GfxRenderer& renderer, int y, const std::vector<TabInfo>& tabs);
// Draw a scroll/page indicator on the right side of the screen
// Shows up/down arrows and current page fraction (e.g., "1/3")
static void drawScrollIndicator(const GfxRenderer& renderer, int currentPage, int totalPages, int contentTop,
int contentHeight);
/**
* Draw a progress bar with percentage text.
* @param renderer The graphics renderer
* @param x Left position of the bar
* @param y Top position of the bar
* @param width Width of the bar
* @param height Height of the bar
* @param current Current progress value
* @param total Total value for 100% progress
*/
static void drawProgressBar(const GfxRenderer& renderer, int x, int y, int width, int height, size_t current,
size_t total);
};

View File

@ -8,15 +8,14 @@
#include "CrossPointSettings.h" #include "CrossPointSettings.h"
#include "CrossPointState.h" #include "CrossPointState.h"
#include "ScreenComponents.h" #include "components/UITheme.h"
#include "fontIds.h" #include "fontIds.h"
#include "images/CrossLarge.h" #include "images/CrossLarge.h"
#include "util/StringUtils.h" #include "util/StringUtils.h"
void SleepActivity::onEnter() { void SleepActivity::onEnter() {
Activity::onEnter(); Activity::onEnter();
GUI.drawPopup(renderer, "Entering Sleep...");
ScreenComponents::drawPopup(renderer, "Entering Sleep...");
if (SETTINGS.sleepScreen == CrossPointSettings::SLEEP_SCREEN_MODE::BLANK) { if (SETTINGS.sleepScreen == CrossPointSettings::SLEEP_SCREEN_MODE::BLANK) {
return renderBlankSleepScreen(); return renderBlankSleepScreen();

View File

@ -8,8 +8,8 @@
#include "CrossPointSettings.h" #include "CrossPointSettings.h"
#include "MappedInputManager.h" #include "MappedInputManager.h"
#include "ScreenComponents.h"
#include "activities/network/WifiSelectionActivity.h" #include "activities/network/WifiSelectionActivity.h"
#include "components/UITheme.h"
#include "fontIds.h" #include "fontIds.h"
#include "network/HttpDownloader.h" #include "network/HttpDownloader.h"
#include "util/StringUtils.h" #include "util/StringUtils.h"
@ -176,7 +176,7 @@ void OpdsBookBrowserActivity::render() const {
if (state == BrowserState::CHECK_WIFI) { if (state == BrowserState::CHECK_WIFI) {
renderer.drawCenteredText(UI_10_FONT_ID, pageHeight / 2, statusMessage.c_str()); renderer.drawCenteredText(UI_10_FONT_ID, pageHeight / 2, statusMessage.c_str());
const auto labels = mappedInput.mapLabels("« Back", "", "", ""); const auto labels = mappedInput.mapLabels("« Back", "", "", "");
renderer.drawButtonHints(UI_10_FONT_ID, labels.btn1, labels.btn2, labels.btn3, labels.btn4); GUI.drawButtonHints(renderer, labels.btn1, labels.btn2, labels.btn3, labels.btn4);
renderer.displayBuffer(); renderer.displayBuffer();
return; return;
} }
@ -184,7 +184,7 @@ void OpdsBookBrowserActivity::render() const {
if (state == BrowserState::LOADING) { if (state == BrowserState::LOADING) {
renderer.drawCenteredText(UI_10_FONT_ID, pageHeight / 2, statusMessage.c_str()); renderer.drawCenteredText(UI_10_FONT_ID, pageHeight / 2, statusMessage.c_str());
const auto labels = mappedInput.mapLabels("« Back", "", "", ""); const auto labels = mappedInput.mapLabels("« Back", "", "", "");
renderer.drawButtonHints(UI_10_FONT_ID, labels.btn1, labels.btn2, labels.btn3, labels.btn4); GUI.drawButtonHints(renderer, labels.btn1, labels.btn2, labels.btn3, labels.btn4);
renderer.displayBuffer(); renderer.displayBuffer();
return; return;
} }
@ -193,7 +193,7 @@ void OpdsBookBrowserActivity::render() const {
renderer.drawCenteredText(UI_10_FONT_ID, pageHeight / 2 - 20, "Error:"); renderer.drawCenteredText(UI_10_FONT_ID, pageHeight / 2 - 20, "Error:");
renderer.drawCenteredText(UI_10_FONT_ID, pageHeight / 2 + 10, errorMessage.c_str()); renderer.drawCenteredText(UI_10_FONT_ID, pageHeight / 2 + 10, errorMessage.c_str());
const auto labels = mappedInput.mapLabels("« Back", "Retry", "", ""); const auto labels = mappedInput.mapLabels("« Back", "Retry", "", "");
renderer.drawButtonHints(UI_10_FONT_ID, labels.btn1, labels.btn2, labels.btn3, labels.btn4); GUI.drawButtonHints(renderer, labels.btn1, labels.btn2, labels.btn3, labels.btn4);
renderer.displayBuffer(); renderer.displayBuffer();
return; return;
} }
@ -206,7 +206,7 @@ void OpdsBookBrowserActivity::render() const {
constexpr int barHeight = 20; constexpr int barHeight = 20;
constexpr int barX = 50; constexpr int barX = 50;
const int barY = pageHeight / 2 + 20; const int barY = pageHeight / 2 + 20;
ScreenComponents::drawProgressBar(renderer, barX, barY, barWidth, barHeight, downloadProgress, downloadTotal); GUI.drawProgressBar(renderer, Rect{barX, barY, barWidth, barHeight}, downloadProgress, downloadTotal);
} }
renderer.displayBuffer(); renderer.displayBuffer();
return; return;
@ -219,7 +219,7 @@ void OpdsBookBrowserActivity::render() const {
confirmLabel = "Download"; confirmLabel = "Download";
} }
const auto labels = mappedInput.mapLabels("« Back", confirmLabel, "", ""); const auto labels = mappedInput.mapLabels("« Back", confirmLabel, "", "");
renderer.drawButtonHints(UI_10_FONT_ID, labels.btn1, labels.btn2, labels.btn3, labels.btn4); GUI.drawButtonHints(renderer, labels.btn1, labels.btn2, labels.btn3, labels.btn4);
if (entries.empty()) { if (entries.empty()) {
renderer.drawCenteredText(UI_10_FONT_ID, pageHeight / 2, "No entries found"); renderer.drawCenteredText(UI_10_FONT_ID, pageHeight / 2, "No entries found");

View File

@ -14,7 +14,8 @@
#include "CrossPointSettings.h" #include "CrossPointSettings.h"
#include "CrossPointState.h" #include "CrossPointState.h"
#include "MappedInputManager.h" #include "MappedInputManager.h"
#include "ScreenComponents.h" #include "RecentBooksStore.h"
#include "components/UITheme.h"
#include "fontIds.h" #include "fontIds.h"
#include "util/StringUtils.h" #include "util/StringUtils.h"
@ -24,12 +25,87 @@ void HomeActivity::taskTrampoline(void* param) {
} }
int HomeActivity::getMenuItemCount() const { int HomeActivity::getMenuItemCount() const {
int count = 3; // My Library, File transfer, Settings int count = 4; // My Library, Recents, File transfer, Settings
if (hasContinueReading) count++; if (!recentBooks.empty()) {
if (hasOpdsUrl) count++; count += recentBooks.size();
}
if (hasOpdsUrl) {
count++;
}
return count; return count;
} }
void HomeActivity::loadRecentBooks(int maxBooks, int coverHeight) {
recentsLoading = true;
bool showingLoading = false;
Rect popupRect;
recentBooks.clear();
const auto& books = RECENT_BOOKS.getBooks();
recentBooks.reserve(std::min(static_cast<int>(books.size()), maxBooks));
int progress = 0;
for (const RecentBook& book : books) {
// Limit to maximum number of recent books
if (recentBooks.size() >= maxBooks) {
break;
}
// Skip if file no longer exists
if (!SdMan.exists(book.path.c_str())) {
continue;
}
if (!book.coverBmpPath.empty()) {
std::string coverPath = UITheme::getCoverThumbPath(book.coverBmpPath, coverHeight);
if (!SdMan.exists(coverPath.c_str())) {
std::string lastBookFileName = "";
const size_t lastSlash = book.path.find_last_of('/');
if (lastSlash != std::string::npos) {
lastBookFileName = book.path.substr(lastSlash + 1);
}
Serial.printf("Loading recent book: %s\n", book.path.c_str());
// If epub, try to load the metadata for title/author and cover
if (StringUtils::checkFileExtension(lastBookFileName, ".epub")) {
Epub epub(book.path, "/.crosspoint");
epub.load(false);
// Try to generate thumbnail image for Continue Reading card
if (!showingLoading) {
showingLoading = true;
popupRect = GUI.drawPopup(renderer, "Loading...");
}
GUI.fillPopupProgress(renderer, popupRect, progress * 30);
epub.generateThumbBmp(coverHeight);
} else if (StringUtils::checkFileExtension(lastBookFileName, ".xtch") ||
StringUtils::checkFileExtension(lastBookFileName, ".xtc")) {
// Handle XTC file
Xtc xtc(book.path, "/.crosspoint");
if (xtc.load()) {
// Try to generate thumbnail image for Continue Reading card
if (!showingLoading) {
showingLoading = true;
popupRect = GUI.drawPopup(renderer, "Loading...");
}
GUI.fillPopupProgress(renderer, popupRect, progress * 30);
xtc.generateThumbBmp(coverHeight);
}
}
}
}
recentBooks.push_back(book);
progress++;
}
Serial.printf("Recent books loaded: %d\n", recentBooks.size());
recentsLoaded = true;
recentsLoading = false;
updateRequired = true;
}
void HomeActivity::onEnter() { void HomeActivity::onEnter() {
Activity::onEnter(); Activity::onEnter();
@ -41,62 +117,13 @@ void HomeActivity::onEnter() {
// Check if OPDS browser URL is configured // Check if OPDS browser URL is configured
hasOpdsUrl = strlen(SETTINGS.opdsServerUrl) > 0; hasOpdsUrl = strlen(SETTINGS.opdsServerUrl) > 0;
if (hasContinueReading) {
// Extract filename from path for display
lastBookTitle = APP_STATE.openEpubPath;
const size_t lastSlash = lastBookTitle.find_last_of('/');
if (lastSlash != std::string::npos) {
lastBookTitle = lastBookTitle.substr(lastSlash + 1);
}
// If epub, try to load the metadata for title/author and cover
if (StringUtils::checkFileExtension(lastBookTitle, ".epub")) {
Epub epub(APP_STATE.openEpubPath, "/.crosspoint");
epub.load(false);
if (!epub.getTitle().empty()) {
lastBookTitle = std::string(epub.getTitle());
}
if (!epub.getAuthor().empty()) {
lastBookAuthor = std::string(epub.getAuthor());
}
// Try to generate thumbnail image for Continue Reading card
if (epub.generateThumbBmp()) {
coverBmpPath = epub.getThumbBmpPath();
hasCoverImage = true;
}
} else if (StringUtils::checkFileExtension(lastBookTitle, ".xtch") ||
StringUtils::checkFileExtension(lastBookTitle, ".xtc")) {
// Handle XTC file
Xtc xtc(APP_STATE.openEpubPath, "/.crosspoint");
if (xtc.load()) {
if (!xtc.getTitle().empty()) {
lastBookTitle = std::string(xtc.getTitle());
}
if (!xtc.getAuthor().empty()) {
lastBookAuthor = std::string(xtc.getAuthor());
}
// Try to generate thumbnail image for Continue Reading card
if (xtc.generateThumbBmp()) {
coverBmpPath = xtc.getThumbBmpPath();
hasCoverImage = true;
}
}
// Remove extension from title if we don't have metadata
if (StringUtils::checkFileExtension(lastBookTitle, ".xtch")) {
lastBookTitle.resize(lastBookTitle.length() - 5);
} else if (StringUtils::checkFileExtension(lastBookTitle, ".xtc")) {
lastBookTitle.resize(lastBookTitle.length() - 4);
}
}
}
selectorIndex = 0; selectorIndex = 0;
// Trigger first update // Trigger first update
updateRequired = true; updateRequired = true;
xTaskCreate(&HomeActivity::taskTrampoline, "HomeActivityTask", xTaskCreate(&HomeActivity::taskTrampoline, "HomeActivityTask",
4096, // Stack size (increased for cover image rendering) 8192, // Stack size
this, // Parameters this, // Parameters
1, // Priority 1, // Priority
&displayTaskHandle // Task handle &displayTaskHandle // Task handle
@ -172,21 +199,24 @@ void HomeActivity::loop() {
if (mappedInput.wasReleased(MappedInputManager::Button::Confirm)) { if (mappedInput.wasReleased(MappedInputManager::Button::Confirm)) {
// Calculate dynamic indices based on which options are available // Calculate dynamic indices based on which options are available
int idx = 0; int idx = 0;
const int continueIdx = hasContinueReading ? idx++ : -1; int menuSelectedIndex = selectorIndex - static_cast<int>(recentBooks.size());
const int myLibraryIdx = idx++; const int myLibraryIdx = idx++;
const int recentsIdx = idx++;
const int opdsLibraryIdx = hasOpdsUrl ? idx++ : -1; const int opdsLibraryIdx = hasOpdsUrl ? idx++ : -1;
const int fileTransferIdx = idx++; const int fileTransferIdx = idx++;
const int settingsIdx = idx; const int settingsIdx = idx;
if (selectorIndex == continueIdx) { if (selectorIndex < recentBooks.size()) {
onContinueReading(); onSelectBook(recentBooks[selectorIndex].path);
} else if (selectorIndex == myLibraryIdx) { } else if (menuSelectedIndex == myLibraryIdx) {
onMyLibraryOpen(); onMyLibraryOpen();
} else if (selectorIndex == opdsLibraryIdx) { } else if (menuSelectedIndex == recentsIdx) {
onRecentsOpen();
} else if (menuSelectedIndex == opdsLibraryIdx) {
onOpdsBrowserOpen(); onOpdsBrowserOpen();
} else if (selectorIndex == fileTransferIdx) { } else if (menuSelectedIndex == fileTransferIdx) {
onFileTransferOpen(); onFileTransferOpen();
} else if (selectorIndex == settingsIdx) { } else if (menuSelectedIndex == settingsIdx) {
onSettingsOpen(); onSettingsOpen();
} }
} else if (prevPressed) { } else if (prevPressed) {
@ -211,350 +241,51 @@ void HomeActivity::displayTaskLoop() {
} }
void HomeActivity::render() { void HomeActivity::render() {
// If we have a stored cover buffer, restore it instead of clearing auto metrics = UITheme::getInstance().getMetrics();
const bool bufferRestored = coverBufferStored && restoreCoverBuffer();
if (!bufferRestored) {
renderer.clearScreen();
}
const auto pageWidth = renderer.getScreenWidth(); const auto pageWidth = renderer.getScreenWidth();
const auto pageHeight = renderer.getScreenHeight(); const auto pageHeight = renderer.getScreenHeight();
constexpr int margin = 20; bool bufferRestored = coverBufferStored && restoreCoverBuffer();
constexpr int bottomMargin = 60; if (!firstRenderDone || (recentsLoaded && !recentsDisplayed)) {
renderer.clearScreen();
// --- 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;
// 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;
// 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;
if (bitmap.getWidth() > bookWidth || bitmap.getHeight() > bookHeight) {
const float imgRatio = static_cast<float>(bitmap.getWidth()) / static_cast<float>(bitmap.getHeight());
const float boxRatio = static_cast<float>(bookWidth) / static_cast<float>(bookHeight);
if (imgRatio > boxRatio) {
coverX = bookX;
coverY = bookY + (bookHeight - static_cast<int>(bookWidth / imgRatio)) / 2;
} else {
coverX = bookX + (bookWidth - static_cast<int>(bookHeight * imgRatio)) / 2;
coverY = bookY;
}
} else {
coverX = bookX + (bookWidth - bitmap.getWidth()) / 2;
coverY = bookY + (bookHeight - bitmap.getHeight()) / 2;
}
// 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
}
} }
GUI.drawHeader(renderer, Rect{0, metrics.topPadding, pageWidth, metrics.homeTopPadding}, nullptr);
if (hasContinueReading) { if (hasContinueReading) {
// Invert text colors based on selection state: if (recentsLoaded) {
// - With cover: selected = white text on black box, unselected = black text on white box recentsDisplayed = true;
// - Without cover: selected = white text on black card, unselected = black text on white card GUI.drawRecentBookCover(renderer, Rect{0, metrics.homeTopPadding, pageWidth, metrics.homeCoverTileHeight},
recentBooks, selectorIndex, coverRendered, coverBufferStored, bufferRestored,
// Split into words (avoid stringstream to keep this light on the MCU) std::bind(&HomeActivity::storeCoverBuffer, this));
std::vector<std::string> words; } else if (!recentsLoading && firstRenderDone) {
words.reserve(8); recentsLoading = true;
size_t pos = 0; loadRecentBooks(metrics.homeRecentBooksCount, metrics.homeCoverHeight);
while (pos < lastBookTitle.size()) {
while (pos < lastBookTitle.size() && lastBookTitle[pos] == ' ') {
++pos;
}
if (pos >= lastBookTitle.size()) {
break;
}
const size_t start = pos;
while (pos < lastBookTitle.size() && lastBookTitle[pos] != ' ') {
++pos;
}
words.emplace_back(lastBookTitle.substr(start, pos - start));
} }
std::vector<std::string> lines;
std::string currentLine;
// Extra padding inside the card so text doesn't hug the border
const int maxLineWidth = bookWidth - 40;
const int spaceWidth = renderer.getSpaceWidth(UI_12_FONT_ID);
for (auto& i : words) {
// If we just hit the line limit (3), stop processing words
if (lines.size() >= 3) {
// Limit to 3 lines
// Still have words left, so add ellipsis to last line
lines.back().append("...");
while (!lines.back().empty() && renderer.getTextWidth(UI_12_FONT_ID, lines.back().c_str()) > maxLineWidth) {
// Remove "..." first, then remove one UTF-8 char, then add "..." back
lines.back().resize(lines.back().size() - 3); // Remove "..."
utf8RemoveLastChar(lines.back());
lines.back().append("...");
}
break;
}
int wordWidth = renderer.getTextWidth(UI_12_FONT_ID, i.c_str());
while (wordWidth > maxLineWidth && !i.empty()) {
// Word itself is too long, trim it (UTF-8 safe)
utf8RemoveLastChar(i);
// Check if we have room for ellipsis
std::string withEllipsis = i + "...";
wordWidth = renderer.getTextWidth(UI_12_FONT_ID, withEllipsis.c_str());
if (wordWidth <= maxLineWidth) {
i = withEllipsis;
break;
}
}
int newLineWidth = renderer.getTextWidth(UI_12_FONT_ID, currentLine.c_str());
if (newLineWidth > 0) {
newLineWidth += spaceWidth;
}
newLineWidth += wordWidth;
if (newLineWidth > maxLineWidth && !currentLine.empty()) {
// New line too long, push old line
lines.push_back(currentLine);
currentLine = i;
} else {
currentLine.append(" ").append(i);
}
}
// If lower than the line limit, push remaining words
if (!currentLine.empty() && lines.size() < 3) {
lines.push_back(currentLine);
}
// Book title text
int totalTextHeight = renderer.getLineHeight(UI_12_FONT_ID) * static_cast<int>(lines.size());
if (!lastBookAuthor.empty()) {
totalTextHeight += renderer.getLineHeight(UI_10_FONT_ID) * 3 / 2;
}
// Vertically center the title block within the card
int titleYStart = bookY + (bookHeight - totalTextHeight) / 2;
// If cover image was rendered, draw box behind title and author
if (coverRendered) {
constexpr int boxPadding = 8;
// Calculate the max text width for the box
int maxTextWidth = 0;
for (const auto& line : lines) {
const int lineWidth = renderer.getTextWidth(UI_12_FONT_ID, line.c_str());
if (lineWidth > maxTextWidth) {
maxTextWidth = lineWidth;
}
}
if (!lastBookAuthor.empty()) {
std::string trimmedAuthor = lastBookAuthor;
while (renderer.getTextWidth(UI_10_FONT_ID, trimmedAuthor.c_str()) > maxLineWidth && !trimmedAuthor.empty()) {
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()) {
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()) {
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 // Build menu items dynamically
std::vector<const char*> menuItems = {"My Library", "File Transfer", "Settings"}; std::vector<const char*> menuItems = {"Browse Files", "Recents", "File Transfer", "Settings"};
if (hasOpdsUrl) { if (hasOpdsUrl) {
// Insert OPDS Browser after My Library // Insert OPDS Browser after My Library
menuItems.insert(menuItems.begin() + 1, "OPDS Browser"); menuItems.insert(menuItems.begin() + 2, "OPDS Browser");
} }
const int menuTileWidth = pageWidth - 2 * margin; GUI.drawButtonMenu(
constexpr int menuTileHeight = 45; renderer,
constexpr int menuSpacing = 8; Rect{0, metrics.homeTopPadding + metrics.homeCoverTileHeight + metrics.verticalSpacing, pageWidth,
const int totalMenuHeight = pageHeight - (metrics.headerHeight + metrics.homeTopPadding + metrics.verticalSpacing * 2 +
static_cast<int>(menuItems.size()) * menuTileHeight + (static_cast<int>(menuItems.size()) - 1) * menuSpacing; metrics.buttonHintsHeight)},
static_cast<int>(menuItems.size()), selectorIndex - recentBooks.size(),
int menuStartY = bookY + bookHeight + 15; [&menuItems](int index) { return std::string(menuItems[index]); }, false, nullptr);
// Ensure we don't collide with the bottom button legend
const int maxMenuStartY = pageHeight - bottomMargin - totalMenuHeight - margin;
if (menuStartY > maxMenuStartY) {
menuStartY = maxMenuStartY;
}
for (size_t i = 0; i < menuItems.size(); ++i) {
const int overallIndex = static_cast<int>(i) + (hasContinueReading ? 1 : 0);
constexpr int tileX = margin;
const int tileY = menuStartY + static_cast<int>(i) * (menuTileHeight + menuSpacing);
const bool selected = selectorIndex == overallIndex;
if (selected) {
renderer.fillRect(tileX, tileY, menuTileWidth, menuTileHeight);
} else {
renderer.drawRect(tileX, tileY, menuTileWidth, menuTileHeight);
}
const char* label = menuItems[i];
const int textWidth = renderer.getTextWidth(UI_10_FONT_ID, label);
const int textX = tileX + (menuTileWidth - textWidth) / 2;
const int lineHeight = renderer.getLineHeight(UI_10_FONT_ID);
const int textY = tileY + (menuTileHeight - lineHeight) / 2; // vertically centered assuming y is top of text
// Invert text when the tile is selected, to contrast with the filled background
renderer.drawText(UI_10_FONT_ID, textX, textY, label, !selected);
}
const auto labels = mappedInput.mapLabels("", "Select", "Up", "Down"); const auto labels = mappedInput.mapLabels("", "Select", "Up", "Down");
renderer.drawButtonHints(UI_10_FONT_ID, labels.btn1, labels.btn2, labels.btn3, labels.btn4); GUI.drawButtonHints(renderer, labels.btn1, labels.btn2, labels.btn3, labels.btn4);
const bool showBatteryPercentage =
SETTINGS.hideBatteryPercentage != CrossPointSettings::HIDE_BATTERY_PERCENTAGE::HIDE_ALWAYS;
// get percentage so we can align text properly
const uint16_t percentage = battery.readPercentage();
const auto percentageText = showBatteryPercentage ? std::to_string(percentage) + "%" : "";
const auto batteryX = pageWidth - 25 - renderer.getTextWidth(SMALL_FONT_ID, percentageText.c_str());
ScreenComponents::drawBattery(renderer, batteryX, 10, showBatteryPercentage);
renderer.displayBuffer(); renderer.displayBuffer();
if (!firstRenderDone) {
firstRenderDone = true;
updateRequired = true;
}
} }

View File

@ -4,8 +4,13 @@
#include <freertos/task.h> #include <freertos/task.h>
#include <functional> #include <functional>
#include <vector>
#include "../Activity.h" #include "../Activity.h"
#include "./MyLibraryActivity.h"
struct RecentBook;
struct Rect;
class HomeActivity final : public Activity { class HomeActivity final : public Activity {
TaskHandle_t displayTaskHandle = nullptr; TaskHandle_t displayTaskHandle = nullptr;
@ -13,16 +18,18 @@ class HomeActivity final : public Activity {
int selectorIndex = 0; int selectorIndex = 0;
bool updateRequired = false; bool updateRequired = false;
bool hasContinueReading = false; bool hasContinueReading = false;
bool recentsLoading = false;
bool recentsLoaded = false;
bool recentsDisplayed = false;
bool firstRenderDone = false;
bool hasOpdsUrl = false; bool hasOpdsUrl = false;
bool hasCoverImage = false;
bool coverRendered = false; // Track if cover has been rendered once bool coverRendered = false; // Track if cover has been rendered once
bool coverBufferStored = false; // Track if cover buffer is stored bool coverBufferStored = false; // Track if cover buffer is stored
uint8_t* coverBuffer = nullptr; // HomeActivity's own buffer for cover image uint8_t* coverBuffer = nullptr; // HomeActivity's own buffer for cover image
std::string lastBookTitle; std::vector<RecentBook> recentBooks;
std::string lastBookAuthor; const std::function<void(const std::string& path)> onSelectBook;
std::string coverBmpPath;
const std::function<void()> onContinueReading;
const std::function<void()> onMyLibraryOpen; const std::function<void()> onMyLibraryOpen;
const std::function<void()> onRecentsOpen;
const std::function<void()> onSettingsOpen; const std::function<void()> onSettingsOpen;
const std::function<void()> onFileTransferOpen; const std::function<void()> onFileTransferOpen;
const std::function<void()> onOpdsBrowserOpen; const std::function<void()> onOpdsBrowserOpen;
@ -34,15 +41,18 @@ class HomeActivity final : public Activity {
bool storeCoverBuffer(); // Store frame buffer for cover image bool storeCoverBuffer(); // Store frame buffer for cover image
bool restoreCoverBuffer(); // Restore frame buffer from stored cover bool restoreCoverBuffer(); // Restore frame buffer from stored cover
void freeCoverBuffer(); // Free the stored cover buffer void freeCoverBuffer(); // Free the stored cover buffer
void loadRecentBooks(int maxBooks, int coverHeight);
public: public:
explicit HomeActivity(GfxRenderer& renderer, MappedInputManager& mappedInput, explicit HomeActivity(GfxRenderer& renderer, MappedInputManager& mappedInput,
const std::function<void()>& onContinueReading, const std::function<void()>& onMyLibraryOpen, const std::function<void(const std::string& path)>& onSelectBook,
const std::function<void()>& onMyLibraryOpen, const std::function<void()>& onRecentsOpen,
const std::function<void()>& onSettingsOpen, const std::function<void()>& onFileTransferOpen, const std::function<void()>& onSettingsOpen, const std::function<void()>& onFileTransferOpen,
const std::function<void()>& onOpdsBrowserOpen) const std::function<void()>& onOpdsBrowserOpen)
: Activity("Home", renderer, mappedInput), : Activity("Home", renderer, mappedInput),
onContinueReading(onContinueReading), onSelectBook(onSelectBook),
onMyLibraryOpen(onMyLibraryOpen), onMyLibraryOpen(onMyLibraryOpen),
onRecentsOpen(onRecentsOpen),
onSettingsOpen(onSettingsOpen), onSettingsOpen(onSettingsOpen),
onFileTransferOpen(onFileTransferOpen), onFileTransferOpen(onFileTransferOpen),
onOpdsBrowserOpen(onOpdsBrowserOpen) {} onOpdsBrowserOpen(onOpdsBrowserOpen) {}

View File

@ -3,26 +3,15 @@
#include <GfxRenderer.h> #include <GfxRenderer.h>
#include <SDCardManager.h> #include <SDCardManager.h>
#include <algorithm>
#include "MappedInputManager.h" #include "MappedInputManager.h"
#include "RecentBooksStore.h" #include "components/UITheme.h"
#include "ScreenComponents.h"
#include "fontIds.h" #include "fontIds.h"
#include "util/StringUtils.h" #include "util/StringUtils.h"
namespace { namespace {
// Layout constants
constexpr int TAB_BAR_Y = 15;
constexpr int CONTENT_START_Y = 60;
constexpr int LINE_HEIGHT = 30;
constexpr int RECENTS_LINE_HEIGHT = 65; // Increased for two-line items
constexpr int LEFT_MARGIN = 20;
constexpr int RIGHT_MARGIN = 40; // Extra space for scroll indicator
// Timing thresholds
constexpr int SKIP_PAGE_MS = 700; constexpr int SKIP_PAGE_MS = 700;
constexpr unsigned long GO_HOME_MS = 1000; constexpr unsigned long GO_HOME_MS = 1000;
} // namespace
void sortFileList(std::vector<std::string>& strs) { void sortFileList(std::vector<std::string>& strs) {
std::sort(begin(strs), end(strs), [](const std::string& str1, const std::string& str2) { std::sort(begin(strs), end(strs), [](const std::string& str1, const std::string& str2) {
@ -33,50 +22,10 @@ void sortFileList(std::vector<std::string>& strs) {
[](const char& char1, const char& char2) { return tolower(char1) < tolower(char2); }); [](const char& char1, const char& char2) { return tolower(char1) < tolower(char2); });
}); });
} }
} // namespace
int MyLibraryActivity::getPageItems() const { void MyLibraryActivity::taskTrampoline(void* param) {
const int screenHeight = renderer.getScreenHeight(); auto* self = static_cast<MyLibraryActivity*>(param);
const int bottomBarHeight = 60; // Space for button hints self->displayTaskLoop();
const int availableHeight = screenHeight - CONTENT_START_Y - bottomBarHeight;
int items = availableHeight / LINE_HEIGHT;
if (items < 1) {
items = 1;
}
return items;
}
int MyLibraryActivity::getCurrentItemCount() const {
if (currentTab == Tab::Recent) {
return static_cast<int>(recentBooks.size());
}
return static_cast<int>(files.size());
}
int MyLibraryActivity::getTotalPages() const {
const int itemCount = getCurrentItemCount();
const int pageItems = getPageItems();
if (itemCount == 0) return 1;
return (itemCount + pageItems - 1) / pageItems;
}
int MyLibraryActivity::getCurrentPage() const {
const int pageItems = getPageItems();
return selectorIndex / pageItems + 1;
}
void MyLibraryActivity::loadRecentBooks() {
recentBooks.clear();
const auto& books = RECENT_BOOKS.getBooks();
recentBooks.reserve(books.size());
for (const auto& book : books) {
// Skip if file no longer exists
if (!SdMan.exists(book.path.c_str())) {
continue;
}
recentBooks.push_back(book);
}
} }
void MyLibraryActivity::loadFiles() { void MyLibraryActivity::loadFiles() {
@ -114,32 +63,18 @@ void MyLibraryActivity::loadFiles() {
sortFileList(files); sortFileList(files);
} }
size_t MyLibraryActivity::findEntry(const std::string& name) const {
for (size_t i = 0; i < files.size(); i++) {
if (files[i] == name) return i;
}
return 0;
}
void MyLibraryActivity::taskTrampoline(void* param) {
auto* self = static_cast<MyLibraryActivity*>(param);
self->displayTaskLoop();
}
void MyLibraryActivity::onEnter() { void MyLibraryActivity::onEnter() {
Activity::onEnter(); Activity::onEnter();
renderingMutex = xSemaphoreCreateMutex(); renderingMutex = xSemaphoreCreateMutex();
// Load data for both tabs
loadRecentBooks();
loadFiles(); loadFiles();
selectorIndex = 0; selectorIndex = 0;
updateRequired = true; updateRequired = true;
xTaskCreate(&MyLibraryActivity::taskTrampoline, "MyLibraryActivityTask", xTaskCreate(&MyLibraryActivity::taskTrampoline, "MyLibraryActivityTask",
4096, // Stack size (increased for epub metadata loading) 4096, // Stack size
this, // Parameters this, // Parameters
1, // Priority 1, // Priority
&displayTaskHandle // Task handle &displayTaskHandle // Task handle
@ -149,8 +84,7 @@ void MyLibraryActivity::onEnter() {
void MyLibraryActivity::onExit() { void MyLibraryActivity::onExit() {
Activity::onExit(); Activity::onExit();
// Wait until not rendering to delete task to avoid killing mid-instruction to // Wait until not rendering to delete task to avoid killing mid-instruction to EPD
// EPD
xSemaphoreTake(renderingMutex, portMAX_DELAY); xSemaphoreTake(renderingMutex, portMAX_DELAY);
if (displayTaskHandle) { if (displayTaskHandle) {
vTaskDelete(displayTaskHandle); vTaskDelete(displayTaskHandle);
@ -163,107 +97,76 @@ void MyLibraryActivity::onExit() {
} }
void MyLibraryActivity::loop() { void MyLibraryActivity::loop() {
const int itemCount = getCurrentItemCount(); // Long press BACK (1s+) goes to root folder
const int pageItems = getPageItems(); if (mappedInput.isPressed(MappedInputManager::Button::Back) && mappedInput.getHeldTime() >= GO_HOME_MS &&
basepath != "/") {
basepath = "/";
loadFiles();
selectorIndex = 0;
updateRequired = true;
return;
}
// Long press BACK (1s+) in Files tab goes to root folder const bool upReleased = mappedInput.wasReleased(MappedInputManager::Button::Left) ||
if (currentTab == Tab::Files && mappedInput.isPressed(MappedInputManager::Button::Back) && mappedInput.wasReleased(MappedInputManager::Button::Up);
mappedInput.getHeldTime() >= GO_HOME_MS) { ;
if (basepath != "/") { const bool downReleased = mappedInput.wasReleased(MappedInputManager::Button::Right) ||
basepath = "/"; mappedInput.wasReleased(MappedInputManager::Button::Down);
const bool skipPage = mappedInput.getHeldTime() > SKIP_PAGE_MS;
const int pageItems = UITheme::getInstance().getNumberOfItemsPerPage(renderer, true, false, true, true);
if (mappedInput.wasReleased(MappedInputManager::Button::Confirm)) {
if (files.empty()) {
return;
}
if (basepath.back() != '/') basepath += "/";
if (files[selectorIndex].back() == '/') {
basepath += files[selectorIndex].substr(0, files[selectorIndex].length() - 1);
loadFiles(); loadFiles();
selectorIndex = 0; selectorIndex = 0;
updateRequired = true; updateRequired = true;
}
return;
}
const bool upReleased = mappedInput.wasReleased(MappedInputManager::Button::Up);
const bool downReleased = mappedInput.wasReleased(MappedInputManager::Button::Down);
const bool leftReleased = mappedInput.wasReleased(MappedInputManager::Button::Left);
const bool rightReleased = mappedInput.wasReleased(MappedInputManager::Button::Right);
const bool skipPage = mappedInput.getHeldTime() > SKIP_PAGE_MS;
// Confirm button - open selected item
if (mappedInput.wasReleased(MappedInputManager::Button::Confirm)) {
if (currentTab == Tab::Recent) {
if (!recentBooks.empty() && selectorIndex < static_cast<int>(recentBooks.size())) {
onSelectBook(recentBooks[selectorIndex].path, currentTab);
}
} else { } else {
// Files tab onSelectBook(basepath + files[selectorIndex]);
if (!files.empty() && selectorIndex < static_cast<int>(files.size())) { return;
if (basepath.back() != '/') basepath += "/";
if (files[selectorIndex].back() == '/') {
// Enter directory
basepath += files[selectorIndex].substr(0, files[selectorIndex].length() - 1);
loadFiles();
selectorIndex = 0;
updateRequired = true;
} else {
// Open file
onSelectBook(basepath + files[selectorIndex], currentTab);
}
}
} }
return;
} }
// Back button
if (mappedInput.wasReleased(MappedInputManager::Button::Back)) { if (mappedInput.wasReleased(MappedInputManager::Button::Back)) {
// Short press: go up one directory, or go home if at root
if (mappedInput.getHeldTime() < GO_HOME_MS) { if (mappedInput.getHeldTime() < GO_HOME_MS) {
if (currentTab == Tab::Files && basepath != "/") { if (basepath != "/") {
// Go up one directory, remembering the directory we came from
const std::string oldPath = basepath; const std::string oldPath = basepath;
basepath.replace(basepath.find_last_of('/'), std::string::npos, ""); basepath.replace(basepath.find_last_of('/'), std::string::npos, "");
if (basepath.empty()) basepath = "/"; if (basepath.empty()) basepath = "/";
loadFiles(); loadFiles();
// Select the directory we just came from
const auto pos = oldPath.find_last_of('/'); const auto pos = oldPath.find_last_of('/');
const std::string dirName = oldPath.substr(pos + 1) + "/"; const std::string dirName = oldPath.substr(pos + 1) + "/";
selectorIndex = static_cast<int>(findEntry(dirName)); selectorIndex = findEntry(dirName);
updateRequired = true; updateRequired = true;
} else { } else {
// Go home
onGoHome(); onGoHome();
} }
} }
return;
} }
// Tab switching: Left/Right always control tabs int listSize = static_cast<int>(files.size());
if (leftReleased && currentTab == Tab::Files) { if (upReleased) {
currentTab = Tab::Recent;
selectorIndex = 0;
updateRequired = true;
return;
}
if (rightReleased && currentTab == Tab::Recent) {
currentTab = Tab::Files;
selectorIndex = 0;
updateRequired = true;
return;
}
// Navigation: Up/Down moves through items only
const bool prevReleased = upReleased;
const bool nextReleased = downReleased;
if (prevReleased && itemCount > 0) {
if (skipPage) { if (skipPage) {
selectorIndex = ((selectorIndex / pageItems - 1) * pageItems + itemCount) % itemCount; selectorIndex = ((selectorIndex / pageItems - 1) * pageItems + listSize) % listSize;
} else { } else {
selectorIndex = (selectorIndex + itemCount - 1) % itemCount; selectorIndex = (selectorIndex + listSize - 1) % listSize;
} }
updateRequired = true; updateRequired = true;
} else if (nextReleased && itemCount > 0) { } else if (downReleased) {
if (skipPage) { if (skipPage) {
selectorIndex = ((selectorIndex / pageItems + 1) * pageItems) % itemCount; selectorIndex = ((selectorIndex / pageItems + 1) * pageItems) % listSize;
} else { } else {
selectorIndex = (selectorIndex + 1) % itemCount; selectorIndex = (selectorIndex + 1) % listSize;
} }
updateRequired = true; updateRequired = true;
} }
@ -284,100 +187,32 @@ void MyLibraryActivity::displayTaskLoop() {
void MyLibraryActivity::render() const { void MyLibraryActivity::render() const {
renderer.clearScreen(); renderer.clearScreen();
// Draw tab bar const auto pageWidth = renderer.getScreenWidth();
std::vector<TabInfo> tabs = {{"Recent", currentTab == Tab::Recent}, {"Files", currentTab == Tab::Files}}; const auto pageHeight = renderer.getScreenHeight();
ScreenComponents::drawTabBar(renderer, TAB_BAR_Y, tabs); auto metrics = UITheme::getInstance().getMetrics();
// Draw content based on current tab auto folderName = basepath == "/" ? "SD card" : basepath.substr(basepath.rfind('/') + 1).c_str();
if (currentTab == Tab::Recent) { GUI.drawHeader(renderer, Rect{0, metrics.topPadding, pageWidth, metrics.headerHeight}, folderName);
renderRecentTab();
const int contentTop = metrics.topPadding + metrics.headerHeight + metrics.verticalSpacing;
const int contentHeight = pageHeight - contentTop - metrics.buttonHintsHeight - metrics.verticalSpacing * 2;
if (files.empty()) {
renderer.drawText(UI_10_FONT_ID, metrics.contentSidePadding, contentTop + 20, "No books found");
} else { } else {
renderFilesTab(); GUI.drawList(
renderer, Rect{0, contentTop, pageWidth, contentHeight}, files.size(), selectorIndex,
[this](int index) { return files[index]; }, false, nullptr, false, nullptr, false, nullptr);
} }
// Draw scroll indicator // Help text
const int screenHeight = renderer.getScreenHeight(); const auto labels = mappedInput.mapLabels("« Home", "Open", "Up", "Down");
const int contentHeight = screenHeight - CONTENT_START_Y - 60; // 60 for bottom bar GUI.drawButtonHints(renderer, labels.btn1, labels.btn2, labels.btn3, labels.btn4);
ScreenComponents::drawScrollIndicator(renderer, getCurrentPage(), getTotalPages(), CONTENT_START_Y, contentHeight);
// Draw side button hints (up/down navigation on right side)
// Note: text is rotated 90° CW, so ">" appears as "^" and "<" appears as "v"
renderer.drawSideButtonHints(UI_10_FONT_ID, ">", "<");
// Draw bottom button hints
const auto labels = mappedInput.mapLabels("« Back", "Open", "<", ">");
renderer.drawButtonHints(UI_10_FONT_ID, labels.btn1, labels.btn2, labels.btn3, labels.btn4);
renderer.displayBuffer(); renderer.displayBuffer();
} }
void MyLibraryActivity::renderRecentTab() const { size_t MyLibraryActivity::findEntry(const std::string& name) const {
const auto pageWidth = renderer.getScreenWidth(); for (size_t i = 0; i < files.size(); i++)
const int pageItems = getPageItems(); if (files[i] == name) return i;
const int bookCount = static_cast<int>(recentBooks.size()); return 0;
if (bookCount == 0) {
renderer.drawText(UI_10_FONT_ID, LEFT_MARGIN, CONTENT_START_Y, "No recent books");
return;
}
const auto pageStartIndex = selectorIndex / pageItems * pageItems;
// Draw selection highlight
renderer.fillRect(0, CONTENT_START_Y + (selectorIndex % pageItems) * RECENTS_LINE_HEIGHT - 2,
pageWidth - RIGHT_MARGIN, RECENTS_LINE_HEIGHT);
// Draw items
for (int i = pageStartIndex; i < bookCount && i < pageStartIndex + pageItems; i++) {
const auto& book = recentBooks[i];
const int y = CONTENT_START_Y + (i % pageItems) * RECENTS_LINE_HEIGHT;
// Line 1: Title
std::string title = book.title;
if (title.empty()) {
// Fallback for older entries or files without metadata
title = book.path;
const size_t lastSlash = title.find_last_of('/');
if (lastSlash != std::string::npos) {
title = title.substr(lastSlash + 1);
}
const size_t dot = title.find_last_of('.');
if (dot != std::string::npos) {
title.resize(dot);
}
}
auto truncatedTitle = renderer.truncatedText(UI_12_FONT_ID, title.c_str(), pageWidth - LEFT_MARGIN - RIGHT_MARGIN);
renderer.drawText(UI_12_FONT_ID, LEFT_MARGIN, y + 2, truncatedTitle.c_str(), i != selectorIndex);
// Line 2: Author
if (!book.author.empty()) {
auto truncatedAuthor =
renderer.truncatedText(UI_10_FONT_ID, book.author.c_str(), pageWidth - LEFT_MARGIN - RIGHT_MARGIN);
renderer.drawText(UI_10_FONT_ID, LEFT_MARGIN, y + 32, truncatedAuthor.c_str(), i != selectorIndex);
}
}
}
void MyLibraryActivity::renderFilesTab() const {
const auto pageWidth = renderer.getScreenWidth();
const int pageItems = getPageItems();
const int fileCount = static_cast<int>(files.size());
if (fileCount == 0) {
renderer.drawText(UI_10_FONT_ID, LEFT_MARGIN, CONTENT_START_Y, "No books found");
return;
}
const auto pageStartIndex = selectorIndex / pageItems * pageItems;
// Draw selection highlight
renderer.fillRect(0, CONTENT_START_Y + (selectorIndex % pageItems) * LINE_HEIGHT - 2, pageWidth - RIGHT_MARGIN,
LINE_HEIGHT);
// Draw items
for (int i = pageStartIndex; i < fileCount && i < pageStartIndex + pageItems; i++) {
auto item = renderer.truncatedText(UI_10_FONT_ID, files[i].c_str(), pageWidth - LEFT_MARGIN - RIGHT_MARGIN);
renderer.drawText(UI_10_FONT_ID, LEFT_MARGIN, CONTENT_START_Y + (i % pageItems) * LINE_HEIGHT, item.c_str(),
i != selectorIndex);
}
} }

View File

@ -8,59 +8,40 @@
#include <vector> #include <vector>
#include "../Activity.h" #include "../Activity.h"
#include "RecentBooksStore.h"
class MyLibraryActivity final : public Activity { class MyLibraryActivity final : public Activity {
public:
enum class Tab { Recent, Files };
private: private:
TaskHandle_t displayTaskHandle = nullptr; TaskHandle_t displayTaskHandle = nullptr;
SemaphoreHandle_t renderingMutex = nullptr; SemaphoreHandle_t renderingMutex = nullptr;
Tab currentTab = Tab::Recent; size_t selectorIndex = 0;
int selectorIndex = 0;
bool updateRequired = false; bool updateRequired = false;
// Recent tab state // Files state
std::vector<RecentBook> recentBooks;
// Files tab state (from FileSelectionActivity)
std::string basepath = "/"; std::string basepath = "/";
std::vector<std::string> files; std::vector<std::string> files;
// Callbacks // Callbacks
const std::function<void(const std::string& path)> onSelectBook;
const std::function<void()> onGoHome; const std::function<void()> onGoHome;
const std::function<void(const std::string& path, Tab fromTab)> onSelectBook;
// Number of items that fit on a page
int getPageItems() const;
int getCurrentItemCount() const;
int getTotalPages() const;
int getCurrentPage() const;
// Data loading
void loadRecentBooks();
void loadFiles();
size_t findEntry(const std::string& name) const;
// Rendering
static void taskTrampoline(void* param); static void taskTrampoline(void* param);
[[noreturn]] void displayTaskLoop(); [[noreturn]] void displayTaskLoop();
void render() const; void render() const;
void renderRecentTab() const;
void renderFilesTab() const; // Data loading
void loadFiles();
size_t findEntry(const std::string& name) const;
public: public:
explicit MyLibraryActivity(GfxRenderer& renderer, MappedInputManager& mappedInput, explicit MyLibraryActivity(GfxRenderer& renderer, MappedInputManager& mappedInput,
const std::function<void()>& onGoHome, const std::function<void()>& onGoHome,
const std::function<void(const std::string& path, Tab fromTab)>& onSelectBook, const std::function<void(const std::string& path)>& onSelectBook,
Tab initialTab = Tab::Recent, std::string initialPath = "/") std::string initialPath = "/")
: Activity("MyLibrary", renderer, mappedInput), : Activity("MyLibrary", renderer, mappedInput),
currentTab(initialTab),
basepath(initialPath.empty() ? "/" : std::move(initialPath)), basepath(initialPath.empty() ? "/" : std::move(initialPath)),
onGoHome(onGoHome), onSelectBook(onSelectBook),
onSelectBook(onSelectBook) {} onGoHome(onGoHome) {}
void onEnter() override; void onEnter() override;
void onExit() override; void onExit() override;
void loop() override; void loop() override;

View File

@ -0,0 +1,149 @@
#include "RecentBooksActivity.h"
#include <GfxRenderer.h>
#include <SDCardManager.h>
#include "MappedInputManager.h"
#include "RecentBooksStore.h"
#include "components/UITheme.h"
#include "fontIds.h"
#include "util/StringUtils.h"
namespace {
constexpr int SKIP_PAGE_MS = 700;
constexpr unsigned long GO_HOME_MS = 1000;
} // namespace
void RecentBooksActivity::taskTrampoline(void* param) {
auto* self = static_cast<RecentBooksActivity*>(param);
self->displayTaskLoop();
}
void RecentBooksActivity::loadRecentBooks() {
recentBooks.clear();
const auto& books = RECENT_BOOKS.getBooks();
recentBooks.reserve(books.size());
for (const auto& book : books) {
// Skip if file no longer exists
if (!SdMan.exists(book.path.c_str())) {
continue;
}
recentBooks.push_back(book);
}
}
void RecentBooksActivity::onEnter() {
Activity::onEnter();
renderingMutex = xSemaphoreCreateMutex();
// Load data
loadRecentBooks();
selectorIndex = 0;
updateRequired = true;
xTaskCreate(&RecentBooksActivity::taskTrampoline, "RecentBooksActivityTask",
4096, // Stack size
this, // Parameters
1, // Priority
&displayTaskHandle // Task handle
);
}
void RecentBooksActivity::onExit() {
Activity::onExit();
// Wait until not rendering to delete task to avoid killing mid-instruction to EPD
xSemaphoreTake(renderingMutex, portMAX_DELAY);
if (displayTaskHandle) {
vTaskDelete(displayTaskHandle);
displayTaskHandle = nullptr;
}
vSemaphoreDelete(renderingMutex);
renderingMutex = nullptr;
recentBooks.clear();
}
void RecentBooksActivity::loop() {
const bool upReleased = mappedInput.wasReleased(MappedInputManager::Button::Left) ||
mappedInput.wasReleased(MappedInputManager::Button::Up);
;
const bool downReleased = mappedInput.wasReleased(MappedInputManager::Button::Right) ||
mappedInput.wasReleased(MappedInputManager::Button::Down);
const bool skipPage = mappedInput.getHeldTime() > SKIP_PAGE_MS;
const int pageItems = UITheme::getInstance().getNumberOfItemsPerPage(renderer, true, false, true, true);
if (mappedInput.wasReleased(MappedInputManager::Button::Confirm)) {
if (!recentBooks.empty() && selectorIndex < static_cast<int>(recentBooks.size())) {
Serial.printf("Selected recent book: %s\n", recentBooks[selectorIndex].path.c_str());
onSelectBook(recentBooks[selectorIndex].path);
return;
}
}
if (mappedInput.wasReleased(MappedInputManager::Button::Back)) {
onGoHome();
}
int listSize = static_cast<int>(recentBooks.size());
if (upReleased) {
if (skipPage) {
selectorIndex = ((selectorIndex / pageItems - 1) * pageItems + listSize) % listSize;
} else {
selectorIndex = (selectorIndex + listSize - 1) % listSize;
}
updateRequired = true;
} else if (downReleased) {
if (skipPage) {
selectorIndex = ((selectorIndex / pageItems + 1) * pageItems) % listSize;
} else {
selectorIndex = (selectorIndex + 1) % listSize;
}
updateRequired = true;
}
}
void RecentBooksActivity::displayTaskLoop() {
while (true) {
if (updateRequired) {
updateRequired = false;
xSemaphoreTake(renderingMutex, portMAX_DELAY);
render();
xSemaphoreGive(renderingMutex);
}
vTaskDelay(10 / portTICK_PERIOD_MS);
}
}
void RecentBooksActivity::render() const {
renderer.clearScreen();
const auto pageWidth = renderer.getScreenWidth();
const auto pageHeight = renderer.getScreenHeight();
auto metrics = UITheme::getInstance().getMetrics();
GUI.drawHeader(renderer, Rect{0, metrics.topPadding, pageWidth, metrics.headerHeight}, "Recent Books");
const int contentTop = metrics.topPadding + metrics.headerHeight + metrics.verticalSpacing;
const int contentHeight = pageHeight - contentTop - metrics.buttonHintsHeight - metrics.verticalSpacing * 2;
// Recent tab
if (recentBooks.empty()) {
renderer.drawText(UI_10_FONT_ID, metrics.contentSidePadding, contentTop + 20, "No recent books");
} else {
GUI.drawList(
renderer, Rect{0, contentTop, pageWidth, contentHeight}, recentBooks.size(), selectorIndex,
[this](int index) { return recentBooks[index].title; }, true,
[this](int index) { return recentBooks[index].author; }, false, nullptr, false, nullptr);
}
// Help text
const auto labels = mappedInput.mapLabels("« Home", "Open", "Up", "Down");
GUI.drawButtonHints(renderer, labels.btn1, labels.btn2, labels.btn3, labels.btn4);
renderer.displayBuffer();
}

View File

@ -0,0 +1,43 @@
#pragma once
#include <freertos/FreeRTOS.h>
#include <freertos/semphr.h>
#include <freertos/task.h>
#include <functional>
#include <string>
#include <vector>
#include "../Activity.h"
#include "RecentBooksStore.h"
class RecentBooksActivity final : public Activity {
private:
TaskHandle_t displayTaskHandle = nullptr;
SemaphoreHandle_t renderingMutex = nullptr;
size_t selectorIndex = 0;
bool updateRequired = false;
// Recent tab state
std::vector<RecentBook> recentBooks;
// Callbacks
const std::function<void(const std::string& path)> onSelectBook;
const std::function<void()> onGoHome;
static void taskTrampoline(void* param);
[[noreturn]] void displayTaskLoop();
void render() const;
// Data loading
void loadRecentBooks();
public:
explicit RecentBooksActivity(GfxRenderer& renderer, MappedInputManager& mappedInput,
const std::function<void()>& onGoHome,
const std::function<void(const std::string& path)>& onSelectBook)
: Activity("RecentBooks", renderer, mappedInput), onSelectBook(onSelectBook), onGoHome(onGoHome) {}
void onEnter() override;
void onExit() override;
void loop() override;
};

View File

@ -6,8 +6,8 @@
#include <esp_task_wdt.h> #include <esp_task_wdt.h>
#include "MappedInputManager.h" #include "MappedInputManager.h"
#include "ScreenComponents.h"
#include "WifiSelectionActivity.h" #include "WifiSelectionActivity.h"
#include "components/UITheme.h"
#include "fontIds.h" #include "fontIds.h"
namespace { namespace {
@ -258,8 +258,7 @@ void CalibreConnectActivity::renderServerRunning() const {
constexpr int barWidth = 300; constexpr int barWidth = 300;
constexpr int barHeight = 16; constexpr int barHeight = 16;
constexpr int barX = (480 - barWidth) / 2; constexpr int barX = (480 - barWidth) / 2;
ScreenComponents::drawProgressBar(renderer, barX, y + 22, barWidth, barHeight, lastProgressReceived, GUI.drawProgressBar(renderer, Rect{barX, y + 22, barWidth, barHeight}, lastProgressReceived, lastProgressTotal);
lastProgressTotal);
y += 40; y += 40;
} }
@ -272,5 +271,5 @@ void CalibreConnectActivity::renderServerRunning() const {
} }
const auto labels = mappedInput.mapLabels("« Exit", "", "", ""); const auto labels = mappedInput.mapLabels("« Exit", "", "", "");
renderer.drawButtonHints(UI_10_FONT_ID, labels.btn1, labels.btn2, labels.btn3, labels.btn4); GUI.drawButtonHints(renderer, labels.btn1, labels.btn2, labels.btn3, labels.btn4);
} }

View File

@ -13,6 +13,7 @@
#include "NetworkModeSelectionActivity.h" #include "NetworkModeSelectionActivity.h"
#include "WifiSelectionActivity.h" #include "WifiSelectionActivity.h"
#include "activities/network/CalibreConnectActivity.h" #include "activities/network/CalibreConnectActivity.h"
#include "components/UITheme.h"
#include "fontIds.h" #include "fontIds.h"
namespace { namespace {
@ -479,5 +480,5 @@ void CrossPointWebServerActivity::renderServerRunning() const {
} }
const auto labels = mappedInput.mapLabels("« Exit", "", "", ""); const auto labels = mappedInput.mapLabels("« Exit", "", "", "");
renderer.drawButtonHints(UI_10_FONT_ID, labels.btn1, labels.btn2, labels.btn3, labels.btn4); GUI.drawButtonHints(renderer, labels.btn1, labels.btn2, labels.btn3, labels.btn4);
} }

View File

@ -3,6 +3,7 @@
#include <GfxRenderer.h> #include <GfxRenderer.h>
#include "MappedInputManager.h" #include "MappedInputManager.h"
#include "components/UITheme.h"
#include "fontIds.h" #include "fontIds.h"
namespace { namespace {
@ -131,7 +132,7 @@ void NetworkModeSelectionActivity::render() const {
// Draw help text at bottom // Draw help text at bottom
const auto labels = mappedInput.mapLabels("« Back", "Select", "", ""); const auto labels = mappedInput.mapLabels("« Back", "Select", "", "");
renderer.drawButtonHints(UI_10_FONT_ID, labels.btn1, labels.btn2, labels.btn3, labels.btn4); GUI.drawButtonHints(renderer, labels.btn1, labels.btn2, labels.btn3, labels.btn4);
renderer.displayBuffer(); renderer.displayBuffer();
} }

View File

@ -8,6 +8,7 @@
#include "MappedInputManager.h" #include "MappedInputManager.h"
#include "WifiCredentialStore.h" #include "WifiCredentialStore.h"
#include "activities/util/KeyboardEntryActivity.h" #include "activities/util/KeyboardEntryActivity.h"
#include "components/UITheme.h"
#include "fontIds.h" #include "fontIds.h"
void WifiSelectionActivity::taskTrampoline(void* param) { void WifiSelectionActivity::taskTrampoline(void* param) {
@ -586,7 +587,7 @@ void WifiSelectionActivity::renderNetworkList() const {
// Draw help text // Draw help text
renderer.drawText(SMALL_FONT_ID, 20, pageHeight - 75, "* = Encrypted | + = Saved"); renderer.drawText(SMALL_FONT_ID, 20, pageHeight - 75, "* = Encrypted | + = Saved");
const auto labels = mappedInput.mapLabels("« Back", "Connect", "", ""); const auto labels = mappedInput.mapLabels("« Back", "Connect", "", "");
renderer.drawButtonHints(UI_10_FONT_ID, labels.btn1, labels.btn2, labels.btn3, labels.btn4); GUI.drawButtonHints(renderer, labels.btn1, labels.btn2, labels.btn3, labels.btn4);
} }
void WifiSelectionActivity::renderConnecting() const { void WifiSelectionActivity::renderConnecting() const {
@ -625,7 +626,7 @@ void WifiSelectionActivity::renderConnected() const {
// Use centralized button hints // Use centralized button hints
const auto labels = mappedInput.mapLabels("", "Continue", "", ""); const auto labels = mappedInput.mapLabels("", "Continue", "", "");
renderer.drawButtonHints(UI_10_FONT_ID, labels.btn1, labels.btn2, labels.btn3, labels.btn4); GUI.drawButtonHints(renderer, labels.btn1, labels.btn2, labels.btn3, labels.btn4);
} }
void WifiSelectionActivity::renderSavePrompt() const { void WifiSelectionActivity::renderSavePrompt() const {
@ -667,7 +668,7 @@ void WifiSelectionActivity::renderSavePrompt() const {
// Use centralized button hints // Use centralized button hints
const auto labels = mappedInput.mapLabels("« Skip", "Select", "Left", "Right"); const auto labels = mappedInput.mapLabels("« Skip", "Select", "Left", "Right");
renderer.drawButtonHints(UI_10_FONT_ID, labels.btn1, labels.btn2, labels.btn3, labels.btn4); GUI.drawButtonHints(renderer, labels.btn1, labels.btn2, labels.btn3, labels.btn4);
} }
void WifiSelectionActivity::renderConnectionFailed() const { void WifiSelectionActivity::renderConnectionFailed() const {
@ -680,7 +681,7 @@ void WifiSelectionActivity::renderConnectionFailed() const {
// Use centralized button hints // Use centralized button hints
const auto labels = mappedInput.mapLabels("« Back", "Continue", "", ""); const auto labels = mappedInput.mapLabels("« Back", "Continue", "", "");
renderer.drawButtonHints(UI_10_FONT_ID, labels.btn1, labels.btn2, labels.btn3, labels.btn4); GUI.drawButtonHints(renderer, labels.btn1, labels.btn2, labels.btn3, labels.btn4);
} }
void WifiSelectionActivity::renderForgetPrompt() const { void WifiSelectionActivity::renderForgetPrompt() const {
@ -722,5 +723,5 @@ void WifiSelectionActivity::renderForgetPrompt() const {
// Use centralized button hints // Use centralized button hints
const auto labels = mappedInput.mapLabels("« Back", "Select", "Left", "Right"); const auto labels = mappedInput.mapLabels("« Back", "Select", "Left", "Right");
renderer.drawButtonHints(UI_10_FONT_ID, labels.btn1, labels.btn2, labels.btn3, labels.btn4); GUI.drawButtonHints(renderer, labels.btn1, labels.btn2, labels.btn3, labels.btn4);
} }

View File

@ -10,7 +10,7 @@
#include "EpubReaderChapterSelectionActivity.h" #include "EpubReaderChapterSelectionActivity.h"
#include "MappedInputManager.h" #include "MappedInputManager.h"
#include "RecentBooksStore.h" #include "RecentBooksStore.h"
#include "ScreenComponents.h" #include "components/UITheme.h"
#include "fontIds.h" #include "fontIds.h"
namespace { namespace {
@ -85,7 +85,7 @@ void EpubReaderActivity::onEnter() {
// Save current epub as last opened epub and add to recent books // Save current epub as last opened epub and add to recent books
APP_STATE.openEpubPath = epub->getPath(); APP_STATE.openEpubPath = epub->getPath();
APP_STATE.saveToFile(); APP_STATE.saveToFile();
RECENT_BOOKS.addBook(epub->getPath(), epub->getTitle(), epub->getAuthor()); RECENT_BOOKS.addBook(epub->getPath(), epub->getTitle(), epub->getAuthor(), epub->getThumbBmpPath());
// Trigger first update // Trigger first update
updateRequired = true; updateRequired = true;
@ -347,13 +347,15 @@ void EpubReaderActivity::renderScreen() {
orientedMarginRight += SETTINGS.screenMargin; orientedMarginRight += SETTINGS.screenMargin;
orientedMarginBottom += SETTINGS.screenMargin; orientedMarginBottom += SETTINGS.screenMargin;
auto metrics = UITheme::getInstance().getMetrics();
// Add status bar margin // Add status bar margin
if (SETTINGS.statusBar != CrossPointSettings::STATUS_BAR_MODE::NONE) { if (SETTINGS.statusBar != CrossPointSettings::STATUS_BAR_MODE::NONE) {
// Add additional margin for status bar if progress bar is shown // Add additional margin for status bar if progress bar is shown
const bool showProgressBar = SETTINGS.statusBar == CrossPointSettings::STATUS_BAR_MODE::FULL_WITH_PROGRESS_BAR || const bool showProgressBar = SETTINGS.statusBar == CrossPointSettings::STATUS_BAR_MODE::FULL_WITH_PROGRESS_BAR ||
SETTINGS.statusBar == CrossPointSettings::STATUS_BAR_MODE::ONLY_PROGRESS_BAR; SETTINGS.statusBar == CrossPointSettings::STATUS_BAR_MODE::ONLY_PROGRESS_BAR;
orientedMarginBottom += statusBarMargin - SETTINGS.screenMargin + orientedMarginBottom += statusBarMargin - SETTINGS.screenMargin +
(showProgressBar ? (ScreenComponents::BOOK_PROGRESS_BAR_HEIGHT + progressBarMarginTop) : 0); (showProgressBar ? (metrics.bookProgressBarHeight + progressBarMarginTop) : 0);
} }
if (!section) { if (!section) {
@ -369,7 +371,7 @@ void EpubReaderActivity::renderScreen() {
viewportHeight, SETTINGS.hyphenationEnabled)) { viewportHeight, SETTINGS.hyphenationEnabled)) {
Serial.printf("[%lu] [ERS] Cache not found, building...\n", millis()); Serial.printf("[%lu] [ERS] Cache not found, building...\n", millis());
const auto popupFn = [this]() { ScreenComponents::drawPopup(renderer, "Indexing..."); }; const auto popupFn = [this]() { GUI.drawPopup(renderer, "Indexing..."); };
if (!section->createSectionFile(SETTINGS.getReaderFontId(), SETTINGS.getReaderLineCompression(), if (!section->createSectionFile(SETTINGS.getReaderFontId(), SETTINGS.getReaderLineCompression(),
SETTINGS.extraParagraphSpacing, SETTINGS.paragraphAlignment, viewportWidth, SETTINGS.extraParagraphSpacing, SETTINGS.paragraphAlignment, viewportWidth,
@ -491,6 +493,8 @@ void EpubReaderActivity::renderContents(std::unique_ptr<Page> page, const int or
void EpubReaderActivity::renderStatusBar(const int orientedMarginRight, const int orientedMarginBottom, void EpubReaderActivity::renderStatusBar(const int orientedMarginRight, const int orientedMarginBottom,
const int orientedMarginLeft) const { const int orientedMarginLeft) const {
auto metrics = UITheme::getInstance().getMetrics();
// determine visible status bar elements // determine visible status bar elements
const bool showProgressPercentage = SETTINGS.statusBar == CrossPointSettings::STATUS_BAR_MODE::FULL; const bool showProgressPercentage = SETTINGS.statusBar == CrossPointSettings::STATUS_BAR_MODE::FULL;
const bool showProgressBar = SETTINGS.statusBar == CrossPointSettings::STATUS_BAR_MODE::FULL_WITH_PROGRESS_BAR || const bool showProgressBar = SETTINGS.statusBar == CrossPointSettings::STATUS_BAR_MODE::FULL_WITH_PROGRESS_BAR ||
@ -534,11 +538,12 @@ void EpubReaderActivity::renderStatusBar(const int orientedMarginRight, const in
if (showProgressBar) { if (showProgressBar) {
// Draw progress bar at the very bottom of the screen, from edge to edge of viewable area // Draw progress bar at the very bottom of the screen, from edge to edge of viewable area
ScreenComponents::drawBookProgressBar(renderer, static_cast<size_t>(bookProgress)); GUI.drawBookProgressBar(renderer, static_cast<size_t>(bookProgress));
} }
if (showBattery) { if (showBattery) {
ScreenComponents::drawBattery(renderer, orientedMarginLeft + 1, textY, showBatteryPercentage); GUI.drawBattery(renderer, Rect{orientedMarginLeft + 1, textY, metrics.batteryWidth, metrics.batteryHeight},
showBatteryPercentage);
} }
if (showChapterTitle) { if (showChapterTitle) {

View File

@ -5,6 +5,7 @@
#include "KOReaderCredentialStore.h" #include "KOReaderCredentialStore.h"
#include "KOReaderSyncActivity.h" #include "KOReaderSyncActivity.h"
#include "MappedInputManager.h" #include "MappedInputManager.h"
#include "components/UITheme.h"
#include "fontIds.h" #include "fontIds.h"
namespace { namespace {
@ -209,7 +210,7 @@ void EpubReaderChapterSelectionActivity::renderScreen() {
// Skip button hints in landscape CW mode (they overlap content) // Skip button hints in landscape CW mode (they overlap content)
if (renderer.getOrientation() != GfxRenderer::LandscapeClockwise) { if (renderer.getOrientation() != GfxRenderer::LandscapeClockwise) {
const auto labels = mappedInput.mapLabels("« Back", "Select", "Up", "Down"); const auto labels = mappedInput.mapLabels("« Back", "Select", "Up", "Down");
renderer.drawButtonHints(UI_10_FONT_ID, labels.btn1, labels.btn2, labels.btn3, labels.btn4); GUI.drawButtonHints(renderer, labels.btn1, labels.btn2, labels.btn3, labels.btn4);
} }
renderer.displayBuffer(); renderer.displayBuffer();

View File

@ -2,6 +2,7 @@
#include <GfxRenderer.h> #include <GfxRenderer.h>
#include "components/UITheme.h"
#include "fontIds.h" #include "fontIds.h"
void EpubReaderMenuActivity::onEnter() { void EpubReaderMenuActivity::onEnter() {
@ -97,7 +98,7 @@ void EpubReaderMenuActivity::renderScreen() {
// Footer / Hints // Footer / Hints
const auto labels = mappedInput.mapLabels("« Back", "Select", "Up", "Down"); const auto labels = mappedInput.mapLabels("« Back", "Select", "Up", "Down");
renderer.drawButtonHints(UI_10_FONT_ID, labels.btn1, labels.btn2, labels.btn3, labels.btn4); GUI.drawButtonHints(renderer, labels.btn1, labels.btn2, labels.btn3, labels.btn4);
renderer.displayBuffer(); renderer.displayBuffer();
} }

View File

@ -8,6 +8,7 @@
#include "KOReaderDocumentId.h" #include "KOReaderDocumentId.h"
#include "MappedInputManager.h" #include "MappedInputManager.h"
#include "activities/network/WifiSelectionActivity.h" #include "activities/network/WifiSelectionActivity.h"
#include "components/UITheme.h"
#include "fontIds.h" #include "fontIds.h"
namespace { namespace {
@ -266,7 +267,7 @@ void KOReaderSyncActivity::render() {
renderer.drawCenteredText(UI_10_FONT_ID, 320, "Set up KOReader account in Settings"); renderer.drawCenteredText(UI_10_FONT_ID, 320, "Set up KOReader account in Settings");
const auto labels = mappedInput.mapLabels("Back", "", "", ""); const auto labels = mappedInput.mapLabels("Back", "", "", "");
renderer.drawButtonHints(UI_10_FONT_ID, labels.btn1, labels.btn2, labels.btn3, labels.btn4); GUI.drawButtonHints(renderer, labels.btn1, labels.btn2, labels.btn3, labels.btn4);
renderer.displayBuffer(); renderer.displayBuffer();
return; return;
} }
@ -339,7 +340,7 @@ void KOReaderSyncActivity::render() {
renderer.drawText(UI_10_FONT_ID, 20, optionY + optionHeight * 2, "Cancel", selectedOption != 2); renderer.drawText(UI_10_FONT_ID, 20, optionY + optionHeight * 2, "Cancel", selectedOption != 2);
const auto labels = mappedInput.mapLabels("", "Select", "", ""); const auto labels = mappedInput.mapLabels("", "Select", "", "");
renderer.drawButtonHints(UI_10_FONT_ID, labels.btn1, labels.btn2, labels.btn3, labels.btn4); GUI.drawButtonHints(renderer, labels.btn1, labels.btn2, labels.btn3, labels.btn4);
renderer.displayBuffer(); renderer.displayBuffer();
return; return;
} }
@ -349,7 +350,7 @@ void KOReaderSyncActivity::render() {
renderer.drawCenteredText(UI_10_FONT_ID, 320, "Upload current position?"); renderer.drawCenteredText(UI_10_FONT_ID, 320, "Upload current position?");
const auto labels = mappedInput.mapLabels("Cancel", "Upload", "", ""); const auto labels = mappedInput.mapLabels("Cancel", "Upload", "", "");
renderer.drawButtonHints(UI_10_FONT_ID, labels.btn1, labels.btn2, labels.btn3, labels.btn4); GUI.drawButtonHints(renderer, labels.btn1, labels.btn2, labels.btn3, labels.btn4);
renderer.displayBuffer(); renderer.displayBuffer();
return; return;
} }
@ -358,7 +359,7 @@ void KOReaderSyncActivity::render() {
renderer.drawCenteredText(UI_10_FONT_ID, 300, "Progress uploaded!", true, EpdFontFamily::BOLD); renderer.drawCenteredText(UI_10_FONT_ID, 300, "Progress uploaded!", true, EpdFontFamily::BOLD);
const auto labels = mappedInput.mapLabels("Back", "", "", ""); const auto labels = mappedInput.mapLabels("Back", "", "", "");
renderer.drawButtonHints(UI_10_FONT_ID, labels.btn1, labels.btn2, labels.btn3, labels.btn4); GUI.drawButtonHints(renderer, labels.btn1, labels.btn2, labels.btn3, labels.btn4);
renderer.displayBuffer(); renderer.displayBuffer();
return; return;
} }
@ -368,7 +369,7 @@ void KOReaderSyncActivity::render() {
renderer.drawCenteredText(UI_10_FONT_ID, 320, statusMessage.c_str()); renderer.drawCenteredText(UI_10_FONT_ID, 320, statusMessage.c_str());
const auto labels = mappedInput.mapLabels("Back", "", "", ""); const auto labels = mappedInput.mapLabels("Back", "", "", "");
renderer.drawButtonHints(UI_10_FONT_ID, labels.btn1, labels.btn2, labels.btn3, labels.btn4); GUI.drawButtonHints(renderer, labels.btn1, labels.btn2, labels.btn3, labels.btn4);
renderer.displayBuffer(); renderer.displayBuffer();
return; return;
} }

View File

@ -74,7 +74,7 @@ std::unique_ptr<Txt> ReaderActivity::loadTxt(const std::string& path) {
void ReaderActivity::goToLibrary(const std::string& fromBookPath) { void ReaderActivity::goToLibrary(const std::string& fromBookPath) {
// If coming from a book, start in that book's folder; otherwise start from root // If coming from a book, start in that book's folder; otherwise start from root
const auto initialPath = fromBookPath.empty() ? "/" : extractFolderPath(fromBookPath); const auto initialPath = fromBookPath.empty() ? "/" : extractFolderPath(fromBookPath);
onGoToLibrary(initialPath, libraryTab); onGoToLibrary(initialPath);
} }
void ReaderActivity::onGoToEpubReader(std::unique_ptr<Epub> epub) { void ReaderActivity::onGoToEpubReader(std::unique_ptr<Epub> epub) {

View File

@ -10,10 +10,9 @@ class Txt;
class ReaderActivity final : public ActivityWithSubactivity { class ReaderActivity final : public ActivityWithSubactivity {
std::string initialBookPath; std::string initialBookPath;
std::string currentBookPath; // Track current book path for navigation std::string currentBookPath; // Track current book path for navigation
MyLibraryActivity::Tab libraryTab; // Track which tab to return to
const std::function<void()> onGoBack; const std::function<void()> onGoBack;
const std::function<void(const std::string&, MyLibraryActivity::Tab)> onGoToLibrary; const std::function<void(const std::string&)> onGoToLibrary;
static std::unique_ptr<Epub> loadEpub(const std::string& path); static std::unique_ptr<Epub> loadEpub(const std::string& path);
static std::unique_ptr<Xtc> loadXtc(const std::string& path); static std::unique_ptr<Xtc> loadXtc(const std::string& path);
static std::unique_ptr<Txt> loadTxt(const std::string& path); static std::unique_ptr<Txt> loadTxt(const std::string& path);
@ -28,11 +27,10 @@ class ReaderActivity final : public ActivityWithSubactivity {
public: public:
explicit ReaderActivity(GfxRenderer& renderer, MappedInputManager& mappedInput, std::string initialBookPath, explicit ReaderActivity(GfxRenderer& renderer, MappedInputManager& mappedInput, std::string initialBookPath,
MyLibraryActivity::Tab libraryTab, const std::function<void()>& onGoBack, const std::function<void()>& onGoBack,
const std::function<void(const std::string&, MyLibraryActivity::Tab)>& onGoToLibrary) const std::function<void(const std::string&)>& onGoToLibrary)
: ActivityWithSubactivity("Reader", renderer, mappedInput), : ActivityWithSubactivity("Reader", renderer, mappedInput),
initialBookPath(std::move(initialBookPath)), initialBookPath(std::move(initialBookPath)),
libraryTab(libraryTab),
onGoBack(onGoBack), onGoBack(onGoBack),
onGoToLibrary(onGoToLibrary) {} onGoToLibrary(onGoToLibrary) {}
void onEnter() override; void onEnter() override;

View File

@ -9,7 +9,7 @@
#include "CrossPointState.h" #include "CrossPointState.h"
#include "MappedInputManager.h" #include "MappedInputManager.h"
#include "RecentBooksStore.h" #include "RecentBooksStore.h"
#include "ScreenComponents.h" #include "components/UITheme.h"
#include "fontIds.h" #include "fontIds.h"
namespace { namespace {
@ -60,7 +60,7 @@ void TxtReaderActivity::onEnter() {
// Save current txt as last opened file and add to recent books // Save current txt as last opened file and add to recent books
APP_STATE.openEpubPath = txt->getPath(); APP_STATE.openEpubPath = txt->getPath();
APP_STATE.saveToFile(); APP_STATE.saveToFile();
RECENT_BOOKS.addBook(txt->getPath(), "", ""); RECENT_BOOKS.addBook(txt->getPath(), "", "", "");
// Trigger first update // Trigger first update
updateRequired = true; updateRequired = true;
@ -168,13 +168,15 @@ void TxtReaderActivity::initializeReader() {
orientedMarginRight += cachedScreenMargin; orientedMarginRight += cachedScreenMargin;
orientedMarginBottom += cachedScreenMargin; orientedMarginBottom += cachedScreenMargin;
auto metrics = UITheme::getInstance().getMetrics();
// Add status bar margin // Add status bar margin
if (SETTINGS.statusBar != CrossPointSettings::STATUS_BAR_MODE::NONE) { if (SETTINGS.statusBar != CrossPointSettings::STATUS_BAR_MODE::NONE) {
// Add additional margin for status bar if progress bar is shown // Add additional margin for status bar if progress bar is shown
const bool showProgressBar = SETTINGS.statusBar == CrossPointSettings::STATUS_BAR_MODE::FULL_WITH_PROGRESS_BAR || const bool showProgressBar = SETTINGS.statusBar == CrossPointSettings::STATUS_BAR_MODE::FULL_WITH_PROGRESS_BAR ||
SETTINGS.statusBar == CrossPointSettings::STATUS_BAR_MODE::ONLY_PROGRESS_BAR; SETTINGS.statusBar == CrossPointSettings::STATUS_BAR_MODE::ONLY_PROGRESS_BAR;
orientedMarginBottom += statusBarMargin - cachedScreenMargin + orientedMarginBottom += statusBarMargin - cachedScreenMargin +
(showProgressBar ? (ScreenComponents::BOOK_PROGRESS_BAR_HEIGHT + progressBarMarginTop) : 0); (showProgressBar ? (metrics.bookProgressBarHeight + progressBarMarginTop) : 0);
} }
viewportWidth = renderer.getScreenWidth() - orientedMarginLeft - orientedMarginRight; viewportWidth = renderer.getScreenWidth() - orientedMarginLeft - orientedMarginRight;
@ -210,7 +212,7 @@ void TxtReaderActivity::buildPageIndex() {
Serial.printf("[%lu] [TRS] Building page index for %zu bytes...\n", millis(), fileSize); Serial.printf("[%lu] [TRS] Building page index for %zu bytes...\n", millis(), fileSize);
ScreenComponents::drawPopup(renderer, "Indexing..."); GUI.drawPopup(renderer, "Indexing...");
while (offset < fileSize) { while (offset < fileSize) {
std::vector<std::string> tempLines; std::vector<std::string> tempLines;
@ -498,6 +500,7 @@ void TxtReaderActivity::renderStatusBar(const int orientedMarginRight, const int
const bool showBatteryPercentage = const bool showBatteryPercentage =
SETTINGS.hideBatteryPercentage == CrossPointSettings::HIDE_BATTERY_PERCENTAGE::HIDE_NEVER; SETTINGS.hideBatteryPercentage == CrossPointSettings::HIDE_BATTERY_PERCENTAGE::HIDE_NEVER;
auto metrics = UITheme::getInstance().getMetrics();
const auto screenHeight = renderer.getScreenHeight(); const auto screenHeight = renderer.getScreenHeight();
const auto textY = screenHeight - orientedMarginBottom - 4; const auto textY = screenHeight - orientedMarginBottom - 4;
int progressTextWidth = 0; int progressTextWidth = 0;
@ -519,11 +522,12 @@ void TxtReaderActivity::renderStatusBar(const int orientedMarginRight, const int
if (showProgressBar) { if (showProgressBar) {
// Draw progress bar at the very bottom of the screen, from edge to edge of viewable area // Draw progress bar at the very bottom of the screen, from edge to edge of viewable area
ScreenComponents::drawBookProgressBar(renderer, static_cast<size_t>(progress)); GUI.drawBookProgressBar(renderer, static_cast<size_t>(progress));
} }
if (showBattery) { if (showBattery) {
ScreenComponents::drawBattery(renderer, orientedMarginLeft, textY, showBatteryPercentage); GUI.drawBattery(renderer, Rect{orientedMarginLeft, textY, metrics.batteryWidth, metrics.batteryHeight},
showBatteryPercentage);
} }
if (showTitle) { if (showTitle) {

View File

@ -16,6 +16,7 @@
#include "MappedInputManager.h" #include "MappedInputManager.h"
#include "RecentBooksStore.h" #include "RecentBooksStore.h"
#include "XtcReaderChapterSelectionActivity.h" #include "XtcReaderChapterSelectionActivity.h"
#include "components/UITheme.h"
#include "fontIds.h" #include "fontIds.h"
namespace { namespace {
@ -45,7 +46,7 @@ void XtcReaderActivity::onEnter() {
// Save current XTC as last opened book and add to recent books // Save current XTC as last opened book and add to recent books
APP_STATE.openEpubPath = xtc->getPath(); APP_STATE.openEpubPath = xtc->getPath();
APP_STATE.saveToFile(); APP_STATE.saveToFile();
RECENT_BOOKS.addBook(xtc->getPath(), xtc->getTitle(), xtc->getAuthor()); RECENT_BOOKS.addBook(xtc->getPath(), xtc->getTitle(), xtc->getAuthor(), xtc->getThumbBmpPath());
// Trigger first update // Trigger first update
updateRequired = true; updateRequired = true;

View File

@ -3,6 +3,7 @@
#include <GfxRenderer.h> #include <GfxRenderer.h>
#include "MappedInputManager.h" #include "MappedInputManager.h"
#include "components/UITheme.h"
#include "fontIds.h" #include "fontIds.h"
namespace { namespace {
@ -152,7 +153,7 @@ void XtcReaderChapterSelectionActivity::renderScreen() {
// Skip button hints in landscape CW mode (they overlap content) // Skip button hints in landscape CW mode (they overlap content)
if (renderer.getOrientation() != GfxRenderer::LandscapeClockwise) { if (renderer.getOrientation() != GfxRenderer::LandscapeClockwise) {
const auto labels = mappedInput.mapLabels("« Back", "Select", "Up", "Down"); const auto labels = mappedInput.mapLabels("« Back", "Select", "Up", "Down");
renderer.drawButtonHints(UI_10_FONT_ID, labels.btn1, labels.btn2, labels.btn3, labels.btn4); GUI.drawButtonHints(renderer, labels.btn1, labels.btn2, labels.btn3, labels.btn4);
} }
renderer.displayBuffer(); renderer.displayBuffer();

View File

@ -7,6 +7,7 @@
#include "CrossPointSettings.h" #include "CrossPointSettings.h"
#include "MappedInputManager.h" #include "MappedInputManager.h"
#include "activities/util/KeyboardEntryActivity.h" #include "activities/util/KeyboardEntryActivity.h"
#include "components/UITheme.h"
#include "fontIds.h" #include "fontIds.h"
namespace { namespace {
@ -183,7 +184,7 @@ void CalibreSettingsActivity::render() {
// Draw button hints // Draw button hints
const auto labels = mappedInput.mapLabels("« Back", "Select", "", ""); const auto labels = mappedInput.mapLabels("« Back", "Select", "", "");
renderer.drawButtonHints(UI_10_FONT_ID, labels.btn1, labels.btn2, labels.btn3, labels.btn4); GUI.drawButtonHints(renderer, labels.btn1, labels.btn2, labels.btn3, labels.btn4);
renderer.displayBuffer(); renderer.displayBuffer();
} }

View File

@ -1,193 +0,0 @@
#include "CategorySettingsActivity.h"
#include <GfxRenderer.h>
#include <HardwareSerial.h>
#include <cstring>
#include "CalibreSettingsActivity.h"
#include "ClearCacheActivity.h"
#include "CrossPointSettings.h"
#include "KOReaderSettingsActivity.h"
#include "MappedInputManager.h"
#include "OtaUpdateActivity.h"
#include "fontIds.h"
void CategorySettingsActivity::taskTrampoline(void* param) {
auto* self = static_cast<CategorySettingsActivity*>(param);
self->displayTaskLoop();
}
void CategorySettingsActivity::onEnter() {
Activity::onEnter();
renderingMutex = xSemaphoreCreateMutex();
selectedSettingIndex = 0;
updateRequired = true;
xTaskCreate(&CategorySettingsActivity::taskTrampoline, "CategorySettingsActivityTask", 4096, this, 1,
&displayTaskHandle);
}
void CategorySettingsActivity::onExit() {
ActivityWithSubactivity::onExit();
// Wait until not rendering to delete task to avoid killing mid-instruction to EPD
xSemaphoreTake(renderingMutex, portMAX_DELAY);
if (displayTaskHandle) {
vTaskDelete(displayTaskHandle);
displayTaskHandle = nullptr;
}
vSemaphoreDelete(renderingMutex);
renderingMutex = nullptr;
}
void CategorySettingsActivity::loop() {
if (subActivity) {
subActivity->loop();
return;
}
// Handle actions with early return
if (mappedInput.wasPressed(MappedInputManager::Button::Confirm)) {
toggleCurrentSetting();
updateRequired = true;
return;
}
if (mappedInput.wasPressed(MappedInputManager::Button::Back)) {
SETTINGS.saveToFile();
onGoBack();
return;
}
// Handle navigation
if (mappedInput.wasPressed(MappedInputManager::Button::Up) ||
mappedInput.wasPressed(MappedInputManager::Button::Left)) {
selectedSettingIndex = (selectedSettingIndex > 0) ? (selectedSettingIndex - 1) : (settingsCount - 1);
updateRequired = true;
} else if (mappedInput.wasPressed(MappedInputManager::Button::Down) ||
mappedInput.wasPressed(MappedInputManager::Button::Right)) {
selectedSettingIndex = (selectedSettingIndex < settingsCount - 1) ? (selectedSettingIndex + 1) : 0;
updateRequired = true;
}
}
void CategorySettingsActivity::toggleCurrentSetting() {
if (selectedSettingIndex < 0 || selectedSettingIndex >= settingsCount) {
return;
}
const auto& setting = settingsList[selectedSettingIndex];
if (setting.type == SettingType::TOGGLE && setting.valuePtr != nullptr) {
// Toggle the boolean value using the member pointer
const bool currentValue = SETTINGS.*(setting.valuePtr);
SETTINGS.*(setting.valuePtr) = !currentValue;
} else if (setting.type == SettingType::ENUM && setting.valuePtr != nullptr) {
const uint8_t currentValue = SETTINGS.*(setting.valuePtr);
SETTINGS.*(setting.valuePtr) = (currentValue + 1) % static_cast<uint8_t>(setting.enumValues.size());
} else if (setting.type == SettingType::VALUE && setting.valuePtr != nullptr) {
const int8_t currentValue = SETTINGS.*(setting.valuePtr);
if (currentValue + setting.valueRange.step > setting.valueRange.max) {
SETTINGS.*(setting.valuePtr) = setting.valueRange.min;
} else {
SETTINGS.*(setting.valuePtr) = currentValue + setting.valueRange.step;
}
} else if (setting.type == SettingType::ACTION) {
if (strcmp(setting.name, "KOReader Sync") == 0) {
xSemaphoreTake(renderingMutex, portMAX_DELAY);
exitActivity();
enterNewActivity(new KOReaderSettingsActivity(renderer, mappedInput, [this] {
exitActivity();
updateRequired = true;
}));
xSemaphoreGive(renderingMutex);
} else if (strcmp(setting.name, "OPDS Browser") == 0) {
xSemaphoreTake(renderingMutex, portMAX_DELAY);
exitActivity();
enterNewActivity(new CalibreSettingsActivity(renderer, mappedInput, [this] {
exitActivity();
updateRequired = true;
}));
xSemaphoreGive(renderingMutex);
} else if (strcmp(setting.name, "Clear Cache") == 0) {
xSemaphoreTake(renderingMutex, portMAX_DELAY);
exitActivity();
enterNewActivity(new ClearCacheActivity(renderer, mappedInput, [this] {
exitActivity();
updateRequired = true;
}));
xSemaphoreGive(renderingMutex);
} else if (strcmp(setting.name, "Check for updates") == 0) {
xSemaphoreTake(renderingMutex, portMAX_DELAY);
exitActivity();
enterNewActivity(new OtaUpdateActivity(renderer, mappedInput, [this] {
exitActivity();
updateRequired = true;
}));
xSemaphoreGive(renderingMutex);
}
} else {
return;
}
SETTINGS.saveToFile();
}
void CategorySettingsActivity::displayTaskLoop() {
while (true) {
if (updateRequired && !subActivity) {
updateRequired = false;
xSemaphoreTake(renderingMutex, portMAX_DELAY);
render();
xSemaphoreGive(renderingMutex);
}
vTaskDelay(10 / portTICK_PERIOD_MS);
}
}
void CategorySettingsActivity::render() const {
renderer.clearScreen();
const auto pageWidth = renderer.getScreenWidth();
const auto pageHeight = renderer.getScreenHeight();
renderer.drawCenteredText(UI_12_FONT_ID, 15, categoryName, true, EpdFontFamily::BOLD);
// Draw selection highlight
renderer.fillRect(0, 60 + selectedSettingIndex * 30 - 2, pageWidth - 1, 30);
// Draw all settings
for (int i = 0; i < settingsCount; i++) {
const int settingY = 60 + i * 30; // 30 pixels between settings
const bool isSelected = (i == selectedSettingIndex);
// Draw setting name
renderer.drawText(UI_10_FONT_ID, 20, settingY, settingsList[i].name, !isSelected);
// Draw value based on setting type
std::string valueText;
if (settingsList[i].type == SettingType::TOGGLE && settingsList[i].valuePtr != nullptr) {
const bool value = SETTINGS.*(settingsList[i].valuePtr);
valueText = value ? "ON" : "OFF";
} else if (settingsList[i].type == SettingType::ENUM && settingsList[i].valuePtr != nullptr) {
const uint8_t value = SETTINGS.*(settingsList[i].valuePtr);
valueText = settingsList[i].enumValues[value];
} else if (settingsList[i].type == SettingType::VALUE && settingsList[i].valuePtr != nullptr) {
valueText = std::to_string(SETTINGS.*(settingsList[i].valuePtr));
}
if (!valueText.empty()) {
const auto width = renderer.getTextWidth(UI_10_FONT_ID, valueText.c_str());
renderer.drawText(UI_10_FONT_ID, pageWidth - 20 - width, settingY, valueText.c_str(), !isSelected);
}
}
renderer.drawText(SMALL_FONT_ID, pageWidth - 20 - renderer.getTextWidth(SMALL_FONT_ID, CROSSPOINT_VERSION),
pageHeight - 60, CROSSPOINT_VERSION);
const auto labels = mappedInput.mapLabels("« Back", "Toggle", "", "");
renderer.drawButtonHints(UI_10_FONT_ID, labels.btn1, labels.btn2, labels.btn3, labels.btn4);
renderer.displayBuffer();
}

View File

@ -1,70 +0,0 @@
#pragma once
#include <freertos/FreeRTOS.h>
#include <freertos/semphr.h>
#include <freertos/task.h>
#include <functional>
#include <string>
#include <vector>
#include "activities/ActivityWithSubactivity.h"
class CrossPointSettings;
enum class SettingType { TOGGLE, ENUM, ACTION, VALUE };
struct SettingInfo {
const char* name;
SettingType type;
uint8_t CrossPointSettings::* valuePtr;
std::vector<std::string> enumValues;
struct ValueRange {
uint8_t min;
uint8_t max;
uint8_t step;
};
ValueRange valueRange;
static SettingInfo Toggle(const char* name, uint8_t CrossPointSettings::* ptr) {
return {name, SettingType::TOGGLE, ptr};
}
static SettingInfo Enum(const char* name, uint8_t CrossPointSettings::* ptr, std::vector<std::string> values) {
return {name, SettingType::ENUM, ptr, std::move(values)};
}
static SettingInfo Action(const char* name) { return {name, SettingType::ACTION, nullptr}; }
static SettingInfo Value(const char* name, uint8_t CrossPointSettings::* ptr, const ValueRange valueRange) {
return {name, SettingType::VALUE, ptr, {}, valueRange};
}
};
class CategorySettingsActivity final : public ActivityWithSubactivity {
TaskHandle_t displayTaskHandle = nullptr;
SemaphoreHandle_t renderingMutex = nullptr;
bool updateRequired = false;
int selectedSettingIndex = 0;
const char* categoryName;
const SettingInfo* settingsList;
int settingsCount;
const std::function<void()> onGoBack;
static void taskTrampoline(void* param);
[[noreturn]] void displayTaskLoop();
void render() const;
void toggleCurrentSetting();
public:
CategorySettingsActivity(GfxRenderer& renderer, MappedInputManager& mappedInput, const char* categoryName,
const SettingInfo* settingsList, int settingsCount, const std::function<void()>& onGoBack)
: ActivityWithSubactivity("CategorySettings", renderer, mappedInput),
categoryName(categoryName),
settingsList(settingsList),
settingsCount(settingsCount),
onGoBack(onGoBack) {}
void onEnter() override;
void onExit() override;
void loop() override;
};

View File

@ -5,6 +5,7 @@
#include <SDCardManager.h> #include <SDCardManager.h>
#include "MappedInputManager.h" #include "MappedInputManager.h"
#include "components/UITheme.h"
#include "fontIds.h" #include "fontIds.h"
void ClearCacheActivity::taskTrampoline(void* param) { void ClearCacheActivity::taskTrampoline(void* param) {
@ -66,7 +67,7 @@ void ClearCacheActivity::render() {
renderer.drawCenteredText(UI_10_FONT_ID, pageHeight / 2 + 30, "when opened again.", true); renderer.drawCenteredText(UI_10_FONT_ID, pageHeight / 2 + 30, "when opened again.", true);
const auto labels = mappedInput.mapLabels("« Cancel", "Clear", "", ""); const auto labels = mappedInput.mapLabels("« Cancel", "Clear", "", "");
renderer.drawButtonHints(UI_10_FONT_ID, labels.btn1, labels.btn2, labels.btn3, labels.btn4); GUI.drawButtonHints(renderer, labels.btn1, labels.btn2, labels.btn3, labels.btn4);
renderer.displayBuffer(); renderer.displayBuffer();
return; return;
} }
@ -86,7 +87,7 @@ void ClearCacheActivity::render() {
renderer.drawCenteredText(UI_10_FONT_ID, pageHeight / 2 + 10, resultText.c_str()); renderer.drawCenteredText(UI_10_FONT_ID, pageHeight / 2 + 10, resultText.c_str());
const auto labels = mappedInput.mapLabels("« Back", "", "", ""); const auto labels = mappedInput.mapLabels("« Back", "", "", "");
renderer.drawButtonHints(UI_10_FONT_ID, labels.btn1, labels.btn2, labels.btn3, labels.btn4); GUI.drawButtonHints(renderer, labels.btn1, labels.btn2, labels.btn3, labels.btn4);
renderer.displayBuffer(); renderer.displayBuffer();
return; return;
} }
@ -96,7 +97,7 @@ void ClearCacheActivity::render() {
renderer.drawCenteredText(UI_10_FONT_ID, pageHeight / 2 + 10, "Check serial output for details"); renderer.drawCenteredText(UI_10_FONT_ID, pageHeight / 2 + 10, "Check serial output for details");
const auto labels = mappedInput.mapLabels("« Back", "", "", ""); const auto labels = mappedInput.mapLabels("« Back", "", "", "");
renderer.drawButtonHints(UI_10_FONT_ID, labels.btn1, labels.btn2, labels.btn3, labels.btn4); GUI.drawButtonHints(renderer, labels.btn1, labels.btn2, labels.btn3, labels.btn4);
renderer.displayBuffer(); renderer.displayBuffer();
return; return;
} }

View File

@ -7,6 +7,7 @@
#include "KOReaderSyncClient.h" #include "KOReaderSyncClient.h"
#include "MappedInputManager.h" #include "MappedInputManager.h"
#include "activities/network/WifiSelectionActivity.h" #include "activities/network/WifiSelectionActivity.h"
#include "components/UITheme.h"
#include "fontIds.h" #include "fontIds.h"
void KOReaderAuthActivity::taskTrampoline(void* param) { void KOReaderAuthActivity::taskTrampoline(void* param) {
@ -136,7 +137,7 @@ void KOReaderAuthActivity::render() {
renderer.drawCenteredText(UI_10_FONT_ID, 320, "KOReader sync is ready to use"); renderer.drawCenteredText(UI_10_FONT_ID, 320, "KOReader sync is ready to use");
const auto labels = mappedInput.mapLabels("Done", "", "", ""); const auto labels = mappedInput.mapLabels("Done", "", "", "");
renderer.drawButtonHints(UI_10_FONT_ID, labels.btn1, labels.btn2, labels.btn3, labels.btn4); GUI.drawButtonHints(renderer, labels.btn1, labels.btn2, labels.btn3, labels.btn4);
renderer.displayBuffer(); renderer.displayBuffer();
return; return;
} }
@ -146,7 +147,7 @@ void KOReaderAuthActivity::render() {
renderer.drawCenteredText(UI_10_FONT_ID, 320, errorMessage.c_str()); renderer.drawCenteredText(UI_10_FONT_ID, 320, errorMessage.c_str());
const auto labels = mappedInput.mapLabels("Back", "", "", ""); const auto labels = mappedInput.mapLabels("Back", "", "", "");
renderer.drawButtonHints(UI_10_FONT_ID, labels.btn1, labels.btn2, labels.btn3, labels.btn4); GUI.drawButtonHints(renderer, labels.btn1, labels.btn2, labels.btn3, labels.btn4);
renderer.displayBuffer(); renderer.displayBuffer();
return; return;
} }

View File

@ -8,6 +8,7 @@
#include "KOReaderCredentialStore.h" #include "KOReaderCredentialStore.h"
#include "MappedInputManager.h" #include "MappedInputManager.h"
#include "activities/util/KeyboardEntryActivity.h" #include "activities/util/KeyboardEntryActivity.h"
#include "components/UITheme.h"
#include "fontIds.h" #include "fontIds.h"
namespace { namespace {
@ -207,7 +208,7 @@ void KOReaderSettingsActivity::render() {
// Draw button hints // Draw button hints
const auto labels = mappedInput.mapLabels("« Back", "Select", "", ""); const auto labels = mappedInput.mapLabels("« Back", "Select", "", "");
renderer.drawButtonHints(UI_10_FONT_ID, labels.btn1, labels.btn2, labels.btn3, labels.btn4); GUI.drawButtonHints(renderer, labels.btn1, labels.btn2, labels.btn3, labels.btn4);
renderer.displayBuffer(); renderer.displayBuffer();
} }

View File

@ -5,6 +5,7 @@
#include "MappedInputManager.h" #include "MappedInputManager.h"
#include "activities/network/WifiSelectionActivity.h" #include "activities/network/WifiSelectionActivity.h"
#include "components/UITheme.h"
#include "fontIds.h" #include "fontIds.h"
#include "network/OtaUpdater.h" #include "network/OtaUpdater.h"
@ -142,7 +143,7 @@ void OtaUpdateActivity::render() {
renderer.drawText(UI_10_FONT_ID, 20, 270, ("New Version: " + updater.getLatestVersion()).c_str()); renderer.drawText(UI_10_FONT_ID, 20, 270, ("New Version: " + updater.getLatestVersion()).c_str());
const auto labels = mappedInput.mapLabels("Cancel", "Update", "", ""); const auto labels = mappedInput.mapLabels("Cancel", "Update", "", "");
renderer.drawButtonHints(UI_10_FONT_ID, labels.btn1, labels.btn2, labels.btn3, labels.btn4); GUI.drawButtonHints(renderer, labels.btn1, labels.btn2, labels.btn3, labels.btn4);
renderer.displayBuffer(); renderer.displayBuffer();
return; return;
} }

View File

@ -3,15 +3,20 @@
#include <GfxRenderer.h> #include <GfxRenderer.h>
#include <HardwareSerial.h> #include <HardwareSerial.h>
#include "CategorySettingsActivity.h" #include "CalibreSettingsActivity.h"
#include "ClearCacheActivity.h"
#include "CrossPointSettings.h" #include "CrossPointSettings.h"
#include "KOReaderSettingsActivity.h"
#include "MappedInputManager.h" #include "MappedInputManager.h"
#include "OtaUpdateActivity.h"
#include "components/UITheme.h"
#include "fontIds.h" #include "fontIds.h"
const char* SettingsActivity::categoryNames[categoryCount] = {"Display", "Reader", "Controls", "System"}; const char* SettingsActivity::categoryNames[categoryCount] = {"Display", "Reader", "Controls", "System"};
namespace { namespace {
constexpr int displaySettingsCount = 6; constexpr int changeTabsMs = 700;
constexpr int displaySettingsCount = 7;
const SettingInfo displaySettings[displaySettingsCount] = { const SettingInfo displaySettings[displaySettingsCount] = {
// Should match with SLEEP_SCREEN_MODE // Should match with SLEEP_SCREEN_MODE
SettingInfo::Enum("Sleep Screen", &CrossPointSettings::sleepScreen, {"Dark", "Light", "Custom", "Cover", "None"}), SettingInfo::Enum("Sleep Screen", &CrossPointSettings::sleepScreen, {"Dark", "Light", "Custom", "Cover", "None"}),
@ -22,7 +27,9 @@ const SettingInfo displaySettings[displaySettingsCount] = {
{"None", "No Progress", "Full w/ Percentage", "Full w/ Progress Bar", "Progress Bar"}), {"None", "No Progress", "Full w/ Percentage", "Full w/ Progress Bar", "Progress Bar"}),
SettingInfo::Enum("Hide Battery %", &CrossPointSettings::hideBatteryPercentage, {"Never", "In Reader", "Always"}), SettingInfo::Enum("Hide Battery %", &CrossPointSettings::hideBatteryPercentage, {"Never", "In Reader", "Always"}),
SettingInfo::Enum("Refresh Frequency", &CrossPointSettings::refreshFrequency, SettingInfo::Enum("Refresh Frequency", &CrossPointSettings::refreshFrequency,
{"1 page", "5 pages", "10 pages", "15 pages", "30 pages"})}; {"1 page", "5 pages", "10 pages", "15 pages", "30 pages"}),
SettingInfo::Enum("UI Theme", &CrossPointSettings::uiTheme, {"Classic", "Lyra"}),
};
constexpr int readerSettingsCount = 9; constexpr int readerSettingsCount = 9;
const SettingInfo readerSettings[readerSettingsCount] = { const SettingInfo readerSettings[readerSettingsCount] = {
@ -67,6 +74,11 @@ void SettingsActivity::onEnter() {
// Reset selection to first category // Reset selection to first category
selectedCategoryIndex = 0; selectedCategoryIndex = 0;
selectedSettingIndex = 0;
// Initialize with first category (Display)
settingsList = displaySettings;
settingsCount = displaySettingsCount;
// Trigger first update // Trigger first update
updateRequired = true; updateRequired = true;
@ -90,6 +102,8 @@ void SettingsActivity::onExit() {
} }
vSemaphoreDelete(renderingMutex); vSemaphoreDelete(renderingMutex);
renderingMutex = nullptr; renderingMutex = nullptr;
UITheme::getInstance().reload(); // Re-apply theme in case it was changed
} }
void SettingsActivity::loop() { void SettingsActivity::loop() {
@ -97,11 +111,19 @@ void SettingsActivity::loop() {
subActivity->loop(); subActivity->loop();
return; return;
} }
bool hasChangedCategory = false;
// Handle category selection // Handle actions with early return
if (mappedInput.wasPressed(MappedInputManager::Button::Confirm)) { if (mappedInput.wasPressed(MappedInputManager::Button::Confirm)) {
enterCategory(selectedCategoryIndex); if (selectedSettingIndex == 0) {
return; selectedCategoryIndex = (selectedCategoryIndex < categoryCount - 1) ? (selectedCategoryIndex + 1) : 0;
hasChangedCategory = true;
updateRequired = true;
} else {
toggleCurrentSetting();
updateRequired = true;
return;
}
} }
if (mappedInput.wasPressed(MappedInputManager::Button::Back)) { if (mappedInput.wasPressed(MappedInputManager::Button::Back)) {
@ -110,56 +132,113 @@ void SettingsActivity::loop() {
return; return;
} }
const bool upReleased = mappedInput.wasReleased(MappedInputManager::Button::Up);
const bool downReleased = mappedInput.wasReleased(MappedInputManager::Button::Down);
const bool leftReleased = mappedInput.wasReleased(MappedInputManager::Button::Left);
const bool rightReleased = mappedInput.wasReleased(MappedInputManager::Button::Right);
const bool changeTab = mappedInput.getHeldTime() > changeTabsMs;
// Handle navigation // Handle navigation
if (mappedInput.wasPressed(MappedInputManager::Button::Up) || if (upReleased && changeTab) {
mappedInput.wasPressed(MappedInputManager::Button::Left)) { hasChangedCategory = true;
// Move selection up (with wrap-around)
selectedCategoryIndex = (selectedCategoryIndex > 0) ? (selectedCategoryIndex - 1) : (categoryCount - 1); selectedCategoryIndex = (selectedCategoryIndex > 0) ? (selectedCategoryIndex - 1) : (categoryCount - 1);
updateRequired = true; updateRequired = true;
} else if (mappedInput.wasPressed(MappedInputManager::Button::Down) || } else if (downReleased && changeTab) {
mappedInput.wasPressed(MappedInputManager::Button::Right)) { hasChangedCategory = true;
// Move selection down (with wrap around)
selectedCategoryIndex = (selectedCategoryIndex < categoryCount - 1) ? (selectedCategoryIndex + 1) : 0; selectedCategoryIndex = (selectedCategoryIndex < categoryCount - 1) ? (selectedCategoryIndex + 1) : 0;
updateRequired = true; updateRequired = true;
} else if (upReleased || leftReleased) {
selectedSettingIndex = (selectedSettingIndex > 0) ? (selectedSettingIndex - 1) : (settingsCount);
updateRequired = true;
} else if (rightReleased || downReleased) {
selectedSettingIndex = (selectedSettingIndex < settingsCount) ? (selectedSettingIndex + 1) : 0;
updateRequired = true;
}
if (hasChangedCategory) {
selectedSettingIndex = (selectedSettingIndex == 0) ? 0 : 1;
switch (selectedCategoryIndex) {
case 0: // Display
settingsList = displaySettings;
settingsCount = displaySettingsCount;
break;
case 1: // Reader
settingsList = readerSettings;
settingsCount = readerSettingsCount;
break;
case 2: // Controls
settingsList = controlsSettings;
settingsCount = controlsSettingsCount;
break;
case 3: // System
settingsList = systemSettings;
settingsCount = systemSettingsCount;
break;
}
} }
} }
void SettingsActivity::enterCategory(int categoryIndex) { void SettingsActivity::toggleCurrentSetting() {
if (categoryIndex < 0 || categoryIndex >= categoryCount) { int selectedSetting = selectedSettingIndex - 1;
if (selectedSetting < 0 || selectedSetting >= settingsCount) {
return; return;
} }
xSemaphoreTake(renderingMutex, portMAX_DELAY); const auto& setting = settingsList[selectedSetting];
exitActivity();
const SettingInfo* settingsList = nullptr; if (setting.type == SettingType::TOGGLE && setting.valuePtr != nullptr) {
int settingsCount = 0; // Toggle the boolean value using the member pointer
const bool currentValue = SETTINGS.*(setting.valuePtr);
switch (categoryIndex) { SETTINGS.*(setting.valuePtr) = !currentValue;
case 0: // Display } else if (setting.type == SettingType::ENUM && setting.valuePtr != nullptr) {
settingsList = displaySettings; const uint8_t currentValue = SETTINGS.*(setting.valuePtr);
settingsCount = displaySettingsCount; SETTINGS.*(setting.valuePtr) = (currentValue + 1) % static_cast<uint8_t>(setting.enumValues.size());
break; } else if (setting.type == SettingType::VALUE && setting.valuePtr != nullptr) {
case 1: // Reader const int8_t currentValue = SETTINGS.*(setting.valuePtr);
settingsList = readerSettings; if (currentValue + setting.valueRange.step > setting.valueRange.max) {
settingsCount = readerSettingsCount; SETTINGS.*(setting.valuePtr) = setting.valueRange.min;
break; } else {
case 2: // Controls SETTINGS.*(setting.valuePtr) = currentValue + setting.valueRange.step;
settingsList = controlsSettings; }
settingsCount = controlsSettingsCount; } else if (setting.type == SettingType::ACTION) {
break; if (strcmp(setting.name, "KOReader Sync") == 0) {
case 3: // System xSemaphoreTake(renderingMutex, portMAX_DELAY);
settingsList = systemSettings; exitActivity();
settingsCount = systemSettingsCount; enterNewActivity(new KOReaderSettingsActivity(renderer, mappedInput, [this] {
break; exitActivity();
updateRequired = true;
}));
xSemaphoreGive(renderingMutex);
} else if (strcmp(setting.name, "OPDS Browser") == 0) {
xSemaphoreTake(renderingMutex, portMAX_DELAY);
exitActivity();
enterNewActivity(new CalibreSettingsActivity(renderer, mappedInput, [this] {
exitActivity();
updateRequired = true;
}));
xSemaphoreGive(renderingMutex);
} else if (strcmp(setting.name, "Clear Cache") == 0) {
xSemaphoreTake(renderingMutex, portMAX_DELAY);
exitActivity();
enterNewActivity(new ClearCacheActivity(renderer, mappedInput, [this] {
exitActivity();
updateRequired = true;
}));
xSemaphoreGive(renderingMutex);
} else if (strcmp(setting.name, "Check for updates") == 0) {
xSemaphoreTake(renderingMutex, portMAX_DELAY);
exitActivity();
enterNewActivity(new OtaUpdateActivity(renderer, mappedInput, [this] {
exitActivity();
updateRequired = true;
}));
xSemaphoreGive(renderingMutex);
}
} else {
return;
} }
enterNewActivity(new CategorySettingsActivity(renderer, mappedInput, categoryNames[categoryIndex], settingsList, SETTINGS.saveToFile();
settingsCount, [this] {
exitActivity();
updateRequired = true;
}));
xSemaphoreGive(renderingMutex);
} }
void SettingsActivity::displayTaskLoop() { void SettingsActivity::displayTaskLoop() {
@ -180,27 +259,48 @@ void SettingsActivity::render() const {
const auto pageWidth = renderer.getScreenWidth(); const auto pageWidth = renderer.getScreenWidth();
const auto pageHeight = renderer.getScreenHeight(); const auto pageHeight = renderer.getScreenHeight();
// Draw header auto metrics = UITheme::getInstance().getMetrics();
renderer.drawCenteredText(UI_12_FONT_ID, 15, "Settings", true, EpdFontFamily::BOLD);
// Draw selection GUI.drawHeader(renderer, Rect{0, metrics.topPadding, pageWidth, metrics.headerHeight}, "Settings");
renderer.fillRect(0, 60 + selectedCategoryIndex * 30 - 2, pageWidth - 1, 30);
// Draw all categories std::vector<TabInfo> tabs;
tabs.reserve(categoryCount);
for (int i = 0; i < categoryCount; i++) { for (int i = 0; i < categoryCount; i++) {
const int categoryY = 60 + i * 30; // 30 pixels between categories tabs.push_back({categoryNames[i], selectedCategoryIndex == i});
// Draw category name
renderer.drawText(UI_10_FONT_ID, 20, categoryY, categoryNames[i], i != selectedCategoryIndex);
} }
GUI.drawTabBar(renderer, Rect{0, metrics.topPadding + metrics.headerHeight, pageWidth, metrics.tabBarHeight}, tabs,
selectedSettingIndex == 0);
// Draw version text above button hints GUI.drawList(
renderer.drawText(SMALL_FONT_ID, pageWidth - 20 - renderer.getTextWidth(SMALL_FONT_ID, CROSSPOINT_VERSION), renderer,
pageHeight - 60, CROSSPOINT_VERSION); Rect{0, metrics.topPadding + metrics.headerHeight + metrics.tabBarHeight + metrics.verticalSpacing, pageWidth,
pageHeight - (metrics.topPadding + metrics.headerHeight + metrics.tabBarHeight + metrics.buttonHintsHeight +
metrics.verticalSpacing * 2)},
settingsCount, selectedSettingIndex - 1, [this](int index) { return std::string(settingsList[index].name); },
false, nullptr, false, nullptr, true,
[this](int i) {
const auto& setting = settingsList[i];
std::string valueText = "";
if (settingsList[i].type == SettingType::TOGGLE && settingsList[i].valuePtr != nullptr) {
const bool value = SETTINGS.*(settingsList[i].valuePtr);
valueText = value ? "ON" : "OFF";
} else if (settingsList[i].type == SettingType::ENUM && settingsList[i].valuePtr != nullptr) {
const uint8_t value = SETTINGS.*(settingsList[i].valuePtr);
valueText = settingsList[i].enumValues[value];
} else if (settingsList[i].type == SettingType::VALUE && settingsList[i].valuePtr != nullptr) {
valueText = std::to_string(SETTINGS.*(settingsList[i].valuePtr));
}
return valueText;
});
// Draw version text
renderer.drawText(SMALL_FONT_ID,
pageWidth - metrics.versionTextRightX - renderer.getTextWidth(SMALL_FONT_ID, CROSSPOINT_VERSION),
metrics.versionTextY, CROSSPOINT_VERSION);
// Draw help text // Draw help text
const auto labels = mappedInput.mapLabels("« Back", "Select", "", ""); const auto labels = mappedInput.mapLabels("« Back", "Toggle", "Up", "Down");
renderer.drawButtonHints(UI_10_FONT_ID, labels.btn1, labels.btn2, labels.btn3, labels.btn4); GUI.drawButtonHints(renderer, labels.btn1, labels.btn2, labels.btn3, labels.btn4);
// Always use standard refresh for settings screen // Always use standard refresh for settings screen
renderer.displayBuffer(); renderer.displayBuffer();

View File

@ -10,13 +10,46 @@
#include "activities/ActivityWithSubactivity.h" #include "activities/ActivityWithSubactivity.h"
class CrossPointSettings; class CrossPointSettings;
struct SettingInfo;
enum class SettingType { TOGGLE, ENUM, ACTION, VALUE };
struct SettingInfo {
const char* name;
SettingType type;
uint8_t CrossPointSettings::* valuePtr;
std::vector<std::string> enumValues;
struct ValueRange {
uint8_t min;
uint8_t max;
uint8_t step;
};
ValueRange valueRange;
static SettingInfo Toggle(const char* name, uint8_t CrossPointSettings::* ptr) {
return {name, SettingType::TOGGLE, ptr};
}
static SettingInfo Enum(const char* name, uint8_t CrossPointSettings::* ptr, std::vector<std::string> values) {
return {name, SettingType::ENUM, ptr, std::move(values)};
}
static SettingInfo Action(const char* name) { return {name, SettingType::ACTION, nullptr}; }
static SettingInfo Value(const char* name, uint8_t CrossPointSettings::* ptr, const ValueRange valueRange) {
return {name, SettingType::VALUE, ptr, {}, valueRange};
}
};
class SettingsActivity final : public ActivityWithSubactivity { class SettingsActivity final : public ActivityWithSubactivity {
TaskHandle_t displayTaskHandle = nullptr; TaskHandle_t displayTaskHandle = nullptr;
SemaphoreHandle_t renderingMutex = nullptr; SemaphoreHandle_t renderingMutex = nullptr;
bool updateRequired = false; bool updateRequired = false;
int selectedCategoryIndex = 0; // Currently selected category int selectedCategoryIndex = 0; // Currently selected category
int selectedSettingIndex = 0;
int settingsCount = 0;
const SettingInfo* settingsList = nullptr;
const std::function<void()> onGoHome; const std::function<void()> onGoHome;
static constexpr int categoryCount = 4; static constexpr int categoryCount = 4;
@ -26,6 +59,7 @@ class SettingsActivity final : public ActivityWithSubactivity {
[[noreturn]] void displayTaskLoop(); [[noreturn]] void displayTaskLoop();
void render() const; void render() const;
void enterCategory(int categoryIndex); void enterCategory(int categoryIndex);
void toggleCurrentSetting();
public: public:
explicit SettingsActivity(GfxRenderer& renderer, MappedInputManager& mappedInput, explicit SettingsActivity(GfxRenderer& renderer, MappedInputManager& mappedInput,

View File

@ -1,6 +1,7 @@
#include "KeyboardEntryActivity.h" #include "KeyboardEntryActivity.h"
#include "MappedInputManager.h" #include "MappedInputManager.h"
#include "components/UITheme.h"
#include "fontIds.h" #include "fontIds.h"
// Keyboard layouts - lowercase // Keyboard layouts - lowercase
@ -354,10 +355,10 @@ void KeyboardEntryActivity::render() const {
// Draw help text // Draw help text
const auto labels = mappedInput.mapLabels("« Back", "Select", "Left", "Right"); const auto labels = mappedInput.mapLabels("« Back", "Select", "Left", "Right");
renderer.drawButtonHints(UI_10_FONT_ID, labels.btn1, labels.btn2, labels.btn3, labels.btn4); GUI.drawButtonHints(renderer, labels.btn1, labels.btn2, labels.btn3, labels.btn4);
// Draw side button hints for Up/Down navigation // Draw side button hints for Up/Down navigation
renderer.drawSideButtonHints(UI_10_FONT_ID, "Up", "Down"); GUI.drawSideButtonHints(renderer, "Up", "Down");
renderer.displayBuffer(); renderer.displayBuffer();
} }

View File

@ -0,0 +1,62 @@
#include "UITheme.h"
#include <GfxRenderer.h>
#include <memory>
#include "RecentBooksStore.h"
#include "components/themes/BaseTheme.h"
#include "components/themes/lyra/LyraTheme.h"
UITheme UITheme::instance;
UITheme::UITheme() {
auto themeType = static_cast<CrossPointSettings::UI_THEME>(SETTINGS.uiTheme);
setTheme(themeType);
}
void UITheme::reload() {
auto themeType = static_cast<CrossPointSettings::UI_THEME>(SETTINGS.uiTheme);
setTheme(themeType);
}
void UITheme::setTheme(CrossPointSettings::UI_THEME type) {
switch (type) {
case CrossPointSettings::UI_THEME::CLASSIC:
Serial.printf("[%lu] [UI] Using Classic theme\n", millis());
currentTheme = new BaseTheme();
currentMetrics = &BaseMetrics::values;
break;
case CrossPointSettings::UI_THEME::LYRA:
Serial.printf("[%lu] [UI] Using Lyra theme\n", millis());
currentTheme = new LyraTheme();
currentMetrics = &LyraMetrics::values;
break;
}
}
int UITheme::getNumberOfItemsPerPage(const GfxRenderer& renderer, bool hasHeader, bool hasTabBar, bool hasButtonHints,
bool hasSubtitle) {
const ThemeMetrics& metrics = UITheme::getInstance().getMetrics();
int reservedHeight = metrics.topPadding;
if (hasHeader) {
reservedHeight += metrics.headerHeight;
}
if (hasTabBar) {
reservedHeight += metrics.tabBarHeight + metrics.verticalSpacing;
}
if (hasButtonHints) {
reservedHeight += metrics.verticalSpacing + metrics.buttonHintsHeight;
}
const int availableHeight = renderer.getScreenHeight() - reservedHeight;
int rowHeight = hasSubtitle ? metrics.listWithSubtitleRowHeight : metrics.listRowHeight;
return availableHeight / rowHeight;
}
std::string UITheme::getCoverThumbPath(std::string coverBmpPath, int coverHeight) {
size_t pos = coverBmpPath.find("[HEIGHT]", 0);
if (pos != std::string::npos) {
coverBmpPath.replace(pos, 8, std::to_string(coverHeight));
}
return coverBmpPath;
}

31
src/components/UITheme.h Normal file
View File

@ -0,0 +1,31 @@
#pragma once
#include <functional>
#include <vector>
#include "CrossPointSettings.h"
#include "components/themes/BaseTheme.h"
class UITheme {
// Static instance
static UITheme instance;
public:
UITheme();
static UITheme& getInstance() { return instance; }
const ThemeMetrics& getMetrics() { return *currentMetrics; }
const BaseTheme& getTheme() { return *currentTheme; }
void reload();
void setTheme(CrossPointSettings::UI_THEME type);
static int getNumberOfItemsPerPage(const GfxRenderer& renderer, bool hasHeader, bool hasTabBar, bool hasButtonHints,
bool hasSubtitle);
static std::string getCoverThumbPath(std::string coverBmpPath, int coverHeight);
private:
const ThemeMetrics* currentMetrics;
const BaseTheme* currentTheme;
};
// Helper macro to access current theme
#define GUI UITheme::getInstance().getTheme()

View File

@ -0,0 +1,642 @@
#include "BaseTheme.h"
#include <GfxRenderer.h>
#include <SDCardManager.h>
#include <Utf8.h>
#include <cstdint>
#include <string>
#include "Battery.h"
#include "RecentBooksStore.h"
#include "components/UITheme.h"
#include "fontIds.h"
// Internal constants
namespace {
constexpr int batteryPercentSpacing = 4;
constexpr int homeMenuMargin = 20;
constexpr int homeMarginTop = 30;
} // namespace
void BaseTheme::drawBattery(const GfxRenderer& renderer, Rect rect, const bool showPercentage) const {
// Left aligned battery icon and percentage
// TODO refactor this so the percentage doesnt change after we position it
const uint16_t percentage = battery.readPercentage();
if (showPercentage) {
const auto percentageText = std::to_string(percentage) + "%";
renderer.drawText(SMALL_FONT_ID, rect.x + batteryPercentSpacing + BaseMetrics::values.batteryWidth, rect.y,
percentageText.c_str());
}
// 1 column on left, 2 columns on right, 5 columns of battery body
const int x = rect.x;
const int y = rect.y + 6;
const int battWidth = BaseMetrics::values.batteryWidth;
// Top line
renderer.drawLine(x + 1, y, x + battWidth - 3, y);
// Bottom line
renderer.drawLine(x + 1, y + rect.height - 1, x + battWidth - 3, y + rect.height - 1);
// Left line
renderer.drawLine(x, y + 1, x, y + rect.height - 2);
// Battery end
renderer.drawLine(x + battWidth - 2, y + 1, x + battWidth - 2, y + rect.height - 2);
renderer.drawPixel(x + battWidth - 1, y + 3);
renderer.drawPixel(x + battWidth - 1, y + rect.height - 4);
renderer.drawLine(x + battWidth - 0, y + 4, x + battWidth - 0, y + rect.height - 5);
// The +1 is to round up, so that we always fill at least one pixel
int filledWidth = percentage * (rect.width - 5) / 100 + 1;
if (filledWidth > rect.width - 5) {
filledWidth = rect.width - 5; // Ensure we don't overflow
}
renderer.fillRect(x + 2, y + 2, filledWidth, rect.height - 4);
}
void BaseTheme::drawProgressBar(const GfxRenderer& renderer, Rect rect, const size_t current,
const size_t total) const {
if (total == 0) {
return;
}
// Use 64-bit arithmetic to avoid overflow for large files
const int percent = static_cast<int>((static_cast<uint64_t>(current) * 100) / total);
// Draw outline
renderer.drawRect(rect.x, rect.y, rect.width, rect.height);
// Draw filled portion
const int fillWidth = (rect.width - 4) * percent / 100;
if (fillWidth > 0) {
renderer.fillRect(rect.x + 2, rect.y + 2, fillWidth, rect.height - 4);
}
// Draw percentage text centered below bar
const std::string percentText = std::to_string(percent) + "%";
renderer.drawCenteredText(UI_10_FONT_ID, rect.y + rect.height + 15, percentText.c_str());
}
void BaseTheme::drawButtonHints(GfxRenderer& renderer, const char* btn1, const char* btn2, const char* btn3,
const char* btn4) const {
const GfxRenderer::Orientation orig_orientation = renderer.getOrientation();
renderer.setOrientation(GfxRenderer::Orientation::Portrait);
const int pageHeight = renderer.getScreenHeight();
constexpr int buttonWidth = 106;
constexpr int buttonHeight = BaseMetrics::values.buttonHintsHeight;
constexpr int buttonY = BaseMetrics::values.buttonHintsHeight; // Distance from bottom
constexpr int textYOffset = 7; // Distance from top of button to text baseline
constexpr int buttonPositions[] = {25, 130, 245, 350};
const char* labels[] = {btn1, btn2, btn3, btn4};
for (int i = 0; i < 4; i++) {
// Only draw if the label is non-empty
if (labels[i] != nullptr && labels[i][0] != '\0') {
const int x = buttonPositions[i];
renderer.fillRect(x, pageHeight - buttonY, buttonWidth, buttonHeight, false);
renderer.drawRect(x, pageHeight - buttonY, buttonWidth, buttonHeight);
const int textWidth = renderer.getTextWidth(UI_10_FONT_ID, labels[i]);
const int textX = x + (buttonWidth - 1 - textWidth) / 2;
renderer.drawText(UI_10_FONT_ID, textX, pageHeight - buttonY + textYOffset, labels[i]);
}
}
renderer.setOrientation(orig_orientation);
}
void BaseTheme::drawSideButtonHints(const GfxRenderer& renderer, const char* topBtn, const char* bottomBtn) const {
const int screenWidth = renderer.getScreenWidth();
constexpr int buttonWidth = BaseMetrics::values.sideButtonHintsWidth; // Width on screen (height when rotated)
constexpr int buttonHeight = 80; // Height on screen (width when rotated)
constexpr int buttonX = 4; // Distance from right edge
// Position for the button group - buttons share a border so they're adjacent
constexpr int topButtonY = 345; // Top button position
const char* labels[] = {topBtn, bottomBtn};
// Draw the shared border for both buttons as one unit
const int x = screenWidth - buttonX - buttonWidth;
// Draw top button outline (3 sides, bottom open)
if (topBtn != nullptr && topBtn[0] != '\0') {
renderer.drawLine(x, topButtonY, x + buttonWidth - 1, topButtonY); // Top
renderer.drawLine(x, topButtonY, x, topButtonY + buttonHeight - 1); // Left
renderer.drawLine(x + buttonWidth - 1, topButtonY, x + buttonWidth - 1, topButtonY + buttonHeight - 1); // Right
}
// Draw shared middle border
if ((topBtn != nullptr && topBtn[0] != '\0') || (bottomBtn != nullptr && bottomBtn[0] != '\0')) {
renderer.drawLine(x, topButtonY + buttonHeight, x + buttonWidth - 1, topButtonY + buttonHeight); // Shared border
}
// Draw bottom button outline (3 sides, top is shared)
if (bottomBtn != nullptr && bottomBtn[0] != '\0') {
renderer.drawLine(x, topButtonY + buttonHeight, x, topButtonY + 2 * buttonHeight - 1); // Left
renderer.drawLine(x + buttonWidth - 1, topButtonY + buttonHeight, x + buttonWidth - 1,
topButtonY + 2 * buttonHeight - 1); // Right
renderer.drawLine(x, topButtonY + 2 * buttonHeight - 1, x + buttonWidth - 1,
topButtonY + 2 * buttonHeight - 1); // Bottom
}
// Draw text for each button
for (int i = 0; i < 2; i++) {
if (labels[i] != nullptr && labels[i][0] != '\0') {
const int y = topButtonY + i * buttonHeight;
// Draw rotated text centered in the button
const int textWidth = renderer.getTextWidth(SMALL_FONT_ID, labels[i]);
const int textHeight = renderer.getTextHeight(SMALL_FONT_ID);
// Center the rotated text in the button
const int textX = x + (buttonWidth - textHeight) / 2;
const int textY = y + (buttonHeight + textWidth) / 2;
renderer.drawTextRotated90CW(SMALL_FONT_ID, textX, textY, labels[i]);
}
}
}
void BaseTheme::drawList(const GfxRenderer& renderer, Rect rect, int itemCount, int selectedIndex,
const std::function<std::string(int index)>& rowTitle, bool hasSubtitle,
const std::function<std::string(int index)>& rowSubtitle, bool hasIcon,
const std::function<std::string(int index)>& rowIcon, bool hasValue,
const std::function<std::string(int index)>& rowValue) const {
int rowHeight = hasSubtitle ? BaseMetrics::values.listWithSubtitleRowHeight : BaseMetrics::values.listRowHeight;
int pageItems = rect.height / rowHeight;
const int totalPages = (itemCount + pageItems - 1) / pageItems;
if (totalPages > 1) {
constexpr int indicatorWidth = 20;
constexpr int arrowSize = 6;
constexpr int margin = 15; // Offset from right edge
const int centerX = rect.x + rect.width - indicatorWidth / 2 - margin;
const int indicatorTop = rect.y; // Offset to avoid overlapping side button hints
const int indicatorBottom = rect.y + rect.height - 30;
// Draw up arrow at top (^) - narrow point at top, wide base at bottom
for (int i = 0; i < arrowSize; ++i) {
const int lineWidth = 1 + i * 2;
const int startX = centerX - i;
renderer.drawLine(startX, indicatorTop + i, startX + lineWidth - 1, indicatorTop + i);
}
// Draw down arrow at bottom (v) - wide base at top, narrow point at bottom
for (int i = 0; i < arrowSize; ++i) {
const int lineWidth = 1 + (arrowSize - 1 - i) * 2;
const int startX = centerX - (arrowSize - 1 - i);
renderer.drawLine(startX, indicatorBottom - arrowSize + 1 + i, startX + lineWidth - 1,
indicatorBottom - arrowSize + 1 + i);
}
}
// Draw selection
int contentWidth = rect.width - 5;
if (selectedIndex >= 0) {
renderer.fillRect(0, rect.y + selectedIndex % pageItems * rowHeight - 2, rect.width, rowHeight);
}
// Draw all items
const auto pageStartIndex = selectedIndex / pageItems * pageItems;
for (int i = pageStartIndex; i < itemCount && i < pageStartIndex + pageItems; i++) {
const int itemY = rect.y + (i % pageItems) * rowHeight;
int textWidth = contentWidth - BaseMetrics::values.contentSidePadding * 2 - (hasValue ? 60 : 0);
// Draw name
auto itemName = rowTitle(i);
auto font = hasSubtitle ? UI_12_FONT_ID : UI_10_FONT_ID;
auto item = renderer.truncatedText(font, itemName.c_str(), textWidth);
renderer.drawText(font, rect.x + BaseMetrics::values.contentSidePadding, itemY, item.c_str(), i != selectedIndex);
if (hasSubtitle) {
// Draw subtitle
std::string subtitleText = rowSubtitle(i);
auto subtitle = renderer.truncatedText(UI_10_FONT_ID, subtitleText.c_str(), textWidth);
renderer.drawText(UI_10_FONT_ID, rect.x + BaseMetrics::values.contentSidePadding, itemY + 30, subtitle.c_str(),
i != selectedIndex);
}
if (hasValue) {
// Draw value
std::string valueText = rowValue(i);
const auto valueTextWidth = renderer.getTextWidth(UI_10_FONT_ID, valueText.c_str());
renderer.drawText(UI_10_FONT_ID, rect.x + contentWidth - BaseMetrics::values.contentSidePadding - valueTextWidth,
itemY, valueText.c_str(), i != selectedIndex);
}
}
}
void BaseTheme::drawHeader(const GfxRenderer& renderer, Rect rect, const char* title) const {
const bool showBatteryPercentage =
SETTINGS.hideBatteryPercentage != CrossPointSettings::HIDE_BATTERY_PERCENTAGE::HIDE_ALWAYS;
int batteryX = rect.x + rect.width - BaseMetrics::values.contentSidePadding - BaseMetrics::values.batteryWidth;
if (showBatteryPercentage) {
const uint16_t percentage = battery.readPercentage();
const auto percentageText = std::to_string(percentage) + "%";
batteryX -= renderer.getTextWidth(SMALL_FONT_ID, percentageText.c_str());
}
drawBattery(renderer, Rect{batteryX, rect.y + 5, BaseMetrics::values.batteryWidth, BaseMetrics::values.batteryHeight},
showBatteryPercentage);
if (title) {
int padding = rect.width - batteryX;
auto truncatedTitle = renderer.truncatedText(UI_12_FONT_ID, title,
rect.width - padding * 2 - BaseMetrics::values.contentSidePadding * 2,
EpdFontFamily::BOLD);
renderer.drawCenteredText(UI_12_FONT_ID, rect.y + 5, truncatedTitle.c_str(), true, EpdFontFamily::BOLD);
}
}
void BaseTheme::drawTabBar(const GfxRenderer& renderer, const Rect rect, const std::vector<TabInfo>& tabs,
bool selected) const {
constexpr int underlineHeight = 2; // Height of selection underline
constexpr int underlineGap = 4; // Gap between text and underline
const int lineHeight = renderer.getLineHeight(UI_12_FONT_ID);
int currentX = rect.x + BaseMetrics::values.contentSidePadding;
for (const auto& tab : tabs) {
const int textWidth =
renderer.getTextWidth(UI_12_FONT_ID, tab.label, tab.selected ? EpdFontFamily::BOLD : EpdFontFamily::REGULAR);
// Draw underline for selected tab
if (tab.selected) {
if (selected) {
renderer.fillRect(currentX - 3, rect.y, textWidth + 6, lineHeight + underlineGap);
} else {
renderer.fillRect(currentX, rect.y + lineHeight + underlineGap, textWidth, underlineHeight);
}
}
// Draw tab label
renderer.drawText(UI_12_FONT_ID, currentX, rect.y, tab.label, !(tab.selected && selected),
tab.selected ? EpdFontFamily::BOLD : EpdFontFamily::REGULAR);
currentX += textWidth + BaseMetrics::values.tabSpacing;
}
}
// Draw the "Recent Book" cover card on the home screen
// TODO: Refactor method to make it cleaner, split into smaller methods
void BaseTheme::drawRecentBookCover(GfxRenderer& renderer, Rect rect, const std::vector<RecentBook>& recentBooks,
const int selectorIndex, bool& coverRendered, bool& coverBufferStored,
bool& bufferRestored, std::function<bool()> storeCoverBuffer) const {
// --- Top "book" card for the current title (selectorIndex == 0) ---
const int bookWidth = rect.width / 2;
const int bookHeight = rect.height;
const int bookX = (rect.width - bookWidth) / 2;
const int bookY = rect.y;
const bool hasContinueReading = !recentBooks.empty();
const bool bookSelected = hasContinueReading && selectorIndex == 0;
// 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;
// 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 && !recentBooks[0].coverBmpPath.empty() && !coverRendered) {
const std::string coverBmpPath =
UITheme::getCoverThumbPath(recentBooks[0].coverBmpPath, BaseMetrics::values.homeCoverHeight);
// First time: load cover from SD and render
FsFile file;
if (SdMan.openFileForRead("HOME", coverBmpPath, file)) {
Bitmap bitmap(file);
if (bitmap.parseHeaders() == BmpReaderError::Ok) {
// Calculate position to center image within the book card
int coverX, coverY;
if (bitmap.getWidth() > bookWidth || bitmap.getHeight() > bookHeight) {
const float imgRatio = static_cast<float>(bitmap.getWidth()) / static_cast<float>(bitmap.getHeight());
const float boxRatio = static_cast<float>(bookWidth) / static_cast<float>(bookHeight);
if (imgRatio > boxRatio) {
coverX = bookX;
coverY = bookY + (bookHeight - static_cast<int>(bookWidth / imgRatio)) / 2;
} else {
coverX = bookX + (bookWidth - static_cast<int>(bookHeight * imgRatio)) / 2;
coverY = bookY;
}
} else {
coverX = bookX + (bookWidth - bitmap.getWidth()) / 2;
coverY = bookY + (bookHeight - bitmap.getHeight()) / 2;
}
// 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
}
}
if (hasContinueReading) {
const std::string& lastBookTitle = recentBooks[0].title;
const std::string& lastBookAuthor = recentBooks[0].author;
// Invert text colors based on selection state:
// - With cover: selected = white text on black box, unselected = black text on white box
// - Without cover: selected = white text on black card, unselected = black text on white card
// Split into words (avoid stringstream to keep this light on the MCU)
std::vector<std::string> words;
words.reserve(8);
size_t pos = 0;
while (pos < lastBookTitle.size()) {
while (pos < lastBookTitle.size() && lastBookTitle[pos] == ' ') {
++pos;
}
if (pos >= lastBookTitle.size()) {
break;
}
const size_t start = pos;
while (pos < lastBookTitle.size() && lastBookTitle[pos] != ' ') {
++pos;
}
words.emplace_back(lastBookTitle.substr(start, pos - start));
}
std::vector<std::string> lines;
std::string currentLine;
// Extra padding inside the card so text doesn't hug the border
const int maxLineWidth = bookWidth - 40;
const int spaceWidth = renderer.getSpaceWidth(UI_12_FONT_ID);
for (auto& i : words) {
// If we just hit the line limit (3), stop processing words
if (lines.size() >= 3) {
// Limit to 3 lines
// Still have words left, so add ellipsis to last line
lines.back().append("...");
while (!lines.back().empty() && renderer.getTextWidth(UI_12_FONT_ID, lines.back().c_str()) > maxLineWidth) {
// Remove "..." first, then remove one UTF-8 char, then add "..." back
lines.back().resize(lines.back().size() - 3); // Remove "..."
utf8RemoveLastChar(lines.back());
lines.back().append("...");
}
break;
}
int wordWidth = renderer.getTextWidth(UI_12_FONT_ID, i.c_str());
while (wordWidth > maxLineWidth && !i.empty()) {
// Word itself is too long, trim it (UTF-8 safe)
utf8RemoveLastChar(i);
// Check if we have room for ellipsis
std::string withEllipsis = i + "...";
wordWidth = renderer.getTextWidth(UI_12_FONT_ID, withEllipsis.c_str());
if (wordWidth <= maxLineWidth) {
i = withEllipsis;
break;
}
}
int newLineWidth = renderer.getTextWidth(UI_12_FONT_ID, currentLine.c_str());
if (newLineWidth > 0) {
newLineWidth += spaceWidth;
}
newLineWidth += wordWidth;
if (newLineWidth > maxLineWidth && !currentLine.empty()) {
// New line too long, push old line
lines.push_back(currentLine);
currentLine = i;
} else {
currentLine.append(" ").append(i);
}
}
// If lower than the line limit, push remaining words
if (!currentLine.empty() && lines.size() < 3) {
lines.push_back(currentLine);
}
// Book title text
int totalTextHeight = renderer.getLineHeight(UI_12_FONT_ID) * static_cast<int>(lines.size());
if (!lastBookAuthor.empty()) {
totalTextHeight += renderer.getLineHeight(UI_10_FONT_ID) * 3 / 2;
}
// Vertically center the title block within the card
int titleYStart = bookY + (bookHeight - totalTextHeight) / 2;
// If cover image was rendered, draw box behind title and author
if (coverRendered) {
constexpr int boxPadding = 8;
// Calculate the max text width for the box
int maxTextWidth = 0;
for (const auto& line : lines) {
const int lineWidth = renderer.getTextWidth(UI_12_FONT_ID, line.c_str());
if (lineWidth > maxTextWidth) {
maxTextWidth = lineWidth;
}
}
if (!lastBookAuthor.empty()) {
std::string trimmedAuthor = lastBookAuthor;
while (renderer.getTextWidth(UI_10_FONT_ID, trimmedAuthor.c_str()) > maxLineWidth && !trimmedAuthor.empty()) {
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 = (rect.width - 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()) {
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()) {
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 = (rect.width - 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");
}
}
void BaseTheme::drawButtonMenu(GfxRenderer& renderer, Rect rect, int buttonCount, int selectedIndex,
const std::function<std::string(int index)>& buttonLabel, bool hasIcon,
const std::function<std::string(int index)>& rowIcon) const {
for (int i = 0; i < buttonCount; ++i) {
const int tileY = BaseMetrics::values.verticalSpacing + rect.y +
static_cast<int>(i) * (BaseMetrics::values.menuRowHeight + BaseMetrics::values.menuSpacing);
const bool selected = selectedIndex == i;
if (selected) {
renderer.fillRect(rect.x + BaseMetrics::values.contentSidePadding, tileY,
rect.width - BaseMetrics::values.contentSidePadding * 2, BaseMetrics::values.menuRowHeight);
} else {
renderer.drawRect(rect.x + BaseMetrics::values.contentSidePadding, tileY,
rect.width - BaseMetrics::values.contentSidePadding * 2, BaseMetrics::values.menuRowHeight);
}
const char* label = buttonLabel(i).c_str();
const int textWidth = renderer.getTextWidth(UI_10_FONT_ID, label);
const int textX = rect.x + (rect.width - textWidth) / 2;
const int lineHeight = renderer.getLineHeight(UI_10_FONT_ID);
const int textY =
tileY + (BaseMetrics::values.menuRowHeight - 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, selectedIndex != i);
}
}
Rect BaseTheme::drawPopup(const GfxRenderer& renderer, const char* message) const {
constexpr int margin = 15;
constexpr int y = 60;
const int textWidth = renderer.getTextWidth(UI_12_FONT_ID, message, EpdFontFamily::BOLD);
const int textHeight = renderer.getLineHeight(UI_12_FONT_ID);
const int w = textWidth + margin * 2;
const int h = textHeight + margin * 2;
const int x = (renderer.getScreenWidth() - w) / 2;
renderer.fillRect(x - 2, y - 2, w + 4, h + 4, true); // frame thickness 2
renderer.fillRect(x, y, w, h, false);
const int textX = x + (w - textWidth) / 2;
const int textY = y + margin - 2;
renderer.drawText(UI_12_FONT_ID, textX, textY, message, true, EpdFontFamily::BOLD);
renderer.displayBuffer();
return Rect{x, y, w, h};
}
void BaseTheme::fillPopupProgress(const GfxRenderer& renderer, const Rect& layout, const int progress) const {
constexpr int barHeight = 4;
const int barWidth = layout.width - 30; // twice the margin in drawPopup to match text width
const int barX = layout.x + (layout.width - barWidth) / 2;
const int barY = layout.y + layout.height - 10;
int fillWidth = barWidth * progress / 100;
renderer.fillRect(barX, barY, fillWidth, barHeight, true);
renderer.displayBuffer(HalDisplay::FAST_REFRESH);
}
void BaseTheme::drawBookProgressBar(const GfxRenderer& renderer, const size_t bookProgress) const {
int vieweableMarginTop, vieweableMarginRight, vieweableMarginBottom, vieweableMarginLeft;
renderer.getOrientedViewableTRBL(&vieweableMarginTop, &vieweableMarginRight, &vieweableMarginBottom,
&vieweableMarginLeft);
const int progressBarMaxWidth = renderer.getScreenWidth() - vieweableMarginLeft - vieweableMarginRight;
const int progressBarY =
renderer.getScreenHeight() - vieweableMarginBottom - BaseMetrics::values.bookProgressBarHeight;
const int barWidth = progressBarMaxWidth * bookProgress / 100;
renderer.fillRect(vieweableMarginLeft, progressBarY, barWidth, BaseMetrics::values.bookProgressBarHeight, true);
}

View File

@ -0,0 +1,118 @@
#pragma once
#include <cstddef>
#include <cstdint>
#include <functional>
#include <vector>
class GfxRenderer;
struct RecentBook;
struct Rect {
int x;
int y;
int width;
int height;
explicit Rect(int x = 0, int y = 0, int width = 0, int height = 0) : x(x), y(y), width(width), height(height) {}
};
struct TabInfo {
const char* label;
bool selected;
};
struct ThemeMetrics {
int batteryWidth;
int batteryHeight;
int topPadding;
int batteryBarHeight;
int headerHeight;
int verticalSpacing;
int contentSidePadding;
int listRowHeight;
int listWithSubtitleRowHeight;
int menuRowHeight;
int menuSpacing;
int tabSpacing;
int tabBarHeight;
int scrollBarWidth;
int scrollBarRightOffset;
int homeTopPadding;
int homeCoverHeight;
int homeCoverTileHeight;
int homeRecentBooksCount;
int buttonHintsHeight;
int sideButtonHintsWidth;
int versionTextRightX;
int versionTextY;
int bookProgressBarHeight;
};
// Default theme implementation (Classic Theme)
// Additional themes can inherit from this and override methods as needed
namespace BaseMetrics {
constexpr ThemeMetrics values = {.batteryWidth = 15,
.batteryHeight = 12,
.topPadding = 5,
.batteryBarHeight = 20,
.headerHeight = 45,
.verticalSpacing = 10,
.contentSidePadding = 20,
.listRowHeight = 30,
.listWithSubtitleRowHeight = 65,
.menuRowHeight = 45,
.menuSpacing = 8,
.tabSpacing = 10,
.tabBarHeight = 50,
.scrollBarWidth = 4,
.scrollBarRightOffset = 5,
.homeTopPadding = 20,
.homeCoverHeight = 400,
.homeCoverTileHeight = 400,
.homeRecentBooksCount = 1,
.buttonHintsHeight = 40,
.sideButtonHintsWidth = 30,
.versionTextRightX = 20,
.versionTextY = 738,
.bookProgressBarHeight = 4};
}
class BaseTheme {
public:
virtual ~BaseTheme() = default;
// Component drawing methods
virtual void drawProgressBar(const GfxRenderer& renderer, Rect rect, size_t current, size_t total) const;
virtual void drawBattery(const GfxRenderer& renderer, Rect rect, bool showPercentage = true) const;
virtual void drawButtonHints(GfxRenderer& renderer, const char* btn1, const char* btn2, const char* btn3,
const char* btn4) const;
virtual void drawSideButtonHints(const GfxRenderer& renderer, const char* topBtn, const char* bottomBtn) const;
virtual void drawList(const GfxRenderer& renderer, Rect rect, int itemCount, int selectedIndex,
const std::function<std::string(int index)>& rowTitle, bool hasSubtitle,
const std::function<std::string(int index)>& rowSubtitle, bool hasIcon,
const std::function<std::string(int index)>& rowIcon, bool hasValue,
const std::function<std::string(int index)>& rowValue) const;
virtual void drawHeader(const GfxRenderer& renderer, Rect rect, const char* title) const;
virtual void drawTabBar(const GfxRenderer& renderer, Rect rect, const std::vector<TabInfo>& tabs,
bool selected) const;
virtual void drawRecentBookCover(GfxRenderer& renderer, Rect rect, const std::vector<RecentBook>& recentBooks,
const int selectorIndex, bool& coverRendered, bool& coverBufferStored,
bool& bufferRestored, std::function<bool()> storeCoverBuffer) const;
virtual void drawButtonMenu(GfxRenderer& renderer, Rect rect, int buttonCount, int selectedIndex,
const std::function<std::string(int index)>& buttonLabel, bool hasIcon,
const std::function<std::string(int index)>& rowIcon) const;
virtual Rect drawPopup(const GfxRenderer& renderer, const char* message) const;
virtual void fillPopupProgress(const GfxRenderer& renderer, const Rect& layout, const int progress) const;
virtual void drawBookProgressBar(const GfxRenderer& renderer, const size_t bookProgress) const;
};

View File

@ -0,0 +1,374 @@
#include "LyraTheme.h"
#include <GfxRenderer.h>
#include <SDCardManager.h>
#include <cstdint>
#include <string>
#include "Battery.h"
#include "RecentBooksStore.h"
#include "components/UITheme.h"
#include "fontIds.h"
#include "util/StringUtils.h"
// Internal constants
namespace {
constexpr int batteryPercentSpacing = 4;
constexpr int hPaddingInSelection = 8;
constexpr int cornerRadius = 6;
constexpr int topHintButtonY = 345;
} // namespace
void LyraTheme::drawBattery(const GfxRenderer& renderer, Rect rect, const bool showPercentage) const {
// Left aligned battery icon and percentage
const uint16_t percentage = battery.readPercentage();
if (showPercentage) {
const auto percentageText = std::to_string(percentage) + "%";
renderer.drawText(SMALL_FONT_ID, rect.x + batteryPercentSpacing + LyraMetrics::values.batteryWidth, rect.y,
percentageText.c_str());
}
// 1 column on left, 2 columns on right, 5 columns of battery body
const int x = rect.x;
const int y = rect.y + 6;
const int battWidth = LyraMetrics::values.batteryWidth;
// Top line
renderer.drawLine(x + 1, y, x + battWidth - 3, y);
// Bottom line
renderer.drawLine(x + 1, y + rect.height - 1, x + battWidth - 3, y + rect.height - 1);
// Left line
renderer.drawLine(x, y + 1, x, y + rect.height - 2);
// Battery end
renderer.drawLine(x + battWidth - 2, y + 1, x + battWidth - 2, y + rect.height - 2);
renderer.drawPixel(x + battWidth - 1, y + 3);
renderer.drawPixel(x + battWidth - 1, y + rect.height - 4);
renderer.drawLine(x + battWidth - 0, y + 4, x + battWidth - 0, y + rect.height - 5);
// Draw bars
if (percentage > 10) {
renderer.fillRect(x + 2, y + 2, 3, rect.height - 4);
}
if (percentage > 40) {
renderer.fillRect(x + 6, y + 2, 3, rect.height - 4);
}
if (percentage > 70) {
renderer.fillRect(x + 10, y + 2, 3, rect.height - 4);
}
}
void LyraTheme::drawHeader(const GfxRenderer& renderer, Rect rect, const char* title) const {
renderer.fillRect(rect.x, rect.y, rect.width, rect.height, false);
const bool showBatteryPercentage =
SETTINGS.hideBatteryPercentage != CrossPointSettings::HIDE_BATTERY_PERCENTAGE::HIDE_ALWAYS;
int batteryX = rect.x + rect.width - LyraMetrics::values.contentSidePadding - LyraMetrics::values.batteryWidth;
if (showBatteryPercentage) {
const uint16_t percentage = battery.readPercentage();
const auto percentageText = std::to_string(percentage) + "%";
batteryX -= renderer.getTextWidth(SMALL_FONT_ID, percentageText.c_str());
}
drawBattery(renderer,
Rect{batteryX, rect.y + 10, LyraMetrics::values.batteryWidth, LyraMetrics::values.batteryHeight},
showBatteryPercentage);
if (title) {
auto truncatedTitle = renderer.truncatedText(
UI_12_FONT_ID, title, rect.width - LyraMetrics::values.contentSidePadding * 2, EpdFontFamily::BOLD);
renderer.drawText(UI_12_FONT_ID, rect.x + LyraMetrics::values.contentSidePadding,
rect.y + LyraMetrics::values.batteryBarHeight + 3, truncatedTitle.c_str(), true,
EpdFontFamily::BOLD);
renderer.drawLine(rect.x, rect.y + rect.height - 3, rect.x + rect.width, rect.y + rect.height - 3, 3, true);
}
}
void LyraTheme::drawTabBar(const GfxRenderer& renderer, Rect rect, const std::vector<TabInfo>& tabs,
bool selected) const {
int currentX = rect.x + LyraMetrics::values.contentSidePadding;
if (selected) {
renderer.fillRectDither(rect.x, rect.y, rect.width, rect.height, Color::LightGray);
}
for (const auto& tab : tabs) {
const int textWidth = renderer.getTextWidth(UI_10_FONT_ID, tab.label, EpdFontFamily::REGULAR);
if (tab.selected) {
if (selected) {
renderer.fillRoundedRect(currentX, rect.y + 1, textWidth + 2 * hPaddingInSelection, rect.height - 4,
cornerRadius, Color::Black);
} else {
renderer.fillRectDither(currentX, rect.y, textWidth + 2 * hPaddingInSelection, rect.height - 3,
Color::LightGray);
renderer.drawLine(currentX, rect.y + rect.height - 3, currentX + textWidth + 2 * hPaddingInSelection,
rect.y + rect.height - 3, 2, true);
}
}
renderer.drawText(UI_10_FONT_ID, currentX + hPaddingInSelection, rect.y + 6, tab.label, !(tab.selected && selected),
EpdFontFamily::REGULAR);
currentX += textWidth + LyraMetrics::values.tabSpacing + 2 * hPaddingInSelection;
}
renderer.drawLine(rect.x, rect.y + rect.height - 1, rect.x + rect.width, rect.y + rect.height - 1, true);
}
void LyraTheme::drawList(const GfxRenderer& renderer, Rect rect, int itemCount, int selectedIndex,
const std::function<std::string(int index)>& rowTitle, bool hasSubtitle,
const std::function<std::string(int index)>& rowSubtitle, bool hasIcon,
const std::function<std::string(int index)>& rowIcon, bool hasValue,
const std::function<std::string(int index)>& rowValue) const {
int rowHeight = hasSubtitle ? LyraMetrics::values.listWithSubtitleRowHeight : LyraMetrics::values.listRowHeight;
int pageItems = rect.height / rowHeight;
const int totalPages = (itemCount + pageItems - 1) / pageItems;
if (totalPages > 1) {
const int scrollAreaHeight = rect.height;
// Draw scroll bar
const int scrollBarHeight = (scrollAreaHeight * pageItems) / itemCount;
const int currentPage = selectedIndex / pageItems;
const int scrollBarY = rect.y + ((scrollAreaHeight - scrollBarHeight) * currentPage) / (totalPages - 1);
const int scrollBarX = rect.x + rect.width - LyraMetrics::values.scrollBarRightOffset;
renderer.drawLine(scrollBarX, rect.y, scrollBarX, rect.y + scrollAreaHeight, true);
renderer.fillRect(scrollBarX - LyraMetrics::values.scrollBarWidth, scrollBarY, LyraMetrics::values.scrollBarWidth,
scrollBarHeight, true);
}
// Draw selection
int contentWidth =
rect.width -
(totalPages > 1 ? (LyraMetrics::values.scrollBarWidth + LyraMetrics::values.scrollBarRightOffset) : 1);
if (selectedIndex >= 0) {
renderer.fillRoundedRect(LyraMetrics::values.contentSidePadding, rect.y + selectedIndex % pageItems * rowHeight,
contentWidth - LyraMetrics::values.contentSidePadding * 2, rowHeight, cornerRadius,
Color::LightGray);
}
// Draw all items
const auto pageStartIndex = selectedIndex / pageItems * pageItems;
for (int i = pageStartIndex; i < itemCount && i < pageStartIndex + pageItems; i++) {
const int itemY = rect.y + (i % pageItems) * rowHeight;
// Draw name
int textWidth = contentWidth - LyraMetrics::values.contentSidePadding * 2 - hPaddingInSelection * 2 -
(hasValue ? 60 : 0); // TODO truncate according to value width?
auto itemName = rowTitle(i);
auto item = renderer.truncatedText(UI_10_FONT_ID, itemName.c_str(), textWidth);
renderer.drawText(UI_10_FONT_ID, rect.x + LyraMetrics::values.contentSidePadding + hPaddingInSelection * 2,
itemY + 6, item.c_str(), true);
if (hasSubtitle) {
// Draw subtitle
std::string subtitleText = rowSubtitle(i);
auto subtitle = renderer.truncatedText(SMALL_FONT_ID, subtitleText.c_str(), textWidth);
renderer.drawText(SMALL_FONT_ID, rect.x + LyraMetrics::values.contentSidePadding + hPaddingInSelection * 2,
itemY + 30, subtitle.c_str(), true);
}
if (hasValue) {
// Draw value
std::string valueText = rowValue(i);
if (!valueText.empty()) {
const auto valueTextWidth = renderer.getTextWidth(UI_10_FONT_ID, valueText.c_str());
if (i == selectedIndex) {
renderer.fillRoundedRect(
contentWidth - LyraMetrics::values.contentSidePadding - hPaddingInSelection * 2 - valueTextWidth, itemY,
valueTextWidth + hPaddingInSelection * 2, rowHeight, cornerRadius, Color::Black);
}
renderer.drawText(UI_10_FONT_ID,
contentWidth - LyraMetrics::values.contentSidePadding - hPaddingInSelection - valueTextWidth,
itemY + 6, valueText.c_str(), i != selectedIndex);
}
}
}
}
void LyraTheme::drawButtonHints(GfxRenderer& renderer, const char* btn1, const char* btn2, const char* btn3,
const char* btn4) const {
const GfxRenderer::Orientation orig_orientation = renderer.getOrientation();
renderer.setOrientation(GfxRenderer::Orientation::Portrait);
const int pageHeight = renderer.getScreenHeight();
constexpr int buttonWidth = 80;
constexpr int smallButtonHeight = 15;
constexpr int buttonHeight = LyraMetrics::values.buttonHintsHeight;
constexpr int buttonY = LyraMetrics::values.buttonHintsHeight; // Distance from bottom
constexpr int textYOffset = 7; // Distance from top of button to text baseline
constexpr int buttonPositions[] = {58, 146, 254, 342};
const char* labels[] = {btn1, btn2, btn3, btn4};
for (int i = 0; i < 4; i++) {
// Only draw if the label is non-empty
const int x = buttonPositions[i];
renderer.fillRect(x, pageHeight - buttonY, buttonWidth, buttonHeight, false);
if (labels[i] != nullptr && labels[i][0] != '\0') {
renderer.drawRoundedRect(x, pageHeight - buttonY, buttonWidth, buttonHeight, 1, cornerRadius, true, true, false,
false, true);
const int textWidth = renderer.getTextWidth(SMALL_FONT_ID, labels[i]);
const int textX = x + (buttonWidth - 1 - textWidth) / 2;
renderer.drawText(SMALL_FONT_ID, textX, pageHeight - buttonY + textYOffset, labels[i]);
} else {
renderer.drawRoundedRect(x, pageHeight - smallButtonHeight, buttonWidth, smallButtonHeight, 1, cornerRadius, true,
true, false, false, true);
}
}
renderer.setOrientation(orig_orientation);
}
void LyraTheme::drawSideButtonHints(const GfxRenderer& renderer, const char* topBtn, const char* bottomBtn) const {
const int screenWidth = renderer.getScreenWidth();
constexpr int buttonWidth = LyraMetrics::values.sideButtonHintsWidth; // Width on screen (height when rotated)
constexpr int buttonHeight = 78; // Height on screen (width when rotated)
// Position for the button group - buttons share a border so they're adjacent
const char* labels[] = {topBtn, bottomBtn};
// Draw the shared border for both buttons as one unit
const int x = screenWidth - buttonWidth;
// Draw top button outline
if (topBtn != nullptr && topBtn[0] != '\0') {
renderer.drawRoundedRect(x, topHintButtonY, buttonWidth, buttonHeight, 1, cornerRadius, true, false, true, false,
true);
}
// Draw bottom button outline
if (bottomBtn != nullptr && bottomBtn[0] != '\0') {
renderer.drawRoundedRect(x, topHintButtonY + buttonHeight + 5, buttonWidth, buttonHeight, 1, cornerRadius, true,
false, true, false, true);
}
// Draw text for each button
for (int i = 0; i < 2; i++) {
if (labels[i] != nullptr && labels[i][0] != '\0') {
const int y = topHintButtonY + (i * buttonHeight + 5);
// Draw rotated text centered in the button
const int textWidth = renderer.getTextWidth(SMALL_FONT_ID, labels[i]);
renderer.drawTextRotated90CW(SMALL_FONT_ID, x, y + (buttonHeight + textWidth) / 2, labels[i]);
}
}
}
void LyraTheme::drawRecentBookCover(GfxRenderer& renderer, Rect rect, const std::vector<RecentBook>& recentBooks,
const int selectorIndex, bool& coverRendered, bool& coverBufferStored,
bool& bufferRestored, std::function<bool()> storeCoverBuffer) const {
const int tileWidth = (rect.width - 2 * LyraMetrics::values.contentSidePadding) / 3;
const int tileHeight = rect.height;
const int bookTitleHeight = tileHeight - LyraMetrics::values.homeCoverHeight - hPaddingInSelection;
const int tileY = rect.y;
const bool hasContinueReading = !recentBooks.empty();
// 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) {
if (!coverRendered) {
for (int i = 0; i < std::min(static_cast<int>(recentBooks.size()), LyraMetrics::values.homeRecentBooksCount);
i++) {
std::string coverPath = recentBooks[i].coverBmpPath;
if (coverPath.empty()) {
continue;
}
const std::string coverBmpPath = UITheme::getCoverThumbPath(coverPath, LyraMetrics::values.homeCoverHeight);
int tileX = LyraMetrics::values.contentSidePadding + tileWidth * i;
// First time: load cover from SD and render
FsFile file;
if (SdMan.openFileForRead("HOME", coverBmpPath, file)) {
Bitmap bitmap(file);
if (bitmap.parseHeaders() == BmpReaderError::Ok) {
float coverHeight = static_cast<float>(bitmap.getHeight());
float coverWidth = static_cast<float>(bitmap.getWidth());
float ratio = coverWidth / coverHeight;
const float tileRatio = static_cast<float>(tileWidth - 2 * hPaddingInSelection) /
static_cast<float>(LyraMetrics::values.homeCoverHeight);
float cropX = 1.0f - (tileRatio / ratio);
renderer.drawBitmap(bitmap, tileX + hPaddingInSelection, tileY + hPaddingInSelection,
tileWidth - 2 * hPaddingInSelection, LyraMetrics::values.homeCoverHeight, cropX);
}
file.close();
}
}
coverBufferStored = storeCoverBuffer();
coverRendered = true;
}
for (int i = 0; i < std::min(static_cast<int>(recentBooks.size()), LyraMetrics::values.homeRecentBooksCount); i++) {
bool bookSelected = (selectorIndex == i);
int tileX = LyraMetrics::values.contentSidePadding + tileWidth * i;
auto title =
renderer.truncatedText(UI_10_FONT_ID, recentBooks[i].title.c_str(), tileWidth - 2 * hPaddingInSelection);
if (bookSelected) {
// Draw selection box
renderer.fillRoundedRect(tileX, tileY, tileWidth, hPaddingInSelection, cornerRadius, true, true, false, false,
Color::LightGray);
renderer.fillRectDither(tileX, tileY + hPaddingInSelection, hPaddingInSelection,
LyraMetrics::values.homeCoverHeight, Color::LightGray);
renderer.fillRectDither(tileX + tileWidth - hPaddingInSelection, tileY + hPaddingInSelection,
hPaddingInSelection, LyraMetrics::values.homeCoverHeight, Color::LightGray);
renderer.fillRoundedRect(tileX, tileY + LyraMetrics::values.homeCoverHeight + hPaddingInSelection, tileWidth,
bookTitleHeight, cornerRadius, false, false, true, true, Color::LightGray);
}
renderer.drawText(UI_10_FONT_ID, tileX + hPaddingInSelection,
tileY + tileHeight - bookTitleHeight + hPaddingInSelection + 5, title.c_str(), true);
}
}
}
void LyraTheme::drawButtonMenu(GfxRenderer& renderer, Rect rect, int buttonCount, int selectedIndex,
const std::function<std::string(int index)>& buttonLabel, bool hasIcon,
const std::function<std::string(int index)>& rowIcon) const {
for (int i = 0; i < buttonCount; ++i) {
int tileWidth = (rect.width - LyraMetrics::values.contentSidePadding * 2 - LyraMetrics::values.menuSpacing) / 2;
Rect tileRect =
Rect{rect.x + LyraMetrics::values.contentSidePadding + (LyraMetrics::values.menuSpacing + tileWidth) * (i % 2),
rect.y + static_cast<int>(i / 2) * (LyraMetrics::values.menuRowHeight + LyraMetrics::values.menuSpacing),
tileWidth, LyraMetrics::values.menuRowHeight};
const bool selected = selectedIndex == i;
if (selected) {
renderer.fillRoundedRect(tileRect.x, tileRect.y, tileRect.width, tileRect.height, cornerRadius, Color::LightGray);
}
const char* label = buttonLabel(i).c_str();
const int textX = tileRect.x + 16;
const int lineHeight = renderer.getLineHeight(UI_12_FONT_ID);
const int textY = tileRect.y + (LyraMetrics::values.menuRowHeight - lineHeight) / 2;
// Invert text when the tile is selected, to contrast with the filled background
renderer.drawText(UI_12_FONT_ID, textX, textY, label, true);
}
}
Rect LyraTheme::drawPopup(const GfxRenderer& renderer, const char* message) const {
constexpr int margin = 15;
constexpr int y = 60;
const int textWidth = renderer.getTextWidth(UI_12_FONT_ID, message, EpdFontFamily::REGULAR);
const int textHeight = renderer.getLineHeight(UI_12_FONT_ID);
const int w = textWidth + margin * 2;
const int h = textHeight + margin * 2;
const int x = (renderer.getScreenWidth() - w) / 2;
renderer.fillRect(x - 5, y - 5, w + 10, h + 10, false);
renderer.drawRect(x, y, w, h, true);
const int textX = x + (w - textWidth) / 2;
const int textY = y + margin - 2;
renderer.drawText(UI_12_FONT_ID, textX, textY, message, true, EpdFontFamily::REGULAR);
renderer.displayBuffer();
return Rect{x, y, w, h};
}

View File

@ -0,0 +1,58 @@
#pragma once
#include "components/themes/BaseTheme.h"
class GfxRenderer;
// Lyra theme metrics (zero runtime cost)
namespace LyraMetrics {
constexpr ThemeMetrics values = {.batteryWidth = 16,
.batteryHeight = 12,
.topPadding = 5,
.batteryBarHeight = 40,
.headerHeight = 84,
.verticalSpacing = 16,
.contentSidePadding = 20,
.listRowHeight = 40,
.listWithSubtitleRowHeight = 60,
.menuRowHeight = 64,
.menuSpacing = 8,
.tabSpacing = 8,
.tabBarHeight = 40,
.scrollBarWidth = 4,
.scrollBarRightOffset = 5,
.homeTopPadding = 56,
.homeCoverHeight = 226,
.homeCoverTileHeight = 287,
.homeRecentBooksCount = 3,
.buttonHintsHeight = 40,
.sideButtonHintsWidth = 19,
.versionTextRightX = 20,
.versionTextY = 55,
.bookProgressBarHeight = 4};
}
class LyraTheme : public BaseTheme {
public:
// Component drawing methods
// void drawProgressBar(const GfxRenderer& renderer, Rect rect, size_t current, size_t total) override;
void drawBattery(const GfxRenderer& renderer, Rect rect, bool showPercentage = true) const override;
void drawHeader(const GfxRenderer& renderer, Rect rect, const char* title) const override;
void drawTabBar(const GfxRenderer& renderer, Rect rect, const std::vector<TabInfo>& tabs,
bool selected) const override;
void drawList(const GfxRenderer& renderer, Rect rect, int itemCount, int selectedIndex,
const std::function<std::string(int index)>& rowTitle, bool hasSubtitle,
const std::function<std::string(int index)>& rowSubtitle, bool hasIcon,
const std::function<std::string(int index)>& rowIcon, bool hasValue,
const std::function<std::string(int index)>& rowValue) const override;
void drawButtonHints(GfxRenderer& renderer, const char* btn1, const char* btn2, const char* btn3,
const char* btn4) const override;
void drawSideButtonHints(const GfxRenderer& renderer, const char* topBtn, const char* bottomBtn) const override;
void drawButtonMenu(GfxRenderer& renderer, Rect rect, int buttonCount, int selectedIndex,
const std::function<std::string(int index)>& buttonLabel, bool hasIcon,
const std::function<std::string(int index)>& rowIcon) const override;
void drawRecentBookCover(GfxRenderer& renderer, Rect rect, const std::vector<RecentBook>& recentBooks,
const int selectorIndex, bool& coverRendered, bool& coverBufferStored, bool& bufferRestored,
std::function<bool()> storeCoverBuffer) const override;
Rect drawPopup(const GfxRenderer& renderer, const char* message) const override;
};

View File

@ -20,10 +20,12 @@
#include "activities/browser/OpdsBookBrowserActivity.h" #include "activities/browser/OpdsBookBrowserActivity.h"
#include "activities/home/HomeActivity.h" #include "activities/home/HomeActivity.h"
#include "activities/home/MyLibraryActivity.h" #include "activities/home/MyLibraryActivity.h"
#include "activities/home/RecentBooksActivity.h"
#include "activities/network/CrossPointWebServerActivity.h" #include "activities/network/CrossPointWebServerActivity.h"
#include "activities/reader/ReaderActivity.h" #include "activities/reader/ReaderActivity.h"
#include "activities/settings/SettingsActivity.h" #include "activities/settings/SettingsActivity.h"
#include "activities/util/FullScreenMessageActivity.h" #include "activities/util/FullScreenMessageActivity.h"
#include "components/UITheme.h"
#include "fontIds.h" #include "fontIds.h"
HalDisplay display; HalDisplay display;
@ -203,13 +205,13 @@ void enterDeepSleep() {
} }
void onGoHome(); void onGoHome();
void onGoToMyLibraryWithTab(const std::string& path, MyLibraryActivity::Tab tab); void onGoToMyLibraryWithPath(const std::string& path);
void onGoToReader(const std::string& initialEpubPath, MyLibraryActivity::Tab fromTab) { void onGoToRecentBooks();
void onGoToReader(const std::string& initialEpubPath) {
exitActivity(); exitActivity();
enterNewActivity( enterNewActivity(
new ReaderActivity(renderer, mappedInputManager, initialEpubPath, fromTab, onGoHome, onGoToMyLibraryWithTab)); new ReaderActivity(renderer, mappedInputManager, initialEpubPath, onGoHome, onGoToMyLibraryWithPath));
} }
void onContinueReading() { onGoToReader(APP_STATE.openEpubPath, MyLibraryActivity::Tab::Recent); }
void onGoToFileTransfer() { void onGoToFileTransfer() {
exitActivity(); exitActivity();
@ -226,9 +228,14 @@ void onGoToMyLibrary() {
enterNewActivity(new MyLibraryActivity(renderer, mappedInputManager, onGoHome, onGoToReader)); enterNewActivity(new MyLibraryActivity(renderer, mappedInputManager, onGoHome, onGoToReader));
} }
void onGoToMyLibraryWithTab(const std::string& path, MyLibraryActivity::Tab tab) { void onGoToRecentBooks() {
exitActivity(); exitActivity();
enterNewActivity(new MyLibraryActivity(renderer, mappedInputManager, onGoHome, onGoToReader, tab, path)); enterNewActivity(new RecentBooksActivity(renderer, mappedInputManager, onGoHome, onGoToReader));
}
void onGoToMyLibraryWithPath(const std::string& path) {
exitActivity();
enterNewActivity(new MyLibraryActivity(renderer, mappedInputManager, onGoHome, onGoToReader, path));
} }
void onGoToBrowser() { void onGoToBrowser() {
@ -238,8 +245,8 @@ void onGoToBrowser() {
void onGoHome() { void onGoHome() {
exitActivity(); exitActivity();
enterNewActivity(new HomeActivity(renderer, mappedInputManager, onContinueReading, onGoToMyLibrary, onGoToSettings, enterNewActivity(new HomeActivity(renderer, mappedInputManager, onGoToReader, onGoToMyLibrary, onGoToRecentBooks,
onGoToFileTransfer, onGoToBrowser)); onGoToSettings, onGoToFileTransfer, onGoToBrowser));
} }
void setupDisplayAndFonts() { void setupDisplayAndFonts() {
@ -293,6 +300,7 @@ void setup() {
SETTINGS.loadFromFile(); SETTINGS.loadFromFile();
KOREADER_STORE.loadFromFile(); KOREADER_STORE.loadFromFile();
UITheme::getInstance().reload();
switch (gpio.getWakeupReason()) { switch (gpio.getWakeupReason()) {
case HalGPIO::WakeupReason::PowerButton: case HalGPIO::WakeupReason::PowerButton:
@ -330,7 +338,7 @@ void setup() {
const auto path = APP_STATE.openEpubPath; const auto path = APP_STATE.openEpubPath;
APP_STATE.openEpubPath = ""; APP_STATE.openEpubPath = "";
APP_STATE.saveToFile(); APP_STATE.saveToFile();
onGoToReader(path, MyLibraryActivity::Tab::Recent); onGoToReader(path);
} }
// Ensure we're not still holding the power button before leaving setup // Ensure we're not still holding the power button before leaving setup