Files
Crosspoint/src/components/themes/BaseTheme.cpp
T
Uri TauberandJulia Nguyen 1db1442319 fix: several bookmarks UX improvments (#2372)
## Summary

This PR enhances the EPUB reader's bookmark system with two
complementary improvements: a per-page bookmark indicator icon and
toggle behavior on the existing long-press action.

---

### What Changed

**Bookmark Toggle (was: add-only)**

The long-press Confirm action now toggles bookmarks rather than always
adding. `addBookmark()` checks whether a bookmark with the same xpath
already exists in the in-memory cache:
- If found → removes it and shows "Bookmark removed."
- If not found → adds it and shows "Bookmark added."

A new `STR_BOOKMARK_REMOVED` translation string was added to support the
removal message.

**Bookmark Icon Indicator**

A `BookmarkIcon` is now drawn at the top-right corner of the page
whenever the current page has a bookmark. `updateBookmarkFlag()` is
called at render time to determine whether the current page is
bookmarked.

**In-Memory Bookmark Cache**

Bookmarks are now loaded into `cachedBookmarks` on `onEnter()` rather
than being re-read from disk on every toggle. All subsequent add/remove
operations work against this cache and flush to disk, avoiding redundant
file reads on each bookmark action.

**Faster bookmarks list**

Previously, calculating "page X/Y" for each entry required decompressing
the entire spine item. We now persist `si`/`pc`/`pp` (spine index, page
count, and page progress) in the bookmark JSON when saving, and restore
them when loading. This avoids the expensive `toCrossPoint()` loop in
`onEnter()`, significantly reducing the cost of initializing the
bookmarks list.

---

### Files Changed

- `EpubReaderActivity.cpp` — `addBookmark()` toggle logic,
`updateBookmarkFlag()` (new), icon rendering in `renderContents()`,
cache initialization in `onEnter()`
- `EpubReaderActivity.h` — new fields: `currentPageBookmarked`,
`bookmarkRemoved`, `cachedBookmarks`; new method declaration
`updateBookmarkFlag()`

---

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

---------

Co-authored-by: Julia Nguyen <julia@uxj.io>
2026-06-23 14:55:01 -04:00

992 lines
45 KiB
C++

#include "BaseTheme.h"
#include <GfxRenderer.h>
#include <HalClock.h>
#include <HalPowerManager.h>
#include <HalStorage.h>
#include <Logging.h>
#include <algorithm>
#include <cstdint>
#include <string>
#include "I18n.h"
#include "RecentBooksStore.h"
#include "components/UITheme.h"
#include "components/icons/bookmark.h"
#include "fontIds.h"
// Internal constants
namespace {
constexpr int homeMenuMargin = 20;
constexpr int homeMarginTop = 30;
constexpr int subtitleY = 738;
constexpr int bookmarkStatusIconWidth = 16;
constexpr int bookmarkStatusIconHeight = 14;
constexpr int bookmarkStatusIconGap = 4;
constexpr int bookmarkStatusIconTopCrop = 2;
bool statusBarTextLaneVisible() {
return SETTINGS.statusBarChapterPageCount || SETTINGS.statusBarBookProgressPercentage ||
SETTINGS.statusBarTitle != CrossPointSettings::STATUS_BAR_TITLE::HIDE_TITLE || SETTINGS.statusBarBattery ||
(SETTINGS.statusBarClock && halClock.isAvailable());
}
void drawBookmarkStatusIcon(const GfxRenderer& renderer, const int x, const int y) {
constexpr int bytesPerRow = bookmarkStatusIconWidth / 8;
for (int row = 0; row < bookmarkStatusIconHeight; ++row) {
for (int col = 0; col < bookmarkStatusIconWidth; ++col) {
const uint8_t byte = BookmarkStatusIcon[(row + bookmarkStatusIconTopCrop) * bytesPerRow + col / 8];
const uint8_t mask = 1U << (7 - (col % 8));
renderer.drawPixel(x + col, y + row, (byte & mask) != 0);
}
}
}
} // namespace
void BaseTheme::drawBatteryOutline(const GfxRenderer& renderer, int x, int y, int battWidth, int rectHeight) {
// 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);
}
void BaseTheme::drawBatteryLightningBolt(const GfxRenderer& renderer, int boltX, int boltY) {
// Draw lightning bolt (white/inverted on black fill for visibility)
renderer.drawLine(boltX + 4, boltY + 0, boltX + 5, boltY + 0, false);
renderer.drawLine(boltX + 3, boltY + 1, boltX + 4, boltY + 1, false);
renderer.drawLine(boltX + 2, boltY + 2, boltX + 5, boltY + 2, false);
renderer.drawLine(boltX + 3, boltY + 3, boltX + 4, boltY + 3, false);
renderer.drawLine(boltX + 2, boltY + 4, boltX + 3, boltY + 4, false);
renderer.drawLine(boltX + 1, boltY + 5, boltX + 4, boltY + 5, false);
renderer.drawLine(boltX + 2, boltY + 6, boltX + 3, boltY + 6, false);
renderer.drawLine(boltX + 1, boltY + 7, boltX + 2, boltY + 7, false);
}
void BaseTheme::fillBatteryIcon(const GfxRenderer& renderer, Rect rect, uint16_t percentage) const {
const bool charging = gpio.isUsbConnected();
const int maxFillWidth = rect.width - 5;
const int fillHeight = rect.height - 4;
if (maxFillWidth <= 0 || fillHeight <= 0) {
return;
}
// +1 to round up so we always fill at least one pixel
int filledWidth = percentage * maxFillWidth / 100 + 1;
if (filledWidth > maxFillWidth) {
filledWidth = maxFillWidth;
}
// When charging, ensure minimum fill so lightning bolt is fully visible
constexpr int minFillForBolt = 8;
if (charging && filledWidth < minFillForBolt) {
filledWidth = std::min(minFillForBolt, maxFillWidth);
}
renderer.fillRect(rect.x + 2, rect.y + 2, filledWidth, fillHeight);
if (charging) {
drawBatteryLightningBolt(renderer, rect.x + 4, rect.y + 2);
}
}
void BaseTheme::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;
if (showPercentage) {
const auto percentageText = std::to_string(percentage) + "%";
renderer.drawText(SMALL_FONT_ID, rect.x + batteryPercentSpacing + rect.width, rect.y, percentageText.c_str());
}
const Rect iconRect{rect.x, y, rect.width, rect.height};
drawBatteryOutline(renderer, rect.x, y, rect.width, rect.height);
fillBatteryIcon(renderer, iconRect, percentage);
}
void BaseTheme::drawBatteryRight(const GfxRenderer& renderer, Rect rect, const bool showPercentage) const {
// Right aligned: percentage on left, icon on right (UI headers)
// rect.x is already positioned for the icon (drawHeader calculated it)
const uint16_t percentage = powerManager.getBatteryPercentage();
const int y = rect.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, rect.x - textWidth - batteryPercentSpacing, rect.y, percentageText.c_str());
}
const Rect iconRect{rect.x, y, rect.width, rect.height};
drawBatteryOutline(renderer, rect.x, y, rect.width, rect.height);
fillBatteryIcon(renderer, iconRect, percentage);
}
void BaseTheme::drawProgressBar(const GfxRenderer& renderer, Rect rect, const size_t current,
const size_t total) const {
if (total == 0) {
return;
}
// 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);
// Draw filled portion
const int fillWidth = (rect.width - 4) * percent / 100;
if (fillWidth > 0) {
renderer.fillRect(rect.x + 2, rect.y + 2, fillWidth, rect.height - 4);
}
// Draw percentage text centered below bar
const std::string percentText = std::to_string(percent) + "%";
renderer.drawCenteredText(UI_10_FONT_ID, rect.y + rect.height + 15, percentText.c_str());
}
void BaseTheme::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 = 106;
constexpr int buttonHeight = BaseMetrics::values.buttonHintsHeight;
constexpr int buttonY = BaseMetrics::values.buttonHintsHeight; // Distance from bottom
constexpr int textYOffset = 7; // Distance from top of button to text baseline
// X3 has wider screen in portrait (528 vs 480), use more spacing
constexpr int x4ButtonPositions[] = {25, 130, 245, 350};
constexpr int x3ButtonPositions[] = {38, 154, 268, 384};
const int* buttonPositions = gpio.deviceIsX3() ? x3ButtonPositions : x4ButtonPositions;
const char* labels[] = {btn1, btn2, btn3, btn4};
for (int i = 0; i < 4; i++) {
// Only draw if the label is non-empty
if (labels[i] != nullptr && labels[i][0] != '\0') {
const int x = buttonPositions[i];
renderer.fillRect(x, pageHeight - buttonY, buttonWidth, buttonHeight, false);
renderer.drawRect(x, pageHeight - buttonY, buttonWidth, buttonHeight);
const int textWidth = renderer.getTextWidth(UI_10_FONT_ID, labels[i]);
const int textX = x + (buttonWidth - 1 - textWidth) / 2;
renderer.drawText(UI_10_FONT_ID, textX, pageHeight - buttonY + textYOffset, labels[i]);
}
}
renderer.setOrientation(orig_orientation);
}
void BaseTheme::drawSideButtonHints(const GfxRenderer& renderer, const char* topBtn, const char* bottomBtn) const {
const int screenWidth = renderer.getScreenWidth();
constexpr int buttonWidth = BaseMetrics::values.sideButtonHintsWidth; // Width on screen (height when rotated)
constexpr int buttonHeight = 80; // Height on screen (width when rotated)
constexpr int buttonMargin = 4;
if (gpio.deviceIsX3()) {
// X3 layout: Up on left side, Down on right side, positioned higher
constexpr int x3ButtonY = 155;
if (topBtn != nullptr && topBtn[0] != '\0') {
const int leftX = buttonMargin;
renderer.drawRect(leftX, x3ButtonY, buttonWidth, buttonHeight);
const int textWidth = renderer.getTextWidth(SMALL_FONT_ID, topBtn);
const int textHeight = renderer.getTextHeight(SMALL_FONT_ID);
const int textX = leftX + (buttonWidth - textHeight) / 2;
const int textY = x3ButtonY + (buttonHeight + textWidth) / 2;
renderer.drawTextRotated90CW(SMALL_FONT_ID, textX, textY, topBtn);
}
if (bottomBtn != nullptr && bottomBtn[0] != '\0') {
const int rightX = screenWidth - buttonMargin - buttonWidth;
renderer.drawRect(rightX, x3ButtonY, buttonWidth, buttonHeight);
const int textWidth = renderer.getTextWidth(SMALL_FONT_ID, bottomBtn);
const int textHeight = renderer.getTextHeight(SMALL_FONT_ID);
const int textX = rightX + (buttonWidth - textHeight) / 2;
const int textY = x3ButtonY + (buttonHeight + textWidth) / 2;
renderer.drawTextRotated90CW(SMALL_FONT_ID, textX, textY, bottomBtn);
}
} else {
// X4 layout: Both buttons stacked on right side
constexpr int topButtonY = 345;
const char* labels[] = {topBtn, bottomBtn};
const int x = screenWidth - buttonMargin - buttonWidth;
if (topBtn != nullptr && topBtn[0] != '\0') {
renderer.drawLine(x, topButtonY, x + buttonWidth - 1, topButtonY);
renderer.drawLine(x, topButtonY, x, topButtonY + buttonHeight - 1);
renderer.drawLine(x + buttonWidth - 1, topButtonY, x + buttonWidth - 1, topButtonY + buttonHeight - 1);
}
if ((topBtn != nullptr && topBtn[0] != '\0') || (bottomBtn != nullptr && bottomBtn[0] != '\0')) {
renderer.drawLine(x, topButtonY + buttonHeight, x + buttonWidth - 1, topButtonY + buttonHeight);
}
if (bottomBtn != nullptr && bottomBtn[0] != '\0') {
renderer.drawLine(x, topButtonY + buttonHeight, x, topButtonY + 2 * buttonHeight - 1);
renderer.drawLine(x + buttonWidth - 1, topButtonY + buttonHeight, x + buttonWidth - 1,
topButtonY + 2 * buttonHeight - 1);
renderer.drawLine(x, topButtonY + 2 * buttonHeight - 1, x + buttonWidth - 1, topButtonY + 2 * buttonHeight - 1);
}
for (int i = 0; i < 2; i++) {
if (labels[i] != nullptr && labels[i][0] != '\0') {
const int y = topButtonY + i * buttonHeight;
const int textWidth = renderer.getTextWidth(SMALL_FONT_ID, labels[i]);
const int textHeight = renderer.getTextHeight(SMALL_FONT_ID);
const int textX = x + (buttonWidth - textHeight) / 2;
const int textY = y + (buttonHeight + textWidth) / 2;
renderer.drawTextRotated90CW(SMALL_FONT_ID, textX, textY, labels[i]);
}
}
}
}
int BaseTheme::getListPageItems(int contentHeight, bool hasSubtitle) const {
int rowHeight = (hasSubtitle) ? BaseMetrics::values.listWithSubtitleRowHeight : BaseMetrics::values.listRowHeight;
return contentHeight / rowHeight;
}
void BaseTheme::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 std::function<bool(int index)>& rowDimmed) const {
int rowHeight =
(rowSubtitle != nullptr) ? BaseMetrics::values.listWithSubtitleRowHeight : BaseMetrics::values.listRowHeight;
int pageItems = rect.height / rowHeight;
const int totalPages = (itemCount + pageItems - 1) / pageItems;
if (totalPages > 1) {
constexpr int indicatorWidth = 20;
constexpr int arrowSize = 6;
constexpr int margin = 15; // Offset from right edge
const int centerX = rect.x + rect.width - indicatorWidth / 2 - margin;
const int indicatorTop = rect.y; // Offset to avoid overlapping side button hints
const int indicatorBottom = rect.y + rect.height - arrowSize;
// Draw up arrow at top (^) - narrow point at top, wide base at bottom
for (int i = 0; i < arrowSize; ++i) {
const int lineWidth = 1 + i * 2;
const int startX = centerX - i;
renderer.drawLine(startX, indicatorTop + i, startX + lineWidth - 1, indicatorTop + i);
}
// Draw down arrow at bottom (v) - wide base at top, narrow point at bottom
for (int i = 0; i < arrowSize; ++i) {
const int lineWidth = 1 + (arrowSize - 1 - i) * 2;
const int startX = centerX - (arrowSize - 1 - i);
renderer.drawLine(startX, indicatorBottom - arrowSize + 1 + i, startX + lineWidth - 1,
indicatorBottom - arrowSize + 1 + i);
}
}
// Draw selection
int contentWidth = rect.width - 5;
if (selectedIndex >= 0) {
renderer.fillRect(rect.x, rect.y + selectedIndex % pageItems * rowHeight - 2, rect.width, rowHeight);
}
constexpr int minValueGap = 10;
// Draw all items
const auto pageStartIndex = selectedIndex / pageItems * pageItems;
for (int i = pageStartIndex; i < itemCount && i < pageStartIndex + pageItems; i++) {
const int itemY = rect.y + (i % pageItems) * rowHeight;
int rowTextWidth = contentWidth - BaseMetrics::values.contentSidePadding * 2;
std::string valueText;
if (rowValue != nullptr) {
valueText = rowValue(i);
if (!valueText.empty()) {
int maxValW = std::max(0, rowTextWidth - 40 - minValueGap);
valueText = renderer.truncatedText(UI_10_FONT_ID, valueText.c_str(), maxValW);
int valueWidth = renderer.getTextWidth(UI_10_FONT_ID, valueText.c_str()) + minValueGap;
rowTextWidth -= valueWidth;
}
}
auto itemName = rowTitle(i);
auto font = UI_10_FONT_ID;
auto item = renderer.truncatedText(font, itemName.c_str(), rowTextWidth);
renderer.drawText(font, rect.x + BaseMetrics::values.contentSidePadding, itemY, item.c_str(), i != selectedIndex);
// Apply checkerboard dither to create gray text effect for dimmed items
if (rowDimmed && rowDimmed(i) && i != selectedIndex) {
const int titleWidth = renderer.getTextWidth(font, item.c_str());
const int lineH = renderer.getLineHeight(font);
const int tx = rect.x + BaseMetrics::values.contentSidePadding;
for (int py = itemY; py < itemY + lineH; py++)
for (int px = tx; px < tx + titleWidth; px++)
if ((px + py) % 2 == 0) renderer.drawPixel(px, py, false);
}
if (rowSubtitle != nullptr) {
std::string subtitleText = rowSubtitle(i);
if (!subtitleText.empty()) {
auto subtitle = renderer.truncatedText(SMALL_FONT_ID, subtitleText.c_str(), rowTextWidth);
renderer.drawText(SMALL_FONT_ID, rect.x + BaseMetrics::values.contentSidePadding, itemY + 22, subtitle.c_str(),
i != selectedIndex);
}
}
if (!valueText.empty()) {
const auto valueTextWidth = renderer.getTextWidth(UI_10_FONT_ID, valueText.c_str());
int valueY = itemY;
if (rowSubtitle != nullptr) {
valueY = itemY + 10;
}
renderer.drawText(UI_10_FONT_ID, rect.x + contentWidth - BaseMetrics::values.contentSidePadding - valueTextWidth,
valueY, valueText.c_str(), i != selectedIndex);
}
}
}
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
const int batteryX = rect.x + rect.width - 12 - BaseMetrics::values.batteryWidth;
drawBatteryRight(renderer,
Rect{batteryX, rect.y + 5, BaseMetrics::values.batteryWidth, BaseMetrics::values.batteryHeight},
showBatteryPercentage);
if (title) {
int padding = rect.width - batteryX + BaseMetrics::values.batteryWidth;
auto truncatedTitle = renderer.truncatedText(UI_12_FONT_ID, title,
rect.width - padding * 2 - BaseMetrics::values.contentSidePadding * 2,
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 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,
bool selected) const {
constexpr int underlineHeight = 2; // Height of selection underline
constexpr int underlineGap = 4; // Gap between text and underline
const int lineHeight = renderer.getLineHeight(UI_12_FONT_ID);
int currentX = rect.x + BaseMetrics::values.contentSidePadding;
for (const auto& tab : tabs) {
const int textWidth =
renderer.getTextWidth(UI_12_FONT_ID, tab.label, tab.selected ? EpdFontFamily::BOLD : EpdFontFamily::REGULAR);
// Draw underline for selected tab
if (tab.selected) {
if (selected) {
renderer.fillRect(currentX - 3, rect.y, textWidth + 6, lineHeight + underlineGap);
} else {
renderer.fillRect(currentX, rect.y + lineHeight + underlineGap, textWidth, underlineHeight);
}
}
// Draw tab label
renderer.drawText(UI_12_FONT_ID, currentX, rect.y, tab.label, !(tab.selected && selected),
tab.selected ? EpdFontFamily::BOLD : EpdFontFamily::REGULAR);
currentX += textWidth + BaseMetrics::values.tabSpacing;
}
}
// Draw the "Recent Book" cover card on the home screen
// TODO: Refactor method to make it cleaner, split into smaller methods
void BaseTheme::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 bool hasContinueReading = !recentBooks.empty();
const bool bookSelected = hasContinueReading && selectorIndex == 0;
// --- Top "book" card for the current title (selectorIndex == 0) ---
// When there's no cover image, use fixed size (half screen)
// When there's cover image, adapt width to image aspect ratio, keep height fixed at 400px
const int baseHeight = rect.height; // Fixed height (400px)
int bookWidth, bookX;
bool hasCoverImage = false;
if (hasContinueReading && !recentBooks[0].coverBmpPath.empty()) {
// Try to get actual image dimensions from BMP header
const std::string coverBmpPath =
UITheme::getCoverThumbPath(recentBooks[0].coverBmpPath, BaseMetrics::values.homeCoverHeight);
HalFile file;
if (Storage.openFileForRead("HOME", coverBmpPath, file)) {
Bitmap bitmap(file);
if (bitmap.parseHeaders() == BmpReaderError::Ok) {
hasCoverImage = true;
const int imgWidth = bitmap.getWidth();
const int imgHeight = bitmap.getHeight();
// Calculate width based on aspect ratio, maintaining baseHeight
if (imgWidth > 0 && imgHeight > 0) {
const float aspectRatio = static_cast<float>(imgWidth) / static_cast<float>(imgHeight);
bookWidth = static_cast<int>(baseHeight * aspectRatio);
// Ensure width doesn't exceed reasonable limits (max 90% of screen width)
const int maxWidth = static_cast<int>(rect.width * 0.9f);
if (bookWidth > maxWidth) {
bookWidth = maxWidth;
}
} else {
bookWidth = rect.width / 2; // Fallback
}
}
}
}
if (!hasCoverImage) {
// No cover: use half screen size
bookWidth = rect.width / 2;
}
bookX = rect.x + (rect.width - bookWidth) / 2;
const int bookY = rect.y;
const int bookHeight = baseHeight;
// Bookmark dimensions (used in multiple places)
const int bookmarkWidth = bookWidth / 8;
const int bookmarkHeight = bookHeight / 5;
const int bookmarkX = bookX + bookWidth - bookmarkWidth - 10;
const int bookmarkY = bookY + 5;
// 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 && !recentBooks[0].coverBmpPath.empty() && !coverRendered) {
const std::string coverBmpPath =
UITheme::getCoverThumbPath(recentBooks[0].coverBmpPath, BaseMetrics::values.homeCoverHeight);
// First time: load cover from SD and render
HalFile file;
if (Storage.openFileForRead("HOME", coverBmpPath, file)) {
Bitmap bitmap(file);
if (bitmap.parseHeaders() == BmpReaderError::Ok) {
LOG_DBG("THEME", "Rendering bmp");
// Draw the cover image (bookWidth and bookHeight already match image aspect ratio)
renderer.drawBitmap(bitmap, bookX, bookY, bookWidth, bookHeight);
// Draw border around the card
renderer.drawRect(bookX, bookY, bookWidth, bookHeight);
// No bookmark ribbon when cover is shown - it would just cover the art
// Store the buffer with cover image for fast navigation
coverBufferStored = storeCoverBuffer();
coverRendered = coverBufferStored; // Only consider it rendered if we successfully stored the buffer
// First render: if selected, draw selection indicators now
if (bookSelected) {
LOG_DBG("THEME", "Drawing selection");
renderer.drawRect(bookX + 1, bookY + 1, bookWidth - 2, bookHeight - 2);
renderer.drawRect(bookX + 2, bookY + 2, bookWidth - 4, bookHeight - 4);
}
}
}
}
if (!bufferRestored && !coverRendered) {
// No cover image: draw border or fill, plus bookmark as visual flair
if (bookSelected) {
renderer.fillRect(bookX, bookY, bookWidth, bookHeight);
} else {
renderer.drawRect(bookX, bookY, bookWidth, bookHeight);
}
// Draw bookmark ribbon when no cover image (visual decoration)
if (hasContinueReading) {
const int notchDepth = bookmarkHeight / 3;
const int centerX = bookmarkX + bookmarkWidth / 2;
const int xPoints[5] = {
bookmarkX, // top-left
bookmarkX + bookmarkWidth, // top-right
bookmarkX + bookmarkWidth, // bottom-right
centerX, // center notch point
bookmarkX // bottom-left
};
const int yPoints[5] = {
bookmarkY, // top-left
bookmarkY, // top-right
bookmarkY + bookmarkHeight, // bottom-right
bookmarkY + bookmarkHeight - notchDepth, // center notch point
bookmarkY + bookmarkHeight // bottom-left
};
// Draw bookmark ribbon (inverted if selected)
renderer.fillPolygon(xPoints, yPoints, 5, !bookSelected);
}
}
// If buffer was restored, draw selection indicators if needed
if (bufferRestored && bookSelected && coverRendered) {
// Draw selection border (no bookmark inversion needed since cover has no bookmark)
renderer.drawRect(bookX + 1, bookY + 1, bookWidth - 2, bookHeight - 2);
renderer.drawRect(bookX + 2, bookY + 2, bookWidth - 4, bookHeight - 4);
} else if (!coverRendered && !bufferRestored) {
// Selection border already handled above in the no-cover case
}
}
if (hasContinueReading) {
const std::string& lastBookTitle = recentBooks[0].title;
const std::string& lastBookAuthor = recentBooks[0].author;
// Invert text colors based on selection state:
// - With cover: selected = white text on black box, unselected = black text on white box
// - Without cover: selected = white text on black card, unselected = black text on white card
auto lines = renderer.wrappedText(UI_12_FONT_ID, lastBookTitle.c_str(), bookWidth - 40, 3);
// Book title text
int totalTextHeight = renderer.getLineHeight(UI_12_FONT_ID) * static_cast<int>(lines.size());
if (!lastBookAuthor.empty()) {
totalTextHeight += renderer.getLineHeight(UI_10_FONT_ID) * 3 / 2;
}
// Vertically center the title block within the card
int titleYStart = bookY + (bookHeight - totalTextHeight) / 2;
const auto truncatedAuthor = lastBookAuthor.empty()
? std::string{}
: renderer.truncatedText(UI_10_FONT_ID, lastBookAuthor.c_str(), bookWidth - 40);
// If cover image was rendered, draw box behind title and author
if (coverRendered) {
constexpr int boxPadding = 8;
// Calculate the max text width for the box
int maxTextWidth = 0;
for (const auto& line : lines) {
const int lineWidth = renderer.getTextWidth(UI_12_FONT_ID, line.c_str());
if (lineWidth > maxTextWidth) {
maxTextWidth = lineWidth;
}
}
if (!truncatedAuthor.empty()) {
const int authorWidth = renderer.getTextWidth(UI_10_FONT_ID, truncatedAuthor.c_str());
if (authorWidth > maxTextWidth) {
maxTextWidth = authorWidth;
}
}
const int boxWidth = maxTextWidth + boxPadding * 2;
const int boxHeight = totalTextHeight + boxPadding * 2;
const int boxX = rect.x + (rect.width - boxWidth) / 2;
const int boxY = titleYStart - boxPadding;
// Draw box (inverted when selected: black box instead of white)
renderer.fillRect(boxX, boxY, boxWidth, boxHeight, bookSelected);
// Draw border around the box (inverted when selected: white border instead of black)
renderer.drawRect(boxX, boxY, boxWidth, boxHeight, !bookSelected);
}
for (const auto& line : lines) {
renderer.drawCenteredText(UI_12_FONT_ID, titleYStart, line.c_str(), !bookSelected);
titleYStart += renderer.getLineHeight(UI_12_FONT_ID);
}
if (!truncatedAuthor.empty()) {
titleYStart += renderer.getLineHeight(UI_10_FONT_ID) / 2;
renderer.drawCenteredText(UI_10_FONT_ID, titleYStart, truncatedAuthor.c_str(), !bookSelected);
}
// "Continue Reading" label at the bottom
const int continueY = bookY + bookHeight - renderer.getLineHeight(UI_10_FONT_ID) * 3 / 2;
if (coverRendered) {
// Draw box behind "Continue Reading" text (inverted when selected: black box instead of white)
const char* continueText = tr(STR_CONTINUE_READING);
const int continueTextWidth = renderer.getTextWidth(UI_10_FONT_ID, continueText);
constexpr int continuePadding = 6;
const int continueBoxWidth = continueTextWidth + continuePadding * 2;
const int continueBoxHeight = renderer.getLineHeight(UI_10_FONT_ID) + continuePadding;
const int continueBoxX = rect.x + (rect.width - continueBoxWidth) / 2;
const int continueBoxY = continueY - continuePadding / 2;
renderer.fillRect(continueBoxX, continueBoxY, continueBoxWidth, continueBoxHeight, bookSelected);
renderer.drawRect(continueBoxX, continueBoxY, continueBoxWidth, continueBoxHeight, !bookSelected);
renderer.drawCenteredText(UI_10_FONT_ID, continueY, continueText, !bookSelected);
} else {
renderer.drawCenteredText(UI_10_FONT_ID, continueY, tr(STR_CONTINUE_READING), !bookSelected);
}
} else {
// No book to continue reading
const int y =
bookY + (bookHeight - renderer.getLineHeight(UI_12_FONT_ID) - renderer.getLineHeight(UI_10_FONT_ID)) / 2;
renderer.drawCenteredText(UI_12_FONT_ID, y, tr(STR_NO_OPEN_BOOK));
renderer.drawCenteredText(UI_10_FONT_ID, y + renderer.getLineHeight(UI_12_FONT_ID), tr(STR_START_READING));
}
}
void BaseTheme::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) {
const int tileY = BaseMetrics::values.verticalSpacing + rect.y +
static_cast<int>(i) * (BaseMetrics::values.menuRowHeight + BaseMetrics::values.menuSpacing);
const bool selected = selectedIndex == i;
if (selected) {
renderer.fillRect(rect.x + BaseMetrics::values.contentSidePadding, tileY,
rect.width - BaseMetrics::values.contentSidePadding * 2, BaseMetrics::values.menuRowHeight);
} else {
renderer.drawRect(rect.x + BaseMetrics::values.contentSidePadding, tileY,
rect.width - BaseMetrics::values.contentSidePadding * 2, BaseMetrics::values.menuRowHeight);
}
std::string labelStr = buttonLabel(i);
const char* label = labelStr.c_str();
const int textWidth = renderer.getTextWidth(UI_10_FONT_ID, label);
const int textX = rect.x + (rect.width - textWidth) / 2;
const int lineHeight = renderer.getLineHeight(UI_10_FONT_ID);
const int textY =
tileY + (BaseMetrics::values.menuRowHeight - lineHeight) / 2; // vertically centered assuming y is top of text
// Invert text when the tile is selected, to contrast with the filled background
renderer.drawText(UI_10_FONT_ID, textX, textY, label, selectedIndex != i);
}
}
Rect BaseTheme::drawPopup(const GfxRenderer& renderer, const char* message) const {
const auto& metrics = UITheme::getInstance().getMetrics();
const int marginX = metrics.popupMarginX;
const int marginY = metrics.popupMarginY;
const int frameThickness = metrics.popupFrameThickness;
const EpdFontFamily::Style popupFontFamily = metrics.popupTextBold ? EpdFontFamily::BOLD : EpdFontFamily::REGULAR;
// Scale y position proportionally to screen height
const int y = static_cast<int>(renderer.getScreenHeight() * metrics.popupTopOffsetRatio);
const int textWidth = renderer.getTextWidth(UI_12_FONT_ID, message, popupFontFamily);
const int textHeight = renderer.getLineHeight(UI_12_FONT_ID);
const int w = textWidth + marginX * 2;
const int h = textHeight + marginY * 2;
const int x = (renderer.getScreenWidth() - w) / 2;
const bool useRoundedPopup = metrics.popupCornerRadius > 0;
if (useRoundedPopup) {
renderer.fillRoundedRect(x - frameThickness, y - frameThickness, w + frameThickness * 2, h + frameThickness * 2,
metrics.popupCornerRadius + frameThickness, Color::White);
renderer.fillRoundedRect(x, y, w, h, metrics.popupCornerRadius, Color::Black);
} else {
renderer.fillRect(x - frameThickness, y - frameThickness, w + frameThickness * 2, h + frameThickness * 2, true);
renderer.fillRect(x, y, w, h, false);
}
const int textX = x + (w - textWidth) / 2;
const int textY = y + marginY + metrics.popupTextBaselineOffsetY;
renderer.drawText(UI_12_FONT_ID, textX, textY, message, metrics.popupTextInverted, popupFontFamily);
renderer.displayBuffer();
return Rect{x, y, w, h};
}
void BaseTheme::fillPopupProgress(const GfxRenderer& renderer, const Rect& layout, const int progress) const {
const auto& metrics = UITheme::getInstance().getMetrics();
const int barHeight = metrics.popupProgressBarHeight;
const int barWidth =
std::max(0, layout.width - metrics.popupMarginX * 2); // twice the margin in drawPopup to match text width
const int barX = layout.x + (layout.width - barWidth) / 2;
const int barY = layout.y + layout.height - metrics.popupMarginY / 2 - barHeight / 2 - 1;
if (barWidth <= 0 || barHeight <= 0) {
renderer.displayBuffer(HalDisplay::FAST_REFRESH);
return;
}
const int scaledProgress = metrics.popupProgressClampPercent ? std::clamp(progress, 0, 100) : progress;
const int fillWidth = barWidth * scaledProgress / 100;
if (metrics.popupProgressDrawOutline) {
renderer.drawRect(barX, barY, barWidth, barHeight, 1, metrics.popupProgressOutlineInverted);
}
if (fillWidth > 0) {
renderer.fillRect(barX, barY, fillWidth, barHeight, metrics.popupProgressFillInverted);
}
renderer.displayBuffer(HalDisplay::FAST_REFRESH);
}
void BaseTheme::drawStatusBar(GfxRenderer& renderer, const float bookProgress, const int currentPage,
const int pageCount, std::string title, const int paddingBottom, const int textYOffset,
const bool fillMargin, const bool isPageBookmarked) const {
auto metrics = UITheme::getInstance().getMetrics();
int orientedMarginTop, orientedMarginRight, orientedMarginBottom, orientedMarginLeft;
renderer.getOrientedViewableTRBL(&orientedMarginTop, &orientedMarginRight, &orientedMarginBottom,
&orientedMarginLeft);
const bool showStatusBarTextLane = statusBarTextLaneVisible();
// Draw Progress Text
const auto screenHeight = renderer.getScreenHeight();
auto textY = screenHeight - UITheme::getInstance().getStatusBarHeight() - orientedMarginBottom - paddingBottom - 4;
int progressTextWidth = 0;
if (SETTINGS.statusBarBookProgressPercentage || SETTINGS.statusBarChapterPageCount) {
// Right aligned text for progress counter
char progressStr[32];
if (SETTINGS.statusBarBookProgressPercentage && SETTINGS.statusBarChapterPageCount) {
snprintf(progressStr, sizeof(progressStr), "%d/%d %.0f%%", currentPage, pageCount, bookProgress);
} else if (SETTINGS.statusBarBookProgressPercentage) {
snprintf(progressStr, sizeof(progressStr), "%.0f%%", bookProgress);
} else {
snprintf(progressStr, sizeof(progressStr), "%d/%d", currentPage, pageCount);
}
progressTextWidth = renderer.getTextWidth(SMALL_FONT_ID, progressStr);
renderer.drawText(
SMALL_FONT_ID,
renderer.getScreenWidth() - metrics.statusBarHorizontalMargin - orientedMarginRight - progressTextWidth, textY,
progressStr);
}
// Draw Progress Bar
if (SETTINGS.statusBarProgressBar != CrossPointSettings::STATUS_BAR_PROGRESS_BAR::HIDE_PROGRESS) {
const int barMarginLeft = fillMargin ? 0 : orientedMarginLeft;
const int barMarginRight = fillMargin ? 0 : orientedMarginRight;
const int progressBarMaxWidth = renderer.getScreenWidth() - barMarginLeft - barMarginRight;
const int progressBarY = renderer.getScreenHeight() - orientedMarginBottom -
((SETTINGS.statusBarProgressBarThickness + 1) * 2) - paddingBottom + (fillMargin ? 1 : 0);
size_t progress;
if (SETTINGS.statusBarProgressBar == CrossPointSettings::STATUS_BAR_PROGRESS_BAR::BOOK_PROGRESS) {
progress = static_cast<size_t>(bookProgress);
} else {
// Chapter progress
progress = (pageCount > 0) ? (static_cast<float>(currentPage) / pageCount) * 100 : 0;
}
const int barWidth = progressBarMaxWidth * progress / 100;
const int barHeight =
((SETTINGS.statusBarProgressBarThickness + 1) * 2) + (fillMargin ? orientedMarginBottom - 1 : 0);
renderer.fillRect(barMarginLeft, progressBarY, barWidth, barHeight, true);
}
// Draw Bookmark
const int leftClusterX = metrics.statusBarHorizontalMargin + orientedMarginLeft + 1;
const bool showBookmarkIcon = showStatusBarTextLane && isPageBookmarked;
const int bookmarkReserveWidth = showBookmarkIcon ? (bookmarkStatusIconWidth + bookmarkStatusIconGap) : 0;
if (showBookmarkIcon) {
const int bookmarkY = textY + 5;
drawBookmarkStatusIcon(renderer, leftClusterX, bookmarkY);
}
// Draw Battery
const bool showBatteryPercentage =
SETTINGS.hideBatteryPercentage == CrossPointSettings::HIDE_BATTERY_PERCENTAGE::HIDE_NEVER;
int leftClusterWidth = bookmarkReserveWidth;
if (SETTINGS.statusBarBattery) {
GUI.drawBatteryLeft(renderer,
Rect{leftClusterX + bookmarkReserveWidth, textY, metrics.batteryWidth, metrics.batteryHeight},
showBatteryPercentage);
leftClusterWidth += showBatteryPercentage ? 50 : 20;
}
// Draw Clock (X3 only — DS3231 RTC)
int clockTextWidth = 0;
if (SETTINGS.statusBarClock && halClock.isAvailable()) {
char timeBuf[9];
if (halClock.formatTime(timeBuf, sizeof(timeBuf), SETTINGS.clockUtcOffsetQ, SETTINGS.clockFormat == 1)) {
clockTextWidth = renderer.getTextWidth(SMALL_FONT_ID, timeBuf);
// Position to the left of the progress text (with a small gap)
const int clockX = renderer.getScreenWidth() - metrics.statusBarHorizontalMargin - orientedMarginRight -
progressTextWidth - (progressTextWidth > 0 ? 10 : 0) - clockTextWidth;
renderer.drawText(SMALL_FONT_ID, clockX, textY, timeBuf);
}
}
// Draw Title
if (!title.empty()) {
textY -= textYOffset;
// Centered chapter title text
// Page width minus existing content with 30px padding on each side
const int rendererableScreenWidth =
renderer.getScreenWidth() - (metrics.statusBarHorizontalMargin * 2) - orientedMarginLeft - orientedMarginRight;
const int titleMarginLeft = leftClusterWidth + 30;
const int clockReserve = clockTextWidth > 0 ? (clockTextWidth + 10) : 0;
const int titleMarginRight = progressTextWidth + clockReserve + 30;
// Attempt to center title on the screen, but if title is too wide then later we will center it within the
// available space.
int titleMarginLeftAdjusted = std::max(titleMarginLeft, titleMarginRight);
int availableTitleSpace = rendererableScreenWidth - 2 * titleMarginLeftAdjusted;
int titleWidth;
titleWidth = renderer.getTextWidth(SMALL_FONT_ID, title.c_str());
if (titleWidth > availableTitleSpace) {
// Not enough space to center on the screen, center it within the remaining space instead
availableTitleSpace = rendererableScreenWidth - titleMarginLeft - titleMarginRight;
titleMarginLeftAdjusted = titleMarginLeft;
}
if (titleWidth > availableTitleSpace) {
title = renderer.truncatedText(SMALL_FONT_ID, title.c_str(), availableTitleSpace);
titleWidth = renderer.getTextWidth(SMALL_FONT_ID, title.c_str());
}
renderer.drawText(SMALL_FONT_ID,
titleMarginLeftAdjusted + metrics.statusBarHorizontalMargin + orientedMarginLeft +
(availableTitleSpace - titleWidth) / 2,
textY, title.c_str());
}
}
void BaseTheme::drawHelpText(const GfxRenderer& renderer, Rect rect, const char* label) const {
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, bool cursorMode,
int contentStartX, int contentWidth) const {
const auto& metrics = UITheme::getInstance().getMetrics();
const int lineHeight = renderer.getLineHeight(UI_12_FONT_ID);
const int lineY = rect.y + rect.height + lineHeight + metrics.verticalSpacing;
const int thickness = cursorMode ? metrics.textFieldCursorThickness : metrics.textFieldNormalThickness;
if (contentWidth > 0) {
renderer.drawLine(rect.x + contentStartX, lineY,
rect.x + contentStartX + contentWidth + metrics.textFieldLineEndOffset, lineY, thickness, true);
} else {
const int lineW = textWidth + metrics.textFieldHorizontalPadding * 2;
const int lineStart = rect.x + (rect.width - lineW) / 2;
renderer.drawLine(lineStart, lineY, lineStart + lineW + metrics.textFieldLineEndOffset, lineY, thickness, true);
}
}
void BaseTheme::drawKeyboardKey(const GfxRenderer& renderer, Rect rect, const char* label, const bool isSelected,
const char* secondaryLabel, const KeyboardKeyType keyType,
const bool inactiveSelection) const {
const auto& metrics = UITheme::getInstance().getMetrics();
const int cr = metrics.keyboardKeyCornerRadius;
const bool isSpecialKey = keyType == KeyboardKeyType::Shift || keyType == KeyboardKeyType::Mode ||
keyType == KeyboardKeyType::Del || keyType == KeyboardKeyType::Space ||
keyType == KeyboardKeyType::Ok || keyType == KeyboardKeyType::Disabled;
if (isSelected) {
if (inactiveSelection) {
if (cr > 0) {
renderer.fillRoundedRect(rect.x, rect.y, rect.width, rect.height, cr, Color::LightGray);
} else {
renderer.drawRect(rect.x, rect.y, rect.width, rect.height, 2, true);
}
} else if (keyType == KeyboardKeyType::Disabled) {
if (cr > 0) {
renderer.fillRoundedRect(rect.x, rect.y, rect.width, rect.height, cr, Color::LightGray);
} else {
renderer.fillRectDither(rect.x, rect.y, rect.width, rect.height, Color::LightGray);
}
} else {
if (cr > 0) {
renderer.fillRoundedRect(rect.x, rect.y, rect.width, rect.height, cr, Color::Black);
} else {
renderer.fillRect(rect.x, rect.y, rect.width, rect.height, true);
}
}
} else {
if (metrics.keyboardFillUnselected) {
if (keyType == KeyboardKeyType::Disabled) {
if (cr > 0) {
renderer.fillRoundedRect(rect.x, rect.y, rect.width, rect.height, cr, Color::LightGray);
} else {
renderer.fillRectDither(rect.x, rect.y, rect.width, rect.height, Color::LightGray);
}
} else {
if (cr > 0) {
renderer.fillRoundedRect(rect.x, rect.y, rect.width, rect.height, cr, Color::White);
} else {
renderer.fillRect(rect.x, rect.y, rect.width, rect.height, false);
}
}
}
const bool shouldDrawOutline =
(metrics.keyboardDrawSpecialOutlineWhenUnselected && isSpecialKey) || metrics.keyboardOutlineAllUnselected;
if (shouldDrawOutline) {
if (cr > 0) {
renderer.drawRoundedRect(rect.x, rect.y, rect.width, rect.height, 1, cr, true);
} else {
renderer.drawRect(rect.x, rect.y, rect.width, rect.height);
}
}
}
const bool invert = isSelected && !inactiveSelection;
if (keyType == KeyboardKeyType::Space) {
const int lineHalfWidth = rect.width * 3 / 10;
const int centerX = rect.x + rect.width / 2;
const int lineY = rect.y + rect.height / 2 + 3;
renderer.drawLine(centerX - lineHalfWidth, lineY, centerX + lineHalfWidth, lineY, 3, !invert);
return;
}
if (keyType == KeyboardKeyType::Del) {
const int centerX = rect.x + rect.width / 2;
const int centerY = rect.y + rect.height / 2;
const int arrowLen = rect.width / 4;
const int arrowHead = std::max(metrics.keyboardMinArrowHeadSize, arrowLen / 2);
renderer.drawLine(centerX - arrowLen / 2, centerY, centerX + arrowLen / 2, centerY, 3, !invert);
renderer.drawLine(centerX - arrowLen / 2, centerY, centerX - arrowLen / 2 + arrowHead, centerY - arrowHead, 3,
!invert);
renderer.drawLine(centerX - arrowLen / 2, centerY, centerX - arrowLen / 2 + arrowHead, centerY + arrowHead, 3,
!invert);
return;
}
if (label == nullptr || label[0] == '\0') {
return;
}
const bool hasSecondary = secondaryLabel != nullptr && secondaryLabel[0] != '\0';
const int itemWidth = renderer.getTextWidth(UI_12_FONT_ID, label);
const int textX = rect.x + (rect.width - itemWidth) / 2;
const int textY = rect.y + (rect.height - renderer.getLineHeight(UI_12_FONT_ID)) / 2;
renderer.drawText(UI_12_FONT_ID, textX, textY, label, !invert);
if (hasSecondary) {
const int secWidth = renderer.getTextWidth(SMALL_FONT_ID, secondaryLabel);
renderer.drawText(SMALL_FONT_ID, rect.x + rect.width - secWidth - metrics.keyboardSecondaryLabelRightPadding,
rect.y + metrics.keyboardSecondaryLabelTopPadding, secondaryLabel, !invert);
}
}