Files
Crosspoint/src/components/themes/lyra/LyraTheme.cpp
T
2026-04-27 10:42:39 +02:00

804 lines
36 KiB
C++

#include "LyraTheme.h"
#include <Epub.h>
#include <FsHelpers.h>
#include <GfxRenderer.h>
#include <HalClock.h>
#include <HalGPIO.h>
#include <HalPowerManager.h>
#include <HalStorage.h>
#include <I18n.h>
#include <Txt.h>
#include <Xtc.h>
#include <cmath>
#include <cstdint>
#include <string>
#include <vector>
#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);
}
}
}
const uint8_t* 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;
}
} // namespace
// 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<int>(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<TabInfo>& 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<std::string(int index)>& rowTitle,
const std::function<std::string(int index)>& rowSubtitle,
const std::function<UIIcon(int index)>& rowIcon,
const std::function<std::string(int index)>& 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;
if (rowIcon != nullptr) {
iconSize = (rowSubtitle != nullptr) ? mainMenuIconSize : listIconSize;
textX += iconSize + hPaddingInSelection;
textWidth -= iconSize + hPaddingInSelection;
}
// Draw all items
const auto pageStartIndex = selectedIndex / pageItems * pageItems;
int iconY = (rowSubtitle != nullptr) ? 16 : 10;
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);
if (iconBitmap != nullptr) {
renderer.drawIcon(iconBitmap, rect.x + LyraMetrics::values.contentSidePadding + hPaddingInSelection,
itemY + iconY, iconSize, iconSize);
}
}
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<RecentBook>& recentBooks,
const int selectorIndex, bool& coverRendered, bool& coverBufferStored,
bool& bufferRestored, std::function<bool()> 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<int>(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;
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();
}
}
// Draw either way
renderer.drawRect(tileX + hPaddingInSelection, tileY + hPaddingInSelection, coverWidth, coverHeight, true);
if (!hasCover) {
// Render empty cover
renderer.fillRect(tileX + hPaddingInSelection, tileY + hPaddingInSelection + (coverHeight / 3), coverWidth,
2 * coverHeight / 3, true);
renderer.drawIcon(CoverIcon, tileX + hPaddingInSelection + 24, tileY + hPaddingInSelection + 24, 32, 32);
}
coverBufferStored = storeCoverBuffer();
coverRendered = coverBufferStored; // Only consider it rendered if we successfully stored the buffer
}
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<const GfxRenderer&>(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 author = renderer.truncatedText(UI_10_FONT_ID, book.author.c_str(), textWidth);
auto series = renderer.truncatedText(UI_10_FONT_ID, book.series.c_str(), textWidth);
const int titleLineHeight = renderer.getLineHeight(UI_12_FONT_ID);
const int titleBlockHeight = titleLineHeight * static_cast<int>(titleLines.size());
const int authorHeight = book.author.empty() ? 0 : (renderer.getLineHeight(UI_10_FONT_ID) * 3 / 2);
const int seriesHeight = book.series.empty() ? 0 : renderer.getLineHeight(UI_10_FONT_ID);
const int totalBlockHeight = titleBlockHeight + 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 (!book.author.empty()) {
titleY += renderer.getLineHeight(UI_10_FONT_ID) / 2;
renderer.drawText(UI_10_FONT_ID, textX, titleY, author.c_str(), true);
titleY += renderer.getLineHeight(UI_10_FONT_ID);
}
if (!book.series.empty()) {
renderer.drawText(UI_10_FONT_ID, textX, titleY, series.c_str(), true);
}
} 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<std::string(int index)>& buttonLabel,
const std::function<UIIcon(int index)>& 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 {
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) {
renderer.drawRoundedRect(rect.x, rect.y, rect.width, rect.height, 1, cornerRadius, true);
}
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 = 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);
}
}