Elements.cpp
1 #include "Elements.h" 2 3 #include <cstdio> 4 #include <cstring> 5 #include <string> 6 7 namespace ui { 8 9 void title(const GfxRenderer& r, const Theme& t, int y, const char* text) { 10 r.drawText(t.readerFontId, t.screenMarginSide, y, text, t.primaryTextBlack, EpdFontFamily::BOLD); 11 } 12 13 void separator(const GfxRenderer& r, const Theme& t, int y) { 14 const int x = t.screenMarginSide + t.itemPaddingX; 15 const int endX = r.getScreenWidth() - t.screenMarginSide; 16 r.drawLine(x, y, endX, y, t.primaryTextBlack); 17 } 18 19 void menuItem(const GfxRenderer& r, const Theme& t, int y, const char* text, bool selected) { 20 const int x = t.screenMarginSide; 21 const int w = r.getScreenWidth() - 2 * t.screenMarginSide; 22 const int h = t.itemHeight; 23 const int textY = y + (h - r.getLineHeight(t.uiFontId)) / 2; 24 25 if (selected) { 26 r.fillRect(x, y, w, h, t.selectionFillBlack); 27 r.drawText(t.uiFontId, x + t.itemPaddingX, textY, text, t.selectionTextBlack); 28 } else { 29 r.drawText(t.uiFontId, x + t.itemPaddingX, textY, text, t.primaryTextBlack); 30 } 31 } 32 33 void toggle(const GfxRenderer& r, const Theme& t, int y, const char* label, bool value, bool selected) { 34 const int x = t.screenMarginSide; 35 const int w = r.getScreenWidth() - 2 * t.screenMarginSide; 36 const int h = t.itemHeight; 37 const int valueX = r.getScreenWidth() - t.screenMarginSide - 50; 38 const int textY = y + (h - r.getLineHeight(t.uiFontId)) / 2; 39 40 if (selected) { 41 r.fillRect(x, y, w, h, t.selectionFillBlack); 42 r.drawText(t.uiFontId, x + t.itemPaddingX, textY, label, t.selectionTextBlack); 43 r.drawText(t.uiFontId, valueX, textY, value ? "ON" : "OFF", t.selectionTextBlack); 44 } else { 45 r.drawText(t.uiFontId, x + t.itemPaddingX, textY, label, t.primaryTextBlack); 46 r.drawText(t.uiFontId, valueX, textY, value ? "ON" : "OFF", t.secondaryTextBlack); 47 } 48 } 49 50 void enumValue(const GfxRenderer& r, const Theme& t, int y, const char* label, const char* value, bool selected) { 51 const int x = t.screenMarginSide; 52 const int w = r.getScreenWidth() - 2 * t.screenMarginSide; 53 const int h = t.itemHeight; 54 const int textY = y + (h - r.getLineHeight(t.uiFontId)) / 2; 55 56 const int valueWidth = r.getTextWidth(t.uiFontId, value); 57 const int valueX = r.getScreenWidth() - t.screenMarginSide - valueWidth - t.itemValuePadding; 58 59 if (selected) { 60 r.fillRect(x, y, w, h, t.selectionFillBlack); 61 r.drawText(t.uiFontId, x + t.itemPaddingX, textY, label, t.selectionTextBlack); 62 r.drawText(t.uiFontId, valueX, textY, value, t.selectionTextBlack); 63 } else { 64 r.drawText(t.uiFontId, x + t.itemPaddingX, textY, label, t.primaryTextBlack); 65 r.drawText(t.uiFontId, valueX, textY, value, t.secondaryTextBlack); 66 } 67 } 68 69 void buttonBar(const GfxRenderer& r, const Theme& t, const char* b1, const char* b2, const char* b3, const char* b4) { 70 r.drawButtonHints(t.uiFontId, b1, b2, b3, b4, t.primaryTextBlack); 71 } 72 73 void buttonBar(const GfxRenderer& r, const Theme& t, const ButtonBar& buttons) { 74 r.drawButtonHints(t.uiFontId, buttons.labels[0], buttons.labels[1], buttons.labels[2], buttons.labels[3], 75 t.primaryTextBlack); 76 } 77 78 void progress(const GfxRenderer& r, const Theme& t, int y, int current, int total) { 79 const int x = t.screenMarginSide + 20; 80 const int w = r.getScreenWidth() - 2 * (t.screenMarginSide + 20); 81 const int h = 16; 82 const int barY = y + 2; 83 84 // Calculate fill width (borderless) 85 if (total > 0) { 86 const int fillW = w * current / total; 87 if (fillW > 0) { 88 r.fillRect(x, barY, fillW, h, t.primaryTextBlack); 89 } 90 } 91 92 // Draw percentage text centered below 93 char buf[16]; 94 if (total > 0) { 95 snprintf(buf, sizeof(buf), "%d%%", (current * 100) / total); 96 } else { 97 snprintf(buf, sizeof(buf), "0%%"); 98 } 99 r.drawCenteredText(t.smallFontId, y + h + 5, buf, t.primaryTextBlack); 100 } 101 102 void text(const GfxRenderer& r, const Theme& t, int y, const char* str) { 103 r.drawText(t.uiFontId, t.screenMarginSide + t.itemPaddingX, y, str, t.primaryTextBlack); 104 } 105 106 int textWrapped(const GfxRenderer& r, const Theme& t, int y, const char* str, int maxLines) { 107 const int maxWidth = r.getScreenWidth() - 2 * (t.screenMarginSide + t.itemPaddingX); 108 const auto lines = r.wrapTextWithHyphenation(t.uiFontId, str, maxWidth, maxLines); 109 const int lineHeight = r.getLineHeight(t.uiFontId); 110 111 int currentY = y; 112 for (const auto& line : lines) { 113 r.drawText(t.uiFontId, t.screenMarginSide + t.itemPaddingX, currentY, line.c_str(), t.primaryTextBlack); 114 currentY += lineHeight; 115 } 116 return static_cast<int>(lines.size()); 117 } 118 119 void image(const GfxRenderer& r, int x, int y, const uint8_t* data, int w, int h) { 120 if (data != nullptr) { 121 r.drawImage(data, x, y, w, h); 122 } 123 } 124 125 void dialog(const GfxRenderer& r, const Theme& t, const char* titleText, const char* msg, int selected) { 126 const int screenW = r.getScreenWidth(); 127 const int screenH = r.getScreenHeight(); 128 129 // Dialog box dimensions 130 const int dialogW = screenW - 60; 131 const int dialogH = 160; 132 const int dialogX = 30; 133 const int dialogY = (screenH - dialogH) / 2; 134 135 // Draw dialog background (clear area, no border) 136 r.clearArea(dialogX, dialogY, dialogW, dialogH, t.backgroundColor); 137 138 // Draw title 139 r.drawCenteredText(t.readerFontId, dialogY + 20, titleText, t.primaryTextBlack, EpdFontFamily::BOLD); 140 141 // Draw message 142 r.drawCenteredText(t.uiFontId, dialogY + 60, msg, t.primaryTextBlack); 143 144 // Draw buttons (Yes/No) 145 const int btnW = 80; 146 const int btnH = 30; 147 const int btnY = dialogY + dialogH - 50; 148 const int btnTextY = btnY + (btnH - r.getLineHeight(t.uiFontId)) / 2; 149 const int yesX = dialogX + (dialogW / 2) - btnW - 20; 150 const int noX = dialogX + (dialogW / 2) + 20; 151 152 // Yes button — DWS hard drop shadow 153 const bool fg = t.primaryTextBlack; 154 r.fillRect(yesX + 2, btnY + 2, btnW, btnH, fg); // Shadow 155 if (selected == 0) { 156 r.fillRect(yesX, btnY, btnW, btnH, t.selectionFillBlack); 157 } else { 158 r.fillRect(yesX, btnY, btnW, btnH, !fg); // Face 159 r.drawRect(yesX, btnY, btnW, btnH, fg); // Border 160 } 161 r.drawText(t.uiFontId, yesX + (btnW - r.getTextWidth(t.uiFontId, "Yes")) / 2, btnTextY, "Yes", 162 selected == 0 ? t.selectionTextBlack : fg); 163 164 // No button — DWS hard drop shadow 165 r.fillRect(noX + 2, btnY + 2, btnW, btnH, fg); // Shadow 166 if (selected == 1) { 167 r.fillRect(noX, btnY, btnW, btnH, t.selectionFillBlack); 168 } else { 169 r.fillRect(noX, btnY, btnW, btnH, !fg); // Face 170 r.drawRect(noX, btnY, btnW, btnH, fg); // Border 171 } 172 r.drawText(t.uiFontId, noX + (btnW - r.getTextWidth(t.uiFontId, "No")) / 2, btnTextY, "No", 173 selected == 1 ? t.selectionTextBlack : fg); 174 } 175 176 // Keyboard layout - 10x10 grid 177 // Row 0: Control row (Backspace, Space, Confirm) 178 // Rows 1-3: lowercase letters + symbols 179 // Rows 4-6: uppercase letters + symbols 180 // Rows 7-9: numbers + symbols 181 // Control chars: \x01 = SPACE, \x02 = BACKSPACE, \x03 = CONFIRM 182 static constexpr char KEYBOARD_GRID[10][10] = { 183 {'\x02', '\x02', '\x02', '\x01', '\x01', '\x01', '\x01', '\x03', '\x03', '\x03'}, // row 0: controls 184 {'a', 'b', 'c', 'd', 'e', 'f', 'g', 'h', 'i', 'j'}, // row 1: lowercase 185 {'k', 'l', 'm', 'n', 'o', 'p', 'q', 'r', 's', 't'}, // row 2: lowercase 186 {'u', 'v', 'w', 'x', 'y', 'z', '.', '-', '_', '@'}, // row 3: lowercase + symbols 187 {'A', 'B', 'C', 'D', 'E', 'F', 'G', 'H', 'I', 'J'}, // row 4: uppercase 188 {'K', 'L', 'M', 'N', 'O', 'P', 'Q', 'R', 'S', 'T'}, // row 5: uppercase 189 {'U', 'V', 'W', 'X', 'Y', 'Z', '!', '#', '$', '%'}, // row 6: uppercase + symbols 190 {'1', '2', '3', '4', '5', '6', '7', '8', '9', '0'}, // row 7: numbers 191 {'^', '&', '*', '(', ')', '+', ' ', '[', ']', '\\'}, // row 8: symbols 192 {'/', ':', ';', '~', '?', '=', '\'', '"', ',', '<'} // row 9: URL/extra symbols 193 }; 194 195 // Zone separator after these rows 196 static constexpr int ZONE_SEPARATORS[] = {0, 3, 6}; 197 static constexpr int NUM_ZONE_SEPARATORS = 3; 198 199 void KeyboardState::moveUp() { 200 if (cursorY > 0) { 201 cursorY--; 202 // When entering control row, snap to nearest control key 203 if (cursorY == CONTROL_ROW) { 204 if (cursorX <= BACKSPACE_END) { 205 cursorX = (BACKSPACE_START + BACKSPACE_END) / 2; 206 } else if (cursorX <= SPACE_END) { 207 cursorX = (SPACE_START + SPACE_END) / 2; 208 } else { 209 cursorX = (CONFIRM_START + CONFIRM_END) / 2; 210 } 211 } 212 } 213 } 214 215 void KeyboardState::moveDown() { 216 if (cursorY < NUM_ROWS - 1) { 217 cursorY++; 218 } 219 } 220 221 void KeyboardState::moveLeft() { 222 if (cursorY == CONTROL_ROW) { 223 // Snap between control buttons 224 if (cursorX >= CONFIRM_START) { 225 cursorX = (SPACE_START + SPACE_END) / 2; 226 } else if (cursorX >= SPACE_START) { 227 cursorX = (BACKSPACE_START + BACKSPACE_END) / 2; 228 } 229 } else { 230 if (cursorX > 0) { 231 cursorX--; 232 } else if (cursorY > 1) { 233 cursorY--; 234 cursorX = KEYS_PER_ROW - 1; 235 } 236 } 237 } 238 239 void KeyboardState::moveRight() { 240 if (cursorY == CONTROL_ROW) { 241 // Snap between control buttons 242 if (cursorX <= BACKSPACE_END) { 243 cursorX = (SPACE_START + SPACE_END) / 2; 244 } else if (cursorX <= SPACE_END) { 245 cursorX = (CONFIRM_START + CONFIRM_END) / 2; 246 } 247 } else { 248 if (cursorX < KEYS_PER_ROW - 1) { 249 cursorX++; 250 } else if (cursorY < NUM_ROWS - 1) { 251 cursorY++; 252 cursorX = 0; 253 } 254 } 255 } 256 257 void keyboard(const GfxRenderer& r, const Theme& t, int y, const KeyboardState& state) { 258 const int screenW = r.getScreenWidth(); 259 const int borderPadding = 10; 260 const int gridWidth = screenW - 2 * t.screenMarginSide - 2 * borderPadding; 261 const int keySpacingH = 2; 262 const int keySpacingV = 6; 263 const int keyW = (gridWidth - (KeyboardState::KEYS_PER_ROW - 1) * keySpacingH) / KeyboardState::KEYS_PER_ROW; 264 const int keyH = 20; 265 const int separatorHeight = 18; 266 const int startX = t.screenMarginSide + borderPadding; 267 268 int currentY = y + borderPadding; 269 int zoneIdx = 0; 270 271 for (int row = 0; row < KeyboardState::NUM_ROWS; row++) { 272 if (row == KeyboardState::CONTROL_ROW) { 273 // Control row: Backspace, Space, Confirm 274 int currentX = startX; 275 276 // Backspace (3 keys wide) 277 const int bsWidth = 3 * keyW + 2 * keySpacingH; 278 const bool bsSelected = state.isOnBackspace(); 279 if (bsSelected) { 280 r.drawText(t.uiFontId, currentX, currentY, "[Backspace]", t.primaryTextBlack); 281 } else { 282 r.drawText(t.uiFontId, currentX + 5, currentY, "Backspace", t.primaryTextBlack); 283 } 284 currentX += bsWidth + keySpacingH; 285 286 // Space (4 keys wide) 287 const int spWidth = 4 * keyW + 3 * keySpacingH; 288 const bool spSelected = state.isOnSpace(); 289 const int spTextX = currentX + (spWidth - r.getTextWidth(t.uiFontId, "Space")) / 2; 290 if (spSelected) { 291 r.drawText(t.uiFontId, spTextX - 6, currentY, "[Space]", t.primaryTextBlack); 292 } else { 293 r.drawText(t.uiFontId, spTextX, currentY, "Space", t.primaryTextBlack); 294 } 295 currentX += spWidth + keySpacingH; 296 297 // Confirm (3 keys wide) 298 const bool cfSelected = state.isOnConfirm(); 299 if (cfSelected) { 300 r.drawText(t.uiFontId, currentX, currentY, "[Confirm]", t.primaryTextBlack); 301 } else { 302 r.drawText(t.uiFontId, currentX + 5, currentY, "Confirm", t.primaryTextBlack); 303 } 304 } else { 305 // Regular character rows 306 for (int col = 0; col < KeyboardState::KEYS_PER_ROW; col++) { 307 const char c = KEYBOARD_GRID[row][col]; 308 const char keyStr[2] = {c, '\0'}; 309 const int keyX = startX + col * (keyW + keySpacingH); 310 const bool isSelected = (state.cursorY == row && state.cursorX == col); 311 312 // Center character in key 313 const int charW = r.getTextWidth(t.uiFontId, keyStr); 314 const int charX = keyX + (keyW - charW) / 2; 315 316 if (isSelected) { 317 r.drawText(t.uiFontId, charX - 6, currentY, "[", t.primaryTextBlack); 318 r.drawText(t.uiFontId, charX, currentY, keyStr, t.primaryTextBlack); 319 r.drawText(t.uiFontId, charX + charW, currentY, "]", t.primaryTextBlack); 320 } else { 321 r.drawText(t.uiFontId, charX, currentY, keyStr, t.primaryTextBlack); 322 } 323 } 324 } 325 326 currentY += keyH + keySpacingV; 327 328 // Draw zone separator after specific rows 329 if (zoneIdx < NUM_ZONE_SEPARATORS && row == ZONE_SEPARATORS[zoneIdx]) { 330 const int sepY = currentY + separatorHeight / 2 - 1; 331 r.drawLine(startX, sepY, startX + gridWidth, sepY, t.primaryTextBlack); 332 currentY += separatorHeight; 333 zoneIdx++; 334 } 335 } 336 } 337 338 char getKeyboardChar(const KeyboardState& state) { 339 if (state.cursorY == KeyboardState::CONTROL_ROW) { 340 // Return special chars for control buttons 341 if (state.isOnBackspace()) return '\x02'; 342 if (state.isOnSpace()) return ' '; 343 if (state.isOnConfirm()) return '\x03'; 344 return '\0'; 345 } 346 if (state.cursorY >= 0 && state.cursorY < KeyboardState::NUM_ROWS && state.cursorX >= 0 && 347 state.cursorX < KeyboardState::KEYS_PER_ROW) { 348 return KEYBOARD_GRID[state.cursorY][state.cursorX]; 349 } 350 return '\0'; 351 } 352 353 void battery(const GfxRenderer& r, const Theme& t, int x, int y, int percent) { 354 // Simple battery icon: [====] 355 const int battW = 30; 356 const int battH = 14; 357 const int tipW = 3; 358 const int tipH = 6; 359 360 // Battery body outline 361 r.drawRect(x, y, battW, battH, t.primaryTextBlack); 362 363 // Battery tip (positive terminal) 364 r.fillRect(x + battW, y + (battH - tipH) / 2, tipW, tipH, t.primaryTextBlack); 365 366 // Fill level 367 const int fillW = ((battW - 4) * percent) / 100; 368 if (fillW > 0) { 369 r.fillRect(x + 2, y + 2, fillW, battH - 4, t.primaryTextBlack); 370 } 371 372 // Percentage text 373 char buf[8]; 374 snprintf(buf, sizeof(buf), "%d%%", percent); 375 r.drawText(t.smallFontId, x + battW + tipW + 5, y, buf, t.primaryTextBlack); 376 } 377 378 void statusBar(const GfxRenderer& r, const Theme& t, int page, int total, int percent) { 379 const int y = r.getScreenHeight() - 25; 380 const int x = t.screenMarginSide; 381 const int screenW = r.getScreenWidth(); 382 383 // Page numbers on left 384 char pageStr[32]; 385 snprintf(pageStr, sizeof(pageStr), "%d / %d", page, total); 386 r.drawText(t.smallFontId, x + 5, y, pageStr, t.primaryTextBlack); 387 388 // Percentage on right 389 char percentStr[8]; 390 snprintf(percentStr, sizeof(percentStr), "%d%%", percent); 391 const int percentW = r.getTextWidth(t.smallFontId, percentStr); 392 r.drawText(t.smallFontId, screenW - x - percentW - 5, y, percentStr, t.primaryTextBlack); 393 } 394 395 void bookCard(const GfxRenderer& r, const Theme& t, int y, const char* titleText, const char* author, 396 const uint8_t* cover, int coverW, int coverH) { 397 const int x = t.screenMarginSide + 10; 398 const int screenW = r.getScreenWidth(); 399 400 // Draw cover if available 401 int textX = x; 402 if (cover != nullptr && coverW > 0 && coverH > 0) { 403 // Scale cover to fit (max 100x150) 404 const int maxCoverW = 100; 405 const int maxCoverH = 150; 406 int drawW = coverW; 407 int drawH = coverH; 408 409 if (drawW > maxCoverW || drawH > maxCoverH) { 410 const float scaleW = static_cast<float>(maxCoverW) / drawW; 411 const float scaleH = static_cast<float>(maxCoverH) / drawH; 412 const float scale = (scaleW < scaleH) ? scaleW : scaleH; 413 drawW = static_cast<int>(drawW * scale); 414 drawH = static_cast<int>(drawH * scale); 415 } 416 417 r.drawImage(cover, x, y, drawW, drawH); 418 textX = x + drawW + 15; 419 } 420 421 // Draw title (may wrap) 422 const int maxTextW = screenW - textX - t.screenMarginSide - 10; 423 const auto titleLines = r.wrapTextWithHyphenation(t.readerFontId, titleText, maxTextW, 2, EpdFontFamily::BOLD); 424 int textY = y + 10; 425 const int lineHeight = r.getLineHeight(t.readerFontId); 426 427 for (const auto& line : titleLines) { 428 r.drawText(t.readerFontId, textX, textY, line.c_str(), t.primaryTextBlack, EpdFontFamily::BOLD); 429 textY += lineHeight; 430 } 431 432 // Draw author below title 433 if (author != nullptr && author[0] != '\0') { 434 textY += 5; 435 r.drawText(t.uiFontId, textX, textY, author, t.secondaryTextBlack); 436 } 437 } 438 439 void fileEntry(const GfxRenderer& r, const Theme& t, int y, const char* name, bool isDir, bool selected) { 440 fileEntry(r, t, y, name, isDir, selected, false); 441 } 442 443 void fileEntry(const GfxRenderer& r, const Theme& t, int y, const char* name, bool isDir, bool selected, 444 bool hasProgress) { 445 const int x = t.screenMarginSide; 446 const int w = r.getScreenWidth() - 2 * t.screenMarginSide; 447 const int h = t.itemHeight; 448 const int textY = y + (h - r.getLineHeight(t.uiFontId)) / 2; 449 450 if (selected) { 451 r.fillRect(x, y, w, h, t.selectionFillBlack); 452 } 453 454 // Draw read indicator (filled square) for books with progress 455 int textStartX = x + t.itemPaddingX; 456 if (!isDir && hasProgress) { 457 const int indicatorSize = 6; 458 const int indicatorX = x + t.itemPaddingX; 459 const int indicatorY = y + (h - indicatorSize) / 2; 460 r.fillRect(indicatorX, indicatorY, indicatorSize, indicatorSize, selected ? t.selectionTextBlack : t.primaryTextBlack); 461 textStartX += indicatorSize + 8; // Offset text past the indicator 462 } 463 464 // Build display name with trailing "/" for directories 465 char displayName[132]; 466 if (isDir) { 467 snprintf(displayName, sizeof(displayName), "%s/", name); 468 } else { 469 strncpy(displayName, name, sizeof(displayName) - 1); 470 displayName[sizeof(displayName) - 1] = '\0'; 471 } 472 473 // Truncate if too long (accounting for indicator space) 474 const int maxTextW = w - (textStartX - x) - t.itemPaddingX; 475 const auto truncated = r.truncatedText(t.uiFontId, displayName, maxTextW); 476 477 r.drawText(t.uiFontId, textStartX, textY, truncated.c_str(), 478 selected ? t.selectionTextBlack : t.primaryTextBlack); 479 } 480 481 void chapterItem(const GfxRenderer& r, const Theme& t, int y, const char* title, uint8_t depth, bool selected, 482 bool isCurrent) { 483 constexpr int depthIndent = 12; 484 constexpr int minWidth = 50; 485 const int x = t.screenMarginSide + depth * depthIndent; 486 const int w = std::max(minWidth, r.getScreenWidth() - x - t.screenMarginSide); 487 const int h = t.itemHeight; 488 const int textY = y + (h - r.getLineHeight(t.uiFontId)) / 2; 489 490 // Selection highlight 491 if (selected) { 492 r.fillRect(x, y, w, h, t.selectionFillBlack); 493 } 494 495 // Current chapter indicator 496 if (isCurrent) { 497 r.drawText(t.uiFontId, t.screenMarginSide, textY, ">", t.primaryTextBlack); 498 } 499 500 // Truncated title 501 const int maxTitleW = w - t.itemPaddingX * 2; 502 const auto truncTitle = r.truncatedText(t.uiFontId, title, maxTitleW); 503 r.drawText(t.uiFontId, x + t.itemPaddingX, textY, truncTitle.c_str(), 504 selected ? t.selectionTextBlack : t.primaryTextBlack); 505 } 506 507 void wifiEntry(const GfxRenderer& r, const Theme& t, int y, const char* ssid, int signal, bool locked, bool selected) { 508 const int x = t.screenMarginSide; 509 const int w = r.getScreenWidth() - 2 * t.screenMarginSide; 510 const int h = t.itemHeight; 511 const int textY = y + (h - r.getLineHeight(t.uiFontId)) / 2; 512 513 if (selected) { 514 r.fillRect(x, y, w, h, t.selectionFillBlack); 515 } 516 517 const bool textColor = selected ? t.selectionTextBlack : t.primaryTextBlack; 518 519 // SSID name 520 const int maxSsidW = w - 80; 521 const auto truncatedSsid = r.truncatedText(t.uiFontId, ssid, maxSsidW); 522 r.drawText(t.uiFontId, x + t.itemPaddingX, textY, truncatedSsid.c_str(), textColor); 523 524 // Signal strength indicator (simple bars) 525 const int signalX = w - 45; 526 const int barW = 4; 527 const int barSpacing = 2; 528 const int barBaseY = y + h - 8; 529 530 // Draw 4 bars based on signal strength (0-100) 531 for (int i = 0; i < 4; i++) { 532 const int barH = 4 + i * 4; 533 const int barX = signalX + i * (barW + barSpacing); 534 const int threshold = 25 * (i + 1); 535 536 if (signal >= threshold) { 537 r.fillRect(barX, barBaseY - barH, barW, barH, textColor); 538 } else { 539 r.drawRect(barX, barBaseY - barH, barW, barH, textColor); 540 } 541 } 542 543 // Lock indicator 544 if (locked) { 545 r.drawText(t.smallFontId, w - 15, y + 8, "*", textColor); 546 } 547 } 548 549 void centeredText(const GfxRenderer& r, const Theme& t, int y, const char* str) { 550 r.drawCenteredText(t.uiFontId, y, str, t.primaryTextBlack); 551 } 552 553 void centeredMessage(const GfxRenderer& r, const Theme& t, int fontId, const char* message) { 554 r.clearScreen(t.backgroundColor); 555 const int y = r.getScreenHeight() / 2 - r.getLineHeight(fontId) / 2; 556 r.drawCenteredText(fontId, y, message, t.primaryTextBlack, EpdFontFamily::BOLD); 557 r.displayBuffer(); 558 } 559 560 void bookPlaceholder(const GfxRenderer& r, const Theme& t, int x, int y, int width, int height) { 561 if (width <= 0 || height <= 0) { 562 return; 563 } 564 565 const bool bgColor = !t.primaryTextBlack; 566 const bool fgColor = t.primaryTextBlack; 567 568 r.fillRect(x, y, width, height, bgColor); 569 570 constexpr int minSize = 50; 571 if (width < minSize || height < minSize) { 572 return; 573 } 574 575 // Scale factors from base design (400x500) 576 const float scaleX = static_cast<float>(width) / 400.0f; 577 const float scaleY = static_cast<float>(height) / 500.0f; 578 const float scale = std::min(scaleX, scaleY); 579 580 // Center the design within the area 581 const int designW = static_cast<int>(400 * scale); 582 const int designH = static_cast<int>(500 * scale); 583 const int offsetX = x + (width - designW) / 2; 584 const int offsetY = y + (height - designH) / 2; 585 586 // Helper lambdas for scaled coordinates 587 auto sx = [&](int v) { return offsetX + static_cast<int>(v * scale); }; 588 auto sy = [&](int v) { return offsetY + static_cast<int>(v * scale); }; 589 auto sw = [&](int v) { return std::max(1, static_cast<int>(v * scale)); }; 590 591 // Line thickness for outlines 592 const int lineThick = std::max(2, sw(4)); 593 594 // Helper to draw thick rectangle outline 595 auto drawThickRect = [&](int rx, int ry, int rw, int rh) { 596 r.fillRect(rx, ry, rw, lineThick, fgColor); // top 597 r.fillRect(rx, ry + rh - lineThick, rw, lineThick, fgColor); // bottom 598 r.fillRect(rx, ry, lineThick, rh, fgColor); // left 599 r.fillRect(rx + rw - lineThick, ry, lineThick, rh, fgColor); // right 600 }; 601 602 // 1. Draw spine (left side, filled) 603 r.fillRect(sx(20), sy(35), sw(20), sw(430), fgColor); 604 605 // 2. Draw page block outline (right side) 606 drawThickRect(sx(330), sy(35), sw(50), sw(430)); 607 // Page lines (5 horizontal lines, drawn as thin rectangles for thickness) 608 const int pageLineYs[] = {65, 110, 155, 200, 245}; 609 for (int py : pageLineYs) { 610 r.fillRect(sx(340), sy(py), sw(35), lineThick, fgColor); 611 } 612 613 // 3. Draw main cover outline (front) 614 drawThickRect(sx(35), sy(35), sw(295), sw(430)); 615 616 // 4. Draw bookmark ribbon (filled rectangle + triangle) 617 const int bmX = sx(280); 618 const int bmY = sy(35); 619 const int bmW = sw(40); 620 const int bmH = sw(45); 621 r.fillRect(bmX, bmY, bmW, bmH, fgColor); 622 // Triangle point (draw as filled lines) 623 const int triangleTop = bmY + bmH; 624 const int triangleTip = sy(100); 625 const int bmCenterX = bmX + bmW / 2; 626 for (int ty = triangleTop; ty <= triangleTip; ty++) { 627 int halfWidth = bmW / 2 * (triangleTip - ty) / (triangleTip - triangleTop); 628 if (halfWidth > 0) { 629 r.drawLine(bmCenterX - halfWidth, ty, bmCenterX + halfWidth, ty, fgColor); 630 } 631 } 632 633 // 5. Draw "No Cover" text centered on front cover 634 const int coverCenterX = sx(35) + sw(295) / 2; 635 const int coverCenterY = sy(35) + sw(430) / 2; 636 const char* noCoverText = "No Cover"; 637 const int textWidth = r.getTextWidth(t.uiFontId, noCoverText); 638 const int textX = coverCenterX - textWidth / 2; 639 const int textY = coverCenterY - r.getLineHeight(t.uiFontId) / 2; 640 r.drawText(t.uiFontId, textX, textY, noCoverText, fgColor); 641 } 642 643 void overlayBox(const GfxRenderer& r, const Theme& t, int fontId, int y, const char* message) { 644 constexpr int boxMargin = 20; 645 const int textWidth = r.getTextWidth(fontId, message); 646 const int boxWidth = textWidth + boxMargin * 2; 647 const int boxHeight = r.getLineHeight(fontId) + boxMargin * 2; 648 const int boxX = (r.getScreenWidth() - boxWidth) / 2; 649 650 r.fillRect(boxX, y, boxWidth, boxHeight, !t.primaryTextBlack); 651 r.drawText(fontId, boxX + boxMargin, y + boxMargin, message, t.primaryTextBlack); 652 } 653 654 void twoColumnRow(const GfxRenderer& r, const Theme& t, int y, const char* label, const char* value) { 655 const int labelX = t.screenMarginSide + t.itemPaddingX; 656 const int valueX = r.getScreenWidth() / 2; 657 658 r.drawText(t.uiFontId, labelX, y, label, t.primaryTextBlack); 659 r.drawText(t.uiFontId, valueX, y, value, t.secondaryTextBlack); 660 } 661 662 void readerStatusBar(const GfxRenderer& r, const Theme& t, int marginLeft, int marginRight, int marginTop, 663 const ReaderStatusBarData& data) { 664 if (data.mode == 0) return; // StatusNone 665 666 const auto screenWidth = r.getScreenWidth(); 667 // Text baseline sits 2px below the top margin origin. 668 const int textY = marginTop + 2; 669 670 // ---- 1. Battery icon + percentage + book progress (right side) ---- 671 // Build the right-side string: "42% 57%" (battery then book progress). 672 // Battery percentage text. 673 char batteryText[8]; 674 int percentage = data.batteryPercent; 675 if (percentage < 0) { 676 snprintf(batteryText, sizeof(batteryText), "--%%"); 677 percentage = 0; 678 } else { 679 snprintf(batteryText, sizeof(batteryText), "%d%%", percentage); 680 } 681 682 // Book progress text. 683 char progressText[8] = {}; 684 int progressTextWidth = 0; 685 if (data.bookProgressPercent >= 0) { 686 snprintf(progressText, sizeof(progressText), "%d%%", data.bookProgressPercent); 687 progressTextWidth = r.getTextWidth(t.smallFontId, progressText); 688 } 689 690 // Battery icon geometry (15x10 px). 691 constexpr int batteryIconWidth = 15; 692 constexpr int batteryIconHeight = 10; 693 constexpr int batteryTextGap = 2; // Between icon and percentage text. 694 constexpr int sectionGap = 10; // Between battery group and progress. 695 696 const int batteryPercentWidth = r.getTextWidth(t.smallFontId, batteryText); 697 698 // Total width of the right-side cluster, laid out right-to-left: 699 // [progress] [gap] [batteryIcon] [gap] [batteryPercent] 700 int rightGroupWidth = batteryIconWidth + batteryTextGap + batteryPercentWidth; 701 if (progressTextWidth > 0) { 702 rightGroupWidth += sectionGap + progressTextWidth; 703 } 704 705 const int rightGroupX = screenWidth - marginRight - rightGroupWidth; 706 707 // Draw battery icon. 708 const int battIconX = rightGroupX; 709 const int battIconY = marginTop + 5; // Vertically center icon with text. 710 711 // Battery outline. 712 r.drawLine(battIconX, battIconY, 713 battIconX + batteryIconWidth - 4, battIconY, t.primaryTextBlack); 714 r.drawLine(battIconX, battIconY + batteryIconHeight - 1, 715 battIconX + batteryIconWidth - 4, battIconY + batteryIconHeight - 1, t.primaryTextBlack); 716 r.drawLine(battIconX, battIconY, 717 battIconX, battIconY + batteryIconHeight - 1, t.primaryTextBlack); 718 r.drawLine(battIconX + batteryIconWidth - 4, battIconY, 719 battIconX + batteryIconWidth - 4, battIconY + batteryIconHeight - 1, t.primaryTextBlack); 720 // Battery terminal nub. 721 r.drawLine(battIconX + batteryIconWidth - 3, battIconY + 2, 722 battIconX + batteryIconWidth - 1, battIconY + 2, t.primaryTextBlack); 723 r.drawLine(battIconX + batteryIconWidth - 3, battIconY + batteryIconHeight - 3, 724 battIconX + batteryIconWidth - 1, battIconY + batteryIconHeight - 3, t.primaryTextBlack); 725 r.drawLine(battIconX + batteryIconWidth - 1, battIconY + 2, 726 battIconX + batteryIconWidth - 1, battIconY + batteryIconHeight - 3, t.primaryTextBlack); 727 728 // Battery fill level. 729 int filledWidth = percentage * (batteryIconWidth - 5) / 100 + 1; 730 if (filledWidth > batteryIconWidth - 5) { 731 filledWidth = batteryIconWidth - 5; 732 } 733 if (filledWidth > 0) { 734 r.fillRect(battIconX + 1, battIconY + 1, filledWidth, batteryIconHeight - 2, t.primaryTextBlack); 735 } 736 737 // Battery percentage text (right of icon). 738 const int battTextX = battIconX + batteryIconWidth + batteryTextGap; 739 r.drawText(t.smallFontId, battTextX, textY, batteryText, t.primaryTextBlack); 740 741 // Book progress percentage (rightmost). 742 if (progressTextWidth > 0) { 743 const int progressX = screenWidth - marginRight - progressTextWidth; 744 r.drawText(t.smallFontId, progressX, textY, progressText, t.primaryTextBlack); 745 } 746 747 // ---- 2. Title (true-centered on screen) ---- 748 if (data.title && data.title[0] != '\0') { 749 // Reserve left margin and battery/progress (right) so the title never 750 // overlaps them. Add a small gap (8px) on each side. 751 constexpr int titlePad = 8; 752 const int leftReserved = marginLeft + titlePad; 753 const int rightReserved = rightGroupWidth + marginRight + titlePad; 754 755 // Maximum width the title may occupy while still being drawable 756 // without overlapping the left or right clusters. 757 const int maxTitleWidth = screenWidth - leftReserved - rightReserved; 758 if (maxTitleWidth <= 0) return; 759 760 std::string titleStr = data.title; 761 int titleWidth = r.getTextWidth(t.smallFontId, titleStr.c_str()); 762 763 // Truncate with ellipsis if the title is too wide. 764 if (titleWidth > maxTitleWidth && titleStr.length() > 3) { 765 const int ellipsisWidth = r.getTextWidth(t.smallFontId, "..."); 766 if (ellipsisWidth > maxTitleWidth) { 767 return; // Cannot fit even the ellipsis; skip the title. 768 } 769 while (titleWidth + ellipsisWidth > maxTitleWidth && titleStr.length() > 0) { 770 titleStr.pop_back(); 771 titleWidth = r.getTextWidth(t.smallFontId, titleStr.c_str()); 772 } 773 titleStr += "..."; 774 titleWidth = r.getTextWidth(t.smallFontId, titleStr.c_str()); 775 } 776 777 // True-center: place the title at the horizontal midpoint of the 778 // screen, then clamp so it does not intrude into the reserved zones. 779 int titleX = (screenWidth - titleWidth) / 2; 780 if (titleX < leftReserved) { 781 titleX = leftReserved; 782 } 783 if (titleX + titleWidth > screenWidth - rightReserved) { 784 titleX = screenWidth - rightReserved - titleWidth; 785 } 786 787 r.drawText(t.smallFontId, titleX, textY, titleStr.c_str(), t.primaryTextBlack); 788 } 789 } 790 791 } // namespace ui