- 1-book view: horizontal layout with cover left, title/author right - 2-3 book view: fix cover stretching by preserving aspect ratio - 0-book view: show "Choose something to read" placeholder - Selection highlight now fully contains title and author text - Add optional clock display in home screen header (AM/PM or 24H) - Add "Home Screen Clock" setting under Display - Add "Set Time" activity for manual clock setting via Settings - Increase homeCoverTileHeight to 310 for title/author breathing room Co-authored-by: Cursor <cursoragent@cursor.com>
562 lines
25 KiB
C++
562 lines
25 KiB
C++
#include "LyraTheme.h"
|
|
|
|
#include <GfxRenderer.h>
|
|
#include <HalStorage.h>
|
|
#include <I18n.h>
|
|
#include <Utf8.h>
|
|
|
|
#include <cstdint>
|
|
#include <string>
|
|
#include <vector>
|
|
|
|
#include "Battery.h"
|
|
#include "RecentBooksStore.h"
|
|
#include "components/UITheme.h"
|
|
#include "fontIds.h"
|
|
#include "util/StringUtils.h"
|
|
|
|
// Internal constants
|
|
namespace {
|
|
constexpr int batteryPercentSpacing = 4;
|
|
constexpr int hPaddingInSelection = 8;
|
|
constexpr int cornerRadius = 6;
|
|
constexpr int topHintButtonY = 345;
|
|
} // namespace
|
|
|
|
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 = battery.readPercentage();
|
|
const int y = rect.y + 6;
|
|
const int battWidth = LyraMetrics::values.batteryWidth;
|
|
|
|
if (showPercentage) {
|
|
const auto percentageText = std::to_string(percentage) + "%";
|
|
renderer.drawText(SMALL_FONT_ID, rect.x + batteryPercentSpacing + battWidth, rect.y, percentageText.c_str());
|
|
}
|
|
|
|
// Draw icon
|
|
const int x = rect.x;
|
|
// Top line
|
|
renderer.drawLine(x + 1, y, x + battWidth - 3, y);
|
|
// Bottom line
|
|
renderer.drawLine(x + 1, y + rect.height - 1, x + battWidth - 3, y + rect.height - 1);
|
|
// Left line
|
|
renderer.drawLine(x, y + 1, x, y + rect.height - 2);
|
|
// Battery end
|
|
renderer.drawLine(x + battWidth - 2, y + 1, x + battWidth - 2, y + rect.height - 2);
|
|
renderer.drawPixel(x + battWidth - 1, y + 3);
|
|
renderer.drawPixel(x + battWidth - 1, y + rect.height - 4);
|
|
renderer.drawLine(x + battWidth - 0, y + 4, x + battWidth - 0, y + rect.height - 5);
|
|
|
|
// Draw bars
|
|
if (percentage > 10) {
|
|
renderer.fillRect(x + 2, y + 2, 3, rect.height - 4);
|
|
}
|
|
if (percentage > 40) {
|
|
renderer.fillRect(x + 6, y + 2, 3, rect.height - 4);
|
|
}
|
|
if (percentage > 70) {
|
|
renderer.fillRect(x + 10, y + 2, 3, rect.height - 4);
|
|
}
|
|
}
|
|
|
|
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 = battery.readPercentage();
|
|
const int y = rect.y + 6;
|
|
const int battWidth = LyraMetrics::values.batteryWidth;
|
|
|
|
if (showPercentage) {
|
|
const auto percentageText = std::to_string(percentage) + "%";
|
|
const int textWidth = renderer.getTextWidth(SMALL_FONT_ID, percentageText.c_str());
|
|
// Clear the area where we're going to draw the text to prevent ghosting
|
|
const auto textHeight = renderer.getTextHeight(SMALL_FONT_ID);
|
|
renderer.fillRect(rect.x - textWidth - batteryPercentSpacing, rect.y, textWidth, textHeight, false);
|
|
// Draw text to the left of the icon
|
|
renderer.drawText(SMALL_FONT_ID, rect.x - textWidth - batteryPercentSpacing, rect.y, percentageText.c_str());
|
|
}
|
|
|
|
// Draw icon at rect.x
|
|
const int x = rect.x;
|
|
// Top line
|
|
renderer.drawLine(x + 1, y, x + battWidth - 3, y);
|
|
// Bottom line
|
|
renderer.drawLine(x + 1, y + rect.height - 1, x + battWidth - 3, y + rect.height - 1);
|
|
// Left line
|
|
renderer.drawLine(x, y + 1, x, y + rect.height - 2);
|
|
// Battery end
|
|
renderer.drawLine(x + battWidth - 2, y + 1, x + battWidth - 2, y + rect.height - 2);
|
|
renderer.drawPixel(x + battWidth - 1, y + 3);
|
|
renderer.drawPixel(x + battWidth - 1, y + rect.height - 4);
|
|
renderer.drawLine(x + battWidth - 0, y + 4, x + battWidth - 0, y + rect.height - 5);
|
|
|
|
// Draw bars
|
|
if (percentage > 10) {
|
|
renderer.fillRect(x + 2, y + 2, 3, rect.height - 4);
|
|
}
|
|
if (percentage > 40) {
|
|
renderer.fillRect(x + 6, y + 2, 3, rect.height - 4);
|
|
}
|
|
if (percentage > 70) {
|
|
renderer.fillRect(x + 10, y + 2, 3, rect.height - 4);
|
|
}
|
|
}
|
|
|
|
void LyraTheme::drawHeader(const GfxRenderer& renderer, Rect rect, const char* title) 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);
|
|
|
|
if (title) {
|
|
auto truncatedTitle = renderer.truncatedText(
|
|
UI_12_FONT_ID, title, rect.width - LyraMetrics::values.contentSidePadding * 2, EpdFontFamily::BOLD);
|
|
renderer.drawText(UI_12_FONT_ID, rect.x + LyraMetrics::values.contentSidePadding,
|
|
rect.y + LyraMetrics::values.batteryBarHeight + 3, truncatedTitle.c_str(), true,
|
|
EpdFontFamily::BOLD);
|
|
renderer.drawLine(rect.x, rect.y + rect.height - 3, rect.x + rect.width, rect.y + rect.height - 3, 3, true);
|
|
}
|
|
}
|
|
|
|
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, 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<std::string(int index)>& rowIcon,
|
|
const std::function<std::string(int index)>& rowValue) 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);
|
|
}
|
|
|
|
// Draw selection
|
|
int contentWidth =
|
|
rect.width -
|
|
(totalPages > 1 ? (LyraMetrics::values.scrollBarWidth + LyraMetrics::values.scrollBarRightOffset) : 1);
|
|
if (selectedIndex >= 0) {
|
|
renderer.fillRoundedRect(LyraMetrics::values.contentSidePadding, rect.y + selectedIndex % pageItems * rowHeight,
|
|
contentWidth - LyraMetrics::values.contentSidePadding * 2, rowHeight, cornerRadius,
|
|
Color::LightGray);
|
|
}
|
|
|
|
// 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;
|
|
|
|
// Draw name
|
|
int textWidth = contentWidth - LyraMetrics::values.contentSidePadding * 2 - hPaddingInSelection * 2 -
|
|
(rowValue != nullptr ? 60 : 0); // TODO truncate according to value width?
|
|
auto itemName = rowTitle(i);
|
|
auto item = renderer.truncatedText(UI_10_FONT_ID, itemName.c_str(), textWidth);
|
|
renderer.drawText(UI_10_FONT_ID, rect.x + LyraMetrics::values.contentSidePadding + hPaddingInSelection * 2,
|
|
itemY + 6, item.c_str(), true);
|
|
|
|
if (rowSubtitle != nullptr) {
|
|
// Draw subtitle
|
|
std::string subtitleText = rowSubtitle(i);
|
|
auto subtitle = renderer.truncatedText(SMALL_FONT_ID, subtitleText.c_str(), textWidth);
|
|
renderer.drawText(SMALL_FONT_ID, rect.x + LyraMetrics::values.contentSidePadding + hPaddingInSelection * 2,
|
|
itemY + 30, subtitle.c_str(), true);
|
|
}
|
|
|
|
if (rowValue != nullptr) {
|
|
// Draw value
|
|
std::string valueText = rowValue(i);
|
|
if (!valueText.empty()) {
|
|
const auto valueTextWidth = renderer.getTextWidth(UI_10_FONT_ID, valueText.c_str());
|
|
|
|
if (i == selectedIndex) {
|
|
renderer.fillRoundedRect(
|
|
contentWidth - LyraMetrics::values.contentSidePadding - hPaddingInSelection * 2 - valueTextWidth, itemY,
|
|
valueTextWidth + hPaddingInSelection * 2, rowHeight, cornerRadius, Color::Black);
|
|
}
|
|
|
|
renderer.drawText(UI_10_FONT_ID,
|
|
contentWidth - LyraMetrics::values.contentSidePadding - hPaddingInSelection - valueTextWidth,
|
|
itemY + 6, valueText.c_str(), i != selectedIndex);
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
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
|
|
constexpr int buttonPositions[] = {58, 146, 254, 342};
|
|
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.fillRect(x, pageHeight - buttonY, buttonWidth, buttonHeight, false);
|
|
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.fillRect(x, pageHeight - smallButtonHeight, buttonWidth, smallButtonHeight, false);
|
|
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)
|
|
// Position for the button group - buttons share a border so they're adjacent
|
|
|
|
const char* labels[] = {topBtn, bottomBtn};
|
|
|
|
// Draw the shared border for both buttons as one unit
|
|
const int x = screenWidth - buttonWidth;
|
|
|
|
// Draw top button outline
|
|
if (topBtn != nullptr && topBtn[0] != '\0') {
|
|
renderer.drawRoundedRect(x, topHintButtonY, buttonWidth, buttonHeight, 1, cornerRadius, true, false, true, false,
|
|
true);
|
|
}
|
|
|
|
// Draw bottom button outline
|
|
if (bottomBtn != nullptr && bottomBtn[0] != '\0') {
|
|
renderer.drawRoundedRect(x, topHintButtonY + buttonHeight + 5, buttonWidth, buttonHeight, 1, cornerRadius, true,
|
|
false, true, false, true);
|
|
}
|
|
|
|
// Draw text for each button
|
|
for (int i = 0; i < 2; i++) {
|
|
if (labels[i] != nullptr && labels[i][0] != '\0') {
|
|
const int y = topHintButtonY + (i * buttonHeight + 5);
|
|
|
|
// Draw rotated text centered in the button
|
|
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 bookCount = std::min(static_cast<int>(recentBooks.size()), LyraMetrics::values.homeRecentBooksCount);
|
|
const int tileHeight = rect.height;
|
|
const int tileY = rect.y;
|
|
const int coverHeight = LyraMetrics::values.homeCoverHeight;
|
|
|
|
if (bookCount == 0) {
|
|
const int centerY = rect.y + (rect.height - renderer.getLineHeight(UI_12_FONT_ID)) / 2;
|
|
renderer.drawCenteredText(UI_12_FONT_ID, centerY, tr(STR_CHOOSE_SOMETHING), true);
|
|
return;
|
|
}
|
|
|
|
// Word-wrap helper: splits text into lines fitting maxWidth, capped at maxLines with ellipsis
|
|
auto wrapText = [&renderer](int fontId, const std::string& text, int maxWidth,
|
|
int maxLines) -> std::vector<std::string> {
|
|
std::vector<std::string> words;
|
|
words.reserve(8);
|
|
size_t pos = 0;
|
|
while (pos < text.size()) {
|
|
while (pos < text.size() && text[pos] == ' ') ++pos;
|
|
if (pos >= text.size()) break;
|
|
const size_t start = pos;
|
|
while (pos < text.size() && text[pos] != ' ') ++pos;
|
|
words.emplace_back(text.substr(start, pos - start));
|
|
}
|
|
|
|
const int spaceWidth = renderer.getSpaceWidth(fontId);
|
|
std::vector<std::string> lines;
|
|
std::string currentLine;
|
|
for (auto& word : words) {
|
|
if (static_cast<int>(lines.size()) >= maxLines) {
|
|
lines.back().append("...");
|
|
while (!lines.back().empty() && renderer.getTextWidth(fontId, lines.back().c_str()) > maxWidth) {
|
|
lines.back().resize(lines.back().size() - 3);
|
|
utf8RemoveLastChar(lines.back());
|
|
lines.back().append("...");
|
|
}
|
|
break;
|
|
}
|
|
int wordWidth = renderer.getTextWidth(fontId, word.c_str());
|
|
while (wordWidth > maxWidth && !word.empty()) {
|
|
utf8RemoveLastChar(word);
|
|
std::string withEllipsis = word + "...";
|
|
wordWidth = renderer.getTextWidth(fontId, withEllipsis.c_str());
|
|
if (wordWidth <= maxWidth) {
|
|
word = withEllipsis;
|
|
break;
|
|
}
|
|
}
|
|
int newLineWidth = renderer.getTextWidth(fontId, currentLine.c_str());
|
|
if (newLineWidth > 0) newLineWidth += spaceWidth;
|
|
newLineWidth += wordWidth;
|
|
if (newLineWidth > maxWidth && !currentLine.empty()) {
|
|
lines.push_back(currentLine);
|
|
currentLine = word;
|
|
} else {
|
|
if (!currentLine.empty()) currentLine.append(" ");
|
|
currentLine.append(word);
|
|
}
|
|
}
|
|
if (!currentLine.empty() && static_cast<int>(lines.size()) < maxLines) {
|
|
lines.push_back(currentLine);
|
|
}
|
|
return lines;
|
|
};
|
|
|
|
// Cover rendering helper: draws bitmap maintaining aspect ratio within a slot.
|
|
// Crops if wider than slot, centers if narrower. Returns actual rendered width.
|
|
auto& storage = HalStorage::getInstance();
|
|
auto renderCoverBitmap = [&renderer, &storage, coverHeight](const std::string& coverBmpPath, int slotX, int slotY,
|
|
int slotWidth) {
|
|
FsFile file;
|
|
if (storage.openFileForRead("HOME", coverBmpPath, file)) {
|
|
Bitmap bitmap(file);
|
|
if (bitmap.parseHeaders() == BmpReaderError::Ok) {
|
|
float bmpW = static_cast<float>(bitmap.getWidth());
|
|
float bmpH = static_cast<float>(bitmap.getHeight());
|
|
float ratio = bmpW / bmpH;
|
|
int naturalWidth = static_cast<int>(coverHeight * ratio);
|
|
|
|
if (naturalWidth >= slotWidth) {
|
|
float slotRatio = static_cast<float>(slotWidth) / static_cast<float>(coverHeight);
|
|
float cropX = 1.0f - (slotRatio / ratio);
|
|
renderer.drawBitmap(bitmap, slotX, slotY, slotWidth, coverHeight, cropX);
|
|
} else {
|
|
int offsetX = (slotWidth - naturalWidth) / 2;
|
|
renderer.drawBitmap(bitmap, slotX + offsetX, slotY, naturalWidth, coverHeight, 0.0f);
|
|
}
|
|
}
|
|
file.close();
|
|
}
|
|
};
|
|
|
|
if (bookCount == 1) {
|
|
// ===== SINGLE BOOK: HORIZONTAL LAYOUT (cover left, text right) =====
|
|
const bool bookSelected = (selectorIndex == 0);
|
|
const int cardX = LyraMetrics::values.contentSidePadding;
|
|
const int cardWidth = rect.width - 2 * LyraMetrics::values.contentSidePadding;
|
|
// Fixed cover slot width based on typical book aspect ratio (~0.65)
|
|
const int coverSlotWidth = static_cast<int>(coverHeight * 0.65f);
|
|
const int textGap = hPaddingInSelection * 2;
|
|
const int textAreaX = cardX + hPaddingInSelection + coverSlotWidth + textGap;
|
|
const int textAreaWidth = cardWidth - hPaddingInSelection * 2 - coverSlotWidth - textGap;
|
|
|
|
if (!coverRendered) {
|
|
renderer.drawRect(cardX + hPaddingInSelection, tileY + hPaddingInSelection, coverSlotWidth, coverHeight);
|
|
if (!recentBooks[0].coverBmpPath.empty()) {
|
|
const std::string coverBmpPath =
|
|
UITheme::getCoverThumbPath(recentBooks[0].coverBmpPath, coverHeight);
|
|
renderCoverBitmap(coverBmpPath, cardX + hPaddingInSelection, tileY + hPaddingInSelection, coverSlotWidth);
|
|
}
|
|
coverBufferStored = storeCoverBuffer();
|
|
coverRendered = true;
|
|
}
|
|
|
|
// Selection highlight: border strips around the cover, fill the text area
|
|
if (bookSelected) {
|
|
// Top strip
|
|
renderer.fillRoundedRect(cardX, tileY, cardWidth, hPaddingInSelection, cornerRadius, true, true, false, false,
|
|
Color::LightGray);
|
|
// Left strip (alongside cover)
|
|
renderer.fillRectDither(cardX, tileY + hPaddingInSelection, hPaddingInSelection, coverHeight, Color::LightGray);
|
|
// Right strip
|
|
renderer.fillRectDither(cardX + cardWidth - hPaddingInSelection, tileY + hPaddingInSelection, hPaddingInSelection,
|
|
coverHeight, Color::LightGray);
|
|
// Text area background (right of cover, alongside cover height)
|
|
renderer.fillRectDither(cardX + hPaddingInSelection + coverSlotWidth, tileY + hPaddingInSelection,
|
|
cardWidth - hPaddingInSelection * 2 - coverSlotWidth, coverHeight, Color::LightGray);
|
|
// Bottom strip (below cover, full width)
|
|
const int bottomY = tileY + hPaddingInSelection + coverHeight;
|
|
const int bottomH = tileHeight - hPaddingInSelection - coverHeight;
|
|
if (bottomH > 0) {
|
|
renderer.fillRoundedRect(cardX, bottomY, cardWidth, bottomH, cornerRadius, false, false, true, true,
|
|
Color::LightGray);
|
|
}
|
|
}
|
|
|
|
// Title: UI_12 font, wrap generously (up to 5 lines)
|
|
auto titleLines = wrapText(UI_12_FONT_ID, recentBooks[0].title, textAreaWidth, 5);
|
|
const int titleLineHeight = renderer.getLineHeight(UI_12_FONT_ID);
|
|
int textY = tileY + hPaddingInSelection + 3;
|
|
for (const auto& line : titleLines) {
|
|
renderer.drawText(UI_12_FONT_ID, textAreaX, textY, line.c_str(), true);
|
|
textY += titleLineHeight;
|
|
}
|
|
|
|
// Author: UI_10 font
|
|
if (!recentBooks[0].author.empty()) {
|
|
textY += 4;
|
|
auto author = renderer.truncatedText(UI_10_FONT_ID, recentBooks[0].author.c_str(), textAreaWidth);
|
|
renderer.drawText(UI_10_FONT_ID, textAreaX, textY, author.c_str(), true);
|
|
}
|
|
|
|
} else {
|
|
// ===== MULTI BOOK: TILE LAYOUT (2-3 books) =====
|
|
const int tileWidth = (rect.width - 2 * LyraMetrics::values.contentSidePadding) / bookCount;
|
|
// Bottom section height: everything below cover + top padding
|
|
const int bottomSectionHeight = tileHeight - coverHeight - hPaddingInSelection;
|
|
|
|
// Render covers (first render only)
|
|
if (!coverRendered) {
|
|
for (int i = 0; i < bookCount; i++) {
|
|
int tileX = LyraMetrics::values.contentSidePadding + tileWidth * i;
|
|
int drawWidth = tileWidth - 2 * hPaddingInSelection;
|
|
renderer.drawRect(tileX + hPaddingInSelection, tileY + hPaddingInSelection, drawWidth, coverHeight);
|
|
if (!recentBooks[i].coverBmpPath.empty()) {
|
|
const std::string coverBmpPath = UITheme::getCoverThumbPath(recentBooks[i].coverBmpPath, coverHeight);
|
|
renderCoverBitmap(coverBmpPath, tileX + hPaddingInSelection, tileY + hPaddingInSelection, drawWidth);
|
|
}
|
|
}
|
|
coverBufferStored = storeCoverBuffer();
|
|
coverRendered = true;
|
|
}
|
|
|
|
// Draw selection and text for each book tile
|
|
for (int i = 0; i < bookCount; i++) {
|
|
bool bookSelected = (selectorIndex == i);
|
|
int tileX = LyraMetrics::values.contentSidePadding + tileWidth * i;
|
|
const int maxTextWidth = tileWidth - 2 * hPaddingInSelection;
|
|
|
|
if (bookSelected) {
|
|
// Top strip
|
|
renderer.fillRoundedRect(tileX, tileY, tileWidth, hPaddingInSelection, cornerRadius, true, true, false, false,
|
|
Color::LightGray);
|
|
// Left/right strips alongside cover
|
|
renderer.fillRectDither(tileX, tileY + hPaddingInSelection, hPaddingInSelection, coverHeight,
|
|
Color::LightGray);
|
|
renderer.fillRectDither(tileX + tileWidth - hPaddingInSelection, tileY + hPaddingInSelection,
|
|
hPaddingInSelection, coverHeight, Color::LightGray);
|
|
// Bottom section: spans from below cover to the card bottom
|
|
renderer.fillRoundedRect(tileX, tileY + coverHeight + hPaddingInSelection, tileWidth, bottomSectionHeight,
|
|
cornerRadius, false, false, true, true, Color::LightGray);
|
|
}
|
|
|
|
// Word-wrap title to 2 lines (UI_10)
|
|
auto titleLines = wrapText(UI_10_FONT_ID, recentBooks[i].title, maxTextWidth, 2);
|
|
const int lineHeight = renderer.getLineHeight(UI_10_FONT_ID);
|
|
|
|
int textY = tileY + coverHeight + hPaddingInSelection + 4;
|
|
for (const auto& line : titleLines) {
|
|
renderer.drawText(UI_10_FONT_ID, tileX + hPaddingInSelection, textY, line.c_str(), true);
|
|
textY += lineHeight;
|
|
}
|
|
|
|
// Author below title
|
|
if (!recentBooks[i].author.empty()) {
|
|
auto author = renderer.truncatedText(SMALL_FONT_ID, recentBooks[i].author.c_str(), maxTextWidth);
|
|
renderer.drawText(SMALL_FONT_ID, tileX + hPaddingInSelection, textY + 2, author.c_str(), true);
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
void LyraTheme::drawButtonMenu(GfxRenderer& renderer, Rect rect, int buttonCount, int selectedIndex,
|
|
const std::function<std::string(int index)>& buttonLabel,
|
|
const std::function<std::string(int index)>& rowIcon) const {
|
|
for (int i = 0; i < buttonCount; ++i) {
|
|
int tileWidth = (rect.width - LyraMetrics::values.contentSidePadding * 2 - LyraMetrics::values.menuSpacing) / 2;
|
|
Rect tileRect =
|
|
Rect{rect.x + LyraMetrics::values.contentSidePadding + (LyraMetrics::values.menuSpacing + tileWidth) * (i % 2),
|
|
rect.y + static_cast<int>(i / 2) * (LyraMetrics::values.menuRowHeight + LyraMetrics::values.menuSpacing),
|
|
tileWidth, LyraMetrics::values.menuRowHeight};
|
|
|
|
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();
|
|
const int textX = tileRect.x + 16;
|
|
const int lineHeight = renderer.getLineHeight(UI_12_FONT_ID);
|
|
const int textY = tileRect.y + (LyraMetrics::values.menuRowHeight - lineHeight) / 2;
|
|
|
|
// Invert text when the tile is selected, to contrast with the filled background
|
|
renderer.drawText(UI_12_FONT_ID, textX, textY, label, true);
|
|
}
|
|
}
|
|
|
|
Rect LyraTheme::drawPopup(const GfxRenderer& renderer, const char* message) const {
|
|
constexpr int margin = 15;
|
|
constexpr int y = 60;
|
|
const int textWidth = renderer.getTextWidth(UI_12_FONT_ID, message, EpdFontFamily::REGULAR);
|
|
const int textHeight = renderer.getLineHeight(UI_12_FONT_ID);
|
|
const int w = textWidth + margin * 2;
|
|
const int h = textHeight + margin * 2;
|
|
const int x = (renderer.getScreenWidth() - w) / 2;
|
|
|
|
renderer.fillRect(x - 5, y - 5, w + 10, h + 10, false);
|
|
renderer.drawRect(x, y, w, h, true);
|
|
|
|
const int textX = x + (w - textWidth) / 2;
|
|
const int textY = y + margin - 2;
|
|
renderer.drawText(UI_12_FONT_ID, textX, textY, message, true, EpdFontFamily::REGULAR);
|
|
renderer.displayBuffer();
|
|
return Rect{x, y, w, h};
|
|
} |