mirror of
https://github.com/daveallie/crosspoint-reader.git
synced 2026-02-04 14:47:37 +03:00
Merge 1b15277c7e into f67c544e16
This commit is contained in:
commit
1eff19c2d0
@ -428,11 +428,12 @@ bool Epub::generateCoverBmp(bool cropped) const {
|
||||
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
|
||||
if (SdMan.exists(getThumbBmpPath().c_str())) {
|
||||
if (SdMan.exists(getThumbBmpPath(height).c_str())) {
|
||||
return true;
|
||||
}
|
||||
|
||||
@ -444,10 +445,7 @@ bool Epub::generateThumbBmp() const {
|
||||
const auto coverImageHref = bookMetadataCache->coreMetadata.coverItemHref;
|
||||
if (coverImageHref.empty()) {
|
||||
Serial.printf("[%lu] [EBP] No known cover image for thumbnail\n", millis());
|
||||
return false;
|
||||
}
|
||||
|
||||
if (coverImageHref.substr(coverImageHref.length() - 4) == ".jpg" ||
|
||||
} else 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());
|
||||
const auto coverJpgTempPath = getCachePath() + "/.cover.jpg";
|
||||
@ -464,14 +462,14 @@ bool Epub::generateThumbBmp() const {
|
||||
}
|
||||
|
||||
FsFile thumbBmp;
|
||||
if (!SdMan.openFileForWrite("EBP", getThumbBmpPath(), thumbBmp)) {
|
||||
if (!SdMan.openFileForWrite("EBP", getThumbBmpPath(height), thumbBmp)) {
|
||||
coverJpg.close();
|
||||
return false;
|
||||
}
|
||||
// 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)
|
||||
constexpr int THUMB_TARGET_WIDTH = 240;
|
||||
constexpr int THUMB_TARGET_HEIGHT = 400;
|
||||
int THUMB_TARGET_WIDTH = height * 0.6;
|
||||
int THUMB_TARGET_HEIGHT = height;
|
||||
const bool success = JpegToBmpConverter::jpegFileTo1BitBmpStreamWithSize(coverJpg, thumbBmp, THUMB_TARGET_WIDTH,
|
||||
THUMB_TARGET_HEIGHT);
|
||||
coverJpg.close();
|
||||
@ -480,7 +478,7 @@ bool Epub::generateThumbBmp() const {
|
||||
|
||||
if (!success) {
|
||||
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(),
|
||||
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());
|
||||
}
|
||||
|
||||
// Write an empty bmp file to avoid generation attempts in the future
|
||||
FsFile thumbBmp;
|
||||
SdMan.openFileForWrite("EBP", getThumbBmpPath(height), thumbBmp);
|
||||
thumbBmp.close();
|
||||
return false;
|
||||
}
|
||||
|
||||
|
||||
@ -48,7 +48,8 @@ class Epub {
|
||||
std::string getCoverBmpPath(bool cropped = false) const;
|
||||
bool generateCoverBmp(bool cropped = false) 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,
|
||||
bool trailingNullByte = false) const;
|
||||
bool readItemContentsToStream(const std::string& itemHref, Print& out, size_t chunkSize) const;
|
||||
|
||||
@ -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 {
|
||||
drawLine(x, y, x + width - 1, y, 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);
|
||||
}
|
||||
|
||||
// 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 {
|
||||
for (int fillY = y; fillY < y + height; fillY++) {
|
||||
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 {
|
||||
int rotatedX = 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);
|
||||
}
|
||||
|
||||
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,
|
||||
const float cropX, const float cropY) const {
|
||||
// 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;
|
||||
}
|
||||
|
||||
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 {
|
||||
if (fontMap.count(fontId) == 0) {
|
||||
Serial.printf("[%lu] [GFX] Font %d not found\n", millis(), fontId);
|
||||
|
||||
@ -7,6 +7,10 @@
|
||||
|
||||
#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 {
|
||||
public:
|
||||
enum RenderMode { BW, GRAYSCALE_LSB, GRAYSCALE_MSB };
|
||||
@ -34,6 +38,8 @@ class GfxRenderer {
|
||||
EpdFontFamily::Style style) const;
|
||||
void freeBwBufferChunks();
|
||||
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:
|
||||
explicit GfxRenderer(HalDisplay& halDisplay) : display(halDisplay), renderMode(BW), orientation(Portrait) {}
|
||||
@ -63,9 +69,20 @@ class GfxRenderer {
|
||||
// Drawing
|
||||
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, 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, 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 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 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,
|
||||
float cropY = 0) 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,
|
||||
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)
|
||||
void drawTextRotated90CW(int fontId, int x, int y, const char* text, bool black = true,
|
||||
EpdFontFamily::Style style = EpdFontFamily::REGULAR) const;
|
||||
int getTextHeight(int fontId) const;
|
||||
|
||||
public:
|
||||
// Grayscale functions
|
||||
void setRenderMode(const RenderMode mode) { this->renderMode = mode; }
|
||||
void copyGrayscaleLsbBuffers() const;
|
||||
|
||||
@ -301,11 +301,12 @@ bool Xtc::generateCoverBmp() const {
|
||||
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
|
||||
if (SdMan.exists(getThumbBmpPath().c_str())) {
|
||||
if (SdMan.exists(getThumbBmpPath(height).c_str())) {
|
||||
return true;
|
||||
}
|
||||
|
||||
@ -333,8 +334,8 @@ bool Xtc::generateThumbBmp() const {
|
||||
const uint8_t bitDepth = parser->getBitDepth();
|
||||
|
||||
// Calculate target dimensions for thumbnail (fit within 240x400 Continue Reading card)
|
||||
constexpr int THUMB_TARGET_WIDTH = 240;
|
||||
constexpr int THUMB_TARGET_HEIGHT = 400;
|
||||
int THUMB_TARGET_WIDTH = height * 0.6;
|
||||
int THUMB_TARGET_HEIGHT = height;
|
||||
|
||||
// Calculate scale factor
|
||||
float scaleX = static_cast<float>(THUMB_TARGET_WIDTH) / pageInfo.width;
|
||||
@ -348,7 +349,7 @@ bool Xtc::generateThumbBmp() const {
|
||||
if (generateCoverBmp()) {
|
||||
FsFile src, dst;
|
||||
if (SdMan.openFileForRead("XTC", getCoverBmpPath(), src)) {
|
||||
if (SdMan.openFileForWrite("XTC", getThumbBmpPath(), dst)) {
|
||||
if (SdMan.openFileForWrite("XTC", getThumbBmpPath(height), dst)) {
|
||||
uint8_t buffer[512];
|
||||
while (src.available()) {
|
||||
size_t bytesRead = src.read(buffer, sizeof(buffer));
|
||||
@ -359,7 +360,7 @@ bool Xtc::generateThumbBmp() const {
|
||||
src.close();
|
||||
}
|
||||
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;
|
||||
}
|
||||
@ -393,7 +394,7 @@ bool Xtc::generateThumbBmp() const {
|
||||
|
||||
// Create thumbnail BMP file - use 1-bit format for fast home screen rendering (no gray passes)
|
||||
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());
|
||||
free(pageBuffer);
|
||||
return false;
|
||||
@ -558,7 +559,7 @@ bool Xtc::generateThumbBmp() const {
|
||||
free(pageBuffer);
|
||||
|
||||
Serial.printf("[%lu] [XTC] Generated thumb BMP (%dx%d): %s\n", millis(), thumbWidth, thumbHeight,
|
||||
getThumbBmpPath().c_str());
|
||||
getThumbBmpPath(height).c_str());
|
||||
return true;
|
||||
}
|
||||
|
||||
|
||||
@ -65,7 +65,8 @@ class Xtc {
|
||||
bool generateCoverBmp() const;
|
||||
// Thumbnail support (for Continue Reading card)
|
||||
std::string getThumbBmpPath() const;
|
||||
bool generateThumbBmp() const;
|
||||
std::string getThumbBmpPath(int height) const;
|
||||
bool generateThumbBmp(int height) const;
|
||||
|
||||
// Page access
|
||||
uint32_t getPageCount() const;
|
||||
|
||||
@ -22,7 +22,7 @@ void readAndValidate(FsFile& file, uint8_t& member, const uint8_t maxValue) {
|
||||
namespace {
|
||||
constexpr uint8_t SETTINGS_FILE_VERSION = 1;
|
||||
// Increment this when adding new persisted settings fields
|
||||
constexpr uint8_t SETTINGS_COUNT = 23;
|
||||
constexpr uint8_t SETTINGS_COUNT = 24;
|
||||
constexpr char SETTINGS_FILE[] = "/.crosspoint/settings.bin";
|
||||
} // namespace
|
||||
|
||||
@ -61,6 +61,7 @@ bool CrossPointSettings::saveToFile() const {
|
||||
serialization::writeString(outputFile, std::string(opdsPassword));
|
||||
serialization::writePod(outputFile, sleepScreenCoverFilter);
|
||||
// New fields added at end for backward compatibility
|
||||
serialization::writePod(outputFile, uiTheme);
|
||||
outputFile.close();
|
||||
|
||||
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);
|
||||
if (++settingsRead >= fileSettingsCount) break;
|
||||
// New fields added at end for backward compatibility
|
||||
serialization::readPod(inputFile, uiTheme);
|
||||
if (++settingsRead >= fileSettingsCount) break;
|
||||
} while (false);
|
||||
|
||||
inputFile.close();
|
||||
|
||||
@ -97,6 +97,9 @@ class CrossPointSettings {
|
||||
// Hide battery percentage
|
||||
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
|
||||
uint8_t sleepScreen = DARK;
|
||||
// Sleep screen cover mode settings
|
||||
@ -137,6 +140,8 @@ class CrossPointSettings {
|
||||
uint8_t hideBatteryPercentage = HIDE_NEVER;
|
||||
// Long-press chapter skip on side buttons
|
||||
uint8_t longPressChapterSkip = 1;
|
||||
// UI Theme
|
||||
uint8_t uiTheme = CLASSIC;
|
||||
|
||||
~CrossPointSettings() = default;
|
||||
|
||||
|
||||
@ -14,7 +14,8 @@ constexpr int MAX_RECENT_BOOKS = 10;
|
||||
|
||||
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
|
||||
auto it =
|
||||
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
|
||||
recentBooks.insert(recentBooks.begin(), {path, title, author});
|
||||
recentBooks.insert(recentBooks.begin(), {path, title, author, coverBmpPath});
|
||||
|
||||
// Trim to max size
|
||||
if (recentBooks.size() > MAX_RECENT_BOOKS) {
|
||||
@ -50,6 +51,7 @@ bool RecentBooksStore::saveToFile() const {
|
||||
serialization::writeString(outputFile, book.path);
|
||||
serialization::writeString(outputFile, book.title);
|
||||
serialization::writeString(outputFile, book.author);
|
||||
serialization::writeString(outputFile, book.coverBmpPath);
|
||||
}
|
||||
|
||||
outputFile.close();
|
||||
@ -77,7 +79,7 @@ bool RecentBooksStore::loadFromFile() {
|
||||
serialization::readString(inputFile, path);
|
||||
// Title and author will be empty, they will be filled when the book is
|
||||
// opened again
|
||||
recentBooks.push_back({path, "", ""});
|
||||
recentBooks.push_back({path, "", "", ""});
|
||||
}
|
||||
} else {
|
||||
Serial.printf("[%lu] [RBS] Deserialization failed: Unknown version %u\n", millis(), version);
|
||||
@ -92,11 +94,12 @@ bool RecentBooksStore::loadFromFile() {
|
||||
recentBooks.reserve(count);
|
||||
|
||||
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, title);
|
||||
serialization::readString(inputFile, author);
|
||||
recentBooks.push_back({path, title, author});
|
||||
serialization::readString(inputFile, coverBmpPath);
|
||||
recentBooks.push_back({path, title, author, coverBmpPath});
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@ -6,6 +6,7 @@ struct RecentBook {
|
||||
std::string path;
|
||||
std::string title;
|
||||
std::string author;
|
||||
std::string coverBmpPath;
|
||||
|
||||
bool operator==(const RecentBook& other) const { return path == other.path; }
|
||||
};
|
||||
@ -23,7 +24,8 @@ class RecentBooksStore {
|
||||
static RecentBooksStore& getInstance() { return instance; }
|
||||
|
||||
// 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)
|
||||
const std::vector<RecentBook>& getBooks() const { return recentBooks; }
|
||||
|
||||
@ -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());
|
||||
}
|
||||
@ -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);
|
||||
};
|
||||
@ -8,15 +8,14 @@
|
||||
|
||||
#include "CrossPointSettings.h"
|
||||
#include "CrossPointState.h"
|
||||
#include "ScreenComponents.h"
|
||||
#include "components/UITheme.h"
|
||||
#include "fontIds.h"
|
||||
#include "images/CrossLarge.h"
|
||||
#include "util/StringUtils.h"
|
||||
|
||||
void SleepActivity::onEnter() {
|
||||
Activity::onEnter();
|
||||
|
||||
ScreenComponents::drawPopup(renderer, "Entering Sleep...");
|
||||
GUI.drawPopup(renderer, "Entering Sleep...");
|
||||
|
||||
if (SETTINGS.sleepScreen == CrossPointSettings::SLEEP_SCREEN_MODE::BLANK) {
|
||||
return renderBlankSleepScreen();
|
||||
|
||||
@ -8,8 +8,8 @@
|
||||
|
||||
#include "CrossPointSettings.h"
|
||||
#include "MappedInputManager.h"
|
||||
#include "ScreenComponents.h"
|
||||
#include "activities/network/WifiSelectionActivity.h"
|
||||
#include "components/UITheme.h"
|
||||
#include "fontIds.h"
|
||||
#include "network/HttpDownloader.h"
|
||||
#include "util/StringUtils.h"
|
||||
@ -176,7 +176,7 @@ void OpdsBookBrowserActivity::render() const {
|
||||
if (state == BrowserState::CHECK_WIFI) {
|
||||
renderer.drawCenteredText(UI_10_FONT_ID, pageHeight / 2, statusMessage.c_str());
|
||||
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();
|
||||
return;
|
||||
}
|
||||
@ -184,7 +184,7 @@ void OpdsBookBrowserActivity::render() const {
|
||||
if (state == BrowserState::LOADING) {
|
||||
renderer.drawCenteredText(UI_10_FONT_ID, pageHeight / 2, statusMessage.c_str());
|
||||
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();
|
||||
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 + 10, errorMessage.c_str());
|
||||
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();
|
||||
return;
|
||||
}
|
||||
@ -206,7 +206,7 @@ void OpdsBookBrowserActivity::render() const {
|
||||
constexpr int barHeight = 20;
|
||||
constexpr int barX = 50;
|
||||
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();
|
||||
return;
|
||||
@ -219,7 +219,7 @@ void OpdsBookBrowserActivity::render() const {
|
||||
confirmLabel = "Download";
|
||||
}
|
||||
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()) {
|
||||
renderer.drawCenteredText(UI_10_FONT_ID, pageHeight / 2, "No entries found");
|
||||
|
||||
@ -14,7 +14,8 @@
|
||||
#include "CrossPointSettings.h"
|
||||
#include "CrossPointState.h"
|
||||
#include "MappedInputManager.h"
|
||||
#include "ScreenComponents.h"
|
||||
#include "RecentBooksStore.h"
|
||||
#include "components/UITheme.h"
|
||||
#include "fontIds.h"
|
||||
#include "util/StringUtils.h"
|
||||
|
||||
@ -24,12 +25,87 @@ void HomeActivity::taskTrampoline(void* param) {
|
||||
}
|
||||
|
||||
int HomeActivity::getMenuItemCount() const {
|
||||
int count = 3; // My Library, File transfer, Settings
|
||||
if (hasContinueReading) count++;
|
||||
if (hasOpdsUrl) count++;
|
||||
int count = 4; // My Library, Recents, File transfer, Settings
|
||||
if (!recentBooks.empty()) {
|
||||
count += recentBooks.size();
|
||||
}
|
||||
if (hasOpdsUrl) {
|
||||
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() {
|
||||
Activity::onEnter();
|
||||
|
||||
@ -41,62 +117,13 @@ void HomeActivity::onEnter() {
|
||||
// Check if OPDS browser URL is configured
|
||||
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;
|
||||
|
||||
// Trigger first update
|
||||
updateRequired = true;
|
||||
|
||||
xTaskCreate(&HomeActivity::taskTrampoline, "HomeActivityTask",
|
||||
4096, // Stack size (increased for cover image rendering)
|
||||
8192, // Stack size
|
||||
this, // Parameters
|
||||
1, // Priority
|
||||
&displayTaskHandle // Task handle
|
||||
@ -172,21 +199,24 @@ void HomeActivity::loop() {
|
||||
if (mappedInput.wasReleased(MappedInputManager::Button::Confirm)) {
|
||||
// Calculate dynamic indices based on which options are available
|
||||
int idx = 0;
|
||||
const int continueIdx = hasContinueReading ? idx++ : -1;
|
||||
int menuSelectedIndex = selectorIndex - static_cast<int>(recentBooks.size());
|
||||
const int myLibraryIdx = idx++;
|
||||
const int recentsIdx = idx++;
|
||||
const int opdsLibraryIdx = hasOpdsUrl ? idx++ : -1;
|
||||
const int fileTransferIdx = idx++;
|
||||
const int settingsIdx = idx;
|
||||
|
||||
if (selectorIndex == continueIdx) {
|
||||
onContinueReading();
|
||||
} else if (selectorIndex == myLibraryIdx) {
|
||||
if (selectorIndex < recentBooks.size()) {
|
||||
onSelectBook(recentBooks[selectorIndex].path);
|
||||
} else if (menuSelectedIndex == myLibraryIdx) {
|
||||
onMyLibraryOpen();
|
||||
} else if (selectorIndex == opdsLibraryIdx) {
|
||||
} else if (menuSelectedIndex == recentsIdx) {
|
||||
onRecentsOpen();
|
||||
} else if (menuSelectedIndex == opdsLibraryIdx) {
|
||||
onOpdsBrowserOpen();
|
||||
} else if (selectorIndex == fileTransferIdx) {
|
||||
} else if (menuSelectedIndex == fileTransferIdx) {
|
||||
onFileTransferOpen();
|
||||
} else if (selectorIndex == settingsIdx) {
|
||||
} else if (menuSelectedIndex == settingsIdx) {
|
||||
onSettingsOpen();
|
||||
}
|
||||
} else if (prevPressed) {
|
||||
@ -211,350 +241,51 @@ void HomeActivity::displayTaskLoop() {
|
||||
}
|
||||
|
||||
void HomeActivity::render() {
|
||||
// If we have a stored cover buffer, restore it instead of clearing
|
||||
const bool bufferRestored = coverBufferStored && restoreCoverBuffer();
|
||||
if (!bufferRestored) {
|
||||
renderer.clearScreen();
|
||||
}
|
||||
|
||||
auto metrics = UITheme::getInstance().getMetrics();
|
||||
const auto pageWidth = renderer.getScreenWidth();
|
||||
const auto pageHeight = renderer.getScreenHeight();
|
||||
|
||||
constexpr int margin = 20;
|
||||
constexpr int bottomMargin = 60;
|
||||
|
||||
// --- 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;
|
||||
bool bufferRestored = coverBufferStored && restoreCoverBuffer();
|
||||
if (!firstRenderDone || (recentsLoaded && !recentsDisplayed)) {
|
||||
renderer.clearScreen();
|
||||
}
|
||||
|
||||
// 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) {
|
||||
// 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;
|
||||
if (recentsLoaded) {
|
||||
recentsDisplayed = true;
|
||||
GUI.drawRecentBookCover(renderer, Rect{0, metrics.homeTopPadding, pageWidth, metrics.homeCoverTileHeight},
|
||||
recentBooks, selectorIndex, coverRendered, coverBufferStored, bufferRestored,
|
||||
std::bind(&HomeActivity::storeCoverBuffer, this));
|
||||
} else if (!recentsLoading && firstRenderDone) {
|
||||
recentsLoading = true;
|
||||
loadRecentBooks(metrics.homeRecentBooksCount, metrics.homeCoverHeight);
|
||||
}
|
||||
}
|
||||
|
||||
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
|
||||
std::vector<const char*> menuItems = {"My Library", "File Transfer", "Settings"};
|
||||
std::vector<const char*> menuItems = {"Browse Files", "Recents", "File Transfer", "Settings"};
|
||||
if (hasOpdsUrl) {
|
||||
// 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;
|
||||
constexpr int menuTileHeight = 45;
|
||||
constexpr int menuSpacing = 8;
|
||||
const int totalMenuHeight =
|
||||
static_cast<int>(menuItems.size()) * menuTileHeight + (static_cast<int>(menuItems.size()) - 1) * menuSpacing;
|
||||
|
||||
int menuStartY = bookY + bookHeight + 15;
|
||||
// Ensure we don't collide with the bottom button legend
|
||||
const int maxMenuStartY = pageHeight - bottomMargin - totalMenuHeight - margin;
|
||||
if (menuStartY > maxMenuStartY) {
|
||||
menuStartY = maxMenuStartY;
|
||||
}
|
||||
|
||||
for (size_t i = 0; i < menuItems.size(); ++i) {
|
||||
const int overallIndex = static_cast<int>(i) + (hasContinueReading ? 1 : 0);
|
||||
constexpr int tileX = margin;
|
||||
const int tileY = menuStartY + static_cast<int>(i) * (menuTileHeight + menuSpacing);
|
||||
const bool selected = selectorIndex == overallIndex;
|
||||
|
||||
if (selected) {
|
||||
renderer.fillRect(tileX, tileY, menuTileWidth, menuTileHeight);
|
||||
} else {
|
||||
renderer.drawRect(tileX, tileY, menuTileWidth, menuTileHeight);
|
||||
}
|
||||
|
||||
const char* label = menuItems[i];
|
||||
const int textWidth = renderer.getTextWidth(UI_10_FONT_ID, label);
|
||||
const int textX = tileX + (menuTileWidth - textWidth) / 2;
|
||||
const int lineHeight = renderer.getLineHeight(UI_10_FONT_ID);
|
||||
const int textY = tileY + (menuTileHeight - lineHeight) / 2; // vertically centered assuming y is top of text
|
||||
|
||||
// Invert text when the tile is selected, to contrast with the filled background
|
||||
renderer.drawText(UI_10_FONT_ID, textX, textY, label, !selected);
|
||||
}
|
||||
GUI.drawButtonMenu(
|
||||
renderer,
|
||||
Rect{0, metrics.homeTopPadding + metrics.homeCoverTileHeight + metrics.verticalSpacing, pageWidth,
|
||||
pageHeight - (metrics.headerHeight + metrics.homeTopPadding + metrics.verticalSpacing * 2 +
|
||||
metrics.buttonHintsHeight)},
|
||||
static_cast<int>(menuItems.size()), selectorIndex - recentBooks.size(),
|
||||
[&menuItems](int index) { return std::string(menuItems[index]); }, false, nullptr);
|
||||
|
||||
const auto labels = mappedInput.mapLabels("", "Select", "Up", "Down");
|
||||
renderer.drawButtonHints(UI_10_FONT_ID, labels.btn1, labels.btn2, labels.btn3, labels.btn4);
|
||||
|
||||
const bool showBatteryPercentage =
|
||||
SETTINGS.hideBatteryPercentage != CrossPointSettings::HIDE_BATTERY_PERCENTAGE::HIDE_ALWAYS;
|
||||
// get percentage so we can align text properly
|
||||
const uint16_t percentage = battery.readPercentage();
|
||||
const auto percentageText = showBatteryPercentage ? std::to_string(percentage) + "%" : "";
|
||||
const auto batteryX = pageWidth - 25 - renderer.getTextWidth(SMALL_FONT_ID, percentageText.c_str());
|
||||
ScreenComponents::drawBattery(renderer, batteryX, 10, showBatteryPercentage);
|
||||
GUI.drawButtonHints(renderer, labels.btn1, labels.btn2, labels.btn3, labels.btn4);
|
||||
|
||||
renderer.displayBuffer();
|
||||
|
||||
if (!firstRenderDone) {
|
||||
firstRenderDone = true;
|
||||
updateRequired = true;
|
||||
}
|
||||
}
|
||||
|
||||
@ -4,8 +4,13 @@
|
||||
#include <freertos/task.h>
|
||||
|
||||
#include <functional>
|
||||
#include <vector>
|
||||
|
||||
#include "../Activity.h"
|
||||
#include "./MyLibraryActivity.h"
|
||||
|
||||
struct RecentBook;
|
||||
struct Rect;
|
||||
|
||||
class HomeActivity final : public Activity {
|
||||
TaskHandle_t displayTaskHandle = nullptr;
|
||||
@ -13,16 +18,18 @@ class HomeActivity final : public Activity {
|
||||
int selectorIndex = 0;
|
||||
bool updateRequired = false;
|
||||
bool hasContinueReading = false;
|
||||
bool recentsLoading = false;
|
||||
bool recentsLoaded = false;
|
||||
bool recentsDisplayed = false;
|
||||
bool firstRenderDone = false;
|
||||
bool hasOpdsUrl = false;
|
||||
bool hasCoverImage = false;
|
||||
bool coverRendered = false; // Track if cover has been rendered once
|
||||
bool coverBufferStored = false; // Track if cover buffer is stored
|
||||
uint8_t* coverBuffer = nullptr; // HomeActivity's own buffer for cover image
|
||||
std::string lastBookTitle;
|
||||
std::string lastBookAuthor;
|
||||
std::string coverBmpPath;
|
||||
const std::function<void()> onContinueReading;
|
||||
std::vector<RecentBook> recentBooks;
|
||||
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()> onOpdsBrowserOpen;
|
||||
@ -34,15 +41,18 @@ class HomeActivity final : public Activity {
|
||||
bool storeCoverBuffer(); // Store frame buffer for cover image
|
||||
bool restoreCoverBuffer(); // Restore frame buffer from stored cover
|
||||
void freeCoverBuffer(); // Free the stored cover buffer
|
||||
void loadRecentBooks(int maxBooks, int coverHeight);
|
||||
|
||||
public:
|
||||
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()>& onOpdsBrowserOpen)
|
||||
: Activity("Home", renderer, mappedInput),
|
||||
onContinueReading(onContinueReading),
|
||||
onSelectBook(onSelectBook),
|
||||
onMyLibraryOpen(onMyLibraryOpen),
|
||||
onRecentsOpen(onRecentsOpen),
|
||||
onSettingsOpen(onSettingsOpen),
|
||||
onFileTransferOpen(onFileTransferOpen),
|
||||
onOpdsBrowserOpen(onOpdsBrowserOpen) {}
|
||||
|
||||
@ -3,26 +3,15 @@
|
||||
#include <GfxRenderer.h>
|
||||
#include <SDCardManager.h>
|
||||
|
||||
#include <algorithm>
|
||||
|
||||
#include "MappedInputManager.h"
|
||||
#include "RecentBooksStore.h"
|
||||
#include "ScreenComponents.h"
|
||||
#include "components/UITheme.h"
|
||||
#include "fontIds.h"
|
||||
#include "util/StringUtils.h"
|
||||
|
||||
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 unsigned long GO_HOME_MS = 1000;
|
||||
} // namespace
|
||||
|
||||
void sortFileList(std::vector<std::string>& strs) {
|
||||
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); });
|
||||
});
|
||||
}
|
||||
} // namespace
|
||||
|
||||
int MyLibraryActivity::getPageItems() const {
|
||||
const int screenHeight = renderer.getScreenHeight();
|
||||
const int bottomBarHeight = 60; // Space for button hints
|
||||
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::taskTrampoline(void* param) {
|
||||
auto* self = static_cast<MyLibraryActivity*>(param);
|
||||
self->displayTaskLoop();
|
||||
}
|
||||
|
||||
void MyLibraryActivity::loadFiles() {
|
||||
@ -114,32 +63,18 @@ void MyLibraryActivity::loadFiles() {
|
||||
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() {
|
||||
Activity::onEnter();
|
||||
|
||||
renderingMutex = xSemaphoreCreateMutex();
|
||||
|
||||
// Load data for both tabs
|
||||
loadRecentBooks();
|
||||
loadFiles();
|
||||
|
||||
selectorIndex = 0;
|
||||
updateRequired = true;
|
||||
|
||||
xTaskCreate(&MyLibraryActivity::taskTrampoline, "MyLibraryActivityTask",
|
||||
4096, // Stack size (increased for epub metadata loading)
|
||||
4096, // Stack size
|
||||
this, // Parameters
|
||||
1, // Priority
|
||||
&displayTaskHandle // Task handle
|
||||
@ -149,8 +84,7 @@ void MyLibraryActivity::onEnter() {
|
||||
void MyLibraryActivity::onExit() {
|
||||
Activity::onExit();
|
||||
|
||||
// Wait until not rendering to delete task to avoid killing mid-instruction to
|
||||
// EPD
|
||||
// Wait until not rendering to delete task to avoid killing mid-instruction to EPD
|
||||
xSemaphoreTake(renderingMutex, portMAX_DELAY);
|
||||
if (displayTaskHandle) {
|
||||
vTaskDelete(displayTaskHandle);
|
||||
@ -163,107 +97,76 @@ void MyLibraryActivity::onExit() {
|
||||
}
|
||||
|
||||
void MyLibraryActivity::loop() {
|
||||
const int itemCount = getCurrentItemCount();
|
||||
const int pageItems = getPageItems();
|
||||
|
||||
// Long press BACK (1s+) in Files tab goes to root folder
|
||||
if (currentTab == Tab::Files && mappedInput.isPressed(MappedInputManager::Button::Back) &&
|
||||
mappedInput.getHeldTime() >= GO_HOME_MS) {
|
||||
if (basepath != "/") {
|
||||
// Long press BACK (1s+) goes to root folder
|
||||
if (mappedInput.isPressed(MappedInputManager::Button::Back) && mappedInput.getHeldTime() >= GO_HOME_MS &&
|
||||
basepath != "/") {
|
||||
basepath = "/";
|
||||
loadFiles();
|
||||
selectorIndex = 0;
|
||||
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 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);
|
||||
|
||||
// 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);
|
||||
if (files.empty()) {
|
||||
return;
|
||||
}
|
||||
} else {
|
||||
// Files tab
|
||||
if (!files.empty() && selectorIndex < static_cast<int>(files.size())) {
|
||||
|
||||
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);
|
||||
}
|
||||
}
|
||||
}
|
||||
onSelectBook(basepath + files[selectorIndex]);
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
// Back button
|
||||
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 (currentTab == Tab::Files && basepath != "/") {
|
||||
// Go up one directory, remembering the directory we came from
|
||||
if (basepath != "/") {
|
||||
const std::string oldPath = basepath;
|
||||
|
||||
basepath.replace(basepath.find_last_of('/'), std::string::npos, "");
|
||||
if (basepath.empty()) basepath = "/";
|
||||
loadFiles();
|
||||
|
||||
// Select the directory we just came from
|
||||
const auto pos = oldPath.find_last_of('/');
|
||||
const std::string dirName = oldPath.substr(pos + 1) + "/";
|
||||
selectorIndex = static_cast<int>(findEntry(dirName));
|
||||
selectorIndex = findEntry(dirName);
|
||||
|
||||
updateRequired = true;
|
||||
} else {
|
||||
// Go home
|
||||
onGoHome();
|
||||
}
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
// Tab switching: Left/Right always control tabs
|
||||
if (leftReleased && currentTab == Tab::Files) {
|
||||
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) {
|
||||
int listSize = static_cast<int>(files.size());
|
||||
if (upReleased) {
|
||||
if (skipPage) {
|
||||
selectorIndex = ((selectorIndex / pageItems - 1) * pageItems + itemCount) % itemCount;
|
||||
selectorIndex = ((selectorIndex / pageItems - 1) * pageItems + listSize) % listSize;
|
||||
} else {
|
||||
selectorIndex = (selectorIndex + itemCount - 1) % itemCount;
|
||||
selectorIndex = (selectorIndex + listSize - 1) % listSize;
|
||||
}
|
||||
updateRequired = true;
|
||||
} else if (nextReleased && itemCount > 0) {
|
||||
} else if (downReleased) {
|
||||
if (skipPage) {
|
||||
selectorIndex = ((selectorIndex / pageItems + 1) * pageItems) % itemCount;
|
||||
selectorIndex = ((selectorIndex / pageItems + 1) * pageItems) % listSize;
|
||||
} else {
|
||||
selectorIndex = (selectorIndex + 1) % itemCount;
|
||||
selectorIndex = (selectorIndex + 1) % listSize;
|
||||
}
|
||||
updateRequired = true;
|
||||
}
|
||||
@ -284,100 +187,32 @@ void MyLibraryActivity::displayTaskLoop() {
|
||||
void MyLibraryActivity::render() const {
|
||||
renderer.clearScreen();
|
||||
|
||||
// Draw tab bar
|
||||
std::vector<TabInfo> tabs = {{"Recent", currentTab == Tab::Recent}, {"Files", currentTab == Tab::Files}};
|
||||
ScreenComponents::drawTabBar(renderer, TAB_BAR_Y, tabs);
|
||||
const auto pageWidth = renderer.getScreenWidth();
|
||||
const auto pageHeight = renderer.getScreenHeight();
|
||||
auto metrics = UITheme::getInstance().getMetrics();
|
||||
|
||||
// Draw content based on current tab
|
||||
if (currentTab == Tab::Recent) {
|
||||
renderRecentTab();
|
||||
auto folderName = basepath == "/" ? "SD card" : basepath.substr(basepath.rfind('/') + 1).c_str();
|
||||
GUI.drawHeader(renderer, Rect{0, metrics.topPadding, pageWidth, metrics.headerHeight}, folderName);
|
||||
|
||||
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 {
|
||||
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
|
||||
const int screenHeight = renderer.getScreenHeight();
|
||||
const int contentHeight = screenHeight - CONTENT_START_Y - 60; // 60 for bottom bar
|
||||
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);
|
||||
// Help text
|
||||
const auto labels = mappedInput.mapLabels("« Home", "Open", "Up", "Down");
|
||||
GUI.drawButtonHints(renderer, labels.btn1, labels.btn2, labels.btn3, labels.btn4);
|
||||
|
||||
renderer.displayBuffer();
|
||||
}
|
||||
|
||||
void MyLibraryActivity::renderRecentTab() const {
|
||||
const auto pageWidth = renderer.getScreenWidth();
|
||||
const int pageItems = getPageItems();
|
||||
const int bookCount = static_cast<int>(recentBooks.size());
|
||||
|
||||
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);
|
||||
}
|
||||
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;
|
||||
}
|
||||
|
||||
@ -8,59 +8,40 @@
|
||||
#include <vector>
|
||||
|
||||
#include "../Activity.h"
|
||||
#include "RecentBooksStore.h"
|
||||
|
||||
class MyLibraryActivity final : public Activity {
|
||||
public:
|
||||
enum class Tab { Recent, Files };
|
||||
|
||||
private:
|
||||
TaskHandle_t displayTaskHandle = nullptr;
|
||||
SemaphoreHandle_t renderingMutex = nullptr;
|
||||
|
||||
Tab currentTab = Tab::Recent;
|
||||
int selectorIndex = 0;
|
||||
size_t selectorIndex = 0;
|
||||
bool updateRequired = false;
|
||||
|
||||
// Recent tab state
|
||||
std::vector<RecentBook> recentBooks;
|
||||
|
||||
// Files tab state (from FileSelectionActivity)
|
||||
// Files state
|
||||
std::string basepath = "/";
|
||||
std::vector<std::string> files;
|
||||
|
||||
// Callbacks
|
||||
const std::function<void(const std::string& path)> onSelectBook;
|
||||
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);
|
||||
[[noreturn]] void displayTaskLoop();
|
||||
void render() const;
|
||||
void renderRecentTab() const;
|
||||
void renderFilesTab() const;
|
||||
|
||||
// Data loading
|
||||
void loadFiles();
|
||||
size_t findEntry(const std::string& name) const;
|
||||
|
||||
public:
|
||||
explicit MyLibraryActivity(GfxRenderer& renderer, MappedInputManager& mappedInput,
|
||||
const std::function<void()>& onGoHome,
|
||||
const std::function<void(const std::string& path, Tab fromTab)>& onSelectBook,
|
||||
Tab initialTab = Tab::Recent, std::string initialPath = "/")
|
||||
const std::function<void(const std::string& path)>& onSelectBook,
|
||||
std::string initialPath = "/")
|
||||
: Activity("MyLibrary", renderer, mappedInput),
|
||||
currentTab(initialTab),
|
||||
basepath(initialPath.empty() ? "/" : std::move(initialPath)),
|
||||
onGoHome(onGoHome),
|
||||
onSelectBook(onSelectBook) {}
|
||||
onSelectBook(onSelectBook),
|
||||
onGoHome(onGoHome) {}
|
||||
void onEnter() override;
|
||||
void onExit() override;
|
||||
void loop() override;
|
||||
|
||||
149
src/activities/home/RecentBooksActivity.cpp
Normal file
149
src/activities/home/RecentBooksActivity.cpp
Normal 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();
|
||||
}
|
||||
43
src/activities/home/RecentBooksActivity.h
Normal file
43
src/activities/home/RecentBooksActivity.h
Normal 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;
|
||||
};
|
||||
@ -6,8 +6,8 @@
|
||||
#include <esp_task_wdt.h>
|
||||
|
||||
#include "MappedInputManager.h"
|
||||
#include "ScreenComponents.h"
|
||||
#include "WifiSelectionActivity.h"
|
||||
#include "components/UITheme.h"
|
||||
#include "fontIds.h"
|
||||
|
||||
namespace {
|
||||
@ -258,8 +258,7 @@ void CalibreConnectActivity::renderServerRunning() const {
|
||||
constexpr int barWidth = 300;
|
||||
constexpr int barHeight = 16;
|
||||
constexpr int barX = (480 - barWidth) / 2;
|
||||
ScreenComponents::drawProgressBar(renderer, barX, y + 22, barWidth, barHeight, lastProgressReceived,
|
||||
lastProgressTotal);
|
||||
GUI.drawProgressBar(renderer, Rect{barX, y + 22, barWidth, barHeight}, lastProgressReceived, lastProgressTotal);
|
||||
y += 40;
|
||||
}
|
||||
|
||||
@ -272,5 +271,5 @@ void CalibreConnectActivity::renderServerRunning() const {
|
||||
}
|
||||
|
||||
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);
|
||||
}
|
||||
|
||||
@ -13,6 +13,7 @@
|
||||
#include "NetworkModeSelectionActivity.h"
|
||||
#include "WifiSelectionActivity.h"
|
||||
#include "activities/network/CalibreConnectActivity.h"
|
||||
#include "components/UITheme.h"
|
||||
#include "fontIds.h"
|
||||
|
||||
namespace {
|
||||
@ -479,5 +480,5 @@ void CrossPointWebServerActivity::renderServerRunning() const {
|
||||
}
|
||||
|
||||
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);
|
||||
}
|
||||
|
||||
@ -3,6 +3,7 @@
|
||||
#include <GfxRenderer.h>
|
||||
|
||||
#include "MappedInputManager.h"
|
||||
#include "components/UITheme.h"
|
||||
#include "fontIds.h"
|
||||
|
||||
namespace {
|
||||
@ -131,7 +132,7 @@ void NetworkModeSelectionActivity::render() const {
|
||||
|
||||
// Draw help text at bottom
|
||||
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();
|
||||
}
|
||||
|
||||
@ -8,6 +8,7 @@
|
||||
#include "MappedInputManager.h"
|
||||
#include "WifiCredentialStore.h"
|
||||
#include "activities/util/KeyboardEntryActivity.h"
|
||||
#include "components/UITheme.h"
|
||||
#include "fontIds.h"
|
||||
|
||||
void WifiSelectionActivity::taskTrampoline(void* param) {
|
||||
@ -586,7 +587,7 @@ void WifiSelectionActivity::renderNetworkList() const {
|
||||
// Draw help text
|
||||
renderer.drawText(SMALL_FONT_ID, 20, pageHeight - 75, "* = Encrypted | + = Saved");
|
||||
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 {
|
||||
@ -625,7 +626,7 @@ void WifiSelectionActivity::renderConnected() const {
|
||||
|
||||
// Use centralized button hints
|
||||
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 {
|
||||
@ -667,7 +668,7 @@ void WifiSelectionActivity::renderSavePrompt() const {
|
||||
|
||||
// Use centralized button hints
|
||||
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 {
|
||||
@ -680,7 +681,7 @@ void WifiSelectionActivity::renderConnectionFailed() const {
|
||||
|
||||
// Use centralized button hints
|
||||
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 {
|
||||
@ -722,5 +723,5 @@ void WifiSelectionActivity::renderForgetPrompt() const {
|
||||
|
||||
// Use centralized button hints
|
||||
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);
|
||||
}
|
||||
|
||||
@ -10,7 +10,7 @@
|
||||
#include "EpubReaderChapterSelectionActivity.h"
|
||||
#include "MappedInputManager.h"
|
||||
#include "RecentBooksStore.h"
|
||||
#include "ScreenComponents.h"
|
||||
#include "components/UITheme.h"
|
||||
#include "fontIds.h"
|
||||
|
||||
namespace {
|
||||
@ -85,7 +85,7 @@ void EpubReaderActivity::onEnter() {
|
||||
// Save current epub as last opened epub and add to recent books
|
||||
APP_STATE.openEpubPath = epub->getPath();
|
||||
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
|
||||
updateRequired = true;
|
||||
@ -347,13 +347,15 @@ void EpubReaderActivity::renderScreen() {
|
||||
orientedMarginRight += SETTINGS.screenMargin;
|
||||
orientedMarginBottom += SETTINGS.screenMargin;
|
||||
|
||||
auto metrics = UITheme::getInstance().getMetrics();
|
||||
|
||||
// Add status bar margin
|
||||
if (SETTINGS.statusBar != CrossPointSettings::STATUS_BAR_MODE::NONE) {
|
||||
// Add additional margin for status bar if progress bar is shown
|
||||
const bool showProgressBar = SETTINGS.statusBar == CrossPointSettings::STATUS_BAR_MODE::FULL_WITH_PROGRESS_BAR ||
|
||||
SETTINGS.statusBar == CrossPointSettings::STATUS_BAR_MODE::ONLY_PROGRESS_BAR;
|
||||
orientedMarginBottom += statusBarMargin - SETTINGS.screenMargin +
|
||||
(showProgressBar ? (ScreenComponents::BOOK_PROGRESS_BAR_HEIGHT + progressBarMarginTop) : 0);
|
||||
(showProgressBar ? (metrics.bookProgressBarHeight + progressBarMarginTop) : 0);
|
||||
}
|
||||
|
||||
if (!section) {
|
||||
@ -369,7 +371,7 @@ void EpubReaderActivity::renderScreen() {
|
||||
viewportHeight, SETTINGS.hyphenationEnabled)) {
|
||||
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(),
|
||||
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,
|
||||
const int orientedMarginLeft) const {
|
||||
auto metrics = UITheme::getInstance().getMetrics();
|
||||
|
||||
// determine visible status bar elements
|
||||
const bool showProgressPercentage = SETTINGS.statusBar == CrossPointSettings::STATUS_BAR_MODE::FULL;
|
||||
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) {
|
||||
// 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) {
|
||||
ScreenComponents::drawBattery(renderer, orientedMarginLeft + 1, textY, showBatteryPercentage);
|
||||
GUI.drawBattery(renderer, Rect{orientedMarginLeft + 1, textY, metrics.batteryWidth, metrics.batteryHeight},
|
||||
showBatteryPercentage);
|
||||
}
|
||||
|
||||
if (showChapterTitle) {
|
||||
|
||||
@ -5,6 +5,7 @@
|
||||
#include "KOReaderCredentialStore.h"
|
||||
#include "KOReaderSyncActivity.h"
|
||||
#include "MappedInputManager.h"
|
||||
#include "components/UITheme.h"
|
||||
#include "fontIds.h"
|
||||
|
||||
namespace {
|
||||
@ -209,7 +210,7 @@ void EpubReaderChapterSelectionActivity::renderScreen() {
|
||||
// Skip button hints in landscape CW mode (they overlap content)
|
||||
if (renderer.getOrientation() != GfxRenderer::LandscapeClockwise) {
|
||||
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();
|
||||
|
||||
@ -2,6 +2,7 @@
|
||||
|
||||
#include <GfxRenderer.h>
|
||||
|
||||
#include "components/UITheme.h"
|
||||
#include "fontIds.h"
|
||||
|
||||
void EpubReaderMenuActivity::onEnter() {
|
||||
@ -97,7 +98,7 @@ void EpubReaderMenuActivity::renderScreen() {
|
||||
|
||||
// Footer / Hints
|
||||
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();
|
||||
}
|
||||
|
||||
@ -8,6 +8,7 @@
|
||||
#include "KOReaderDocumentId.h"
|
||||
#include "MappedInputManager.h"
|
||||
#include "activities/network/WifiSelectionActivity.h"
|
||||
#include "components/UITheme.h"
|
||||
#include "fontIds.h"
|
||||
|
||||
namespace {
|
||||
@ -266,7 +267,7 @@ void KOReaderSyncActivity::render() {
|
||||
renderer.drawCenteredText(UI_10_FONT_ID, 320, "Set up KOReader account in Settings");
|
||||
|
||||
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();
|
||||
return;
|
||||
}
|
||||
@ -339,7 +340,7 @@ void KOReaderSyncActivity::render() {
|
||||
renderer.drawText(UI_10_FONT_ID, 20, optionY + optionHeight * 2, "Cancel", selectedOption != 2);
|
||||
|
||||
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();
|
||||
return;
|
||||
}
|
||||
@ -349,7 +350,7 @@ void KOReaderSyncActivity::render() {
|
||||
renderer.drawCenteredText(UI_10_FONT_ID, 320, "Upload current position?");
|
||||
|
||||
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();
|
||||
return;
|
||||
}
|
||||
@ -358,7 +359,7 @@ void KOReaderSyncActivity::render() {
|
||||
renderer.drawCenteredText(UI_10_FONT_ID, 300, "Progress uploaded!", true, EpdFontFamily::BOLD);
|
||||
|
||||
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();
|
||||
return;
|
||||
}
|
||||
@ -368,7 +369,7 @@ void KOReaderSyncActivity::render() {
|
||||
renderer.drawCenteredText(UI_10_FONT_ID, 320, statusMessage.c_str());
|
||||
|
||||
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();
|
||||
return;
|
||||
}
|
||||
|
||||
@ -74,7 +74,7 @@ std::unique_ptr<Txt> ReaderActivity::loadTxt(const std::string& path) {
|
||||
void ReaderActivity::goToLibrary(const std::string& fromBookPath) {
|
||||
// If coming from a book, start in that book's folder; otherwise start from root
|
||||
const auto initialPath = fromBookPath.empty() ? "/" : extractFolderPath(fromBookPath);
|
||||
onGoToLibrary(initialPath, libraryTab);
|
||||
onGoToLibrary(initialPath);
|
||||
}
|
||||
|
||||
void ReaderActivity::onGoToEpubReader(std::unique_ptr<Epub> epub) {
|
||||
|
||||
@ -11,9 +11,8 @@ class Txt;
|
||||
class ReaderActivity final : public ActivityWithSubactivity {
|
||||
std::string initialBookPath;
|
||||
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(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<Xtc> loadXtc(const std::string& path);
|
||||
static std::unique_ptr<Txt> loadTxt(const std::string& path);
|
||||
@ -28,11 +27,10 @@ class ReaderActivity final : public ActivityWithSubactivity {
|
||||
|
||||
public:
|
||||
explicit ReaderActivity(GfxRenderer& renderer, MappedInputManager& mappedInput, std::string initialBookPath,
|
||||
MyLibraryActivity::Tab libraryTab, const std::function<void()>& onGoBack,
|
||||
const std::function<void(const std::string&, MyLibraryActivity::Tab)>& onGoToLibrary)
|
||||
const std::function<void()>& onGoBack,
|
||||
const std::function<void(const std::string&)>& onGoToLibrary)
|
||||
: ActivityWithSubactivity("Reader", renderer, mappedInput),
|
||||
initialBookPath(std::move(initialBookPath)),
|
||||
libraryTab(libraryTab),
|
||||
onGoBack(onGoBack),
|
||||
onGoToLibrary(onGoToLibrary) {}
|
||||
void onEnter() override;
|
||||
|
||||
@ -9,7 +9,7 @@
|
||||
#include "CrossPointState.h"
|
||||
#include "MappedInputManager.h"
|
||||
#include "RecentBooksStore.h"
|
||||
#include "ScreenComponents.h"
|
||||
#include "components/UITheme.h"
|
||||
#include "fontIds.h"
|
||||
|
||||
namespace {
|
||||
@ -60,7 +60,7 @@ void TxtReaderActivity::onEnter() {
|
||||
// Save current txt as last opened file and add to recent books
|
||||
APP_STATE.openEpubPath = txt->getPath();
|
||||
APP_STATE.saveToFile();
|
||||
RECENT_BOOKS.addBook(txt->getPath(), "", "");
|
||||
RECENT_BOOKS.addBook(txt->getPath(), "", "", "");
|
||||
|
||||
// Trigger first update
|
||||
updateRequired = true;
|
||||
@ -168,13 +168,15 @@ void TxtReaderActivity::initializeReader() {
|
||||
orientedMarginRight += cachedScreenMargin;
|
||||
orientedMarginBottom += cachedScreenMargin;
|
||||
|
||||
auto metrics = UITheme::getInstance().getMetrics();
|
||||
|
||||
// Add status bar margin
|
||||
if (SETTINGS.statusBar != CrossPointSettings::STATUS_BAR_MODE::NONE) {
|
||||
// Add additional margin for status bar if progress bar is shown
|
||||
const bool showProgressBar = SETTINGS.statusBar == CrossPointSettings::STATUS_BAR_MODE::FULL_WITH_PROGRESS_BAR ||
|
||||
SETTINGS.statusBar == CrossPointSettings::STATUS_BAR_MODE::ONLY_PROGRESS_BAR;
|
||||
orientedMarginBottom += statusBarMargin - cachedScreenMargin +
|
||||
(showProgressBar ? (ScreenComponents::BOOK_PROGRESS_BAR_HEIGHT + progressBarMarginTop) : 0);
|
||||
(showProgressBar ? (metrics.bookProgressBarHeight + progressBarMarginTop) : 0);
|
||||
}
|
||||
|
||||
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);
|
||||
|
||||
ScreenComponents::drawPopup(renderer, "Indexing...");
|
||||
GUI.drawPopup(renderer, "Indexing...");
|
||||
|
||||
while (offset < fileSize) {
|
||||
std::vector<std::string> tempLines;
|
||||
@ -498,6 +500,7 @@ void TxtReaderActivity::renderStatusBar(const int orientedMarginRight, const int
|
||||
const bool showBatteryPercentage =
|
||||
SETTINGS.hideBatteryPercentage == CrossPointSettings::HIDE_BATTERY_PERCENTAGE::HIDE_NEVER;
|
||||
|
||||
auto metrics = UITheme::getInstance().getMetrics();
|
||||
const auto screenHeight = renderer.getScreenHeight();
|
||||
const auto textY = screenHeight - orientedMarginBottom - 4;
|
||||
int progressTextWidth = 0;
|
||||
@ -519,11 +522,12 @@ void TxtReaderActivity::renderStatusBar(const int orientedMarginRight, const int
|
||||
|
||||
if (showProgressBar) {
|
||||
// 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) {
|
||||
ScreenComponents::drawBattery(renderer, orientedMarginLeft, textY, showBatteryPercentage);
|
||||
GUI.drawBattery(renderer, Rect{orientedMarginLeft, textY, metrics.batteryWidth, metrics.batteryHeight},
|
||||
showBatteryPercentage);
|
||||
}
|
||||
|
||||
if (showTitle) {
|
||||
|
||||
@ -16,6 +16,7 @@
|
||||
#include "MappedInputManager.h"
|
||||
#include "RecentBooksStore.h"
|
||||
#include "XtcReaderChapterSelectionActivity.h"
|
||||
#include "components/UITheme.h"
|
||||
#include "fontIds.h"
|
||||
|
||||
namespace {
|
||||
@ -45,7 +46,7 @@ void XtcReaderActivity::onEnter() {
|
||||
// Save current XTC as last opened book and add to recent books
|
||||
APP_STATE.openEpubPath = xtc->getPath();
|
||||
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
|
||||
updateRequired = true;
|
||||
|
||||
@ -3,6 +3,7 @@
|
||||
#include <GfxRenderer.h>
|
||||
|
||||
#include "MappedInputManager.h"
|
||||
#include "components/UITheme.h"
|
||||
#include "fontIds.h"
|
||||
|
||||
namespace {
|
||||
@ -152,7 +153,7 @@ void XtcReaderChapterSelectionActivity::renderScreen() {
|
||||
// Skip button hints in landscape CW mode (they overlap content)
|
||||
if (renderer.getOrientation() != GfxRenderer::LandscapeClockwise) {
|
||||
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();
|
||||
|
||||
@ -7,6 +7,7 @@
|
||||
#include "CrossPointSettings.h"
|
||||
#include "MappedInputManager.h"
|
||||
#include "activities/util/KeyboardEntryActivity.h"
|
||||
#include "components/UITheme.h"
|
||||
#include "fontIds.h"
|
||||
|
||||
namespace {
|
||||
@ -183,7 +184,7 @@ void CalibreSettingsActivity::render() {
|
||||
|
||||
// Draw button hints
|
||||
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();
|
||||
}
|
||||
|
||||
@ -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();
|
||||
}
|
||||
@ -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;
|
||||
};
|
||||
@ -5,6 +5,7 @@
|
||||
#include <SDCardManager.h>
|
||||
|
||||
#include "MappedInputManager.h"
|
||||
#include "components/UITheme.h"
|
||||
#include "fontIds.h"
|
||||
|
||||
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);
|
||||
|
||||
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();
|
||||
return;
|
||||
}
|
||||
@ -86,7 +87,7 @@ void ClearCacheActivity::render() {
|
||||
renderer.drawCenteredText(UI_10_FONT_ID, pageHeight / 2 + 10, resultText.c_str());
|
||||
|
||||
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();
|
||||
return;
|
||||
}
|
||||
@ -96,7 +97,7 @@ void ClearCacheActivity::render() {
|
||||
renderer.drawCenteredText(UI_10_FONT_ID, pageHeight / 2 + 10, "Check serial output for details");
|
||||
|
||||
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();
|
||||
return;
|
||||
}
|
||||
|
||||
@ -7,6 +7,7 @@
|
||||
#include "KOReaderSyncClient.h"
|
||||
#include "MappedInputManager.h"
|
||||
#include "activities/network/WifiSelectionActivity.h"
|
||||
#include "components/UITheme.h"
|
||||
#include "fontIds.h"
|
||||
|
||||
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");
|
||||
|
||||
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();
|
||||
return;
|
||||
}
|
||||
@ -146,7 +147,7 @@ void KOReaderAuthActivity::render() {
|
||||
renderer.drawCenteredText(UI_10_FONT_ID, 320, errorMessage.c_str());
|
||||
|
||||
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();
|
||||
return;
|
||||
}
|
||||
|
||||
@ -8,6 +8,7 @@
|
||||
#include "KOReaderCredentialStore.h"
|
||||
#include "MappedInputManager.h"
|
||||
#include "activities/util/KeyboardEntryActivity.h"
|
||||
#include "components/UITheme.h"
|
||||
#include "fontIds.h"
|
||||
|
||||
namespace {
|
||||
@ -207,7 +208,7 @@ void KOReaderSettingsActivity::render() {
|
||||
|
||||
// Draw button hints
|
||||
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();
|
||||
}
|
||||
|
||||
@ -5,6 +5,7 @@
|
||||
|
||||
#include "MappedInputManager.h"
|
||||
#include "activities/network/WifiSelectionActivity.h"
|
||||
#include "components/UITheme.h"
|
||||
#include "fontIds.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());
|
||||
|
||||
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();
|
||||
return;
|
||||
}
|
||||
|
||||
@ -3,15 +3,20 @@
|
||||
#include <GfxRenderer.h>
|
||||
#include <HardwareSerial.h>
|
||||
|
||||
#include "CategorySettingsActivity.h"
|
||||
#include "CalibreSettingsActivity.h"
|
||||
#include "ClearCacheActivity.h"
|
||||
#include "CrossPointSettings.h"
|
||||
#include "KOReaderSettingsActivity.h"
|
||||
#include "MappedInputManager.h"
|
||||
#include "OtaUpdateActivity.h"
|
||||
#include "components/UITheme.h"
|
||||
#include "fontIds.h"
|
||||
|
||||
const char* SettingsActivity::categoryNames[categoryCount] = {"Display", "Reader", "Controls", "System"};
|
||||
|
||||
namespace {
|
||||
constexpr int displaySettingsCount = 6;
|
||||
constexpr int changeTabsMs = 700;
|
||||
constexpr int displaySettingsCount = 7;
|
||||
const SettingInfo displaySettings[displaySettingsCount] = {
|
||||
// Should match with SLEEP_SCREEN_MODE
|
||||
SettingInfo::Enum("Sleep Screen", &CrossPointSettings::sleepScreen, {"Dark", "Light", "Custom", "Cover", "None"}),
|
||||
@ -22,7 +27,9 @@ const SettingInfo displaySettings[displaySettingsCount] = {
|
||||
{"None", "No Progress", "Full w/ Percentage", "Full w/ Progress Bar", "Progress Bar"}),
|
||||
SettingInfo::Enum("Hide Battery %", &CrossPointSettings::hideBatteryPercentage, {"Never", "In Reader", "Always"}),
|
||||
SettingInfo::Enum("Refresh Frequency", &CrossPointSettings::refreshFrequency,
|
||||
{"1 page", "5 pages", "10 pages", "15 pages", "30 pages"})};
|
||||
{"1 page", "5 pages", "10 pages", "15 pages", "30 pages"}),
|
||||
SettingInfo::Enum("UI Theme", &CrossPointSettings::uiTheme, {"Classic", "Lyra"}),
|
||||
};
|
||||
|
||||
constexpr int readerSettingsCount = 9;
|
||||
const SettingInfo readerSettings[readerSettingsCount] = {
|
||||
@ -67,6 +74,11 @@ void SettingsActivity::onEnter() {
|
||||
|
||||
// Reset selection to first category
|
||||
selectedCategoryIndex = 0;
|
||||
selectedSettingIndex = 0;
|
||||
|
||||
// Initialize with first category (Display)
|
||||
settingsList = displaySettings;
|
||||
settingsCount = displaySettingsCount;
|
||||
|
||||
// Trigger first update
|
||||
updateRequired = true;
|
||||
@ -90,6 +102,8 @@ void SettingsActivity::onExit() {
|
||||
}
|
||||
vSemaphoreDelete(renderingMutex);
|
||||
renderingMutex = nullptr;
|
||||
|
||||
UITheme::getInstance().reload(); // Re-apply theme in case it was changed
|
||||
}
|
||||
|
||||
void SettingsActivity::loop() {
|
||||
@ -97,12 +111,20 @@ void SettingsActivity::loop() {
|
||||
subActivity->loop();
|
||||
return;
|
||||
}
|
||||
bool hasChangedCategory = false;
|
||||
|
||||
// Handle category selection
|
||||
// Handle actions with early return
|
||||
if (mappedInput.wasPressed(MappedInputManager::Button::Confirm)) {
|
||||
enterCategory(selectedCategoryIndex);
|
||||
if (selectedSettingIndex == 0) {
|
||||
selectedCategoryIndex = (selectedCategoryIndex < categoryCount - 1) ? (selectedCategoryIndex + 1) : 0;
|
||||
hasChangedCategory = true;
|
||||
updateRequired = true;
|
||||
} else {
|
||||
toggleCurrentSetting();
|
||||
updateRequired = true;
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
if (mappedInput.wasPressed(MappedInputManager::Button::Back)) {
|
||||
SETTINGS.saveToFile();
|
||||
@ -110,32 +132,32 @@ void SettingsActivity::loop() {
|
||||
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
|
||||
if (mappedInput.wasPressed(MappedInputManager::Button::Up) ||
|
||||
mappedInput.wasPressed(MappedInputManager::Button::Left)) {
|
||||
// Move selection up (with wrap-around)
|
||||
if (upReleased && changeTab) {
|
||||
hasChangedCategory = true;
|
||||
selectedCategoryIndex = (selectedCategoryIndex > 0) ? (selectedCategoryIndex - 1) : (categoryCount - 1);
|
||||
updateRequired = true;
|
||||
} else if (mappedInput.wasPressed(MappedInputManager::Button::Down) ||
|
||||
mappedInput.wasPressed(MappedInputManager::Button::Right)) {
|
||||
// Move selection down (with wrap around)
|
||||
} else if (downReleased && changeTab) {
|
||||
hasChangedCategory = true;
|
||||
selectedCategoryIndex = (selectedCategoryIndex < categoryCount - 1) ? (selectedCategoryIndex + 1) : 0;
|
||||
updateRequired = true;
|
||||
}
|
||||
}
|
||||
|
||||
void SettingsActivity::enterCategory(int categoryIndex) {
|
||||
if (categoryIndex < 0 || categoryIndex >= categoryCount) {
|
||||
return;
|
||||
} else if (upReleased || leftReleased) {
|
||||
selectedSettingIndex = (selectedSettingIndex > 0) ? (selectedSettingIndex - 1) : (settingsCount);
|
||||
updateRequired = true;
|
||||
} else if (rightReleased || downReleased) {
|
||||
selectedSettingIndex = (selectedSettingIndex < settingsCount) ? (selectedSettingIndex + 1) : 0;
|
||||
updateRequired = true;
|
||||
}
|
||||
|
||||
xSemaphoreTake(renderingMutex, portMAX_DELAY);
|
||||
exitActivity();
|
||||
|
||||
const SettingInfo* settingsList = nullptr;
|
||||
int settingsCount = 0;
|
||||
|
||||
switch (categoryIndex) {
|
||||
if (hasChangedCategory) {
|
||||
selectedSettingIndex = (selectedSettingIndex == 0) ? 0 : 1;
|
||||
switch (selectedCategoryIndex) {
|
||||
case 0: // Display
|
||||
settingsList = displaySettings;
|
||||
settingsCount = displaySettingsCount;
|
||||
@ -153,13 +175,70 @@ void SettingsActivity::enterCategory(int categoryIndex) {
|
||||
settingsCount = systemSettingsCount;
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
enterNewActivity(new CategorySettingsActivity(renderer, mappedInput, categoryNames[categoryIndex], settingsList,
|
||||
settingsCount, [this] {
|
||||
void SettingsActivity::toggleCurrentSetting() {
|
||||
int selectedSetting = selectedSettingIndex - 1;
|
||||
if (selectedSetting < 0 || selectedSetting >= settingsCount) {
|
||||
return;
|
||||
}
|
||||
|
||||
const auto& setting = settingsList[selectedSetting];
|
||||
|
||||
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 SettingsActivity::displayTaskLoop() {
|
||||
@ -180,27 +259,48 @@ void SettingsActivity::render() const {
|
||||
const auto pageWidth = renderer.getScreenWidth();
|
||||
const auto pageHeight = renderer.getScreenHeight();
|
||||
|
||||
// Draw header
|
||||
renderer.drawCenteredText(UI_12_FONT_ID, 15, "Settings", true, EpdFontFamily::BOLD);
|
||||
auto metrics = UITheme::getInstance().getMetrics();
|
||||
|
||||
// Draw selection
|
||||
renderer.fillRect(0, 60 + selectedCategoryIndex * 30 - 2, pageWidth - 1, 30);
|
||||
GUI.drawHeader(renderer, Rect{0, metrics.topPadding, pageWidth, metrics.headerHeight}, "Settings");
|
||||
|
||||
// Draw all categories
|
||||
std::vector<TabInfo> tabs;
|
||||
tabs.reserve(categoryCount);
|
||||
for (int i = 0; i < categoryCount; i++) {
|
||||
const int categoryY = 60 + i * 30; // 30 pixels between categories
|
||||
|
||||
// Draw category name
|
||||
renderer.drawText(UI_10_FONT_ID, 20, categoryY, categoryNames[i], i != selectedCategoryIndex);
|
||||
tabs.push_back({categoryNames[i], selectedCategoryIndex == i});
|
||||
}
|
||||
GUI.drawTabBar(renderer, Rect{0, metrics.topPadding + metrics.headerHeight, pageWidth, metrics.tabBarHeight}, tabs,
|
||||
selectedSettingIndex == 0);
|
||||
|
||||
// Draw version text above button hints
|
||||
renderer.drawText(SMALL_FONT_ID, pageWidth - 20 - renderer.getTextWidth(SMALL_FONT_ID, CROSSPOINT_VERSION),
|
||||
pageHeight - 60, CROSSPOINT_VERSION);
|
||||
GUI.drawList(
|
||||
renderer,
|
||||
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
|
||||
const auto labels = mappedInput.mapLabels("« Back", "Select", "", "");
|
||||
renderer.drawButtonHints(UI_10_FONT_ID, labels.btn1, labels.btn2, labels.btn3, labels.btn4);
|
||||
const auto labels = mappedInput.mapLabels("« Back", "Toggle", "Up", "Down");
|
||||
GUI.drawButtonHints(renderer, labels.btn1, labels.btn2, labels.btn3, labels.btn4);
|
||||
|
||||
// Always use standard refresh for settings screen
|
||||
renderer.displayBuffer();
|
||||
|
||||
@ -10,13 +10,46 @@
|
||||
#include "activities/ActivityWithSubactivity.h"
|
||||
|
||||
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 {
|
||||
TaskHandle_t displayTaskHandle = nullptr;
|
||||
SemaphoreHandle_t renderingMutex = nullptr;
|
||||
bool updateRequired = false;
|
||||
int selectedCategoryIndex = 0; // Currently selected category
|
||||
int selectedSettingIndex = 0;
|
||||
int settingsCount = 0;
|
||||
const SettingInfo* settingsList = nullptr;
|
||||
|
||||
const std::function<void()> onGoHome;
|
||||
|
||||
static constexpr int categoryCount = 4;
|
||||
@ -26,6 +59,7 @@ class SettingsActivity final : public ActivityWithSubactivity {
|
||||
[[noreturn]] void displayTaskLoop();
|
||||
void render() const;
|
||||
void enterCategory(int categoryIndex);
|
||||
void toggleCurrentSetting();
|
||||
|
||||
public:
|
||||
explicit SettingsActivity(GfxRenderer& renderer, MappedInputManager& mappedInput,
|
||||
|
||||
@ -1,6 +1,7 @@
|
||||
#include "KeyboardEntryActivity.h"
|
||||
|
||||
#include "MappedInputManager.h"
|
||||
#include "components/UITheme.h"
|
||||
#include "fontIds.h"
|
||||
|
||||
// Keyboard layouts - lowercase
|
||||
@ -354,10 +355,10 @@ void KeyboardEntryActivity::render() const {
|
||||
|
||||
// Draw help text
|
||||
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
|
||||
renderer.drawSideButtonHints(UI_10_FONT_ID, "Up", "Down");
|
||||
GUI.drawSideButtonHints(renderer, "Up", "Down");
|
||||
|
||||
renderer.displayBuffer();
|
||||
}
|
||||
|
||||
62
src/components/UITheme.cpp
Normal file
62
src/components/UITheme.cpp
Normal 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
31
src/components/UITheme.h
Normal 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()
|
||||
642
src/components/themes/BaseTheme.cpp
Normal file
642
src/components/themes/BaseTheme.cpp
Normal 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);
|
||||
}
|
||||
118
src/components/themes/BaseTheme.h
Normal file
118
src/components/themes/BaseTheme.h
Normal 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;
|
||||
};
|
||||
374
src/components/themes/lyra/LyraTheme.cpp
Normal file
374
src/components/themes/lyra/LyraTheme.cpp
Normal 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};
|
||||
}
|
||||
58
src/components/themes/lyra/LyraTheme.h
Normal file
58
src/components/themes/lyra/LyraTheme.h
Normal 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;
|
||||
};
|
||||
26
src/main.cpp
26
src/main.cpp
@ -20,10 +20,12 @@
|
||||
#include "activities/browser/OpdsBookBrowserActivity.h"
|
||||
#include "activities/home/HomeActivity.h"
|
||||
#include "activities/home/MyLibraryActivity.h"
|
||||
#include "activities/home/RecentBooksActivity.h"
|
||||
#include "activities/network/CrossPointWebServerActivity.h"
|
||||
#include "activities/reader/ReaderActivity.h"
|
||||
#include "activities/settings/SettingsActivity.h"
|
||||
#include "activities/util/FullScreenMessageActivity.h"
|
||||
#include "components/UITheme.h"
|
||||
#include "fontIds.h"
|
||||
|
||||
HalDisplay display;
|
||||
@ -203,13 +205,13 @@ void enterDeepSleep() {
|
||||
}
|
||||
|
||||
void onGoHome();
|
||||
void onGoToMyLibraryWithTab(const std::string& path, MyLibraryActivity::Tab tab);
|
||||
void onGoToReader(const std::string& initialEpubPath, MyLibraryActivity::Tab fromTab) {
|
||||
void onGoToMyLibraryWithPath(const std::string& path);
|
||||
void onGoToRecentBooks();
|
||||
void onGoToReader(const std::string& initialEpubPath) {
|
||||
exitActivity();
|
||||
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() {
|
||||
exitActivity();
|
||||
@ -226,9 +228,14 @@ void onGoToMyLibrary() {
|
||||
enterNewActivity(new MyLibraryActivity(renderer, mappedInputManager, onGoHome, onGoToReader));
|
||||
}
|
||||
|
||||
void onGoToMyLibraryWithTab(const std::string& path, MyLibraryActivity::Tab tab) {
|
||||
void onGoToRecentBooks() {
|
||||
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() {
|
||||
@ -238,8 +245,8 @@ void onGoToBrowser() {
|
||||
|
||||
void onGoHome() {
|
||||
exitActivity();
|
||||
enterNewActivity(new HomeActivity(renderer, mappedInputManager, onContinueReading, onGoToMyLibrary, onGoToSettings,
|
||||
onGoToFileTransfer, onGoToBrowser));
|
||||
enterNewActivity(new HomeActivity(renderer, mappedInputManager, onGoToReader, onGoToMyLibrary, onGoToRecentBooks,
|
||||
onGoToSettings, onGoToFileTransfer, onGoToBrowser));
|
||||
}
|
||||
|
||||
void setupDisplayAndFonts() {
|
||||
@ -293,6 +300,7 @@ void setup() {
|
||||
|
||||
SETTINGS.loadFromFile();
|
||||
KOREADER_STORE.loadFromFile();
|
||||
UITheme::getInstance().reload();
|
||||
|
||||
switch (gpio.getWakeupReason()) {
|
||||
case HalGPIO::WakeupReason::PowerButton:
|
||||
@ -330,7 +338,7 @@ void setup() {
|
||||
const auto path = APP_STATE.openEpubPath;
|
||||
APP_STATE.openEpubPath = "";
|
||||
APP_STATE.saveToFile();
|
||||
onGoToReader(path, MyLibraryActivity::Tab::Recent);
|
||||
onGoToReader(path);
|
||||
}
|
||||
|
||||
// Ensure we're not still holding the power button before leaving setup
|
||||
|
||||
Loading…
Reference in New Issue
Block a user