Files
Crosspoint/src/components/themes/BaseTheme.cpp
T
Justin Mitchell cecdbefa0e Normalize whitespace in comments and includes
Standardize spacing in inline comments and remove extra blank line in include statement for consistency
2026-07-09 00:21:25 -04:00

1131 lines
52 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/bluetooth.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 bookmarkStatusIconTopCrop = 2;
constexpr int bluetoothStatusIconWidth = 16;
constexpr int bluetoothStatusIconHeight = 16;
constexpr int statusIconGap = 4;
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);
}
}
}
void drawBluetoothStatusIcon(const GfxRenderer& renderer, const int x, const int y) {
constexpr int bytesPerRow = bluetoothStatusIconWidth / 8;
for (int row = 0; row < bluetoothStatusIconHeight; ++row) {
for (int col = 0; col < bluetoothStatusIconWidth; ++col) {
const uint8_t byte = BluetoothStatusIcon[row * 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 bool pageCountEstimated,
const bool bluetoothConnected) 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;
const int leftClusterX = metrics.statusBarHorizontalMargin + orientedMarginLeft + 1;
const int rightClusterX = renderer.getScreenWidth() - metrics.statusBarHorizontalMargin - orientedMarginRight;
int leftClusterWidth = 0;
int rightClusterWidth = 0;
if (SETTINGS.statusBarBookProgressPercentage || SETTINGS.statusBarChapterPageCount) {
// Right aligned text for progress counter
char progressStr[32];
// Prefix the page count with "~" while a still-building spine only yields an estimated total.
const char* estimatePrefix = pageCountEstimated ? "~" : "";
if (SETTINGS.statusBarBookProgressPercentage && SETTINGS.statusBarChapterPageCount) {
snprintf(progressStr, sizeof(progressStr), "%s%d/%d %.0f%%", estimatePrefix, currentPage, pageCount,
bookProgress);
} else if (SETTINGS.statusBarBookProgressPercentage) {
snprintf(progressStr, sizeof(progressStr), "%.0f%%", bookProgress);
} else {
snprintf(progressStr, sizeof(progressStr), "%s%d/%d", estimatePrefix, currentPage, pageCount);
}
int progressTextWidth = renderer.getTextWidth(SMALL_FONT_ID, progressStr);
renderer.drawText(SMALL_FONT_ID, rightClusterX - progressTextWidth, textY, progressStr);
rightClusterWidth += progressTextWidth;
}
// 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 Battery
const bool showBatteryPercentage =
SETTINGS.hideBatteryPercentage == CrossPointSettings::HIDE_BATTERY_PERCENTAGE::HIDE_NEVER;
if (SETTINGS.statusBarBattery) {
GUI.drawBatteryLeft(renderer,
Rect{leftClusterX + leftClusterWidth, textY, metrics.batteryWidth, metrics.batteryHeight},
showBatteryPercentage);
int batteryWidth = metrics.batteryWidth;
if (showBatteryPercentage) {
const uint16_t percentage = powerManager.getBatteryPercentage();
// width of icon + spacing + text for layout purposes
batteryWidth +=
batteryPercentSpacing + renderer.getTextWidth(SMALL_FONT_ID, (std::to_string(percentage) + "%").c_str());
}
leftClusterWidth += batteryWidth;
}
// Draw Clock (X3 only — DS3231 RTC)
if (SETTINGS.statusBarClock && halClock.isAvailable()) {
char timeBuf[9];
if (halClock.formatTime(timeBuf, sizeof(timeBuf), SETTINGS.clockUtcOffsetQ, SETTINGS.clockFormat == 1)) {
int clockTextWidth = renderer.getTextWidth(SMALL_FONT_ID, timeBuf);
int clockX = 0;
// Position to the left or right of the progress text (with a small gap)
if (SETTINGS.statusBarClock == CrossPointSettings::STATUS_BAR_CLOCK_LEFT) {
clockX = leftClusterX + leftClusterWidth + (leftClusterWidth > 0 ? 10 : 0);
leftClusterWidth += clockTextWidth + 10;
} else if (SETTINGS.statusBarClock == CrossPointSettings::STATUS_BAR_CLOCK_RIGHT) {
clockX = rightClusterX - rightClusterWidth - (rightClusterWidth > 0 ? 10 : 0) - clockTextWidth;
rightClusterWidth += clockTextWidth + 10;
}
renderer.drawText(SMALL_FONT_ID, clockX, textY, timeBuf);
}
}
// Draw status icons
if (showStatusBarTextLane && bluetoothConnected) {
const int bluetoothGap = leftClusterWidth > 0 ? statusIconGap : 0;
const int bluetoothX = leftClusterX + leftClusterWidth + bluetoothGap;
const int bluetoothY = textY + 3;
drawBluetoothStatusIcon(renderer, bluetoothX, bluetoothY);
leftClusterWidth += bluetoothStatusIconWidth + bluetoothGap;
}
if (showStatusBarTextLane && isPageBookmarked) {
const int bookmarkGap = leftClusterWidth > 0 ? statusIconGap : 0;
const int bookmarkX = leftClusterX + leftClusterWidth + bookmarkGap;
const int bookmarkY = textY + 5;
drawBookmarkStatusIcon(renderer, bookmarkX, bookmarkY);
leftClusterWidth += bookmarkStatusIconWidth + bookmarkGap;
}
// 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 titleMarginRight = rightClusterWidth + 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);
}
}
void BaseTheme::drawOptionPopup(const GfxRenderer& renderer, const char* title, const std::vector<std::string>& options,
int selectedIndex) const {
const auto& metrics = UITheme::getInstance().getMetrics();
const auto pageWidth = renderer.getScreenWidth();
const auto pageHeight = renderer.getScreenHeight();
const int optionFontId = metrics.optionPopupUseSmallFont ? UI_10_FONT_ID : UI_12_FONT_ID;
const EpdFontFamily::Style optionStyle =
metrics.optionPopupOptionFontBold ? EpdFontFamily::BOLD : EpdFontFamily::REGULAR;
const int itemSpacing = metrics.optionPopupItemSpacing;
const int innerPadding = metrics.optionPopupInnerPadding;
const int selectionHPadding = metrics.optionPopupSelectionHPadding;
const int selectionVPadding = metrics.optionPopupSelectionVPadding;
const int optionLineHeight = renderer.getLineHeight(optionFontId);
const int titleLineHeight = renderer.getLineHeight(UI_12_FONT_ID);
const int rowHeight = optionLineHeight + selectionVPadding * 2;
int maxTextWidth = renderer.getTextWidth(UI_12_FONT_ID, title, EpdFontFamily::BOLD);
for (const auto& opt : options) {
int w = renderer.getTextWidth(optionFontId, opt.c_str(), optionStyle);
if (w > maxTextWidth) maxTextWidth = w;
}
const int optionCount = static_cast<int>(options.size());
const int listHeight = rowHeight * optionCount + itemSpacing * (optionCount - 1);
const int dialogW = std::min((maxTextWidth + innerPadding * 2 + selectionHPadding * 2) * 12 / 10,
pageWidth - metrics.optionPopupDialogSideMargin * 2);
const int contentHeight = titleLineHeight + metrics.optionPopupTitleGap + listHeight;
const int dialogH = contentHeight + innerPadding * 2;
const int dialogX = (pageWidth - dialogW) / 2;
const int dialogY = (pageHeight - dialogH) / 2;
const int frameThickness = metrics.popupFrameThickness;
const int frameRadius = metrics.popupCornerRadius;
if (frameRadius > 0) {
renderer.fillRoundedRect(dialogX - frameThickness, dialogY - frameThickness, dialogW + frameThickness * 2,
dialogH + frameThickness * 2, frameRadius + frameThickness, Color::White);
renderer.fillRoundedRect(dialogX, dialogY, dialogW, dialogH, frameRadius, Color::Black);
renderer.fillRoundedRect(dialogX + frameThickness, dialogY + frameThickness, dialogW - frameThickness * 2,
dialogH - frameThickness * 2,
frameRadius - frameThickness > 0 ? frameRadius - frameThickness : 0, Color::White);
} else {
renderer.fillRect(dialogX - frameThickness, dialogY - frameThickness, dialogW + frameThickness * 2,
dialogH + frameThickness * 2, true);
renderer.fillRect(dialogX, dialogY, dialogW, dialogH, false);
}
int y = dialogY + innerPadding;
renderer.drawCenteredText(UI_12_FONT_ID, y, title, true, EpdFontFamily::BOLD);
y += titleLineHeight;
if (metrics.optionPopupTitleSeparator) {
const int sepY = y + metrics.optionPopupTitleGap / 2;
renderer.drawLine(dialogX + innerPadding, sepY, dialogX + dialogW - innerPadding, sepY, true);
}
y += metrics.optionPopupTitleGap;
const int itemRectX = dialogX + innerPadding;
const int itemRectW = dialogW - innerPadding * 2;
const int selectionRadius = metrics.optionPopupSelectionRadius;
for (int i = 0; i < optionCount; i++) {
const int itemY = y + i * (rowHeight + itemSpacing);
const bool selected = (i == selectedIndex);
const char* labelText = options[i].c_str();
if (metrics.optionPopupDrawAllRows || selected) {
Color rowColor;
if (selected) {
rowColor = metrics.optionPopupSelectionLight ? Color::LightGray : Color::Black;
} else {
rowColor = Color::White;
}
if (selectionRadius > 0) {
renderer.fillRoundedRect(itemRectX, itemY, itemRectW, rowHeight, selectionRadius, rowColor);
} else {
renderer.fillRect(itemRectX, itemY, itemRectW, rowHeight, rowColor == Color::Black);
}
}
const int textW = renderer.getTextWidth(optionFontId, labelText, optionStyle);
const int textY = itemY + (rowHeight - optionLineHeight) / 2;
const int textX = itemRectX + (itemRectW - textW) / 2;
// Unselected items: text is dark (invert=true means draw on white bg).
// Selected on dark bg: text must be white (invert=false).
// Selected on light bg: text stays dark (invert=true).
const bool invertText = selected ? metrics.optionPopupSelectionLight : true;
renderer.drawText(optionFontId, textX, textY, labelText, invertText, optionStyle);
}
}