#include "BaseTheme.h" #include #include #include #include #include #include #include #include #include "CrossPointSettings.h" #include "I18n.h" #include "RecentBooksStore.h" #include "components/UITheme.h" #include "fontIds.h" // Internal constants namespace { constexpr int batteryPercentSpacing = 4; constexpr int homeMenuMargin = 20; constexpr int homeMarginTop = 30; constexpr int subtitleY = 738; // Helper: draw battery icon at given position void drawBatteryIcon(const GfxRenderer& renderer, int x, int y, int battWidth, int rectHeight, uint16_t percentage) { // Top line renderer.drawLine(x + 1, y, x + battWidth - 3, y); // Bottom line renderer.drawLine(x + 1, y + rectHeight - 1, x + battWidth - 3, y + rectHeight - 1); // Left line renderer.drawLine(x, y + 1, x, y + rectHeight - 2); // Battery end renderer.drawLine(x + battWidth - 2, y + 1, x + battWidth - 2, y + rectHeight - 2); renderer.drawPixel(x + battWidth - 1, y + 3); renderer.drawPixel(x + battWidth - 1, y + rectHeight - 4); renderer.drawLine(x + battWidth - 0, y + 4, x + battWidth - 0, y + rectHeight - 5); // The +1 is to round up, so that we always fill at least one pixel int filledWidth = percentage * (battWidth - 5) / 100 + 1; if (filledWidth > battWidth - 5) { filledWidth = battWidth - 5; // Ensure we don't overflow } renderer.fillRect(x + 2, y + 2, filledWidth, rectHeight - 4); } } // namespace void BaseTheme::drawBatteryLeft(const GfxRenderer& renderer, Rect rect, const bool showPercentage) const { // Left aligned: icon on left, percentage on right (reader mode) const uint16_t percentage = powerManager.getBatteryPercentage(); const int y = rect.y + 6; if (showPercentage) { const auto percentageText = std::to_string(percentage) + "%"; renderer.drawText(SMALL_FONT_ID, rect.x + batteryPercentSpacing + BaseMetrics::values.batteryWidth, rect.y, percentageText.c_str()); } drawBatteryIcon(renderer, rect.x, y, BaseMetrics::values.batteryWidth, rect.height, percentage); } void BaseTheme::drawBatteryRight(const GfxRenderer& renderer, Rect rect, const bool showPercentage) const { // Right aligned: percentage on left, icon on right (UI headers) // rect.x is already positioned for the icon (drawHeader calculated it) const uint16_t percentage = powerManager.getBatteryPercentage(); const int y = rect.y + 6; if (showPercentage) { const auto percentageText = std::to_string(percentage) + "%"; const int textWidth = renderer.getTextWidth(SMALL_FONT_ID, percentageText.c_str()); // Clear the area where we're going to draw the text to prevent ghosting const auto textHeight = renderer.getTextHeight(SMALL_FONT_ID); renderer.fillRect(rect.x - textWidth - batteryPercentSpacing, rect.y, textWidth, textHeight, false); // Draw text to the left of the icon renderer.drawText(SMALL_FONT_ID, rect.x - textWidth - batteryPercentSpacing, rect.y, percentageText.c_str()); } // Icon is already at correct position from rect.x drawBatteryIcon(renderer, rect.x, y, BaseMetrics::values.batteryWidth, rect.height, percentage); } void BaseTheme::drawProgressBar(const GfxRenderer& renderer, Rect rect, const size_t current, const size_t total) const { if (total == 0) { return; } // Use 64-bit arithmetic to avoid overflow for large files const int percent = static_cast((static_cast(current) * 100) / total); LOG_DBG("UI", "Drawing progress bar: current=%u, total=%u, percent=%d", current, total, percent); // Draw outline renderer.drawRect(rect.x, rect.y, rect.width, rect.height); // Draw filled portion const int fillWidth = (rect.width - 4) * percent / 100; if (fillWidth > 0) { renderer.fillRect(rect.x + 2, rect.y + 2, fillWidth, rect.height - 4); } // Draw percentage text centered below bar const std::string percentText = std::to_string(percent) + "%"; renderer.drawCenteredText(UI_10_FONT_ID, rect.y + rect.height + 15, percentText.c_str()); } void BaseTheme::drawButtonHints(GfxRenderer& renderer, const char* btn1, const char* btn2, const char* btn3, const char* btn4) const { const GfxRenderer::Orientation orig_orientation = renderer.getOrientation(); renderer.setOrientation(GfxRenderer::Orientation::Portrait); const int pageHeight = renderer.getScreenHeight(); constexpr int buttonWidth = 106; constexpr int buttonHeight = BaseMetrics::values.buttonHintsHeight; constexpr int buttonY = BaseMetrics::values.buttonHintsHeight; // Distance from bottom constexpr int textYOffset = 7; // Distance from top of button to text baseline constexpr int buttonPositions[] = {25, 130, 245, 350}; const char* labels[] = {btn1, btn2, btn3, btn4}; for (int i = 0; i < 4; i++) { // Only draw if the label is non-empty if (labels[i] != nullptr && labels[i][0] != '\0') { const int x = buttonPositions[i]; renderer.fillRect(x, pageHeight - buttonY, buttonWidth, buttonHeight, false); renderer.drawRect(x, pageHeight - buttonY, buttonWidth, buttonHeight); const int textWidth = renderer.getTextWidth(UI_10_FONT_ID, labels[i]); const int textX = x + (buttonWidth - 1 - textWidth) / 2; renderer.drawText(UI_10_FONT_ID, textX, pageHeight - buttonY + textYOffset, labels[i]); } } renderer.setOrientation(orig_orientation); } void BaseTheme::drawSideButtonHints(const GfxRenderer& renderer, const char* topBtn, const char* bottomBtn) const { const int screenWidth = renderer.getScreenWidth(); constexpr int buttonWidth = BaseMetrics::values.sideButtonHintsWidth; // Width on screen (height when rotated) constexpr int buttonHeight = 80; // Height on screen (width when rotated) constexpr int buttonX = 4; // Distance from right edge // Position for the button group - buttons share a border so they're adjacent constexpr int topButtonY = 345; // Top button position const char* labels[] = {topBtn, bottomBtn}; // Draw the shared border for both buttons as one unit const int x = screenWidth - buttonX - buttonWidth; // Draw top button outline (3 sides, bottom open) if (topBtn != nullptr && topBtn[0] != '\0') { renderer.drawLine(x, topButtonY, x + buttonWidth - 1, topButtonY); // Top renderer.drawLine(x, topButtonY, x, topButtonY + buttonHeight - 1); // Left renderer.drawLine(x + buttonWidth - 1, topButtonY, x + buttonWidth - 1, topButtonY + buttonHeight - 1); // Right } // Draw shared middle border if ((topBtn != nullptr && topBtn[0] != '\0') || (bottomBtn != nullptr && bottomBtn[0] != '\0')) { renderer.drawLine(x, topButtonY + buttonHeight, x + buttonWidth - 1, topButtonY + buttonHeight); // Shared border } // Draw bottom button outline (3 sides, top is shared) if (bottomBtn != nullptr && bottomBtn[0] != '\0') { renderer.drawLine(x, topButtonY + buttonHeight, x, topButtonY + 2 * buttonHeight - 1); // Left renderer.drawLine(x + buttonWidth - 1, topButtonY + buttonHeight, x + buttonWidth - 1, topButtonY + 2 * buttonHeight - 1); // Right renderer.drawLine(x, topButtonY + 2 * buttonHeight - 1, x + buttonWidth - 1, topButtonY + 2 * buttonHeight - 1); // Bottom } // Draw text for each button for (int i = 0; i < 2; i++) { if (labels[i] != nullptr && labels[i][0] != '\0') { const int y = topButtonY + i * buttonHeight; // Draw rotated text centered in the button const int textWidth = renderer.getTextWidth(SMALL_FONT_ID, labels[i]); const int textHeight = renderer.getTextHeight(SMALL_FONT_ID); // Center the rotated text in the button const int textX = x + (buttonWidth - textHeight) / 2; const int textY = y + (buttonHeight + textWidth) / 2; renderer.drawTextRotated90CW(SMALL_FONT_ID, textX, textY, labels[i]); } } } void BaseTheme::drawList(const GfxRenderer& renderer, Rect rect, int itemCount, int selectedIndex, const std::function& rowTitle, const std::function& rowSubtitle, const std::function& rowIcon, const std::function& rowValue, bool highlightValue) const { int rowHeight = (rowSubtitle != nullptr) ? BaseMetrics::values.listWithSubtitleRowHeight : BaseMetrics::values.listRowHeight; int pageItems = rect.height / rowHeight; const int totalPages = (itemCount + pageItems - 1) / pageItems; if (totalPages > 1) { constexpr int indicatorWidth = 20; constexpr int arrowSize = 6; constexpr int margin = 15; // Offset from right edge const int centerX = rect.x + rect.width - indicatorWidth / 2 - margin; const int indicatorTop = rect.y; // Offset to avoid overlapping side button hints const int indicatorBottom = rect.y + rect.height - arrowSize; // Draw up arrow at top (^) - narrow point at top, wide base at bottom for (int i = 0; i < arrowSize; ++i) { const int lineWidth = 1 + i * 2; const int startX = centerX - i; renderer.drawLine(startX, indicatorTop + i, startX + lineWidth - 1, indicatorTop + i); } // Draw down arrow at bottom (v) - wide base at top, narrow point at bottom for (int i = 0; i < arrowSize; ++i) { const int lineWidth = 1 + (arrowSize - 1 - i) * 2; const int startX = centerX - (arrowSize - 1 - i); renderer.drawLine(startX, indicatorBottom - arrowSize + 1 + i, startX + lineWidth - 1, indicatorBottom - arrowSize + 1 + i); } } // Draw selection int contentWidth = rect.width - 5; if (selectedIndex >= 0) { renderer.fillRect(0, rect.y + selectedIndex % pageItems * rowHeight - 2, rect.width, rowHeight); } // Draw all items const auto pageStartIndex = selectedIndex / pageItems * pageItems; for (int i = pageStartIndex; i < itemCount && i < pageStartIndex + pageItems; i++) { const int itemY = rect.y + (i % pageItems) * rowHeight; int textWidth = contentWidth - BaseMetrics::values.contentSidePadding * 2 - (rowValue != nullptr ? 60 : 0); // Draw name auto itemName = rowTitle(i); auto font = (rowSubtitle != nullptr) ? UI_12_FONT_ID : UI_10_FONT_ID; auto item = renderer.truncatedText(font, itemName.c_str(), textWidth); renderer.drawText(font, rect.x + BaseMetrics::values.contentSidePadding, itemY, item.c_str(), i != selectedIndex); if (rowSubtitle != nullptr) { // Draw subtitle std::string subtitleText = rowSubtitle(i); auto subtitle = renderer.truncatedText(UI_10_FONT_ID, subtitleText.c_str(), textWidth); renderer.drawText(UI_10_FONT_ID, rect.x + BaseMetrics::values.contentSidePadding, itemY + 30, subtitle.c_str(), i != selectedIndex); } if (rowValue != nullptr) { // Draw value std::string valueText = rowValue(i); const auto valueTextWidth = renderer.getTextWidth(UI_10_FONT_ID, valueText.c_str()); renderer.drawText(UI_10_FONT_ID, rect.x + contentWidth - BaseMetrics::values.contentSidePadding - valueTextWidth, itemY, valueText.c_str(), i != selectedIndex); } } } void BaseTheme::drawHeader(const GfxRenderer& renderer, Rect rect, const char* title, const char* subtitle) const { // Hide last battery draw constexpr int maxBatteryWidth = 80; renderer.fillRect(rect.x + rect.width - maxBatteryWidth, rect.y + 5, maxBatteryWidth, BaseMetrics::values.batteryHeight + 10, false); const bool showBatteryPercentage = SETTINGS.hideBatteryPercentage != CrossPointSettings::HIDE_BATTERY_PERCENTAGE::HIDE_ALWAYS; // Position icon at right edge, drawBatteryRight will place text to the left const int batteryX = rect.x + rect.width - 12 - BaseMetrics::values.batteryWidth; drawBatteryRight(renderer, Rect{batteryX, rect.y + 5, BaseMetrics::values.batteryWidth, BaseMetrics::values.batteryHeight}, showBatteryPercentage); // Draw clock on the left side (symmetric with battery on the right) if (SETTINGS.clockFormat != CrossPointSettings::CLOCK_OFF) { time_t now = time(nullptr); struct tm* t = localtime(&now); if (t != nullptr && t->tm_year > 100) { char timeBuf[16]; if (SETTINGS.clockFormat == CrossPointSettings::CLOCK_24H) { snprintf(timeBuf, sizeof(timeBuf), "%02d:%02d", t->tm_hour, t->tm_min); } else { int hour12 = t->tm_hour % 12; if (hour12 == 0) hour12 = 12; snprintf(timeBuf, sizeof(timeBuf), "%d:%02d %s", hour12, t->tm_min, t->tm_hour >= 12 ? "PM" : "AM"); } int clockFont = SMALL_FONT_ID; if (SETTINGS.clockSize == CrossPointSettings::CLOCK_SIZE_MEDIUM) clockFont = UI_10_FONT_ID; else if (SETTINGS.clockSize == CrossPointSettings::CLOCK_SIZE_LARGE) clockFont = UI_12_FONT_ID; renderer.drawText(clockFont, rect.x + 12, rect.y + 5, timeBuf, true); } } if (title) { int padding = rect.width - batteryX + BaseMetrics::values.batteryWidth; auto truncatedTitle = renderer.truncatedText(UI_12_FONT_ID, title, rect.width - padding * 2 - BaseMetrics::values.contentSidePadding * 2, EpdFontFamily::BOLD); renderer.drawCenteredText(UI_12_FONT_ID, rect.y + 5, truncatedTitle.c_str(), true, EpdFontFamily::BOLD); } 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, bool selected) const { constexpr int underlineHeight = 2; // Height of selection underline constexpr int underlineGap = 4; // Gap between text and underline const int lineHeight = renderer.getLineHeight(UI_12_FONT_ID); int currentX = rect.x + BaseMetrics::values.contentSidePadding; for (const auto& tab : tabs) { const int textWidth = renderer.getTextWidth(UI_12_FONT_ID, tab.label, tab.selected ? EpdFontFamily::BOLD : EpdFontFamily::REGULAR); // Draw underline for selected tab if (tab.selected) { if (selected) { renderer.fillRect(currentX - 3, rect.y, textWidth + 6, lineHeight + underlineGap); } else { renderer.fillRect(currentX, rect.y + lineHeight + underlineGap, textWidth, underlineHeight); } } // Draw tab label renderer.drawText(UI_12_FONT_ID, currentX, rect.y, tab.label, !(tab.selected && selected), tab.selected ? EpdFontFamily::BOLD : EpdFontFamily::REGULAR); currentX += textWidth + BaseMetrics::values.tabSpacing; } } // Draw the "Recent Book" cover card on the home screen // TODO: Refactor method to make it cleaner, split into smaller methods void BaseTheme::drawRecentBookCover(GfxRenderer& renderer, Rect rect, const std::vector& recentBooks, const int selectorIndex, bool& coverRendered, bool& coverBufferStored, bool& bufferRestored, std::function storeCoverBuffer) const { const bool hasContinueReading = !recentBooks.empty(); const bool bookSelected = hasContinueReading && selectorIndex == 0; // --- Top "book" card for the current title (selectorIndex == 0) --- // Adapt width to cover image aspect ratio; fall back to half screen when no cover const int baseHeight = rect.height; int bookWidth; bool hasCoverImage = false; if (hasContinueReading && !recentBooks[0].coverBmpPath.empty()) { const std::string coverBmpPath = UITheme::getCoverThumbPath(recentBooks[0].coverBmpPath, BaseMetrics::values.homeCoverHeight); FsFile file; if (Storage.openFileForRead("HOME", coverBmpPath, file)) { Bitmap bitmap(file); if (bitmap.parseHeaders() == BmpReaderError::Ok) { hasCoverImage = true; const int imgWidth = bitmap.getWidth(); const int imgHeight = bitmap.getHeight(); if (imgWidth > 0 && imgHeight > 0) { const float aspectRatio = static_cast(imgWidth) / static_cast(imgHeight); bookWidth = static_cast(baseHeight * aspectRatio); const int maxWidth = static_cast(rect.width * 0.9f); if (bookWidth > maxWidth) { bookWidth = maxWidth; } } else { bookWidth = rect.width / 2; } } file.close(); } } if (!hasCoverImage) { bookWidth = rect.width / 2; } const int bookX = rect.x + (rect.width - bookWidth) / 2; const int bookY = rect.y; const int bookHeight = baseHeight; // Bookmark dimensions (used in multiple places) const int bookmarkWidth = bookWidth / 8; const int bookmarkHeight = bookHeight / 5; const int bookmarkX = bookX + bookWidth - bookmarkWidth - 10; const int bookmarkY = bookY + 5; // Draw book card regardless, fill with message based on `hasContinueReading` { // Draw cover image as background if available (inside the box) // Only load from SD on first render, then use stored buffer if (hasContinueReading && !recentBooks[0].coverBmpPath.empty() && !coverRendered) { const std::string coverBmpPath = UITheme::getCoverThumbPath(recentBooks[0].coverBmpPath, BaseMetrics::values.homeCoverHeight); // First time: load cover from SD and render FsFile file; if (Storage.openFileForRead("HOME", coverBmpPath, file)) { Bitmap bitmap(file); if (bitmap.parseHeaders() == BmpReaderError::Ok) { LOG_DBG("THEME", "Rendering bmp"); renderer.drawBitmap(bitmap, bookX, bookY, bookWidth, bookHeight); 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) { LOG_DBG("THEME", "Drawing selection"); renderer.drawRect(bookX + 1, bookY + 1, bookWidth - 2, bookHeight - 2); renderer.drawRect(bookX + 2, bookY + 2, bookWidth - 4, bookHeight - 4); } } file.close(); } } if (!bufferRestored && !coverRendered) { // No cover image: draw border or fill, plus bookmark as visual flair if (bookSelected) { renderer.fillRect(bookX, bookY, bookWidth, bookHeight); } else { renderer.drawRect(bookX, bookY, bookWidth, bookHeight); } // Draw bookmark ribbon when no cover image (visual decoration) if (hasContinueReading) { const int notchDepth = bookmarkHeight / 3; const int centerX = bookmarkX + bookmarkWidth / 2; const int xPoints[5] = { bookmarkX, // top-left bookmarkX + bookmarkWidth, // top-right bookmarkX + bookmarkWidth, // bottom-right centerX, // center notch point bookmarkX // bottom-left }; const int yPoints[5] = { bookmarkY, // top-left bookmarkY, // top-right bookmarkY + bookmarkHeight, // bottom-right bookmarkY + bookmarkHeight - notchDepth, // center notch point bookmarkY + bookmarkHeight // bottom-left }; // Draw bookmark ribbon (inverted if selected) renderer.fillPolygon(xPoints, yPoints, 5, !bookSelected); } } // If buffer was restored, draw selection indicators if needed if (bufferRestored && bookSelected && coverRendered) { // Draw selection border (no bookmark inversion needed since cover has no bookmark) renderer.drawRect(bookX + 1, bookY + 1, bookWidth - 2, bookHeight - 2); renderer.drawRect(bookX + 2, bookY + 2, bookWidth - 4, bookHeight - 4); } else if (!coverRendered && !bufferRestored) { // Selection border already handled above in the no-cover case } } if (hasContinueReading) { const std::string& lastBookTitle = recentBooks[0].title; const std::string& lastBookAuthor = recentBooks[0].author; // Invert text colors based on selection state: // - With cover: selected = white text on black box, unselected = black text on white box // - Without cover: selected = white text on black card, unselected = black text on white card // Split into words (avoid stringstream to keep this light on the MCU) std::vector words; words.reserve(8); size_t pos = 0; while (pos < lastBookTitle.size()) { while (pos < lastBookTitle.size() && lastBookTitle[pos] == ' ') { ++pos; } if (pos >= lastBookTitle.size()) { break; } const size_t start = pos; while (pos < lastBookTitle.size() && lastBookTitle[pos] != ' ') { ++pos; } words.emplace_back(lastBookTitle.substr(start, pos - start)); } std::vector 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(lines.size()); if (!lastBookAuthor.empty()) { totalTextHeight += renderer.getLineHeight(UI_10_FONT_ID) * 3 / 2; } // Vertically center the title block within the card int titleYStart = bookY + (bookHeight - totalTextHeight) / 2; // If cover image was rendered, draw box behind title and author if (coverRendered) { constexpr int boxPadding = 8; // Calculate the max text width for the box int maxTextWidth = 0; for (const auto& line : lines) { const int lineWidth = renderer.getTextWidth(UI_12_FONT_ID, line.c_str()); if (lineWidth > maxTextWidth) { maxTextWidth = lineWidth; } } if (!lastBookAuthor.empty()) { std::string trimmedAuthor = lastBookAuthor; while (renderer.getTextWidth(UI_10_FONT_ID, trimmedAuthor.c_str()) > maxLineWidth && !trimmedAuthor.empty()) { utf8RemoveLastChar(trimmedAuthor); } if (renderer.getTextWidth(UI_10_FONT_ID, trimmedAuthor.c_str()) < renderer.getTextWidth(UI_10_FONT_ID, lastBookAuthor.c_str())) { trimmedAuthor.append("..."); } const int authorWidth = renderer.getTextWidth(UI_10_FONT_ID, trimmedAuthor.c_str()); if (authorWidth > maxTextWidth) { maxTextWidth = authorWidth; } } const int boxWidth = maxTextWidth + boxPadding * 2; const int boxHeight = totalTextHeight + boxPadding * 2; const int boxX = rect.x + (rect.width - boxWidth) / 2; const int boxY = titleYStart - boxPadding; // Draw box (inverted when selected: black box instead of white) renderer.fillRect(boxX, boxY, boxWidth, boxHeight, bookSelected); // Draw border around the box (inverted when selected: white border instead of black) renderer.drawRect(boxX, boxY, boxWidth, boxHeight, !bookSelected); } for (const auto& line : lines) { renderer.drawCenteredText(UI_12_FONT_ID, titleYStart, line.c_str(), !bookSelected); titleYStart += renderer.getLineHeight(UI_12_FONT_ID); } if (!lastBookAuthor.empty()) { titleYStart += renderer.getLineHeight(UI_10_FONT_ID) / 2; std::string trimmedAuthor = lastBookAuthor; // Trim author if too long (UTF-8 safe) bool wasTrimmed = false; while (renderer.getTextWidth(UI_10_FONT_ID, trimmedAuthor.c_str()) > maxLineWidth && !trimmedAuthor.empty()) { utf8RemoveLastChar(trimmedAuthor); wasTrimmed = true; } if (wasTrimmed && !trimmedAuthor.empty()) { // Make room for ellipsis while (renderer.getTextWidth(UI_10_FONT_ID, (trimmedAuthor + "...").c_str()) > maxLineWidth && !trimmedAuthor.empty()) { utf8RemoveLastChar(trimmedAuthor); } trimmedAuthor.append("..."); } renderer.drawCenteredText(UI_10_FONT_ID, titleYStart, trimmedAuthor.c_str(), !bookSelected); } // "Continue Reading" label at the bottom const int continueY = bookY + bookHeight - renderer.getLineHeight(UI_10_FONT_ID) * 3 / 2; if (coverRendered) { // Draw box behind "Continue Reading" text (inverted when selected: black box instead of white) const char* continueText = tr(STR_CONTINUE_READING); const int continueTextWidth = renderer.getTextWidth(UI_10_FONT_ID, continueText); constexpr int continuePadding = 6; const int continueBoxWidth = continueTextWidth + continuePadding * 2; const int continueBoxHeight = renderer.getLineHeight(UI_10_FONT_ID) + continuePadding; const int continueBoxX = rect.x + (rect.width - continueBoxWidth) / 2; const int continueBoxY = continueY - continuePadding / 2; renderer.fillRect(continueBoxX, continueBoxY, continueBoxWidth, continueBoxHeight, bookSelected); renderer.drawRect(continueBoxX, continueBoxY, continueBoxWidth, continueBoxHeight, !bookSelected); renderer.drawCenteredText(UI_10_FONT_ID, continueY, continueText, !bookSelected); } else { renderer.drawCenteredText(UI_10_FONT_ID, continueY, tr(STR_CONTINUE_READING), !bookSelected); } } else { // No book to continue reading const int y = bookY + (bookHeight - renderer.getLineHeight(UI_12_FONT_ID) - renderer.getLineHeight(UI_10_FONT_ID)) / 2; renderer.drawCenteredText(UI_12_FONT_ID, y, "No open book"); renderer.drawCenteredText(UI_10_FONT_ID, y + renderer.getLineHeight(UI_12_FONT_ID), "Start reading below"); } } void BaseTheme::drawButtonMenu(GfxRenderer& renderer, Rect rect, int buttonCount, int selectedIndex, const std::function& buttonLabel, const std::function& rowIcon) const { for (int i = 0; i < buttonCount; ++i) { const int tileY = BaseMetrics::values.verticalSpacing + rect.y + static_cast(i) * (BaseMetrics::values.menuRowHeight + BaseMetrics::values.menuSpacing); const bool selected = selectedIndex == i; if (selected) { renderer.fillRect(rect.x + BaseMetrics::values.contentSidePadding, tileY, rect.width - BaseMetrics::values.contentSidePadding * 2, BaseMetrics::values.menuRowHeight); } else { renderer.drawRect(rect.x + BaseMetrics::values.contentSidePadding, tileY, rect.width - BaseMetrics::values.contentSidePadding * 2, BaseMetrics::values.menuRowHeight); } std::string labelStr = buttonLabel(i); const char* label = labelStr.c_str(); const int textWidth = renderer.getTextWidth(UI_10_FONT_ID, label); const int textX = rect.x + (rect.width - textWidth) / 2; const int lineHeight = renderer.getLineHeight(UI_10_FONT_ID); const int textY = tileY + (BaseMetrics::values.menuRowHeight - lineHeight) / 2; // vertically centered assuming y is top of text // Invert text when the tile is selected, to contrast with the filled background renderer.drawText(UI_10_FONT_ID, textX, textY, label, selectedIndex != i); } } Rect BaseTheme::drawPopup(const GfxRenderer& renderer, const char* message) const { constexpr int margin = 15; constexpr int y = 60; const int textWidth = renderer.getTextWidth(UI_12_FONT_ID, message, EpdFontFamily::BOLD); const int textHeight = renderer.getLineHeight(UI_12_FONT_ID); const int w = textWidth + margin * 2; const int h = textHeight + margin * 2; const int x = (renderer.getScreenWidth() - w) / 2; renderer.fillRect(x - 2, y - 2, w + 4, h + 4, true); // frame thickness 2 renderer.fillRect(x, y, w, h, false); const int textX = x + (w - textWidth) / 2; const int textY = y + margin - 2; renderer.drawText(UI_12_FONT_ID, textX, textY, message, true, EpdFontFamily::BOLD); renderer.displayBuffer(); return Rect{x, y, w, h}; } void BaseTheme::fillPopupProgress(const GfxRenderer& renderer, const Rect& layout, const int progress) const { constexpr int barHeight = 4; const int barWidth = layout.width - 30; // twice the margin in drawPopup to match text width const int barX = layout.x + (layout.width - barWidth) / 2; const int barY = layout.y + layout.height - 10; int fillWidth = barWidth * progress / 100; renderer.fillRect(barX, barY, fillWidth, barHeight, true); renderer.displayBuffer(HalDisplay::FAST_REFRESH); } void BaseTheme::drawReadingProgressBar(const GfxRenderer& renderer, const size_t bookProgress) const { int vieweableMarginTop, vieweableMarginRight, vieweableMarginBottom, vieweableMarginLeft; renderer.getOrientedViewableTRBL(&vieweableMarginTop, &vieweableMarginRight, &vieweableMarginBottom, &vieweableMarginLeft); const int progressBarMaxWidth = renderer.getScreenWidth() - vieweableMarginLeft - vieweableMarginRight; const int progressBarY = renderer.getScreenHeight() - vieweableMarginBottom - BaseMetrics::values.bookProgressBarHeight; const int barWidth = progressBarMaxWidth * bookProgress / 100; renderer.fillRect(vieweableMarginLeft, progressBarY, barWidth, BaseMetrics::values.bookProgressBarHeight, true); } 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()); } void BaseTheme::drawTextField(const GfxRenderer& renderer, Rect rect, const int textWidth) const { renderer.drawText(UI_12_FONT_ID, rect.x + 10, rect.y, "["); renderer.drawText(UI_12_FONT_ID, rect.x + rect.width - 15, rect.y + rect.height, "]"); } void BaseTheme::drawKeyboardKey(const GfxRenderer& renderer, Rect rect, const char* label, const bool isSelected) const { const int itemWidth = renderer.getTextWidth(UI_10_FONT_ID, label); const int textX = rect.x + (rect.width - itemWidth) / 2; if (isSelected) { renderer.drawText(UI_10_FONT_ID, textX - 6, rect.y, "["); renderer.drawText(UI_10_FONT_ID, textX + itemWidth, rect.y, "]"); } renderer.drawText(UI_10_FONT_ID, textX, rect.y, label); }