/ src / ui / Elements.cpp
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