A fork of https://github.com/crosspoint-reader/crosspoint-reader
0
fork

Configure Feed

Select the types of activity you want to include in your feed.

feat: UI themes, Lyra (#528)

## Summary

### What is the goal of this PR?

- Visual UI overhaul
- UI theme selection

### What changes are included?

- Added a setting "UI Theme": Classic, Lyra
- The classic theme is the current Crosspoint theme
- The Lyra theme implements these mockups:
https://www.figma.com/design/UhxoV4DgUnfrDQgMPPTXog/Lyra-Theme?node-id=2003-7596&t=4CSOZqf0n9uQMxDt-0
by Discord users yagofarias, ruby and gan_shu
- New functions in GFXRenderer to render rounded rectangles, greyscale
fills (using dithering) and thick lines
- Basic UI components are factored into BaseTheme methods which can be
overridden by each additional theme. Methods that are not overridden
will fallback to BaseTheme behavior. This means any new
features/components in CrossPoint only need to be developed for the
"Classic" BaseTheme.
- Additional themes can easily be developed by the community using this
foundation

![IMG_7649
Medium](https://github.com/user-attachments/assets/b516f5a9-2636-4565-acff-91a25b93b39b)
![IMG_7746
Medium](https://github.com/user-attachments/assets/def41810-ab6e-4952-b40f-b9ce7d62bea8)
![IMG_7651
Medium](https://github.com/user-attachments/assets/518a9a6d-107a-4be3-9533-43a2b64b944b)



## Additional Context

- Only the Home, Library and main Settings screens have been implemented
so far, this will be extended to the transfer screens and chapter
selection screen later on, but we need to get the ball rolling somehow
:)
- Loading extra covers on the home screen in the Lyra theme takes a
little more time (about 2 seconds), I added a loading bar popup (reusing
the Indexing progress bar from the reader view, factored into a neat UI
component) but the popup adds ~400ms to the loading time.
- ~~Home screen thumbnails will need to be generated separately for each
theme, because they are displayed in different sizes. Because we're
using dithering, displaying a thumb with the wrong size causes the
picture to look janky or dark as it does on the screenshots above. No
worries this will be fixed in a future PR.~~ Thumbs are now generated
with a size parameter
- UI Icons will need to be implemented in a future PR.

---

### AI Usage

While CrossPoint doesn't have restrictions on AI tools in contributing,
please be transparent about their usage as it
helps set the right context for reviewers.

Did you use AI tools to help write this code? _**PARTIALLY**_
This is not a vibe coded PR. Copilot was used for autocompletion to save
time but I reviewed, understood and edited all generated code.

---------

Co-authored-by: Dave Allie <dave@daveallie.com>

authored by

CaptainFrito
Dave Allie
and committed by
GitHub
bf87a7dc 2cf799f4

+2302 -1392
+14 -12
lib/Epub/Epub.cpp
··· 513 513 return false; 514 514 } 515 515 516 - std::string Epub::getThumbBmpPath() const { return cachePath + "/thumb.bmp"; } 516 + std::string Epub::getThumbBmpPath() const { return cachePath + "/thumb_[HEIGHT].bmp"; } 517 + std::string Epub::getThumbBmpPath(int height) const { return cachePath + "/thumb_" + std::to_string(height) + ".bmp"; } 517 518 518 - bool Epub::generateThumbBmp() const { 519 + bool Epub::generateThumbBmp(int height) const { 519 520 // Already generated, return true 520 - if (SdMan.exists(getThumbBmpPath().c_str())) { 521 + if (SdMan.exists(getThumbBmpPath(height).c_str())) { 521 522 return true; 522 523 } 523 524 ··· 529 530 const auto coverImageHref = bookMetadataCache->coreMetadata.coverItemHref; 530 531 if (coverImageHref.empty()) { 531 532 Serial.printf("[%lu] [EBP] No known cover image for thumbnail\n", millis()); 532 - return false; 533 - } 534 - 535 - if (coverImageHref.substr(coverImageHref.length() - 4) == ".jpg" || 536 - coverImageHref.substr(coverImageHref.length() - 5) == ".jpeg") { 533 + } else if (coverImageHref.substr(coverImageHref.length() - 4) == ".jpg" || 534 + coverImageHref.substr(coverImageHref.length() - 5) == ".jpeg") { 537 535 Serial.printf("[%lu] [EBP] Generating thumb BMP from JPG cover image\n", millis()); 538 536 const auto coverJpgTempPath = getCachePath() + "/.cover.jpg"; 539 537 ··· 549 547 } 550 548 551 549 FsFile thumbBmp; 552 - if (!SdMan.openFileForWrite("EBP", getThumbBmpPath(), thumbBmp)) { 550 + if (!SdMan.openFileForWrite("EBP", getThumbBmpPath(height), thumbBmp)) { 553 551 coverJpg.close(); 554 552 return false; 555 553 } 556 554 // Use smaller target size for Continue Reading card (half of screen: 240x400) 557 555 // Generate 1-bit BMP for fast home screen rendering (no gray passes needed) 558 - constexpr int THUMB_TARGET_WIDTH = 240; 559 - constexpr int THUMB_TARGET_HEIGHT = 400; 556 + int THUMB_TARGET_WIDTH = height * 0.6; 557 + int THUMB_TARGET_HEIGHT = height; 560 558 const bool success = JpegToBmpConverter::jpegFileTo1BitBmpStreamWithSize(coverJpg, thumbBmp, THUMB_TARGET_WIDTH, 561 559 THUMB_TARGET_HEIGHT); 562 560 coverJpg.close(); ··· 565 563 566 564 if (!success) { 567 565 Serial.printf("[%lu] [EBP] Failed to generate thumb BMP from JPG cover image\n", millis()); 568 - SdMan.remove(getThumbBmpPath().c_str()); 566 + SdMan.remove(getThumbBmpPath(height).c_str()); 569 567 } 570 568 Serial.printf("[%lu] [EBP] Generated thumb BMP from JPG cover image, success: %s\n", millis(), 571 569 success ? "yes" : "no"); ··· 574 572 Serial.printf("[%lu] [EBP] Cover image is not a JPG, skipping thumbnail\n", millis()); 575 573 } 576 574 575 + // Write an empty bmp file to avoid generation attempts in the future 576 + FsFile thumbBmp; 577 + SdMan.openFileForWrite("EBP", getThumbBmpPath(height), thumbBmp); 578 + thumbBmp.close(); 577 579 return false; 578 580 } 579 581
+2 -1
lib/Epub/Epub.h
··· 56 56 std::string getCoverBmpPath(bool cropped = false) const; 57 57 bool generateCoverBmp(bool cropped = false) const; 58 58 std::string getThumbBmpPath() const; 59 - bool generateThumbBmp() const; 59 + std::string getThumbBmpPath(int height) const; 60 + bool generateThumbBmp(int height) const; 60 61 uint8_t* readItemContentsToBytes(const std::string& itemHref, size_t* size = nullptr, 61 62 bool trailingNullByte = false) const; 62 63 bool readItemContentsToStream(const std::string& itemHref, Print& out, size_t chunkSize) const;
+213 -79
lib/GfxRenderer/GfxRenderer.cpp
··· 130 130 } 131 131 } 132 132 133 + void GfxRenderer::drawLine(int x1, int y1, int x2, int y2, const int lineWidth, const bool state) const { 134 + for (int i = 0; i < lineWidth; i++) { 135 + drawLine(x1, y1 + i, x2, y2 + i, state); 136 + } 137 + } 138 + 133 139 void GfxRenderer::drawRect(const int x, const int y, const int width, const int height, const bool state) const { 134 140 drawLine(x, y, x + width - 1, y, state); 135 141 drawLine(x + width - 1, y, x + width - 1, y + height - 1, state); ··· 137 143 drawLine(x, y, x, y + height - 1, state); 138 144 } 139 145 146 + // Border is inside the rectangle 147 + void GfxRenderer::drawRect(const int x, const int y, const int width, const int height, const int lineWidth, 148 + const bool state) const { 149 + for (int i = 0; i < lineWidth; i++) { 150 + drawLine(x + i, y + i, x + width - i, y + i, state); 151 + drawLine(x + width - i, y + i, x + width - i, y + height - i, state); 152 + drawLine(x + width - i, y + height - i, x + i, y + height - i, state); 153 + drawLine(x + i, y + height - i, x + i, y + i, state); 154 + } 155 + } 156 + 157 + void GfxRenderer::drawArc(const int maxRadius, const int cx, const int cy, const int xDir, const int yDir, 158 + const int lineWidth, const bool state) const { 159 + const int stroke = std::min(lineWidth, maxRadius); 160 + const int innerRadius = std::max(maxRadius - stroke, 0); 161 + const int outerRadiusSq = maxRadius * maxRadius; 162 + const int innerRadiusSq = innerRadius * innerRadius; 163 + for (int dy = 0; dy <= maxRadius; ++dy) { 164 + for (int dx = 0; dx <= maxRadius; ++dx) { 165 + const int distSq = dx * dx + dy * dy; 166 + if (distSq > outerRadiusSq || distSq < innerRadiusSq) { 167 + continue; 168 + } 169 + const int px = cx + xDir * dx; 170 + const int py = cy + yDir * dy; 171 + drawPixel(px, py, state); 172 + } 173 + } 174 + }; 175 + 176 + // Border is inside the rectangle, rounded corners 177 + void GfxRenderer::drawRoundedRect(const int x, const int y, const int width, const int height, const int lineWidth, 178 + const int cornerRadius, bool state) const { 179 + drawRoundedRect(x, y, width, height, lineWidth, cornerRadius, true, true, true, true, state); 180 + } 181 + 182 + // Border is inside the rectangle, rounded corners 183 + void GfxRenderer::drawRoundedRect(const int x, const int y, const int width, const int height, const int lineWidth, 184 + const int cornerRadius, bool roundTopLeft, bool roundTopRight, bool roundBottomLeft, 185 + bool roundBottomRight, bool state) const { 186 + if (lineWidth <= 0 || width <= 0 || height <= 0) { 187 + return; 188 + } 189 + 190 + const int maxRadius = std::min({cornerRadius, width / 2, height / 2}); 191 + if (maxRadius <= 0) { 192 + drawRect(x, y, width, height, lineWidth, state); 193 + return; 194 + } 195 + 196 + const int stroke = std::min(lineWidth, maxRadius); 197 + const int right = x + width - 1; 198 + const int bottom = y + height - 1; 199 + 200 + const int horizontalWidth = width - 2 * maxRadius; 201 + if (horizontalWidth > 0) { 202 + if (roundTopLeft || roundTopRight) { 203 + fillRect(x + maxRadius, y, horizontalWidth, stroke, state); 204 + } 205 + if (roundBottomLeft || roundBottomRight) { 206 + fillRect(x + maxRadius, bottom - stroke + 1, horizontalWidth, stroke, state); 207 + } 208 + } 209 + 210 + const int verticalHeight = height - 2 * maxRadius; 211 + if (verticalHeight > 0) { 212 + if (roundTopLeft || roundBottomLeft) { 213 + fillRect(x, y + maxRadius, stroke, verticalHeight, state); 214 + } 215 + if (roundTopRight || roundBottomRight) { 216 + fillRect(right - stroke + 1, y + maxRadius, stroke, verticalHeight, state); 217 + } 218 + } 219 + 220 + if (roundTopLeft) { 221 + drawArc(maxRadius, x + maxRadius, y + maxRadius, -1, -1, lineWidth, state); 222 + } 223 + if (roundTopRight) { 224 + drawArc(maxRadius, right - maxRadius, y + maxRadius, 1, -1, lineWidth, state); 225 + } 226 + if (roundBottomRight) { 227 + drawArc(maxRadius, right - maxRadius, bottom - maxRadius, 1, 1, lineWidth, state); 228 + } 229 + if (roundBottomLeft) { 230 + drawArc(maxRadius, x + maxRadius, bottom - maxRadius, -1, 1, lineWidth, state); 231 + } 232 + } 233 + 140 234 void GfxRenderer::fillRect(const int x, const int y, const int width, const int height, const bool state) const { 141 235 for (int fillY = y; fillY < y + height; fillY++) { 142 236 drawLine(x, fillY, x + width - 1, fillY, state); 143 237 } 144 238 } 145 239 240 + static constexpr uint8_t bayer4x4[4][4] = { 241 + {0, 8, 2, 10}, 242 + {12, 4, 14, 6}, 243 + {3, 11, 1, 9}, 244 + {15, 7, 13, 5}, 245 + }; 246 + static constexpr int matrixSize = 4; 247 + static constexpr int matrixLevels = matrixSize * matrixSize; 248 + 249 + void GfxRenderer::drawPixelDither(const int x, const int y, Color color) const { 250 + if (color == Color::Clear) { 251 + } else if (color == Color::Black) { 252 + drawPixel(x, y, true); 253 + } else if (color == Color::White) { 254 + drawPixel(x, y, false); 255 + } else { 256 + // Use dithering 257 + const int greyLevel = static_cast<int>(color) - 1; // 0-15 258 + const int normalizedGrey = (greyLevel * 255) / (matrixLevels - 1); 259 + const int clampedGrey = std::max(0, std::min(normalizedGrey, 255)); 260 + const int threshold = (clampedGrey * (matrixLevels + 1)) / 256; 261 + 262 + const int matrixX = x & (matrixSize - 1); 263 + const int matrixY = y & (matrixSize - 1); 264 + const uint8_t patternValue = bayer4x4[matrixY][matrixX]; 265 + const bool black = patternValue < threshold; 266 + drawPixel(x, y, black); 267 + } 268 + } 269 + 270 + // Use Bayer matrix 4x4 dithering to fill the rectangle with a grey level 271 + void GfxRenderer::fillRectDither(const int x, const int y, const int width, const int height, Color color) const { 272 + if (color == Color::Clear) { 273 + } else if (color == Color::Black) { 274 + fillRect(x, y, width, height, true); 275 + } else if (color == Color::White) { 276 + fillRect(x, y, width, height, false); 277 + } else { 278 + for (int fillY = y; fillY < y + height; fillY++) { 279 + for (int fillX = x; fillX < x + width; fillX++) { 280 + drawPixelDither(fillX, fillY, color); 281 + } 282 + } 283 + } 284 + } 285 + 286 + void GfxRenderer::fillArc(const int maxRadius, const int cx, const int cy, const int xDir, const int yDir, 287 + Color color) const { 288 + const int radiusSq = maxRadius * maxRadius; 289 + for (int dy = 0; dy <= maxRadius; ++dy) { 290 + for (int dx = 0; dx <= maxRadius; ++dx) { 291 + const int distSq = dx * dx + dy * dy; 292 + const int px = cx + xDir * dx; 293 + const int py = cy + yDir * dy; 294 + if (distSq <= radiusSq) { 295 + drawPixelDither(px, py, color); 296 + } 297 + } 298 + } 299 + } 300 + 301 + void GfxRenderer::fillRoundedRect(const int x, const int y, const int width, const int height, const int cornerRadius, 302 + const Color color) const { 303 + fillRoundedRect(x, y, width, height, cornerRadius, true, true, true, true, color); 304 + } 305 + 306 + void GfxRenderer::fillRoundedRect(const int x, const int y, const int width, const int height, const int cornerRadius, 307 + bool roundTopLeft, bool roundTopRight, bool roundBottomLeft, bool roundBottomRight, 308 + const Color color) const { 309 + if (width <= 0 || height <= 0) { 310 + return; 311 + } 312 + 313 + const int maxRadius = std::min({cornerRadius, width / 2, height / 2}); 314 + if (maxRadius <= 0) { 315 + fillRectDither(x, y, width, height, color); 316 + return; 317 + } 318 + 319 + const int horizontalWidth = width - 2 * maxRadius; 320 + if (horizontalWidth > 0) { 321 + fillRectDither(x + maxRadius + 1, y, horizontalWidth - 2, height, color); 322 + } 323 + 324 + const int verticalHeight = height - 2 * maxRadius - 2; 325 + if (verticalHeight > 0) { 326 + fillRectDither(x, y + maxRadius + 1, maxRadius + 1, verticalHeight, color); 327 + fillRectDither(x + width - maxRadius - 1, y + maxRadius + 1, maxRadius + 1, verticalHeight, color); 328 + } 329 + 330 + if (roundTopLeft) { 331 + fillArc(maxRadius, x + maxRadius, y + maxRadius, -1, -1, color); 332 + } else { 333 + fillRectDither(x, y, maxRadius + 1, maxRadius + 1, color); 334 + } 335 + 336 + if (roundTopRight) { 337 + fillArc(maxRadius, x + width - maxRadius - 1, y + maxRadius, 1, -1, color); 338 + } else { 339 + fillRectDither(x + width - maxRadius - 1, y, maxRadius + 1, maxRadius + 1, color); 340 + } 341 + 342 + if (roundBottomRight) { 343 + fillArc(maxRadius, x + width - maxRadius - 1, y + height - maxRadius - 1, 1, 1, color); 344 + } else { 345 + fillRectDither(x + width - maxRadius - 1, y + height - maxRadius - 1, maxRadius + 1, maxRadius + 1, color); 346 + } 347 + 348 + if (roundBottomLeft) { 349 + fillArc(maxRadius, x + maxRadius, y + height - maxRadius - 1, -1, 1, color); 350 + } else { 351 + fillRectDither(x, y + height - maxRadius - 1, maxRadius + 1, maxRadius + 1, color); 352 + } 353 + } 354 + 146 355 void GfxRenderer::drawImage(const uint8_t bitmap[], const int x, const int y, const int width, const int height) const { 147 356 int rotatedX = 0; 148 357 int rotatedY = 0; ··· 164 373 } 165 374 // TODO: Rotate bits 166 375 display.drawImage(bitmap, rotatedX, rotatedY, width, height); 376 + } 377 + 378 + void GfxRenderer::drawIcon(const uint8_t bitmap[], const int x, const int y, const int width, const int height) const { 379 + display.drawImage(bitmap, y, getScreenWidth() - width - x, height, width); 167 380 } 168 381 169 382 void GfxRenderer::drawBitmap(const Bitmap& bitmap, const int x, const int y, const int maxWidth, const int maxHeight, ··· 500 713 } 501 714 502 715 return fontMap.at(fontId).getData(EpdFontFamily::REGULAR)->advanceY; 503 - } 504 - 505 - void GfxRenderer::drawButtonHints(const int fontId, const char* btn1, const char* btn2, const char* btn3, 506 - const char* btn4) { 507 - const Orientation orig_orientation = getOrientation(); 508 - setOrientation(Orientation::Portrait); 509 - 510 - const int pageHeight = getScreenHeight(); 511 - constexpr int buttonWidth = 106; 512 - constexpr int buttonHeight = 40; 513 - constexpr int buttonY = 40; // Distance from bottom 514 - constexpr int textYOffset = 7; // Distance from top of button to text baseline 515 - constexpr int buttonPositions[] = {25, 130, 245, 350}; 516 - const char* labels[] = {btn1, btn2, btn3, btn4}; 517 - 518 - for (int i = 0; i < 4; i++) { 519 - // Only draw if the label is non-empty 520 - if (labels[i] != nullptr && labels[i][0] != '\0') { 521 - const int x = buttonPositions[i]; 522 - fillRect(x, pageHeight - buttonY, buttonWidth, buttonHeight, false); 523 - drawRect(x, pageHeight - buttonY, buttonWidth, buttonHeight); 524 - const int textWidth = getTextWidth(fontId, labels[i]); 525 - const int textX = x + (buttonWidth - 1 - textWidth) / 2; 526 - drawText(fontId, textX, pageHeight - buttonY + textYOffset, labels[i]); 527 - } 528 - } 529 - 530 - setOrientation(orig_orientation); 531 - } 532 - 533 - void GfxRenderer::drawSideButtonHints(const int fontId, const char* topBtn, const char* bottomBtn) const { 534 - const int screenWidth = getScreenWidth(); 535 - constexpr int buttonWidth = 40; // Width on screen (height when rotated) 536 - constexpr int buttonHeight = 80; // Height on screen (width when rotated) 537 - constexpr int buttonX = 5; // Distance from right edge 538 - // Position for the button group - buttons share a border so they're adjacent 539 - constexpr int topButtonY = 345; // Top button position 540 - 541 - const char* labels[] = {topBtn, bottomBtn}; 542 - 543 - // Draw the shared border for both buttons as one unit 544 - const int x = screenWidth - buttonX - buttonWidth; 545 - 546 - // Draw top button outline (3 sides, bottom open) 547 - if (topBtn != nullptr && topBtn[0] != '\0') { 548 - drawLine(x, topButtonY, x + buttonWidth - 1, topButtonY); // Top 549 - drawLine(x, topButtonY, x, topButtonY + buttonHeight - 1); // Left 550 - drawLine(x + buttonWidth - 1, topButtonY, x + buttonWidth - 1, topButtonY + buttonHeight - 1); // Right 551 - } 552 - 553 - // Draw shared middle border 554 - if ((topBtn != nullptr && topBtn[0] != '\0') || (bottomBtn != nullptr && bottomBtn[0] != '\0')) { 555 - drawLine(x, topButtonY + buttonHeight, x + buttonWidth - 1, topButtonY + buttonHeight); // Shared border 556 - } 557 - 558 - // Draw bottom button outline (3 sides, top is shared) 559 - if (bottomBtn != nullptr && bottomBtn[0] != '\0') { 560 - drawLine(x, topButtonY + buttonHeight, x, topButtonY + 2 * buttonHeight - 1); // Left 561 - drawLine(x + buttonWidth - 1, topButtonY + buttonHeight, x + buttonWidth - 1, 562 - topButtonY + 2 * buttonHeight - 1); // Right 563 - drawLine(x, topButtonY + 2 * buttonHeight - 1, x + buttonWidth - 1, topButtonY + 2 * buttonHeight - 1); // Bottom 564 - } 565 - 566 - // Draw text for each button 567 - for (int i = 0; i < 2; i++) { 568 - if (labels[i] != nullptr && labels[i][0] != '\0') { 569 - const int y = topButtonY + i * buttonHeight; 570 - 571 - // Draw rotated text centered in the button 572 - const int textWidth = getTextWidth(fontId, labels[i]); 573 - const int textHeight = getTextHeight(fontId); 574 - 575 - // Center the rotated text in the button 576 - const int textX = x + (buttonWidth - textHeight) / 2; 577 - const int textY = y + (buttonHeight + textWidth) / 2; 578 - 579 - drawTextRotated90CW(fontId, textX, textY, labels[i]); 580 - } 581 - } 582 716 } 583 717 584 718 int GfxRenderer::getTextHeight(const int fontId) const {
+17 -6
lib/GfxRenderer/GfxRenderer.h
··· 7 7 8 8 #include "Bitmap.h" 9 9 10 + // Color representation: uint8_t mapped to 4x4 Bayer matrix dithering levels 11 + // 0 = transparent, 1-16 = gray levels (white to black) 12 + enum Color : uint8_t { Clear = 0x00, White = 0x01, LightGray = 0x05, DarkGray = 0x0A, Black = 0x10 }; 13 + 10 14 class GfxRenderer { 11 15 public: 12 16 enum RenderMode { BW, GRAYSCALE_LSB, GRAYSCALE_MSB }; ··· 34 38 EpdFontFamily::Style style) const; 35 39 void freeBwBufferChunks(); 36 40 void rotateCoordinates(int x, int y, int* rotatedX, int* rotatedY) const; 41 + void drawPixelDither(int x, int y, Color color) const; 42 + void fillArc(int maxRadius, int cx, int cy, int xDir, int yDir, Color color) const; 37 43 38 44 public: 39 45 explicit GfxRenderer(HalDisplay& halDisplay) : display(halDisplay), renderMode(BW), orientation(Portrait) {} ··· 63 69 // Drawing 64 70 void drawPixel(int x, int y, bool state = true) const; 65 71 void drawLine(int x1, int y1, int x2, int y2, bool state = true) const; 72 + void drawLine(int x1, int y1, int x2, int y2, int lineWidth, bool state) const; 73 + void drawArc(int maxRadius, int cx, int cy, int xDir, int yDir, int lineWidth, bool state) const; 66 74 void drawRect(int x, int y, int width, int height, bool state = true) const; 75 + void drawRect(int x, int y, int width, int height, int lineWidth, bool state) const; 76 + void drawRoundedRect(int x, int y, int width, int height, int lineWidth, int cornerRadius, bool state) const; 77 + void drawRoundedRect(int x, int y, int width, int height, int lineWidth, int cornerRadius, bool roundTopLeft, 78 + bool roundTopRight, bool roundBottomLeft, bool roundBottomRight, bool state) const; 67 79 void fillRect(int x, int y, int width, int height, bool state = true) const; 80 + void fillRectDither(int x, int y, int width, int height, Color color) const; 81 + void fillRoundedRect(int x, int y, int width, int height, int cornerRadius, Color color) const; 82 + void fillRoundedRect(int x, int y, int width, int height, int cornerRadius, bool roundTopLeft, bool roundTopRight, 83 + bool roundBottomLeft, bool roundBottomRight, Color color) const; 68 84 void drawImage(const uint8_t bitmap[], int x, int y, int width, int height) const; 85 + void drawIcon(const uint8_t bitmap[], int x, int y, int width, int height) const; 69 86 void drawBitmap(const Bitmap& bitmap, int x, int y, int maxWidth, int maxHeight, float cropX = 0, 70 87 float cropY = 0) const; 71 88 void drawBitmap1Bit(const Bitmap& bitmap, int x, int y, int maxWidth, int maxHeight) const; ··· 84 101 std::string truncatedText(int fontId, const char* text, int maxWidth, 85 102 EpdFontFamily::Style style = EpdFontFamily::REGULAR) const; 86 103 87 - // UI Components 88 - void drawButtonHints(int fontId, const char* btn1, const char* btn2, const char* btn3, const char* btn4); 89 - void drawSideButtonHints(int fontId, const char* topBtn, const char* bottomBtn) const; 90 - 91 - private: 92 104 // Helper for drawing rotated text (90 degrees clockwise, for side buttons) 93 105 void drawTextRotated90CW(int fontId, int x, int y, const char* text, bool black = true, 94 106 EpdFontFamily::Style style = EpdFontFamily::REGULAR) const; 95 107 int getTextHeight(int fontId) const; 96 108 97 - public: 98 109 // Grayscale functions 99 110 void setRenderMode(const RenderMode mode) { this->renderMode = mode; } 100 111 void copyGrayscaleLsbBuffers() const;
+10 -9
lib/Xtc/Xtc.cpp
··· 301 301 return true; 302 302 } 303 303 304 - std::string Xtc::getThumbBmpPath() const { return cachePath + "/thumb.bmp"; } 304 + std::string Xtc::getThumbBmpPath() const { return cachePath + "/thumb_[HEIGHT].bmp"; } 305 + std::string Xtc::getThumbBmpPath(int height) const { return cachePath + "/thumb_" + std::to_string(height) + ".bmp"; } 305 306 306 - bool Xtc::generateThumbBmp() const { 307 + bool Xtc::generateThumbBmp(int height) const { 307 308 // Already generated 308 - if (SdMan.exists(getThumbBmpPath().c_str())) { 309 + if (SdMan.exists(getThumbBmpPath(height).c_str())) { 309 310 return true; 310 311 } 311 312 ··· 333 334 const uint8_t bitDepth = parser->getBitDepth(); 334 335 335 336 // Calculate target dimensions for thumbnail (fit within 240x400 Continue Reading card) 336 - constexpr int THUMB_TARGET_WIDTH = 240; 337 - constexpr int THUMB_TARGET_HEIGHT = 400; 337 + int THUMB_TARGET_WIDTH = height * 0.6; 338 + int THUMB_TARGET_HEIGHT = height; 338 339 339 340 // Calculate scale factor 340 341 float scaleX = static_cast<float>(THUMB_TARGET_WIDTH) / pageInfo.width; ··· 348 349 if (generateCoverBmp()) { 349 350 FsFile src, dst; 350 351 if (SdMan.openFileForRead("XTC", getCoverBmpPath(), src)) { 351 - if (SdMan.openFileForWrite("XTC", getThumbBmpPath(), dst)) { 352 + if (SdMan.openFileForWrite("XTC", getThumbBmpPath(height), dst)) { 352 353 uint8_t buffer[512]; 353 354 while (src.available()) { 354 355 size_t bytesRead = src.read(buffer, sizeof(buffer)); ··· 359 360 src.close(); 360 361 } 361 362 Serial.printf("[%lu] [XTC] Copied cover to thumb (no scaling needed)\n", millis()); 362 - return SdMan.exists(getThumbBmpPath().c_str()); 363 + return SdMan.exists(getThumbBmpPath(height).c_str()); 363 364 } 364 365 return false; 365 366 } ··· 393 394 394 395 // Create thumbnail BMP file - use 1-bit format for fast home screen rendering (no gray passes) 395 396 FsFile thumbBmp; 396 - if (!SdMan.openFileForWrite("XTC", getThumbBmpPath(), thumbBmp)) { 397 + if (!SdMan.openFileForWrite("XTC", getThumbBmpPath(height), thumbBmp)) { 397 398 Serial.printf("[%lu] [XTC] Failed to create thumb BMP file\n", millis()); 398 399 free(pageBuffer); 399 400 return false; ··· 558 559 free(pageBuffer); 559 560 560 561 Serial.printf("[%lu] [XTC] Generated thumb BMP (%dx%d): %s\n", millis(), thumbWidth, thumbHeight, 561 - getThumbBmpPath().c_str()); 562 + getThumbBmpPath(height).c_str()); 562 563 return true; 563 564 } 564 565
+2 -1
lib/Xtc/Xtc.h
··· 65 65 bool generateCoverBmp() const; 66 66 // Thumbnail support (for Continue Reading card) 67 67 std::string getThumbBmpPath() const; 68 - bool generateThumbBmp() const; 68 + std::string getThumbBmpPath(int height) const; 69 + bool generateThumbBmp(int height) const; 69 70 70 71 // Page access 71 72 uint32_t getPageCount() const;
+4 -1
src/CrossPointSettings.cpp
··· 22 22 namespace { 23 23 constexpr uint8_t SETTINGS_FILE_VERSION = 1; 24 24 // Increment this when adding new persisted settings fields 25 - constexpr uint8_t SETTINGS_COUNT = 23; 25 + constexpr uint8_t SETTINGS_COUNT = 24; 26 26 constexpr char SETTINGS_FILE[] = "/.crosspoint/settings.bin"; 27 27 } // namespace 28 28 ··· 61 61 serialization::writeString(outputFile, std::string(opdsPassword)); 62 62 serialization::writePod(outputFile, sleepScreenCoverFilter); 63 63 // New fields added at end for backward compatibility 64 + serialization::writePod(outputFile, uiTheme); 64 65 outputFile.close(); 65 66 66 67 Serial.printf("[%lu] [CPS] Settings saved to file\n", millis()); ··· 149 150 readAndValidate(inputFile, sleepScreenCoverFilter, SLEEP_SCREEN_COVER_FILTER_COUNT); 150 151 if (++settingsRead >= fileSettingsCount) break; 151 152 // New fields added at end for backward compatibility 153 + serialization::readPod(inputFile, uiTheme); 154 + if (++settingsRead >= fileSettingsCount) break; 152 155 } while (false); 153 156 154 157 inputFile.close();
+5
src/CrossPointSettings.h
··· 97 97 // Hide battery percentage 98 98 enum HIDE_BATTERY_PERCENTAGE { HIDE_NEVER = 0, HIDE_READER = 1, HIDE_ALWAYS = 2, HIDE_BATTERY_PERCENTAGE_COUNT }; 99 99 100 + // UI Theme 101 + enum UI_THEME { CLASSIC = 0, LYRA = 1 }; 102 + 100 103 // Sleep screen settings 101 104 uint8_t sleepScreen = DARK; 102 105 // Sleep screen cover mode settings ··· 137 140 uint8_t hideBatteryPercentage = HIDE_NEVER; 138 141 // Long-press chapter skip on side buttons 139 142 uint8_t longPressChapterSkip = 1; 143 + // UI Theme 144 + uint8_t uiTheme = LYRA; 140 145 141 146 ~CrossPointSettings() = default; 142 147
+53 -9
src/RecentBooksStore.cpp
··· 1 1 #include "RecentBooksStore.h" 2 2 3 + #include <Epub.h> 3 4 #include <HardwareSerial.h> 4 5 #include <SDCardManager.h> 5 6 #include <Serialization.h> 7 + #include <Xtc.h> 6 8 7 9 #include <algorithm> 10 + 11 + #include "util/StringUtils.h" 8 12 9 13 namespace { 10 - constexpr uint8_t RECENT_BOOKS_FILE_VERSION = 2; 14 + constexpr uint8_t RECENT_BOOKS_FILE_VERSION = 3; 11 15 constexpr char RECENT_BOOKS_FILE[] = "/.crosspoint/recent.bin"; 12 16 constexpr int MAX_RECENT_BOOKS = 10; 13 17 } // namespace 14 18 15 19 RecentBooksStore RecentBooksStore::instance; 16 20 17 - void RecentBooksStore::addBook(const std::string& path, const std::string& title, const std::string& author) { 21 + void RecentBooksStore::addBook(const std::string& path, const std::string& title, const std::string& author, 22 + const std::string& coverBmpPath) { 18 23 // Remove existing entry if present 19 24 auto it = 20 25 std::find_if(recentBooks.begin(), recentBooks.end(), [&](const RecentBook& book) { return book.path == path; }); ··· 23 28 } 24 29 25 30 // Add to front 26 - recentBooks.insert(recentBooks.begin(), {path, title, author}); 31 + recentBooks.insert(recentBooks.begin(), {path, title, author, coverBmpPath}); 27 32 28 33 // Trim to max size 29 34 if (recentBooks.size() > MAX_RECENT_BOOKS) { ··· 50 55 serialization::writeString(outputFile, book.path); 51 56 serialization::writeString(outputFile, book.title); 52 57 serialization::writeString(outputFile, book.author); 58 + serialization::writeString(outputFile, book.coverBmpPath); 53 59 } 54 60 55 61 outputFile.close(); ··· 57 63 return true; 58 64 } 59 65 66 + RecentBook RecentBooksStore::getDataFromBook(std::string path) const { 67 + std::string lastBookFileName = ""; 68 + const size_t lastSlash = path.find_last_of('/'); 69 + if (lastSlash != std::string::npos) { 70 + lastBookFileName = path.substr(lastSlash + 1); 71 + } 72 + 73 + Serial.printf("Loading recent book: %s\n", path.c_str()); 74 + 75 + // If epub, try to load the metadata for title/author and cover 76 + if (StringUtils::checkFileExtension(lastBookFileName, ".epub")) { 77 + Epub epub(path, "/.crosspoint"); 78 + epub.load(false); 79 + return RecentBook{path, epub.getTitle(), epub.getAuthor(), epub.getThumbBmpPath()}; 80 + } else if (StringUtils::checkFileExtension(lastBookFileName, ".xtch") || 81 + StringUtils::checkFileExtension(lastBookFileName, ".xtc")) { 82 + // Handle XTC file 83 + Xtc xtc(path, "/.crosspoint"); 84 + if (xtc.load()) { 85 + return RecentBook{path, xtc.getTitle(), xtc.getAuthor(), xtc.getThumbBmpPath()}; 86 + } 87 + } else if (StringUtils::checkFileExtension(lastBookFileName, ".txt") || 88 + StringUtils::checkFileExtension(lastBookFileName, ".md")) { 89 + return RecentBook{path, lastBookFileName, "", ""}; 90 + } 91 + return RecentBook{path, "", "", ""}; 92 + } 93 + 60 94 bool RecentBooksStore::loadFromFile() { 61 95 FsFile inputFile; 62 96 if (!SdMan.openFileForRead("RBS", RECENT_BOOKS_FILE, inputFile)) { ··· 66 100 uint8_t version; 67 101 serialization::readPod(inputFile, version); 68 102 if (version != RECENT_BOOKS_FILE_VERSION) { 69 - if (version == 1) { 103 + if (version == 1 || version == 2) { 70 104 // Old version, just read paths 71 105 uint8_t count; 72 106 serialization::readPod(inputFile, count); ··· 75 109 for (uint8_t i = 0; i < count; i++) { 76 110 std::string path; 77 111 serialization::readString(inputFile, path); 78 - // Title and author will be empty, they will be filled when the book is 79 - // opened again 80 - recentBooks.push_back({path, "", ""}); 112 + 113 + // load book to get missing data 114 + RecentBook book = getDataFromBook(path); 115 + if (book.title.empty() && book.author.empty() && version == 2) { 116 + // Fall back to loading what we can from the store 117 + std::string title, author; 118 + serialization::readString(inputFile, title); 119 + serialization::readString(inputFile, author); 120 + recentBooks.push_back({path, title, author, ""}); 121 + } else { 122 + recentBooks.push_back(book); 123 + } 81 124 } 82 125 } else { 83 126 Serial.printf("[%lu] [RBS] Deserialization failed: Unknown version %u\n", millis(), version); ··· 92 135 recentBooks.reserve(count); 93 136 94 137 for (uint8_t i = 0; i < count; i++) { 95 - std::string path, title, author; 138 + std::string path, title, author, coverBmpPath; 96 139 serialization::readString(inputFile, path); 97 140 serialization::readString(inputFile, title); 98 141 serialization::readString(inputFile, author); 99 - recentBooks.push_back({path, title, author}); 142 + serialization::readString(inputFile, coverBmpPath); 143 + recentBooks.push_back({path, title, author, coverBmpPath}); 100 144 } 101 145 } 102 146
+4 -1
src/RecentBooksStore.h
··· 6 6 std::string path; 7 7 std::string title; 8 8 std::string author; 9 + std::string coverBmpPath; 9 10 10 11 bool operator==(const RecentBook& other) const { return path == other.path; } 11 12 }; ··· 23 24 static RecentBooksStore& getInstance() { return instance; } 24 25 25 26 // Add a book to the recent list (moves to front if already exists) 26 - void addBook(const std::string& path, const std::string& title, const std::string& author); 27 + void addBook(const std::string& path, const std::string& title, const std::string& author, 28 + const std::string& coverBmpPath); 27 29 28 30 // Get the list of recent books (most recent first) 29 31 const std::vector<RecentBook>& getBooks() const { return recentBooks; } ··· 34 36 bool saveToFile() const; 35 37 36 38 bool loadFromFile(); 39 + RecentBook getDataFromBook(std::string path) const; 37 40 }; 38 41 39 42 // Helper macro to access recent books store
-178
src/ScreenComponents.cpp
··· 1 - #include "ScreenComponents.h" 2 - 3 - #include <GfxRenderer.h> 4 - 5 - #include <cstdint> 6 - #include <string> 7 - 8 - #include "Battery.h" 9 - #include "fontIds.h" 10 - 11 - void ScreenComponents::drawBattery(const GfxRenderer& renderer, const int left, const int top, 12 - const bool showPercentage) { 13 - // Left aligned battery icon and percentage 14 - const uint16_t percentage = battery.readPercentage(); 15 - const auto percentageText = showPercentage ? std::to_string(percentage) + "%" : ""; 16 - renderer.drawText(SMALL_FONT_ID, left + 20, top, percentageText.c_str()); 17 - 18 - // 1 column on left, 2 columns on right, 5 columns of battery body 19 - constexpr int batteryWidth = 15; 20 - constexpr int batteryHeight = 12; 21 - const int x = left; 22 - const int y = top + 6; 23 - 24 - // Top line 25 - renderer.drawLine(x + 1, y, x + batteryWidth - 3, y); 26 - // Bottom line 27 - renderer.drawLine(x + 1, y + batteryHeight - 1, x + batteryWidth - 3, y + batteryHeight - 1); 28 - // Left line 29 - renderer.drawLine(x, y + 1, x, y + batteryHeight - 2); 30 - // Battery end 31 - renderer.drawLine(x + batteryWidth - 2, y + 1, x + batteryWidth - 2, y + batteryHeight - 2); 32 - renderer.drawPixel(x + batteryWidth - 1, y + 3); 33 - renderer.drawPixel(x + batteryWidth - 1, y + batteryHeight - 4); 34 - renderer.drawLine(x + batteryWidth - 0, y + 4, x + batteryWidth - 0, y + batteryHeight - 5); 35 - 36 - // The +1 is to round up, so that we always fill at least one pixel 37 - int filledWidth = percentage * (batteryWidth - 5) / 100 + 1; 38 - if (filledWidth > batteryWidth - 5) { 39 - filledWidth = batteryWidth - 5; // Ensure we don't overflow 40 - } 41 - 42 - renderer.fillRect(x + 2, y + 2, filledWidth, batteryHeight - 4); 43 - } 44 - 45 - ScreenComponents::PopupLayout ScreenComponents::drawPopup(const GfxRenderer& renderer, const char* message) { 46 - constexpr int margin = 15; 47 - constexpr int y = 60; 48 - const int textWidth = renderer.getTextWidth(UI_12_FONT_ID, message, EpdFontFamily::BOLD); 49 - const int textHeight = renderer.getLineHeight(UI_12_FONT_ID); 50 - const int w = textWidth + margin * 2; 51 - const int h = textHeight + margin * 2; 52 - const int x = (renderer.getScreenWidth() - w) / 2; 53 - 54 - renderer.fillRect(x - 2, y - 2, w + 4, h + 4, true); // frame thickness 2 55 - renderer.fillRect(x, y, w, h, false); 56 - 57 - const int textX = x + (w - textWidth) / 2; 58 - const int textY = y + margin - 2; 59 - renderer.drawText(UI_12_FONT_ID, textX, textY, message, true, EpdFontFamily::BOLD); 60 - renderer.displayBuffer(); 61 - return {x, y, w, h}; 62 - } 63 - 64 - void ScreenComponents::fillPopupProgress(const GfxRenderer& renderer, const PopupLayout& layout, const int progress) { 65 - constexpr int barHeight = 4; 66 - const int barWidth = layout.width - 30; // twice the margin in drawPopup to match text width 67 - const int barX = layout.x + (layout.width - barWidth) / 2; 68 - const int barY = layout.y + layout.height - 10; 69 - 70 - int fillWidth = barWidth * progress / 100; 71 - 72 - renderer.fillRect(barX, barY, fillWidth, barHeight, true); 73 - 74 - renderer.displayBuffer(HalDisplay::FAST_REFRESH); 75 - } 76 - 77 - void ScreenComponents::drawBookProgressBar(const GfxRenderer& renderer, const size_t bookProgress) { 78 - int vieweableMarginTop, vieweableMarginRight, vieweableMarginBottom, vieweableMarginLeft; 79 - renderer.getOrientedViewableTRBL(&vieweableMarginTop, &vieweableMarginRight, &vieweableMarginBottom, 80 - &vieweableMarginLeft); 81 - 82 - const int progressBarMaxWidth = renderer.getScreenWidth() - vieweableMarginLeft - vieweableMarginRight; 83 - const int progressBarY = renderer.getScreenHeight() - vieweableMarginBottom - BOOK_PROGRESS_BAR_HEIGHT; 84 - const int barWidth = progressBarMaxWidth * bookProgress / 100; 85 - renderer.fillRect(vieweableMarginLeft, progressBarY, barWidth, BOOK_PROGRESS_BAR_HEIGHT, true); 86 - } 87 - 88 - int ScreenComponents::drawTabBar(const GfxRenderer& renderer, const int y, const std::vector<TabInfo>& tabs) { 89 - constexpr int tabPadding = 20; // Horizontal padding between tabs 90 - constexpr int leftMargin = 20; // Left margin for first tab 91 - constexpr int underlineHeight = 2; // Height of selection underline 92 - constexpr int underlineGap = 4; // Gap between text and underline 93 - 94 - const int lineHeight = renderer.getLineHeight(UI_12_FONT_ID); 95 - const int tabBarHeight = lineHeight + underlineGap + underlineHeight; 96 - 97 - int currentX = leftMargin; 98 - 99 - for (const auto& tab : tabs) { 100 - const int textWidth = 101 - renderer.getTextWidth(UI_12_FONT_ID, tab.label, tab.selected ? EpdFontFamily::BOLD : EpdFontFamily::REGULAR); 102 - 103 - // Draw tab label 104 - renderer.drawText(UI_12_FONT_ID, currentX, y, tab.label, true, 105 - tab.selected ? EpdFontFamily::BOLD : EpdFontFamily::REGULAR); 106 - 107 - // Draw underline for selected tab 108 - if (tab.selected) { 109 - renderer.fillRect(currentX, y + lineHeight + underlineGap, textWidth, underlineHeight); 110 - } 111 - 112 - currentX += textWidth + tabPadding; 113 - } 114 - 115 - return tabBarHeight; 116 - } 117 - 118 - void ScreenComponents::drawScrollIndicator(const GfxRenderer& renderer, const int currentPage, const int totalPages, 119 - const int contentTop, const int contentHeight) { 120 - if (totalPages <= 1) { 121 - return; // No need for indicator if only one page 122 - } 123 - 124 - const int screenWidth = renderer.getScreenWidth(); 125 - constexpr int indicatorWidth = 20; 126 - constexpr int arrowSize = 6; 127 - constexpr int margin = 15; // Offset from right edge 128 - 129 - const int centerX = screenWidth - indicatorWidth / 2 - margin; 130 - const int indicatorTop = contentTop + 60; // Offset to avoid overlapping side button hints 131 - const int indicatorBottom = contentTop + contentHeight - 30; 132 - 133 - // Draw up arrow at top (^) - narrow point at top, wide base at bottom 134 - for (int i = 0; i < arrowSize; ++i) { 135 - const int lineWidth = 1 + i * 2; 136 - const int startX = centerX - i; 137 - renderer.drawLine(startX, indicatorTop + i, startX + lineWidth - 1, indicatorTop + i); 138 - } 139 - 140 - // Draw down arrow at bottom (v) - wide base at top, narrow point at bottom 141 - for (int i = 0; i < arrowSize; ++i) { 142 - const int lineWidth = 1 + (arrowSize - 1 - i) * 2; 143 - const int startX = centerX - (arrowSize - 1 - i); 144 - renderer.drawLine(startX, indicatorBottom - arrowSize + 1 + i, startX + lineWidth - 1, 145 - indicatorBottom - arrowSize + 1 + i); 146 - } 147 - 148 - // Draw page fraction in the middle (e.g., "1/3") 149 - const std::string pageText = std::to_string(currentPage) + "/" + std::to_string(totalPages); 150 - const int textWidth = renderer.getTextWidth(SMALL_FONT_ID, pageText.c_str()); 151 - const int textX = centerX - textWidth / 2; 152 - const int textY = (indicatorTop + indicatorBottom) / 2 - renderer.getLineHeight(SMALL_FONT_ID) / 2; 153 - 154 - renderer.drawText(SMALL_FONT_ID, textX, textY, pageText.c_str()); 155 - } 156 - 157 - void ScreenComponents::drawProgressBar(const GfxRenderer& renderer, const int x, const int y, const int width, 158 - const int height, const size_t current, const size_t total) { 159 - if (total == 0) { 160 - return; 161 - } 162 - 163 - // Use 64-bit arithmetic to avoid overflow for large files 164 - const int percent = static_cast<int>((static_cast<uint64_t>(current) * 100) / total); 165 - 166 - // Draw outline 167 - renderer.drawRect(x, y, width, height); 168 - 169 - // Draw filled portion 170 - const int fillWidth = (width - 4) * percent / 100; 171 - if (fillWidth > 0) { 172 - renderer.fillRect(x + 2, y + 2, fillWidth, height - 4); 173 - } 174 - 175 - // Draw percentage text centered below bar 176 - const std::string percentText = std::to_string(percent) + "%"; 177 - renderer.drawCenteredText(UI_10_FONT_ID, y + height + 15, percentText.c_str()); 178 - }
-53
src/ScreenComponents.h
··· 1 - #pragma once 2 - 3 - #include <cstddef> 4 - #include <cstdint> 5 - #include <vector> 6 - 7 - class GfxRenderer; 8 - 9 - struct TabInfo { 10 - const char* label; 11 - bool selected; 12 - }; 13 - 14 - class ScreenComponents { 15 - public: 16 - static const int BOOK_PROGRESS_BAR_HEIGHT = 4; 17 - 18 - struct PopupLayout { 19 - int x; 20 - int y; 21 - int width; 22 - int height; 23 - }; 24 - 25 - static void drawBattery(const GfxRenderer& renderer, int left, int top, bool showPercentage = true); 26 - static void drawBookProgressBar(const GfxRenderer& renderer, size_t bookProgress); 27 - 28 - static PopupLayout drawPopup(const GfxRenderer& renderer, const char* message); 29 - 30 - static void fillPopupProgress(const GfxRenderer& renderer, const PopupLayout& layout, int progress); 31 - 32 - // Draw a horizontal tab bar with underline indicator for selected tab 33 - // Returns the height of the tab bar (for positioning content below) 34 - static int drawTabBar(const GfxRenderer& renderer, int y, const std::vector<TabInfo>& tabs); 35 - 36 - // Draw a scroll/page indicator on the right side of the screen 37 - // Shows up/down arrows and current page fraction (e.g., "1/3") 38 - static void drawScrollIndicator(const GfxRenderer& renderer, int currentPage, int totalPages, int contentTop, 39 - int contentHeight); 40 - 41 - /** 42 - * Draw a progress bar with percentage text. 43 - * @param renderer The graphics renderer 44 - * @param x Left position of the bar 45 - * @param y Top position of the bar 46 - * @param width Width of the bar 47 - * @param height Height of the bar 48 - * @param current Current progress value 49 - * @param total Total value for 100% progress 50 - */ 51 - static void drawProgressBar(const GfxRenderer& renderer, int x, int y, int width, int height, size_t current, 52 - size_t total); 53 - };
+2 -3
src/activities/boot_sleep/SleepActivity.cpp
··· 8 8 9 9 #include "CrossPointSettings.h" 10 10 #include "CrossPointState.h" 11 - #include "ScreenComponents.h" 11 + #include "components/UITheme.h" 12 12 #include "fontIds.h" 13 13 #include "images/CrossLarge.h" 14 14 #include "util/StringUtils.h" 15 15 16 16 void SleepActivity::onEnter() { 17 17 Activity::onEnter(); 18 - 19 - ScreenComponents::drawPopup(renderer, "Entering Sleep..."); 18 + GUI.drawPopup(renderer, "Entering Sleep..."); 20 19 21 20 if (SETTINGS.sleepScreen == CrossPointSettings::SLEEP_SCREEN_MODE::BLANK) { 22 21 return renderBlankSleepScreen();
+6 -6
src/activities/browser/OpdsBookBrowserActivity.cpp
··· 8 8 9 9 #include "CrossPointSettings.h" 10 10 #include "MappedInputManager.h" 11 - #include "ScreenComponents.h" 12 11 #include "activities/network/WifiSelectionActivity.h" 12 + #include "components/UITheme.h" 13 13 #include "fontIds.h" 14 14 #include "network/HttpDownloader.h" 15 15 #include "util/StringUtils.h" ··· 176 176 if (state == BrowserState::CHECK_WIFI) { 177 177 renderer.drawCenteredText(UI_10_FONT_ID, pageHeight / 2, statusMessage.c_str()); 178 178 const auto labels = mappedInput.mapLabels("« Back", "", "", ""); 179 - renderer.drawButtonHints(UI_10_FONT_ID, labels.btn1, labels.btn2, labels.btn3, labels.btn4); 179 + GUI.drawButtonHints(renderer, labels.btn1, labels.btn2, labels.btn3, labels.btn4); 180 180 renderer.displayBuffer(); 181 181 return; 182 182 } ··· 184 184 if (state == BrowserState::LOADING) { 185 185 renderer.drawCenteredText(UI_10_FONT_ID, pageHeight / 2, statusMessage.c_str()); 186 186 const auto labels = mappedInput.mapLabels("« Back", "", "", ""); 187 - renderer.drawButtonHints(UI_10_FONT_ID, labels.btn1, labels.btn2, labels.btn3, labels.btn4); 187 + GUI.drawButtonHints(renderer, labels.btn1, labels.btn2, labels.btn3, labels.btn4); 188 188 renderer.displayBuffer(); 189 189 return; 190 190 } ··· 193 193 renderer.drawCenteredText(UI_10_FONT_ID, pageHeight / 2 - 20, "Error:"); 194 194 renderer.drawCenteredText(UI_10_FONT_ID, pageHeight / 2 + 10, errorMessage.c_str()); 195 195 const auto labels = mappedInput.mapLabels("« Back", "Retry", "", ""); 196 - renderer.drawButtonHints(UI_10_FONT_ID, labels.btn1, labels.btn2, labels.btn3, labels.btn4); 196 + GUI.drawButtonHints(renderer, labels.btn1, labels.btn2, labels.btn3, labels.btn4); 197 197 renderer.displayBuffer(); 198 198 return; 199 199 } ··· 206 206 constexpr int barHeight = 20; 207 207 constexpr int barX = 50; 208 208 const int barY = pageHeight / 2 + 20; 209 - ScreenComponents::drawProgressBar(renderer, barX, barY, barWidth, barHeight, downloadProgress, downloadTotal); 209 + GUI.drawProgressBar(renderer, Rect{barX, barY, barWidth, barHeight}, downloadProgress, downloadTotal); 210 210 } 211 211 renderer.displayBuffer(); 212 212 return; ··· 219 219 confirmLabel = "Download"; 220 220 } 221 221 const auto labels = mappedInput.mapLabels("« Back", confirmLabel, "", ""); 222 - renderer.drawButtonHints(UI_10_FONT_ID, labels.btn1, labels.btn2, labels.btn3, labels.btn4); 222 + GUI.drawButtonHints(renderer, labels.btn1, labels.btn2, labels.btn3, labels.btn4); 223 223 224 224 if (entries.empty()) { 225 225 renderer.drawCenteredText(UI_10_FONT_ID, pageHeight / 2, "No entries found");
+121 -390
src/activities/home/HomeActivity.cpp
··· 14 14 #include "CrossPointSettings.h" 15 15 #include "CrossPointState.h" 16 16 #include "MappedInputManager.h" 17 - #include "ScreenComponents.h" 17 + #include "RecentBooksStore.h" 18 + #include "components/UITheme.h" 18 19 #include "fontIds.h" 19 20 #include "util/StringUtils.h" 20 21 ··· 24 25 } 25 26 26 27 int HomeActivity::getMenuItemCount() const { 27 - int count = 3; // My Library, File transfer, Settings 28 - if (hasContinueReading) count++; 29 - if (hasOpdsUrl) count++; 28 + int count = 4; // My Library, Recents, File transfer, Settings 29 + if (!recentBooks.empty()) { 30 + count += recentBooks.size(); 31 + } 32 + if (hasOpdsUrl) { 33 + count++; 34 + } 30 35 return count; 31 36 } 32 37 33 - void HomeActivity::onEnter() { 34 - Activity::onEnter(); 35 - 36 - renderingMutex = xSemaphoreCreateMutex(); 38 + void HomeActivity::loadRecentBooks(int maxBooks, int coverHeight) { 39 + recentsLoading = true; 40 + bool showingLoading = false; 41 + Rect popupRect; 37 42 38 - // Check if we have a book to continue reading 39 - hasContinueReading = !APP_STATE.openEpubPath.empty() && SdMan.exists(APP_STATE.openEpubPath.c_str()); 43 + recentBooks.clear(); 44 + const auto& books = RECENT_BOOKS.getBooks(); 45 + recentBooks.reserve(std::min(static_cast<int>(books.size()), maxBooks)); 40 46 41 - // Check if OPDS browser URL is configured 42 - hasOpdsUrl = strlen(SETTINGS.opdsServerUrl) > 0; 47 + int progress = 0; 48 + for (const RecentBook& book : books) { 49 + // Limit to maximum number of recent books 50 + if (recentBooks.size() >= maxBooks) { 51 + break; 52 + } 43 53 44 - if (hasContinueReading) { 45 - // Extract filename from path for display 46 - lastBookTitle = APP_STATE.openEpubPath; 47 - const size_t lastSlash = lastBookTitle.find_last_of('/'); 48 - if (lastSlash != std::string::npos) { 49 - lastBookTitle = lastBookTitle.substr(lastSlash + 1); 54 + // Skip if file no longer exists 55 + if (!SdMan.exists(book.path.c_str())) { 56 + continue; 50 57 } 51 58 52 - // If epub, try to load the metadata for title/author and cover 53 - if (StringUtils::checkFileExtension(lastBookTitle, ".epub")) { 54 - Epub epub(APP_STATE.openEpubPath, "/.crosspoint"); 55 - // Skip loading css since we only need metadata here 56 - epub.load(false, true); 57 - if (!epub.getTitle().empty()) { 58 - lastBookTitle = std::string(epub.getTitle()); 59 - } 60 - if (!epub.getAuthor().empty()) { 61 - lastBookAuthor = std::string(epub.getAuthor()); 62 - } 63 - // Try to generate thumbnail image for Continue Reading card 64 - if (epub.generateThumbBmp()) { 65 - coverBmpPath = epub.getThumbBmpPath(); 66 - hasCoverImage = true; 67 - } 68 - } else if (StringUtils::checkFileExtension(lastBookTitle, ".xtch") || 69 - StringUtils::checkFileExtension(lastBookTitle, ".xtc")) { 70 - // Handle XTC file 71 - Xtc xtc(APP_STATE.openEpubPath, "/.crosspoint"); 72 - if (xtc.load()) { 73 - if (!xtc.getTitle().empty()) { 74 - lastBookTitle = std::string(xtc.getTitle()); 75 - } 76 - if (!xtc.getAuthor().empty()) { 77 - lastBookAuthor = std::string(xtc.getAuthor()); 59 + if (!book.coverBmpPath.empty()) { 60 + std::string coverPath = UITheme::getCoverThumbPath(book.coverBmpPath, coverHeight); 61 + if (!SdMan.exists(coverPath.c_str())) { 62 + std::string lastBookFileName = ""; 63 + const size_t lastSlash = book.path.find_last_of('/'); 64 + if (lastSlash != std::string::npos) { 65 + lastBookFileName = book.path.substr(lastSlash + 1); 78 66 } 79 - // Try to generate thumbnail image for Continue Reading card 80 - if (xtc.generateThumbBmp()) { 81 - coverBmpPath = xtc.getThumbBmpPath(); 82 - hasCoverImage = true; 67 + 68 + Serial.printf("Loading recent book: %s\n", book.path.c_str()); 69 + 70 + // If epub, try to load the metadata for title/author and cover 71 + if (StringUtils::checkFileExtension(lastBookFileName, ".epub")) { 72 + Epub epub(book.path, "/.crosspoint"); 73 + // Skip loading css since we only need metadata here 74 + epub.load(false, true); 75 + 76 + // Try to generate thumbnail image for Continue Reading card 77 + if (!showingLoading) { 78 + showingLoading = true; 79 + popupRect = GUI.drawPopup(renderer, "Loading..."); 80 + } 81 + GUI.fillPopupProgress(renderer, popupRect, progress * 30); 82 + epub.generateThumbBmp(coverHeight); 83 + } else if (StringUtils::checkFileExtension(lastBookFileName, ".xtch") || 84 + StringUtils::checkFileExtension(lastBookFileName, ".xtc")) { 85 + // Handle XTC file 86 + Xtc xtc(book.path, "/.crosspoint"); 87 + if (xtc.load()) { 88 + // Try to generate thumbnail image for Continue Reading card 89 + if (!showingLoading) { 90 + showingLoading = true; 91 + popupRect = GUI.drawPopup(renderer, "Loading..."); 92 + } 93 + GUI.fillPopupProgress(renderer, popupRect, progress * 30); 94 + xtc.generateThumbBmp(coverHeight); 95 + } 83 96 } 84 97 } 85 - // Remove extension from title if we don't have metadata 86 - if (StringUtils::checkFileExtension(lastBookTitle, ".xtch")) { 87 - lastBookTitle.resize(lastBookTitle.length() - 5); 88 - } else if (StringUtils::checkFileExtension(lastBookTitle, ".xtc")) { 89 - lastBookTitle.resize(lastBookTitle.length() - 4); 90 - } 91 98 } 99 + 100 + recentBooks.push_back(book); 101 + progress++; 92 102 } 93 103 104 + Serial.printf("Recent books loaded: %d\n", recentBooks.size()); 105 + recentsLoaded = true; 106 + recentsLoading = false; 107 + updateRequired = true; 108 + } 109 + 110 + void HomeActivity::onEnter() { 111 + Activity::onEnter(); 112 + 113 + renderingMutex = xSemaphoreCreateMutex(); 114 + 115 + // Check if we have a book to continue reading 116 + hasContinueReading = !APP_STATE.openEpubPath.empty() && SdMan.exists(APP_STATE.openEpubPath.c_str()); 117 + 118 + // Check if OPDS browser URL is configured 119 + hasOpdsUrl = strlen(SETTINGS.opdsServerUrl) > 0; 120 + 94 121 selectorIndex = 0; 95 122 96 123 // Trigger first update 97 124 updateRequired = true; 98 125 99 126 xTaskCreate(&HomeActivity::taskTrampoline, "HomeActivityTask", 100 - 4096, // Stack size (increased for cover image rendering) 127 + 8192, // Stack size 101 128 this, // Parameters 102 129 1, // Priority 103 130 &displayTaskHandle // Task handle ··· 173 200 if (mappedInput.wasReleased(MappedInputManager::Button::Confirm)) { 174 201 // Calculate dynamic indices based on which options are available 175 202 int idx = 0; 176 - const int continueIdx = hasContinueReading ? idx++ : -1; 203 + int menuSelectedIndex = selectorIndex - static_cast<int>(recentBooks.size()); 177 204 const int myLibraryIdx = idx++; 205 + const int recentsIdx = idx++; 178 206 const int opdsLibraryIdx = hasOpdsUrl ? idx++ : -1; 179 207 const int fileTransferIdx = idx++; 180 208 const int settingsIdx = idx; 181 209 182 - if (selectorIndex == continueIdx) { 183 - onContinueReading(); 184 - } else if (selectorIndex == myLibraryIdx) { 210 + if (selectorIndex < recentBooks.size()) { 211 + onSelectBook(recentBooks[selectorIndex].path); 212 + } else if (menuSelectedIndex == myLibraryIdx) { 185 213 onMyLibraryOpen(); 186 - } else if (selectorIndex == opdsLibraryIdx) { 214 + } else if (menuSelectedIndex == recentsIdx) { 215 + onRecentsOpen(); 216 + } else if (menuSelectedIndex == opdsLibraryIdx) { 187 217 onOpdsBrowserOpen(); 188 - } else if (selectorIndex == fileTransferIdx) { 218 + } else if (menuSelectedIndex == fileTransferIdx) { 189 219 onFileTransferOpen(); 190 - } else if (selectorIndex == settingsIdx) { 220 + } else if (menuSelectedIndex == settingsIdx) { 191 221 onSettingsOpen(); 192 222 } 193 223 } else if (prevPressed) { ··· 212 242 } 213 243 214 244 void HomeActivity::render() { 215 - // If we have a stored cover buffer, restore it instead of clearing 216 - const bool bufferRestored = coverBufferStored && restoreCoverBuffer(); 217 - if (!bufferRestored) { 218 - renderer.clearScreen(); 219 - } 220 - 245 + auto metrics = UITheme::getInstance().getMetrics(); 221 246 const auto pageWidth = renderer.getScreenWidth(); 222 247 const auto pageHeight = renderer.getScreenHeight(); 223 248 224 - constexpr int margin = 20; 225 - constexpr int bottomMargin = 60; 226 - 227 - // --- Top "book" card for the current title (selectorIndex == 0) --- 228 - const int bookWidth = pageWidth / 2; 229 - const int bookHeight = pageHeight / 2; 230 - const int bookX = (pageWidth - bookWidth) / 2; 231 - constexpr int bookY = 30; 232 - const bool bookSelected = hasContinueReading && selectorIndex == 0; 233 - 234 - // Bookmark dimensions (used in multiple places) 235 - const int bookmarkWidth = bookWidth / 8; 236 - const int bookmarkHeight = bookHeight / 5; 237 - const int bookmarkX = bookX + bookWidth - bookmarkWidth - 10; 238 - const int bookmarkY = bookY + 5; 239 - 240 - // Draw book card regardless, fill with message based on `hasContinueReading` 241 - { 242 - // Draw cover image as background if available (inside the box) 243 - // Only load from SD on first render, then use stored buffer 244 - if (hasContinueReading && hasCoverImage && !coverBmpPath.empty() && !coverRendered) { 245 - // First time: load cover from SD and render 246 - FsFile file; 247 - if (SdMan.openFileForRead("HOME", coverBmpPath, file)) { 248 - Bitmap bitmap(file); 249 - if (bitmap.parseHeaders() == BmpReaderError::Ok) { 250 - // Calculate position to center image within the book card 251 - int coverX, coverY; 252 - 253 - if (bitmap.getWidth() > bookWidth || bitmap.getHeight() > bookHeight) { 254 - const float imgRatio = static_cast<float>(bitmap.getWidth()) / static_cast<float>(bitmap.getHeight()); 255 - const float boxRatio = static_cast<float>(bookWidth) / static_cast<float>(bookHeight); 256 - 257 - if (imgRatio > boxRatio) { 258 - coverX = bookX; 259 - coverY = bookY + (bookHeight - static_cast<int>(bookWidth / imgRatio)) / 2; 260 - } else { 261 - coverX = bookX + (bookWidth - static_cast<int>(bookHeight * imgRatio)) / 2; 262 - coverY = bookY; 263 - } 264 - } else { 265 - coverX = bookX + (bookWidth - bitmap.getWidth()) / 2; 266 - coverY = bookY + (bookHeight - bitmap.getHeight()) / 2; 267 - } 268 - 269 - // Draw the cover image centered within the book card 270 - renderer.drawBitmap(bitmap, coverX, coverY, bookWidth, bookHeight); 271 - 272 - // Draw border around the card 273 - renderer.drawRect(bookX, bookY, bookWidth, bookHeight); 274 - 275 - // No bookmark ribbon when cover is shown - it would just cover the art 276 - 277 - // Store the buffer with cover image for fast navigation 278 - coverBufferStored = storeCoverBuffer(); 279 - coverRendered = true; 280 - 281 - // First render: if selected, draw selection indicators now 282 - if (bookSelected) { 283 - renderer.drawRect(bookX + 1, bookY + 1, bookWidth - 2, bookHeight - 2); 284 - renderer.drawRect(bookX + 2, bookY + 2, bookWidth - 4, bookHeight - 4); 285 - } 286 - } 287 - file.close(); 288 - } 289 - } else if (!bufferRestored && !coverRendered) { 290 - // No cover image: draw border or fill, plus bookmark as visual flair 291 - if (bookSelected) { 292 - renderer.fillRect(bookX, bookY, bookWidth, bookHeight); 293 - } else { 294 - renderer.drawRect(bookX, bookY, bookWidth, bookHeight); 295 - } 249 + bool bufferRestored = coverBufferStored && restoreCoverBuffer(); 250 + if (!firstRenderDone || (recentsLoaded && !recentsDisplayed)) { 251 + renderer.clearScreen(); 252 + } 296 253 297 - // Draw bookmark ribbon when no cover image (visual decoration) 298 - if (hasContinueReading) { 299 - const int notchDepth = bookmarkHeight / 3; 300 - const int centerX = bookmarkX + bookmarkWidth / 2; 301 - 302 - const int xPoints[5] = { 303 - bookmarkX, // top-left 304 - bookmarkX + bookmarkWidth, // top-right 305 - bookmarkX + bookmarkWidth, // bottom-right 306 - centerX, // center notch point 307 - bookmarkX // bottom-left 308 - }; 309 - const int yPoints[5] = { 310 - bookmarkY, // top-left 311 - bookmarkY, // top-right 312 - bookmarkY + bookmarkHeight, // bottom-right 313 - bookmarkY + bookmarkHeight - notchDepth, // center notch point 314 - bookmarkY + bookmarkHeight // bottom-left 315 - }; 316 - 317 - // Draw bookmark ribbon (inverted if selected) 318 - renderer.fillPolygon(xPoints, yPoints, 5, !bookSelected); 319 - } 320 - } 321 - 322 - // If buffer was restored, draw selection indicators if needed 323 - if (bufferRestored && bookSelected && coverRendered) { 324 - // Draw selection border (no bookmark inversion needed since cover has no bookmark) 325 - renderer.drawRect(bookX + 1, bookY + 1, bookWidth - 2, bookHeight - 2); 326 - renderer.drawRect(bookX + 2, bookY + 2, bookWidth - 4, bookHeight - 4); 327 - } else if (!coverRendered && !bufferRestored) { 328 - // Selection border already handled above in the no-cover case 329 - } 330 - } 254 + GUI.drawHeader(renderer, Rect{0, metrics.topPadding, pageWidth, metrics.homeTopPadding}, nullptr); 331 255 332 256 if (hasContinueReading) { 333 - // Invert text colors based on selection state: 334 - // - With cover: selected = white text on black box, unselected = black text on white box 335 - // - Without cover: selected = white text on black card, unselected = black text on white card 336 - 337 - // Split into words (avoid stringstream to keep this light on the MCU) 338 - std::vector<std::string> words; 339 - words.reserve(8); 340 - size_t pos = 0; 341 - while (pos < lastBookTitle.size()) { 342 - while (pos < lastBookTitle.size() && lastBookTitle[pos] == ' ') { 343 - ++pos; 344 - } 345 - if (pos >= lastBookTitle.size()) { 346 - break; 347 - } 348 - const size_t start = pos; 349 - while (pos < lastBookTitle.size() && lastBookTitle[pos] != ' ') { 350 - ++pos; 351 - } 352 - words.emplace_back(lastBookTitle.substr(start, pos - start)); 353 - } 354 - 355 - std::vector<std::string> lines; 356 - std::string currentLine; 357 - // Extra padding inside the card so text doesn't hug the border 358 - const int maxLineWidth = bookWidth - 40; 359 - const int spaceWidth = renderer.getSpaceWidth(UI_12_FONT_ID); 360 - 361 - for (auto& i : words) { 362 - // If we just hit the line limit (3), stop processing words 363 - if (lines.size() >= 3) { 364 - // Limit to 3 lines 365 - // Still have words left, so add ellipsis to last line 366 - lines.back().append("..."); 367 - 368 - while (!lines.back().empty() && renderer.getTextWidth(UI_12_FONT_ID, lines.back().c_str()) > maxLineWidth) { 369 - // Remove "..." first, then remove one UTF-8 char, then add "..." back 370 - lines.back().resize(lines.back().size() - 3); // Remove "..." 371 - utf8RemoveLastChar(lines.back()); 372 - lines.back().append("..."); 373 - } 374 - break; 375 - } 376 - 377 - int wordWidth = renderer.getTextWidth(UI_12_FONT_ID, i.c_str()); 378 - while (wordWidth > maxLineWidth && !i.empty()) { 379 - // Word itself is too long, trim it (UTF-8 safe) 380 - utf8RemoveLastChar(i); 381 - // Check if we have room for ellipsis 382 - std::string withEllipsis = i + "..."; 383 - wordWidth = renderer.getTextWidth(UI_12_FONT_ID, withEllipsis.c_str()); 384 - if (wordWidth <= maxLineWidth) { 385 - i = withEllipsis; 386 - break; 387 - } 388 - } 389 - 390 - int newLineWidth = renderer.getTextWidth(UI_12_FONT_ID, currentLine.c_str()); 391 - if (newLineWidth > 0) { 392 - newLineWidth += spaceWidth; 393 - } 394 - newLineWidth += wordWidth; 395 - 396 - if (newLineWidth > maxLineWidth && !currentLine.empty()) { 397 - // New line too long, push old line 398 - lines.push_back(currentLine); 399 - currentLine = i; 400 - } else { 401 - currentLine.append(" ").append(i); 402 - } 403 - } 404 - 405 - // If lower than the line limit, push remaining words 406 - if (!currentLine.empty() && lines.size() < 3) { 407 - lines.push_back(currentLine); 408 - } 409 - 410 - // Book title text 411 - int totalTextHeight = renderer.getLineHeight(UI_12_FONT_ID) * static_cast<int>(lines.size()); 412 - if (!lastBookAuthor.empty()) { 413 - totalTextHeight += renderer.getLineHeight(UI_10_FONT_ID) * 3 / 2; 257 + if (recentsLoaded) { 258 + recentsDisplayed = true; 259 + GUI.drawRecentBookCover(renderer, Rect{0, metrics.homeTopPadding, pageWidth, metrics.homeCoverTileHeight}, 260 + recentBooks, selectorIndex, coverRendered, coverBufferStored, bufferRestored, 261 + std::bind(&HomeActivity::storeCoverBuffer, this)); 262 + } else if (!recentsLoading && firstRenderDone) { 263 + recentsLoading = true; 264 + loadRecentBooks(metrics.homeRecentBooksCount, metrics.homeCoverHeight); 414 265 } 415 - 416 - // Vertically center the title block within the card 417 - int titleYStart = bookY + (bookHeight - totalTextHeight) / 2; 418 - 419 - // If cover image was rendered, draw box behind title and author 420 - if (coverRendered) { 421 - constexpr int boxPadding = 8; 422 - // Calculate the max text width for the box 423 - int maxTextWidth = 0; 424 - for (const auto& line : lines) { 425 - const int lineWidth = renderer.getTextWidth(UI_12_FONT_ID, line.c_str()); 426 - if (lineWidth > maxTextWidth) { 427 - maxTextWidth = lineWidth; 428 - } 429 - } 430 - if (!lastBookAuthor.empty()) { 431 - std::string trimmedAuthor = lastBookAuthor; 432 - while (renderer.getTextWidth(UI_10_FONT_ID, trimmedAuthor.c_str()) > maxLineWidth && !trimmedAuthor.empty()) { 433 - utf8RemoveLastChar(trimmedAuthor); 434 - } 435 - if (renderer.getTextWidth(UI_10_FONT_ID, trimmedAuthor.c_str()) < 436 - renderer.getTextWidth(UI_10_FONT_ID, lastBookAuthor.c_str())) { 437 - trimmedAuthor.append("..."); 438 - } 439 - const int authorWidth = renderer.getTextWidth(UI_10_FONT_ID, trimmedAuthor.c_str()); 440 - if (authorWidth > maxTextWidth) { 441 - maxTextWidth = authorWidth; 442 - } 443 - } 444 - 445 - const int boxWidth = maxTextWidth + boxPadding * 2; 446 - const int boxHeight = totalTextHeight + boxPadding * 2; 447 - const int boxX = (pageWidth - boxWidth) / 2; 448 - const int boxY = titleYStart - boxPadding; 449 - 450 - // Draw box (inverted when selected: black box instead of white) 451 - renderer.fillRect(boxX, boxY, boxWidth, boxHeight, bookSelected); 452 - // Draw border around the box (inverted when selected: white border instead of black) 453 - renderer.drawRect(boxX, boxY, boxWidth, boxHeight, !bookSelected); 454 - } 455 - 456 - for (const auto& line : lines) { 457 - renderer.drawCenteredText(UI_12_FONT_ID, titleYStart, line.c_str(), !bookSelected); 458 - titleYStart += renderer.getLineHeight(UI_12_FONT_ID); 459 - } 460 - 461 - if (!lastBookAuthor.empty()) { 462 - titleYStart += renderer.getLineHeight(UI_10_FONT_ID) / 2; 463 - std::string trimmedAuthor = lastBookAuthor; 464 - // Trim author if too long (UTF-8 safe) 465 - bool wasTrimmed = false; 466 - while (renderer.getTextWidth(UI_10_FONT_ID, trimmedAuthor.c_str()) > maxLineWidth && !trimmedAuthor.empty()) { 467 - utf8RemoveLastChar(trimmedAuthor); 468 - wasTrimmed = true; 469 - } 470 - if (wasTrimmed && !trimmedAuthor.empty()) { 471 - // Make room for ellipsis 472 - while (renderer.getTextWidth(UI_10_FONT_ID, (trimmedAuthor + "...").c_str()) > maxLineWidth && 473 - !trimmedAuthor.empty()) { 474 - utf8RemoveLastChar(trimmedAuthor); 475 - } 476 - trimmedAuthor.append("..."); 477 - } 478 - renderer.drawCenteredText(UI_10_FONT_ID, titleYStart, trimmedAuthor.c_str(), !bookSelected); 479 - } 480 - 481 - // "Continue Reading" label at the bottom 482 - const int continueY = bookY + bookHeight - renderer.getLineHeight(UI_10_FONT_ID) * 3 / 2; 483 - if (coverRendered) { 484 - // Draw box behind "Continue Reading" text (inverted when selected: black box instead of white) 485 - const char* continueText = "Continue Reading"; 486 - const int continueTextWidth = renderer.getTextWidth(UI_10_FONT_ID, continueText); 487 - constexpr int continuePadding = 6; 488 - const int continueBoxWidth = continueTextWidth + continuePadding * 2; 489 - const int continueBoxHeight = renderer.getLineHeight(UI_10_FONT_ID) + continuePadding; 490 - const int continueBoxX = (pageWidth - continueBoxWidth) / 2; 491 - const int continueBoxY = continueY - continuePadding / 2; 492 - renderer.fillRect(continueBoxX, continueBoxY, continueBoxWidth, continueBoxHeight, bookSelected); 493 - renderer.drawRect(continueBoxX, continueBoxY, continueBoxWidth, continueBoxHeight, !bookSelected); 494 - renderer.drawCenteredText(UI_10_FONT_ID, continueY, continueText, !bookSelected); 495 - } else { 496 - renderer.drawCenteredText(UI_10_FONT_ID, continueY, "Continue Reading", !bookSelected); 497 - } 498 - } else { 499 - // No book to continue reading 500 - const int y = 501 - bookY + (bookHeight - renderer.getLineHeight(UI_12_FONT_ID) - renderer.getLineHeight(UI_10_FONT_ID)) / 2; 502 - renderer.drawCenteredText(UI_12_FONT_ID, y, "No open book"); 503 - renderer.drawCenteredText(UI_10_FONT_ID, y + renderer.getLineHeight(UI_12_FONT_ID), "Start reading below"); 504 266 } 505 267 506 - // --- Bottom menu tiles --- 507 268 // Build menu items dynamically 508 - std::vector<const char*> menuItems = {"My Library", "File Transfer", "Settings"}; 269 + std::vector<const char*> menuItems = {"Browse Files", "Recents", "File Transfer", "Settings"}; 509 270 if (hasOpdsUrl) { 510 271 // Insert OPDS Browser after My Library 511 - menuItems.insert(menuItems.begin() + 1, "OPDS Browser"); 272 + menuItems.insert(menuItems.begin() + 2, "OPDS Browser"); 512 273 } 513 274 514 - const int menuTileWidth = pageWidth - 2 * margin; 515 - constexpr int menuTileHeight = 45; 516 - constexpr int menuSpacing = 8; 517 - const int totalMenuHeight = 518 - static_cast<int>(menuItems.size()) * menuTileHeight + (static_cast<int>(menuItems.size()) - 1) * menuSpacing; 275 + GUI.drawButtonMenu( 276 + renderer, 277 + Rect{0, metrics.homeTopPadding + metrics.homeCoverTileHeight + metrics.verticalSpacing, pageWidth, 278 + pageHeight - (metrics.headerHeight + metrics.homeTopPadding + metrics.verticalSpacing * 2 + 279 + metrics.buttonHintsHeight)}, 280 + static_cast<int>(menuItems.size()), selectorIndex - recentBooks.size(), 281 + [&menuItems](int index) { return std::string(menuItems[index]); }, nullptr); 519 282 520 - int menuStartY = bookY + bookHeight + 15; 521 - // Ensure we don't collide with the bottom button legend 522 - const int maxMenuStartY = pageHeight - bottomMargin - totalMenuHeight - margin; 523 - if (menuStartY > maxMenuStartY) { 524 - menuStartY = maxMenuStartY; 525 - } 283 + const auto labels = mappedInput.mapLabels("", "Select", "Up", "Down"); 284 + GUI.drawButtonHints(renderer, labels.btn1, labels.btn2, labels.btn3, labels.btn4); 526 285 527 - for (size_t i = 0; i < menuItems.size(); ++i) { 528 - const int overallIndex = static_cast<int>(i) + (hasContinueReading ? 1 : 0); 529 - constexpr int tileX = margin; 530 - const int tileY = menuStartY + static_cast<int>(i) * (menuTileHeight + menuSpacing); 531 - const bool selected = selectorIndex == overallIndex; 286 + renderer.displayBuffer(); 532 287 533 - if (selected) { 534 - renderer.fillRect(tileX, tileY, menuTileWidth, menuTileHeight); 535 - } else { 536 - renderer.drawRect(tileX, tileY, menuTileWidth, menuTileHeight); 537 - } 538 - 539 - const char* label = menuItems[i]; 540 - const int textWidth = renderer.getTextWidth(UI_10_FONT_ID, label); 541 - const int textX = tileX + (menuTileWidth - textWidth) / 2; 542 - const int lineHeight = renderer.getLineHeight(UI_10_FONT_ID); 543 - const int textY = tileY + (menuTileHeight - lineHeight) / 2; // vertically centered assuming y is top of text 544 - 545 - // Invert text when the tile is selected, to contrast with the filled background 546 - renderer.drawText(UI_10_FONT_ID, textX, textY, label, !selected); 288 + if (!firstRenderDone) { 289 + firstRenderDone = true; 290 + updateRequired = true; 547 291 } 548 - 549 - const auto labels = mappedInput.mapLabels("", "Select", "Up", "Down"); 550 - renderer.drawButtonHints(UI_10_FONT_ID, labels.btn1, labels.btn2, labels.btn3, labels.btn4); 551 - 552 - const bool showBatteryPercentage = 553 - SETTINGS.hideBatteryPercentage != CrossPointSettings::HIDE_BATTERY_PERCENTAGE::HIDE_ALWAYS; 554 - // get percentage so we can align text properly 555 - const uint16_t percentage = battery.readPercentage(); 556 - const auto percentageText = showBatteryPercentage ? std::to_string(percentage) + "%" : ""; 557 - const auto batteryX = pageWidth - 25 - renderer.getTextWidth(SMALL_FONT_ID, percentageText.c_str()); 558 - ScreenComponents::drawBattery(renderer, batteryX, 10, showBatteryPercentage); 559 - 560 - renderer.displayBuffer(); 561 292 }
+17 -7
src/activities/home/HomeActivity.h
··· 4 4 #include <freertos/task.h> 5 5 6 6 #include <functional> 7 + #include <vector> 7 8 8 9 #include "../Activity.h" 10 + #include "./MyLibraryActivity.h" 11 + 12 + struct RecentBook; 13 + struct Rect; 9 14 10 15 class HomeActivity final : public Activity { 11 16 TaskHandle_t displayTaskHandle = nullptr; ··· 13 18 int selectorIndex = 0; 14 19 bool updateRequired = false; 15 20 bool hasContinueReading = false; 21 + bool recentsLoading = false; 22 + bool recentsLoaded = false; 23 + bool recentsDisplayed = false; 24 + bool firstRenderDone = false; 16 25 bool hasOpdsUrl = false; 17 - bool hasCoverImage = false; 18 26 bool coverRendered = false; // Track if cover has been rendered once 19 27 bool coverBufferStored = false; // Track if cover buffer is stored 20 28 uint8_t* coverBuffer = nullptr; // HomeActivity's own buffer for cover image 21 - std::string lastBookTitle; 22 - std::string lastBookAuthor; 23 - std::string coverBmpPath; 24 - const std::function<void()> onContinueReading; 29 + std::vector<RecentBook> recentBooks; 30 + const std::function<void(const std::string& path)> onSelectBook; 25 31 const std::function<void()> onMyLibraryOpen; 32 + const std::function<void()> onRecentsOpen; 26 33 const std::function<void()> onSettingsOpen; 27 34 const std::function<void()> onFileTransferOpen; 28 35 const std::function<void()> onOpdsBrowserOpen; ··· 34 41 bool storeCoverBuffer(); // Store frame buffer for cover image 35 42 bool restoreCoverBuffer(); // Restore frame buffer from stored cover 36 43 void freeCoverBuffer(); // Free the stored cover buffer 44 + void loadRecentBooks(int maxBooks, int coverHeight); 37 45 38 46 public: 39 47 explicit HomeActivity(GfxRenderer& renderer, MappedInputManager& mappedInput, 40 - const std::function<void()>& onContinueReading, const std::function<void()>& onMyLibraryOpen, 48 + const std::function<void(const std::string& path)>& onSelectBook, 49 + const std::function<void()>& onMyLibraryOpen, const std::function<void()>& onRecentsOpen, 41 50 const std::function<void()>& onSettingsOpen, const std::function<void()>& onFileTransferOpen, 42 51 const std::function<void()>& onOpdsBrowserOpen) 43 52 : Activity("Home", renderer, mappedInput), 44 - onContinueReading(onContinueReading), 53 + onSelectBook(onSelectBook), 45 54 onMyLibraryOpen(onMyLibraryOpen), 55 + onRecentsOpen(onRecentsOpen), 46 56 onSettingsOpen(onSettingsOpen), 47 57 onFileTransferOpen(onFileTransferOpen), 48 58 onOpdsBrowserOpen(onOpdsBrowserOpen) {}
+63 -228
src/activities/home/MyLibraryActivity.cpp
··· 3 3 #include <GfxRenderer.h> 4 4 #include <SDCardManager.h> 5 5 6 - #include <algorithm> 7 - 8 6 #include "MappedInputManager.h" 9 - #include "RecentBooksStore.h" 10 - #include "ScreenComponents.h" 7 + #include "components/UITheme.h" 11 8 #include "fontIds.h" 12 9 #include "util/StringUtils.h" 13 10 14 11 namespace { 15 - // Layout constants 16 - constexpr int TAB_BAR_Y = 15; 17 - constexpr int CONTENT_START_Y = 60; 18 - constexpr int LINE_HEIGHT = 30; 19 - constexpr int RECENTS_LINE_HEIGHT = 65; // Increased for two-line items 20 - constexpr int LEFT_MARGIN = 20; 21 - constexpr int RIGHT_MARGIN = 40; // Extra space for scroll indicator 22 - 23 - // Timing thresholds 24 12 constexpr int SKIP_PAGE_MS = 700; 25 13 constexpr unsigned long GO_HOME_MS = 1000; 14 + } // namespace 26 15 27 16 void sortFileList(std::vector<std::string>& strs) { 28 17 std::sort(begin(strs), end(strs), [](const std::string& str1, const std::string& str2) { ··· 33 22 [](const char& char1, const char& char2) { return tolower(char1) < tolower(char2); }); 34 23 }); 35 24 } 36 - } // namespace 37 25 38 - int MyLibraryActivity::getPageItems() const { 39 - const int screenHeight = renderer.getScreenHeight(); 40 - const int bottomBarHeight = 60; // Space for button hints 41 - const int availableHeight = screenHeight - CONTENT_START_Y - bottomBarHeight; 42 - int items = availableHeight / LINE_HEIGHT; 43 - if (items < 1) { 44 - items = 1; 45 - } 46 - return items; 47 - } 48 - 49 - int MyLibraryActivity::getCurrentItemCount() const { 50 - if (currentTab == Tab::Recent) { 51 - return static_cast<int>(recentBooks.size()); 52 - } 53 - return static_cast<int>(files.size()); 54 - } 55 - 56 - int MyLibraryActivity::getTotalPages() const { 57 - const int itemCount = getCurrentItemCount(); 58 - const int pageItems = getPageItems(); 59 - if (itemCount == 0) return 1; 60 - return (itemCount + pageItems - 1) / pageItems; 61 - } 62 - 63 - int MyLibraryActivity::getCurrentPage() const { 64 - const int pageItems = getPageItems(); 65 - return selectorIndex / pageItems + 1; 66 - } 67 - 68 - void MyLibraryActivity::loadRecentBooks() { 69 - recentBooks.clear(); 70 - const auto& books = RECENT_BOOKS.getBooks(); 71 - recentBooks.reserve(books.size()); 72 - 73 - for (const auto& book : books) { 74 - // Skip if file no longer exists 75 - if (!SdMan.exists(book.path.c_str())) { 76 - continue; 77 - } 78 - recentBooks.push_back(book); 79 - } 26 + void MyLibraryActivity::taskTrampoline(void* param) { 27 + auto* self = static_cast<MyLibraryActivity*>(param); 28 + self->displayTaskLoop(); 80 29 } 81 30 82 31 void MyLibraryActivity::loadFiles() { ··· 112 61 } 113 62 root.close(); 114 63 sortFileList(files); 115 - } 116 - 117 - size_t MyLibraryActivity::findEntry(const std::string& name) const { 118 - for (size_t i = 0; i < files.size(); i++) { 119 - if (files[i] == name) return i; 120 - } 121 - return 0; 122 - } 123 - 124 - void MyLibraryActivity::taskTrampoline(void* param) { 125 - auto* self = static_cast<MyLibraryActivity*>(param); 126 - self->displayTaskLoop(); 127 64 } 128 65 129 66 void MyLibraryActivity::onEnter() { ··· 131 68 132 69 renderingMutex = xSemaphoreCreateMutex(); 133 70 134 - // Load data for both tabs 135 - loadRecentBooks(); 136 71 loadFiles(); 137 72 138 73 selectorIndex = 0; 139 74 updateRequired = true; 140 75 141 76 xTaskCreate(&MyLibraryActivity::taskTrampoline, "MyLibraryActivityTask", 142 - 4096, // Stack size (increased for epub metadata loading) 77 + 4096, // Stack size 143 78 this, // Parameters 144 79 1, // Priority 145 80 &displayTaskHandle // Task handle ··· 149 84 void MyLibraryActivity::onExit() { 150 85 Activity::onExit(); 151 86 152 - // Wait until not rendering to delete task to avoid killing mid-instruction to 153 - // EPD 87 + // Wait until not rendering to delete task to avoid killing mid-instruction to EPD 154 88 xSemaphoreTake(renderingMutex, portMAX_DELAY); 155 89 if (displayTaskHandle) { 156 90 vTaskDelete(displayTaskHandle); ··· 163 97 } 164 98 165 99 void MyLibraryActivity::loop() { 166 - const int itemCount = getCurrentItemCount(); 167 - const int pageItems = getPageItems(); 168 - 169 - // Long press BACK (1s+) in Files tab goes to root folder 170 - if (currentTab == Tab::Files && mappedInput.isPressed(MappedInputManager::Button::Back) && 171 - mappedInput.getHeldTime() >= GO_HOME_MS) { 172 - if (basepath != "/") { 173 - basepath = "/"; 174 - loadFiles(); 175 - selectorIndex = 0; 176 - updateRequired = true; 177 - } 100 + // Long press BACK (1s+) goes to root folder 101 + if (mappedInput.isPressed(MappedInputManager::Button::Back) && mappedInput.getHeldTime() >= GO_HOME_MS && 102 + basepath != "/") { 103 + basepath = "/"; 104 + loadFiles(); 105 + selectorIndex = 0; 106 + updateRequired = true; 178 107 return; 179 108 } 180 109 181 - const bool upReleased = mappedInput.wasReleased(MappedInputManager::Button::Up); 182 - const bool downReleased = mappedInput.wasReleased(MappedInputManager::Button::Down); 183 - const bool leftReleased = mappedInput.wasReleased(MappedInputManager::Button::Left); 184 - const bool rightReleased = mappedInput.wasReleased(MappedInputManager::Button::Right); 110 + const bool upReleased = mappedInput.wasReleased(MappedInputManager::Button::Left) || 111 + mappedInput.wasReleased(MappedInputManager::Button::Up); 112 + ; 113 + const bool downReleased = mappedInput.wasReleased(MappedInputManager::Button::Right) || 114 + mappedInput.wasReleased(MappedInputManager::Button::Down); 185 115 186 116 const bool skipPage = mappedInput.getHeldTime() > SKIP_PAGE_MS; 117 + const int pageItems = UITheme::getInstance().getNumberOfItemsPerPage(renderer, true, false, true, true); 187 118 188 - // Confirm button - open selected item 189 119 if (mappedInput.wasReleased(MappedInputManager::Button::Confirm)) { 190 - if (currentTab == Tab::Recent) { 191 - if (!recentBooks.empty() && selectorIndex < static_cast<int>(recentBooks.size())) { 192 - onSelectBook(recentBooks[selectorIndex].path, currentTab); 193 - } 120 + if (files.empty()) { 121 + return; 122 + } 123 + 124 + if (basepath.back() != '/') basepath += "/"; 125 + if (files[selectorIndex].back() == '/') { 126 + basepath += files[selectorIndex].substr(0, files[selectorIndex].length() - 1); 127 + loadFiles(); 128 + selectorIndex = 0; 129 + updateRequired = true; 194 130 } else { 195 - // Files tab 196 - if (!files.empty() && selectorIndex < static_cast<int>(files.size())) { 197 - if (basepath.back() != '/') basepath += "/"; 198 - if (files[selectorIndex].back() == '/') { 199 - // Enter directory 200 - basepath += files[selectorIndex].substr(0, files[selectorIndex].length() - 1); 201 - loadFiles(); 202 - selectorIndex = 0; 203 - updateRequired = true; 204 - } else { 205 - // Open file 206 - onSelectBook(basepath + files[selectorIndex], currentTab); 207 - } 208 - } 131 + onSelectBook(basepath + files[selectorIndex]); 132 + return; 209 133 } 210 - return; 211 134 } 212 135 213 - // Back button 214 136 if (mappedInput.wasReleased(MappedInputManager::Button::Back)) { 137 + // Short press: go up one directory, or go home if at root 215 138 if (mappedInput.getHeldTime() < GO_HOME_MS) { 216 - if (currentTab == Tab::Files && basepath != "/") { 217 - // Go up one directory, remembering the directory we came from 139 + if (basepath != "/") { 218 140 const std::string oldPath = basepath; 141 + 219 142 basepath.replace(basepath.find_last_of('/'), std::string::npos, ""); 220 143 if (basepath.empty()) basepath = "/"; 221 144 loadFiles(); 222 145 223 - // Select the directory we just came from 224 146 const auto pos = oldPath.find_last_of('/'); 225 147 const std::string dirName = oldPath.substr(pos + 1) + "/"; 226 - selectorIndex = static_cast<int>(findEntry(dirName)); 148 + selectorIndex = findEntry(dirName); 227 149 228 150 updateRequired = true; 229 151 } else { 230 - // Go home 231 152 onGoHome(); 232 153 } 233 154 } 234 - return; 235 155 } 236 156 237 - // Tab switching: Left/Right always control tabs 238 - if (leftReleased && currentTab == Tab::Files) { 239 - currentTab = Tab::Recent; 240 - selectorIndex = 0; 241 - updateRequired = true; 242 - return; 243 - } 244 - if (rightReleased && currentTab == Tab::Recent) { 245 - currentTab = Tab::Files; 246 - selectorIndex = 0; 247 - updateRequired = true; 248 - return; 249 - } 250 - 251 - // Navigation: Up/Down moves through items only 252 - const bool prevReleased = upReleased; 253 - const bool nextReleased = downReleased; 254 - 255 - if (prevReleased && itemCount > 0) { 157 + int listSize = static_cast<int>(files.size()); 158 + if (upReleased) { 256 159 if (skipPage) { 257 - selectorIndex = ((selectorIndex / pageItems - 1) * pageItems + itemCount) % itemCount; 160 + selectorIndex = ((selectorIndex / pageItems - 1) * pageItems + listSize) % listSize; 258 161 } else { 259 - selectorIndex = (selectorIndex + itemCount - 1) % itemCount; 162 + selectorIndex = (selectorIndex + listSize - 1) % listSize; 260 163 } 261 164 updateRequired = true; 262 - } else if (nextReleased && itemCount > 0) { 165 + } else if (downReleased) { 263 166 if (skipPage) { 264 - selectorIndex = ((selectorIndex / pageItems + 1) * pageItems) % itemCount; 167 + selectorIndex = ((selectorIndex / pageItems + 1) * pageItems) % listSize; 265 168 } else { 266 - selectorIndex = (selectorIndex + 1) % itemCount; 169 + selectorIndex = (selectorIndex + 1) % listSize; 267 170 } 268 171 updateRequired = true; 269 172 } ··· 284 187 void MyLibraryActivity::render() const { 285 188 renderer.clearScreen(); 286 189 287 - // Draw tab bar 288 - std::vector<TabInfo> tabs = {{"Recent", currentTab == Tab::Recent}, {"Files", currentTab == Tab::Files}}; 289 - ScreenComponents::drawTabBar(renderer, TAB_BAR_Y, tabs); 190 + const auto pageWidth = renderer.getScreenWidth(); 191 + const auto pageHeight = renderer.getScreenHeight(); 192 + auto metrics = UITheme::getInstance().getMetrics(); 290 193 291 - // Draw content based on current tab 292 - if (currentTab == Tab::Recent) { 293 - renderRecentTab(); 194 + auto folderName = basepath == "/" ? "SD card" : basepath.substr(basepath.rfind('/') + 1).c_str(); 195 + GUI.drawHeader(renderer, Rect{0, metrics.topPadding, pageWidth, metrics.headerHeight}, folderName); 196 + 197 + const int contentTop = metrics.topPadding + metrics.headerHeight + metrics.verticalSpacing; 198 + const int contentHeight = pageHeight - contentTop - metrics.buttonHintsHeight - metrics.verticalSpacing * 2; 199 + if (files.empty()) { 200 + renderer.drawText(UI_10_FONT_ID, metrics.contentSidePadding, contentTop + 20, "No books found"); 294 201 } else { 295 - renderFilesTab(); 202 + GUI.drawList( 203 + renderer, Rect{0, contentTop, pageWidth, contentHeight}, files.size(), selectorIndex, 204 + [this](int index) { return files[index]; }, nullptr, nullptr, nullptr); 296 205 } 297 206 298 - // Draw scroll indicator 299 - const int screenHeight = renderer.getScreenHeight(); 300 - const int contentHeight = screenHeight - CONTENT_START_Y - 60; // 60 for bottom bar 301 - ScreenComponents::drawScrollIndicator(renderer, getCurrentPage(), getTotalPages(), CONTENT_START_Y, contentHeight); 302 - 303 - // Draw side button hints (up/down navigation on right side) 304 - // Note: text is rotated 90° CW, so ">" appears as "^" and "<" appears as "v" 305 - renderer.drawSideButtonHints(UI_10_FONT_ID, ">", "<"); 306 - 307 - // Draw bottom button hints 308 - const auto labels = mappedInput.mapLabels("« Back", "Open", "<", ">"); 309 - renderer.drawButtonHints(UI_10_FONT_ID, labels.btn1, labels.btn2, labels.btn3, labels.btn4); 207 + // Help text 208 + const auto labels = mappedInput.mapLabels("« Home", "Open", "Up", "Down"); 209 + GUI.drawButtonHints(renderer, labels.btn1, labels.btn2, labels.btn3, labels.btn4); 310 210 311 211 renderer.displayBuffer(); 312 212 } 313 213 314 - void MyLibraryActivity::renderRecentTab() const { 315 - const auto pageWidth = renderer.getScreenWidth(); 316 - const int pageItems = getPageItems(); 317 - const int bookCount = static_cast<int>(recentBooks.size()); 318 - 319 - if (bookCount == 0) { 320 - renderer.drawText(UI_10_FONT_ID, LEFT_MARGIN, CONTENT_START_Y, "No recent books"); 321 - return; 322 - } 323 - 324 - const auto pageStartIndex = selectorIndex / pageItems * pageItems; 325 - 326 - // Draw selection highlight 327 - renderer.fillRect(0, CONTENT_START_Y + (selectorIndex % pageItems) * RECENTS_LINE_HEIGHT - 2, 328 - pageWidth - RIGHT_MARGIN, RECENTS_LINE_HEIGHT); 329 - 330 - // Draw items 331 - for (int i = pageStartIndex; i < bookCount && i < pageStartIndex + pageItems; i++) { 332 - const auto& book = recentBooks[i]; 333 - const int y = CONTENT_START_Y + (i % pageItems) * RECENTS_LINE_HEIGHT; 334 - 335 - // Line 1: Title 336 - std::string title = book.title; 337 - if (title.empty()) { 338 - // Fallback for older entries or files without metadata 339 - title = book.path; 340 - const size_t lastSlash = title.find_last_of('/'); 341 - if (lastSlash != std::string::npos) { 342 - title = title.substr(lastSlash + 1); 343 - } 344 - const size_t dot = title.find_last_of('.'); 345 - if (dot != std::string::npos) { 346 - title.resize(dot); 347 - } 348 - } 349 - auto truncatedTitle = renderer.truncatedText(UI_12_FONT_ID, title.c_str(), pageWidth - LEFT_MARGIN - RIGHT_MARGIN); 350 - renderer.drawText(UI_12_FONT_ID, LEFT_MARGIN, y + 2, truncatedTitle.c_str(), i != selectorIndex); 351 - 352 - // Line 2: Author 353 - if (!book.author.empty()) { 354 - auto truncatedAuthor = 355 - renderer.truncatedText(UI_10_FONT_ID, book.author.c_str(), pageWidth - LEFT_MARGIN - RIGHT_MARGIN); 356 - renderer.drawText(UI_10_FONT_ID, LEFT_MARGIN, y + 32, truncatedAuthor.c_str(), i != selectorIndex); 357 - } 358 - } 359 - } 360 - 361 - void MyLibraryActivity::renderFilesTab() const { 362 - const auto pageWidth = renderer.getScreenWidth(); 363 - const int pageItems = getPageItems(); 364 - const int fileCount = static_cast<int>(files.size()); 365 - 366 - if (fileCount == 0) { 367 - renderer.drawText(UI_10_FONT_ID, LEFT_MARGIN, CONTENT_START_Y, "No books found"); 368 - return; 369 - } 370 - 371 - const auto pageStartIndex = selectorIndex / pageItems * pageItems; 372 - 373 - // Draw selection highlight 374 - renderer.fillRect(0, CONTENT_START_Y + (selectorIndex % pageItems) * LINE_HEIGHT - 2, pageWidth - RIGHT_MARGIN, 375 - LINE_HEIGHT); 376 - 377 - // Draw items 378 - for (int i = pageStartIndex; i < fileCount && i < pageStartIndex + pageItems; i++) { 379 - auto item = renderer.truncatedText(UI_10_FONT_ID, files[i].c_str(), pageWidth - LEFT_MARGIN - RIGHT_MARGIN); 380 - renderer.drawText(UI_10_FONT_ID, LEFT_MARGIN, CONTENT_START_Y + (i % pageItems) * LINE_HEIGHT, item.c_str(), 381 - i != selectorIndex); 382 - } 214 + size_t MyLibraryActivity::findEntry(const std::string& name) const { 215 + for (size_t i = 0; i < files.size(); i++) 216 + if (files[i] == name) return i; 217 + return 0; 383 218 }
+10 -29
src/activities/home/MyLibraryActivity.h
··· 8 8 #include <vector> 9 9 10 10 #include "../Activity.h" 11 - #include "RecentBooksStore.h" 12 11 13 12 class MyLibraryActivity final : public Activity { 14 - public: 15 - enum class Tab { Recent, Files }; 16 - 17 13 private: 18 14 TaskHandle_t displayTaskHandle = nullptr; 19 15 SemaphoreHandle_t renderingMutex = nullptr; 20 16 21 - Tab currentTab = Tab::Recent; 22 - int selectorIndex = 0; 17 + size_t selectorIndex = 0; 23 18 bool updateRequired = false; 24 19 25 - // Recent tab state 26 - std::vector<RecentBook> recentBooks; 27 - 28 - // Files tab state (from FileSelectionActivity) 20 + // Files state 29 21 std::string basepath = "/"; 30 22 std::vector<std::string> files; 31 23 32 24 // Callbacks 25 + const std::function<void(const std::string& path)> onSelectBook; 33 26 const std::function<void()> onGoHome; 34 - const std::function<void(const std::string& path, Tab fromTab)> onSelectBook; 35 27 36 - // Number of items that fit on a page 37 - int getPageItems() const; 38 - int getCurrentItemCount() const; 39 - int getTotalPages() const; 40 - int getCurrentPage() const; 28 + static void taskTrampoline(void* param); 29 + [[noreturn]] void displayTaskLoop(); 30 + void render() const; 41 31 42 32 // Data loading 43 - void loadRecentBooks(); 44 33 void loadFiles(); 45 34 size_t findEntry(const std::string& name) const; 46 - 47 - // Rendering 48 - static void taskTrampoline(void* param); 49 - [[noreturn]] void displayTaskLoop(); 50 - void render() const; 51 - void renderRecentTab() const; 52 - void renderFilesTab() const; 53 35 54 36 public: 55 37 explicit MyLibraryActivity(GfxRenderer& renderer, MappedInputManager& mappedInput, 56 38 const std::function<void()>& onGoHome, 57 - const std::function<void(const std::string& path, Tab fromTab)>& onSelectBook, 58 - Tab initialTab = Tab::Recent, std::string initialPath = "/") 39 + const std::function<void(const std::string& path)>& onSelectBook, 40 + std::string initialPath = "/") 59 41 : Activity("MyLibrary", renderer, mappedInput), 60 - currentTab(initialTab), 61 42 basepath(initialPath.empty() ? "/" : std::move(initialPath)), 62 - onGoHome(onGoHome), 63 - onSelectBook(onSelectBook) {} 43 + onSelectBook(onSelectBook), 44 + onGoHome(onGoHome) {} 64 45 void onEnter() override; 65 46 void onExit() override; 66 47 void loop() override;
+149
src/activities/home/RecentBooksActivity.cpp
··· 1 + #include "RecentBooksActivity.h" 2 + 3 + #include <GfxRenderer.h> 4 + #include <SDCardManager.h> 5 + 6 + #include "MappedInputManager.h" 7 + #include "RecentBooksStore.h" 8 + #include "components/UITheme.h" 9 + #include "fontIds.h" 10 + #include "util/StringUtils.h" 11 + 12 + namespace { 13 + constexpr int SKIP_PAGE_MS = 700; 14 + constexpr unsigned long GO_HOME_MS = 1000; 15 + } // namespace 16 + 17 + void RecentBooksActivity::taskTrampoline(void* param) { 18 + auto* self = static_cast<RecentBooksActivity*>(param); 19 + self->displayTaskLoop(); 20 + } 21 + 22 + void RecentBooksActivity::loadRecentBooks() { 23 + recentBooks.clear(); 24 + const auto& books = RECENT_BOOKS.getBooks(); 25 + recentBooks.reserve(books.size()); 26 + 27 + for (const auto& book : books) { 28 + // Skip if file no longer exists 29 + if (!SdMan.exists(book.path.c_str())) { 30 + continue; 31 + } 32 + recentBooks.push_back(book); 33 + } 34 + } 35 + 36 + void RecentBooksActivity::onEnter() { 37 + Activity::onEnter(); 38 + 39 + renderingMutex = xSemaphoreCreateMutex(); 40 + 41 + // Load data 42 + loadRecentBooks(); 43 + 44 + selectorIndex = 0; 45 + updateRequired = true; 46 + 47 + xTaskCreate(&RecentBooksActivity::taskTrampoline, "RecentBooksActivityTask", 48 + 4096, // Stack size 49 + this, // Parameters 50 + 1, // Priority 51 + &displayTaskHandle // Task handle 52 + ); 53 + } 54 + 55 + void RecentBooksActivity::onExit() { 56 + Activity::onExit(); 57 + 58 + // Wait until not rendering to delete task to avoid killing mid-instruction to EPD 59 + xSemaphoreTake(renderingMutex, portMAX_DELAY); 60 + if (displayTaskHandle) { 61 + vTaskDelete(displayTaskHandle); 62 + displayTaskHandle = nullptr; 63 + } 64 + vSemaphoreDelete(renderingMutex); 65 + renderingMutex = nullptr; 66 + 67 + recentBooks.clear(); 68 + } 69 + 70 + void RecentBooksActivity::loop() { 71 + const bool upReleased = mappedInput.wasReleased(MappedInputManager::Button::Left) || 72 + mappedInput.wasReleased(MappedInputManager::Button::Up); 73 + ; 74 + const bool downReleased = mappedInput.wasReleased(MappedInputManager::Button::Right) || 75 + mappedInput.wasReleased(MappedInputManager::Button::Down); 76 + 77 + const bool skipPage = mappedInput.getHeldTime() > SKIP_PAGE_MS; 78 + const int pageItems = UITheme::getInstance().getNumberOfItemsPerPage(renderer, true, false, true, true); 79 + 80 + if (mappedInput.wasReleased(MappedInputManager::Button::Confirm)) { 81 + if (!recentBooks.empty() && selectorIndex < static_cast<int>(recentBooks.size())) { 82 + Serial.printf("Selected recent book: %s\n", recentBooks[selectorIndex].path.c_str()); 83 + onSelectBook(recentBooks[selectorIndex].path); 84 + return; 85 + } 86 + } 87 + 88 + if (mappedInput.wasReleased(MappedInputManager::Button::Back)) { 89 + onGoHome(); 90 + } 91 + 92 + int listSize = static_cast<int>(recentBooks.size()); 93 + if (upReleased) { 94 + if (skipPage) { 95 + selectorIndex = ((selectorIndex / pageItems - 1) * pageItems + listSize) % listSize; 96 + } else { 97 + selectorIndex = (selectorIndex + listSize - 1) % listSize; 98 + } 99 + updateRequired = true; 100 + } else if (downReleased) { 101 + if (skipPage) { 102 + selectorIndex = ((selectorIndex / pageItems + 1) * pageItems) % listSize; 103 + } else { 104 + selectorIndex = (selectorIndex + 1) % listSize; 105 + } 106 + updateRequired = true; 107 + } 108 + } 109 + 110 + void RecentBooksActivity::displayTaskLoop() { 111 + while (true) { 112 + if (updateRequired) { 113 + updateRequired = false; 114 + xSemaphoreTake(renderingMutex, portMAX_DELAY); 115 + render(); 116 + xSemaphoreGive(renderingMutex); 117 + } 118 + vTaskDelay(10 / portTICK_PERIOD_MS); 119 + } 120 + } 121 + 122 + void RecentBooksActivity::render() const { 123 + renderer.clearScreen(); 124 + 125 + const auto pageWidth = renderer.getScreenWidth(); 126 + const auto pageHeight = renderer.getScreenHeight(); 127 + auto metrics = UITheme::getInstance().getMetrics(); 128 + 129 + GUI.drawHeader(renderer, Rect{0, metrics.topPadding, pageWidth, metrics.headerHeight}, "Recent Books"); 130 + 131 + const int contentTop = metrics.topPadding + metrics.headerHeight + metrics.verticalSpacing; 132 + const int contentHeight = pageHeight - contentTop - metrics.buttonHintsHeight - metrics.verticalSpacing * 2; 133 + 134 + // Recent tab 135 + if (recentBooks.empty()) { 136 + renderer.drawText(UI_10_FONT_ID, metrics.contentSidePadding, contentTop + 20, "No recent books"); 137 + } else { 138 + GUI.drawList( 139 + renderer, Rect{0, contentTop, pageWidth, contentHeight}, recentBooks.size(), selectorIndex, 140 + [this](int index) { return recentBooks[index].title; }, [this](int index) { return recentBooks[index].author; }, 141 + nullptr, nullptr); 142 + } 143 + 144 + // Help text 145 + const auto labels = mappedInput.mapLabels("« Home", "Open", "Up", "Down"); 146 + GUI.drawButtonHints(renderer, labels.btn1, labels.btn2, labels.btn3, labels.btn4); 147 + 148 + renderer.displayBuffer(); 149 + }
+43
src/activities/home/RecentBooksActivity.h
··· 1 + #pragma once 2 + #include <freertos/FreeRTOS.h> 3 + #include <freertos/semphr.h> 4 + #include <freertos/task.h> 5 + 6 + #include <functional> 7 + #include <string> 8 + #include <vector> 9 + 10 + #include "../Activity.h" 11 + #include "RecentBooksStore.h" 12 + 13 + class RecentBooksActivity final : public Activity { 14 + private: 15 + TaskHandle_t displayTaskHandle = nullptr; 16 + SemaphoreHandle_t renderingMutex = nullptr; 17 + 18 + size_t selectorIndex = 0; 19 + bool updateRequired = false; 20 + 21 + // Recent tab state 22 + std::vector<RecentBook> recentBooks; 23 + 24 + // Callbacks 25 + const std::function<void(const std::string& path)> onSelectBook; 26 + const std::function<void()> onGoHome; 27 + 28 + static void taskTrampoline(void* param); 29 + [[noreturn]] void displayTaskLoop(); 30 + void render() const; 31 + 32 + // Data loading 33 + void loadRecentBooks(); 34 + 35 + public: 36 + explicit RecentBooksActivity(GfxRenderer& renderer, MappedInputManager& mappedInput, 37 + const std::function<void()>& onGoHome, 38 + const std::function<void(const std::string& path)>& onSelectBook) 39 + : Activity("RecentBooks", renderer, mappedInput), onSelectBook(onSelectBook), onGoHome(onGoHome) {} 40 + void onEnter() override; 41 + void onExit() override; 42 + void loop() override; 43 + };
+3 -4
src/activities/network/CalibreConnectActivity.cpp
··· 6 6 #include <esp_task_wdt.h> 7 7 8 8 #include "MappedInputManager.h" 9 - #include "ScreenComponents.h" 10 9 #include "WifiSelectionActivity.h" 10 + #include "components/UITheme.h" 11 11 #include "fontIds.h" 12 12 13 13 namespace { ··· 258 258 constexpr int barWidth = 300; 259 259 constexpr int barHeight = 16; 260 260 constexpr int barX = (480 - barWidth) / 2; 261 - ScreenComponents::drawProgressBar(renderer, barX, y + 22, barWidth, barHeight, lastProgressReceived, 262 - lastProgressTotal); 261 + GUI.drawProgressBar(renderer, Rect{barX, y + 22, barWidth, barHeight}, lastProgressReceived, lastProgressTotal); 263 262 y += 40; 264 263 } 265 264 ··· 272 271 } 273 272 274 273 const auto labels = mappedInput.mapLabels("« Exit", "", "", ""); 275 - renderer.drawButtonHints(UI_10_FONT_ID, labels.btn1, labels.btn2, labels.btn3, labels.btn4); 274 + GUI.drawButtonHints(renderer, labels.btn1, labels.btn2, labels.btn3, labels.btn4); 276 275 }
+2 -1
src/activities/network/CrossPointWebServerActivity.cpp
··· 13 13 #include "NetworkModeSelectionActivity.h" 14 14 #include "WifiSelectionActivity.h" 15 15 #include "activities/network/CalibreConnectActivity.h" 16 + #include "components/UITheme.h" 16 17 #include "fontIds.h" 17 18 18 19 namespace { ··· 479 480 } 480 481 481 482 const auto labels = mappedInput.mapLabels("« Exit", "", "", ""); 482 - renderer.drawButtonHints(UI_10_FONT_ID, labels.btn1, labels.btn2, labels.btn3, labels.btn4); 483 + GUI.drawButtonHints(renderer, labels.btn1, labels.btn2, labels.btn3, labels.btn4); 483 484 }
+2 -1
src/activities/network/NetworkModeSelectionActivity.cpp
··· 3 3 #include <GfxRenderer.h> 4 4 5 5 #include "MappedInputManager.h" 6 + #include "components/UITheme.h" 6 7 #include "fontIds.h" 7 8 8 9 namespace { ··· 131 132 132 133 // Draw help text at bottom 133 134 const auto labels = mappedInput.mapLabels("« Back", "Select", "", ""); 134 - renderer.drawButtonHints(UI_10_FONT_ID, labels.btn1, labels.btn2, labels.btn3, labels.btn4); 135 + GUI.drawButtonHints(renderer, labels.btn1, labels.btn2, labels.btn3, labels.btn4); 135 136 136 137 renderer.displayBuffer(); 137 138 }
+6 -5
src/activities/network/WifiSelectionActivity.cpp
··· 8 8 #include "MappedInputManager.h" 9 9 #include "WifiCredentialStore.h" 10 10 #include "activities/util/KeyboardEntryActivity.h" 11 + #include "components/UITheme.h" 11 12 #include "fontIds.h" 12 13 13 14 void WifiSelectionActivity::taskTrampoline(void* param) { ··· 586 587 // Draw help text 587 588 renderer.drawText(SMALL_FONT_ID, 20, pageHeight - 75, "* = Encrypted | + = Saved"); 588 589 const auto labels = mappedInput.mapLabels("« Back", "Connect", "", ""); 589 - renderer.drawButtonHints(UI_10_FONT_ID, labels.btn1, labels.btn2, labels.btn3, labels.btn4); 590 + GUI.drawButtonHints(renderer, labels.btn1, labels.btn2, labels.btn3, labels.btn4); 590 591 } 591 592 592 593 void WifiSelectionActivity::renderConnecting() const { ··· 625 626 626 627 // Use centralized button hints 627 628 const auto labels = mappedInput.mapLabels("", "Continue", "", ""); 628 - renderer.drawButtonHints(UI_10_FONT_ID, labels.btn1, labels.btn2, labels.btn3, labels.btn4); 629 + GUI.drawButtonHints(renderer, labels.btn1, labels.btn2, labels.btn3, labels.btn4); 629 630 } 630 631 631 632 void WifiSelectionActivity::renderSavePrompt() const { ··· 667 668 668 669 // Use centralized button hints 669 670 const auto labels = mappedInput.mapLabels("« Skip", "Select", "Left", "Right"); 670 - renderer.drawButtonHints(UI_10_FONT_ID, labels.btn1, labels.btn2, labels.btn3, labels.btn4); 671 + GUI.drawButtonHints(renderer, labels.btn1, labels.btn2, labels.btn3, labels.btn4); 671 672 } 672 673 673 674 void WifiSelectionActivity::renderConnectionFailed() const { ··· 680 681 681 682 // Use centralized button hints 682 683 const auto labels = mappedInput.mapLabels("« Back", "Continue", "", ""); 683 - renderer.drawButtonHints(UI_10_FONT_ID, labels.btn1, labels.btn2, labels.btn3, labels.btn4); 684 + GUI.drawButtonHints(renderer, labels.btn1, labels.btn2, labels.btn3, labels.btn4); 684 685 } 685 686 686 687 void WifiSelectionActivity::renderForgetPrompt() const { ··· 722 723 723 724 // Use centralized button hints 724 725 const auto labels = mappedInput.mapLabels("« Back", "Select", "Left", "Right"); 725 - renderer.drawButtonHints(UI_10_FONT_ID, labels.btn1, labels.btn2, labels.btn3, labels.btn4); 726 + GUI.drawButtonHints(renderer, labels.btn1, labels.btn2, labels.btn3, labels.btn4); 726 727 }
+11 -6
src/activities/reader/EpubReaderActivity.cpp
··· 10 10 #include "EpubReaderChapterSelectionActivity.h" 11 11 #include "MappedInputManager.h" 12 12 #include "RecentBooksStore.h" 13 - #include "ScreenComponents.h" 13 + #include "components/UITheme.h" 14 14 #include "fontIds.h" 15 15 16 16 namespace { ··· 85 85 // Save current epub as last opened epub and add to recent books 86 86 APP_STATE.openEpubPath = epub->getPath(); 87 87 APP_STATE.saveToFile(); 88 - RECENT_BOOKS.addBook(epub->getPath(), epub->getTitle(), epub->getAuthor()); 88 + RECENT_BOOKS.addBook(epub->getPath(), epub->getTitle(), epub->getAuthor(), epub->getThumbBmpPath()); 89 89 90 90 // Trigger first update 91 91 updateRequired = true; ··· 347 347 orientedMarginRight += SETTINGS.screenMargin; 348 348 orientedMarginBottom += SETTINGS.screenMargin; 349 349 350 + auto metrics = UITheme::getInstance().getMetrics(); 351 + 350 352 // Add status bar margin 351 353 if (SETTINGS.statusBar != CrossPointSettings::STATUS_BAR_MODE::NONE) { 352 354 // Add additional margin for status bar if progress bar is shown 353 355 const bool showProgressBar = SETTINGS.statusBar == CrossPointSettings::STATUS_BAR_MODE::FULL_WITH_PROGRESS_BAR || 354 356 SETTINGS.statusBar == CrossPointSettings::STATUS_BAR_MODE::ONLY_PROGRESS_BAR; 355 357 orientedMarginBottom += statusBarMargin - SETTINGS.screenMargin + 356 - (showProgressBar ? (ScreenComponents::BOOK_PROGRESS_BAR_HEIGHT + progressBarMarginTop) : 0); 358 + (showProgressBar ? (metrics.bookProgressBarHeight + progressBarMarginTop) : 0); 357 359 } 358 360 359 361 if (!section) { ··· 369 371 viewportHeight, SETTINGS.hyphenationEnabled)) { 370 372 Serial.printf("[%lu] [ERS] Cache not found, building...\n", millis()); 371 373 372 - const auto popupFn = [this]() { ScreenComponents::drawPopup(renderer, "Indexing..."); }; 374 + const auto popupFn = [this]() { GUI.drawPopup(renderer, "Indexing..."); }; 373 375 374 376 if (!section->createSectionFile(SETTINGS.getReaderFontId(), SETTINGS.getReaderLineCompression(), 375 377 SETTINGS.extraParagraphSpacing, SETTINGS.paragraphAlignment, viewportWidth, ··· 491 493 492 494 void EpubReaderActivity::renderStatusBar(const int orientedMarginRight, const int orientedMarginBottom, 493 495 const int orientedMarginLeft) const { 496 + auto metrics = UITheme::getInstance().getMetrics(); 497 + 494 498 // determine visible status bar elements 495 499 const bool showProgressPercentage = SETTINGS.statusBar == CrossPointSettings::STATUS_BAR_MODE::FULL; 496 500 const bool showProgressBar = SETTINGS.statusBar == CrossPointSettings::STATUS_BAR_MODE::FULL_WITH_PROGRESS_BAR || ··· 534 538 535 539 if (showProgressBar) { 536 540 // Draw progress bar at the very bottom of the screen, from edge to edge of viewable area 537 - ScreenComponents::drawBookProgressBar(renderer, static_cast<size_t>(bookProgress)); 541 + GUI.drawBookProgressBar(renderer, static_cast<size_t>(bookProgress)); 538 542 } 539 543 540 544 if (showBattery) { 541 - ScreenComponents::drawBattery(renderer, orientedMarginLeft + 1, textY, showBatteryPercentage); 545 + GUI.drawBattery(renderer, Rect{orientedMarginLeft + 1, textY, metrics.batteryWidth, metrics.batteryHeight}, 546 + showBatteryPercentage); 542 547 } 543 548 544 549 if (showChapterTitle) {
+2 -1
src/activities/reader/EpubReaderChapterSelectionActivity.cpp
··· 5 5 #include "KOReaderCredentialStore.h" 6 6 #include "KOReaderSyncActivity.h" 7 7 #include "MappedInputManager.h" 8 + #include "components/UITheme.h" 8 9 #include "fontIds.h" 9 10 10 11 namespace { ··· 209 210 // Skip button hints in landscape CW mode (they overlap content) 210 211 if (renderer.getOrientation() != GfxRenderer::LandscapeClockwise) { 211 212 const auto labels = mappedInput.mapLabels("« Back", "Select", "Up", "Down"); 212 - renderer.drawButtonHints(UI_10_FONT_ID, labels.btn1, labels.btn2, labels.btn3, labels.btn4); 213 + GUI.drawButtonHints(renderer, labels.btn1, labels.btn2, labels.btn3, labels.btn4); 213 214 } 214 215 215 216 renderer.displayBuffer();
+2 -1
src/activities/reader/EpubReaderMenuActivity.cpp
··· 2 2 3 3 #include <GfxRenderer.h> 4 4 5 + #include "components/UITheme.h" 5 6 #include "fontIds.h" 6 7 7 8 void EpubReaderMenuActivity::onEnter() { ··· 97 98 98 99 // Footer / Hints 99 100 const auto labels = mappedInput.mapLabels("« Back", "Select", "Up", "Down"); 100 - renderer.drawButtonHints(UI_10_FONT_ID, labels.btn1, labels.btn2, labels.btn3, labels.btn4); 101 + GUI.drawButtonHints(renderer, labels.btn1, labels.btn2, labels.btn3, labels.btn4); 101 102 102 103 renderer.displayBuffer(); 103 104 }
+6 -5
src/activities/reader/KOReaderSyncActivity.cpp
··· 8 8 #include "KOReaderDocumentId.h" 9 9 #include "MappedInputManager.h" 10 10 #include "activities/network/WifiSelectionActivity.h" 11 + #include "components/UITheme.h" 11 12 #include "fontIds.h" 12 13 13 14 namespace { ··· 266 267 renderer.drawCenteredText(UI_10_FONT_ID, 320, "Set up KOReader account in Settings"); 267 268 268 269 const auto labels = mappedInput.mapLabels("Back", "", "", ""); 269 - renderer.drawButtonHints(UI_10_FONT_ID, labels.btn1, labels.btn2, labels.btn3, labels.btn4); 270 + GUI.drawButtonHints(renderer, labels.btn1, labels.btn2, labels.btn3, labels.btn4); 270 271 renderer.displayBuffer(); 271 272 return; 272 273 } ··· 339 340 renderer.drawText(UI_10_FONT_ID, 20, optionY + optionHeight * 2, "Cancel", selectedOption != 2); 340 341 341 342 const auto labels = mappedInput.mapLabels("", "Select", "", ""); 342 - renderer.drawButtonHints(UI_10_FONT_ID, labels.btn1, labels.btn2, labels.btn3, labels.btn4); 343 + GUI.drawButtonHints(renderer, labels.btn1, labels.btn2, labels.btn3, labels.btn4); 343 344 renderer.displayBuffer(); 344 345 return; 345 346 } ··· 349 350 renderer.drawCenteredText(UI_10_FONT_ID, 320, "Upload current position?"); 350 351 351 352 const auto labels = mappedInput.mapLabels("Cancel", "Upload", "", ""); 352 - renderer.drawButtonHints(UI_10_FONT_ID, labels.btn1, labels.btn2, labels.btn3, labels.btn4); 353 + GUI.drawButtonHints(renderer, labels.btn1, labels.btn2, labels.btn3, labels.btn4); 353 354 renderer.displayBuffer(); 354 355 return; 355 356 } ··· 358 359 renderer.drawCenteredText(UI_10_FONT_ID, 300, "Progress uploaded!", true, EpdFontFamily::BOLD); 359 360 360 361 const auto labels = mappedInput.mapLabels("Back", "", "", ""); 361 - renderer.drawButtonHints(UI_10_FONT_ID, labels.btn1, labels.btn2, labels.btn3, labels.btn4); 362 + GUI.drawButtonHints(renderer, labels.btn1, labels.btn2, labels.btn3, labels.btn4); 362 363 renderer.displayBuffer(); 363 364 return; 364 365 } ··· 368 369 renderer.drawCenteredText(UI_10_FONT_ID, 320, statusMessage.c_str()); 369 370 370 371 const auto labels = mappedInput.mapLabels("Back", "", "", ""); 371 - renderer.drawButtonHints(UI_10_FONT_ID, labels.btn1, labels.btn2, labels.btn3, labels.btn4); 372 + GUI.drawButtonHints(renderer, labels.btn1, labels.btn2, labels.btn3, labels.btn4); 372 373 renderer.displayBuffer(); 373 374 return; 374 375 }
+1 -1
src/activities/reader/ReaderActivity.cpp
··· 74 74 void ReaderActivity::goToLibrary(const std::string& fromBookPath) { 75 75 // If coming from a book, start in that book's folder; otherwise start from root 76 76 const auto initialPath = fromBookPath.empty() ? "/" : extractFolderPath(fromBookPath); 77 - onGoToLibrary(initialPath, libraryTab); 77 + onGoToLibrary(initialPath); 78 78 } 79 79 80 80 void ReaderActivity::onGoToEpubReader(std::unique_ptr<Epub> epub) {
+4 -6
src/activities/reader/ReaderActivity.h
··· 10 10 11 11 class ReaderActivity final : public ActivityWithSubactivity { 12 12 std::string initialBookPath; 13 - std::string currentBookPath; // Track current book path for navigation 14 - MyLibraryActivity::Tab libraryTab; // Track which tab to return to 13 + std::string currentBookPath; // Track current book path for navigation 15 14 const std::function<void()> onGoBack; 16 - const std::function<void(const std::string&, MyLibraryActivity::Tab)> onGoToLibrary; 15 + const std::function<void(const std::string&)> onGoToLibrary; 17 16 static std::unique_ptr<Epub> loadEpub(const std::string& path); 18 17 static std::unique_ptr<Xtc> loadXtc(const std::string& path); 19 18 static std::unique_ptr<Txt> loadTxt(const std::string& path); ··· 28 27 29 28 public: 30 29 explicit ReaderActivity(GfxRenderer& renderer, MappedInputManager& mappedInput, std::string initialBookPath, 31 - MyLibraryActivity::Tab libraryTab, const std::function<void()>& onGoBack, 32 - const std::function<void(const std::string&, MyLibraryActivity::Tab)>& onGoToLibrary) 30 + const std::function<void()>& onGoBack, 31 + const std::function<void(const std::string&)>& onGoToLibrary) 33 32 : ActivityWithSubactivity("Reader", renderer, mappedInput), 34 33 initialBookPath(std::move(initialBookPath)), 35 - libraryTab(libraryTab), 36 34 onGoBack(onGoBack), 37 35 onGoToLibrary(onGoToLibrary) {} 38 36 void onEnter() override;
+13 -7
src/activities/reader/TxtReaderActivity.cpp
··· 9 9 #include "CrossPointState.h" 10 10 #include "MappedInputManager.h" 11 11 #include "RecentBooksStore.h" 12 - #include "ScreenComponents.h" 12 + #include "components/UITheme.h" 13 13 #include "fontIds.h" 14 14 15 15 namespace { ··· 58 58 txt->setupCacheDir(); 59 59 60 60 // Save current txt as last opened file and add to recent books 61 - APP_STATE.openEpubPath = txt->getPath(); 61 + auto filePath = txt->getPath(); 62 + auto fileName = filePath.substr(filePath.rfind('/') + 1); 63 + APP_STATE.openEpubPath = filePath; 62 64 APP_STATE.saveToFile(); 63 - RECENT_BOOKS.addBook(txt->getPath(), "", ""); 65 + RECENT_BOOKS.addBook(filePath, fileName, "", ""); 64 66 65 67 // Trigger first update 66 68 updateRequired = true; ··· 168 170 orientedMarginRight += cachedScreenMargin; 169 171 orientedMarginBottom += cachedScreenMargin; 170 172 173 + auto metrics = UITheme::getInstance().getMetrics(); 174 + 171 175 // Add status bar margin 172 176 if (SETTINGS.statusBar != CrossPointSettings::STATUS_BAR_MODE::NONE) { 173 177 // Add additional margin for status bar if progress bar is shown 174 178 const bool showProgressBar = SETTINGS.statusBar == CrossPointSettings::STATUS_BAR_MODE::FULL_WITH_PROGRESS_BAR || 175 179 SETTINGS.statusBar == CrossPointSettings::STATUS_BAR_MODE::ONLY_PROGRESS_BAR; 176 180 orientedMarginBottom += statusBarMargin - cachedScreenMargin + 177 - (showProgressBar ? (ScreenComponents::BOOK_PROGRESS_BAR_HEIGHT + progressBarMarginTop) : 0); 181 + (showProgressBar ? (metrics.bookProgressBarHeight + progressBarMarginTop) : 0); 178 182 } 179 183 180 184 viewportWidth = renderer.getScreenWidth() - orientedMarginLeft - orientedMarginRight; ··· 210 214 211 215 Serial.printf("[%lu] [TRS] Building page index for %zu bytes...\n", millis(), fileSize); 212 216 213 - ScreenComponents::drawPopup(renderer, "Indexing..."); 217 + GUI.drawPopup(renderer, "Indexing..."); 214 218 215 219 while (offset < fileSize) { 216 220 std::vector<std::string> tempLines; ··· 498 502 const bool showBatteryPercentage = 499 503 SETTINGS.hideBatteryPercentage == CrossPointSettings::HIDE_BATTERY_PERCENTAGE::HIDE_NEVER; 500 504 505 + auto metrics = UITheme::getInstance().getMetrics(); 501 506 const auto screenHeight = renderer.getScreenHeight(); 502 507 const auto textY = screenHeight - orientedMarginBottom - 4; 503 508 int progressTextWidth = 0; ··· 519 524 520 525 if (showProgressBar) { 521 526 // Draw progress bar at the very bottom of the screen, from edge to edge of viewable area 522 - ScreenComponents::drawBookProgressBar(renderer, static_cast<size_t>(progress)); 527 + GUI.drawBookProgressBar(renderer, static_cast<size_t>(progress)); 523 528 } 524 529 525 530 if (showBattery) { 526 - ScreenComponents::drawBattery(renderer, orientedMarginLeft, textY, showBatteryPercentage); 531 + GUI.drawBattery(renderer, Rect{orientedMarginLeft, textY, metrics.batteryWidth, metrics.batteryHeight}, 532 + showBatteryPercentage); 527 533 } 528 534 529 535 if (showTitle) {
+2 -1
src/activities/reader/XtcReaderActivity.cpp
··· 16 16 #include "MappedInputManager.h" 17 17 #include "RecentBooksStore.h" 18 18 #include "XtcReaderChapterSelectionActivity.h" 19 + #include "components/UITheme.h" 19 20 #include "fontIds.h" 20 21 21 22 namespace { ··· 45 46 // Save current XTC as last opened book and add to recent books 46 47 APP_STATE.openEpubPath = xtc->getPath(); 47 48 APP_STATE.saveToFile(); 48 - RECENT_BOOKS.addBook(xtc->getPath(), xtc->getTitle(), xtc->getAuthor()); 49 + RECENT_BOOKS.addBook(xtc->getPath(), xtc->getTitle(), xtc->getAuthor(), xtc->getThumbBmpPath()); 49 50 50 51 // Trigger first update 51 52 updateRequired = true;
+2 -1
src/activities/reader/XtcReaderChapterSelectionActivity.cpp
··· 3 3 #include <GfxRenderer.h> 4 4 5 5 #include "MappedInputManager.h" 6 + #include "components/UITheme.h" 6 7 #include "fontIds.h" 7 8 8 9 namespace { ··· 152 153 // Skip button hints in landscape CW mode (they overlap content) 153 154 if (renderer.getOrientation() != GfxRenderer::LandscapeClockwise) { 154 155 const auto labels = mappedInput.mapLabels("« Back", "Select", "Up", "Down"); 155 - renderer.drawButtonHints(UI_10_FONT_ID, labels.btn1, labels.btn2, labels.btn3, labels.btn4); 156 + GUI.drawButtonHints(renderer, labels.btn1, labels.btn2, labels.btn3, labels.btn4); 156 157 } 157 158 158 159 renderer.displayBuffer();
+2 -1
src/activities/settings/CalibreSettingsActivity.cpp
··· 7 7 #include "CrossPointSettings.h" 8 8 #include "MappedInputManager.h" 9 9 #include "activities/util/KeyboardEntryActivity.h" 10 + #include "components/UITheme.h" 10 11 #include "fontIds.h" 11 12 12 13 namespace { ··· 183 184 184 185 // Draw button hints 185 186 const auto labels = mappedInput.mapLabels("« Back", "Select", "", ""); 186 - renderer.drawButtonHints(UI_10_FONT_ID, labels.btn1, labels.btn2, labels.btn3, labels.btn4); 187 + GUI.drawButtonHints(renderer, labels.btn1, labels.btn2, labels.btn3, labels.btn4); 187 188 188 189 renderer.displayBuffer(); 189 190 }
-193
src/activities/settings/CategorySettingsActivity.cpp
··· 1 - #include "CategorySettingsActivity.h" 2 - 3 - #include <GfxRenderer.h> 4 - #include <HardwareSerial.h> 5 - 6 - #include <cstring> 7 - 8 - #include "CalibreSettingsActivity.h" 9 - #include "ClearCacheActivity.h" 10 - #include "CrossPointSettings.h" 11 - #include "KOReaderSettingsActivity.h" 12 - #include "MappedInputManager.h" 13 - #include "OtaUpdateActivity.h" 14 - #include "fontIds.h" 15 - 16 - void CategorySettingsActivity::taskTrampoline(void* param) { 17 - auto* self = static_cast<CategorySettingsActivity*>(param); 18 - self->displayTaskLoop(); 19 - } 20 - 21 - void CategorySettingsActivity::onEnter() { 22 - Activity::onEnter(); 23 - renderingMutex = xSemaphoreCreateMutex(); 24 - 25 - selectedSettingIndex = 0; 26 - updateRequired = true; 27 - 28 - xTaskCreate(&CategorySettingsActivity::taskTrampoline, "CategorySettingsActivityTask", 4096, this, 1, 29 - &displayTaskHandle); 30 - } 31 - 32 - void CategorySettingsActivity::onExit() { 33 - ActivityWithSubactivity::onExit(); 34 - 35 - // Wait until not rendering to delete task to avoid killing mid-instruction to EPD 36 - xSemaphoreTake(renderingMutex, portMAX_DELAY); 37 - if (displayTaskHandle) { 38 - vTaskDelete(displayTaskHandle); 39 - displayTaskHandle = nullptr; 40 - } 41 - vSemaphoreDelete(renderingMutex); 42 - renderingMutex = nullptr; 43 - } 44 - 45 - void CategorySettingsActivity::loop() { 46 - if (subActivity) { 47 - subActivity->loop(); 48 - return; 49 - } 50 - 51 - // Handle actions with early return 52 - if (mappedInput.wasPressed(MappedInputManager::Button::Confirm)) { 53 - toggleCurrentSetting(); 54 - updateRequired = true; 55 - return; 56 - } 57 - 58 - if (mappedInput.wasPressed(MappedInputManager::Button::Back)) { 59 - SETTINGS.saveToFile(); 60 - onGoBack(); 61 - return; 62 - } 63 - 64 - // Handle navigation 65 - if (mappedInput.wasPressed(MappedInputManager::Button::Up) || 66 - mappedInput.wasPressed(MappedInputManager::Button::Left)) { 67 - selectedSettingIndex = (selectedSettingIndex > 0) ? (selectedSettingIndex - 1) : (settingsCount - 1); 68 - updateRequired = true; 69 - } else if (mappedInput.wasPressed(MappedInputManager::Button::Down) || 70 - mappedInput.wasPressed(MappedInputManager::Button::Right)) { 71 - selectedSettingIndex = (selectedSettingIndex < settingsCount - 1) ? (selectedSettingIndex + 1) : 0; 72 - updateRequired = true; 73 - } 74 - } 75 - 76 - void CategorySettingsActivity::toggleCurrentSetting() { 77 - if (selectedSettingIndex < 0 || selectedSettingIndex >= settingsCount) { 78 - return; 79 - } 80 - 81 - const auto& setting = settingsList[selectedSettingIndex]; 82 - 83 - if (setting.type == SettingType::TOGGLE && setting.valuePtr != nullptr) { 84 - // Toggle the boolean value using the member pointer 85 - const bool currentValue = SETTINGS.*(setting.valuePtr); 86 - SETTINGS.*(setting.valuePtr) = !currentValue; 87 - } else if (setting.type == SettingType::ENUM && setting.valuePtr != nullptr) { 88 - const uint8_t currentValue = SETTINGS.*(setting.valuePtr); 89 - SETTINGS.*(setting.valuePtr) = (currentValue + 1) % static_cast<uint8_t>(setting.enumValues.size()); 90 - } else if (setting.type == SettingType::VALUE && setting.valuePtr != nullptr) { 91 - const int8_t currentValue = SETTINGS.*(setting.valuePtr); 92 - if (currentValue + setting.valueRange.step > setting.valueRange.max) { 93 - SETTINGS.*(setting.valuePtr) = setting.valueRange.min; 94 - } else { 95 - SETTINGS.*(setting.valuePtr) = currentValue + setting.valueRange.step; 96 - } 97 - } else if (setting.type == SettingType::ACTION) { 98 - if (strcmp(setting.name, "KOReader Sync") == 0) { 99 - xSemaphoreTake(renderingMutex, portMAX_DELAY); 100 - exitActivity(); 101 - enterNewActivity(new KOReaderSettingsActivity(renderer, mappedInput, [this] { 102 - exitActivity(); 103 - updateRequired = true; 104 - })); 105 - xSemaphoreGive(renderingMutex); 106 - } else if (strcmp(setting.name, "OPDS Browser") == 0) { 107 - xSemaphoreTake(renderingMutex, portMAX_DELAY); 108 - exitActivity(); 109 - enterNewActivity(new CalibreSettingsActivity(renderer, mappedInput, [this] { 110 - exitActivity(); 111 - updateRequired = true; 112 - })); 113 - xSemaphoreGive(renderingMutex); 114 - } else if (strcmp(setting.name, "Clear Cache") == 0) { 115 - xSemaphoreTake(renderingMutex, portMAX_DELAY); 116 - exitActivity(); 117 - enterNewActivity(new ClearCacheActivity(renderer, mappedInput, [this] { 118 - exitActivity(); 119 - updateRequired = true; 120 - })); 121 - xSemaphoreGive(renderingMutex); 122 - } else if (strcmp(setting.name, "Check for updates") == 0) { 123 - xSemaphoreTake(renderingMutex, portMAX_DELAY); 124 - exitActivity(); 125 - enterNewActivity(new OtaUpdateActivity(renderer, mappedInput, [this] { 126 - exitActivity(); 127 - updateRequired = true; 128 - })); 129 - xSemaphoreGive(renderingMutex); 130 - } 131 - } else { 132 - return; 133 - } 134 - 135 - SETTINGS.saveToFile(); 136 - } 137 - 138 - void CategorySettingsActivity::displayTaskLoop() { 139 - while (true) { 140 - if (updateRequired && !subActivity) { 141 - updateRequired = false; 142 - xSemaphoreTake(renderingMutex, portMAX_DELAY); 143 - render(); 144 - xSemaphoreGive(renderingMutex); 145 - } 146 - vTaskDelay(10 / portTICK_PERIOD_MS); 147 - } 148 - } 149 - 150 - void CategorySettingsActivity::render() const { 151 - renderer.clearScreen(); 152 - 153 - const auto pageWidth = renderer.getScreenWidth(); 154 - const auto pageHeight = renderer.getScreenHeight(); 155 - 156 - renderer.drawCenteredText(UI_12_FONT_ID, 15, categoryName, true, EpdFontFamily::BOLD); 157 - 158 - // Draw selection highlight 159 - renderer.fillRect(0, 60 + selectedSettingIndex * 30 - 2, pageWidth - 1, 30); 160 - 161 - // Draw all settings 162 - for (int i = 0; i < settingsCount; i++) { 163 - const int settingY = 60 + i * 30; // 30 pixels between settings 164 - const bool isSelected = (i == selectedSettingIndex); 165 - 166 - // Draw setting name 167 - renderer.drawText(UI_10_FONT_ID, 20, settingY, settingsList[i].name, !isSelected); 168 - 169 - // Draw value based on setting type 170 - std::string valueText; 171 - if (settingsList[i].type == SettingType::TOGGLE && settingsList[i].valuePtr != nullptr) { 172 - const bool value = SETTINGS.*(settingsList[i].valuePtr); 173 - valueText = value ? "ON" : "OFF"; 174 - } else if (settingsList[i].type == SettingType::ENUM && settingsList[i].valuePtr != nullptr) { 175 - const uint8_t value = SETTINGS.*(settingsList[i].valuePtr); 176 - valueText = settingsList[i].enumValues[value]; 177 - } else if (settingsList[i].type == SettingType::VALUE && settingsList[i].valuePtr != nullptr) { 178 - valueText = std::to_string(SETTINGS.*(settingsList[i].valuePtr)); 179 - } 180 - if (!valueText.empty()) { 181 - const auto width = renderer.getTextWidth(UI_10_FONT_ID, valueText.c_str()); 182 - renderer.drawText(UI_10_FONT_ID, pageWidth - 20 - width, settingY, valueText.c_str(), !isSelected); 183 - } 184 - } 185 - 186 - renderer.drawText(SMALL_FONT_ID, pageWidth - 20 - renderer.getTextWidth(SMALL_FONT_ID, CROSSPOINT_VERSION), 187 - pageHeight - 60, CROSSPOINT_VERSION); 188 - 189 - const auto labels = mappedInput.mapLabels("« Back", "Toggle", "", ""); 190 - renderer.drawButtonHints(UI_10_FONT_ID, labels.btn1, labels.btn2, labels.btn3, labels.btn4); 191 - 192 - renderer.displayBuffer(); 193 - }
-70
src/activities/settings/CategorySettingsActivity.h
··· 1 - #pragma once 2 - #include <freertos/FreeRTOS.h> 3 - #include <freertos/semphr.h> 4 - #include <freertos/task.h> 5 - 6 - #include <functional> 7 - #include <string> 8 - #include <vector> 9 - 10 - #include "activities/ActivityWithSubactivity.h" 11 - 12 - class CrossPointSettings; 13 - 14 - enum class SettingType { TOGGLE, ENUM, ACTION, VALUE }; 15 - 16 - struct SettingInfo { 17 - const char* name; 18 - SettingType type; 19 - uint8_t CrossPointSettings::* valuePtr; 20 - std::vector<std::string> enumValues; 21 - 22 - struct ValueRange { 23 - uint8_t min; 24 - uint8_t max; 25 - uint8_t step; 26 - }; 27 - ValueRange valueRange; 28 - 29 - static SettingInfo Toggle(const char* name, uint8_t CrossPointSettings::* ptr) { 30 - return {name, SettingType::TOGGLE, ptr}; 31 - } 32 - 33 - static SettingInfo Enum(const char* name, uint8_t CrossPointSettings::* ptr, std::vector<std::string> values) { 34 - return {name, SettingType::ENUM, ptr, std::move(values)}; 35 - } 36 - 37 - static SettingInfo Action(const char* name) { return {name, SettingType::ACTION, nullptr}; } 38 - 39 - static SettingInfo Value(const char* name, uint8_t CrossPointSettings::* ptr, const ValueRange valueRange) { 40 - return {name, SettingType::VALUE, ptr, {}, valueRange}; 41 - } 42 - }; 43 - 44 - class CategorySettingsActivity final : public ActivityWithSubactivity { 45 - TaskHandle_t displayTaskHandle = nullptr; 46 - SemaphoreHandle_t renderingMutex = nullptr; 47 - bool updateRequired = false; 48 - int selectedSettingIndex = 0; 49 - const char* categoryName; 50 - const SettingInfo* settingsList; 51 - int settingsCount; 52 - const std::function<void()> onGoBack; 53 - 54 - static void taskTrampoline(void* param); 55 - [[noreturn]] void displayTaskLoop(); 56 - void render() const; 57 - void toggleCurrentSetting(); 58 - 59 - public: 60 - CategorySettingsActivity(GfxRenderer& renderer, MappedInputManager& mappedInput, const char* categoryName, 61 - const SettingInfo* settingsList, int settingsCount, const std::function<void()>& onGoBack) 62 - : ActivityWithSubactivity("CategorySettings", renderer, mappedInput), 63 - categoryName(categoryName), 64 - settingsList(settingsList), 65 - settingsCount(settingsCount), 66 - onGoBack(onGoBack) {} 67 - void onEnter() override; 68 - void onExit() override; 69 - void loop() override; 70 - };
+4 -3
src/activities/settings/ClearCacheActivity.cpp
··· 5 5 #include <SDCardManager.h> 6 6 7 7 #include "MappedInputManager.h" 8 + #include "components/UITheme.h" 8 9 #include "fontIds.h" 9 10 10 11 void ClearCacheActivity::taskTrampoline(void* param) { ··· 66 67 renderer.drawCenteredText(UI_10_FONT_ID, pageHeight / 2 + 30, "when opened again.", true); 67 68 68 69 const auto labels = mappedInput.mapLabels("« Cancel", "Clear", "", ""); 69 - renderer.drawButtonHints(UI_10_FONT_ID, labels.btn1, labels.btn2, labels.btn3, labels.btn4); 70 + GUI.drawButtonHints(renderer, labels.btn1, labels.btn2, labels.btn3, labels.btn4); 70 71 renderer.displayBuffer(); 71 72 return; 72 73 } ··· 86 87 renderer.drawCenteredText(UI_10_FONT_ID, pageHeight / 2 + 10, resultText.c_str()); 87 88 88 89 const auto labels = mappedInput.mapLabels("« Back", "", "", ""); 89 - renderer.drawButtonHints(UI_10_FONT_ID, labels.btn1, labels.btn2, labels.btn3, labels.btn4); 90 + GUI.drawButtonHints(renderer, labels.btn1, labels.btn2, labels.btn3, labels.btn4); 90 91 renderer.displayBuffer(); 91 92 return; 92 93 } ··· 96 97 renderer.drawCenteredText(UI_10_FONT_ID, pageHeight / 2 + 10, "Check serial output for details"); 97 98 98 99 const auto labels = mappedInput.mapLabels("« Back", "", "", ""); 99 - renderer.drawButtonHints(UI_10_FONT_ID, labels.btn1, labels.btn2, labels.btn3, labels.btn4); 100 + GUI.drawButtonHints(renderer, labels.btn1, labels.btn2, labels.btn3, labels.btn4); 100 101 renderer.displayBuffer(); 101 102 return; 102 103 }
+3 -2
src/activities/settings/KOReaderAuthActivity.cpp
··· 7 7 #include "KOReaderSyncClient.h" 8 8 #include "MappedInputManager.h" 9 9 #include "activities/network/WifiSelectionActivity.h" 10 + #include "components/UITheme.h" 10 11 #include "fontIds.h" 11 12 12 13 void KOReaderAuthActivity::taskTrampoline(void* param) { ··· 136 137 renderer.drawCenteredText(UI_10_FONT_ID, 320, "KOReader sync is ready to use"); 137 138 138 139 const auto labels = mappedInput.mapLabels("Done", "", "", ""); 139 - renderer.drawButtonHints(UI_10_FONT_ID, labels.btn1, labels.btn2, labels.btn3, labels.btn4); 140 + GUI.drawButtonHints(renderer, labels.btn1, labels.btn2, labels.btn3, labels.btn4); 140 141 renderer.displayBuffer(); 141 142 return; 142 143 } ··· 146 147 renderer.drawCenteredText(UI_10_FONT_ID, 320, errorMessage.c_str()); 147 148 148 149 const auto labels = mappedInput.mapLabels("Back", "", "", ""); 149 - renderer.drawButtonHints(UI_10_FONT_ID, labels.btn1, labels.btn2, labels.btn3, labels.btn4); 150 + GUI.drawButtonHints(renderer, labels.btn1, labels.btn2, labels.btn3, labels.btn4); 150 151 renderer.displayBuffer(); 151 152 return; 152 153 }
+2 -1
src/activities/settings/KOReaderSettingsActivity.cpp
··· 8 8 #include "KOReaderCredentialStore.h" 9 9 #include "MappedInputManager.h" 10 10 #include "activities/util/KeyboardEntryActivity.h" 11 + #include "components/UITheme.h" 11 12 #include "fontIds.h" 12 13 13 14 namespace { ··· 207 208 208 209 // Draw button hints 209 210 const auto labels = mappedInput.mapLabels("« Back", "Select", "", ""); 210 - renderer.drawButtonHints(UI_10_FONT_ID, labels.btn1, labels.btn2, labels.btn3, labels.btn4); 211 + GUI.drawButtonHints(renderer, labels.btn1, labels.btn2, labels.btn3, labels.btn4); 211 212 212 213 renderer.displayBuffer(); 213 214 }
+2 -1
src/activities/settings/OtaUpdateActivity.cpp
··· 5 5 6 6 #include "MappedInputManager.h" 7 7 #include "activities/network/WifiSelectionActivity.h" 8 + #include "components/UITheme.h" 8 9 #include "fontIds.h" 9 10 #include "network/OtaUpdater.h" 10 11 ··· 142 143 renderer.drawText(UI_10_FONT_ID, 20, 270, ("New Version: " + updater.getLatestVersion()).c_str()); 143 144 144 145 const auto labels = mappedInput.mapLabels("Cancel", "Update", "", ""); 145 - renderer.drawButtonHints(UI_10_FONT_ID, labels.btn1, labels.btn2, labels.btn3, labels.btn4); 146 + GUI.drawButtonHints(renderer, labels.btn1, labels.btn2, labels.btn3, labels.btn4); 146 147 renderer.displayBuffer(); 147 148 return; 148 149 }
+156 -56
src/activities/settings/SettingsActivity.cpp
··· 3 3 #include <GfxRenderer.h> 4 4 #include <HardwareSerial.h> 5 5 6 - #include "CategorySettingsActivity.h" 6 + #include "CalibreSettingsActivity.h" 7 + #include "ClearCacheActivity.h" 7 8 #include "CrossPointSettings.h" 9 + #include "KOReaderSettingsActivity.h" 8 10 #include "MappedInputManager.h" 11 + #include "OtaUpdateActivity.h" 12 + #include "components/UITheme.h" 9 13 #include "fontIds.h" 10 14 11 15 const char* SettingsActivity::categoryNames[categoryCount] = {"Display", "Reader", "Controls", "System"}; 12 16 13 17 namespace { 14 - constexpr int displaySettingsCount = 6; 18 + constexpr int changeTabsMs = 700; 19 + constexpr int displaySettingsCount = 7; 15 20 const SettingInfo displaySettings[displaySettingsCount] = { 16 21 // Should match with SLEEP_SCREEN_MODE 17 22 SettingInfo::Enum("Sleep Screen", &CrossPointSettings::sleepScreen, {"Dark", "Light", "Custom", "Cover", "None"}), ··· 22 27 {"None", "No Progress", "Full w/ Percentage", "Full w/ Progress Bar", "Progress Bar"}), 23 28 SettingInfo::Enum("Hide Battery %", &CrossPointSettings::hideBatteryPercentage, {"Never", "In Reader", "Always"}), 24 29 SettingInfo::Enum("Refresh Frequency", &CrossPointSettings::refreshFrequency, 25 - {"1 page", "5 pages", "10 pages", "15 pages", "30 pages"})}; 30 + {"1 page", "5 pages", "10 pages", "15 pages", "30 pages"}), 31 + SettingInfo::Enum("UI Theme", &CrossPointSettings::uiTheme, {"Classic", "Lyra"}), 32 + }; 26 33 27 34 constexpr int readerSettingsCount = 9; 28 35 const SettingInfo readerSettings[readerSettingsCount] = { ··· 67 74 68 75 // Reset selection to first category 69 76 selectedCategoryIndex = 0; 77 + selectedSettingIndex = 0; 78 + 79 + // Initialize with first category (Display) 80 + settingsList = displaySettings; 81 + settingsCount = displaySettingsCount; 70 82 71 83 // Trigger first update 72 84 updateRequired = true; ··· 90 102 } 91 103 vSemaphoreDelete(renderingMutex); 92 104 renderingMutex = nullptr; 105 + 106 + UITheme::getInstance().reload(); // Re-apply theme in case it was changed 93 107 } 94 108 95 109 void SettingsActivity::loop() { ··· 97 111 subActivity->loop(); 98 112 return; 99 113 } 114 + bool hasChangedCategory = false; 100 115 101 - // Handle category selection 116 + // Handle actions with early return 102 117 if (mappedInput.wasPressed(MappedInputManager::Button::Confirm)) { 103 - enterCategory(selectedCategoryIndex); 104 - return; 118 + if (selectedSettingIndex == 0) { 119 + selectedCategoryIndex = (selectedCategoryIndex < categoryCount - 1) ? (selectedCategoryIndex + 1) : 0; 120 + hasChangedCategory = true; 121 + updateRequired = true; 122 + } else { 123 + toggleCurrentSetting(); 124 + updateRequired = true; 125 + return; 126 + } 105 127 } 106 128 107 129 if (mappedInput.wasPressed(MappedInputManager::Button::Back)) { ··· 109 131 onGoHome(); 110 132 return; 111 133 } 134 + 135 + const bool upReleased = mappedInput.wasReleased(MappedInputManager::Button::Up); 136 + const bool downReleased = mappedInput.wasReleased(MappedInputManager::Button::Down); 137 + const bool leftReleased = mappedInput.wasReleased(MappedInputManager::Button::Left); 138 + const bool rightReleased = mappedInput.wasReleased(MappedInputManager::Button::Right); 139 + const bool changeTab = mappedInput.getHeldTime() > changeTabsMs; 112 140 113 141 // Handle navigation 114 - if (mappedInput.wasPressed(MappedInputManager::Button::Up) || 115 - mappedInput.wasPressed(MappedInputManager::Button::Left)) { 116 - // Move selection up (with wrap-around) 142 + if (upReleased && changeTab) { 143 + hasChangedCategory = true; 117 144 selectedCategoryIndex = (selectedCategoryIndex > 0) ? (selectedCategoryIndex - 1) : (categoryCount - 1); 118 145 updateRequired = true; 119 - } else if (mappedInput.wasPressed(MappedInputManager::Button::Down) || 120 - mappedInput.wasPressed(MappedInputManager::Button::Right)) { 121 - // Move selection down (with wrap around) 146 + } else if (downReleased && changeTab) { 147 + hasChangedCategory = true; 122 148 selectedCategoryIndex = (selectedCategoryIndex < categoryCount - 1) ? (selectedCategoryIndex + 1) : 0; 123 149 updateRequired = true; 150 + } else if (upReleased || leftReleased) { 151 + selectedSettingIndex = (selectedSettingIndex > 0) ? (selectedSettingIndex - 1) : (settingsCount); 152 + updateRequired = true; 153 + } else if (rightReleased || downReleased) { 154 + selectedSettingIndex = (selectedSettingIndex < settingsCount) ? (selectedSettingIndex + 1) : 0; 155 + updateRequired = true; 156 + } 157 + 158 + if (hasChangedCategory) { 159 + selectedSettingIndex = (selectedSettingIndex == 0) ? 0 : 1; 160 + switch (selectedCategoryIndex) { 161 + case 0: // Display 162 + settingsList = displaySettings; 163 + settingsCount = displaySettingsCount; 164 + break; 165 + case 1: // Reader 166 + settingsList = readerSettings; 167 + settingsCount = readerSettingsCount; 168 + break; 169 + case 2: // Controls 170 + settingsList = controlsSettings; 171 + settingsCount = controlsSettingsCount; 172 + break; 173 + case 3: // System 174 + settingsList = systemSettings; 175 + settingsCount = systemSettingsCount; 176 + break; 177 + } 124 178 } 125 179 } 126 180 127 - void SettingsActivity::enterCategory(int categoryIndex) { 128 - if (categoryIndex < 0 || categoryIndex >= categoryCount) { 181 + void SettingsActivity::toggleCurrentSetting() { 182 + int selectedSetting = selectedSettingIndex - 1; 183 + if (selectedSetting < 0 || selectedSetting >= settingsCount) { 129 184 return; 130 185 } 131 186 132 - xSemaphoreTake(renderingMutex, portMAX_DELAY); 133 - exitActivity(); 187 + const auto& setting = settingsList[selectedSetting]; 134 188 135 - const SettingInfo* settingsList = nullptr; 136 - int settingsCount = 0; 137 - 138 - switch (categoryIndex) { 139 - case 0: // Display 140 - settingsList = displaySettings; 141 - settingsCount = displaySettingsCount; 142 - break; 143 - case 1: // Reader 144 - settingsList = readerSettings; 145 - settingsCount = readerSettingsCount; 146 - break; 147 - case 2: // Controls 148 - settingsList = controlsSettings; 149 - settingsCount = controlsSettingsCount; 150 - break; 151 - case 3: // System 152 - settingsList = systemSettings; 153 - settingsCount = systemSettingsCount; 154 - break; 189 + if (setting.type == SettingType::TOGGLE && setting.valuePtr != nullptr) { 190 + // Toggle the boolean value using the member pointer 191 + const bool currentValue = SETTINGS.*(setting.valuePtr); 192 + SETTINGS.*(setting.valuePtr) = !currentValue; 193 + } else if (setting.type == SettingType::ENUM && setting.valuePtr != nullptr) { 194 + const uint8_t currentValue = SETTINGS.*(setting.valuePtr); 195 + SETTINGS.*(setting.valuePtr) = (currentValue + 1) % static_cast<uint8_t>(setting.enumValues.size()); 196 + } else if (setting.type == SettingType::VALUE && setting.valuePtr != nullptr) { 197 + const int8_t currentValue = SETTINGS.*(setting.valuePtr); 198 + if (currentValue + setting.valueRange.step > setting.valueRange.max) { 199 + SETTINGS.*(setting.valuePtr) = setting.valueRange.min; 200 + } else { 201 + SETTINGS.*(setting.valuePtr) = currentValue + setting.valueRange.step; 202 + } 203 + } else if (setting.type == SettingType::ACTION) { 204 + if (strcmp(setting.name, "KOReader Sync") == 0) { 205 + xSemaphoreTake(renderingMutex, portMAX_DELAY); 206 + exitActivity(); 207 + enterNewActivity(new KOReaderSettingsActivity(renderer, mappedInput, [this] { 208 + exitActivity(); 209 + updateRequired = true; 210 + })); 211 + xSemaphoreGive(renderingMutex); 212 + } else if (strcmp(setting.name, "OPDS Browser") == 0) { 213 + xSemaphoreTake(renderingMutex, portMAX_DELAY); 214 + exitActivity(); 215 + enterNewActivity(new CalibreSettingsActivity(renderer, mappedInput, [this] { 216 + exitActivity(); 217 + updateRequired = true; 218 + })); 219 + xSemaphoreGive(renderingMutex); 220 + } else if (strcmp(setting.name, "Clear Cache") == 0) { 221 + xSemaphoreTake(renderingMutex, portMAX_DELAY); 222 + exitActivity(); 223 + enterNewActivity(new ClearCacheActivity(renderer, mappedInput, [this] { 224 + exitActivity(); 225 + updateRequired = true; 226 + })); 227 + xSemaphoreGive(renderingMutex); 228 + } else if (strcmp(setting.name, "Check for updates") == 0) { 229 + xSemaphoreTake(renderingMutex, portMAX_DELAY); 230 + exitActivity(); 231 + enterNewActivity(new OtaUpdateActivity(renderer, mappedInput, [this] { 232 + exitActivity(); 233 + updateRequired = true; 234 + })); 235 + xSemaphoreGive(renderingMutex); 236 + } 237 + } else { 238 + return; 155 239 } 156 240 157 - enterNewActivity(new CategorySettingsActivity(renderer, mappedInput, categoryNames[categoryIndex], settingsList, 158 - settingsCount, [this] { 159 - exitActivity(); 160 - updateRequired = true; 161 - })); 162 - xSemaphoreGive(renderingMutex); 241 + SETTINGS.saveToFile(); 163 242 } 164 243 165 244 void SettingsActivity::displayTaskLoop() { ··· 180 259 const auto pageWidth = renderer.getScreenWidth(); 181 260 const auto pageHeight = renderer.getScreenHeight(); 182 261 183 - // Draw header 184 - renderer.drawCenteredText(UI_12_FONT_ID, 15, "Settings", true, EpdFontFamily::BOLD); 262 + auto metrics = UITheme::getInstance().getMetrics(); 185 263 186 - // Draw selection 187 - renderer.fillRect(0, 60 + selectedCategoryIndex * 30 - 2, pageWidth - 1, 30); 264 + GUI.drawHeader(renderer, Rect{0, metrics.topPadding, pageWidth, metrics.headerHeight}, "Settings"); 188 265 189 - // Draw all categories 266 + std::vector<TabInfo> tabs; 267 + tabs.reserve(categoryCount); 190 268 for (int i = 0; i < categoryCount; i++) { 191 - const int categoryY = 60 + i * 30; // 30 pixels between categories 269 + tabs.push_back({categoryNames[i], selectedCategoryIndex == i}); 270 + } 271 + GUI.drawTabBar(renderer, Rect{0, metrics.topPadding + metrics.headerHeight, pageWidth, metrics.tabBarHeight}, tabs, 272 + selectedSettingIndex == 0); 192 273 193 - // Draw category name 194 - renderer.drawText(UI_10_FONT_ID, 20, categoryY, categoryNames[i], i != selectedCategoryIndex); 195 - } 274 + GUI.drawList( 275 + renderer, 276 + Rect{0, metrics.topPadding + metrics.headerHeight + metrics.tabBarHeight + metrics.verticalSpacing, pageWidth, 277 + pageHeight - (metrics.topPadding + metrics.headerHeight + metrics.tabBarHeight + metrics.buttonHintsHeight + 278 + metrics.verticalSpacing * 2)}, 279 + settingsCount, selectedSettingIndex - 1, [this](int index) { return std::string(settingsList[index].name); }, 280 + nullptr, nullptr, 281 + [this](int i) { 282 + const auto& setting = settingsList[i]; 283 + std::string valueText = ""; 284 + if (settingsList[i].type == SettingType::TOGGLE && settingsList[i].valuePtr != nullptr) { 285 + const bool value = SETTINGS.*(settingsList[i].valuePtr); 286 + valueText = value ? "ON" : "OFF"; 287 + } else if (settingsList[i].type == SettingType::ENUM && settingsList[i].valuePtr != nullptr) { 288 + const uint8_t value = SETTINGS.*(settingsList[i].valuePtr); 289 + valueText = settingsList[i].enumValues[value]; 290 + } else if (settingsList[i].type == SettingType::VALUE && settingsList[i].valuePtr != nullptr) { 291 + valueText = std::to_string(SETTINGS.*(settingsList[i].valuePtr)); 292 + } 293 + return valueText; 294 + }); 196 295 197 - // Draw version text above button hints 198 - renderer.drawText(SMALL_FONT_ID, pageWidth - 20 - renderer.getTextWidth(SMALL_FONT_ID, CROSSPOINT_VERSION), 199 - pageHeight - 60, CROSSPOINT_VERSION); 296 + // Draw version text 297 + renderer.drawText(SMALL_FONT_ID, 298 + pageWidth - metrics.versionTextRightX - renderer.getTextWidth(SMALL_FONT_ID, CROSSPOINT_VERSION), 299 + metrics.versionTextY, CROSSPOINT_VERSION); 200 300 201 301 // Draw help text 202 - const auto labels = mappedInput.mapLabels("« Back", "Select", "", ""); 203 - renderer.drawButtonHints(UI_10_FONT_ID, labels.btn1, labels.btn2, labels.btn3, labels.btn4); 302 + const auto labels = mappedInput.mapLabels("« Back", "Toggle", "Up", "Down"); 303 + GUI.drawButtonHints(renderer, labels.btn1, labels.btn2, labels.btn3, labels.btn4); 204 304 205 305 // Always use standard refresh for settings screen 206 306 renderer.displayBuffer();
+35 -1
src/activities/settings/SettingsActivity.h
··· 10 10 #include "activities/ActivityWithSubactivity.h" 11 11 12 12 class CrossPointSettings; 13 - struct SettingInfo; 13 + 14 + enum class SettingType { TOGGLE, ENUM, ACTION, VALUE }; 15 + 16 + struct SettingInfo { 17 + const char* name; 18 + SettingType type; 19 + uint8_t CrossPointSettings::* valuePtr; 20 + std::vector<std::string> enumValues; 21 + 22 + struct ValueRange { 23 + uint8_t min; 24 + uint8_t max; 25 + uint8_t step; 26 + }; 27 + ValueRange valueRange; 28 + 29 + static SettingInfo Toggle(const char* name, uint8_t CrossPointSettings::* ptr) { 30 + return {name, SettingType::TOGGLE, ptr}; 31 + } 32 + 33 + static SettingInfo Enum(const char* name, uint8_t CrossPointSettings::* ptr, std::vector<std::string> values) { 34 + return {name, SettingType::ENUM, ptr, std::move(values)}; 35 + } 36 + 37 + static SettingInfo Action(const char* name) { return {name, SettingType::ACTION, nullptr}; } 38 + 39 + static SettingInfo Value(const char* name, uint8_t CrossPointSettings::* ptr, const ValueRange valueRange) { 40 + return {name, SettingType::VALUE, ptr, {}, valueRange}; 41 + } 42 + }; 14 43 15 44 class SettingsActivity final : public ActivityWithSubactivity { 16 45 TaskHandle_t displayTaskHandle = nullptr; 17 46 SemaphoreHandle_t renderingMutex = nullptr; 18 47 bool updateRequired = false; 19 48 int selectedCategoryIndex = 0; // Currently selected category 49 + int selectedSettingIndex = 0; 50 + int settingsCount = 0; 51 + const SettingInfo* settingsList = nullptr; 52 + 20 53 const std::function<void()> onGoHome; 21 54 22 55 static constexpr int categoryCount = 4; ··· 26 59 [[noreturn]] void displayTaskLoop(); 27 60 void render() const; 28 61 void enterCategory(int categoryIndex); 62 + void toggleCurrentSetting(); 29 63 30 64 public: 31 65 explicit SettingsActivity(GfxRenderer& renderer, MappedInputManager& mappedInput,
+3 -2
src/activities/util/KeyboardEntryActivity.cpp
··· 1 1 #include "KeyboardEntryActivity.h" 2 2 3 3 #include "MappedInputManager.h" 4 + #include "components/UITheme.h" 4 5 #include "fontIds.h" 5 6 6 7 // Keyboard layouts - lowercase ··· 354 355 355 356 // Draw help text 356 357 const auto labels = mappedInput.mapLabels("« Back", "Select", "Left", "Right"); 357 - renderer.drawButtonHints(UI_10_FONT_ID, labels.btn1, labels.btn2, labels.btn3, labels.btn4); 358 + GUI.drawButtonHints(renderer, labels.btn1, labels.btn2, labels.btn3, labels.btn4); 358 359 359 360 // Draw side button hints for Up/Down navigation 360 - renderer.drawSideButtonHints(UI_10_FONT_ID, "Up", "Down"); 361 + GUI.drawSideButtonHints(renderer, "Up", "Down"); 361 362 362 363 renderer.displayBuffer(); 363 364 }
+62
src/components/UITheme.cpp
··· 1 + #include "UITheme.h" 2 + 3 + #include <GfxRenderer.h> 4 + 5 + #include <memory> 6 + 7 + #include "RecentBooksStore.h" 8 + #include "components/themes/BaseTheme.h" 9 + #include "components/themes/lyra/LyraTheme.h" 10 + 11 + UITheme UITheme::instance; 12 + 13 + UITheme::UITheme() { 14 + auto themeType = static_cast<CrossPointSettings::UI_THEME>(SETTINGS.uiTheme); 15 + setTheme(themeType); 16 + } 17 + 18 + void UITheme::reload() { 19 + auto themeType = static_cast<CrossPointSettings::UI_THEME>(SETTINGS.uiTheme); 20 + setTheme(themeType); 21 + } 22 + 23 + void UITheme::setTheme(CrossPointSettings::UI_THEME type) { 24 + switch (type) { 25 + case CrossPointSettings::UI_THEME::CLASSIC: 26 + Serial.printf("[%lu] [UI] Using Classic theme\n", millis()); 27 + currentTheme = new BaseTheme(); 28 + currentMetrics = &BaseMetrics::values; 29 + break; 30 + case CrossPointSettings::UI_THEME::LYRA: 31 + Serial.printf("[%lu] [UI] Using Lyra theme\n", millis()); 32 + currentTheme = new LyraTheme(); 33 + currentMetrics = &LyraMetrics::values; 34 + break; 35 + } 36 + } 37 + 38 + int UITheme::getNumberOfItemsPerPage(const GfxRenderer& renderer, bool hasHeader, bool hasTabBar, bool hasButtonHints, 39 + bool hasSubtitle) { 40 + const ThemeMetrics& metrics = UITheme::getInstance().getMetrics(); 41 + int reservedHeight = metrics.topPadding; 42 + if (hasHeader) { 43 + reservedHeight += metrics.headerHeight; 44 + } 45 + if (hasTabBar) { 46 + reservedHeight += metrics.tabBarHeight + metrics.verticalSpacing; 47 + } 48 + if (hasButtonHints) { 49 + reservedHeight += metrics.verticalSpacing + metrics.buttonHintsHeight; 50 + } 51 + const int availableHeight = renderer.getScreenHeight() - reservedHeight; 52 + int rowHeight = hasSubtitle ? metrics.listWithSubtitleRowHeight : metrics.listRowHeight; 53 + return availableHeight / rowHeight; 54 + } 55 + 56 + std::string UITheme::getCoverThumbPath(std::string coverBmpPath, int coverHeight) { 57 + size_t pos = coverBmpPath.find("[HEIGHT]", 0); 58 + if (pos != std::string::npos) { 59 + coverBmpPath.replace(pos, 8, std::to_string(coverHeight)); 60 + } 61 + return coverBmpPath; 62 + }
+31
src/components/UITheme.h
··· 1 + #pragma once 2 + 3 + #include <functional> 4 + #include <vector> 5 + 6 + #include "CrossPointSettings.h" 7 + #include "components/themes/BaseTheme.h" 8 + 9 + class UITheme { 10 + // Static instance 11 + static UITheme instance; 12 + 13 + public: 14 + UITheme(); 15 + static UITheme& getInstance() { return instance; } 16 + 17 + const ThemeMetrics& getMetrics() { return *currentMetrics; } 18 + const BaseTheme& getTheme() { return *currentTheme; } 19 + void reload(); 20 + void setTheme(CrossPointSettings::UI_THEME type); 21 + static int getNumberOfItemsPerPage(const GfxRenderer& renderer, bool hasHeader, bool hasTabBar, bool hasButtonHints, 22 + bool hasSubtitle); 23 + static std::string getCoverThumbPath(std::string coverBmpPath, int coverHeight); 24 + 25 + private: 26 + const ThemeMetrics* currentMetrics; 27 + const BaseTheme* currentTheme; 28 + }; 29 + 30 + // Helper macro to access current theme 31 + #define GUI UITheme::getInstance().getTheme()
+643
src/components/themes/BaseTheme.cpp
··· 1 + #include "BaseTheme.h" 2 + 3 + #include <GfxRenderer.h> 4 + #include <SDCardManager.h> 5 + #include <Utf8.h> 6 + 7 + #include <cstdint> 8 + #include <string> 9 + 10 + #include "Battery.h" 11 + #include "RecentBooksStore.h" 12 + #include "components/UITheme.h" 13 + #include "fontIds.h" 14 + 15 + // Internal constants 16 + namespace { 17 + constexpr int batteryPercentSpacing = 4; 18 + constexpr int homeMenuMargin = 20; 19 + constexpr int homeMarginTop = 30; 20 + } // namespace 21 + 22 + void BaseTheme::drawBattery(const GfxRenderer& renderer, Rect rect, const bool showPercentage) const { 23 + // Left aligned battery icon and percentage 24 + // TODO refactor this so the percentage doesnt change after we position it 25 + const uint16_t percentage = battery.readPercentage(); 26 + if (showPercentage) { 27 + const auto percentageText = std::to_string(percentage) + "%"; 28 + renderer.drawText(SMALL_FONT_ID, rect.x + batteryPercentSpacing + BaseMetrics::values.batteryWidth, rect.y, 29 + percentageText.c_str()); 30 + } 31 + // 1 column on left, 2 columns on right, 5 columns of battery body 32 + const int x = rect.x; 33 + const int y = rect.y + 6; 34 + const int battWidth = BaseMetrics::values.batteryWidth; 35 + 36 + // Top line 37 + renderer.drawLine(x + 1, y, x + battWidth - 3, y); 38 + // Bottom line 39 + renderer.drawLine(x + 1, y + rect.height - 1, x + battWidth - 3, y + rect.height - 1); 40 + // Left line 41 + renderer.drawLine(x, y + 1, x, y + rect.height - 2); 42 + // Battery end 43 + renderer.drawLine(x + battWidth - 2, y + 1, x + battWidth - 2, y + rect.height - 2); 44 + renderer.drawPixel(x + battWidth - 1, y + 3); 45 + renderer.drawPixel(x + battWidth - 1, y + rect.height - 4); 46 + renderer.drawLine(x + battWidth - 0, y + 4, x + battWidth - 0, y + rect.height - 5); 47 + 48 + // The +1 is to round up, so that we always fill at least one pixel 49 + int filledWidth = percentage * (rect.width - 5) / 100 + 1; 50 + if (filledWidth > rect.width - 5) { 51 + filledWidth = rect.width - 5; // Ensure we don't overflow 52 + } 53 + 54 + renderer.fillRect(x + 2, y + 2, filledWidth, rect.height - 4); 55 + } 56 + 57 + void BaseTheme::drawProgressBar(const GfxRenderer& renderer, Rect rect, const size_t current, 58 + const size_t total) const { 59 + if (total == 0) { 60 + return; 61 + } 62 + 63 + // Use 64-bit arithmetic to avoid overflow for large files 64 + const int percent = static_cast<int>((static_cast<uint64_t>(current) * 100) / total); 65 + 66 + // Draw outline 67 + renderer.drawRect(rect.x, rect.y, rect.width, rect.height); 68 + 69 + // Draw filled portion 70 + const int fillWidth = (rect.width - 4) * percent / 100; 71 + if (fillWidth > 0) { 72 + renderer.fillRect(rect.x + 2, rect.y + 2, fillWidth, rect.height - 4); 73 + } 74 + 75 + // Draw percentage text centered below bar 76 + const std::string percentText = std::to_string(percent) + "%"; 77 + renderer.drawCenteredText(UI_10_FONT_ID, rect.y + rect.height + 15, percentText.c_str()); 78 + } 79 + 80 + void BaseTheme::drawButtonHints(GfxRenderer& renderer, const char* btn1, const char* btn2, const char* btn3, 81 + const char* btn4) const { 82 + const GfxRenderer::Orientation orig_orientation = renderer.getOrientation(); 83 + renderer.setOrientation(GfxRenderer::Orientation::Portrait); 84 + 85 + const int pageHeight = renderer.getScreenHeight(); 86 + constexpr int buttonWidth = 106; 87 + constexpr int buttonHeight = BaseMetrics::values.buttonHintsHeight; 88 + constexpr int buttonY = BaseMetrics::values.buttonHintsHeight; // Distance from bottom 89 + constexpr int textYOffset = 7; // Distance from top of button to text baseline 90 + constexpr int buttonPositions[] = {25, 130, 245, 350}; 91 + const char* labels[] = {btn1, btn2, btn3, btn4}; 92 + 93 + for (int i = 0; i < 4; i++) { 94 + // Only draw if the label is non-empty 95 + if (labels[i] != nullptr && labels[i][0] != '\0') { 96 + const int x = buttonPositions[i]; 97 + renderer.fillRect(x, pageHeight - buttonY, buttonWidth, buttonHeight, false); 98 + renderer.drawRect(x, pageHeight - buttonY, buttonWidth, buttonHeight); 99 + const int textWidth = renderer.getTextWidth(UI_10_FONT_ID, labels[i]); 100 + const int textX = x + (buttonWidth - 1 - textWidth) / 2; 101 + renderer.drawText(UI_10_FONT_ID, textX, pageHeight - buttonY + textYOffset, labels[i]); 102 + } 103 + } 104 + 105 + renderer.setOrientation(orig_orientation); 106 + } 107 + 108 + void BaseTheme::drawSideButtonHints(const GfxRenderer& renderer, const char* topBtn, const char* bottomBtn) const { 109 + const int screenWidth = renderer.getScreenWidth(); 110 + constexpr int buttonWidth = BaseMetrics::values.sideButtonHintsWidth; // Width on screen (height when rotated) 111 + constexpr int buttonHeight = 80; // Height on screen (width when rotated) 112 + constexpr int buttonX = 4; // Distance from right edge 113 + // Position for the button group - buttons share a border so they're adjacent 114 + constexpr int topButtonY = 345; // Top button position 115 + 116 + const char* labels[] = {topBtn, bottomBtn}; 117 + 118 + // Draw the shared border for both buttons as one unit 119 + const int x = screenWidth - buttonX - buttonWidth; 120 + 121 + // Draw top button outline (3 sides, bottom open) 122 + if (topBtn != nullptr && topBtn[0] != '\0') { 123 + renderer.drawLine(x, topButtonY, x + buttonWidth - 1, topButtonY); // Top 124 + renderer.drawLine(x, topButtonY, x, topButtonY + buttonHeight - 1); // Left 125 + renderer.drawLine(x + buttonWidth - 1, topButtonY, x + buttonWidth - 1, topButtonY + buttonHeight - 1); // Right 126 + } 127 + 128 + // Draw shared middle border 129 + if ((topBtn != nullptr && topBtn[0] != '\0') || (bottomBtn != nullptr && bottomBtn[0] != '\0')) { 130 + renderer.drawLine(x, topButtonY + buttonHeight, x + buttonWidth - 1, topButtonY + buttonHeight); // Shared border 131 + } 132 + 133 + // Draw bottom button outline (3 sides, top is shared) 134 + if (bottomBtn != nullptr && bottomBtn[0] != '\0') { 135 + renderer.drawLine(x, topButtonY + buttonHeight, x, topButtonY + 2 * buttonHeight - 1); // Left 136 + renderer.drawLine(x + buttonWidth - 1, topButtonY + buttonHeight, x + buttonWidth - 1, 137 + topButtonY + 2 * buttonHeight - 1); // Right 138 + renderer.drawLine(x, topButtonY + 2 * buttonHeight - 1, x + buttonWidth - 1, 139 + topButtonY + 2 * buttonHeight - 1); // Bottom 140 + } 141 + 142 + // Draw text for each button 143 + for (int i = 0; i < 2; i++) { 144 + if (labels[i] != nullptr && labels[i][0] != '\0') { 145 + const int y = topButtonY + i * buttonHeight; 146 + 147 + // Draw rotated text centered in the button 148 + const int textWidth = renderer.getTextWidth(SMALL_FONT_ID, labels[i]); 149 + const int textHeight = renderer.getTextHeight(SMALL_FONT_ID); 150 + 151 + // Center the rotated text in the button 152 + const int textX = x + (buttonWidth - textHeight) / 2; 153 + const int textY = y + (buttonHeight + textWidth) / 2; 154 + 155 + renderer.drawTextRotated90CW(SMALL_FONT_ID, textX, textY, labels[i]); 156 + } 157 + } 158 + } 159 + 160 + void BaseTheme::drawList(const GfxRenderer& renderer, Rect rect, int itemCount, int selectedIndex, 161 + const std::function<std::string(int index)>& rowTitle, 162 + const std::function<std::string(int index)>& rowSubtitle, 163 + const std::function<std::string(int index)>& rowIcon, 164 + const std::function<std::string(int index)>& rowValue) const { 165 + int rowHeight = 166 + (rowSubtitle != nullptr) ? BaseMetrics::values.listWithSubtitleRowHeight : BaseMetrics::values.listRowHeight; 167 + int pageItems = rect.height / rowHeight; 168 + 169 + const int totalPages = (itemCount + pageItems - 1) / pageItems; 170 + if (totalPages > 1) { 171 + constexpr int indicatorWidth = 20; 172 + constexpr int arrowSize = 6; 173 + constexpr int margin = 15; // Offset from right edge 174 + 175 + const int centerX = rect.x + rect.width - indicatorWidth / 2 - margin; 176 + const int indicatorTop = rect.y; // Offset to avoid overlapping side button hints 177 + const int indicatorBottom = rect.y + rect.height - 30; 178 + 179 + // Draw up arrow at top (^) - narrow point at top, wide base at bottom 180 + for (int i = 0; i < arrowSize; ++i) { 181 + const int lineWidth = 1 + i * 2; 182 + const int startX = centerX - i; 183 + renderer.drawLine(startX, indicatorTop + i, startX + lineWidth - 1, indicatorTop + i); 184 + } 185 + 186 + // Draw down arrow at bottom (v) - wide base at top, narrow point at bottom 187 + for (int i = 0; i < arrowSize; ++i) { 188 + const int lineWidth = 1 + (arrowSize - 1 - i) * 2; 189 + const int startX = centerX - (arrowSize - 1 - i); 190 + renderer.drawLine(startX, indicatorBottom - arrowSize + 1 + i, startX + lineWidth - 1, 191 + indicatorBottom - arrowSize + 1 + i); 192 + } 193 + } 194 + 195 + // Draw selection 196 + int contentWidth = rect.width - 5; 197 + if (selectedIndex >= 0) { 198 + renderer.fillRect(0, rect.y + selectedIndex % pageItems * rowHeight - 2, rect.width, rowHeight); 199 + } 200 + // Draw all items 201 + const auto pageStartIndex = selectedIndex / pageItems * pageItems; 202 + for (int i = pageStartIndex; i < itemCount && i < pageStartIndex + pageItems; i++) { 203 + const int itemY = rect.y + (i % pageItems) * rowHeight; 204 + int textWidth = contentWidth - BaseMetrics::values.contentSidePadding * 2 - (rowValue != nullptr ? 60 : 0); 205 + 206 + // Draw name 207 + auto itemName = rowTitle(i); 208 + auto font = (rowSubtitle != nullptr) ? UI_12_FONT_ID : UI_10_FONT_ID; 209 + auto item = renderer.truncatedText(font, itemName.c_str(), textWidth); 210 + renderer.drawText(font, rect.x + BaseMetrics::values.contentSidePadding, itemY, item.c_str(), i != selectedIndex); 211 + 212 + if (rowSubtitle != nullptr) { 213 + // Draw subtitle 214 + std::string subtitleText = rowSubtitle(i); 215 + auto subtitle = renderer.truncatedText(UI_10_FONT_ID, subtitleText.c_str(), textWidth); 216 + renderer.drawText(UI_10_FONT_ID, rect.x + BaseMetrics::values.contentSidePadding, itemY + 30, subtitle.c_str(), 217 + i != selectedIndex); 218 + } 219 + 220 + if (rowValue != nullptr) { 221 + // Draw value 222 + std::string valueText = rowValue(i); 223 + const auto valueTextWidth = renderer.getTextWidth(UI_10_FONT_ID, valueText.c_str()); 224 + renderer.drawText(UI_10_FONT_ID, rect.x + contentWidth - BaseMetrics::values.contentSidePadding - valueTextWidth, 225 + itemY, valueText.c_str(), i != selectedIndex); 226 + } 227 + } 228 + } 229 + 230 + void BaseTheme::drawHeader(const GfxRenderer& renderer, Rect rect, const char* title) const { 231 + const bool showBatteryPercentage = 232 + SETTINGS.hideBatteryPercentage != CrossPointSettings::HIDE_BATTERY_PERCENTAGE::HIDE_ALWAYS; 233 + int batteryX = rect.x + rect.width - BaseMetrics::values.contentSidePadding - BaseMetrics::values.batteryWidth; 234 + if (showBatteryPercentage) { 235 + const uint16_t percentage = battery.readPercentage(); 236 + const auto percentageText = std::to_string(percentage) + "%"; 237 + batteryX -= renderer.getTextWidth(SMALL_FONT_ID, percentageText.c_str()); 238 + } 239 + drawBattery(renderer, Rect{batteryX, rect.y + 5, BaseMetrics::values.batteryWidth, BaseMetrics::values.batteryHeight}, 240 + showBatteryPercentage); 241 + 242 + if (title) { 243 + int padding = rect.width - batteryX; 244 + auto truncatedTitle = renderer.truncatedText(UI_12_FONT_ID, title, 245 + rect.width - padding * 2 - BaseMetrics::values.contentSidePadding * 2, 246 + EpdFontFamily::BOLD); 247 + renderer.drawCenteredText(UI_12_FONT_ID, rect.y + 5, truncatedTitle.c_str(), true, EpdFontFamily::BOLD); 248 + } 249 + } 250 + 251 + void BaseTheme::drawTabBar(const GfxRenderer& renderer, const Rect rect, const std::vector<TabInfo>& tabs, 252 + bool selected) const { 253 + constexpr int underlineHeight = 2; // Height of selection underline 254 + constexpr int underlineGap = 4; // Gap between text and underline 255 + 256 + const int lineHeight = renderer.getLineHeight(UI_12_FONT_ID); 257 + 258 + int currentX = rect.x + BaseMetrics::values.contentSidePadding; 259 + 260 + for (const auto& tab : tabs) { 261 + const int textWidth = 262 + renderer.getTextWidth(UI_12_FONT_ID, tab.label, tab.selected ? EpdFontFamily::BOLD : EpdFontFamily::REGULAR); 263 + 264 + // Draw underline for selected tab 265 + if (tab.selected) { 266 + if (selected) { 267 + renderer.fillRect(currentX - 3, rect.y, textWidth + 6, lineHeight + underlineGap); 268 + } else { 269 + renderer.fillRect(currentX, rect.y + lineHeight + underlineGap, textWidth, underlineHeight); 270 + } 271 + } 272 + 273 + // Draw tab label 274 + renderer.drawText(UI_12_FONT_ID, currentX, rect.y, tab.label, !(tab.selected && selected), 275 + tab.selected ? EpdFontFamily::BOLD : EpdFontFamily::REGULAR); 276 + 277 + currentX += textWidth + BaseMetrics::values.tabSpacing; 278 + } 279 + } 280 + 281 + // Draw the "Recent Book" cover card on the home screen 282 + // TODO: Refactor method to make it cleaner, split into smaller methods 283 + void BaseTheme::drawRecentBookCover(GfxRenderer& renderer, Rect rect, const std::vector<RecentBook>& recentBooks, 284 + const int selectorIndex, bool& coverRendered, bool& coverBufferStored, 285 + bool& bufferRestored, std::function<bool()> storeCoverBuffer) const { 286 + // --- Top "book" card for the current title (selectorIndex == 0) --- 287 + const int bookWidth = rect.width / 2; 288 + const int bookHeight = rect.height; 289 + const int bookX = (rect.width - bookWidth) / 2; 290 + const int bookY = rect.y; 291 + const bool hasContinueReading = !recentBooks.empty(); 292 + const bool bookSelected = hasContinueReading && selectorIndex == 0; 293 + 294 + // Bookmark dimensions (used in multiple places) 295 + const int bookmarkWidth = bookWidth / 8; 296 + const int bookmarkHeight = bookHeight / 5; 297 + const int bookmarkX = bookX + bookWidth - bookmarkWidth - 10; 298 + const int bookmarkY = bookY + 5; 299 + 300 + // Draw book card regardless, fill with message based on `hasContinueReading` 301 + { 302 + // Draw cover image as background if available (inside the box) 303 + // Only load from SD on first render, then use stored buffer 304 + if (hasContinueReading && !recentBooks[0].coverBmpPath.empty() && !coverRendered) { 305 + const std::string coverBmpPath = 306 + UITheme::getCoverThumbPath(recentBooks[0].coverBmpPath, BaseMetrics::values.homeCoverHeight); 307 + 308 + // First time: load cover from SD and render 309 + FsFile file; 310 + if (SdMan.openFileForRead("HOME", coverBmpPath, file)) { 311 + Bitmap bitmap(file); 312 + if (bitmap.parseHeaders() == BmpReaderError::Ok) { 313 + // Calculate position to center image within the book card 314 + int coverX, coverY; 315 + 316 + if (bitmap.getWidth() > bookWidth || bitmap.getHeight() > bookHeight) { 317 + const float imgRatio = static_cast<float>(bitmap.getWidth()) / static_cast<float>(bitmap.getHeight()); 318 + const float boxRatio = static_cast<float>(bookWidth) / static_cast<float>(bookHeight); 319 + 320 + if (imgRatio > boxRatio) { 321 + coverX = bookX; 322 + coverY = bookY + (bookHeight - static_cast<int>(bookWidth / imgRatio)) / 2; 323 + } else { 324 + coverX = bookX + (bookWidth - static_cast<int>(bookHeight * imgRatio)) / 2; 325 + coverY = bookY; 326 + } 327 + } else { 328 + coverX = bookX + (bookWidth - bitmap.getWidth()) / 2; 329 + coverY = bookY + (bookHeight - bitmap.getHeight()) / 2; 330 + } 331 + 332 + // Draw the cover image centered within the book card 333 + renderer.drawBitmap(bitmap, coverX, coverY, bookWidth, bookHeight); 334 + 335 + // Draw border around the card 336 + renderer.drawRect(bookX, bookY, bookWidth, bookHeight); 337 + 338 + // No bookmark ribbon when cover is shown - it would just cover the art 339 + 340 + // Store the buffer with cover image for fast navigation 341 + coverBufferStored = storeCoverBuffer(); 342 + coverRendered = true; 343 + 344 + // First render: if selected, draw selection indicators now 345 + if (bookSelected) { 346 + renderer.drawRect(bookX + 1, bookY + 1, bookWidth - 2, bookHeight - 2); 347 + renderer.drawRect(bookX + 2, bookY + 2, bookWidth - 4, bookHeight - 4); 348 + } 349 + } 350 + file.close(); 351 + } 352 + } else if (!bufferRestored && !coverRendered) { 353 + // No cover image: draw border or fill, plus bookmark as visual flair 354 + if (bookSelected) { 355 + renderer.fillRect(bookX, bookY, bookWidth, bookHeight); 356 + } else { 357 + renderer.drawRect(bookX, bookY, bookWidth, bookHeight); 358 + } 359 + 360 + // Draw bookmark ribbon when no cover image (visual decoration) 361 + if (hasContinueReading) { 362 + const int notchDepth = bookmarkHeight / 3; 363 + const int centerX = bookmarkX + bookmarkWidth / 2; 364 + 365 + const int xPoints[5] = { 366 + bookmarkX, // top-left 367 + bookmarkX + bookmarkWidth, // top-right 368 + bookmarkX + bookmarkWidth, // bottom-right 369 + centerX, // center notch point 370 + bookmarkX // bottom-left 371 + }; 372 + const int yPoints[5] = { 373 + bookmarkY, // top-left 374 + bookmarkY, // top-right 375 + bookmarkY + bookmarkHeight, // bottom-right 376 + bookmarkY + bookmarkHeight - notchDepth, // center notch point 377 + bookmarkY + bookmarkHeight // bottom-left 378 + }; 379 + 380 + // Draw bookmark ribbon (inverted if selected) 381 + renderer.fillPolygon(xPoints, yPoints, 5, !bookSelected); 382 + } 383 + } 384 + 385 + // If buffer was restored, draw selection indicators if needed 386 + if (bufferRestored && bookSelected && coverRendered) { 387 + // Draw selection border (no bookmark inversion needed since cover has no bookmark) 388 + renderer.drawRect(bookX + 1, bookY + 1, bookWidth - 2, bookHeight - 2); 389 + renderer.drawRect(bookX + 2, bookY + 2, bookWidth - 4, bookHeight - 4); 390 + } else if (!coverRendered && !bufferRestored) { 391 + // Selection border already handled above in the no-cover case 392 + } 393 + } 394 + 395 + if (hasContinueReading) { 396 + const std::string& lastBookTitle = recentBooks[0].title; 397 + const std::string& lastBookAuthor = recentBooks[0].author; 398 + 399 + // Invert text colors based on selection state: 400 + // - With cover: selected = white text on black box, unselected = black text on white box 401 + // - Without cover: selected = white text on black card, unselected = black text on white card 402 + 403 + // Split into words (avoid stringstream to keep this light on the MCU) 404 + std::vector<std::string> words; 405 + words.reserve(8); 406 + size_t pos = 0; 407 + while (pos < lastBookTitle.size()) { 408 + while (pos < lastBookTitle.size() && lastBookTitle[pos] == ' ') { 409 + ++pos; 410 + } 411 + if (pos >= lastBookTitle.size()) { 412 + break; 413 + } 414 + const size_t start = pos; 415 + while (pos < lastBookTitle.size() && lastBookTitle[pos] != ' ') { 416 + ++pos; 417 + } 418 + words.emplace_back(lastBookTitle.substr(start, pos - start)); 419 + } 420 + 421 + std::vector<std::string> lines; 422 + std::string currentLine; 423 + // Extra padding inside the card so text doesn't hug the border 424 + const int maxLineWidth = bookWidth - 40; 425 + const int spaceWidth = renderer.getSpaceWidth(UI_12_FONT_ID); 426 + 427 + for (auto& i : words) { 428 + // If we just hit the line limit (3), stop processing words 429 + if (lines.size() >= 3) { 430 + // Limit to 3 lines 431 + // Still have words left, so add ellipsis to last line 432 + lines.back().append("..."); 433 + 434 + while (!lines.back().empty() && renderer.getTextWidth(UI_12_FONT_ID, lines.back().c_str()) > maxLineWidth) { 435 + // Remove "..." first, then remove one UTF-8 char, then add "..." back 436 + lines.back().resize(lines.back().size() - 3); // Remove "..." 437 + utf8RemoveLastChar(lines.back()); 438 + lines.back().append("..."); 439 + } 440 + break; 441 + } 442 + 443 + int wordWidth = renderer.getTextWidth(UI_12_FONT_ID, i.c_str()); 444 + while (wordWidth > maxLineWidth && !i.empty()) { 445 + // Word itself is too long, trim it (UTF-8 safe) 446 + utf8RemoveLastChar(i); 447 + // Check if we have room for ellipsis 448 + std::string withEllipsis = i + "..."; 449 + wordWidth = renderer.getTextWidth(UI_12_FONT_ID, withEllipsis.c_str()); 450 + if (wordWidth <= maxLineWidth) { 451 + i = withEllipsis; 452 + break; 453 + } 454 + } 455 + 456 + int newLineWidth = renderer.getTextWidth(UI_12_FONT_ID, currentLine.c_str()); 457 + if (newLineWidth > 0) { 458 + newLineWidth += spaceWidth; 459 + } 460 + newLineWidth += wordWidth; 461 + 462 + if (newLineWidth > maxLineWidth && !currentLine.empty()) { 463 + // New line too long, push old line 464 + lines.push_back(currentLine); 465 + currentLine = i; 466 + } else { 467 + currentLine.append(" ").append(i); 468 + } 469 + } 470 + 471 + // If lower than the line limit, push remaining words 472 + if (!currentLine.empty() && lines.size() < 3) { 473 + lines.push_back(currentLine); 474 + } 475 + 476 + // Book title text 477 + int totalTextHeight = renderer.getLineHeight(UI_12_FONT_ID) * static_cast<int>(lines.size()); 478 + if (!lastBookAuthor.empty()) { 479 + totalTextHeight += renderer.getLineHeight(UI_10_FONT_ID) * 3 / 2; 480 + } 481 + 482 + // Vertically center the title block within the card 483 + int titleYStart = bookY + (bookHeight - totalTextHeight) / 2; 484 + 485 + // If cover image was rendered, draw box behind title and author 486 + if (coverRendered) { 487 + constexpr int boxPadding = 8; 488 + // Calculate the max text width for the box 489 + int maxTextWidth = 0; 490 + for (const auto& line : lines) { 491 + const int lineWidth = renderer.getTextWidth(UI_12_FONT_ID, line.c_str()); 492 + if (lineWidth > maxTextWidth) { 493 + maxTextWidth = lineWidth; 494 + } 495 + } 496 + if (!lastBookAuthor.empty()) { 497 + std::string trimmedAuthor = lastBookAuthor; 498 + while (renderer.getTextWidth(UI_10_FONT_ID, trimmedAuthor.c_str()) > maxLineWidth && !trimmedAuthor.empty()) { 499 + utf8RemoveLastChar(trimmedAuthor); 500 + } 501 + if (renderer.getTextWidth(UI_10_FONT_ID, trimmedAuthor.c_str()) < 502 + renderer.getTextWidth(UI_10_FONT_ID, lastBookAuthor.c_str())) { 503 + trimmedAuthor.append("..."); 504 + } 505 + const int authorWidth = renderer.getTextWidth(UI_10_FONT_ID, trimmedAuthor.c_str()); 506 + if (authorWidth > maxTextWidth) { 507 + maxTextWidth = authorWidth; 508 + } 509 + } 510 + 511 + const int boxWidth = maxTextWidth + boxPadding * 2; 512 + const int boxHeight = totalTextHeight + boxPadding * 2; 513 + const int boxX = (rect.width - boxWidth) / 2; 514 + const int boxY = titleYStart - boxPadding; 515 + 516 + // Draw box (inverted when selected: black box instead of white) 517 + renderer.fillRect(boxX, boxY, boxWidth, boxHeight, bookSelected); 518 + // Draw border around the box (inverted when selected: white border instead of black) 519 + renderer.drawRect(boxX, boxY, boxWidth, boxHeight, !bookSelected); 520 + } 521 + 522 + for (const auto& line : lines) { 523 + renderer.drawCenteredText(UI_12_FONT_ID, titleYStart, line.c_str(), !bookSelected); 524 + titleYStart += renderer.getLineHeight(UI_12_FONT_ID); 525 + } 526 + 527 + if (!lastBookAuthor.empty()) { 528 + titleYStart += renderer.getLineHeight(UI_10_FONT_ID) / 2; 529 + std::string trimmedAuthor = lastBookAuthor; 530 + // Trim author if too long (UTF-8 safe) 531 + bool wasTrimmed = false; 532 + while (renderer.getTextWidth(UI_10_FONT_ID, trimmedAuthor.c_str()) > maxLineWidth && !trimmedAuthor.empty()) { 533 + utf8RemoveLastChar(trimmedAuthor); 534 + wasTrimmed = true; 535 + } 536 + if (wasTrimmed && !trimmedAuthor.empty()) { 537 + // Make room for ellipsis 538 + while (renderer.getTextWidth(UI_10_FONT_ID, (trimmedAuthor + "...").c_str()) > maxLineWidth && 539 + !trimmedAuthor.empty()) { 540 + utf8RemoveLastChar(trimmedAuthor); 541 + } 542 + trimmedAuthor.append("..."); 543 + } 544 + renderer.drawCenteredText(UI_10_FONT_ID, titleYStart, trimmedAuthor.c_str(), !bookSelected); 545 + } 546 + 547 + // "Continue Reading" label at the bottom 548 + const int continueY = bookY + bookHeight - renderer.getLineHeight(UI_10_FONT_ID) * 3 / 2; 549 + if (coverRendered) { 550 + // Draw box behind "Continue Reading" text (inverted when selected: black box instead of white) 551 + const char* continueText = "Continue Reading"; 552 + const int continueTextWidth = renderer.getTextWidth(UI_10_FONT_ID, continueText); 553 + constexpr int continuePadding = 6; 554 + const int continueBoxWidth = continueTextWidth + continuePadding * 2; 555 + const int continueBoxHeight = renderer.getLineHeight(UI_10_FONT_ID) + continuePadding; 556 + const int continueBoxX = (rect.width - continueBoxWidth) / 2; 557 + const int continueBoxY = continueY - continuePadding / 2; 558 + renderer.fillRect(continueBoxX, continueBoxY, continueBoxWidth, continueBoxHeight, bookSelected); 559 + renderer.drawRect(continueBoxX, continueBoxY, continueBoxWidth, continueBoxHeight, !bookSelected); 560 + renderer.drawCenteredText(UI_10_FONT_ID, continueY, continueText, !bookSelected); 561 + } else { 562 + renderer.drawCenteredText(UI_10_FONT_ID, continueY, "Continue Reading", !bookSelected); 563 + } 564 + } else { 565 + // No book to continue reading 566 + const int y = 567 + bookY + (bookHeight - renderer.getLineHeight(UI_12_FONT_ID) - renderer.getLineHeight(UI_10_FONT_ID)) / 2; 568 + renderer.drawCenteredText(UI_12_FONT_ID, y, "No open book"); 569 + renderer.drawCenteredText(UI_10_FONT_ID, y + renderer.getLineHeight(UI_12_FONT_ID), "Start reading below"); 570 + } 571 + } 572 + 573 + void BaseTheme::drawButtonMenu(GfxRenderer& renderer, Rect rect, int buttonCount, int selectedIndex, 574 + const std::function<std::string(int index)>& buttonLabel, 575 + const std::function<std::string(int index)>& rowIcon) const { 576 + for (int i = 0; i < buttonCount; ++i) { 577 + const int tileY = BaseMetrics::values.verticalSpacing + rect.y + 578 + static_cast<int>(i) * (BaseMetrics::values.menuRowHeight + BaseMetrics::values.menuSpacing); 579 + 580 + const bool selected = selectedIndex == i; 581 + 582 + if (selected) { 583 + renderer.fillRect(rect.x + BaseMetrics::values.contentSidePadding, tileY, 584 + rect.width - BaseMetrics::values.contentSidePadding * 2, BaseMetrics::values.menuRowHeight); 585 + } else { 586 + renderer.drawRect(rect.x + BaseMetrics::values.contentSidePadding, tileY, 587 + rect.width - BaseMetrics::values.contentSidePadding * 2, BaseMetrics::values.menuRowHeight); 588 + } 589 + 590 + const char* label = buttonLabel(i).c_str(); 591 + const int textWidth = renderer.getTextWidth(UI_10_FONT_ID, label); 592 + const int textX = rect.x + (rect.width - textWidth) / 2; 593 + const int lineHeight = renderer.getLineHeight(UI_10_FONT_ID); 594 + const int textY = 595 + tileY + (BaseMetrics::values.menuRowHeight - lineHeight) / 2; // vertically centered assuming y is top of text 596 + // Invert text when the tile is selected, to contrast with the filled background 597 + renderer.drawText(UI_10_FONT_ID, textX, textY, label, selectedIndex != i); 598 + } 599 + } 600 + 601 + Rect BaseTheme::drawPopup(const GfxRenderer& renderer, const char* message) const { 602 + constexpr int margin = 15; 603 + constexpr int y = 60; 604 + const int textWidth = renderer.getTextWidth(UI_12_FONT_ID, message, EpdFontFamily::BOLD); 605 + const int textHeight = renderer.getLineHeight(UI_12_FONT_ID); 606 + const int w = textWidth + margin * 2; 607 + const int h = textHeight + margin * 2; 608 + const int x = (renderer.getScreenWidth() - w) / 2; 609 + 610 + renderer.fillRect(x - 2, y - 2, w + 4, h + 4, true); // frame thickness 2 611 + renderer.fillRect(x, y, w, h, false); 612 + 613 + const int textX = x + (w - textWidth) / 2; 614 + const int textY = y + margin - 2; 615 + renderer.drawText(UI_12_FONT_ID, textX, textY, message, true, EpdFontFamily::BOLD); 616 + renderer.displayBuffer(); 617 + return Rect{x, y, w, h}; 618 + } 619 + 620 + void BaseTheme::fillPopupProgress(const GfxRenderer& renderer, const Rect& layout, const int progress) const { 621 + constexpr int barHeight = 4; 622 + const int barWidth = layout.width - 30; // twice the margin in drawPopup to match text width 623 + const int barX = layout.x + (layout.width - barWidth) / 2; 624 + const int barY = layout.y + layout.height - 10; 625 + 626 + int fillWidth = barWidth * progress / 100; 627 + 628 + renderer.fillRect(barX, barY, fillWidth, barHeight, true); 629 + 630 + renderer.displayBuffer(HalDisplay::FAST_REFRESH); 631 + } 632 + 633 + void BaseTheme::drawBookProgressBar(const GfxRenderer& renderer, const size_t bookProgress) const { 634 + int vieweableMarginTop, vieweableMarginRight, vieweableMarginBottom, vieweableMarginLeft; 635 + renderer.getOrientedViewableTRBL(&vieweableMarginTop, &vieweableMarginRight, &vieweableMarginBottom, 636 + &vieweableMarginLeft); 637 + 638 + const int progressBarMaxWidth = renderer.getScreenWidth() - vieweableMarginLeft - vieweableMarginRight; 639 + const int progressBarY = 640 + renderer.getScreenHeight() - vieweableMarginBottom - BaseMetrics::values.bookProgressBarHeight; 641 + const int barWidth = progressBarMaxWidth * bookProgress / 100; 642 + renderer.fillRect(vieweableMarginLeft, progressBarY, barWidth, BaseMetrics::values.bookProgressBarHeight, true); 643 + }
+118
src/components/themes/BaseTheme.h
··· 1 + #pragma once 2 + 3 + #include <cstddef> 4 + #include <cstdint> 5 + #include <functional> 6 + #include <vector> 7 + 8 + class GfxRenderer; 9 + struct RecentBook; 10 + 11 + struct Rect { 12 + int x; 13 + int y; 14 + int width; 15 + int height; 16 + 17 + explicit Rect(int x = 0, int y = 0, int width = 0, int height = 0) : x(x), y(y), width(width), height(height) {} 18 + }; 19 + 20 + struct TabInfo { 21 + const char* label; 22 + bool selected; 23 + }; 24 + 25 + struct ThemeMetrics { 26 + int batteryWidth; 27 + int batteryHeight; 28 + 29 + int topPadding; 30 + int batteryBarHeight; 31 + int headerHeight; 32 + int verticalSpacing; 33 + 34 + int contentSidePadding; 35 + int listRowHeight; 36 + int listWithSubtitleRowHeight; 37 + int menuRowHeight; 38 + int menuSpacing; 39 + 40 + int tabSpacing; 41 + int tabBarHeight; 42 + 43 + int scrollBarWidth; 44 + int scrollBarRightOffset; 45 + 46 + int homeTopPadding; 47 + int homeCoverHeight; 48 + int homeCoverTileHeight; 49 + int homeRecentBooksCount; 50 + 51 + int buttonHintsHeight; 52 + int sideButtonHintsWidth; 53 + 54 + int versionTextRightX; 55 + int versionTextY; 56 + 57 + int bookProgressBarHeight; 58 + }; 59 + 60 + // Default theme implementation (Classic Theme) 61 + // Additional themes can inherit from this and override methods as needed 62 + 63 + namespace BaseMetrics { 64 + constexpr ThemeMetrics values = {.batteryWidth = 15, 65 + .batteryHeight = 12, 66 + .topPadding = 5, 67 + .batteryBarHeight = 20, 68 + .headerHeight = 45, 69 + .verticalSpacing = 10, 70 + .contentSidePadding = 20, 71 + .listRowHeight = 30, 72 + .listWithSubtitleRowHeight = 65, 73 + .menuRowHeight = 45, 74 + .menuSpacing = 8, 75 + .tabSpacing = 10, 76 + .tabBarHeight = 50, 77 + .scrollBarWidth = 4, 78 + .scrollBarRightOffset = 5, 79 + .homeTopPadding = 20, 80 + .homeCoverHeight = 400, 81 + .homeCoverTileHeight = 400, 82 + .homeRecentBooksCount = 1, 83 + .buttonHintsHeight = 40, 84 + .sideButtonHintsWidth = 30, 85 + .versionTextRightX = 20, 86 + .versionTextY = 738, 87 + .bookProgressBarHeight = 4}; 88 + } 89 + 90 + class BaseTheme { 91 + public: 92 + virtual ~BaseTheme() = default; 93 + 94 + // Component drawing methods 95 + virtual void drawProgressBar(const GfxRenderer& renderer, Rect rect, size_t current, size_t total) const; 96 + virtual void drawBattery(const GfxRenderer& renderer, Rect rect, bool showPercentage = true) const; 97 + virtual void drawButtonHints(GfxRenderer& renderer, const char* btn1, const char* btn2, const char* btn3, 98 + const char* btn4) const; 99 + virtual void drawSideButtonHints(const GfxRenderer& renderer, const char* topBtn, const char* bottomBtn) const; 100 + virtual void drawList(const GfxRenderer& renderer, Rect rect, int itemCount, int selectedIndex, 101 + const std::function<std::string(int index)>& rowTitle, 102 + const std::function<std::string(int index)>& rowSubtitle, 103 + const std::function<std::string(int index)>& rowIcon, 104 + const std::function<std::string(int index)>& rowValue) const; 105 + 106 + virtual void drawHeader(const GfxRenderer& renderer, Rect rect, const char* title) const; 107 + virtual void drawTabBar(const GfxRenderer& renderer, Rect rect, const std::vector<TabInfo>& tabs, 108 + bool selected) const; 109 + virtual void drawRecentBookCover(GfxRenderer& renderer, Rect rect, const std::vector<RecentBook>& recentBooks, 110 + const int selectorIndex, bool& coverRendered, bool& coverBufferStored, 111 + bool& bufferRestored, std::function<bool()> storeCoverBuffer) const; 112 + virtual void drawButtonMenu(GfxRenderer& renderer, Rect rect, int buttonCount, int selectedIndex, 113 + const std::function<std::string(int index)>& buttonLabel, 114 + const std::function<std::string(int index)>& rowIcon) const; 115 + virtual Rect drawPopup(const GfxRenderer& renderer, const char* message) const; 116 + virtual void fillPopupProgress(const GfxRenderer& renderer, const Rect& layout, const int progress) const; 117 + virtual void drawBookProgressBar(const GfxRenderer& renderer, const size_t bookProgress) const; 118 + };
+375
src/components/themes/lyra/LyraTheme.cpp
··· 1 + #include "LyraTheme.h" 2 + 3 + #include <GfxRenderer.h> 4 + #include <SDCardManager.h> 5 + 6 + #include <cstdint> 7 + #include <string> 8 + 9 + #include "Battery.h" 10 + #include "RecentBooksStore.h" 11 + #include "components/UITheme.h" 12 + #include "fontIds.h" 13 + #include "util/StringUtils.h" 14 + 15 + // Internal constants 16 + namespace { 17 + constexpr int batteryPercentSpacing = 4; 18 + constexpr int hPaddingInSelection = 8; 19 + constexpr int cornerRadius = 6; 20 + constexpr int topHintButtonY = 345; 21 + } // namespace 22 + 23 + void LyraTheme::drawBattery(const GfxRenderer& renderer, Rect rect, const bool showPercentage) const { 24 + // Left aligned battery icon and percentage 25 + const uint16_t percentage = battery.readPercentage(); 26 + if (showPercentage) { 27 + const auto percentageText = std::to_string(percentage) + "%"; 28 + renderer.drawText(SMALL_FONT_ID, rect.x + batteryPercentSpacing + LyraMetrics::values.batteryWidth, rect.y, 29 + percentageText.c_str()); 30 + } 31 + // 1 column on left, 2 columns on right, 5 columns of battery body 32 + const int x = rect.x; 33 + const int y = rect.y + 6; 34 + const int battWidth = LyraMetrics::values.batteryWidth; 35 + 36 + // Top line 37 + renderer.drawLine(x + 1, y, x + battWidth - 3, y); 38 + // Bottom line 39 + renderer.drawLine(x + 1, y + rect.height - 1, x + battWidth - 3, y + rect.height - 1); 40 + // Left line 41 + renderer.drawLine(x, y + 1, x, y + rect.height - 2); 42 + // Battery end 43 + renderer.drawLine(x + battWidth - 2, y + 1, x + battWidth - 2, y + rect.height - 2); 44 + renderer.drawPixel(x + battWidth - 1, y + 3); 45 + renderer.drawPixel(x + battWidth - 1, y + rect.height - 4); 46 + renderer.drawLine(x + battWidth - 0, y + 4, x + battWidth - 0, y + rect.height - 5); 47 + 48 + // Draw bars 49 + if (percentage > 10) { 50 + renderer.fillRect(x + 2, y + 2, 3, rect.height - 4); 51 + } 52 + if (percentage > 40) { 53 + renderer.fillRect(x + 6, y + 2, 3, rect.height - 4); 54 + } 55 + if (percentage > 70) { 56 + renderer.fillRect(x + 10, y + 2, 3, rect.height - 4); 57 + } 58 + } 59 + 60 + void LyraTheme::drawHeader(const GfxRenderer& renderer, Rect rect, const char* title) const { 61 + renderer.fillRect(rect.x, rect.y, rect.width, rect.height, false); 62 + 63 + const bool showBatteryPercentage = 64 + SETTINGS.hideBatteryPercentage != CrossPointSettings::HIDE_BATTERY_PERCENTAGE::HIDE_ALWAYS; 65 + int batteryX = rect.x + rect.width - LyraMetrics::values.contentSidePadding - LyraMetrics::values.batteryWidth; 66 + if (showBatteryPercentage) { 67 + const uint16_t percentage = battery.readPercentage(); 68 + const auto percentageText = std::to_string(percentage) + "%"; 69 + batteryX -= renderer.getTextWidth(SMALL_FONT_ID, percentageText.c_str()); 70 + } 71 + drawBattery(renderer, 72 + Rect{batteryX, rect.y + 10, LyraMetrics::values.batteryWidth, LyraMetrics::values.batteryHeight}, 73 + showBatteryPercentage); 74 + 75 + if (title) { 76 + auto truncatedTitle = renderer.truncatedText( 77 + UI_12_FONT_ID, title, rect.width - LyraMetrics::values.contentSidePadding * 2, EpdFontFamily::BOLD); 78 + renderer.drawText(UI_12_FONT_ID, rect.x + LyraMetrics::values.contentSidePadding, 79 + rect.y + LyraMetrics::values.batteryBarHeight + 3, truncatedTitle.c_str(), true, 80 + EpdFontFamily::BOLD); 81 + renderer.drawLine(rect.x, rect.y + rect.height - 3, rect.x + rect.width, rect.y + rect.height - 3, 3, true); 82 + } 83 + } 84 + 85 + void LyraTheme::drawTabBar(const GfxRenderer& renderer, Rect rect, const std::vector<TabInfo>& tabs, 86 + bool selected) const { 87 + int currentX = rect.x + LyraMetrics::values.contentSidePadding; 88 + 89 + if (selected) { 90 + renderer.fillRectDither(rect.x, rect.y, rect.width, rect.height, Color::LightGray); 91 + } 92 + 93 + for (const auto& tab : tabs) { 94 + const int textWidth = renderer.getTextWidth(UI_10_FONT_ID, tab.label, EpdFontFamily::REGULAR); 95 + 96 + if (tab.selected) { 97 + if (selected) { 98 + renderer.fillRoundedRect(currentX, rect.y + 1, textWidth + 2 * hPaddingInSelection, rect.height - 4, 99 + cornerRadius, Color::Black); 100 + } else { 101 + renderer.fillRectDither(currentX, rect.y, textWidth + 2 * hPaddingInSelection, rect.height - 3, 102 + Color::LightGray); 103 + renderer.drawLine(currentX, rect.y + rect.height - 3, currentX + textWidth + 2 * hPaddingInSelection, 104 + rect.y + rect.height - 3, 2, true); 105 + } 106 + } 107 + 108 + renderer.drawText(UI_10_FONT_ID, currentX + hPaddingInSelection, rect.y + 6, tab.label, !(tab.selected && selected), 109 + EpdFontFamily::REGULAR); 110 + 111 + currentX += textWidth + LyraMetrics::values.tabSpacing + 2 * hPaddingInSelection; 112 + } 113 + 114 + renderer.drawLine(rect.x, rect.y + rect.height - 1, rect.x + rect.width, rect.y + rect.height - 1, true); 115 + } 116 + 117 + void LyraTheme::drawList(const GfxRenderer& renderer, Rect rect, int itemCount, int selectedIndex, 118 + const std::function<std::string(int index)>& rowTitle, 119 + const std::function<std::string(int index)>& rowSubtitle, 120 + const std::function<std::string(int index)>& rowIcon, 121 + const std::function<std::string(int index)>& rowValue) const { 122 + int rowHeight = 123 + (rowSubtitle != nullptr) ? LyraMetrics::values.listWithSubtitleRowHeight : LyraMetrics::values.listRowHeight; 124 + int pageItems = rect.height / rowHeight; 125 + 126 + const int totalPages = (itemCount + pageItems - 1) / pageItems; 127 + if (totalPages > 1) { 128 + const int scrollAreaHeight = rect.height; 129 + 130 + // Draw scroll bar 131 + const int scrollBarHeight = (scrollAreaHeight * pageItems) / itemCount; 132 + const int currentPage = selectedIndex / pageItems; 133 + const int scrollBarY = rect.y + ((scrollAreaHeight - scrollBarHeight) * currentPage) / (totalPages - 1); 134 + const int scrollBarX = rect.x + rect.width - LyraMetrics::values.scrollBarRightOffset; 135 + renderer.drawLine(scrollBarX, rect.y, scrollBarX, rect.y + scrollAreaHeight, true); 136 + renderer.fillRect(scrollBarX - LyraMetrics::values.scrollBarWidth, scrollBarY, LyraMetrics::values.scrollBarWidth, 137 + scrollBarHeight, true); 138 + } 139 + 140 + // Draw selection 141 + int contentWidth = 142 + rect.width - 143 + (totalPages > 1 ? (LyraMetrics::values.scrollBarWidth + LyraMetrics::values.scrollBarRightOffset) : 1); 144 + if (selectedIndex >= 0) { 145 + renderer.fillRoundedRect(LyraMetrics::values.contentSidePadding, rect.y + selectedIndex % pageItems * rowHeight, 146 + contentWidth - LyraMetrics::values.contentSidePadding * 2, rowHeight, cornerRadius, 147 + Color::LightGray); 148 + } 149 + 150 + // Draw all items 151 + const auto pageStartIndex = selectedIndex / pageItems * pageItems; 152 + for (int i = pageStartIndex; i < itemCount && i < pageStartIndex + pageItems; i++) { 153 + const int itemY = rect.y + (i % pageItems) * rowHeight; 154 + 155 + // Draw name 156 + int textWidth = contentWidth - LyraMetrics::values.contentSidePadding * 2 - hPaddingInSelection * 2 - 157 + (rowValue != nullptr ? 60 : 0); // TODO truncate according to value width? 158 + auto itemName = rowTitle(i); 159 + auto item = renderer.truncatedText(UI_10_FONT_ID, itemName.c_str(), textWidth); 160 + renderer.drawText(UI_10_FONT_ID, rect.x + LyraMetrics::values.contentSidePadding + hPaddingInSelection * 2, 161 + itemY + 6, item.c_str(), true); 162 + 163 + if (rowSubtitle != nullptr) { 164 + // Draw subtitle 165 + std::string subtitleText = rowSubtitle(i); 166 + auto subtitle = renderer.truncatedText(SMALL_FONT_ID, subtitleText.c_str(), textWidth); 167 + renderer.drawText(SMALL_FONT_ID, rect.x + LyraMetrics::values.contentSidePadding + hPaddingInSelection * 2, 168 + itemY + 30, subtitle.c_str(), true); 169 + } 170 + 171 + if (rowValue != nullptr) { 172 + // Draw value 173 + std::string valueText = rowValue(i); 174 + if (!valueText.empty()) { 175 + const auto valueTextWidth = renderer.getTextWidth(UI_10_FONT_ID, valueText.c_str()); 176 + 177 + if (i == selectedIndex) { 178 + renderer.fillRoundedRect( 179 + contentWidth - LyraMetrics::values.contentSidePadding - hPaddingInSelection * 2 - valueTextWidth, itemY, 180 + valueTextWidth + hPaddingInSelection * 2, rowHeight, cornerRadius, Color::Black); 181 + } 182 + 183 + renderer.drawText(UI_10_FONT_ID, 184 + contentWidth - LyraMetrics::values.contentSidePadding - hPaddingInSelection - valueTextWidth, 185 + itemY + 6, valueText.c_str(), i != selectedIndex); 186 + } 187 + } 188 + } 189 + } 190 + 191 + void LyraTheme::drawButtonHints(GfxRenderer& renderer, const char* btn1, const char* btn2, const char* btn3, 192 + const char* btn4) const { 193 + const GfxRenderer::Orientation orig_orientation = renderer.getOrientation(); 194 + renderer.setOrientation(GfxRenderer::Orientation::Portrait); 195 + 196 + const int pageHeight = renderer.getScreenHeight(); 197 + constexpr int buttonWidth = 80; 198 + constexpr int smallButtonHeight = 15; 199 + constexpr int buttonHeight = LyraMetrics::values.buttonHintsHeight; 200 + constexpr int buttonY = LyraMetrics::values.buttonHintsHeight; // Distance from bottom 201 + constexpr int textYOffset = 7; // Distance from top of button to text baseline 202 + constexpr int buttonPositions[] = {58, 146, 254, 342}; 203 + const char* labels[] = {btn1, btn2, btn3, btn4}; 204 + 205 + for (int i = 0; i < 4; i++) { 206 + // Only draw if the label is non-empty 207 + const int x = buttonPositions[i]; 208 + renderer.fillRect(x, pageHeight - buttonY, buttonWidth, buttonHeight, false); 209 + if (labels[i] != nullptr && labels[i][0] != '\0') { 210 + renderer.drawRoundedRect(x, pageHeight - buttonY, buttonWidth, buttonHeight, 1, cornerRadius, true, true, false, 211 + false, true); 212 + const int textWidth = renderer.getTextWidth(SMALL_FONT_ID, labels[i]); 213 + const int textX = x + (buttonWidth - 1 - textWidth) / 2; 214 + renderer.drawText(SMALL_FONT_ID, textX, pageHeight - buttonY + textYOffset, labels[i]); 215 + } else { 216 + renderer.drawRoundedRect(x, pageHeight - smallButtonHeight, buttonWidth, smallButtonHeight, 1, cornerRadius, true, 217 + true, false, false, true); 218 + } 219 + } 220 + 221 + renderer.setOrientation(orig_orientation); 222 + } 223 + 224 + void LyraTheme::drawSideButtonHints(const GfxRenderer& renderer, const char* topBtn, const char* bottomBtn) const { 225 + const int screenWidth = renderer.getScreenWidth(); 226 + constexpr int buttonWidth = LyraMetrics::values.sideButtonHintsWidth; // Width on screen (height when rotated) 227 + constexpr int buttonHeight = 78; // Height on screen (width when rotated) 228 + // Position for the button group - buttons share a border so they're adjacent 229 + 230 + const char* labels[] = {topBtn, bottomBtn}; 231 + 232 + // Draw the shared border for both buttons as one unit 233 + const int x = screenWidth - buttonWidth; 234 + 235 + // Draw top button outline 236 + if (topBtn != nullptr && topBtn[0] != '\0') { 237 + renderer.drawRoundedRect(x, topHintButtonY, buttonWidth, buttonHeight, 1, cornerRadius, true, false, true, false, 238 + true); 239 + } 240 + 241 + // Draw bottom button outline 242 + if (bottomBtn != nullptr && bottomBtn[0] != '\0') { 243 + renderer.drawRoundedRect(x, topHintButtonY + buttonHeight + 5, buttonWidth, buttonHeight, 1, cornerRadius, true, 244 + false, true, false, true); 245 + } 246 + 247 + // Draw text for each button 248 + for (int i = 0; i < 2; i++) { 249 + if (labels[i] != nullptr && labels[i][0] != '\0') { 250 + const int y = topHintButtonY + (i * buttonHeight + 5); 251 + 252 + // Draw rotated text centered in the button 253 + const int textWidth = renderer.getTextWidth(SMALL_FONT_ID, labels[i]); 254 + 255 + renderer.drawTextRotated90CW(SMALL_FONT_ID, x, y + (buttonHeight + textWidth) / 2, labels[i]); 256 + } 257 + } 258 + } 259 + 260 + void LyraTheme::drawRecentBookCover(GfxRenderer& renderer, Rect rect, const std::vector<RecentBook>& recentBooks, 261 + const int selectorIndex, bool& coverRendered, bool& coverBufferStored, 262 + bool& bufferRestored, std::function<bool()> storeCoverBuffer) const { 263 + const int tileWidth = (rect.width - 2 * LyraMetrics::values.contentSidePadding) / 3; 264 + const int tileHeight = rect.height; 265 + const int bookTitleHeight = tileHeight - LyraMetrics::values.homeCoverHeight - hPaddingInSelection; 266 + const int tileY = rect.y; 267 + const bool hasContinueReading = !recentBooks.empty(); 268 + 269 + // Draw book card regardless, fill with message based on `hasContinueReading` 270 + // Draw cover image as background if available (inside the box) 271 + // Only load from SD on first render, then use stored buffer 272 + if (hasContinueReading) { 273 + if (!coverRendered) { 274 + for (int i = 0; i < std::min(static_cast<int>(recentBooks.size()), LyraMetrics::values.homeRecentBooksCount); 275 + i++) { 276 + std::string coverPath = recentBooks[i].coverBmpPath; 277 + if (coverPath.empty()) { 278 + continue; 279 + } 280 + 281 + const std::string coverBmpPath = UITheme::getCoverThumbPath(coverPath, LyraMetrics::values.homeCoverHeight); 282 + 283 + int tileX = LyraMetrics::values.contentSidePadding + tileWidth * i; 284 + 285 + // First time: load cover from SD and render 286 + FsFile file; 287 + if (SdMan.openFileForRead("HOME", coverBmpPath, file)) { 288 + Bitmap bitmap(file); 289 + if (bitmap.parseHeaders() == BmpReaderError::Ok) { 290 + float coverHeight = static_cast<float>(bitmap.getHeight()); 291 + float coverWidth = static_cast<float>(bitmap.getWidth()); 292 + float ratio = coverWidth / coverHeight; 293 + const float tileRatio = static_cast<float>(tileWidth - 2 * hPaddingInSelection) / 294 + static_cast<float>(LyraMetrics::values.homeCoverHeight); 295 + float cropX = 1.0f - (tileRatio / ratio); 296 + 297 + renderer.drawBitmap(bitmap, tileX + hPaddingInSelection, tileY + hPaddingInSelection, 298 + tileWidth - 2 * hPaddingInSelection, LyraMetrics::values.homeCoverHeight, cropX); 299 + } 300 + file.close(); 301 + } 302 + } 303 + 304 + coverBufferStored = storeCoverBuffer(); 305 + coverRendered = true; 306 + } 307 + 308 + for (int i = 0; i < std::min(static_cast<int>(recentBooks.size()), LyraMetrics::values.homeRecentBooksCount); i++) { 309 + bool bookSelected = (selectorIndex == i); 310 + 311 + int tileX = LyraMetrics::values.contentSidePadding + tileWidth * i; 312 + auto title = 313 + renderer.truncatedText(UI_10_FONT_ID, recentBooks[i].title.c_str(), tileWidth - 2 * hPaddingInSelection); 314 + 315 + if (bookSelected) { 316 + // Draw selection box 317 + renderer.fillRoundedRect(tileX, tileY, tileWidth, hPaddingInSelection, cornerRadius, true, true, false, false, 318 + Color::LightGray); 319 + renderer.fillRectDither(tileX, tileY + hPaddingInSelection, hPaddingInSelection, 320 + LyraMetrics::values.homeCoverHeight, Color::LightGray); 321 + renderer.fillRectDither(tileX + tileWidth - hPaddingInSelection, tileY + hPaddingInSelection, 322 + hPaddingInSelection, LyraMetrics::values.homeCoverHeight, Color::LightGray); 323 + renderer.fillRoundedRect(tileX, tileY + LyraMetrics::values.homeCoverHeight + hPaddingInSelection, tileWidth, 324 + bookTitleHeight, cornerRadius, false, false, true, true, Color::LightGray); 325 + } 326 + renderer.drawText(UI_10_FONT_ID, tileX + hPaddingInSelection, 327 + tileY + tileHeight - bookTitleHeight + hPaddingInSelection + 5, title.c_str(), true); 328 + } 329 + } 330 + } 331 + 332 + void LyraTheme::drawButtonMenu(GfxRenderer& renderer, Rect rect, int buttonCount, int selectedIndex, 333 + const std::function<std::string(int index)>& buttonLabel, 334 + const std::function<std::string(int index)>& rowIcon) const { 335 + for (int i = 0; i < buttonCount; ++i) { 336 + int tileWidth = (rect.width - LyraMetrics::values.contentSidePadding * 2 - LyraMetrics::values.menuSpacing) / 2; 337 + Rect tileRect = 338 + Rect{rect.x + LyraMetrics::values.contentSidePadding + (LyraMetrics::values.menuSpacing + tileWidth) * (i % 2), 339 + rect.y + static_cast<int>(i / 2) * (LyraMetrics::values.menuRowHeight + LyraMetrics::values.menuSpacing), 340 + tileWidth, LyraMetrics::values.menuRowHeight}; 341 + 342 + const bool selected = selectedIndex == i; 343 + 344 + if (selected) { 345 + renderer.fillRoundedRect(tileRect.x, tileRect.y, tileRect.width, tileRect.height, cornerRadius, Color::LightGray); 346 + } 347 + 348 + const char* label = buttonLabel(i).c_str(); 349 + const int textX = tileRect.x + 16; 350 + const int lineHeight = renderer.getLineHeight(UI_12_FONT_ID); 351 + const int textY = tileRect.y + (LyraMetrics::values.menuRowHeight - lineHeight) / 2; 352 + 353 + // Invert text when the tile is selected, to contrast with the filled background 354 + renderer.drawText(UI_12_FONT_ID, textX, textY, label, true); 355 + } 356 + } 357 + 358 + Rect LyraTheme::drawPopup(const GfxRenderer& renderer, const char* message) const { 359 + constexpr int margin = 15; 360 + constexpr int y = 60; 361 + const int textWidth = renderer.getTextWidth(UI_12_FONT_ID, message, EpdFontFamily::REGULAR); 362 + const int textHeight = renderer.getLineHeight(UI_12_FONT_ID); 363 + const int w = textWidth + margin * 2; 364 + const int h = textHeight + margin * 2; 365 + const int x = (renderer.getScreenWidth() - w) / 2; 366 + 367 + renderer.fillRect(x - 5, y - 5, w + 10, h + 10, false); 368 + renderer.drawRect(x, y, w, h, true); 369 + 370 + const int textX = x + (w - textWidth) / 2; 371 + const int textY = y + margin - 2; 372 + renderer.drawText(UI_12_FONT_ID, textX, textY, message, true, EpdFontFamily::REGULAR); 373 + renderer.displayBuffer(); 374 + return Rect{x, y, w, h}; 375 + }
+58
src/components/themes/lyra/LyraTheme.h
··· 1 + #pragma once 2 + 3 + #include "components/themes/BaseTheme.h" 4 + 5 + class GfxRenderer; 6 + 7 + // Lyra theme metrics (zero runtime cost) 8 + namespace LyraMetrics { 9 + constexpr ThemeMetrics values = {.batteryWidth = 16, 10 + .batteryHeight = 12, 11 + .topPadding = 5, 12 + .batteryBarHeight = 40, 13 + .headerHeight = 84, 14 + .verticalSpacing = 16, 15 + .contentSidePadding = 20, 16 + .listRowHeight = 40, 17 + .listWithSubtitleRowHeight = 60, 18 + .menuRowHeight = 64, 19 + .menuSpacing = 8, 20 + .tabSpacing = 8, 21 + .tabBarHeight = 40, 22 + .scrollBarWidth = 4, 23 + .scrollBarRightOffset = 5, 24 + .homeTopPadding = 56, 25 + .homeCoverHeight = 226, 26 + .homeCoverTileHeight = 287, 27 + .homeRecentBooksCount = 3, 28 + .buttonHintsHeight = 40, 29 + .sideButtonHintsWidth = 19, 30 + .versionTextRightX = 20, 31 + .versionTextY = 55, 32 + .bookProgressBarHeight = 4}; 33 + } 34 + 35 + class LyraTheme : public BaseTheme { 36 + public: 37 + // Component drawing methods 38 + // void drawProgressBar(const GfxRenderer& renderer, Rect rect, size_t current, size_t total) override; 39 + void drawBattery(const GfxRenderer& renderer, Rect rect, bool showPercentage = true) const override; 40 + void drawHeader(const GfxRenderer& renderer, Rect rect, const char* title) const override; 41 + void drawTabBar(const GfxRenderer& renderer, Rect rect, const std::vector<TabInfo>& tabs, 42 + bool selected) const override; 43 + void drawList(const GfxRenderer& renderer, Rect rect, int itemCount, int selectedIndex, 44 + const std::function<std::string(int index)>& rowTitle, 45 + const std::function<std::string(int index)>& rowSubtitle, 46 + const std::function<std::string(int index)>& rowIcon, 47 + const std::function<std::string(int index)>& rowValue) const override; 48 + void drawButtonHints(GfxRenderer& renderer, const char* btn1, const char* btn2, const char* btn3, 49 + const char* btn4) const override; 50 + void drawSideButtonHints(const GfxRenderer& renderer, const char* topBtn, const char* bottomBtn) const override; 51 + void drawButtonMenu(GfxRenderer& renderer, Rect rect, int buttonCount, int selectedIndex, 52 + const std::function<std::string(int index)>& buttonLabel, 53 + const std::function<std::string(int index)>& rowIcon) const override; 54 + void drawRecentBookCover(GfxRenderer& renderer, Rect rect, const std::vector<RecentBook>& recentBooks, 55 + const int selectorIndex, bool& coverRendered, bool& coverBufferStored, bool& bufferRestored, 56 + std::function<bool()> storeCoverBuffer) const override; 57 + Rect drawPopup(const GfxRenderer& renderer, const char* message) const override; 58 + };
+17 -9
src/main.cpp
··· 20 20 #include "activities/browser/OpdsBookBrowserActivity.h" 21 21 #include "activities/home/HomeActivity.h" 22 22 #include "activities/home/MyLibraryActivity.h" 23 + #include "activities/home/RecentBooksActivity.h" 23 24 #include "activities/network/CrossPointWebServerActivity.h" 24 25 #include "activities/reader/ReaderActivity.h" 25 26 #include "activities/settings/SettingsActivity.h" 26 27 #include "activities/util/FullScreenMessageActivity.h" 28 + #include "components/UITheme.h" 27 29 #include "fontIds.h" 28 30 29 31 HalDisplay display; ··· 203 205 } 204 206 205 207 void onGoHome(); 206 - void onGoToMyLibraryWithTab(const std::string& path, MyLibraryActivity::Tab tab); 207 - void onGoToReader(const std::string& initialEpubPath, MyLibraryActivity::Tab fromTab) { 208 + void onGoToMyLibraryWithPath(const std::string& path); 209 + void onGoToRecentBooks(); 210 + void onGoToReader(const std::string& initialEpubPath) { 208 211 exitActivity(); 209 212 enterNewActivity( 210 - new ReaderActivity(renderer, mappedInputManager, initialEpubPath, fromTab, onGoHome, onGoToMyLibraryWithTab)); 213 + new ReaderActivity(renderer, mappedInputManager, initialEpubPath, onGoHome, onGoToMyLibraryWithPath)); 211 214 } 212 - void onContinueReading() { onGoToReader(APP_STATE.openEpubPath, MyLibraryActivity::Tab::Recent); } 213 215 214 216 void onGoToFileTransfer() { 215 217 exitActivity(); ··· 226 228 enterNewActivity(new MyLibraryActivity(renderer, mappedInputManager, onGoHome, onGoToReader)); 227 229 } 228 230 229 - void onGoToMyLibraryWithTab(const std::string& path, MyLibraryActivity::Tab tab) { 231 + void onGoToRecentBooks() { 230 232 exitActivity(); 231 - enterNewActivity(new MyLibraryActivity(renderer, mappedInputManager, onGoHome, onGoToReader, tab, path)); 233 + enterNewActivity(new RecentBooksActivity(renderer, mappedInputManager, onGoHome, onGoToReader)); 234 + } 235 + 236 + void onGoToMyLibraryWithPath(const std::string& path) { 237 + exitActivity(); 238 + enterNewActivity(new MyLibraryActivity(renderer, mappedInputManager, onGoHome, onGoToReader, path)); 232 239 } 233 240 234 241 void onGoToBrowser() { ··· 238 245 239 246 void onGoHome() { 240 247 exitActivity(); 241 - enterNewActivity(new HomeActivity(renderer, mappedInputManager, onContinueReading, onGoToMyLibrary, onGoToSettings, 242 - onGoToFileTransfer, onGoToBrowser)); 248 + enterNewActivity(new HomeActivity(renderer, mappedInputManager, onGoToReader, onGoToMyLibrary, onGoToRecentBooks, 249 + onGoToSettings, onGoToFileTransfer, onGoToBrowser)); 243 250 } 244 251 245 252 void setupDisplayAndFonts() { ··· 293 300 294 301 SETTINGS.loadFromFile(); 295 302 KOREADER_STORE.loadFromFile(); 303 + UITheme::getInstance().reload(); 296 304 297 305 switch (gpio.getWakeupReason()) { 298 306 case HalGPIO::WakeupReason::PowerButton: ··· 330 338 const auto path = APP_STATE.openEpubPath; 331 339 APP_STATE.openEpubPath = ""; 332 340 APP_STATE.saveToFile(); 333 - onGoToReader(path, MyLibraryActivity::Tab::Recent); 341 + onGoToReader(path); 334 342 } 335 343 336 344 // Ensure we're not still holding the power button before leaving setup