Files
crosspoint-reader-mod/src/components/themes/lyra/LyraTheme.cpp
cottongin 51dc498768 feat: Expandable selected row for long filenames in File Browser
When the selected row's filename overflows the available text width
(with extension), the row expands to 2 lines with smart text wrapping.
The file extension moves to the second row (right-aligned). Non-selected
rows retain single-line truncation.

Key behaviors:
- 3-tier text wrapping: preferred delimiters (" - ", " -- ", en/em-dash),
  word boundaries, then character-level fallback
- Row-height line spacing for natural visual rhythm
- Icons aligned with line 1 (LyraTheme)
- Pagination uses effectivePageItems with anti-leak clamping to prevent
  page boundary shifts while ensuring all items remain accessible
- Boundary item duplication: items bumped from a page due to expansion
  appear at the top of the next page, guarded against cascading

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-02-20 19:42:56 -05:00

909 lines
39 KiB
C++

#include "LyraTheme.h"
#include <GfxRenderer.h>
#include <HalPowerManager.h>
#include <HalStorage.h>
#include <I18n.h>
#include <Utf8.h>
#include <cstdint>
#include <ctime>
#include <string>
#include <vector>
#include "CrossPointSettings.h"
#include "RecentBooksStore.h"
#include "components/UITheme.h"
#include "components/icons/book.h"
#include "components/icons/book24.h"
#include "components/icons/cover.h"
#include "components/icons/file24.h"
#include "components/icons/folder.h"
#include "components/icons/folder24.h"
#include "components/icons/hotspot.h"
#include "components/icons/image24.h"
#include "components/icons/library.h"
#include "components/icons/recent.h"
#include "components/icons/settings2.h"
#include "components/icons/text24.h"
#include "components/icons/transfer.h"
#include "components/icons/wifi.h"
#include "fontIds.h"
// Internal constants
namespace {
constexpr int batteryPercentSpacing = 4;
constexpr int hPaddingInSelection = 8;
constexpr int cornerRadius = 6;
constexpr int topHintButtonY = 345;
constexpr int popupMarginX = 16;
constexpr int popupMarginY = 12;
constexpr int maxSubtitleWidth = 100;
constexpr int maxListValueWidth = 200;
constexpr int mainMenuIconSize = 32;
constexpr int listIconSize = 24;
constexpr int mainMenuColumns = 2;
const uint8_t* iconForName(UIIcon icon, int size) {
if (size == 24) {
switch (icon) {
case UIIcon::Folder:
return Folder24Icon;
case UIIcon::Text:
return Text24Icon;
case UIIcon::Image:
return Image24Icon;
case UIIcon::Book:
return Book24Icon;
case UIIcon::File:
return File24Icon;
default:
return nullptr;
}
} else if (size == 32) {
switch (icon) {
case UIIcon::Folder:
return FolderIcon;
case UIIcon::Book:
return BookIcon;
case UIIcon::Recent:
return RecentIcon;
case UIIcon::Settings:
return Settings2Icon;
case UIIcon::Transfer:
return TransferIcon;
case UIIcon::Library:
return LibraryIcon;
case UIIcon::Wifi:
return WifiIcon;
case UIIcon::Hotspot:
return HotspotIcon;
default:
return nullptr;
}
}
return nullptr;
}
std::string truncateWithEllipsis(const GfxRenderer& renderer, int fontId, const std::string& text, int maxWidth) {
std::string truncated = text;
std::string withEllipsis = truncated + "...";
while (!truncated.empty() && renderer.getTextWidth(fontId, withEllipsis.c_str()) > maxWidth) {
utf8RemoveLastChar(truncated);
withEllipsis = truncated + "...";
}
return truncated.empty() ? std::string("...") : withEllipsis;
}
std::vector<std::string> wrapTextToLines(const GfxRenderer& renderer, int fontId, const std::string& text, int maxWidth,
int maxLines) {
std::vector<std::string> lines;
if (text.empty() || maxWidth <= 0 || maxLines <= 0) return lines;
if (renderer.getTextWidth(fontId, text.c_str()) <= maxWidth) {
lines.push_back(text);
return lines;
}
if (maxLines == 1) {
lines.push_back(truncateWithEllipsis(renderer, fontId, text, maxWidth));
return lines;
}
static const char* const preferredDelimiters[] = {" -- ", " - ", " \xe2\x80\x93 ", " \xe2\x80\x94 "};
for (const char* delim : preferredDelimiters) {
size_t delimLen = strlen(delim);
auto pos = text.rfind(delim);
if (pos != std::string::npos && pos > 0) {
std::string firstPart = text.substr(0, pos);
if (renderer.getTextWidth(fontId, firstPart.c_str()) <= maxWidth) {
lines.push_back(firstPart);
std::string remainder = text.substr(pos + delimLen);
if (renderer.getTextWidth(fontId, remainder.c_str()) > maxWidth) {
lines.push_back(truncateWithEllipsis(renderer, fontId, remainder, maxWidth));
} else {
lines.push_back(remainder);
}
return lines;
}
}
}
std::string currentLine;
const unsigned char* ptr = reinterpret_cast<const unsigned char*>(text.c_str());
std::string lineAtBreak;
const unsigned char* ptrAtBreak = nullptr;
while (*ptr != 0) {
const unsigned char* charStart = ptr;
uint32_t cp = utf8NextCodepoint(&ptr);
std::string nextChar(reinterpret_cast<const char*>(charStart), static_cast<size_t>(ptr - charStart));
std::string candidate = currentLine + nextChar;
if (renderer.getTextWidth(fontId, candidate.c_str()) <= maxWidth) {
currentLine = candidate;
if (cp == ' ' || cp == '-') {
lineAtBreak = currentLine;
ptrAtBreak = ptr;
}
continue;
}
if (static_cast<int>(lines.size()) < maxLines - 1 && !currentLine.empty()) {
if (ptrAtBreak != nullptr) {
std::string line = lineAtBreak;
while (!line.empty() && line.back() == ' ') line.pop_back();
lines.push_back(line);
ptr = ptrAtBreak;
while (*ptr == ' ') ++ptr;
currentLine.clear();
} else {
lines.push_back(currentLine);
currentLine = nextChar;
}
lineAtBreak.clear();
ptrAtBreak = nullptr;
} else {
lines.push_back(truncateWithEllipsis(renderer, fontId, currentLine, maxWidth));
return lines;
}
}
if (!currentLine.empty()) {
lines.push_back(currentLine);
}
return lines;
}
} // namespace
void LyraTheme::drawBatteryLeft(const GfxRenderer& renderer, Rect rect, const bool showPercentage) const {
// Left aligned: icon on left, percentage on right (reader mode)
const uint16_t percentage = powerManager.getBatteryPercentage();
const int y = rect.y + 6;
const int battWidth = LyraMetrics::values.batteryWidth;
if (showPercentage) {
const auto percentageText = std::to_string(percentage) + "%";
renderer.drawText(SMALL_FONT_ID, rect.x + batteryPercentSpacing + battWidth, rect.y, percentageText.c_str());
}
// Draw icon
const int x = rect.x;
// Top line
renderer.drawLine(x + 1, y, x + battWidth - 3, y);
// Bottom line
renderer.drawLine(x + 1, y + rect.height - 1, x + battWidth - 3, y + rect.height - 1);
// Left line
renderer.drawLine(x, y + 1, x, y + rect.height - 2);
// Battery end
renderer.drawLine(x + battWidth - 2, y + 1, x + battWidth - 2, y + rect.height - 2);
renderer.drawPixel(x + battWidth - 1, y + 3);
renderer.drawPixel(x + battWidth - 1, y + rect.height - 4);
renderer.drawLine(x + battWidth - 0, y + 4, x + battWidth - 0, y + rect.height - 5);
// Draw bars
if (percentage > 10) {
renderer.fillRect(x + 2, y + 2, 3, rect.height - 4);
}
if (percentage > 40) {
renderer.fillRect(x + 6, y + 2, 3, rect.height - 4);
}
if (percentage > 70) {
renderer.fillRect(x + 10, y + 2, 3, rect.height - 4);
}
}
void LyraTheme::drawBatteryRight(const GfxRenderer& renderer, Rect rect, const bool showPercentage) const {
// Right aligned: percentage on left, icon on right (UI headers)
const uint16_t percentage = powerManager.getBatteryPercentage();
const int y = rect.y + 6;
const int battWidth = LyraMetrics::values.batteryWidth;
if (showPercentage) {
const auto percentageText = std::to_string(percentage) + "%";
const int textWidth = renderer.getTextWidth(SMALL_FONT_ID, percentageText.c_str());
// Clear the area where we're going to draw the text to prevent ghosting
const auto textHeight = renderer.getTextHeight(SMALL_FONT_ID);
renderer.fillRect(rect.x - textWidth - batteryPercentSpacing, rect.y, textWidth, textHeight, false);
// Draw text to the left of the icon
renderer.drawText(SMALL_FONT_ID, rect.x - textWidth - batteryPercentSpacing, rect.y, percentageText.c_str());
}
// Draw icon at rect.x
const int x = rect.x;
// Top line
renderer.drawLine(x + 1, y, x + battWidth - 3, y);
// Bottom line
renderer.drawLine(x + 1, y + rect.height - 1, x + battWidth - 3, y + rect.height - 1);
// Left line
renderer.drawLine(x, y + 1, x, y + rect.height - 2);
// Battery end
renderer.drawLine(x + battWidth - 2, y + 1, x + battWidth - 2, y + rect.height - 2);
renderer.drawPixel(x + battWidth - 1, y + 3);
renderer.drawPixel(x + battWidth - 1, y + rect.height - 4);
renderer.drawLine(x + battWidth - 0, y + 4, x + battWidth - 0, y + rect.height - 5);
// Draw bars
if (percentage > 10) {
renderer.fillRect(x + 2, y + 2, 3, rect.height - 4);
}
if (percentage > 40) {
renderer.fillRect(x + 6, y + 2, 3, rect.height - 4);
}
if (percentage > 70) {
renderer.fillRect(x + 10, y + 2, 3, rect.height - 4);
}
}
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 =
SETTINGS.hideBatteryPercentage != CrossPointSettings::HIDE_BATTERY_PERCENTAGE::HIDE_ALWAYS;
// Position icon at right edge, drawBatteryRight will place text to the left
const int batteryX = rect.x + rect.width - 12 - LyraMetrics::values.batteryWidth;
drawBatteryRight(renderer,
Rect{batteryX, rect.y + 5, LyraMetrics::values.batteryWidth, LyraMetrics::values.batteryHeight},
showBatteryPercentage);
// Draw clock on the left side (symmetric with battery on the right)
if (SETTINGS.clockFormat != CrossPointSettings::CLOCK_OFF) {
time_t now = time(nullptr);
struct tm* t = localtime(&now);
if (t != nullptr && t->tm_year > 100) {
char timeBuf[16];
if (SETTINGS.clockFormat == CrossPointSettings::CLOCK_24H) {
snprintf(timeBuf, sizeof(timeBuf), "%02d:%02d", t->tm_hour, t->tm_min);
} else {
int hour12 = t->tm_hour % 12;
if (hour12 == 0) hour12 = 12;
snprintf(timeBuf, sizeof(timeBuf), "%d:%02d %s", hour12, t->tm_min, t->tm_hour >= 12 ? "PM" : "AM");
}
int clockFont = SMALL_FONT_ID;
if (SETTINGS.clockSize == CrossPointSettings::CLOCK_SIZE_MEDIUM)
clockFont = UI_10_FONT_ID;
else if (SETTINGS.clockSize == CrossPointSettings::CLOCK_SIZE_LARGE)
clockFont = UI_12_FONT_ID;
renderer.drawText(clockFont, rect.x + 12, rect.y + 5, timeBuf, true);
}
}
int maxTitleWidth =
rect.width - LyraMetrics::values.contentSidePadding * 2 - (subtitle != nullptr ? maxSubtitleWidth : 0);
if (title) {
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 - 1, 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 - 1, rect.y + rect.height - 1, true);
}
void LyraTheme::drawTabBar(const GfxRenderer& renderer, Rect rect, const std::vector<TabInfo>& tabs,
bool selected) const {
int currentX = rect.x + LyraMetrics::values.contentSidePadding;
if (selected) {
renderer.fillRectDither(rect.x, rect.y, rect.width, rect.height, Color::LightGray);
}
for (const auto& tab : tabs) {
const int textWidth = renderer.getTextWidth(UI_10_FONT_ID, tab.label, EpdFontFamily::REGULAR);
if (tab.selected) {
if (selected) {
renderer.fillRoundedRect(currentX, rect.y + 1, textWidth + 2 * hPaddingInSelection, rect.height - 4,
cornerRadius, Color::Black);
} else {
renderer.fillRectDither(currentX, rect.y, textWidth + 2 * hPaddingInSelection, rect.height - 3,
Color::LightGray);
renderer.drawLine(currentX, rect.y + rect.height - 3, currentX + textWidth + 2 * hPaddingInSelection,
rect.y + rect.height - 3, 2, true);
}
}
renderer.drawText(UI_10_FONT_ID, currentX + hPaddingInSelection, rect.y + 6, tab.label, !(tab.selected && selected),
EpdFontFamily::REGULAR);
currentX += textWidth + LyraMetrics::values.tabSpacing + 2 * hPaddingInSelection;
}
renderer.drawLine(rect.x, rect.y + rect.height - 1, rect.x + rect.width - 1, rect.y + rect.height - 1, true);
}
void LyraTheme::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 {
int rowHeight =
(rowSubtitle != nullptr) ? LyraMetrics::values.listWithSubtitleRowHeight : LyraMetrics::values.listRowHeight;
int pageItems = rect.height / rowHeight;
// Detect if selected row's title overflows and needs 2-line expansion
bool selectedExpands = false;
if (selectedIndex >= 0 && rowSubtitle == nullptr && rowValue != nullptr) {
int prelTotalPages = (itemCount + pageItems - 1) / pageItems;
int prelContentWidth =
rect.width -
(prelTotalPages > 1 ? (LyraMetrics::values.scrollBarWidth + LyraMetrics::values.scrollBarRightOffset) : 1);
int prelTextWidth = prelContentWidth - LyraMetrics::values.contentSidePadding * 2 - hPaddingInSelection * 2;
if (rowIcon != nullptr) prelTextWidth -= listIconSize + hPaddingInSelection;
auto selTitle = rowTitle(selectedIndex);
auto selValue = rowValue(selectedIndex);
int selValueWidth = 0;
if (!selValue.empty()) {
selValue = renderer.truncatedText(UI_10_FONT_ID, selValue.c_str(), maxListValueWidth);
selValueWidth = renderer.getTextWidth(UI_10_FONT_ID, selValue.c_str()) + hPaddingInSelection;
}
if (renderer.getTextWidth(UI_10_FONT_ID, selTitle.c_str()) > prelTextWidth - selValueWidth) {
selectedExpands = true;
}
}
const int effectivePageItems = selectedExpands ? std::max(1, pageItems - 1) : pageItems;
const int totalPages = (itemCount + effectivePageItems - 1) / effectivePageItems;
if (totalPages > 1) {
const int scrollAreaHeight = rect.height;
const int scrollBarHeight = (scrollAreaHeight * effectivePageItems) / itemCount;
const int currentPage = selectedIndex / effectivePageItems;
const int scrollBarY = rect.y + ((scrollAreaHeight - scrollBarHeight) * currentPage) / (totalPages - 1);
const int scrollBarX = rect.x + rect.width - LyraMetrics::values.scrollBarRightOffset;
renderer.drawLine(scrollBarX, rect.y, scrollBarX, rect.y + scrollAreaHeight, true);
renderer.fillRect(scrollBarX - LyraMetrics::values.scrollBarWidth, scrollBarY, LyraMetrics::values.scrollBarWidth,
scrollBarHeight, true);
}
int contentWidth =
rect.width -
(totalPages > 1 ? (LyraMetrics::values.scrollBarWidth + LyraMetrics::values.scrollBarRightOffset) : 1);
// Compute page start: use effective page items but prevent backward leak
int pageStartIndex;
if (selectedExpands) {
int rawStart = selectedIndex / effectivePageItems * effectivePageItems;
int originalStart = selectedIndex / pageItems * pageItems;
pageStartIndex = std::max(rawStart, originalStart);
if (selectedIndex >= pageStartIndex + effectivePageItems) {
pageStartIndex = selectedIndex - effectivePageItems + 1;
}
if (pageStartIndex > 0 && pageStartIndex == originalStart
&& selectedIndex < pageStartIndex + effectivePageItems - 1) {
int checkTextWidth = contentWidth - LyraMetrics::values.contentSidePadding * 2 - hPaddingInSelection * 2;
if (rowIcon != nullptr) checkTextWidth -= listIconSize + hPaddingInSelection;
auto prevTitle = rowTitle(pageStartIndex - 1);
int prevValueWidth = 0;
if (rowValue != nullptr) {
auto prevValue = rowValue(pageStartIndex - 1);
prevValue = renderer.truncatedText(UI_10_FONT_ID, prevValue.c_str(), maxListValueWidth);
if (!prevValue.empty()) {
prevValueWidth = renderer.getTextWidth(UI_10_FONT_ID, prevValue.c_str()) + hPaddingInSelection;
}
}
if (renderer.getTextWidth(UI_10_FONT_ID, prevTitle.c_str()) > checkTextWidth - prevValueWidth) {
pageStartIndex--;
}
}
} else {
pageStartIndex = selectedIndex / pageItems * pageItems;
// Include previous page's boundary item if it would need expansion when selected,
// so it doesn't vanish when navigating from it to the current page.
if (pageStartIndex > 0 && selectedIndex < pageStartIndex + pageItems - 1) {
int checkTextWidth = contentWidth - LyraMetrics::values.contentSidePadding * 2 - hPaddingInSelection * 2;
if (rowIcon != nullptr) checkTextWidth -= listIconSize + hPaddingInSelection;
auto prevTitle = rowTitle(pageStartIndex - 1);
int prevValueWidth = 0;
if (rowValue != nullptr) {
auto prevValue = rowValue(pageStartIndex - 1);
prevValue = renderer.truncatedText(UI_10_FONT_ID, prevValue.c_str(), maxListValueWidth);
if (!prevValue.empty()) {
prevValueWidth = renderer.getTextWidth(UI_10_FONT_ID, prevValue.c_str()) + hPaddingInSelection;
}
}
if (renderer.getTextWidth(UI_10_FONT_ID, prevTitle.c_str()) > checkTextWidth - prevValueWidth) {
pageStartIndex--;
}
}
}
// Draw selection highlight
if (selectedIndex >= 0) {
int selRowsBeforeOnPage = selectedIndex - pageStartIndex;
int selY = rect.y + selRowsBeforeOnPage * rowHeight;
int selHeight = selectedExpands ? 2 * rowHeight : rowHeight;
renderer.fillRoundedRect(LyraMetrics::values.contentSidePadding, selY,
contentWidth - LyraMetrics::values.contentSidePadding * 2, selHeight, cornerRadius,
Color::LightGray);
}
int textX = rect.x + LyraMetrics::values.contentSidePadding + hPaddingInSelection;
int textWidth = contentWidth - LyraMetrics::values.contentSidePadding * 2 - hPaddingInSelection * 2;
int iconSize = listIconSize;
if (rowIcon != nullptr) {
iconSize = (rowSubtitle != nullptr) ? mainMenuIconSize : listIconSize;
textX += iconSize + hPaddingInSelection;
textWidth -= iconSize + hPaddingInSelection;
}
// Draw all items
int iconY = (rowSubtitle != nullptr) ? 16 : 10;
int yPos = rect.y;
for (int i = pageStartIndex; i < itemCount && i < pageStartIndex + effectivePageItems; i++) {
const bool isExpanded = (selectedExpands && i == selectedIndex);
int valueWidth = 0;
std::string valueText;
if (rowValue != nullptr) {
valueText = rowValue(i);
valueText = renderer.truncatedText(UI_10_FONT_ID, valueText.c_str(), maxListValueWidth);
if (!valueText.empty()) {
valueWidth = renderer.getTextWidth(UI_10_FONT_ID, valueText.c_str()) + hPaddingInSelection;
}
}
auto itemName = rowTitle(i);
if (isExpanded) {
int wrapWidth = textWidth;
auto lines = wrapTextToLines(renderer, UI_10_FONT_ID, itemName, wrapWidth, 2);
for (size_t l = 0; l < lines.size(); ++l) {
renderer.drawText(UI_10_FONT_ID, textX, yPos + 7 + static_cast<int>(l) * rowHeight, lines[l].c_str(), true);
}
if (rowIcon != nullptr) {
UIIcon icon = rowIcon(i);
const uint8_t* iconBitmap = iconForName(icon, iconSize);
if (iconBitmap != nullptr) {
renderer.drawIcon(iconBitmap, rect.x + LyraMetrics::values.contentSidePadding + hPaddingInSelection,
yPos + iconY, iconSize, iconSize);
}
}
if (!valueText.empty()) {
renderer.drawText(UI_10_FONT_ID,
rect.x + contentWidth - LyraMetrics::values.contentSidePadding - valueWidth,
yPos + rowHeight + 7, valueText.c_str(), true);
}
yPos += 2 * rowHeight;
} else {
int rowTextWidth = textWidth - valueWidth;
auto item = renderer.truncatedText(UI_10_FONT_ID, itemName.c_str(), rowTextWidth);
renderer.drawText(UI_10_FONT_ID, textX, yPos + 7, item.c_str(), true);
if (rowIcon != nullptr) {
UIIcon icon = rowIcon(i);
const uint8_t* iconBitmap = iconForName(icon, iconSize);
if (iconBitmap != nullptr) {
renderer.drawIcon(iconBitmap, rect.x + LyraMetrics::values.contentSidePadding + hPaddingInSelection,
yPos + iconY, iconSize, iconSize);
}
}
if (rowSubtitle != nullptr) {
std::string subtitleText = rowSubtitle(i);
auto subtitle = renderer.truncatedText(SMALL_FONT_ID, subtitleText.c_str(), rowTextWidth);
renderer.drawText(SMALL_FONT_ID, textX, yPos + 30, subtitle.c_str(), true);
}
if (!valueText.empty()) {
if (i == selectedIndex && highlightValue) {
renderer.fillRoundedRect(
contentWidth - LyraMetrics::values.contentSidePadding - hPaddingInSelection - valueWidth, yPos,
valueWidth + hPaddingInSelection, rowHeight, cornerRadius, Color::Black);
}
renderer.drawText(UI_10_FONT_ID,
rect.x + contentWidth - LyraMetrics::values.contentSidePadding - valueWidth, yPos + 6,
valueText.c_str(), !(i == selectedIndex && highlightValue));
}
yPos += rowHeight;
}
}
}
void LyraTheme::drawButtonHints(GfxRenderer& renderer, const char* btn1, const char* btn2, const char* btn3,
const char* btn4) const {
const GfxRenderer::Orientation orig_orientation = renderer.getOrientation();
renderer.setOrientation(GfxRenderer::Orientation::Portrait);
const int pageHeight = renderer.getScreenHeight();
constexpr int buttonWidth = 80;
constexpr int smallButtonHeight = 15;
constexpr int buttonHeight = LyraMetrics::values.buttonHintsHeight;
constexpr int buttonY = LyraMetrics::values.buttonHintsHeight; // Distance from bottom
constexpr int textYOffset = 7; // Distance from top of button to text baseline
constexpr int buttonPositions[] = {58, 146, 254, 342};
const char* labels[] = {btn1, btn2, btn3, btn4};
for (int i = 0; i < 4; i++) {
const int x = buttonPositions[i];
if (labels[i] != nullptr && labels[i][0] != '\0') {
// Draw the filled background and border for a FULL-sized button
renderer.fillRect(x, pageHeight - buttonY, buttonWidth, buttonHeight, false);
renderer.drawRoundedRect(x, pageHeight - buttonY, buttonWidth, buttonHeight, 1, cornerRadius, true, true, false,
false, true);
const int textWidth = renderer.getTextWidth(SMALL_FONT_ID, labels[i]);
const int textX = x + (buttonWidth - 1 - textWidth) / 2;
renderer.drawText(SMALL_FONT_ID, textX, pageHeight - buttonY + textYOffset, labels[i]);
} else {
// Draw the filled background and border for a SMALL-sized button
renderer.fillRect(x, pageHeight - smallButtonHeight, buttonWidth, smallButtonHeight, false);
renderer.drawRoundedRect(x, pageHeight - smallButtonHeight, buttonWidth, smallButtonHeight, 1, cornerRadius, true,
true, false, false, true);
}
}
renderer.setOrientation(orig_orientation);
}
void LyraTheme::drawSideButtonHints(const GfxRenderer& renderer, const char* topBtn, const char* bottomBtn) const {
const int screenWidth = renderer.getScreenWidth();
constexpr int buttonWidth = LyraMetrics::values.sideButtonHintsWidth; // Width on screen (height when rotated)
constexpr int buttonHeight = 78; // Height on screen (width when rotated)
// Position for the button group - buttons share a border so they're adjacent
const char* labels[] = {topBtn, bottomBtn};
// Draw the shared border for both buttons as one unit
const int x = screenWidth - buttonWidth;
// Draw top button outline
if (topBtn != nullptr && topBtn[0] != '\0') {
renderer.drawRoundedRect(x, topHintButtonY, buttonWidth, buttonHeight, 1, cornerRadius, true, false, true, false,
true);
}
// Draw bottom button outline
if (bottomBtn != nullptr && bottomBtn[0] != '\0') {
renderer.drawRoundedRect(x, topHintButtonY + buttonHeight + 5, buttonWidth, buttonHeight, 1, cornerRadius, true,
false, true, false, true);
}
// Draw text for each button
for (int i = 0; i < 2; i++) {
if (labels[i] != nullptr && labels[i][0] != '\0') {
const int y = topHintButtonY + (i * buttonHeight + 5);
// Draw rotated text centered in the button
const int textWidth = renderer.getTextWidth(SMALL_FONT_ID, labels[i]);
renderer.drawTextRotated90CW(SMALL_FONT_ID, x, y + (buttonHeight + textWidth) / 2, labels[i]);
}
}
}
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 bookCount = std::min(static_cast<int>(recentBooks.size()), LyraMetrics::values.homeRecentBooksCount);
const int tileHeight = rect.height;
const int tileY = rect.y;
const int coverHeight = LyraMetrics::values.homeCoverHeight;
if (bookCount == 0) {
drawEmptyRecents(renderer, rect);
return;
}
auto wrapText = [&renderer](int fontId, const std::string& text, int maxWidth,
int maxLines) -> std::vector<std::string> {
std::vector<std::string> words;
words.reserve(8);
size_t pos = 0;
while (pos < text.size()) {
while (pos < text.size() && text[pos] == ' ') ++pos;
if (pos >= text.size()) break;
const size_t start = pos;
while (pos < text.size() && text[pos] != ' ') ++pos;
words.emplace_back(text.substr(start, pos - start));
}
const int spaceWidth = renderer.getSpaceWidth(fontId);
std::vector<std::string> lines;
std::string currentLine;
for (auto& word : words) {
if (static_cast<int>(lines.size()) >= maxLines) {
lines.back().append("...");
while (!lines.back().empty() && renderer.getTextWidth(fontId, lines.back().c_str()) > maxWidth) {
lines.back().resize(lines.back().size() - 3);
utf8RemoveLastChar(lines.back());
lines.back().append("...");
}
break;
}
int wordWidth = renderer.getTextWidth(fontId, word.c_str());
while (wordWidth > maxWidth && !word.empty()) {
utf8RemoveLastChar(word);
std::string withEllipsis = word + "...";
wordWidth = renderer.getTextWidth(fontId, withEllipsis.c_str());
if (wordWidth <= maxWidth) {
word = withEllipsis;
break;
}
}
int newLineWidth = renderer.getTextWidth(fontId, currentLine.c_str());
if (newLineWidth > 0) newLineWidth += spaceWidth;
newLineWidth += wordWidth;
if (newLineWidth > maxWidth && !currentLine.empty()) {
lines.push_back(currentLine);
currentLine = word;
} else {
if (!currentLine.empty()) currentLine.append(" ");
currentLine.append(word);
}
}
if (!currentLine.empty() && static_cast<int>(lines.size()) < maxLines) {
lines.push_back(currentLine);
}
return lines;
};
auto& storage = HalStorage::getInstance();
auto renderCoverBitmap = [&renderer, &storage, coverHeight](const std::string& coverBmpPath, int slotX, int slotY,
int slotWidth) {
FsFile file;
if (storage.openFileForRead("HOME", coverBmpPath, file)) {
Bitmap bitmap(file);
if (bitmap.parseHeaders() == BmpReaderError::Ok) {
float bmpW = static_cast<float>(bitmap.getWidth());
float bmpH = static_cast<float>(bitmap.getHeight());
float ratio = bmpW / bmpH;
int naturalWidth = static_cast<int>(coverHeight * ratio);
if (naturalWidth >= slotWidth) {
float slotRatio = static_cast<float>(slotWidth) / static_cast<float>(coverHeight);
float cropX = 1.0f - (slotRatio / ratio);
renderer.drawBitmap(bitmap, slotX, slotY, slotWidth, coverHeight, cropX);
} else {
int offsetX = (slotWidth - naturalWidth) / 2;
renderer.drawBitmap(bitmap, slotX + offsetX, slotY, naturalWidth, coverHeight, 0.0f);
}
}
file.close();
}
};
if (bookCount == 1) {
const bool bookSelected = (selectorIndex == 0);
const int cardX = LyraMetrics::values.contentSidePadding;
const int cardWidth = rect.width - 2 * LyraMetrics::values.contentSidePadding;
const int coverSlotWidth = static_cast<int>(coverHeight * 0.65f);
const int textGap = hPaddingInSelection * 2;
const int textAreaX = cardX + hPaddingInSelection + coverSlotWidth + textGap;
const int textAreaWidth = cardWidth - hPaddingInSelection * 2 - coverSlotWidth - textGap;
if (!coverRendered) {
renderer.drawRect(cardX + hPaddingInSelection, tileY + hPaddingInSelection, coverSlotWidth, coverHeight);
if (!recentBooks[0].coverBmpPath.empty()) {
const std::string coverBmpPath = UITheme::getCoverThumbPath(recentBooks[0].coverBmpPath, coverHeight);
renderCoverBitmap(coverBmpPath, cardX + hPaddingInSelection, tileY + hPaddingInSelection, coverSlotWidth);
}
coverBufferStored = storeCoverBuffer();
coverRendered = true;
}
if (bookSelected) {
renderer.fillRoundedRect(cardX, tileY, cardWidth, hPaddingInSelection, cornerRadius, true, true, false, false,
Color::LightGray);
renderer.fillRectDither(cardX, tileY + hPaddingInSelection, hPaddingInSelection, coverHeight, Color::LightGray);
renderer.fillRectDither(cardX + cardWidth - hPaddingInSelection, tileY + hPaddingInSelection, hPaddingInSelection,
coverHeight, Color::LightGray);
renderer.fillRectDither(cardX + hPaddingInSelection + coverSlotWidth, tileY + hPaddingInSelection,
cardWidth - hPaddingInSelection * 2 - coverSlotWidth, coverHeight, Color::LightGray);
const int bottomY = tileY + hPaddingInSelection + coverHeight;
const int bottomH = tileHeight - hPaddingInSelection - coverHeight;
if (bottomH > 0) {
renderer.fillRoundedRect(cardX, bottomY, cardWidth, bottomH, cornerRadius, false, false, true, true,
Color::LightGray);
}
}
auto titleLines = wrapText(UI_12_FONT_ID, recentBooks[0].title, textAreaWidth, 5);
const int titleLineHeight = renderer.getLineHeight(UI_12_FONT_ID);
int textY = tileY + hPaddingInSelection + 3;
for (const auto& line : titleLines) {
renderer.drawText(UI_12_FONT_ID, textAreaX, textY, line.c_str(), true);
textY += titleLineHeight;
}
if (!recentBooks[0].author.empty()) {
textY += 4;
auto author = renderer.truncatedText(UI_10_FONT_ID, recentBooks[0].author.c_str(), textAreaWidth);
renderer.drawText(UI_10_FONT_ID, textAreaX, textY, author.c_str(), true);
}
} else {
const int tileWidth = (rect.width - 2 * LyraMetrics::values.contentSidePadding) / bookCount;
const int bottomSectionHeight = tileHeight - coverHeight - hPaddingInSelection;
if (!coverRendered) {
for (int i = 0; i < bookCount; i++) {
int tileX = LyraMetrics::values.contentSidePadding + tileWidth * i;
int drawWidth = tileWidth - 2 * hPaddingInSelection;
renderer.drawRect(tileX + hPaddingInSelection, tileY + hPaddingInSelection, drawWidth, coverHeight);
if (!recentBooks[i].coverBmpPath.empty()) {
const std::string coverBmpPath = UITheme::getCoverThumbPath(recentBooks[i].coverBmpPath, coverHeight);
renderCoverBitmap(coverBmpPath, tileX + hPaddingInSelection, tileY + hPaddingInSelection, drawWidth);
}
}
coverBufferStored = storeCoverBuffer();
coverRendered = true;
}
for (int i = 0; i < bookCount; i++) {
bool bookSelected = (selectorIndex == i);
int tileX = LyraMetrics::values.contentSidePadding + tileWidth * i;
const int maxTextWidth = tileWidth - 2 * hPaddingInSelection;
if (bookSelected) {
renderer.fillRoundedRect(tileX, tileY, tileWidth, hPaddingInSelection, cornerRadius, true, true, false, false,
Color::LightGray);
renderer.fillRectDither(tileX, tileY + hPaddingInSelection, hPaddingInSelection, coverHeight, Color::LightGray);
renderer.fillRectDither(tileX + tileWidth - hPaddingInSelection, tileY + hPaddingInSelection,
hPaddingInSelection, coverHeight, Color::LightGray);
renderer.fillRoundedRect(tileX, tileY + coverHeight + hPaddingInSelection, tileWidth, bottomSectionHeight,
cornerRadius, false, false, true, true, Color::LightGray);
}
auto titleLines = wrapText(UI_10_FONT_ID, recentBooks[i].title, maxTextWidth, 2);
const int lineHeight = renderer.getLineHeight(UI_10_FONT_ID);
int textY = tileY + coverHeight + hPaddingInSelection + 4;
for (const auto& line : titleLines) {
renderer.drawText(UI_10_FONT_ID, tileX + hPaddingInSelection, textY, line.c_str(), true);
textY += lineHeight;
}
if (!recentBooks[i].author.empty()) {
auto author = renderer.truncatedText(SMALL_FONT_ID, recentBooks[i].author.c_str(), maxTextWidth);
renderer.drawText(SMALL_FONT_ID, tileX + hPaddingInSelection, textY + 2, author.c_str(), true);
}
}
}
}
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<UIIcon(int index)>& rowIcon) const {
for (int i = 0; i < buttonCount; ++i) {
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;
if (selected) {
renderer.fillRoundedRect(tileRect.x, tileRect.y, tileRect.width, tileRect.height, cornerRadius, Color::LightGray);
}
std::string labelStr = buttonLabel(i);
const char* label = labelStr.c_str();
int textX = tileRect.x + 16;
const int lineHeight = renderer.getLineHeight(UI_12_FONT_ID);
const int textY = tileRect.y + (LyraMetrics::values.menuRowHeight - lineHeight) / 2;
if (rowIcon != nullptr) {
UIIcon icon = rowIcon(i);
const uint8_t* iconBitmap = iconForName(icon, mainMenuIconSize);
if (iconBitmap != nullptr) {
renderer.drawIcon(iconBitmap, textX, textY + 3, mainMenuIconSize, mainMenuIconSize);
textX += mainMenuIconSize + hPaddingInSelection + 2;
}
}
renderer.drawText(UI_12_FONT_ID, textX, textY, label, true);
}
}
Rect LyraTheme::drawPopup(const GfxRenderer& renderer, const char* message) const {
constexpr int y = 132;
constexpr int outline = 2;
const int textWidth = renderer.getTextWidth(UI_12_FONT_ID, message, EpdFontFamily::REGULAR);
const int textHeight = renderer.getLineHeight(UI_12_FONT_ID);
const int w = textWidth + popupMarginX * 2;
const int h = textHeight + popupMarginY * 2;
const int x = (renderer.getScreenWidth() - w) / 2;
renderer.fillRoundedRect(x - outline, y - outline, w + outline * 2, h + outline * 2, cornerRadius + outline,
Color::White);
renderer.fillRoundedRect(x, y, w, h, cornerRadius, Color::Black);
const int textX = x + (w - textWidth) / 2;
const int textY = y + popupMarginY - 2;
renderer.drawText(UI_12_FONT_ID, textX, textY, message, false, EpdFontFamily::REGULAR);
renderer.displayBuffer();
return Rect{x, y, w, h};
}
void LyraTheme::fillPopupProgress(const GfxRenderer& renderer, const Rect& layout, const int progress) const {
constexpr int barHeight = 4;
const int barWidth = layout.width - popupMarginX * 2;
const int barX = layout.x + (layout.width - barWidth) / 2;
const int barY = layout.y + layout.height - popupMarginY / 2 - barHeight / 2 - 1;
int fillWidth = barWidth * progress / 100;
renderer.fillRect(barX, barY, fillWidth, barHeight, false);
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);
}