diff --git a/lib/GfxRenderer/GfxRenderer.cpp b/lib/GfxRenderer/GfxRenderer.cpp index 565ca351..9b0942cb 100644 --- a/lib/GfxRenderer/GfxRenderer.cpp +++ b/lib/GfxRenderer/GfxRenderer.cpp @@ -830,7 +830,8 @@ std::string GfxRenderer::truncatedText(const int fontId, const char* text, const if (!text || maxWidth <= 0) return ""; std::string item = text; - const char* ellipsis = "..."; + // U+2026 HORIZONTAL ELLIPSIS (UTF-8: 0xE2 0x80 0xA6) + const char* ellipsis = "\xe2\x80\xa6"; int textWidth = getTextWidth(fontId, item.c_str(), style); if (textWidth <= maxWidth) { // Text fits, return as is @@ -844,6 +845,70 @@ std::string GfxRenderer::truncatedText(const int fontId, const char* text, const return item.empty() ? ellipsis : item + ellipsis; } +std::vector GfxRenderer::wrappedText(const int fontId, const char* text, const int maxWidth, + const int maxLines, const EpdFontFamily::Style style) const { + std::vector lines; + + if (!text || maxWidth <= 0 || maxLines <= 0) return lines; + + std::string remaining = text; + std::string currentLine; + + while (!remaining.empty()) { + if (static_cast(lines.size()) == maxLines - 1) { + // Last available line: combine any word already started on this line with + // the rest of the text, then let truncatedText fit it with an ellipsis. + std::string lastContent = currentLine.empty() ? remaining : currentLine + " " + remaining; + lines.push_back(truncatedText(fontId, lastContent.c_str(), maxWidth, style)); + return lines; + } + + // Find next word + size_t spacePos = remaining.find(' '); + std::string word; + + if (spacePos == std::string::npos) { + word = remaining; + remaining.clear(); + } else { + word = remaining.substr(0, spacePos); + remaining.erase(0, spacePos + 1); + } + + std::string testLine = currentLine.empty() ? word : currentLine + " " + word; + + if (getTextWidth(fontId, testLine.c_str(), style) <= maxWidth) { + currentLine = testLine; + } else { + if (!currentLine.empty()) { + lines.push_back(currentLine); + // If the carried-over word itself exceeds maxWidth, truncate it and + // push it as a complete line immediately — storing it in currentLine + // would allow a subsequent short word to be appended after the ellipsis. + if (getTextWidth(fontId, word.c_str(), style) > maxWidth) { + lines.push_back(truncatedText(fontId, word.c_str(), maxWidth, style)); + currentLine.clear(); + if (static_cast(lines.size()) >= maxLines) return lines; + } else { + currentLine = word; + } + } else { + // Single word wider than maxWidth: truncate and stop to avoid complicated + // splitting rules (different between languages). Results in an aesthetically + // pleasing end. + lines.push_back(truncatedText(fontId, word.c_str(), maxWidth, style)); + return lines; + } + } + } + + if (!currentLine.empty() && static_cast(lines.size()) < maxLines) { + lines.push_back(currentLine); + } + + return lines; +} + // Note: Internal driver treats screen in command orientation; this library exposes a logical orientation int GfxRenderer::getScreenWidth() const { switch (orientation) { diff --git a/lib/GfxRenderer/GfxRenderer.h b/lib/GfxRenderer/GfxRenderer.h index 8873a986..e2bd1d0d 100644 --- a/lib/GfxRenderer/GfxRenderer.h +++ b/lib/GfxRenderer/GfxRenderer.h @@ -5,6 +5,8 @@ #include #include +#include +#include #include "Bitmap.h" @@ -120,6 +122,11 @@ class GfxRenderer { int getLineHeight(int fontId) const; std::string truncatedText(int fontId, const char* text, int maxWidth, EpdFontFamily::Style style = EpdFontFamily::REGULAR) const; + /// Word-wrap \p text into at most \p maxLines lines, each no wider than + /// \p maxWidth pixels. Overflowing words and excess lines are UTF-8-safely + /// truncated with an ellipsis (U+2026). + std::vector wrappedText(int fontId, const char* text, int maxWidth, int maxLines, + EpdFontFamily::Style style = EpdFontFamily::REGULAR) const; // Helper for drawing rotated text (90 degrees clockwise, for side buttons) void drawTextRotated90CW(int fontId, int x, int y, const char* text, bool black = true, diff --git a/src/components/themes/BaseTheme.cpp b/src/components/themes/BaseTheme.cpp index 3425dac6..a3766ba0 100644 --- a/src/components/themes/BaseTheme.cpp +++ b/src/components/themes/BaseTheme.cpp @@ -4,7 +4,6 @@ #include #include #include -#include #include #include @@ -488,82 +487,7 @@ void BaseTheme::drawRecentBookCover(GfxRenderer& renderer, Rect rect, const std: // - With cover: selected = white text on black box, unselected = black text on white box // - Without cover: selected = white text on black card, unselected = black text on white card - // Split into words (avoid stringstream to keep this light on the MCU) - std::vector words; - words.reserve(8); - size_t pos = 0; - while (pos < lastBookTitle.size()) { - while (pos < lastBookTitle.size() && lastBookTitle[pos] == ' ') { - ++pos; - } - if (pos >= lastBookTitle.size()) { - break; - } - const size_t start = pos; - while (pos < lastBookTitle.size() && lastBookTitle[pos] != ' ') { - ++pos; - } - words.emplace_back(lastBookTitle.substr(start, pos - start)); - } - - std::vector lines; - std::string currentLine; - // Extra padding inside the card so text doesn't hug the border - const int maxLineWidth = bookWidth - 40; - const int spaceWidth = renderer.getSpaceWidth(UI_12_FONT_ID, EpdFontFamily::REGULAR); - - for (auto& i : words) { - // If we just hit the line limit (3), stop processing words - if (lines.size() >= 3) { - // Limit to 3 lines - // Still have words left, so add ellipsis to last line - lines.back().append("..."); - - while (!lines.back().empty() && lines.back().size() > 3 && - renderer.getTextWidth(UI_12_FONT_ID, lines.back().c_str()) > maxLineWidth) { - // Remove "..." first, then remove one UTF-8 char, then add "..." back - lines.back().resize(lines.back().size() - 3); // Remove "..." - utf8RemoveLastChar(lines.back()); - lines.back().append("..."); - } - break; - } - - int wordWidth = renderer.getTextWidth(UI_12_FONT_ID, i.c_str()); - while (wordWidth > maxLineWidth && !i.empty()) { - // Word itself is too long, trim it (UTF-8 safe) - utf8RemoveLastChar(i); - // Check if we have room for ellipsis - std::string withEllipsis = i + "..."; - wordWidth = renderer.getTextWidth(UI_12_FONT_ID, withEllipsis.c_str()); - if (wordWidth <= maxLineWidth) { - i = withEllipsis; - break; - } - } - if (i.empty()) continue; // Skip words that couldn't fit even truncated - - int newLineWidth = renderer.getTextAdvanceX(UI_12_FONT_ID, currentLine.c_str(), EpdFontFamily::REGULAR); - if (newLineWidth > 0) { - newLineWidth += spaceWidth; - } - newLineWidth += renderer.getTextAdvanceX(UI_12_FONT_ID, i.c_str(), EpdFontFamily::REGULAR); - - if (newLineWidth > maxLineWidth && !currentLine.empty()) { - // New line too long, push old line - lines.push_back(currentLine); - currentLine = i; - } else if (currentLine.empty()) { - currentLine = i; - } else { - currentLine.append(" ").append(i); - } - } - - // If lower than the line limit, push remaining words - if (!currentLine.empty() && lines.size() < 3) { - lines.push_back(currentLine); - } + auto lines = renderer.wrappedText(UI_12_FONT_ID, lastBookTitle.c_str(), bookWidth - 40, 3); // Book title text int totalTextHeight = renderer.getLineHeight(UI_12_FONT_ID) * static_cast(lines.size()); @@ -574,6 +498,10 @@ void BaseTheme::drawRecentBookCover(GfxRenderer& renderer, Rect rect, const std: // Vertically center the title block within the card int titleYStart = bookY + (bookHeight - totalTextHeight) / 2; + const auto truncatedAuthor = lastBookAuthor.empty() + ? std::string{} + : renderer.truncatedText(UI_10_FONT_ID, lastBookAuthor.c_str(), bookWidth - 40); + // If cover image was rendered, draw box behind title and author if (coverRendered) { constexpr int boxPadding = 8; @@ -585,16 +513,8 @@ void BaseTheme::drawRecentBookCover(GfxRenderer& renderer, Rect rect, const std: maxTextWidth = lineWidth; } } - if (!lastBookAuthor.empty()) { - std::string trimmedAuthor = lastBookAuthor; - while (renderer.getTextWidth(UI_10_FONT_ID, trimmedAuthor.c_str()) > maxLineWidth && !trimmedAuthor.empty()) { - utf8RemoveLastChar(trimmedAuthor); - } - if (renderer.getTextWidth(UI_10_FONT_ID, trimmedAuthor.c_str()) < - renderer.getTextWidth(UI_10_FONT_ID, lastBookAuthor.c_str())) { - trimmedAuthor.append("..."); - } - const int authorWidth = renderer.getTextWidth(UI_10_FONT_ID, trimmedAuthor.c_str()); + if (!truncatedAuthor.empty()) { + const int authorWidth = renderer.getTextWidth(UI_10_FONT_ID, truncatedAuthor.c_str()); if (authorWidth > maxTextWidth) { maxTextWidth = authorWidth; } @@ -616,24 +536,9 @@ void BaseTheme::drawRecentBookCover(GfxRenderer& renderer, Rect rect, const std: titleYStart += renderer.getLineHeight(UI_12_FONT_ID); } - if (!lastBookAuthor.empty()) { + if (!truncatedAuthor.empty()) { titleYStart += renderer.getLineHeight(UI_10_FONT_ID) / 2; - std::string trimmedAuthor = lastBookAuthor; - // Trim author if too long (UTF-8 safe) - bool wasTrimmed = false; - while (renderer.getTextWidth(UI_10_FONT_ID, trimmedAuthor.c_str()) > maxLineWidth && !trimmedAuthor.empty()) { - utf8RemoveLastChar(trimmedAuthor); - wasTrimmed = true; - } - if (wasTrimmed && !trimmedAuthor.empty()) { - // Make room for ellipsis - while (renderer.getTextWidth(UI_10_FONT_ID, (trimmedAuthor + "...").c_str()) > maxLineWidth && - !trimmedAuthor.empty()) { - utf8RemoveLastChar(trimmedAuthor); - } - trimmedAuthor.append("..."); - } - renderer.drawCenteredText(UI_10_FONT_ID, titleYStart, trimmedAuthor.c_str(), !bookSelected); + renderer.drawCenteredText(UI_10_FONT_ID, titleYStart, truncatedAuthor.c_str(), !bookSelected); } // "Continue Reading" label at the bottom diff --git a/src/components/themes/lyra/Lyra3CoversTheme.cpp b/src/components/themes/lyra/Lyra3CoversTheme.cpp index adbae934..68d8b234 100644 --- a/src/components/themes/lyra/Lyra3CoversTheme.cpp +++ b/src/components/themes/lyra/Lyra3CoversTheme.cpp @@ -5,6 +5,7 @@ #include #include +#include #include "RecentBooksStore.h" #include "components/UITheme.h" @@ -15,15 +16,12 @@ namespace { constexpr int hPaddingInSelection = 8; constexpr int cornerRadius = 6; -int coverWidth = 0; } // namespace void Lyra3CoversTheme::drawRecentBookCover(GfxRenderer& renderer, Rect rect, const std::vector& recentBooks, const int selectorIndex, bool& coverRendered, bool& coverBufferStored, bool& bufferRestored, std::function storeCoverBuffer) const { const int tileWidth = (rect.width - 2 * Lyra3CoversMetrics::values.contentSidePadding) / 3; - const int tileHeight = rect.height; - const int bookTitleHeight = tileHeight - Lyra3CoversMetrics::values.homeCoverHeight - hPaddingInSelection; const int tileY = rect.y; const bool hasContinueReading = !recentBooks.empty(); @@ -87,8 +85,15 @@ void Lyra3CoversTheme::drawRecentBookCover(GfxRenderer& renderer, Rect rect, con bool bookSelected = (selectorIndex == i); int tileX = Lyra3CoversMetrics::values.contentSidePadding + tileWidth * i; - auto title = - renderer.truncatedText(UI_10_FONT_ID, recentBooks[i].title.c_str(), tileWidth - 2 * hPaddingInSelection); + + const int maxLineWidth = tileWidth - 2 * hPaddingInSelection; + + auto titleLines = renderer.wrappedText(SMALL_FONT_ID, recentBooks[i].title.c_str(), maxLineWidth, 3); + + const int titleLineHeight = renderer.getLineHeight(SMALL_FONT_ID); + const int dynamicBlockHeight = static_cast(titleLines.size()) * titleLineHeight; + // Add a little padding below the text inside the selection box just like the top padding (5 + hPaddingSelection) + const int dynamicTitleBoxHeight = dynamicBlockHeight + hPaddingInSelection + 5; if (bookSelected) { // Draw selection box @@ -99,10 +104,15 @@ void Lyra3CoversTheme::drawRecentBookCover(GfxRenderer& renderer, Rect rect, con renderer.fillRectDither(tileX + tileWidth - hPaddingInSelection, tileY + hPaddingInSelection, hPaddingInSelection, Lyra3CoversMetrics::values.homeCoverHeight, Color::LightGray); renderer.fillRoundedRect(tileX, tileY + Lyra3CoversMetrics::values.homeCoverHeight + hPaddingInSelection, - tileWidth, bookTitleHeight, cornerRadius, false, false, true, true, Color::LightGray); + tileWidth, dynamicTitleBoxHeight, cornerRadius, false, false, true, true, + Color::LightGray); + } + + int currentY = tileY + Lyra3CoversMetrics::values.homeCoverHeight + hPaddingInSelection + 5; + for (const auto& line : titleLines) { + renderer.drawText(SMALL_FONT_ID, tileX + hPaddingInSelection, currentY, line.c_str(), true); + currentY += titleLineHeight; } - renderer.drawText(UI_10_FONT_ID, tileX + hPaddingInSelection, - tileY + tileHeight - bookTitleHeight + hPaddingInSelection + 5, title.c_str(), true); } } else { drawEmptyRecents(renderer, rect); diff --git a/src/components/themes/lyra/Lyra3CoversTheme.h b/src/components/themes/lyra/Lyra3CoversTheme.h index 2bb8ccb3..a9177d3f 100644 --- a/src/components/themes/lyra/Lyra3CoversTheme.h +++ b/src/components/themes/lyra/Lyra3CoversTheme.h @@ -25,7 +25,7 @@ constexpr ThemeMetrics values = {.batteryWidth = 16, .scrollBarRightOffset = 5, .homeTopPadding = 56, .homeCoverHeight = 226, - .homeCoverTileHeight = 287, + .homeCoverTileHeight = 300, .homeRecentBooksCount = 3, .buttonHintsHeight = 40, .sideButtonHintsWidth = 30, diff --git a/src/components/themes/lyra/LyraTheme.cpp b/src/components/themes/lyra/LyraTheme.cpp index 5b2abd7c..36c19501 100644 --- a/src/components/themes/lyra/LyraTheme.cpp +++ b/src/components/themes/lyra/LyraTheme.cpp @@ -4,7 +4,6 @@ #include #include #include -#include #include #include @@ -485,57 +484,7 @@ void LyraTheme::drawRecentBookCover(GfxRenderer& renderer, Rect rect, const std: hPaddingInSelection, cornerRadius, false, false, true, true, Color::LightGray); } - // Wrap title to up to 3 lines (word-wrap by advance width) - const std::string& lastBookTitle = book.title; - std::vector words; - words.reserve(8); - std::string::size_type wordStart = 0; - std::string::size_type wordEnd = 0; - // find_first_not_of skips leading/interstitial spaces - while ((wordStart = lastBookTitle.find_first_not_of(' ', wordEnd)) != std::string::npos) { - wordEnd = lastBookTitle.find(' ', wordStart); - if (wordEnd == std::string::npos) wordEnd = lastBookTitle.size(); - words.emplace_back(lastBookTitle.substr(wordStart, wordEnd - wordStart)); - } - const int maxLineWidth = textWidth; - const int spaceWidth = renderer.getSpaceWidth(UI_12_FONT_ID, EpdFontFamily::BOLD); - std::vector titleLines; - std::string currentLine; - for (auto& w : words) { - if (titleLines.size() >= 3) { - titleLines.back().append("..."); - while (!titleLines.back().empty() && titleLines.back().size() > 3 && - renderer.getTextWidth(UI_12_FONT_ID, titleLines.back().c_str(), EpdFontFamily::BOLD) > maxLineWidth) { - titleLines.back().resize(titleLines.back().size() - 3); - utf8RemoveLastChar(titleLines.back()); - titleLines.back().append("..."); - } - break; - } - int wordW = renderer.getTextWidth(UI_12_FONT_ID, w.c_str(), EpdFontFamily::BOLD); - while (wordW > maxLineWidth && !w.empty()) { - utf8RemoveLastChar(w); - std::string withE = w + "..."; - wordW = renderer.getTextWidth(UI_12_FONT_ID, withE.c_str(), EpdFontFamily::BOLD); - if (wordW <= maxLineWidth) { - w = withE; - break; - } - } - if (w.empty()) continue; // Skip words that couldn't fit even truncated - int newW = renderer.getTextAdvanceX(UI_12_FONT_ID, currentLine.c_str(), EpdFontFamily::BOLD); - if (newW > 0) newW += spaceWidth; - newW += renderer.getTextAdvanceX(UI_12_FONT_ID, w.c_str(), EpdFontFamily::BOLD); - if (newW > maxLineWidth && !currentLine.empty()) { - titleLines.push_back(currentLine); - currentLine = w; - } else if (currentLine.empty()) { - currentLine = w; - } else { - currentLine.append(" ").append(w); - } - } - if (!currentLine.empty() && titleLines.size() < 3) titleLines.push_back(currentLine); + auto titleLines = renderer.wrappedText(UI_12_FONT_ID, book.title.c_str(), textWidth, 3, EpdFontFamily::BOLD); auto author = renderer.truncatedText(UI_10_FONT_ID, book.author.c_str(), textWidth); const int titleLineHeight = renderer.getLineHeight(UI_12_FONT_ID);