diff --git a/lib/I18n/I18nKeys.h b/lib/I18n/I18nKeys.h index 3eca31ec..f089d2b9 100644 --- a/lib/I18n/I18nKeys.h +++ b/lib/I18n/I18nKeys.h @@ -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 }; diff --git a/lib/I18n/translations/czech.yaml b/lib/I18n/translations/czech.yaml index 4ce1b645..8072502b 100644 --- a/lib/I18n/translations/czech.yaml +++ b/lib/I18n/translations/czech.yaml @@ -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" diff --git a/lib/I18n/translations/english.yaml b/lib/I18n/translations/english.yaml index e0e989cc..936bd171 100644 --- a/lib/I18n/translations/english.yaml +++ b/lib/I18n/translations/english.yaml @@ -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" diff --git a/lib/I18n/translations/french.yaml b/lib/I18n/translations/french.yaml index 00af367c..91db26f8 100644 --- a/lib/I18n/translations/french.yaml +++ b/lib/I18n/translations/french.yaml @@ -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" diff --git a/lib/I18n/translations/german.yaml b/lib/I18n/translations/german.yaml index 0879c925..7b5099a9 100644 --- a/lib/I18n/translations/german.yaml +++ b/lib/I18n/translations/german.yaml @@ -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" diff --git a/lib/I18n/translations/portuguese.yaml b/lib/I18n/translations/portuguese.yaml index 484a33f1..b980c779 100644 --- a/lib/I18n/translations/portuguese.yaml +++ b/lib/I18n/translations/portuguese.yaml @@ -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" diff --git a/lib/I18n/translations/russian.yaml b/lib/I18n/translations/russian.yaml index 18727e32..fdfee21b 100644 --- a/lib/I18n/translations/russian.yaml +++ b/lib/I18n/translations/russian.yaml @@ -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: "Установить время" diff --git a/lib/I18n/translations/spanish.yaml b/lib/I18n/translations/spanish.yaml index 4556aad6..1a3db532 100644 --- a/lib/I18n/translations/spanish.yaml +++ b/lib/I18n/translations/spanish.yaml @@ -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" diff --git a/lib/I18n/translations/swedish.yaml b/lib/I18n/translations/swedish.yaml index 7cf2795b..57341a78 100644 --- a/lib/I18n/translations/swedish.yaml +++ b/lib/I18n/translations/swedish.yaml @@ -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" diff --git a/src/CrossPointSettings.cpp b/src/CrossPointSettings.cpp index 15912766..a1e60612 100644 --- a/src/CrossPointSettings.cpp +++ b/src/CrossPointSettings.cpp @@ -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) { diff --git a/src/CrossPointSettings.h b/src/CrossPointSettings.h index d7fade36..732449c9 100644 --- a/src/CrossPointSettings.h +++ b/src/CrossPointSettings.h @@ -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 diff --git a/src/SettingsList.h b/src/SettingsList.h index 2f78f3f9..91c6a3e7 100644 --- a/src/SettingsList.h +++ b/src/SettingsList.h @@ -76,6 +76,9 @@ inline std::vector 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( diff --git a/src/activities/home/HomeActivity.cpp b/src/activities/home/HomeActivity.cpp index f43039b8..f8bf7765 100644 --- a/src/activities/home/HomeActivity.cpp +++ b/src/activities/home/HomeActivity.cpp @@ -9,7 +9,9 @@ #include #include +#include #include +#include #include #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)); diff --git a/src/activities/settings/SetTimeActivity.cpp b/src/activities/settings/SetTimeActivity.cpp new file mode 100644 index 00000000..63648d8b --- /dev/null +++ b/src/activities/settings/SetTimeActivity.cpp @@ -0,0 +1,157 @@ +#include "SetTimeActivity.h" + +#include +#include + +#include +#include +#include + +#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); +} diff --git a/src/activities/settings/SetTimeActivity.h b/src/activities/settings/SetTimeActivity.h new file mode 100644 index 00000000..193330b7 --- /dev/null +++ b/src/activities/settings/SetTimeActivity.h @@ -0,0 +1,27 @@ +#pragma once + +#include + +#include "activities/Activity.h" + +class SetTimeActivity final : public Activity { + public: + explicit SetTimeActivity(GfxRenderer& renderer, MappedInputManager& mappedInput, + const std::function& 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 onBack; + + // 0 = editing hours, 1 = editing minutes + uint8_t selectedField = 0; + int hour = 12; + int minute = 0; + + void applyTime(); +}; diff --git a/src/activities/settings/SettingsActivity.cpp b/src/activities/settings/SettingsActivity.cpp index 5eb00ac1..3adda2e7 100644 --- a/src/activities/settings/SettingsActivity.cpp +++ b/src/activities/settings/SettingsActivity.cpp @@ -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; diff --git a/src/activities/settings/SettingsActivity.h b/src/activities/settings/SettingsActivity.h index 802060e9..10c7e1a0 100644 --- a/src/activities/settings/SettingsActivity.h +++ b/src/activities/settings/SettingsActivity.h @@ -21,6 +21,7 @@ enum class SettingAction { ClearCache, CheckForUpdates, Language, + SetTime, }; struct SettingInfo { diff --git a/src/components/themes/lyra/LyraTheme.cpp b/src/components/themes/lyra/LyraTheme.cpp index 16df6a8d..e6206639 100644 --- a/src/components/themes/lyra/LyraTheme.cpp +++ b/src/components/themes/lyra/LyraTheme.cpp @@ -2,9 +2,12 @@ #include #include +#include +#include #include #include +#include #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& recentBooks, const int selectorIndex, bool& coverRendered, bool& coverBufferStored, bool& bufferRestored, std::function storeCoverBuffer) const { - const int tileWidth = (rect.width - 2 * LyraMetrics::values.contentSidePadding) / 3; + const int bookCount = std::min(static_cast(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(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(bitmap.getHeight()); - float coverWidth = static_cast(bitmap.getWidth()); - float ratio = coverWidth / coverHeight; - const float tileRatio = static_cast(tileWidth - 2 * hPaddingInSelection) / - static_cast(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::vector 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 lines; + std::string currentLine; + for (auto& word : words) { + if (static_cast(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(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(bitmap.getWidth()); + float bmpH = static_cast(bitmap.getHeight()); + float ratio = bmpW / bmpH; + int naturalWidth = static_cast(coverHeight * ratio); + + if (naturalWidth >= slotWidth) { + float slotRatio = static_cast(slotWidth) / static_cast(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(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(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); } } } diff --git a/src/components/themes/lyra/LyraTheme.h b/src/components/themes/lyra/LyraTheme.h index 0a76471a..541ecbd9 100644 --- a/src/components/themes/lyra/LyraTheme.h +++ b/src/components/themes/lyra/LyraTheme.h @@ -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,