3 Commits

Author SHA1 Message Date
cottongin
51dc498768 feat: Expandable selected row for long filenames in File Browser
When the selected row's filename overflows the available text width
(with extension), the row expands to 2 lines with smart text wrapping.
The file extension moves to the second row (right-aligned). Non-selected
rows retain single-line truncation.

Key behaviors:
- 3-tier text wrapping: preferred delimiters (" - ", " -- ", en/em-dash),
  word boundaries, then character-level fallback
- Row-height line spacing for natural visual rhythm
- Icons aligned with line 1 (LyraTheme)
- Pagination uses effectivePageItems with anti-leak clamping to prevent
  page boundary shifts while ensuring all items remain accessible
- Boundary item duplication: items bumped from a page due to expansion
  appear at the top of the next page, guarded against cascading

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-02-20 19:42:56 -05:00
cottongin
406c3aeace fix: Port upstream PRs #1038, #1037, #1045, #1019
- #1038 (partial): Add .erase() for consumed words in layoutAndExtractLines
  to fix redundant early flush bug; fix wordContinues flag in hyphenateWordAtIndex
- #1037: Add combining mark handling for hyphenation (NFC-like precomposition)
  and rendering (base glyph tracking in EpdFont, GfxRenderer including CCW)
- #1045: Shorten STR_FORGET_BUTTON labels across all 9 translation files
- #1019: Display file extensions in File Browser via getFileExtension helper
- Pull romanian.yaml from upstream/master (merged PR #987)

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-02-20 16:27:59 -05:00
cottongin
55a1fef01a fix: Port upstream 1.1.0-rc PRs #1014, #1018, #990 and align #1002
Port three new upstream commits and align the existing #1002 port:

- PR #1014: Strip unused CSS rules by filtering unsupported selector
  types (+, >, [, :, #, ~, *, descendants) in processRuleBlockWithStyle.
  Fix normalized() trailing whitespace to also strip newlines.
- PR #1018: Add deleteCache() to CssParser, move CSS_CACHE_VERSION to
  static class member, remove stale cache on version mismatch, invalidate
  section caches (Storage.removeDir) when CSS is rebuilt. Refactor
  parseCssFiles() to early-return when cache exists.
- PR #990: Adapt classic theme continue-reading card width to cover
  aspect ratio (clamped to 90% screen width), increase homeTopPadding
  20->40, fix centering with rect.x offset for boxX/continueBoxX.
- #1002 alignment: Add tryInterpretLength() to skip non-numeric CSS
  values (auto, inherit), add "both width and height set" image sizing
  branch in ChapterHtmlSlimParser.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-02-20 15:52:30 -05:00
24 changed files with 1346 additions and 209 deletions

View File

@@ -17,6 +17,11 @@ void EpdFont::getTextBounds(const char* string, const int startX, const int star
int cursorX = startX;
const int cursorY = startY;
int lastBaseX = startX;
int lastBaseAdvance = 0;
int lastBaseTop = 0;
bool hasBaseGlyph = false;
constexpr int MIN_COMBINING_GAP_PX = 1;
uint32_t cp;
while ((cp = utf8NextCodepoint(reinterpret_cast<const uint8_t**>(&string)))) {
const EpdGlyph* glyph = getGlyph(cp);
@@ -30,11 +35,30 @@ void EpdFont::getTextBounds(const char* string, const int startX, const int star
continue;
}
*minX = std::min(*minX, cursorX + glyph->left);
*maxX = std::max(*maxX, cursorX + glyph->left + glyph->width);
*minY = std::min(*minY, cursorY + glyph->top - glyph->height);
*maxY = std::max(*maxY, cursorY + glyph->top);
cursorX += glyph->advanceX;
const bool isCombining = utf8IsCombiningMark(cp);
int raiseBy = 0;
if (isCombining && hasBaseGlyph) {
const int currentGap = glyph->top - glyph->height - lastBaseTop;
if (currentGap < MIN_COMBINING_GAP_PX) {
raiseBy = MIN_COMBINING_GAP_PX - currentGap;
}
}
const int glyphBaseX = (isCombining && hasBaseGlyph) ? (lastBaseX + lastBaseAdvance / 2) : cursorX;
const int glyphBaseY = cursorY - raiseBy;
*minX = std::min(*minX, glyphBaseX + glyph->left);
*maxX = std::max(*maxX, glyphBaseX + glyph->left + glyph->width);
*minY = std::min(*minY, glyphBaseY + glyph->top - glyph->height);
*maxY = std::max(*maxY, glyphBaseY + glyph->top);
if (!isCombining) {
lastBaseX = cursorX;
lastBaseAdvance = glyph->advanceX;
lastBaseTop = glyph->top;
hasBaseGlyph = true;
cursorX += glyph->advanceX;
}
}
}

View File

@@ -213,74 +213,69 @@ bool Epub::parseTocNavFile() const {
}
void Epub::parseCssFiles() const {
// Maximum CSS file size we'll attempt to parse (uncompressed)
// Larger files risk memory exhaustion on ESP32
constexpr size_t MAX_CSS_FILE_SIZE = 128 * 1024; // 128KB
// Minimum heap required before attempting CSS parsing
constexpr size_t MIN_HEAP_FOR_CSS_PARSING = 64 * 1024; // 64KB
constexpr size_t MAX_CSS_FILE_SIZE = 128 * 1024;
constexpr size_t MIN_HEAP_FOR_CSS_PARSING = 64 * 1024;
if (cssFiles.empty()) {
LOG_DBG("EBP", "No CSS files to parse, but CssParser created for inline styles");
}
// See if we have a cached version of the CSS rules
if (!cssParser->hasCache()) {
// No cache yet - parse CSS files
for (const auto& cssPath : cssFiles) {
LOG_DBG("EBP", "Parsing CSS file: %s", cssPath.c_str());
LOG_DBG("EBP", "CSS files to parse: %zu", cssFiles.size());
// Check heap before parsing - CSS parsing allocates heavily
const uint32_t freeHeap = ESP.getFreeHeap();
if (freeHeap < MIN_HEAP_FOR_CSS_PARSING) {
LOG_ERR("EBP", "Insufficient heap for CSS parsing (%u bytes free, need %zu), skipping: %s", freeHeap,
MIN_HEAP_FOR_CSS_PARSING, cssPath.c_str());
if (cssParser->hasCache()) {
LOG_DBG("EBP", "CSS cache exists, skipping parseCssFiles");
return;
}
for (const auto& cssPath : cssFiles) {
LOG_DBG("EBP", "Parsing CSS file: %s", cssPath.c_str());
const uint32_t freeHeap = ESP.getFreeHeap();
if (freeHeap < MIN_HEAP_FOR_CSS_PARSING) {
LOG_ERR("EBP", "Insufficient heap for CSS parsing (%u bytes free, need %zu), skipping: %s", freeHeap,
MIN_HEAP_FOR_CSS_PARSING, cssPath.c_str());
continue;
}
size_t cssFileSize = 0;
if (getItemSize(cssPath, &cssFileSize)) {
if (cssFileSize > MAX_CSS_FILE_SIZE) {
LOG_ERR("EBP", "CSS file too large (%zu bytes > %zu max), skipping: %s", cssFileSize, MAX_CSS_FILE_SIZE,
cssPath.c_str());
continue;
}
}
// Check CSS file size before decompressing - skip files that are too large
size_t cssFileSize = 0;
if (getItemSize(cssPath, &cssFileSize)) {
if (cssFileSize > MAX_CSS_FILE_SIZE) {
LOG_ERR("EBP", "CSS file too large (%zu bytes > %zu max), skipping: %s", cssFileSize, MAX_CSS_FILE_SIZE,
cssPath.c_str());
continue;
}
}
// Extract CSS file to temp location
const auto tmpCssPath = getCachePath() + "/.tmp.css";
FsFile tempCssFile;
if (!Storage.openFileForWrite("EBP", tmpCssPath, tempCssFile)) {
LOG_ERR("EBP", "Could not create temp CSS file");
continue;
}
if (!readItemContentsToStream(cssPath, tempCssFile, 1024)) {
LOG_ERR("EBP", "Could not read CSS file: %s", cssPath.c_str());
tempCssFile.close();
Storage.remove(tmpCssPath.c_str());
continue;
}
tempCssFile.close();
// Parse the CSS file
if (!Storage.openFileForRead("EBP", tmpCssPath, tempCssFile)) {
LOG_ERR("EBP", "Could not open temp CSS file for reading");
Storage.remove(tmpCssPath.c_str());
continue;
}
cssParser->loadFromStream(tempCssFile);
const auto tmpCssPath = getCachePath() + "/.tmp.css";
FsFile tempCssFile;
if (!Storage.openFileForWrite("EBP", tmpCssPath, tempCssFile)) {
LOG_ERR("EBP", "Could not create temp CSS file");
continue;
}
if (!readItemContentsToStream(cssPath, tempCssFile, 1024)) {
LOG_ERR("EBP", "Could not read CSS file: %s", cssPath.c_str());
tempCssFile.close();
Storage.remove(tmpCssPath.c_str());
continue;
}
tempCssFile.close();
// Save to cache for next time
if (!cssParser->saveToCache()) {
LOG_ERR("EBP", "Failed to save CSS rules to cache");
if (!Storage.openFileForRead("EBP", tmpCssPath, tempCssFile)) {
LOG_ERR("EBP", "Could not open temp CSS file for reading");
Storage.remove(tmpCssPath.c_str());
continue;
}
cssParser->clear();
LOG_DBG("EBP", "Loaded %zu CSS style rules from %zu files", cssParser->ruleCount(), cssFiles.size());
cssParser->loadFromStream(tempCssFile);
tempCssFile.close();
Storage.remove(tmpCssPath.c_str());
}
if (!cssParser->saveToCache()) {
LOG_ERR("EBP", "Failed to save CSS rules to cache");
}
cssParser->clear();
LOG_DBG("EBP", "Loaded %zu CSS style rules from %zu files", cssParser->ruleCount(), cssFiles.size());
}
// load in the meta data for the epub file
@@ -294,14 +289,17 @@ bool Epub::load(const bool buildIfMissing, const bool skipLoadingCss) {
// Try to load existing cache first
if (bookMetadataCache->load()) {
if (!skipLoadingCss && !cssParser->hasCache()) {
LOG_DBG("EBP", "Warning: CSS rules cache not found, attempting to parse CSS files");
// to get CSS file list
if (!parseContentOpf(bookMetadataCache->coreMetadata)) {
LOG_ERR("EBP", "Could not parse content.opf from cached bookMetadata for CSS files");
// continue anyway - book will work without CSS and we'll still load any inline style CSS
if (!skipLoadingCss) {
if (!cssParser->hasCache() || !cssParser->loadFromCache()) {
LOG_DBG("EBP", "CSS rules cache missing or stale, attempting to parse CSS files");
cssParser->deleteCache();
if (!parseContentOpf(bookMetadataCache->coreMetadata)) {
LOG_ERR("EBP", "Could not parse content.opf from cached bookMetadata for CSS files");
}
parseCssFiles();
Storage.removeDir((cachePath + "/sections").c_str());
}
parseCssFiles();
}
LOG_DBG("EBP", "Loaded ePub: %s", filepath.c_str());
return true;
@@ -400,8 +398,8 @@ bool Epub::load(const bool buildIfMissing, const bool skipLoadingCss) {
}
if (!skipLoadingCss) {
// Parse CSS files after cache reload
parseCssFiles();
Storage.removeDir((cachePath + "/sections").c_str());
}
LOG_DBG("EBP", "Loaded ePub: %s", filepath.c_str());

View File

@@ -100,6 +100,15 @@ void ParsedText::layoutAndExtractLines(const GfxRenderer& renderer, const int fo
for (size_t i = 0; i < lineCount; ++i) {
extractLine(i, pageWidth, spaceWidth, wordWidths, wordContinues, lineBreakIndices, processLine);
}
// Remove consumed words so size() reflects only remaining words
if (lineCount > 0) {
const size_t consumed = lineBreakIndices[lineCount - 1];
words.erase(words.begin(), words.begin() + consumed);
wordStyles.erase(wordStyles.begin(), wordStyles.begin() + consumed);
wordContinues.erase(wordContinues.begin(), wordContinues.begin() + consumed);
forceBreakAfter.erase(forceBreakAfter.begin(), forceBreakAfter.begin() + consumed);
}
}
std::vector<uint16_t> ParsedText::calculateWordWidths(const GfxRenderer& renderer, const int fontId) {
@@ -392,11 +401,8 @@ bool ParsedText::hyphenateWordAtIndex(const size_t wordIndex, const int availabl
words.insert(words.begin() + wordIndex + 1, remainder);
wordStyles.insert(wordStyles.begin() + wordIndex + 1, style);
// The remainder inherits whatever continuation status the original word had with the word after it.
const bool originalContinuedToNext = wordContinues[wordIndex];
// The original word (now prefix) does NOT continue to remainder (hyphen separates them)
wordContinues[wordIndex] = false;
wordContinues.insert(wordContinues.begin() + wordIndex + 1, originalContinuedToNext);
// Preserve the prefix's attach-to-previous flag; allow a break between prefix and remainder.
wordContinues.insert(wordContinues.begin() + wordIndex + 1, false);
// Forced break belongs to the original whole word; transfer it to the remainder (last part).
if (!forceBreakAfter.empty()) {

View File

@@ -74,7 +74,7 @@ std::string CssParser::normalized(const std::string& s) {
}
// Remove trailing space
if (!result.empty() && result.back() == ' ') {
while (!result.empty() && (result.back() == ' ' || result.back() == '\n')) {
result.pop_back();
}
return result;
@@ -189,10 +189,18 @@ CssTextDecoration CssParser::interpretDecoration(const std::string& val) {
}
CssLength CssParser::interpretLength(const std::string& val) {
const std::string v = normalized(val);
if (v.empty()) return CssLength{};
CssLength result;
tryInterpretLength(val, result);
return result;
}
bool CssParser::tryInterpretLength(const std::string& val, CssLength& out) {
const std::string v = normalized(val);
if (v.empty()) {
out = CssLength{};
return false;
}
// Find where the number ends
size_t unitStart = v.size();
for (size_t i = 0; i < v.size(); ++i) {
const char c = v[i];
@@ -205,12 +213,13 @@ CssLength CssParser::interpretLength(const std::string& val) {
const std::string numPart = v.substr(0, unitStart);
const std::string unitPart = v.substr(unitStart);
// Parse numeric value
char* endPtr = nullptr;
const float numericValue = std::strtof(numPart.c_str(), &endPtr);
if (endPtr == numPart.c_str()) return CssLength{}; // No number parsed
if (endPtr == numPart.c_str()) {
out = CssLength{};
return false; // No number parsed (e.g. auto, inherit, initial)
}
// Determine unit type (preserve for deferred resolution)
auto unit = CssUnit::Pixels;
if (unitPart == "em") {
unit = CssUnit::Em;
@@ -221,9 +230,9 @@ CssLength CssParser::interpretLength(const std::string& val) {
} else if (unitPart == "%") {
unit = CssUnit::Percent;
}
// px and unitless default to Pixels
return CssLength{numericValue, unit};
out = CssLength{numericValue, unit};
return true;
}
// Declaration parsing
@@ -296,11 +305,17 @@ void CssParser::parseDeclarationIntoStyle(const std::string& decl, CssStyle& sty
1;
}
} else if (propNameBuf == "height") {
style.imageHeight = interpretLength(propValueBuf);
style.defined.imageHeight = 1;
CssLength len;
if (tryInterpretLength(propValueBuf, len)) {
style.imageHeight = len;
style.defined.imageHeight = 1;
}
} else if (propNameBuf == "width") {
style.width = interpretLength(propValueBuf);
style.defined.width = 1;
CssLength len;
if (tryInterpretLength(propValueBuf, len)) {
style.width = len;
style.defined.width = 1;
}
}
}
@@ -349,6 +364,17 @@ void CssParser::processRuleBlockWithStyle(const std::string& selectorGroup, cons
std::string key = normalized(sel);
if (key.empty()) continue;
// Skip unsupported selector types to reduce memory usage.
// We only match: tag, tag.class, .class
if (key.find('+') != std::string::npos) continue; // adjacent sibling
if (key.find('>') != std::string::npos) continue; // child combinator
if (key.find('[') != std::string::npos) continue; // attribute selector
if (key.find(':') != std::string::npos) continue; // pseudo selector
if (key.find('#') != std::string::npos) continue; // ID selector
if (key.find('~') != std::string::npos) continue; // general sibling
if (key.find('*') != std::string::npos) continue; // wildcard
if (key.find(' ') != std::string::npos) continue; // descendant combinator
// Skip if this would exceed the rule limit
if (rulesBySelector_.size() >= MAX_RULES) {
LOG_DBG("CSS", "Reached max rules limit, stopping selector processing");
@@ -534,6 +560,7 @@ CssStyle CssParser::resolveStyle(const std::string& tagName, const std::string&
result.applyOver(tagIt->second);
}
// TODO: Support combinations of classes (e.g. style on .class1.class2)
// 2. Apply class styles (medium priority)
if (!classAttr.empty()) {
const auto classes = splitWhitespace(classAttr);
@@ -547,6 +574,7 @@ CssStyle CssParser::resolveStyle(const std::string& tagName, const std::string&
}
}
// TODO: Support combinations of classes (e.g. style on p.class1.class2)
// 3. Apply element.class styles (higher priority)
for (const auto& cls : classes) {
std::string combinedKey = tag + "." + normalized(cls);
@@ -567,12 +595,15 @@ CssStyle CssParser::parseInlineStyle(const std::string& styleValue) { return par
// Cache serialization
// Cache format version - increment when format changes
constexpr uint8_t CSS_CACHE_VERSION = 3;
// Cache file name (version is CssParser::CSS_CACHE_VERSION)
constexpr char rulesCache[] = "/css_rules.cache";
bool CssParser::hasCache() const { return Storage.exists((cachePath + rulesCache).c_str()); }
void CssParser::deleteCache() const {
if (hasCache()) Storage.remove((cachePath + rulesCache).c_str());
}
bool CssParser::saveToCache() const {
if (cachePath.empty()) {
return false;
@@ -584,7 +615,7 @@ bool CssParser::saveToCache() const {
}
// Write version
file.write(CSS_CACHE_VERSION);
file.write(CssParser::CSS_CACHE_VERSION);
// Write rule count
const auto ruleCount = static_cast<uint16_t>(rulesBySelector_.size());
@@ -662,9 +693,11 @@ bool CssParser::loadFromCache() {
// Read and verify version
uint8_t version = 0;
if (file.read(&version, 1) != 1 || version != CSS_CACHE_VERSION) {
LOG_DBG("CSS", "Cache version mismatch (got %u, expected %u)", version, CSS_CACHE_VERSION);
if (file.read(&version, 1) != 1 || version != CssParser::CSS_CACHE_VERSION) {
LOG_DBG("CSS", "Cache version mismatch (got %u, expected %u), removing stale cache for rebuild", version,
CssParser::CSS_CACHE_VERSION);
file.close();
Storage.remove((cachePath + rulesCache).c_str());
return false;
}

View File

@@ -82,6 +82,11 @@ class CssParser {
*/
bool hasCache() const;
/**
* Delete CSS rules cache file if it exists
*/
void deleteCache() const;
/**
* Save parsed CSS rules to a cache file.
* @return true if cache was written successfully
@@ -91,10 +96,14 @@ class CssParser {
/**
* Load CSS rules from a cache file.
* Clears any existing rules before loading.
* Removes stale cache file on version mismatch.
* @return true if cache was loaded successfully
*/
bool loadFromCache();
// Bump when CSS cache format or rules change; section caches are invalidated when this changes
static constexpr uint8_t CSS_CACHE_VERSION = 3;
private:
// Storage: maps normalized selector -> style properties
std::unordered_map<std::string, CssStyle> rulesBySelector_;
@@ -113,6 +122,7 @@ class CssParser {
static CssFontWeight interpretFontWeight(const std::string& val);
static CssTextDecoration interpretDecoration(const std::string& val);
static CssLength interpretLength(const std::string& val);
static bool tryInterpretLength(const std::string& val, CssLength& out);
// String utilities
static std::string normalized(const std::string& s);

View File

@@ -174,6 +174,213 @@ std::vector<CodepointInfo> collectCodepoints(const std::string& word) {
while (*ptr != 0) {
const unsigned char* current = ptr;
const uint32_t cp = utf8NextCodepoint(&ptr);
// If this is a combining diacritic (e.g., U+0301 = acute) and there's
// a previous base character that can be composed into a single
// precomposed Unicode scalar (Latin-1 / Latin-Extended), do that
// composition here. This provides lightweight NFC-like behavior for
// common Western European diacritics (acute, grave, circumflex, tilde,
// diaeresis, cedilla) without pulling in a full Unicode normalization
// library.
if (!cps.empty()) {
uint32_t prev = cps.back().value;
uint32_t composed = 0;
switch (cp) {
case 0x0300: // grave
switch (prev) {
case 0x0041:
composed = 0x00C0;
break; // A -> À
case 0x0061:
composed = 0x00E0;
break; // a -> à
case 0x0045:
composed = 0x00C8;
break; // E -> È
case 0x0065:
composed = 0x00E8;
break; // e -> è
case 0x0049:
composed = 0x00CC;
break; // I -> Ì
case 0x0069:
composed = 0x00EC;
break; // i -> ì
case 0x004F:
composed = 0x00D2;
break; // O -> Ò
case 0x006F:
composed = 0x00F2;
break; // o -> ò
case 0x0055:
composed = 0x00D9;
break; // U -> Ù
case 0x0075:
composed = 0x00F9;
break; // u -> ù
default:
break;
}
break;
case 0x0301: // acute
switch (prev) {
case 0x0041:
composed = 0x00C1;
break; // A -> Á
case 0x0061:
composed = 0x00E1;
break; // a -> á
case 0x0045:
composed = 0x00C9;
break; // E -> É
case 0x0065:
composed = 0x00E9;
break; // e -> é
case 0x0049:
composed = 0x00CD;
break; // I -> Í
case 0x0069:
composed = 0x00ED;
break; // i -> í
case 0x004F:
composed = 0x00D3;
break; // O -> Ó
case 0x006F:
composed = 0x00F3;
break; // o -> ó
case 0x0055:
composed = 0x00DA;
break; // U -> Ú
case 0x0075:
composed = 0x00FA;
break; // u -> ú
case 0x0059:
composed = 0x00DD;
break; // Y -> Ý
case 0x0079:
composed = 0x00FD;
break; // y -> ý
default:
break;
}
break;
case 0x0302: // circumflex
switch (prev) {
case 0x0041:
composed = 0x00C2;
break; // A -> Â
case 0x0061:
composed = 0x00E2;
break; // a -> â
case 0x0045:
composed = 0x00CA;
break; // E -> Ê
case 0x0065:
composed = 0x00EA;
break; // e -> ê
case 0x0049:
composed = 0x00CE;
break; // I -> Î
case 0x0069:
composed = 0x00EE;
break; // i -> î
case 0x004F:
composed = 0x00D4;
break; // O -> Ô
case 0x006F:
composed = 0x00F4;
break; // o -> ô
case 0x0055:
composed = 0x00DB;
break; // U -> Û
case 0x0075:
composed = 0x00FB;
break; // u -> û
default:
break;
}
break;
case 0x0303: // tilde
switch (prev) {
case 0x0041:
composed = 0x00C3;
break; // A -> Ã
case 0x0061:
composed = 0x00E3;
break; // a -> ã
case 0x004E:
composed = 0x00D1;
break; // N -> Ñ
case 0x006E:
composed = 0x00F1;
break; // n -> ñ
default:
break;
}
break;
case 0x0308: // diaeresis/umlaut
switch (prev) {
case 0x0041:
composed = 0x00C4;
break; // A -> Ä
case 0x0061:
composed = 0x00E4;
break; // a -> ä
case 0x0045:
composed = 0x00CB;
break; // E -> Ë
case 0x0065:
composed = 0x00EB;
break; // e -> ë
case 0x0049:
composed = 0x00CF;
break; // I -> Ï
case 0x0069:
composed = 0x00EF;
break; // i -> ï
case 0x004F:
composed = 0x00D6;
break; // O -> Ö
case 0x006F:
composed = 0x00F6;
break; // o -> ö
case 0x0055:
composed = 0x00DC;
break; // U -> Ü
case 0x0075:
composed = 0x00FC;
break; // u -> ü
case 0x0059:
composed = 0x0178;
break; // Y -> Ÿ
case 0x0079:
composed = 0x00FF;
break; // y -> ÿ
default:
break;
}
break;
case 0x0327: // cedilla
switch (prev) {
case 0x0043:
composed = 0x00C7;
break; // C -> Ç
case 0x0063:
composed = 0x00E7;
break; // c -> ç
default:
break;
}
break;
default:
break;
}
if (composed != 0) {
cps.back().value = composed;
continue; // skip pushing the combining mark itself
}
}
cps.push_back({cp, static_cast<size_t>(current - base)});
}

View File

@@ -429,12 +429,39 @@ void XMLCALL ChapterHtmlSlimParser::startElement(void* userData, const XML_Char*
const bool hasCssHeight = imgStyle.hasImageHeight();
const bool hasCssWidth = imgStyle.hasWidth();
if (hasCssHeight && dims.width > 0 && dims.height > 0) {
if (hasCssHeight && hasCssWidth && dims.width > 0 && dims.height > 0) {
displayHeight = static_cast<int>(
imgStyle.imageHeight.toPixels(emSize, static_cast<float>(self->viewportHeight)) + 0.5f);
displayWidth = static_cast<int>(
imgStyle.width.toPixels(emSize, static_cast<float>(self->viewportWidth)) + 0.5f);
if (displayHeight < 1) displayHeight = 1;
if (displayWidth < 1) displayWidth = 1;
if (displayWidth > self->viewportWidth || displayHeight > self->viewportHeight) {
float scaleX = (displayWidth > self->viewportWidth)
? static_cast<float>(self->viewportWidth) / displayWidth
: 1.0f;
float scaleY = (displayHeight > self->viewportHeight)
? static_cast<float>(self->viewportHeight) / displayHeight
: 1.0f;
float scale = (scaleX < scaleY) ? scaleX : scaleY;
displayWidth = static_cast<int>(displayWidth * scale + 0.5f);
displayHeight = static_cast<int>(displayHeight * scale + 0.5f);
if (displayWidth < 1) displayWidth = 1;
if (displayHeight < 1) displayHeight = 1;
}
LOG_DBG("EHP", "Display size from CSS height+width: %dx%d", displayWidth, displayHeight);
} else if (hasCssHeight && !hasCssWidth && dims.width > 0 && dims.height > 0) {
displayHeight = static_cast<int>(
imgStyle.imageHeight.toPixels(emSize, static_cast<float>(self->viewportHeight)) + 0.5f);
if (displayHeight < 1) displayHeight = 1;
displayWidth =
static_cast<int>(displayHeight * (static_cast<float>(dims.width) / dims.height) + 0.5f);
if (displayHeight > self->viewportHeight) {
displayHeight = self->viewportHeight;
displayWidth =
static_cast<int>(displayHeight * (static_cast<float>(dims.width) / dims.height) + 0.5f);
if (displayWidth < 1) displayWidth = 1;
}
if (displayWidth > self->viewportWidth) {
displayWidth = self->viewportWidth;
displayHeight =

View File

@@ -174,12 +174,14 @@ static void renderCharImpl(const GfxRenderer& renderer, GfxRenderer::RenderMode
}
}
if constexpr (rotation == TextRotation::Rotated90CW) {
*cursorY -= glyph->advanceX;
} else if constexpr (rotation == TextRotation::Rotated90CCW) {
*cursorY += glyph->advanceX;
} else {
*cursorX += glyph->advanceX;
if (!utf8IsCombiningMark(cp)) {
if constexpr (rotation == TextRotation::Rotated90CW) {
*cursorY -= glyph->advanceX;
} else if constexpr (rotation == TextRotation::Rotated90CCW) {
*cursorY += glyph->advanceX;
} else {
*cursorX += glyph->advanceX;
}
}
}
@@ -241,6 +243,11 @@ void GfxRenderer::drawText(const int fontId, const int x, const int y, const cha
const EpdFontFamily::Style style) const {
int yPos = y + getFontAscenderSize(fontId);
int xpos = x;
int lastBaseX = x;
int lastBaseY = yPos;
int lastBaseAdvance = 0;
int lastBaseTop = 0;
bool hasBaseGlyph = false;
// cannot draw a NULL / empty string
if (text == nullptr || *text == '\0') {
@@ -253,9 +260,43 @@ void GfxRenderer::drawText(const int fontId, const int x, const int y, const cha
return;
}
const auto& font = fontIt->second;
constexpr int MIN_COMBINING_GAP_PX = 1;
uint32_t cp;
while ((cp = utf8NextCodepoint(reinterpret_cast<const uint8_t**>(&text)))) {
if (utf8IsCombiningMark(cp) && hasBaseGlyph) {
const EpdGlyph* combiningGlyph = font.getGlyph(cp, style);
if (!combiningGlyph) {
combiningGlyph = font.getGlyph(REPLACEMENT_GLYPH, style);
}
int raiseBy = 0;
if (combiningGlyph) {
const int currentGap = combiningGlyph->top - combiningGlyph->height - lastBaseTop;
if (currentGap < MIN_COMBINING_GAP_PX) {
raiseBy = MIN_COMBINING_GAP_PX - currentGap;
}
}
int combiningX = lastBaseX + lastBaseAdvance / 2;
int combiningY = lastBaseY - raiseBy;
renderChar(font, cp, &combiningX, &combiningY, black, style);
continue;
}
const EpdGlyph* glyph = font.getGlyph(cp, style);
if (!glyph) {
glyph = font.getGlyph(REPLACEMENT_GLYPH, style);
}
if (!utf8IsCombiningMark(cp)) {
lastBaseX = xpos;
lastBaseY = yPos;
lastBaseAdvance = glyph ? glyph->advanceX : 0;
lastBaseTop = glyph ? glyph->top : 0;
hasBaseGlyph = true;
}
renderChar(font, cp, &xpos, &yPos, black, style);
}
}
@@ -963,6 +1004,9 @@ int GfxRenderer::getTextAdvanceX(const int fontId, const char* text, const EpdFo
int width = 0;
const auto& font = fontIt->second;
while ((cp = utf8NextCodepoint(reinterpret_cast<const uint8_t**>(&text)))) {
if (utf8IsCombiningMark(cp)) {
continue;
}
const EpdGlyph* glyph = font.getGlyph(cp, style);
if (!glyph) glyph = font.getGlyph(REPLACEMENT_GLYPH, style);
if (glyph) width += glyph->advanceX;
@@ -1016,9 +1060,48 @@ void GfxRenderer::drawTextRotated90CW(const int fontId, const int x, const int y
int xPos = x;
int yPos = y;
int lastBaseX = x;
int lastBaseY = y;
int lastBaseAdvance = 0;
int lastBaseTop = 0;
bool hasBaseGlyph = false;
constexpr int MIN_COMBINING_GAP_PX = 1;
uint32_t cp;
while ((cp = utf8NextCodepoint(reinterpret_cast<const uint8_t**>(&text)))) {
if (utf8IsCombiningMark(cp) && hasBaseGlyph) {
const EpdGlyph* combiningGlyph = font.getGlyph(cp, style);
if (!combiningGlyph) {
combiningGlyph = font.getGlyph(REPLACEMENT_GLYPH, style);
}
int raiseBy = 0;
if (combiningGlyph) {
const int currentGap = combiningGlyph->top - combiningGlyph->height - lastBaseTop;
if (currentGap < MIN_COMBINING_GAP_PX) {
raiseBy = MIN_COMBINING_GAP_PX - currentGap;
}
}
int combiningX = lastBaseX - raiseBy;
int combiningY = lastBaseY - lastBaseAdvance / 2;
renderCharImpl<TextRotation::Rotated90CW>(*this, renderMode, font, cp, &combiningX, &combiningY, black, style);
continue;
}
const EpdGlyph* glyph = font.getGlyph(cp, style);
if (!glyph) {
glyph = font.getGlyph(REPLACEMENT_GLYPH, style);
}
if (!utf8IsCombiningMark(cp)) {
lastBaseX = xPos;
lastBaseY = yPos;
lastBaseAdvance = glyph ? glyph->advanceX : 0;
lastBaseTop = glyph ? glyph->top : 0;
hasBaseGlyph = true;
}
renderCharImpl<TextRotation::Rotated90CW>(*this, renderMode, font, cp, &xPos, &yPos, black, style);
}
}
@@ -1040,9 +1123,48 @@ void GfxRenderer::drawTextRotated90CCW(const int fontId, const int x, const int
int xPos = x;
int yPos = y;
int lastBaseX = x;
int lastBaseY = y;
int lastBaseAdvance = 0;
int lastBaseTop = 0;
bool hasBaseGlyph = false;
constexpr int MIN_COMBINING_GAP_PX = 1;
uint32_t cp;
while ((cp = utf8NextCodepoint(reinterpret_cast<const uint8_t**>(&text)))) {
if (utf8IsCombiningMark(cp) && hasBaseGlyph) {
const EpdGlyph* combiningGlyph = font.getGlyph(cp, style);
if (!combiningGlyph) {
combiningGlyph = font.getGlyph(REPLACEMENT_GLYPH, style);
}
int raiseBy = 0;
if (combiningGlyph) {
const int currentGap = combiningGlyph->top - combiningGlyph->height - lastBaseTop;
if (currentGap < MIN_COMBINING_GAP_PX) {
raiseBy = MIN_COMBINING_GAP_PX - currentGap;
}
}
int combiningX = lastBaseX + raiseBy;
int combiningY = lastBaseY + lastBaseAdvance / 2;
renderCharImpl<TextRotation::Rotated90CCW>(*this, renderMode, font, cp, &combiningX, &combiningY, black, style);
continue;
}
const EpdGlyph* glyph = font.getGlyph(cp, style);
if (!glyph) {
glyph = font.getGlyph(REPLACEMENT_GLYPH, style);
}
if (!utf8IsCombiningMark(cp)) {
lastBaseX = xPos;
lastBaseY = yPos;
lastBaseAdvance = glyph ? glyph->advanceX : 0;
lastBaseTop = glyph ? glyph->top : 0;
hasBaseGlyph = true;
}
renderCharImpl<TextRotation::Rotated90CCW>(*this, renderMode, font, cp, &xPos, &yPos, black, style);
}
}

View File

@@ -13,6 +13,7 @@ extern const char* const STRINGS_CZ[];
extern const char* const STRINGS_PO[];
extern const char* const STRINGS_RU[];
extern const char* const STRINGS_SV[];
extern const char* const STRINGS_RO[];
} // namespace i18n_strings
// Language enum
@@ -25,6 +26,7 @@ enum class Language : uint8_t {
PORTUGUESE = 5,
RUSSIAN = 6,
SWEDISH = 7,
ROMANIAN = 8,
_COUNT
};
@@ -419,6 +421,8 @@ inline const char* const* getStringArray(Language lang) {
return i18n_strings::STRINGS_RU;
case Language::SWEDISH:
return i18n_strings::STRINGS_SV;
case Language::ROMANIAN:
return i18n_strings::STRINGS_RO;
default:
return i18n_strings::STRINGS_EN;
}

View File

@@ -15,5 +15,6 @@ extern const char* const STRINGS_CZ[];
extern const char* const STRINGS_PO[];
extern const char* const STRINGS_RU[];
extern const char* const STRINGS_SV[];
extern const char* const STRINGS_RO[];
} // namespace i18n_strings

View File

@@ -267,7 +267,7 @@ STR_MENU_RECENT_BOOKS: "Nedávné knihy"
STR_NO_RECENT_BOOKS: "Žádné nedávné knihy"
STR_CALIBRE_DESC: "Používat přenosy bezdrátových zařízení Calibre"
STR_FORGET_AND_REMOVE: "Zapomenout síť a odstranit uložené heslo?"
STR_FORGET_BUTTON: "Zapomenout na síť"
STR_FORGET_BUTTON: "Zapomenout"
STR_CALIBRE_STARTING: "Spuštění Calibre..."
STR_CALIBRE_SETUP: "Nastavení"
STR_CALIBRE_STATUS: "Stav"

View File

@@ -267,7 +267,7 @@ STR_MENU_RECENT_BOOKS: "Recent Books"
STR_NO_RECENT_BOOKS: "No recent books"
STR_CALIBRE_DESC: "Use Calibre wireless device transfers"
STR_FORGET_AND_REMOVE: "Forget network and remove saved password?"
STR_FORGET_BUTTON: "Forget network"
STR_FORGET_BUTTON: "Forget"
STR_CALIBRE_STARTING: "Starting Calibre..."
STR_CALIBRE_SETUP: "Setup"
STR_CALIBRE_STATUS: "Status"

View File

@@ -267,7 +267,7 @@ STR_MENU_RECENT_BOOKS: "Livres récents"
STR_NO_RECENT_BOOKS: "Aucun livre récent"
STR_CALIBRE_DESC: "Utiliser les transferts sans fil Calibre"
STR_FORGET_AND_REMOVE: "Oublier le réseau et supprimer le mot de passe enregistré ?"
STR_FORGET_BUTTON: "Oublier le réseau"
STR_FORGET_BUTTON: "Oublier"
STR_CALIBRE_STARTING: "Démarrage de Calibre..."
STR_CALIBRE_SETUP: "Configuration"
STR_CALIBRE_STATUS: "Statut"

View File

@@ -267,7 +267,7 @@ STR_MENU_RECENT_BOOKS: "Zuletzt gelesen"
STR_NO_RECENT_BOOKS: "Keine Bücher"
STR_CALIBRE_DESC: "Calibre-Übertragung (WLAN)"
STR_FORGET_AND_REMOVE: "WLAN entfernen & Passwort löschen?"
STR_FORGET_BUTTON: "WLAN entfernen"
STR_FORGET_BUTTON: "Entfernen"
STR_CALIBRE_STARTING: "Calibre starten…"
STR_CALIBRE_SETUP: "Installation"
STR_CALIBRE_STATUS: "Status"

View File

@@ -267,7 +267,7 @@ STR_MENU_RECENT_BOOKS: "Livros recentes"
STR_NO_RECENT_BOOKS: "Sem livros recentes"
STR_CALIBRE_DESC: "Usar transferências sem fio Calibre"
STR_FORGET_AND_REMOVE: "Esquecer a rede e remover a senha salva?"
STR_FORGET_BUTTON: "Esquecer rede"
STR_FORGET_BUTTON: "Esquecer"
STR_CALIBRE_STARTING: "Iniciando Calibre..."
STR_CALIBRE_SETUP: "Configuração"
STR_CALIBRE_STATUS: "Status"

View File

@@ -0,0 +1,318 @@
_language_name: "Română"
_language_code: "ROMANIAN"
_order: "8"
STR_CROSSPOINT: "CrossPoint"
STR_BOOTING: "PORNEŞTE"
STR_SLEEPING: "REPAUS"
STR_ENTERING_SLEEP: "Intră în repaus..."
STR_BROWSE_FILES: "Răsfoieşte fişierele"
STR_FILE_TRANSFER: "Transfer de fişiere"
STR_SETTINGS_TITLE: "Setări"
STR_CALIBRE_LIBRARY: "Biblioteca Calibre"
STR_CONTINUE_READING: "Continuă lectura"
STR_NO_OPEN_BOOK: "Nicio carte deschisă"
STR_START_READING: "Începeţi lectura"
STR_BOOKS: "Cărţi"
STR_NO_BOOKS_FOUND: "Nicio carte găsită"
STR_SELECT_CHAPTER: "Selectaţi capitolul"
STR_NO_CHAPTERS: "Niciun capitol"
STR_END_OF_BOOK: "Sfârşitul cărţii"
STR_EMPTY_CHAPTER: "Capitol gol"
STR_INDEXING: "Indexează..."
STR_MEMORY_ERROR: "Eroare de memorie"
STR_PAGE_LOAD_ERROR: "Eroare la încărcarea paginii"
STR_EMPTY_FILE: "Fişier gol"
STR_OUT_OF_BOUNDS: "Eroare: În afara limitelor"
STR_LOADING: "Se încarcă..."
STR_LOADING_POPUP: "Se încarcă..."
STR_LOAD_XTC_FAILED: "Eroare la încărcarea XTC"
STR_LOAD_TXT_FAILED: "Eroare la încărcarea TXT"
STR_LOAD_EPUB_FAILED: "Eroare la încărcarea EPUB"
STR_SD_CARD_ERROR: "Eroare la cardul SD"
STR_WIFI_NETWORKS: "Reţele WiFi"
STR_NO_NETWORKS: "Nu s-au găsit reţele"
STR_NETWORKS_FOUND: "%zu reţele găsite"
STR_SCANNING: "Scanează..."
STR_CONNECTING: "Se conectează..."
STR_CONNECTED: "Conectat!"
STR_CONNECTION_FAILED: "Conexiune eşuată"
STR_CONNECTION_TIMEOUT: "Timp de conectare depăşit"
STR_FORGET_NETWORK: "Uitaţi reţeaua?"
STR_SAVE_PASSWORD: "Salvaţi parola?"
STR_REMOVE_PASSWORD: "Ştergeţi parola salvată?"
STR_PRESS_OK_SCAN: "Apăsaţi OK pentru a scana din nou"
STR_PRESS_ANY_CONTINUE: "Apăsaţi orice buton pentru a continua"
STR_SELECT_HINT: "STÂNGA/DREAPTA: Selectaţi | OK: Confirmaţi"
STR_HOW_CONNECT: "Cum doriţi să vă conectaţi?"
STR_JOIN_NETWORK: "Conectaţi-vă la o reţea"
STR_CREATE_HOTSPOT: "Creaţi un hotspot"
STR_JOIN_DESC: "Conectaţi-vă la o reţea WiFi existentă"
STR_HOTSPOT_DESC: "Creaţi un hotspot WiFi"
STR_STARTING_HOTSPOT: "Hotspot porneşte..."
STR_HOTSPOT_MODE: "Mod Hotspot"
STR_CONNECT_WIFI_HINT: "Conectaţi-vă dispozitivul la această reţea WiFi"
STR_OPEN_URL_HINT: "Deschideţi acest URL în browserul dvs."
STR_OR_HTTP_PREFIX: "sau http://"
STR_SCAN_QR_HINT: "sau scanaţi codul QR cu telefonul dvs.:"
STR_CALIBRE_WIRELESS: "Calibre Wireless"
STR_CALIBRE_WEB_URL: "Calibre URL"
STR_CONNECT_WIRELESS: "Conectaţi-vă ca dispozitiv wireless"
STR_NETWORK_LEGEND: "* = Criptat | + = Salvat"
STR_MAC_ADDRESS: "Adresă MAC:"
STR_CHECKING_WIFI: "Verificare WiFi..."
STR_ENTER_WIFI_PASSWORD: "Introduceţi parola WiFi"
STR_ENTER_TEXT: "Introduceţi textul"
STR_TO_PREFIX: "la "
STR_CALIBRE_DISCOVERING: "Descoperă Calibre..."
STR_CALIBRE_CONNECTING_TO: "Se conectează la "
STR_CALIBRE_CONNECTED_TO: "Conectat la "
STR_CALIBRE_WAITING_COMMANDS: "Se aşteaptă comenzi..."
STR_CONNECTION_FAILED_RETRYING: "(Conexiune eşuată, se reîncearcă)"
STR_CALIBRE_DISCONNECTED: "Calibre deconectat"
STR_CALIBRE_WAITING_TRANSFER: "Se aşteaptă transfer..."
STR_CALIBRE_TRANSFER_HINT: "Dacă transferul eşuează, activaţi\\n'Ignoraţi spaţiul liber' în setările\\nplugin-ului SmartDevice din Calibre."
STR_CALIBRE_RECEIVING: "Se primeşte: "
STR_CALIBRE_RECEIVED: "Primite: "
STR_CALIBRE_WAITING_MORE: "Se aşteaptă mai multe..."
STR_CALIBRE_FAILED_CREATE_FILE: "Creare fişier eşuată"
STR_CALIBRE_PASSWORD_REQUIRED: "Necesită parolă"
STR_CALIBRE_TRANSFER_INTERRUPTED: "Transfer întrerupt"
STR_CALIBRE_INSTRUCTION_1: "1) Instalaţi plugin-ul CrossPoint Reader"
STR_CALIBRE_INSTRUCTION_2: "2) Fiţi în aceeaşi reţea WiFi"
STR_CALIBRE_INSTRUCTION_3: "3) În Calibre: \"Trimiteţi la dispozitiv\""
STR_CALIBRE_INSTRUCTION_4: "\"Păstraţi acest ecran deschis în timpul trimiterii\""
STR_CAT_DISPLAY: "Ecran"
STR_CAT_READER: "Lectură"
STR_CAT_CONTROLS: "Controale"
STR_CAT_SYSTEM: "Sistem"
STR_SLEEP_SCREEN: "Ecran de repaus"
STR_SLEEP_COVER_MODE: "Mod ecran de repaus cu copertă"
STR_STATUS_BAR: "Bara de stare"
STR_HIDE_BATTERY: "Ascunde procentul bateriei"
STR_EXTRA_SPACING: "Spaţiere suplimentară între paragrafe"
STR_TEXT_AA: "Anti-Aliasing text"
STR_SHORT_PWR_BTN: "Apăsare scurtă întrerupător"
STR_ORIENTATION: "Orientare lectură"
STR_FRONT_BTN_LAYOUT: "Aspect butoane frontale"
STR_SIDE_BTN_LAYOUT: "Aspect butoane laterale (lectură)"
STR_LONG_PRESS_SKIP: "Sărire capitol la apăsare lungă"
STR_FONT_FAMILY: "Familie font lectură"
STR_EXT_READER_FONT: "Font lectură extern"
STR_EXT_CHINESE_FONT: "Font lectură"
STR_EXT_UI_FONT: "Font meniu"
STR_FONT_SIZE: "Dimensiune font"
STR_LINE_SPACING: "Spaţiere între rânduri"
STR_ASCII_LETTER_SPACING: "Spaţiere litere ASCII "
STR_ASCII_DIGIT_SPACING: "Spaţiere cifre ASCII"
STR_CJK_SPACING: "Spaţiere CJK"
STR_COLOR_MODE: "Mod culoare"
STR_SCREEN_MARGIN: "Margine ecran lectură"
STR_PARA_ALIGNMENT: "Aliniere paragrafe reader"
STR_HYPHENATION: "Silabisire"
STR_TIME_TO_SLEEP: "Timp până la repaus"
STR_REFRESH_FREQ: "Frecvenţă reîmprospătare"
STR_CALIBRE_SETTINGS: "Setări Calibre"
STR_KOREADER_SYNC: "Sincronizare KOReader"
STR_CHECK_UPDATES: "Căutaţi actualizări"
STR_LANGUAGE: "Limbă"
STR_SELECT_WALLPAPER: "Selectaţi imaginea de fundal"
STR_CLEAR_READING_CACHE: "Goliţi cache-ul de lectură"
STR_CALIBRE: "Calibre"
STR_USERNAME: "Utilizator"
STR_PASSWORD: "Parolă"
STR_SYNC_SERVER_URL: "URL server sincronizare"
STR_DOCUMENT_MATCHING: "Corespondenţă document"
STR_AUTHENTICATE: "Autentificare"
STR_KOREADER_USERNAME: "Nume utilizator KOReader"
STR_KOREADER_PASSWORD: "Parolă KOReader"
STR_FILENAME: "Nume fişier"
STR_BINARY: "Fişier binar"
STR_SET_CREDENTIALS_FIRST: "Vă rugăm să setaţi mai întâi acreditările"
STR_WIFI_CONN_FAILED: "Conexiune WiFi eşuată"
STR_AUTHENTICATING: "Se autentifică..."
STR_AUTH_SUCCESS: "Autentificare reuşită!"
STR_KOREADER_AUTH: "Autentificare KOReader"
STR_SYNC_READY: "Sincronizare KOReader gata de utilizare"
STR_AUTH_FAILED: "Autentificare eşuată"
STR_DONE: "Gata"
STR_CLEAR_CACHE_WARNING_1: "Aceasta va şterge tot cache-ul de lectură."
STR_CLEAR_CACHE_WARNING_2: "Tot progresul de lectură va fi pierdut!"
STR_CLEAR_CACHE_WARNING_3: "Cărţile vor trebui reindexate"
STR_CLEAR_CACHE_WARNING_4: "când vor fi deschise din nou."
STR_CLEARING_CACHE: "Se şterge cache-ul..."
STR_CACHE_CLEARED: "Cache şters"
STR_ITEMS_REMOVED: "elemente eliminate"
STR_FAILED_LOWER: "eşuat"
STR_CLEAR_CACHE_FAILED: "ştergerea cache-ului a eşuat"
STR_CHECK_SERIAL_OUTPUT: "Verificaţi ieşirea serială pentru detalii"
STR_DARK: "Întunecat"
STR_LIGHT: "Luminos"
STR_CUSTOM: "Personalizat"
STR_COVER: "Copertă"
STR_NONE_OPT: "Niciunul"
STR_FIT: "Potrivit"
STR_CROP: "Decupat"
STR_NO_PROGRESS: "Fără progres"
STR_FULL_OPT: "Complet"
STR_NEVER: "Niciodată"
STR_IN_READER: "În lectură"
STR_ALWAYS: "Întotdeauna"
STR_IGNORE: "Ignoră"
STR_SLEEP: "Repaus"
STR_PAGE_TURN: "Răsfoire pagină"
STR_PORTRAIT: "Vertical"
STR_LANDSCAPE_CW: "Orizontal dreapta"
STR_INVERTED: "Invers"
STR_LANDSCAPE_CCW: "Orizontal stânga"
STR_FRONT_LAYOUT_BCLR: "Înapoi, Cnfrm, St, Dr"
STR_FRONT_LAYOUT_LRBC: "St, Dr, Înapoi, Cnfrm"
STR_FRONT_LAYOUT_LBCR: "St, Înapoi, Cnfrm, Dr"
STR_PREV_NEXT: "Înainte/Înapoi"
STR_NEXT_PREV: "Înapoi/Înainte"
STR_BOOKERLY: "Bookerly"
STR_NOTO_SANS: "Noto Sans"
STR_OPEN_DYSLEXIC: "Open Dyslexic"
STR_SMALL: "Mic"
STR_MEDIUM: "Mediu"
STR_LARGE: "Mare"
STR_X_LARGE: "Foarte mare"
STR_TIGHT: "Strâns"
STR_NORMAL: "Normal"
STR_WIDE: "Larg"
STR_JUSTIFY: "Aliniere"
STR_ALIGN_LEFT: "Stânga"
STR_CENTER: "Centru"
STR_ALIGN_RIGHT: "Dreapta"
STR_MIN_1: "1 min"
STR_MIN_5: "5 min"
STR_MIN_10: "10 min"
STR_MIN_15: "15 min"
STR_MIN_30: "30 min"
STR_PAGES_1: "1 pagină"
STR_PAGES_5: "5 pagini"
STR_PAGES_10: "10 pagini"
STR_PAGES_15: "15 pagini"
STR_PAGES_30: "30 pagini"
STR_UPDATE: "Actualizare"
STR_CHECKING_UPDATE: "Se verifică actualizările..."
STR_NEW_UPDATE: "Nouă actualizare disponibilă!"
STR_CURRENT_VERSION: "Versiune curentă: "
STR_NEW_VERSION: "Noua versiune: "
STR_UPDATING: "Se actualizează..."
STR_NO_UPDATE: "Nicio actualizare disponibilă"
STR_UPDATE_FAILED: "Actualizare eşuată"
STR_UPDATE_COMPLETE: "Actualizare completă"
STR_POWER_ON_HINT: "Apăsaţi şi menţineţi apăsat întrerupătorul pentru a porni din nou"
STR_EXTERNAL_FONT: "Font extern"
STR_BUILTIN_DISABLED: "Încorporat (Dezactivat)"
STR_NO_ENTRIES: "Niciun rezultat găsit"
STR_DOWNLOADING: "Se descarcă..."
STR_DOWNLOAD_FAILED: "Descărcare eşuată"
STR_ERROR_MSG: "Eroare:"
STR_UNNAMED: "Fără nume"
STR_NO_SERVER_URL: "Niciun URL de server configurat"
STR_FETCH_FEED_FAILED: "Eşec la preluarea feed-ului"
STR_PARSE_FEED_FAILED: "Eşec la analizarea feed-ului"
STR_NETWORK_PREFIX: "Reţea: "
STR_IP_ADDRESS_PREFIX: "Adresă IP: "
STR_SCAN_QR_WIFI_HINT: "sau scanaţi codul QR cu telefonul pentru a vă conecta la Wifi."
STR_ERROR_GENERAL_FAILURE: "Eroare: Eşec general"
STR_ERROR_NETWORK_NOT_FOUND: "Eroare: Reţea negăsită"
STR_ERROR_CONNECTION_TIMEOUT: "Eroare: Timp de conectare depăşit"
STR_SD_CARD: "Card SD"
STR_BACK: "« Înapoi"
STR_EXIT: "« Ieşire"
STR_HOME: "« Acasă"
STR_SAVE: "« Salvare"
STR_SELECT: "Selectează"
STR_TOGGLE: "Schimbă"
STR_CONFIRM: "Confirmă"
STR_CANCEL: "Anulare"
STR_CONNECT: "Conectare"
STR_OPEN: "Deschidere"
STR_DOWNLOAD: "Descarcă"
STR_RETRY: "Reîncercare"
STR_YES: "Da"
STR_NO: "Nu"
STR_STATE_ON: "Pornit"
STR_STATE_OFF: "Oprit"
STR_SET: "Setare"
STR_NOT_SET: "Neconfigurat"
STR_DIR_LEFT: "Stânga"
STR_DIR_RIGHT: "Dreapta"
STR_DIR_UP: "Sus"
STR_DIR_DOWN: "Jos"
STR_CAPS_ON: "CAPS"
STR_CAPS_OFF: "caps"
STR_OK_BUTTON: "OK"
STR_ON_MARKER: "[ON]"
STR_SLEEP_COVER_FILTER: "Filtru ecran de repaus"
STR_FILTER_CONTRAST: "Contrast"
STR_STATUS_BAR_FULL_PERCENT: "Complet cu procentaj"
STR_STATUS_BAR_FULL_BOOK: "Complet cu bara de carte"
STR_STATUS_BAR_BOOK_ONLY: "Doar bara de carte"
STR_STATUS_BAR_FULL_CHAPTER: "Complet cu bara de capitol"
STR_UI_THEME: "Tema UI"
STR_THEME_CLASSIC: "Clasic"
STR_THEME_LYRA: "Lyra"
STR_SUNLIGHT_FADING_FIX: "Corecţie estompare lumină"
STR_REMAP_FRONT_BUTTONS: "Remapare butoane frontale"
STR_OPDS_BROWSER: "Browser OPDS"
STR_COVER_CUSTOM: "Copertă + Personalizat"
STR_RECENTS: "Recente"
STR_MENU_RECENT_BOOKS: "Cărţi recente"
STR_NO_RECENT_BOOKS: "Nicio carte recentă"
STR_CALIBRE_DESC: "Utilizaţi transferurile wireless ale dispozitivului Calibre"
STR_FORGET_AND_REMOVE: "Uitaţi reţeaua şi eliminaţi parola salvată?"
STR_FORGET_BUTTON: "Uitaţi"
STR_CALIBRE_STARTING: "Pornirea Calibre..."
STR_CALIBRE_SETUP: "Configurare"
STR_CALIBRE_STATUS: "Stare"
STR_CLEAR_BUTTON: "ştergere"
STR_DEFAULT_VALUE: "Implicit"
STR_REMAP_PROMPT: "Apăsaţi un buton frontal pentru fiecare rol"
STR_UNASSIGNED: "Neatribuit"
STR_ALREADY_ASSIGNED: "Deja atribuit"
STR_REMAP_RESET_HINT: "Buton lateral Sus: Resetaţi la aspectul implicit"
STR_REMAP_CANCEL_HINT: "Buton lateral Jos: Anulaţi remaparea"
STR_HW_BACK_LABEL: "Înapoi (butonul 1)"
STR_HW_CONFIRM_LABEL: "Confirmare (butonul 2)"
STR_HW_LEFT_LABEL: "Stânga (butonul 3)"
STR_HW_RIGHT_LABEL: "Dreapta (butonul 4)"
STR_GO_TO_PERCENT: "Săriţi la %"
STR_GO_HOME_BUTTON: "Acasă"
STR_SYNC_PROGRESS: "Progres sincronizare"
STR_DELETE_CACHE: "Ştergere cache cărţi"
STR_CHAPTER_PREFIX: "Capitol: "
STR_PAGES_SEPARATOR: " pagini | "
STR_BOOK_PREFIX: "Carte: "
STR_KBD_SHIFT: "shift"
STR_KBD_SHIFT_CAPS: "SHIFT"
STR_KBD_LOCK: "LOCK"
STR_CALIBRE_URL_HINT: "Pentru Calibre, adăugaţi /opds la URL"
STR_PERCENT_STEP_HINT: "Stânga/Dreapta: 1% Sus/Jos: 10%"
STR_SYNCING_TIME: "Timp de sincronizare..."
STR_CALC_HASH: "Calcularea hash-ului documentului..."
STR_HASH_FAILED: "Eşec la calcularea hash-ului documentului"
STR_FETCH_PROGRESS: "Preluarea progresului de la distanţă..."
STR_UPLOAD_PROGRESS: "Încărcarea progresului..."
STR_NO_CREDENTIALS_MSG: "Nicio acreditare configurată"
STR_KOREADER_SETUP_HINT: "Configuraţi contul KOReader în setări"
STR_PROGRESS_FOUND: "Progres găsit!"
STR_REMOTE_LABEL: "Remote:"
STR_LOCAL_LABEL: "Local:"
STR_PAGE_OVERALL_FORMAT: "Pagina %d, %.2f%% din total"
STR_PAGE_TOTAL_OVERALL_FORMAT: "Pagina %d/%d, %.2f%% din total"
STR_DEVICE_FROM_FORMAT: " De la: %s"
STR_APPLY_REMOTE: "Aplică progresul remote"
STR_UPLOAD_LOCAL: "Încărcaţi progresul local"
STR_NO_REMOTE_MSG: "Niciun progres remote găsit"
STR_UPLOAD_PROMPT: "Încărcaţi poziţia curentă?"
STR_UPLOAD_SUCCESS: "Progres încărcat!"
STR_SYNC_FAILED_MSG: "Sincronizare eşuată"
STR_SECTION_PREFIX: "Secţiune "
STR_UPLOAD: "Încărcare"
STR_BOOK_S_STYLE: "Stilul cărţii"
STR_EMBEDDED_STYLE: "Stil încorporat"
STR_OPDS_SERVER_URL: "URL server OPDS"

View File

@@ -267,7 +267,7 @@ STR_MENU_RECENT_BOOKS: "Недавние книги"
STR_NO_RECENT_BOOKS: "Нет недавних книг"
STR_CALIBRE_DESC: "Использовать беспроводную передачу Calibre"
STR_FORGET_AND_REMOVE: "Забыть сеть и удалить сохранённый пароль?"
STR_FORGET_BUTTON: "Забыть сеть"
STR_FORGET_BUTTON: "Забыть"
STR_CALIBRE_STARTING: "Запуск Calibre..."
STR_CALIBRE_SETUP: "Настройка"
STR_CALIBRE_STATUS: "Статус"

View File

@@ -267,7 +267,7 @@ STR_MENU_RECENT_BOOKS: "Libros recientes"
STR_NO_RECENT_BOOKS: "No hay libros recientes"
STR_CALIBRE_DESC: "Utilice las transferencias dispositivos inalámbricos de calibre"
STR_FORGET_AND_REMOVE: "Olvidar la red y eliminar la contraseña guardada?"
STR_FORGET_BUTTON: "Olvidar la red"
STR_FORGET_BUTTON: "Olvidar"
STR_CALIBRE_STARTING: "Iniciando calibre..."
STR_CALIBRE_SETUP: "Configuración"
STR_CALIBRE_STATUS: "Estado"

View File

@@ -267,7 +267,7 @@ STR_MENU_RECENT_BOOKS: "Senaste böckerna"
STR_NO_RECENT_BOOKS: "Inga senaste böcker"
STR_CALIBRE_DESC: "Använd Calibres trådlösa enhetsöverföring"
STR_FORGET_AND_REMOVE: "Glöm nätverk och ta bort sparat lösenord?"
STR_FORGET_BUTTON: "Glöm nätverk"
STR_FORGET_BUTTON: "Glöm"
STR_CALIBRE_STARTING: "Starar Calibre…"
STR_CALIBRE_SETUP: "Inställning"
STR_CALIBRE_STATUS: "Status"

View File

@@ -9,3 +9,11 @@ uint32_t utf8NextCodepoint(const unsigned char** string);
size_t utf8RemoveLastChar(std::string& str);
// Truncate string by removing N UTF-8 codepoints from the end.
void utf8TruncateChars(std::string& str, size_t numChars);
// Returns true for Unicode combining diacritical marks that should not advance the cursor.
inline bool utf8IsCombiningMark(const uint32_t cp) {
return (cp >= 0x0300 && cp <= 0x036F) // Combining Diacritical Marks
|| (cp >= 0x1DC0 && cp <= 0x1DFF) // Combining Diacritical Marks Supplement
|| (cp >= 0x20D0 && cp <= 0x20FF) // Combining Diacritical Marks for Symbols
|| (cp >= 0xFE20 && cp <= 0xFE2F); // Combining Half Marks
}

View File

@@ -196,6 +196,15 @@ std::string getFileName(std::string filename) {
return filename.substr(0, pos);
}
std::string getFileExtension(std::string filename) {
if (filename.back() == '/') {
return "";
}
const auto pos = filename.rfind('.');
if (pos == std::string::npos) return "";
return filename.substr(pos);
}
void MyLibraryActivity::render(Activity::RenderLock&&) {
renderer.clearScreen();
@@ -214,7 +223,8 @@ void MyLibraryActivity::render(Activity::RenderLock&&) {
GUI.drawList(
renderer, Rect{0, contentTop, pageWidth, contentHeight}, files.size(), selectorIndex,
[this](int index) { return getFileName(files[index]); }, nullptr,
[this](int index) { return UITheme::getFileIcon(files[index]); });
[this](int index) { return UITheme::getFileIcon(files[index]); },
[this](int index) { return getFileExtension(files[index]); }, false);
}
// Help text

View File

@@ -45,6 +45,105 @@ void drawBatteryIcon(const GfxRenderer& renderer, int x, int y, int battWidth, i
renderer.fillRect(x + 2, y + 2, filledWidth, rectHeight - 4);
}
// Truncate a string with "..." to fit within maxWidth.
std::string truncateWithEllipsis(const GfxRenderer& renderer, int fontId, const std::string& text, int maxWidth) {
std::string truncated = text;
std::string withEllipsis = truncated + "...";
while (!truncated.empty() && renderer.getTextWidth(fontId, withEllipsis.c_str()) > maxWidth) {
utf8RemoveLastChar(truncated);
withEllipsis = truncated + "...";
}
return truncated.empty() ? std::string("...") : withEllipsis;
}
// Text wrapping with 3-tier break logic:
// 1) Preferred delimiters: " -- ", " - ", en-dash, em-dash (title-author separator)
// 2) Word boundaries: last space or hyphen that fits
// 3) Character-level fallback for long unbroken tokens
// The last allowed line is truncated with "..." if it overflows.
std::vector<std::string> wrapTextToLines(const GfxRenderer& renderer, int fontId, const std::string& text, int maxWidth,
int maxLines) {
std::vector<std::string> lines;
if (text.empty() || maxWidth <= 0 || maxLines <= 0) return lines;
if (renderer.getTextWidth(fontId, text.c_str()) <= maxWidth) {
lines.push_back(text);
return lines;
}
if (maxLines == 1) {
lines.push_back(truncateWithEllipsis(renderer, fontId, text, maxWidth));
return lines;
}
// Tier 1: Try preferred delimiters (last occurrence to maximize line 1 content).
// \xe2\x80\x93 = en-dash, \xe2\x80\x94 = em-dash
static const char* const preferredDelimiters[] = {" -- ", " - ", " \xe2\x80\x93 ", " \xe2\x80\x94 "};
for (const char* delim : preferredDelimiters) {
size_t delimLen = strlen(delim);
auto pos = text.rfind(delim);
if (pos != std::string::npos && pos > 0) {
std::string firstPart = text.substr(0, pos);
if (renderer.getTextWidth(fontId, firstPart.c_str()) <= maxWidth) {
lines.push_back(firstPart);
std::string remainder = text.substr(pos + delimLen);
if (renderer.getTextWidth(fontId, remainder.c_str()) > maxWidth) {
lines.push_back(truncateWithEllipsis(renderer, fontId, remainder, maxWidth));
} else {
lines.push_back(remainder);
}
return lines;
}
}
}
// Tier 2 & 3: Word-boundary wrapping with character-level fallback.
std::string currentLine;
const unsigned char* ptr = reinterpret_cast<const unsigned char*>(text.c_str());
std::string lineAtBreak;
const unsigned char* ptrAtBreak = nullptr;
while (*ptr != 0) {
const unsigned char* charStart = ptr;
uint32_t cp = utf8NextCodepoint(&ptr);
std::string nextChar(reinterpret_cast<const char*>(charStart), static_cast<size_t>(ptr - charStart));
std::string candidate = currentLine + nextChar;
if (renderer.getTextWidth(fontId, candidate.c_str()) <= maxWidth) {
currentLine = candidate;
if (cp == ' ' || cp == '-') {
lineAtBreak = currentLine;
ptrAtBreak = ptr;
}
continue;
}
// Overflow
if (static_cast<int>(lines.size()) < maxLines - 1 && !currentLine.empty()) {
if (ptrAtBreak != nullptr) {
std::string line = lineAtBreak;
while (!line.empty() && line.back() == ' ') line.pop_back();
lines.push_back(line);
ptr = ptrAtBreak;
while (*ptr == ' ') ++ptr;
currentLine.clear();
} else {
lines.push_back(currentLine);
currentLine = nextChar;
}
lineAtBreak.clear();
ptrAtBreak = nullptr;
} else {
lines.push_back(truncateWithEllipsis(renderer, fontId, currentLine, maxWidth));
return lines;
}
}
if (!currentLine.empty()) {
lines.push_back(currentLine);
}
return lines;
}
} // namespace
void BaseTheme::drawBatteryLeft(const GfxRenderer& renderer, Rect rect, const bool showPercentage) const {
@@ -193,25 +292,36 @@ void BaseTheme::drawList(const GfxRenderer& renderer, Rect rect, int itemCount,
int rowHeight =
(rowSubtitle != nullptr) ? BaseMetrics::values.listWithSubtitleRowHeight : BaseMetrics::values.listRowHeight;
int pageItems = rect.height / rowHeight;
int contentWidth = rect.width - 5;
auto font = (rowSubtitle != nullptr) ? UI_12_FONT_ID : UI_10_FONT_ID;
const int totalPages = (itemCount + pageItems - 1) / pageItems;
// Detect if selected row's title overflows and needs 2-line expansion
bool selectedExpands = false;
if (selectedIndex >= 0 && rowSubtitle == nullptr && rowValue != nullptr) {
int titleTextWidth = contentWidth - BaseMetrics::values.contentSidePadding * 2 - 60;
auto selTitle = rowTitle(selectedIndex);
if (renderer.getTextWidth(font, selTitle.c_str()) > titleTextWidth) {
selectedExpands = true;
}
}
const int effectivePageItems = selectedExpands ? std::max(1, pageItems - 1) : pageItems;
const int totalPages = (itemCount + effectivePageItems - 1) / effectivePageItems;
if (totalPages > 1) {
constexpr int indicatorWidth = 20;
constexpr int arrowSize = 6;
constexpr int margin = 15; // Offset from right edge
constexpr int margin = 15;
const int centerX = rect.x + rect.width - indicatorWidth / 2 - margin;
const int indicatorTop = rect.y; // Offset to avoid overlapping side button hints
const int indicatorTop = rect.y;
const int indicatorBottom = rect.y + rect.height - arrowSize;
// Draw up arrow at top (^) - narrow point at top, wide base at bottom
for (int i = 0; i < arrowSize; ++i) {
const int lineWidth = 1 + i * 2;
const int startX = centerX - i;
renderer.drawLine(startX, indicatorTop + i, startX + lineWidth - 1, indicatorTop + i);
}
// Draw down arrow at bottom (v) - wide base at top, narrow point at bottom
for (int i = 0; i < arrowSize; ++i) {
const int lineWidth = 1 + (arrowSize - 1 - i) * 2;
const int startX = centerX - (arrowSize - 1 - i);
@@ -220,37 +330,89 @@ void BaseTheme::drawList(const GfxRenderer& renderer, Rect rect, int itemCount,
}
}
// Draw selection
int contentWidth = rect.width - 5;
if (selectedIndex >= 0) {
renderer.fillRect(0, rect.y + selectedIndex % pageItems * rowHeight - 2, rect.width, rowHeight);
}
// Draw all items
const auto pageStartIndex = selectedIndex / pageItems * pageItems;
for (int i = pageStartIndex; i < itemCount && i < pageStartIndex + pageItems; i++) {
const int itemY = rect.y + (i % pageItems) * rowHeight;
int textWidth = contentWidth - BaseMetrics::values.contentSidePadding * 2 - (rowValue != nullptr ? 60 : 0);
// Draw name
auto itemName = rowTitle(i);
auto font = (rowSubtitle != nullptr) ? UI_12_FONT_ID : UI_10_FONT_ID;
auto item = renderer.truncatedText(font, itemName.c_str(), textWidth);
renderer.drawText(font, rect.x + BaseMetrics::values.contentSidePadding, itemY, item.c_str(), i != selectedIndex);
if (rowSubtitle != nullptr) {
// Draw subtitle
std::string subtitleText = rowSubtitle(i);
auto subtitle = renderer.truncatedText(UI_10_FONT_ID, subtitleText.c_str(), textWidth);
renderer.drawText(UI_10_FONT_ID, rect.x + BaseMetrics::values.contentSidePadding, itemY + 30, subtitle.c_str(),
i != selectedIndex);
// Compute page start: use effective page items but prevent backward leak
int pageStartIndex;
if (selectedExpands) {
int rawStart = selectedIndex / effectivePageItems * effectivePageItems;
int originalStart = selectedIndex / pageItems * pageItems;
pageStartIndex = std::max(rawStart, originalStart);
if (selectedIndex >= pageStartIndex + effectivePageItems) {
pageStartIndex = selectedIndex - effectivePageItems + 1;
}
if (pageStartIndex > 0 && pageStartIndex == originalStart
&& selectedIndex < pageStartIndex + effectivePageItems - 1) {
int checkWidth = contentWidth - BaseMetrics::values.contentSidePadding * 2 - 60;
auto prevTitle = rowTitle(pageStartIndex - 1);
if (renderer.getTextWidth(font, prevTitle.c_str()) > checkWidth) {
pageStartIndex--;
}
}
} else {
pageStartIndex = selectedIndex / pageItems * pageItems;
// Include previous page's boundary item if it would need expansion when selected,
// so it doesn't vanish when navigating from it to the current page.
if (pageStartIndex > 0 && selectedIndex < pageStartIndex + pageItems - 1) {
int checkWidth = contentWidth - BaseMetrics::values.contentSidePadding * 2 - 60;
auto prevTitle = rowTitle(pageStartIndex - 1);
if (renderer.getTextWidth(font, prevTitle.c_str()) > checkWidth) {
pageStartIndex--;
}
}
}
if (rowValue != nullptr) {
// Draw value
std::string valueText = rowValue(i);
const auto valueTextWidth = renderer.getTextWidth(UI_10_FONT_ID, valueText.c_str());
renderer.drawText(UI_10_FONT_ID, rect.x + contentWidth - BaseMetrics::values.contentSidePadding - valueTextWidth,
itemY, valueText.c_str(), i != selectedIndex);
// Draw selection highlight
if (selectedIndex >= 0) {
int selRowsBeforeOnPage = selectedIndex - pageStartIndex;
int selY = rect.y + selRowsBeforeOnPage * rowHeight - 2;
int selHeight = selectedExpands ? 2 * rowHeight : rowHeight;
renderer.fillRect(0, selY, rect.width, selHeight);
}
// Draw all items
int yPos = rect.y;
for (int i = pageStartIndex; i < itemCount && i < pageStartIndex + effectivePageItems; i++) {
const bool isExpanded = (selectedExpands && i == selectedIndex);
auto itemName = rowTitle(i);
if (isExpanded) {
int wrapWidth = contentWidth - BaseMetrics::values.contentSidePadding * 2;
auto lines = wrapTextToLines(renderer, font, itemName, wrapWidth, 2);
for (size_t l = 0; l < lines.size(); ++l) {
renderer.drawText(font, rect.x + BaseMetrics::values.contentSidePadding,
yPos + static_cast<int>(l) * rowHeight, lines[l].c_str(), false);
}
if (rowValue != nullptr) {
std::string valueText = rowValue(i);
const auto valueTextWidth = renderer.getTextWidth(UI_10_FONT_ID, valueText.c_str());
renderer.drawText(UI_10_FONT_ID,
rect.x + contentWidth - BaseMetrics::values.contentSidePadding - valueTextWidth,
yPos + rowHeight, valueText.c_str(), false);
}
yPos += 2 * rowHeight;
} else {
int textWidth = contentWidth - BaseMetrics::values.contentSidePadding * 2 - (rowValue != nullptr ? 60 : 0);
auto item = renderer.truncatedText(font, itemName.c_str(), textWidth);
renderer.drawText(font, rect.x + BaseMetrics::values.contentSidePadding, yPos, item.c_str(),
i != selectedIndex);
if (rowSubtitle != nullptr) {
std::string subtitleText = rowSubtitle(i);
auto subtitle = renderer.truncatedText(UI_10_FONT_ID, subtitleText.c_str(), textWidth);
renderer.drawText(UI_10_FONT_ID, rect.x + BaseMetrics::values.contentSidePadding, yPos + 30,
subtitle.c_str(), i != selectedIndex);
}
if (rowValue != nullptr) {
std::string valueText = rowValue(i);
const auto valueTextWidth = renderer.getTextWidth(UI_10_FONT_ID, valueText.c_str());
renderer.drawText(UI_10_FONT_ID,
rect.x + contentWidth - BaseMetrics::values.contentSidePadding - valueTextWidth, yPos,
valueText.c_str(), i != selectedIndex);
}
yPos += rowHeight;
}
}
}
@@ -365,14 +527,52 @@ void BaseTheme::drawTabBar(const GfxRenderer& renderer, const Rect rect, const s
void BaseTheme::drawRecentBookCover(GfxRenderer& renderer, Rect rect, const std::vector<RecentBook>& recentBooks,
const int selectorIndex, bool& coverRendered, bool& coverBufferStored,
bool& bufferRestored, std::function<bool()> storeCoverBuffer) const {
// --- Top "book" card for the current title (selectorIndex == 0) ---
const int bookWidth = rect.width / 2;
const int bookHeight = rect.height;
const int bookX = (rect.width - bookWidth) / 2;
const int bookY = rect.y;
const bool hasContinueReading = !recentBooks.empty();
const bool bookSelected = hasContinueReading && selectorIndex == 0;
// --- Top "book" card for the current title (selectorIndex == 0) ---
// Adapt width to cover image aspect ratio; fall back to half screen when no cover
const int baseHeight = rect.height;
int bookWidth;
bool hasCoverImage = false;
if (hasContinueReading && !recentBooks[0].coverBmpPath.empty()) {
const std::string coverBmpPath =
UITheme::getCoverThumbPath(recentBooks[0].coverBmpPath, BaseMetrics::values.homeCoverHeight);
FsFile file;
if (Storage.openFileForRead("HOME", coverBmpPath, file)) {
Bitmap bitmap(file);
if (bitmap.parseHeaders() == BmpReaderError::Ok) {
hasCoverImage = true;
const int imgWidth = bitmap.getWidth();
const int imgHeight = bitmap.getHeight();
if (imgWidth > 0 && imgHeight > 0) {
const float aspectRatio = static_cast<float>(imgWidth) / static_cast<float>(imgHeight);
bookWidth = static_cast<int>(baseHeight * aspectRatio);
const int maxWidth = static_cast<int>(rect.width * 0.9f);
if (bookWidth > maxWidth) {
bookWidth = maxWidth;
}
} else {
bookWidth = rect.width / 2;
}
}
file.close();
}
}
if (!hasCoverImage) {
bookWidth = rect.width / 2;
}
const int bookX = rect.x + (rect.width - bookWidth) / 2;
const int bookY = rect.y;
const int bookHeight = baseHeight;
// Bookmark dimensions (used in multiple places)
const int bookmarkWidth = bookWidth / 8;
const int bookmarkHeight = bookHeight / 5;
@@ -394,29 +594,9 @@ void BaseTheme::drawRecentBookCover(GfxRenderer& renderer, Rect rect, const std:
Bitmap bitmap(file);
if (bitmap.parseHeaders() == BmpReaderError::Ok) {
LOG_DBG("THEME", "Rendering bmp");
// Calculate position to center image within the book card
int coverX, coverY;
if (bitmap.getWidth() > bookWidth || bitmap.getHeight() > bookHeight) {
const float imgRatio = static_cast<float>(bitmap.getWidth()) / static_cast<float>(bitmap.getHeight());
const float boxRatio = static_cast<float>(bookWidth) / static_cast<float>(bookHeight);
renderer.drawBitmap(bitmap, bookX, bookY, bookWidth, bookHeight);
if (imgRatio > boxRatio) {
coverX = bookX;
coverY = bookY + (bookHeight - static_cast<int>(bookWidth / imgRatio)) / 2;
} else {
coverX = bookX + (bookWidth - static_cast<int>(bookHeight * imgRatio)) / 2;
coverY = bookY;
}
} else {
coverX = bookX + (bookWidth - bitmap.getWidth()) / 2;
coverY = bookY + (bookHeight - bitmap.getHeight()) / 2;
}
// Draw the cover image centered within the book card
renderer.drawBitmap(bitmap, coverX, coverY, bookWidth, bookHeight);
// Draw border around the card
renderer.drawRect(bookX, bookY, bookWidth, bookHeight);
// No bookmark ribbon when cover is shown - it would just cover the art
@@ -597,7 +777,7 @@ void BaseTheme::drawRecentBookCover(GfxRenderer& renderer, Rect rect, const std:
const int boxWidth = maxTextWidth + boxPadding * 2;
const int boxHeight = totalTextHeight + boxPadding * 2;
const int boxX = (rect.width - boxWidth) / 2;
const int boxX = rect.x + (rect.width - boxWidth) / 2;
const int boxY = titleYStart - boxPadding;
// Draw box (inverted when selected: black box instead of white)
@@ -640,7 +820,7 @@ void BaseTheme::drawRecentBookCover(GfxRenderer& renderer, Rect rect, const std:
constexpr int continuePadding = 6;
const int continueBoxWidth = continueTextWidth + continuePadding * 2;
const int continueBoxHeight = renderer.getLineHeight(UI_10_FONT_ID) + continuePadding;
const int continueBoxX = (rect.width - continueBoxWidth) / 2;
const int continueBoxX = rect.x + (rect.width - continueBoxWidth) / 2;
const int continueBoxY = continueY - continuePadding / 2;
renderer.fillRect(continueBoxX, continueBoxY, continueBoxWidth, continueBoxHeight, bookSelected);
renderer.drawRect(continueBoxX, continueBoxY, continueBoxWidth, continueBoxHeight, !bookSelected);

View File

@@ -82,7 +82,7 @@ constexpr ThemeMetrics values = {.batteryWidth = 15,
.tabBarHeight = 50,
.scrollBarWidth = 4,
.scrollBarRightOffset = 5,
.homeTopPadding = 20,
.homeTopPadding = 40,
.homeCoverHeight = 400,
.homeCoverTileHeight = 400,
.homeRecentBooksCount = 1,

View File

@@ -84,6 +84,95 @@ const uint8_t* iconForName(UIIcon icon, int size) {
}
return nullptr;
}
std::string truncateWithEllipsis(const GfxRenderer& renderer, int fontId, const std::string& text, int maxWidth) {
std::string truncated = text;
std::string withEllipsis = truncated + "...";
while (!truncated.empty() && renderer.getTextWidth(fontId, withEllipsis.c_str()) > maxWidth) {
utf8RemoveLastChar(truncated);
withEllipsis = truncated + "...";
}
return truncated.empty() ? std::string("...") : withEllipsis;
}
std::vector<std::string> wrapTextToLines(const GfxRenderer& renderer, int fontId, const std::string& text, int maxWidth,
int maxLines) {
std::vector<std::string> lines;
if (text.empty() || maxWidth <= 0 || maxLines <= 0) return lines;
if (renderer.getTextWidth(fontId, text.c_str()) <= maxWidth) {
lines.push_back(text);
return lines;
}
if (maxLines == 1) {
lines.push_back(truncateWithEllipsis(renderer, fontId, text, maxWidth));
return lines;
}
static const char* const preferredDelimiters[] = {" -- ", " - ", " \xe2\x80\x93 ", " \xe2\x80\x94 "};
for (const char* delim : preferredDelimiters) {
size_t delimLen = strlen(delim);
auto pos = text.rfind(delim);
if (pos != std::string::npos && pos > 0) {
std::string firstPart = text.substr(0, pos);
if (renderer.getTextWidth(fontId, firstPart.c_str()) <= maxWidth) {
lines.push_back(firstPart);
std::string remainder = text.substr(pos + delimLen);
if (renderer.getTextWidth(fontId, remainder.c_str()) > maxWidth) {
lines.push_back(truncateWithEllipsis(renderer, fontId, remainder, maxWidth));
} else {
lines.push_back(remainder);
}
return lines;
}
}
}
std::string currentLine;
const unsigned char* ptr = reinterpret_cast<const unsigned char*>(text.c_str());
std::string lineAtBreak;
const unsigned char* ptrAtBreak = nullptr;
while (*ptr != 0) {
const unsigned char* charStart = ptr;
uint32_t cp = utf8NextCodepoint(&ptr);
std::string nextChar(reinterpret_cast<const char*>(charStart), static_cast<size_t>(ptr - charStart));
std::string candidate = currentLine + nextChar;
if (renderer.getTextWidth(fontId, candidate.c_str()) <= maxWidth) {
currentLine = candidate;
if (cp == ' ' || cp == '-') {
lineAtBreak = currentLine;
ptrAtBreak = ptr;
}
continue;
}
if (static_cast<int>(lines.size()) < maxLines - 1 && !currentLine.empty()) {
if (ptrAtBreak != nullptr) {
std::string line = lineAtBreak;
while (!line.empty() && line.back() == ' ') line.pop_back();
lines.push_back(line);
ptr = ptrAtBreak;
while (*ptr == ' ') ++ptr;
currentLine.clear();
} else {
lines.push_back(currentLine);
currentLine = nextChar;
}
lineAtBreak.clear();
ptrAtBreak = nullptr;
} else {
lines.push_back(truncateWithEllipsis(renderer, fontId, currentLine, maxWidth));
return lines;
}
}
if (!currentLine.empty()) {
lines.push_back(currentLine);
}
return lines;
}
} // namespace
void LyraTheme::drawBatteryLeft(const GfxRenderer& renderer, Rect rect, const bool showPercentage) const {
@@ -278,13 +367,35 @@ void LyraTheme::drawList(const GfxRenderer& renderer, Rect rect, int itemCount,
(rowSubtitle != nullptr) ? LyraMetrics::values.listWithSubtitleRowHeight : LyraMetrics::values.listRowHeight;
int pageItems = rect.height / rowHeight;
const int totalPages = (itemCount + pageItems - 1) / pageItems;
// Detect if selected row's title overflows and needs 2-line expansion
bool selectedExpands = false;
if (selectedIndex >= 0 && rowSubtitle == nullptr && rowValue != nullptr) {
int prelTotalPages = (itemCount + pageItems - 1) / pageItems;
int prelContentWidth =
rect.width -
(prelTotalPages > 1 ? (LyraMetrics::values.scrollBarWidth + LyraMetrics::values.scrollBarRightOffset) : 1);
int prelTextWidth = prelContentWidth - LyraMetrics::values.contentSidePadding * 2 - hPaddingInSelection * 2;
if (rowIcon != nullptr) prelTextWidth -= listIconSize + hPaddingInSelection;
auto selTitle = rowTitle(selectedIndex);
auto selValue = rowValue(selectedIndex);
int selValueWidth = 0;
if (!selValue.empty()) {
selValue = renderer.truncatedText(UI_10_FONT_ID, selValue.c_str(), maxListValueWidth);
selValueWidth = renderer.getTextWidth(UI_10_FONT_ID, selValue.c_str()) + hPaddingInSelection;
}
if (renderer.getTextWidth(UI_10_FONT_ID, selTitle.c_str()) > prelTextWidth - selValueWidth) {
selectedExpands = true;
}
}
const int effectivePageItems = selectedExpands ? std::max(1, pageItems - 1) : pageItems;
const int totalPages = (itemCount + effectivePageItems - 1) / effectivePageItems;
if (totalPages > 1) {
const int scrollAreaHeight = rect.height;
// Draw scroll bar
const int scrollBarHeight = (scrollAreaHeight * pageItems) / itemCount;
const int currentPage = selectedIndex / pageItems;
const int scrollBarHeight = (scrollAreaHeight * effectivePageItems) / itemCount;
const int currentPage = selectedIndex / effectivePageItems;
const int scrollBarY = rect.y + ((scrollAreaHeight - scrollBarHeight) * currentPage) / (totalPages - 1);
const int scrollBarX = rect.x + rect.width - LyraMetrics::values.scrollBarRightOffset;
renderer.drawLine(scrollBarX, rect.y, scrollBarX, rect.y + scrollAreaHeight, true);
@@ -292,19 +403,71 @@ void LyraTheme::drawList(const GfxRenderer& renderer, Rect rect, int itemCount,
scrollBarHeight, true);
}
// Draw selection
int contentWidth =
rect.width -
(totalPages > 1 ? (LyraMetrics::values.scrollBarWidth + LyraMetrics::values.scrollBarRightOffset) : 1);
// Compute page start: use effective page items but prevent backward leak
int pageStartIndex;
if (selectedExpands) {
int rawStart = selectedIndex / effectivePageItems * effectivePageItems;
int originalStart = selectedIndex / pageItems * pageItems;
pageStartIndex = std::max(rawStart, originalStart);
if (selectedIndex >= pageStartIndex + effectivePageItems) {
pageStartIndex = selectedIndex - effectivePageItems + 1;
}
if (pageStartIndex > 0 && pageStartIndex == originalStart
&& selectedIndex < pageStartIndex + effectivePageItems - 1) {
int checkTextWidth = contentWidth - LyraMetrics::values.contentSidePadding * 2 - hPaddingInSelection * 2;
if (rowIcon != nullptr) checkTextWidth -= listIconSize + hPaddingInSelection;
auto prevTitle = rowTitle(pageStartIndex - 1);
int prevValueWidth = 0;
if (rowValue != nullptr) {
auto prevValue = rowValue(pageStartIndex - 1);
prevValue = renderer.truncatedText(UI_10_FONT_ID, prevValue.c_str(), maxListValueWidth);
if (!prevValue.empty()) {
prevValueWidth = renderer.getTextWidth(UI_10_FONT_ID, prevValue.c_str()) + hPaddingInSelection;
}
}
if (renderer.getTextWidth(UI_10_FONT_ID, prevTitle.c_str()) > checkTextWidth - prevValueWidth) {
pageStartIndex--;
}
}
} else {
pageStartIndex = selectedIndex / pageItems * pageItems;
// Include previous page's boundary item if it would need expansion when selected,
// so it doesn't vanish when navigating from it to the current page.
if (pageStartIndex > 0 && selectedIndex < pageStartIndex + pageItems - 1) {
int checkTextWidth = contentWidth - LyraMetrics::values.contentSidePadding * 2 - hPaddingInSelection * 2;
if (rowIcon != nullptr) checkTextWidth -= listIconSize + hPaddingInSelection;
auto prevTitle = rowTitle(pageStartIndex - 1);
int prevValueWidth = 0;
if (rowValue != nullptr) {
auto prevValue = rowValue(pageStartIndex - 1);
prevValue = renderer.truncatedText(UI_10_FONT_ID, prevValue.c_str(), maxListValueWidth);
if (!prevValue.empty()) {
prevValueWidth = renderer.getTextWidth(UI_10_FONT_ID, prevValue.c_str()) + hPaddingInSelection;
}
}
if (renderer.getTextWidth(UI_10_FONT_ID, prevTitle.c_str()) > checkTextWidth - prevValueWidth) {
pageStartIndex--;
}
}
}
// Draw selection highlight
if (selectedIndex >= 0) {
renderer.fillRoundedRect(LyraMetrics::values.contentSidePadding, rect.y + selectedIndex % pageItems * rowHeight,
contentWidth - LyraMetrics::values.contentSidePadding * 2, rowHeight, cornerRadius,
int selRowsBeforeOnPage = selectedIndex - pageStartIndex;
int selY = rect.y + selRowsBeforeOnPage * rowHeight;
int selHeight = selectedExpands ? 2 * rowHeight : rowHeight;
renderer.fillRoundedRect(LyraMetrics::values.contentSidePadding, selY,
contentWidth - LyraMetrics::values.contentSidePadding * 2, selHeight, cornerRadius,
Color::LightGray);
}
int textX = rect.x + LyraMetrics::values.contentSidePadding + hPaddingInSelection;
int textWidth = contentWidth - LyraMetrics::values.contentSidePadding * 2 - hPaddingInSelection * 2;
int iconSize;
int iconSize = listIconSize;
if (rowIcon != nullptr) {
iconSize = (rowSubtitle != nullptr) ? mainMenuIconSize : listIconSize;
textX += iconSize + hPaddingInSelection;
@@ -312,52 +475,78 @@ void LyraTheme::drawList(const GfxRenderer& renderer, Rect rect, int itemCount,
}
// Draw all items
const auto pageStartIndex = selectedIndex / pageItems * pageItems;
int iconY = (rowSubtitle != nullptr) ? 16 : 10;
for (int i = pageStartIndex; i < itemCount && i < pageStartIndex + pageItems; i++) {
const int itemY = rect.y + (i % pageItems) * rowHeight;
int rowTextWidth = textWidth;
int yPos = rect.y;
for (int i = pageStartIndex; i < itemCount && i < pageStartIndex + effectivePageItems; i++) {
const bool isExpanded = (selectedExpands && i == selectedIndex);
// Draw name
int valueWidth = 0;
std::string valueText = "";
std::string valueText;
if (rowValue != nullptr) {
valueText = rowValue(i);
valueText = renderer.truncatedText(UI_10_FONT_ID, valueText.c_str(), maxListValueWidth);
valueWidth = renderer.getTextWidth(UI_10_FONT_ID, valueText.c_str()) + hPaddingInSelection;
rowTextWidth -= valueWidth;
if (!valueText.empty()) {
valueWidth = renderer.getTextWidth(UI_10_FONT_ID, valueText.c_str()) + hPaddingInSelection;
}
}
auto itemName = rowTitle(i);
auto item = renderer.truncatedText(UI_10_FONT_ID, itemName.c_str(), rowTextWidth);
renderer.drawText(UI_10_FONT_ID, textX, itemY + 7, item.c_str(), true);
if (rowIcon != nullptr) {
UIIcon icon = rowIcon(i);
const uint8_t* iconBitmap = iconForName(icon, iconSize);
if (iconBitmap != nullptr) {
renderer.drawIcon(iconBitmap, rect.x + LyraMetrics::values.contentSidePadding + hPaddingInSelection,
itemY + iconY, iconSize, iconSize);
}
}
if (isExpanded) {
int wrapWidth = textWidth;
auto lines = wrapTextToLines(renderer, UI_10_FONT_ID, itemName, wrapWidth, 2);
if (rowSubtitle != nullptr) {
// Draw subtitle
std::string subtitleText = rowSubtitle(i);
auto subtitle = renderer.truncatedText(SMALL_FONT_ID, subtitleText.c_str(), rowTextWidth);
renderer.drawText(SMALL_FONT_ID, textX, itemY + 30, subtitle.c_str(), true);
}
// Draw value
if (!valueText.empty()) {
if (i == selectedIndex && highlightValue) {
renderer.fillRoundedRect(
contentWidth - LyraMetrics::values.contentSidePadding - hPaddingInSelection - valueWidth, itemY,
valueWidth + hPaddingInSelection, rowHeight, cornerRadius, Color::Black);
for (size_t l = 0; l < lines.size(); ++l) {
renderer.drawText(UI_10_FONT_ID, textX, yPos + 7 + static_cast<int>(l) * rowHeight, lines[l].c_str(), true);
}
renderer.drawText(UI_10_FONT_ID, rect.x + contentWidth - LyraMetrics::values.contentSidePadding - valueWidth,
itemY + 6, valueText.c_str(), !(i == selectedIndex && highlightValue));
if (rowIcon != nullptr) {
UIIcon icon = rowIcon(i);
const uint8_t* iconBitmap = iconForName(icon, iconSize);
if (iconBitmap != nullptr) {
renderer.drawIcon(iconBitmap, rect.x + LyraMetrics::values.contentSidePadding + hPaddingInSelection,
yPos + iconY, iconSize, iconSize);
}
}
if (!valueText.empty()) {
renderer.drawText(UI_10_FONT_ID,
rect.x + contentWidth - LyraMetrics::values.contentSidePadding - valueWidth,
yPos + rowHeight + 7, valueText.c_str(), true);
}
yPos += 2 * rowHeight;
} else {
int rowTextWidth = textWidth - valueWidth;
auto item = renderer.truncatedText(UI_10_FONT_ID, itemName.c_str(), rowTextWidth);
renderer.drawText(UI_10_FONT_ID, textX, yPos + 7, item.c_str(), true);
if (rowIcon != nullptr) {
UIIcon icon = rowIcon(i);
const uint8_t* iconBitmap = iconForName(icon, iconSize);
if (iconBitmap != nullptr) {
renderer.drawIcon(iconBitmap, rect.x + LyraMetrics::values.contentSidePadding + hPaddingInSelection,
yPos + iconY, iconSize, iconSize);
}
}
if (rowSubtitle != nullptr) {
std::string subtitleText = rowSubtitle(i);
auto subtitle = renderer.truncatedText(SMALL_FONT_ID, subtitleText.c_str(), rowTextWidth);
renderer.drawText(SMALL_FONT_ID, textX, yPos + 30, subtitle.c_str(), true);
}
if (!valueText.empty()) {
if (i == selectedIndex && highlightValue) {
renderer.fillRoundedRect(
contentWidth - LyraMetrics::values.contentSidePadding - hPaddingInSelection - valueWidth, yPos,
valueWidth + hPaddingInSelection, rowHeight, cornerRadius, Color::Black);
}
renderer.drawText(UI_10_FONT_ID,
rect.x + contentWidth - LyraMetrics::values.contentSidePadding - valueWidth, yPos + 6,
valueText.c_str(), !(i == selectedIndex && highlightValue));
}
yPos += rowHeight;
}
}
}