mod: improve home screen with adaptive layouts, clock, and set time
- 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>
This commit is contained in:
@@ -2,9 +2,12 @@
|
||||
|
||||
#include <GfxRenderer.h>
|
||||
#include <HalStorage.h>
|
||||
#include <I18n.h>
|
||||
#include <Utf8.h>
|
||||
|
||||
#include <cstdint>
|
||||
#include <string>
|
||||
#include <vector>
|
||||
|
||||
#include "Battery.h"
|
||||
#include "RecentBooksStore.h"
|
||||
@@ -300,69 +303,214 @@ void LyraTheme::drawSideButtonHints(const GfxRenderer& renderer, const char* top
|
||||
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) / 3;
|
||||
const int bookCount = std::min(static_cast<int>(recentBooks.size()), LyraMetrics::values.homeRecentBooksCount);
|
||||
const int tileHeight = rect.height;
|
||||
const int bookTitleHeight = tileHeight - LyraMetrics::values.homeCoverHeight - hPaddingInSelection;
|
||||
const int tileY = rect.y;
|
||||
const bool hasContinueReading = !recentBooks.empty();
|
||||
const int coverHeight = LyraMetrics::values.homeCoverHeight;
|
||||
|
||||
// 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) {
|
||||
if (!coverRendered) {
|
||||
for (int i = 0; i < std::min(static_cast<int>(recentBooks.size()), LyraMetrics::values.homeRecentBooksCount);
|
||||
i++) {
|
||||
std::string coverPath = recentBooks[i].coverBmpPath;
|
||||
int tileX = LyraMetrics::values.contentSidePadding + tileWidth * i;
|
||||
renderer.drawRect(tileX + hPaddingInSelection, tileY + hPaddingInSelection,
|
||||
tileWidth - 2 * hPaddingInSelection, LyraMetrics::values.homeCoverHeight);
|
||||
if (!coverPath.empty()) {
|
||||
const std::string coverBmpPath = UITheme::getCoverThumbPath(coverPath, 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;
|
||||
}
|
||||
|
||||
// First time: load cover from SD and render
|
||||
FsFile file;
|
||||
if (Storage.openFileForRead("HOME", coverBmpPath, file)) {
|
||||
Bitmap bitmap(file);
|
||||
if (bitmap.parseHeaders() == BmpReaderError::Ok) {
|
||||
float coverHeight = static_cast<float>(bitmap.getHeight());
|
||||
float coverWidth = static_cast<float>(bitmap.getWidth());
|
||||
float ratio = coverWidth / coverHeight;
|
||||
const float tileRatio = static_cast<float>(tileWidth - 2 * hPaddingInSelection) /
|
||||
static_cast<float>(LyraMetrics::values.homeCoverHeight);
|
||||
float cropX = 1.0f - (tileRatio / ratio);
|
||||
renderer.drawBitmap(bitmap, tileX + hPaddingInSelection, tileY + hPaddingInSelection,
|
||||
tileWidth - 2 * hPaddingInSelection, LyraMetrics::values.homeCoverHeight, cropX);
|
||||
}
|
||||
file.close();
|
||||
}
|
||||
// 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;
|
||||
}
|
||||
|
||||
for (int i = 0; i < std::min(static_cast<int>(recentBooks.size()), LyraMetrics::values.homeRecentBooksCount); i++) {
|
||||
bool bookSelected = (selectorIndex == i);
|
||||
// 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;
|
||||
auto title =
|
||||
renderer.truncatedText(UI_10_FONT_ID, recentBooks[i].title.c_str(), tileWidth - 2 * hPaddingInSelection);
|
||||
const int maxTextWidth = tileWidth - 2 * hPaddingInSelection;
|
||||
|
||||
if (bookSelected) {
|
||||
// Draw selection box
|
||||
// Top strip
|
||||
renderer.fillRoundedRect(tileX, tileY, tileWidth, hPaddingInSelection, cornerRadius, true, true, false, false,
|
||||
Color::LightGray);
|
||||
renderer.fillRectDither(tileX, tileY + hPaddingInSelection, hPaddingInSelection,
|
||||
LyraMetrics::values.homeCoverHeight, Color::LightGray);
|
||||
// Left/right strips alongside cover
|
||||
renderer.fillRectDither(tileX, tileY + hPaddingInSelection, hPaddingInSelection, coverHeight,
|
||||
Color::LightGray);
|
||||
renderer.fillRectDither(tileX + tileWidth - hPaddingInSelection, tileY + hPaddingInSelection,
|
||||
hPaddingInSelection, LyraMetrics::values.homeCoverHeight, Color::LightGray);
|
||||
renderer.fillRoundedRect(tileX, tileY + LyraMetrics::values.homeCoverHeight + hPaddingInSelection, tileWidth,
|
||||
bookTitleHeight, cornerRadius, false, false, true, true, Color::LightGray);
|
||||
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);
|
||||
}
|
||||
renderer.drawText(UI_10_FONT_ID, tileX + hPaddingInSelection,
|
||||
tileY + tileHeight - bookTitleHeight + hPaddingInSelection + 5, title.c_str(), true);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -23,7 +23,7 @@ constexpr ThemeMetrics values = {.batteryWidth = 16,
|
||||
.scrollBarRightOffset = 5,
|
||||
.homeTopPadding = 56,
|
||||
.homeCoverHeight = 226,
|
||||
.homeCoverTileHeight = 287,
|
||||
.homeCoverTileHeight = 310,
|
||||
.homeRecentBooksCount = 3,
|
||||
.buttonHintsHeight = 40,
|
||||
.sideButtonHintsWidth = 30,
|
||||
|
||||
Reference in New Issue
Block a user