Compare commits

...

8 Commits

Author SHA1 Message Date
CaptainFrito
2750238fc4 feat: UI themes, Lyra 2026-02-01 16:01:41 +07:00
Gaspar Fabrega Ragni
4dd73a211a
fix: custom sleep not showing image at index 0 (#639)
Some checks are pending
CI / build (push) Waiting to run
## Summary

* Fixing custom sleep behaviour where the first image in the /sleep
directory is not shown
* image at index 0 is not being rendered when more than 1 image is
stored in /sleep directory, because `APP_STATE.lastSleepImage` is always
0.

## Additional Context

* `APP_STATE.lastSleepImage` is reset to 0 when a epub is open, this
value is only used to compare it to the randomly selected one in
`renderCustomSleepScreen()` that should always be a valid index, since
the list of valid bmp images is colected from scratch. -> no need to
reset it and block image @ index 0 from being rendered

---

### 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? _**NO**_

Co-authored-by: Oyster <Oyster@home>
2026-02-01 19:22:52 +11:00
Thomas Foskolos
634f6279cb
docs: Update USER_GUIDE.md (#625)
Add Greek as not supported language atm on the use guide
2026-02-01 19:21:36 +11:00
nscheung
11b2a59233
fix: Hide button hints in landscape CW mode (#637)
## Summary

* This change hides the button hints from overlapping chapter titles
when in landscape CW mode.

Before

![Before](https://github.com/user-attachments/assets/3015a9b3-3fa5-443b-a641-3e65841a6fbc)
After

![After](https://github.com/user-attachments/assets/011de15d-5ae6-429c-8f91-d8f37abe52d9)

## Additional Context

* I initially considered implementing an offset fix, but with potential
UI changes on the horizon, hiding the button hints appears to be the
simplest solution for now.

---

### 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 >
2026-02-01 19:21:28 +11:00
Luke Stein
12c20bb09e
fix: WiFi error screen text clarifications (#612)
## Summary

* Clarify strings on Wifi connection error screens
* I have confirmed on device that these are short enough not to overflow
screen margins

## Additional Context

* Several screens give duplicative text (e.g., header "Connection
Failed" with contents text "Connection failed") or slightly confusing
text (header "Forget Network?" with text "Remove saved password?")

---

### AI Usage

Did you use AI tools to help write this code? **No**
2026-02-01 19:19:23 +11:00
Arthur Tazhitdinov
6b7065b986
fix: don't wake up after USB connect (#576)
## Summary

* fixes problem that if short power button press is enabled, connecting
device to usb leads to waking up
2026-02-01 18:51:31 +11:00
Arthur Tazhitdinov
f4df513bf3
feat(ui): change popup logic (#442)
## Summary

* refactors Indexing popups into ScreenComponents (they had different
implementations in different files)
* removes Indexing popup for small chapters
* only show Indexing popup (without progress bar) for large chapters
(using same minimum file size condition as for progress bar before)

## Additional Context

* Having to show even single popup message and redraw the screen slows
down the flow significantly
* Testing results:
    * Opening large chapter with progress bar - 11 seconds
* Same chapter without progress bar, only single Indexing popup - 5
seconds

---

### AI Usage

Did you use AI tools to help write this code? _**< PARTIALLY>**_
2026-02-01 18:41:24 +11:00
Jonas Diemer
f935b59a41
feat: Add reading menu and delete cache function (#433)
## Summary

* Adds a menu in the Epub reader
* The Chapter selection is moved there to pos 1 (so it can be reached by
double tapping the confirm button)
* A Go Home is there, too
* Most significantly, a function "Delete Book Cache" is added. This
returns to main (to avoid directly rebuilding cached items, eg. if this
is used to debug/develop other areas - and it's also easier ;))

Probably, the Sync function could now be moved from the Chapter
selection to this menu, too.

---

### 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**_
2026-02-01 18:34:30 +11:00
58 changed files with 2619 additions and 1511 deletions

View File

@ -201,7 +201,7 @@ CrossPoint renders text using the following Unicode character blocks, enabling s
* **Latin Script (Basic, Supplement, Extended-A):** Covers English, German, French, Spanish, Portuguese, Italian, Dutch, Swedish, Norwegian, Danish, Finnish, Polish, Czech, Hungarian, Romanian, Slovak, Slovenian, Turkish, and others. * **Latin Script (Basic, Supplement, Extended-A):** Covers English, German, French, Spanish, Portuguese, Italian, Dutch, Swedish, Norwegian, Danish, Finnish, Polish, Czech, Hungarian, Romanian, Slovak, Slovenian, Turkish, and others.
* **Cyrillic Script (Standard and Extended):** Covers Russian, Ukrainian, Belarusian, Bulgarian, Serbian, Macedonian, Kazakh, Kyrgyz, Mongolian, and others. * **Cyrillic Script (Standard and Extended):** Covers Russian, Ukrainian, Belarusian, Bulgarian, Serbian, Macedonian, Kazakh, Kyrgyz, Mongolian, and others.
What is not supported: Chinese, Japanese, Korean, Vietnamese, Hebrew, Arabic and Farsi. What is not supported: Chinese, Japanese, Korean, Vietnamese, Hebrew, Arabic, Greek and Farsi.
--- ---

View File

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

View File

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

View File

@ -123,9 +123,7 @@ bool Section::clearCache() const {
bool Section::createSectionFile(const int fontId, const float lineCompression, const bool extraParagraphSpacing, bool Section::createSectionFile(const int fontId, const float lineCompression, const bool extraParagraphSpacing,
const uint8_t paragraphAlignment, const uint16_t viewportWidth, const uint8_t paragraphAlignment, const uint16_t viewportWidth,
const uint16_t viewportHeight, const bool hyphenationEnabled, const uint16_t viewportHeight, const bool hyphenationEnabled,
const std::function<void()>& progressSetupFn, const std::function<void()>& popupFn) {
const std::function<void(int)>& progressFn) {
constexpr uint32_t MIN_SIZE_FOR_PROGRESS = 50 * 1024; // 50KB
const auto localPath = epub->getSpineItem(spineIndex).href; const auto localPath = epub->getSpineItem(spineIndex).href;
const auto tmpHtmlPath = epub->getCachePath() + "/.tmp_" + std::to_string(spineIndex) + ".html"; const auto tmpHtmlPath = epub->getCachePath() + "/.tmp_" + std::to_string(spineIndex) + ".html";
@ -171,11 +169,6 @@ bool Section::createSectionFile(const int fontId, const float lineCompression, c
Serial.printf("[%lu] [SCT] Streamed temp HTML to %s (%d bytes)\n", millis(), tmpHtmlPath.c_str(), fileSize); Serial.printf("[%lu] [SCT] Streamed temp HTML to %s (%d bytes)\n", millis(), tmpHtmlPath.c_str(), fileSize);
// Only show progress bar for larger chapters where rendering overhead is worth it
if (progressSetupFn && fileSize >= MIN_SIZE_FOR_PROGRESS) {
progressSetupFn();
}
if (!SdMan.openFileForWrite("SCT", filePath, file)) { if (!SdMan.openFileForWrite("SCT", filePath, file)) {
return false; return false;
} }
@ -186,8 +179,7 @@ bool Section::createSectionFile(const int fontId, const float lineCompression, c
ChapterHtmlSlimParser visitor( ChapterHtmlSlimParser visitor(
tmpHtmlPath, renderer, fontId, lineCompression, extraParagraphSpacing, paragraphAlignment, viewportWidth, tmpHtmlPath, renderer, fontId, lineCompression, extraParagraphSpacing, paragraphAlignment, viewportWidth,
viewportHeight, hyphenationEnabled, viewportHeight, hyphenationEnabled,
[this, &lut](std::unique_ptr<Page> page) { lut.emplace_back(this->onPageComplete(std::move(page))); }, [this, &lut](std::unique_ptr<Page> page) { lut.emplace_back(this->onPageComplete(std::move(page))); }, popupFn);
progressFn);
Hyphenator::setPreferredLanguage(epub->getLanguage()); Hyphenator::setPreferredLanguage(epub->getLanguage());
success = visitor.parseAndBuildPages(); success = visitor.parseAndBuildPages();

View File

@ -33,7 +33,6 @@ class Section {
bool clearCache() const; bool clearCache() const;
bool createSectionFile(int fontId, float lineCompression, bool extraParagraphSpacing, uint8_t paragraphAlignment, bool createSectionFile(int fontId, float lineCompression, bool extraParagraphSpacing, uint8_t paragraphAlignment,
uint16_t viewportWidth, uint16_t viewportHeight, bool hyphenationEnabled, uint16_t viewportWidth, uint16_t viewportHeight, bool hyphenationEnabled,
const std::function<void()>& progressSetupFn = nullptr, const std::function<void()>& popupFn = nullptr);
const std::function<void(int)>& progressFn = nullptr);
std::unique_ptr<Page> loadPageFromSectionFile(); std::unique_ptr<Page> loadPageFromSectionFile();
}; };

View File

@ -10,8 +10,8 @@
const char* HEADER_TAGS[] = {"h1", "h2", "h3", "h4", "h5", "h6"}; const char* HEADER_TAGS[] = {"h1", "h2", "h3", "h4", "h5", "h6"};
constexpr int NUM_HEADER_TAGS = sizeof(HEADER_TAGS) / sizeof(HEADER_TAGS[0]); constexpr int NUM_HEADER_TAGS = sizeof(HEADER_TAGS) / sizeof(HEADER_TAGS[0]);
// Minimum file size (in bytes) to show progress bar - smaller chapters don't benefit from it // Minimum file size (in bytes) to show indexing popup - smaller chapters don't benefit from it
constexpr size_t MIN_SIZE_FOR_PROGRESS = 50 * 1024; // 50KB constexpr size_t MIN_SIZE_FOR_POPUP = 50 * 1024; // 50KB
const char* BLOCK_TAGS[] = {"p", "li", "div", "br", "blockquote"}; const char* BLOCK_TAGS[] = {"p", "li", "div", "br", "blockquote"};
constexpr int NUM_BLOCK_TAGS = sizeof(BLOCK_TAGS) / sizeof(BLOCK_TAGS[0]); constexpr int NUM_BLOCK_TAGS = sizeof(BLOCK_TAGS) / sizeof(BLOCK_TAGS[0]);
@ -289,10 +289,10 @@ bool ChapterHtmlSlimParser::parseAndBuildPages() {
return false; return false;
} }
// Get file size for progress calculation // Get file size to decide whether to show indexing popup.
const size_t totalSize = file.size(); if (popupFn && file.size() >= MIN_SIZE_FOR_POPUP) {
size_t bytesRead = 0; popupFn();
int lastProgress = -1; }
XML_SetUserData(parser, this); XML_SetUserData(parser, this);
XML_SetElementHandler(parser, startElement, endElement); XML_SetElementHandler(parser, startElement, endElement);
@ -322,17 +322,6 @@ bool ChapterHtmlSlimParser::parseAndBuildPages() {
return false; return false;
} }
// Update progress (call every 10% change to avoid too frequent updates)
// Only show progress for larger chapters where rendering overhead is worth it
bytesRead += len;
if (progressFn && totalSize >= MIN_SIZE_FOR_PROGRESS) {
const int progress = static_cast<int>((bytesRead * 100) / totalSize);
if (lastProgress / 10 != progress / 10) {
lastProgress = progress;
progressFn(progress);
}
}
done = file.available() == 0; done = file.available() == 0;
if (XML_ParseBuffer(parser, static_cast<int>(len), done) == XML_STATUS_ERROR) { if (XML_ParseBuffer(parser, static_cast<int>(len), done) == XML_STATUS_ERROR) {

View File

@ -18,7 +18,7 @@ class ChapterHtmlSlimParser {
const std::string& filepath; const std::string& filepath;
GfxRenderer& renderer; GfxRenderer& renderer;
std::function<void(std::unique_ptr<Page>)> completePageFn; std::function<void(std::unique_ptr<Page>)> completePageFn;
std::function<void(int)> progressFn; // Progress callback (0-100) std::function<void()> popupFn; // Popup callback
int depth = 0; int depth = 0;
int skipUntilDepth = INT_MAX; int skipUntilDepth = INT_MAX;
int boldUntilDepth = INT_MAX; int boldUntilDepth = INT_MAX;
@ -52,7 +52,7 @@ class ChapterHtmlSlimParser {
const uint8_t paragraphAlignment, const uint16_t viewportWidth, const uint8_t paragraphAlignment, const uint16_t viewportWidth,
const uint16_t viewportHeight, const bool hyphenationEnabled, const uint16_t viewportHeight, const bool hyphenationEnabled,
const std::function<void(std::unique_ptr<Page>)>& completePageFn, const std::function<void(std::unique_ptr<Page>)>& completePageFn,
const std::function<void(int)>& progressFn = nullptr) const std::function<void()>& popupFn = nullptr)
: filepath(filepath), : filepath(filepath),
renderer(renderer), renderer(renderer),
fontId(fontId), fontId(fontId),
@ -63,7 +63,7 @@ class ChapterHtmlSlimParser {
viewportHeight(viewportHeight), viewportHeight(viewportHeight),
hyphenationEnabled(hyphenationEnabled), hyphenationEnabled(hyphenationEnabled),
completePageFn(completePageFn), completePageFn(completePageFn),
progressFn(progressFn) {} popupFn(popupFn) {}
~ChapterHtmlSlimParser() = default; ~ChapterHtmlSlimParser() = default;
bool parseAndBuildPages(); bool parseAndBuildPages();
void addLineToPage(std::shared_ptr<TextBlock> line); void addLineToPage(std::shared_ptr<TextBlock> line);

View File

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

View File

@ -7,6 +7,16 @@
#include "Bitmap.h" #include "Bitmap.h"
// Color representation: uint8_t mapped to 4x4 Bayer matrix dithering levels
// 0 = transparent, 1-16 = gray levels (white to black)
using Color = uint8_t;
constexpr Color COLOR_CLEAR = 0x00;
constexpr Color COLOR_WHITE = 0x01;
constexpr Color COLOR_LIGHT_GRAY = 0x05;
constexpr Color COLOR_DARK_GRAY = 0x0A;
constexpr Color COLOR_BLACK = 0x10;
class GfxRenderer { class GfxRenderer {
public: public:
enum RenderMode { BW, GRAYSCALE_LSB, GRAYSCALE_MSB }; enum RenderMode { BW, GRAYSCALE_LSB, GRAYSCALE_MSB };
@ -34,6 +44,8 @@ class GfxRenderer {
EpdFontFamily::Style style) const; EpdFontFamily::Style style) const;
void freeBwBufferChunks(); void freeBwBufferChunks();
void rotateCoordinates(int x, int y, int* rotatedX, int* rotatedY) const; void rotateCoordinates(int x, int y, int* rotatedX, int* rotatedY) const;
void drawPixelDither(int x, int y, Color color) const;
void fillArc(int maxRadius, int cx, int cy, int xDir, int yDir, Color color) const;
public: public:
explicit GfxRenderer(HalDisplay& halDisplay) : display(halDisplay), renderMode(BW), orientation(Portrait) {} explicit GfxRenderer(HalDisplay& halDisplay) : display(halDisplay), renderMode(BW), orientation(Portrait) {}
@ -56,16 +68,27 @@ class GfxRenderer {
int getScreenHeight() const; int getScreenHeight() const;
void displayBuffer(HalDisplay::RefreshMode refreshMode = HalDisplay::FAST_REFRESH) const; void displayBuffer(HalDisplay::RefreshMode refreshMode = HalDisplay::FAST_REFRESH) const;
// EXPERIMENTAL: Windowed update - display only a rectangular region // EXPERIMENTAL: Windowed update - display only a rectangular region
void displayWindow(int x, int y, int width, int height) const; // void displayWindow(int x, int y, int width, int height) const;
void invertScreen() const; void invertScreen() const;
void clearScreen(uint8_t color = 0xFF) const; void clearScreen(uint8_t color = 0xFF) const;
// Drawing // Drawing
void drawPixel(int x, int y, bool state = true) const; void drawPixel(int x, int y, bool state = true) const;
void drawLine(int x1, int y1, int x2, int y2, bool state = true) const; void drawLine(int x1, int y1, int x2, int y2, bool state = true) const;
void drawLine(int x1, int y1, int x2, int y2, int lineWidth, bool state) const;
void drawArc(int maxRadius, int cx, int cy, int xDir, int yDir, int lineWidth, bool state) const;
void drawRect(int x, int y, int width, int height, bool state = true) const; void drawRect(int x, int y, int width, int height, bool state = true) const;
void drawRect(int x, int y, int width, int height, int lineWidth, bool state) const;
void drawRoundedRect(int x, int y, int width, int height, int lineWidth, int cornerRadius, bool state) const;
void drawRoundedRect(int x, int y, int width, int height, int lineWidth, int cornerRadius, bool roundTopLeft,
bool roundTopRight, bool roundBottomLeft, bool roundBottomRight, bool state) const;
void fillRect(int x, int y, int width, int height, bool state = true) const; void fillRect(int x, int y, int width, int height, bool state = true) const;
void fillRectDither(int x, int y, int width, int height, Color color) const;
void fillRoundedRect(int x, int y, int width, int height, int cornerRadius, Color color) const;
void fillRoundedRect(int x, int y, int width, int height, int cornerRadius, bool roundTopLeft, bool roundTopRight,
bool roundBottomLeft, bool roundBottomRight, Color color) const;
void drawImage(const uint8_t bitmap[], int x, int y, int width, int height) const; void drawImage(const uint8_t bitmap[], int x, int y, int width, int height) const;
void drawIcon(const uint8_t bitmap[], int x, int y, int width, int height) const;
void drawBitmap(const Bitmap& bitmap, int x, int y, int maxWidth, int maxHeight, float cropX = 0, void drawBitmap(const Bitmap& bitmap, int x, int y, int maxWidth, int maxHeight, float cropX = 0,
float cropY = 0) const; float cropY = 0) const;
void drawBitmap1Bit(const Bitmap& bitmap, int x, int y, int maxWidth, int maxHeight) const; void drawBitmap1Bit(const Bitmap& bitmap, int x, int y, int maxWidth, int maxHeight) const;
@ -83,17 +106,11 @@ class GfxRenderer {
std::string truncatedText(int fontId, const char* text, int maxWidth, std::string truncatedText(int fontId, const char* text, int maxWidth,
EpdFontFamily::Style style = EpdFontFamily::REGULAR) const; EpdFontFamily::Style style = EpdFontFamily::REGULAR) const;
// UI Components
void drawButtonHints(int fontId, const char* btn1, const char* btn2, const char* btn3, const char* btn4);
void drawSideButtonHints(int fontId, const char* topBtn, const char* bottomBtn) const;
private:
// Helper for drawing rotated text (90 degrees clockwise, for side buttons) // Helper for drawing rotated text (90 degrees clockwise, for side buttons)
void drawTextRotated90CW(int fontId, int x, int y, const char* text, bool black = true, void drawTextRotated90CW(int fontId, int x, int y, const char* text, bool black = true,
EpdFontFamily::Style style = EpdFontFamily::REGULAR) const; EpdFontFamily::Style style = EpdFontFamily::REGULAR) const;
int getTextHeight(int fontId) const; int getTextHeight(int fontId) const;
public:
// Grayscale functions // Grayscale functions
void setRenderMode(const RenderMode mode) { this->renderMode = mode; } void setRenderMode(const RenderMode mode) { this->renderMode = mode; }
void copyGrayscaleLsbBuffers() const; void copyGrayscaleLsbBuffers() const;

View File

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

View File

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

View File

@ -24,12 +24,13 @@ bool HalGPIO::wasAnyReleased() const { return inputMgr.wasAnyReleased(); }
unsigned long HalGPIO::getHeldTime() const { return inputMgr.getHeldTime(); } unsigned long HalGPIO::getHeldTime() const { return inputMgr.getHeldTime(); }
void HalGPIO::startDeepSleep() { void HalGPIO::startDeepSleep() {
esp_deep_sleep_enable_gpio_wakeup(1ULL << InputManager::POWER_BUTTON_PIN, ESP_GPIO_WAKEUP_GPIO_LOW);
// Ensure that the power button has been released to avoid immediately turning back on if you're holding it // Ensure that the power button has been released to avoid immediately turning back on if you're holding it
while (inputMgr.isPressed(BTN_POWER)) { while (inputMgr.isPressed(BTN_POWER)) {
delay(50); delay(50);
inputMgr.update(); inputMgr.update();
} }
// Arm the wakeup trigger *after* the button is released
esp_deep_sleep_enable_gpio_wakeup(1ULL << InputManager::POWER_BUTTON_PIN, ESP_GPIO_WAKEUP_GPIO_LOW);
// Enter Deep Sleep // Enter Deep Sleep
esp_deep_sleep_start(); esp_deep_sleep_start();
} }
@ -44,12 +45,20 @@ bool HalGPIO::isUsbConnected() const {
return digitalRead(UART0_RXD) == HIGH; return digitalRead(UART0_RXD) == HIGH;
} }
bool HalGPIO::isWakeupByPowerButton() const { HalGPIO::WakeupReason HalGPIO::getWakeupReason() const {
const bool usbConnected = isUsbConnected();
const auto wakeupCause = esp_sleep_get_wakeup_cause(); const auto wakeupCause = esp_sleep_get_wakeup_cause();
const auto resetReason = esp_reset_reason(); const auto resetReason = esp_reset_reason();
if (isUsbConnected()) {
return wakeupCause == ESP_SLEEP_WAKEUP_GPIO; if ((wakeupCause == ESP_SLEEP_WAKEUP_UNDEFINED && resetReason == ESP_RST_POWERON && !usbConnected) ||
} else { (wakeupCause == ESP_SLEEP_WAKEUP_GPIO && resetReason == ESP_RST_DEEPSLEEP && usbConnected)) {
return (wakeupCause == ESP_SLEEP_WAKEUP_UNDEFINED) && (resetReason == ESP_RST_POWERON); return WakeupReason::PowerButton;
} }
} if (wakeupCause == ESP_SLEEP_WAKEUP_UNDEFINED && resetReason == ESP_RST_UNKNOWN && usbConnected) {
return WakeupReason::AfterFlash;
}
if (wakeupCause == ESP_SLEEP_WAKEUP_UNDEFINED && resetReason == ESP_RST_POWERON && usbConnected) {
return WakeupReason::AfterUSBPower;
}
return WakeupReason::Other;
}

View File

@ -47,8 +47,9 @@ class HalGPIO {
// Check if USB is connected // Check if USB is connected
bool isUsbConnected() const; bool isUsbConnected() const;
// Check if wakeup was caused by power button press enum class WakeupReason { PowerButton, AfterFlash, AfterUSBPower, Other };
bool isWakeupByPowerButton() const;
WakeupReason getWakeupReason() const;
// Button indices // Button indices
static constexpr uint8_t BTN_BACK = 0; static constexpr uint8_t BTN_BACK = 0;

View File

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

View File

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

View File

@ -10,6 +10,11 @@ struct RecentBook {
bool operator==(const RecentBook& other) const { return path == other.path; } bool operator==(const RecentBook& other) const { return path == other.path; }
}; };
struct RecentBookWithCover {
RecentBook book;
std::string coverBmpPath;
};
class RecentBooksStore { class RecentBooksStore {
// Static instance // Static instance
static RecentBooksStore instance; static RecentBooksStore instance;

View File

@ -1,146 +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);
}
void ScreenComponents::drawBookProgressBar(const GfxRenderer& renderer, const size_t bookProgress) {
int vieweableMarginTop, vieweableMarginRight, vieweableMarginBottom, vieweableMarginLeft;
renderer.getOrientedViewableTRBL(&vieweableMarginTop, &vieweableMarginRight, &vieweableMarginBottom,
&vieweableMarginLeft);
const int progressBarMaxWidth = renderer.getScreenWidth() - vieweableMarginLeft - vieweableMarginRight;
const int progressBarY = renderer.getScreenHeight() - vieweableMarginBottom - BOOK_PROGRESS_BAR_HEIGHT;
const int barWidth = progressBarMaxWidth * bookProgress / 100;
renderer.fillRect(vieweableMarginLeft, progressBarY, barWidth, BOOK_PROGRESS_BAR_HEIGHT, true);
}
int ScreenComponents::drawTabBar(const GfxRenderer& renderer, const int y, const std::vector<TabInfo>& tabs) {
constexpr int tabPadding = 20; // Horizontal padding between tabs
constexpr int leftMargin = 20; // Left margin for first tab
constexpr int underlineHeight = 2; // Height of selection underline
constexpr int underlineGap = 4; // Gap between text and underline
const int lineHeight = renderer.getLineHeight(UI_12_FONT_ID);
const int tabBarHeight = lineHeight + underlineGap + underlineHeight;
int currentX = leftMargin;
for (const auto& tab : tabs) {
const int textWidth =
renderer.getTextWidth(UI_12_FONT_ID, tab.label, tab.selected ? EpdFontFamily::BOLD : EpdFontFamily::REGULAR);
// Draw tab label
renderer.drawText(UI_12_FONT_ID, currentX, y, tab.label, true,
tab.selected ? EpdFontFamily::BOLD : EpdFontFamily::REGULAR);
// Draw underline for selected tab
if (tab.selected) {
renderer.fillRect(currentX, y + lineHeight + underlineGap, textWidth, underlineHeight);
}
currentX += textWidth + tabPadding;
}
return tabBarHeight;
}
void ScreenComponents::drawScrollIndicator(const GfxRenderer& renderer, const int currentPage, const int totalPages,
const int contentTop, const int contentHeight) {
if (totalPages <= 1) {
return; // No need for indicator if only one page
}
const int screenWidth = renderer.getScreenWidth();
constexpr int indicatorWidth = 20;
constexpr int arrowSize = 6;
constexpr int margin = 15; // Offset from right edge
const int centerX = screenWidth - indicatorWidth / 2 - margin;
const int indicatorTop = contentTop + 60; // Offset to avoid overlapping side button hints
const int indicatorBottom = contentTop + contentHeight - 30;
// Draw up arrow at top (^) - narrow point at top, wide base at bottom
for (int i = 0; i < arrowSize; ++i) {
const int lineWidth = 1 + i * 2;
const int startX = centerX - i;
renderer.drawLine(startX, indicatorTop + i, startX + lineWidth - 1, indicatorTop + i);
}
// Draw down arrow at bottom (v) - wide base at top, narrow point at bottom
for (int i = 0; i < arrowSize; ++i) {
const int lineWidth = 1 + (arrowSize - 1 - i) * 2;
const int startX = centerX - (arrowSize - 1 - i);
renderer.drawLine(startX, indicatorBottom - arrowSize + 1 + i, startX + lineWidth - 1,
indicatorBottom - arrowSize + 1 + i);
}
// Draw page fraction in the middle (e.g., "1/3")
const std::string pageText = std::to_string(currentPage) + "/" + std::to_string(totalPages);
const int textWidth = renderer.getTextWidth(SMALL_FONT_ID, pageText.c_str());
const int textX = centerX - textWidth / 2;
const int textY = (indicatorTop + indicatorBottom) / 2 - renderer.getLineHeight(SMALL_FONT_ID) / 2;
renderer.drawText(SMALL_FONT_ID, textX, textY, pageText.c_str());
}
void ScreenComponents::drawProgressBar(const GfxRenderer& renderer, const int x, const int y, const int width,
const int height, const size_t current, const size_t total) {
if (total == 0) {
return;
}
// Use 64-bit arithmetic to avoid overflow for large files
const int percent = static_cast<int>((static_cast<uint64_t>(current) * 100) / total);
// Draw outline
renderer.drawRect(x, y, width, height);
// Draw filled portion
const int fillWidth = (width - 4) * percent / 100;
if (fillWidth > 0) {
renderer.fillRect(x + 2, y + 2, fillWidth, height - 4);
}
// Draw percentage text centered below bar
const std::string percentText = std::to_string(percent) + "%";
renderer.drawCenteredText(UI_10_FONT_ID, y + height + 15, percentText.c_str());
}

View File

@ -1,42 +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;
static void drawBattery(const GfxRenderer& renderer, int left, int top, bool showPercentage = true);
static void drawBookProgressBar(const GfxRenderer& renderer, size_t bookProgress);
// Draw a horizontal tab bar with underline indicator for selected tab
// Returns the height of the tab bar (for positioning content below)
static int drawTabBar(const GfxRenderer& renderer, int y, const std::vector<TabInfo>& tabs);
// Draw a scroll/page indicator on the right side of the screen
// Shows up/down arrows and current page fraction (e.g., "1/3")
static void drawScrollIndicator(const GfxRenderer& renderer, int currentPage, int totalPages, int contentTop,
int contentHeight);
/**
* Draw a progress bar with percentage text.
* @param renderer The graphics renderer
* @param x Left position of the bar
* @param y Top position of the bar
* @param width Width of the bar
* @param height Height of the bar
* @param current Current progress value
* @param total Total value for 100% progress
*/
static void drawProgressBar(const GfxRenderer& renderer, int x, int y, int width, int height, size_t current,
size_t total);
};

View File

@ -8,13 +8,14 @@
#include "CrossPointSettings.h" #include "CrossPointSettings.h"
#include "CrossPointState.h" #include "CrossPointState.h"
#include "components/UITheme.h"
#include "fontIds.h" #include "fontIds.h"
#include "images/CrossLarge.h" #include "images/CrossLarge.h"
#include "util/StringUtils.h" #include "util/StringUtils.h"
void SleepActivity::onEnter() { void SleepActivity::onEnter() {
Activity::onEnter(); Activity::onEnter();
renderPopup("Entering Sleep..."); UITheme::drawPopup(renderer, "Entering Sleep...");
if (SETTINGS.sleepScreen == CrossPointSettings::SLEEP_SCREEN_MODE::BLANK) { if (SETTINGS.sleepScreen == CrossPointSettings::SLEEP_SCREEN_MODE::BLANK) {
return renderBlankSleepScreen(); return renderBlankSleepScreen();
@ -31,20 +32,6 @@ void SleepActivity::onEnter() {
renderDefaultSleepScreen(); renderDefaultSleepScreen();
} }
void SleepActivity::renderPopup(const char* message) const {
const int textWidth = renderer.getTextWidth(UI_12_FONT_ID, message, EpdFontFamily::BOLD);
constexpr int margin = 20;
const int x = (renderer.getScreenWidth() - textWidth - margin * 2) / 2;
constexpr int y = 117;
const int w = textWidth + margin * 2;
const int h = renderer.getLineHeight(UI_12_FONT_ID) + margin * 2;
// renderer.clearScreen();
renderer.fillRect(x - 5, y - 5, w + 10, h + 10, true);
renderer.fillRect(x + 5, y + 5, w - 10, h - 10, false);
renderer.drawText(UI_12_FONT_ID, x + margin, y + margin, message, true, EpdFontFamily::BOLD);
renderer.displayBuffer();
}
void SleepActivity::renderCustomSleepScreen() const { void SleepActivity::renderCustomSleepScreen() const {
// Check if we have a /sleep directory // Check if we have a /sleep directory
auto dir = SdMan.open("/sleep"); auto dir = SdMan.open("/sleep");

View File

@ -10,7 +10,6 @@ class SleepActivity final : public Activity {
void onEnter() override; void onEnter() override;
private: private:
void renderPopup(const char* message) const;
void renderDefaultSleepScreen() const; void renderDefaultSleepScreen() const;
void renderCustomSleepScreen() const; void renderCustomSleepScreen() const;
void renderCoverSleepScreen() const; void renderCoverSleepScreen() const;

View File

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

View File

@ -13,7 +13,8 @@
#include "CrossPointSettings.h" #include "CrossPointSettings.h"
#include "CrossPointState.h" #include "CrossPointState.h"
#include "MappedInputManager.h" #include "MappedInputManager.h"
#include "ScreenComponents.h" #include "RecentBooksStore.h"
#include "components/UITheme.h"
#include "fontIds.h" #include "fontIds.h"
#include "util/StringUtils.h" #include "util/StringUtils.h"
@ -23,12 +24,85 @@ void HomeActivity::taskTrampoline(void* param) {
} }
int HomeActivity::getMenuItemCount() const { int HomeActivity::getMenuItemCount() const {
int count = 3; // My Library, File transfer, Settings int count = 4; // My Library, Recents, File transfer, Settings
if (hasContinueReading) count++; if (!recentBooks.empty()) {
if (hasOpdsUrl) count++; count += recentBooks.size();
}
if (hasOpdsUrl) {
count++;
}
return count; return count;
} }
void HomeActivity::loadRecentBooks(int maxBooks, int coverHeight, Rect& popupRect) {
recentsLoading = true;
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) {
const std::string& path = book.path;
// Limit to maximum number of recent books
if (recentBooks.size() >= maxBooks) {
break;
}
// Skip if file no longer exists
if (!SdMan.exists(path.c_str())) {
continue;
}
std::string coverBmpPath = "";
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);
// Try to generate thumbnail image for Continue Reading card
coverBmpPath = epub.getThumbBmpPath(coverHeight);
if (!SdMan.exists(coverBmpPath.c_str())) {
UITheme::fillPopupProgress(renderer, popupRect, progress * 30);
if (!epub.generateThumbBmp(coverHeight)) {
coverBmpPath = "";
}
}
} else if (StringUtils::checkFileExtension(lastBookFileName, ".xtch") ||
StringUtils::checkFileExtension(lastBookFileName, ".xtc")) {
// Handle XTC file
Xtc xtc(path, "/.crosspoint");
if (xtc.load()) {
// Try to generate thumbnail image for Continue Reading card
coverBmpPath = xtc.getThumbBmpPath(coverHeight);
if (!SdMan.exists(coverBmpPath.c_str())) {
UITheme::fillPopupProgress(renderer, popupRect, progress * 30);
if (!xtc.generateThumbBmp(coverHeight)) {
coverBmpPath = "";
}
}
}
}
recentBooks.push_back(RecentBookWithCover{book, coverBmpPath});
progress++;
}
Serial.printf("Recent books loaded: %d\n", recentBooks.size());
recentsLoaded = true;
recentsLoading = false;
updateRequired = true;
}
void HomeActivity::onEnter() { void HomeActivity::onEnter() {
Activity::onEnter(); Activity::onEnter();
@ -40,62 +114,13 @@ void HomeActivity::onEnter() {
// Check if OPDS browser URL is configured // Check if OPDS browser URL is configured
hasOpdsUrl = strlen(SETTINGS.opdsServerUrl) > 0; hasOpdsUrl = strlen(SETTINGS.opdsServerUrl) > 0;
if (hasContinueReading) {
// Extract filename from path for display
lastBookTitle = APP_STATE.openEpubPath;
const size_t lastSlash = lastBookTitle.find_last_of('/');
if (lastSlash != std::string::npos) {
lastBookTitle = lastBookTitle.substr(lastSlash + 1);
}
// If epub, try to load the metadata for title/author and cover
if (StringUtils::checkFileExtension(lastBookTitle, ".epub")) {
Epub epub(APP_STATE.openEpubPath, "/.crosspoint");
epub.load(false);
if (!epub.getTitle().empty()) {
lastBookTitle = std::string(epub.getTitle());
}
if (!epub.getAuthor().empty()) {
lastBookAuthor = std::string(epub.getAuthor());
}
// Try to generate thumbnail image for Continue Reading card
if (epub.generateThumbBmp()) {
coverBmpPath = epub.getThumbBmpPath();
hasCoverImage = true;
}
} else if (StringUtils::checkFileExtension(lastBookTitle, ".xtch") ||
StringUtils::checkFileExtension(lastBookTitle, ".xtc")) {
// Handle XTC file
Xtc xtc(APP_STATE.openEpubPath, "/.crosspoint");
if (xtc.load()) {
if (!xtc.getTitle().empty()) {
lastBookTitle = std::string(xtc.getTitle());
}
if (!xtc.getAuthor().empty()) {
lastBookAuthor = std::string(xtc.getAuthor());
}
// Try to generate thumbnail image for Continue Reading card
if (xtc.generateThumbBmp()) {
coverBmpPath = xtc.getThumbBmpPath();
hasCoverImage = true;
}
}
// Remove extension from title if we don't have metadata
if (StringUtils::checkFileExtension(lastBookTitle, ".xtch")) {
lastBookTitle.resize(lastBookTitle.length() - 5);
} else if (StringUtils::checkFileExtension(lastBookTitle, ".xtc")) {
lastBookTitle.resize(lastBookTitle.length() - 4);
}
}
}
selectorIndex = 0; selectorIndex = 0;
// Trigger first update // Trigger first update
updateRequired = true; updateRequired = true;
xTaskCreate(&HomeActivity::taskTrampoline, "HomeActivityTask", xTaskCreate(&HomeActivity::taskTrampoline, "HomeActivityTask",
4096, // Stack size (increased for cover image rendering) 8192, // Stack size
this, // Parameters this, // Parameters
1, // Priority 1, // Priority
&displayTaskHandle // Task handle &displayTaskHandle // Task handle
@ -171,21 +196,24 @@ void HomeActivity::loop() {
if (mappedInput.wasReleased(MappedInputManager::Button::Confirm)) { if (mappedInput.wasReleased(MappedInputManager::Button::Confirm)) {
// Calculate dynamic indices based on which options are available // Calculate dynamic indices based on which options are available
int idx = 0; int idx = 0;
const int continueIdx = hasContinueReading ? idx++ : -1; int menuSelectedIndex = selectorIndex - static_cast<int>(recentBooks.size());
const int myLibraryIdx = idx++; const int myLibraryIdx = idx++;
const int recentsIdx = idx++;
const int opdsLibraryIdx = hasOpdsUrl ? idx++ : -1; const int opdsLibraryIdx = hasOpdsUrl ? idx++ : -1;
const int fileTransferIdx = idx++; const int fileTransferIdx = idx++;
const int settingsIdx = idx; const int settingsIdx = idx;
if (selectorIndex == continueIdx) { if (selectorIndex < recentBooks.size()) {
onContinueReading(); onSelectBook(recentBooks[selectorIndex].book.path);
} else if (selectorIndex == myLibraryIdx) { } else if (menuSelectedIndex == myLibraryIdx) {
onMyLibraryOpen(); onMyLibraryOpen();
} else if (selectorIndex == opdsLibraryIdx) { } else if (menuSelectedIndex == recentsIdx) {
onRecentsOpen();
} else if (menuSelectedIndex == opdsLibraryIdx) {
onOpdsBrowserOpen(); onOpdsBrowserOpen();
} else if (selectorIndex == fileTransferIdx) { } else if (menuSelectedIndex == fileTransferIdx) {
onFileTransferOpen(); onFileTransferOpen();
} else if (selectorIndex == settingsIdx) { } else if (menuSelectedIndex == settingsIdx) {
onSettingsOpen(); onSettingsOpen();
} }
} else if (prevPressed) { } else if (prevPressed) {
@ -210,350 +238,52 @@ void HomeActivity::displayTaskLoop() {
} }
void HomeActivity::render() { void HomeActivity::render() {
// If we have a stored cover buffer, restore it instead of clearing auto metrics = UITheme::getMetrics();
const bool bufferRestored = coverBufferStored && restoreCoverBuffer();
if (!bufferRestored) {
renderer.clearScreen();
}
const auto pageWidth = renderer.getScreenWidth(); const auto pageWidth = renderer.getScreenWidth();
const auto pageHeight = renderer.getScreenHeight(); const auto pageHeight = renderer.getScreenHeight();
constexpr int margin = 20; bool bufferRestored = coverBufferStored && restoreCoverBuffer();
constexpr int bottomMargin = 60; if (!firstRenderDone || (recentsLoaded && !recentsDisplayed)) {
renderer.clearScreen();
// --- Top "book" card for the current title (selectorIndex == 0) ---
const int bookWidth = pageWidth / 2;
const int bookHeight = pageHeight / 2;
const int bookX = (pageWidth - bookWidth) / 2;
constexpr int bookY = 30;
const bool bookSelected = hasContinueReading && selectorIndex == 0;
// Bookmark dimensions (used in multiple places)
const int bookmarkWidth = bookWidth / 8;
const int bookmarkHeight = bookHeight / 5;
const int bookmarkX = bookX + bookWidth - bookmarkWidth - 10;
const int bookmarkY = bookY + 5;
// Draw book card regardless, fill with message based on `hasContinueReading`
{
// Draw cover image as background if available (inside the box)
// Only load from SD on first render, then use stored buffer
if (hasContinueReading && hasCoverImage && !coverBmpPath.empty() && !coverRendered) {
// First time: load cover from SD and render
FsFile file;
if (SdMan.openFileForRead("HOME", coverBmpPath, file)) {
Bitmap bitmap(file);
if (bitmap.parseHeaders() == BmpReaderError::Ok) {
// Calculate position to center image within the book card
int coverX, coverY;
if (bitmap.getWidth() > bookWidth || bitmap.getHeight() > bookHeight) {
const float imgRatio = static_cast<float>(bitmap.getWidth()) / static_cast<float>(bitmap.getHeight());
const float boxRatio = static_cast<float>(bookWidth) / static_cast<float>(bookHeight);
if (imgRatio > boxRatio) {
coverX = bookX;
coverY = bookY + (bookHeight - static_cast<int>(bookWidth / imgRatio)) / 2;
} else {
coverX = bookX + (bookWidth - static_cast<int>(bookHeight * imgRatio)) / 2;
coverY = bookY;
}
} else {
coverX = bookX + (bookWidth - bitmap.getWidth()) / 2;
coverY = bookY + (bookHeight - bitmap.getHeight()) / 2;
}
// Draw the cover image centered within the book card
renderer.drawBitmap(bitmap, coverX, coverY, bookWidth, bookHeight);
// Draw border around the card
renderer.drawRect(bookX, bookY, bookWidth, bookHeight);
// No bookmark ribbon when cover is shown - it would just cover the art
// Store the buffer with cover image for fast navigation
coverBufferStored = storeCoverBuffer();
coverRendered = true;
// First render: if selected, draw selection indicators now
if (bookSelected) {
renderer.drawRect(bookX + 1, bookY + 1, bookWidth - 2, bookHeight - 2);
renderer.drawRect(bookX + 2, bookY + 2, bookWidth - 4, bookHeight - 4);
}
}
file.close();
}
} else if (!bufferRestored && !coverRendered) {
// No cover image: draw border or fill, plus bookmark as visual flair
if (bookSelected) {
renderer.fillRect(bookX, bookY, bookWidth, bookHeight);
} else {
renderer.drawRect(bookX, bookY, bookWidth, bookHeight);
}
// Draw bookmark ribbon when no cover image (visual decoration)
if (hasContinueReading) {
const int notchDepth = bookmarkHeight / 3;
const int centerX = bookmarkX + bookmarkWidth / 2;
const int xPoints[5] = {
bookmarkX, // top-left
bookmarkX + bookmarkWidth, // top-right
bookmarkX + bookmarkWidth, // bottom-right
centerX, // center notch point
bookmarkX // bottom-left
};
const int yPoints[5] = {
bookmarkY, // top-left
bookmarkY, // top-right
bookmarkY + bookmarkHeight, // bottom-right
bookmarkY + bookmarkHeight - notchDepth, // center notch point
bookmarkY + bookmarkHeight // bottom-left
};
// Draw bookmark ribbon (inverted if selected)
renderer.fillPolygon(xPoints, yPoints, 5, !bookSelected);
}
}
// If buffer was restored, draw selection indicators if needed
if (bufferRestored && bookSelected && coverRendered) {
// Draw selection border (no bookmark inversion needed since cover has no bookmark)
renderer.drawRect(bookX + 1, bookY + 1, bookWidth - 2, bookHeight - 2);
renderer.drawRect(bookX + 2, bookY + 2, bookWidth - 4, bookHeight - 4);
} else if (!coverRendered && !bufferRestored) {
// Selection border already handled above in the no-cover case
}
} }
UITheme::drawHeader(renderer, Rect{0, metrics.topPadding, pageWidth, metrics.homeTopPadding}, nullptr);
if (hasContinueReading) { if (hasContinueReading) {
// Invert text colors based on selection state: if (recentsLoaded) {
// - With cover: selected = white text on black box, unselected = black text on white box recentsDisplayed = true;
// - Without cover: selected = white text on black card, unselected = black text on white card UITheme::drawRecentBookCover(renderer, Rect{0, metrics.homeTopPadding, pageWidth, metrics.homeCoverTileHeight},
recentBooks, selectorIndex, coverRendered, coverBufferStored, bufferRestored,
// Split into words (avoid stringstream to keep this light on the MCU) std::bind(&HomeActivity::storeCoverBuffer, this));
std::vector<std::string> words; } else if (!recentsLoading && firstRenderDone) {
words.reserve(8); recentsLoading = true;
size_t pos = 0; Rect popupRect = UITheme::drawPopup(renderer, "Loading...");
while (pos < lastBookTitle.size()) { loadRecentBooks(metrics.homeRecentBooksCount, metrics.homeCoverHeight, popupRect);
while (pos < lastBookTitle.size() && lastBookTitle[pos] == ' ') {
++pos;
}
if (pos >= lastBookTitle.size()) {
break;
}
const size_t start = pos;
while (pos < lastBookTitle.size() && lastBookTitle[pos] != ' ') {
++pos;
}
words.emplace_back(lastBookTitle.substr(start, pos - start));
} }
std::vector<std::string> lines;
std::string currentLine;
// Extra padding inside the card so text doesn't hug the border
const int maxLineWidth = bookWidth - 40;
const int spaceWidth = renderer.getSpaceWidth(UI_12_FONT_ID);
for (auto& i : words) {
// If we just hit the line limit (3), stop processing words
if (lines.size() >= 3) {
// Limit to 3 lines
// Still have words left, so add ellipsis to last line
lines.back().append("...");
while (!lines.back().empty() && renderer.getTextWidth(UI_12_FONT_ID, lines.back().c_str()) > maxLineWidth) {
// Remove "..." first, then remove one UTF-8 char, then add "..." back
lines.back().resize(lines.back().size() - 3); // Remove "..."
StringUtils::utf8RemoveLastChar(lines.back());
lines.back().append("...");
}
break;
}
int wordWidth = renderer.getTextWidth(UI_12_FONT_ID, i.c_str());
while (wordWidth > maxLineWidth && !i.empty()) {
// Word itself is too long, trim it (UTF-8 safe)
StringUtils::utf8RemoveLastChar(i);
// Check if we have room for ellipsis
std::string withEllipsis = i + "...";
wordWidth = renderer.getTextWidth(UI_12_FONT_ID, withEllipsis.c_str());
if (wordWidth <= maxLineWidth) {
i = withEllipsis;
break;
}
}
int newLineWidth = renderer.getTextWidth(UI_12_FONT_ID, currentLine.c_str());
if (newLineWidth > 0) {
newLineWidth += spaceWidth;
}
newLineWidth += wordWidth;
if (newLineWidth > maxLineWidth && !currentLine.empty()) {
// New line too long, push old line
lines.push_back(currentLine);
currentLine = i;
} else {
currentLine.append(" ").append(i);
}
}
// If lower than the line limit, push remaining words
if (!currentLine.empty() && lines.size() < 3) {
lines.push_back(currentLine);
}
// Book title text
int totalTextHeight = renderer.getLineHeight(UI_12_FONT_ID) * static_cast<int>(lines.size());
if (!lastBookAuthor.empty()) {
totalTextHeight += renderer.getLineHeight(UI_10_FONT_ID) * 3 / 2;
}
// Vertically center the title block within the card
int titleYStart = bookY + (bookHeight - totalTextHeight) / 2;
// If cover image was rendered, draw box behind title and author
if (coverRendered) {
constexpr int boxPadding = 8;
// Calculate the max text width for the box
int maxTextWidth = 0;
for (const auto& line : lines) {
const int lineWidth = renderer.getTextWidth(UI_12_FONT_ID, line.c_str());
if (lineWidth > maxTextWidth) {
maxTextWidth = lineWidth;
}
}
if (!lastBookAuthor.empty()) {
std::string trimmedAuthor = lastBookAuthor;
while (renderer.getTextWidth(UI_10_FONT_ID, trimmedAuthor.c_str()) > maxLineWidth && !trimmedAuthor.empty()) {
StringUtils::utf8RemoveLastChar(trimmedAuthor);
}
if (renderer.getTextWidth(UI_10_FONT_ID, trimmedAuthor.c_str()) <
renderer.getTextWidth(UI_10_FONT_ID, lastBookAuthor.c_str())) {
trimmedAuthor.append("...");
}
const int authorWidth = renderer.getTextWidth(UI_10_FONT_ID, trimmedAuthor.c_str());
if (authorWidth > maxTextWidth) {
maxTextWidth = authorWidth;
}
}
const int boxWidth = maxTextWidth + boxPadding * 2;
const int boxHeight = totalTextHeight + boxPadding * 2;
const int boxX = (pageWidth - boxWidth) / 2;
const int boxY = titleYStart - boxPadding;
// Draw box (inverted when selected: black box instead of white)
renderer.fillRect(boxX, boxY, boxWidth, boxHeight, bookSelected);
// Draw border around the box (inverted when selected: white border instead of black)
renderer.drawRect(boxX, boxY, boxWidth, boxHeight, !bookSelected);
}
for (const auto& line : lines) {
renderer.drawCenteredText(UI_12_FONT_ID, titleYStart, line.c_str(), !bookSelected);
titleYStart += renderer.getLineHeight(UI_12_FONT_ID);
}
if (!lastBookAuthor.empty()) {
titleYStart += renderer.getLineHeight(UI_10_FONT_ID) / 2;
std::string trimmedAuthor = lastBookAuthor;
// Trim author if too long (UTF-8 safe)
bool wasTrimmed = false;
while (renderer.getTextWidth(UI_10_FONT_ID, trimmedAuthor.c_str()) > maxLineWidth && !trimmedAuthor.empty()) {
StringUtils::utf8RemoveLastChar(trimmedAuthor);
wasTrimmed = true;
}
if (wasTrimmed && !trimmedAuthor.empty()) {
// Make room for ellipsis
while (renderer.getTextWidth(UI_10_FONT_ID, (trimmedAuthor + "...").c_str()) > maxLineWidth &&
!trimmedAuthor.empty()) {
StringUtils::utf8RemoveLastChar(trimmedAuthor);
}
trimmedAuthor.append("...");
}
renderer.drawCenteredText(UI_10_FONT_ID, titleYStart, trimmedAuthor.c_str(), !bookSelected);
}
// "Continue Reading" label at the bottom
const int continueY = bookY + bookHeight - renderer.getLineHeight(UI_10_FONT_ID) * 3 / 2;
if (coverRendered) {
// Draw box behind "Continue Reading" text (inverted when selected: black box instead of white)
const char* continueText = "Continue Reading";
const int continueTextWidth = renderer.getTextWidth(UI_10_FONT_ID, continueText);
constexpr int continuePadding = 6;
const int continueBoxWidth = continueTextWidth + continuePadding * 2;
const int continueBoxHeight = renderer.getLineHeight(UI_10_FONT_ID) + continuePadding;
const int continueBoxX = (pageWidth - continueBoxWidth) / 2;
const int continueBoxY = continueY - continuePadding / 2;
renderer.fillRect(continueBoxX, continueBoxY, continueBoxWidth, continueBoxHeight, bookSelected);
renderer.drawRect(continueBoxX, continueBoxY, continueBoxWidth, continueBoxHeight, !bookSelected);
renderer.drawCenteredText(UI_10_FONT_ID, continueY, continueText, !bookSelected);
} else {
renderer.drawCenteredText(UI_10_FONT_ID, continueY, "Continue Reading", !bookSelected);
}
} else {
// No book to continue reading
const int y =
bookY + (bookHeight - renderer.getLineHeight(UI_12_FONT_ID) - renderer.getLineHeight(UI_10_FONT_ID)) / 2;
renderer.drawCenteredText(UI_12_FONT_ID, y, "No open book");
renderer.drawCenteredText(UI_10_FONT_ID, y + renderer.getLineHeight(UI_12_FONT_ID), "Start reading below");
} }
// --- Bottom menu tiles ---
// Build menu items dynamically // Build menu items dynamically
std::vector<const char*> menuItems = {"My Library", "File Transfer", "Settings"}; std::vector<const char*> menuItems = {"Browse Files", "Recents", "File Transfer", "Settings"};
if (hasOpdsUrl) { if (hasOpdsUrl) {
// Insert OPDS Browser after My Library // Insert OPDS Browser after My Library
menuItems.insert(menuItems.begin() + 1, "OPDS Browser"); menuItems.insert(menuItems.begin() + 2, "OPDS Browser");
} }
const int menuTileWidth = pageWidth - 2 * margin; UITheme::drawButtonMenu(
constexpr int menuTileHeight = 45; renderer,
constexpr int menuSpacing = 8; Rect{0, metrics.homeTopPadding + metrics.homeCoverTileHeight + metrics.verticalSpacing, pageWidth,
const int totalMenuHeight = pageHeight - (metrics.headerHeight + metrics.homeTopPadding + metrics.verticalSpacing * 2 +
static_cast<int>(menuItems.size()) * menuTileHeight + (static_cast<int>(menuItems.size()) - 1) * menuSpacing; metrics.buttonHintsHeight)},
static_cast<int>(menuItems.size()), selectorIndex - recentBooks.size(),
int menuStartY = bookY + bookHeight + 15; [&menuItems](int index) { return std::string(menuItems[index]); }, false, nullptr);
// Ensure we don't collide with the bottom button legend
const int maxMenuStartY = pageHeight - bottomMargin - totalMenuHeight - margin;
if (menuStartY > maxMenuStartY) {
menuStartY = maxMenuStartY;
}
for (size_t i = 0; i < menuItems.size(); ++i) {
const int overallIndex = static_cast<int>(i) + (hasContinueReading ? 1 : 0);
constexpr int tileX = margin;
const int tileY = menuStartY + static_cast<int>(i) * (menuTileHeight + menuSpacing);
const bool selected = selectorIndex == overallIndex;
if (selected) {
renderer.fillRect(tileX, tileY, menuTileWidth, menuTileHeight);
} else {
renderer.drawRect(tileX, tileY, menuTileWidth, menuTileHeight);
}
const char* label = menuItems[i];
const int textWidth = renderer.getTextWidth(UI_10_FONT_ID, label);
const int textX = tileX + (menuTileWidth - textWidth) / 2;
const int lineHeight = renderer.getLineHeight(UI_10_FONT_ID);
const int textY = tileY + (menuTileHeight - lineHeight) / 2; // vertically centered assuming y is top of text
// Invert text when the tile is selected, to contrast with the filled background
renderer.drawText(UI_10_FONT_ID, textX, textY, label, !selected);
}
const auto labels = mappedInput.mapLabels("", "Select", "Up", "Down"); const auto labels = mappedInput.mapLabels("", "Select", "Up", "Down");
renderer.drawButtonHints(UI_10_FONT_ID, labels.btn1, labels.btn2, labels.btn3, labels.btn4); UITheme::drawButtonHints(renderer, labels.btn1, labels.btn2, labels.btn3, labels.btn4);
const bool showBatteryPercentage =
SETTINGS.hideBatteryPercentage != CrossPointSettings::HIDE_BATTERY_PERCENTAGE::HIDE_ALWAYS;
// get percentage so we can align text properly
const uint16_t percentage = battery.readPercentage();
const auto percentageText = showBatteryPercentage ? std::to_string(percentage) + "%" : "";
const auto batteryX = pageWidth - 25 - renderer.getTextWidth(SMALL_FONT_ID, percentageText.c_str());
ScreenComponents::drawBattery(renderer, batteryX, 10, showBatteryPercentage);
renderer.displayBuffer(); renderer.displayBuffer();
if (!firstRenderDone) {
firstRenderDone = true;
updateRequired = true;
}
} }

View File

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

View File

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

View File

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

View File

@ -0,0 +1,148 @@
#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::getNumberOfItemsPerPage(renderer, true, 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::getMetrics();
UITheme::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 {
UITheme::drawList(
renderer, Rect{0, contentTop, pageWidth, contentHeight}, recentBooks.size(), selectorIndex,
[this](int index) { return recentBooks[index].title; }, false, nullptr, false, nullptr);
}
// Help text
const auto labels = mappedInput.mapLabels("« Home", "Open", "Up", "Down");
UITheme::drawButtonHints(renderer, labels.btn1, labels.btn2, labels.btn3, labels.btn4);
renderer.displayBuffer();
}

View File

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

View File

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

View File

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

View File

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

View File

@ -8,6 +8,7 @@
#include "MappedInputManager.h" #include "MappedInputManager.h"
#include "WifiCredentialStore.h" #include "WifiCredentialStore.h"
#include "activities/util/KeyboardEntryActivity.h" #include "activities/util/KeyboardEntryActivity.h"
#include "components/UITheme.h"
#include "fontIds.h" #include "fontIds.h"
void WifiSelectionActivity::taskTrampoline(void* param) { void WifiSelectionActivity::taskTrampoline(void* param) {
@ -266,9 +267,9 @@ void WifiSelectionActivity::checkConnectionStatus() {
} }
if (status == WL_CONNECT_FAILED || status == WL_NO_SSID_AVAIL) { if (status == WL_CONNECT_FAILED || status == WL_NO_SSID_AVAIL) {
connectionError = "Connection failed"; connectionError = "Error: General failure";
if (status == WL_NO_SSID_AVAIL) { if (status == WL_NO_SSID_AVAIL) {
connectionError = "Network not found"; connectionError = "Error: Network not found";
} }
state = WifiSelectionState::CONNECTION_FAILED; state = WifiSelectionState::CONNECTION_FAILED;
updateRequired = true; updateRequired = true;
@ -278,7 +279,7 @@ void WifiSelectionActivity::checkConnectionStatus() {
// Check for timeout // Check for timeout
if (millis() - connectionStartTime > CONNECTION_TIMEOUT_MS) { if (millis() - connectionStartTime > CONNECTION_TIMEOUT_MS) {
WiFi.disconnect(); WiFi.disconnect();
connectionError = "Connection timeout"; connectionError = "Error: Connection timeout";
state = WifiSelectionState::CONNECTION_FAILED; state = WifiSelectionState::CONNECTION_FAILED;
updateRequired = true; updateRequired = true;
return; return;
@ -586,7 +587,7 @@ void WifiSelectionActivity::renderNetworkList() const {
// Draw help text // Draw help text
renderer.drawText(SMALL_FONT_ID, 20, pageHeight - 75, "* = Encrypted | + = Saved"); renderer.drawText(SMALL_FONT_ID, 20, pageHeight - 75, "* = Encrypted | + = Saved");
const auto labels = mappedInput.mapLabels("« Back", "Connect", "", ""); const auto labels = mappedInput.mapLabels("« Back", "Connect", "", "");
renderer.drawButtonHints(UI_10_FONT_ID, labels.btn1, labels.btn2, labels.btn3, labels.btn4); UITheme::drawButtonHints(renderer, labels.btn1, labels.btn2, labels.btn3, labels.btn4);
} }
void WifiSelectionActivity::renderConnecting() const { void WifiSelectionActivity::renderConnecting() const {
@ -625,7 +626,7 @@ void WifiSelectionActivity::renderConnected() const {
// Use centralized button hints // Use centralized button hints
const auto labels = mappedInput.mapLabels("", "Continue", "", ""); const auto labels = mappedInput.mapLabels("", "Continue", "", "");
renderer.drawButtonHints(UI_10_FONT_ID, labels.btn1, labels.btn2, labels.btn3, labels.btn4); UITheme::drawButtonHints(renderer, labels.btn1, labels.btn2, labels.btn3, labels.btn4);
} }
void WifiSelectionActivity::renderSavePrompt() const { void WifiSelectionActivity::renderSavePrompt() const {
@ -667,7 +668,7 @@ void WifiSelectionActivity::renderSavePrompt() const {
// Use centralized button hints // Use centralized button hints
const auto labels = mappedInput.mapLabels("« Skip", "Select", "Left", "Right"); const auto labels = mappedInput.mapLabels("« Skip", "Select", "Left", "Right");
renderer.drawButtonHints(UI_10_FONT_ID, labels.btn1, labels.btn2, labels.btn3, labels.btn4); UITheme::drawButtonHints(renderer, labels.btn1, labels.btn2, labels.btn3, labels.btn4);
} }
void WifiSelectionActivity::renderConnectionFailed() const { void WifiSelectionActivity::renderConnectionFailed() const {
@ -680,7 +681,7 @@ void WifiSelectionActivity::renderConnectionFailed() const {
// Use centralized button hints // Use centralized button hints
const auto labels = mappedInput.mapLabels("« Back", "Continue", "", ""); const auto labels = mappedInput.mapLabels("« Back", "Continue", "", "");
renderer.drawButtonHints(UI_10_FONT_ID, labels.btn1, labels.btn2, labels.btn3, labels.btn4); UITheme::drawButtonHints(renderer, labels.btn1, labels.btn2, labels.btn3, labels.btn4);
} }
void WifiSelectionActivity::renderForgetPrompt() const { void WifiSelectionActivity::renderForgetPrompt() const {
@ -689,7 +690,7 @@ void WifiSelectionActivity::renderForgetPrompt() const {
const auto height = renderer.getLineHeight(UI_10_FONT_ID); const auto height = renderer.getLineHeight(UI_10_FONT_ID);
const auto top = (pageHeight - height * 3) / 2; const auto top = (pageHeight - height * 3) / 2;
renderer.drawCenteredText(UI_12_FONT_ID, top - 40, "Forget Network?", true, EpdFontFamily::BOLD); renderer.drawCenteredText(UI_12_FONT_ID, top - 40, "Connection Failed", true, EpdFontFamily::BOLD);
std::string ssidInfo = "Network: " + selectedSSID; std::string ssidInfo = "Network: " + selectedSSID;
if (ssidInfo.length() > 28) { if (ssidInfo.length() > 28) {
@ -697,7 +698,7 @@ void WifiSelectionActivity::renderForgetPrompt() const {
} }
renderer.drawCenteredText(UI_10_FONT_ID, top, ssidInfo.c_str()); renderer.drawCenteredText(UI_10_FONT_ID, top, ssidInfo.c_str());
renderer.drawCenteredText(UI_10_FONT_ID, top + 40, "Remove saved password?"); renderer.drawCenteredText(UI_10_FONT_ID, top + 40, "Forget network and remove saved password?");
// Draw Cancel/Forget network buttons // Draw Cancel/Forget network buttons
const int buttonY = top + 80; const int buttonY = top + 80;
@ -722,5 +723,5 @@ void WifiSelectionActivity::renderForgetPrompt() const {
// Use centralized button hints // Use centralized button hints
const auto labels = mappedInput.mapLabels("« Back", "Select", "Left", "Right"); const auto labels = mappedInput.mapLabels("« Back", "Select", "Left", "Right");
renderer.drawButtonHints(UI_10_FONT_ID, labels.btn1, labels.btn2, labels.btn3, labels.btn4); UITheme::drawButtonHints(renderer, labels.btn1, labels.btn2, labels.btn3, labels.btn4);
} }

View File

@ -10,7 +10,7 @@
#include "EpubReaderChapterSelectionActivity.h" #include "EpubReaderChapterSelectionActivity.h"
#include "MappedInputManager.h" #include "MappedInputManager.h"
#include "RecentBooksStore.h" #include "RecentBooksStore.h"
#include "ScreenComponents.h" #include "components/UITheme.h"
#include "fontIds.h" #include "fontIds.h"
namespace { namespace {
@ -130,31 +130,9 @@ void EpubReaderActivity::loop() {
const int currentPage = section ? section->currentPage : 0; const int currentPage = section ? section->currentPage : 0;
const int totalPages = section ? section->pageCount : 0; const int totalPages = section ? section->pageCount : 0;
exitActivity(); exitActivity();
enterNewActivity(new EpubReaderChapterSelectionActivity( enterNewActivity(new EpubReaderMenuActivity(
this->renderer, this->mappedInput, epub, epub->getPath(), currentSpineIndex, currentPage, totalPages, this->renderer, this->mappedInput, epub->getTitle(), [this]() { onReaderMenuBack(); },
[this] { [this](EpubReaderMenuActivity::MenuAction action) { onReaderMenuConfirm(action); }));
exitActivity();
updateRequired = true;
},
[this](const int newSpineIndex) {
if (currentSpineIndex != newSpineIndex) {
currentSpineIndex = newSpineIndex;
nextPageNumber = 0;
section.reset();
}
exitActivity();
updateRequired = true;
},
[this](const int newSpineIndex, const int newPage) {
// Handle sync position
if (currentSpineIndex != newSpineIndex || (section && section->currentPage != newPage)) {
currentSpineIndex = newSpineIndex;
nextPageNumber = newPage;
section.reset();
}
exitActivity();
updateRequired = true;
}));
xSemaphoreGive(renderingMutex); xSemaphoreGive(renderingMutex);
} }
@ -242,6 +220,89 @@ void EpubReaderActivity::loop() {
} }
} }
void EpubReaderActivity::onReaderMenuBack() {
exitActivity();
updateRequired = true;
}
void EpubReaderActivity::onReaderMenuConfirm(EpubReaderMenuActivity::MenuAction action) {
switch (action) {
case EpubReaderMenuActivity::MenuAction::SELECT_CHAPTER: {
// Calculate values BEFORE we start destroying things
const int currentP = section ? section->currentPage : 0;
const int totalP = section ? section->pageCount : 0;
const int spineIdx = currentSpineIndex;
const std::string path = epub->getPath();
xSemaphoreTake(renderingMutex, portMAX_DELAY);
// 1. Close the menu
exitActivity();
// 2. Open the Chapter Selector
enterNewActivity(new EpubReaderChapterSelectionActivity(
this->renderer, this->mappedInput, epub, path, spineIdx, currentP, totalP,
[this] {
exitActivity();
updateRequired = true;
},
[this](const int newSpineIndex) {
if (currentSpineIndex != newSpineIndex) {
currentSpineIndex = newSpineIndex;
nextPageNumber = 0;
section.reset();
}
exitActivity();
updateRequired = true;
},
[this](const int newSpineIndex, const int newPage) {
if (currentSpineIndex != newSpineIndex || (section && section->currentPage != newPage)) {
currentSpineIndex = newSpineIndex;
nextPageNumber = newPage;
section.reset();
}
exitActivity();
updateRequired = true;
}));
xSemaphoreGive(renderingMutex);
break;
}
case EpubReaderMenuActivity::MenuAction::GO_HOME: {
// 2. Trigger the reader's "Go Home" callback
if (onGoHome) {
onGoHome();
}
break;
}
case EpubReaderMenuActivity::MenuAction::DELETE_CACHE: {
xSemaphoreTake(renderingMutex, portMAX_DELAY);
if (epub) {
// 2. BACKUP: Read current progress
// We use the current variables that track our position
uint16_t backupSpine = currentSpineIndex;
uint16_t backupPage = section->currentPage;
uint16_t backupPageCount = section->pageCount;
section.reset();
// 3. WIPE: Clear the cache directory
epub->clearCache();
// 4. RESTORE: Re-setup the directory and rewrite the progress file
epub->setupCacheDir();
saveProgress(backupSpine, backupPage, backupPageCount);
}
exitActivity();
updateRequired = true;
xSemaphoreGive(renderingMutex);
if (onGoHome) onGoHome();
break;
}
}
}
void EpubReaderActivity::displayTaskLoop() { void EpubReaderActivity::displayTaskLoop() {
while (true) { while (true) {
if (updateRequired) { if (updateRequired) {
@ -286,13 +347,15 @@ void EpubReaderActivity::renderScreen() {
orientedMarginRight += SETTINGS.screenMargin; orientedMarginRight += SETTINGS.screenMargin;
orientedMarginBottom += SETTINGS.screenMargin; orientedMarginBottom += SETTINGS.screenMargin;
auto metrics = UITheme::getMetrics();
// Add status bar margin // Add status bar margin
if (SETTINGS.statusBar != CrossPointSettings::STATUS_BAR_MODE::NONE) { if (SETTINGS.statusBar != CrossPointSettings::STATUS_BAR_MODE::NONE) {
// Add additional margin for status bar if progress bar is shown // Add additional margin for status bar if progress bar is shown
const bool showProgressBar = SETTINGS.statusBar == CrossPointSettings::STATUS_BAR_MODE::FULL_WITH_PROGRESS_BAR || const bool showProgressBar = SETTINGS.statusBar == CrossPointSettings::STATUS_BAR_MODE::FULL_WITH_PROGRESS_BAR ||
SETTINGS.statusBar == CrossPointSettings::STATUS_BAR_MODE::ONLY_PROGRESS_BAR; SETTINGS.statusBar == CrossPointSettings::STATUS_BAR_MODE::ONLY_PROGRESS_BAR;
orientedMarginBottom += statusBarMargin - SETTINGS.screenMargin + orientedMarginBottom += statusBarMargin - SETTINGS.screenMargin +
(showProgressBar ? (ScreenComponents::BOOK_PROGRESS_BAR_HEIGHT + progressBarMarginTop) : 0); (showProgressBar ? (metrics.bookProgressBarHeight + progressBarMarginTop) : 0);
} }
if (!section) { if (!section) {
@ -308,49 +371,11 @@ void EpubReaderActivity::renderScreen() {
viewportHeight, SETTINGS.hyphenationEnabled)) { viewportHeight, SETTINGS.hyphenationEnabled)) {
Serial.printf("[%lu] [ERS] Cache not found, building...\n", millis()); Serial.printf("[%lu] [ERS] Cache not found, building...\n", millis());
// Progress bar dimensions const auto popupFn = [this]() { UITheme::drawPopup(renderer, "Indexing..."); };
constexpr int barWidth = 200;
constexpr int barHeight = 10;
constexpr int boxMargin = 20;
const int textWidth = renderer.getTextWidth(UI_12_FONT_ID, "Indexing...");
const int boxWidthWithBar = (barWidth > textWidth ? barWidth : textWidth) + boxMargin * 2;
const int boxWidthNoBar = textWidth + boxMargin * 2;
const int boxHeightWithBar = renderer.getLineHeight(UI_12_FONT_ID) + barHeight + boxMargin * 3;
const int boxHeightNoBar = renderer.getLineHeight(UI_12_FONT_ID) + boxMargin * 2;
const int boxXWithBar = (renderer.getScreenWidth() - boxWidthWithBar) / 2;
const int boxXNoBar = (renderer.getScreenWidth() - boxWidthNoBar) / 2;
constexpr int boxY = 50;
const int barX = boxXWithBar + (boxWidthWithBar - barWidth) / 2;
const int barY = boxY + renderer.getLineHeight(UI_12_FONT_ID) + boxMargin * 2;
// Always show "Indexing..." text first
{
renderer.fillRect(boxXNoBar, boxY, boxWidthNoBar, boxHeightNoBar, false);
renderer.drawText(UI_12_FONT_ID, boxXNoBar + boxMargin, boxY + boxMargin, "Indexing...");
renderer.drawRect(boxXNoBar + 5, boxY + 5, boxWidthNoBar - 10, boxHeightNoBar - 10);
renderer.displayBuffer();
pagesUntilFullRefresh = 0;
}
// Setup callback - only called for chapters >= 50KB, redraws with progress bar
auto progressSetup = [this, boxXWithBar, boxWidthWithBar, boxHeightWithBar, barX, barY] {
renderer.fillRect(boxXWithBar, boxY, boxWidthWithBar, boxHeightWithBar, false);
renderer.drawText(UI_12_FONT_ID, boxXWithBar + boxMargin, boxY + boxMargin, "Indexing...");
renderer.drawRect(boxXWithBar + 5, boxY + 5, boxWidthWithBar - 10, boxHeightWithBar - 10);
renderer.drawRect(barX, barY, barWidth, barHeight);
renderer.displayBuffer();
};
// Progress callback to update progress bar
auto progressCallback = [this, barX, barY, barWidth, barHeight](int progress) {
const int fillWidth = (barWidth - 2) * progress / 100;
renderer.fillRect(barX + 1, barY + 1, fillWidth, barHeight - 2, true);
renderer.displayBuffer(HalDisplay::FAST_REFRESH);
};
if (!section->createSectionFile(SETTINGS.getReaderFontId(), SETTINGS.getReaderLineCompression(), if (!section->createSectionFile(SETTINGS.getReaderFontId(), SETTINGS.getReaderLineCompression(),
SETTINGS.extraParagraphSpacing, SETTINGS.paragraphAlignment, viewportWidth, SETTINGS.extraParagraphSpacing, SETTINGS.paragraphAlignment, viewportWidth,
viewportHeight, SETTINGS.hyphenationEnabled, progressSetup, progressCallback)) { viewportHeight, SETTINGS.hyphenationEnabled, popupFn)) {
Serial.printf("[%lu] [ERS] Failed to persist page data to SD\n", millis()); Serial.printf("[%lu] [ERS] Failed to persist page data to SD\n", millis());
section.reset(); section.reset();
return; return;
@ -407,21 +432,26 @@ void EpubReaderActivity::renderScreen() {
renderContents(std::move(p), orientedMarginTop, orientedMarginRight, orientedMarginBottom, orientedMarginLeft); renderContents(std::move(p), orientedMarginTop, orientedMarginRight, orientedMarginBottom, orientedMarginLeft);
Serial.printf("[%lu] [ERS] Rendered page in %dms\n", millis(), millis() - start); Serial.printf("[%lu] [ERS] Rendered page in %dms\n", millis(), millis() - start);
} }
saveProgress(currentSpineIndex, section->currentPage, section->pageCount);
}
void EpubReaderActivity::saveProgress(int spineIndex, int currentPage, int pageCount) {
FsFile f; FsFile f;
if (SdMan.openFileForWrite("ERS", epub->getCachePath() + "/progress.bin", f)) { if (SdMan.openFileForWrite("ERS", epub->getCachePath() + "/progress.bin", f)) {
uint8_t data[6]; uint8_t data[6];
data[0] = currentSpineIndex & 0xFF; data[0] = currentSpineIndex & 0xFF;
data[1] = (currentSpineIndex >> 8) & 0xFF; data[1] = (currentSpineIndex >> 8) & 0xFF;
data[2] = section->currentPage & 0xFF; data[2] = currentPage & 0xFF;
data[3] = (section->currentPage >> 8) & 0xFF; data[3] = (currentPage >> 8) & 0xFF;
data[4] = section->pageCount & 0xFF; data[4] = pageCount & 0xFF;
data[5] = (section->pageCount >> 8) & 0xFF; data[5] = (pageCount >> 8) & 0xFF;
f.write(data, 6); f.write(data, 6);
f.close(); f.close();
Serial.printf("[ERS] Progress saved: Chapter %d, Page %d\n", spineIndex, currentPage);
} else {
Serial.printf("[ERS] Could not save progress!\n");
} }
} }
void EpubReaderActivity::renderContents(std::unique_ptr<Page> page, const int orientedMarginTop, void EpubReaderActivity::renderContents(std::unique_ptr<Page> page, const int orientedMarginTop,
const int orientedMarginRight, const int orientedMarginBottom, const int orientedMarginRight, const int orientedMarginBottom,
const int orientedMarginLeft) { const int orientedMarginLeft) {
@ -463,6 +493,8 @@ void EpubReaderActivity::renderContents(std::unique_ptr<Page> page, const int or
void EpubReaderActivity::renderStatusBar(const int orientedMarginRight, const int orientedMarginBottom, void EpubReaderActivity::renderStatusBar(const int orientedMarginRight, const int orientedMarginBottom,
const int orientedMarginLeft) const { const int orientedMarginLeft) const {
auto metrics = UITheme::getMetrics();
// determine visible status bar elements // determine visible status bar elements
const bool showProgressPercentage = SETTINGS.statusBar == CrossPointSettings::STATUS_BAR_MODE::FULL; const bool showProgressPercentage = SETTINGS.statusBar == CrossPointSettings::STATUS_BAR_MODE::FULL;
const bool showProgressBar = SETTINGS.statusBar == CrossPointSettings::STATUS_BAR_MODE::FULL_WITH_PROGRESS_BAR || const bool showProgressBar = SETTINGS.statusBar == CrossPointSettings::STATUS_BAR_MODE::FULL_WITH_PROGRESS_BAR ||
@ -506,11 +538,12 @@ void EpubReaderActivity::renderStatusBar(const int orientedMarginRight, const in
if (showProgressBar) { if (showProgressBar) {
// Draw progress bar at the very bottom of the screen, from edge to edge of viewable area // Draw progress bar at the very bottom of the screen, from edge to edge of viewable area
ScreenComponents::drawBookProgressBar(renderer, static_cast<size_t>(bookProgress)); UITheme::drawBookProgressBar(renderer, static_cast<size_t>(bookProgress));
} }
if (showBattery) { if (showBattery) {
ScreenComponents::drawBattery(renderer, orientedMarginLeft + 1, textY, showBatteryPercentage); UITheme::drawBattery(renderer, Rect{orientedMarginLeft + 1, textY, metrics.batteryWidth, metrics.batteryHeight},
showBatteryPercentage);
} }
if (showChapterTitle) { if (showChapterTitle) {

View File

@ -5,6 +5,7 @@
#include <freertos/semphr.h> #include <freertos/semphr.h>
#include <freertos/task.h> #include <freertos/task.h>
#include "EpubReaderMenuActivity.h"
#include "activities/ActivityWithSubactivity.h" #include "activities/ActivityWithSubactivity.h"
class EpubReaderActivity final : public ActivityWithSubactivity { class EpubReaderActivity final : public ActivityWithSubactivity {
@ -27,6 +28,9 @@ class EpubReaderActivity final : public ActivityWithSubactivity {
void renderContents(std::unique_ptr<Page> page, int orientedMarginTop, int orientedMarginRight, void renderContents(std::unique_ptr<Page> page, int orientedMarginTop, int orientedMarginRight,
int orientedMarginBottom, int orientedMarginLeft); int orientedMarginBottom, int orientedMarginLeft);
void renderStatusBar(int orientedMarginRight, int orientedMarginBottom, int orientedMarginLeft) const; void renderStatusBar(int orientedMarginRight, int orientedMarginBottom, int orientedMarginLeft) const;
void saveProgress(int spineIndex, int currentPage, int pageCount);
void onReaderMenuBack();
void onReaderMenuConfirm(EpubReaderMenuActivity::MenuAction action);
public: public:
explicit EpubReaderActivity(GfxRenderer& renderer, MappedInputManager& mappedInput, std::unique_ptr<Epub> epub, explicit EpubReaderActivity(GfxRenderer& renderer, MappedInputManager& mappedInput, std::unique_ptr<Epub> epub,

View File

@ -5,6 +5,7 @@
#include "KOReaderCredentialStore.h" #include "KOReaderCredentialStore.h"
#include "KOReaderSyncActivity.h" #include "KOReaderSyncActivity.h"
#include "MappedInputManager.h" #include "MappedInputManager.h"
#include "components/UITheme.h"
#include "fontIds.h" #include "fontIds.h"
namespace { namespace {
@ -181,9 +182,7 @@ void EpubReaderChapterSelectionActivity::renderScreen() {
const int pageItems = getPageItems(); const int pageItems = getPageItems();
const int totalItems = getTotalItems(); const int totalItems = getTotalItems();
const std::string title = renderer.drawCenteredText(UI_12_FONT_ID, 15, "Go to Chapter", true, EpdFontFamily::BOLD);
renderer.truncatedText(UI_12_FONT_ID, epub->getTitle().c_str(), pageWidth - 40, EpdFontFamily::BOLD);
renderer.drawCenteredText(UI_12_FONT_ID, 15, title.c_str(), true, EpdFontFamily::BOLD);
const auto pageStartIndex = selectorIndex / pageItems * pageItems; const auto pageStartIndex = selectorIndex / pageItems * pageItems;
renderer.fillRect(0, 60 + (selectorIndex % pageItems) * 30 - 2, pageWidth - 1, 30); renderer.fillRect(0, 60 + (selectorIndex % pageItems) * 30 - 2, pageWidth - 1, 30);
@ -208,8 +207,11 @@ void EpubReaderChapterSelectionActivity::renderScreen() {
} }
} }
const auto labels = mappedInput.mapLabels("« Back", "Select", "Up", "Down"); // Skip button hints in landscape CW mode (they overlap content)
renderer.drawButtonHints(UI_10_FONT_ID, labels.btn1, labels.btn2, labels.btn3, labels.btn4); if (renderer.getOrientation() != GfxRenderer::LandscapeClockwise) {
const auto labels = mappedInput.mapLabels("« Back", "Select", "Up", "Down");
UITheme::drawButtonHints(renderer, labels.btn1, labels.btn2, labels.btn3, labels.btn4);
}
renderer.displayBuffer(); renderer.displayBuffer();
} }

View File

@ -0,0 +1,104 @@
#include "EpubReaderMenuActivity.h"
#include <GfxRenderer.h>
#include "components/UITheme.h"
#include "fontIds.h"
void EpubReaderMenuActivity::onEnter() {
ActivityWithSubactivity::onEnter();
renderingMutex = xSemaphoreCreateMutex();
updateRequired = true;
xTaskCreate(&EpubReaderMenuActivity::taskTrampoline, "EpubMenuTask", 4096, this, 1, &displayTaskHandle);
}
void EpubReaderMenuActivity::onExit() {
ActivityWithSubactivity::onExit();
xSemaphoreTake(renderingMutex, portMAX_DELAY);
if (displayTaskHandle) {
vTaskDelete(displayTaskHandle);
displayTaskHandle = nullptr;
}
vSemaphoreDelete(renderingMutex);
renderingMutex = nullptr;
}
void EpubReaderMenuActivity::taskTrampoline(void* param) {
auto* self = static_cast<EpubReaderMenuActivity*>(param);
self->displayTaskLoop();
}
void EpubReaderMenuActivity::displayTaskLoop() {
while (true) {
if (updateRequired && !subActivity) {
updateRequired = false;
xSemaphoreTake(renderingMutex, portMAX_DELAY);
renderScreen();
xSemaphoreGive(renderingMutex);
}
vTaskDelay(10 / portTICK_PERIOD_MS);
}
}
void EpubReaderMenuActivity::loop() {
if (subActivity) {
subActivity->loop();
return;
}
// Use local variables for items we need to check after potential deletion
if (mappedInput.wasReleased(MappedInputManager::Button::Up) ||
mappedInput.wasReleased(MappedInputManager::Button::Left)) {
selectedIndex = (selectedIndex + menuItems.size() - 1) % menuItems.size();
updateRequired = true;
} else if (mappedInput.wasReleased(MappedInputManager::Button::Down) ||
mappedInput.wasReleased(MappedInputManager::Button::Right)) {
selectedIndex = (selectedIndex + 1) % menuItems.size();
updateRequired = true;
} else if (mappedInput.wasReleased(MappedInputManager::Button::Confirm)) {
// 1. Capture the callback and action locally
auto actionCallback = onAction;
auto selectedAction = menuItems[selectedIndex].action;
// 2. Execute the callback
actionCallback(selectedAction);
// 3. CRITICAL: Return immediately. 'this' is likely deleted now.
return;
} else if (mappedInput.wasReleased(MappedInputManager::Button::Back)) {
onBack();
return; // Also return here just in case
}
}
void EpubReaderMenuActivity::renderScreen() {
renderer.clearScreen();
const auto pageWidth = renderer.getScreenWidth();
// Title
const std::string truncTitle =
renderer.truncatedText(UI_12_FONT_ID, title.c_str(), pageWidth - 40, EpdFontFamily::BOLD);
renderer.drawCenteredText(UI_12_FONT_ID, 15, truncTitle.c_str(), true, EpdFontFamily::BOLD);
// Menu Items
constexpr int startY = 60;
constexpr int lineHeight = 30;
for (size_t i = 0; i < menuItems.size(); ++i) {
const int displayY = startY + (i * lineHeight);
const bool isSelected = (static_cast<int>(i) == selectedIndex);
if (isSelected) {
renderer.fillRect(0, displayY, pageWidth - 1, lineHeight, true);
}
renderer.drawText(UI_10_FONT_ID, 20, displayY, menuItems[i].label.c_str(), !isSelected);
}
// Footer / Hints
const auto labels = mappedInput.mapLabels("« Back", "Select", "Up", "Down");
UITheme::drawButtonHints(renderer, labels.btn1, labels.btn2, labels.btn3, labels.btn4);
renderer.displayBuffer();
}

View File

@ -0,0 +1,51 @@
#pragma once
#include <Epub.h>
#include <freertos/FreeRTOS.h>
#include <freertos/semphr.h>
#include <freertos/task.h>
#include <functional>
#include <string>
#include <vector>
#include "../ActivityWithSubactivity.h"
#include "MappedInputManager.h"
class EpubReaderMenuActivity final : public ActivityWithSubactivity {
public:
enum class MenuAction { SELECT_CHAPTER, GO_HOME, DELETE_CACHE };
explicit EpubReaderMenuActivity(GfxRenderer& renderer, MappedInputManager& mappedInput, const std::string& title,
const std::function<void()>& onBack, const std::function<void(MenuAction)>& onAction)
: ActivityWithSubactivity("EpubReaderMenu", renderer, mappedInput),
title(title),
onBack(onBack),
onAction(onAction) {}
void onEnter() override;
void onExit() override;
void loop() override;
private:
struct MenuItem {
MenuAction action;
std::string label;
};
const std::vector<MenuItem> menuItems = {{MenuAction::SELECT_CHAPTER, "Go to Chapter"},
{MenuAction::GO_HOME, "Go Home"},
{MenuAction::DELETE_CACHE, "Delete Book Cache"}};
int selectedIndex = 0;
bool updateRequired = false;
TaskHandle_t displayTaskHandle = nullptr;
SemaphoreHandle_t renderingMutex = nullptr;
std::string title = "Reader Menu";
const std::function<void()> onBack;
const std::function<void(MenuAction)> onAction;
static void taskTrampoline(void* param);
[[noreturn]] void displayTaskLoop();
void renderScreen();
};

View File

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

View File

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

View File

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

View File

@ -9,7 +9,7 @@
#include "CrossPointState.h" #include "CrossPointState.h"
#include "MappedInputManager.h" #include "MappedInputManager.h"
#include "RecentBooksStore.h" #include "RecentBooksStore.h"
#include "ScreenComponents.h" #include "components/UITheme.h"
#include "fontIds.h" #include "fontIds.h"
namespace { namespace {
@ -168,13 +168,15 @@ void TxtReaderActivity::initializeReader() {
orientedMarginRight += cachedScreenMargin; orientedMarginRight += cachedScreenMargin;
orientedMarginBottom += cachedScreenMargin; orientedMarginBottom += cachedScreenMargin;
auto metrics = UITheme::getMetrics();
// Add status bar margin // Add status bar margin
if (SETTINGS.statusBar != CrossPointSettings::STATUS_BAR_MODE::NONE) { if (SETTINGS.statusBar != CrossPointSettings::STATUS_BAR_MODE::NONE) {
// Add additional margin for status bar if progress bar is shown // Add additional margin for status bar if progress bar is shown
const bool showProgressBar = SETTINGS.statusBar == CrossPointSettings::STATUS_BAR_MODE::FULL_WITH_PROGRESS_BAR || const bool showProgressBar = SETTINGS.statusBar == CrossPointSettings::STATUS_BAR_MODE::FULL_WITH_PROGRESS_BAR ||
SETTINGS.statusBar == CrossPointSettings::STATUS_BAR_MODE::ONLY_PROGRESS_BAR; SETTINGS.statusBar == CrossPointSettings::STATUS_BAR_MODE::ONLY_PROGRESS_BAR;
orientedMarginBottom += statusBarMargin - cachedScreenMargin + orientedMarginBottom += statusBarMargin - cachedScreenMargin +
(showProgressBar ? (ScreenComponents::BOOK_PROGRESS_BAR_HEIGHT + progressBarMarginTop) : 0); (showProgressBar ? (metrics.bookProgressBarHeight + progressBarMarginTop) : 0);
} }
viewportWidth = renderer.getScreenWidth() - orientedMarginLeft - orientedMarginRight; viewportWidth = renderer.getScreenWidth() - orientedMarginLeft - orientedMarginRight;
@ -207,28 +209,10 @@ void TxtReaderActivity::buildPageIndex() {
size_t offset = 0; size_t offset = 0;
const size_t fileSize = txt->getFileSize(); const size_t fileSize = txt->getFileSize();
int lastProgressPercent = -1;
Serial.printf("[%lu] [TRS] Building page index for %zu bytes...\n", millis(), fileSize); Serial.printf("[%lu] [TRS] Building page index for %zu bytes...\n", millis(), fileSize);
// Progress bar dimensions (matching EpubReaderActivity style) UITheme::drawPopup(renderer, "Indexing...");
constexpr int barWidth = 200;
constexpr int barHeight = 10;
constexpr int boxMargin = 20;
const int textWidth = renderer.getTextWidth(UI_12_FONT_ID, "Indexing...");
const int boxWidth = (barWidth > textWidth ? barWidth : textWidth) + boxMargin * 2;
const int boxHeight = renderer.getLineHeight(UI_12_FONT_ID) + barHeight + boxMargin * 3;
const int boxX = (renderer.getScreenWidth() - boxWidth) / 2;
constexpr int boxY = 50;
const int barX = boxX + (boxWidth - barWidth) / 2;
const int barY = boxY + renderer.getLineHeight(UI_12_FONT_ID) + boxMargin * 2;
// Draw initial progress box
renderer.fillRect(boxX, boxY, boxWidth, boxHeight, false);
renderer.drawText(UI_12_FONT_ID, boxX + boxMargin, boxY + boxMargin, "Indexing...");
renderer.drawRect(boxX + 5, boxY + 5, boxWidth - 10, boxHeight - 10);
renderer.drawRect(barX, barY, barWidth, barHeight);
renderer.displayBuffer();
while (offset < fileSize) { while (offset < fileSize) {
std::vector<std::string> tempLines; std::vector<std::string> tempLines;
@ -248,17 +232,6 @@ void TxtReaderActivity::buildPageIndex() {
pageOffsets.push_back(offset); pageOffsets.push_back(offset);
} }
// Update progress bar every 10% (matching EpubReaderActivity logic)
int progressPercent = (offset * 100) / fileSize;
if (lastProgressPercent / 10 != progressPercent / 10) {
lastProgressPercent = progressPercent;
// Fill progress bar
const int fillWidth = (barWidth - 2) * progressPercent / 100;
renderer.fillRect(barX + 1, barY + 1, fillWidth, barHeight - 2, true);
renderer.displayBuffer(HalDisplay::FAST_REFRESH);
}
// Yield to other tasks periodically // Yield to other tasks periodically
if (pageOffsets.size() % 20 == 0) { if (pageOffsets.size() % 20 == 0) {
vTaskDelay(1); vTaskDelay(1);
@ -402,9 +375,6 @@ void TxtReaderActivity::renderScreen() {
// Initialize reader if not done // Initialize reader if not done
if (!initialized) { if (!initialized) {
renderer.clearScreen();
renderer.drawCenteredText(UI_12_FONT_ID, 300, "Indexing...", true, EpdFontFamily::BOLD);
renderer.displayBuffer();
initializeReader(); initializeReader();
} }
@ -530,6 +500,7 @@ void TxtReaderActivity::renderStatusBar(const int orientedMarginRight, const int
const bool showBatteryPercentage = const bool showBatteryPercentage =
SETTINGS.hideBatteryPercentage == CrossPointSettings::HIDE_BATTERY_PERCENTAGE::HIDE_NEVER; SETTINGS.hideBatteryPercentage == CrossPointSettings::HIDE_BATTERY_PERCENTAGE::HIDE_NEVER;
auto metrics = UITheme::getMetrics();
const auto screenHeight = renderer.getScreenHeight(); const auto screenHeight = renderer.getScreenHeight();
const auto textY = screenHeight - orientedMarginBottom - 4; const auto textY = screenHeight - orientedMarginBottom - 4;
int progressTextWidth = 0; int progressTextWidth = 0;
@ -551,11 +522,12 @@ void TxtReaderActivity::renderStatusBar(const int orientedMarginRight, const int
if (showProgressBar) { if (showProgressBar) {
// Draw progress bar at the very bottom of the screen, from edge to edge of viewable area // Draw progress bar at the very bottom of the screen, from edge to edge of viewable area
ScreenComponents::drawBookProgressBar(renderer, static_cast<size_t>(progress)); UITheme::drawBookProgressBar(renderer, static_cast<size_t>(progress));
} }
if (showBattery) { if (showBattery) {
ScreenComponents::drawBattery(renderer, orientedMarginLeft, textY, showBatteryPercentage); UITheme::drawBattery(renderer, Rect{orientedMarginLeft, textY, metrics.batteryWidth, metrics.batteryHeight},
showBatteryPercentage);
} }
if (showTitle) { if (showTitle) {

View File

@ -3,6 +3,7 @@
#include <GfxRenderer.h> #include <GfxRenderer.h>
#include "MappedInputManager.h" #include "MappedInputManager.h"
#include "components/UITheme.h"
#include "fontIds.h" #include "fontIds.h"
namespace { namespace {
@ -149,8 +150,11 @@ void XtcReaderChapterSelectionActivity::renderScreen() {
renderer.drawText(UI_10_FONT_ID, 20, 60 + (i % pageItems) * 30, title, i != selectorIndex); renderer.drawText(UI_10_FONT_ID, 20, 60 + (i % pageItems) * 30, title, i != selectorIndex);
} }
const auto labels = mappedInput.mapLabels("« Back", "Select", "Up", "Down"); // Skip button hints in landscape CW mode (they overlap content)
renderer.drawButtonHints(UI_10_FONT_ID, labels.btn1, labels.btn2, labels.btn3, labels.btn4); if (renderer.getOrientation() != GfxRenderer::LandscapeClockwise) {
const auto labels = mappedInput.mapLabels("« Back", "Select", "Up", "Down");
UITheme::drawButtonHints(renderer, labels.btn1, labels.btn2, labels.btn3, labels.btn4);
}
renderer.displayBuffer(); renderer.displayBuffer();
} }

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

133
src/components/UITheme.cpp Normal file
View File

@ -0,0 +1,133 @@
#include "UITheme.h"
#include <GfxRenderer.h>
#include <memory>
#include "RecentBooksStore.h"
#include "components/themes/BaseTheme.h"
#include "components/themes/lyra/LyraTheme.h"
std::unique_ptr<BaseTheme> currentTheme = nullptr;
const ThemeMetrics* UITheme::currentMetrics = &BaseMetrics::values;
// Initialize theme based on settings
void UITheme::initialize() {
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 = std::unique_ptr<BaseTheme>(new BaseTheme());
currentMetrics = &BaseMetrics::values;
break;
case CrossPointSettings::UI_THEME::LYRA:
Serial.printf("[%lu] [UI] Using Lyra theme\n", millis());
currentTheme = std::unique_ptr<BaseTheme>(new LyraTheme());
currentMetrics = &LyraMetrics::values;
break;
}
}
int UITheme::getNumberOfItemsPerPage(const GfxRenderer& renderer, bool hasHeader, bool hasTabBar, bool hasButtonHints) {
const ThemeMetrics& metrics = UITheme::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;
return availableHeight / metrics.listRowHeight;
}
// Forward all component methods to the current theme
void UITheme::drawProgressBar(const GfxRenderer& renderer, Rect rect, size_t current, size_t total) {
if (currentTheme != nullptr) {
currentTheme->drawProgressBar(renderer, rect, current, total);
}
}
void UITheme::drawBattery(const GfxRenderer& renderer, Rect rect, bool showPercentage) {
if (currentTheme != nullptr) {
currentTheme->drawBattery(renderer, rect, showPercentage);
}
}
void UITheme::drawButtonHints(GfxRenderer& renderer, const char* btn1, const char* btn2, const char* btn3,
const char* btn4) {
if (currentTheme != nullptr) {
currentTheme->drawButtonHints(renderer, btn1, btn2, btn3, btn4);
}
}
void UITheme::drawSideButtonHints(const GfxRenderer& renderer, const char* topBtn, const char* bottomBtn) {
if (currentTheme != nullptr) {
currentTheme->drawSideButtonHints(renderer, topBtn, bottomBtn);
}
}
void UITheme::drawList(const GfxRenderer& renderer, Rect rect, int itemCount, int selectedIndex,
const std::function<std::string(int index)>& rowTitle, bool hasIcon,
const std::function<std::string(int index)>& rowIcon, bool hasValue,
const std::function<std::string(int index)>& rowValue) {
if (currentTheme != nullptr) {
currentTheme->drawList(renderer, rect, itemCount, selectedIndex, rowTitle, hasIcon, rowIcon, hasValue, rowValue);
}
}
void UITheme::drawHeader(const GfxRenderer& renderer, Rect rect, const char* title) {
if (currentTheme != nullptr) {
currentTheme->drawHeader(renderer, rect, title);
}
}
void UITheme::drawTabBar(const GfxRenderer& renderer, const Rect rect, const std::vector<TabInfo>& tabs,
bool selected) {
if (currentTheme != nullptr) {
currentTheme->drawTabBar(renderer, rect, tabs, selected);
}
}
void UITheme::drawRecentBookCover(GfxRenderer& renderer, Rect rect, const std::vector<RecentBookWithCover>& recentBooks,
const int selectorIndex, bool& coverRendered, bool& coverBufferStored,
bool& bufferRestored, std::function<bool()> storeCoverBuffer) {
if (currentTheme != nullptr) {
currentTheme->drawRecentBookCover(renderer, rect, recentBooks, selectorIndex, coverRendered, coverBufferStored,
bufferRestored, storeCoverBuffer);
}
}
void UITheme::drawButtonMenu(GfxRenderer& renderer, Rect rect, int buttonCount, int selectedIndex,
const std::function<std::string(int index)>& buttonLabel, bool hasIcon,
const std::function<std::string(int index)>& rowIcon) {
if (currentTheme != nullptr) {
currentTheme->drawButtonMenu(renderer, rect, buttonCount, selectedIndex, buttonLabel, hasIcon, rowIcon);
}
}
Rect UITheme::drawPopup(const GfxRenderer& renderer, const char* message) {
if (currentTheme != nullptr) {
return currentTheme->drawPopup(renderer, message);
}
return Rect{0, 0, 0, 0};
}
void UITheme::fillPopupProgress(const GfxRenderer& renderer, const Rect& layout, const int progress) {
if (currentTheme != nullptr) {
currentTheme->fillPopupProgress(renderer, layout, progress);
}
}
void UITheme::drawBookProgressBar(const GfxRenderer& renderer, const size_t bookProgress) {
if (currentTheme != nullptr) {
currentTheme->drawBookProgressBar(renderer, bookProgress);
}
}

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

@ -0,0 +1,95 @@
#pragma once
#include <functional>
#include <vector>
#include "CrossPointSettings.h"
class GfxRenderer;
struct RecentBookWithCover;
struct Rect {
int x;
int y;
int width;
int height;
// Constructor for explicit initialization
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 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;
};
struct PopupCallbacks {
std::function<void()> setup;
std::function<void(int)> update;
};
class UITheme {
private:
static const ThemeMetrics* currentMetrics;
public:
static void initialize();
static void setTheme(CrossPointSettings::UI_THEME type);
static const ThemeMetrics& getMetrics() { return *currentMetrics; }
static int getNumberOfItemsPerPage(const GfxRenderer& renderer, bool hasHeader, bool hasTabBar, bool hasButtonHints);
static void drawProgressBar(const GfxRenderer& renderer, Rect rect, size_t current, size_t total);
static void drawBattery(const GfxRenderer& renderer, Rect rect, bool showPercentage = true);
static void drawButtonHints(GfxRenderer& renderer, const char* btn1, const char* btn2, const char* btn3,
const char* btn4);
static void drawSideButtonHints(const GfxRenderer& renderer, const char* topBtn, const char* bottomBtn);
static void drawList(const GfxRenderer& renderer, Rect rect, int itemCount, int selectedIndex,
const std::function<std::string(int index)>& rowTitle, bool hasIcon,
const std::function<std::string(int index)>& rowIcon, bool hasValue,
const std::function<std::string(int index)>& rowValue);
static void drawHeader(const GfxRenderer& renderer, Rect rect, const char* title);
static void drawTabBar(const GfxRenderer& renderer, Rect rect, const std::vector<TabInfo>& tabs, bool selected);
static void drawRecentBookCover(GfxRenderer& renderer, Rect rect, const std::vector<RecentBookWithCover>& recentBooks,
const int selectorIndex, bool& coverRendered, bool& coverBufferStored,
bool& bufferRestored, std::function<bool()> storeCoverBuffer);
static void drawButtonMenu(GfxRenderer& renderer, Rect rect, int buttonCount, int selectedIndex,
const std::function<std::string(int index)>& buttonLabel, bool hasIcon,
const std::function<std::string(int index)>& rowIcon);
static Rect drawPopup(const GfxRenderer& renderer, const char* message);
static void fillPopupProgress(const GfxRenderer& renderer, const Rect& layout, const int progress);
static void drawBookProgressBar(const GfxRenderer& renderer, const size_t bookProgress);
};

View File

@ -0,0 +1,628 @@
#include "BaseTheme.h"
#include <GfxRenderer.h>
#include <SDCardManager.h>
#include <cstdint>
#include <string>
#include "Battery.h"
#include "RecentBooksStore.h"
#include "fontIds.h"
#include "util/StringUtils.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) {
// 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) {
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 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 int screenWidth = renderer.getScreenWidth();
constexpr int buttonWidth = BaseMetrics::values.sideButtonHintsWidth; // Width on screen (height when rotated)
constexpr int buttonHeight = 80; // Height on screen (width when rotated)
constexpr int buttonX = 4; // Distance from right edge
// Position for the button group - buttons share a border so they're adjacent
constexpr int topButtonY = 345; // Top button position
const char* labels[] = {topBtn, bottomBtn};
// Draw the shared border for both buttons as one unit
const int x = screenWidth - buttonX - buttonWidth;
// Draw top button outline (3 sides, bottom open)
if (topBtn != nullptr && topBtn[0] != '\0') {
renderer.drawLine(x, topButtonY, x + buttonWidth - 1, topButtonY); // Top
renderer.drawLine(x, topButtonY, x, topButtonY + buttonHeight - 1); // Left
renderer.drawLine(x + buttonWidth - 1, topButtonY, x + buttonWidth - 1, topButtonY + buttonHeight - 1); // Right
}
// Draw shared middle border
if ((topBtn != nullptr && topBtn[0] != '\0') || (bottomBtn != nullptr && bottomBtn[0] != '\0')) {
renderer.drawLine(x, topButtonY + buttonHeight, x + buttonWidth - 1, topButtonY + buttonHeight); // Shared border
}
// Draw bottom button outline (3 sides, top is shared)
if (bottomBtn != nullptr && bottomBtn[0] != '\0') {
renderer.drawLine(x, topButtonY + buttonHeight, x, topButtonY + 2 * buttonHeight - 1); // Left
renderer.drawLine(x + buttonWidth - 1, topButtonY + buttonHeight, x + buttonWidth - 1,
topButtonY + 2 * buttonHeight - 1); // Right
renderer.drawLine(x, topButtonY + 2 * buttonHeight - 1, x + buttonWidth - 1,
topButtonY + 2 * buttonHeight - 1); // Bottom
}
// Draw text for each button
for (int i = 0; i < 2; i++) {
if (labels[i] != nullptr && labels[i][0] != '\0') {
const int y = topButtonY + i * buttonHeight;
// Draw rotated text centered in the button
const int textWidth = renderer.getTextWidth(SMALL_FONT_ID, labels[i]);
const int textHeight = renderer.getTextHeight(SMALL_FONT_ID);
// Center the rotated text in the button
const int textX = x + (buttonWidth - textHeight) / 2;
const int textY = y + (buttonHeight + textWidth) / 2;
renderer.drawTextRotated90CW(SMALL_FONT_ID, textX, textY, labels[i]);
}
}
}
void BaseTheme::drawList(const GfxRenderer& renderer, Rect rect, int itemCount, int selectedIndex,
const std::function<std::string(int index)>& rowTitle, bool hasIcon,
const std::function<std::string(int index)>& rowIcon, bool hasValue,
const std::function<std::string(int index)>& rowValue) {
int pageItems = rect.height / BaseMetrics::values.listRowHeight;
const int rowHeight = BaseMetrics::values.listRowHeight;
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 - BaseMetrics::values.sideButtonHintsWidth - 5;
if (selectedIndex >= 0) {
renderer.fillRect(0, rect.y + selectedIndex % pageItems * rowHeight - 2, contentWidth, 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;
// Draw name
auto itemName = rowTitle(i);
auto item = renderer.truncatedText(UI_10_FONT_ID, itemName.c_str(),
contentWidth - BaseMetrics::values.contentSidePadding * 2 -
(hasValue ? 60 : 0)); // TODO truncate according to value width?
renderer.drawText(UI_10_FONT_ID, rect.x + BaseMetrics::values.contentSidePadding, itemY, item.c_str(),
i != selectedIndex);
if (hasValue) {
// Draw value
std::string valueText = rowValue(i);
const auto textWidth = renderer.getTextWidth(UI_10_FONT_ID, valueText.c_str());
renderer.drawText(UI_10_FONT_ID, contentWidth - BaseMetrics::values.contentSidePadding - textWidth, itemY,
valueText.c_str(), i != selectedIndex);
}
}
}
void BaseTheme::drawHeader(const GfxRenderer& renderer, Rect rect, const char* title) {
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) {
renderer.drawCenteredText(UI_12_FONT_ID, rect.y + 5, title, true, EpdFontFamily::BOLD);
}
}
void BaseTheme::drawTabBar(const GfxRenderer& renderer, const Rect rect, const std::vector<TabInfo>& tabs,
bool selected) {
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<RecentBookWithCover>& recentBooks, const int selectorIndex,
bool& coverRendered, bool& coverBufferStored, bool& bufferRestored,
std::function<bool()> storeCoverBuffer) {
// --- 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 = recentBooks[0].coverBmpPath;
// 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].book.title;
const std::string& lastBookAuthor = recentBooks[0].book.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 "..."
StringUtils::utf8RemoveLastChar(lines.back());
lines.back().append("...");
}
break;
}
int wordWidth = renderer.getTextWidth(UI_12_FONT_ID, i.c_str());
while (wordWidth > maxLineWidth && !i.empty()) {
// Word itself is too long, trim it (UTF-8 safe)
StringUtils::utf8RemoveLastChar(i);
// Check if we have room for ellipsis
std::string withEllipsis = i + "...";
wordWidth = renderer.getTextWidth(UI_12_FONT_ID, withEllipsis.c_str());
if (wordWidth <= maxLineWidth) {
i = withEllipsis;
break;
}
}
int newLineWidth = renderer.getTextWidth(UI_12_FONT_ID, currentLine.c_str());
if (newLineWidth > 0) {
newLineWidth += spaceWidth;
}
newLineWidth += wordWidth;
if (newLineWidth > maxLineWidth && !currentLine.empty()) {
// New line too long, push old line
lines.push_back(currentLine);
currentLine = i;
} else {
currentLine.append(" ").append(i);
}
}
// If lower than the line limit, push remaining words
if (!currentLine.empty() && lines.size() < 3) {
lines.push_back(currentLine);
}
// Book title text
int totalTextHeight = renderer.getLineHeight(UI_12_FONT_ID) * static_cast<int>(lines.size());
if (!lastBookAuthor.empty()) {
totalTextHeight += renderer.getLineHeight(UI_10_FONT_ID) * 3 / 2;
}
// Vertically center the title block within the card
int titleYStart = bookY + (bookHeight - totalTextHeight) / 2;
// If cover image was rendered, draw box behind title and author
if (coverRendered) {
constexpr int boxPadding = 8;
// Calculate the max text width for the box
int maxTextWidth = 0;
for (const auto& line : lines) {
const int lineWidth = renderer.getTextWidth(UI_12_FONT_ID, line.c_str());
if (lineWidth > maxTextWidth) {
maxTextWidth = lineWidth;
}
}
if (!lastBookAuthor.empty()) {
std::string trimmedAuthor = lastBookAuthor;
while (renderer.getTextWidth(UI_10_FONT_ID, trimmedAuthor.c_str()) > maxLineWidth && !trimmedAuthor.empty()) {
StringUtils::utf8RemoveLastChar(trimmedAuthor);
}
if (renderer.getTextWidth(UI_10_FONT_ID, trimmedAuthor.c_str()) <
renderer.getTextWidth(UI_10_FONT_ID, lastBookAuthor.c_str())) {
trimmedAuthor.append("...");
}
const int authorWidth = renderer.getTextWidth(UI_10_FONT_ID, trimmedAuthor.c_str());
if (authorWidth > maxTextWidth) {
maxTextWidth = authorWidth;
}
}
const int boxWidth = maxTextWidth + boxPadding * 2;
const int boxHeight = totalTextHeight + boxPadding * 2;
const int boxX = (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()) {
StringUtils::utf8RemoveLastChar(trimmedAuthor);
wasTrimmed = true;
}
if (wasTrimmed && !trimmedAuthor.empty()) {
// Make room for ellipsis
while (renderer.getTextWidth(UI_10_FONT_ID, (trimmedAuthor + "...").c_str()) > maxLineWidth &&
!trimmedAuthor.empty()) {
StringUtils::utf8RemoveLastChar(trimmedAuthor);
}
trimmedAuthor.append("...");
}
renderer.drawCenteredText(UI_10_FONT_ID, titleYStart, trimmedAuthor.c_str(), !bookSelected);
}
// "Continue Reading" label at the bottom
const int continueY = bookY + bookHeight - renderer.getLineHeight(UI_10_FONT_ID) * 3 / 2;
if (coverRendered) {
// Draw box behind "Continue Reading" text (inverted when selected: black box instead of white)
const char* continueText = "Continue Reading";
const int continueTextWidth = renderer.getTextWidth(UI_10_FONT_ID, continueText);
constexpr int continuePadding = 6;
const int continueBoxWidth = continueTextWidth + continuePadding * 2;
const int continueBoxHeight = renderer.getLineHeight(UI_10_FONT_ID) + continuePadding;
const int continueBoxX = (rect.width - continueBoxWidth) / 2;
const int continueBoxY = continueY - continuePadding / 2;
renderer.fillRect(continueBoxX, continueBoxY, continueBoxWidth, continueBoxHeight, bookSelected);
renderer.drawRect(continueBoxX, continueBoxY, continueBoxWidth, continueBoxHeight, !bookSelected);
renderer.drawCenteredText(UI_10_FONT_ID, continueY, continueText, !bookSelected);
} else {
renderer.drawCenteredText(UI_10_FONT_ID, continueY, "Continue Reading", !bookSelected);
}
} else {
// No book to continue reading
const int y =
bookY + (bookHeight - renderer.getLineHeight(UI_12_FONT_ID) - renderer.getLineHeight(UI_10_FONT_ID)) / 2;
renderer.drawCenteredText(UI_12_FONT_ID, y, "No open book");
renderer.drawCenteredText(UI_10_FONT_ID, y + renderer.getLineHeight(UI_12_FONT_ID), "Start reading below");
}
}
void BaseTheme::drawButtonMenu(GfxRenderer& renderer, Rect rect, int buttonCount, int selectedIndex,
const std::function<std::string(int index)>& buttonLabel, bool hasIcon,
const std::function<std::string(int index)>& rowIcon) {
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) {
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) {
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) {
int vieweableMarginTop, vieweableMarginRight, vieweableMarginBottom, vieweableMarginLeft;
renderer.getOrientedViewableTRBL(&vieweableMarginTop, &vieweableMarginRight, &vieweableMarginBottom,
&vieweableMarginLeft);
const int progressBarMaxWidth = renderer.getScreenWidth() - vieweableMarginLeft - vieweableMarginRight;
const int progressBarY =
renderer.getScreenHeight() - vieweableMarginBottom - BaseMetrics::values.bookProgressBarHeight;
const int barWidth = progressBarMaxWidth * bookProgress / 100;
renderer.fillRect(vieweableMarginLeft, progressBarY, barWidth, BaseMetrics::values.bookProgressBarHeight, true);
}

View File

@ -0,0 +1,68 @@
#pragma once
#include <cstddef>
#include <cstdint>
#include <functional>
#include "components/UITheme.h"
class GfxRenderer;
struct RecentBookInfo;
// 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,
.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);
virtual void drawBattery(const GfxRenderer& renderer, Rect rect, bool showPercentage = true);
virtual void drawButtonHints(GfxRenderer& renderer, const char* btn1, const char* btn2, const char* btn3,
const char* btn4);
virtual void drawSideButtonHints(const GfxRenderer& renderer, const char* topBtn, const char* bottomBtn);
virtual void drawList(const GfxRenderer& renderer, Rect rect, int itemCount, int selectedIndex,
const std::function<std::string(int index)>& rowTitle, bool hasIcon,
const std::function<std::string(int index)>& rowIcon, bool hasValue,
const std::function<std::string(int index)>& rowValue);
virtual void drawHeader(const GfxRenderer& renderer, Rect rect, const char* title);
virtual void drawTabBar(const GfxRenderer& renderer, Rect rect, const std::vector<TabInfo>& tabs, bool selected);
virtual void drawRecentBookCover(GfxRenderer& renderer, Rect rect,
const std::vector<RecentBookWithCover>& recentBooks, const int selectorIndex,
bool& coverRendered, bool& coverBufferStored, bool& bufferRestored,
std::function<bool()> storeCoverBuffer);
virtual void drawButtonMenu(GfxRenderer& renderer, Rect rect, int buttonCount, int selectedIndex,
const std::function<std::string(int index)>& buttonLabel, bool hasIcon,
const std::function<std::string(int index)>& rowIcon);
virtual Rect drawPopup(const GfxRenderer& renderer, const char* message);
virtual void fillPopupProgress(const GfxRenderer& renderer, const Rect& layout, const int progress);
virtual void drawBookProgressBar(const GfxRenderer& renderer, const size_t bookProgress);
};

View File

@ -0,0 +1,361 @@
#include "LyraTheme.h"
#include <GfxRenderer.h>
#include <SDCardManager.h>
#include <cstdint>
#include <string>
#include "Battery.h"
#include "RecentBooksStore.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) {
// 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) {
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) {
renderer.drawText(UI_12_FONT_ID, rect.x + LyraMetrics::values.contentSidePadding,
rect.y + LyraMetrics::values.batteryBarHeight + 3, title, 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) {
int currentX = rect.x + LyraMetrics::values.contentSidePadding;
if (selected) {
renderer.fillRectDither(rect.x, rect.y, rect.width, rect.height, COLOR_LIGHT_GRAY);
}
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_LIGHT_GRAY);
renderer.drawLine(currentX, rect.y + rect.height - 3, currentX + textWidth + 2 * hPaddingInSelection,
rect.y + rect.height - 3, 2, true);
}
}
renderer.drawText(UI_10_FONT_ID, currentX + hPaddingInSelection, rect.y + 6, tab.label, !(tab.selected && selected),
EpdFontFamily::REGULAR);
currentX += textWidth + LyraMetrics::values.tabSpacing + 2 * hPaddingInSelection;
}
renderer.drawLine(rect.x, rect.y + rect.height - 1, rect.x + rect.width, rect.y + rect.height - 1, true);
}
void LyraTheme::drawList(const GfxRenderer& renderer, Rect rect, int itemCount, int selectedIndex,
const std::function<std::string(int index)>& rowTitle, bool hasIcon,
const std::function<std::string(int index)>& rowIcon, bool hasValue,
const std::function<std::string(int index)>& rowValue) {
int pageItems = rect.height / LyraMetrics::values.listRowHeight;
const int rowHeight = LyraMetrics::values.listRowHeight;
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_LIGHT_GRAY);
}
// 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
auto itemName = rowTitle(i);
auto item =
renderer.truncatedText(UI_10_FONT_ID, itemName.c_str(),
contentWidth - LyraMetrics::values.contentSidePadding * 2 - hPaddingInSelection * 2 -
(hasValue ? 60 : 0)); // TODO truncate according to value width?
renderer.drawText(UI_10_FONT_ID, rect.x + LyraMetrics::values.contentSidePadding + hPaddingInSelection * 2,
itemY + 6, item.c_str(), true);
if (hasValue) {
// Draw value
std::string valueText = rowValue(i);
if (!valueText.empty()) {
const auto textWidth = renderer.getTextWidth(UI_10_FONT_ID, valueText.c_str());
if (i == selectedIndex) {
renderer.fillRoundedRect(
contentWidth - LyraMetrics::values.contentSidePadding - hPaddingInSelection * 2 - textWidth, itemY,
textWidth + hPaddingInSelection * 2, rowHeight, cornerRadius, COLOR_BLACK);
}
renderer.drawText(UI_10_FONT_ID,
contentWidth - LyraMetrics::values.contentSidePadding - hPaddingInSelection - textWidth,
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 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 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<RecentBookWithCover>& recentBooks, const int selectorIndex,
bool& coverRendered, bool& coverBufferStored, bool& bufferRestored,
std::function<bool()> storeCoverBuffer) {
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++) {
const std::string& coverBmpPath = recentBooks[i].coverBmpPath;
if (coverBmpPath.empty()) {
continue;
}
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].book.title.c_str(), tileWidth - 2 * hPaddingInSelection);
if (bookSelected) {
// Draw selection box
renderer.fillRoundedRect(tileX, tileY, tileWidth, hPaddingInSelection, cornerRadius, true, true, false, false,
COLOR_LIGHT_GRAY);
renderer.fillRectDither(tileX, tileY + hPaddingInSelection, hPaddingInSelection,
LyraMetrics::values.homeCoverHeight, COLOR_LIGHT_GRAY);
renderer.fillRectDither(tileX + tileWidth - hPaddingInSelection, tileY + hPaddingInSelection,
hPaddingInSelection, LyraMetrics::values.homeCoverHeight, COLOR_LIGHT_GRAY);
renderer.fillRoundedRect(tileX, tileY + LyraMetrics::values.homeCoverHeight + hPaddingInSelection, tileWidth,
bookTitleHeight, cornerRadius, false, false, true, true, COLOR_LIGHT_GRAY);
}
renderer.drawText(UI_10_FONT_ID, tileX + hPaddingInSelection,
tileY + tileHeight - bookTitleHeight + hPaddingInSelection + 5, title.c_str(), true);
}
}
}
void LyraTheme::drawButtonMenu(GfxRenderer& renderer, Rect rect, int buttonCount, int selectedIndex,
const std::function<std::string(int index)>& buttonLabel, bool hasIcon,
const std::function<std::string(int index)>& rowIcon) {
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_LIGHT_GRAY);
}
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) {
constexpr int margin = 15;
constexpr int y = 60;
const int textWidth = renderer.getTextWidth(UI_12_FONT_ID, message, EpdFontFamily::REGULAR);
const int textHeight = renderer.getLineHeight(UI_12_FONT_ID);
const int w = textWidth + margin * 2;
const int h = textHeight + margin * 2;
const int x = (renderer.getScreenWidth() - w) / 2;
renderer.fillRect(x - 5, y - 5, w + 10, h + 10, false);
renderer.drawRect(x, y, w, h, true);
const int textX = x + (w - textWidth) / 2;
const int textY = y + margin - 2;
renderer.drawText(UI_12_FONT_ID, textX, textY, message, true, EpdFontFamily::REGULAR);
renderer.displayBuffer();
return Rect{x, y, w, h};
}

View File

@ -0,0 +1,55 @@
#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,
.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) override;
void drawHeader(const GfxRenderer& renderer, Rect rect, const char* title) override;
void drawTabBar(const GfxRenderer& renderer, Rect rect, const std::vector<TabInfo>& tabs, bool selected) override;
void drawList(const GfxRenderer& renderer, Rect rect, int itemCount, int selectedIndex,
const std::function<std::string(int index)>& rowTitle, bool hasIcon,
const std::function<std::string(int index)>& rowIcon, bool hasValue,
const std::function<std::string(int index)>& rowValue) override;
void drawButtonHints(GfxRenderer& renderer, const char* btn1, const char* btn2, const char* btn3,
const char* btn4) override;
void drawSideButtonHints(const GfxRenderer& renderer, const char* topBtn, const char* bottomBtn) override;
void drawButtonMenu(GfxRenderer& renderer, Rect rect, int buttonCount, int selectedIndex,
const std::function<std::string(int index)>& buttonLabel, bool hasIcon,
const std::function<std::string(int index)>& rowIcon) override;
void drawRecentBookCover(GfxRenderer& renderer, Rect rect, const std::vector<RecentBookWithCover>& recentBooks,
const int selectorIndex, bool& coverRendered, bool& coverBufferStored, bool& bufferRestored,
std::function<bool()> storeCoverBuffer) override;
virtual Rect drawPopup(const GfxRenderer& renderer, const char* message) override;
};

View File

@ -20,10 +20,12 @@
#include "activities/browser/OpdsBookBrowserActivity.h" #include "activities/browser/OpdsBookBrowserActivity.h"
#include "activities/home/HomeActivity.h" #include "activities/home/HomeActivity.h"
#include "activities/home/MyLibraryActivity.h" #include "activities/home/MyLibraryActivity.h"
#include "activities/home/RecentBooksActivity.h"
#include "activities/network/CrossPointWebServerActivity.h" #include "activities/network/CrossPointWebServerActivity.h"
#include "activities/reader/ReaderActivity.h" #include "activities/reader/ReaderActivity.h"
#include "activities/settings/SettingsActivity.h" #include "activities/settings/SettingsActivity.h"
#include "activities/util/FullScreenMessageActivity.h" #include "activities/util/FullScreenMessageActivity.h"
#include "components/UITheme.h"
#include "fontIds.h" #include "fontIds.h"
HalDisplay display; HalDisplay display;
@ -203,13 +205,13 @@ void enterDeepSleep() {
} }
void onGoHome(); void onGoHome();
void onGoToMyLibraryWithTab(const std::string& path, MyLibraryActivity::Tab tab); void onGoToMyLibraryWithPath(const std::string& path);
void onGoToReader(const std::string& initialEpubPath, MyLibraryActivity::Tab fromTab) { void onGoToRecentBooks();
void onGoToReader(const std::string& initialEpubPath) {
exitActivity(); exitActivity();
enterNewActivity( enterNewActivity(
new ReaderActivity(renderer, mappedInputManager, initialEpubPath, fromTab, onGoHome, onGoToMyLibraryWithTab)); new ReaderActivity(renderer, mappedInputManager, initialEpubPath, onGoHome, onGoToMyLibraryWithPath));
} }
void onContinueReading() { onGoToReader(APP_STATE.openEpubPath, MyLibraryActivity::Tab::Recent); }
void onGoToFileTransfer() { void onGoToFileTransfer() {
exitActivity(); exitActivity();
@ -226,9 +228,14 @@ void onGoToMyLibrary() {
enterNewActivity(new MyLibraryActivity(renderer, mappedInputManager, onGoHome, onGoToReader)); enterNewActivity(new MyLibraryActivity(renderer, mappedInputManager, onGoHome, onGoToReader));
} }
void onGoToMyLibraryWithTab(const std::string& path, MyLibraryActivity::Tab tab) { void onGoToRecentBooks() {
exitActivity(); exitActivity();
enterNewActivity(new MyLibraryActivity(renderer, mappedInputManager, onGoHome, onGoToReader, tab, path)); enterNewActivity(new RecentBooksActivity(renderer, mappedInputManager, onGoHome, onGoToReader));
}
void onGoToMyLibraryWithPath(const std::string& path) {
exitActivity();
enterNewActivity(new MyLibraryActivity(renderer, mappedInputManager, onGoHome, onGoToReader, path));
} }
void onGoToBrowser() { void onGoToBrowser() {
@ -238,8 +245,8 @@ void onGoToBrowser() {
void onGoHome() { void onGoHome() {
exitActivity(); exitActivity();
enterNewActivity(new HomeActivity(renderer, mappedInputManager, onContinueReading, onGoToMyLibrary, onGoToSettings, enterNewActivity(new HomeActivity(renderer, mappedInputManager, onGoToReader, onGoToMyLibrary, onGoToRecentBooks,
onGoToFileTransfer, onGoToBrowser)); onGoToSettings, onGoToFileTransfer, onGoToBrowser));
} }
void setupDisplayAndFonts() { void setupDisplayAndFonts() {
@ -293,11 +300,24 @@ void setup() {
SETTINGS.loadFromFile(); SETTINGS.loadFromFile();
KOREADER_STORE.loadFromFile(); KOREADER_STORE.loadFromFile();
UITheme::initialize();
if (gpio.isWakeupByPowerButton()) { switch (gpio.getWakeupReason()) {
// For normal wakeups, verify power button press duration case HalGPIO::WakeupReason::PowerButton:
Serial.printf("[%lu] [ ] Verifying power button press duration\n", millis()); // For normal wakeups, verify power button press duration
verifyPowerButtonDuration(); Serial.printf("[%lu] [ ] Verifying power button press duration\n", millis());
verifyPowerButtonDuration();
break;
case HalGPIO::WakeupReason::AfterUSBPower:
// If USB power caused a cold boot, go back to sleep
Serial.printf("[%lu] [ ] Wakeup reason: After USB Power\n", millis());
gpio.startDeepSleep();
break;
case HalGPIO::WakeupReason::AfterFlash:
// After flashing, just proceed to boot
case HalGPIO::WakeupReason::Other:
default:
break;
} }
// First serial output only here to avoid timing inconsistencies for power button press duration verification // First serial output only here to avoid timing inconsistencies for power button press duration verification
@ -317,9 +337,8 @@ void setup() {
// Clear app state to avoid getting into a boot loop if the epub doesn't load // Clear app state to avoid getting into a boot loop if the epub doesn't load
const auto path = APP_STATE.openEpubPath; const auto path = APP_STATE.openEpubPath;
APP_STATE.openEpubPath = ""; APP_STATE.openEpubPath = "";
APP_STATE.lastSleepImage = 0;
APP_STATE.saveToFile(); APP_STATE.saveToFile();
onGoToReader(path, MyLibraryActivity::Tab::Recent); onGoToReader(path);
} }
// Ensure we're not still holding the power button before leaving setup // Ensure we're not still holding the power button before leaving setup