mirror of
https://github.com/daveallie/crosspoint-reader.git
synced 2026-02-05 15:17:37 +03:00
feat: UI themes, Lyra (#528)
## Summary ### What is the goal of this PR? - Visual UI overhaul - UI theme selection ### What changes are included? - Added a setting "UI Theme": Classic, Lyra - The classic theme is the current Crosspoint theme - The Lyra theme implements these mockups: https://www.figma.com/design/UhxoV4DgUnfrDQgMPPTXog/Lyra-Theme?node-id=2003-7596&t=4CSOZqf0n9uQMxDt-0 by Discord users yagofarias, ruby and gan_shu - New functions in GFXRenderer to render rounded rectangles, greyscale fills (using dithering) and thick lines - Basic UI components are factored into BaseTheme methods which can be overridden by each additional theme. Methods that are not overridden will fallback to BaseTheme behavior. This means any new features/components in CrossPoint only need to be developed for the "Classic" BaseTheme. - Additional themes can easily be developed by the community using this foundation    ## Additional Context - Only the Home, Library and main Settings screens have been implemented so far, this will be extended to the transfer screens and chapter selection screen later on, but we need to get the ball rolling somehow :) - Loading extra covers on the home screen in the Lyra theme takes a little more time (about 2 seconds), I added a loading bar popup (reusing the Indexing progress bar from the reader view, factored into a neat UI component) but the popup adds ~400ms to the loading time. - ~~Home screen thumbnails will need to be generated separately for each theme, because they are displayed in different sizes. Because we're using dithering, displaying a thumb with the wrong size causes the picture to look janky or dark as it does on the screenshots above. No worries this will be fixed in a future PR.~~ Thumbs are now generated with a size parameter - UI Icons will need to be implemented in a future PR. --- ### AI Usage While CrossPoint doesn't have restrictions on AI tools in contributing, please be transparent about their usage as it helps set the right context for reviewers. Did you use AI tools to help write this code? _**PARTIALLY**_ This is not a vibe coded PR. Copilot was used for autocompletion to save time but I reviewed, understood and edited all generated code. --------- Co-authored-by: Dave Allie <dave@daveallie.com>
This commit is contained in:
parent
2cf799f45b
commit
bf87a7dc60
@ -513,11 +513,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;
|
||||
}
|
||||
|
||||
@ -529,11 +530,8 @@ 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" ||
|
||||
coverImageHref.substr(coverImageHref.length() - 5) == ".jpeg") {
|
||||
} 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";
|
||||
|
||||
@ -549,14 +547,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();
|
||||
@ -565,7 +563,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");
|
||||
@ -574,6 +572,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;
|
||||
}
|
||||
|
||||
|
||||
@ -56,7 +56,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)
|
||||
@ -502,85 +715,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;
|
||||
@ -84,17 +101,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;
|
||||
|
||||
@ -1 +1 @@
|
||||
Subproject commit bd4e6707503ab9c97d13ee0d8f8c69e9ff03cd12
|
||||
Subproject commit c8ce3949b3368329c290ca1858e65dc3416fc591
|
||||
@ -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 = LYRA;
|
||||
|
||||
~CrossPointSettings() = default;
|
||||
|
||||
|
||||
@ -1,20 +1,25 @@
|
||||
#include "RecentBooksStore.h"
|
||||
|
||||
#include <Epub.h>
|
||||
#include <HardwareSerial.h>
|
||||
#include <SDCardManager.h>
|
||||
#include <Serialization.h>
|
||||
#include <Xtc.h>
|
||||
|
||||
#include <algorithm>
|
||||
|
||||
#include "util/StringUtils.h"
|
||||
|
||||
namespace {
|
||||
constexpr uint8_t RECENT_BOOKS_FILE_VERSION = 2;
|
||||
constexpr uint8_t RECENT_BOOKS_FILE_VERSION = 3;
|
||||
constexpr char RECENT_BOOKS_FILE[] = "/.crosspoint/recent.bin";
|
||||
constexpr int MAX_RECENT_BOOKS = 10;
|
||||
} // namespace
|
||||
|
||||
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 +28,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 +55,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();
|
||||
@ -57,6 +63,34 @@ bool RecentBooksStore::saveToFile() const {
|
||||
return true;
|
||||
}
|
||||
|
||||
RecentBook RecentBooksStore::getDataFromBook(std::string path) const {
|
||||
std::string lastBookFileName = "";
|
||||
const size_t lastSlash = path.find_last_of('/');
|
||||
if (lastSlash != std::string::npos) {
|
||||
lastBookFileName = path.substr(lastSlash + 1);
|
||||
}
|
||||
|
||||
Serial.printf("Loading recent book: %s\n", path.c_str());
|
||||
|
||||
// If epub, try to load the metadata for title/author and cover
|
||||
if (StringUtils::checkFileExtension(lastBookFileName, ".epub")) {
|
||||
Epub epub(path, "/.crosspoint");
|
||||
epub.load(false);
|
||||
return RecentBook{path, epub.getTitle(), epub.getAuthor(), epub.getThumbBmpPath()};
|
||||
} else if (StringUtils::checkFileExtension(lastBookFileName, ".xtch") ||
|
||||
StringUtils::checkFileExtension(lastBookFileName, ".xtc")) {
|
||||
// Handle XTC file
|
||||
Xtc xtc(path, "/.crosspoint");
|
||||
if (xtc.load()) {
|
||||
return RecentBook{path, xtc.getTitle(), xtc.getAuthor(), xtc.getThumbBmpPath()};
|
||||
}
|
||||
} else if (StringUtils::checkFileExtension(lastBookFileName, ".txt") ||
|
||||
StringUtils::checkFileExtension(lastBookFileName, ".md")) {
|
||||
return RecentBook{path, lastBookFileName, "", ""};
|
||||
}
|
||||
return RecentBook{path, "", "", ""};
|
||||
}
|
||||
|
||||
bool RecentBooksStore::loadFromFile() {
|
||||
FsFile inputFile;
|
||||
if (!SdMan.openFileForRead("RBS", RECENT_BOOKS_FILE, inputFile)) {
|
||||
@ -66,7 +100,7 @@ bool RecentBooksStore::loadFromFile() {
|
||||
uint8_t version;
|
||||
serialization::readPod(inputFile, version);
|
||||
if (version != RECENT_BOOKS_FILE_VERSION) {
|
||||
if (version == 1) {
|
||||
if (version == 1 || version == 2) {
|
||||
// Old version, just read paths
|
||||
uint8_t count;
|
||||
serialization::readPod(inputFile, count);
|
||||
@ -75,9 +109,18 @@ bool RecentBooksStore::loadFromFile() {
|
||||
for (uint8_t i = 0; i < count; i++) {
|
||||
std::string path;
|
||||
serialization::readString(inputFile, path);
|
||||
// Title and author will be empty, they will be filled when the book is
|
||||
// opened again
|
||||
recentBooks.push_back({path, "", ""});
|
||||
|
||||
// load book to get missing data
|
||||
RecentBook book = getDataFromBook(path);
|
||||
if (book.title.empty() && book.author.empty() && version == 2) {
|
||||
// Fall back to loading what we can from the store
|
||||
std::string title, author;
|
||||
serialization::readString(inputFile, title);
|
||||
serialization::readString(inputFile, author);
|
||||
recentBooks.push_back({path, title, author, ""});
|
||||
} else {
|
||||
recentBooks.push_back(book);
|
||||
}
|
||||
}
|
||||
} else {
|
||||
Serial.printf("[%lu] [RBS] Deserialization failed: Unknown version %u\n", millis(), version);
|
||||
@ -92,11 +135,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; }
|
||||
@ -34,6 +36,7 @@ class RecentBooksStore {
|
||||
bool saveToFile() const;
|
||||
|
||||
bool loadFromFile();
|
||||
RecentBook getDataFromBook(std::string path) const;
|
||||
};
|
||||
|
||||
// Helper macro to access recent books store
|
||||
|
||||
@ -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,88 @@ 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");
|
||||
// Skip loading css since we only need metadata here
|
||||
epub.load(false, true);
|
||||
|
||||
// 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,63 +118,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");
|
||||
// Skip loading css since we only need metadata here
|
||||
epub.load(false, true);
|
||||
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
|
||||
@ -173,21 +200,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) {
|
||||
@ -212,350 +242,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;
|
||||
}
|
||||
|
||||
// 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
|
||||
}
|
||||
bool bufferRestored = coverBufferStored && restoreCoverBuffer();
|
||||
if (!firstRenderDone || (recentsLoaded && !recentsDisplayed)) {
|
||||
renderer.clearScreen();
|
||||
}
|
||||
|
||||
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));
|
||||
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);
|
||||
}
|
||||
|
||||
std::vector<std::string> lines;
|
||||
std::string currentLine;
|
||||
// Extra padding inside the card so text doesn't hug the border
|
||||
const int maxLineWidth = bookWidth - 40;
|
||||
const int spaceWidth = renderer.getSpaceWidth(UI_12_FONT_ID);
|
||||
|
||||
for (auto& i : words) {
|
||||
// If we just hit the line limit (3), stop processing words
|
||||
if (lines.size() >= 3) {
|
||||
// Limit to 3 lines
|
||||
// Still have words left, so add ellipsis to last line
|
||||
lines.back().append("...");
|
||||
|
||||
while (!lines.back().empty() && renderer.getTextWidth(UI_12_FONT_ID, lines.back().c_str()) > maxLineWidth) {
|
||||
// Remove "..." first, then remove one UTF-8 char, then add "..." back
|
||||
lines.back().resize(lines.back().size() - 3); // Remove "..."
|
||||
utf8RemoveLastChar(lines.back());
|
||||
lines.back().append("...");
|
||||
}
|
||||
break;
|
||||
}
|
||||
|
||||
int wordWidth = renderer.getTextWidth(UI_12_FONT_ID, i.c_str());
|
||||
while (wordWidth > maxLineWidth && !i.empty()) {
|
||||
// Word itself is too long, trim it (UTF-8 safe)
|
||||
utf8RemoveLastChar(i);
|
||||
// Check if we have room for ellipsis
|
||||
std::string withEllipsis = i + "...";
|
||||
wordWidth = renderer.getTextWidth(UI_12_FONT_ID, withEllipsis.c_str());
|
||||
if (wordWidth <= maxLineWidth) {
|
||||
i = withEllipsis;
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
int newLineWidth = renderer.getTextWidth(UI_12_FONT_ID, currentLine.c_str());
|
||||
if (newLineWidth > 0) {
|
||||
newLineWidth += spaceWidth;
|
||||
}
|
||||
newLineWidth += wordWidth;
|
||||
|
||||
if (newLineWidth > maxLineWidth && !currentLine.empty()) {
|
||||
// New line too long, push old line
|
||||
lines.push_back(currentLine);
|
||||
currentLine = i;
|
||||
} else {
|
||||
currentLine.append(" ").append(i);
|
||||
}
|
||||
}
|
||||
|
||||
// If lower than the line limit, push remaining words
|
||||
if (!currentLine.empty() && lines.size() < 3) {
|
||||
lines.push_back(currentLine);
|
||||
}
|
||||
|
||||
// Book title text
|
||||
int totalTextHeight = renderer.getLineHeight(UI_12_FONT_ID) * static_cast<int>(lines.size());
|
||||
if (!lastBookAuthor.empty()) {
|
||||
totalTextHeight += renderer.getLineHeight(UI_10_FONT_ID) * 3 / 2;
|
||||
}
|
||||
|
||||
// Vertically center the title block within the card
|
||||
int titleYStart = bookY + (bookHeight - totalTextHeight) / 2;
|
||||
|
||||
// If cover image was rendered, draw box behind title and author
|
||||
if (coverRendered) {
|
||||
constexpr int boxPadding = 8;
|
||||
// Calculate the max text width for the box
|
||||
int maxTextWidth = 0;
|
||||
for (const auto& line : lines) {
|
||||
const int lineWidth = renderer.getTextWidth(UI_12_FONT_ID, line.c_str());
|
||||
if (lineWidth > maxTextWidth) {
|
||||
maxTextWidth = lineWidth;
|
||||
}
|
||||
}
|
||||
if (!lastBookAuthor.empty()) {
|
||||
std::string trimmedAuthor = lastBookAuthor;
|
||||
while (renderer.getTextWidth(UI_10_FONT_ID, trimmedAuthor.c_str()) > maxLineWidth && !trimmedAuthor.empty()) {
|
||||
utf8RemoveLastChar(trimmedAuthor);
|
||||
}
|
||||
if (renderer.getTextWidth(UI_10_FONT_ID, trimmedAuthor.c_str()) <
|
||||
renderer.getTextWidth(UI_10_FONT_ID, lastBookAuthor.c_str())) {
|
||||
trimmedAuthor.append("...");
|
||||
}
|
||||
const int authorWidth = renderer.getTextWidth(UI_10_FONT_ID, trimmedAuthor.c_str());
|
||||
if (authorWidth > maxTextWidth) {
|
||||
maxTextWidth = authorWidth;
|
||||
}
|
||||
}
|
||||
|
||||
const int boxWidth = maxTextWidth + boxPadding * 2;
|
||||
const int boxHeight = totalTextHeight + boxPadding * 2;
|
||||
const int boxX = (pageWidth - boxWidth) / 2;
|
||||
const int boxY = titleYStart - boxPadding;
|
||||
|
||||
// Draw box (inverted when selected: black box instead of white)
|
||||
renderer.fillRect(boxX, boxY, boxWidth, boxHeight, bookSelected);
|
||||
// Draw border around the box (inverted when selected: white border instead of black)
|
||||
renderer.drawRect(boxX, boxY, boxWidth, boxHeight, !bookSelected);
|
||||
}
|
||||
|
||||
for (const auto& line : lines) {
|
||||
renderer.drawCenteredText(UI_12_FONT_ID, titleYStart, line.c_str(), !bookSelected);
|
||||
titleYStart += renderer.getLineHeight(UI_12_FONT_ID);
|
||||
}
|
||||
|
||||
if (!lastBookAuthor.empty()) {
|
||||
titleYStart += renderer.getLineHeight(UI_10_FONT_ID) / 2;
|
||||
std::string trimmedAuthor = lastBookAuthor;
|
||||
// Trim author if too long (UTF-8 safe)
|
||||
bool wasTrimmed = false;
|
||||
while (renderer.getTextWidth(UI_10_FONT_ID, trimmedAuthor.c_str()) > maxLineWidth && !trimmedAuthor.empty()) {
|
||||
utf8RemoveLastChar(trimmedAuthor);
|
||||
wasTrimmed = true;
|
||||
}
|
||||
if (wasTrimmed && !trimmedAuthor.empty()) {
|
||||
// Make room for ellipsis
|
||||
while (renderer.getTextWidth(UI_10_FONT_ID, (trimmedAuthor + "...").c_str()) > maxLineWidth &&
|
||||
!trimmedAuthor.empty()) {
|
||||
utf8RemoveLastChar(trimmedAuthor);
|
||||
}
|
||||
trimmedAuthor.append("...");
|
||||
}
|
||||
renderer.drawCenteredText(UI_10_FONT_ID, titleYStart, trimmedAuthor.c_str(), !bookSelected);
|
||||
}
|
||||
|
||||
// "Continue Reading" label at the bottom
|
||||
const int continueY = bookY + bookHeight - renderer.getLineHeight(UI_10_FONT_ID) * 3 / 2;
|
||||
if (coverRendered) {
|
||||
// Draw box behind "Continue Reading" text (inverted when selected: black box instead of white)
|
||||
const char* continueText = "Continue Reading";
|
||||
const int continueTextWidth = renderer.getTextWidth(UI_10_FONT_ID, continueText);
|
||||
constexpr int continuePadding = 6;
|
||||
const int continueBoxWidth = continueTextWidth + continuePadding * 2;
|
||||
const int continueBoxHeight = renderer.getLineHeight(UI_10_FONT_ID) + continuePadding;
|
||||
const int continueBoxX = (pageWidth - continueBoxWidth) / 2;
|
||||
const int continueBoxY = continueY - continuePadding / 2;
|
||||
renderer.fillRect(continueBoxX, continueBoxY, continueBoxWidth, continueBoxHeight, bookSelected);
|
||||
renderer.drawRect(continueBoxX, continueBoxY, continueBoxWidth, continueBoxHeight, !bookSelected);
|
||||
renderer.drawCenteredText(UI_10_FONT_ID, continueY, continueText, !bookSelected);
|
||||
} else {
|
||||
renderer.drawCenteredText(UI_10_FONT_ID, continueY, "Continue Reading", !bookSelected);
|
||||
}
|
||||
} else {
|
||||
// No book to continue reading
|
||||
const int y =
|
||||
bookY + (bookHeight - renderer.getLineHeight(UI_12_FONT_ID) - renderer.getLineHeight(UI_10_FONT_ID)) / 2;
|
||||
renderer.drawCenteredText(UI_12_FONT_ID, y, "No open book");
|
||||
renderer.drawCenteredText(UI_10_FONT_ID, y + renderer.getLineHeight(UI_12_FONT_ID), "Start reading below");
|
||||
}
|
||||
|
||||
// --- Bottom menu tiles ---
|
||||
// Build menu items dynamically
|
||||
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]); }, 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+) goes to root folder
|
||||
if (mappedInput.isPressed(MappedInputManager::Button::Back) && mappedInput.getHeldTime() >= GO_HOME_MS &&
|
||||
basepath != "/") {
|
||||
basepath = "/";
|
||||
loadFiles();
|
||||
selectorIndex = 0;
|
||||
updateRequired = true;
|
||||
return;
|
||||
}
|
||||
|
||||
// Long press BACK (1s+) in Files tab goes to root folder
|
||||
if (currentTab == Tab::Files && mappedInput.isPressed(MappedInputManager::Button::Back) &&
|
||||
mappedInput.getHeldTime() >= GO_HOME_MS) {
|
||||
if (basepath != "/") {
|
||||
basepath = "/";
|
||||
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 (files.empty()) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (basepath.back() != '/') basepath += "/";
|
||||
if (files[selectorIndex].back() == '/') {
|
||||
basepath += files[selectorIndex].substr(0, files[selectorIndex].length() - 1);
|
||||
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 skipPage = mappedInput.getHeldTime() > SKIP_PAGE_MS;
|
||||
|
||||
// Confirm button - open selected item
|
||||
if (mappedInput.wasReleased(MappedInputManager::Button::Confirm)) {
|
||||
if (currentTab == Tab::Recent) {
|
||||
if (!recentBooks.empty() && selectorIndex < static_cast<int>(recentBooks.size())) {
|
||||
onSelectBook(recentBooks[selectorIndex].path, currentTab);
|
||||
}
|
||||
} else {
|
||||
// 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;
|
||||
}
|
||||
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]; }, nullptr, nullptr, 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; }, [this](int index) { return recentBooks[index].author; },
|
||||
nullptr, 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) {
|
||||
|
||||
@ -10,10 +10,9 @@ 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
|
||||
std::string currentBookPath; // Track current book path for navigation
|
||||
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 {
|
||||
@ -58,9 +58,11 @@ void TxtReaderActivity::onEnter() {
|
||||
txt->setupCacheDir();
|
||||
|
||||
// Save current txt as last opened file and add to recent books
|
||||
APP_STATE.openEpubPath = txt->getPath();
|
||||
auto filePath = txt->getPath();
|
||||
auto fileName = filePath.substr(filePath.rfind('/') + 1);
|
||||
APP_STATE.openEpubPath = filePath;
|
||||
APP_STATE.saveToFile();
|
||||
RECENT_BOOKS.addBook(txt->getPath(), "", "");
|
||||
RECENT_BOOKS.addBook(filePath, fileName, "", "");
|
||||
|
||||
// Trigger first update
|
||||
updateRequired = true;
|
||||
@ -168,13 +170,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 +214,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 +502,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 +524,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,11 +111,19 @@ 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);
|
||||
return;
|
||||
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)) {
|
||||
@ -110,56 +132,113 @@ 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;
|
||||
} else if (upReleased || leftReleased) {
|
||||
selectedSettingIndex = (selectedSettingIndex > 0) ? (selectedSettingIndex - 1) : (settingsCount);
|
||||
updateRequired = true;
|
||||
} else if (rightReleased || downReleased) {
|
||||
selectedSettingIndex = (selectedSettingIndex < settingsCount) ? (selectedSettingIndex + 1) : 0;
|
||||
updateRequired = true;
|
||||
}
|
||||
|
||||
if (hasChangedCategory) {
|
||||
selectedSettingIndex = (selectedSettingIndex == 0) ? 0 : 1;
|
||||
switch (selectedCategoryIndex) {
|
||||
case 0: // Display
|
||||
settingsList = displaySettings;
|
||||
settingsCount = displaySettingsCount;
|
||||
break;
|
||||
case 1: // Reader
|
||||
settingsList = readerSettings;
|
||||
settingsCount = readerSettingsCount;
|
||||
break;
|
||||
case 2: // Controls
|
||||
settingsList = controlsSettings;
|
||||
settingsCount = controlsSettingsCount;
|
||||
break;
|
||||
case 3: // System
|
||||
settingsList = systemSettings;
|
||||
settingsCount = systemSettingsCount;
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
void SettingsActivity::enterCategory(int categoryIndex) {
|
||||
if (categoryIndex < 0 || categoryIndex >= categoryCount) {
|
||||
void SettingsActivity::toggleCurrentSetting() {
|
||||
int selectedSetting = selectedSettingIndex - 1;
|
||||
if (selectedSetting < 0 || selectedSetting >= settingsCount) {
|
||||
return;
|
||||
}
|
||||
|
||||
xSemaphoreTake(renderingMutex, portMAX_DELAY);
|
||||
exitActivity();
|
||||
const auto& setting = settingsList[selectedSetting];
|
||||
|
||||
const SettingInfo* settingsList = nullptr;
|
||||
int settingsCount = 0;
|
||||
|
||||
switch (categoryIndex) {
|
||||
case 0: // Display
|
||||
settingsList = displaySettings;
|
||||
settingsCount = displaySettingsCount;
|
||||
break;
|
||||
case 1: // Reader
|
||||
settingsList = readerSettings;
|
||||
settingsCount = readerSettingsCount;
|
||||
break;
|
||||
case 2: // Controls
|
||||
settingsList = controlsSettings;
|
||||
settingsCount = controlsSettingsCount;
|
||||
break;
|
||||
case 3: // System
|
||||
settingsList = systemSettings;
|
||||
settingsCount = systemSettingsCount;
|
||||
break;
|
||||
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;
|
||||
}
|
||||
|
||||
enterNewActivity(new CategorySettingsActivity(renderer, mappedInput, categoryNames[categoryIndex], settingsList,
|
||||
settingsCount, [this] {
|
||||
exitActivity();
|
||||
updateRequired = true;
|
||||
}));
|
||||
xSemaphoreGive(renderingMutex);
|
||||
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); },
|
||||
nullptr, nullptr,
|
||||
[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()
|
||||
643
src/components/themes/BaseTheme.cpp
Normal file
643
src/components/themes/BaseTheme.cpp
Normal file
@ -0,0 +1,643 @@
|
||||
#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,
|
||||
const std::function<std::string(int index)>& rowSubtitle,
|
||||
const std::function<std::string(int index)>& rowIcon,
|
||||
const std::function<std::string(int index)>& rowValue) const {
|
||||
int rowHeight =
|
||||
(rowSubtitle != nullptr) ? 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 - (rowValue != nullptr ? 60 : 0);
|
||||
|
||||
// Draw name
|
||||
auto itemName = rowTitle(i);
|
||||
auto font = (rowSubtitle != nullptr) ? 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 (rowSubtitle != nullptr) {
|
||||
// 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 (rowValue != nullptr) {
|
||||
// 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,
|
||||
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,
|
||||
const std::function<std::string(int index)>& rowSubtitle,
|
||||
const std::function<std::string(int index)>& rowIcon,
|
||||
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,
|
||||
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;
|
||||
};
|
||||
375
src/components/themes/lyra/LyraTheme.cpp
Normal file
375
src/components/themes/lyra/LyraTheme.cpp
Normal file
@ -0,0 +1,375 @@
|
||||
#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,
|
||||
const std::function<std::string(int index)>& rowSubtitle,
|
||||
const std::function<std::string(int index)>& rowIcon,
|
||||
const std::function<std::string(int index)>& rowValue) const {
|
||||
int rowHeight =
|
||||
(rowSubtitle != nullptr) ? 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 -
|
||||
(rowValue != nullptr ? 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 (rowSubtitle != nullptr) {
|
||||
// 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 (rowValue != nullptr) {
|
||||
// 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,
|
||||
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,
|
||||
const std::function<std::string(int index)>& rowSubtitle,
|
||||
const std::function<std::string(int index)>& rowIcon,
|
||||
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,
|
||||
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