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:
@@ -370,6 +370,11 @@ enum class StrId : uint16_t {
|
||||
STR_OVERRIDE_LETTERBOX_FILL,
|
||||
STR_PREFERRED_PORTRAIT,
|
||||
STR_PREFERRED_LANDSCAPE,
|
||||
STR_CHOOSE_SOMETHING,
|
||||
STR_HOME_SCREEN_CLOCK,
|
||||
STR_CLOCK_AMPM,
|
||||
STR_CLOCK_24H,
|
||||
STR_SET_TIME,
|
||||
// Sentinel - must be last
|
||||
_COUNT
|
||||
};
|
||||
|
||||
@@ -315,3 +315,8 @@ STR_UPLOAD: "Nahrát"
|
||||
STR_BOOK_S_STYLE: "Styl knihy"
|
||||
STR_EMBEDDED_STYLE: "Vložený styl"
|
||||
STR_OPDS_SERVER_URL: "URL serveru OPDS"
|
||||
STR_CHOOSE_SOMETHING: "Vyberte si něco ke čtení"
|
||||
STR_HOME_SCREEN_CLOCK: "Hodiny na domovské obrazovce"
|
||||
STR_CLOCK_AMPM: "AM/PM"
|
||||
STR_CLOCK_24H: "24 hodin"
|
||||
STR_SET_TIME: "Nastavit čas"
|
||||
|
||||
@@ -336,3 +336,8 @@ STR_TOGGLE_FONT_SIZE: "Toggle Font Size"
|
||||
STR_OVERRIDE_LETTERBOX_FILL: "Override Letterbox Fill"
|
||||
STR_PREFERRED_PORTRAIT: "Preferred Portrait"
|
||||
STR_PREFERRED_LANDSCAPE: "Preferred Landscape"
|
||||
STR_CHOOSE_SOMETHING: "Choose something to read"
|
||||
STR_HOME_SCREEN_CLOCK: "Home Screen Clock"
|
||||
STR_CLOCK_AMPM: "AM/PM"
|
||||
STR_CLOCK_24H: "24 Hour"
|
||||
STR_SET_TIME: "Set Time"
|
||||
|
||||
@@ -315,3 +315,8 @@ STR_UPLOAD: "Envoi"
|
||||
STR_BOOK_S_STYLE: "Style du livre"
|
||||
STR_EMBEDDED_STYLE: "Style intégré"
|
||||
STR_OPDS_SERVER_URL: "URL du serveur OPDS"
|
||||
STR_CHOOSE_SOMETHING: "Choisissez quelque chose à lire"
|
||||
STR_HOME_SCREEN_CLOCK: "Horloge écran d'accueil"
|
||||
STR_CLOCK_AMPM: "AM/PM"
|
||||
STR_CLOCK_24H: "24 heures"
|
||||
STR_SET_TIME: "Régler l'heure"
|
||||
|
||||
@@ -315,3 +315,8 @@ STR_UPLOAD: "Hochladen"
|
||||
STR_BOOK_S_STYLE: "Buch-Stil"
|
||||
STR_EMBEDDED_STYLE: "Eingebetteter Stil"
|
||||
STR_OPDS_SERVER_URL: "OPDS-Server-URL"
|
||||
STR_CHOOSE_SOMETHING: "Wähle etwas zum Lesen"
|
||||
STR_HOME_SCREEN_CLOCK: "Startbildschirm-Uhr"
|
||||
STR_CLOCK_AMPM: "AM/PM"
|
||||
STR_CLOCK_24H: "24 Stunden"
|
||||
STR_SET_TIME: "Uhrzeit einstellen"
|
||||
|
||||
@@ -315,3 +315,8 @@ STR_UPLOAD: "Enviar"
|
||||
STR_BOOK_S_STYLE: "Estilo do livro"
|
||||
STR_EMBEDDED_STYLE: "Estilo embutido"
|
||||
STR_OPDS_SERVER_URL: "URL do servidor OPDS"
|
||||
STR_CHOOSE_SOMETHING: "Escolha algo para ler"
|
||||
STR_HOME_SCREEN_CLOCK: "Relógio da tela inicial"
|
||||
STR_CLOCK_AMPM: "AM/PM"
|
||||
STR_CLOCK_24H: "24 horas"
|
||||
STR_SET_TIME: "Definir hora"
|
||||
|
||||
@@ -315,3 +315,8 @@ STR_UPLOAD: "Отправить"
|
||||
STR_BOOK_S_STYLE: "Стиль книги"
|
||||
STR_EMBEDDED_STYLE: "Встроенный стиль"
|
||||
STR_OPDS_SERVER_URL: "URL OPDS сервера"
|
||||
STR_CHOOSE_SOMETHING: "Выберите что-нибудь для чтения"
|
||||
STR_HOME_SCREEN_CLOCK: "Часы на главном экране"
|
||||
STR_CLOCK_AMPM: "AM/PM"
|
||||
STR_CLOCK_24H: "24 часа"
|
||||
STR_SET_TIME: "Установить время"
|
||||
|
||||
@@ -315,3 +315,8 @@ STR_UPLOAD: "Subir"
|
||||
STR_BOOK_S_STYLE: "Estilo del libro"
|
||||
STR_EMBEDDED_STYLE: "Estilo integrado"
|
||||
STR_OPDS_SERVER_URL: "URL del servidor OPDS"
|
||||
STR_CHOOSE_SOMETHING: "Elige algo para leer"
|
||||
STR_HOME_SCREEN_CLOCK: "Reloj de pantalla de inicio"
|
||||
STR_CLOCK_AMPM: "AM/PM"
|
||||
STR_CLOCK_24H: "24 horas"
|
||||
STR_SET_TIME: "Establecer hora"
|
||||
|
||||
@@ -315,3 +315,8 @@ STR_UPLOAD: "Uppladdning"
|
||||
STR_BOOK_S_STYLE: "Bokstil"
|
||||
STR_EMBEDDED_STYLE: "Inbäddad stil"
|
||||
STR_OPDS_SERVER_URL: "OPDS-serveradress"
|
||||
STR_CHOOSE_SOMETHING: "Välj något att läsa"
|
||||
STR_HOME_SCREEN_CLOCK: "Klocka på hemskärmen"
|
||||
STR_CLOCK_AMPM: "AM/PM"
|
||||
STR_CLOCK_24H: "24 timmar"
|
||||
STR_SET_TIME: "Ställ in tid"
|
||||
|
||||
@@ -138,6 +138,7 @@ uint8_t CrossPointSettings::writeSettings(FsFile& file, bool count_only) const {
|
||||
// New fields added at end for backward compatibility
|
||||
writer.writeItem(file, preferredPortrait);
|
||||
writer.writeItem(file, preferredLandscape);
|
||||
writer.writeItem(file, homeScreenClock);
|
||||
|
||||
return writer.item_count;
|
||||
}
|
||||
@@ -273,6 +274,8 @@ bool CrossPointSettings::loadFromFile() {
|
||||
if (++settingsRead >= fileSettingsCount) break;
|
||||
readAndValidate(inputFile, preferredLandscape, ORIENTATION_COUNT);
|
||||
if (++settingsRead >= fileSettingsCount) break;
|
||||
readAndValidate(inputFile, homeScreenClock, CLOCK_FORMAT_COUNT);
|
||||
if (++settingsRead >= fileSettingsCount) break;
|
||||
} while (false);
|
||||
|
||||
if (frontButtonMappingRead) {
|
||||
|
||||
@@ -128,6 +128,9 @@ class CrossPointSettings {
|
||||
// UI Theme
|
||||
enum UI_THEME { CLASSIC = 0, LYRA = 1 };
|
||||
|
||||
// Home screen clock format
|
||||
enum CLOCK_FORMAT { CLOCK_OFF = 0, CLOCK_AMPM = 1, CLOCK_24H = 2, CLOCK_FORMAT_COUNT };
|
||||
|
||||
// Sleep screen settings
|
||||
uint8_t sleepScreen = DARK;
|
||||
// Sleep screen cover mode settings
|
||||
@@ -189,6 +192,9 @@ class CrossPointSettings {
|
||||
uint8_t preferredPortrait = PORTRAIT;
|
||||
uint8_t preferredLandscape = LANDSCAPE_CW;
|
||||
|
||||
// Home screen clock display format (OFF by default)
|
||||
uint8_t homeScreenClock = CLOCK_OFF;
|
||||
|
||||
~CrossPointSettings() = default;
|
||||
|
||||
// Get singleton instance
|
||||
|
||||
@@ -76,6 +76,9 @@ inline std::vector<SettingInfo> getSettingsList() {
|
||||
{StrId::STR_THEME_CLASSIC, StrId::STR_THEME_LYRA}, "uiTheme", StrId::STR_CAT_DISPLAY),
|
||||
SettingInfo::Toggle(StrId::STR_SUNLIGHT_FADING_FIX, &CrossPointSettings::fadingFix, "fadingFix",
|
||||
StrId::STR_CAT_DISPLAY),
|
||||
SettingInfo::Enum(StrId::STR_HOME_SCREEN_CLOCK, &CrossPointSettings::homeScreenClock,
|
||||
{StrId::STR_STATE_OFF, StrId::STR_CLOCK_AMPM, StrId::STR_CLOCK_24H}, "homeScreenClock",
|
||||
StrId::STR_CAT_DISPLAY),
|
||||
|
||||
// --- Reader ---
|
||||
SettingInfo::DynamicEnum(
|
||||
|
||||
@@ -9,7 +9,9 @@
|
||||
#include <PlaceholderCoverGenerator.h>
|
||||
#include <Xtc.h>
|
||||
|
||||
#include <cstdio>
|
||||
#include <cstring>
|
||||
#include <ctime>
|
||||
#include <vector>
|
||||
|
||||
#include "Battery.h"
|
||||
@@ -238,6 +240,23 @@ void HomeActivity::render(Activity::RenderLock&&) {
|
||||
|
||||
GUI.drawHeader(renderer, Rect{0, metrics.topPadding, pageWidth, metrics.homeTopPadding}, nullptr);
|
||||
|
||||
// Draw clock in the header area (left side) if enabled
|
||||
if (SETTINGS.homeScreenClock != CrossPointSettings::CLOCK_OFF) {
|
||||
time_t now = time(nullptr);
|
||||
struct tm* t = localtime(&now);
|
||||
if (t != nullptr && t->tm_year > 100) {
|
||||
char timeBuf[16];
|
||||
if (SETTINGS.homeScreenClock == CrossPointSettings::CLOCK_24H) {
|
||||
snprintf(timeBuf, sizeof(timeBuf), "%02d:%02d", t->tm_hour, t->tm_min);
|
||||
} else {
|
||||
int hour12 = t->tm_hour % 12;
|
||||
if (hour12 == 0) hour12 = 12;
|
||||
snprintf(timeBuf, sizeof(timeBuf), "%d:%02d %s", hour12, t->tm_min, t->tm_hour >= 12 ? "PM" : "AM");
|
||||
}
|
||||
renderer.drawText(SMALL_FONT_ID, metrics.contentSidePadding, metrics.topPadding, timeBuf, true);
|
||||
}
|
||||
}
|
||||
|
||||
GUI.drawRecentBookCover(renderer, Rect{0, metrics.homeTopPadding, pageWidth, metrics.homeCoverTileHeight},
|
||||
recentBooks, selectorIndex, coverRendered, coverBufferStored, bufferRestored,
|
||||
std::bind(&HomeActivity::storeCoverBuffer, this));
|
||||
|
||||
157
src/activities/settings/SetTimeActivity.cpp
Normal file
157
src/activities/settings/SetTimeActivity.cpp
Normal file
@@ -0,0 +1,157 @@
|
||||
#include "SetTimeActivity.h"
|
||||
|
||||
#include <GfxRenderer.h>
|
||||
#include <I18n.h>
|
||||
|
||||
#include <cstdio>
|
||||
#include <ctime>
|
||||
#include <sys/time.h>
|
||||
|
||||
#include "CrossPointSettings.h"
|
||||
#include "MappedInputManager.h"
|
||||
#include "components/UITheme.h"
|
||||
#include "fontIds.h"
|
||||
|
||||
void SetTimeActivity::onEnter() {
|
||||
Activity::onEnter();
|
||||
|
||||
// Initialize from current system time if it's been set (year > 2000)
|
||||
time_t now = time(nullptr);
|
||||
struct tm* t = localtime(&now);
|
||||
if (t != nullptr && t->tm_year > 100) {
|
||||
hour = t->tm_hour;
|
||||
minute = t->tm_min;
|
||||
} else {
|
||||
hour = 12;
|
||||
minute = 0;
|
||||
}
|
||||
|
||||
selectedField = 0;
|
||||
requestUpdate();
|
||||
}
|
||||
|
||||
void SetTimeActivity::onExit() { Activity::onExit(); }
|
||||
|
||||
void SetTimeActivity::loop() {
|
||||
// Back button: discard and exit
|
||||
if (mappedInput.wasReleased(MappedInputManager::Button::Back)) {
|
||||
onBack();
|
||||
return;
|
||||
}
|
||||
|
||||
// Confirm button: apply time and exit
|
||||
if (mappedInput.wasReleased(MappedInputManager::Button::Confirm)) {
|
||||
applyTime();
|
||||
onBack();
|
||||
return;
|
||||
}
|
||||
|
||||
// Left/Right: switch between hour and minute fields
|
||||
if (mappedInput.wasReleased(MappedInputManager::Button::Left)) {
|
||||
selectedField = 0;
|
||||
requestUpdate();
|
||||
return;
|
||||
}
|
||||
if (mappedInput.wasReleased(MappedInputManager::Button::Right)) {
|
||||
selectedField = 1;
|
||||
requestUpdate();
|
||||
return;
|
||||
}
|
||||
|
||||
// Up/Down: increment/decrement the selected field
|
||||
if (mappedInput.wasPressed(MappedInputManager::Button::Up)) {
|
||||
if (selectedField == 0) {
|
||||
hour = (hour + 1) % 24;
|
||||
} else {
|
||||
minute = (minute + 1) % 60;
|
||||
}
|
||||
requestUpdate();
|
||||
return;
|
||||
}
|
||||
if (mappedInput.wasPressed(MappedInputManager::Button::Down)) {
|
||||
if (selectedField == 0) {
|
||||
hour = (hour + 23) % 24;
|
||||
} else {
|
||||
minute = (minute + 59) % 60;
|
||||
}
|
||||
requestUpdate();
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
void SetTimeActivity::render(Activity::RenderLock&&) {
|
||||
renderer.clearScreen();
|
||||
|
||||
const auto pageWidth = renderer.getScreenWidth();
|
||||
const int lineHeight12 = renderer.getLineHeight(UI_12_FONT_ID);
|
||||
|
||||
// Title
|
||||
renderer.drawCenteredText(UI_12_FONT_ID, 20, tr(STR_SET_TIME), true, EpdFontFamily::BOLD);
|
||||
|
||||
// Format hour and minute strings
|
||||
char hourStr[4];
|
||||
char minuteStr[4];
|
||||
snprintf(hourStr, sizeof(hourStr), "%02d", hour);
|
||||
snprintf(minuteStr, sizeof(minuteStr), "%02d", minute);
|
||||
|
||||
const int colonWidth = renderer.getTextWidth(UI_12_FONT_ID, " : ");
|
||||
const int digitWidth = renderer.getTextWidth(UI_12_FONT_ID, "00");
|
||||
const int totalWidth = digitWidth * 2 + colonWidth;
|
||||
const int startX = (pageWidth - totalWidth) / 2;
|
||||
const int timeY = 80;
|
||||
|
||||
// Draw selection highlight behind the selected field
|
||||
constexpr int highlightPad = 6;
|
||||
if (selectedField == 0) {
|
||||
renderer.fillRoundedRect(startX - highlightPad, timeY - 4, digitWidth + highlightPad * 2, lineHeight12 + 8, 6,
|
||||
Color::LightGray);
|
||||
} else {
|
||||
renderer.fillRoundedRect(startX + digitWidth + colonWidth - highlightPad, timeY - 4,
|
||||
digitWidth + highlightPad * 2, lineHeight12 + 8, 6, Color::LightGray);
|
||||
}
|
||||
|
||||
// Draw the time digits and colon
|
||||
renderer.drawText(UI_12_FONT_ID, startX, timeY, hourStr, true);
|
||||
renderer.drawText(UI_12_FONT_ID, startX + digitWidth, timeY, " : ", true);
|
||||
renderer.drawText(UI_12_FONT_ID, startX + digitWidth + colonWidth, timeY, minuteStr, true);
|
||||
|
||||
// Draw up/down arrows above and below the selected field
|
||||
const int arrowX = (selectedField == 0) ? startX + digitWidth / 2 : startX + digitWidth + colonWidth + digitWidth / 2;
|
||||
const int arrowUpY = timeY - 20;
|
||||
const int arrowDownY = timeY + lineHeight12 + 12;
|
||||
// Up arrow (simple triangle using lines)
|
||||
constexpr int arrowSize = 6;
|
||||
for (int row = 0; row < arrowSize; row++) {
|
||||
renderer.drawLine(arrowX - row, arrowUpY + row, arrowX + row, arrowUpY + row);
|
||||
}
|
||||
// Down arrow
|
||||
for (int row = 0; row < arrowSize; row++) {
|
||||
renderer.drawLine(arrowX - row, arrowDownY + arrowSize - 1 - row, arrowX + row, arrowDownY + arrowSize - 1 - row);
|
||||
}
|
||||
|
||||
// Button hints
|
||||
const auto labels = mappedInput.mapLabels(tr(STR_BACK), tr(STR_SAVE), tr(STR_DIR_UP), tr(STR_DIR_DOWN));
|
||||
GUI.drawButtonHints(renderer, labels.btn1, labels.btn2, labels.btn3, labels.btn4);
|
||||
|
||||
renderer.displayBuffer();
|
||||
}
|
||||
|
||||
void SetTimeActivity::applyTime() {
|
||||
time_t now = time(nullptr);
|
||||
struct tm newTime = {};
|
||||
struct tm* current = localtime(&now);
|
||||
if (current != nullptr && current->tm_year > 100) {
|
||||
newTime = *current;
|
||||
} else {
|
||||
// If time was never set, use a reasonable date (2025-01-01)
|
||||
newTime.tm_year = 125; // years since 1900
|
||||
newTime.tm_mon = 0;
|
||||
newTime.tm_mday = 1;
|
||||
}
|
||||
newTime.tm_hour = hour;
|
||||
newTime.tm_min = minute;
|
||||
newTime.tm_sec = 0;
|
||||
time_t newEpoch = mktime(&newTime);
|
||||
struct timeval tv = {.tv_sec = newEpoch, .tv_usec = 0};
|
||||
settimeofday(&tv, nullptr);
|
||||
}
|
||||
27
src/activities/settings/SetTimeActivity.h
Normal file
27
src/activities/settings/SetTimeActivity.h
Normal file
@@ -0,0 +1,27 @@
|
||||
#pragma once
|
||||
|
||||
#include <functional>
|
||||
|
||||
#include "activities/Activity.h"
|
||||
|
||||
class SetTimeActivity final : public Activity {
|
||||
public:
|
||||
explicit SetTimeActivity(GfxRenderer& renderer, MappedInputManager& mappedInput,
|
||||
const std::function<void()>& onBack)
|
||||
: Activity("SetTime", renderer, mappedInput), onBack(onBack) {}
|
||||
|
||||
void onEnter() override;
|
||||
void onExit() override;
|
||||
void loop() override;
|
||||
void render(Activity::RenderLock&&) override;
|
||||
|
||||
private:
|
||||
const std::function<void()> onBack;
|
||||
|
||||
// 0 = editing hours, 1 = editing minutes
|
||||
uint8_t selectedField = 0;
|
||||
int hour = 12;
|
||||
int minute = 0;
|
||||
|
||||
void applyTime();
|
||||
};
|
||||
@@ -11,6 +11,7 @@
|
||||
#include "LanguageSelectActivity.h"
|
||||
#include "MappedInputManager.h"
|
||||
#include "OtaUpdateActivity.h"
|
||||
#include "SetTimeActivity.h"
|
||||
#include "SettingsList.h"
|
||||
#include "activities/network/WifiSelectionActivity.h"
|
||||
#include "components/UITheme.h"
|
||||
@@ -43,6 +44,7 @@ void SettingsActivity::onEnter() {
|
||||
}
|
||||
|
||||
// Append device-only ACTION items
|
||||
displaySettings.push_back(SettingInfo::Action(StrId::STR_SET_TIME, SettingAction::SetTime));
|
||||
controlsSettings.insert(controlsSettings.begin(),
|
||||
SettingInfo::Action(StrId::STR_REMAP_FRONT_BUTTONS, SettingAction::RemapFrontButtons));
|
||||
systemSettings.push_back(SettingInfo::Action(StrId::STR_WIFI_NETWORKS, SettingAction::Network));
|
||||
@@ -202,6 +204,9 @@ void SettingsActivity::toggleCurrentSetting() {
|
||||
case SettingAction::Language:
|
||||
enterSubActivity(new LanguageSelectActivity(renderer, mappedInput, onComplete));
|
||||
break;
|
||||
case SettingAction::SetTime:
|
||||
enterSubActivity(new SetTimeActivity(renderer, mappedInput, onComplete));
|
||||
break;
|
||||
case SettingAction::None:
|
||||
// Do nothing
|
||||
break;
|
||||
|
||||
@@ -21,6 +21,7 @@ enum class SettingAction {
|
||||
ClearCache,
|
||||
CheckForUpdates,
|
||||
Language,
|
||||
SetTime,
|
||||
};
|
||||
|
||||
struct SettingInfo {
|
||||
|
||||
@@ -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