feat: UI themes, Lyra (#528)

## Summary

### What is the goal of this PR?

- Visual UI overhaul
- UI theme selection

### What changes are included?

- Added a setting "UI Theme": Classic, Lyra
- The classic theme is the current Crosspoint theme
- The Lyra theme implements these mockups:
https://www.figma.com/design/UhxoV4DgUnfrDQgMPPTXog/Lyra-Theme?node-id=2003-7596&t=4CSOZqf0n9uQMxDt-0
by Discord users yagofarias, ruby and gan_shu
- New functions in GFXRenderer to render rounded rectangles, greyscale
fills (using dithering) and thick lines
- Basic UI components are factored into BaseTheme methods which can be
overridden by each additional theme. Methods that are not overridden
will fallback to BaseTheme behavior. This means any new
features/components in CrossPoint only need to be developed for the
"Classic" BaseTheme.
- Additional themes can easily be developed by the community using this
foundation

![IMG_7649
Medium](https://github.com/user-attachments/assets/b516f5a9-2636-4565-acff-91a25b93b39b)
![IMG_7746
Medium](https://github.com/user-attachments/assets/def41810-ab6e-4952-b40f-b9ce7d62bea8)
![IMG_7651
Medium](https://github.com/user-attachments/assets/518a9a6d-107a-4be3-9533-43a2b64b944b)



## Additional Context

- Only the Home, Library and main Settings screens have been implemented
so far, this will be extended to the transfer screens and chapter
selection screen later on, but we need to get the ball rolling somehow
:)
- Loading extra covers on the home screen in the Lyra theme takes a
little more time (about 2 seconds), I added a loading bar popup (reusing
the Indexing progress bar from the reader view, factored into a neat UI
component) but the popup adds ~400ms to the loading time.
- ~~Home screen thumbnails will need to be generated separately for each
theme, because they are displayed in different sizes. Because we're
using dithering, displaying a thumb with the wrong size causes the
picture to look janky or dark as it does on the screenshots above. No
worries this will be fixed in a future PR.~~ Thumbs are now generated
with a size parameter
- UI Icons will need to be implemented in a future PR.

---

### AI Usage

While CrossPoint doesn't have restrictions on AI tools in contributing,
please be transparent about their usage as it
helps set the right context for reviewers.

Did you use AI tools to help write this code? _**PARTIALLY**_
This is not a vibe coded PR. Copilot was used for autocompletion to save
time but I reviewed, understood and edited all generated code.

---------

Co-authored-by: Dave Allie <dave@daveallie.com>
This commit is contained in:
CaptainFrito
2026-02-05 17:50:11 +07:00
committed by GitHub
parent 2cf799f45b
commit bf87a7dc60
51 changed files with 2307 additions and 1397 deletions

View File

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

View File

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

View File

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

View File

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

View File

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

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;
};