#include "BaseTheme.h" #include #include #include #include #include #include #include #include #include "I18n.h" #include "RecentBooksStore.h" #include "components/UITheme.h" #include "components/icons/bookmark.h" #include "fontIds.h" // Internal constants namespace { constexpr int homeMenuMargin = 20; constexpr int homeMarginTop = 30; constexpr int subtitleY = 738; constexpr int bookmarkStatusIconWidth = 16; constexpr int bookmarkStatusIconHeight = 14; constexpr int bookmarkStatusIconGap = 4; constexpr int bookmarkStatusIconTopCrop = 2; bool statusBarTextLaneVisible() { return SETTINGS.statusBarChapterPageCount || SETTINGS.statusBarBookProgressPercentage || SETTINGS.statusBarTitle != CrossPointSettings::STATUS_BAR_TITLE::HIDE_TITLE || SETTINGS.statusBarBattery || (SETTINGS.statusBarClock && halClock.isAvailable()); } void drawBookmarkStatusIcon(const GfxRenderer& renderer, const int x, const int y) { constexpr int bytesPerRow = bookmarkStatusIconWidth / 8; for (int row = 0; row < bookmarkStatusIconHeight; ++row) { for (int col = 0; col < bookmarkStatusIconWidth; ++col) { const uint8_t byte = BookmarkStatusIcon[(row + bookmarkStatusIconTopCrop) * bytesPerRow + col / 8]; const uint8_t mask = 1U << (7 - (col % 8)); renderer.drawPixel(x + col, y + row, (byte & mask) != 0); } } } } // namespace void BaseTheme::drawBatteryOutline(const GfxRenderer& renderer, int x, int y, int battWidth, int rectHeight) { // 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); } void BaseTheme::drawBatteryLightningBolt(const GfxRenderer& renderer, int boltX, int boltY) { // Draw lightning bolt (white/inverted on black fill for visibility) renderer.drawLine(boltX + 4, boltY + 0, boltX + 5, boltY + 0, false); renderer.drawLine(boltX + 3, boltY + 1, boltX + 4, boltY + 1, false); renderer.drawLine(boltX + 2, boltY + 2, boltX + 5, boltY + 2, false); renderer.drawLine(boltX + 3, boltY + 3, boltX + 4, boltY + 3, false); renderer.drawLine(boltX + 2, boltY + 4, boltX + 3, boltY + 4, false); renderer.drawLine(boltX + 1, boltY + 5, boltX + 4, boltY + 5, false); renderer.drawLine(boltX + 2, boltY + 6, boltX + 3, boltY + 6, false); renderer.drawLine(boltX + 1, boltY + 7, boltX + 2, boltY + 7, false); } void BaseTheme::fillBatteryIcon(const GfxRenderer& renderer, Rect rect, uint16_t percentage) const { const bool charging = gpio.isUsbConnected(); const int maxFillWidth = rect.width - 5; const int fillHeight = rect.height - 4; if (maxFillWidth <= 0 || fillHeight <= 0) { return; } // +1 to round up so we always fill at least one pixel int filledWidth = percentage * maxFillWidth / 100 + 1; if (filledWidth > maxFillWidth) { filledWidth = maxFillWidth; } // When charging, ensure minimum fill so lightning bolt is fully visible constexpr int minFillForBolt = 8; if (charging && filledWidth < minFillForBolt) { filledWidth = std::min(minFillForBolt, maxFillWidth); } renderer.fillRect(rect.x + 2, rect.y + 2, filledWidth, fillHeight); if (charging) { drawBatteryLightningBolt(renderer, rect.x + 4, rect.y + 2); } } 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 + rect.width, rect.y, percentageText.c_str()); } const Rect iconRect{rect.x, y, rect.width, rect.height}; drawBatteryOutline(renderer, rect.x, y, rect.width, rect.height); fillBatteryIcon(renderer, iconRect, 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()); renderer.drawText(SMALL_FONT_ID, rect.x - textWidth - batteryPercentSpacing, rect.y, percentageText.c_str()); } const Rect iconRect{rect.x, y, rect.width, rect.height}; drawBatteryOutline(renderer, rect.x, y, rect.width, rect.height); fillBatteryIcon(renderer, iconRect, 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 // X3 has wider screen in portrait (528 vs 480), use more spacing constexpr int x4ButtonPositions[] = {25, 130, 245, 350}; constexpr int x3ButtonPositions[] = {38, 154, 268, 384}; const int* buttonPositions = gpio.deviceIsX3() ? x3ButtonPositions : x4ButtonPositions; 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 buttonMargin = 4; if (gpio.deviceIsX3()) { // X3 layout: Up on left side, Down on right side, positioned higher constexpr int x3ButtonY = 155; if (topBtn != nullptr && topBtn[0] != '\0') { const int leftX = buttonMargin; renderer.drawRect(leftX, x3ButtonY, buttonWidth, buttonHeight); const int textWidth = renderer.getTextWidth(SMALL_FONT_ID, topBtn); const int textHeight = renderer.getTextHeight(SMALL_FONT_ID); const int textX = leftX + (buttonWidth - textHeight) / 2; const int textY = x3ButtonY + (buttonHeight + textWidth) / 2; renderer.drawTextRotated90CW(SMALL_FONT_ID, textX, textY, topBtn); } if (bottomBtn != nullptr && bottomBtn[0] != '\0') { const int rightX = screenWidth - buttonMargin - buttonWidth; renderer.drawRect(rightX, x3ButtonY, buttonWidth, buttonHeight); const int textWidth = renderer.getTextWidth(SMALL_FONT_ID, bottomBtn); const int textHeight = renderer.getTextHeight(SMALL_FONT_ID); const int textX = rightX + (buttonWidth - textHeight) / 2; const int textY = x3ButtonY + (buttonHeight + textWidth) / 2; renderer.drawTextRotated90CW(SMALL_FONT_ID, textX, textY, bottomBtn); } } else { // X4 layout: Both buttons stacked on right side constexpr int topButtonY = 345; const char* labels[] = {topBtn, bottomBtn}; const int x = screenWidth - buttonMargin - buttonWidth; if (topBtn != nullptr && topBtn[0] != '\0') { renderer.drawLine(x, topButtonY, x + buttonWidth - 1, topButtonY); renderer.drawLine(x, topButtonY, x, topButtonY + buttonHeight - 1); renderer.drawLine(x + buttonWidth - 1, topButtonY, x + buttonWidth - 1, topButtonY + buttonHeight - 1); } if ((topBtn != nullptr && topBtn[0] != '\0') || (bottomBtn != nullptr && bottomBtn[0] != '\0')) { renderer.drawLine(x, topButtonY + buttonHeight, x + buttonWidth - 1, topButtonY + buttonHeight); } if (bottomBtn != nullptr && bottomBtn[0] != '\0') { renderer.drawLine(x, topButtonY + buttonHeight, x, topButtonY + 2 * buttonHeight - 1); renderer.drawLine(x + buttonWidth - 1, topButtonY + buttonHeight, x + buttonWidth - 1, topButtonY + 2 * buttonHeight - 1); renderer.drawLine(x, topButtonY + 2 * buttonHeight - 1, x + buttonWidth - 1, topButtonY + 2 * buttonHeight - 1); } for (int i = 0; i < 2; i++) { if (labels[i] != nullptr && labels[i][0] != '\0') { const int y = topButtonY + i * buttonHeight; const int textWidth = renderer.getTextWidth(SMALL_FONT_ID, labels[i]); const int textHeight = renderer.getTextHeight(SMALL_FONT_ID); const int textX = x + (buttonWidth - textHeight) / 2; const int textY = y + (buttonHeight + textWidth) / 2; renderer.drawTextRotated90CW(SMALL_FONT_ID, textX, textY, labels[i]); } } } } int BaseTheme::getListPageItems(int contentHeight, bool hasSubtitle) const { int rowHeight = (hasSubtitle) ? BaseMetrics::values.listWithSubtitleRowHeight : BaseMetrics::values.listRowHeight; return contentHeight / rowHeight; } 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 std::function& rowDimmed) 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(rect.x, rect.y + selectedIndex % pageItems * rowHeight - 2, rect.width, rowHeight); } constexpr int minValueGap = 10; // 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 rowTextWidth = contentWidth - BaseMetrics::values.contentSidePadding * 2; std::string valueText; if (rowValue != nullptr) { valueText = rowValue(i); if (!valueText.empty()) { int maxValW = std::max(0, rowTextWidth - 40 - minValueGap); valueText = renderer.truncatedText(UI_10_FONT_ID, valueText.c_str(), maxValW); int valueWidth = renderer.getTextWidth(UI_10_FONT_ID, valueText.c_str()) + minValueGap; rowTextWidth -= valueWidth; } } auto itemName = rowTitle(i); auto font = UI_10_FONT_ID; auto item = renderer.truncatedText(font, itemName.c_str(), rowTextWidth); renderer.drawText(font, rect.x + BaseMetrics::values.contentSidePadding, itemY, item.c_str(), i != selectedIndex); // Apply checkerboard dither to create gray text effect for dimmed items if (rowDimmed && rowDimmed(i) && i != selectedIndex) { const int titleWidth = renderer.getTextWidth(font, item.c_str()); const int lineH = renderer.getLineHeight(font); const int tx = rect.x + BaseMetrics::values.contentSidePadding; for (int py = itemY; py < itemY + lineH; py++) for (int px = tx; px < tx + titleWidth; px++) if ((px + py) % 2 == 0) renderer.drawPixel(px, py, false); } if (rowSubtitle != nullptr) { std::string subtitleText = rowSubtitle(i); if (!subtitleText.empty()) { auto subtitle = renderer.truncatedText(SMALL_FONT_ID, subtitleText.c_str(), rowTextWidth); renderer.drawText(SMALL_FONT_ID, rect.x + BaseMetrics::values.contentSidePadding, itemY + 22, subtitle.c_str(), i != selectedIndex); } } if (!valueText.empty()) { const auto valueTextWidth = renderer.getTextWidth(UI_10_FONT_ID, valueText.c_str()); int valueY = itemY; if (rowSubtitle != nullptr) { valueY = itemY + 10; } renderer.drawText(UI_10_FONT_ID, rect.x + contentWidth - BaseMetrics::values.contentSidePadding - valueTextWidth, valueY, 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); 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 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) --- // When there's no cover image, use fixed size (half screen) // When there's cover image, adapt width to image aspect ratio, keep height fixed at 400px const int baseHeight = rect.height; // Fixed height (400px) int bookWidth, bookX; bool hasCoverImage = false; if (hasContinueReading && !recentBooks[0].coverBmpPath.empty()) { // Try to get actual image dimensions from BMP header const std::string coverBmpPath = UITheme::getCoverThumbPath(recentBooks[0].coverBmpPath, BaseMetrics::values.homeCoverHeight); HalFile 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(); // Calculate width based on aspect ratio, maintaining baseHeight if (imgWidth > 0 && imgHeight > 0) { const float aspectRatio = static_cast(imgWidth) / static_cast(imgHeight); bookWidth = static_cast(baseHeight * aspectRatio); // Ensure width doesn't exceed reasonable limits (max 90% of screen width) const int maxWidth = static_cast(rect.width * 0.9f); if (bookWidth > maxWidth) { bookWidth = maxWidth; } } else { bookWidth = rect.width / 2; // Fallback } } } } if (!hasCoverImage) { // No cover: use half screen size bookWidth = rect.width / 2; } 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 HalFile file; if (Storage.openFileForRead("HOME", coverBmpPath, file)) { Bitmap bitmap(file); if (bitmap.parseHeaders() == BmpReaderError::Ok) { LOG_DBG("THEME", "Rendering bmp"); // Draw the cover image (bookWidth and bookHeight already match image aspect ratio) renderer.drawBitmap(bitmap, bookX, bookY, bookWidth, bookHeight); // Draw border around the card renderer.drawRect(bookX, bookY, bookWidth, bookHeight); // No bookmark ribbon when cover is shown - it would just cover the art // Store the buffer with cover image for fast navigation coverBufferStored = storeCoverBuffer(); coverRendered = coverBufferStored; // Only consider it rendered if we successfully stored the buffer // 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); } } } } 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 auto lines = renderer.wrappedText(UI_12_FONT_ID, lastBookTitle.c_str(), bookWidth - 40, 3); // 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; const auto truncatedAuthor = lastBookAuthor.empty() ? std::string{} : renderer.truncatedText(UI_10_FONT_ID, lastBookAuthor.c_str(), bookWidth - 40); // 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 (!truncatedAuthor.empty()) { const int authorWidth = renderer.getTextWidth(UI_10_FONT_ID, truncatedAuthor.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 (!truncatedAuthor.empty()) { titleYStart += renderer.getLineHeight(UI_10_FONT_ID) / 2; renderer.drawCenteredText(UI_10_FONT_ID, titleYStart, truncatedAuthor.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, tr(STR_NO_OPEN_BOOK)); renderer.drawCenteredText(UI_10_FONT_ID, y + renderer.getLineHeight(UI_12_FONT_ID), tr(STR_START_READING)); } } 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 { const auto& metrics = UITheme::getInstance().getMetrics(); const int marginX = metrics.popupMarginX; const int marginY = metrics.popupMarginY; const int frameThickness = metrics.popupFrameThickness; const EpdFontFamily::Style popupFontFamily = metrics.popupTextBold ? EpdFontFamily::BOLD : EpdFontFamily::REGULAR; // Scale y position proportionally to screen height const int y = static_cast(renderer.getScreenHeight() * metrics.popupTopOffsetRatio); const int textWidth = renderer.getTextWidth(UI_12_FONT_ID, message, popupFontFamily); const int textHeight = renderer.getLineHeight(UI_12_FONT_ID); const int w = textWidth + marginX * 2; const int h = textHeight + marginY * 2; const int x = (renderer.getScreenWidth() - w) / 2; const bool useRoundedPopup = metrics.popupCornerRadius > 0; if (useRoundedPopup) { renderer.fillRoundedRect(x - frameThickness, y - frameThickness, w + frameThickness * 2, h + frameThickness * 2, metrics.popupCornerRadius + frameThickness, Color::White); renderer.fillRoundedRect(x, y, w, h, metrics.popupCornerRadius, Color::Black); } else { renderer.fillRect(x - frameThickness, y - frameThickness, w + frameThickness * 2, h + frameThickness * 2, true); renderer.fillRect(x, y, w, h, false); } const int textX = x + (w - textWidth) / 2; const int textY = y + marginY + metrics.popupTextBaselineOffsetY; renderer.drawText(UI_12_FONT_ID, textX, textY, message, metrics.popupTextInverted, popupFontFamily); renderer.displayBuffer(); return Rect{x, y, w, h}; } void BaseTheme::fillPopupProgress(const GfxRenderer& renderer, const Rect& layout, const int progress) const { const auto& metrics = UITheme::getInstance().getMetrics(); const int barHeight = metrics.popupProgressBarHeight; const int barWidth = std::max(0, layout.width - metrics.popupMarginX * 2); // 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 - metrics.popupMarginY / 2 - barHeight / 2 - 1; if (barWidth <= 0 || barHeight <= 0) { renderer.displayBuffer(HalDisplay::FAST_REFRESH); return; } const int scaledProgress = metrics.popupProgressClampPercent ? std::clamp(progress, 0, 100) : progress; const int fillWidth = barWidth * scaledProgress / 100; if (metrics.popupProgressDrawOutline) { renderer.drawRect(barX, barY, barWidth, barHeight, 1, metrics.popupProgressOutlineInverted); } if (fillWidth > 0) { renderer.fillRect(barX, barY, fillWidth, barHeight, metrics.popupProgressFillInverted); } renderer.displayBuffer(HalDisplay::FAST_REFRESH); } void BaseTheme::drawStatusBar(GfxRenderer& renderer, const float bookProgress, const int currentPage, const int pageCount, std::string title, const int paddingBottom, const int textYOffset, const bool fillMargin, const bool isPageBookmarked, const bool pageCountEstimated) const { auto metrics = UITheme::getInstance().getMetrics(); int orientedMarginTop, orientedMarginRight, orientedMarginBottom, orientedMarginLeft; renderer.getOrientedViewableTRBL(&orientedMarginTop, &orientedMarginRight, &orientedMarginBottom, &orientedMarginLeft); const bool showStatusBarTextLane = statusBarTextLaneVisible(); // Draw Progress Text const auto screenHeight = renderer.getScreenHeight(); auto textY = screenHeight - UITheme::getInstance().getStatusBarHeight() - orientedMarginBottom - paddingBottom - 4; const int leftClusterX = metrics.statusBarHorizontalMargin + orientedMarginLeft + 1; const int rightClusterX = renderer.getScreenWidth() - metrics.statusBarHorizontalMargin - orientedMarginRight; int leftClusterWidth = 0; int rightClusterWidth = 0; if (SETTINGS.statusBarBookProgressPercentage || SETTINGS.statusBarChapterPageCount) { // Right aligned text for progress counter char progressStr[32]; // Prefix the page count with "~" while a still-building spine only yields an estimated total. const char* estimatePrefix = pageCountEstimated ? "~" : ""; if (SETTINGS.statusBarBookProgressPercentage && SETTINGS.statusBarChapterPageCount) { snprintf(progressStr, sizeof(progressStr), "%s%d/%d %.0f%%", estimatePrefix, currentPage, pageCount, bookProgress); } else if (SETTINGS.statusBarBookProgressPercentage) { snprintf(progressStr, sizeof(progressStr), "%.0f%%", bookProgress); } else { snprintf(progressStr, sizeof(progressStr), "%s%d/%d", estimatePrefix, currentPage, pageCount); } int progressTextWidth = renderer.getTextWidth(SMALL_FONT_ID, progressStr); renderer.drawText(SMALL_FONT_ID, rightClusterX - progressTextWidth, textY, progressStr); rightClusterWidth += progressTextWidth; } // Draw Progress Bar if (SETTINGS.statusBarProgressBar != CrossPointSettings::STATUS_BAR_PROGRESS_BAR::HIDE_PROGRESS) { const int barMarginLeft = fillMargin ? 0 : orientedMarginLeft; const int barMarginRight = fillMargin ? 0 : orientedMarginRight; const int progressBarMaxWidth = renderer.getScreenWidth() - barMarginLeft - barMarginRight; const int progressBarY = renderer.getScreenHeight() - orientedMarginBottom - ((SETTINGS.statusBarProgressBarThickness + 1) * 2) - paddingBottom + (fillMargin ? 1 : 0); size_t progress; if (SETTINGS.statusBarProgressBar == CrossPointSettings::STATUS_BAR_PROGRESS_BAR::BOOK_PROGRESS) { progress = static_cast(bookProgress); } else { // Chapter progress progress = (pageCount > 0) ? (static_cast(currentPage) / pageCount) * 100 : 0; } const int barWidth = progressBarMaxWidth * progress / 100; const int barHeight = ((SETTINGS.statusBarProgressBarThickness + 1) * 2) + (fillMargin ? orientedMarginBottom - 1 : 0); renderer.fillRect(barMarginLeft, progressBarY, barWidth, barHeight, true); } // Draw Battery const bool showBatteryPercentage = SETTINGS.hideBatteryPercentage == CrossPointSettings::HIDE_BATTERY_PERCENTAGE::HIDE_NEVER; if (SETTINGS.statusBarBattery) { GUI.drawBatteryLeft(renderer, Rect{leftClusterX + leftClusterWidth, textY, metrics.batteryWidth, metrics.batteryHeight}, showBatteryPercentage); int batteryWidth = metrics.batteryWidth; if (showBatteryPercentage) { const uint16_t percentage = powerManager.getBatteryPercentage(); // width of icon + spacing + text for layout purposes batteryWidth += batteryPercentSpacing + renderer.getTextWidth(SMALL_FONT_ID, (std::to_string(percentage) + "%").c_str()); } leftClusterWidth += batteryWidth; } // Draw Clock (X3 only — DS3231 RTC) if (SETTINGS.statusBarClock && halClock.isAvailable()) { char timeBuf[9]; if (halClock.formatTime(timeBuf, sizeof(timeBuf), SETTINGS.clockUtcOffsetQ, SETTINGS.clockFormat == 1)) { int clockTextWidth = renderer.getTextWidth(SMALL_FONT_ID, timeBuf); int clockX = 0; // Position to the left or right of the progress text (with a small gap) if (SETTINGS.statusBarClock == CrossPointSettings::STATUS_BAR_CLOCK_LEFT) { clockX = leftClusterX + leftClusterWidth + (leftClusterWidth > 0 ? 10 : 0); leftClusterWidth += clockTextWidth + 10; } else if (SETTINGS.statusBarClock == CrossPointSettings::STATUS_BAR_CLOCK_RIGHT) { clockX = rightClusterX - rightClusterWidth - (rightClusterWidth > 0 ? 10 : 0) - clockTextWidth; rightClusterWidth += clockTextWidth + 10; } renderer.drawText(SMALL_FONT_ID, clockX, textY, timeBuf); } } // Draw Bookmark if (showStatusBarTextLane && isPageBookmarked) { const int bookmarkGap = leftClusterWidth > 0 ? bookmarkStatusIconGap : 0; const int bookmarkX = leftClusterX + leftClusterWidth + bookmarkGap; const int bookmarkY = textY + 5; drawBookmarkStatusIcon(renderer, bookmarkX, bookmarkY); leftClusterWidth += bookmarkStatusIconWidth + bookmarkGap; } // Draw Title if (!title.empty()) { textY -= textYOffset; // Centered chapter title text // Page width minus existing content with 30px padding on each side const int rendererableScreenWidth = renderer.getScreenWidth() - (metrics.statusBarHorizontalMargin * 2) - orientedMarginLeft - orientedMarginRight; const int titleMarginLeft = leftClusterWidth + 30; const int titleMarginRight = rightClusterWidth + 30; // Attempt to center title on the screen, but if title is too wide then later we will center it within the // available space. int titleMarginLeftAdjusted = std::max(titleMarginLeft, titleMarginRight); int availableTitleSpace = rendererableScreenWidth - 2 * titleMarginLeftAdjusted; int titleWidth; titleWidth = renderer.getTextWidth(SMALL_FONT_ID, title.c_str()); if (titleWidth > availableTitleSpace) { // Not enough space to center on the screen, center it within the remaining space instead availableTitleSpace = rendererableScreenWidth - titleMarginLeft - titleMarginRight; titleMarginLeftAdjusted = titleMarginLeft; } if (titleWidth > availableTitleSpace) { title = renderer.truncatedText(SMALL_FONT_ID, title.c_str(), availableTitleSpace); titleWidth = renderer.getTextWidth(SMALL_FONT_ID, title.c_str()); } renderer.drawText(SMALL_FONT_ID, titleMarginLeftAdjusted + metrics.statusBarHorizontalMargin + orientedMarginLeft + (availableTitleSpace - titleWidth) / 2, textY, title.c_str()); } } void BaseTheme::drawHelpText(const GfxRenderer& renderer, Rect rect, const char* label) const { 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, bool cursorMode, int contentStartX, int contentWidth) const { const auto& metrics = UITheme::getInstance().getMetrics(); const int lineHeight = renderer.getLineHeight(UI_12_FONT_ID); const int lineY = rect.y + rect.height + lineHeight + metrics.verticalSpacing; const int thickness = cursorMode ? metrics.textFieldCursorThickness : metrics.textFieldNormalThickness; if (contentWidth > 0) { renderer.drawLine(rect.x + contentStartX, lineY, rect.x + contentStartX + contentWidth + metrics.textFieldLineEndOffset, lineY, thickness, true); } else { const int lineW = textWidth + metrics.textFieldHorizontalPadding * 2; const int lineStart = rect.x + (rect.width - lineW) / 2; renderer.drawLine(lineStart, lineY, lineStart + lineW + metrics.textFieldLineEndOffset, lineY, thickness, true); } } void BaseTheme::drawKeyboardKey(const GfxRenderer& renderer, Rect rect, const char* label, const bool isSelected, const char* secondaryLabel, const KeyboardKeyType keyType, const bool inactiveSelection) const { const auto& metrics = UITheme::getInstance().getMetrics(); const int cr = metrics.keyboardKeyCornerRadius; const bool isSpecialKey = keyType == KeyboardKeyType::Shift || keyType == KeyboardKeyType::Mode || keyType == KeyboardKeyType::Del || keyType == KeyboardKeyType::Space || keyType == KeyboardKeyType::Ok || keyType == KeyboardKeyType::Disabled; if (isSelected) { if (inactiveSelection) { if (cr > 0) { renderer.fillRoundedRect(rect.x, rect.y, rect.width, rect.height, cr, Color::LightGray); } else { renderer.drawRect(rect.x, rect.y, rect.width, rect.height, 2, true); } } else if (keyType == KeyboardKeyType::Disabled) { if (cr > 0) { renderer.fillRoundedRect(rect.x, rect.y, rect.width, rect.height, cr, Color::LightGray); } else { renderer.fillRectDither(rect.x, rect.y, rect.width, rect.height, Color::LightGray); } } else { if (cr > 0) { renderer.fillRoundedRect(rect.x, rect.y, rect.width, rect.height, cr, Color::Black); } else { renderer.fillRect(rect.x, rect.y, rect.width, rect.height, true); } } } else { if (metrics.keyboardFillUnselected) { if (keyType == KeyboardKeyType::Disabled) { if (cr > 0) { renderer.fillRoundedRect(rect.x, rect.y, rect.width, rect.height, cr, Color::LightGray); } else { renderer.fillRectDither(rect.x, rect.y, rect.width, rect.height, Color::LightGray); } } else { if (cr > 0) { renderer.fillRoundedRect(rect.x, rect.y, rect.width, rect.height, cr, Color::White); } else { renderer.fillRect(rect.x, rect.y, rect.width, rect.height, false); } } } const bool shouldDrawOutline = (metrics.keyboardDrawSpecialOutlineWhenUnselected && isSpecialKey) || metrics.keyboardOutlineAllUnselected; if (shouldDrawOutline) { if (cr > 0) { renderer.drawRoundedRect(rect.x, rect.y, rect.width, rect.height, 1, cr, true); } else { renderer.drawRect(rect.x, rect.y, rect.width, rect.height); } } } const bool invert = isSelected && !inactiveSelection; if (keyType == KeyboardKeyType::Space) { const int lineHalfWidth = rect.width * 3 / 10; const int centerX = rect.x + rect.width / 2; const int lineY = rect.y + rect.height / 2 + 3; renderer.drawLine(centerX - lineHalfWidth, lineY, centerX + lineHalfWidth, lineY, 3, !invert); return; } if (keyType == KeyboardKeyType::Del) { const int centerX = rect.x + rect.width / 2; const int centerY = rect.y + rect.height / 2; const int arrowLen = rect.width / 4; const int arrowHead = std::max(metrics.keyboardMinArrowHeadSize, arrowLen / 2); renderer.drawLine(centerX - arrowLen / 2, centerY, centerX + arrowLen / 2, centerY, 3, !invert); renderer.drawLine(centerX - arrowLen / 2, centerY, centerX - arrowLen / 2 + arrowHead, centerY - arrowHead, 3, !invert); renderer.drawLine(centerX - arrowLen / 2, centerY, centerX - arrowLen / 2 + arrowHead, centerY + arrowHead, 3, !invert); return; } if (label == nullptr || label[0] == '\0') { return; } const bool hasSecondary = secondaryLabel != nullptr && secondaryLabel[0] != '\0'; const int itemWidth = renderer.getTextWidth(UI_12_FONT_ID, label); const int textX = rect.x + (rect.width - itemWidth) / 2; const int textY = rect.y + (rect.height - renderer.getLineHeight(UI_12_FONT_ID)) / 2; renderer.drawText(UI_12_FONT_ID, textX, textY, label, !invert); if (hasSecondary) { const int secWidth = renderer.getTextWidth(SMALL_FONT_ID, secondaryLabel); renderer.drawText(SMALL_FONT_ID, rect.x + rect.width - secWidth - metrics.keyboardSecondaryLabelRightPadding, rect.y + metrics.keyboardSecondaryLabelTopPadding, secondaryLabel, !invert); } } void BaseTheme::drawOptionPopup(const GfxRenderer& renderer, const char* title, const std::vector& options, int selectedIndex) const { const auto& metrics = UITheme::getInstance().getMetrics(); const auto pageWidth = renderer.getScreenWidth(); const auto pageHeight = renderer.getScreenHeight(); const int optionFontId = metrics.optionPopupUseSmallFont ? UI_10_FONT_ID : UI_12_FONT_ID; const EpdFontFamily::Style optionStyle = metrics.optionPopupOptionFontBold ? EpdFontFamily::BOLD : EpdFontFamily::REGULAR; const int itemSpacing = metrics.optionPopupItemSpacing; const int innerPadding = metrics.optionPopupInnerPadding; const int selectionHPadding = metrics.optionPopupSelectionHPadding; const int selectionVPadding = metrics.optionPopupSelectionVPadding; const int optionLineHeight = renderer.getLineHeight(optionFontId); const int titleLineHeight = renderer.getLineHeight(UI_12_FONT_ID); const int rowHeight = optionLineHeight + selectionVPadding * 2; int maxTextWidth = renderer.getTextWidth(UI_12_FONT_ID, title, EpdFontFamily::BOLD); for (const auto& opt : options) { int w = renderer.getTextWidth(optionFontId, opt.c_str(), optionStyle); if (w > maxTextWidth) maxTextWidth = w; } const int optionCount = static_cast(options.size()); const int listHeight = rowHeight * optionCount + itemSpacing * (optionCount - 1); const int dialogW = std::min((maxTextWidth + innerPadding * 2 + selectionHPadding * 2) * 12 / 10, pageWidth - metrics.optionPopupDialogSideMargin * 2); const int contentHeight = titleLineHeight + metrics.optionPopupTitleGap + listHeight; const int dialogH = contentHeight + innerPadding * 2; const int dialogX = (pageWidth - dialogW) / 2; const int dialogY = (pageHeight - dialogH) / 2; const int frameThickness = metrics.popupFrameThickness; const int frameRadius = metrics.popupCornerRadius; if (frameRadius > 0) { renderer.fillRoundedRect(dialogX - frameThickness, dialogY - frameThickness, dialogW + frameThickness * 2, dialogH + frameThickness * 2, frameRadius + frameThickness, Color::White); renderer.fillRoundedRect(dialogX, dialogY, dialogW, dialogH, frameRadius, Color::Black); renderer.fillRoundedRect(dialogX + frameThickness, dialogY + frameThickness, dialogW - frameThickness * 2, dialogH - frameThickness * 2, frameRadius - frameThickness > 0 ? frameRadius - frameThickness : 0, Color::White); } else { renderer.fillRect(dialogX - frameThickness, dialogY - frameThickness, dialogW + frameThickness * 2, dialogH + frameThickness * 2, true); renderer.fillRect(dialogX, dialogY, dialogW, dialogH, false); } int y = dialogY + innerPadding; renderer.drawCenteredText(UI_12_FONT_ID, y, title, true, EpdFontFamily::BOLD); y += titleLineHeight; if (metrics.optionPopupTitleSeparator) { const int sepY = y + metrics.optionPopupTitleGap / 2; renderer.drawLine(dialogX + innerPadding, sepY, dialogX + dialogW - innerPadding, sepY, true); } y += metrics.optionPopupTitleGap; const int itemRectX = dialogX + innerPadding; const int itemRectW = dialogW - innerPadding * 2; const int selectionRadius = metrics.optionPopupSelectionRadius; for (int i = 0; i < optionCount; i++) { const int itemY = y + i * (rowHeight + itemSpacing); const bool selected = (i == selectedIndex); const char* labelText = options[i].c_str(); if (metrics.optionPopupDrawAllRows || selected) { Color rowColor; if (selected) { rowColor = metrics.optionPopupSelectionLight ? Color::LightGray : Color::Black; } else { rowColor = Color::White; } if (selectionRadius > 0) { renderer.fillRoundedRect(itemRectX, itemY, itemRectW, rowHeight, selectionRadius, rowColor); } else { renderer.fillRect(itemRectX, itemY, itemRectW, rowHeight, rowColor == Color::Black); } } const int textW = renderer.getTextWidth(optionFontId, labelText, optionStyle); const int textY = itemY + (rowHeight - optionLineHeight) / 2; const int textX = itemRectX + (itemRectW - textW) / 2; // Unselected items: text is dark (invert=true means draw on white bg). // Selected on dark bg: text must be white (invert=false). // Selected on light bg: text stays dark (invert=true). const bool invertText = selected ? metrics.optionPopupSelectionLight : true; renderer.drawText(optionFontId, textX, textY, labelText, invertText, optionStyle); } }