## Summary Continue my changes to introduce the HAL infrastructure from https://github.com/crosspoint-reader/crosspoint-reader/pull/522 This PR touches quite a lot of files, but most of them are just name changing. It should not have any impacts to the end behavior. ## Additional Context My plan is to firstly add this small shim layer, which sounds useless at first, but then I'll implement an emulated driver which can be helpful for testing and for development. Currently, on my fork, I'm using a FS driver that allow "mounting" a local directory from my computer to the device, much like the `-v` mount option on docker. This allows me to quickly reset `.crosspoint` directory if anything goes wrong. I plan to upstream this feature when this PR get merged. --- ### AI Usage While CrossPoint doesn't have restrictions on AI tools in contributing, please be transparent about their usage as it helps set the right context for reviewers. Did you use AI tools to help write this code? NO
649 lines
28 KiB
C++
649 lines
28 KiB
C++
#include "BaseTheme.h"
|
|
|
|
#include <GfxRenderer.h>
|
|
#include <HalStorage.h>
|
|
#include <Utf8.h>
|
|
|
|
#include <cstdint>
|
|
#include <string>
|
|
|
|
#include "Battery.h"
|
|
#include "RecentBooksStore.h"
|
|
#include "components/UITheme.h"
|
|
#include "fontIds.h"
|
|
|
|
// Internal constants
|
|
namespace {
|
|
constexpr int batteryPercentSpacing = 4;
|
|
constexpr int homeMenuMargin = 20;
|
|
constexpr int homeMarginTop = 30;
|
|
} // namespace
|
|
|
|
void BaseTheme::drawBattery(const GfxRenderer& renderer, Rect rect, const bool showPercentage) const {
|
|
// Left aligned battery icon and percentage
|
|
// TODO refactor this so the percentage doesnt change after we position it
|
|
const uint16_t percentage = battery.readPercentage();
|
|
if (showPercentage) {
|
|
const auto percentageText = std::to_string(percentage) + "%";
|
|
renderer.drawText(SMALL_FONT_ID, rect.x + batteryPercentSpacing + BaseMetrics::values.batteryWidth, rect.y,
|
|
percentageText.c_str());
|
|
}
|
|
// 1 column on left, 2 columns on right, 5 columns of battery body
|
|
const int x = rect.x;
|
|
const int y = rect.y + 6;
|
|
const int battWidth = BaseMetrics::values.batteryWidth;
|
|
|
|
// Top line
|
|
renderer.drawLine(x + 1, y, x + battWidth - 3, y);
|
|
// Bottom line
|
|
renderer.drawLine(x + 1, y + rect.height - 1, x + battWidth - 3, y + rect.height - 1);
|
|
// Left line
|
|
renderer.drawLine(x, y + 1, x, y + rect.height - 2);
|
|
// Battery end
|
|
renderer.drawLine(x + battWidth - 2, y + 1, x + battWidth - 2, y + rect.height - 2);
|
|
renderer.drawPixel(x + battWidth - 1, y + 3);
|
|
renderer.drawPixel(x + battWidth - 1, y + rect.height - 4);
|
|
renderer.drawLine(x + battWidth - 0, y + 4, x + battWidth - 0, y + rect.height - 5);
|
|
|
|
// The +1 is to round up, so that we always fill at least one pixel
|
|
int filledWidth = percentage * (rect.width - 5) / 100 + 1;
|
|
if (filledWidth > rect.width - 5) {
|
|
filledWidth = rect.width - 5; // Ensure we don't overflow
|
|
}
|
|
|
|
renderer.fillRect(x + 2, y + 2, filledWidth, rect.height - 4);
|
|
}
|
|
|
|
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);
|
|
|
|
// 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
|
|
constexpr int buttonPositions[] = {25, 130, 245, 350};
|
|
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 buttonX = 4; // Distance from right edge
|
|
// Position for the button group - buttons share a border so they're adjacent
|
|
constexpr int topButtonY = 345; // Top button position
|
|
|
|
const char* labels[] = {topBtn, bottomBtn};
|
|
|
|
// Draw the shared border for both buttons as one unit
|
|
const int x = screenWidth - buttonX - buttonWidth;
|
|
|
|
// Draw top button outline (3 sides, bottom open)
|
|
if (topBtn != nullptr && topBtn[0] != '\0') {
|
|
renderer.drawLine(x, topButtonY, x + buttonWidth - 1, topButtonY); // Top
|
|
renderer.drawLine(x, topButtonY, x, topButtonY + buttonHeight - 1); // Left
|
|
renderer.drawLine(x + buttonWidth - 1, topButtonY, x + buttonWidth - 1, topButtonY + buttonHeight - 1); // Right
|
|
}
|
|
|
|
// Draw shared middle border
|
|
if ((topBtn != nullptr && topBtn[0] != '\0') || (bottomBtn != nullptr && bottomBtn[0] != '\0')) {
|
|
renderer.drawLine(x, topButtonY + buttonHeight, x + buttonWidth - 1, topButtonY + buttonHeight); // Shared border
|
|
}
|
|
|
|
// Draw bottom button outline (3 sides, top is shared)
|
|
if (bottomBtn != nullptr && bottomBtn[0] != '\0') {
|
|
renderer.drawLine(x, topButtonY + buttonHeight, x, topButtonY + 2 * buttonHeight - 1); // Left
|
|
renderer.drawLine(x + buttonWidth - 1, topButtonY + buttonHeight, x + buttonWidth - 1,
|
|
topButtonY + 2 * buttonHeight - 1); // Right
|
|
renderer.drawLine(x, topButtonY + 2 * buttonHeight - 1, x + buttonWidth - 1,
|
|
topButtonY + 2 * buttonHeight - 1); // Bottom
|
|
}
|
|
|
|
// Draw text for each button
|
|
for (int i = 0; i < 2; i++) {
|
|
if (labels[i] != nullptr && labels[i][0] != '\0') {
|
|
const int y = topButtonY + i * buttonHeight;
|
|
|
|
// Draw rotated text centered in the button
|
|
const int textWidth = renderer.getTextWidth(SMALL_FONT_ID, labels[i]);
|
|
const int textHeight = renderer.getTextHeight(SMALL_FONT_ID);
|
|
|
|
// Center the rotated text in the button
|
|
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<std::string(int index)>& rowIcon,
|
|
const std::function<std::string(int index)>& rowValue) 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 {
|
|
const bool showBatteryPercentage =
|
|
SETTINGS.hideBatteryPercentage != CrossPointSettings::HIDE_BATTERY_PERCENTAGE::HIDE_ALWAYS;
|
|
int batteryX = rect.x + rect.width - BaseMetrics::values.contentSidePadding - BaseMetrics::values.batteryWidth;
|
|
if (showBatteryPercentage) {
|
|
const uint16_t percentage = battery.readPercentage();
|
|
const auto percentageText = std::to_string(percentage) + "%";
|
|
batteryX -= renderer.getTextWidth(SMALL_FONT_ID, percentageText.c_str());
|
|
}
|
|
drawBattery(renderer, Rect{batteryX, rect.y + 5, BaseMetrics::values.batteryWidth, BaseMetrics::values.batteryHeight},
|
|
showBatteryPercentage);
|
|
|
|
if (title) {
|
|
int padding = rect.width - batteryX;
|
|
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);
|
|
}
|
|
}
|
|
|
|
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 {
|
|
// --- Top "book" card for the current title (selectorIndex == 0) ---
|
|
const int bookWidth = rect.width / 2;
|
|
const int bookHeight = rect.height;
|
|
const int bookX = (rect.width - bookWidth) / 2;
|
|
const int bookY = rect.y;
|
|
const bool hasContinueReading = !recentBooks.empty();
|
|
const bool bookSelected = hasContinueReading && selectorIndex == 0;
|
|
|
|
// 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) {
|
|
Serial.printf("Rendering bmp\n");
|
|
// Calculate position to center image within the book card
|
|
int coverX, coverY;
|
|
|
|
if (bitmap.getWidth() > bookWidth || bitmap.getHeight() > bookHeight) {
|
|
const float imgRatio = static_cast<float>(bitmap.getWidth()) / static_cast<float>(bitmap.getHeight());
|
|
const float boxRatio = static_cast<float>(bookWidth) / static_cast<float>(bookHeight);
|
|
|
|
if (imgRatio > boxRatio) {
|
|
coverX = bookX;
|
|
coverY = bookY + (bookHeight - static_cast<int>(bookWidth / imgRatio)) / 2;
|
|
} else {
|
|
coverX = bookX + (bookWidth - static_cast<int>(bookHeight * imgRatio)) / 2;
|
|
coverY = bookY;
|
|
}
|
|
} else {
|
|
coverX = bookX + (bookWidth - bitmap.getWidth()) / 2;
|
|
coverY = bookY + (bookHeight - bitmap.getHeight()) / 2;
|
|
}
|
|
|
|
// Draw the cover image centered within the book card
|
|
renderer.drawBitmap(bitmap, coverX, coverY, 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 = true;
|
|
|
|
// First render: if selected, draw selection indicators now
|
|
if (bookSelected) {
|
|
Serial.printf("Drawing selection\n");
|
|
renderer.drawRect(bookX + 1, bookY + 1, bookWidth - 2, bookHeight - 2);
|
|
renderer.drawRect(bookX + 2, bookY + 2, bookWidth - 4, bookHeight - 4);
|
|
}
|
|
}
|
|
file.close();
|
|
}
|
|
}
|
|
|
|
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
|
|
|
|
// Split into words (avoid stringstream to keep this light on the MCU)
|
|
std::vector<std::string> words;
|
|
words.reserve(8);
|
|
size_t pos = 0;
|
|
while (pos < lastBookTitle.size()) {
|
|
while (pos < lastBookTitle.size() && lastBookTitle[pos] == ' ') {
|
|
++pos;
|
|
}
|
|
if (pos >= lastBookTitle.size()) {
|
|
break;
|
|
}
|
|
const size_t start = pos;
|
|
while (pos < lastBookTitle.size() && lastBookTitle[pos] != ' ') {
|
|
++pos;
|
|
}
|
|
words.emplace_back(lastBookTitle.substr(start, pos - start));
|
|
}
|
|
|
|
std::vector<std::string> lines;
|
|
std::string currentLine;
|
|
// Extra padding inside the card so text doesn't hug the border
|
|
const int maxLineWidth = bookWidth - 40;
|
|
const int spaceWidth = renderer.getSpaceWidth(UI_12_FONT_ID);
|
|
|
|
for (auto& i : words) {
|
|
// If we just hit the line limit (3), stop processing words
|
|
if (lines.size() >= 3) {
|
|
// Limit to 3 lines
|
|
// Still have words left, so add ellipsis to last line
|
|
lines.back().append("...");
|
|
|
|
while (!lines.back().empty() && renderer.getTextWidth(UI_12_FONT_ID, lines.back().c_str()) > maxLineWidth) {
|
|
// Remove "..." first, then remove one UTF-8 char, then add "..." back
|
|
lines.back().resize(lines.back().size() - 3); // Remove "..."
|
|
utf8RemoveLastChar(lines.back());
|
|
lines.back().append("...");
|
|
}
|
|
break;
|
|
}
|
|
|
|
int wordWidth = renderer.getTextWidth(UI_12_FONT_ID, i.c_str());
|
|
while (wordWidth > maxLineWidth && !i.empty()) {
|
|
// Word itself is too long, trim it (UTF-8 safe)
|
|
utf8RemoveLastChar(i);
|
|
// Check if we have room for ellipsis
|
|
std::string withEllipsis = i + "...";
|
|
wordWidth = renderer.getTextWidth(UI_12_FONT_ID, withEllipsis.c_str());
|
|
if (wordWidth <= maxLineWidth) {
|
|
i = withEllipsis;
|
|
break;
|
|
}
|
|
}
|
|
|
|
int newLineWidth = renderer.getTextWidth(UI_12_FONT_ID, currentLine.c_str());
|
|
if (newLineWidth > 0) {
|
|
newLineWidth += spaceWidth;
|
|
}
|
|
newLineWidth += wordWidth;
|
|
|
|
if (newLineWidth > maxLineWidth && !currentLine.empty()) {
|
|
// New line too long, push old line
|
|
lines.push_back(currentLine);
|
|
currentLine = i;
|
|
} else {
|
|
currentLine.append(" ").append(i);
|
|
}
|
|
}
|
|
|
|
// If lower than the line limit, push remaining words
|
|
if (!currentLine.empty() && lines.size() < 3) {
|
|
lines.push_back(currentLine);
|
|
}
|
|
|
|
// 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;
|
|
|
|
// 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 (!lastBookAuthor.empty()) {
|
|
std::string trimmedAuthor = lastBookAuthor;
|
|
while (renderer.getTextWidth(UI_10_FONT_ID, trimmedAuthor.c_str()) > maxLineWidth && !trimmedAuthor.empty()) {
|
|
utf8RemoveLastChar(trimmedAuthor);
|
|
}
|
|
if (renderer.getTextWidth(UI_10_FONT_ID, trimmedAuthor.c_str()) <
|
|
renderer.getTextWidth(UI_10_FONT_ID, lastBookAuthor.c_str())) {
|
|
trimmedAuthor.append("...");
|
|
}
|
|
const int authorWidth = renderer.getTextWidth(UI_10_FONT_ID, trimmedAuthor.c_str());
|
|
if (authorWidth > maxTextWidth) {
|
|
maxTextWidth = authorWidth;
|
|
}
|
|
}
|
|
|
|
const int boxWidth = maxTextWidth + boxPadding * 2;
|
|
const int boxHeight = totalTextHeight + boxPadding * 2;
|
|
const int boxX = (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 (!lastBookAuthor.empty()) {
|
|
titleYStart += renderer.getLineHeight(UI_10_FONT_ID) / 2;
|
|
std::string trimmedAuthor = lastBookAuthor;
|
|
// Trim author if too long (UTF-8 safe)
|
|
bool wasTrimmed = false;
|
|
while (renderer.getTextWidth(UI_10_FONT_ID, trimmedAuthor.c_str()) > maxLineWidth && !trimmedAuthor.empty()) {
|
|
utf8RemoveLastChar(trimmedAuthor);
|
|
wasTrimmed = true;
|
|
}
|
|
if (wasTrimmed && !trimmedAuthor.empty()) {
|
|
// Make room for ellipsis
|
|
while (renderer.getTextWidth(UI_10_FONT_ID, (trimmedAuthor + "...").c_str()) > maxLineWidth &&
|
|
!trimmedAuthor.empty()) {
|
|
utf8RemoveLastChar(trimmedAuthor);
|
|
}
|
|
trimmedAuthor.append("...");
|
|
}
|
|
renderer.drawCenteredText(UI_10_FONT_ID, titleYStart, trimmedAuthor.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 = "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.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, "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<std::string(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);
|
|
}
|
|
|
|
const char* label = buttonLabel(i).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;
|
|
constexpr int y = 60;
|
|
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::drawReadingProgressBar(const GfxRenderer& renderer, const size_t bookProgress) const {
|
|
int vieweableMarginTop, vieweableMarginRight, vieweableMarginBottom, vieweableMarginLeft;
|
|
renderer.getOrientedViewableTRBL(&vieweableMarginTop, &vieweableMarginRight, &vieweableMarginBottom,
|
|
&vieweableMarginLeft);
|
|
|
|
const int progressBarMaxWidth = renderer.getScreenWidth() - vieweableMarginLeft - vieweableMarginRight;
|
|
const int progressBarY =
|
|
renderer.getScreenHeight() - vieweableMarginBottom - BaseMetrics::values.bookProgressBarHeight;
|
|
const int barWidth = progressBarMaxWidth * bookProgress / 100;
|
|
renderer.fillRect(vieweableMarginLeft, progressBarY, barWidth, BaseMetrics::values.bookProgressBarHeight, true);
|
|
}
|