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 <yzq6x5zypy@privaterelay.appleid.com>
Co-authored-by: Zach Nelson <zach@zdnelson.com>
Co-authored-by: Uri Tauber <142022451+Uri-Tauber@users.noreply.github.com>
This commit is contained in:
bunsoootchi
2026-04-27 19:01:22 +03:00
committed by GitHub
co-authored by CaptainFrito Zach Nelson Uri Tauber
parent 33386953d9
commit b8a51522ca
18 changed files with 548 additions and 8 deletions
+35
View File
@@ -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<Color::LightGray>(x + dx, y + dy); // top-left
drawPixelDither<Color::LightGray>(x + width - 1 - dx, y + dy); // top-right
drawPixelDither<Color::LightGray>(x + dx, y + height - 1 - dy); // bottom-left
drawPixelDither<Color::LightGray>(x + width - 1 - dx, y + height - 1 - dy); // bottom-right
} else if (color == Color::DarkGray) {
drawPixelDither<Color::DarkGray>(x + dx, y + dy); // top-left
drawPixelDither<Color::DarkGray>(x + width - 1 - dx, y + dy); // top-right
drawPixelDither<Color::DarkGray>(x + dx, y + height - 1 - dy); // bottom-left
drawPixelDither<Color::DarkGray>(x + width - 1 - dx, y + height - 1 - dy); // bottom-right
}
}
}
}
}
template <Color color>
void GfxRenderer::fillArc(const int maxRadius, const int cx, const int cy, const int xDir, const int yDir) const {
if (maxRadius <= 0) return;
+1
View File
@@ -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;
+1
View File
@@ -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"
+1
View File
@@ -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"
+1
View File
@@ -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"
+1
View File
@@ -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"
+1
View File
@@ -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"
+1
View File
@@ -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: "Переназначить передние кнопки"
+1
View File
@@ -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"
+1
View File
@@ -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"
+1 -1
View File
@@ -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 };
+3 -2
View File
@@ -31,8 +31,9 @@ inline const std::vector<SettingInfo>& 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),
+13 -5
View File
@@ -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<int>(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<int>(menuItems.size()),
metrics.homeContinueReadingInMenu ? selectorIndex : selectorIndex - recentBooks.size(),
[&menuItems](int index) { return std::string(menuItems[index]); },
[&menuIcons](int index) { return menuIcons[index]; });
+6
View File
@@ -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<LyraTheme>();
currentMetrics = &LyraMetrics::values;
break;
case CrossPointSettings::UI_THEME::ROUNDEDRAFF:
LOG_DBG("UI", "Using RoundedRaff theme");
currentTheme = std::make_unique<RoundedRaffTheme>();
currentMetrics = &RoundedRaffMetrics::values;
break;
case CrossPointSettings::UI_THEME::LYRA_3_COVERS:
LOG_DBG("UI", "Using Lyra 3 Covers theme");
currentTheme = std::make_unique<Lyra3CoversTheme>();
+4
View File
@@ -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,
+2
View File
@@ -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,
@@ -0,0 +1,411 @@
#include "RoundedRaffTheme.h"
#include <GfxRenderer.h>
#include <HalPowerManager.h>
#include <HalStorage.h>
#include <I18n.h>
#include <algorithm>
#include <cctype>
#include <string>
#include <vector>
#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<unsigned char>(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<TabInfo>& tabs,
bool selected) const {
if (tabs.empty()) {
return;
}
const int slotWidth = rect.width / static_cast<int>(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<int>(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<RecentBook>& recentBooks,
const int selectorIndex, bool& coverRendered, bool& coverBufferStored,
bool& bufferRestored, std::function<bool()> 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<std::string(int index)>& buttonLabel,
const std::function<UIIcon(int index)>& 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<std::string(int index)>& rowTitle,
const std::function<std::string(int index)>& rowSubtitle,
const std::function<UIIcon(int index)>& rowIcon,
const std::function<std::string(int index)>& rowValue, bool highlightValue) const {
(void)rowIcon;
(void)highlightValue;
const bool hasSubtitle = static_cast<bool>(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);
}
@@ -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<TabInfo>& tabs,
bool selected) const override;
void drawRecentBookCover(GfxRenderer& renderer, Rect rect, const std::vector<RecentBook>& recentBooks,
int selectorIndex, bool& coverRendered, bool& coverBufferStored, bool& bufferRestored,
std::function<bool()> storeCoverBuffer) const override;
void drawButtonMenu(GfxRenderer& renderer, Rect rect, int buttonCount, int selectedIndex,
const std::function<std::string(int index)>& buttonLabel,
const std::function<UIIcon(int index)>& rowIcon) const override;
void drawList(const GfxRenderer& renderer, Rect rect, int itemCount, int selectedIndex,
const std::function<std::string(int index)>& rowTitle,
const std::function<std::string(int index)>& rowSubtitle = nullptr,
const std::function<UIIcon(int index)>& rowIcon = nullptr,
const std::function<std::string(int index)>& 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; }
};