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:
cottongin
2026-02-17 00:46:05 -05:00
parent 1d7971ae60
commit 7b3de29c59
19 changed files with 459 additions and 45 deletions

View File

@@ -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);
}
}
}

View File

@@ -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,