feat: Lyra screens (#732)

## Summary

Implements Lyra theme for some more Crosspoint screens:

![IMG_7960
Medium](https://github.com/user-attachments/assets/5d97d91d-e5eb-4296-bbf4-917e142d9095)
![IMG_7961
Medium](https://github.com/user-attachments/assets/02d61964-2632-45ff-83c7-48b95882eb9c)
![IMG_7962
Medium](https://github.com/user-attachments/assets/cf42d20f-3a85-4669-b497-1cac4653fa5a)
![IMG_7963
Medium](https://github.com/user-attachments/assets/a8f59c37-db70-407c-a06d-3e40613a0f55)
![IMG_7964
Medium](https://github.com/user-attachments/assets/0fdaac72-077a-48f6-a8c5-1cd806a58937)
![IMG_7965
Medium](https://github.com/user-attachments/assets/5169f037-8ba8-4488-9a8a-06f5146ec1d9)


## Additional Context

- A bit of refactoring for list scrolling logic

---

### 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? _**NO**_

---------

Co-authored-by: Dave Allie <dave@daveallie.com>
This commit is contained in:
CaptainFrito
2026-02-19 17:16:55 +07:00
committed by GitHub
parent c6ddc5d6a0
commit e7ee6ff05e
38 changed files with 787 additions and 551 deletions

View File

@@ -7,6 +7,7 @@
#include "RecentBooksStore.h"
#include "components/themes/BaseTheme.h"
#include "components/themes/lyra/Lyra3CoversTheme.h"
#include "components/themes/lyra/LyraTheme.h"
UITheme UITheme::instance;
@@ -33,6 +34,11 @@ void UITheme::setTheme(CrossPointSettings::UI_THEME type) {
currentTheme = new LyraTheme();
currentMetrics = &LyraMetrics::values;
break;
case CrossPointSettings::UI_THEME::LYRA_3_COVERS:
LOG_DBG("UI", "Using Lyra 3 Covers theme");
currentTheme = new Lyra3CoversTheme();
currentMetrics = &Lyra3CoversMetrics::values;
break;
}
}

View File

@@ -1,7 +1,6 @@
#pragma once
#include <functional>
#include <vector>
#include "CrossPointSettings.h"
#include "components/themes/BaseTheme.h"

View File

@@ -19,6 +19,7 @@ namespace {
constexpr int batteryPercentSpacing = 4;
constexpr int homeMenuMargin = 20;
constexpr int homeMarginTop = 30;
constexpr int subtitleY = 738;
// Helper: draw battery icon at given position
void drawBatteryIcon(const GfxRenderer& renderer, int x, int y, int battWidth, int rectHeight, uint16_t percentage) {
@@ -87,6 +88,7 @@ void BaseTheme::drawProgressBar(const GfxRenderer& renderer, Rect rect, const si
// Use 64-bit arithmetic to avoid overflow for large files
const int percent = static_cast<int>((static_cast<uint64_t>(current) * 100) / total);
LOG_DBG("UI", "Drawing progress bar: current=%u, total=%u, percent=%d", current, total, percent);
// Draw outline
renderer.drawRect(rect.x, rect.y, rect.width, rect.height);
@@ -185,7 +187,7 @@ void BaseTheme::drawList(const GfxRenderer& renderer, Rect rect, int itemCount,
const std::function<std::string(int index)>& rowTitle,
const std::function<std::string(int index)>& rowSubtitle,
const std::function<std::string(int index)>& rowIcon,
const std::function<std::string(int index)>& rowValue) const {
const std::function<std::string(int index)>& rowValue, bool highlightValue) const {
int rowHeight =
(rowSubtitle != nullptr) ? BaseMetrics::values.listWithSubtitleRowHeight : BaseMetrics::values.listRowHeight;
int pageItems = rect.height / rowHeight;
@@ -251,7 +253,12 @@ void BaseTheme::drawList(const GfxRenderer& renderer, Rect rect, int itemCount,
}
}
void BaseTheme::drawHeader(const GfxRenderer& renderer, Rect rect, const char* title) const {
void BaseTheme::drawHeader(const GfxRenderer& renderer, Rect rect, const char* title, const char* subtitle) const {
// Hide last battery draw
constexpr int maxBatteryWidth = 80;
renderer.fillRect(rect.x + rect.width - maxBatteryWidth, rect.y + 5, maxBatteryWidth,
BaseMetrics::values.batteryHeight + 10, false);
const bool showBatteryPercentage =
SETTINGS.hideBatteryPercentage != CrossPointSettings::HIDE_BATTERY_PERCENTAGE::HIDE_ALWAYS;
// Position icon at right edge, drawBatteryRight will place text to the left
@@ -267,6 +274,36 @@ void BaseTheme::drawHeader(const GfxRenderer& renderer, Rect rect, const char* t
EpdFontFamily::BOLD);
renderer.drawCenteredText(UI_12_FONT_ID, rect.y + 5, truncatedTitle.c_str(), true, EpdFontFamily::BOLD);
}
if (subtitle) {
auto truncatedSubtitle = renderer.truncatedText(
SMALL_FONT_ID, subtitle, rect.width - BaseMetrics::values.contentSidePadding * 2, EpdFontFamily::REGULAR);
int truncatedSubtitleWidth = renderer.getTextWidth(SMALL_FONT_ID, truncatedSubtitle.c_str());
renderer.drawText(SMALL_FONT_ID,
rect.x + rect.width - BaseMetrics::values.contentSidePadding - truncatedSubtitleWidth, subtitleY,
truncatedSubtitle.c_str(), true);
}
}
void BaseTheme::drawSubHeader(const GfxRenderer& renderer, Rect rect, const char* label, const char* rightLabel) const {
constexpr int underlineHeight = 2; // Height of selection underline
constexpr int underlineGap = 4; // Gap between text and underline
constexpr int maxListValueWidth = 200;
int currentX = rect.x + BaseMetrics::values.contentSidePadding;
int rightSpace = BaseMetrics::values.contentSidePadding;
if (rightLabel) {
auto truncatedRightLabel =
renderer.truncatedText(SMALL_FONT_ID, rightLabel, maxListValueWidth, EpdFontFamily::REGULAR);
int rightLabelWidth = renderer.getTextWidth(SMALL_FONT_ID, truncatedRightLabel.c_str());
renderer.drawText(SMALL_FONT_ID, rect.x + rect.width - BaseMetrics::values.contentSidePadding - rightLabelWidth,
rect.y + 7, truncatedRightLabel.c_str());
rightSpace += rightLabelWidth + 10;
}
auto truncatedLabel = renderer.truncatedText(
UI_12_FONT_ID, label, rect.width - BaseMetrics::values.contentSidePadding - rightSpace, EpdFontFamily::REGULAR);
renderer.drawText(UI_12_FONT_ID, currentX, rect.y, truncatedLabel.c_str(), true, EpdFontFamily::REGULAR);
}
void BaseTheme::drawTabBar(const GfxRenderer& renderer, const Rect rect, const std::vector<TabInfo>& tabs,
@@ -668,3 +705,26 @@ void BaseTheme::drawReadingProgressBar(const GfxRenderer& renderer, const size_t
const int barWidth = progressBarMaxWidth * bookProgress / 100;
renderer.fillRect(vieweableMarginLeft, progressBarY, barWidth, BaseMetrics::values.bookProgressBarHeight, true);
}
void BaseTheme::drawHelpText(const GfxRenderer& renderer, Rect rect, const char* label) const {
auto metrics = UITheme::getInstance().getMetrics();
auto truncatedLabel =
renderer.truncatedText(SMALL_FONT_ID, label, rect.width - metrics.contentSidePadding * 2, EpdFontFamily::REGULAR);
renderer.drawCenteredText(SMALL_FONT_ID, rect.y, truncatedLabel.c_str());
}
void BaseTheme::drawTextField(const GfxRenderer& renderer, Rect rect, const int textWidth) const {
renderer.drawText(UI_12_FONT_ID, rect.x + 10, rect.y, "[");
renderer.drawText(UI_12_FONT_ID, rect.x + rect.width - 15, rect.y + rect.height, "]");
}
void BaseTheme::drawKeyboardKey(const GfxRenderer& renderer, Rect rect, const char* label,
const bool isSelected) const {
const int itemWidth = renderer.getTextWidth(UI_10_FONT_ID, label);
const int textX = rect.x + (rect.width - itemWidth) / 2;
if (isSelected) {
renderer.drawText(UI_10_FONT_ID, textX - 6, rect.y, "[");
renderer.drawText(UI_10_FONT_ID, textX + itemWidth, rect.y, "]");
}
renderer.drawText(UI_10_FONT_ID, textX, rect.y, label);
}

View File

@@ -51,10 +51,14 @@ struct ThemeMetrics {
int buttonHintsHeight;
int sideButtonHintsWidth;
int versionTextRightX;
int versionTextY;
int progressBarHeight;
int bookProgressBarHeight;
int keyboardKeyWidth;
int keyboardKeyHeight;
int keyboardKeySpacing;
bool keyboardBottomAligned;
bool keyboardCenteredText;
};
// Default theme implementation (Classic Theme)
@@ -82,9 +86,13 @@ constexpr ThemeMetrics values = {.batteryWidth = 15,
.homeRecentBooksCount = 1,
.buttonHintsHeight = 40,
.sideButtonHintsWidth = 30,
.versionTextRightX = 20,
.versionTextY = 738,
.bookProgressBarHeight = 4};
.progressBarHeight = 16,
.bookProgressBarHeight = 4,
.keyboardKeyWidth = 18,
.keyboardKeyHeight = 18,
.keyboardKeySpacing = 3,
.keyboardBottomAligned = false,
.keyboardCenteredText = false};
}
class BaseTheme {
@@ -102,11 +110,14 @@ class BaseTheme {
virtual void drawSideButtonHints(const GfxRenderer& renderer, const char* topBtn, const char* bottomBtn) const;
virtual 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,
const std::function<std::string(int index)>& rowIcon,
const std::function<std::string(int index)>& rowValue) const;
virtual void drawHeader(const GfxRenderer& renderer, Rect rect, const char* title) const;
const std::function<std::string(int index)>& rowSubtitle = nullptr,
const std::function<std::string(int index)>& rowIcon = nullptr,
const std::function<std::string(int index)>& rowValue = nullptr,
bool highlightValue = false) const;
virtual void drawHeader(const GfxRenderer& renderer, Rect rect, const char* title,
const char* subtitle = nullptr) const;
virtual void drawSubHeader(const GfxRenderer& renderer, Rect rect, const char* label,
const char* rightLabel = nullptr) const;
virtual void drawTabBar(const GfxRenderer& renderer, Rect rect, const std::vector<TabInfo>& tabs,
bool selected) const;
virtual void drawRecentBookCover(GfxRenderer& renderer, Rect rect, const std::vector<RecentBook>& recentBooks,
@@ -118,4 +129,7 @@ class BaseTheme {
virtual Rect drawPopup(const GfxRenderer& renderer, const char* message) const;
virtual void fillPopupProgress(const GfxRenderer& renderer, const Rect& layout, const int progress) const;
virtual void drawReadingProgressBar(const GfxRenderer& renderer, const size_t bookProgress) const;
virtual void drawHelpText(const GfxRenderer& renderer, Rect rect, const char* label) const;
virtual void drawTextField(const GfxRenderer& renderer, Rect rect, const int textWidth) const;
virtual void drawKeyboardKey(const GfxRenderer& renderer, Rect rect, const char* label, const bool isSelected) const;
};

View File

@@ -0,0 +1,104 @@
#include "Lyra3CoversTheme.h"
#include <GfxRenderer.h>
#include <HalStorage.h>
#include <cstdint>
#include <string>
#include "Battery.h"
#include "RecentBooksStore.h"
#include "components/UITheme.h"
#include "fontIds.h"
#include "util/StringUtils.h"
// Internal constants
namespace {
constexpr int hPaddingInSelection = 8;
constexpr int cornerRadius = 6;
int coverWidth = 0;
} // namespace
void Lyra3CoversTheme::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 * Lyra3CoversMetrics::values.contentSidePadding) / 3;
const int tileHeight = rect.height;
const int bookTitleHeight = tileHeight - Lyra3CoversMetrics::values.homeCoverHeight - hPaddingInSelection;
const int tileY = rect.y;
const bool hasContinueReading = !recentBooks.empty();
// Draw book card regardless, fill with message based on `hasContinueReading`
// Draw cover image as background if available (inside the box)
// Only load from SD on first render, then use stored buffer
if (hasContinueReading) {
if (!coverRendered) {
for (int i = 0;
i < std::min(static_cast<int>(recentBooks.size()), Lyra3CoversMetrics::values.homeRecentBooksCount); i++) {
std::string coverPath = recentBooks[i].coverBmpPath;
bool hasCover = true;
int tileX = Lyra3CoversMetrics::values.contentSidePadding + tileWidth * i;
if (coverPath.empty()) {
hasCover = false;
} else {
const std::string coverBmpPath =
UITheme::getCoverThumbPath(coverPath, Lyra3CoversMetrics::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) {
float coverHeight = static_cast<float>(bitmap.getHeight());
float coverWidth = static_cast<float>(bitmap.getWidth());
float ratio = coverWidth / coverHeight;
const float tileRatio = static_cast<float>(tileWidth - 2 * hPaddingInSelection) /
static_cast<float>(Lyra3CoversMetrics::values.homeCoverHeight);
float cropX = 1.0f - (tileRatio / ratio);
renderer.drawBitmap(bitmap, tileX + hPaddingInSelection, tileY + hPaddingInSelection,
tileWidth - 2 * hPaddingInSelection, Lyra3CoversMetrics::values.homeCoverHeight,
cropX);
} else {
hasCover = false;
}
file.close();
}
}
if (!hasCover) {
renderer.drawRect(tileX + hPaddingInSelection, tileY + hPaddingInSelection,
tileWidth - 2 * hPaddingInSelection, Lyra3CoversMetrics::values.homeCoverHeight);
}
}
coverBufferStored = storeCoverBuffer();
coverRendered = true;
}
for (int i = 0; i < std::min(static_cast<int>(recentBooks.size()), Lyra3CoversMetrics::values.homeRecentBooksCount);
i++) {
bool bookSelected = (selectorIndex == i);
int tileX = Lyra3CoversMetrics::values.contentSidePadding + tileWidth * i;
auto title =
renderer.truncatedText(UI_10_FONT_ID, recentBooks[i].title.c_str(), tileWidth - 2 * hPaddingInSelection);
if (bookSelected) {
// Draw selection box
renderer.fillRoundedRect(tileX, tileY, tileWidth, hPaddingInSelection, cornerRadius, true, true, false, false,
Color::LightGray);
renderer.fillRectDither(tileX, tileY + hPaddingInSelection, hPaddingInSelection,
Lyra3CoversMetrics::values.homeCoverHeight, Color::LightGray);
renderer.fillRectDither(tileX + tileWidth - hPaddingInSelection, tileY + hPaddingInSelection,
hPaddingInSelection, Lyra3CoversMetrics::values.homeCoverHeight, Color::LightGray);
renderer.fillRoundedRect(tileX, tileY + Lyra3CoversMetrics::values.homeCoverHeight + hPaddingInSelection,
tileWidth, bookTitleHeight, cornerRadius, false, false, true, true, Color::LightGray);
}
renderer.drawText(UI_10_FONT_ID, tileX + hPaddingInSelection,
tileY + tileHeight - bookTitleHeight + hPaddingInSelection + 5, title.c_str(), true);
}
} else {
drawEmptyRecents(renderer, rect);
}
}

View File

@@ -0,0 +1,41 @@
#pragma once
#include "components/themes/lyra/LyraTheme.h"
class GfxRenderer;
// Lyra theme metrics (zero runtime cost)
namespace Lyra3CoversMetrics {
constexpr ThemeMetrics values = {.batteryWidth = 16,
.batteryHeight = 12,
.topPadding = 5,
.batteryBarHeight = 40,
.headerHeight = 84,
.verticalSpacing = 16,
.contentSidePadding = 20,
.listRowHeight = 40,
.listWithSubtitleRowHeight = 60,
.menuRowHeight = 64,
.menuSpacing = 8,
.tabSpacing = 8,
.tabBarHeight = 40,
.scrollBarWidth = 4,
.scrollBarRightOffset = 5,
.homeTopPadding = 56,
.homeCoverHeight = 226,
.homeCoverTileHeight = 287,
.homeRecentBooksCount = 3,
.buttonHintsHeight = 40,
.sideButtonHintsWidth = 30,
.progressBarHeight = 16,
.bookProgressBarHeight = 4};
}
class Lyra3CoversTheme : public LyraTheme {
public:
void drawRecentBookCover(GfxRenderer& renderer, Rect rect, const std::vector<RecentBook>& recentBooks,
const int selectorIndex, bool& coverRendered, bool& coverBufferStored, bool& bufferRestored,
std::function<bool()> storeCoverBuffer) const override;
};

View File

@@ -2,6 +2,7 @@
#include <GfxRenderer.h>
#include <HalStorage.h>
#include <I18n.h>
#include <cstdint>
#include <string>
@@ -20,6 +21,9 @@ constexpr int cornerRadius = 6;
constexpr int topHintButtonY = 345;
constexpr int popupMarginX = 16;
constexpr int popupMarginY = 12;
constexpr int maxSubtitleWidth = 100;
constexpr int maxListValueWidth = 200;
int coverWidth = 0;
} // namespace
void LyraTheme::drawBatteryLeft(const GfxRenderer& renderer, Rect rect, const bool showPercentage) const {
@@ -101,7 +105,7 @@ void LyraTheme::drawBatteryRight(const GfxRenderer& renderer, Rect rect, const b
}
}
void LyraTheme::drawHeader(const GfxRenderer& renderer, Rect rect, const char* title) const {
void LyraTheme::drawHeader(const GfxRenderer& renderer, Rect rect, const char* title, const char* subtitle) const {
renderer.fillRect(rect.x, rect.y, rect.width, rect.height, false);
const bool showBatteryPercentage =
@@ -112,14 +116,43 @@ void LyraTheme::drawHeader(const GfxRenderer& renderer, Rect rect, const char* t
Rect{batteryX, rect.y + 5, LyraMetrics::values.batteryWidth, LyraMetrics::values.batteryHeight},
showBatteryPercentage);
int maxTitleWidth =
rect.width - LyraMetrics::values.contentSidePadding * 2 - (subtitle != nullptr ? maxSubtitleWidth : 0);
if (title) {
auto truncatedTitle = renderer.truncatedText(
UI_12_FONT_ID, title, rect.width - LyraMetrics::values.contentSidePadding * 2, EpdFontFamily::BOLD);
auto truncatedTitle = renderer.truncatedText(UI_12_FONT_ID, title, maxTitleWidth, EpdFontFamily::BOLD);
renderer.drawText(UI_12_FONT_ID, rect.x + LyraMetrics::values.contentSidePadding,
rect.y + LyraMetrics::values.batteryBarHeight + 3, truncatedTitle.c_str(), true,
EpdFontFamily::BOLD);
renderer.drawLine(rect.x, rect.y + rect.height - 3, rect.x + rect.width, rect.y + rect.height - 3, 3, true);
}
if (subtitle) {
auto truncatedSubtitle = renderer.truncatedText(SMALL_FONT_ID, subtitle, maxSubtitleWidth, EpdFontFamily::REGULAR);
int truncatedSubtitleWidth = renderer.getTextWidth(SMALL_FONT_ID, truncatedSubtitle.c_str());
renderer.drawText(SMALL_FONT_ID,
rect.x + rect.width - LyraMetrics::values.contentSidePadding - truncatedSubtitleWidth,
rect.y + 50, truncatedSubtitle.c_str(), true);
}
}
void LyraTheme::drawSubHeader(const GfxRenderer& renderer, Rect rect, const char* label, const char* rightLabel) const {
int currentX = rect.x + LyraMetrics::values.contentSidePadding;
int rightSpace = LyraMetrics::values.contentSidePadding;
if (rightLabel) {
auto truncatedRightLabel =
renderer.truncatedText(SMALL_FONT_ID, rightLabel, maxListValueWidth, EpdFontFamily::REGULAR);
int rightLabelWidth = renderer.getTextWidth(SMALL_FONT_ID, truncatedRightLabel.c_str());
renderer.drawText(SMALL_FONT_ID, rect.x + rect.width - LyraMetrics::values.contentSidePadding - rightLabelWidth,
rect.y + 7, truncatedRightLabel.c_str());
rightSpace += rightLabelWidth + hPaddingInSelection;
}
auto truncatedLabel = renderer.truncatedText(
UI_10_FONT_ID, label, rect.width - LyraMetrics::values.contentSidePadding - rightSpace, EpdFontFamily::REGULAR);
renderer.drawText(UI_10_FONT_ID, currentX, rect.y + 6, truncatedLabel.c_str(), true, EpdFontFamily::REGULAR);
renderer.drawLine(rect.x, rect.y + rect.height - 1, rect.x + rect.width, rect.y + rect.height - 1, true);
}
void LyraTheme::drawTabBar(const GfxRenderer& renderer, Rect rect, const std::vector<TabInfo>& tabs,
@@ -158,7 +191,7 @@ void LyraTheme::drawList(const GfxRenderer& renderer, Rect rect, int itemCount,
const std::function<std::string(int index)>& rowTitle,
const std::function<std::string(int index)>& rowSubtitle,
const std::function<std::string(int index)>& rowIcon,
const std::function<std::string(int index)>& rowValue) const {
const std::function<std::string(int index)>& rowValue, bool highlightValue) const {
int rowHeight =
(rowSubtitle != nullptr) ? LyraMetrics::values.listWithSubtitleRowHeight : LyraMetrics::values.listRowHeight;
int pageItems = rect.height / rowHeight;
@@ -193,8 +226,14 @@ void LyraTheme::drawList(const GfxRenderer& renderer, Rect rect, int itemCount,
const int itemY = rect.y + (i % pageItems) * rowHeight;
// Draw name
int textWidth = contentWidth - LyraMetrics::values.contentSidePadding * 2 - hPaddingInSelection * 2 -
(rowValue != nullptr ? 60 : 0); // TODO truncate according to value width?
int valueWidth = 0;
std::string valueText = "";
if (rowValue != nullptr) {
valueText = rowValue(i);
valueText = renderer.truncatedText(UI_10_FONT_ID, valueText.c_str(), maxListValueWidth);
valueWidth = renderer.getTextWidth(UI_10_FONT_ID, valueText.c_str()) + hPaddingInSelection;
}
int textWidth = contentWidth - LyraMetrics::values.contentSidePadding * 2 - hPaddingInSelection * 2 - valueWidth;
auto itemName = rowTitle(i);
auto item = renderer.truncatedText(UI_10_FONT_ID, itemName.c_str(), textWidth);
renderer.drawText(UI_10_FONT_ID, rect.x + LyraMetrics::values.contentSidePadding + hPaddingInSelection * 2,
@@ -208,22 +247,16 @@ void LyraTheme::drawList(const GfxRenderer& renderer, Rect rect, int itemCount,
itemY + 30, subtitle.c_str(), true);
}
if (rowValue != nullptr) {
// Draw value
std::string valueText = rowValue(i);
if (!valueText.empty()) {
const auto valueTextWidth = renderer.getTextWidth(UI_10_FONT_ID, valueText.c_str());
if (i == selectedIndex) {
renderer.fillRoundedRect(
contentWidth - LyraMetrics::values.contentSidePadding - hPaddingInSelection * 2 - valueTextWidth, itemY,
valueTextWidth + hPaddingInSelection * 2, rowHeight, cornerRadius, Color::Black);
}
renderer.drawText(UI_10_FONT_ID,
contentWidth - LyraMetrics::values.contentSidePadding - hPaddingInSelection - valueTextWidth,
itemY + 6, valueText.c_str(), i != selectedIndex);
// Draw value
if (!valueText.empty()) {
if (i == selectedIndex && highlightValue) {
renderer.fillRoundedRect(
contentWidth - LyraMetrics::values.contentSidePadding - hPaddingInSelection - valueWidth, itemY,
valueWidth + hPaddingInSelection, rowHeight, cornerRadius, Color::Black);
}
renderer.drawText(UI_10_FONT_ID, rect.x + contentWidth - LyraMetrics::values.contentSidePadding - valueWidth,
itemY + 6, valueText.c_str(), !(i == selectedIndex && highlightValue));
}
}
}
@@ -302,82 +335,98 @@ void LyraTheme::drawSideButtonHints(const GfxRenderer& renderer, const char* top
void LyraTheme::drawRecentBookCover(GfxRenderer& renderer, Rect rect, const std::vector<RecentBook>& recentBooks,
const int selectorIndex, bool& coverRendered, bool& coverBufferStored,
bool& bufferRestored, std::function<bool()> storeCoverBuffer) const {
const int tileWidth = (rect.width - 2 * LyraMetrics::values.contentSidePadding) / 3;
const int tileWidth = rect.width - 2 * LyraMetrics::values.contentSidePadding;
const int tileHeight = rect.height;
const int bookTitleHeight = tileHeight - LyraMetrics::values.homeCoverHeight - hPaddingInSelection;
const int tileY = rect.y;
const bool hasContinueReading = !recentBooks.empty();
if (coverWidth == 0) {
coverWidth = LyraMetrics::values.homeCoverHeight * 0.6;
}
// 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) {
for (int i = 0; i < std::min(static_cast<int>(recentBooks.size()), LyraMetrics::values.homeRecentBooksCount);
i++) {
std::string coverPath = recentBooks[i].coverBmpPath;
int tileX = LyraMetrics::values.contentSidePadding + tileWidth * i;
renderer.drawRect(tileX + hPaddingInSelection, tileY + hPaddingInSelection, tileWidth - 2 * hPaddingInSelection,
LyraMetrics::values.homeCoverHeight);
if (!coverPath.empty()) {
const std::string coverBmpPath = UITheme::getCoverThumbPath(coverPath, LyraMetrics::values.homeCoverHeight);
std::string coverPath = book.coverBmpPath;
bool hasCover = true;
int tileX = LyraMetrics::values.contentSidePadding;
if (coverPath.empty()) {
hasCover = false;
} else {
const std::string coverBmpPath = UITheme::getCoverThumbPath(coverPath, LyraMetrics::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) {
float coverHeight = static_cast<float>(bitmap.getHeight());
float coverWidth = static_cast<float>(bitmap.getWidth());
float ratio = coverWidth / coverHeight;
const float tileRatio = static_cast<float>(tileWidth - 2 * hPaddingInSelection) /
static_cast<float>(LyraMetrics::values.homeCoverHeight);
float cropX = 1.0f - (tileRatio / ratio);
renderer.drawBitmap(bitmap, tileX + hPaddingInSelection, tileY + hPaddingInSelection,
tileWidth - 2 * hPaddingInSelection, LyraMetrics::values.homeCoverHeight, cropX);
}
file.close();
// 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 + hPaddingInSelection, tileY + hPaddingInSelection, coverWidth,
LyraMetrics::values.homeCoverHeight);
} else {
hasCover = false;
}
file.close();
}
}
if (!hasCover) {
renderer.drawRect(tileX + hPaddingInSelection, tileY + hPaddingInSelection, coverWidth,
LyraMetrics::values.homeCoverHeight);
}
coverBufferStored = storeCoverBuffer();
coverRendered = true;
}
for (int i = 0; i < std::min(static_cast<int>(recentBooks.size()), LyraMetrics::values.homeRecentBooksCount); i++) {
bool bookSelected = (selectorIndex == i);
bool bookSelected = (selectorIndex == 0);
int tileX = LyraMetrics::values.contentSidePadding + tileWidth * i;
auto title =
renderer.truncatedText(UI_10_FONT_ID, recentBooks[i].title.c_str(), tileWidth - 2 * hPaddingInSelection);
int tileX = LyraMetrics::values.contentSidePadding;
int textWidth = tileWidth - 2 * hPaddingInSelection - LyraMetrics::values.verticalSpacing - coverWidth;
if (bookSelected) {
// Draw selection box
renderer.fillRoundedRect(tileX, tileY, tileWidth, hPaddingInSelection, cornerRadius, true, true, false, false,
Color::LightGray);
renderer.fillRectDither(tileX, tileY + hPaddingInSelection, hPaddingInSelection,
LyraMetrics::values.homeCoverHeight, 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);
}
renderer.drawText(UI_10_FONT_ID, tileX + hPaddingInSelection,
tileY + tileHeight - bookTitleHeight + hPaddingInSelection + 5, title.c_str(), true);
if (bookSelected) {
// Draw selection box
renderer.fillRoundedRect(tileX, tileY, tileWidth, hPaddingInSelection, cornerRadius, true, true, false, false,
Color::LightGray);
renderer.fillRectDither(tileX, tileY + hPaddingInSelection, hPaddingInSelection,
LyraMetrics::values.homeCoverHeight, Color::LightGray);
renderer.fillRectDither(tileX + hPaddingInSelection + coverWidth, tileY + hPaddingInSelection,
tileWidth - hPaddingInSelection - coverWidth, LyraMetrics::values.homeCoverHeight,
Color::LightGray);
renderer.fillRoundedRect(tileX, tileY + LyraMetrics::values.homeCoverHeight + hPaddingInSelection, tileWidth,
hPaddingInSelection, cornerRadius, false, false, true, true, Color::LightGray);
}
auto title = renderer.truncatedText(UI_12_FONT_ID, book.title.c_str(), textWidth, EpdFontFamily::BOLD);
auto author = renderer.truncatedText(UI_10_FONT_ID, book.author.c_str(), textWidth);
auto bookTitleHeight = renderer.getTextHeight(UI_12_FONT_ID);
renderer.drawText(UI_12_FONT_ID, tileX + hPaddingInSelection + coverWidth + LyraMetrics::values.verticalSpacing,
tileY + tileHeight / 2 - bookTitleHeight, title.c_str(), true, EpdFontFamily::BOLD);
renderer.drawText(UI_10_FONT_ID, tileX + hPaddingInSelection + coverWidth + LyraMetrics::values.verticalSpacing,
tileY + tileHeight / 2 + 5, author.c_str(), true);
} else {
drawEmptyRecents(renderer, rect);
}
}
void LyraTheme::drawEmptyRecents(const GfxRenderer& renderer, const Rect rect) const {
constexpr int padding = 48;
renderer.drawText(UI_12_FONT_ID, rect.x + padding,
rect.y + rect.height / 2 - renderer.getLineHeight(UI_12_FONT_ID) - 2, tr(STR_NO_OPEN_BOOK), true,
EpdFontFamily::BOLD);
renderer.drawText(UI_10_FONT_ID, rect.x + padding, rect.y + rect.height / 2 + 2, tr(STR_START_READING), true);
}
void LyraTheme::drawButtonMenu(GfxRenderer& renderer, Rect rect, int buttonCount, int selectedIndex,
const std::function<std::string(int index)>& buttonLabel,
const std::function<std::string(int index)>& rowIcon) const {
for (int i = 0; i < buttonCount; ++i) {
int tileWidth = (rect.width - LyraMetrics::values.contentSidePadding * 2 - LyraMetrics::values.menuSpacing) / 2;
Rect tileRect =
Rect{rect.x + LyraMetrics::values.contentSidePadding + (LyraMetrics::values.menuSpacing + tileWidth) * (i % 2),
rect.y + static_cast<int>(i / 2) * (LyraMetrics::values.menuRowHeight + LyraMetrics::values.menuSpacing),
tileWidth, LyraMetrics::values.menuRowHeight};
int tileWidth = rect.width - LyraMetrics::values.contentSidePadding * 2;
Rect tileRect = Rect{rect.x + LyraMetrics::values.contentSidePadding,
rect.y + i * (LyraMetrics::values.menuRowHeight + LyraMetrics::values.menuSpacing), tileWidth,
LyraMetrics::values.menuRowHeight};
const bool selected = selectedIndex == i;
@@ -432,3 +481,21 @@ void LyraTheme::fillPopupProgress(const GfxRenderer& renderer, const Rect& layou
renderer.displayBuffer(HalDisplay::FAST_REFRESH);
}
void LyraTheme::drawTextField(const GfxRenderer& renderer, Rect rect, const int textWidth) const {
int lineY = rect.y + rect.height + renderer.getLineHeight(UI_12_FONT_ID) + LyraMetrics::values.verticalSpacing;
int lineW = textWidth + hPaddingInSelection * 2;
renderer.drawLine(rect.x + (rect.width - lineW) / 2, lineY, rect.x + (rect.width + lineW) / 2, lineY, 3);
}
void LyraTheme::drawKeyboardKey(const GfxRenderer& renderer, Rect rect, const char* label,
const bool isSelected) const {
if (isSelected) {
renderer.fillRoundedRect(rect.x, rect.y, rect.width, rect.height, cornerRadius, Color::Black);
}
const int textWidth = renderer.getTextWidth(UI_12_FONT_ID, label);
const int textX = rect.x + (rect.width - textWidth) / 2;
const int textY = rect.y + (rect.height - renderer.getLineHeight(UI_12_FONT_ID)) / 2;
renderer.drawText(UI_12_FONT_ID, textX, textY, label, !isSelected);
}

View File

@@ -23,13 +23,17 @@ constexpr ThemeMetrics values = {.batteryWidth = 16,
.scrollBarRightOffset = 5,
.homeTopPadding = 56,
.homeCoverHeight = 226,
.homeCoverTileHeight = 287,
.homeRecentBooksCount = 3,
.homeCoverTileHeight = 242,
.homeRecentBooksCount = 1,
.buttonHintsHeight = 40,
.sideButtonHintsWidth = 30,
.versionTextRightX = 20,
.versionTextY = 55,
.bookProgressBarHeight = 4};
.progressBarHeight = 16,
.bookProgressBarHeight = 4,
.keyboardKeyWidth = 31,
.keyboardKeyHeight = 50,
.keyboardKeySpacing = 0,
.keyboardBottomAligned = true,
.keyboardCenteredText = true};
}
class LyraTheme : public BaseTheme {
@@ -38,14 +42,16 @@ class LyraTheme : public BaseTheme {
// void drawProgressBar(const GfxRenderer& renderer, Rect rect, size_t current, size_t total) override;
void drawBatteryLeft(const GfxRenderer& renderer, Rect rect, bool showPercentage = true) const override;
void drawBatteryRight(const GfxRenderer& renderer, Rect rect, bool showPercentage = true) const override;
void drawHeader(const GfxRenderer& renderer, Rect rect, const char* title) const override;
void drawHeader(const GfxRenderer& renderer, Rect rect, const char* title, const char* subtitle) const override;
void drawSubHeader(const GfxRenderer& renderer, Rect rect, const char* label,
const char* rightLabel = nullptr) const override;
void drawTabBar(const GfxRenderer& renderer, Rect rect, const std::vector<TabInfo>& tabs,
bool selected) 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,
const std::function<std::string(int index)>& rowIcon,
const std::function<std::string(int index)>& rowValue) const override;
const std::function<std::string(int index)>& rowValue, bool highlightValue) const override;
void drawButtonHints(GfxRenderer& renderer, const char* btn1, const char* btn2, const char* btn3,
const char* btn4) const override;
void drawSideButtonHints(const GfxRenderer& renderer, const char* topBtn, const char* bottomBtn) const override;
@@ -55,6 +61,9 @@ class LyraTheme : public BaseTheme {
void drawRecentBookCover(GfxRenderer& renderer, Rect rect, const std::vector<RecentBook>& recentBooks,
const int selectorIndex, bool& coverRendered, bool& coverBufferStored, bool& bufferRestored,
std::function<bool()> storeCoverBuffer) const override;
void drawEmptyRecents(const GfxRenderer& renderer, const Rect rect) const;
Rect drawPopup(const GfxRenderer& renderer, const char* message) const override;
void fillPopupProgress(const GfxRenderer& renderer, const Rect& layout, const int progress) const override;
void drawTextField(const GfxRenderer& renderer, Rect rect, const int textWidth) const override;
void drawKeyboardKey(const GfxRenderer& renderer, Rect rect, const char* label, const bool isSelected) const override;
};