From 3abcd0d05d185b8cfdc17bf6d0a57d5103a32e38 Mon Sep 17 00:00:00 2001 From: Dave Allie Date: Tue, 30 Dec 2025 22:18:10 +1000 Subject: [PATCH] Redesign home screen (#166) ## Summary * Redesigned home screen with big option to continue reading and slightly nicer options to navigate to core sections * Attempt to use the cached EPUB details (title, author) if they exist, otherwise fall back to file name * Adjusted button hints on home screen, removed Back option and changed left/right to up/down ## Additional Context * Core of this work comes from @ChandhokTannay in https://github.com/ChandhokTannay/crosspoint-reader/commit/1d36a86ef1f016e796ef993c14fb1f9ea2f0101d --- lib/Epub/Epub.cpp | 7 +- lib/Epub/Epub.h | 2 +- src/activities/home/HomeActivity.cpp | 233 +++++++++++++++++++++++---- src/activities/home/HomeActivity.h | 2 + 4 files changed, 208 insertions(+), 36 deletions(-) diff --git a/lib/Epub/Epub.cpp b/lib/Epub/Epub.cpp index 29a89243..ec009832 100644 --- a/lib/Epub/Epub.cpp +++ b/lib/Epub/Epub.cpp @@ -139,7 +139,7 @@ bool Epub::parseTocNcxFile() const { } // load in the meta data for the epub file -bool Epub::load() { +bool Epub::load(const bool buildIfMissing) { Serial.printf("[%lu] [EBP] Loading ePub: %s\n", millis(), filepath.c_str()); // Initialize spine/TOC cache @@ -151,6 +151,11 @@ bool Epub::load() { return true; } + // If we didn't load from cache above and we aren't allowed to build, fail now + if (!buildIfMissing) { + return false; + } + // Cache doesn't exist or is invalid, build it Serial.printf("[%lu] [EBP] Cache not found, building spine/TOC cache\n", millis()); setupCacheDir(); diff --git a/lib/Epub/Epub.h b/lib/Epub/Epub.h index 4bd9c46f..1b82462d 100644 --- a/lib/Epub/Epub.h +++ b/lib/Epub/Epub.h @@ -34,7 +34,7 @@ class Epub { } ~Epub() = default; std::string& getBasePath() { return contentBasePath; } - bool load(); + bool load(bool buildIfMissing = true); bool clearCache() const; void setupCacheDir() const; const std::string& getCachePath() const; diff --git a/src/activities/home/HomeActivity.cpp b/src/activities/home/HomeActivity.cpp index 294adb8a..02dc2395 100644 --- a/src/activities/home/HomeActivity.cpp +++ b/src/activities/home/HomeActivity.cpp @@ -1,5 +1,6 @@ #include "HomeActivity.h" +#include #include #include @@ -22,6 +23,33 @@ void HomeActivity::onEnter() { // Check if we have a book to continue reading hasContinueReading = !APP_STATE.openEpubPath.empty() && SdMan.exists(APP_STATE.openEpubPath.c_str()); + 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); + } + + const std::string ext4 = lastBookTitle.length() >= 4 ? lastBookTitle.substr(lastBookTitle.length() - 4) : ""; + const std::string ext5 = lastBookTitle.length() >= 5 ? lastBookTitle.substr(lastBookTitle.length() - 5) : ""; + // If epub, try to load the metadata for title/author + if (ext5 == ".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()); + } + } else if (ext5 == ".xtch") { + lastBookTitle.resize(lastBookTitle.length() - 5); + } else if (ext4 == ".xtc") { + lastBookTitle.resize(lastBookTitle.length() - 4); + } + } + selectorIndex = 0; // Trigger first update @@ -103,51 +131,188 @@ void HomeActivity::render() const { renderer.clearScreen(); const auto pageWidth = renderer.getScreenWidth(); - renderer.drawCenteredText(UI_12_FONT_ID, 15, "CrossPoint Reader", true, BOLD); + const auto pageHeight = renderer.getScreenHeight(); - // Draw selection - renderer.fillRect(0, 60 + selectorIndex * 30 - 2, pageWidth - 1, 30); + constexpr int margin = 20; + constexpr int bottomMargin = 60; - int menuY = 60; - int menuIndex = 0; + // --- 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; - if (hasContinueReading) { - // Extract filename from path for display - std::string bookName = APP_STATE.openEpubPath; - const size_t lastSlash = bookName.find_last_of('/'); - if (lastSlash != std::string::npos) { - bookName = bookName.substr(lastSlash + 1); - } - // Remove .epub extension - if (bookName.length() > 5 && bookName.substr(bookName.length() - 5) == ".epub") { - bookName.resize(bookName.length() - 5); + // Draw book card regardless, fill with message based on `hasContinueReading` + { + if (bookSelected) { + renderer.fillRect(bookX, bookY, bookWidth, bookHeight); + } else { + renderer.drawRect(bookX, bookY, bookWidth, bookHeight); } - // Truncate if too long - std::string continueLabel = "Continue: " + bookName; - int itemWidth = renderer.getTextWidth(UI_10_FONT_ID, continueLabel.c_str()); - while (itemWidth > renderer.getScreenWidth() - 40 && continueLabel.length() > 8) { - continueLabel.replace(continueLabel.length() - 5, 5, "..."); - itemWidth = renderer.getTextWidth(UI_10_FONT_ID, continueLabel.c_str()); - Serial.printf("[%lu] [HOM] width: %lu, pageWidth: %lu\n", millis(), itemWidth, pageWidth); - } + // Bookmark icon in the top-right corner of the card + const int bookmarkWidth = bookWidth / 8; + const int bookmarkHeight = bookHeight / 5; + const int bookmarkX = bookX + bookWidth - bookmarkWidth - 8; + constexpr int bookmarkY = bookY + 1; - renderer.drawText(UI_10_FONT_ID, 20, menuY, continueLabel.c_str(), selectorIndex != menuIndex); - menuY += 30; - menuIndex++; + // Main bookmark body (solid) + renderer.fillRect(bookmarkX, bookmarkY, bookmarkWidth, bookmarkHeight, !bookSelected); + + // Carve out an inverted triangle notch at the bottom center to create angled points + const int notchHeight = bookmarkHeight / 2; // depth of the notch + for (int i = 0; i < notchHeight; ++i) { + const int y = bookmarkY + bookmarkHeight - 1 - i; + const int xStart = bookmarkX + i; + const int width = bookmarkWidth - 2 * i; + if (width <= 0) { + break; + } + // Draw a horizontal strip in the opposite color to "cut" the notch + renderer.fillRect(xStart, y, width, 1, bookSelected); + } } - renderer.drawText(UI_10_FONT_ID, 20, menuY, "Browse", selectorIndex != menuIndex); - menuY += 30; - menuIndex++; + if (hasContinueReading) { + // Split into words (avoid stringstream to keep this light on the MCU) + std::vector 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)); + } - renderer.drawText(UI_10_FONT_ID, 20, menuY, "File transfer", selectorIndex != menuIndex); - menuY += 30; - menuIndex++; + std::vector 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); - renderer.drawText(UI_10_FONT_ID, 20, menuY, "Settings", selectorIndex != menuIndex); + 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("..."); - const auto labels = mappedInput.mapLabels("Back", "Confirm", "Left", "Right"); + while (!lines.back().empty() && renderer.getTextWidth(UI_12_FONT_ID, lines.back().c_str()) > maxLineWidth) { + lines.back().resize(lines.back().size() - 5); + lines.back().append("..."); + } + break; + } + + int wordWidth = renderer.getTextWidth(UI_12_FONT_ID, i.c_str()); + while (wordWidth > maxLineWidth && i.size() > 5) { + // Word itself is too long, trim it + i.resize(i.size() - 5); + i.append("..."); + wordWidth = renderer.getTextWidth(UI_12_FONT_ID, i.c_str()); + } + + 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(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; + + 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 + while (renderer.getTextWidth(UI_10_FONT_ID, trimmedAuthor.c_str()) > maxLineWidth && !trimmedAuthor.empty()) { + trimmedAuthor.resize(trimmedAuthor.size() - 5); + trimmedAuthor.append("..."); + } + renderer.drawCenteredText(UI_10_FONT_ID, titleYStart, trimmedAuthor.c_str(), !bookSelected); + } + + renderer.drawCenteredText(UI_10_FONT_ID, bookY + bookHeight - renderer.getLineHeight(UI_10_FONT_ID) * 3 / 2, + "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 (indices 1-3) --- + const int menuTileWidth = pageWidth - 2 * margin; + constexpr int menuTileHeight = 50; + constexpr int menuSpacing = 10; + constexpr int totalMenuHeight = 3 * menuTileHeight + 2 * menuSpacing; + + int menuStartY = bookY + bookHeight + 20; + // Ensure we don't collide with the bottom button legend + const int maxMenuStartY = pageHeight - bottomMargin - totalMenuHeight - margin; + if (menuStartY > maxMenuStartY) { + menuStartY = maxMenuStartY; + } + + for (int i = 0; i < 3; ++i) { + constexpr const char* items[3] = {"Browse files", "File transfer", "Settings"}; + const int overallIndex = i + (getMenuItemCount() - 3); + constexpr int tileX = margin; + const int tileY = menuStartY + 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 = items[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("", "Confirm", "Up", "Down"); renderer.drawButtonHints(UI_10_FONT_ID, labels.btn1, labels.btn2, labels.btn3, labels.btn4); renderer.displayBuffer(); diff --git a/src/activities/home/HomeActivity.h b/src/activities/home/HomeActivity.h index fcbab549..b6c9767d 100644 --- a/src/activities/home/HomeActivity.h +++ b/src/activities/home/HomeActivity.h @@ -13,6 +13,8 @@ class HomeActivity final : public Activity { int selectorIndex = 0; bool updateRequired = false; bool hasContinueReading = false; + std::string lastBookTitle; + std::string lastBookAuthor; const std::function onContinueReading; const std::function onReaderOpen; const std::function onSettingsOpen;