From b8a51522ca4cd1184054bd10cc5f785a999c7919 Mon Sep 17 00:00:00 2001 From: bunsoootchi Date: Tue, 28 Apr 2026 00:01:22 +0800 Subject: [PATCH] feat(theme): add roundedraff theme and fix sleep cover crop grid artifacts (#918) ## Summary - Add new `roundedraff` theme. - Fix sleep screen artifact where book cover showed grid lines in `Cover` mode with `Crop`. ## Additional Context ## Key changes - Added `src/components/themes/roundedraff/` theme implementation. - Updated sleep cover rendering logic in: - `src/activities/boot_sleep/SleepActivity.cpp` - `src/activities/boot_sleep/SleepActivity.h` - Improved cover generation/regeneration paths in: - `lib/Epub/Epub.cpp` - `lib/Epub/Epub.h` - `lib/Txt/Txt.cpp` - `lib/Txt/Txt.h` ## Verification - Sleep screen set to Book cover - Sleep screen cover mode set to Crop - Confirmed grid artifacts no longer appear on cover sleep screen. ### AI Usage While CrossPoint doesn't have restrictions on AI tools in contributing, please be transparent about their usage as it helps set the right context for reviewers. Did you use AI tools to help write this code? _**< YES >**_ --------- Co-authored-by: CaptainFrito Co-authored-by: Zach Nelson Co-authored-by: Uri Tauber <142022451+Uri-Tauber@users.noreply.github.com> --- lib/GfxRenderer/GfxRenderer.cpp | 35 ++ lib/GfxRenderer/GfxRenderer.h | 1 + lib/I18n/translations/czech.yaml | 1 + lib/I18n/translations/english.yaml | 1 + lib/I18n/translations/french.yaml | 1 + lib/I18n/translations/german.yaml | 1 + lib/I18n/translations/portuguese.yaml | 1 + lib/I18n/translations/russian.yaml | 1 + lib/I18n/translations/spanish.yaml | 1 + lib/I18n/translations/swedish.yaml | 1 + src/CrossPointSettings.h | 2 +- src/SettingsList.h | 5 +- src/activities/home/HomeActivity.cpp | 18 +- src/components/UITheme.cpp | 6 + src/components/themes/BaseTheme.h | 4 + src/components/themes/lyra/LyraTheme.h | 2 + .../themes/roundedraff/RoundedRaffTheme.cpp | 411 ++++++++++++++++++ .../themes/roundedraff/RoundedRaffTheme.h | 64 +++ 18 files changed, 548 insertions(+), 8 deletions(-) create mode 100644 src/components/themes/roundedraff/RoundedRaffTheme.cpp create mode 100644 src/components/themes/roundedraff/RoundedRaffTheme.h diff --git a/lib/GfxRenderer/GfxRenderer.cpp b/lib/GfxRenderer/GfxRenderer.cpp index a343badcc..70e282c56 100644 --- a/lib/GfxRenderer/GfxRenderer.cpp +++ b/lib/GfxRenderer/GfxRenderer.cpp @@ -493,6 +493,41 @@ void GfxRenderer::fillRectDither(const int x, const int y, const int width, cons } } +void GfxRenderer::maskRoundedRectOutsideCorners(const int x, const int y, const int width, const int height, + const int radius, const Color color) const { + if (radius <= 0 || color == Color::Clear) { + return; + } + + const int rr = radius - 1; + const int rr2 = rr * rr; + for (int dy = 0; dy < radius; dy++) { + for (int dx = 0; dx < radius; dx++) { + const int tx = rr - dx; + const int ty = rr - dy; + if (tx * tx + ty * ty > rr2) { + if (color == Color::White || color == Color::Black) { + bool state = color == Color::Black; + drawPixel(x + dx, y + dy, state); // top-left + drawPixel(x + width - 1 - dx, y + dy, state); // top-right + drawPixel(x + dx, y + height - 1 - dy, state); // bottom-left + drawPixel(x + width - 1 - dx, y + height - 1 - dy, state); // bottom-right + } else if (color == Color::LightGray) { + drawPixelDither(x + dx, y + dy); // top-left + drawPixelDither(x + width - 1 - dx, y + dy); // top-right + drawPixelDither(x + dx, y + height - 1 - dy); // bottom-left + drawPixelDither(x + width - 1 - dx, y + height - 1 - dy); // bottom-right + } else if (color == Color::DarkGray) { + drawPixelDither(x + dx, y + dy); // top-left + drawPixelDither(x + width - 1 - dx, y + dy); // top-right + drawPixelDither(x + dx, y + height - 1 - dy); // bottom-left + drawPixelDither(x + width - 1 - dx, y + height - 1 - dy); // bottom-right + } + } + } + } +} + template void GfxRenderer::fillArc(const int maxRadius, const int cx, const int cy, const int xDir, const int yDir) const { if (maxRadius <= 0) return; diff --git a/lib/GfxRenderer/GfxRenderer.h b/lib/GfxRenderer/GfxRenderer.h index e683e3122..9e24b39e5 100644 --- a/lib/GfxRenderer/GfxRenderer.h +++ b/lib/GfxRenderer/GfxRenderer.h @@ -100,6 +100,7 @@ class GfxRenderer { void drawRoundedRect(int x, int y, int width, int height, int lineWidth, int cornerRadius, bool state) const; void drawRoundedRect(int x, int y, int width, int height, int lineWidth, int cornerRadius, bool roundTopLeft, bool roundTopRight, bool roundBottomLeft, bool roundBottomRight, bool state) const; + void maskRoundedRectOutsideCorners(int x, int y, int width, int height, int radius, Color color = Color::White) const; void fillRect(int x, int y, int width, int height, bool state = true) const; void fillRectDither(int x, int y, int width, int height, Color color) const; void fillRoundedRect(int x, int y, int width, int height, int cornerRadius, Color color) const; diff --git a/lib/I18n/translations/czech.yaml b/lib/I18n/translations/czech.yaml index 69921ca25..9ba06d622 100644 --- a/lib/I18n/translations/czech.yaml +++ b/lib/I18n/translations/czech.yaml @@ -202,6 +202,7 @@ STR_FILTER_CONTRAST: "Kontrast" STR_UI_THEME: "Šablona rozhraní" STR_THEME_CLASSIC: "Klasická" STR_THEME_LYRA: "Lyra" +STR_THEME_ROUNDEDRAFF: "RoundedRaff" STR_THEME_LYRA_EXTENDED: "Lyra Extended" STR_SUNLIGHT_FADING_FIX: "Oprava blednutí na slunci" STR_REMAP_FRONT_BUTTONS: "Přemapovat přední tlačítka" diff --git a/lib/I18n/translations/english.yaml b/lib/I18n/translations/english.yaml index 64316bcf4..1aba1ea06 100644 --- a/lib/I18n/translations/english.yaml +++ b/lib/I18n/translations/english.yaml @@ -228,6 +228,7 @@ STR_BATTERY: "Battery" STR_UI_THEME: "UI Theme" STR_THEME_CLASSIC: "Classic" STR_THEME_LYRA: "Lyra" +STR_THEME_ROUNDEDRAFF: "RoundedRaff" STR_THEME_LYRA_EXTENDED: "Lyra Extended" STR_SUNLIGHT_FADING_FIX: "Sunlight Fading Fix" STR_REMAP_FRONT_BUTTONS: "Remap Front Buttons" diff --git a/lib/I18n/translations/french.yaml b/lib/I18n/translations/french.yaml index 3aa0293de..d300b4448 100644 --- a/lib/I18n/translations/french.yaml +++ b/lib/I18n/translations/french.yaml @@ -225,6 +225,7 @@ STR_BATTERY: "Batterie" STR_UI_THEME: "Thème interface" STR_THEME_CLASSIC: "Classique" STR_THEME_LYRA: "Lyra" +STR_THEME_ROUNDEDRAFF: "RoundedRaff" STR_THEME_LYRA_EXTENDED: "Lyra Extended" STR_SUNLIGHT_FADING_FIX: "Correction lisibilité au soleil" STR_REMAP_FRONT_BUTTONS: "Configurer boutons façade" diff --git a/lib/I18n/translations/german.yaml b/lib/I18n/translations/german.yaml index ee29fc008..8fb171112 100644 --- a/lib/I18n/translations/german.yaml +++ b/lib/I18n/translations/german.yaml @@ -225,6 +225,7 @@ STR_BATTERY: "Batterie" STR_UI_THEME: "System-Design" STR_THEME_CLASSIC: "Klassisch" STR_THEME_LYRA: "Lyra" +STR_THEME_ROUNDEDRAFF: "RoundedRaff" STR_THEME_LYRA_EXTENDED: "Lyra Extended" STR_SUNLIGHT_FADING_FIX: "Anti-Verblassen" STR_REMAP_FRONT_BUTTONS: "Vordere Tasten belegen" diff --git a/lib/I18n/translations/portuguese.yaml b/lib/I18n/translations/portuguese.yaml index 6af6f21f0..7a0fd8feb 100644 --- a/lib/I18n/translations/portuguese.yaml +++ b/lib/I18n/translations/portuguese.yaml @@ -202,6 +202,7 @@ STR_FILTER_CONTRAST: "Contraste" STR_UI_THEME: "Tema da interface" STR_THEME_CLASSIC: "Clássico" STR_THEME_LYRA: "Lyra" +STR_THEME_ROUNDEDRAFF: "RoundedRaff" STR_THEME_LYRA_EXTENDED: "Lyra Extended" STR_SUNLIGHT_FADING_FIX: "Ajuste desbotamento ao sol" STR_REMAP_FRONT_BUTTONS: "Remapear botões frontais" diff --git a/lib/I18n/translations/russian.yaml b/lib/I18n/translations/russian.yaml index bb5f263bb..fdd032d16 100644 --- a/lib/I18n/translations/russian.yaml +++ b/lib/I18n/translations/russian.yaml @@ -225,6 +225,7 @@ STR_BATTERY: "Батарея" STR_UI_THEME: "Тема интерфейса" STR_THEME_CLASSIC: "Классическая" STR_THEME_LYRA: "Lyra" +STR_THEME_ROUNDEDRAFF: "RoundedRaff" STR_THEME_LYRA_EXTENDED: "Lyra Extended" STR_SUNLIGHT_FADING_FIX: "Компенсация выцветания" STR_REMAP_FRONT_BUTTONS: "Переназначить передние кнопки" diff --git a/lib/I18n/translations/spanish.yaml b/lib/I18n/translations/spanish.yaml index 13643feb8..2a105bf8b 100644 --- a/lib/I18n/translations/spanish.yaml +++ b/lib/I18n/translations/spanish.yaml @@ -226,6 +226,7 @@ STR_UI_THEME: "Interfaz" STR_THEME_CLASSIC: "Clásico" STR_THEME_LYRA: "Lyra" STR_THEME_LYRA_EXTENDED: "Lyra Extendido" +STR_THEME_ROUNDEDRAFF: "RoundedRaff" STR_SUNLIGHT_FADING_FIX: "Corrección de desvanecimiento" STR_REMAP_FRONT_BUTTONS: "Reconfigurar botones frontales" STR_OPDS_BROWSER: "Navegador OPDS" diff --git a/lib/I18n/translations/swedish.yaml b/lib/I18n/translations/swedish.yaml index ca5d2f20a..bb58cbdf2 100644 --- a/lib/I18n/translations/swedish.yaml +++ b/lib/I18n/translations/swedish.yaml @@ -228,6 +228,7 @@ STR_BATTERY: "Batteri" STR_UI_THEME: "Användargränssnittstema" STR_THEME_CLASSIC: "Klassisk" STR_THEME_LYRA: "Lyra" +STR_THEME_ROUNDEDRAFF: "RoundedRaff" STR_THEME_LYRA_EXTENDED: "Lyra utökad" STR_SUNLIGHT_FADING_FIX: "Fix för solskensmattning" STR_REMAP_FRONT_BUTTONS: "Ändra frontknappar" diff --git a/src/CrossPointSettings.h b/src/CrossPointSettings.h index 7e50ac4fc..a407e2d2a 100644 --- a/src/CrossPointSettings.h +++ b/src/CrossPointSettings.h @@ -132,7 +132,7 @@ class CrossPointSettings { enum HIDE_BATTERY_PERCENTAGE { HIDE_NEVER = 0, HIDE_READER = 1, HIDE_ALWAYS = 2, HIDE_BATTERY_PERCENTAGE_COUNT }; // UI Theme - enum UI_THEME { CLASSIC = 0, LYRA = 1, LYRA_3_COVERS = 2 }; + enum UI_THEME { CLASSIC = 0, LYRA = 1, LYRA_3_COVERS = 2, ROUNDEDRAFF = 3 }; // Image rendering in EPUB reader enum IMAGE_RENDERING { IMAGES_DISPLAY = 0, IMAGES_PLACEHOLDER = 1, IMAGES_SUPPRESS = 2, IMAGE_RENDERING_COUNT }; diff --git a/src/SettingsList.h b/src/SettingsList.h index 70b4c4a36..aaa00f8a1 100644 --- a/src/SettingsList.h +++ b/src/SettingsList.h @@ -31,8 +31,9 @@ inline const std::vector& getSettingsList() { {StrId::STR_PAGES_1, StrId::STR_PAGES_5, StrId::STR_PAGES_10, StrId::STR_PAGES_15, StrId::STR_PAGES_30}, "refreshFrequency", StrId::STR_CAT_DISPLAY), SettingInfo::Enum(StrId::STR_UI_THEME, &CrossPointSettings::uiTheme, - {StrId::STR_THEME_CLASSIC, StrId::STR_THEME_LYRA, StrId::STR_THEME_LYRA_EXTENDED}, "uiTheme", - StrId::STR_CAT_DISPLAY), + {StrId::STR_THEME_CLASSIC, StrId::STR_THEME_LYRA, StrId::STR_THEME_LYRA_EXTENDED, + StrId::STR_THEME_ROUNDEDRAFF}, + "uiTheme", StrId::STR_CAT_DISPLAY), SettingInfo::Toggle(StrId::STR_SUNLIGHT_FADING_FIX, &CrossPointSettings::fadingFix, "fadingFix", StrId::STR_CAT_DISPLAY), diff --git a/src/activities/home/HomeActivity.cpp b/src/activities/home/HomeActivity.cpp index 4e1338292..ff07b4f0e 100644 --- a/src/activities/home/HomeActivity.cpp +++ b/src/activities/home/HomeActivity.cpp @@ -218,7 +218,8 @@ void HomeActivity::render(RenderLock&&) { renderer.clearScreen(); bool bufferRestored = coverBufferStored && restoreCoverBuffer(); - GUI.drawHeader(renderer, Rect{0, metrics.topPadding, pageWidth, metrics.homeTopPadding}, nullptr); + GUI.drawHeader(renderer, Rect{0, metrics.topPadding, pageWidth, metrics.homeTopPadding}, + metrics.homeContinueReadingInMenu && !recentBooks.empty() ? recentBooks[0].title.c_str() : nullptr); GUI.drawRecentBookCover(renderer, Rect{0, metrics.homeTopPadding, pageWidth, metrics.homeCoverTileHeight}, recentBooks, selectorIndex, coverRendered, coverBufferStored, bufferRestored, @@ -234,12 +235,19 @@ void HomeActivity::render(RenderLock&&) { menuIcons.insert(menuIcons.begin() + 2, Library); } + if (metrics.homeContinueReadingInMenu) { + // Insert Continue Reading at the top if enabled in theme + menuItems.insert(menuItems.begin(), tr(STR_CONTINUE_READING)); + menuIcons.insert(menuIcons.begin(), Book); + } + GUI.drawButtonMenu( renderer, - Rect{0, metrics.homeTopPadding + metrics.homeCoverTileHeight + metrics.verticalSpacing, pageWidth, - pageHeight - (metrics.headerHeight + metrics.homeTopPadding + metrics.verticalSpacing * 2 + - metrics.buttonHintsHeight)}, - static_cast(menuItems.size()), selectorIndex - recentBooks.size(), + Rect{0, metrics.homeTopPadding + metrics.homeCoverTileHeight + metrics.homeMenuTopOffset, pageWidth, + pageHeight - (metrics.headerHeight + metrics.homeTopPadding + metrics.verticalSpacing + + metrics.homeMenuTopOffset + metrics.buttonHintsHeight)}, + static_cast(menuItems.size()), + metrics.homeContinueReadingInMenu ? selectorIndex : selectorIndex - recentBooks.size(), [&menuItems](int index) { return std::string(menuItems[index]); }, [&menuIcons](int index) { return menuIcons[index]; }); diff --git a/src/components/UITheme.cpp b/src/components/UITheme.cpp index 425981a21..7e3a58757 100644 --- a/src/components/UITheme.cpp +++ b/src/components/UITheme.cpp @@ -11,6 +11,7 @@ #include "components/themes/BaseTheme.h" #include "components/themes/lyra/Lyra3CoversTheme.h" #include "components/themes/lyra/LyraTheme.h" +#include "components/themes/roundedraff/RoundedRaffTheme.h" namespace { constexpr int SKIP_PAGE_MS = 700; @@ -40,6 +41,11 @@ void UITheme::setTheme(CrossPointSettings::UI_THEME type) { currentTheme = std::make_unique(); currentMetrics = &LyraMetrics::values; break; + case CrossPointSettings::UI_THEME::ROUNDEDRAFF: + LOG_DBG("UI", "Using RoundedRaff theme"); + currentTheme = std::make_unique(); + currentMetrics = &RoundedRaffMetrics::values; + break; case CrossPointSettings::UI_THEME::LYRA_3_COVERS: LOG_DBG("UI", "Using Lyra 3 Covers theme"); currentTheme = std::make_unique(); diff --git a/src/components/themes/BaseTheme.h b/src/components/themes/BaseTheme.h index 6dd462e7d..e24f8074a 100644 --- a/src/components/themes/BaseTheme.h +++ b/src/components/themes/BaseTheme.h @@ -48,6 +48,8 @@ struct ThemeMetrics { int homeCoverHeight; int homeCoverTileHeight; int homeRecentBooksCount; + bool homeContinueReadingInMenu; + int homeMenuTopOffset; int buttonHintsHeight; int sideButtonHintsWidth; @@ -97,6 +99,8 @@ constexpr ThemeMetrics values = {.batteryWidth = 15, .homeCoverHeight = 400, .homeCoverTileHeight = 400, .homeRecentBooksCount = 1, + .homeContinueReadingInMenu = false, + .homeMenuTopOffset = 10, .buttonHintsHeight = 40, .sideButtonHintsWidth = 30, .progressBarHeight = 16, diff --git a/src/components/themes/lyra/LyraTheme.h b/src/components/themes/lyra/LyraTheme.h index c539cf6b9..eec76b103 100644 --- a/src/components/themes/lyra/LyraTheme.h +++ b/src/components/themes/lyra/LyraTheme.h @@ -25,6 +25,8 @@ constexpr ThemeMetrics values = {.batteryWidth = 16, .homeCoverHeight = 226, .homeCoverTileHeight = 242, .homeRecentBooksCount = 1, + .homeContinueReadingInMenu = false, + .homeMenuTopOffset = 16, .buttonHintsHeight = 40, .sideButtonHintsWidth = 30, .progressBarHeight = 16, diff --git a/src/components/themes/roundedraff/RoundedRaffTheme.cpp b/src/components/themes/roundedraff/RoundedRaffTheme.cpp new file mode 100644 index 000000000..12876f4a6 --- /dev/null +++ b/src/components/themes/roundedraff/RoundedRaffTheme.cpp @@ -0,0 +1,411 @@ +#include "RoundedRaffTheme.h" + +#include +#include +#include +#include + +#include +#include +#include +#include + +#include "RecentBooksStore.h" +#include "components/UITheme.h" +#include "components/icons/cover.h" +#include "fontIds.h" + +namespace { +constexpr int kCoverRadius = 18; +constexpr int kMenuRadius = 30; +constexpr int kBottomRadius = 15; +constexpr int kRowRadius = 20; +constexpr int kInteractiveInsetX = 20; +constexpr int kSelectableRowGap = 6; +constexpr int batteryPercentSpacing = 4; +constexpr int kTitleFontId = UI_12_FONT_ID; // Requested main title size: 12px +constexpr int kSubtitleFontId = SMALL_FONT_ID; // Requested subtitle size: 8px +constexpr int kGuideFontId = SMALL_FONT_ID; // Closest available to requested 6px + +void drawScrollBar(const GfxRenderer& renderer, Rect rect, int itemCount, int pageStartIndex, int pageItems) { + if (itemCount <= 0 || pageItems <= 0 || itemCount <= pageItems) { + return; + } + + const int barW = RoundedRaffMetrics::values.scrollBarWidth; + const int barX = rect.x + rect.width - RoundedRaffMetrics::values.scrollBarRightOffset - barW; + const int barY = rect.y; + const int barH = rect.height; + + const int thumbH = std::max(10, (barH * pageItems) / itemCount); + const int maxStart = std::max(1, itemCount - pageItems); + const int maxTravel = std::max(1, barH - thumbH); + const int thumbY = barY + (pageStartIndex * maxTravel) / maxStart; + + renderer.fillRect(barX, thumbY, barW, thumbH); +} + +void drawBatteryIcon(const GfxRenderer& renderer, int x, int y, int battWidth, int rectHeight, uint16_t percentage) { + // Top line + renderer.drawLine(x + 1, y, x + battWidth - 3, y); + // Bottom line + renderer.drawLine(x + 1, y + rectHeight - 1, x + battWidth - 3, y + rectHeight - 1); + // Left line + renderer.drawLine(x, y + 1, x, y + rectHeight - 2); + // Battery end + renderer.drawLine(x + battWidth - 2, y + 1, x + battWidth - 2, y + rectHeight - 2); + renderer.drawPixel(x + battWidth - 1, y + 3); + renderer.drawPixel(x + battWidth - 1, y + rectHeight - 4); + renderer.drawLine(x + battWidth - 0, y + 4, x + battWidth - 0, y + rectHeight - 5); + + // The +1 is to round up, so that we always fill at least one pixel. + int filledWidth = percentage * (battWidth - 5) / 100 + 1; + if (filledWidth > battWidth - 5) { + filledWidth = battWidth - 5; // Ensure we don't overflow. + } + + renderer.fillRect(x + 2, y + 2, filledWidth, rectHeight - 4); +} + +void drawBatteryRightStable(const GfxRenderer& renderer, Rect iconRect, uint16_t percentage, bool showPercentage) { + // Match BaseTheme::drawBatteryRight layout, but use a stable percentage value for this render. + const int iconY = iconRect.y + 6; + + if (showPercentage) { + const auto percentageText = std::to_string(percentage) + "%"; + const int textWidth = renderer.getTextWidth(SMALL_FONT_ID, percentageText.c_str()); + renderer.drawText(SMALL_FONT_ID, iconRect.x - textWidth - batteryPercentSpacing, iconRect.y, + percentageText.c_str()); + } + + drawBatteryIcon(renderer, iconRect.x, iconY, RoundedRaffMetrics::values.batteryWidth, iconRect.height, percentage); +} + +std::string sanitizeButtonLabel(std::string label) { + // Remove common directional prefixes/symbols (e.g. "<< Home", unsupported icon glyphs). + while (!label.empty() && !std::isalnum(static_cast(label[0]))) { + label.erase(0, 1); + } + // Trim any extra left spaces. + while (!label.empty() && label[0] == ' ') { + label.erase(0, 1); + } + return label; +} + +} // namespace +int coverWidth = 0; + +void RoundedRaffTheme::drawHeader(const GfxRenderer& renderer, Rect rect, const char* title, + const char* subtitle) const { + (void)subtitle; + // Home screen header is custom-rendered in drawRecentBookCover. + if (title == nullptr) { + return; + } + const int sidePadding = RoundedRaffMetrics::values.contentSidePadding; + const int titleX = rect.x + sidePadding; + const int titleY = rect.y + 14; + + const bool showBatteryPercentage = + SETTINGS.hideBatteryPercentage != CrossPointSettings::HIDE_BATTERY_PERCENTAGE::HIDE_ALWAYS; + const uint16_t percentage = powerManager.getBatteryPercentage(); + const int batteryIconX = rect.x + rect.width - sidePadding - RoundedRaffMetrics::values.batteryWidth; + int batteryGroupLeftX = batteryIconX; + if (showBatteryPercentage) { + const auto percentageText = std::to_string(percentage) + "%"; + batteryGroupLeftX -= renderer.getTextWidth(SMALL_FONT_ID, percentageText.c_str()) + batteryPercentSpacing; + + // Clear a fixed-width area for the battery percentage to avoid ghosting when digit count changes (e.g. 100% -> + // 99%). + const int maxTextWidth = renderer.getTextWidth(SMALL_FONT_ID, "100%"); + const int clearW = maxTextWidth + batteryPercentSpacing + RoundedRaffMetrics::values.batteryWidth; + const int clearH = std::max(renderer.getTextHeight(SMALL_FONT_ID), RoundedRaffMetrics::values.batteryHeight + 8); + renderer.fillRect(batteryIconX - maxTextWidth - batteryPercentSpacing, rect.y + 14, clearW, clearH, false); + } + + const int maxTextWidth = std::max(0, batteryGroupLeftX - 20 - titleX); + auto headerTitle = renderer.truncatedText(kTitleFontId, title, maxTextWidth, EpdFontFamily::BOLD); + renderer.drawText(kTitleFontId, titleX, titleY, headerTitle.c_str(), true, EpdFontFamily::BOLD); + drawBatteryRightStable(renderer, + Rect{batteryIconX, rect.y + 14, RoundedRaffMetrics::values.batteryWidth, + RoundedRaffMetrics::values.batteryHeight}, + percentage, showBatteryPercentage); +} + +void RoundedRaffTheme::drawTabBar(const GfxRenderer& renderer, Rect rect, const std::vector& tabs, + bool selected) const { + if (tabs.empty()) { + return; + } + + const int slotWidth = rect.width / static_cast(tabs.size()); + const int tabY = rect.y + 4; + const int tabHeight = rect.height - 12; + + for (size_t i = 0; i < tabs.size(); i++) { + const int slotX = rect.x + static_cast(i) * slotWidth; + const int tabX = slotX + 4; + const int tabWidth = slotWidth - 8; + const auto& tab = tabs[i]; + + if (tab.selected) { + renderer.fillRoundedRect(tabX, tabY, tabWidth, tabHeight, 18, selected ? Color::Black : Color::DarkGray); + } + + const int textWidth = renderer.getTextWidth(kTitleFontId, tab.label, EpdFontFamily::BOLD); + const int textX = slotX + (slotWidth - textWidth) / 2; + const int textY = tabY + (tabHeight - renderer.getLineHeight(kTitleFontId)) / 2; + renderer.drawText(kTitleFontId, textX, textY, tab.label, !(tab.selected), EpdFontFamily::BOLD); + } + + // Full-width divider between tabs and setting rows. + renderer.drawLine(rect.x, rect.y + rect.height - 1, rect.x + rect.width, rect.y + rect.height - 1, true); +} + +void RoundedRaffTheme::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 * RoundedRaffMetrics::values.contentSidePadding; + const int tileHeight = rect.height; + const int tileY = rect.y; + const bool hasContinueReading = !recentBooks.empty(); + if (coverWidth == 0) { + coverWidth = RoundedRaffMetrics::values.homeCoverHeight * 0.6; + } + const int imgY = tileY + (tileHeight - RoundedRaffMetrics::values.homeCoverHeight) / 2; + const int tileX = RoundedRaffMetrics::values.contentSidePadding; + + // 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) { + RecentBook book = recentBooks[0]; + if (!coverRendered) { + std::string coverPath = book.coverBmpPath; + bool hasCover = true; + if (coverPath.empty()) { + hasCover = false; + } else { + const std::string coverBmpPath = + UITheme::getCoverThumbPath(coverPath, RoundedRaffMetrics::values.homeCoverHeight); + + // First time: load cover from SD and render + FsFile file; + if (Storage.openFileForRead("HOME", coverBmpPath, file)) { + Bitmap bitmap(file); + if (bitmap.parseHeaders() == BmpReaderError::Ok) { + coverWidth = bitmap.getWidth(); + renderer.drawBitmap(bitmap, tileX + (tileWidth - coverWidth) / 2, imgY, coverWidth, + RoundedRaffMetrics::values.homeCoverHeight); + renderer.maskRoundedRectOutsideCorners(tileX + (tileWidth - coverWidth) / 2, imgY, coverWidth, + RoundedRaffMetrics::values.homeCoverHeight, kCoverRadius, + Color::LightGray); + } else { + hasCover = false; + } + file.close(); + } + } + + // Draw either way + renderer.drawRoundedRect(tileX + (tileWidth - coverWidth) / 2, imgY, coverWidth, + RoundedRaffMetrics::values.homeCoverHeight, 1, kCoverRadius, true); + + if (!hasCover) { + // Render empty cover + renderer.fillRect(tileX + (tileWidth - coverWidth) / 2, imgY + (RoundedRaffMetrics::values.homeCoverHeight / 3), + coverWidth, 2 * RoundedRaffMetrics::values.homeCoverHeight / 3, true); + renderer.drawIcon(CoverIcon, tileX + (tileWidth - coverWidth) / 2 + 24, imgY + 24, 32, 32); + renderer.maskRoundedRectOutsideCorners(tileX + (tileWidth - coverWidth) / 2, imgY, coverWidth, + RoundedRaffMetrics::values.homeCoverHeight, kCoverRadius, + Color::LightGray); + } + + coverBufferStored = storeCoverBuffer(); + coverRendered = coverBufferStored; // Only consider it rendered if we successfully stored the buffer + } + + renderer.fillRoundedRect(tileX, tileY, tileWidth, imgY - tileY, kRowRadius, true, true, false, false, + Color::LightGray); + renderer.fillRectDither(tileX, imgY, (tileWidth - coverWidth) / 2, RoundedRaffMetrics::values.homeCoverHeight, + Color::LightGray); + renderer.fillRectDither(tileX + (tileWidth + coverWidth) / 2, imgY, (tileWidth - coverWidth) / 2, + RoundedRaffMetrics::values.homeCoverHeight, Color::LightGray); + renderer.fillRoundedRect(tileX, imgY + RoundedRaffMetrics::values.homeCoverHeight, tileWidth, + tileHeight - (imgY - tileY + RoundedRaffMetrics::values.homeCoverHeight), kRowRadius, + false, false, true, true, Color::LightGray); + } else { + renderer.fillRoundedRect(tileX, tileY, tileWidth, tileHeight, kRowRadius, Color::LightGray); + renderer.drawCenteredText(kTitleFontId, rect.y + rect.height / 2 - renderer.getLineHeight(kTitleFontId) / 2, + tr(STR_NO_OPEN_BOOK)); + } +} + +void RoundedRaffTheme::drawButtonMenu(GfxRenderer& renderer, Rect rect, int buttonCount, int selectedIndex, + const std::function& buttonLabel, + const std::function& rowIcon) const { + (void)rowIcon; + const int sidePadding = RoundedRaffMetrics::values.contentSidePadding; + const int rowX = rect.x + sidePadding; + const int rowHeight = renderer.getLineHeight(kTitleFontId) + 20; // 10px top + 10px bottom + const int rowGap = kSelectableRowGap; + const int rowStep = rowHeight + rowGap; + const int pageItems = std::max(1, rect.height / rowStep); + const int safeSelectedIndex = std::max(0, selectedIndex); + const int pageStartIndex = (safeSelectedIndex / pageItems) * pageItems; + const int menuTop = rect.y; + const int textLineHeight = renderer.getLineHeight(kTitleFontId); + const int menuMaxWidth = std::max(0, rect.width - sidePadding * 2); + + for (int i = pageStartIndex; i < buttonCount && i < pageStartIndex + pageItems; ++i) { + const std::string label = buttonLabel(i); + const int rowY = menuTop + (i - pageStartIndex) * rowStep; + constexpr int kRowPaddingX = 40; // 20px L/R + const int maxLabelWidth = std::max(0, menuMaxWidth - kRowPaddingX); + const std::string truncatedLabel = + renderer.truncatedText(kTitleFontId, label.c_str(), maxLabelWidth, EpdFontFamily::BOLD); + const int rowWidth = std::min( + menuMaxWidth, renderer.getTextWidth(kTitleFontId, truncatedLabel.c_str(), EpdFontFamily::BOLD) + kRowPaddingX); + const bool isSelected = selectedIndex == i; + renderer.fillRoundedRect(rowX, rowY, rowWidth, rowHeight, kMenuRadius, isSelected ? Color::Black : Color::White); + const int textY = rowY + (rowHeight - textLineHeight) / 2; + const int textX = rowX + kInteractiveInsetX; + if (selectedIndex == i) { + renderer.drawText(kTitleFontId, textX, textY, truncatedLabel.c_str(), false, EpdFontFamily::BOLD); + } else { + renderer.drawText(kTitleFontId, textX, textY, truncatedLabel.c_str(), true, EpdFontFamily::BOLD); + } + } + + drawScrollBar(renderer, rect, buttonCount, pageStartIndex, pageItems); +} + +void RoundedRaffTheme::drawList(const GfxRenderer& renderer, Rect rect, int itemCount, int selectedIndex, + const std::function& rowTitle, + const std::function& rowSubtitle, + const std::function& rowIcon, + const std::function& rowValue, bool highlightValue) const { + (void)rowIcon; + (void)highlightValue; + const bool hasSubtitle = static_cast(rowSubtitle); + const int titleLineHeight = renderer.getLineHeight(kTitleFontId); + const int subtitleLineHeight = renderer.getLineHeight(kSubtitleFontId); + constexpr int subtitleTopPadding = 10; + constexpr int subtitleBottomPadding = 10; + constexpr int subtitleInterLineGap = 4; + const int subtitleRowHeight = + subtitleTopPadding + titleLineHeight + subtitleInterLineGap + subtitleLineHeight + subtitleBottomPadding; + const int rowHeight = hasSubtitle ? subtitleRowHeight : RoundedRaffMetrics::values.listRowHeight; + const int rowStep = rowHeight + kSelectableRowGap; + const int pageItems = std::max(1, rect.height / rowStep); + const int pageStartIndex = std::max(0, selectedIndex / pageItems) * pageItems; + + const int sidePadding = RoundedRaffMetrics::values.contentSidePadding; + const int rowX = rect.x + sidePadding; + const int rowWidth = rect.width - sidePadding * 2; + + for (int i = pageStartIndex; i < itemCount && i < pageStartIndex + pageItems; i++) { + const int rowY = rect.y + (i % pageItems) * rowStep; + const bool isSelected = i == selectedIndex; + renderer.fillRoundedRect(rowX, rowY, rowWidth, rowHeight, kRowRadius, isSelected ? Color::Black : Color::White); + + constexpr int kMinTitleWidth = 40; + constexpr int kMinValueGap = kInteractiveInsetX; + int textAreaWidth = rowWidth - kInteractiveInsetX * 2; + if (rowValue) { + std::string valueText = rowValue(i); + if (!valueText.empty()) { + const int maxValueWidth = std::max(0, rowWidth - kInteractiveInsetX * 2 - kMinValueGap - kMinTitleWidth); + if (maxValueWidth > 0) { + const std::string truncatedValue = + renderer.truncatedText(kTitleFontId, valueText.c_str(), maxValueWidth, EpdFontFamily::REGULAR); + const int valueW = renderer.getTextWidth(kTitleFontId, truncatedValue.c_str(), EpdFontFamily::REGULAR); + renderer.drawText(kTitleFontId, rowX + rowWidth - kInteractiveInsetX - valueW, + rowY + (rowHeight - renderer.getLineHeight(kTitleFontId)) / 2, truncatedValue.c_str(), + !isSelected, EpdFontFamily::REGULAR); + textAreaWidth = std::max(0, textAreaWidth - valueW - kMinValueGap); + } + } + } + + if (hasSubtitle) { + const std::string subtitleRaw = rowSubtitle(i); + auto title = renderer.truncatedText(kTitleFontId, rowTitle(i).c_str(), textAreaWidth, EpdFontFamily::BOLD); + + if (subtitleRaw.empty()) { + // If there is no subtitle/author, center title vertically in the full row. + const int centeredTitleY = rowY + (rowHeight - titleLineHeight) / 2; + renderer.drawText(kTitleFontId, rowX + kInteractiveInsetX, centeredTitleY, title.c_str(), !isSelected, + EpdFontFamily::BOLD); + } else { + const int titleY = rowY + subtitleTopPadding; + const int subtitleY = titleY + titleLineHeight + subtitleInterLineGap; + auto subtitle = + renderer.truncatedText(kSubtitleFontId, subtitleRaw.c_str(), textAreaWidth, EpdFontFamily::REGULAR); + renderer.drawText(kTitleFontId, rowX + kInteractiveInsetX, titleY, title.c_str(), !isSelected, + EpdFontFamily::BOLD); + renderer.drawText(kSubtitleFontId, rowX + kInteractiveInsetX, subtitleY, subtitle.c_str(), !isSelected, + EpdFontFamily::REGULAR); + } + } else { + auto title = renderer.truncatedText(kTitleFontId, rowTitle(i).c_str(), textAreaWidth, EpdFontFamily::BOLD); + renderer.drawText(kTitleFontId, rowX + kInteractiveInsetX, + rowY + (rowHeight - renderer.getLineHeight(kTitleFontId)) / 2, title.c_str(), !isSelected, + EpdFontFamily::BOLD); + } + } + + drawScrollBar(renderer, rect, itemCount, pageStartIndex, pageItems); +} + +void RoundedRaffTheme::drawButtonHints(GfxRenderer& renderer, const char* btn1, const char* btn2, const char* btn3, + const char* btn4) const { + const GfxRenderer::Orientation origOrientation = renderer.getOrientation(); + renderer.setOrientation(GfxRenderer::Orientation::Portrait); + + const int pageWidth = renderer.getScreenWidth(); + const int pageHeight = renderer.getScreenHeight(); + const int sidePadding = 20; + const int groupGap = 10; + const int bottomMargin = 10; + const int hintHeight = RoundedRaffMetrics::values.buttonHintsHeight - 10; // 30px total guide height + const int groupWidth = (pageWidth - sidePadding * 2 - groupGap) / 2; + const int hintY = pageHeight - hintHeight - bottomMargin; + const int textY = hintY + (hintHeight - renderer.getLineHeight(kGuideFontId)) / 2; + + const bool backDisabled = (btn1 == nullptr || btn1[0] == '\0'); + const int leftGroupX = sidePadding; + const int rightGroupX = leftGroupX + groupWidth + groupGap; + const std::string backLabel = backDisabled ? "" : sanitizeButtonLabel(std::string(btn1)); + // Callers should provide the button labels. If a label is not specified, it should render empty. + const std::string selectText = (btn2 && btn2[0] != '\0') ? sanitizeButtonLabel(std::string(btn2)) : ""; + const std::string upText = (btn3 && btn3[0] != '\0') ? sanitizeButtonLabel(std::string(btn3)) : ""; + const std::string downText = (btn4 && btn4[0] != '\0') ? sanitizeButtonLabel(std::string(btn4)) : ""; + + // Ensure button hints always "win" visually even if other elements accidentally render into this area. + renderer.fillRect(leftGroupX, hintY, groupWidth, hintHeight, false); + renderer.fillRect(rightGroupX, hintY, groupWidth, hintHeight, false); + + renderer.drawRoundedRect(leftGroupX, hintY, groupWidth, hintHeight, 2, kBottomRadius, true); + const int selectWidth = renderer.getTextWidth(kGuideFontId, selectText.c_str(), EpdFontFamily::REGULAR); + const int downWidth = renderer.getTextWidth(kGuideFontId, downText.c_str(), EpdFontFamily::REGULAR); + constexpr int innerEdgePadding = 16; + + const int backX = leftGroupX + innerEdgePadding; + const int selectX = leftGroupX + groupWidth - innerEdgePadding - selectWidth; + const int upX = rightGroupX + innerEdgePadding; + const int downX = rightGroupX + groupWidth - innerEdgePadding - downWidth; + + if (!backDisabled) { + renderer.drawText(kGuideFontId, backX, textY, backLabel.c_str(), true, EpdFontFamily::REGULAR); + } + renderer.drawText(kGuideFontId, selectX, textY, selectText.c_str(), true, EpdFontFamily::REGULAR); + + renderer.drawRoundedRect(rightGroupX, hintY, groupWidth, hintHeight, 2, kBottomRadius, true); + + renderer.drawText(kGuideFontId, upX, textY, upText.c_str(), true, EpdFontFamily::REGULAR); + renderer.drawText(kGuideFontId, downX, textY, downText.c_str(), true, EpdFontFamily::REGULAR); + + renderer.setOrientation(origOrientation); +} diff --git a/src/components/themes/roundedraff/RoundedRaffTheme.h b/src/components/themes/roundedraff/RoundedRaffTheme.h new file mode 100644 index 000000000..7131aa8dc --- /dev/null +++ b/src/components/themes/roundedraff/RoundedRaffTheme.h @@ -0,0 +1,64 @@ +#pragma once + +#include "components/themes/BaseTheme.h" + +class GfxRenderer; + +namespace RoundedRaffMetrics { +constexpr ThemeMetrics values = {.batteryWidth = 15, + .batteryHeight = 12, + .topPadding = 0, + .batteryBarHeight = 20, + .headerHeight = 45, + .verticalSpacing = 10, + .contentSidePadding = 20, + .listRowHeight = 42, + .listWithSubtitleRowHeight = 69, + .menuRowHeight = 42, + .menuSpacing = 6, + .tabSpacing = 10, + .tabBarHeight = 50, + .scrollBarWidth = 4, + .scrollBarRightOffset = 5, + .homeTopPadding = 55, + // Smaller cover tile so the home menu sits higher (fits 5 items without overlap). + .homeCoverHeight = 300, + .homeCoverTileHeight = 350, + .homeRecentBooksCount = 1, + .homeContinueReadingInMenu = true, + .homeMenuTopOffset = 20, + .buttonHintsHeight = 40, + .sideButtonHintsWidth = 30, + .progressBarHeight = 16, + .progressBarMarginTop = 1, + .statusBarHorizontalMargin = 5, + .statusBarVerticalMargin = 19, + .keyboardKeyWidth = 22, + .keyboardKeyHeight = 30, + .keyboardKeySpacing = 10, + .keyboardBottomAligned = false, + .keyboardCenteredText = false}; +} + +class RoundedRaffTheme : public BaseTheme { + public: + void drawHeader(const GfxRenderer& renderer, Rect rect, const char* title, + const char* subtitle = nullptr) const override; + void drawTabBar(const GfxRenderer& renderer, Rect rect, const std::vector& tabs, + bool selected) const override; + void drawRecentBookCover(GfxRenderer& renderer, Rect rect, const std::vector& recentBooks, + int selectorIndex, bool& coverRendered, bool& coverBufferStored, bool& bufferRestored, + std::function storeCoverBuffer) const override; + void drawButtonMenu(GfxRenderer& renderer, Rect rect, int buttonCount, int selectedIndex, + const std::function& buttonLabel, + const std::function& rowIcon) const override; + void drawList(const GfxRenderer& renderer, Rect rect, int itemCount, int selectedIndex, + const std::function& rowTitle, + const std::function& rowSubtitle = nullptr, + const std::function& rowIcon = nullptr, + const std::function& rowValue = nullptr, + bool highlightValue = false) const override; + void drawButtonHints(GfxRenderer& renderer, const char* btn1, const char* btn2, const char* btn3, + const char* btn4) const override; + bool homeMenuShowsContinueReading() const { return true; } +};