diff --git a/lib/GfxRenderer/GfxRenderer.cpp b/lib/GfxRenderer/GfxRenderer.cpp index 14024bc4a..67f901c6a 100644 --- a/lib/GfxRenderer/GfxRenderer.cpp +++ b/lib/GfxRenderer/GfxRenderer.cpp @@ -659,6 +659,80 @@ void GfxRenderer::displayBuffer(const HalDisplay::RefreshMode refreshMode) const display.displayBuffer(refreshMode, fadingFix); } +std::vector GfxRenderer::wrappedText( + const int fontId, + const char* text, + const int maxWidth, + const int maxLines, + const EpdFontFamily::Style style) const +{ + std::vector lines; + + if (!text || maxWidth <= 0 || maxLines <= 0) + return lines; + + std::string remaining = text; + std::string currentLine; + + while (!remaining.empty()) + { + if ((int)lines.size() == maxLines - 1) + { + std::string lastLine = truncatedText(fontId, remaining.c_str(), maxWidth, style); + lines.push_back(lastLine); + return lines; + } + + // Find next word + size_t spacePos = remaining.find(' '); + std::string word; + + if (spacePos == std::string::npos) + { + word = remaining; + remaining.clear(); + } + else + { + word = remaining.substr(0, spacePos); + remaining.erase(0, spacePos + 1); + } + + std::string testLine = currentLine.empty() + ? word + : currentLine + " " + word; + + if (getTextWidth(fontId, testLine.c_str(), style) <= maxWidth) + { + currentLine = testLine; + } + else + { + if (!currentLine.empty()) + { + lines.push_back(currentLine); + currentLine = word; + } + else + { + // If a single word longer than maxWidth, we will truncate it and return + // this is to avoid complicated splitting rules since they are different + // between languages. This results in an aesthetically pleasing end. + std::string truncated = truncatedText(fontId, word.c_str(), maxWidth, style); + lines.push_back(truncated); + return lines; + } + } + } + + if (!currentLine.empty() && (int)lines.size() < maxLines) + { + lines.push_back(currentLine); + } + + return lines; +} + std::string GfxRenderer::truncatedText(const int fontId, const char* text, const int maxWidth, const EpdFontFamily::Style style) const { if (!text || maxWidth <= 0) return ""; diff --git a/lib/GfxRenderer/GfxRenderer.h b/lib/GfxRenderer/GfxRenderer.h index 4540774ee..90016c485 100644 --- a/lib/GfxRenderer/GfxRenderer.h +++ b/lib/GfxRenderer/GfxRenderer.h @@ -110,6 +110,8 @@ class GfxRenderer { std::string truncatedText(int fontId, const char* text, int maxWidth, EpdFontFamily::Style style = EpdFontFamily::REGULAR) const; + std::vector wrappedText(int fontId, const char* text, int maxWidth, int maxLines, + EpdFontFamily::Style style = EpdFontFamily::REGULAR) const; // Helper for drawing rotated text (90 degrees clockwise, for side buttons) void drawTextRotated90CW(int fontId, int x, int y, const char* text, bool black = true, EpdFontFamily::Style style = EpdFontFamily::REGULAR) const; diff --git a/src/CrossPointSettings.h b/src/CrossPointSettings.h index 1348519f5..f18452afe 100644 --- a/src/CrossPointSettings.h +++ b/src/CrossPointSettings.h @@ -117,7 +117,7 @@ class CrossPointSettings { 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 }; + enum UI_THEME { CLASSIC = 0, LYRA = 1, LYRA_3_COVERS = 2 }; // Sleep screen settings uint8_t sleepScreen = DARK; diff --git a/src/SettingsList.h b/src/SettingsList.h index e493f40f3..228ca7977 100644 --- a/src/SettingsList.h +++ b/src/SettingsList.h @@ -26,7 +26,8 @@ inline std::vector getSettingsList() { "hideBatteryPercentage", "Display"), SettingInfo::Enum("Refresh Frequency", &CrossPointSettings::refreshFrequency, {"1 page", "5 pages", "10 pages", "15 pages", "30 pages"}, "refreshFrequency", "Display"), - SettingInfo::Enum("UI Theme", &CrossPointSettings::uiTheme, {"Classic", "Lyra"}, "uiTheme", "Display"), + SettingInfo::Enum("UI Theme", &CrossPointSettings::uiTheme, {"Classic", "Lyra", "Lyra Extended"}, "uiTheme", + "Display"), SettingInfo::Toggle("Sunlight Fading Fix", &CrossPointSettings::fadingFix, "fadingFix", "Display"), // --- Reader --- diff --git a/src/activities/home/MyLibraryActivity.cpp b/src/activities/home/MyLibraryActivity.cpp index 3d5dbf1a8..c2c97d941 100644 --- a/src/activities/home/MyLibraryActivity.cpp +++ b/src/activities/home/MyLibraryActivity.cpp @@ -191,7 +191,6 @@ void MyLibraryActivity::loop() { } int listSize = static_cast(files.size()); - buttonNavigator.onNextRelease([this, listSize] { selectorIndex = ButtonNavigator::nextIndex(static_cast(selectorIndex), listSize); updateRequired = true; diff --git a/src/activities/network/CalibreConnectActivity.cpp b/src/activities/network/CalibreConnectActivity.cpp index c7f636d66..92ede6610 100644 --- a/src/activities/network/CalibreConnectActivity.cpp +++ b/src/activities/network/CalibreConnectActivity.cpp @@ -201,75 +201,66 @@ void CalibreConnectActivity::displayTaskLoop() { } void CalibreConnectActivity::render() const { - if (state == CalibreConnectState::SERVER_RUNNING) { - renderer.clearScreen(); - renderServerRunning(); - renderer.displayBuffer(); + if (subActivity) { return; } - renderer.clearScreen(); + auto metrics = UITheme::getInstance().getMetrics(); + const auto pageWidth = renderer.getScreenWidth(); const auto pageHeight = renderer.getScreenHeight(); + + renderer.clearScreen(); + + GUI.drawHeader(renderer, Rect{0, metrics.topPadding, pageWidth, metrics.headerHeight}, "Connect to Calibre"); + const auto height = renderer.getLineHeight(UI_10_FONT_ID); + const auto top = (pageHeight - height) / 2; + if (state == CalibreConnectState::SERVER_STARTING) { - renderer.drawCenteredText(UI_12_FONT_ID, pageHeight / 2 - 20, "Starting Calibre...", true, EpdFontFamily::BOLD); + renderer.drawCenteredText(UI_12_FONT_ID, top, "Starting Calibre..."); } else if (state == CalibreConnectState::ERROR) { - renderer.drawCenteredText(UI_12_FONT_ID, pageHeight / 2 - 20, "Calibre setup failed", true, EpdFontFamily::BOLD); - } - renderer.displayBuffer(); -} - -void CalibreConnectActivity::renderServerRunning() const { - constexpr int LINE_SPACING = 24; - constexpr int SMALL_SPACING = 20; - constexpr int SECTION_SPACING = 40; - constexpr int TOP_PADDING = 14; - renderer.drawCenteredText(UI_12_FONT_ID, 15, "Connect to Calibre", true, EpdFontFamily::BOLD); - - int y = 55 + TOP_PADDING; - renderer.drawCenteredText(UI_10_FONT_ID, y, "Network", true, EpdFontFamily::BOLD); - y += LINE_SPACING; - std::string ssidInfo = "Network: " + connectedSSID; - if (ssidInfo.length() > 28) { - ssidInfo.replace(25, ssidInfo.length() - 25, "..."); - } - renderer.drawCenteredText(UI_10_FONT_ID, y, ssidInfo.c_str()); - renderer.drawCenteredText(UI_10_FONT_ID, y + LINE_SPACING, ("IP: " + connectedIP).c_str()); - - y += LINE_SPACING * 2 + SECTION_SPACING; - renderer.drawCenteredText(UI_10_FONT_ID, y, "Setup", true, EpdFontFamily::BOLD); - y += LINE_SPACING; - renderer.drawCenteredText(SMALL_FONT_ID, y, "1) Install CrossPoint Reader plugin"); - renderer.drawCenteredText(SMALL_FONT_ID, y + SMALL_SPACING, "2) Be on the same WiFi network"); - renderer.drawCenteredText(SMALL_FONT_ID, y + SMALL_SPACING * 2, "3) In Calibre: \"Send to device\""); - renderer.drawCenteredText(SMALL_FONT_ID, y + SMALL_SPACING * 3, "Keep this screen open while sending"); - - y += SMALL_SPACING * 3 + SECTION_SPACING; - renderer.drawCenteredText(UI_10_FONT_ID, y, "Status", true, EpdFontFamily::BOLD); - y += LINE_SPACING; - if (lastProgressTotal > 0 && lastProgressReceived <= lastProgressTotal) { - std::string label = "Receiving"; - if (!currentUploadName.empty()) { - label += ": " + currentUploadName; - if (label.length() > 34) { - label.replace(31, label.length() - 31, "..."); + renderer.drawCenteredText(UI_12_FONT_ID, top, "Calibre setup failed", true, EpdFontFamily::BOLD); + } else if (state == CalibreConnectState::SERVER_RUNNING) { + GUI.drawSubHeader(renderer, Rect{0, metrics.topPadding + metrics.headerHeight, pageWidth, metrics.tabBarHeight}, + connectedSSID.c_str(), ("IP: " + connectedIP).c_str()); + + int y = metrics.topPadding + metrics.headerHeight + metrics.tabBarHeight + metrics.verticalSpacing * 4; + const auto heightText12 = renderer.getTextHeight(UI_12_FONT_ID); + renderer.drawText(UI_12_FONT_ID, metrics.contentSidePadding, y, "Setup", true, EpdFontFamily::BOLD); + y += heightText12 + metrics.verticalSpacing * 2; + + renderer.drawText(SMALL_FONT_ID, metrics.contentSidePadding, y, "1) Install CrossPoint Reader plugin"); + renderer.drawText(SMALL_FONT_ID, metrics.contentSidePadding, y + height, "2) Be on the same WiFi network"); + renderer.drawText(SMALL_FONT_ID, metrics.contentSidePadding, y + height * 2, "3) In Calibre: \"Send to device\""); + renderer.drawText(SMALL_FONT_ID, metrics.contentSidePadding, y + height * 3, "Keep this screen open while sending"); + + y += height * 3 + metrics.verticalSpacing * 4; + renderer.drawText(UI_12_FONT_ID, metrics.contentSidePadding, y, "Status", true, EpdFontFamily::BOLD); + y += heightText12 + metrics.verticalSpacing * 2; + + if (lastProgressTotal > 0 && lastProgressReceived <= lastProgressTotal) { + std::string label = "Receiving"; + if (!currentUploadName.empty()) { + label += ": " + currentUploadName; + label = renderer.truncatedText(SMALL_FONT_ID, label.c_str(), pageWidth - metrics.contentSidePadding * 2, + EpdFontFamily::REGULAR); } + renderer.drawText(SMALL_FONT_ID, metrics.contentSidePadding, y, label.c_str()); + GUI.drawProgressBar(renderer, + Rect{metrics.contentSidePadding, y + height + metrics.verticalSpacing, + pageWidth - metrics.contentSidePadding * 2, metrics.progressBarHeight}, + lastProgressReceived, lastProgressTotal); + y += height + metrics.verticalSpacing * 2 + metrics.progressBarHeight; } - renderer.drawCenteredText(SMALL_FONT_ID, y, label.c_str()); - constexpr int barWidth = 300; - constexpr int barHeight = 16; - constexpr int barX = (480 - barWidth) / 2; - GUI.drawProgressBar(renderer, Rect{barX, y + 22, barWidth, barHeight}, lastProgressReceived, lastProgressTotal); - y += 40; - } - if (lastCompleteAt > 0 && (millis() - lastCompleteAt) < 6000) { - std::string msg = "Received: " + lastCompleteName; - if (msg.length() > 36) { - msg.replace(33, msg.length() - 33, "..."); + if (lastCompleteAt > 0 && (millis() - lastCompleteAt) < 6000) { + std::string msg = "Received: " + lastCompleteName; + msg = renderer.truncatedText(SMALL_FONT_ID, msg.c_str(), pageWidth - metrics.contentSidePadding * 2, + EpdFontFamily::REGULAR); + renderer.drawText(SMALL_FONT_ID, metrics.contentSidePadding, y, msg.c_str()); } - renderer.drawCenteredText(SMALL_FONT_ID, y, msg.c_str()); - } - const auto labels = mappedInput.mapLabels("« Exit", "", "", ""); - GUI.drawButtonHints(renderer, labels.btn1, labels.btn2, labels.btn3, labels.btn4); + const auto labels = mappedInput.mapLabels("« Exit", "", "", ""); + GUI.drawButtonHints(renderer, labels.btn1, labels.btn2, labels.btn3, labels.btn4); + } + renderer.displayBuffer(); } diff --git a/src/activities/network/CrossPointWebServerActivity.cpp b/src/activities/network/CrossPointWebServerActivity.cpp index 0338d8257..efb6bbe20 100644 --- a/src/activities/network/CrossPointWebServerActivity.cpp +++ b/src/activities/network/CrossPointWebServerActivity.cpp @@ -23,6 +23,8 @@ constexpr const char* AP_PASSWORD = nullptr; // Open network for ease of use constexpr const char* AP_HOSTNAME = "crosspoint"; constexpr uint8_t AP_CHANNEL = 1; constexpr uint8_t AP_MAX_CONNECTIONS = 4; +constexpr int QR_CODE_WIDTH = 6 * 33; +constexpr int QR_CODE_HEIGHT = 200; // DNS server for captive portal (redirects all DNS queries to our IP) DNSServer* dnsServer = nullptr; @@ -51,7 +53,7 @@ void CrossPointWebServerActivity::onEnter() { updateRequired = true; xTaskCreate(&CrossPointWebServerActivity::taskTrampoline, "WebServerActivityTask", - 2048, // Stack size + 4096, // Stack size this, // Parameters 1, // Priority &displayTaskHandle // Task handle @@ -381,14 +383,24 @@ void CrossPointWebServerActivity::displayTaskLoop() { void CrossPointWebServerActivity::render() const { // Only render our own UI when server is running // Subactivities handle their own rendering - if (state == WebServerActivityState::SERVER_RUNNING) { - renderer.clearScreen(); - renderServerRunning(); - renderer.displayBuffer(); - } else if (state == WebServerActivityState::AP_STARTING) { + if (state == WebServerActivityState::SERVER_RUNNING || state == WebServerActivityState::AP_STARTING) { renderer.clearScreen(); + auto metrics = UITheme::getInstance().getMetrics(); + const auto pageWidth = renderer.getScreenWidth(); const auto pageHeight = renderer.getScreenHeight(); - renderer.drawCenteredText(UI_12_FONT_ID, pageHeight / 2 - 20, "Starting Hotspot...", true, EpdFontFamily::BOLD); + + GUI.drawHeader(renderer, Rect{0, metrics.topPadding, pageWidth, metrics.headerHeight}, + isApMode ? "Hotspot Mode" : "Network Transfer", nullptr); + + if (state == WebServerActivityState::SERVER_RUNNING) { + GUI.drawSubHeader(renderer, Rect{0, metrics.topPadding + metrics.headerHeight, pageWidth, metrics.tabBarHeight}, + connectedSSID.c_str()); + renderServerRunning(); + } else { + const auto height = renderer.getLineHeight(UI_10_FONT_ID); + const auto top = (pageHeight - height) / 2; + renderer.drawCenteredText(UI_10_FONT_ID, top, "Starting Hotspot..."); + } renderer.displayBuffer(); } } @@ -416,67 +428,69 @@ void drawQRCode(const GfxRenderer& renderer, const int x, const int y, const std } void CrossPointWebServerActivity::renderServerRunning() const { - // Use consistent line spacing - constexpr int LINE_SPACING = 28; // Space between lines + auto metrics = UITheme::getInstance().getMetrics(); + const auto pageWidth = renderer.getScreenWidth(); - renderer.drawCenteredText(UI_12_FONT_ID, 15, "File Transfer", true, EpdFontFamily::BOLD); + GUI.drawHeader(renderer, Rect{0, metrics.topPadding, pageWidth, metrics.headerHeight}, + isApMode ? "Hotspot Mode" : "Network Transfer", nullptr); + GUI.drawSubHeader(renderer, Rect{0, metrics.topPadding + metrics.headerHeight, pageWidth, metrics.tabBarHeight}, + connectedSSID.c_str()); + int startY = metrics.topPadding + metrics.headerHeight + metrics.tabBarHeight + metrics.verticalSpacing * 2; if (isApMode) { - // AP mode display - center the content block - int startY = 55; - - renderer.drawCenteredText(UI_10_FONT_ID, startY, "Hotspot Mode", true, EpdFontFamily::BOLD); + // AP mode display + renderer.drawText(UI_12_FONT_ID, metrics.contentSidePadding, startY, "Scan QR code or connect to Wifi", true, + EpdFontFamily::BOLD); + startY += renderer.getLineHeight(UI_12_FONT_ID) + metrics.verticalSpacing * 2; - std::string ssidInfo = "Network: " + connectedSSID; - renderer.drawCenteredText(UI_10_FONT_ID, startY + LINE_SPACING, ssidInfo.c_str()); + // Show QR code for Wifi + const std::string wifiConfig = std::string("WIFI:S:") + connectedSSID + ";;"; + drawQRCode(renderer, metrics.contentSidePadding, startY, wifiConfig); - renderer.drawCenteredText(SMALL_FONT_ID, startY + LINE_SPACING * 2, "Connect your device to this WiFi network"); + // Show network name + renderer.drawText(UI_10_FONT_ID, metrics.contentSidePadding + QR_CODE_WIDTH + metrics.verticalSpacing, startY + 80, + connectedSSID.c_str()); + renderer.drawText(SMALL_FONT_ID, metrics.contentSidePadding + QR_CODE_WIDTH + metrics.verticalSpacing, startY + 100, + "network"); - renderer.drawCenteredText(SMALL_FONT_ID, startY + LINE_SPACING * 3, - "or scan QR code with your phone to connect to Wifi."); - // Show QR code for URL - const std::string wifiConfig = std::string("WIFI:S:") + connectedSSID + ";;"; - drawQRCode(renderer, (480 - 6 * 33) / 2, startY + LINE_SPACING * 4, wifiConfig); + startY += QR_CODE_HEIGHT + 2 * metrics.verticalSpacing; - startY += 6 * 29 + 3 * LINE_SPACING; // Show primary URL (hostname) - std::string hostnameUrl = std::string("http://") + AP_HOSTNAME + ".local/"; - renderer.drawCenteredText(UI_10_FONT_ID, startY + LINE_SPACING * 3, hostnameUrl.c_str(), true, EpdFontFamily::BOLD); + renderer.drawText(UI_12_FONT_ID, metrics.contentSidePadding, startY, "Scan QR code or open URL", true, + EpdFontFamily::BOLD); + startY += renderer.getLineHeight(UI_12_FONT_ID) + metrics.verticalSpacing * 2; - // Show IP address as fallback + std::string hostnameUrl = std::string("http://") + AP_HOSTNAME + ".local/"; std::string ipUrl = "or http://" + connectedIP + "/"; - renderer.drawCenteredText(SMALL_FONT_ID, startY + LINE_SPACING * 4, ipUrl.c_str()); - renderer.drawCenteredText(SMALL_FONT_ID, startY + LINE_SPACING * 5, "Open this URL in your browser"); // Show QR code for URL - renderer.drawCenteredText(SMALL_FONT_ID, startY + LINE_SPACING * 6, "or scan QR code with your phone:"); - drawQRCode(renderer, (480 - 6 * 33) / 2, startY + LINE_SPACING * 7, hostnameUrl); + drawQRCode(renderer, metrics.contentSidePadding, startY, hostnameUrl); + + // Show IP address as fallback + renderer.drawText(UI_10_FONT_ID, metrics.contentSidePadding + QR_CODE_WIDTH + metrics.verticalSpacing, startY + 80, + hostnameUrl.c_str()); + renderer.drawText(SMALL_FONT_ID, metrics.contentSidePadding + QR_CODE_WIDTH + metrics.verticalSpacing, startY + 100, + ipUrl.c_str()); } else { - // STA mode display (original behavior) - const int startY = 65; + startY += metrics.verticalSpacing * 2; - std::string ssidInfo = "Network: " + connectedSSID; - if (ssidInfo.length() > 28) { - ssidInfo.replace(25, ssidInfo.length() - 25, "..."); - } - renderer.drawCenteredText(UI_10_FONT_ID, startY, ssidInfo.c_str()); + // STA mode display (original behavior) + // std::string ipInfo = "IP Address: " + connectedIP; + renderer.drawCenteredText(UI_12_FONT_ID, startY, "Scan QR code or open URL", true, EpdFontFamily::BOLD); + startY += renderer.getLineHeight(UI_12_FONT_ID) + metrics.verticalSpacing * 2; - std::string ipInfo = "IP Address: " + connectedIP; - renderer.drawCenteredText(UI_10_FONT_ID, startY + LINE_SPACING, ipInfo.c_str()); + // Show QR code for URL + std::string webInfo = "http://" + connectedIP + "/"; + drawQRCode(renderer, (pageWidth - QR_CODE_WIDTH) / 2, startY, webInfo); + startY += QR_CODE_HEIGHT + metrics.verticalSpacing * 2; // Show web server URL prominently - std::string webInfo = "http://" + connectedIP + "/"; - renderer.drawCenteredText(UI_10_FONT_ID, startY + LINE_SPACING * 2, webInfo.c_str(), true, EpdFontFamily::BOLD); + renderer.drawCenteredText(UI_10_FONT_ID, startY, webInfo.c_str(), true); + startY += renderer.getLineHeight(UI_10_FONT_ID) + 5; // Also show hostname URL std::string hostnameUrl = std::string("or http://") + AP_HOSTNAME + ".local/"; - renderer.drawCenteredText(SMALL_FONT_ID, startY + LINE_SPACING * 3, hostnameUrl.c_str()); - - renderer.drawCenteredText(SMALL_FONT_ID, startY + LINE_SPACING * 4, "Open this URL in your browser"); - - // Show QR code for URL - drawQRCode(renderer, (480 - 6 * 33) / 2, startY + LINE_SPACING * 6, webInfo); - renderer.drawCenteredText(SMALL_FONT_ID, startY + LINE_SPACING * 5, "or scan QR code with your phone:"); + renderer.drawCenteredText(SMALL_FONT_ID, startY, hostnameUrl.c_str(), true); } const auto labels = mappedInput.mapLabels("« Exit", "", "", ""); diff --git a/src/activities/network/NetworkModeSelectionActivity.cpp b/src/activities/network/NetworkModeSelectionActivity.cpp index bee13d8c1..e320e44f4 100644 --- a/src/activities/network/NetworkModeSelectionActivity.cpp +++ b/src/activities/network/NetworkModeSelectionActivity.cpp @@ -33,7 +33,7 @@ void NetworkModeSelectionActivity::onEnter() { updateRequired = true; xTaskCreate(&NetworkModeSelectionActivity::taskTrampoline, "NetworkModeTask", - 2048, // Stack size + 4096, // Stack size this, // Parameters 1, // Priority &displayTaskHandle // Task handle @@ -97,38 +97,23 @@ void NetworkModeSelectionActivity::displayTaskLoop() { } void NetworkModeSelectionActivity::render() const { - renderer.clearScreen(); - + auto metrics = UITheme::getInstance().getMetrics(); const auto pageWidth = renderer.getScreenWidth(); const auto pageHeight = renderer.getScreenHeight(); - // Draw header - renderer.drawCenteredText(UI_12_FONT_ID, 15, "File Transfer", true, EpdFontFamily::BOLD); - - // Draw subtitle - renderer.drawCenteredText(UI_10_FONT_ID, 50, "How would you like to connect?"); - - // Draw menu items centered on screen - constexpr int itemHeight = 50; // Height for each menu item (including description) - const int startY = (pageHeight - (MENU_ITEM_COUNT * itemHeight)) / 2 + 10; - - for (int i = 0; i < MENU_ITEM_COUNT; i++) { - const int itemY = startY + i * itemHeight; - const bool isSelected = (i == selectedIndex); + renderer.clearScreen(); - // Draw selection highlight (black fill) for selected item - if (isSelected) { - renderer.fillRect(20, itemY - 2, pageWidth - 40, itemHeight - 6); - } + GUI.drawHeader(renderer, Rect{0, metrics.topPadding, pageWidth, metrics.headerHeight}, "File Transfer"); - // Draw text: black=false (white text) when selected (on black background) - // black=true (black text) when not selected (on white background) - renderer.drawText(UI_10_FONT_ID, 30, itemY, MENU_ITEMS[i], /*black=*/!isSelected); - renderer.drawText(SMALL_FONT_ID, 30, itemY + 22, MENU_DESCRIPTIONS[i], /*black=*/!isSelected); - } + const int contentTop = metrics.topPadding + metrics.headerHeight + metrics.verticalSpacing; + const int contentHeight = pageHeight - contentTop - metrics.buttonHintsHeight - metrics.verticalSpacing * 2; + GUI.drawList( + renderer, Rect{0, contentTop, pageWidth, contentHeight}, static_cast(MENU_ITEM_COUNT), selectedIndex, + [this](int index) { return MENU_ITEMS[index]; }, [this](int index) { return MENU_DESCRIPTIONS[index]; }, nullptr, + nullptr); // Draw help text at bottom - const auto labels = mappedInput.mapLabels("« Back", "Select", "", ""); + const auto labels = mappedInput.mapLabels("« Back", "Select", "Up", "Down"); GUI.drawButtonHints(renderer, labels.btn1, labels.btn2, labels.btn3, labels.btn4); renderer.displayBuffer(); diff --git a/src/activities/network/WifiSelectionActivity.cpp b/src/activities/network/WifiSelectionActivity.cpp index 83af4e07f..e2c3a0ee4 100644 --- a/src/activities/network/WifiSelectionActivity.cpp +++ b/src/activities/network/WifiSelectionActivity.cpp @@ -178,13 +178,12 @@ void WifiSelectionActivity::processWifiScanResults() { networks.push_back(pair.second); } - // Sort by signal strength (strongest first) - std::sort(networks.begin(), networks.end(), - [](const WifiNetworkInfo& a, const WifiNetworkInfo& b) { return a.rssi > b.rssi; }); - - // Show networks with PW first + // Sort: saved-password networks first, then by signal strength (strongest first) std::sort(networks.begin(), networks.end(), [](const WifiNetworkInfo& a, const WifiNetworkInfo& b) { - return a.hasSavedPassword && !b.hasSavedPassword; + if (a.hasSavedPassword != b.hasSavedPassword) { + return a.hasSavedPassword; + } + return a.rssi > b.rssi; }); WiFi.scanDelete(); @@ -490,15 +489,12 @@ std::string WifiSelectionActivity::getSignalStrengthIndicator(const int32_t rssi return "||||"; // Excellent } if (rssi >= -60) { - return "||| "; // Good + return " |||"; // Good } if (rssi >= -70) { - return "|| "; // Fair + return " ||"; // Fair } - if (rssi >= -80) { - return "| "; // Weak - } - return " "; // Very weak + return " |"; // Very weak } void WifiSelectionActivity::displayTaskLoop() { @@ -529,6 +525,17 @@ void WifiSelectionActivity::displayTaskLoop() { void WifiSelectionActivity::render() const { renderer.clearScreen(); + auto metrics = UITheme::getInstance().getMetrics(); + const auto pageWidth = renderer.getScreenWidth(); + const auto pageHeight = renderer.getScreenHeight(); + + // Draw header + char countStr[32]; + snprintf(countStr, sizeof(countStr), "%zu found", networks.size()); + GUI.drawHeader(renderer, Rect{0, metrics.topPadding, pageWidth, metrics.headerHeight}, "WiFi Networks", countStr); + GUI.drawSubHeader(renderer, Rect{0, metrics.topPadding + metrics.headerHeight, pageWidth, metrics.tabBarHeight}, + cachedMacAddress.c_str()); + switch (state) { case WifiSelectionState::AUTO_CONNECTING: renderConnecting(); @@ -560,12 +567,10 @@ void WifiSelectionActivity::render() const { } void WifiSelectionActivity::renderNetworkList() const { + auto metrics = UITheme::getInstance().getMetrics(); const auto pageWidth = renderer.getScreenWidth(); const auto pageHeight = renderer.getScreenHeight(); - // Draw header - renderer.drawCenteredText(UI_12_FONT_ID, 15, "WiFi Networks", true, EpdFontFamily::BOLD); - if (networks.empty()) { // No networks found or scan failed const auto height = renderer.getLineHeight(UI_10_FONT_ID); @@ -573,69 +578,21 @@ void WifiSelectionActivity::renderNetworkList() const { renderer.drawCenteredText(UI_10_FONT_ID, top, "No networks found"); renderer.drawCenteredText(SMALL_FONT_ID, top + height + 10, "Press Connect to scan again"); } else { - // Calculate how many networks we can display - constexpr int startY = 60; - constexpr int lineHeight = 25; - const int maxVisibleNetworks = (pageHeight - startY - 40) / lineHeight; - - // Calculate scroll offset to keep selected item visible - int scrollOffset = 0; - if (selectedNetworkIndex >= maxVisibleNetworks) { - scrollOffset = selectedNetworkIndex - maxVisibleNetworks + 1; - } - - // Draw networks - int displayIndex = 0; - for (size_t i = scrollOffset; i < networks.size() && displayIndex < maxVisibleNetworks; i++, displayIndex++) { - const int networkY = startY + displayIndex * lineHeight; - const auto& network = networks[i]; - - // Draw selection indicator - if (static_cast(i) == selectedNetworkIndex) { - renderer.drawText(UI_10_FONT_ID, 5, networkY, ">"); - } - - // Draw network name (truncate if too long) - std::string displayName = network.ssid; - if (displayName.length() > 33) { - displayName.replace(30, displayName.length() - 30, "..."); - } - renderer.drawText(UI_10_FONT_ID, 20, networkY, displayName.c_str()); - - // Draw signal strength indicator - std::string signalStr = getSignalStrengthIndicator(network.rssi); - renderer.drawText(UI_10_FONT_ID, pageWidth - 90, networkY, signalStr.c_str()); - - // Draw saved indicator (checkmark) for networks with saved passwords - if (network.hasSavedPassword) { - renderer.drawText(UI_10_FONT_ID, pageWidth - 50, networkY, "+"); - } - - // Draw lock icon for encrypted networks - if (network.isEncrypted) { - renderer.drawText(UI_10_FONT_ID, pageWidth - 30, networkY, "*"); - } - } - - // Draw scroll indicators if needed - if (scrollOffset > 0) { - renderer.drawText(SMALL_FONT_ID, pageWidth - 15, startY - 10, "^"); - } - if (scrollOffset + maxVisibleNetworks < static_cast(networks.size())) { - renderer.drawText(SMALL_FONT_ID, pageWidth - 15, startY + maxVisibleNetworks * lineHeight, "v"); - } - - // Show network count - char countStr[32]; - snprintf(countStr, sizeof(countStr), "%zu networks found", networks.size()); - renderer.drawText(SMALL_FONT_ID, 20, pageHeight - 90, countStr); - } - - // Show MAC address above the network count and legend - renderer.drawText(SMALL_FONT_ID, 20, pageHeight - 105, cachedMacAddress.c_str()); - - // Draw help text - renderer.drawText(SMALL_FONT_ID, 20, pageHeight - 75, "* = Encrypted | + = Saved"); + int contentTop = metrics.topPadding + metrics.headerHeight + metrics.tabBarHeight + metrics.verticalSpacing; + int contentHeight = pageHeight - contentTop - metrics.buttonHintsHeight - metrics.verticalSpacing * 2; + GUI.drawList( + renderer, Rect{0, contentTop, pageWidth, contentHeight}, static_cast(networks.size()), + selectedNetworkIndex, [this](int index) { return networks[index].ssid; }, nullptr, nullptr, + [this](int index) { + auto network = networks[index]; + return std::string(network.hasSavedPassword ? "+ " : "") + (network.isEncrypted ? "* " : "") + + getSignalStrengthIndicator(network.rssi); + }); + } + + GUI.drawHelpText(renderer, + Rect{0, pageHeight - metrics.buttonHintsHeight - metrics.contentSidePadding - 15, pageWidth, 20}, + "* = Encrypted ||| = Strength + = Saved"); const bool hasSavedPassword = !networks.empty() && networks[selectedNetworkIndex].hasSavedPassword; const char* forgetLabel = hasSavedPassword ? "Forget" : ""; diff --git a/src/activities/network/WifiSelectionActivity.h b/src/activities/network/WifiSelectionActivity.h index 32eb36dbd..9790e349a 100644 --- a/src/activities/network/WifiSelectionActivity.h +++ b/src/activities/network/WifiSelectionActivity.h @@ -50,7 +50,7 @@ class WifiSelectionActivity final : public ActivityWithSubactivity { ButtonNavigator buttonNavigator; bool updateRequired = false; WifiSelectionState state = WifiSelectionState::SCANNING; - int selectedNetworkIndex = 0; + size_t selectedNetworkIndex = 0; std::vector networks; const std::function onComplete; diff --git a/src/activities/settings/ButtonRemapActivity.cpp b/src/activities/settings/ButtonRemapActivity.cpp index 43184735a..fc9e24428 100644 --- a/src/activities/settings/ButtonRemapActivity.cpp +++ b/src/activities/settings/ButtonRemapActivity.cpp @@ -126,9 +126,6 @@ void ButtonRemapActivity::loop() { } void ButtonRemapActivity::render() { - renderer.clearScreen(); - - const auto pageWidth = renderer.getScreenWidth(); const auto labelForHardware = [&](uint8_t hardwareIndex) -> const char* { for (uint8_t i = 0; i < kRoleCount; i++) { if (tempMapping[i] == hardwareIndex) { @@ -138,35 +135,41 @@ void ButtonRemapActivity::render() { return "-"; }; - renderer.drawCenteredText(UI_12_FONT_ID, 15, "Remap Front Buttons", true, EpdFontFamily::BOLD); - renderer.drawCenteredText(UI_10_FONT_ID, 40, "Press a front button for each role"); - - for (uint8_t i = 0; i < kRoleCount; i++) { - const int y = 70 + i * 30; - const bool isSelected = (i == currentStep); - - // Highlight the role that is currently being assigned. - if (isSelected) { - renderer.fillRect(0, y - 2, pageWidth - 1, 30); - } + auto metrics = UITheme::getInstance().getMetrics(); + const auto pageWidth = renderer.getScreenWidth(); + const auto pageHeight = renderer.getScreenHeight(); - const char* roleName = getRoleName(i); - renderer.drawText(UI_10_FONT_ID, 20, y, roleName, !isSelected); + renderer.clearScreen(); - // Show currently assigned hardware button (or unassigned). - const char* assigned = (tempMapping[i] == kUnassigned) ? "Unassigned" : getHardwareName(tempMapping[i]); - const auto width = renderer.getTextWidth(UI_10_FONT_ID, assigned); - renderer.drawText(UI_10_FONT_ID, pageWidth - 20 - width, y, assigned, !isSelected); - } + GUI.drawHeader(renderer, Rect{0, metrics.topPadding, pageWidth, metrics.headerHeight}, "Remap Front Buttons"); + GUI.drawSubHeader(renderer, Rect{0, metrics.topPadding + metrics.headerHeight, pageWidth, metrics.tabBarHeight}, + "Press a front button for each role"); + + int topOffset = metrics.topPadding + metrics.headerHeight + metrics.tabBarHeight + metrics.verticalSpacing; + int contentHeight = pageHeight - topOffset - metrics.buttonHintsHeight - metrics.verticalSpacing; + GUI.drawList( + renderer, Rect{0, topOffset, pageWidth, contentHeight}, kRoleCount, currentStep, + [&](int index) { return getRoleName(static_cast(index)); }, nullptr, nullptr, + [&](int index) { + uint8_t assignedButton = tempMapping[static_cast(index)]; + return (assignedButton == kUnassigned) ? "Unassigned" : getHardwareName(assignedButton); + }, + true); // Temporary warning banner for duplicates. if (!errorMessage.empty()) { - renderer.drawCenteredText(UI_10_FONT_ID, 210, errorMessage.c_str(), true); + GUI.drawHelpText(renderer, + Rect{0, pageHeight - metrics.buttonHintsHeight - metrics.contentSidePadding - 15, pageWidth, 20}, + errorMessage.c_str()); } // Provide side button actions at the bottom of the screen (split across two lines). - renderer.drawCenteredText(SMALL_FONT_ID, 250, "Side button Up: Reset to default layout", true); - renderer.drawCenteredText(SMALL_FONT_ID, 280, "Side button Down: Cancel remapping", true); + GUI.drawHelpText(renderer, + Rect{0, topOffset + 4 * metrics.listRowHeight + 4 * metrics.verticalSpacing, pageWidth, 20}, + "Side button Up: Reset to default layout"); + GUI.drawHelpText(renderer, + Rect{0, topOffset + 4 * metrics.listRowHeight + 5 * metrics.verticalSpacing + 20, pageWidth, 20}, + "Side button Down: Cancel remapping"); // Live preview of logical labels under front buttons. // This mirrors the on-device front button order: Back, Confirm, Left, Right. diff --git a/src/activities/settings/CalibreSettingsActivity.cpp b/src/activities/settings/CalibreSettingsActivity.cpp index 7b7a0ed48..47fe418c2 100644 --- a/src/activities/settings/CalibreSettingsActivity.cpp +++ b/src/activities/settings/CalibreSettingsActivity.cpp @@ -150,41 +150,36 @@ void CalibreSettingsActivity::displayTaskLoop() { } void CalibreSettingsActivity::render() { - renderer.clearScreen(); - + auto metrics = UITheme::getInstance().getMetrics(); const auto pageWidth = renderer.getScreenWidth(); + const auto pageHeight = renderer.getScreenHeight(); - // Draw header - renderer.drawCenteredText(UI_12_FONT_ID, 15, "OPDS Browser", true, EpdFontFamily::BOLD); - - // Draw info text about Calibre - renderer.drawCenteredText(UI_10_FONT_ID, 40, "For Calibre, add /opds to your URL"); - - // Draw selection highlight - renderer.fillRect(0, 70 + selectedIndex * 30 - 2, pageWidth - 1, 30); - - // Draw menu items - for (int i = 0; i < MENU_ITEMS; i++) { - const int settingY = 70 + i * 30; - const bool isSelected = (i == selectedIndex); - - renderer.drawText(UI_10_FONT_ID, 20, settingY, menuNames[i], !isSelected); - - // Draw status for each setting - const char* status = "[Not Set]"; - if (i == 0) { - status = (strlen(SETTINGS.opdsServerUrl) > 0) ? "[Set]" : "[Not Set]"; - } else if (i == 1) { - status = (strlen(SETTINGS.opdsUsername) > 0) ? "[Set]" : "[Not Set]"; - } else if (i == 2) { - status = (strlen(SETTINGS.opdsPassword) > 0) ? "[Set]" : "[Not Set]"; - } - const auto width = renderer.getTextWidth(UI_10_FONT_ID, status); - renderer.drawText(UI_10_FONT_ID, pageWidth - 20 - width, settingY, status, !isSelected); - } + renderer.clearScreen(); - // Draw button hints - const auto labels = mappedInput.mapLabels("« Back", "Select", "", ""); + GUI.drawHeader(renderer, Rect{0, metrics.topPadding, pageWidth, metrics.headerHeight}, "OPDS Browser"); + GUI.drawSubHeader(renderer, Rect{0, metrics.topPadding + metrics.headerHeight, pageWidth, metrics.tabBarHeight}, + "For Calibre, add /opds to your URL"); + + const int contentTop = metrics.topPadding + metrics.headerHeight + metrics.verticalSpacing + metrics.tabBarHeight; + const int contentHeight = pageHeight - contentTop - metrics.buttonHintsHeight - metrics.verticalSpacing * 2; + GUI.drawList( + renderer, Rect{0, contentTop, pageWidth, contentHeight}, static_cast(MENU_ITEMS), + static_cast(selectedIndex), [this](int index) { return menuNames[index]; }, nullptr, nullptr, + [this](int index) { + // Draw status for each setting + if (index == 0) { + return (strlen(SETTINGS.opdsServerUrl) > 0) ? SETTINGS.opdsServerUrl : "[Not Set]"; + } else if (index == 1) { + return (strlen(SETTINGS.opdsUsername) > 0) ? SETTINGS.opdsUsername : "[Not Set]"; + } else if (index == 2) { + return (strlen(SETTINGS.opdsPassword) > 0) ? "******" : "[Not Set]"; + } + return "[Not Set]"; + }, + true); + + // Draw help text at bottom + const auto labels = mappedInput.mapLabels("« Back", "Select", "Up", "Down"); GUI.drawButtonHints(renderer, labels.btn1, labels.btn2, labels.btn3, labels.btn4); renderer.displayBuffer(); diff --git a/src/activities/settings/CalibreSettingsActivity.h b/src/activities/settings/CalibreSettingsActivity.h index 53de46bc6..88bc336d7 100644 --- a/src/activities/settings/CalibreSettingsActivity.h +++ b/src/activities/settings/CalibreSettingsActivity.h @@ -28,7 +28,7 @@ class CalibreSettingsActivity final : public ActivityWithSubactivity { ButtonNavigator buttonNavigator; bool updateRequired = false; - int selectedIndex = 0; + size_t selectedIndex = 0; const std::function onBack; static void taskTrampoline(void* param); diff --git a/src/activities/settings/ClearCacheActivity.cpp b/src/activities/settings/ClearCacheActivity.cpp index b17627dc6..a3b89c5e8 100644 --- a/src/activities/settings/ClearCacheActivity.cpp +++ b/src/activities/settings/ClearCacheActivity.cpp @@ -54,10 +54,13 @@ void ClearCacheActivity::displayTaskLoop() { } void ClearCacheActivity::render() { + auto metrics = UITheme::getInstance().getMetrics(); + const auto pageWidth = renderer.getScreenWidth(); const auto pageHeight = renderer.getScreenHeight(); renderer.clearScreen(); - renderer.drawCenteredText(UI_12_FONT_ID, 15, "Clear Cache", true, EpdFontFamily::BOLD); + + GUI.drawHeader(renderer, Rect{0, metrics.topPadding, pageWidth, metrics.headerHeight}, "Clear Cache"); if (state == WARNING) { renderer.drawCenteredText(UI_10_FONT_ID, pageHeight / 2 - 60, "This will clear all cached book data.", true); @@ -73,7 +76,7 @@ void ClearCacheActivity::render() { } if (state == CLEARING) { - renderer.drawCenteredText(UI_10_FONT_ID, pageHeight / 2, "Clearing cache...", true, EpdFontFamily::BOLD); + renderer.drawCenteredText(UI_10_FONT_ID, pageHeight / 2, "Clearing cache..."); renderer.displayBuffer(); return; } diff --git a/src/activities/settings/KOReaderAuthActivity.cpp b/src/activities/settings/KOReaderAuthActivity.cpp index 78d6ec84e..dd63485ed 100644 --- a/src/activities/settings/KOReaderAuthActivity.cpp +++ b/src/activities/settings/KOReaderAuthActivity.cpp @@ -123,34 +123,29 @@ void KOReaderAuthActivity::render() { return; } - renderer.clearScreen(); - renderer.drawCenteredText(UI_12_FONT_ID, 15, "KOReader Auth", true, EpdFontFamily::BOLD); + auto metrics = UITheme::getInstance().getMetrics(); + const auto pageWidth = renderer.getScreenWidth(); + const auto pageHeight = renderer.getScreenHeight(); - if (state == AUTHENTICATING) { - renderer.drawCenteredText(UI_10_FONT_ID, 300, statusMessage.c_str(), true, EpdFontFamily::BOLD); - renderer.displayBuffer(); - return; - } + renderer.clearScreen(); - if (state == SUCCESS) { - renderer.drawCenteredText(UI_10_FONT_ID, 280, "Success!", true, EpdFontFamily::BOLD); - renderer.drawCenteredText(UI_10_FONT_ID, 320, "KOReader sync is ready to use"); + GUI.drawHeader(renderer, Rect{0, metrics.topPadding, pageWidth, metrics.headerHeight}, "KOReader Sync"); + const auto height = renderer.getLineHeight(UI_10_FONT_ID); + const auto top = (pageHeight - height) / 2; - const auto labels = mappedInput.mapLabels("Done", "", "", ""); - GUI.drawButtonHints(renderer, labels.btn1, labels.btn2, labels.btn3, labels.btn4); - renderer.displayBuffer(); - return; + if (state == AUTHENTICATING) { + renderer.drawCenteredText(UI_10_FONT_ID, top, statusMessage.c_str()); + } else if (state == SUCCESS) { + renderer.drawCenteredText(UI_10_FONT_ID, top, "Success!", true, EpdFontFamily::BOLD); + renderer.drawCenteredText(UI_10_FONT_ID, top + height + 10, "KOReader sync is ready to use"); + } else if (state == FAILED) { + renderer.drawCenteredText(UI_10_FONT_ID, top, "Error", true, EpdFontFamily::BOLD); + renderer.drawCenteredText(UI_10_FONT_ID, top + height + 10, errorMessage.c_str()); } - if (state == FAILED) { - renderer.drawCenteredText(UI_10_FONT_ID, 280, "Authentication Failed", true, EpdFontFamily::BOLD); - renderer.drawCenteredText(UI_10_FONT_ID, 320, errorMessage.c_str()); - - const auto labels = mappedInput.mapLabels("Back", "", "", ""); - GUI.drawButtonHints(renderer, labels.btn1, labels.btn2, labels.btn3, labels.btn4); - renderer.displayBuffer(); - return; - } + const auto labels = mappedInput.mapLabels("Back", "", "", ""); + GUI.drawButtonHints(renderer, labels.btn1, labels.btn2, labels.btn3, labels.btn4); + renderer.displayBuffer(); } void KOReaderAuthActivity::loop() { diff --git a/src/activities/settings/KOReaderSettingsActivity.cpp b/src/activities/settings/KOReaderSettingsActivity.cpp index a72151d62..3edb935ec 100644 --- a/src/activities/settings/KOReaderSettingsActivity.cpp +++ b/src/activities/settings/KOReaderSettingsActivity.cpp @@ -172,43 +172,40 @@ void KOReaderSettingsActivity::displayTaskLoop() { } void KOReaderSettingsActivity::render() { - renderer.clearScreen(); - + auto metrics = UITheme::getInstance().getMetrics(); const auto pageWidth = renderer.getScreenWidth(); + const auto pageHeight = renderer.getScreenHeight(); - // Draw header - renderer.drawCenteredText(UI_12_FONT_ID, 15, "KOReader Sync", true, EpdFontFamily::BOLD); - - // Draw selection highlight - renderer.fillRect(0, 60 + selectedIndex * 30 - 2, pageWidth - 1, 30); - - // Draw menu items - for (int i = 0; i < MENU_ITEMS; i++) { - const int settingY = 60 + i * 30; - const bool isSelected = (i == selectedIndex); - - renderer.drawText(UI_10_FONT_ID, 20, settingY, menuNames[i], !isSelected); - - // Draw status for each item - const char* status = ""; - if (i == 0) { - status = KOREADER_STORE.getUsername().empty() ? "[Not Set]" : "[Set]"; - } else if (i == 1) { - status = KOREADER_STORE.getPassword().empty() ? "[Not Set]" : "[Set]"; - } else if (i == 2) { - status = KOREADER_STORE.getServerUrl().empty() ? "[Default]" : "[Custom]"; - } else if (i == 3) { - status = KOREADER_STORE.getMatchMethod() == DocumentMatchMethod::FILENAME ? "[Filename]" : "[Binary]"; - } else if (i == 4) { - status = KOREADER_STORE.hasCredentials() ? "" : "[Set credentials first]"; - } - - const auto width = renderer.getTextWidth(UI_10_FONT_ID, status); - renderer.drawText(UI_10_FONT_ID, pageWidth - 20 - width, settingY, status, !isSelected); - } + renderer.clearScreen(); - // Draw button hints - const auto labels = mappedInput.mapLabels("« Back", "Select", "", ""); + GUI.drawHeader(renderer, Rect{0, metrics.topPadding, pageWidth, metrics.headerHeight}, "KOReader Sync"); + + const int contentTop = metrics.topPadding + metrics.headerHeight + metrics.verticalSpacing; + const int contentHeight = pageHeight - contentTop - metrics.buttonHintsHeight - metrics.verticalSpacing * 2; + GUI.drawList( + renderer, Rect{0, contentTop, pageWidth, contentHeight}, static_cast(MENU_ITEMS), + static_cast(selectedIndex), [this](int index) { return menuNames[index]; }, nullptr, nullptr, + [this](int index) { + // Draw status for each setting + if (index == 0) { + auto username = KOREADER_STORE.getUsername(); + return username.empty() ? std::string("[Not Set]") : username; + } else if (index == 1) { + return std::string(KOREADER_STORE.getPassword().empty() ? "[Not Set]" : "******"); + } else if (index == 2) { + auto serverUrl = KOREADER_STORE.getServerUrl(); + return serverUrl.empty() ? std::string("Default") : serverUrl; + } else if (index == 3) { + return std::string(KOREADER_STORE.getMatchMethod() == DocumentMatchMethod::FILENAME ? "Filename" : "Binary"); + } else if (index == 4) { + return std::string(KOREADER_STORE.hasCredentials() ? "" : "[Set credentials first]"); + } + return std::string("[Not Set]"); + }, + true); + + // Draw help text at bottom + const auto labels = mappedInput.mapLabels("« Back", "Select", "Up", "Down"); GUI.drawButtonHints(renderer, labels.btn1, labels.btn2, labels.btn3, labels.btn4); renderer.displayBuffer(); diff --git a/src/activities/settings/KOReaderSettingsActivity.h b/src/activities/settings/KOReaderSettingsActivity.h index 24f2f820b..fc8cf1b37 100644 --- a/src/activities/settings/KOReaderSettingsActivity.h +++ b/src/activities/settings/KOReaderSettingsActivity.h @@ -28,7 +28,7 @@ class KOReaderSettingsActivity final : public ActivityWithSubactivity { ButtonNavigator buttonNavigator; bool updateRequired = false; - int selectedIndex = 0; + size_t selectedIndex = 0; const std::function onBack; static void taskTrampoline(void* param); diff --git a/src/activities/settings/OtaUpdateActivity.cpp b/src/activities/settings/OtaUpdateActivity.cpp index c6f16a788..c25aae4e0 100644 --- a/src/activities/settings/OtaUpdateActivity.cpp +++ b/src/activities/settings/OtaUpdateActivity.cpp @@ -61,7 +61,7 @@ void OtaUpdateActivity::onEnter() { renderingMutex = xSemaphoreCreateMutex(); xTaskCreate(&OtaUpdateActivity::taskTrampoline, "OtaUpdateActivityTask", - 2048, // Stack size + 4096, // Stack size this, // Parameters 1, // Priority &displayTaskHandle // Task handle @@ -114,6 +114,16 @@ void OtaUpdateActivity::render() { return; } + auto metrics = UITheme::getInstance().getMetrics(); + const auto pageWidth = renderer.getScreenWidth(); + const auto pageHeight = renderer.getScreenHeight(); + + renderer.clearScreen(); + + GUI.drawHeader(renderer, Rect{0, metrics.topPadding, pageWidth, metrics.headerHeight}, "Update"); + const auto height = renderer.getLineHeight(UI_10_FONT_ID); + const auto top = (pageHeight - height) / 2; + float updaterProgress = 0; if (state == UPDATE_IN_PROGRESS) { Serial.printf("[%lu] [OTA] Update progress: %d / %d\n", millis(), updater.getProcessedSize(), @@ -126,60 +136,44 @@ void OtaUpdateActivity::render() { lastUpdaterPercentage = static_cast(updaterProgress * 100); } - const auto pageWidth = renderer.getScreenWidth(); - - renderer.clearScreen(); - renderer.drawCenteredText(UI_12_FONT_ID, 15, "Update", true, EpdFontFamily::BOLD); - if (state == CHECKING_FOR_UPDATE) { - renderer.drawCenteredText(UI_10_FONT_ID, 300, "Checking for update...", true, EpdFontFamily::BOLD); - renderer.displayBuffer(); - return; - } - - if (state == WAITING_CONFIRMATION) { - renderer.drawCenteredText(UI_10_FONT_ID, 200, "New update available!", true, EpdFontFamily::BOLD); - renderer.drawText(UI_10_FONT_ID, 20, 250, "Current Version: " CROSSPOINT_VERSION); - renderer.drawText(UI_10_FONT_ID, 20, 270, ("New Version: " + updater.getLatestVersion()).c_str()); + renderer.drawCenteredText(UI_10_FONT_ID, top, "Checking for update..."); + } else if (state == WAITING_CONFIRMATION) { + renderer.drawCenteredText(UI_10_FONT_ID, top, "New update available!", true, EpdFontFamily::BOLD); + renderer.drawText(UI_10_FONT_ID, metrics.contentSidePadding, top + height + metrics.verticalSpacing, + "Current Version: " CROSSPOINT_VERSION); + renderer.drawText(UI_10_FONT_ID, metrics.contentSidePadding, top + height * 2 + metrics.verticalSpacing * 2, + ("New Version: " + updater.getLatestVersion()).c_str()); const auto labels = mappedInput.mapLabels("Cancel", "Update", "", ""); GUI.drawButtonHints(renderer, labels.btn1, labels.btn2, labels.btn3, labels.btn4); - renderer.displayBuffer(); - return; - } + } else if (state == UPDATE_IN_PROGRESS) { + renderer.drawCenteredText(UI_10_FONT_ID, top, "Updating..."); - if (state == UPDATE_IN_PROGRESS) { - renderer.drawCenteredText(UI_10_FONT_ID, 310, "Updating...", true, EpdFontFamily::BOLD); - renderer.drawRect(20, 350, pageWidth - 40, 50); - renderer.fillRect(24, 354, static_cast(updaterProgress * static_cast(pageWidth - 44)), 42); - renderer.drawCenteredText(UI_10_FONT_ID, 420, + int y = top + height + metrics.verticalSpacing; + GUI.drawProgressBar( + renderer, + Rect{metrics.contentSidePadding, y, pageWidth - metrics.contentSidePadding * 2, metrics.progressBarHeight}, + static_cast(updaterProgress * 100), 100); + + y += metrics.progressBarHeight + metrics.verticalSpacing; + renderer.drawCenteredText(UI_10_FONT_ID, y, (std::to_string(static_cast(updaterProgress * 100)) + "%").c_str()); + y += height + metrics.verticalSpacing; renderer.drawCenteredText( - UI_10_FONT_ID, 440, + UI_10_FONT_ID, y, (std::to_string(updater.getProcessedSize()) + " / " + std::to_string(updater.getTotalSize())).c_str()); - renderer.displayBuffer(); - return; - } - - if (state == NO_UPDATE) { - renderer.drawCenteredText(UI_10_FONT_ID, 300, "No update available", true, EpdFontFamily::BOLD); - renderer.displayBuffer(); - return; + } else if (state == NO_UPDATE) { + renderer.drawCenteredText(UI_10_FONT_ID, top, "No update available", true, EpdFontFamily::BOLD); + } else if (state == FAILED) { + renderer.drawCenteredText(UI_10_FONT_ID, top, "Update failed", true, EpdFontFamily::BOLD); + } else if (state == FINISHED) { + renderer.drawCenteredText(UI_10_FONT_ID, top, "Update complete", true, EpdFontFamily::BOLD); + renderer.drawCenteredText(UI_10_FONT_ID, top + height + metrics.verticalSpacing, + "Press and hold power button to turn back on"); } - if (state == FAILED) { - renderer.drawCenteredText(UI_10_FONT_ID, 300, "Update failed", true, EpdFontFamily::BOLD); - renderer.displayBuffer(); - return; - } - - if (state == FINISHED) { - renderer.drawCenteredText(UI_10_FONT_ID, 300, "Update complete", true, EpdFontFamily::BOLD); - renderer.drawCenteredText(UI_10_FONT_ID, 350, "Press and hold power button to turn back on"); - renderer.displayBuffer(); - state = SHUTTING_DOWN; - return; - } + renderer.displayBuffer(); } void OtaUpdateActivity::loop() { diff --git a/src/activities/settings/SettingsActivity.cpp b/src/activities/settings/SettingsActivity.cpp index 7d3a60169..e7e6238cf 100644 --- a/src/activities/settings/SettingsActivity.cpp +++ b/src/activities/settings/SettingsActivity.cpp @@ -248,7 +248,8 @@ void SettingsActivity::render() const { auto metrics = UITheme::getInstance().getMetrics(); - GUI.drawHeader(renderer, Rect{0, metrics.topPadding, pageWidth, metrics.headerHeight}, "Settings"); + GUI.drawHeader(renderer, Rect{0, metrics.topPadding, pageWidth, metrics.headerHeight}, "Settings", + CROSSPOINT_VERSION); std::vector tabs; tabs.reserve(categoryCount); @@ -278,12 +279,8 @@ void SettingsActivity::render() const { valueText = std::to_string(SETTINGS.*(settings[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); + }, + true); // Draw help text const auto labels = mappedInput.mapLabels("« Back", "Toggle", "Up", "Down"); diff --git a/src/components/UITheme.cpp b/src/components/UITheme.cpp index 9dbe429e3..358282a39 100644 --- a/src/components/UITheme.cpp +++ b/src/components/UITheme.cpp @@ -4,9 +4,14 @@ #include +#include "MappedInputManager.h" #include "RecentBooksStore.h" #include "components/themes/BaseTheme.h" +#include "components/themes/lyra/Lyra3CoversTheme.h" #include "components/themes/lyra/LyraTheme.h" +namespace { +constexpr int SKIP_PAGE_MS = 700; +} // namespace UITheme UITheme::instance; @@ -32,6 +37,11 @@ void UITheme::setTheme(CrossPointSettings::UI_THEME type) { currentTheme = new LyraTheme(); currentMetrics = &LyraMetrics::values; break; + case CrossPointSettings::UI_THEME::LYRA_3_COVERS: + Serial.printf("[%lu] [UI] Using Lyra 3 Covers theme\n", millis()); + currentTheme = new Lyra3CoversTheme(); + currentMetrics = &Lyra3CoversMetrics::values; + break; } } diff --git a/src/components/UITheme.h b/src/components/UITheme.h index 0a30223ba..a7fd89ba3 100644 --- a/src/components/UITheme.h +++ b/src/components/UITheme.h @@ -6,6 +6,8 @@ #include "CrossPointSettings.h" #include "components/themes/BaseTheme.h" +class MappedInputManager; + class UITheme { // Static instance static UITheme instance; diff --git a/src/components/themes/BaseTheme.cpp b/src/components/themes/BaseTheme.cpp index 14783e558..bd8253fd6 100644 --- a/src/components/themes/BaseTheme.cpp +++ b/src/components/themes/BaseTheme.cpp @@ -17,6 +17,7 @@ namespace { constexpr int batteryPercentSpacing = 4; constexpr int homeMenuMargin = 20; constexpr int homeMarginTop = 30; +constexpr int subtitleY = 738; } // namespace void BaseTheme::drawBattery(const GfxRenderer& renderer, Rect rect, const bool showPercentage) const { @@ -63,6 +64,7 @@ void BaseTheme::drawProgressBar(const GfxRenderer& renderer, Rect rect, const si // Use 64-bit arithmetic to avoid overflow for large files const int percent = static_cast((static_cast(current) * 100) / total); + Serial.printf("Drawing progress bar: current=%u, total=%u, percent=%d\n", current, total, percent); // Draw outline renderer.drawRect(rect.x, rect.y, rect.width, rect.height); @@ -161,7 +163,7 @@ void BaseTheme::drawList(const GfxRenderer& renderer, Rect rect, int itemCount, const std::function& rowTitle, const std::function& rowSubtitle, const std::function& rowIcon, - const std::function& rowValue) const { + const std::function& rowValue, bool highlightValue) const { int rowHeight = (rowSubtitle != nullptr) ? BaseMetrics::values.listWithSubtitleRowHeight : BaseMetrics::values.listRowHeight; int pageItems = rect.height / rowHeight; @@ -227,7 +229,7 @@ void BaseTheme::drawList(const GfxRenderer& renderer, Rect rect, int itemCount, } } -void BaseTheme::drawHeader(const GfxRenderer& renderer, Rect rect, const char* title) const { +void BaseTheme::drawHeader(const GfxRenderer& renderer, Rect rect, const char* title, const char* subtitle) const { const bool showBatteryPercentage = SETTINGS.hideBatteryPercentage != CrossPointSettings::HIDE_BATTERY_PERCENTAGE::HIDE_ALWAYS; int batteryX = rect.x + rect.width - BaseMetrics::values.contentSidePadding - BaseMetrics::values.batteryWidth; @@ -246,6 +248,36 @@ void BaseTheme::drawHeader(const GfxRenderer& renderer, Rect rect, const char* t EpdFontFamily::BOLD); renderer.drawCenteredText(UI_12_FONT_ID, rect.y + 5, truncatedTitle.c_str(), true, EpdFontFamily::BOLD); } + + if (subtitle) { + auto truncatedSubtitle = renderer.truncatedText( + SMALL_FONT_ID, subtitle, rect.width - BaseMetrics::values.contentSidePadding * 2, EpdFontFamily::REGULAR); + int truncatedSubtitleWidth = renderer.getTextWidth(SMALL_FONT_ID, truncatedSubtitle.c_str()); + renderer.drawText(SMALL_FONT_ID, + rect.x + rect.width - BaseMetrics::values.contentSidePadding - truncatedSubtitleWidth, subtitleY, + truncatedSubtitle.c_str(), true); + } +} + +void BaseTheme::drawSubHeader(const GfxRenderer& renderer, Rect rect, const char* label, const char* rightLabel) const { + constexpr int underlineHeight = 2; // Height of selection underline + constexpr int underlineGap = 4; // Gap between text and underline + constexpr int maxListValueWidth = 200; + + int currentX = rect.x + BaseMetrics::values.contentSidePadding; + int rightSpace = BaseMetrics::values.contentSidePadding; + if (rightLabel) { + auto truncatedRightLabel = + renderer.truncatedText(SMALL_FONT_ID, rightLabel, maxListValueWidth, EpdFontFamily::REGULAR); + int rightLabelWidth = renderer.getTextWidth(SMALL_FONT_ID, truncatedRightLabel.c_str()); + renderer.drawText(SMALL_FONT_ID, rect.x + rect.width - BaseMetrics::values.contentSidePadding - rightLabelWidth, + rect.y + 7, truncatedRightLabel.c_str()); + rightSpace += rightLabelWidth + 10; + } + + auto truncatedLabel = renderer.truncatedText( + UI_12_FONT_ID, label, rect.width - BaseMetrics::values.contentSidePadding - rightSpace, EpdFontFamily::REGULAR); + renderer.drawText(UI_12_FONT_ID, currentX, rect.y, truncatedLabel.c_str(), true, EpdFontFamily::REGULAR); } void BaseTheme::drawTabBar(const GfxRenderer& renderer, const Rect rect, const std::vector& tabs, @@ -646,3 +678,10 @@ void BaseTheme::drawReadingProgressBar(const GfxRenderer& renderer, const size_t const int barWidth = progressBarMaxWidth * bookProgress / 100; renderer.fillRect(vieweableMarginLeft, progressBarY, barWidth, BaseMetrics::values.bookProgressBarHeight, true); } + +void BaseTheme::drawHelpText(const GfxRenderer& renderer, Rect rect, const char* label) const { + auto metrics = UITheme::getInstance().getMetrics(); + auto truncatedLabel = + renderer.truncatedText(SMALL_FONT_ID, label, rect.width - metrics.contentSidePadding * 2, EpdFontFamily::REGULAR); + renderer.drawCenteredText(SMALL_FONT_ID, rect.y, truncatedLabel.c_str()); +} \ No newline at end of file diff --git a/src/components/themes/BaseTheme.h b/src/components/themes/BaseTheme.h index 038a2913d..53f630e40 100644 --- a/src/components/themes/BaseTheme.h +++ b/src/components/themes/BaseTheme.h @@ -51,9 +51,7 @@ struct ThemeMetrics { int buttonHintsHeight; int sideButtonHintsWidth; - int versionTextRightX; - int versionTextY; - + int progressBarHeight; int bookProgressBarHeight; }; @@ -82,8 +80,7 @@ constexpr ThemeMetrics values = {.batteryWidth = 15, .homeRecentBooksCount = 1, .buttonHintsHeight = 40, .sideButtonHintsWidth = 30, - .versionTextRightX = 20, - .versionTextY = 738, + .progressBarHeight = 16, .bookProgressBarHeight = 4}; } @@ -99,11 +96,14 @@ class BaseTheme { virtual void drawSideButtonHints(const GfxRenderer& renderer, const char* topBtn, const char* bottomBtn) const; virtual void drawList(const GfxRenderer& renderer, Rect rect, int itemCount, int selectedIndex, const std::function& rowTitle, - const std::function& rowSubtitle, - const std::function& rowIcon, - const std::function& rowValue) const; - - virtual void drawHeader(const GfxRenderer& renderer, Rect rect, const char* title) const; + const std::function& rowSubtitle = nullptr, + const std::function& rowIcon = nullptr, + const std::function& rowValue = nullptr, + bool highlightValue = false) const; + virtual void drawHeader(const GfxRenderer& renderer, Rect rect, const char* title, + const char* subtitle = nullptr) const; + virtual void drawSubHeader(const GfxRenderer& renderer, Rect rect, const char* label, + const char* rightLabel = nullptr) const; virtual void drawTabBar(const GfxRenderer& renderer, Rect rect, const std::vector& tabs, bool selected) const; virtual void drawRecentBookCover(GfxRenderer& renderer, Rect rect, const std::vector& recentBooks, @@ -115,4 +115,5 @@ class BaseTheme { virtual Rect drawPopup(const GfxRenderer& renderer, const char* message) const; virtual void fillPopupProgress(const GfxRenderer& renderer, const Rect& layout, const int progress) const; virtual void drawReadingProgressBar(const GfxRenderer& renderer, const size_t bookProgress) const; + virtual void drawHelpText(const GfxRenderer& renderer, Rect rect, const char* label) const; }; \ No newline at end of file diff --git a/src/components/themes/lyra/Lyra3CoversTheme.cpp b/src/components/themes/lyra/Lyra3CoversTheme.cpp new file mode 100644 index 000000000..32d890056 --- /dev/null +++ b/src/components/themes/lyra/Lyra3CoversTheme.cpp @@ -0,0 +1,102 @@ +#include "Lyra3CoversTheme.h" + +#include +#include + +#include +#include + +#include "Battery.h" +#include "RecentBooksStore.h" +#include "components/UITheme.h" +#include "fontIds.h" +#include "util/StringUtils.h" + +// Internal constants +namespace { +constexpr int hPaddingInSelection = 8; +constexpr int cornerRadius = 6; +int coverWidth = 0; +} // namespace + +void Lyra3CoversTheme::drawRecentBookCover(GfxRenderer& renderer, Rect rect, const std::vector& recentBooks, + const int selectorIndex, bool& coverRendered, bool& coverBufferStored, + bool& bufferRestored, std::function storeCoverBuffer) const { + const int tileWidth = (rect.width - 2 * Lyra3CoversMetrics::values.contentSidePadding) / 3; + const int tileHeight = rect.height; + const int bookTitleHeight = tileHeight - Lyra3CoversMetrics::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(recentBooks.size()), Lyra3CoversMetrics::values.homeRecentBooksCount); i++) { + std::string coverPath = recentBooks[i].coverBmpPath; + bool hasCover = true; + int tileX = Lyra3CoversMetrics::values.contentSidePadding + tileWidth * i; + if (coverPath.empty()) { + hasCover = false; + } else { + const std::string coverBmpPath = + UITheme::getCoverThumbPath(coverPath, Lyra3CoversMetrics::values.homeCoverHeight); + + // First time: load cover from SD and render + FsFile file; + if (Storage.openFileForRead("HOME", coverBmpPath, file)) { + Bitmap bitmap(file); + if (bitmap.parseHeaders() == BmpReaderError::Ok) { + float coverHeight = static_cast(bitmap.getHeight()); + float coverWidth = static_cast(bitmap.getWidth()); + float ratio = coverWidth / coverHeight; + const float tileRatio = static_cast(tileWidth - 2 * hPaddingInSelection) / + static_cast(Lyra3CoversMetrics::values.homeCoverHeight); + float cropX = 1.0f - (tileRatio / ratio); + + renderer.drawBitmap(bitmap, tileX + hPaddingInSelection, tileY + hPaddingInSelection, + tileWidth - 2 * hPaddingInSelection, Lyra3CoversMetrics::values.homeCoverHeight, + cropX); + } else { + hasCover = false; + } + file.close(); + } + } + + if (!hasCover) { + renderer.drawRect(tileX + hPaddingInSelection, tileY + hPaddingInSelection, + tileWidth - 2 * hPaddingInSelection, Lyra3CoversMetrics::values.homeCoverHeight); + } + } + + coverBufferStored = storeCoverBuffer(); + coverRendered = true; + } + + for (int i = 0; i < std::min(static_cast(recentBooks.size()), Lyra3CoversMetrics::values.homeRecentBooksCount); + i++) { + bool bookSelected = (selectorIndex == i); + + int tileX = Lyra3CoversMetrics::values.contentSidePadding + tileWidth * i; + auto title = + renderer.truncatedText(UI_10_FONT_ID, recentBooks[i].title.c_str(), tileWidth - 2 * hPaddingInSelection); + + if (bookSelected) { + // Draw selection box + renderer.fillRoundedRect(tileX, tileY, tileWidth, hPaddingInSelection, cornerRadius, true, true, false, false, + Color::LightGray); + renderer.fillRectDither(tileX, tileY + hPaddingInSelection, hPaddingInSelection, + Lyra3CoversMetrics::values.homeCoverHeight, Color::LightGray); + renderer.fillRectDither(tileX + tileWidth - hPaddingInSelection, tileY + hPaddingInSelection, + hPaddingInSelection, Lyra3CoversMetrics::values.homeCoverHeight, Color::LightGray); + renderer.fillRoundedRect(tileX, tileY + Lyra3CoversMetrics::values.homeCoverHeight + hPaddingInSelection, + tileWidth, bookTitleHeight, cornerRadius, false, false, true, true, Color::LightGray); + } + renderer.drawText(UI_10_FONT_ID, tileX + hPaddingInSelection, + tileY + tileHeight - bookTitleHeight + hPaddingInSelection + 5, title.c_str(), true); + } + } +} \ No newline at end of file diff --git a/src/components/themes/lyra/Lyra3CoversTheme.h b/src/components/themes/lyra/Lyra3CoversTheme.h new file mode 100644 index 000000000..c02b43206 --- /dev/null +++ b/src/components/themes/lyra/Lyra3CoversTheme.h @@ -0,0 +1,41 @@ + + +#pragma once + +#include "components/themes/lyra/LyraTheme.h" + +class GfxRenderer; + +// Lyra theme metrics (zero runtime cost) +namespace Lyra3CoversMetrics { +constexpr ThemeMetrics values = {.batteryWidth = 16, + .batteryHeight = 12, + .topPadding = 5, + .batteryBarHeight = 40, + .headerHeight = 84, + .verticalSpacing = 16, + .contentSidePadding = 20, + .listRowHeight = 40, + .listWithSubtitleRowHeight = 60, + .menuRowHeight = 64, + .menuSpacing = 8, + .tabSpacing = 8, + .tabBarHeight = 40, + .scrollBarWidth = 4, + .scrollBarRightOffset = 5, + .homeTopPadding = 56, + .homeCoverHeight = 226, + .homeCoverTileHeight = 287, + .homeRecentBooksCount = 3, + .buttonHintsHeight = 40, + .sideButtonHintsWidth = 30, + .progressBarHeight = 16, + .bookProgressBarHeight = 4}; +} + +class Lyra3CoversTheme : public LyraTheme { + public: + void drawRecentBookCover(GfxRenderer& renderer, Rect rect, const std::vector& recentBooks, + const int selectorIndex, bool& coverRendered, bool& coverBufferStored, bool& bufferRestored, + std::function storeCoverBuffer) const override; +}; diff --git a/src/components/themes/lyra/LyraTheme.cpp b/src/components/themes/lyra/LyraTheme.cpp index 2e3ad4cd8..5fb9079a1 100644 --- a/src/components/themes/lyra/LyraTheme.cpp +++ b/src/components/themes/lyra/LyraTheme.cpp @@ -18,6 +18,9 @@ constexpr int batteryPercentSpacing = 4; constexpr int hPaddingInSelection = 8; constexpr int cornerRadius = 6; constexpr int topHintButtonY = 345; +constexpr int maxSubtitleWidth = 100; +constexpr int maxListValueWidth = 200; +int coverWidth = 0; } // namespace void LyraTheme::drawBattery(const GfxRenderer& renderer, Rect rect, const bool showPercentage) const { @@ -57,7 +60,7 @@ void LyraTheme::drawBattery(const GfxRenderer& renderer, Rect rect, const bool s } } -void LyraTheme::drawHeader(const GfxRenderer& renderer, Rect rect, const char* title) const { +void LyraTheme::drawHeader(const GfxRenderer& renderer, Rect rect, const char* title, const char* subtitle) const { renderer.fillRect(rect.x, rect.y, rect.width, rect.height, false); const bool showBatteryPercentage = @@ -72,14 +75,43 @@ void LyraTheme::drawHeader(const GfxRenderer& renderer, Rect rect, const char* t Rect{batteryX, rect.y + 10, LyraMetrics::values.batteryWidth, LyraMetrics::values.batteryHeight}, showBatteryPercentage); + int maxTitleWidth = + rect.width - LyraMetrics::values.contentSidePadding * 2 - (subtitle != nullptr ? maxSubtitleWidth : 0); + if (title) { - auto truncatedTitle = renderer.truncatedText( - UI_12_FONT_ID, title, rect.width - LyraMetrics::values.contentSidePadding * 2, EpdFontFamily::BOLD); + auto truncatedTitle = renderer.truncatedText(UI_12_FONT_ID, title, maxTitleWidth, EpdFontFamily::BOLD); renderer.drawText(UI_12_FONT_ID, rect.x + LyraMetrics::values.contentSidePadding, rect.y + LyraMetrics::values.batteryBarHeight + 3, truncatedTitle.c_str(), true, EpdFontFamily::BOLD); renderer.drawLine(rect.x, rect.y + rect.height - 3, rect.x + rect.width, rect.y + rect.height - 3, 3, true); } + + if (subtitle) { + auto truncatedSubtitle = renderer.truncatedText(SMALL_FONT_ID, subtitle, maxSubtitleWidth, EpdFontFamily::REGULAR); + int truncatedSubtitleWidth = renderer.getTextWidth(SMALL_FONT_ID, truncatedSubtitle.c_str()); + renderer.drawText(SMALL_FONT_ID, + rect.x + rect.width - LyraMetrics::values.contentSidePadding - truncatedSubtitleWidth, + rect.y + 50, truncatedSubtitle.c_str(), true); + } +} + +void LyraTheme::drawSubHeader(const GfxRenderer& renderer, Rect rect, const char* label, const char* rightLabel) const { + int currentX = rect.x + LyraMetrics::values.contentSidePadding; + int rightSpace = LyraMetrics::values.contentSidePadding; + if (rightLabel) { + auto truncatedRightLabel = + renderer.truncatedText(SMALL_FONT_ID, rightLabel, maxListValueWidth, EpdFontFamily::REGULAR); + int rightLabelWidth = renderer.getTextWidth(SMALL_FONT_ID, truncatedRightLabel.c_str()); + renderer.drawText(SMALL_FONT_ID, rect.x + rect.width - LyraMetrics::values.contentSidePadding - rightLabelWidth, + rect.y + 7, truncatedRightLabel.c_str()); + rightSpace += rightLabelWidth + hPaddingInSelection; + } + + auto truncatedLabel = renderer.truncatedText( + UI_10_FONT_ID, label, rect.width - LyraMetrics::values.contentSidePadding - rightSpace, EpdFontFamily::REGULAR); + renderer.drawText(UI_10_FONT_ID, currentX, rect.y + 6, truncatedLabel.c_str(), true, EpdFontFamily::REGULAR); + + renderer.drawLine(rect.x, rect.y + rect.height - 1, rect.x + rect.width, rect.y + rect.height - 1, true); } void LyraTheme::drawTabBar(const GfxRenderer& renderer, Rect rect, const std::vector& tabs, @@ -118,7 +150,7 @@ void LyraTheme::drawList(const GfxRenderer& renderer, Rect rect, int itemCount, const std::function& rowTitle, const std::function& rowSubtitle, const std::function& rowIcon, - const std::function& rowValue) const { + const std::function& rowValue, bool highlightValue) const { int rowHeight = (rowSubtitle != nullptr) ? LyraMetrics::values.listWithSubtitleRowHeight : LyraMetrics::values.listRowHeight; int pageItems = rect.height / rowHeight; @@ -153,8 +185,14 @@ void LyraTheme::drawList(const GfxRenderer& renderer, Rect rect, int itemCount, const int itemY = rect.y + (i % pageItems) * rowHeight; // Draw name - int textWidth = contentWidth - LyraMetrics::values.contentSidePadding * 2 - hPaddingInSelection * 2 - - (rowValue != nullptr ? 60 : 0); // TODO truncate according to value width? + int valueWidth = 0; + std::string valueText = ""; + if (rowValue != nullptr) { + valueText = rowValue(i); + valueText = renderer.truncatedText(UI_10_FONT_ID, valueText.c_str(), maxListValueWidth); + valueWidth = renderer.getTextWidth(UI_10_FONT_ID, valueText.c_str()) + hPaddingInSelection; + } + int textWidth = contentWidth - LyraMetrics::values.contentSidePadding * 2 - hPaddingInSelection * 2 - valueWidth; auto itemName = rowTitle(i); auto item = renderer.truncatedText(UI_10_FONT_ID, itemName.c_str(), textWidth); renderer.drawText(UI_10_FONT_ID, rect.x + LyraMetrics::values.contentSidePadding + hPaddingInSelection * 2, @@ -168,22 +206,16 @@ void LyraTheme::drawList(const GfxRenderer& renderer, Rect rect, int itemCount, itemY + 30, subtitle.c_str(), true); } - if (rowValue != nullptr) { - // Draw value - std::string valueText = rowValue(i); - if (!valueText.empty()) { - const auto valueTextWidth = renderer.getTextWidth(UI_10_FONT_ID, valueText.c_str()); - - if (i == selectedIndex) { - renderer.fillRoundedRect( - contentWidth - LyraMetrics::values.contentSidePadding - hPaddingInSelection * 2 - valueTextWidth, itemY, - valueTextWidth + hPaddingInSelection * 2, rowHeight, cornerRadius, Color::Black); - } - - renderer.drawText(UI_10_FONT_ID, - contentWidth - LyraMetrics::values.contentSidePadding - hPaddingInSelection - valueTextWidth, - itemY + 6, valueText.c_str(), i != selectedIndex); + // Draw value + if (!valueText.empty()) { + if (i == selectedIndex && highlightValue) { + renderer.fillRoundedRect( + contentWidth - LyraMetrics::values.contentSidePadding - hPaddingInSelection - valueWidth, itemY, + valueWidth + hPaddingInSelection, rowHeight, cornerRadius, Color::Black); } + + renderer.drawText(UI_10_FONT_ID, contentWidth - LyraMetrics::values.contentSidePadding - valueWidth, itemY + 6, + valueText.c_str(), !(i == selectedIndex && highlightValue)); } } } @@ -260,79 +292,90 @@ void LyraTheme::drawSideButtonHints(const GfxRenderer& renderer, const char* top void LyraTheme::drawRecentBookCover(GfxRenderer& renderer, Rect rect, const std::vector& recentBooks, const int selectorIndex, bool& coverRendered, bool& coverBufferStored, bool& bufferRestored, std::function storeCoverBuffer) const { - const int tileWidth = (rect.width - 2 * LyraMetrics::values.contentSidePadding) / 3; + const int tileWidth = rect.width - 2 * LyraMetrics::values.contentSidePadding; const int tileHeight = rect.height; - const int bookTitleHeight = tileHeight - LyraMetrics::values.homeCoverHeight - hPaddingInSelection; const int tileY = rect.y; + const int titleLines = 3; const bool hasContinueReading = !recentBooks.empty(); + if (coverWidth == 0) { + coverWidth = LyraMetrics::values.homeCoverHeight * 0.6; + } // 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) { + RecentBook book = recentBooks[0]; if (!coverRendered) { - for (int i = 0; i < std::min(static_cast(recentBooks.size()), LyraMetrics::values.homeRecentBooksCount); - i++) { - std::string coverPath = recentBooks[i].coverBmpPath; - bool hasCover = true; - int tileX = LyraMetrics::values.contentSidePadding + tileWidth * i; - if (coverPath.empty()) { - hasCover = false; - } else { - const std::string coverBmpPath = UITheme::getCoverThumbPath(coverPath, LyraMetrics::values.homeCoverHeight); - - // First time: load cover from SD and render - FsFile file; - if (Storage.openFileForRead("HOME", coverBmpPath, file)) { - Bitmap bitmap(file); - if (bitmap.parseHeaders() == BmpReaderError::Ok) { - float coverHeight = static_cast(bitmap.getHeight()); - float coverWidth = static_cast(bitmap.getWidth()); - float ratio = coverWidth / coverHeight; - const float tileRatio = static_cast(tileWidth - 2 * hPaddingInSelection) / - static_cast(LyraMetrics::values.homeCoverHeight); - float cropX = 1.0f - (tileRatio / ratio); - - renderer.drawBitmap(bitmap, tileX + hPaddingInSelection, tileY + hPaddingInSelection, - tileWidth - 2 * hPaddingInSelection, LyraMetrics::values.homeCoverHeight, cropX); - } else { - hasCover = false; - } - file.close(); + std::string coverPath = book.coverBmpPath; + bool hasCover = true; + int tileX = LyraMetrics::values.contentSidePadding; + if (coverPath.empty()) { + hasCover = false; + } else { + const std::string coverBmpPath = UITheme::getCoverThumbPath(coverPath, LyraMetrics::values.homeCoverHeight); + + // First time: load cover from SD and render + FsFile file; + if (Storage.openFileForRead("HOME", coverBmpPath, file)) { + Bitmap bitmap(file); + if (bitmap.parseHeaders() == BmpReaderError::Ok) { + coverWidth = bitmap.getWidth(); + renderer.drawBitmap(bitmap, tileX + hPaddingInSelection, tileY + hPaddingInSelection, coverWidth, + LyraMetrics::values.homeCoverHeight); + } else { + hasCover = false; } + file.close(); } + } - if (!hasCover) { - renderer.drawRect(tileX + hPaddingInSelection, tileY + hPaddingInSelection, - tileWidth - 2 * hPaddingInSelection, LyraMetrics::values.homeCoverHeight); - } + if (!hasCover) { + renderer.drawRect(tileX + hPaddingInSelection, tileY + hPaddingInSelection, coverWidth, + LyraMetrics::values.homeCoverHeight); } + } - coverBufferStored = storeCoverBuffer(); - coverRendered = true; + coverBufferStored = storeCoverBuffer(); + coverRendered = true; + + bool bookSelected = (selectorIndex == 0); + + int tileX = LyraMetrics::values.contentSidePadding; + int textWidth = tileWidth - 2 * hPaddingInSelection - LyraMetrics::values.verticalSpacing - coverWidth; + + if (bookSelected) { + // Draw selection box + renderer.fillRoundedRect(tileX, tileY, tileWidth, hPaddingInSelection, cornerRadius, true, true, false, false, + Color::LightGray); + renderer.fillRectDither(tileX, tileY + hPaddingInSelection, hPaddingInSelection, + LyraMetrics::values.homeCoverHeight, Color::LightGray); + renderer.fillRectDither(tileX + hPaddingInSelection + coverWidth, tileY + hPaddingInSelection, + tileWidth - hPaddingInSelection - coverWidth, LyraMetrics::values.homeCoverHeight, + Color::LightGray); + renderer.fillRoundedRect(tileX, tileY + LyraMetrics::values.homeCoverHeight + hPaddingInSelection, tileWidth, + hPaddingInSelection, cornerRadius, false, false, true, true, Color::LightGray); } - for (int i = 0; i < std::min(static_cast(recentBooks.size()), LyraMetrics::values.homeRecentBooksCount); i++) { - bool bookSelected = (selectorIndex == i); - - int tileX = LyraMetrics::values.contentSidePadding + tileWidth * i; - auto title = - renderer.truncatedText(UI_10_FONT_ID, recentBooks[i].title.c_str(), tileWidth - 2 * hPaddingInSelection); - - if (bookSelected) { - // Draw selection box - renderer.fillRoundedRect(tileX, tileY, tileWidth, hPaddingInSelection, cornerRadius, true, true, false, false, - Color::LightGray); - renderer.fillRectDither(tileX, tileY + hPaddingInSelection, hPaddingInSelection, - LyraMetrics::values.homeCoverHeight, Color::LightGray); - renderer.fillRectDither(tileX + tileWidth - hPaddingInSelection, tileY + hPaddingInSelection, - hPaddingInSelection, LyraMetrics::values.homeCoverHeight, Color::LightGray); - renderer.fillRoundedRect(tileX, tileY + LyraMetrics::values.homeCoverHeight + hPaddingInSelection, tileWidth, - bookTitleHeight, cornerRadius, false, false, true, true, Color::LightGray); - } - renderer.drawText(UI_10_FONT_ID, tileX + hPaddingInSelection, - tileY + tileHeight - bookTitleHeight + hPaddingInSelection + 5, title.c_str(), true); + //auto title = renderer.truncatedText(UI_12_FONT_ID, book.title.c_str(), textWidth, EpdFontFamily::BOLD); + auto title = renderer.wrappedText(UI_12_FONT_ID, book.title.c_str(), textWidth, titleLines, EpdFontFamily::BOLD); + auto author = renderer.truncatedText(UI_10_FONT_ID, book.author.c_str(), textWidth); + auto bookTitleHeight = renderer.getTextHeight(UI_12_FONT_ID); + auto totalTitleTextHeight = title.size() * bookTitleHeight; + int startY = tileY + tileHeight / 2; + + for (size_t i = 0; i < title.size(); ++i) + { + int y = startY - (title.size() - i) * bookTitleHeight; // move upward + renderer.drawText(UI_12_FONT_ID, + tileX + hPaddingInSelection + coverWidth + LyraMetrics::values.verticalSpacing, + y, + title[i].c_str(), + true, + EpdFontFamily::BOLD); } + renderer.drawText(UI_10_FONT_ID, tileX + hPaddingInSelection + coverWidth + LyraMetrics::values.verticalSpacing, + tileY + tileHeight / 2 + 5, author.c_str(), true); } } @@ -340,11 +383,10 @@ void LyraTheme::drawButtonMenu(GfxRenderer& renderer, Rect rect, int buttonCount const std::function& buttonLabel, const std::function& rowIcon) const { for (int i = 0; i < buttonCount; ++i) { - int tileWidth = (rect.width - LyraMetrics::values.contentSidePadding * 2 - LyraMetrics::values.menuSpacing) / 2; - Rect tileRect = - Rect{rect.x + LyraMetrics::values.contentSidePadding + (LyraMetrics::values.menuSpacing + tileWidth) * (i % 2), - rect.y + static_cast(i / 2) * (LyraMetrics::values.menuRowHeight + LyraMetrics::values.menuSpacing), - tileWidth, LyraMetrics::values.menuRowHeight}; + int tileWidth = rect.width - LyraMetrics::values.contentSidePadding * 2; + Rect tileRect = Rect{rect.x + LyraMetrics::values.contentSidePadding, + rect.y + i * (LyraMetrics::values.menuRowHeight + LyraMetrics::values.menuSpacing), tileWidth, + LyraMetrics::values.menuRowHeight}; const bool selected = selectedIndex == i; diff --git a/src/components/themes/lyra/LyraTheme.h b/src/components/themes/lyra/LyraTheme.h index 93ec05792..c20a42cdb 100644 --- a/src/components/themes/lyra/LyraTheme.h +++ b/src/components/themes/lyra/LyraTheme.h @@ -23,28 +23,28 @@ constexpr ThemeMetrics values = {.batteryWidth = 16, .scrollBarRightOffset = 5, .homeTopPadding = 56, .homeCoverHeight = 226, - .homeCoverTileHeight = 287, - .homeRecentBooksCount = 3, + .homeCoverTileHeight = 242, + .homeRecentBooksCount = 1, .buttonHintsHeight = 40, .sideButtonHintsWidth = 30, - .versionTextRightX = 20, - .versionTextY = 55, + .progressBarHeight = 16, .bookProgressBarHeight = 4}; } class LyraTheme : public BaseTheme { public: // Component drawing methods - // void drawProgressBar(const GfxRenderer& renderer, Rect rect, size_t current, size_t total) override; void drawBattery(const GfxRenderer& renderer, Rect rect, bool showPercentage = true) const override; - void drawHeader(const GfxRenderer& renderer, Rect rect, const char* title) const override; + void drawHeader(const GfxRenderer& renderer, Rect rect, const char* title, const char* subtitle) const override; + void drawSubHeader(const GfxRenderer& renderer, Rect rect, const char* label, + const char* rightLabel = nullptr) const override; void drawTabBar(const GfxRenderer& renderer, Rect rect, const std::vector& tabs, bool selected) const override; void drawList(const GfxRenderer& renderer, Rect rect, int itemCount, int selectedIndex, const std::function& rowTitle, const std::function& rowSubtitle, const std::function& rowIcon, - const std::function& rowValue) const override; + const std::function& rowValue, bool highlightValue) const override; void drawButtonHints(GfxRenderer& renderer, const char* btn1, const char* btn2, const char* btn3, const char* btn4) const override; void drawSideButtonHints(const GfxRenderer& renderer, const char* topBtn, const char* bottomBtn) const override;