#include "LyraTheme.h" #include #include #include #include #include #include #include #include #include #include #include #include #include #include #include "RecentBooksStore.h" #include "components/UITheme.h" #include "components/icons/book.h" #include "components/icons/book24.h" #include "components/icons/cover.h" #include "components/icons/file24.h" #include "components/icons/folder.h" #include "components/icons/folder24.h" #include "components/icons/hotspot.h" #include "components/icons/image24.h" #include "components/icons/library.h" #include "components/icons/recent.h" #include "components/icons/settings2.h" #include "components/icons/text24.h" #include "components/icons/transfer.h" #include "components/icons/weather24.h" #include "components/icons/weather32.h" #include "components/icons/wifi.h" #include "fontIds.h" // Internal constants namespace { constexpr int hPaddingInSelection = 8; constexpr int cornerRadius = 6; constexpr int topHintButtonY = 345; constexpr int popupMarginX = 16; constexpr int popupMarginY = 12; constexpr int maxListValueWidth = 200; constexpr int mainMenuIconSize = 32; constexpr int listIconSize = 24; constexpr int mainMenuColumns = 2; constexpr int minAdaptiveMenuRowHeight = 40; constexpr int progressBadgeInset = 8; int coverWidth = 0; int clampProgressPercent(const int progressPercent) { if (progressPercent < 0) return 0; if (progressPercent > 100) return 100; return progressPercent; } void drawLyraBatteryIcon(const GfxRenderer& renderer, int x, int y, int battWidth, int rectHeight, uint16_t percentage) { BaseTheme::drawBatteryOutline(renderer, x, y, battWidth, rectHeight); const bool charging = gpio.isUsbConnected(); if (charging) { // Draw solid fill when charging so lightning bolt is visible renderer.fillRect(x + 2, y + 2, battWidth - 5, rectHeight - 4); BaseTheme::drawBatteryLightningBolt(renderer, x + 4, y + 2); } else { // Draw bars when not charging if (percentage > 10) { renderer.fillRect(x + 2, y + 2, 3, rectHeight - 4); } if (percentage > 40) { renderer.fillRect(x + 6, y + 2, 3, rectHeight - 4); } if (percentage > 70) { renderer.fillRect(x + 10, y + 2, 3, rectHeight - 4); } } } } // namespace const uint8_t* LyraTheme::iconForName(UIIcon icon, int size) { if (size == 24) { switch (icon) { case UIIcon::Folder: return Folder24Icon; case UIIcon::Text: return Text24Icon; case UIIcon::Image: return Image24Icon; case UIIcon::Book: return Book24Icon; case UIIcon::File: return File24Icon; case UIIcon::Weather: return Weather24Icon; default: return nullptr; } } else if (size == 32) { switch (icon) { case UIIcon::Folder: return FolderIcon; case UIIcon::Book: return BookIcon; case UIIcon::Recent: return RecentIcon; case UIIcon::Settings: return Settings2Icon; case UIIcon::Transfer: return TransferIcon; case UIIcon::Library: return LibraryIcon; case UIIcon::Wifi: return WifiIcon; case UIIcon::Hotspot: return HotspotIcon; case UIIcon::Weather: return Weather32Icon; default: return nullptr; } } return nullptr; } // Reads the overall progress percent stored as the last byte of progress.bin. // The cache path is derived from the book path alone (no epub/xtc/txt loading needed). // Returns -1 if the file is absent or the percent byte is not yet written. int LyraTheme::getRecentBookProgressPercent(const RecentBook& book) { if (book.path.empty()) { return -1; } std::string cachePath; int percentByteOffset = 0; // byte index of the percent field in progress.bin if (FsHelpers::hasEpubExtension(book.path)) { cachePath = Epub(book.path, "/.crosspoint").getCachePath(); percentByteOffset = 6; // epub: [spineIdx(2), page(2), chapterPageCount(2), percent(1)] } else if (FsHelpers::hasXtcExtension(book.path)) { cachePath = Xtc(book.path, "/.crosspoint").getCachePath(); percentByteOffset = 4; // xtc: [page(4), percent(1)] } else if (FsHelpers::hasTxtExtension(book.path) || FsHelpers::hasMarkdownExtension(book.path)) { cachePath = Txt(book.path, "/.crosspoint").getCachePath(); percentByteOffset = 6; // [page(2), offset(4), percent(1)] } else { return -1; } FsFile progressFile; if (!Storage.openFileForRead("LYR", cachePath + "/progress.bin", progressFile)) { return -1; } uint8_t data[7]; const int dataSize = progressFile.read(data, 7); progressFile.close(); if (dataSize < percentByteOffset + 1) { return -1; // old format (or pre-render placeholder) without the percent byte } return clampProgressPercent(static_cast(data[percentByteOffset])); } void LyraTheme::drawProgressBadge(const GfxRenderer& renderer, Rect anchorRect, int progressPercent) { if (progressPercent < 0) { return; } const std::string badgeText = std::to_string(clampProgressPercent(progressPercent)) + "%"; const int textWidth = renderer.getTextWidth(SMALL_FONT_ID, badgeText.c_str()); const int textHeight = renderer.getLineHeight(SMALL_FONT_ID); const int badgeWidth = textWidth + 12; const int badgeHeight = textHeight + 6; const int badgeX = anchorRect.x + anchorRect.width - badgeWidth - progressBadgeInset; const int badgeY = anchorRect.y + progressBadgeInset; renderer.fillRoundedRect(badgeX, badgeY, badgeWidth, badgeHeight, 5, Color::Black); renderer.drawText(SMALL_FONT_ID, badgeX + (badgeWidth - textWidth) / 2, badgeY + 3, badgeText.c_str(), false); } void LyraTheme::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(); if (showPercentage) { const auto percentageText = std::to_string(percentage) + "%"; renderer.drawText(SMALL_FONT_ID, rect.x + BaseTheme::batteryPercentSpacing + LyraMetrics::values.batteryWidth, rect.y, percentageText.c_str()); } drawLyraBatteryIcon(renderer, rect.x, rect.y + 6, LyraMetrics::values.batteryWidth, rect.height, percentage); } void LyraTheme::drawBatteryRight(const GfxRenderer& renderer, Rect rect, const bool showPercentage) const { // Right aligned: percentage on left, icon on right (UI headers) const uint16_t percentage = powerManager.getBatteryPercentage(); 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 - BaseTheme::batteryPercentSpacing, rect.y, textWidth, textHeight, false); // Draw text to the left of the icon renderer.drawText(SMALL_FONT_ID, rect.x - textWidth - BaseTheme::batteryPercentSpacing, rect.y, percentageText.c_str()); } drawLyraBatteryIcon(renderer, rect.x, rect.y + 6, LyraMetrics::values.batteryWidth, rect.height, percentage); } 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 = 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 - LyraMetrics::values.batteryWidth; drawBatteryRight(renderer, Rect{batteryX, rect.y + 5, LyraMetrics::values.batteryWidth, LyraMetrics::values.batteryHeight}, showBatteryPercentage); // Draw clock in header if (SETTINGS.useClock) { char clockStr[16]; HalClock::formatTime(clockStr, sizeof(clockStr), !SETTINGS.clockFormat12h); renderer.drawText(SMALL_FONT_ID, rect.x + LyraMetrics::values.contentSidePadding, rect.y + 5, clockStr); } int maxTitleWidth = title != nullptr ? renderer.getTextWidth(UI_12_FONT_ID, title, EpdFontFamily::BOLD) : 0; int maxSubtitleWidth = subtitle != nullptr ? renderer.getTextWidth(SMALL_FONT_ID, subtitle, EpdFontFamily::REGULAR) : 0; // Available space is the distance between the side paddings, and a with side padding between title and subtitle. const int availableSpace = rect.width - LyraMetrics::values.contentSidePadding * 3; if (maxTitleWidth + maxSubtitleWidth > availableSpace) { if ((maxTitleWidth > availableSpace / 2) && (maxSubtitleWidth > availableSpace / 2)) { // Both are wider then half the space, truncate both. maxTitleWidth = availableSpace / 2; maxSubtitleWidth = availableSpace / 2; } else { // Truncate the the longest one if (maxTitleWidth > maxSubtitleWidth) { maxTitleWidth = availableSpace - maxSubtitleWidth; } else { maxSubtitleWidth = availableSpace - maxTitleWidth; } } } if (title) { 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 - 1, 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 - 1, rect.y + rect.height - 1, true); } void LyraTheme::drawTabBar(const GfxRenderer& renderer, Rect rect, const std::vector& tabs, bool selected) const { int currentX = rect.x + LyraMetrics::values.contentSidePadding; if (selected) { renderer.fillRectDither(rect.x, rect.y, rect.width, rect.height, Color::LightGray); } for (const auto& tab : tabs) { const int textWidth = renderer.getTextWidth(UI_10_FONT_ID, tab.label, EpdFontFamily::REGULAR); if (tab.selected) { if (selected) { renderer.fillRoundedRect(currentX, rect.y + 1, textWidth + 2 * hPaddingInSelection, rect.height - 4, cornerRadius, Color::Black); } else { renderer.fillRectDither(currentX, rect.y, textWidth + 2 * hPaddingInSelection, rect.height - 3, Color::LightGray); renderer.drawLine(currentX, rect.y + rect.height - 3, currentX + textWidth + 2 * hPaddingInSelection, rect.y + rect.height - 3, 2, true); } } renderer.drawText(UI_10_FONT_ID, currentX + hPaddingInSelection, rect.y + 6, tab.label, !(tab.selected && selected), EpdFontFamily::REGULAR); currentX += textWidth + LyraMetrics::values.tabSpacing + 2 * hPaddingInSelection; } renderer.drawLine(rect.x, rect.y + rect.height - 1, rect.x + rect.width - 1, rect.y + rect.height - 1, true); } void LyraTheme::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) ? LyraMetrics::values.listWithSubtitleRowHeight : LyraMetrics::values.listRowHeight; int pageItems = rect.height / rowHeight; const int totalPages = (itemCount + pageItems - 1) / pageItems; if (totalPages > 1) { const int scrollAreaHeight = rect.height; // Draw scroll bar const int scrollBarHeight = (scrollAreaHeight * pageItems) / itemCount; const int currentPage = selectedIndex / pageItems; const int scrollBarY = rect.y + ((scrollAreaHeight - scrollBarHeight) * currentPage) / (totalPages - 1); const int scrollBarX = rect.x + rect.width - LyraMetrics::values.scrollBarRightOffset; renderer.drawLine(scrollBarX, rect.y, scrollBarX, rect.y + scrollAreaHeight, true); renderer.fillRect(scrollBarX - LyraMetrics::values.scrollBarWidth, scrollBarY, LyraMetrics::values.scrollBarWidth, scrollBarHeight, true); } bool selectedIsSeparator = false; if (selectedIndex >= 0 && selectedIndex < itemCount && rowTitle != nullptr) { selectedIsSeparator = UITheme::isSeparatorTitle(rowTitle(selectedIndex)); } // Draw selection int contentWidth = rect.width - (totalPages > 1 ? (LyraMetrics::values.scrollBarWidth + LyraMetrics::values.scrollBarRightOffset) : 1); if (selectedIndex >= 0 && !selectedIsSeparator) { renderer.fillRoundedRect( rect.x + LyraMetrics::values.contentSidePadding, rect.y + selectedIndex % pageItems * rowHeight, contentWidth - LyraMetrics::values.contentSidePadding * 2, rowHeight, cornerRadius, Color::LightGray); } int textX = rect.x + LyraMetrics::values.contentSidePadding + hPaddingInSelection; int textWidth = contentWidth - LyraMetrics::values.contentSidePadding * 2 - hPaddingInSelection * 2; int iconSize = 0; int iconY = 0; if (rowIcon != nullptr) { iconSize = (rowSubtitle != nullptr) ? mainMenuIconSize : listIconSize; iconY = (rowHeight - iconSize) / 2; textX += iconSize + hPaddingInSelection; textWidth -= iconSize + hPaddingInSelection; } // 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 = textWidth; // Draw name 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; rowTextWidth -= valueWidth; } auto itemName = rowTitle(i); const bool isSeparator = UITheme::isSeparatorTitle(itemName); if (isSeparator) { itemName = UITheme::stripSeparatorTitle(itemName); drawListSeparator(renderer, Rect{rect.x + LyraMetrics::values.contentSidePadding, itemY, contentWidth - LyraMetrics::values.contentSidePadding * 2, rowHeight}, textX, rowTextWidth, itemName); continue; } auto item = renderer.truncatedText(UI_10_FONT_ID, itemName.c_str(), rowTextWidth); renderer.drawText(UI_10_FONT_ID, textX, itemY + 7, item.c_str(), true); if (rowIcon != nullptr) { UIIcon icon = rowIcon(i); const uint8_t* iconBitmap = iconForName(icon, iconSize); int renderSize = iconSize; if (iconBitmap == nullptr && rowSubtitle != nullptr) { renderSize = listIconSize; iconBitmap = iconForName(icon, renderSize); } if (iconBitmap != nullptr) { renderer.drawIcon(iconBitmap, rect.x + LyraMetrics::values.contentSidePadding + hPaddingInSelection, itemY + iconY, renderSize, renderSize); } } if (rowSubtitle != nullptr) { std::string subtitleText = rowSubtitle(i); const auto nl = subtitleText.find('\n'); if (nl != std::string::npos) { // Two-line subtitle: first line (author) at +24, second line (series) at +40 auto line1 = renderer.truncatedText(SMALL_FONT_ID, subtitleText.substr(0, nl).c_str(), rowTextWidth); renderer.drawText(SMALL_FONT_ID, textX, itemY + 24, line1.c_str(), true); auto line2 = renderer.truncatedText(SMALL_FONT_ID, subtitleText.substr(nl + 1).c_str(), rowTextWidth); renderer.drawText(SMALL_FONT_ID, textX, itemY + 40, line2.c_str(), true); } else { auto subtitle = renderer.truncatedText(SMALL_FONT_ID, subtitleText.c_str(), rowTextWidth); renderer.drawText(SMALL_FONT_ID, textX, itemY + 30, subtitle.c_str(), true); } } // Draw value if (!valueText.empty()) { if (i == selectedIndex && highlightValue) { renderer.fillRoundedRect( rect.x + contentWidth - LyraMetrics::values.contentSidePadding - hPaddingInSelection - valueWidth, itemY, valueWidth + hPaddingInSelection, rowHeight, cornerRadius, Color::Black); } renderer.drawText(UI_10_FONT_ID, rect.x + contentWidth - LyraMetrics::values.contentSidePadding - valueWidth, itemY + 6, valueText.c_str(), !(i == selectedIndex && highlightValue)); } } } void LyraTheme::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 = 80; constexpr int smallButtonHeight = 15; constexpr int buttonHeight = LyraMetrics::values.buttonHintsHeight; constexpr int buttonY = LyraMetrics::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[] = {58, 146, 254, 342}; constexpr int x3ButtonPositions[] = {65, 157, 291, 383}; const int* buttonPositions = gpio.deviceIsX3() ? x3ButtonPositions : x4ButtonPositions; const char* labels[] = {btn1, btn2, btn3, btn4}; for (int i = 0; i < 4; i++) { const int x = buttonPositions[i]; if (labels[i] != nullptr && labels[i][0] != '\0') { // Draw the filled background and border for a FULL-sized button renderer.fillRoundedRect(x, pageHeight - buttonY, buttonWidth, buttonHeight, cornerRadius, Color::White); renderer.drawRoundedRect(x, pageHeight - buttonY, buttonWidth, buttonHeight, 1, cornerRadius, true, true, false, false, true); const int textWidth = renderer.getTextWidth(SMALL_FONT_ID, labels[i]); const int textX = x + (buttonWidth - 1 - textWidth) / 2; renderer.drawText(SMALL_FONT_ID, textX, pageHeight - buttonY + textYOffset, labels[i]); } else { // Draw the filled background and border for a SMALL-sized button renderer.fillRoundedRect(x, pageHeight - smallButtonHeight, buttonWidth, smallButtonHeight, cornerRadius, Color::White); renderer.drawRoundedRect(x, pageHeight - smallButtonHeight, buttonWidth, smallButtonHeight, 1, cornerRadius, true, true, false, false, true); } } renderer.setOrientation(orig_orientation); } void LyraTheme::drawSideButtonHints(const GfxRenderer& renderer, const char* topBtn, const char* bottomBtn) const { const int screenWidth = renderer.getScreenWidth(); constexpr int buttonWidth = LyraMetrics::values.sideButtonHintsWidth; // Width on screen (height when rotated) constexpr int buttonHeight = 78; // Height on screen (width when rotated) constexpr int buttonMargin = 0; 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') { renderer.drawRoundedRect(buttonMargin, x3ButtonY, buttonWidth, buttonHeight, 1, cornerRadius, false, true, false, true, true); const int textWidth = renderer.getTextWidth(SMALL_FONT_ID, topBtn); renderer.drawTextRotated90CW(SMALL_FONT_ID, buttonMargin, x3ButtonY + (buttonHeight + textWidth) / 2, topBtn); } if (bottomBtn != nullptr && bottomBtn[0] != '\0') { const int rightX = screenWidth - buttonWidth; renderer.drawRoundedRect(rightX, x3ButtonY, buttonWidth, buttonHeight, 1, cornerRadius, true, false, true, false, true); const int textWidth = renderer.getTextWidth(SMALL_FONT_ID, bottomBtn); renderer.drawTextRotated90CW(SMALL_FONT_ID, rightX, x3ButtonY + (buttonHeight + textWidth) / 2, bottomBtn); } } else { // X4 layout: Both buttons stacked on right side const char* labels[] = {topBtn, bottomBtn}; const int x = screenWidth - buttonWidth; if (topBtn != nullptr && topBtn[0] != '\0') { renderer.drawRoundedRect(x, topHintButtonY, buttonWidth, buttonHeight, 1, cornerRadius, true, false, true, false, true); } if (bottomBtn != nullptr && bottomBtn[0] != '\0') { renderer.drawRoundedRect(x, topHintButtonY + buttonHeight + 5, buttonWidth, buttonHeight, 1, cornerRadius, true, false, true, false, true); } for (int i = 0; i < 2; i++) { if (labels[i] != nullptr && labels[i][0] != '\0') { const int y = topHintButtonY + (i * buttonHeight) + 5; const int textWidth = renderer.getTextWidth(SMALL_FONT_ID, labels[i]); renderer.drawTextRotated90CW(SMALL_FONT_ID, x, y + (buttonHeight + textWidth) / 2, labels[i]); } } } } 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; const int tileHeight = rect.height; const int tileY = rect.y; const int coverHeight = std::max(120, tileHeight - 2 * hPaddingInSelection); const bool hasContinueReading = !recentBooks.empty(); if (coverWidth == 0) { coverWidth = static_cast(coverHeight * 0.6f); } // 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]; const int progressPercent = getRecentBookProgressPercent(book); if (!coverRendered) { std::string coverPath = book.coverBmpPath; bool hasCover = true; bool coverPending = false; int tileX = LyraMetrics::values.contentSidePadding; if (coverPath.empty()) { hasCover = false; } else { const std::string coverBmpPath = UITheme::getCoverThumbPath(coverPath, coverHeight); // 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, coverHeight); } else { hasCover = false; } file.close(); } else { hasCover = false; coverPending = true; // path exists but BMP not ready yet } } // Draw either way renderer.drawRect(tileX + hPaddingInSelection, tileY + hPaddingInSelection, coverWidth, coverHeight, true); if (!hasCover) { if (coverPending) { // Cover is being generated — show a loading label centred in the cover area const char* loadingText = tr(STR_LOADING); const int textW = renderer.getTextWidth(SMALL_FONT_ID, loadingText); const int textH = renderer.getLineHeight(SMALL_FONT_ID); renderer.drawText(SMALL_FONT_ID, tileX + hPaddingInSelection + (coverWidth - textW) / 2, tileY + hPaddingInSelection + (coverHeight - textH) / 2, loadingText, true); } else { // No cover at all — render empty cover placeholder renderer.fillRect(tileX + hPaddingInSelection, tileY + hPaddingInSelection + (coverHeight / 3), coverWidth, 2 * coverHeight / 3, true); renderer.drawIcon(CoverIcon, tileX + hPaddingInSelection + 24, tileY + hPaddingInSelection + 24, 32, 32); } } // Only cache the frame buffer once the cover is definitively resolved (loaded or confirmed absent). // If a cover is still being generated we keep coverRendered=false so the next render will retry. if (!coverPending) { coverBufferStored = storeCoverBuffer(); coverRendered = coverBufferStored; } } 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, coverHeight, Color::LightGray); renderer.fillRectDither(tileX + hPaddingInSelection + coverWidth, tileY + hPaddingInSelection, tileWidth - hPaddingInSelection - coverWidth, coverHeight, Color::LightGray); renderer.fillRoundedRect(tileX, tileY + coverHeight + hPaddingInSelection, tileWidth, hPaddingInSelection, cornerRadius, false, false, true, true, Color::LightGray); } drawProgressBadge( static_cast(renderer), Rect{tileX + hPaddingInSelection + coverWidth + LyraMetrics::values.verticalSpacing, tileY, tileWidth - 2 * hPaddingInSelection - coverWidth - LyraMetrics::values.verticalSpacing, tileHeight}, progressPercent); auto titleLines = renderer.wrappedText(UI_12_FONT_ID, book.title.c_str(), textWidth, 3, EpdFontFamily::BOLD); auto authorLines = renderer.wrappedText(UI_10_FONT_ID, book.author.c_str(), textWidth, 2); auto seriesLines = renderer.wrappedText(UI_10_FONT_ID, book.series.c_str(), textWidth, 2); const int titleLineHeight = renderer.getLineHeight(UI_12_FONT_ID); const int smallLineHeight = renderer.getLineHeight(UI_10_FONT_ID); const int titleBlockHeight = titleLineHeight * static_cast(titleLines.size()); const int titleAuthorSpacing = (!authorLines.empty() || !seriesLines.empty()) ? (smallLineHeight / 2) : 0; const int authorHeight = static_cast(authorLines.size()) * smallLineHeight; const int seriesHeight = static_cast(seriesLines.size()) * smallLineHeight; const int totalBlockHeight = titleBlockHeight + titleAuthorSpacing + authorHeight + seriesHeight; int titleY = tileY + tileHeight / 2 - totalBlockHeight / 2; const int textX = tileX + hPaddingInSelection + coverWidth + LyraMetrics::values.verticalSpacing; for (const auto& line : titleLines) { renderer.drawText(UI_12_FONT_ID, textX, titleY, line.c_str(), true, EpdFontFamily::BOLD); titleY += titleLineHeight; } if (!authorLines.empty() || !seriesLines.empty()) { titleY += titleAuthorSpacing; } for (const auto& line : authorLines) { renderer.drawText(UI_10_FONT_ID, textX, titleY, line.c_str(), true); titleY += smallLineHeight; } for (const auto& line : seriesLines) { renderer.drawText(UI_10_FONT_ID, textX, titleY, line.c_str(), true); titleY += smallLineHeight; } } else { drawEmptyRecents(renderer, rect); } } void LyraTheme::drawEmptyRecents(const GfxRenderer& renderer, const Rect rect) const { constexpr int padding = 48; renderer.drawText(UI_12_FONT_ID, rect.x + padding, rect.y + rect.height / 2 - renderer.getLineHeight(UI_12_FONT_ID) - 2, tr(STR_NO_OPEN_BOOK), true, EpdFontFamily::BOLD); renderer.drawText(UI_10_FONT_ID, rect.x + padding, rect.y + rect.height / 2 + 2, tr(STR_START_READING), true); } void LyraTheme::drawButtonMenu(GfxRenderer& renderer, Rect rect, int buttonCount, int selectedIndex, const std::function& buttonLabel, const std::function& rowIcon) const { int rowHeight = LyraMetrics::values.menuRowHeight; int rowSpacing = LyraMetrics::values.menuSpacing; if (buttonCount > 0 && rect.height > 0) { const int defaultHeight = buttonCount * rowHeight + std::max(0, buttonCount - 1) * rowSpacing; if (defaultHeight > rect.height) { const int spacingSlots = std::max(1, buttonCount - 1); rowSpacing = std::max(0, (rect.height - buttonCount * rowHeight) / spacingSlots); if (buttonCount * rowHeight + std::max(0, buttonCount - 1) * rowSpacing > rect.height) { rowHeight = std::max(minAdaptiveMenuRowHeight, (rect.height - std::max(0, buttonCount - 1) * rowSpacing) / buttonCount); } if (buttonCount * rowHeight + std::max(0, buttonCount - 1) * rowSpacing > rect.height) { rowHeight = std::max(1, rect.height / buttonCount); rowSpacing = 0; } } } for (int i = 0; i < buttonCount; ++i) { int tileWidth = rect.width - LyraMetrics::values.contentSidePadding * 2; Rect tileRect = Rect{rect.x + LyraMetrics::values.contentSidePadding, rect.y + i * (rowHeight + rowSpacing), tileWidth, rowHeight}; const bool selected = selectedIndex == i; if (selected) { renderer.fillRoundedRect(tileRect.x, tileRect.y, tileRect.width, tileRect.height, cornerRadius, Color::LightGray); } std::string labelStr = buttonLabel(i); const char* label = labelStr.c_str(); int textX = tileRect.x + 16; const int lineHeight = renderer.getLineHeight(UI_12_FONT_ID); const int textY = tileRect.y + (rowHeight - lineHeight) / 2; if (rowIcon != nullptr) { UIIcon icon = rowIcon(i); int iconSize = mainMenuIconSize; const uint8_t* iconBitmap = iconForName(icon, iconSize); if (iconBitmap == nullptr) { // Some icons only have 24px variants; fall back so they still render. iconSize = listIconSize; iconBitmap = iconForName(icon, iconSize); } if (iconBitmap != nullptr) { renderer.drawIcon(iconBitmap, textX, textY + 3, iconSize, iconSize); textX += iconSize + hPaddingInSelection + 2; } } renderer.drawText(UI_12_FONT_ID, textX, textY, label, true); } } Rect LyraTheme::drawPopup(const GfxRenderer& renderer, const char* message) const { constexpr int y = 132; constexpr int outline = 2; const int textWidth = renderer.getTextWidth(UI_12_FONT_ID, message, EpdFontFamily::REGULAR); const int textHeight = renderer.getLineHeight(UI_12_FONT_ID); const int w = textWidth + popupMarginX * 2; const int h = textHeight + popupMarginY * 2; const int x = (renderer.getScreenWidth() - w) / 2; renderer.fillRoundedRect(x - outline, y - outline, w + outline * 2, h + outline * 2, cornerRadius + outline, Color::White); renderer.fillRoundedRect(x, y, w, h, cornerRadius, Color::Black); const int textX = x + (w - textWidth) / 2; const int textY = y + popupMarginY - 2; renderer.drawText(UI_12_FONT_ID, textX, textY, message, false, EpdFontFamily::REGULAR); renderer.displayBuffer(); return Rect{x, y, w, h}; } void LyraTheme::fillPopupProgress(const GfxRenderer& renderer, const Rect& layout, const int progress) const { constexpr int barHeight = 4; // Twice the margin in drawPopup to match text width const int barWidth = layout.width - popupMarginX * 2; const int barX = layout.x + (layout.width - barWidth) / 2; // Center inside the margin of drawPopup. The - 1 is added to account for the - 2 in drawPopup. const int barY = layout.y + layout.height - popupMarginY / 2 - barHeight / 2 - 1; int fillWidth = barWidth * progress / 100; renderer.fillRect(barX, barY, fillWidth, barHeight, false); renderer.displayBuffer(HalDisplay::FAST_REFRESH); } void LyraTheme::drawTextField(const GfxRenderer& renderer, Rect rect, const int textWidth, bool cursorMode, int contentStartX, int contentWidth) const { int lineY = rect.y + rect.height + renderer.getLineHeight(UI_12_FONT_ID) + LyraMetrics::values.verticalSpacing; const int thickness = cursorMode ? 3 : 2; if (contentWidth > 0) { renderer.drawLine(rect.x + contentStartX, lineY, rect.x + contentStartX + contentWidth, lineY, thickness, true); } else { int lineW = textWidth + hPaddingInSelection * 2; renderer.drawLine(rect.x + (rect.width - lineW) / 2, lineY, rect.x + (rect.width + lineW) / 2, lineY, thickness, true); } } void LyraTheme::drawKeyboardKey(const GfxRenderer& renderer, Rect rect, const char* label, const bool isSelected, const char* secondaryLabel, const KeyboardKeyType keyType, const bool inactiveSelection) const { if (isSelected) { if (inactiveSelection) { renderer.fillRoundedRect(rect.x, rect.y, rect.width, rect.height, cornerRadius, Color::LightGray); } else if (keyType == KeyboardKeyType::Disabled) { renderer.fillRoundedRect(rect.x, rect.y, rect.width, rect.height, cornerRadius, Color::LightGray); } else { renderer.fillRoundedRect(rect.x, rect.y, rect.width, rect.height, cornerRadius, Color::Black); } } else if (keyType == KeyboardKeyType::Shift || keyType == KeyboardKeyType::Mode || keyType == KeyboardKeyType::Del || keyType == KeyboardKeyType::Space || keyType == KeyboardKeyType::Ok || keyType == KeyboardKeyType::Disabled) { renderer.drawRoundedRect(rect.x, rect.y, rect.width, rect.height, 1, cornerRadius, true); } const bool invert = isSelected && !inactiveSelection && keyType != KeyboardKeyType::Disabled; 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 = 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; } const bool hasSecondary = secondaryLabel != nullptr && secondaryLabel[0] != '\0'; const int primaryOffset = 0; const int fontId = (keyType == KeyboardKeyType::Shift || keyType == KeyboardKeyType::Mode || keyType == KeyboardKeyType::Reveal || keyType == KeyboardKeyType::Ok) ? UI_10_FONT_ID : UI_12_FONT_ID; const int textWidth = renderer.getTextWidth(fontId, label); const int textX = rect.x + (rect.width - textWidth) / 2; const int textY = rect.y + (rect.height - renderer.getLineHeight(fontId)) / 2 + primaryOffset; renderer.drawText(fontId, 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 - 1, rect.y, secondaryLabel, !invert); } }