Something went wrong. Try again.
A fork of https://github.com/crosspoint-reader/crosspoint-reader
Something went wrong. Try again.
41 kB · 870 lines
C++
at master
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706707708709710711712713714715716717718719720721722723724725726727728729730731732733734735736737738739740741742743744745746747748749750751752753754755756757758759760761762763764765766767768769770771772773774775776777778779780781782783784785786787788789790791792793794795796797798799800801802803804805806807808809810811812813814815816817818819820821822823824825826827828829830831832833834835836837838839840841842843844845846847848849850851852853854855856857858859860861862863864865866867868869870871#include "BaseTheme.h"
#include <GfxRenderer.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 "fontIds.h"
// Internal constantsnamespace {constexpr int homeMenuMargin = 20;constexpr int homeMarginTop = 30;constexpr int subtitleY = 738;
// Helper: draw battery icon at given positionvoid drawBatteryIcon(const GfxRenderer& renderer, int x, int y, int battWidth, int rectHeight, uint16_t percentage) { // Draw battery outline (shared code) BaseTheme::drawBatteryOutline(renderer, x, y, battWidth, rectHeight);
const bool charging = gpio.isUsbConnected();
// The +1 is to round up, so that we always fill at least one pixel const int maxFillWidth = battWidth - 5; const int fillHeight = rectHeight - 4; if (maxFillWidth <= 0 || fillHeight <= 0) { return; } 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(x + 2, y + 2, filledWidth, fillHeight);
// Draw lightning bolt when charging (white/inverted on black fill for visibility) if (charging) { BaseTheme::drawBatteryLightningBolt(renderer, x + 4, y + 2); }}} // 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::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 + BaseTheme::batteryPercentSpacing + BaseMetrics::values.batteryWidth, rect.y, percentageText.c_str()); }
drawBatteryIcon(renderer, rect.x, y, BaseMetrics::values.batteryWidth, rect.height, 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()); // 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 - BaseTheme::batteryPercentSpacing, rect.y, textWidth, textHeight, false); // Draw text to the left of the icon renderer.drawText(SMALL_FONT_ID, rect.x - textWidth - BaseTheme::batteryPercentSpacing, rect.y, percentageText.c_str()); }
// Icon is already at correct position from rect.x drawBatteryIcon(renderer, rect.x, y, BaseMetrics::values.batteryWidth, rect.height, 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]); } } }}
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 { 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(0, rect.y + selectedIndex % pageItems * rowHeight - 2, rect.width, rowHeight); } // 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 textWidth = contentWidth - BaseMetrics::values.contentSidePadding * 2 - (rowValue != nullptr ? 60 : 0);
// Draw name auto itemName = rowTitle(i); auto font = (rowSubtitle != nullptr) ? UI_12_FONT_ID : UI_10_FONT_ID; auto item = renderer.truncatedText(font, itemName.c_str(), textWidth); renderer.drawText(font, rect.x + BaseMetrics::values.contentSidePadding, itemY, item.c_str(), i != selectedIndex);
if (rowSubtitle != nullptr) { // Draw subtitle std::string subtitleText = rowSubtitle(i); auto subtitle = renderer.truncatedText(UI_10_FONT_ID, subtitleText.c_str(), textWidth); renderer.drawText(UI_10_FONT_ID, rect.x + BaseMetrics::values.contentSidePadding, itemY + 30, subtitle.c_str(), i != selectedIndex); }
if (rowValue != nullptr) { // Draw value std::string valueText = rowValue(i); const auto valueTextWidth = renderer.getTextWidth(UI_10_FONT_ID, valueText.c_str()); renderer.drawText(UI_10_FONT_ID, rect.x + contentWidth - BaseMetrics::values.contentSidePadding - valueTextWidth, itemY, 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 underlineHeight = 2; // Height of selection underline constexpr int underlineGap = 4; // Gap between text and underline constexpr int maxListValueWidth = 200;
int currentX = rect.x + BaseMetrics::values.contentSidePadding; int rightSpace = BaseMetrics::values.contentSidePadding; if (rightLabel) { auto truncatedRightLabel = renderer.truncatedText(SMALL_FONT_ID, rightLabel, maxListValueWidth, EpdFontFamily::REGULAR); int rightLabelWidth = renderer.getTextWidth(SMALL_FONT_ID, truncatedRightLabel.c_str()); renderer.drawText(SMALL_FONT_ID, rect.x + rect.width - BaseMetrics::values.contentSidePadding - rightLabelWidth, rect.y + 7, truncatedRightLabel.c_str()); rightSpace += rightLabelWidth + 10; }
auto truncatedLabel = renderer.truncatedText( UI_12_FONT_ID, label, rect.width - BaseMetrics::values.contentSidePadding - rightSpace, EpdFontFamily::REGULAR); renderer.drawText(UI_12_FONT_ID, currentX, rect.y, truncatedLabel.c_str(), true, EpdFontFamily::REGULAR);}
void BaseTheme::drawTabBar(const GfxRenderer& renderer, const Rect rect, const std::vector<TabInfo>& tabs, 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 methodsvoid 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);
FsFile 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 FsFile 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, "No open book"); renderer.drawCenteredText(UI_10_FONT_ID, y + renderer.getLineHeight(UI_12_FONT_ID), "Start reading below"); }}
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 { constexpr int margin = 15; // Scale y position proportionally to screen height (7.5% from top) const int y = static_cast<int>(renderer.getScreenHeight() * 0.075f); const int textWidth = renderer.getTextWidth(UI_12_FONT_ID, message, EpdFontFamily::BOLD); const int textHeight = renderer.getLineHeight(UI_12_FONT_ID); const int w = textWidth + margin * 2; const int h = textHeight + margin * 2; const int x = (renderer.getScreenWidth() - w) / 2;
renderer.fillRect(x - 2, y - 2, w + 4, h + 4, true); // frame thickness 2 renderer.fillRect(x, y, w, h, false);
const int textX = x + (w - textWidth) / 2; const int textY = y + margin - 2; renderer.drawText(UI_12_FONT_ID, textX, textY, message, true, EpdFontFamily::BOLD); renderer.displayBuffer(); return Rect{x, y, w, h};}
void BaseTheme::fillPopupProgress(const GfxRenderer& renderer, const Rect& layout, const int progress) const { constexpr int barHeight = 4; const int barWidth = layout.width - 30; // 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 - 10;
int fillWidth = barWidth * progress / 100;
renderer.fillRect(barX, barY, fillWidth, barHeight, true);
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 { auto metrics = UITheme::getInstance().getMetrics(); int orientedMarginTop, orientedMarginRight, orientedMarginBottom, orientedMarginLeft; renderer.getOrientedViewableTRBL(&orientedMarginTop, &orientedMarginRight, &orientedMarginBottom, &orientedMarginLeft);
// 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 progressBarMaxWidth = renderer.getScreenWidth() - orientedMarginLeft - orientedMarginRight; const int progressBarY = renderer.getScreenHeight() - orientedMarginBottom - ((SETTINGS.statusBarProgressBarThickness + 1) * 2) - paddingBottom; 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; renderer.fillRect(orientedMarginLeft, progressBarY, barWidth, ((SETTINGS.statusBarProgressBarThickness + 1) * 2), true); }
// Draw Battery const bool showBatteryPercentage = SETTINGS.hideBatteryPercentage == CrossPointSettings::HIDE_BATTERY_PERCENTAGE::HIDE_NEVER; if (SETTINGS.statusBarBattery) { GUI.drawBatteryLeft(renderer, Rect{metrics.statusBarHorizontalMargin + orientedMarginLeft + 1, textY, metrics.batteryWidth, metrics.batteryHeight}, showBatteryPercentage); }
// 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 batterySize = SETTINGS.statusBarBattery ? (showBatteryPercentage ? 50 : 20) : 0; const int titleMarginLeft = batterySize + 30; const int titleMarginRight = progressTextWidth + 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 ? 3 : 1; if (contentWidth > 0) { renderer.drawLine(rect.x + contentStartX, lineY, rect.x + contentStartX + contentWidth, lineY, thickness, true); } else { const int hPadding = 6; const int lineW = textWidth + hPadding * 2; renderer.drawLine(rect.x + (rect.width - lineW) / 2, lineY, rect.x + (rect.width + lineW) / 2, 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;
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 (keyType == KeyboardKeyType::Shift || keyType == KeyboardKeyType::Mode || keyType == KeyboardKeyType::Del || keyType == KeyboardKeyType::Space || keyType == KeyboardKeyType::Ok || keyType == KeyboardKeyType::Disabled) { 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 = 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; }
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 - 1, rect.y, secondaryLabel, !invert); }}