From c81b58d3336f8be1abcf906393c77a4dc62466ea Mon Sep 17 00:00:00 2001 From: Sung-jin Brian Hong Date: Tue, 22 Sep 2026 02:55:57 +0900 Subject: [PATCH 01/62] feat: add word and character spacing controls (#3528) --- docs/file-formats.md | 13 +- lib/Epub/Epub/ParsedText.cpp | 169 +++++++++++------- lib/Epub/Epub/ParsedText.h | 4 +- lib/Epub/Epub/ReaderRenderSpec.h | 2 + lib/Epub/Epub/Section.cpp | 19 +- lib/Epub/Epub/blocks/BlockStyle.h | 2 + lib/Epub/Epub/blocks/TextBlock.cpp | 33 ++-- .../Epub/parsers/ChapterHtmlSlimParser.cpp | 18 +- lib/Epub/Epub/parsers/ChapterHtmlSlimParser.h | 6 + lib/GfxRenderer/GfxRenderer.cpp | 38 ++-- lib/GfxRenderer/GfxRenderer.h | 12 +- lib/I18n/translations/english.yaml | 15 ++ src/CrossPointSettings.cpp | 2 + src/CrossPointSettings.h | 7 + src/SettingsList.h | 10 ++ .../settings/TextSettingsActivity.cpp | 42 ++++- .../settings/TextSettingsActivity.h | 2 +- .../settings/TextSettingsPreview.cpp | 5 +- src/activities/settings/TextSettingsPreview.h | 2 + src/util/DictHtmlPages.cpp | 1 + .../ChapterHtmlSlimParserTest.cpp | 106 +++++++++++ .../stubs/GfxRenderer.h | 23 ++- 22 files changed, 412 insertions(+), 119 deletions(-) diff --git a/docs/file-formats.md b/docs/file-formats.md index efa83fa72cd..2ebe830db48 100644 --- a/docs/file-formats.md +++ b/docs/file-formats.md @@ -90,6 +90,14 @@ if (parsedSize != fileSize) { ## `section.bin` +### Version 47 + +The section header adds signed `characterSpacing` (pixels) and unsigned +`wordSpacingPercent` after `focusReadingEnabled`; both participate in cache +validation. Each TextBlock's BlockStyle stores only `characterSpacing` after +`directionDefined`. Word spacing is resolved into cached word positions during +layout. Sections from earlier versions are rebuilt. + ### Version 46 Version 46 keeps the version 45 serialized layout unchanged. It was bumped @@ -183,7 +191,7 @@ import std.mem; import std.string; import std.core; -#define EXPECTED_VERSION 41 +#define EXPECTED_VERSION 47 #define MAX_STRING_LENGTH 65535 #define FOOTNOTE_NUMBER_LEN 32 #define FOOTNOTE_HREF_LEN 256 @@ -240,6 +248,7 @@ struct BlockStyle { bool textIndentDefined; bool isRtl; bool directionDefined; + s8 characterSpacing; }; struct TextBlock { @@ -346,6 +355,8 @@ struct SectionBin { bool embeddedStyle; u8 imageRendering; bool focusReadingEnabled; + s8 characterSpacing; + u8 wordSpacingPercent; u16 pageCount; u32 pageLutOffset; diff --git a/lib/Epub/Epub/ParsedText.cpp b/lib/Epub/Epub/ParsedText.cpp index f3ec205b8ea..a203e6f8f9e 100644 --- a/lib/Epub/Epub/ParsedText.cpp +++ b/lib/Epub/Epub/ParsedText.cpp @@ -210,17 +210,22 @@ void stripSoftHyphensInPlace(std::string& word) { } } +constexpr int scaleSpace(const int advance, const uint8_t wordSpacingPercent) { + return wordSpacingPercent == 100 ? advance : (advance * wordSpacingPercent + 50) / 100; +} + // Returns the advance width for a word while ignoring soft hyphen glyphs and optionally appending a visible hyphen. // Uses advance width (sum of glyph advances + kerning) rather than bounding box width so that italic glyph overhangs // don't inflate inter-word spacing. uint16_t measureWordWidth(const GfxRenderer& renderer, const int fontId, const std::string& word, - const EpdFontFamily::Style style, const bool appendHyphen = false) { + const EpdFontFamily::Style style, const int8_t characterSpacing, + const uint8_t wordSpacingPercent, const bool appendHyphen = false) { if (word.size() == 1 && word[0] == ' ' && !appendHyphen) { - return renderer.getSpaceWidth(fontId, style); + return scaleSpace(renderer.getSpaceWidth(fontId, style), wordSpacingPercent); } const bool hasSoftHyphen = containsSoftHyphen(word); if (!hasSoftHyphen && !appendHyphen) { - return renderer.getTextAdvanceX(fontId, word.c_str(), style); + return renderer.getTextAdvanceX(fontId, word.c_str(), style, characterSpacing); } std::string sanitized = word; @@ -230,7 +235,7 @@ uint16_t measureWordWidth(const GfxRenderer& renderer, const int fontId, const s if (appendHyphen) { sanitized.push_back('-'); } - return renderer.getTextAdvanceX(fontId, sanitized.c_str(), style); + return renderer.getTextAdvanceX(fontId, sanitized.c_str(), style, characterSpacing); } // True when a token ends with a hyphen or dash that allows a line break after it. U+00AD is @@ -250,7 +255,8 @@ constexpr size_t FOCUS_PREFIX_BUF_SIZE = 40; // Advance from the token origin to the start of its regular-weight suffix: the bold prefix plus // the kerning across the weight change. Doubles as the suffix's x offset inside the word. uint16_t measureFocusPrefixAdvance(const GfxRenderer& renderer, const int fontId, const std::string& word, - const EpdFontFamily::Style style, const uint8_t focusBoundary) { + const EpdFontFamily::Style style, const uint8_t focusBoundary, + const int8_t characterSpacing) { char prefixBuf[FOCUS_PREFIX_BUF_SIZE]; const size_t prefixLen = std::min(focusBoundary, FOCUS_PREFIX_BUF_SIZE - 1); memcpy(prefixBuf, word.data(), prefixLen); @@ -258,26 +264,29 @@ uint16_t measureFocusPrefixAdvance(const GfxRenderer& renderer, const int fontId const auto boldStyle = static_cast(style | EpdFontFamily::BOLD); const auto* suffixPtr = reinterpret_cast(word.c_str() + focusBoundary); - const int kerning = renderer.getKerning(fontId, lastCodepoint(prefixBuf), utf8NextCodepoint(&suffixPtr), boldStyle); - return static_cast(renderer.getTextAdvanceX(fontId, prefixBuf, boldStyle) + kerning); + const int kerning = + renderer.getKerning(fontId, lastCodepoint(prefixBuf), utf8NextCodepoint(&suffixPtr), boldStyle, characterSpacing); + return static_cast(renderer.getTextAdvanceX(fontId, prefixBuf, boldStyle, characterSpacing) + kerning); } // Advance width of a whole token, accounting for a bold focus prefix when it has one. uint16_t measureFocusWordWidth(const GfxRenderer& renderer, const int fontId, const std::string& word, const EpdFontFamily::Style style, const uint8_t focusBoundary, + const int8_t characterSpacing, const uint8_t wordSpacingPercent, const bool appendHyphen = false) { if (focusBoundary == 0) { - return measureWordWidth(renderer, fontId, word, style, appendHyphen); + return measureWordWidth(renderer, fontId, word, style, characterSpacing, wordSpacingPercent, appendHyphen); } if (focusBoundary >= word.size()) { // The bold run covers the whole token, as for a split candidate ending on the boundary. return measureWordWidth(renderer, fontId, word, static_cast(style | EpdFontFamily::BOLD), - appendHyphen); + characterSpacing, wordSpacingPercent, appendHyphen); } - const uint16_t suffixWidth = - appendHyphen ? measureWordWidth(renderer, fontId, word.substr(focusBoundary), style, true) - : static_cast(renderer.getTextAdvanceX(fontId, word.c_str() + focusBoundary, style)); - return measureFocusPrefixAdvance(renderer, fontId, word, style, focusBoundary) + suffixWidth; + const uint16_t suffixWidth = appendHyphen ? measureWordWidth(renderer, fontId, word.substr(focusBoundary), style, + characterSpacing, wordSpacingPercent, true) + : static_cast(renderer.getTextAdvanceX( + fontId, word.c_str() + focusBoundary, style, characterSpacing)); + return measureFocusPrefixAdvance(renderer, fontId, word, style, focusBoundary, characterSpacing) + suffixWidth; } // Focus boundaries for the two halves of a token split at `splitOffset`. @@ -673,17 +682,21 @@ int ParsedText::resolveFirstLineIndent(const bool isFirstLine, const GfxRenderer return 0; } if (!extraParagraphSpacing) { - return renderer.getSpaceWidth(fontId, EpdFontFamily::REGULAR) * 3; + return scaleSpace(renderer.getSpaceWidth(fontId, EpdFontFamily::REGULAR), wordSpacingPercent) * 3; } return 0; } // Consumes data to minimize memory usage void ParsedText::layoutAndExtractLines(const GfxRenderer& renderer, const int fontId, const uint16_t viewportWidth, const std::function, uint32_t)>& processLine, - const bool includeLastLine) { + const bool includeLastLine, const int8_t characterSpacing, + const uint8_t wordSpacingPercent) { if (words.empty()) { return; } + // Stamped here rather than at construction: the parser replaces blockStyle as CSS resolves. + blockStyle.characterSpacing = characterSpacing; + this->wordSpacingPercent = wordSpacingPercent; // Per-paragraph RTL auto-detection: only when CSS/HTML didn't explicitly set direction. // Explicit dir="ltr" must be respected and not overridden by content heuristic. @@ -776,9 +789,11 @@ int ParsedText::calculateRubyExtraStartOffset(const size_t wordIdx, const size_t } int groupActualWidth = 0; for (size_t k = 0; k < groupWordCount; ++k) { - groupActualWidth += measureWordWidth(renderer, fontId, words[wordIdx + k], wordStyles[wordIdx + k]); + groupActualWidth += measureWordWidth(renderer, fontId, words[wordIdx + k], wordStyles[wordIdx + k], + blockStyle.characterSpacing, wordSpacingPercent); } - const int rubyWidth = renderer.getTextAdvanceX(fontId, rubyTexts[wordIdx].c_str(), EpdFontFamily::SUP); + const int rubyWidth = + renderer.getTextAdvanceX(fontId, rubyTexts[wordIdx].c_str(), EpdFontFamily::SUP, blockStyle.characterSpacing); if (rubyWidth <= groupActualWidth) { return 0; } @@ -819,9 +834,11 @@ int ParsedText::calculateRubyExtraEndOffset(const size_t lineStartIdx, const siz // Measure the group. int groupActualWidth = 0; for (size_t k = leaderIdx; k < lineBreakIdx; ++k) { - groupActualWidth += measureWordWidth(renderer, fontId, words[k], wordStyles[k]); + groupActualWidth += + measureWordWidth(renderer, fontId, words[k], wordStyles[k], blockStyle.characterSpacing, wordSpacingPercent); } - const int rubyWidth = renderer.getTextAdvanceX(fontId, rubyTexts[leaderIdx].c_str(), EpdFontFamily::SUP); + const int rubyWidth = + renderer.getTextAdvanceX(fontId, rubyTexts[leaderIdx].c_str(), EpdFontFamily::SUP, blockStyle.characterSpacing); if (rubyWidth <= groupActualWidth) { return 0; } @@ -834,7 +851,8 @@ std::vector ParsedText::calculateWordWidths(const GfxRenderer& rendere wordWidths.reserve(words.size()); for (size_t i = 0; i < words.size(); ++i) { - wordWidths.push_back(measureFocusWordWidth(renderer, fontId, words[i], wordStyles[i], wordFocusBoundary[i])); + wordWidths.push_back(measureFocusWordWidth(renderer, fontId, words[i], wordStyles[i], wordFocusBoundary[i], + blockStyle.characterSpacing, wordSpacingPercent)); } // Adjust widths for ruby groups to comply with JLReq standards @@ -859,7 +877,8 @@ std::vector ParsedText::calculateWordWidths(const GfxRenderer& rendere g.baseWidth += wordWidths[i + g.count]; g.count++; } - g.rubyWidth = renderer.getTextAdvanceX(fontId, rubyTexts[i].c_str(), EpdFontFamily::SUP); + g.rubyWidth = + renderer.getTextAdvanceX(fontId, rubyTexts[i].c_str(), EpdFontFamily::SUP, blockStyle.characterSpacing); g.leftOverlap = std::max(0, (g.rubyWidth - g.baseWidth) / 2); g.rightOverlap = std::max(0, (g.rubyWidth - g.baseWidth) / 2); groups.push_back(g); @@ -977,12 +996,14 @@ std::vector ParsedText::computeLineBreaks(const GfxRenderer& renderer, c int gap = 0; if (j > static_cast(i) && continuesVec[j]) { // Attached and breakable-attached boundaries both use kerning when kept on one line. - gap = renderer.getKerning(fontId, lastCodepoint(words[j - 1]), firstCodepoint(words[j]), wordStyles[j - 1]); + gap = renderer.getKerning(fontId, lastCodepoint(words[j - 1]), firstCodepoint(words[j]), wordStyles[j - 1], + blockStyle.characterSpacing); } else if (j > static_cast(i) && noSpaceBeforeVec[j]) { - gap = 0; + gap = blockStyle.characterSpacing; } else if (j > static_cast(i)) { - gap = - renderer.getSpaceAdvance(fontId, lastCodepoint(words[j - 1]), firstCodepoint(words[j]), wordStyles[j - 1]); + gap = scaleSpace( + renderer.getSpaceAdvance(fontId, lastCodepoint(words[j - 1]), firstCodepoint(words[j]), wordStyles[j - 1]), + wordSpacingPercent); } // Calculate extraStartOffset for the first word on the line (i) (protect left margin) @@ -1086,13 +1107,16 @@ std::vector ParsedText::computeHyphenatedLineBreaks(const GfxRenderer& r int spacing = 0; if (!isFirstWord && continuesVec[currentIndex]) { // Attached and breakable-attached boundaries both use kerning when kept on one line. - spacing = renderer.getKerning(fontId, lastCodepoint(words[currentIndex - 1]), - firstCodepoint(words[currentIndex]), wordStyles[currentIndex - 1]); + spacing = + renderer.getKerning(fontId, lastCodepoint(words[currentIndex - 1]), firstCodepoint(words[currentIndex]), + wordStyles[currentIndex - 1], blockStyle.characterSpacing); } else if (!isFirstWord && noSpaceBeforeVec[currentIndex]) { - spacing = 0; + spacing = blockStyle.characterSpacing; } else if (!isFirstWord) { - spacing = renderer.getSpaceAdvance(fontId, lastCodepoint(words[currentIndex - 1]), - firstCodepoint(words[currentIndex]), wordStyles[currentIndex - 1]); + spacing = + scaleSpace(renderer.getSpaceAdvance(fontId, lastCodepoint(words[currentIndex - 1]), + firstCodepoint(words[currentIndex]), wordStyles[currentIndex - 1]), + wordSpacingPercent); } const int candidateWidth = spacing + wordWidths[currentIndex]; @@ -1172,7 +1196,8 @@ bool ParsedText::hyphenateWordAtIndex(const size_t wordIndex, const int availabl const bool needsHyphen = info.requiresInsertedHyphen; const int prefixWidth = measureFocusWordWidth(renderer, fontId, word.substr(0, offset), style, - focusBoundaryBefore(focusBoundary, offset), needsHyphen); + focusBoundaryBefore(focusBoundary, offset), + blockStyle.characterSpacing, wordSpacingPercent, needsHyphen); if (prefixWidth > availableWidth || prefixWidth <= chosenWidth) { continue; // Skip if too wide or not an improvement } @@ -1247,7 +1272,8 @@ bool ParsedText::hyphenateWordAtIndex(const size_t wordIndex, const int availabl // Update cached widths to reflect the new prefix/remainder pairing. wordWidths[wordIndex] = static_cast(chosenWidth); const uint16_t remainderWidth = - measureFocusWordWidth(renderer, fontId, remainder, style, wordFocusBoundary[wordIndex + 1]); + measureFocusWordWidth(renderer, fontId, remainder, style, wordFocusBoundary[wordIndex + 1], + blockStyle.characterSpacing, wordSpacingPercent); wordWidths.insert(wordWidths.begin() + wordIndex + 1, remainderWidth); return true; } @@ -1302,11 +1328,16 @@ void ParsedText::extractLine(const size_t breakIndex, const int pageWidth, const actualGapCount++; } if (continuesVec[boundaryIdx]) { - totalNaturalGaps += renderer.getKerning(fontId, lastCodepoint(lineWords[wordIdx - 1]), - firstCodepoint(lineWords[wordIdx]), lineWordStyles[wordIdx - 1]); - } else if (!noSpaceBeforeVec[boundaryIdx]) { - totalNaturalGaps += renderer.getSpaceAdvance(fontId, lastCodepoint(lineWords[wordIdx - 1]), - firstCodepoint(lineWords[wordIdx]), lineWordStyles[wordIdx - 1]); + totalNaturalGaps += + renderer.getKerning(fontId, lastCodepoint(lineWords[wordIdx - 1]), firstCodepoint(lineWords[wordIdx]), + lineWordStyles[wordIdx - 1], blockStyle.characterSpacing); + } else if (noSpaceBeforeVec[boundaryIdx]) { + totalNaturalGaps += blockStyle.characterSpacing; + } else { + totalNaturalGaps += + scaleSpace(renderer.getSpaceAdvance(fontId, lastCodepoint(lineWords[wordIdx - 1]), + firstCodepoint(lineWords[wordIdx]), lineWordStyles[wordIdx - 1]), + wordSpacingPercent); } } @@ -1389,18 +1420,21 @@ void ParsedText::extractLine(const size_t breakIndex, const int pageWidth, const // Unicode break opportunity with no inserted Latin-style space. It is still // a stretchable gap for justified CJK/Korean text. reorderedGapCount++; + reorderedNaturalGaps += blockStyle.characterSpacing; } else if (wordIdx > 0 && !reorderedContinuesScratch[wordIdx]) { reorderedGapCount++; - reorderedNaturalGaps += renderer.getSpaceAdvance(fontId, lastCodepoint(reorderedWordsScratch[wordIdx - 1]), - firstCodepoint(reorderedWordsScratch[wordIdx]), - reorderedStylesScratch[wordIdx - 1]); + reorderedNaturalGaps += + scaleSpace(renderer.getSpaceAdvance(fontId, lastCodepoint(reorderedWordsScratch[wordIdx - 1]), + firstCodepoint(reorderedWordsScratch[wordIdx]), + reorderedStylesScratch[wordIdx - 1]), + wordSpacingPercent); } else if (wordIdx > 0 && reorderedContinuesScratch[wordIdx]) { if (reorderedWordsScratch[wordIdx] == " ") { reorderedGapCount++; } - reorderedNaturalGaps += - renderer.getKerning(fontId, lastCodepoint(reorderedWordsScratch[wordIdx - 1]), - firstCodepoint(reorderedWordsScratch[wordIdx]), reorderedStylesScratch[wordIdx - 1]); + reorderedNaturalGaps += renderer.getKerning(fontId, lastCodepoint(reorderedWordsScratch[wordIdx - 1]), + firstCodepoint(reorderedWordsScratch[wordIdx]), + reorderedStylesScratch[wordIdx - 1], blockStyle.characterSpacing); } } @@ -1438,9 +1472,9 @@ void ParsedText::extractLine(const size_t breakIndex, const int pageWidth, const const bool nextIsContinuation = wordIdx + 1 < reorderedWidthsScratch.size() && reorderedContinuesScratch[wordIdx + 1]; if (nextIsContinuation) { - int advance = - renderer.getKerning(fontId, lastCodepoint(reorderedWordsScratch[wordIdx]), - firstCodepoint(reorderedWordsScratch[wordIdx + 1]), reorderedStylesScratch[wordIdx]); + int advance = renderer.getKerning(fontId, lastCodepoint(reorderedWordsScratch[wordIdx]), + firstCodepoint(reorderedWordsScratch[wordIdx + 1]), + reorderedStylesScratch[wordIdx], blockStyle.characterSpacing); // wordIdx > 0 mirrors the gap accounting above (which skips index 0): a leading // no-break space must not receive justifyExtra, or the line over-stretches by one // gap and the last word is pushed past the right margin (issue #2185). @@ -1451,10 +1485,12 @@ void ParsedText::extractLine(const size_t breakIndex, const int pageWidth, const xpos += advance; } else if (wordIdx + 1 < reorderedWidthsScratch.size()) { const bool nextNoSpace = reorderedNoSpaceBeforeScratch[wordIdx + 1]; - int gap = nextNoSpace ? 0 - : renderer.getSpaceAdvance(fontId, lastCodepoint(reorderedWordsScratch[wordIdx]), - firstCodepoint(reorderedWordsScratch[wordIdx + 1]), - reorderedStylesScratch[wordIdx]); + int gap = nextNoSpace + ? blockStyle.characterSpacing + : scaleSpace(renderer.getSpaceAdvance(fontId, lastCodepoint(reorderedWordsScratch[wordIdx]), + firstCodepoint(reorderedWordsScratch[wordIdx + 1]), + reorderedStylesScratch[wordIdx]), + wordSpacingPercent); if (effectiveAlignment == CssTextAlign::Justify && !isLastLine) { gap += reorderedJustifyExtra; } @@ -1484,8 +1520,9 @@ void ParsedText::extractLine(const size_t breakIndex, const int pageWidth, const const bool nextIsContinuation = wordIdx + 1 < lineWordCount && continuesVec[lastBreakAt + wordIdx + 1]; if (nextIsContinuation) { // Cross-boundary kerning for continuation words - int advance = renderer.getKerning(fontId, lastCodepoint(lineWords[wordIdx]), - firstCodepoint(lineWords[wordIdx + 1]), lineWordStyles[wordIdx]); + int advance = + renderer.getKerning(fontId, lastCodepoint(lineWords[wordIdx]), firstCodepoint(lineWords[wordIdx + 1]), + lineWordStyles[wordIdx], blockStyle.characterSpacing); // wordIdx > 0: see the LTR branch — a leading no-break space is not a justifiable gap. if (wordIdx > 0 && lineWords[wordIdx] == " " && continuesVec[lastBreakAt + wordIdx] && effectiveAlignment == CssTextAlign::Justify && !isLastLine) { @@ -1497,10 +1534,11 @@ void ParsedText::extractLine(const size_t breakIndex, const int pageWidth, const bool nextNoSpace = false; if (wordIdx + 1 < lineWordCount) { nextNoSpace = noSpaceBeforeVec[lastBreakAt + wordIdx + 1]; - gap = nextNoSpace - ? 0 - : renderer.getSpaceAdvance(fontId, lastCodepoint(lineWords[wordIdx]), - firstCodepoint(lineWords[wordIdx + 1]), lineWordStyles[wordIdx]); + gap = nextNoSpace ? blockStyle.characterSpacing + : scaleSpace(renderer.getSpaceAdvance(fontId, lastCodepoint(lineWords[wordIdx]), + firstCodepoint(lineWords[wordIdx + 1]), + lineWordStyles[wordIdx]), + wordSpacingPercent); } if (wordIdx + 1 < lineWordCount && effectiveAlignment == CssTextAlign::Justify && !isLastLine) { gap += justifyExtra; @@ -1523,8 +1561,9 @@ void ParsedText::extractLine(const size_t breakIndex, const int pageWidth, const const bool nextIsContinuation = wordIdx + 1 < lineWordCount && continuesVec[lastBreakAt + wordIdx + 1]; if (nextIsContinuation) { int advance = wordWidths[lastBreakAt + wordIdx]; - advance += renderer.getKerning(fontId, lastCodepoint(lineWords[wordIdx]), - firstCodepoint(lineWords[wordIdx + 1]), lineWordStyles[wordIdx]); + advance += + renderer.getKerning(fontId, lastCodepoint(lineWords[wordIdx]), firstCodepoint(lineWords[wordIdx + 1]), + lineWordStyles[wordIdx], blockStyle.characterSpacing); // wordIdx > 0 mirrors the gap accounting above (which skips index 0): a leading // no-break space must not receive justifyExtra, or the line over-stretches by one // gap and the last word is pushed past the right margin (issue #2185). @@ -1538,10 +1577,11 @@ void ParsedText::extractLine(const size_t breakIndex, const int pageWidth, const bool nextNoSpace = false; if (wordIdx + 1 < lineWordCount) { nextNoSpace = noSpaceBeforeVec[lastBreakAt + wordIdx + 1]; - gap = nextNoSpace - ? 0 - : renderer.getSpaceAdvance(fontId, lastCodepoint(lineWords[wordIdx]), - firstCodepoint(lineWords[wordIdx + 1]), lineWordStyles[wordIdx]); + gap = nextNoSpace ? blockStyle.characterSpacing + : scaleSpace(renderer.getSpaceAdvance(fontId, lastCodepoint(lineWords[wordIdx]), + firstCodepoint(lineWords[wordIdx + 1]), + lineWordStyles[wordIdx]), + wordSpacingPercent); } if (wordIdx + 1 < lineWordCount && effectiveAlignment == CssTextAlign::Justify && !isLastLine) { gap += justifyExtra; @@ -1620,8 +1660,9 @@ void ParsedText::extractLine(const size_t breakIndex, const int pageWidth, const for (size_t i = 0; i < lineWordCount; i++) { const uint8_t boundary = focusBoundaryAt(i); outBoundaries.push_back(boundary); - outSuffixX.push_back( - boundary == 0 ? 0 : measureFocusPrefixAdvance(renderer, fontId, lineWords[i], lineWordStyles[i], boundary)); + outSuffixX.push_back(boundary == 0 ? 0 + : measureFocusPrefixAdvance(renderer, fontId, lineWords[i], lineWordStyles[i], + boundary, blockStyle.characterSpacing)); } auto block = makeUniqueNoThrow(lineWords, lineXPos, lineWordStyles, outBoundaries, outSuffixX, blockStyle, diff --git a/lib/Epub/Epub/ParsedText.h b/lib/Epub/Epub/ParsedText.h index 929b108c26e..d46ce7a2177 100644 --- a/lib/Epub/Epub/ParsedText.h +++ b/lib/Epub/Epub/ParsedText.h @@ -54,6 +54,7 @@ class ParsedText { std::vector visibleOffsetRebases; std::deque rubyTexts; BlockStyle blockStyle; + uint8_t wordSpacingPercent = 100; bool extraParagraphSpacing; bool hyphenationEnabled; bool focusReadingEnabled; @@ -119,5 +120,6 @@ class ParsedText { bool isEmpty() const { return words.empty(); } void layoutAndExtractLines(const GfxRenderer& renderer, int fontId, uint16_t viewportWidth, const std::function, uint32_t)>& processLine, - bool includeLastLine = true); + bool includeLastLine = true, int8_t characterSpacing = 0, + uint8_t wordSpacingPercent = 100); }; diff --git a/lib/Epub/Epub/ReaderRenderSpec.h b/lib/Epub/Epub/ReaderRenderSpec.h index 7d701498fc5..70dd4d09d63 100644 --- a/lib/Epub/Epub/ReaderRenderSpec.h +++ b/lib/Epub/Epub/ReaderRenderSpec.h @@ -14,6 +14,8 @@ struct ReaderRenderSpec { int fontId = 0; float lineCompression = 1.0f; bool extraParagraphSpacing = false; + int8_t characterSpacing = 0; + uint8_t wordSpacingPercent = 100; uint8_t paragraphAlignment = 0; uint16_t viewportWidth = 0; uint16_t viewportHeight = 0; diff --git a/lib/Epub/Epub/Section.cpp b/lib/Epub/Epub/Section.cpp index f9310161c5b..a6b1ee2df2c 100644 --- a/lib/Epub/Epub/Section.cpp +++ b/lib/Epub/Epub/Section.cpp @@ -51,7 +51,8 @@ namespace { // v45: Internal EPUB links preserve CSS superscript/subscript positioning. // v46: Ordered lists number their items, list-style-type: none suppresses markers, // and
    /
      containers contribute their own margins/padding to child insets. -constexpr uint8_t SECTION_FILE_VERSION = 46; +// v47: Word and character spacing in the header (cache validation); cached BlockStyle stores only character spacing. +constexpr uint8_t SECTION_FILE_VERSION = 47; // Written into the version field while a build is in progress; patched to // SECTION_FILE_VERSION only when the build is finalized. An abandoned / // crash-interrupted .bin therefore carries version 0, which loadSectionFile rejects @@ -72,7 +73,8 @@ constexpr uint8_t SECTION_FILE_PARTIAL_VERSION = 0xFE - (SECTION_FILE_VERSION - constexpr uint32_t HEADER_SIZE = sizeof(uint8_t) + sizeof(int) + sizeof(float) + sizeof(bool) + sizeof(uint8_t) + sizeof(uint16_t) + sizeof(uint16_t) + sizeof(uint16_t) + sizeof(bool) + sizeof(bool) + sizeof(uint8_t) + sizeof(bool) + sizeof(uint32_t) + sizeof(uint32_t) + - sizeof(uint32_t) + sizeof(uint32_t) + sizeof(uint32_t); + sizeof(uint32_t) + sizeof(uint32_t) + sizeof(uint32_t) + sizeof(int8_t) + + sizeof(uint8_t); } // namespace // Out-of-line so the unique_ptr in BuildContext can be @@ -119,7 +121,8 @@ void Section::writeSectionFileHeader(const ReaderRenderSpec& spec) { sizeof(spec.extraParagraphSpacing) + sizeof(spec.paragraphAlignment) + sizeof(spec.viewportWidth) + sizeof(spec.viewportHeight) + sizeof(pageCount) + sizeof(spec.hyphenationEnabled) + sizeof(spec.embeddedStyle) + - sizeof(spec.imageRendering) + sizeof(spec.focusReadingEnabled) + sizeof(uint32_t) + + sizeof(spec.imageRendering) + sizeof(spec.focusReadingEnabled) + + sizeof(spec.characterSpacing) + sizeof(spec.wordSpacingPercent) + sizeof(uint32_t) + sizeof(uint32_t) + sizeof(uint32_t) + sizeof(uint32_t) + sizeof(uint32_t), "Header size mismatch"); // Written as the incomplete sentinel; finalizeBuild() patches it to @@ -135,6 +138,8 @@ void Section::writeSectionFileHeader(const ReaderRenderSpec& spec) { serialization::writePod(file, spec.embeddedStyle); serialization::writePod(file, spec.imageRendering); serialization::writePod(file, spec.focusReadingEnabled); + serialization::writePod(file, spec.characterSpacing); + serialization::writePod(file, spec.wordSpacingPercent); serialization::writePod(file, pageCount); // Placeholder for page count (will be initially 0, patched later) serialization::writePod(file, static_cast(0)); // Placeholder for LUT offset (patched later) serialization::writePod(file, static_cast(0)); // Placeholder for anchor map offset (patched later) @@ -171,6 +176,8 @@ bool Section::loadSectionFile(const ReaderRenderSpec& spec) { bool fileEmbeddedStyle; uint8_t fileImageRendering; bool fileFocusReadingEnabled; + int8_t fileCharacterSpacing; + uint8_t fileWordSpacingPercent; serialization::readPod(file, fileFontId); serialization::readPod(file, fileLineCompression); serialization::readPod(file, fileExtraParagraphSpacing); @@ -181,12 +188,15 @@ bool Section::loadSectionFile(const ReaderRenderSpec& spec) { serialization::readPod(file, fileEmbeddedStyle); serialization::readPod(file, fileImageRendering); serialization::readPod(file, fileFocusReadingEnabled); + serialization::readPod(file, fileCharacterSpacing); + serialization::readPod(file, fileWordSpacingPercent); if (spec.fontId != fileFontId || spec.lineCompression != fileLineCompression || spec.extraParagraphSpacing != fileExtraParagraphSpacing || spec.paragraphAlignment != fileParagraphAlignment || spec.viewportWidth != fileViewportWidth || spec.viewportHeight != fileViewportHeight || spec.hyphenationEnabled != fileHyphenationEnabled || spec.embeddedStyle != fileEmbeddedStyle || - spec.imageRendering != fileImageRendering || spec.focusReadingEnabled != fileFocusReadingEnabled) { + spec.imageRendering != fileImageRendering || spec.focusReadingEnabled != fileFocusReadingEnabled || + spec.characterSpacing != fileCharacterSpacing || spec.wordSpacingPercent != fileWordSpacingPercent) { file.close(); LOG_ERR("SCT", "Deserialization failed: Parameters do not match"); clearCache(); @@ -439,6 +449,7 @@ bool Section::startBuild(const ReaderRenderSpec& spec, const std::functionparser->setTextSpacing(spec.characterSpacing, spec.wordSpacingPercent); Hyphenator::setPreferredLanguage(epub->getLanguage()); build_ = std::move(ctx); diff --git a/lib/Epub/Epub/blocks/BlockStyle.h b/lib/Epub/Epub/blocks/BlockStyle.h index e7f4dbf465b..e68493d26ce 100644 --- a/lib/Epub/Epub/blocks/BlockStyle.h +++ b/lib/Epub/Epub/blocks/BlockStyle.h @@ -17,6 +17,8 @@ struct BlockStyle { CssTextAlign alignment = CssTextAlign::Justify; + // Extra pixels between glyphs during layout and drawing. + int8_t characterSpacing = 0; // Spacing (in pixels) int16_t marginTop = 0; int16_t marginBottom = 0; diff --git a/lib/Epub/Epub/blocks/TextBlock.cpp b/lib/Epub/Epub/blocks/TextBlock.cpp index f0c1acd73b2..cda40aabcd0 100644 --- a/lib/Epub/Epub/blocks/TextBlock.cpp +++ b/lib/Epub/Epub/blocks/TextBlock.cpp @@ -131,6 +131,7 @@ void TextBlock::render(const GfxRenderer& renderer, const int fontId, const int LOG_ERR("TXB", "Render skipped: invalid block"); return; } + const int8_t tracking = blockStyle.characterSpacing; const bool scanning = renderer.isFontCacheScanning(); const int ascender = renderer.getFontAscenderSize(fontId); @@ -154,9 +155,9 @@ void TextBlock::render(const GfxRenderer& renderer, const int fontId, const int } int groupActualWidth = 0; for (int k = 0; k < groupWordCount; ++k) { - groupActualWidth += renderer.getTextAdvanceX(fontId, wordText(i + k), wordStyle(i + k)); + groupActualWidth += renderer.getTextAdvanceX(fontId, wordText(i + k), wordStyle(i + k), tracking); } - const int rubyWidth = renderer.getTextAdvanceX(fontId, rubyTexts[i].c_str(), EpdFontFamily::SUP); + const int rubyWidth = renderer.getTextAdvanceX(fontId, rubyTexts[i].c_str(), EpdFontFamily::SUP, tracking); const int leaderWordX = xposArr[i] + x; const auto baseDir = static_cast(BidiUtils::detectParagraphLevel(wordText(i), blockStyle.isRtl ? 1 : 0)); @@ -237,19 +238,19 @@ void TextBlock::render(const GfxRenderer& renderer, const int fontId, const int std::min({static_cast(boundary), static_cast(wordTextLen(i)), sizeof(boldBuf) - 1}); memcpy(boldBuf, word, boldLen); boldBuf[boldLen] = '\0'; - renderer.drawText(fontId, drawX, wordY, boldBuf, true, boldStyle, baseDir); + renderer.drawText(fontId, drawX, wordY, boldBuf, true, boldStyle, baseDir, tracking); const int suffixX = drawX + focusSuffixXArr[i]; - renderer.drawText(fontId, suffixX, wordY, word + boldLen, true, currentStyle, baseDir); + renderer.drawText(fontId, suffixX, wordY, word + boldLen, true, currentStyle, baseDir, tracking); } else { - renderer.drawText(fontId, drawX, wordY, word, true, currentStyle, baseDir); + renderer.drawText(fontId, drawX, wordY, word, true, currentStyle, baseDir, tracking); } // Horizontal ruby text rendering if (blockHasRuby && i < rubyTexts.size() && !rubyTexts[i].empty() && (wordStyle(i) & EpdFontFamily::RUBY_CONTINUE) == 0) { const int rubyY = wordY - ascender; - renderer.drawText(fontId, rubies[i].x, rubyY, rubies[i].text.c_str(), true, EpdFontFamily::SUP, - rubies[i].baseDir); + renderer.drawText(fontId, rubies[i].x, rubyY, rubies[i].text.c_str(), true, EpdFontFamily::SUP, rubies[i].baseDir, + tracking); } if (scanning) { @@ -258,21 +259,17 @@ void TextBlock::render(const GfxRenderer& renderer, const int fontId, const int if (EpdFontFamily::hasTextDecoration(currentStyle)) { int lineStartX = drawX; - int lineWidth = renderer.getTextWidth(fontId, word, currentStyle, baseDir); - - if ((currentStyle & (EpdFontFamily::SUP | EpdFontFamily::SUB)) != 0) { - lineWidth = (lineWidth + 1) / 2; - } + int lineWidth = renderer.getTextAdvanceX(fontId, word, currentStyle, tracking, baseDir, + GfxRenderer::TextMeasureMode::Rendered); // Do not decorate the synthetic em-space used for paragraph indentation. if (wordTextLen(i) >= 3 && static_cast(word[0]) == 0xE2 && static_cast(word[1]) == 0x80 && static_cast(word[2]) == 0x83) { const char* visibleText = word + 3; - lineStartX += renderer.getTextAdvanceX(fontId, "\xe2\x80\x83", currentStyle); - lineWidth = renderer.getTextWidth(fontId, visibleText, currentStyle, baseDir); - if ((currentStyle & (EpdFontFamily::SUP | EpdFontFamily::SUB)) != 0) { - lineWidth = (lineWidth + 1) / 2; - } + lineStartX += renderer.getTextAdvanceX(fontId, "\xe2\x80\x83", currentStyle, tracking, baseDir, + GfxRenderer::TextMeasureMode::Rendered); + lineWidth = renderer.getTextAdvanceX(fontId, visibleText, currentStyle, tracking, baseDir, + GfxRenderer::TextMeasureMode::Rendered); } for (auto& line : decorationLines) { @@ -338,6 +335,7 @@ bool TextBlock::serialize(HalFile& file) const { serialization::writePod(file, blockStyle.textIndentDefined); serialization::writePod(file, blockStyle.isRtl); serialization::writePod(file, blockStyle.directionDefined); + serialization::writePod(file, blockStyle.characterSpacing); return true; } @@ -436,6 +434,7 @@ std::unique_ptr TextBlock::deserialize(HalFile& file) { serialization::readPod(file, blockStyle.textIndentDefined); serialization::readPod(file, blockStyle.isRtl); serialization::readPod(file, blockStyle.directionDefined); + serialization::readPod(file, blockStyle.characterSpacing); return block; } diff --git a/lib/Epub/Epub/parsers/ChapterHtmlSlimParser.cpp b/lib/Epub/Epub/parsers/ChapterHtmlSlimParser.cpp index 81d80e0d1bc..5ec38ecbeeb 100644 --- a/lib/Epub/Epub/parsers/ChapterHtmlSlimParser.cpp +++ b/lib/Epub/Epub/parsers/ChapterHtmlSlimParser.cpp @@ -596,14 +596,16 @@ void ChapterHtmlSlimParser::finishTableRow() { lines.reserve(MAX_GRID_TABLE_CELL_WORDS * 2); } tableRowCells[column]->layoutAndExtractLines( - renderer, fontId, textWidth, [this, &lines](std::unique_ptr line, const uint32_t offset) { + renderer, fontId, textWidth, + [this, &lines](std::unique_ptr line, const uint32_t offset) { const size_t lineIndex = lines.size(); lines.push_back(std::move(line)); if (tableLineVisibleOffsets.size() <= lineIndex) { tableLineVisibleOffsets.resize(lineIndex + 1, UINT32_MAX); } tableLineVisibleOffsets[lineIndex] = std::min(tableLineVisibleOffsets[lineIndex], offset); - }); + }, + true, characterSpacing, wordSpacingPercent); maxLineCount = std::max(maxLineCount, lines.size()); } tableRowCells.clear(); @@ -1754,7 +1756,7 @@ void XMLCALL ChapterHtmlSlimParser::characterData(void* userData, const XML_Char [self](std::unique_ptr textBlock, const uint32_t offset) { self->addLineToPage(std::move(textBlock), offset); }, - false); + false, self->characterSpacing, self->wordSpacingPercent); } } @@ -2221,10 +2223,12 @@ void ChapterHtmlSlimParser::makePages() { const uint16_t effectiveWidth = (horizontalInset < viewportWidth) ? static_cast(viewportWidth - horizontalInset) : viewportWidth; - currentTextBlock->layoutAndExtractLines(renderer, fontId, effectiveWidth, - [this](std::unique_ptr textBlock, const uint32_t offset) { - addLineToPage(std::move(textBlock), offset); - }); + currentTextBlock->layoutAndExtractLines( + renderer, fontId, effectiveWidth, + [this](std::unique_ptr textBlock, const uint32_t offset) { + addLineToPage(std::move(textBlock), offset); + }, + true, characterSpacing, wordSpacingPercent); // Fallback: transfer any remaining pending footnotes to current page. // Normally addLineToPage handles this via word-index tracking, but this catches diff --git a/lib/Epub/Epub/parsers/ChapterHtmlSlimParser.h b/lib/Epub/Epub/parsers/ChapterHtmlSlimParser.h index c7169e37b72..7c81055f829 100644 --- a/lib/Epub/Epub/parsers/ChapterHtmlSlimParser.h +++ b/lib/Epub/Epub/parsers/ChapterHtmlSlimParser.h @@ -50,6 +50,8 @@ class ChapterHtmlSlimParser { int fontId; float lineCompression; bool extraParagraphSpacing; + int8_t characterSpacing = 0; + uint8_t wordSpacingPercent = 100; uint8_t paragraphAlignment; uint16_t viewportWidth; uint16_t viewportHeight; @@ -210,6 +212,10 @@ class ChapterHtmlSlimParser { tocAnchors(std::move(tocAnchors)) {} ~ChapterHtmlSlimParser(); + void setTextSpacing(const int8_t character, const uint8_t wordPercent) { + characterSpacing = character; + wordSpacingPercent = wordPercent; + } // One-shot parse: builds every page before returning (begin + step* + finish). bool parseAndBuildPages(); diff --git a/lib/GfxRenderer/GfxRenderer.cpp b/lib/GfxRenderer/GfxRenderer.cpp index 8362917887f..c2056349929 100644 --- a/lib/GfxRenderer/GfxRenderer.cpp +++ b/lib/GfxRenderer/GfxRenderer.cpp @@ -15,6 +15,10 @@ #include "FontCacheManager.h" namespace { +constexpr int trackingBetween(const uint32_t leftCp, const uint32_t rightCp, const int8_t tracking) { + const auto isSpace = [](const uint32_t cp) { return cp == ' ' || cp == 0xA0 || cp == 0x3000; }; + return leftCp == 0 || isSpace(leftCp) || isSpace(rightCp) ? 0 : tracking; +} /** * Resolves the requested style to the best available style in the given SD card font. @@ -645,7 +649,8 @@ void GfxRenderer::drawCenteredText(const int fontId, const int y, const char* te } void GfxRenderer::drawText(const int fontId, const int x, const int y, const char* text, const bool black, - const EpdFontFamily::Style style, const BidiUtils::BidiBaseDir baseDir) const { + const EpdFontFamily::Style style, const BidiUtils::BidiBaseDir baseDir, + const int8_t tracking) const { // cannot draw a NULL / empty string if (text == nullptr || *text == '\0') { return; @@ -721,7 +726,7 @@ void GfxRenderer::drawText(const int fontId, const int x, const int y, const cha // where they fall on the line. if (prevCp != 0) { const auto kernFP = font.getKerning(prevCp, cp, style); // 4.4 fixed-point kern - lastBaseX += fp4::toPixel(prevAdvanceFP + kernFP); // snap 12.4 fixed-point to nearest pixel + lastBaseX += fp4::toPixel(prevAdvanceFP + kernFP) + trackingBetween(prevCp, cp, tracking); } const EpdGlyph* glyph = font.getGlyph(cp, style); @@ -2026,14 +2031,15 @@ int GfxRenderer::getSpaceAdvance(const int fontId, const uint32_t leftCp, const } int GfxRenderer::getKerning(const int fontId, const uint32_t leftCp, const uint32_t rightCp, - const EpdFontFamily::Style style) const { + const EpdFontFamily::Style style, const int8_t tracking) const { const auto fontIt = fontMap.find(fontId); if (fontIt == fontMap.end()) return 0; - const int kernFP = fontIt->second.getKerning(leftCp, rightCp, style); // 4.4 fixed-point - return fp4::toPixel(kernFP); // snap 4.4 fixed-point to nearest pixel + const int kernFP = fontIt->second.getKerning(leftCp, rightCp, style); // 4.4 fixed-point + return fp4::toPixel(kernFP) + trackingBetween(leftCp, rightCp, tracking); // snap 4.4 fixed-point to nearest pixel } -int GfxRenderer::getTextAdvanceX(const int fontId, const char* text, EpdFontFamily::Style style) const { +int GfxRenderer::getTextAdvanceX(const int fontId, const char* text, EpdFontFamily::Style style, const int8_t tracking, + const BidiUtils::BidiBaseDir baseDir, const TextMeasureMode mode) const { // Match the font drawText would use for CJK-bearing strings (see resolveTextFontId). const int resolvedFontId = resolveTextFontId(fontId, text, style); // Measure the exact codepoint stream drawText renders: bidi-reordered and @@ -2043,14 +2049,16 @@ int GfxRenderer::getTextAdvanceX(const int fontId, const char* text, EpdFontFami // lines come out wider than they draw — uneven word gaps and a ragged // right margin. std::string visual; - text = resolveVisualText(text, visual, BidiUtils::BidiBaseDir::AUTO); + text = resolveVisualText(text, visual, baseDir); // Advance table fast-path for SD card fonts during layout. // No kerning/ligature lookup — consistent with previous metadataOnly behavior // where kern/lig data was not loaded. auto sdIt = sdCardFonts_.find(resolvedFontId); - if (sdIt != sdCardFonts_.end() && sdIt->second->hasAdvanceTable()) { + if (mode == TextMeasureMode::Layout && sdIt != sdCardFonts_.end() && sdIt->second->hasAdvanceTable()) { int32_t widthFP = 0; + int trackingPx = 0; + uint32_t prevCp = 0; const bool isSupSub = (style & (EpdFontFamily::SUP | EpdFontFamily::SUB)) != 0; const uint8_t styleIdx = resolveSdCardStyle(*sdIt->second, style); const auto fontIt = fontMap.find(resolvedFontId); @@ -2065,13 +2073,17 @@ int GfxRenderer::getTextAdvanceX(const int fontId, const char* text, EpdFontFami continue; } int32_t advFP = sdIt->second->getAdvance(cp, styleIdx); - if (advFP == 0 && !utf8IsCombiningMark(cp)) { - const EpdGlyph* glyph = font.getGlyph(cp, style); - advFP = glyph ? glyph->advanceX : 0; + if (!utf8IsCombiningMark(cp)) { + if (advFP == 0) { + const EpdGlyph* glyph = font.getGlyph(cp, style); + advFP = glyph ? glyph->advanceX : 0; + } + trackingPx += trackingBetween(prevCp, cp, tracking); + prevCp = cp; } widthFP += isSupSub ? (advFP + 1) / 2 : advFP; } - return fp4::toPixel(widthFP); + return fp4::toPixel(widthFP) + trackingPx; } const auto fontIt = fontMap.find(resolvedFontId); @@ -2099,7 +2111,7 @@ int GfxRenderer::getTextAdvanceX(const int fontId, const char* text, EpdFontFami // matching drawText so measurement and rendering agree exactly. if (prevCp != 0) { const auto kernFP = font.getKerning(prevCp, cp, style); // 4.4 fixed-point kern - widthPx += fp4::toPixel(prevAdvanceFP + kernFP); // snap 12.4 fixed-point to nearest pixel + widthPx += fp4::toPixel(prevAdvanceFP + kernFP) + trackingBetween(prevCp, cp, tracking); } const EpdGlyph* glyph = font.getGlyph(cp, style); diff --git a/lib/GfxRenderer/GfxRenderer.h b/lib/GfxRenderer/GfxRenderer.h index c224e225dc9..b090361eebb 100644 --- a/lib/GfxRenderer/GfxRenderer.h +++ b/lib/GfxRenderer/GfxRenderer.h @@ -299,6 +299,8 @@ class GfxRenderer { void writeFramebufferRegion(int x, int y, int w, int h, const uint8_t* src); // Text + // Layout may use advance-only SD font tables; rendered measurement includes kerning and ligatures. + enum class TextMeasureMode { Layout, Rendered }; int getTextWidth(int fontId, const char* text, EpdFontFamily::Style style = EpdFontFamily::REGULAR, BidiUtils::BidiBaseDir baseDir = BidiUtils::BidiBaseDir::AUTO) const; void drawCenteredText(int fontId, int y, const char* text, bool black = true, @@ -306,15 +308,17 @@ class GfxRenderer { BidiUtils::BidiBaseDir baseDir = BidiUtils::BidiBaseDir::AUTO) const; void drawText(int fontId, int x, int y, const char* text, bool black = true, EpdFontFamily::Style style = EpdFontFamily::REGULAR, - BidiUtils::BidiBaseDir baseDir = BidiUtils::BidiBaseDir::AUTO) const; + BidiUtils::BidiBaseDir baseDir = BidiUtils::BidiBaseDir::AUTO, int8_t tracking = 0) const; int getSpaceWidth(int fontId, EpdFontFamily::Style style = EpdFontFamily::REGULAR) const; /// Returns the total inter-word advance: fp4::toPixel(spaceAdvance + kern(leftCp,' ') + kern(' ',rightCp)). /// Using a single snap avoids the +/-1 px rounding error that arises when space advance and kern are /// snapped separately and then added as integers. int getSpaceAdvance(int fontId, uint32_t leftCp, uint32_t rightCp, EpdFontFamily::Style style) const; - /// Returns the kerning adjustment between two adjacent codepoints. - int getKerning(int fontId, uint32_t leftCp, uint32_t rightCp, EpdFontFamily::Style style) const; - int getTextAdvanceX(int fontId, const char* text, EpdFontFamily::Style style) const; + /// Returns kerning plus optional tracking between two adjacent codepoints. + int getKerning(int fontId, uint32_t leftCp, uint32_t rightCp, EpdFontFamily::Style style, int8_t tracking = 0) const; + int getTextAdvanceX(int fontId, const char* text, EpdFontFamily::Style style, int8_t tracking = 0, + BidiUtils::BidiBaseDir baseDir = BidiUtils::BidiBaseDir::AUTO, + TextMeasureMode mode = TextMeasureMode::Layout) const; int getFontAscenderSize(int fontId) const; int getLineHeight(int fontId) const; int getLineHeight(int fontId, float compression) const; diff --git a/lib/I18n/translations/english.yaml b/lib/I18n/translations/english.yaml index 8beefe9fc8a..6b5ca820ae0 100644 --- a/lib/I18n/translations/english.yaml +++ b/lib/I18n/translations/english.yaml @@ -498,3 +498,18 @@ STR_HOME_BUTTON_TAP: "Tap" STR_HOME_BUTTON_DOUBLE_TAP: "Double Tap" STR_HOME_BUTTON_LONG_PRESS: "Long Press" STR_TOGGLE_FRONTLIGHT: "Toggle Frontlight" + +STR_WORD_SPACING: "Word spacing" +STR_CHARACTER_SPACING: "Character spacing" +STR_SPACING_MINUS_2: "-2 px" +STR_SPACING_MINUS_1: "-1 px" +STR_SPACING_ZERO: "0 px" +STR_SPACING_PLUS_1: "+1 px" +STR_SPACING_PLUS_2: "+2 px" +STR_SPACING_50_PERCENT: "50%" +STR_SPACING_75_PERCENT: "75%" +STR_SPACING_100_PERCENT: "100%" +STR_SPACING_125_PERCENT: "125%" +STR_SPACING_150_PERCENT: "150%" +STR_SPACING_175_PERCENT: "175%" +STR_SPACING_200_PERCENT: "200%" diff --git a/src/CrossPointSettings.cpp b/src/CrossPointSettings.cpp index 147cfa9c42c..917054cde32 100644 --- a/src/CrossPointSettings.cpp +++ b/src/CrossPointSettings.cpp @@ -275,6 +275,8 @@ ReaderRenderSpec CrossPointSettings::readerRenderSpec(const uint16_t viewportWid ReaderRenderSpec spec; spec.fontId = getReaderFontId(); spec.lineCompression = getReaderLineCompression(); + spec.characterSpacing = getCharacterSpacing(); + spec.wordSpacingPercent = wordSpacing; spec.extraParagraphSpacing = extraParagraphSpacing != 0; spec.paragraphAlignment = paragraphAlignment; spec.viewportWidth = viewportWidth; diff --git a/src/CrossPointSettings.h b/src/CrossPointSettings.h index 2750add9de1..d33133efe74 100644 --- a/src/CrossPointSettings.h +++ b/src/CrossPointSettings.h @@ -240,6 +240,13 @@ class CrossPointSettings : public PersistableStore { uint8_t clockHasBeenSynced = 0; // Text rendering settings uint8_t extraParagraphSpacing = 1; + static constexpr uint8_t WORD_SPACING_MIN = 50; + static constexpr uint8_t WORD_SPACING_MAX = 200; + static constexpr uint8_t WORD_SPACING_STEP = 25; + uint8_t wordSpacing = 100; // percent of the font's space advance + static constexpr uint8_t CHARACTER_SPACING_OFFSET = 2; // stored 0..4 maps to -2..+2 px + uint8_t characterSpacing = CHARACTER_SPACING_OFFSET; + int8_t getCharacterSpacing() const { return static_cast(characterSpacing - CHARACTER_SPACING_OFFSET); } uint8_t textAntiAliasing = 1; // Short power button click behaviour uint8_t shortPwrBtn = IGNORE; diff --git a/src/SettingsList.h b/src/SettingsList.h index bf40847461a..e49e423432d 100644 --- a/src/SettingsList.h +++ b/src/SettingsList.h @@ -264,6 +264,16 @@ inline std::vector getSettingsList(const SdCardFontRegistry* regist {StrId::STR_TIGHT, StrId::STR_NORMAL, StrId::STR_WIDE, StrId::STR_EXTRA_WIDE}, "lineSpacing", StrId::STR_CAT_READER) .withTextSettings(), + SettingInfo::Value(StrId::STR_WORD_SPACING, &CrossPointSettings::wordSpacing, + {CrossPointSettings::WORD_SPACING_MIN, CrossPointSettings::WORD_SPACING_MAX, + CrossPointSettings::WORD_SPACING_STEP}, + "wordSpacing", StrId::STR_CAT_READER) + .withTextSettings(), + SettingInfo::Enum(StrId::STR_CHARACTER_SPACING, &CrossPointSettings::characterSpacing, + {StrId::STR_SPACING_MINUS_2, StrId::STR_SPACING_MINUS_1, StrId::STR_SPACING_ZERO, + StrId::STR_SPACING_PLUS_1, StrId::STR_SPACING_PLUS_2}, + "characterSpacing", StrId::STR_CAT_READER) + .withTextSettings(), SettingInfo::Value(StrId::STR_SCREEN_MARGIN, &CrossPointSettings::screenMargin, {CrossPointSettings::SCREEN_MARGIN_MIN, CrossPointSettings::SCREEN_MARGIN_MAX, CrossPointSettings::SCREEN_MARGIN_STEP}, diff --git a/src/activities/settings/TextSettingsActivity.cpp b/src/activities/settings/TextSettingsActivity.cpp index 936272d6299..b24ac93465c 100644 --- a/src/activities/settings/TextSettingsActivity.cpp +++ b/src/activities/settings/TextSettingsActivity.cpp @@ -24,8 +24,9 @@ namespace { // Tab labels for Font | Size | Layout | Style. constexpr StrId TAB_NAME_IDS[] = {StrId::STR_FONT, StrId::STR_SIZE, StrId::STR_LAYOUT, StrId::STR_STYLE}; -constexpr StrId LAYOUT_ROW_NAME_IDS[] = {StrId::STR_LINE_SPACING, StrId::STR_EXTRA_SPACING, StrId::STR_ALIGNMENT, - StrId::STR_SCREEN_MARGIN}; +constexpr StrId LAYOUT_ROW_NAME_IDS[] = {StrId::STR_LINE_SPACING, StrId::STR_WORD_SPACING, + StrId::STR_CHARACTER_SPACING, StrId::STR_EXTRA_SPACING, + StrId::STR_ALIGNMENT, StrId::STR_SCREEN_MARGIN}; constexpr StrId STYLE_ROW_NAME_IDS[] = {StrId::STR_FOCUS_READING, StrId::STR_HYPHENATION, StrId::STR_EMBEDDED_STYLE, StrId::STR_TEXT_AA}; @@ -44,11 +45,22 @@ int findCurrentFontIndex(const SdCardFontRegistry* registry, const char* sdFontF } constexpr StrId LINE_SPACING_IDS[] = {StrId::STR_TIGHT, StrId::STR_NORMAL, StrId::STR_WIDE, StrId::STR_EXTRA_WIDE}; +constexpr StrId WORD_SPACING_IDS[] = {StrId::STR_SPACING_50_PERCENT, StrId::STR_SPACING_75_PERCENT, + StrId::STR_SPACING_100_PERCENT, StrId::STR_SPACING_125_PERCENT, + StrId::STR_SPACING_150_PERCENT, StrId::STR_SPACING_175_PERCENT, + StrId::STR_SPACING_200_PERCENT}; +constexpr StrId CHARACTER_SPACING_IDS[] = {StrId::STR_SPACING_MINUS_2, StrId::STR_SPACING_MINUS_1, + StrId::STR_SPACING_ZERO, StrId::STR_SPACING_PLUS_1, + StrId::STR_SPACING_PLUS_2}; constexpr StrId ALIGNMENT_IDS[] = {StrId::STR_JUSTIFY, StrId::STR_ALIGN_LEFT, StrId::STR_CENTER, StrId::STR_ALIGN_RIGHT, StrId::STR_BOOK_S_STYLE}; constexpr int MARGIN_MIN = CrossPointSettings::SCREEN_MARGIN_MIN; constexpr int MARGIN_MAX = CrossPointSettings::SCREEN_MARGIN_MAX; constexpr int MARGIN_STEP = CrossPointSettings::SCREEN_MARGIN_STEP; +constexpr int WORD_SPACING_MIN = CrossPointSettings::WORD_SPACING_MIN; +constexpr int WORD_SPACING_MAX = CrossPointSettings::WORD_SPACING_MAX; +constexpr int WORD_SPACING_STEP = CrossPointSettings::WORD_SPACING_STEP; +static_assert(std::size(WORD_SPACING_IDS) == (WORD_SPACING_MAX - WORD_SPACING_MIN) / WORD_SPACING_STEP + 1); } // namespace TextSettingsActivity::TextSettingsActivity(GfxRenderer& renderer, MappedInputManager& mappedInput, @@ -391,6 +403,25 @@ void TextSettingsActivity::confirmLayoutRow(int row) { }); requestUpdate(); break; + case LayoutRow::WordSpacing: { + const int cur = (std::clamp(SETTINGS.wordSpacing, WORD_SPACING_MIN, WORD_SPACING_MAX) - WORD_SPACING_MIN) / + WORD_SPACING_STEP; + optionPopup_.show(StrId::STR_WORD_SPACING, WORD_SPACING_IDS, static_cast(std::size(WORD_SPACING_IDS)), cur, + [](int idx) { + SETTINGS.wordSpacing = static_cast(WORD_SPACING_MIN + idx * WORD_SPACING_STEP); + SETTINGS.saveToFile(); + }); + requestUpdate(); + break; + } + case LayoutRow::CharacterSpacing: + optionPopup_.show(StrId::STR_CHARACTER_SPACING, CHARACTER_SPACING_IDS, + static_cast(std::size(CHARACTER_SPACING_IDS)), SETTINGS.characterSpacing, [](int idx) { + SETTINGS.characterSpacing = static_cast(idx); + SETTINGS.saveToFile(); + }); + requestUpdate(); + break; case LayoutRow::ScreenMargin: { std::vector options; options.reserve((MARGIN_MAX - MARGIN_MIN) / MARGIN_STEP + 1); @@ -421,6 +452,13 @@ std::string TextSettingsActivity::layoutValueText(int row) const { const uint8_t v = SETTINGS.paragraphAlignment; return v < std::size(ALIGNMENT_IDS) ? I18N.get(ALIGNMENT_IDS[v]) : I18N.get(StrId::STR_JUSTIFY); } + case LayoutRow::WordSpacing: + return std::to_string(SETTINGS.wordSpacing) + "%"; + case LayoutRow::CharacterSpacing: { + const uint8_t v = SETTINGS.characterSpacing; + return v < std::size(CHARACTER_SPACING_IDS) ? I18N.get(CHARACTER_SPACING_IDS[v]) + : I18N.get(StrId::STR_SPACING_ZERO); + } case LayoutRow::ScreenMargin: return std::to_string(SETTINGS.screenMargin); diff --git a/src/activities/settings/TextSettingsActivity.h b/src/activities/settings/TextSettingsActivity.h index d226967128b..38db5c5ae67 100644 --- a/src/activities/settings/TextSettingsActivity.h +++ b/src/activities/settings/TextSettingsActivity.h @@ -29,7 +29,7 @@ class TextSettingsActivity final : public UiTabListActivity { private: // Row indices per tab. enum class (not plain enum) so a LayoutRow can't be // silently confused with a StyleRow of equal value. - enum class LayoutRow { LineSpacing, ParaSpacing, Alignment, ScreenMargin, Count }; + enum class LayoutRow { LineSpacing, WordSpacing, CharacterSpacing, ParaSpacing, Alignment, ScreenMargin, Count }; enum class StyleRow { FocusReading, Hyphenation, EmbeddedStyle, AntiAliasing, Count }; // --- UiTabListActivity contract --- diff --git a/src/activities/settings/TextSettingsPreview.cpp b/src/activities/settings/TextSettingsPreview.cpp index 1a4491c3cd2..95621974e02 100644 --- a/src/activities/settings/TextSettingsPreview.cpp +++ b/src/activities/settings/TextSettingsPreview.cpp @@ -57,7 +57,8 @@ void relayout(PreviewLayout& layout, const GfxRenderer& renderer, int fontId, in parsed.layoutAndExtractLines( renderer, fontId, static_cast(textWidth), - [&layout](std::unique_ptr line, uint32_t) { layout.lines.push_back(std::move(line)); }); + [&layout](std::unique_ptr line, uint32_t) { layout.lines.push_back(std::move(line)); }, true, + SETTINGS.getCharacterSpacing(), SETTINGS.wordSpacing); } } // namespace @@ -103,6 +104,8 @@ void renderPreview(const GfxRenderer& renderer, PreviewLayout& layout, int previ .lineCompression = compression, .alignment = SETTINGS.paragraphAlignment, .extraParagraphSpacing = SETTINGS.extraParagraphSpacing != 0, + .characterSpacing = SETTINGS.getCharacterSpacing(), + .wordSpacingPercent = SETTINGS.wordSpacing, .focusReading = SETTINGS.focusReadingEnabled != 0, .hyphenation = SETTINGS.hyphenationEnabled != 0}; if (key != layout.key) { diff --git a/src/activities/settings/TextSettingsPreview.h b/src/activities/settings/TextSettingsPreview.h index f350a7f124a..f931c7748ec 100644 --- a/src/activities/settings/TextSettingsPreview.h +++ b/src/activities/settings/TextSettingsPreview.h @@ -18,6 +18,8 @@ struct PreviewKey { float lineCompression = -1.0f; uint8_t alignment = 0xFF; bool extraParagraphSpacing = false; + int8_t characterSpacing = 0; + uint8_t wordSpacingPercent = 100; bool focusReading = false; bool hyphenation = false; bool operator==(const PreviewKey&) const = default; diff --git a/src/util/DictHtmlPages.cpp b/src/util/DictHtmlPages.cpp index 59f416918ae..6795016cb72 100644 --- a/src/util/DictHtmlPages.cpp +++ b/src/util/DictHtmlPages.cpp @@ -275,6 +275,7 @@ bool buildDictionaryHtmlPages(GfxRenderer& renderer, const std::string& definiti if (!parser) { LOG_ERR("DHTML", "OOM: ChapterHtmlSlimParser"); } else { + parser->setTextSpacing(SETTINGS.getCharacterSpacing(), SETTINGS.wordSpacing); ok = parser->parseAndBuildPages(); // closes the file on both outcomes } } diff --git a/test/chapter_html_slim_parser/ChapterHtmlSlimParserTest.cpp b/test/chapter_html_slim_parser/ChapterHtmlSlimParserTest.cpp index d016f0bb56c..11b3d1fac06 100644 --- a/test/chapter_html_slim_parser/ChapterHtmlSlimParserTest.cpp +++ b/test/chapter_html_slim_parser/ChapterHtmlSlimParserTest.cpp @@ -199,3 +199,109 @@ TEST_F(ChapterHtmlSlimParserTest, DivWithHiddenAttributeContentShouldBeSkipped) } } // namespace + +TEST(TextSpacingLayout, TrackingSeparatesCjkTokensAndScalesWordSpaces) { + GfxRenderer renderer; + for (bool hyphenation : {false, true}) { + BlockStyle style; + style.alignment = CssTextAlign::Left; + style.textIndentDefined = true; + ParsedText text(false, hyphenation, false, style); + text.addWord("가나다", EpdFontFamily::REGULAR); + text.addWord("라마", EpdFontFamily::REGULAR); + unsigned lines = 0; + text.layoutAndExtractLines( + renderer, 0, 200, + [&](std::unique_ptr line, auto) { + ++lines; + ASSERT_EQ(line->wordCount(), 5); + EXPECT_EQ(line->wordXpos(0), 0); + EXPECT_EQ(line->wordXpos(1), 7); // 8 px syllable, -1 px tracking + EXPECT_EQ(line->wordXpos(2), 14); + EXPECT_EQ(line->wordXpos(3), 28); // 8 px syllable plus 150% of a 4 px space, no tracking + EXPECT_EQ(line->wordXpos(4), 35); + }, + true, -1, 150); + EXPECT_EQ(lines, 1u); + } + EXPECT_EQ(renderer.getTextAdvanceX(0, "ab", EpdFontFamily::REGULAR), 16); + EXPECT_EQ(renderer.getSpaceWidth(0, EpdFontFamily::REGULAR), 4); +} + +TEST(TextSpacingLayout, WordSpacingChangesWrapThreshold) { + GfxRenderer renderer; + for (uint8_t percent : {50, 100, 125, 200}) { + BlockStyle style; + style.alignment = CssTextAlign::Left; + style.textIndentDefined = true; + ParsedText text(false, false, false, style); + text.addWord("ab", EpdFontFamily::REGULAR); + text.addWord("cd", EpdFontFamily::REGULAR); + unsigned lines = 0; + text.layoutAndExtractLines(renderer, 0, 36, [&](std::unique_ptr, auto) { ++lines; }, true, 0, percent); + EXPECT_EQ(lines, percent > 100 ? 2u : 1u); // 16 + 16 + scaled 4 px space + } +} + +TEST(TextSpacingLayout, CachedPageRestoresSpacing) { + GfxRenderer renderer; + BlockStyle style; + style.alignment = CssTextAlign::Left; + style.textIndentDefined = true; + ParsedText text(false, false, false, style); + text.addWord("가나다", EpdFontFamily::REGULAR); + text.addWord("라마", EpdFontFamily::REGULAR); + const auto path = (std::filesystem::temp_directory_path() / "crosspoint-text-spacing.bin").string(); + unsigned lines = 0; + text.layoutAndExtractLines( + renderer, 0, 200, + [&](std::unique_ptr line, auto) { + ++lines; + Page page; + page.elements.push_back(std::make_unique(std::move(line), 4, 12)); + const auto* original = static_cast(*page.elements[0]).getBlock(); + { + HalFile file; + ASSERT_TRUE(file.open(path.c_str(), "wb")); + ASSERT_TRUE(page.serialize(file)); + } + HalFile file; + ASSERT_TRUE(file.open(path.c_str(), "rb")); + auto cachedPage = Page::deserialize(file); + ASSERT_NE(cachedPage, nullptr); + ASSERT_EQ(cachedPage->elements.size(), 1); + const auto* cached = static_cast(*cachedPage->elements[0]).getBlock(); + ASSERT_NE(cached, nullptr); + EXPECT_EQ(cached->getBlockStyle().characterSpacing, -2); + ASSERT_EQ(cached->wordCount(), 5); + EXPECT_EQ(cached->wordXpos(3) - cached->wordXpos(2), 10); // 8 + half-width space + EXPECT_EQ(file.position(), file.size()); + ASSERT_EQ(cached->wordCount(), original->wordCount()); + for (uint16_t i = 0; i < original->wordCount(); ++i) EXPECT_EQ(cached->wordXpos(i), original->wordXpos(i)); + }, + true, -2, 50); + EXPECT_EQ(lines, 1u); + std::filesystem::remove(path); +} + +TEST_F(ChapterHtmlSlimParserTest, ParserAppliesTextSpacingToParagraphs) { + parser.setTextSpacing(-1, 150); + parser.beginParse(); + ChapterHtmlSlimParser::startElement(&parser, "p", nullptr); + const std::string text = "\xea\xb0\x80\xeb\x82\x98\xeb\x8b\xa4 \xeb\x9d\xbc\xeb\xa7\x88"; // 가나다 라마 + ChapterHtmlSlimParser::characterData(&parser, text.c_str(), static_cast(text.size())); + ChapterHtmlSlimParser::endElement(&parser, "p"); + parser.makePages(); + ASSERT_NE(parser.currentPage, nullptr); + unsigned lines = 0; + for (const auto& element : parser.currentPage->elements) { + if (element->getTag() != TAG_PageLine) continue; + const auto& block = *static_cast(*element).getBlock(); + ++lines; + ASSERT_EQ(block.wordCount(), 5); + EXPECT_EQ(block.getBlockStyle().characterSpacing, -1); + EXPECT_EQ(block.wordXpos(1) - block.wordXpos(0), 7); // 8 px syllable, -1 px tracking + EXPECT_EQ(block.wordXpos(3) - block.wordXpos(2), 14); // syllable plus 150% of a 4 px space + } + EXPECT_EQ(lines, 1u); +} diff --git a/test/chapter_html_slim_parser/stubs/GfxRenderer.h b/test/chapter_html_slim_parser/stubs/GfxRenderer.h index 316ca87361f..d47edf7aa40 100644 --- a/test/chapter_html_slim_parser/stubs/GfxRenderer.h +++ b/test/chapter_html_slim_parser/stubs/GfxRenderer.h @@ -1,6 +1,7 @@ #pragma once #include +#include #include #include @@ -11,10 +12,15 @@ enum class BidiBaseDir : signed char { AUTO = -1, LTR = 0, RTL = 1 }; class GfxRenderer { public: + enum class TextMeasureMode { Layout, Rendered }; + // Fixture metrics: every glyph is 8 px wide, a space is 4 px, kerning is zero. + static int trackingBetween(uint32_t left, uint32_t right, int8_t tracking) { + return left == 0 || left == ' ' || right == ' ' ? 0 : tracking; + } bool isFontCacheScanning() const { return false; } void drawLine(int, int, int, int, int, bool) const {} void drawText(int, int, int, const char*, bool, EpdFontFamily::Style, - BidiUtils::BidiBaseDir = BidiUtils::BidiBaseDir::AUTO) const {} + BidiUtils::BidiBaseDir = BidiUtils::BidiBaseDir::AUTO, int8_t = 0) const {} int getTextWidth(int font, const char* text, EpdFontFamily::Style style, BidiUtils::BidiBaseDir = BidiUtils::BidiBaseDir::AUTO) const { return getTextAdvanceX(font, text, style); @@ -24,12 +30,21 @@ class GfxRenderer { int getLineHeight(int, float = 1.0f) const { return 16; } int getFontAscenderSize(int) const { return 12; } int getSpaceWidth(int, EpdFontFamily::Style) const { return 4; } - int getTextAdvanceX(int, const char* text, EpdFontFamily::Style) const { + int getTextAdvanceX(int, const char* text, EpdFontFamily::Style, int8_t tracking = 0, + BidiUtils::BidiBaseDir = BidiUtils::BidiBaseDir::AUTO, + TextMeasureMode = TextMeasureMode::Layout) const { int width = 0; - while (*text++) width += 8; + uint32_t previous = 0; + while (const uint32_t cp = utf8NextCodepoint(reinterpret_cast(&text))) { + if (utf8IsCombiningMark(cp)) continue; + width += 8 + trackingBetween(previous, cp, tracking); + previous = cp; + } return width; } - int getKerning(int, uint32_t, uint32_t, EpdFontFamily::Style) const { return 0; } + int getKerning(int, uint32_t left, uint32_t right, EpdFontFamily::Style, int8_t tracking = 0) const { + return trackingBetween(left, right, tracking); + } int getSpaceAdvance(int, uint32_t, uint32_t, EpdFontFamily::Style) const { return 4; } bool isSdCardFont(int) const { return false; } void ensureSdCardFontReady(int, const std::deque&, bool, uint8_t) const {} From 10d0aa1ecd4e891de48860c855c7f90c736fe64e Mon Sep 17 00:00:00 2001 From: Uri Tauber Date: Mon, 21 Sep 2026 22:28:58 +0300 Subject: [PATCH 02/62] feat: refresh library (#3608) --- freeink-sdk | 2 +- lib/I18n/translations/english.yaml | 5 +- lib/LibraryIndex/LibraryBuilder.cpp | 31 ++++ lib/LibraryIndex/LibraryBuilder.h | 5 + .../browser/OpdsBookBrowserActivity.cpp | 2 + .../library/LibraryListActivity.cpp | 164 ++++++++++++++---- src/activities/library/LibraryListActivity.h | 18 ++ src/activities/settings/SettingsActivity.cpp | 27 --- src/activities/settings/SettingsActivity.h | 2 - src/components/icons/listIcons.h | 19 +- src/components/icons/listIcons.manifest | 1 + src/network/CrossPointWebServer.cpp | 11 ++ 12 files changed, 220 insertions(+), 67 deletions(-) diff --git a/freeink-sdk b/freeink-sdk index ffcbb1c7a38..13418e0986b 160000 --- a/freeink-sdk +++ b/freeink-sdk @@ -1 +1 @@ -Subproject commit ffcbb1c7a38c357c4a37deb68c0c6dceadc50768 +Subproject commit 13418e0986b05039bf056e050a6df5305d47c209 diff --git a/lib/I18n/translations/english.yaml b/lib/I18n/translations/english.yaml index 6b5ca820ae0..15cd8b13ba1 100644 --- a/lib/I18n/translations/english.yaml +++ b/lib/I18n/translations/english.yaml @@ -149,10 +149,11 @@ STR_LIBRARY_TAB_AUTHOR: "Author" STR_LIBRARY_SEARCH: "Search" STR_LIBRARY_HOLD_SORT: "Hold: Reverse sort" STR_LIBRARY_HOLD_GROUPS: "Hold: Groups" +STR_LIBRARY_HOLD_OPTIONS: "Hold: Options" STR_LIBRARY_POSITION: "%d/%d books" STR_LIBRARY_GROUP_POSITION: "%d/%d groups" STR_LIBRARY_USE_METADATA: "Use Book Metadata" -STR_LIBRARY_REBUILD: "Rebuild Library Index" +STR_LIBRARY_REBUILD: "Refresh library" STR_LIBRARY_REBUILDING: "Reading your books..." STR_LIBRARY_REBUILD_DONE: "Library index rebuilt" STR_LIBRARY_REBUILD_FAILED: "Could not rebuild the index" @@ -363,7 +364,7 @@ STR_COVER_CUSTOM: "Cover + Custom" STR_QUICK_RESUME: "Quick Resume" STR_TRANSPARENT: "Transparent" STR_MENU_RECENT_BOOKS: "Recent Books" -STR_REMOVE_FROM_RECENTS: "Remove from Recent Books?" +STR_REMOVE_FROM_RECENTS: "Remove from Recents" STR_CALIBRE_DESC: "Use Calibre wireless device transfers" STR_FORGET_AND_REMOVE: "Forget network and remove saved password?" STR_FORGET_BUTTON: "Forget" diff --git a/lib/LibraryIndex/LibraryBuilder.cpp b/lib/LibraryIndex/LibraryBuilder.cpp index 0000766513d..16538c14f6f 100644 --- a/lib/LibraryIndex/LibraryBuilder.cpp +++ b/lib/LibraryIndex/LibraryBuilder.cpp @@ -23,7 +23,11 @@ constexpr char INDEX_PATH[] = "/.crosspoint/library.idx"; constexpr char NEW_PATH[] = "/.crosspoint/library.new"; constexpr char BACKUP_PATH[] = "/.crosspoint/library.bak"; constexpr char STAGE_PATH[] = "/.crosspoint/library.stage"; +constexpr char DIRTY_PATH[] = "/.crosspoint/library.dirty"; constexpr char CACHE_DIR[] = "/.crosspoint"; +// Sticky fallback when the marker could not be persisted (card unavailable at +// write time): callers must still see the index as stale until a rebuild clears it. +bool dirtyInMemory = false; constexpr size_t LIBRARY_IO_BUFFER_SIZE = 4096; // Matches lib/FileIndex's buffer so a name this walk accepts is one the file @@ -174,6 +178,13 @@ bool installNewIndex() { return true; } +void clearLibraryIndexDirty() { + dirtyInMemory = false; + if (Storage.exists(DIRTY_PATH) && !Storage.remove(DIRTY_PATH)) { + LOG_ERR("LIBIDX", "cannot clear dirty marker"); + } +} + bool isBookName(const std::string& name) { return FsHelpers::checkFileExtension(name, ".epub") || FsHelpers::checkFileExtension(name, ".txt") || FsHelpers::checkFileExtension(name, ".md") || FsHelpers::checkFileExtension(name, ".xtc"); @@ -1032,6 +1043,24 @@ bool emitIndex(const char* folderStagePath, WalkState& st, const uint16_t* order const char* libraryIndexPath() { return INDEX_PATH; } +bool markLibraryIndexDirty() { + if (Storage.exists(DIRTY_PATH)) return true; + if (!Storage.exists(CACHE_DIR) && !Storage.mkdir(CACHE_DIR)) { + LOG_ERR("LIBIDX", "cannot create cache directory for dirty marker"); + dirtyInMemory = true; // not persisted: stay dirty until the next rebuild + return true; + } + HalFile marker; + if (!Storage.openFileForWrite("LIBIDX", DIRTY_PATH, marker)) { + LOG_ERR("LIBIDX", "cannot create dirty marker"); + dirtyInMemory = true; + return true; + } + return true; +} + +bool isLibraryIndexDirty() { return dirtyInMemory || Storage.exists(DIRTY_PATH); } + bool buildLibraryIndex(const char* rootPath, BuildStats& stats, const bool readMetadata) { const uint32_t startMs = millis(); uint32_t serviceUnits = 0; @@ -1160,6 +1189,7 @@ bool buildLibraryIndex(const char* rootPath, BuildStats& stats, const bool readM LOG_INF("LIBIDX", "unchanged: %u reused, %u parsed, no replacement, %ums", static_cast(stats.metadataReused), static_cast(stats.parsed), static_cast(stats.walkMs)); + clearLibraryIndexDirty(); return true; } @@ -1342,6 +1372,7 @@ bool buildLibraryIndex(const char* rootPath, BuildStats& stats, const bool readM static_cast(stats.parsed), static_cast(stats.metadataReused), static_cast(stats.indexReplaced), static_cast(stats.duplicatesDropped), static_cast(stats.unreadableSkipped), static_cast(stats.walkMs)); + if (ok) clearLibraryIndexDirty(); return ok; } diff --git a/lib/LibraryIndex/LibraryBuilder.h b/lib/LibraryIndex/LibraryBuilder.h index a2f5dbfaa42..38b2c66bbeb 100644 --- a/lib/LibraryIndex/LibraryBuilder.h +++ b/lib/LibraryIndex/LibraryBuilder.h @@ -70,4 +70,9 @@ bool buildLibraryIndex(const char* rootPath, BuildStats& stats, bool readMetadat // Live index path, shared by the builder and activity. const char* libraryIndexPath(); +// A successful book transfer marks the retained index stale. The next Library +// entry rebuilds it through the normal reconciliation path. +bool markLibraryIndexDirty(); +bool isLibraryIndexDirty(); + } // namespace library diff --git a/src/activities/browser/OpdsBookBrowserActivity.cpp b/src/activities/browser/OpdsBookBrowserActivity.cpp index ae710d0c8ad..762e4cf7c7f 100644 --- a/src/activities/browser/OpdsBookBrowserActivity.cpp +++ b/src/activities/browser/OpdsBookBrowserActivity.cpp @@ -6,6 +6,7 @@ #include #include #include +#include #include #include #include @@ -552,6 +553,7 @@ void OpdsBookBrowserActivity::downloadBook(const OpdsEntry& book) { if (result == HttpDownloader::OK) { clearBookCache(filename); + library::markLibraryIndexDirty(); state = BrowserState::LOADING; statusMessage = tr(STR_LOADING); fetchFeed(currentPath); diff --git a/src/activities/library/LibraryListActivity.cpp b/src/activities/library/LibraryListActivity.cpp index f5f3d93b690..1973fc651f5 100644 --- a/src/activities/library/LibraryListActivity.cpp +++ b/src/activities/library/LibraryListActivity.cpp @@ -20,6 +20,7 @@ #include "activities/util/KeyboardEntryActivity.h" #include "components/UIScale.h" #include "components/UITheme.h" +#include "components/icons/listIcons.h" #include "components/icons/search32.h" #include "fontIds.h" #include "util/BookCacheUtils.h" @@ -79,6 +80,7 @@ void LibraryListActivity::onEnter() { RenderLock lock(*this); UiTabListActivity::onEnter(); app.on(ACTION_SEARCH, &LibraryListActivity::searchActionTrampoline, this); + app.on(ACTION_REBUILD, &LibraryListActivity::rebuildActionTrampoline, this); // Recent is backed by the resident store. Prune before opening the index so // its persistence write never overlaps the long-lived index reader. @@ -87,7 +89,8 @@ void LibraryListActivity::onEnter() { // Rebuild when the index is missing, invalid, or was built with the other // metadata mode. Otherwise entering the screen stays instant. const bool readMetadata = SETTINGS.libraryUseMetadata != 0; - const bool rebuildNeeded = !index.open(library::libraryIndexPath()) || index.header().metadataEnabled != readMetadata; + const bool rebuildNeeded = library::isLibraryIndexDirty() || !index.open(library::libraryIndexPath()) || + index.header().metadataEnabled != readMetadata; if (rebuildNeeded) { index.close(); GUI.drawPopup(renderer, tr(STR_LIBRARY_REBUILDING)); @@ -202,11 +205,15 @@ void LibraryListActivity::openSelectedBook() { return; } } - // The reader screen this opens has its own surfaces; a lingering tap flash - // would gray an unrelated element there. + openBookByPath(path); +} + +// Shared by row activation and the options menu: the reader screen this opens +// has its own surfaces; a lingering tap flash would gray an unrelated element +// there. The index handle is released first — on hardware only one reader can +// hold a file open at a time, and the reader is about to open files of its own. +void LibraryListActivity::openBookByPath(const std::string& path) { app.clearTapFlash(); - // Release the index handle first: on hardware only one reader can hold a file - // open at a time, and the reader is about to open files of its own. index.close(); onSelectBook(path); } @@ -220,18 +227,14 @@ void LibraryListActivity::activateIndex(const int index) { } // Row long-press prompts delete wherever grouping does not own the gesture: -// the Recent sort has no groups, and an active search is already a flat list -// the reader narrowed down on purpose ("find it, hold it, delete it"). -// Unfiltered Title/Author lists keep collapse-to-groups. Pinned rows are the -// exception: holding one offers remove-from-recents, as the old Recent tab -// did. +// an active search is already a flat list the reader narrowed down on purpose +// ("find it, hold it, delete it"). Unfiltered Title/Author lists keep +// collapse-to-groups. The Recent shelf always opens the row options menu. bool LibraryListActivity::deleteEligible() const { return !groupsCollapsed && (!query.empty() || !groupable()); } void LibraryListActivity::onRowLongPress(const int index) { - if (index < pinnedCount()) { - const auto& books = RECENT_BOOKS.getBooks(); - if (index < 0 || index >= static_cast(books.size())) return; - promptRemoveRecentBook(books[static_cast(index)].path, books[static_cast(index)].title); + if (isRecentSort(sortOrder)) { + showRecentBookOptions(index); } else if (deleteEligible()) { promptDeleteBook(index); } else if (!groupsCollapsed && groupable()) { @@ -241,6 +244,92 @@ void LibraryListActivity::onRowLongPress(const int index) { } } +// Recent-shelf long-press menu (button hold and touch long-press). The first +// rows may come from RecentBooksStore; the rest are index rows sorted by +// modification time. Only store rows can be removed from recents. +void LibraryListActivity::showRecentBookOptions(const int entry) { + if (entry < 0 || entry >= listCount()) return; + + std::string path; + std::string title; + const bool isStoreRow = entry < pinnedCount(); + if (isStoreRow) { + const auto& books = RECENT_BOOKS.getBooks(); + if (entry >= static_cast(books.size())) return; + path = books[static_cast(entry)].path; + title = books[static_cast(entry)].title; + } else { + if (!index.isOpen()) return; + const uint16_t ordinal = index.ordinalForRow(sortOrder, static_cast(rowFor(entry))); + library::ClixRecord record{}; + std::string author; + if (ordinal == 0xFFFF || !index.readRecord(ordinal, record) || !index.readPath(record, path) || + !rowTextFor(entry, title, author)) { + LOG_ERR("LIB", "cannot resolve Recent row %d", entry); + return; + } + } + + const char* STORE_OPTIONS[] = {tr(STR_OPEN), tr(STR_REMOVE_FROM_RECENTS), tr(STR_DELETE), tr(STR_LIBRARY_REBUILD)}; + const char* INDEX_OPTIONS[] = {tr(STR_OPEN), tr(STR_DELETE), tr(STR_LIBRARY_REBUILD)}; + app.clearTapFlash(); + optionPopup.show(tr(STR_LIBRARY), title.c_str(), isStoreRow ? STORE_OPTIONS : INDEX_OPTIONS, isStoreRow ? 4 : 3, 0, + [this, path, title, isStoreRow](const int choice) { + swallowHeldReleases(); + switch (choice) { + case 0: + openBookByPath(path); + break; + case 1: + if (isStoreRow) { + promptRemoveRecentBook(path, title); + } else { + promptDeleteBookByPath(path, title); + } + break; + case 2: + if (isStoreRow) + promptDeleteBookByPath(path, title); + else + promptRebuildIndex(); + break; + case 3: + if (isStoreRow) promptRebuildIndex(); + break; + default: + break; + } + }); + requestUpdate(); +} + +// Manual index refresh, same card discipline as the onEnter rebuild: the walk +// wants the card to itself, and the render task must not read the index (or +// the filter) around it. +void LibraryListActivity::promptRebuildIndex() { + RenderLock lock(*this); + GUI.drawPopup(renderer, tr(STR_LIBRARY_REBUILDING)); + index.close(); + rebuildIndex(); + if (!index.open(library::libraryIndexPath())) LOG_ERR("LIB", "cannot open library index"); + resetAfterRebuild(); + requestUpdate(true); +} + +void LibraryListActivity::resetAfterRebuild() { + // Sort positions, group starts, and pinned rows all point into the old order. + applyFilter(); + resolvePinned(); + auto& nav = activeNav(); + const int count = listCount(); + if (count == 0) { + nav.selected = 0; + } else if (nav.selected > count) { + nav.selected = count; + } + nav.followOnBuild = true; +} + void LibraryListActivity::promptRemoveRecentBook(const std::string& path, const std::string& title) { const bool reopenIndex = index.isOpen(); index.close(); @@ -284,7 +373,10 @@ void LibraryListActivity::promptDeleteBook(const int entry) { std::string title; std::string author; rowTextFor(entry, title, author); + promptDeleteBookByPath(path, title); +} +void LibraryListActivity::promptDeleteBookByPath(const std::string& path, const std::string& title) { // The dialog and the delete both want the card; reopen when we resume. index.close(); auto confirmation = @@ -312,18 +404,7 @@ void LibraryListActivity::promptDeleteBook(const int entry) { } if (!index.open(library::libraryIndexPath())) LOG_ERR("LIB", "cannot reopen library index"); if (!result.isCancelled) { - // Search positions, group starts, and pinned rows point into the old - // order. - applyFilter(); - resolvePinned(); - auto& nav = activeNav(); - const int count = listCount(); - if (count == 0) { - nav.selected = 0; - } else if (nav.selected > count) { - nav.selected = count; - } - nav.followOnBuild = true; + resetAfterRebuild(); } } if (!result.isCancelled) { @@ -569,6 +650,10 @@ void LibraryListActivity::searchActionTrampoline(const fui::ActionEvent&, void* static_cast(user)->openSearch(); } +void LibraryListActivity::rebuildActionTrampoline(const fui::ActionEvent&, void* user) { + static_cast(user)->promptRebuildIndex(); +} + // Title and author for one entry, read straight from the index. Only ever // called for rows about to be drawn, so at most a screenful of strings exists // at once. @@ -602,6 +687,8 @@ bool LibraryListActivity::rowTextFor(const int entry, std::string& title, std::s } bool LibraryListActivity::handleCustomInput() { + if (optionPopup.handleInput(mappedInput, [this] { requestUpdate(); })) return true; + if (lockNextConfirmRelease && mappedInput.wasReleased(MappedInputManager::Button::Confirm)) { lockNextConfirmRelease = false; return true; @@ -626,12 +713,8 @@ bool LibraryListActivity::handleButtons() { if (mappedInput.wasLongPressed(MappedInputManager::Button::Confirm, LONG_PRESS_MS)) { if (tabsFocused()) { if (!degraded) toggleSortDirection(); - } else if (selectedEntry() < pinnedCount()) { - const auto& books = RECENT_BOOKS.getBooks(); - if (selectedEntry() < static_cast(books.size())) { - const auto& book = books[static_cast(selectedEntry())]; - promptRemoveRecentBook(book.path, book.title); - } + } else if (isRecentSort(sortOrder)) { + showRecentBookOptions(selectedEntry()); } else if (deleteEligible()) { if (count > 0) promptDeleteBook(selectedEntry()); } else if (!groupsCollapsed && groupable()) { @@ -830,8 +913,14 @@ void LibraryListActivity::buildHeader(UiScreen& screen) { header.trailingStyles = fui::plainStyles(fui::Paint::solid(fui::Color::Black)); header.borderEdges = fui::EdgeBottom; if (!degraded) { + // Keep both touch actions together on the right; button boards reach + // rebuild through the row options menu. header.trailingIcon = fui::bitmapFromIcon(icon_search_32); header.trailingAction = ACTION_SEARCH; + if (mappedInput.hasTouch()) { + header.trailingAdjacentIcon = fui::bitmapFromIcon(icon_refresh_cw_32); + header.trailingAdjacentAction = ACTION_REBUILD; + } const int titleFontId = uiScaleSpec().titleFontId; header.actionOffsetY = static_cast((renderer.getLineHeight(titleFontId) - renderer.getTextHeight(titleFontId)) / 2); @@ -897,8 +986,8 @@ void LibraryListActivity::drawHoldHelp() const { const char* help = nullptr; if (tabsFocused() && !degraded) help = tr(STR_LIBRARY_HOLD_SORT); - else if (!tabsFocused() && selectedEntry() < pinnedCount()) - help = tr(STR_HOLD_OPEN_TO_REMOVE); // pinned recents: hold removes from the list + else if (!tabsFocused() && isRecentSort(sortOrder) && listCount() > 0) + help = tr(STR_LIBRARY_HOLD_OPTIONS); // recent rows: hold opens the row menu else if (!tabsFocused() && deleteEligible() && listCount() > 0) help = tr(STR_HOLD_OPEN_TO_DELETE); else if (!tabsFocused() && groupable()) @@ -911,6 +1000,13 @@ void LibraryListActivity::drawHoldHelp() const { GUI.drawHelpText(renderer, Rect{SIDE_PADDING, y, renderer.getScreenWidth() / 2 - SIDE_PADDING, lineHeight}, help); } +// OptionPopup is a self-contained modal: it owns the whole frame (hints +// included) whenever it is up, mirroring the FileBrowser pattern. +void LibraryListActivity::render(RenderLock&& lock) { + if (optionPopup.processRender(renderer, mappedInput)) return; + UiTabListActivity::render(std::move(lock)); +} + void LibraryListActivity::drawFooter() { drawPositionReadout(); drawHoldHelp(); diff --git a/src/activities/library/LibraryListActivity.h b/src/activities/library/LibraryListActivity.h index 41af51e7fdb..6b63c7d5329 100644 --- a/src/activities/library/LibraryListActivity.h +++ b/src/activities/library/LibraryListActivity.h @@ -9,6 +9,7 @@ #include "RecentBooksStore.h" #include "activities/UiTabListActivity.h" +#include "components/OptionPopup.h" // One Library screen: every indexed book on the card shown by recency, title, // or author. The Recent shelf orders by file modification time (when a book @@ -53,10 +54,14 @@ class LibraryListActivity final : public UiTabListActivity { // The FreeInkUI header owns both the title and search touch target. void drawChrome() override {} void drawFooter() override; + // OptionPopup is a self-contained modal: it owns rendering (and the button + // hints) whenever it is up. + void render(RenderLock&& lock) override; private: // The screen's own actions, after the base's ACTION_ROW / ACTION_TAB. static constexpr freeink::ui::ActionId ACTION_SEARCH = ACTION_TAB_USER; + static constexpr freeink::ui::ActionId ACTION_REBUILD = ACTION_SEARCH + 1; // Walk the card and write a fresh index. Blocking, with a popup: at ~70 books // it is well under a second, and it only runs when the index is missing or the @@ -66,11 +71,19 @@ class LibraryListActivity final : public UiTabListActivity { // Input void openSelectedBook(); void openSearch(); + // Shared tail of row activation and the options menu's Open entry. + void openBookByPath(const std::string& path); + void promptRebuildIndex(); + void resetAfterRebuild(); + // Recent-row long-press menu: open / remove from recents / delete / rebuild. + void showRecentBookOptions(int entry); void promptRemoveRecentBook(const std::string& path, const std::string& title); // Long-press delete owns the gesture where grouping does not apply: the // Recent sort, degraded lists, and any active search result. bool deleteEligible() const; + // Resolves the row's path and title, then confirms via promptDeleteBookByPath. void promptDeleteBook(int entry); + void promptDeleteBookByPath(const std::string& path, const std::string& title); bool collapseGroups(int bookEntry); void expandGroup(int groupEntry); void restoreExpandedList(); @@ -80,6 +93,7 @@ class LibraryListActivity final : public UiTabListActivity { // not also act here. Records what to swallow on the next release. void swallowHeldReleases(); static void searchActionTrampoline(const freeink::ui::ActionEvent& event, void* user); + static void rebuildActionTrampoline(const freeink::ui::ActionEvent& event, void* user); // Data void applyFilter(); @@ -167,4 +181,8 @@ class LibraryListActivity final : public UiTabListActivity { bool lockNextConfirmRelease = false; bool lockNextBackRelease = false; + + // Row options modal (Recent long-press menu); owned here so it outlives the + // touch event that opened it. + OptionPopup optionPopup; }; diff --git a/src/activities/settings/SettingsActivity.cpp b/src/activities/settings/SettingsActivity.cpp index 2f52efc1a43..c8ed3babd76 100644 --- a/src/activities/settings/SettingsActivity.cpp +++ b/src/activities/settings/SettingsActivity.cpp @@ -104,7 +104,6 @@ void SettingsActivity::rebuildSettingsLists() { systemSettings.push_back(SettingInfo::Action(StrId::STR_KOREADER_SYNC, SettingAction::KOReaderSync)); systemSettings.push_back(SettingInfo::Action(StrId::STR_OPDS_SERVERS, SettingAction::OPDSBrowser)); systemSettings.push_back(SettingInfo::Action(StrId::STR_CLEAR_READING_CACHE, SettingAction::ClearCache)); - systemSettings.push_back(SettingInfo::Action(StrId::STR_LIBRARY_REBUILD, SettingAction::RebuildLibraryIndex)); // OTA fetches this board's own release asset (see OtaUpdater); boards whose // asset isn't published yet just report no update available. systemSettings.push_back(SettingInfo::Action(StrId::STR_CHECK_UPDATES, SettingAction::CheckForUpdates)); @@ -398,9 +397,6 @@ void SettingsActivity::toggleCurrentSetting() { case SettingAction::ClearCache: startActivityForResult(std::make_unique(renderer, mappedInput), resultHandler); break; - case SettingAction::RebuildLibraryIndex: - rebuildLibraryIndex(); - break; case SettingAction::CheckForUpdates: startActivityForResult(std::make_unique(renderer, mappedInput), resultHandler); break; @@ -485,29 +481,6 @@ void SettingsActivity::syncQuickResumeTimeoutForSleepScreen(bool sleepScreenChan } } -void SettingsActivity::rebuildLibraryIndex() { - // Prevent SD-backed fonts from opening a second reader while EPUB metadata is scanned. - // Keep the popup static because an e-ink refresh per folder would dominate the rebuild. - RenderLock lock(*this); - GUI.drawPopup(renderer, tr(STR_LIBRARY_REBUILDING)); - - library::BuildStats stats; - const bool ok = library::buildLibraryIndex("/", stats, SETTINGS.libraryUseMetadata != 0); - if (ok) { - LOG_INF("LIB", "rebuild: %u books (%u new, %u renamed, %u removed, %u enriched) in %ums", - static_cast(stats.books), static_cast(stats.added), - static_cast(stats.renamed), static_cast(stats.removed), - static_cast(stats.enriched), static_cast(stats.walkMs)); - if (stats.dedupDegraded) LOG_ERR("LIB", "rebuild completed without duplicate detection"); - } else { - LOG_ERR("LIB", "index rebuild failed"); - } - - GUI.drawPopup(renderer, ok ? tr(STR_LIBRARY_REBUILD_DONE) : tr(STR_LIBRARY_REBUILD_FAILED)); - delay(1200); - requestUpdate(true); -} - void SettingsActivity::openSleepTimeoutPicker() { startActivityForResult( std::make_unique( diff --git a/src/activities/settings/SettingsActivity.h b/src/activities/settings/SettingsActivity.h index 5d9001de3b0..a46025cb138 100644 --- a/src/activities/settings/SettingsActivity.h +++ b/src/activities/settings/SettingsActivity.h @@ -21,7 +21,6 @@ enum class SettingAction { OPDSBrowser, Network, ClearCache, - RebuildLibraryIndex, CheckForUpdates, SdFirmwareUpdate, Language, @@ -221,7 +220,6 @@ class SettingsActivity final : public UiTabListActivity { void enterCategory(int categoryIndex); void toggleCurrentSetting(); void openSleepTimeoutPicker(); - void rebuildLibraryIndex(); void rebuildSettingsLists(); void syncQuickResumeTimeoutForSleepScreen(bool sleepScreenChanged, bool quickResumeTimeoutChanged); diff --git a/src/components/icons/listIcons.h b/src/components/icons/listIcons.h index 823297f179c..f1a0bb94605 100644 --- a/src/components/icons/listIcons.h +++ b/src/components/icons/listIcons.h @@ -3,7 +3,7 @@ #include "Icon.h" // Generated by libs/assets/Icons/tools/gen_icons.py from Lucide SVGs. Do not edit. -// Sizes: 24, 32px. Icons: 15. +// Sizes: 24, 32px. Icons: 16. // folder (lucide: folder) static const uint8_t icon_folder_24_bits[] = { @@ -259,3 +259,20 @@ static const uint8_t icon_blocks_32_bits[] = { 0x7F, 0x8F, 0xF1, 0xFE, 0x7F, 0x8F, 0xF1, 0xFE, 0x7F, 0x8F, 0xF1, 0xFE, 0x7F, 0xCF, 0xF1, 0xFC, 0x7F, 0xC0, 0x00, 0x00, 0x7F, 0xE0, 0x00, 0x00, 0xFF, 0xF8, 0x00, 0x03, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF}; static const freeink::Icon icon_blocks_32 = {32, 32, 16, icon_blocks_32_bits}; + +// refresh-cw (lucide: refresh-cw) +static const uint8_t icon_refresh_cw_24_bits[] = { + 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0x81, 0xF3, 0xFE, 0x00, 0x73, 0xFC, 0x7E, 0x13, 0xF9, 0xFF, 0x83, + 0xF3, 0xFF, 0xC3, 0xE7, 0xFE, 0x03, 0xE7, 0xFE, 0x03, 0xCF, 0xFF, 0xFF, 0xCF, 0xFF, 0xFF, 0xCF, 0xFF, 0xF3, + 0xCF, 0xFF, 0xF3, 0xFF, 0xFF, 0xF3, 0xFF, 0xFF, 0xF3, 0xC0, 0x7F, 0xE7, 0xC0, 0x7F, 0xE7, 0xC3, 0xFF, 0xCF, + 0xC1, 0xFF, 0x9F, 0xC8, 0x7E, 0x3F, 0xCE, 0x00, 0x7F, 0xCF, 0x81, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF}; +static const freeink::Icon icon_refresh_cw_24 = {24, 24, 12, icon_refresh_cw_24_bits}; +static const uint8_t icon_refresh_cw_32_bits[] = { + 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xF0, 0x0F, 0xE7, 0xFF, 0x80, 0x01, + 0xE7, 0xFF, 0x03, 0xC0, 0xE7, 0xFE, 0x1F, 0xF8, 0x27, 0xFC, 0x3F, 0xFE, 0x07, 0xF8, 0xFF, 0xFF, 0x07, 0xF0, 0xFF, + 0xF8, 0x07, 0xF1, 0xFF, 0xF0, 0x07, 0xF3, 0xFF, 0xF0, 0x07, 0xE3, 0xFF, 0xFF, 0xFF, 0xE3, 0xFF, 0xFF, 0xFF, 0xE7, + 0xFF, 0xFF, 0xFF, 0xE7, 0xFF, 0xFF, 0xE7, 0xE7, 0xFF, 0xFF, 0xE7, 0xFF, 0xFF, 0xFF, 0xE7, 0xFF, 0xFF, 0xFF, 0xC7, + 0xFF, 0xFF, 0xFF, 0xC7, 0xE0, 0x0F, 0xFF, 0xCF, 0xE0, 0x0F, 0xFF, 0x8F, 0xE0, 0x1F, 0xFF, 0x1F, 0xE0, 0xFF, 0xFF, + 0x1F, 0xE0, 0x7F, 0xFC, 0x3F, 0xE4, 0x1F, 0xF8, 0x7F, 0xE7, 0x03, 0xC0, 0xFF, 0xE7, 0x80, 0x03, 0xFF, 0xE7, 0xF0, + 0x0F, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF}; +static const freeink::Icon icon_refresh_cw_32 = {32, 32, 15, icon_refresh_cw_32_bits}; diff --git a/src/components/icons/listIcons.manifest b/src/components/icons/listIcons.manifest index a9711892cf2..53eec100966 100644 --- a/src/components/icons/listIcons.manifest +++ b/src/components/icons/listIcons.manifest @@ -23,3 +23,4 @@ download = download bookmark = bookmark usb = usb blocks = blocks +refresh-cw = refresh-cw diff --git a/src/network/CrossPointWebServer.cpp b/src/network/CrossPointWebServer.cpp index 13029c76bd5..ab8fb39a66b 100644 --- a/src/network/CrossPointWebServer.cpp +++ b/src/network/CrossPointWebServer.cpp @@ -5,6 +5,7 @@ #include #include #include +#include #include #include #include @@ -32,6 +33,13 @@ namespace { // Folders/files to hide from the web interface file browser // Note: Items starting with "." are automatically hidden constexpr const char* HIDDEN_ITEMS[] = {"System Volume Information", "XTCache"}; + +// Formats the library index tracks (LibraryIndex isBookName): an upload of any +// of these must mark the index dirty so the next Library entry rebuilds it. +bool isLibraryBookFile(const String& filename) { + return FsHelpers::checkFileExtension(filename, ".epub") || FsHelpers::checkFileExtension(filename, ".txt") || + FsHelpers::checkFileExtension(filename, ".md") || FsHelpers::checkFileExtension(filename, ".xtc"); +} constexpr uint16_t UDP_PORTS[] = {54982, 48123, 39001, 44044, 59678}; constexpr uint16_t LOCAL_UDP_PORT = 8134; @@ -781,6 +789,7 @@ void CrossPointWebServer::handleUpload(UploadState& state) const { if (!filePath.endsWith("/")) filePath += "/"; filePath += state.fileName; clearBookCache(filePath.c_str()); + if (isLibraryBookFile(state.fileName)) library::markLibraryIndexDirty(); } } } else if (upload.status == UPLOAD_FILE_ABORTED) { @@ -1694,6 +1703,7 @@ void CrossPointWebServer::onWebSocketEvent(uint8_t num, WStype_t type, uint8_t* wsLastCompleteAt = millis(); LOG_DBG("WS", "Zero-byte upload complete: %s", filePath.c_str()); clearBookCache(filePath.c_str()); + if (isLibraryBookFile(wsUploadFileName)) library::markLibraryIndexDirty(); wsServer->sendTXT(num, "DONE"); wsLastProgressSent = 0; break; @@ -1763,6 +1773,7 @@ void CrossPointWebServer::onWebSocketEvent(uint8_t num, WStype_t type, uint8_t* if (!filePath.endsWith("/")) filePath += "/"; filePath += wsUploadFileName; clearBookCache(filePath.c_str()); + if (isLibraryBookFile(wsUploadFileName)) library::markLibraryIndexDirty(); wsServer->sendTXT(num, "DONE"); wsLastProgressSent = 0; From 12cc816fb3d2093f56d9d7705cbadcae370d4163 Mon Sep 17 00:00:00 2001 From: Justin Mitchell Date: Tue, 22 Sep 2026 00:08:10 -0400 Subject: [PATCH 03/62] Remove RenderLock from EpubReaderActivity destructor ActivityManager already holds RenderLock when destroying activities. Taking another RenderLock in the destructor causes self-deadlock since renderingMutex is non-recursive. --- src/activities/reader/EpubReaderActivity.cpp | 7 +++---- 1 file changed, 3 insertions(+), 4 deletions(-) diff --git a/src/activities/reader/EpubReaderActivity.cpp b/src/activities/reader/EpubReaderActivity.cpp index ec8105cddfe..a1c4b13cafd 100644 --- a/src/activities/reader/EpubReaderActivity.cpp +++ b/src/activities/reader/EpubReaderActivity.cpp @@ -152,10 +152,9 @@ void moveFinishedBookToReadFolder(const std::string& srcPath, const std::string& EpubReaderActivity::~EpubReaderActivity() { ImageBlock::setExtractor(nullptr, nullptr); - if (overlayRefreshPending) { - RenderLock lock; // whatever screen follows paints the framebuffer - settleOverlayRefresh(); - } + // ActivityManager destroys activities with its RenderLock already held; + // taking another here self-deadlocks (renderingMutex is non-recursive). + settleOverlayRefresh(); discardOverlayPage(); // free the overlay's page snapshot if one is held if (footnoteDepth > 0 && epub) { From 6f94d1ad5d84a81ec8a28c06b0afe01a7300fe75 Mon Sep 17 00:00:00 2001 From: julian <489233+naydichev@users.noreply.github.com> Date: Tue, 22 Sep 2026 07:08:24 +0200 Subject: [PATCH 04/62] fix: unblock X4 Pro frontlight double-click when short power press = Sleep, add Home key option (#3089) --- src/main.cpp | 39 ++++++++++++++++++++++++++++++++++----- 1 file changed, 34 insertions(+), 5 deletions(-) diff --git a/src/main.cpp b/src/main.cpp index fa430369081..0753a36175a 100644 --- a/src/main.cpp +++ b/src/main.cpp @@ -684,16 +684,38 @@ void loop() { return; } + const bool x4ProDoubleClickPwrLight = BoardConfig::isX4Pro() && SETTINGS.doubleClickPwrLight; + #if FREEINK_CAP_TOUCH // A single X4 Pro power click becomes Confirm only after the frontlight // double-click window expires without a second click. mappedInputManager.setPowerConfirmClickFrame(false); - if (SETTINGS.shortPwrBtn == CrossPointSettings::SHORT_PWRBTN::PWR_CONFIRM && BoardConfig::isX4Pro() && + if (SETTINGS.shortPwrBtn == CrossPointSettings::SHORT_PWRBTN::PWR_CONFIRM && x4ProDoubleClickPwrLight) { + if (lastX4ProPowerClickAt != 0 && millis() - lastX4ProPowerClickAt > X4PRO_POWER_DOUBLE_CLICK_MS) { + lastX4ProPowerClickAt = 0; + mappedInputManager.setPowerConfirmClickFrame(true); + } + // A release held too long to be a double-click candidate (but still within + // the normal Confirm press duration) never reaches handleX4ProFrontlightDoubleClick's + // click tracking above, so it needs its own Confirm check here. + if (mappedInputManager.wasReleased(MappedInputManager::Button::Power) && + gpio.getPowerButtonHeldTime() > X4PRO_POWER_CLICK_MAX_HOLD_MS && + gpio.getPowerButtonHeldTime() <= SETTINGS.getPowerButtonDuration()) { + mappedInputManager.setPowerConfirmClickFrame(true); + } + } +#endif + + // Same deferral for SLEEP: getPowerButtonDuration() drops to 10ms so a quick + // tap sleeps the device, which otherwise fires on button-down and never lets + // a second click land. Sleep only once the double-click window has passed. + if (SETTINGS.shortPwrBtn == CrossPointSettings::SHORT_PWRBTN::SLEEP && x4ProDoubleClickPwrLight && lastX4ProPowerClickAt != 0 && millis() - lastX4ProPowerClickAt > X4PRO_POWER_DOUBLE_CLICK_MS) { lastX4ProPowerClickAt = 0; - mappedInputManager.setPowerConfirmClickFrame(true); + enterDeepSleep(); + // This should never be hit as `enterDeepSleep` calls esp_deep_sleep_start + return; } -#endif const unsigned long sleepTimeoutMs = SETTINGS.getSleepTimeoutMs(); if (sleepTimeoutMs > 0 && millis() - lastActivityTime >= sleepTimeoutMs) { @@ -709,8 +731,15 @@ void loop() { static bool powerReleasedSinceWake = false; if (!gpio.isPressed(HalGPIO::BTN_POWER)) powerReleasedSinceWake = true; - if (powerReleasedSinceWake && millis() >= allowSleepAt && gpio.isPressed(HalGPIO::BTN_POWER) && - gpio.getPowerButtonHeldTime() > SETTINGS.getPowerButtonDuration()) { + // On X4 Pro with SLEEP, a press still within the click window is a + // double-click candidate — let it be released and evaluated above instead + // of sleeping on button-down. + const bool x4ProAwaitingClickWindow = x4ProDoubleClickPwrLight && + SETTINGS.shortPwrBtn == CrossPointSettings::SHORT_PWRBTN::SLEEP && + gpio.getPowerButtonHeldTime() <= X4PRO_POWER_CLICK_MAX_HOLD_MS; + + if (!x4ProAwaitingClickWindow && powerReleasedSinceWake && millis() >= allowSleepAt && + gpio.isPressed(HalGPIO::BTN_POWER) && gpio.getPowerButtonHeldTime() > SETTINGS.getPowerButtonDuration()) { // If the screenshot combination is potentially being pressed, don't sleep if (gpio.isPressed(HalGPIO::BTN_DOWN)) { return; From 362c88112112acf044792dc3098a58d1fe8783b5 Mon Sep 17 00:00:00 2001 From: Justin Mitchell Date: Tue, 22 Sep 2026 10:51:55 -0400 Subject: [PATCH 05/62] feat: Adds Cover Grid UI Theme for PSRAM capable devices (#3657) Co-authored-by: Uri Tauber --- freeink-sdk | 2 +- lib/Epub/Epub.cpp | 24 +- lib/Epub/Epub.h | 3 + lib/Epub/Epub/parsers/ContentOpfParser.cpp | 21 +- lib/GfxRenderer/GfxRenderer.h | 4 + lib/I18n/translations/english.yaml | 1 + lib/hal/HalMemory.cpp | 8 + lib/hal/HalMemory.h | 9 + src/CrossPointSettings.h | 2 +- src/SettingsList.h | 14 +- src/activities/home/HomeActivity.cpp | 181 ++++++++++- src/activities/home/HomeActivity.h | 6 + src/components/CoverGridHomeUi.cpp | 282 ++++++++++++++++++ src/components/CoverGridHomeUi.h | 65 ++++ src/components/HomeCoverCache.cpp | 83 ++++++ src/components/HomeCoverCache.h | 41 +++ src/components/OptionPopup.h | 23 +- src/components/UITheme.cpp | 25 +- src/components/UITheme.h | 10 +- src/components/themes/BaseTheme.cpp | 61 +++- src/components/themes/BaseTheme.h | 15 + .../themes/lyra/Lyra3CoversTheme.cpp | 26 +- src/components/themes/lyra/Lyra3CoversTheme.h | 1 + src/components/themes/lyra/LyraTheme.cpp | 14 +- .../themes/roundedraff/RoundedRaffTheme.cpp | 7 +- .../themes/roundedraff/RoundedRaffTheme.h | 1 + src/util/BookProgress.cpp | 77 +++++ src/util/BookProgress.h | 6 + .../ContentOpfParserTest.cpp | 53 ++++ test/content_opf_parser/stubs/Epub.h | 13 +- test/inflate_stream/stubs/esp_heap_caps.h | 4 + 31 files changed, 1012 insertions(+), 70 deletions(-) create mode 100644 src/components/CoverGridHomeUi.cpp create mode 100644 src/components/CoverGridHomeUi.h create mode 100644 src/components/HomeCoverCache.cpp create mode 100644 src/components/HomeCoverCache.h create mode 100644 src/util/BookProgress.cpp create mode 100644 src/util/BookProgress.h diff --git a/freeink-sdk b/freeink-sdk index 13418e0986b..58769544252 160000 --- a/freeink-sdk +++ b/freeink-sdk @@ -1 +1 @@ -Subproject commit 13418e0986b05039bf056e050a6df5305d47c209 +Subproject commit 587695442525245dcf9322903c92f72731d11494 diff --git a/lib/Epub/Epub.cpp b/lib/Epub/Epub.cpp index e5abd3ab105..867af47f73a 100644 --- a/lib/Epub/Epub.cpp +++ b/lib/Epub/Epub.cpp @@ -780,7 +780,29 @@ bool Epub::generateThumbBmp(int height) const { return false; } - const auto coverImageHref = bookMetadataCache->coreMetadata.coverItemHref; + return generateThumbBmpForCover(height, bookMetadataCache->coreMetadata.coverItemHref); +} + +bool Epub::generateThumbBmpFromSource(int height) { + if (Storage.exists(getThumbBmpPath(height).c_str())) return true; + // Parser input and metadata outlive parsing but exceed the small task stack budget. + auto metadata = makeUniqueNoThrow(); + auto zip = makeUniqueNoThrow(filepath); + if (!metadata || !zip) { + LOG_ERR("EBP", "OOM: cover metadata"); + return false; + } + if (!zip->open()) { + LOG_ERR("EBP", "Could not open EPUB for cover metadata"); + return false; + } + if (!parseContentOpf(*metadata, /*writeSpineEntries=*/false, /*metadataOnly=*/false, zip.get())) return false; + zip.reset(); + setupCacheDir(); + return generateThumbBmpForCover(height, metadata->coverItemHref); +} + +bool Epub::generateThumbBmpForCover(int height, const std::string& coverImageHref) const { if (coverImageHref.empty()) { LOG_DBG("EBP", "No known cover image for thumbnail"); } else if (FsHelpers::hasJpgExtension(coverImageHref)) { diff --git a/lib/Epub/Epub.h b/lib/Epub/Epub.h index ced7f5633bb..ecab22548da 100644 --- a/lib/Epub/Epub.h +++ b/lib/Epub/Epub.h @@ -33,6 +33,7 @@ class Epub { bool findContentOpfFile(std::string* contentOpfFile, ZipFile* sharedZip = nullptr) const; bool parseContentOpf(BookMetadataCache::BookMetadata& bookMetadata, bool writeSpineEntries = true, bool metadataOnly = false, ZipFile* sharedZip = nullptr); + bool generateThumbBmpForCover(int height, const std::string& coverImageHref) const; bool parseTocNcxFile() const; bool parseTocNavFile() const; void discoverCssFilesFromZip(); @@ -59,6 +60,8 @@ class Epub { std::string getThumbBmpPath() const; std::string getThumbBmpPath(int height) const; bool generateThumbBmp(int height) const; + // Locate the cover without building spine, TOC, or reading caches. + bool generateThumbBmpFromSource(int height); uint8_t* readItemContentsToBytes(const std::string& itemHref, size_t* size = nullptr, bool trailingNullByte = false) const; bool readItemContentsToStream(const std::string& itemHref, Print& out, size_t chunkSize, diff --git a/lib/Epub/Epub/parsers/ContentOpfParser.cpp b/lib/Epub/Epub/parsers/ContentOpfParser.cpp index e102237a498..fd740952de3 100644 --- a/lib/Epub/Epub/parsers/ContentOpfParser.cpp +++ b/lib/Epub/Epub/parsers/ContentOpfParser.cpp @@ -81,7 +81,7 @@ bool ContentOpfParser::setup() { ContentOpfParser::~ContentOpfParser() { destroyXmlParser(parser); - if (metadataOnly) { + if (metadataOnly || !cache) { return; } if (tempItemStore) { @@ -180,7 +180,7 @@ void XMLCALL ContentOpfParser::startElement(void* userData, const XML_Char* name if (self->state == IN_PACKAGE && xmlLocalNameEquals(name, "manifest")) { self->state = IN_MANIFEST; - if (!Storage.openFileForWrite("COF", self->cachePath + itemCacheFile, self->tempItemStore)) { + if (self->cache && !Storage.openFileForWrite("COF", self->cachePath + itemCacheFile, self->tempItemStore)) { LOG_ERR("COF", "Couldn't open temp items file for writing. This is probably going to be a fatal error."); } return; @@ -188,7 +188,7 @@ void XMLCALL ContentOpfParser::startElement(void* userData, const XML_Char* name if (self->state == IN_PACKAGE && xmlLocalNameEquals(name, "spine")) { self->state = IN_SPINE; - if (!Storage.openFileForRead("COF", self->cachePath + itemCacheFile, self->tempItemStore)) { + if (self->cache && !Storage.openFileForRead("COF", self->cachePath + itemCacheFile, self->tempItemStore)) { LOG_ERR("COF", "Couldn't open temp items file for reading. This is probably going to be a fatal error."); } @@ -209,7 +209,7 @@ void XMLCALL ContentOpfParser::startElement(void* userData, const XML_Char* name self->state = IN_GUIDE; // TODO Remove print LOG_DBG("COF", "Entering guide state."); - if (!Storage.openFileForRead("COF", self->cachePath + itemCacheFile, self->tempItemStore)) { + if (self->cache && !Storage.openFileForRead("COF", self->cachePath + itemCacheFile, self->tempItemStore)) { LOG_ERR("COF", "Couldn't open temp items file for reading. This is probably going to be a fatal error."); } return; @@ -260,9 +260,10 @@ void XMLCALL ContentOpfParser::startElement(void* userData, const XML_Char* name self->itemIndex.push_back(entry); } - // Write items down to SD card - serialization::writeString(self->tempItemStore, itemId); - serialization::writeString(self->tempItemStore, href); + if (self->tempItemStore) { + serialization::writeString(self->tempItemStore, itemId); + serialization::writeString(self->tempItemStore, href); + } if (itemId == self->coverItemId) { // Some EPUBs set meta name="cover" to an XHTML wrapper item. @@ -424,19 +425,19 @@ void XMLCALL ContentOpfParser::endElement(void* userData, const XML_Char* name) if (self->state == IN_SPINE && xmlLocalNameEquals(name, "spine")) { self->state = IN_PACKAGE; - self->tempItemStore.close(); + if (self->tempItemStore) self->tempItemStore.close(); return; } if (self->state == IN_GUIDE && xmlLocalNameEquals(name, "guide")) { self->state = IN_PACKAGE; - self->tempItemStore.close(); + if (self->tempItemStore) self->tempItemStore.close(); return; } if (self->state == IN_MANIFEST && xmlLocalNameEquals(name, "manifest")) { self->state = IN_PACKAGE; - self->tempItemStore.close(); + if (self->tempItemStore) self->tempItemStore.close(); return; } diff --git a/lib/GfxRenderer/GfxRenderer.h b/lib/GfxRenderer/GfxRenderer.h index b090361eebb..fcbf0b5279d 100644 --- a/lib/GfxRenderer/GfxRenderer.h +++ b/lib/GfxRenderer/GfxRenderer.h @@ -14,6 +14,7 @@ enum class BidiBaseDir : signed char { AUTO = -1, LTR = 0, RTL = 1 }; class FontCacheManager; class SdCardFont; +#include #include #include #include @@ -258,6 +259,9 @@ class GfxRenderer { // Drawing // UI drawing clip in logical coordinates; independent of panel orientation. + std::array getClipRect() const { + return {clipLeft_, clipTop_, clipRight_ - clipLeft_, clipBottom_ - clipTop_}; + } void setClipRect(int x, int y, int width, int height) const { clipLeft_ = x; clipTop_ = y; diff --git a/lib/I18n/translations/english.yaml b/lib/I18n/translations/english.yaml index 15cd8b13ba1..0289eb05658 100644 --- a/lib/I18n/translations/english.yaml +++ b/lib/I18n/translations/english.yaml @@ -500,6 +500,7 @@ STR_HOME_BUTTON_DOUBLE_TAP: "Double Tap" STR_HOME_BUTTON_LONG_PRESS: "Long Press" STR_TOGGLE_FRONTLIGHT: "Toggle Frontlight" +STR_THEME_COVER_GRID: "Cover Grid" STR_WORD_SPACING: "Word spacing" STR_CHARACTER_SPACING: "Character spacing" STR_SPACING_MINUS_2: "-2 px" diff --git a/lib/hal/HalMemory.cpp b/lib/hal/HalMemory.cpp index 8324005bc09..686e91ffd4a 100644 --- a/lib/hal/HalMemory.cpp +++ b/lib/hal/HalMemory.cpp @@ -16,3 +16,11 @@ HalMemory::HeapStats HalMemory::getDefaultHeap() { return readHeapStats(MALLOC_C HalMemory::HeapStats HalMemory::getInternalHeap() { return readHeapStats(MALLOC_CAP_INTERNAL); } HalMemory::HeapStats HalMemory::getPsramHeap() { return readHeapStats(MALLOC_CAP_SPIRAM); } + +void HalMemory::PsramDeleter::operator()(uint8_t* buffer) const { heap_caps_free(buffer); } + +HalMemory::PsramBuffer HalMemory::allocatePsram(size_t bytes) { + // Capability allocation is required to keep image caches out of internal RAM; + // the owning handle releases this block through the matching heap API. + return PsramBuffer(static_cast(heap_caps_malloc(bytes, MALLOC_CAP_SPIRAM | MALLOC_CAP_8BIT))); +} diff --git a/lib/hal/HalMemory.h b/lib/hal/HalMemory.h index 62407ff13e0..f51616beccd 100644 --- a/lib/hal/HalMemory.h +++ b/lib/hal/HalMemory.h @@ -1,6 +1,8 @@ #pragma once #include +#include +#include class HalMemory { public: @@ -11,6 +13,13 @@ class HalMemory { size_t largestBlockBytes; }; + struct PsramDeleter { + void operator()(uint8_t* buffer) const; + }; + using PsramBuffer = std::unique_ptr; + // Never falls back to internal RAM. Null on allocation failure or absent PSRAM. + static PsramBuffer allocatePsram(size_t bytes); + // Default-capability memory includes PSRAM when registered with the allocator. static HeapStats getDefaultHeap(); static HeapStats getInternalHeap(); diff --git a/src/CrossPointSettings.h b/src/CrossPointSettings.h index d33133efe74..801a28c1ab0 100644 --- a/src/CrossPointSettings.h +++ b/src/CrossPointSettings.h @@ -173,7 +173,7 @@ class CrossPointSettings : public PersistableStore { }; // UI Theme - enum UI_THEME { CLASSIC = 0, LYRA = 1, LYRA_3_COVERS = 2, ROUNDEDRAFF = 3 }; + enum UI_THEME { CLASSIC = 0, LYRA = 1, LYRA_3_COVERS = 2, ROUNDEDRAFF = 3, COVER_GRID = 4 }; // Image rendering in EPUB reader enum IMAGE_RENDERING { IMAGES_DISPLAY = 0, IMAGES_PLACEHOLDER = 1, IMAGES_SUPPRESS = 2, IMAGE_RENDERING_COUNT }; diff --git a/src/SettingsList.h b/src/SettingsList.h index e49e423432d..b84e3e5b54d 100644 --- a/src/SettingsList.h +++ b/src/SettingsList.h @@ -17,6 +17,7 @@ #include "KOReaderCredentialStore.h" #include "ReaderFontSizes.h" #include "activities/settings/SettingsActivity.h" +#include "components/UITheme.h" #include "util/DictionaryRegistry.h" // Build the font family setting dynamically. When registry is non-null, SD card fonts @@ -186,6 +187,13 @@ inline std::vector buildLongPressMenuValues() { return {VALUES, VALUES + count}; } +inline std::vector homeThemeValues() { + static constexpr StrId VALUES[] = {StrId::STR_THEME_CLASSIC, StrId::STR_THEME_LYRA, StrId::STR_THEME_LYRA_EXTENDED, + StrId::STR_THEME_ROUNDEDRAFF, StrId::STR_THEME_COVER_GRID}; + const size_t count = UITheme::supportsCoverGrid() ? std::size(VALUES) : std::size(VALUES) - 1; + return {VALUES, VALUES + count}; +} + // Shared settings list used by both the device settings UI and the web settings API. // Each entry has a key (for JSON API) and category (for grouping). // ACTION-type entries and entries without a key are device-only. @@ -235,10 +243,8 @@ inline std::vector getSettingsList(const SdCardFontRegistry* regist {StrId::STR_PAGES_1, StrId::STR_PAGES_5, StrId::STR_PAGES_10, StrId::STR_PAGES_15, StrId::STR_PAGES_30, StrId::STR_NEVER}, "refreshFrequency", StrId::STR_CAT_DISPLAY), - SettingInfo::Enum(StrId::STR_UI_THEME, &CrossPointSettings::uiTheme, - {StrId::STR_THEME_CLASSIC, StrId::STR_THEME_LYRA, StrId::STR_THEME_LYRA_EXTENDED, - StrId::STR_THEME_ROUNDEDRAFF}, - "uiTheme", StrId::STR_CAT_DISPLAY), + SettingInfo::Enum(StrId::STR_UI_THEME, &CrossPointSettings::uiTheme, homeThemeValues(), "uiTheme", + StrId::STR_CAT_DISPLAY), SettingInfo::Toggle(StrId::STR_SUNLIGHT_FADING_FIX, &CrossPointSettings::fadingFix, "fadingFix", StrId::STR_CAT_DISPLAY), #if FREEINK_CAP_FRONTLIGHT diff --git a/src/activities/home/HomeActivity.cpp b/src/activities/home/HomeActivity.cpp index 495098f24ac..675118900da 100644 --- a/src/activities/home/HomeActivity.cpp +++ b/src/activities/home/HomeActivity.cpp @@ -7,6 +7,9 @@ #include #include #include +#include +#include +#include #include #include @@ -36,7 +39,7 @@ int HomeActivity::getMenuItemCount() const { void HomeActivity::loadRecentBooks(int maxBooks) { recentBooks.clear(); const auto& books = RECENT_BOOKS.getBooks(); - recentBooks.reserve(std::min(static_cast(books.size()), maxBooks)); + recentBooks.reserve(coverGridUi ? maxBooks : std::min(static_cast(books.size()), maxBooks)); for (const RecentBook& book : books) { // Limit to maximum number of recent books @@ -53,6 +56,109 @@ void HomeActivity::loadRecentBooks(int maxBooks) { } } +void HomeActivity::fillCoverGridFromLibrary() { + if (recentBooks.size() >= CoverGridHomeUi::MAX_BOOKS) return; + // Keep the index and record together off the task stack; reuse for every row. + struct LibraryReader { + library::LibraryIndexFile index; + library::ClixRecord record; + }; + auto reader = makeUniqueNoThrow(); + if (!reader) { + LOG_ERR("HOME", "OOM: library index"); + return; + } + auto& index = reader->index; + auto& record = reader->record; + if (!index.open(library::libraryIndexPath())) { + index.close(); + GUI.drawPopup(renderer, tr(STR_LIBRARY_REBUILDING)); + library::BuildStats stats; + if (!library::buildLibraryIndex("/", stats, SETTINGS.libraryUseMetadata != 0) || + !index.open(library::libraryIndexPath())) { + LOG_ERR("HOME", "Cannot populate cover grid from library"); + return; + } + } + for (uint16_t row = 0; row < index.bookCount() && recentBooks.size() < CoverGridHomeUi::MAX_BOOKS; ++row) { + RecentBook book; + if (!index.readRecord(index.ordinalForRow(library::SortOrder::RecentDesc, row), record) || + !index.readPath(record, book.path)) + continue; + if (std::any_of(recentBooks.begin(), recentBooks.end(), + [&](const RecentBook& existing) { return existing.path == book.path; }) || + RecentBooksStore::isMissing(book)) + continue; + if (!index.readTitle(record, book.title) && !index.readName(record, book.title)) continue; + index.readAuthor(record, book.author); + if (index.ioFailed()) break; + recentBooks.push_back(std::move(book)); + } +} + +void HomeActivity::resolveGridCoverPaths() { + for (auto& book : recentBooks) { + if (!book.coverBmpPath.empty()) continue; + // Constructors only derive cache paths; no metadata parsing or image generation. + // Keep these large objects off the task stack and release each before the next book. + if (FsHelpers::hasEpubExtension(book.path)) { + auto epub = makeUniqueNoThrow(book.path, "/.crosspoint"); + if (!epub) { + LOG_ERR("HOME", "OOM: EPUB thumbnail path"); + continue; + } + book.coverBmpPath = epub->getThumbBmpPath(); + } else if (FsHelpers::hasXtcExtension(book.path)) { + auto xtc = makeUniqueNoThrow(book.path, "/.crosspoint"); + if (!xtc) { + LOG_ERR("HOME", "OOM: XTC thumbnail path"); + continue; + } + book.coverBmpPath = xtc->getThumbBmpPath(); + } + } +} + +void HomeActivity::loadGridCover(RecentBook& book, int height, bool& showingLoading, Rect& popupRect) { + if (!book.coverBmpPath.empty() && Storage.exists(UITheme::getCoverThumbPath(book.coverBmpPath, height).c_str())) + return; + // Only one parser lives at a time; EPUB/XTC objects exceed the stack budget. + if (FsHelpers::hasEpubExtension(book.path)) { + auto epub = makeUniqueNoThrow(book.path, "/.crosspoint"); + if (!epub) { + LOG_ERR("HOME", "OOM: cover EPUB"); + return; + } + book.coverBmpPath = epub->getThumbBmpPath(); + if (Storage.exists(epub->getThumbBmpPath(height).c_str())) return; + if (!showingLoading) { + showingLoading = true; + popupRect = GUI.drawPopup(renderer, tr(STR_LOADING_POPUP)); + GUI.fillPopupProgress(renderer, popupRect, 0); + } + if (epub->generateThumbBmpFromSource(height)) { + return; + } + } else if (FsHelpers::hasXtcExtension(book.path)) { + auto xtc = makeUniqueNoThrow(book.path, "/.crosspoint"); + if (!xtc) { + LOG_ERR("HOME", "OOM: cover XTC"); + return; + } + book.coverBmpPath = xtc->getThumbBmpPath(); + if (Storage.exists(xtc->getThumbBmpPath(height).c_str())) return; + if (!showingLoading) { + showingLoading = true; + popupRect = GUI.drawPopup(renderer, tr(STR_LOADING_POPUP)); + GUI.fillPopupProgress(renderer, popupRect, 0); + } + if (xtc->load() && xtc->generateThumbBmp(height)) { + return; + } + } + book.coverBmpPath.clear(); +} + void HomeActivity::loadRecentCovers(int coverHeight) { recentsLoading = true; bool showingLoading = false; @@ -60,8 +166,17 @@ void HomeActivity::loadRecentCovers(int coverHeight) { int progress = 0; for (RecentBook& book : recentBooks) { + // The cover grid draws each slot at its own size; generating at any other + // height would rescale the dithered thumb at draw time and alias badly. + const int thumbHeight = coverGridUi ? coverGridUi->thumbHeightFor(progress) : coverHeight; + if (coverGridUi) { + loadGridCover(book, thumbHeight, showingLoading, popupRect); + ++progress; + if (showingLoading) GUI.fillPopupProgress(renderer, popupRect, progress * 100 / recentBooks.size()); + continue; + } if (!book.coverBmpPath.empty()) { - std::string coverPath = UITheme::getCoverThumbPath(book.coverBmpPath, coverHeight); + std::string coverPath = UITheme::getCoverThumbPath(book.coverBmpPath, thumbHeight); if (!Storage.exists(coverPath.c_str())) { // If epub, try to load the metadata for title/author and cover if (FsHelpers::hasEpubExtension(book.path)) { @@ -75,7 +190,7 @@ void HomeActivity::loadRecentCovers(int coverHeight) { popupRect = GUI.drawPopup(renderer, tr(STR_LOADING_POPUP)); } GUI.fillPopupProgress(renderer, popupRect, 10 + progress * (90 / recentBooks.size())); - bool success = epub.generateThumbBmp(coverHeight); + bool success = epub.generateThumbBmp(thumbHeight); if (!success) { RECENT_BOOKS.updateBook(book.path, book.title, book.author, ""); book.coverBmpPath = ""; @@ -92,7 +207,7 @@ void HomeActivity::loadRecentCovers(int coverHeight) { popupRect = GUI.drawPopup(renderer, tr(STR_LOADING_POPUP)); } GUI.fillPopupProgress(renderer, popupRect, 10 + progress * (90 / recentBooks.size())); - bool success = xtc.generateThumbBmp(coverHeight); + bool success = xtc.generateThumbBmp(thumbHeight); if (!success) { RECENT_BOOKS.updateBook(book.path, book.title, book.author, ""); book.coverBmpPath = ""; @@ -116,7 +231,18 @@ void HomeActivity::onEnter() { hasOpdsServers = OPDS_STORE.hasServers(); const auto& metrics = UITheme::getInstance().getMetrics(); - loadRecentBooks(metrics.homeRecentBooksCount); + if (UITheme::getInstance().hasCoverGridHome()) { + // Screen-lifetime interaction tables and component properties exceed the stack budget. + coverGridUi = makeUniqueNoThrow(renderer); + if (!coverGridUi) LOG_ERR("HOME", "OOM: cover grid UI; using standard home"); + } + loadRecentBooks(coverGridUi ? CoverGridHomeUi::MAX_BOOKS : metrics.homeRecentBooksCount); + hasContinueReading = !recentBooks.empty(); + if (coverGridUi) { + fillCoverGridFromLibrary(); + resolveGridCoverPaths(); + coverGridUi->begin(recentBooks, hasOpdsServers, hasContinueReading); + } const auto base = static_cast(recentBooks.size()); selectorIndex = initialMenuItem == HomeMenuItem::NONE ? 0 : base + menuItemToIndex(initialMenuItem, hasOpdsServers); @@ -128,6 +254,8 @@ void HomeActivity::onEnter() { void HomeActivity::onExit() { Activity::onExit(); + coverGridUi.reset(); + // Free the stored cover buffer if any freeCoverBuffer(); } @@ -224,11 +352,22 @@ void HomeActivity::loop() { // Back is otherwise unused on the home menu: open the most recently read // book directly (recentBooks is most-recent-first and already pruned of // files missing from the SD card). - if (mappedInput.wasReleased(MappedInputManager::Button::Back) && !recentBooks.empty()) { + if (mappedInput.wasReleased(MappedInputManager::Button::Back) && hasContinueReading && !recentBooks.empty()) { onSelectBook(recentBooks[0].path); return; } + if (coverGridUi) { + const int touched = coverGridUi->selectedAction(mappedInput); + if (touched >= 0 && touched < menuCount) { + selectorIndex = touched; + activateSelection(); + return; + } + if (mappedInput.wasReleased(MappedInputManager::Button::Confirm)) activateSelection(); + return; + } + const int coverColumnCount = std::max(1, metrics.homeRecentBooksCount); const int recentCount = std::min(static_cast(recentBooks.size()), coverColumnCount); const int coverColumnWidth = (renderer.getScreenWidth() - 2 * metrics.contentSidePadding) / coverColumnCount; @@ -284,6 +423,33 @@ void HomeActivity::render(RenderLock&&) { const auto pageHeight = renderer.getScreenHeight(); renderer.clearScreen(); + if (coverGridUi) { + coverGridUi->setSelection(selectorIndex); + UITheme::getInstance().drawCoverGridHome(*coverGridUi); + const auto labels = mappedInput.mapLabels(hasContinueReading ? tr(STR_RESUME) : "", tr(STR_SELECT), tr(STR_DIR_UP), + tr(STR_DIR_DOWN)); + GUI.drawButtonHints(renderer, labels.btn1, labels.btn2, labels.btn3, labels.btn4); + renderer.displayBuffer(cleanInitialRefresh && !firstRenderDone ? HalDisplay::HALF_REFRESH + : HalDisplay::FAST_REFRESH); + // Slot heights are recorded during the draw above; a change (first layout + // pass, orientation switch) means the paths must point at those sizes and + // any missing thumbs must be generated. Refreshing the paths right away + // lets the next pass draw already-cached thumbs before generation runs. + const bool coverSpecChanged = coverGridUi->takeThumbHeightsChanged(); + if (coverSpecChanged) { + coverGridUi->refreshCoverPaths(); + recentsLoaded = false; + } + if (!firstRenderDone) { + firstRenderDone = true; + requestUpdate(); + } else if (!recentsLoaded && !recentsLoading) { + loadRecentCovers(CoverGridHomeUi::THUMB_HEIGHT); + coverGridUi->refreshCoverPaths(); + requestUpdate(); + } + return; + } bool bufferRestored = coverBufferStored && restoreCoverBuffer(); // Band spans topPadding..homeTopPadding: the cover tile starts at the fixed @@ -341,7 +507,8 @@ void HomeActivity::render(RenderLock&&) { requestUpdate(); } else if (!recentsLoaded && !recentsLoading) { recentsLoading = true; - loadRecentCovers(metrics.homeCoverHeight); + const int themeThumbHeight = GUI.homeCoverThumbHeight(renderer); + loadRecentCovers(themeThumbHeight > 0 ? themeThumbHeight : metrics.homeCoverHeight); } } diff --git a/src/activities/home/HomeActivity.h b/src/activities/home/HomeActivity.h index 12187f580b4..b9f5c2243b1 100644 --- a/src/activities/home/HomeActivity.h +++ b/src/activities/home/HomeActivity.h @@ -5,17 +5,20 @@ #include "./FileBrowserActivity.h" #include "RecentBooksStore.h" #include "activities/Activity.h" +#include "components/CoverGridHomeUi.h" #include "util/ButtonNavigator.h" struct Rect; class HomeActivity final : public Activity { + std::unique_ptr coverGridUi; ButtonNavigator buttonNavigator; int selectorIndex = 0; bool recentsLoading = false; bool recentsLoaded = false; bool firstRenderDone = false; bool hasOpdsServers = false; + bool hasContinueReading = false; bool coverRendered = false; // Track if cover has been rendered once bool coverBufferStored = false; // Track if cover buffer is stored uint8_t* coverBuffer = nullptr; // HomeActivity's own buffer for cover image @@ -69,6 +72,9 @@ class HomeActivity final : public Activity { void freeCoverBuffer(); // Free the stored cover buffer void loadRecentBooks(int maxBooks); void loadRecentCovers(int coverHeight); + void fillCoverGridFromLibrary(); + void resolveGridCoverPaths(); + void loadGridCover(RecentBook& book, int height, bool& showingLoading, Rect& popupRect); public: explicit HomeActivity(GfxRenderer& renderer, MappedInputManager& mappedInput, diff --git a/src/components/CoverGridHomeUi.cpp b/src/components/CoverGridHomeUi.cpp new file mode 100644 index 00000000000..c9dfdc753ba --- /dev/null +++ b/src/components/CoverGridHomeUi.cpp @@ -0,0 +1,282 @@ +#include "CoverGridHomeUi.h" + +#include +#include + +#include +#include +#include + +#include "MappedInputManager.h" +#include "UITheme.h" +#include "icons/blocks.h" +#include "icons/book.h" +#include "icons/folder.h" +#include "icons/library.h" +#include "icons/settings2.h" +#include "icons/transfer.h" +#include "util/BookProgress.h" + +namespace fui = freeink::ui; +namespace { +constexpr fui::ActionId SELECT = 1; +} // namespace + +CoverGridHomeUi::CoverGridHomeUi(GfxRenderer& renderer) + : UiAppHost(renderer), coverCache(renderer), renderer(renderer) {} + +void CoverGridHomeUi::begin(const std::vector& recent, bool opds, bool continuing) { + books = &recent; + hasOpds = opds; + hasContinueReading = continuing; + if (!recent.empty()) coverCache.begin(); + resetUi(); + app.on(SELECT, &CoverGridHomeUi::onAction, this); + app.setScreen(&CoverGridHomeUi::screenFn, this); + refreshCoverPaths(); + progress = hasContinueReading && !books->empty() ? loadBookProgress(books->front().path) : -1; + if (progress >= 0) snprintf(progressText, sizeof(progressText), "%d%%", progress); +} + +void CoverGridHomeUi::refreshCoverPaths() { + coverCache.invalidate(); + for (size_t i = 0; i < books->size() && i < coverPaths.size(); ++i) refreshCoverPath(i); +} + +void CoverGridHomeUi::refreshCoverPath(size_t index) { + if (index >= books->size() || index >= coverPaths.size()) return; + coverCache.invalidate(index); + coverPaths[index] = thumbHeights[index] > 0 + ? UITheme::getCoverThumbPath((*books)[index].coverBmpPath, thumbHeights[index]) + : std::string(); + if (index != 0) return; + coverCache.readSize(coverPaths[0], featuredCoverWidth, featuredCoverHeight); +} + +int CoverGridHomeUi::thumbHeightFor(size_t index) const { + return index < thumbHeights.size() && thumbHeights[index] > 0 ? thumbHeights[index] : THUMB_HEIGHT; +} + +bool CoverGridHomeUi::takeThumbHeightsChanged() { return std::exchange(thumbHeightsChanged, false); } + +void CoverGridHomeUi::noteThumbHeight(size_t index, int slotWidth, int slotHeight) { + if (index >= thumbHeights.size()) return; + // Thumbs cover a (0.6*h, h) target box, so a height of max(h, w*5/3) makes + // every cover overfill the slot; the cover renderer crops the overflow (full bleed). + const int height = std::max({1, slotHeight, slotWidth * 5 / 3 + 2}); + if (thumbHeights[index] != height) { + thumbHeights[index] = height; + thumbHeightsChanged = true; + refreshCoverPath(index); + } +} + +void CoverGridHomeUi::onAction(const fui::ActionEvent& event, void* user) { + auto& self = *static_cast(user); + self.pending = event.value; + self.app.clearTapFlash(); +} + +int CoverGridHomeUi::selectedAction(const MappedInputManager& input) { + pending = -1; + const auto touch = routeTouch(input); + return touch.snap.touchReleased ? pending : -1; +} + +void CoverGridHomeUi::screenFn(UiScreen& screen, void* user) { static_cast(user)->draw(screen); } + +void CoverGridHomeUi::draw(UiScreen& screen) { + coverCache.prepare(); + const auto& theme = screen.theme(); + const auto safe = UITheme::getInstance().getScreenSafeArea(renderer, true); + screen.setContentMarginFromScreen(fui::Insets{ + static_cast(safe.y), static_cast(renderer.getScreenWidth() - safe.x - safe.width), + static_cast(renderer.getScreenHeight() - safe.y - safe.height), static_cast(safe.x)}); + screen.insetContent(fui::Insets{theme.spaceSm, theme.spaceLg, theme.spaceSm, theme.spaceLg}); + const bool landscape = renderer.getScreenWidth() > renderer.getScreenHeight(); + const auto header = screen.takeTop(UITheme::getInstance().getMetrics().batteryBarHeight); + auto tabRect = screen.takeBottom(UITheme::getInstance().getMetrics().coverGridTabBarHeight, theme.spaceMd); + if (books->empty()) { + drawTabs(screen, tabRect.inset(fui::Insets{0, 6, 0, 6})); + drawEmpty(screen); + drawHeaderBand(header, tabRect.x + 6, tabRect.x + tabRect.width - 6); + return; + } + auto headingText = theme.titleText; + headingText.bold = true; + auto headingRect = screen.takeTop(screen.target().lineHeight(headingText.font), theme.spaceSm); + // Bound the featured section while leaving room for its metadata. + const int16_t featuredHeight = std::min( + screen.body().height, std::max(std::min(240, screen.body().height * 3 / 10), + screen.target().lineHeight(theme.bodyText.font) * (landscape ? 1 : 2) + + screen.target().lineHeight(theme.smallText.font) * 2 + 32)); + drawCurrent(screen, screen.takeTop(featuredHeight, theme.spaceMd)); + drawGrid(screen); + const auto& gridRect = gridBounds; + tabRect.x = gridRect.x + grid.cellInset.left; + tabRect.width = gridRect.width - grid.cellInset.left - grid.cellInset.right; + headingRect.x = tabRect.x; + headingRect.width = tabRect.width; + screen.target().text(headingRect, hasContinueReading ? tr(STR_CONTINUE_READING) : tr(STR_START_READING), headingText); + drawTabs(screen, tabRect); + // Drawn last so it can borrow the grid geometry, like the tab bar above. + drawHeaderBand(header, tabRect.x, tabRect.x + tabRect.width); +} + +void CoverGridHomeUi::drawHeaderBand(fui::Rect header, int coverLeft, int coverRight) { + // Same alignment trick as the tabs: the clock's left edge and the battery's + // right edge sit on the outer cover columns. drawHeader anchors both at + // headerStatusInset() from the band edges, and the clock text is + // left-anchored, so 1- vs 2-digit hours never move it. + const int inset = GUI.headerStatusInset(); + const int headerX = std::max(0, coverLeft - inset); + const int headerRight = std::min(renderer.getScreenWidth(), coverRight + inset); + GUI.drawHeader(renderer, Rect{headerX, header.y, headerRight - headerX, header.height}, nullptr); +} + +void CoverGridHomeUi::drawEmpty(UiScreen& screen) { + const auto& theme = screen.theme(); + const auto body = screen.body(); + auto title = theme.titleText; + title.bold = true; + title.align = fui::TextAlign::Center; + auto message = theme.bodyText; + message.align = fui::TextAlign::Center; + constexpr int16_t ICON_SIZE = 32; + const int16_t titleHeight = screen.target().lineHeight(title.font); + const int16_t messageHeight = screen.target().lineHeight(message.font); + const int16_t contentHeight = ICON_SIZE + theme.spaceLg + titleHeight + theme.spaceSm + messageHeight; + int16_t y = body.y + std::max(0, (body.height - contentHeight) / 2); + renderer.drawIcon(BookIcon, body.x + (body.width - ICON_SIZE) / 2, y, ICON_SIZE); + y += ICON_SIZE + theme.spaceLg; + screen.target().text(fui::Rect{body.x, y, body.width, titleHeight}, tr(STR_NO_OPEN_BOOK), title); + y += titleHeight + theme.spaceSm; + screen.target().text(fui::Rect{body.x, y, body.width, messageHeight}, tr(STR_START_READING), message); +} + +void CoverGridHomeUi::drawCurrent(UiScreen& screen, fui::Rect rect) { + const auto& theme = screen.theme(); + const auto& book = books->front(); + card.title = book.title.c_str(); + card.author = book.author.empty() ? nullptr : book.author.c_str(); + card.meta = nullptr; + card.progressLabel = progress >= 0 ? progressText : nullptr; + card.centerTextOnCover = true; + card.progress = std::max(0, progress); + card.progressMax = progress >= 0 ? 100 : 0; + card.action = SELECT; + card.state = selected == 0 ? fui::StateSelected : fui::StateNormal; + card.selectionIndicator = fui::BookCardSelectionIndicator::Card; + card.styles = theme.listRow; + card.styles.selected.background = fui::Paint::dither(fui::Color::LightGray); + card.styles.selected.foreground = fui::Paint::solid(fui::Color::Black); + card.styles.selected.radius = theme.listRowRadius; + card.styles.active = card.styles.selected; + card.titleText = theme.bodyText; + card.titleText.maxLines = renderer.getScreenWidth() > renderer.getScreenHeight() ? 1 : 2; + card.authorText = theme.smallText; + card.progressText = theme.smallText; + card.progressHeight = 6; + card.padding = fui::Insets{6, 6, 6, 6}; + card.gap = theme.spaceLg + theme.spaceSm; + card.coverSize.height = std::max(1, std::min(rect.height - 12, (rect.width / 3) * 5 / 3)); + card.coverSize.width = std::max(1, card.coverSize.height * 3 / 5); + noteThumbHeight(0, card.coverSize.width, card.coverSize.height); + // Generation bounds stay stable; the displayed cover follows the actual image. + if (featuredCoverWidth > 0 && featuredCoverHeight > 0) { + const float scale = std::min(1.0f, std::min(float(card.coverSize.width) / featuredCoverWidth, + float(card.coverSize.height) / featuredCoverHeight)); + card.coverSize.width = std::max(1, static_cast(featuredCoverWidth * scale)); + card.coverSize.height = std::max(1, static_cast(featuredCoverHeight * scale)); + } + gridBounds = layoutGrid(screen, screen.body()); + rect.x = gridBounds.x; + rect.width = gridBounds.width; + card.coverPainterUserData = this; + card.coverPainter = [](fui::DrawTarget& target, fui::Rect cover, const fui::BookCardProps&, void* user) { + return static_cast(user)->paintFramedCover(target, cover, 0); + }; + fui::bookCard(screen.frame(), rect, card); +} + +fui::Rect CoverGridHomeUi::layoutGrid(UiScreen& screen, fui::Rect rect) { + const auto& theme = screen.theme(); + grid.gap = std::max(theme.spaceSm, rect.width * 2 / 100); + grid.rowGap = grid.gap; + grid.cellInset = fui::Insets{6, 6, 6, 6}; + const int maxCoverWidth = std::max(1, (rect.width - (GRID_COLUMNS - 1) * grid.gap) / GRID_COLUMNS - 12); + const int maxCoverHeight = std::max(1, (rect.height - (GRID_ROWS - 1) * grid.rowGap) / GRID_ROWS - 12); + grid.coverSize.height = std::max(1, std::min({maxCoverHeight, maxCoverWidth * 5 / 3, card.coverSize.height * 3 / 2})); + grid.coverSize.width = std::max(1, grid.coverSize.height * 3 / 5); + grid.rowHeight = grid.coverSize.height + 12; + const int gridWidth = GRID_COLUMNS * (grid.coverSize.width + 12) + (GRID_COLUMNS - 1) * grid.gap; + rect.x += (rect.width - gridWidth) / 2; + rect.width = gridWidth; + rect.height = GRID_ROWS * grid.rowHeight + (GRID_ROWS - 1) * grid.rowGap; + return rect; +} + +void CoverGridHomeUi::drawGrid(UiScreen& screen) { + const auto rect = gridBounds; + grid.count = books->size() > 1 ? books->size() - 1 : 0; + grid.columns = GRID_COLUMNS; + grid.columnLayout = fui::CoverGridColumnLayout::SpaceBetween; + grid.action = SELECT; + grid.inputMask = fui::InputTouch; + grid.selectedIndex = selected > 0 && selected < static_cast(books->size()) ? selected - 1 : -1; + grid.selectionIndicator = fui::CoverGridSelectionIndicator::Cell; + grid.cellStyles = card.styles; + grid.labelHeight = 0; + grid.labelGap = 0; + for (size_t i = 1; i < thumbHeights.size(); ++i) noteThumbHeight(i, grid.coverSize.width, grid.coverSize.height); + grid.scrollIndicator = false; + grid.itemProvider = [](uint16_t index, void*) { return fui::coverGridItem(nullptr, index + 1); }; + grid.coverPainterUserData = this; + grid.coverPainter = [](fui::DrawTarget& target, fui::Rect cover, const fui::CoverGridItem&, uint16_t index, + void* user) { + return static_cast(user)->paintFramedCover(target, cover, index + 1); + }; + fui::coverGrid(screen.frame(), rect, grid); +} + +void CoverGridHomeUi::drawTabs(UiScreen& screen, fui::Rect rect) { + static constexpr const uint8_t* ICONS[] = {FolderIcon, LibraryIcon, BlocksIcon, TransferIcon, Settings2Icon}; + int count = 0; + for (int i = 0; i < 5; ++i) { + if (i == 2 && !hasOpds) continue; + auto& tab = tabItems[count]; + tab.value = books->size() + count; + tab.selected = selected == tab.value; + tab.label = nullptr; + ++count; + } + tabs.tabs = tabItems.data(); + tabs.count = count; + tabs.layout = fui::TabBarLayout::SpaceBetween; + tabs.action = SELECT; + tabs.inputMask = fui::InputTouch; + tabs.iconSize = 32; + tabs.iconPainterUserData = this; + tabs.iconPainter = [](fui::DrawTarget&, fui::Rect iconRect, const fui::TabItem& tab, uint8_t, void* user) { + const auto& self = *static_cast(user); + const int index = tab.value - static_cast(self.books->size()); + const int icon = !self.hasOpds && index >= 2 ? index + 1 : index; + self.renderer.drawIcon(ICONS[icon], iconRect.x, iconRect.y, iconRect.width); + return true; + }; + tabs.tabStyles.normal.background = fui::Paint::solid(fui::Color::White); + tabs.tabStyles.selected.background = fui::Paint::solid(fui::Color::White); + tabs.selectedUnderline = 2; + tabs.distributedSlotWidth = 0; + fui::tabBar(screen.frame(), rect, tabs); +} + +bool CoverGridHomeUi::paintFramedCover(fui::DrawTarget& target, fui::Rect rect, size_t index) { + constexpr int16_t SHADOW_OFFSET = 2; + const auto ink = fui::Paint::solid(fui::Color::Black); + target.fill(fui::Rect{rect.right(), static_cast(rect.y + SHADOW_OFFSET), SHADOW_OFFSET, rect.height}, ink); + target.fill(fui::Rect{static_cast(rect.x + SHADOW_OFFSET), rect.bottom(), rect.width, SHADOW_OFFSET}, ink); + const bool drawn = index < coverPaths.size() && coverCache.paint(rect, index, coverPaths[index]); + target.stroke(rect, ink, 1, 0); + return drawn; +} diff --git a/src/components/CoverGridHomeUi.h b/src/components/CoverGridHomeUi.h new file mode 100644 index 00000000000..d77364dfb90 --- /dev/null +++ b/src/components/CoverGridHomeUi.h @@ -0,0 +1,65 @@ +#pragma once + +#include +#include +#include + +#include "HomeCoverCache.h" +#include "RecentBooksStore.h" +#include "UiAppHost.h" +#include "components/bars/tab-bar.h" +#include "components/media/book-card.h" +#include "components/media/cover-grid.h" + +class CoverGridHomeUi final : public UiAppHost { + public: + static constexpr int THUMB_HEIGHT = 400; + static constexpr int GRID_COLUMNS = 3; + static constexpr int GRID_ROWS = 2; + static constexpr int MAX_BOOKS = 1 + GRID_COLUMNS * GRID_ROWS; + static_assert(MAX_BOOKS <= HomeCoverCache::MAX_COVERS); + explicit CoverGridHomeUi(GfxRenderer& renderer); + void begin(const std::vector& books, bool hasOpds, bool hasContinueReading); + void refreshCoverPaths(); + void setSelection(int selection) { selected = selection; } + int selectedAction(const MappedInputManager& input); + // Exact generation height for a slot, recorded during draw. Thumbs must be + // generated at the drawn size: rescaling a dithered 1-bit image aliases badly. + int thumbHeightFor(size_t index) const; + bool takeThumbHeightsChanged(); + + private: + static void screenFn(UiScreen& screen, void* user); + static void onAction(const freeink::ui::ActionEvent& event, void* user); + void draw(UiScreen& screen); + void drawHeaderBand(freeink::ui::Rect header, int coverLeft, int coverRight); + void drawEmpty(UiScreen& screen); + void drawCurrent(UiScreen& screen, freeink::ui::Rect rect); + void drawGrid(UiScreen& screen); + freeink::ui::Rect layoutGrid(UiScreen& screen, freeink::ui::Rect rect); + void drawTabs(UiScreen& screen, freeink::ui::Rect rect); + bool paintFramedCover(freeink::ui::DrawTarget& target, freeink::ui::Rect rect, size_t index); + void refreshCoverPath(size_t index); + void noteThumbHeight(size_t index, int slotWidth, int slotHeight); + + HomeCoverCache coverCache; + GfxRenderer& renderer; + const std::vector* books = nullptr; + std::array coverPaths; + int featuredCoverWidth = 0; + int featuredCoverHeight = 0; + std::array thumbHeights{}; + bool thumbHeightsChanged = false; + int selected = 0; + int pending = -1; + int progress = -1; + bool hasOpds = false; + bool hasContinueReading = false; + char progressText[12]{}; + // Component styles and interaction tables stay off the render task's stack. + freeink::ui::BookCardProps card; + freeink::ui::CoverGridProps grid; + freeink::ui::Rect gridBounds{}; + freeink::ui::TabBarProps tabs; + std::array tabItems; +}; diff --git a/src/components/HomeCoverCache.cpp b/src/components/HomeCoverCache.cpp new file mode 100644 index 00000000000..28b5ea873e7 --- /dev/null +++ b/src/components/HomeCoverCache.cpp @@ -0,0 +1,83 @@ +#include "HomeCoverCache.h" + +#include +#include + +#include + +#include "UITheme.h" + +namespace fui = freeink::ui; + +void HomeCoverCache::begin() { + // Cover regions do not overlap. Each may widen by one physical byte per row. + coverCacheCapacity = renderer.getRegionByteSize(0, 0, renderer.getScreenWidth(), renderer.getScreenHeight()) + + MAX_COVERS * std::max(renderer.getScreenWidth(), renderer.getScreenHeight()); + coverCache = HalMemory::allocatePsram(coverCacheCapacity); + if (!coverCache) { + LOG_ERR("HOME", "PSRAM cover cache unavailable (%u bytes); rendering uncached", unsigned(coverCacheCapacity)); + coverCacheCapacity = 0; + } +} + +void HomeCoverCache::invalidate() { + coverCacheUsed = 0; + cachedCovers.fill(CachedCover{}); +} + +void HomeCoverCache::invalidate(size_t index) { + if (index < cachedCovers.size()) cachedCovers[index].valid = false; +} + +void HomeCoverCache::prepare() { + const int orientation = static_cast(renderer.getOrientation()); + if (coverCacheOrientation != orientation) { + invalidate(); + coverCacheOrientation = orientation; + } +} + +void HomeCoverCache::readSize(const std::string& path, int& width, int& height) { + width = height = 0; + if (!path.empty() && Storage.exists(path.c_str()) && Storage.openFileForRead("HOME", path, coverFile)) { + if (coverBitmap.parseHeaders() == BmpReaderError::Ok) { + width = coverBitmap.getWidth(); + height = coverBitmap.getHeight(); + } + coverFile.close(); + } +} + +bool HomeCoverCache::paint(fui::Rect rect, size_t index, const std::string& path) { + if (index >= cachedCovers.size()) return false; + auto& cached = cachedCovers[index]; + if (coverCache && cached.valid && cached.rect.x == rect.x && cached.rect.y == rect.y && + cached.rect.width == rect.width && cached.rect.height == rect.height && + renderer.copyBufferToRegion(rect.x, rect.y, rect.width, rect.height, coverCache.get() + cached.offset, + cached.bytes)) { + return true; + } + cached.valid = false; + bool drawn = false; + if (!path.empty() && Storage.openFileForRead("HOME", path, coverFile)) { + if (coverBitmap.parseHeaders() == BmpReaderError::Ok && coverBitmap.getWidth() > 0 && coverBitmap.getHeight() > 0) { + drawn = GUI.drawCoverThumbFill(renderer, coverBitmap, Rect{rect.x, rect.y, rect.width, rect.height}); + } + coverFile.close(); + } + if (!drawn) GUI.drawCoverPlaceholder(renderer, Rect{rect.x, rect.y, rect.width, rect.height}); + if (coverCache) { + const size_t needed = renderer.getRegionByteSize(rect.x, rect.y, rect.width, rect.height); + if (needed > cached.bytes && needed <= coverCacheCapacity - coverCacheUsed) { + cached.offset = coverCacheUsed; + cached.bytes = needed; + coverCacheUsed += needed; + } + if (needed > 0 && needed <= cached.bytes) { + cached.rect = rect; + cached.valid = renderer.copyRegionToBuffer(rect.x, rect.y, rect.width, rect.height, + coverCache.get() + cached.offset, cached.bytes); + } + } + return true; // The cover slot is painted, including fallback art. +} diff --git a/src/components/HomeCoverCache.h b/src/components/HomeCoverCache.h new file mode 100644 index 00000000000..8659028cbf8 --- /dev/null +++ b/src/components/HomeCoverCache.h @@ -0,0 +1,41 @@ +#pragma once + +#include +#include +#include +#include + +#include +#include + +class GfxRenderer; + +// Screen-lifetime thumbnail decoding and PSRAM region snapshots. +class HomeCoverCache { + public: + static constexpr size_t MAX_COVERS = 7; + explicit HomeCoverCache(GfxRenderer& renderer) : renderer(renderer) {} + void begin(); + void prepare(); + void invalidate(); + void invalidate(size_t index); + void readSize(const std::string& path, int& width, int& height); + bool paint(freeink::ui::Rect rect, size_t index, const std::string& path); + + private: + struct CachedCover { + freeink::ui::Rect rect{}; + size_t offset = 0; + size_t bytes = 0; + bool valid = false; + }; + HalMemory::PsramBuffer coverCache; + std::array cachedCovers{}; + size_t coverCacheCapacity = 0; + size_t coverCacheUsed = 0; + int coverCacheOrientation = -1; + + HalFile coverFile; + Bitmap coverBitmap{coverFile}; + GfxRenderer& renderer; +}; diff --git a/src/components/OptionPopup.h b/src/components/OptionPopup.h index 4bf4c2c77e4..48cd0f29fd0 100644 --- a/src/components/OptionPopup.h +++ b/src/components/OptionPopup.h @@ -34,10 +34,7 @@ class OptionPopup { for (int i = 0; i < optionCount; i++) { ownedStrings[i] = I18N.get(optionIds[i]); } - selectedIndex = currentIndex; - onSelectCallback = std::move(onSelect); - uiReady = false; - active = true; + activate(currentIndex, std::move(onSelect)); } void show(const char* titleStr, const char* const* options, int optionCount, int currentIndex, @@ -48,10 +45,7 @@ class OptionPopup { for (int i = 0; i < optionCount; i++) { ownedStrings[i] = options[i]; } - selectedIndex = currentIndex; - onSelectCallback = std::move(onSelect); - uiReady = false; - active = true; + activate(currentIndex, std::move(onSelect)); } // As above, plus a subject line inside the dialog (a book or event title). @@ -67,10 +61,7 @@ class OptionPopup { title = I18N.get(titleId); headline.clear(); ownedStrings = options; - selectedIndex = currentIndex; - onSelectCallback = std::move(onSelect); - uiReady = false; - active = true; + activate(currentIndex, std::move(onSelect)); } bool handleInput(MappedInputManager& input, const std::function& requestUpdate) { @@ -263,6 +254,14 @@ class OptionPopup { static constexpr freeink::ui::ActionId ACTION_OPTION = 1; static constexpr freeink::ui::ActionId ACTION_CHROME = 2; + void activate(int currentIndex, std::function onSelect) { + const int count = std::min(ownedStrings.size(), MAX_OPTIONS); + selectedIndex = currentIndex >= 0 && currentIndex < count ? currentIndex : 0; + onSelectCallback = std::move(onSelect); + uiReady = false; + active = count > 0; + } + bool active = false; std::string title; std::string headline; diff --git a/src/components/UITheme.cpp b/src/components/UITheme.cpp index 88d298011be..83e25f01b02 100644 --- a/src/components/UITheme.cpp +++ b/src/components/UITheme.cpp @@ -3,13 +3,16 @@ #include #include #include +#include #include +#include #include #include #include "MappedInputManager.h" #include "RecentBooksStore.h" +#include "components/CoverGridHomeUi.h" #include "components/themes/BaseTheme.h" #include "components/themes/lyra/Lyra3CoversTheme.h" #include "components/themes/lyra/LyraTheme.h" @@ -27,18 +30,34 @@ void UITheme::reload() { setTheme(themeType); } +bool UITheme::supportsCoverGrid() { return HalMemory::getPsramHeap().totalBytes > 0; } + +bool UITheme::hasCoverGridHome() { return SETTINGS.uiTheme == CrossPointSettings::COVER_GRID && supportsCoverGrid(); } + +void UITheme::drawCoverGridHome(CoverGridHomeUi& home) { home.renderUi(); } + void UITheme::setTheme(CrossPointSettings::UI_THEME type) { + if (type == CrossPointSettings::COVER_GRID && !supportsCoverGrid()) type = CrossPointSettings::LYRA; + switch (type) { case CrossPointSettings::UI_THEME::CLASSIC: LOG_DBG("UI", "Using Classic theme"); currentTheme = std::make_unique(); currentMetrics = &BaseMetrics::values; break; - case CrossPointSettings::UI_THEME::LYRA: - LOG_DBG("UI", "Using Lyra theme"); - currentTheme = std::make_unique(); + case CrossPointSettings::UI_THEME::COVER_GRID: + case CrossPointSettings::UI_THEME::LYRA: { + // The cover home owns its screen-lifetime UI state; other screens retain Lyra styling. + auto theme = makeUniqueNoThrow(); + if (!theme) { + LOG_ERR("UI", "OOM: Lyra theme"); + return; + } + currentTheme = std::move(theme); currentMetrics = &LyraMetrics::values; + LOG_DBG("UI", "Using Lyra theme"); break; + } case CrossPointSettings::UI_THEME::ROUNDEDRAFF: LOG_DBG("UI", "Using RoundedRaff theme"); currentTheme = std::make_unique(); diff --git a/src/components/UITheme.h b/src/components/UITheme.h index b83126218e4..bcd2bbc6ae6 100644 --- a/src/components/UITheme.h +++ b/src/components/UITheme.h @@ -8,6 +8,8 @@ #include "CrossPointSettings.h" #include "components/themes/BaseTheme.h" +class CoverGridHomeUi; + class UITheme { // Static instance static UITheme instance; @@ -19,7 +21,7 @@ class UITheme { static UITheme& getInstance() { return instance; } const ThemeMetrics& getMetrics() const; - const BaseTheme& getTheme() const { return *currentTheme; } + const BaseTheme& getTheme() const { return currentTheme ? *currentTheme : fallbackTheme; } Rect getScreenSafeArea(const GfxRenderer& renderer, bool hasFrontButtonHints = false, bool hasSideButtonHints = false); static void drawCenteredText(const GfxRenderer& renderer, Rect screen, int fontId, int y, const char* text, @@ -29,6 +31,9 @@ class UITheme { int maxLines, bool black = true, EpdFontFamily::Style style = EpdFontFamily::REGULAR, TextVerticalAlignment verticalAlignment = TextVerticalAlignment::CENTER); + static bool supportsCoverGrid(); + static bool hasCoverGridHome(); + static void drawCoverGridHome(CoverGridHomeUi& home); void reload(); void setTheme(CrossPointSettings::UI_THEME type); static std::string getCoverThumbPath(std::string coverBmpPath, int coverHeight); @@ -37,7 +42,8 @@ class UITheme { static int getProgressBarHeight(); private: - const ThemeMetrics* currentMetrics; + BaseTheme fallbackTheme; + const ThemeMetrics* currentMetrics = &BaseMetrics::values; std::unique_ptr currentTheme; mutable ThemeMetrics adjustedMetrics; mutable bool metricsValid = false; diff --git a/src/components/themes/BaseTheme.cpp b/src/components/themes/BaseTheme.cpp index d25a80810ab..b4b05a3824f 100644 --- a/src/components/themes/BaseTheme.cpp +++ b/src/components/themes/BaseTheme.cpp @@ -19,6 +19,7 @@ #include "components/UIThemeTokens.h" #include "components/UiAppHelpers.h" #include "components/icons/bookmark.h" +#include "components/icons/cover.h" #include "fontIds.h" // Internal constants @@ -112,6 +113,34 @@ void BaseTheme::drawBatteryLeft(const GfxRenderer& renderer, Rect rect, const bo fillBatteryIcon(renderer, iconRect, percentage); } +void BaseTheme::drawCoverPlaceholder(const GfxRenderer& renderer, Rect rect) { + if (rect.width <= 0 || rect.height <= 0) return; + const int topHeight = rect.height / 3; + renderer.fillRect(rect.x, rect.y, rect.width, rect.height, false); + renderer.fillRect(rect.x, rect.y + topHeight, rect.width, rect.height - topHeight, true); + renderer.drawRect(rect.x, rect.y, rect.width, rect.height, true); + constexpr int ICON_SIZE = 32; + if (rect.width >= ICON_SIZE + 4 && topHeight >= ICON_SIZE + 4) { + const int insetX = std::min(24, (rect.width - ICON_SIZE) / 2); + const int insetY = std::min(24, (topHeight - ICON_SIZE) / 2); + renderer.drawIcon(CoverIcon, rect.x + insetX, rect.y + insetY, ICON_SIZE); + } +} + +bool BaseTheme::drawCoverThumbFill(const GfxRenderer& renderer, const Bitmap& bitmap, Rect slot) { + if (slot.width <= 0 || slot.height <= 0) return false; + const int x = slot.x + (slot.width - bitmap.getWidth()) / 2; + const int y = slot.y + (slot.height - bitmap.getHeight()) / 2; + const auto clip = renderer.getClipRect(); + const int left = std::max(slot.x, clip[0]); + const int top = std::max(slot.y, clip[1]); + renderer.setClipRect(left, top, std::max(0, std::min(slot.x + slot.width, clip[0] + clip[2]) - left), + std::max(0, std::min(slot.y + slot.height, clip[1] + clip[3]) - top)); + const bool drawn = renderer.drawBitmap(bitmap, x, y, bitmap.getWidth(), bitmap.getHeight()); + renderer.setClipRect(clip[0], clip[1], clip[2], clip[3]); + return drawn; +} + void BaseTheme::drawProgressBar(const GfxRenderer& renderer, Rect rect, const size_t current, const size_t total) { if (total == 0) { return; @@ -273,6 +302,11 @@ void BaseTheme::drawSideButtonHints(const GfxRenderer& renderer, const char* top } } +int BaseTheme::headerStatusInset() { + const ThemeMetrics& metrics = UITheme::getInstance().getMetrics(); + return metrics.headerBatteryDetached ? 12 : metrics.headerSidePadding; +} + void BaseTheme::drawHeader(const GfxRenderer& renderer, Rect rect, const char* title, const char* subtitle) const { // Every activity header renders through the FreeInkUI header + battery // indicator components, styled by the active theme's tokens (padding, @@ -311,10 +345,11 @@ void BaseTheme::drawHeader(const GfxRenderer& renderer, Rect rect, const char* t } // Header clock, opposite the battery, on every screen that draws this - // header band (SETTINGS.clockShowInHeader). + // header band (SETTINGS.clockShowInHeader). Themes whose title layout has no + // room for the clock's left reserve opt out via headerShowsClock. char clockText[10] = {0}; int16_t clockWidth = 0; - if (SETTINGS.clockShowInHeader && halClock.isAvailable() && + if (metrics.headerShowsClock && SETTINGS.clockShowInHeader && halClock.isAvailable() && halClock.formatTime(clockText, sizeof(clockText), SETTINGS.clockFormat == 1)) { clockWidth = ui.target.measureText(fui::GfxRendererTarget::FONT_SMALL, clockText, tokens.smallText).width; } else { @@ -390,17 +425,30 @@ void BaseTheme::drawHeader(const GfxRenderer& renderer, Rect rect, const char* t // strip (batteryBarHeight) — the legacy shared-line headers drew the battery // at the top edge, and it keeps the lower-right corner free for the manual // right label below. - const int16_t batteryEdgeInset = batteryDetached ? 12 : tokens.headerSidePadding; + const int16_t batteryEdgeInset = static_cast(headerStatusInset()); const int16_t batteryX = batteryLeft ? static_cast(band.x + batteryEdgeInset) : static_cast(band.right() - batteryEdgeInset - batteryReserve); const int16_t batteryH = static_cast(metrics.batteryBarHeight); fui::batteryIndicator(ui.frame, fui::Rect{batteryX, band.y, batteryReserve, batteryH}, battery); if (clockText[0] != '\0') { + // Ink-align a left-anchored clock: the first digit's ink starts its side + // bearing right of the pen ('1' bears more than '9'), so shift the pen + // left by that bearing to keep the visible edge fixed on the inset. + int16_t clockBearing = 0; + if (!batteryLeft) { + const auto& fonts = renderer.getFontMap(); + const auto it = fonts.find(SMALL_FONT_ID); + if (it != fonts.end()) { + if (const EpdGlyph* glyph = it->second.getGlyph(static_cast(clockText[0]))) { + clockBearing = static_cast(glyph->left); + } + } + } // Same top strip and edge inset as the battery, mirrored to the other // side, so the two read as one balanced status line. const int16_t clockX = batteryLeft ? static_cast(band.right() - batteryEdgeInset - clockWidth) - : static_cast(band.x + batteryEdgeInset); + : static_cast(band.x + batteryEdgeInset - clockBearing); ui.target.text(fui::Rect{clockX, band.y, clockWidth, batteryH}, clockText, tokens.smallText); } @@ -511,8 +559,9 @@ void BaseTheme::drawRecentBookCover(GfxRenderer& renderer, Rect rect, const std: if (bitmap.parseHeaders() == BmpReaderError::Ok) { LOG_DBG("THEME", "Rendering bmp"); - // Draw the cover image (bookWidth and bookHeight already match image aspect ratio) - renderer.drawBitmap(bitmap, bookX, bookY, bookWidth, bookHeight); + // The card matches the cover aspect except when width-capped; fill + // the card 1:1 and crop the overflow rather than rescale the dither. + drawCoverThumbFill(renderer, bitmap, Rect{bookX, bookY, bookWidth, bookHeight}); // Draw border around the card renderer.drawRect(bookX, bookY, bookWidth, bookHeight); diff --git a/src/components/themes/BaseTheme.h b/src/components/themes/BaseTheme.h index ad62a8036dd..17f93ab2f0e 100644 --- a/src/components/themes/BaseTheme.h +++ b/src/components/themes/BaseTheme.h @@ -6,6 +6,7 @@ #include #include +class Bitmap; class GfxRenderer; struct RecentBook; @@ -54,11 +55,15 @@ struct ThemeMetrics { // the lower sub-band spanning the full width (Lyra), vs sharing the title // line with a width reserve (Classic, RoundedRaff). bool headerBatteryDetached; + // Header clock opt-out for themes whose title layout can't spare the left + // reserve (RoundedRaff); the user setting still governs the themes that can. + bool headerShowsClock = true; int menuRowHeight; int menuSpacing; int tabSpacing; int tabBarHeight; + int coverGridTabBarHeight = 72; // Selected-tab pill fills its equal-width slot (legacy RoundedRaff tabs) // instead of shrinking to hug the label (legacy Lyra tabs). bool tabPillFullSlot = false; @@ -221,6 +226,10 @@ class BaseTheme { virtual ~BaseTheme() = default; // Component drawing methods + static void drawCoverPlaceholder(const GfxRenderer& renderer, Rect rect); + // Draws a pre-dithered cover thumb 1:1, centered and clipped to fill the + // slot. Rescaling a dithered bitmap aliases badly, so overflow is cropped. + static bool drawCoverThumbFill(const GfxRenderer& renderer, const Bitmap& bitmap, Rect slot); static void drawProgressBar(const GfxRenderer& renderer, Rect rect, size_t current, size_t total); void drawBatteryLeft(const GfxRenderer& renderer, Rect rect, bool showPercentage = true) const; // Left aligned (reader mode) @@ -240,6 +249,9 @@ class BaseTheme { // SETTINGS.clockShowInHeader and an RTC is present. virtual void drawHeader(const GfxRenderer& renderer, Rect rect, const char* title, const char* subtitle = nullptr) const; + // Edge inset drawHeader uses for the clock/battery status line (detached + // layouts hug the corner with a legacy 12px inset instead of the padding). + static int headerStatusInset(); virtual void drawSubHeader(const GfxRenderer& renderer, Rect rect, const char* label, const char* rightLabel = nullptr) const; virtual void drawRecentBookCover(GfxRenderer& renderer, Rect rect, const std::vector& recentBooks, @@ -258,6 +270,9 @@ class BaseTheme { virtual void drawTextField(const GfxRenderer& renderer, Rect rect, const int textWidth, bool cursorMode = false, int contentStartX = 0, int contentWidth = 0) const; virtual bool showsFileIcons() const { return false; } + // Thumb generation height for home covers; 0 means use metrics.homeCoverHeight. + // Themes with slots wider than 0.6 aspect override this so covers still fill. + virtual int homeCoverThumbHeight(const GfxRenderer&) const { return 0; } // Shared constants and helpers for battery drawing (used by all themes) static constexpr int batteryPercentSpacing = 4; diff --git a/src/components/themes/lyra/Lyra3CoversTheme.cpp b/src/components/themes/lyra/Lyra3CoversTheme.cpp index f360798ca7a..5600e6f93f0 100644 --- a/src/components/themes/lyra/Lyra3CoversTheme.cpp +++ b/src/components/themes/lyra/Lyra3CoversTheme.cpp @@ -3,6 +3,7 @@ #include #include +#include #include #include #include @@ -18,6 +19,13 @@ constexpr int hPaddingInSelection = 8; constexpr int cornerRadius = 6; } // namespace +int Lyra3CoversTheme::homeCoverThumbHeight(const GfxRenderer& renderer) const { + const int tileWidth = (renderer.getScreenWidth() - 2 * Lyra3CoversMetrics::values.contentSidePadding) / 3; + // Thumbs cover a (0.6*h, h) target box; in landscape the tile is wider than + // 0.6 aspect, so request a taller thumb and let the draw crop vertically. + return std::max(Lyra3CoversMetrics::values.homeCoverHeight, (tileWidth - 2 * hPaddingInSelection) * 5 / 3 + 2); +} + void Lyra3CoversTheme::drawRecentBookCover(GfxRenderer& renderer, Rect rect, const std::vector& recentBooks, const int selectorIndex, bool& coverRendered, bool& coverBufferStored, bool& bufferRestored, std::function storeCoverBuffer) const { @@ -38,24 +46,18 @@ void Lyra3CoversTheme::drawRecentBookCover(GfxRenderer& renderer, Rect rect, con if (coverPath.empty()) { hasCover = false; } else { - const std::string coverBmpPath = - UITheme::getCoverThumbPath(coverPath, Lyra3CoversMetrics::values.homeCoverHeight); + const std::string coverBmpPath = UITheme::getCoverThumbPath(coverPath, homeCoverThumbHeight(renderer)); // First time: load cover from SD and render HalFile file; if (Storage.openFileForRead("HOME", coverBmpPath, file)) { Bitmap bitmap(file); if (bitmap.parseHeaders() == BmpReaderError::Ok) { - float coverHeight = static_cast(bitmap.getHeight()); - float coverWidth = static_cast(bitmap.getWidth()); - float ratio = coverWidth / coverHeight; - const float tileRatio = static_cast(tileWidth - 2 * hPaddingInSelection) / - static_cast(Lyra3CoversMetrics::values.homeCoverHeight); - float cropX = 1.0f - (tileRatio / ratio); - - renderer.drawBitmap(bitmap, tileX + hPaddingInSelection, tileY + hPaddingInSelection, - tileWidth - 2 * hPaddingInSelection, Lyra3CoversMetrics::values.homeCoverHeight, - cropX); + // Fill the fixed tile 1:1 and crop the overflow; the old crop + // factor went negative for covers narrower than the tile. + drawCoverThumbFill(renderer, bitmap, + Rect{tileX + hPaddingInSelection, tileY + hPaddingInSelection, + tileWidth - 2 * hPaddingInSelection, Lyra3CoversMetrics::values.homeCoverHeight}); } else { hasCover = false; } diff --git a/src/components/themes/lyra/Lyra3CoversTheme.h b/src/components/themes/lyra/Lyra3CoversTheme.h index b17faed6d9c..b825ee85f89 100644 --- a/src/components/themes/lyra/Lyra3CoversTheme.h +++ b/src/components/themes/lyra/Lyra3CoversTheme.h @@ -20,4 +20,5 @@ class Lyra3CoversTheme : public LyraTheme { void drawRecentBookCover(GfxRenderer& renderer, Rect rect, const std::vector& recentBooks, const int selectorIndex, bool& coverRendered, bool& coverBufferStored, bool& bufferRestored, std::function storeCoverBuffer) const override; + int homeCoverThumbHeight(const GfxRenderer& renderer) const override; }; diff --git a/src/components/themes/lyra/LyraTheme.cpp b/src/components/themes/lyra/LyraTheme.cpp index 031afec0088..a8a01243d54 100644 --- a/src/components/themes/lyra/LyraTheme.cpp +++ b/src/components/themes/lyra/LyraTheme.cpp @@ -236,8 +236,11 @@ void LyraTheme::drawRecentBookCover(GfxRenderer& renderer, Rect rect, const std: Bitmap bitmap(file); if (bitmap.parseHeaders() == BmpReaderError::Ok) { coverWidth = bitmap.getWidth(); - renderer.drawBitmap(bitmap, tileX + hPaddingInSelection, tileY + hPaddingInSelection, coverWidth, - LyraMetrics::values.homeCoverHeight); + // Narrow covers come out taller than the slot; fill 1:1 and crop + // vertically instead of rescaling the dither. + drawCoverThumbFill(renderer, bitmap, + Rect{tileX + hPaddingInSelection, tileY + hPaddingInSelection, coverWidth, + LyraMetrics::values.homeCoverHeight}); } else { hasCover = false; } @@ -250,11 +253,8 @@ void LyraTheme::drawRecentBookCover(GfxRenderer& renderer, Rect rect, const std: LyraMetrics::values.homeCoverHeight, true); if (!hasCover) { - // Render empty cover - renderer.fillRect(tileX + hPaddingInSelection, - tileY + hPaddingInSelection + (LyraMetrics::values.homeCoverHeight / 3), coverWidth, - 2 * LyraMetrics::values.homeCoverHeight / 3, true); - renderer.drawIcon(CoverIcon, tileX + hPaddingInSelection + 24, tileY + hPaddingInSelection + 24, 32); + drawCoverPlaceholder(renderer, Rect{tileX + hPaddingInSelection, tileY + hPaddingInSelection, coverWidth, + LyraMetrics::values.homeCoverHeight}); } coverBufferStored = storeCoverBuffer(); diff --git a/src/components/themes/roundedraff/RoundedRaffTheme.cpp b/src/components/themes/roundedraff/RoundedRaffTheme.cpp index 823bc1dda4c..167637e89f9 100644 --- a/src/components/themes/roundedraff/RoundedRaffTheme.cpp +++ b/src/components/themes/roundedraff/RoundedRaffTheme.cpp @@ -88,8 +88,11 @@ void RoundedRaffTheme::drawRecentBookCover(GfxRenderer& renderer, Rect rect, con Bitmap bitmap(file); if (bitmap.parseHeaders() == BmpReaderError::Ok) { coverWidth = bitmap.getWidth(); - renderer.drawBitmap(bitmap, tileX + (tileWidth - coverWidth) / 2, imgY, coverWidth, - RoundedRaffMetrics::values.homeCoverHeight); + // Narrow covers come out taller than the slot; fill 1:1 and crop + // vertically instead of rescaling the dither. + drawCoverThumbFill(renderer, bitmap, + Rect{tileX + (tileWidth - coverWidth) / 2, imgY, coverWidth, + RoundedRaffMetrics::values.homeCoverHeight}); renderer.maskRoundedRectOutsideCorners(tileX + (tileWidth - coverWidth) / 2, imgY, coverWidth, RoundedRaffMetrics::values.homeCoverHeight, kCoverRadius, Color::LightGray); diff --git a/src/components/themes/roundedraff/RoundedRaffTheme.h b/src/components/themes/roundedraff/RoundedRaffTheme.h index fd430b4e087..2b23072d37a 100644 --- a/src/components/themes/roundedraff/RoundedRaffTheme.h +++ b/src/components/themes/roundedraff/RoundedRaffTheme.h @@ -31,6 +31,7 @@ constexpr ThemeMetrics values = {.batteryWidth = 15, .headerTitleAlign = 0, // left .headerBatterySide = 0, .headerBatteryDetached = false, + .headerShowsClock = false, .menuRowHeight = 42, // not authoritative: getMenuRowHeight() derives the drawn height .menuSpacing = 6, .tabSpacing = 10, diff --git a/src/util/BookProgress.cpp b/src/util/BookProgress.cpp new file mode 100644 index 00000000000..bafebcffeeb --- /dev/null +++ b/src/util/BookProgress.cpp @@ -0,0 +1,77 @@ +#include "BookProgress.h" + +#include +#include +#include +#include +#include +#include +#include + +#include + +namespace { +uint32_t readLe32(const uint8_t* p) { + return uint32_t(p[0]) | (uint32_t(p[1]) << 8) | (uint32_t(p[2]) << 16) | (uint32_t(p[3]) << 24); +} +} // namespace + +int loadBookProgress(const std::string& path) { + uint8_t data[10]{}; + if (FsHelpers::hasEpubExtension(path)) { + // Metadata objects exceed the stack budget; only the featured book is loaded, once per entry. + auto epub = makeUniqueNoThrow(path, "/.crosspoint"); + if (!epub) { + LOG_ERR("HOME", "OOM: progress metadata"); + return -1; + } + if (!epub->load(false, true)) return -1; + HalFile file; + if (!Storage.openFileForRead("HOME", epub->getCachePath() + "/progress.bin", file)) return -1; + const int size = file.read(data, sizeof(data)); + if (size != 4 && size != 6 && size != 10) return -1; + const int spine = data[0] | (data[1] << 8); + const int page = data[2] | (data[3] << 8); + const int total = size >= 6 ? data[4] | (data[5] << 8) : 0; + if (epub->getSpineItemsCount() <= 0 || epub->getBookSize() == 0) return -1; + if (spine == epub->getSpineItemsCount()) return 100; + if (spine > epub->getSpineItemsCount()) return -1; + const float fraction = total > 0 && page != UINT16_MAX ? std::clamp(float(page) / total, 0.0f, 1.0f) : 0; + return std::clamp(static_cast(epub->calculateProgress(spine, fraction) * 100 + 0.5f), 0, 100); + } + if (FsHelpers::hasXtcExtension(path)) { + auto xtc = makeUniqueNoThrow(path, "/.crosspoint"); + if (!xtc) { + LOG_ERR("HOME", "OOM: XTC progress metadata"); + return -1; + } + if (!xtc->load()) return -1; + HalFile file; + if (!Storage.openFileForRead("HOME", xtc->getCachePath() + "/progress.bin", file) || file.read(data, 4) != 4) + return -1; + const uint32_t page = readLe32(data); + if (xtc->getPageCount() == 0) return -1; + if (page >= xtc->getPageCount()) return 100; + return xtc->calculateProgress(page); + } + if (FsHelpers::hasTxtExtension(path) || FsHelpers::hasMarkdownExtension(path)) { + Txt txt(path, "/.crosspoint"); + HalFile file; + if (!Storage.openFileForRead("HOME", txt.getCachePath() + "/progress.bin", file) || file.read(data, 4) != 4) + return -1; + const uint32_t page = data[0] | (data[1] << 8); + HalFile index; + // TXT index v3: magic, version, file size, four layout fields, alignment, page count. + uint8_t header[30]; + if (!Storage.openFileForRead("HOME", txt.getCachePath() + "/index.bin", index) || + index.read(header, sizeof(header)) != sizeof(header)) + return -1; + if (readLe32(header) != 0x54585449 || header[4] != 3) return -1; + const uint32_t pages = readLe32(header + 26); + if (pages == 0 || pages > (index.size() - sizeof(header)) / 4) return -1; + HalFile source; + if (!Storage.openFileForRead("HOME", path, source) || source.size() != readLe32(header + 5)) return -1; + return std::min(100, static_cast((page + 1) * 100ULL / pages)); + } + return -1; +} diff --git a/src/util/BookProgress.h b/src/util/BookProgress.h new file mode 100644 index 00000000000..54256acdde5 --- /dev/null +++ b/src/util/BookProgress.h @@ -0,0 +1,6 @@ +#pragma once + +#include + +// Saved reading percentage, or -1 when unavailable. Never builds reading caches. +int loadBookProgress(const std::string& path); diff --git a/test/content_opf_parser/ContentOpfParserTest.cpp b/test/content_opf_parser/ContentOpfParserTest.cpp index b6fd645f67a..c5ed72b4bdd 100644 --- a/test/content_opf_parser/ContentOpfParserTest.cpp +++ b/test/content_opf_parser/ContentOpfParserTest.cpp @@ -3,6 +3,7 @@ #include #include "ContentOpfParser.h" +#include "Epub/BookMetadataCache.h" namespace { @@ -82,3 +83,55 @@ TEST(ContentOpfParserMetadata, NeverEntersManifestWhenMetadataElementIsMissing) EXPECT_EQ(Storage.writeOpens, 0); EXPECT_EQ(Storage.readOpens, 0); } + +TEST(ContentOpfParserCover, ResolvesEpub2CoverWithoutReadingCacheStorage) { + const std::string xml = R"( + + + + )"; + const std::string cachePath = "/missing-cache"; + const std::string basePath = "OPS/"; + Storage = {}; + { + ContentOpfParser parser(cachePath, basePath, xml.size(), nullptr); + parse(parser, xml); + EXPECT_EQ(parser.coverItemHref, "OPS/cover.jpg"); + EXPECT_EQ(parser.guideCoverPageHref, "OPS/cover.xhtml"); + } + EXPECT_EQ(Storage.writeOpens, 0); + EXPECT_EQ(Storage.readOpens, 0); +} + +TEST(ContentOpfParserCover, ResolvesEpub3CoverWithoutReadingCacheStorage) { + const std::string xml = R"( + + )"; + const std::string cachePath = "/missing-cache"; + const std::string basePath = "OPS/"; + Storage = {}; + { + ContentOpfParser parser(cachePath, basePath, xml.size(), nullptr); + parse(parser, xml); + EXPECT_EQ(parser.coverItemHref, "OPS/cover.png"); + } + EXPECT_EQ(Storage.writeOpens, 0); + EXPECT_EQ(Storage.readOpens, 0); +} + +TEST(ContentOpfParserCover, ReadingParserStillOpensManifestCache) { + const std::string xml = R"( + + )"; + const std::string cachePath = "/reading-cache"; + const std::string basePath = "OPS/"; + BookMetadataCache cache; + Storage = {}; + { + ContentOpfParser parser(cachePath, basePath, xml.size(), &cache); + parse(parser, xml); + EXPECT_EQ(parser.coverItemHref, "OPS/cover.png"); + } + EXPECT_EQ(Storage.writeOpens, 1); + EXPECT_EQ(Storage.readOpens, 1); +} diff --git a/test/content_opf_parser/stubs/Epub.h b/test/content_opf_parser/stubs/Epub.h index 4841211f663..3e923990e94 100644 --- a/test/content_opf_parser/stubs/Epub.h +++ b/test/content_opf_parser/stubs/Epub.h @@ -1,5 +1,7 @@ #pragma once +#include + #include #include #include @@ -7,17 +9,24 @@ class HalFile { public: explicit operator bool() const { return open_; } - void close() { open_ = false; } + void close() { + EXPECT_TRUE(initialized_) << "Closing a default HalFile asserts on device"; + open_ = false; + } bool seek(size_t position) { position_ = position; return true; } size_t position() const { return position_; } int available() const { return 0; } - void markOpen() { open_ = true; } + void markOpen() { + initialized_ = true; + open_ = true; + } private: bool open_ = false; + bool initialized_ = false; size_t position_ = 0; }; diff --git a/test/inflate_stream/stubs/esp_heap_caps.h b/test/inflate_stream/stubs/esp_heap_caps.h index ab0e23a1504..c282fb7f244 100644 --- a/test/inflate_stream/stubs/esp_heap_caps.h +++ b/test/inflate_stream/stubs/esp_heap_caps.h @@ -1,9 +1,13 @@ #pragma once #include #include +#include constexpr uint32_t MALLOC_CAP_DEFAULT = 1; constexpr uint32_t MALLOC_CAP_SPIRAM = 2; constexpr uint32_t MALLOC_CAP_INTERNAL = 4; +constexpr uint32_t MALLOC_CAP_8BIT = 8; +inline void* heap_caps_malloc(size_t size, uint32_t) { return std::malloc(size); } +inline void heap_caps_free(void* ptr) { std::free(ptr); } inline size_t heap_caps_get_free_size(uint32_t) { return 0; } inline size_t heap_caps_get_largest_free_block(uint32_t) { return 0; } inline size_t heap_caps_get_total_size(uint32_t) { return 0; } From 581f59f9373fac117acdb1147ca17b19ae0ed290 Mon Sep 17 00:00:00 2001 From: Justin Mitchell Date: Tue, 22 Sep 2026 11:11:59 -0400 Subject: [PATCH 06/62] Change book card selection indicator to CoverFrame Update the selection indicator style from Card to CoverFrame for book cards in the cover grid home UI. --- src/components/CoverGridHomeUi.cpp | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/components/CoverGridHomeUi.cpp b/src/components/CoverGridHomeUi.cpp index c9dfdc753ba..01e244d064e 100644 --- a/src/components/CoverGridHomeUi.cpp +++ b/src/components/CoverGridHomeUi.cpp @@ -166,7 +166,7 @@ void CoverGridHomeUi::drawCurrent(UiScreen& screen, fui::Rect rect) { card.progressMax = progress >= 0 ? 100 : 0; card.action = SELECT; card.state = selected == 0 ? fui::StateSelected : fui::StateNormal; - card.selectionIndicator = fui::BookCardSelectionIndicator::Card; + card.selectionIndicator = fui::BookCardSelectionIndicator::CoverFrame; card.styles = theme.listRow; card.styles.selected.background = fui::Paint::dither(fui::Color::LightGray); card.styles.selected.foreground = fui::Paint::solid(fui::Color::Black); From b6cbb5e124b91631b46f7ece7434a812adfcd1e2 Mon Sep 17 00:00:00 2001 From: Justin Mitchell Date: Tue, 22 Sep 2026 12:31:41 -0400 Subject: [PATCH 07/62] feat: Add CrossInk style swipe and tap controls (#3586) Co-authored-by: Julia Nguyen Co-authored-by: julian naydichev <489233+naydichev@users.noreply.github.com> --- lib/I18n/translations/arabic.yaml | 2 - lib/I18n/translations/catalan.yaml | 2 - lib/I18n/translations/english.yaml | 10 +++- lib/I18n/translations/german.yaml | 2 - lib/I18n/translations/hebrew.yaml | 2 - lib/I18n/translations/italian.yaml | 2 - lib/I18n/translations/persian.yaml | 2 - lib/I18n/translations/russian.yaml | 2 - lib/I18n/translations/spanish.yaml | 2 - lib/I18n/translations/swedish.yaml | 2 - lib/I18n/translations/valencian.yaml | 2 - src/CrossPointSettings.cpp | 15 ++++++ src/CrossPointSettings.h | 40 +++++++++++----- src/MappedInputManager.cpp | 6 +++ src/SettingsList.h | 25 +++++++--- src/activities/reader/ReaderUtils.h | 70 +++++++++++++++------------- 16 files changed, 113 insertions(+), 73 deletions(-) diff --git a/lib/I18n/translations/arabic.yaml b/lib/I18n/translations/arabic.yaml index 765462c54e1..a599417c565 100644 --- a/lib/I18n/translations/arabic.yaml +++ b/lib/I18n/translations/arabic.yaml @@ -261,8 +261,6 @@ STR_HIDE: "إخفاء" STR_STATE_ON: "تشغيل" STR_STATE_OFF: "إيقاف" STR_STATE_TAP: "نقر" -STR_STATE_SWIPE: "تمرير" -STR_STATE_INVERTED_TAP: "نقر معكوس" STR_FRONTLIGHT: "الإضاءة الأمامية" STR_BRIGHTNESS: "السطوع" STR_WARMTH: "الدفء" diff --git a/lib/I18n/translations/catalan.yaml b/lib/I18n/translations/catalan.yaml index 9c03fd64a76..2a752996441 100644 --- a/lib/I18n/translations/catalan.yaml +++ b/lib/I18n/translations/catalan.yaml @@ -271,9 +271,7 @@ STR_KEY_SHIFT: "Maj" STR_KEY_MODE_SYMBOLS: "?123" STR_KEY_MODE_ABC: "abc" STR_STATE_TAP: "Toc" -STR_STATE_SWIPE: "Lliscament" STR_STATE_SWIPE_UP: "Llisca cap amunt" -STR_STATE_INVERTED_TAP: "Toc invertit" STR_FRONTLIGHT: "Llum frontal" STR_BRIGHTNESS: "Brillantor" STR_WARMTH: "Calidesa" diff --git a/lib/I18n/translations/english.yaml b/lib/I18n/translations/english.yaml index 0289eb05658..3e9b776e1f9 100644 --- a/lib/I18n/translations/english.yaml +++ b/lib/I18n/translations/english.yaml @@ -223,6 +223,14 @@ STR_ORIENTATION_INVERTED: "Portrait 180°" STR_LANDSCAPE_CCW: "Landscape CCW" STR_PREV_NEXT: "Prev/Next" STR_NEXT_PREV: "Next/Prev" +STR_NEXT_NEXT: "Next/Next" +STR_PREV_PREV: "Prev/Prev" +STR_NEXT_PAGE_GESTURE: "Next Page" +STR_PREV_PAGE_GESTURE: "Previous Page" +STR_TAP_AND_SWIPE: "Tap & Swipe" +STR_TAP_ONLY: "Tap Only" +STR_SWIPE_ONLY: "Swipe Only" +STR_INVERTED_TAP: "Inverted Tap" STR_KOSYNC: "KOSync" STR_BOOKMARK_OPTION: "Bookmark" STR_DICTIONARY: "Dictionary" @@ -294,9 +302,7 @@ STR_HIDE: "Hide" STR_STATE_ON: "On" STR_STATE_OFF: "Off" STR_STATE_TAP: "Tap" -STR_STATE_SWIPE: "Swipe" STR_STATE_SWIPE_UP: "Swipe Up" -STR_STATE_INVERTED_TAP: "Inverted Tap" STR_FRONTLIGHT: "Frontlight" STR_BRIGHTNESS: "Brightness" STR_WARMTH: "Warmth" diff --git a/lib/I18n/translations/german.yaml b/lib/I18n/translations/german.yaml index 18fec2cf41b..f2a1f34fc61 100644 --- a/lib/I18n/translations/german.yaml +++ b/lib/I18n/translations/german.yaml @@ -462,9 +462,7 @@ STR_TOUCH_TOGGLE: "Touch" STR_SHOW_READER_MENU: "Lesemenü anzeigen" STR_READER_MENU: "Lesemenü" STR_STATE_TAP: "Tippen" -STR_STATE_SWIPE: "Wischen" STR_STATE_SWIPE_UP: "Nach oben wischen" -STR_STATE_INVERTED_TAP: "Invertiertes Tippen" STR_FRONTLIGHT: "Frontlicht" STR_BRIGHTNESS: "Helligkeit" STR_WARMTH: "Wärme" diff --git a/lib/I18n/translations/hebrew.yaml b/lib/I18n/translations/hebrew.yaml index 96c14e073db..cb9080daa70 100644 --- a/lib/I18n/translations/hebrew.yaml +++ b/lib/I18n/translations/hebrew.yaml @@ -196,9 +196,7 @@ STR_LANDSCAPE_CCW: "לרוחב (שמאלה)" STR_PREV_NEXT: "הקודם/הבא" STR_NEXT_PREV: "הבא/הקודם" STR_STATE_TAP: "הקשה" -STR_STATE_SWIPE: "החלקה" STR_STATE_SWIPE_UP: "החלקה מעלה" -STR_STATE_INVERTED_TAP: "הקשה הפוכה" STR_FRONTLIGHT: "תאורה קדמית" STR_BRIGHTNESS: "בהירות" STR_WARMTH: "חמימות" diff --git a/lib/I18n/translations/italian.yaml b/lib/I18n/translations/italian.yaml index 1dbab90fcea..a5dde67b534 100644 --- a/lib/I18n/translations/italian.yaml +++ b/lib/I18n/translations/italian.yaml @@ -391,10 +391,8 @@ STR_SLEEPING: "SOSPENSIONE" STR_SMART_SYNC: "Smart sync" STR_START_READING: "Inizia a leggere di seguito" STR_STARTING_HOTSPOT: "Avvio Hotspot..." -STR_STATE_INVERTED_TAP: "Tocco invertito" STR_STATE_OFF: "OFF" STR_STATE_ON: "ON" -STR_STATE_SWIPE: "Scorrimento" STR_STATE_SWIPE_UP: "Scorrimento in alto" STR_STATE_TAP: "Tocco" STR_STEP_HINT_FRONT: "Pulsanti frontali:" diff --git a/lib/I18n/translations/persian.yaml b/lib/I18n/translations/persian.yaml index 1391af2c2e8..2d95e36b9ad 100644 --- a/lib/I18n/translations/persian.yaml +++ b/lib/I18n/translations/persian.yaml @@ -270,9 +270,7 @@ STR_HIDE: "مخفی" STR_STATE_ON: "روشن" STR_STATE_OFF: "خاموش" STR_STATE_TAP: "لمس" -STR_STATE_SWIPE: "کشیدن" STR_STATE_SWIPE_UP: "کشیدن به بالا" -STR_STATE_INVERTED_TAP: "لمس معکوس" STR_FRONTLIGHT: "نور صفحه" STR_BRIGHTNESS: "روشنایی" STR_WARMTH: "گرمی نور" diff --git a/lib/I18n/translations/russian.yaml b/lib/I18n/translations/russian.yaml index cae3acb8d8d..9e1c8574680 100644 --- a/lib/I18n/translations/russian.yaml +++ b/lib/I18n/translations/russian.yaml @@ -269,9 +269,7 @@ STR_HIDE: "Скрыть" STR_STATE_ON: "ВКЛ" STR_STATE_OFF: "ВЫКЛ" STR_STATE_TAP: "Тап" -STR_STATE_SWIPE: "Свайп" STR_STATE_SWIPE_UP: "Свайп вверх" -STR_STATE_INVERTED_TAP: "Обратный тап" STR_FRONTLIGHT: "Подсветка" STR_BRIGHTNESS: "Яркость" STR_WARMTH: "Теплота" diff --git a/lib/I18n/translations/spanish.yaml b/lib/I18n/translations/spanish.yaml index e31d2b318a2..cfc06ea17af 100644 --- a/lib/I18n/translations/spanish.yaml +++ b/lib/I18n/translations/spanish.yaml @@ -276,9 +276,7 @@ STR_KEY_SHIFT: "Mayús" STR_KEY_MODE_SYMBOLS: "?123" STR_KEY_MODE_ABC: "abc" STR_STATE_TAP: "Toque" -STR_STATE_SWIPE: "Deslizamiento" STR_STATE_SWIPE_UP: "Deslizar hacia arriba" -STR_STATE_INVERTED_TAP: "Toque invertido" STR_FRONTLIGHT: "Luz frontal" STR_BRIGHTNESS: "Brillo" STR_WARMTH: "Calidez" diff --git a/lib/I18n/translations/swedish.yaml b/lib/I18n/translations/swedish.yaml index 3ecd09b86fe..1fad734e543 100644 --- a/lib/I18n/translations/swedish.yaml +++ b/lib/I18n/translations/swedish.yaml @@ -269,9 +269,7 @@ STR_HIDE: "Dölj" STR_STATE_ON: "PÅ" STR_STATE_OFF: "AV" STR_STATE_TAP: "Tryck" -STR_STATE_SWIPE: "Svep" STR_STATE_SWIPE_UP: "Svep upp" -STR_STATE_INVERTED_TAP: "Inverterat tryck" STR_FRONTLIGHT: "Frontljus" STR_BRIGHTNESS: "Ljusstyrka" STR_WARMTH: "Värme" diff --git a/lib/I18n/translations/valencian.yaml b/lib/I18n/translations/valencian.yaml index 255edae7e04..ce45d6e96b8 100644 --- a/lib/I18n/translations/valencian.yaml +++ b/lib/I18n/translations/valencian.yaml @@ -277,9 +277,7 @@ STR_KEY_SHIFT: "Maj" STR_KEY_MODE_SYMBOLS: "?123" STR_KEY_MODE_ABC: "abc" STR_STATE_TAP: "Toc" -STR_STATE_SWIPE: "Lliscament" STR_STATE_SWIPE_UP: "Llisca cap amunt" -STR_STATE_INVERTED_TAP: "Toc invertit" STR_FRONTLIGHT: "Llum frontal" STR_BRIGHTNESS: "Brillantor" STR_WARMTH: "Calidesa" diff --git a/src/CrossPointSettings.cpp b/src/CrossPointSettings.cpp index 917054cde32..616cdf0f407 100644 --- a/src/CrossPointSettings.cpp +++ b/src/CrossPointSettings.cpp @@ -179,6 +179,21 @@ bool CrossPointSettings::fromJson(JsonVariantConst doc) { } } + // Older files stored one combined touch mode under "touchReaderControls": + // 0=off, 1=tap, 2=swipe, 3=inverted tap. Split it into the master toggle + // plus the per-direction gesture pair (the generic loop above already folded + // out-of-range toggle values back to the On default). + if (doc["pageTurnGesture"].isNull() && doc["previousPageGesture"].isNull() && + doc["touchReaderControls"].is()) { + const uint8_t mode = doc["touchReaderControls"].as(); + if (mode >= 1 && mode <= 3) { + touchReaderControls = TOUCH_READER_ON; + pageTurnGesture = mode == 1 ? TAP_ONLY : mode == 2 ? SWIPE_ONLY : INVERTED_TAP; + previousPageGesture = pageTurnGesture; + needsResave = true; + } + } + if (doc["sleepTimeoutMinutes"].isNull() && !doc["sleepTimeout"].isNull()) { const uint8_t legacyValue = clamp(doc["sleepTimeout"] | (uint8_t)SLEEP_10_MIN, SLEEP_TIMEOUT_COUNT, (uint8_t)SLEEP_10_MIN); diff --git a/src/CrossPointSettings.h b/src/CrossPointSettings.h index 801a28c1ab0..73df56b410a 100644 --- a/src/CrossPointSettings.h +++ b/src/CrossPointSettings.h @@ -94,8 +94,16 @@ class CrossPointSettings : public PersistableStore { }; // Side button layout options - // Default: Up = Previous, Down = Next - enum SIDE_BUTTON_LAYOUT { PREV_NEXT = 0, NEXT_PREV = 1, SIDE_BUTTONS_DISABLED = 2, SIDE_BUTTON_LAYOUT_COUNT }; + // Default: Up = Previous, Down = Next. NEXT_NEXT / PREV_PREV assign both + // buttons to the same direction for one-handed reading. + enum SIDE_BUTTON_LAYOUT { + PREV_NEXT = 0, + NEXT_PREV = 1, + SIDE_BUTTONS_DISABLED = 2, + NEXT_NEXT = 3, + PREV_PREV = 4, + SIDE_BUTTON_LAYOUT_COUNT + }; // Font family options (built-in fonts only; SD card fonts use sdFontFamilyName) enum FONT_FAMILY { NOTOSERIF = 0, NOTOSANS = 1, FONT_FAMILY_COUNT }; @@ -137,7 +145,8 @@ class CrossPointSettings : public PersistableStore { REFRESH_FREQUENCY_COUNT }; - // Short power button press actions + // Short power button press actions. PWR_CONFIRM is only offered on touch + // boards (see SettingsList.h). enum SHORT_PWRBTN { IGNORE = 0, SLEEP = 1, @@ -185,12 +194,18 @@ class CrossPointSettings : public PersistableStore { enum TILT_PAGE_TURN { TILT_OFF = 0, TILT_NORMAL = 1, TILT_NVERTED = 2, TILT_PAGE_TURN_COUNT }; - enum TOUCH_READER_CONTROLS { - TOUCH_READER_OFF = 0, - TOUCH_READER_ON = 1, - TOUCH_READER_SWIPE = 2, - TOUCH_READER_INVERTED_TAP = 3, - TOUCH_READER_CONTROLS_COUNT + enum TOUCH_READER_CONTROLS { TOUCH_READER_OFF = 0, TOUCH_READER_ON = 1, TOUCH_READER_CONTROLS_COUNT }; + + // Per-direction reader page-turn gestures. INVERTED_TAP is tap-only; either + // direction set to it swaps both directions' shared tap zones (see + // ReaderUtils::detectTouchPageTurn). + enum PAGE_TURN_GESTURE { + TAP_AND_SWIPE = 0, + TAP_ONLY = 1, + SWIPE_ONLY = 2, + INVERTED_TAP = 3, + PAGE_TURN_GESTURE_DISABLED = 4, + PAGE_TURN_GESTURE_COUNT }; // How the reader menu opens on touch boards. Persisted under the legacy @@ -333,8 +348,11 @@ class CrossPointSettings : public PersistableStore { uint8_t imageRendering = IMAGES_DISPLAY; // Tilt-based page turning (X3 only — requires QMI8658 IMU) uint8_t tiltPageTurn = TILT_OFF; - // Touch screen reader zones/gestures on boards with a touch controller. - uint8_t touchReaderControls = TOUCH_READER_SWIPE; + // Master reader-touch toggle on boards with a touch controller. + uint8_t touchReaderControls = TOUCH_READER_ON; + // Which gestures turn the page in each direction (PAGE_TURN_GESTURE). + uint8_t pageTurnGesture = SWIPE_ONLY; + uint8_t previousPageGesture = SWIPE_ONLY; // Reader menu open gesture (SHOW_READER_MENU: off / center tap / bottom-edge // up-swipe). Only surfaced on home-key boards, where Home is the capacitive // key and the bottom edge is free; elsewhere it stays at the Tap default. diff --git a/src/MappedInputManager.cpp b/src/MappedInputManager.cpp index 96e3512a528..bc47b0e9377 100644 --- a/src/MappedInputManager.cpp +++ b/src/MappedInputManager.cpp @@ -110,6 +110,9 @@ bool MappedInputManager::mapButton(const Button button, bool (HalGPIO::*fn)(uint return (gpio.*fn)(isNavDirectionSwapped() ? HalGPIO::BTN_DOWN : HalGPIO::BTN_UP); case CrossPointSettings::NEXT_PREV: return (gpio.*fn)(isNavDirectionSwapped() ? HalGPIO::BTN_UP : HalGPIO::BTN_DOWN); + case CrossPointSettings::PREV_PREV: + return (gpio.*fn)(HalGPIO::BTN_UP) || (gpio.*fn)(HalGPIO::BTN_DOWN); + case CrossPointSettings::NEXT_NEXT: case CrossPointSettings::SIDE_BUTTONS_DISABLED: default: return false; @@ -121,6 +124,9 @@ bool MappedInputManager::mapButton(const Button button, bool (HalGPIO::*fn)(uint return (gpio.*fn)(isNavDirectionSwapped() ? HalGPIO::BTN_UP : HalGPIO::BTN_DOWN); case CrossPointSettings::NEXT_PREV: return (gpio.*fn)(isNavDirectionSwapped() ? HalGPIO::BTN_DOWN : HalGPIO::BTN_UP); + case CrossPointSettings::NEXT_NEXT: + return (gpio.*fn)(HalGPIO::BTN_UP) || (gpio.*fn)(HalGPIO::BTN_DOWN); + case CrossPointSettings::PREV_PREV: case CrossPointSettings::SIDE_BUTTONS_DISABLED: default: return false; diff --git a/src/SettingsList.h b/src/SettingsList.h index b84e3e5b54d..5171b649d10 100644 --- a/src/SettingsList.h +++ b/src/SettingsList.h @@ -317,12 +317,19 @@ inline std::vector getSettingsList(const SdCardFontRegistry* regist StrId::STR_CAT_READER), // --- Controls --- SettingInfo::Enum(StrId::STR_SIDE_BTN_LAYOUT, &CrossPointSettings::sideButtonLayout, - {StrId::STR_PREV_NEXT, StrId::STR_NEXT_PREV, StrId::STR_DISABLED}, "sideButtonLayout", - StrId::STR_CAT_CONTROLS), - SettingInfo::Enum( - StrId::STR_TOUCH_READER_CONTROLS, &CrossPointSettings::touchReaderControls, - {StrId::STR_STATE_OFF, StrId::STR_STATE_TAP, StrId::STR_STATE_SWIPE, StrId::STR_STATE_INVERTED_TAP}, - "touchReaderControls", StrId::STR_CAT_CONTROLS), + {StrId::STR_PREV_NEXT, StrId::STR_NEXT_PREV, StrId::STR_DISABLED, StrId::STR_NEXT_NEXT, + StrId::STR_PREV_PREV}, + "sideButtonLayout", StrId::STR_CAT_CONTROLS), + SettingInfo::Toggle(StrId::STR_TOUCH_READER_CONTROLS, &CrossPointSettings::touchReaderControls, + "touchReaderControls", StrId::STR_CAT_CONTROLS), + SettingInfo::Enum(StrId::STR_NEXT_PAGE_GESTURE, &CrossPointSettings::pageTurnGesture, + {StrId::STR_TAP_AND_SWIPE, StrId::STR_TAP_ONLY, StrId::STR_SWIPE_ONLY, + StrId::STR_INVERTED_TAP, StrId::STR_DISABLED}, + "pageTurnGesture", StrId::STR_CAT_CONTROLS), + SettingInfo::Enum(StrId::STR_PREV_PAGE_GESTURE, &CrossPointSettings::previousPageGesture, + {StrId::STR_TAP_AND_SWIPE, StrId::STR_TAP_ONLY, StrId::STR_SWIPE_ONLY, + StrId::STR_INVERTED_TAP, StrId::STR_DISABLED}, + "previousPageGesture", StrId::STR_CAT_CONTROLS), // Persisted under the legacy "tapForReaderMenu" key: old saves map // 0 = Off, 1 = Tap. SettingInfo::Enum(StrId::STR_SHOW_READER_MENU, &CrossPointSettings::showReaderMenu, @@ -498,7 +505,11 @@ inline std::vector getSettingsList(const SdCardFontRegistry* regist // The reader menu style stays available on button boards (the toolbar // chrome is button-navigable); only the touch controls are hidden. v.erase(std::remove_if(v.begin(), v.end(), - [](const SettingInfo& s) { return s.nameId == StrId::STR_TOUCH_READER_CONTROLS; }), + [](const SettingInfo& s) { + return s.nameId == StrId::STR_TOUCH_READER_CONTROLS || + s.nameId == StrId::STR_NEXT_PAGE_GESTURE || + s.nameId == StrId::STR_PREV_PAGE_GESTURE; + }), v.end()); } // The reader-menu gesture choice only makes sense where the menu stays diff --git a/src/activities/reader/ReaderUtils.h b/src/activities/reader/ReaderUtils.h index 782d0e9766b..ff23401bb57 100644 --- a/src/activities/reader/ReaderUtils.h +++ b/src/activities/reader/ReaderUtils.h @@ -5,7 +5,6 @@ #include #include #include -#include #include "MappedInputManager.h" #include "activities/ActivityManager.h" @@ -18,10 +17,14 @@ constexpr unsigned long SKIP_HOLD_MS = 700; constexpr unsigned long BOOKMARK_HOLD_MS = 400; constexpr unsigned long BOOKMARK_MESSAGE_DURATION_MS = 2500; -enum ReaderTouchAction : freeink::ui::ActionId { - READER_TOUCH_PREV = 1, - READER_TOUCH_NEXT = 3, -}; +inline bool gestureAllowsSwipe(const uint8_t gesture) { + return gesture == CrossPointSettings::TAP_AND_SWIPE || gesture == CrossPointSettings::SWIPE_ONLY; +} + +inline bool gestureAllowsTap(const uint8_t gesture) { + return gesture == CrossPointSettings::TAP_AND_SWIPE || gesture == CrossPointSettings::TAP_ONLY || + gesture == CrossPointSettings::INVERTED_TAP; +} inline void applyOrientation(GfxRenderer& renderer, const uint8_t orientation) { switch (orientation) { @@ -81,15 +84,17 @@ inline TouchPageTurn detectTouchPageTurn(const GfxRenderer& renderer, const Mapp return result; } - if (SETTINGS.touchReaderControls == CrossPointSettings::TOUCH_READER_SWIPE) { - // Horizontal swipes turn pages; taps remain free for the centered reader-menu - // zone. A slow swipe never becomes a long-press chapter skip. - const auto dir = input.wasSwipe(); - if (dir == MappedInputManager::SwipeDir::Left) { - result.next = true; - } else if (dir == MappedInputManager::SwipeDir::Right) { - result.prev = true; - } + // A slow swipe never becomes a long-press chapter skip. + const auto dir = input.wasSwipe(); + if (dir != MappedInputManager::SwipeDir::None) { + result.next = dir == MappedInputManager::SwipeDir::Left && gestureAllowsSwipe(SETTINGS.pageTurnGesture); + result.prev = dir == MappedInputManager::SwipeDir::Right && gestureAllowsSwipe(SETTINGS.previousPageGesture); + return result; + } + + const bool nextTaps = gestureAllowsTap(SETTINGS.pageTurnGesture); + const bool prevTaps = gestureAllowsTap(SETTINGS.previousPageGesture); + if (!nextTaps && !prevTaps) { return result; } @@ -99,31 +104,30 @@ inline TouchPageTurn detectTouchPageTurn(const GfxRenderer& renderer, const Mapp return result; } - const int16_t width = static_cast(renderer.getScreenWidth()); - const int16_t height = static_cast(renderer.getScreenHeight()); - // Outer thirds only: the center column contains the reader-menu tap target - // (isTouchMenuTap below), so it must not double as a page turn. - const int16_t zoneWidth = width / 3; - const bool inverted = SETTINGS.touchReaderControls == CrossPointSettings::TOUCH_READER_INVERTED_TAP; - const freeink::ui::TapZone zones[] = { - {freeink::ui::Rect{0, 0, zoneWidth, height}, inverted ? READER_TOUCH_NEXT : READER_TOUCH_PREV}, - {freeink::ui::Rect{static_cast(width - zoneWidth), 0, zoneWidth, height}, - inverted ? READER_TOUCH_PREV : READER_TOUCH_NEXT}, - }; - - for (const auto& zone : zones) { - if (!zone.enabled || !zone.rect.contains(static_cast(x), static_cast(y))) continue; - result.prev = zone.action == READER_TOUCH_PREV; - result.next = zone.action == READER_TOUCH_NEXT; - break; + const int width = renderer.getScreenWidth(); + const int height = renderer.getScreenHeight(); + // The centered reader-menu tap target (isTouchMenuTap below) keeps priority + // over the page-turn zones. + if (SETTINGS.showReaderMenu == CrossPointSettings::READER_MENU_TAP && x >= width / 3 && x < width - width / 3 && + y >= height / 3 && y < height - height / 3) { + return result; } + + // Give the whole page to the sole tap-enabled direction. When both accept + // taps, split at the left third; either Inverted Tap setting swaps the + // shared zones. + const bool inverted = SETTINGS.pageTurnGesture == CrossPointSettings::INVERTED_TAP || + SETTINGS.previousPageGesture == CrossPointSettings::INVERTED_TAP; + const bool nextZone = inverted ? x < (width * 2) / 3 : x >= width / 3; + result.next = nextTaps && (!prevTaps || nextZone); + result.prev = prevTaps && (!nextTaps || !nextZone); result.heldMs = gpio.lastTouchHeldMs(); return result; } // Tap in the center third of the screen: the tap path into the reader menu on -// every touch board. The page-turn tap zones are the outer horizontal thirds, -// so the centered rectangle remains free in tap mode. The Off/Swipe Up +// every touch board. detectTouchPageTurn() excludes this centered rectangle, +// so it remains free in tap mode. The Off/Swipe Up // alternatives are only surfaced on home-key boards (SettingsList), where the // menu stays reachable through the key's long-press function. inline bool isTouchMenuTap(const GfxRenderer& renderer, const MappedInputManager& input) { From 04ca1d53c0759fbb3143e3e6957d8501c29ee56d Mon Sep 17 00:00:00 2001 From: Sung-jin Brian Hong Date: Wed, 23 Sep 2026 02:03:59 +0900 Subject: [PATCH 08/62] perf: reduce per-pixel overhead in font rendering (#3633) --- lib/GfxRenderer/GfxRenderer.cpp | 116 ++++++++----------- lib/GfxRenderer/GfxRenderer.h | 7 ++ lib/GfxRenderer/GlyphBitmap.h | 155 ++++++++++++++++++++++++++ test/CMakeLists.txt | 1 + test/glyph_bitmap/CMakeLists.txt | 3 + test/glyph_bitmap/GlyphBitmapTest.cpp | 126 +++++++++++++++++++++ 6 files changed, 337 insertions(+), 71 deletions(-) create mode 100644 lib/GfxRenderer/GlyphBitmap.h create mode 100644 test/glyph_bitmap/CMakeLists.txt create mode 100644 test/glyph_bitmap/GlyphBitmapTest.cpp diff --git a/lib/GfxRenderer/GfxRenderer.cpp b/lib/GfxRenderer/GfxRenderer.cpp index c2056349929..c97f01bcaf2 100644 --- a/lib/GfxRenderer/GfxRenderer.cpp +++ b/lib/GfxRenderer/GfxRenderer.cpp @@ -13,6 +13,7 @@ #include "../Memory/Memory.h" #include "FontCacheManager.h" +#include "GlyphBitmap.h" namespace { constexpr int trackingBetween(const uint32_t leftCp, const uint32_t rightCp, const int8_t tracking) { @@ -499,78 +500,51 @@ static void renderCharImpl(const GfxRenderer& renderer, GfxRenderer::RenderMode } const uint8_t* bitmap = renderer.getGlyphBitmap(fontData, glyph); + if (bitmap == nullptr) return; - if (bitmap != nullptr) { - // For Normal: outer loop advances screenY, inner loop advances screenX - // For Rotated: outer loop advances screenX, inner loop advances screenY (in reverse) - int outerBase, innerBase; - if constexpr (rotation == TextRotation::Rotated90CW) { - outerBase = cursorX + fontData->ascender - top; // screenX = outerBase + glyphY - innerBase = cursorY - left; // screenY = innerBase - glyphX - } else { - outerBase = cursorY - top; // screenY = outerBase + glyphY - innerBase = cursorX + left; // screenX = innerBase + glyphX - } - - if (is2Bit) { - int pixelPosition = 0; - for (int glyphY = 0; glyphY < height; glyphY++) { - const int outerCoord = outerBase + glyphY; - for (int glyphX = 0; glyphX < width; glyphX++, pixelPosition++) { - int screenX, screenY; - if constexpr (rotation == TextRotation::Rotated90CW) { - screenX = outerCoord; - screenY = innerBase - glyphX; - } else { - screenX = innerBase + glyphX; - screenY = outerCoord; - } - - const uint8_t byte = bitmap[pixelPosition >> 2]; - const uint8_t bit_index = (3 - (pixelPosition & 3)) * 2; - // the direct bit from the font is 0 -> white, 1 -> light gray, 2 -> dark gray, 3 -> black - // we swap this to better match the way images and screen think about colors: - // 0 -> black, 1 -> dark grey, 2 -> light grey, 3 -> white - const uint8_t bmpVal = 3 - ((byte >> bit_index) & 0x3); - - if (renderMode == GfxRenderer::BW && bmpVal < 3) { - // Black (also paints over the grays in BW mode) - renderer.drawPixel(screenX, screenY, pixelState); - } else if (renderMode == GfxRenderer::GRAYSCALE_MSB && (bmpVal == 1 || bmpVal == 2)) { - // Light gray (also mark the MSB if it's going to be a dark gray too) - // Dedicated X3 gray LUTs now provide proper 4-level gray on both devices - // We have to flag pixels in reverse for the gray buffers, as 0 leave alone, 1 update - renderer.drawPixel(screenX, screenY, false); - } else if (renderMode == GfxRenderer::GRAYSCALE_LSB && bmpVal == 1) { - // Dark gray - renderer.drawPixel(screenX, screenY, false); - } - } - } - } else { - int pixelPosition = 0; - for (int glyphY = 0; glyphY < height; glyphY++) { - const int outerCoord = outerBase + glyphY; - for (int glyphX = 0; glyphX < width; glyphX++, pixelPosition++) { - int screenX, screenY; - if constexpr (rotation == TextRotation::Rotated90CW) { - screenX = outerCoord; - screenY = innerBase - glyphX; - } else { - screenX = innerBase + glyphX; - screenY = outerCoord; - } - - const uint8_t byte = bitmap[pixelPosition >> 3]; - const uint8_t bit_index = 7 - (pixelPosition & 7); - - if ((byte >> bit_index) & 1) { - renderer.drawPixel(screenX, screenY, pixelState); - } - } - } - } - } + // Logical placement of glyph pixel (0, 0) and the logical step for one move + // along each glyph axis. Rotated text runs glyph x up the screen and glyph + // y to the right. + glyphBitmap::Frame frame; + if constexpr (rotation == TextRotation::Rotated90CW) { + frame = {cursorX + fontData->ascender - top, cursorY - left, 0, -1, 1, 0}; + } else { + frame = {cursorX + left, cursorY - top, 1, 0, 0, 1}; + } + renderer.drawGlyphBitmap(bitmap, width, height, frame, is2Bit, renderMode, pixelState); +} + +// Draw an unscaled glyph placed by a logical frame. Equivalent to calling +// drawPixel() for each ink pixel, but the clip test, orientation rotation, +// strip-band check and address math are resolved once per glyph rather than +// once per pixel. +void GfxRenderer::drawGlyphBitmap(const uint8_t* bitmap, const int width, const int height, + const glyphBitmap::Frame& frame, const bool twoBit, const RenderMode mode, + const bool state) const { + // Apply the logical clip rectangle before rotating, in glyph-local pixels. + glyphBitmap::Clip clip{0, 0, width, height}; + glyphBitmap::clipToRect(frame, clipLeft_, clipTop_, clipRight_, clipBottom_, clip); + + // Writes go to the framebuffer, or to the strip scratch in tiled grayscale + // mode; getWriteOriginY()/getWriteRows() bound the rows that exist there. + glyphBitmap::Target target{getWriteTarget(), panelWidth, panelWidthBytes, getWriteOriginY(), getWriteRows(), {}}; + // Rotate the glyph origin once, then derive the two physical axes by + // rotating its neighbours along each logical axis. Together these encode + // the text rotation and the panel orientation as one orthogonal transform. + glyphBitmap::Frame& physical = target.frame; + rotateCoordinates(orientation, frame.x, frame.y, &physical.x, &physical.y, panelWidth, panelHeight); + int nextX, nextY; + rotateCoordinates(orientation, frame.x + frame.dxX, frame.y + frame.dxY, &nextX, &nextY, panelWidth, panelHeight); + physical.dxX = nextX - physical.x; + physical.dxY = nextY - physical.y; + rotateCoordinates(orientation, frame.x + frame.dyX, frame.y + frame.dyY, &nextX, &nextY, panelWidth, panelHeight); + physical.dyX = nextX - physical.x; + physical.dyY = nextY - physical.y; + + const glyphBitmap::Plane plane = mode == BW ? glyphBitmap::Plane::BW + : mode == GRAYSCALE_MSB ? glyphBitmap::Plane::GrayMSB + : glyphBitmap::Plane::GrayLSB; + glyphBitmap::draw(bitmap, width, height, twoBit, plane, state, target, clip); } // IMPORTANT: This function is in critical rendering path and is called for every pixel. Please keep it as simple and diff --git a/lib/GfxRenderer/GfxRenderer.h b/lib/GfxRenderer/GfxRenderer.h index fcbf0b5279d..e4eb0683df2 100644 --- a/lib/GfxRenderer/GfxRenderer.h +++ b/lib/GfxRenderer/GfxRenderer.h @@ -23,6 +23,10 @@ class SdCardFont; #include "Bitmap.h" +namespace glyphBitmap { +struct Frame; +} + // Color representation: uint8_t mapped to 4x4 Bayer matrix dithering levels // 0 = transparent, 1-16 = gray levels (white to black) enum Color : uint8_t { Clear = 0x00, White = 0x01, LightGray = 0x05, DarkGray = 0x0A, Black = 0x10 }; @@ -269,6 +273,9 @@ class GfxRenderer { clipBottom_ = y + height; } void drawPixel(int x, int y, bool state = true) const; + // Fast path for unrotated glyphs: same result as drawPixel() per ink pixel, clipped and rotated once per glyph. + void drawGlyphBitmap(const uint8_t* bitmap, int width, int height, const glyphBitmap::Frame& frame, bool twoBit, + RenderMode mode, bool state) const; void drawLine(int x1, int y1, int x2, int y2, bool state = true) const; void drawLine(int x1, int y1, int x2, int y2, int lineWidth, bool state) const; void drawArc(int maxRadius, int cx, int cy, int xDir, int yDir, int lineWidth, bool state) const; diff --git a/lib/GfxRenderer/GlyphBitmap.h b/lib/GfxRenderer/GlyphBitmap.h new file mode 100644 index 00000000000..034a1b99c47 --- /dev/null +++ b/lib/GfxRenderer/GlyphBitmap.h @@ -0,0 +1,155 @@ +#pragma once + +#include +#include + +// Glyph rasterizer that resolves orientation, clipping and framebuffer +// addressing once per glyph instead of once per pixel. It has no dependency +// on GfxRenderer so it can be unit-tested on the host. +namespace glyphBitmap { + +// Framebuffer plane a glyph is painted into. +enum class Plane : uint8_t { BW, GrayLSB, GrayMSB }; + +// Position of glyph pixel (0, 0) plus the step for one move along the glyph's +// x and y axes, all in one coordinate space. The axes must describe an +// orthogonal unit rotation: each is +/-1 on exactly one component. +struct Frame { + int x; + int y; + int dxX; + int dxY; + int dyX; + int dyY; +}; + +// Where a glyph lands in physical framebuffer space. +struct Target { + uint8_t* buffer; // First byte of row originY (framebuffer or strip scratch) + int width; // Physical panel width in pixels + int stride; // Bytes per physical row + int originY; // First physical row held by buffer + int rows; // Number of physical rows held by buffer + Frame frame; // Physical placement of the glyph +}; + +// Half-open rectangle in glyph-local pixel coordinates. +struct Clip { + int left; + int top; + int right; + int bottom; +}; + +// Narrow [start, end) on one glyph axis so that base + i * step stays within +// [lower, upper). step is +1 or -1 because the transform is orthogonal. +inline void clipAxis(int base, int step, int lower, int upper, int& start, int& end) { + if (step > 0) { + start = std::max(start, lower - base); + end = std::min(end, upper - base); + } else { + start = std::max(start, base - upper + 1); + end = std::min(end, base - lower + 1); + } +} + +// Narrow a glyph-local clip so every kept pixel maps inside the half-open +// rectangle [left, right) x [top, bottom) of the space frame is expressed in. +// Which glyph axis runs along that space's x depends on the rotation: +// dxX != 0 means glyph x does. Runs twice per glyph, so it stays inline +// under -Os. +__attribute__((always_inline)) inline void clipToRect(const Frame& frame, int left, int top, int right, int bottom, + Clip& clip) { + if (frame.dxX != 0) { + clipAxis(frame.x, frame.dxX, left, right, clip.left, clip.right); + clipAxis(frame.y, frame.dyY, top, bottom, clip.top, clip.bottom); + } else { + clipAxis(frame.x, frame.dyX, left, right, clip.top, clip.bottom); + clipAxis(frame.y, frame.dxY, top, bottom, clip.left, clip.right); + } +} + +// Keep pixel writes inline when decoding a group of four pixels. +__attribute__((always_inline)) inline void paint(uint8_t* buffer, int destination, uint8_t ink, uint8_t levels, + bool clearBits) { + if ((levels & (1u << ink)) == 0) return; + const uint8_t mask = 0x80u >> (destination & 7); + if (clearBits) + buffer[destination >> 3] &= static_cast(~mask); + else + buffer[destination >> 3] |= mask; +} + +// Paint a packed glyph into target. +// +// bitmap: rows are contiguous, MSB first, 1 or 2 bits per pixel; widths need +// not be byte-aligned. 2bpp values are 0=white, 1=light gray, 2=dark gray, +// 3=black. 1bpp value 1 is ink. +// plane: which 2bpp values are painted. BW paints every non-white value and +// honours state (true clears the bit, i.e. black). The gray planes only ever +// set bits, because there 0 means leave alone and 1 means update: MSB paints +// both grays, LSB paints dark gray only. 1bpp glyphs paint ink with state on +// every plane. +// Clipping happens before pixel decoding, so fully hidden glyphs cost nothing. +inline void draw(const uint8_t* bitmap, int width, int height, bool twoBit, Plane plane, bool state, + const Target& target, Clip clip) { + // Bit n of levels set means source value n is painted. + uint8_t levels = 0x02; + bool clearBits = state; + if (twoBit) { + levels = plane == Plane::BW ? 0x0e : plane == Plane::GrayMSB ? 0x06 : 0x04; + if (plane != Plane::BW) clearBits = false; + } + + // Intersect with the glyph bounds, then with the panel width and the + // buffered row band. + clip.left = std::max(clip.left, 0); + clip.top = std::max(clip.top, 0); + clip.right = std::min(clip.right, width); + clip.bottom = std::min(clip.bottom, height); + clipToRect(target.frame, 0, target.originY, target.width, target.originY + target.rows, clip); + if (clip.left >= clip.right || clip.top >= clip.bottom) return; + + // Walk the framebuffer as a flat bit index. One glyph column advances + // stepX bits and one glyph row advances stepY bits; each is +/-1 for the + // axis that maps to physical x, or +/-strideBits for physical y. + const Frame& frame = target.frame; + const int strideBits = target.stride * 8; + const int stepX = frame.dxY * strideBits + frame.dxX; + const int stepY = frame.dyY * strideBits + frame.dyX; + int rowBit = (frame.y - target.originY) * strideBits + frame.x + clip.left * stepX + clip.top * stepY; + for (int y = clip.top; y < clip.bottom; ++y, rowBit += stepY) { + int source = y * width + clip.left; + int destination = rowBit; + int remaining = clip.right - clip.left; + if (twoBit) { + // Rows are bit-contiguous, not byte-padded. Align after any clipped prefix. + while (remaining && (source & 3)) { + paint(target.buffer, destination, (bitmap[source >> 2] >> (6 - (source & 3) * 2)) & 3, levels, clearBits); + ++source; + destination += stepX; + --remaining; + } + while (remaining >= 4) { + const uint8_t packed = bitmap[source >> 2]; + paint(target.buffer, destination, packed >> 6, levels, clearBits); + paint(target.buffer, destination + stepX, (packed >> 4) & 3, levels, clearBits); + paint(target.buffer, destination + 2 * stepX, (packed >> 2) & 3, levels, clearBits); + paint(target.buffer, destination + 3 * stepX, packed & 3, levels, clearBits); + source += 4; + destination += 4 * stepX; + remaining -= 4; + } + } + // One-bit glyphs and the clipped tail use scalar decoding. + while (remaining--) { + const uint8_t ink = twoBit ? ((bitmap[source >> 2] >> (6 - (source & 3) * 2)) & 3) + : ((bitmap[source >> 3] >> (7 - (source & 7))) & 1); + paint(target.buffer, destination, ink, levels, clearBits); + ++source; + destination += stepX; + } + } +} + +} // namespace glyphBitmap diff --git a/test/CMakeLists.txt b/test/CMakeLists.txt index 24a6724a529..4cdffef1965 100644 --- a/test/CMakeLists.txt +++ b/test/CMakeLists.txt @@ -71,3 +71,4 @@ add_subdirectory(fs_helpers) add_subdirectory(absolute_grayscale) add_subdirectory(sd_card_font) +add_subdirectory(glyph_bitmap) diff --git a/test/glyph_bitmap/CMakeLists.txt b/test/glyph_bitmap/CMakeLists.txt new file mode 100644 index 00000000000..e36daeecfb8 --- /dev/null +++ b/test/glyph_bitmap/CMakeLists.txt @@ -0,0 +1,3 @@ +add_executable(GlyphBitmapTest GlyphBitmapTest.cpp) +target_link_libraries(GlyphBitmapTest PRIVATE crosspoint_test_common GTest::gtest_main) +gtest_discover_tests(GlyphBitmapTest) diff --git a/test/glyph_bitmap/GlyphBitmapTest.cpp b/test/glyph_bitmap/GlyphBitmapTest.cpp new file mode 100644 index 00000000000..6b6ace26183 --- /dev/null +++ b/test/glyph_bitmap/GlyphBitmapTest.cpp @@ -0,0 +1,126 @@ +#include + +#include +#include +#include + +#include "lib/GfxRenderer/GlyphBitmap.h" + +namespace { +constexpr int PANEL_WIDTH = 40; +constexpr int PANEL_HEIGHT = 32; +constexpr int STRIDE = PANEL_WIDTH / 8; + +using glyphBitmap::Plane; + +// Logical placements used by the renderer: unrotated text, and side-button +// labels rotated 90 degrees clockwise (glyph x runs up, glyph y runs right). +constexpr std::array, 2> AXES = {{{1, 0, 0, 1}, {0, -1, 1, 0}}}; + +std::pair physical(int orientation, int x, int y) { + switch (orientation) { + case 0: + return {y, PANEL_HEIGHT - 1 - x}; + case 1: + return {PANEL_WIDTH - 1 - x, PANEL_HEIGHT - 1 - y}; + case 2: + return {PANEL_WIDTH - 1 - y, x}; + default: + return {x, y}; + } +} + +// Logical rectangle the renderer clips to; the glyph-local clip is derived from it. +struct Rect { + int left; + int top; + int right; + int bottom; +}; + +void compare(int orientation, int rotation, bool twoBit, Plane plane, bool state, int width, int height, int x, int y, + int originY, int rows, Rect clip) { + SCOPED_TRACE(::testing::Message() << orientation << ',' << rotation << ',' << twoBit << ',' << static_cast(plane) + << ',' << state << " size=" << width << 'x' << height << " at=" << x << ',' << y + << " band=" << originY << ',' << rows); + const auto [dxX, dxY, dyX, dyY] = AXES[rotation]; + const glyphBitmap::Frame logical{x, y, dxX, dxY, dyX, dyY}; + std::vector bitmap((width * height * (twoBit ? 2 : 1) + 7) / 8); + for (size_t i = 0; i < bitmap.size(); ++i) bitmap[i] = static_cast(i * 73 + 0x1b); + // Nonuniform background and guards verify transparent pixels and all off-band bytes. + std::vector expected(rows * STRIDE + 32); + for (size_t i = 0; i < expected.size(); ++i) expected[i] = static_cast(i * 53 + 0xa5); + auto actual = expected; + for (int gy = 0; gy < height; ++gy) { + for (int gx = 0; gx < width; ++gx) { + const int lx = x + gx * dxX + gy * dyX; + const int ly = y + gx * dxY + gy * dyY; + if (lx < clip.left || ly < clip.top || lx >= clip.right || ly >= clip.bottom) continue; + const auto [px, py] = physical(orientation, lx, ly); + if (px < 0 || px >= PANEL_WIDTH || py < originY || py >= originY + rows) continue; + const int source = gy * width + gx; + bool draw = false; + bool black = state; + if (twoBit) { + const int value = 3 - ((bitmap[source / 4] >> ((3 - source % 4) * 2)) & 3); + if (plane == Plane::BW) draw = value < 3; + if (plane == Plane::GrayLSB) draw = value == 1; + if (plane == Plane::GrayMSB) draw = value == 1 || value == 2; + if (plane != Plane::BW) black = false; + } else { + draw = (bitmap[source / 8] >> (7 - source % 8)) & 1; + } + if (!draw) continue; + auto& byte = expected[16 + (py - originY) * STRIDE + px / 8]; + if (black) + byte &= static_cast(~(0x80 >> (px % 8))); + else + byte |= 0x80 >> (px % 8); + } + } + + glyphBitmap::Clip local{0, 0, width, height}; + glyphBitmap::clipToRect(logical, clip.left, clip.top, clip.right, clip.bottom, local); + + const auto [px, py] = physical(orientation, x, y); + const auto [xx, xy] = physical(orientation, x + dxX, y + dxY); + const auto [yx, yy] = physical(orientation, x + dyX, y + dyY); + const glyphBitmap::Target target{ + actual.data() + 16, PANEL_WIDTH, STRIDE, originY, rows, {px, py, xx - px, xy - py, yx - px, yy - py}}; + glyphBitmap::draw(bitmap.data(), width, height, twoBit, plane, state, target, local); + EXPECT_EQ(expected, actual); +} +} // namespace + +TEST(GlyphBitmap, MatchesPerPixelReferenceAcrossOrientationsRotationsPlanesAndClipping) { + for (int orientation = 0; orientation < 4; ++orientation) { + for (int rotation = 0; rotation < 2; ++rotation) { + for (bool twoBit : {false, true}) { + for (Plane plane : {Plane::BW, Plane::GrayLSB, Plane::GrayMSB}) { + for (bool state : {false, true}) { + for (int width : {1, 3, 7, 16, 31}) { + for (const auto [x, y] : {std::pair{-5, -3}, std::pair{0, 0}, std::pair{9, 11}, std::pair{29, 31}}) { + for (const auto [origin, rows] : + {std::pair{0, 32}, std::pair{0, 7}, std::pair{7, 11}, std::pair{28, 4}}) { + compare(orientation, rotation, twoBit, plane, state, width, 13, x, y, origin, rows, + {-20, -20, 60, 60}); + compare(orientation, rotation, twoBit, plane, state, width, 13, x, y, origin, rows, {2, 3, 18, 20}); + } + } + } + } + } + } + } + } +} + +TEST(GlyphBitmap, EmptyAndFullyClippedGlyphsDoNotWrite) { + for (int orientation = 0; orientation < 4; ++orientation) { + for (int rotation = 0; rotation < 2; ++rotation) { + compare(orientation, rotation, true, Plane::BW, true, 0, 0, 0, 0, 0, 32, {0, 0, 40, 32}); + compare(orientation, rotation, true, Plane::BW, true, 11, 9, 100, 100, 0, 32, {0, 0, 40, 32}); + compare(orientation, rotation, true, Plane::BW, true, 11, 9, 0, 0, 0, 32, {12, 10, 15, 20}); + } + } +} From 004c4892667ae09e922cffe397cc3e8da9f4bfba Mon Sep 17 00:00:00 2001 From: Justin Mitchell Date: Tue, 22 Sep 2026 14:07:49 -0400 Subject: [PATCH 09/62] feat: Convert slider dialogs to popups over current page (#3669) --- freeink-sdk | 2 +- src/activities/reader/EpubReaderActivity.cpp | 12 +- .../EpubReaderPercentSelectionActivity.cpp | 37 ++-- .../EpubReaderPercentSelectionActivity.h | 5 +- .../util/IntervalSelectionActivity.cpp | 37 ++-- .../util/IntervalSelectionActivity.h | 5 +- src/components/UiSliderDialog.h | 181 ++++++++++++------ 7 files changed, 179 insertions(+), 100 deletions(-) diff --git a/freeink-sdk b/freeink-sdk index 58769544252..deb62ab7e02 160000 --- a/freeink-sdk +++ b/freeink-sdk @@ -1 +1 @@ -Subproject commit 587695442525245dcf9322903c92f72731d11494 +Subproject commit deb62ab7e02b8216c5ee86a0721bf99f985c6f23 diff --git a/src/activities/reader/EpubReaderActivity.cpp b/src/activities/reader/EpubReaderActivity.cpp index a1c4b13cafd..330edc5bee9 100644 --- a/src/activities/reader/EpubReaderActivity.cpp +++ b/src/activities/reader/EpubReaderActivity.cpp @@ -2485,7 +2485,17 @@ void EpubReaderActivity::activateMoreRow(int row) { } // Leaf actions open their own screen / perform the action; close the overlay first. overlay = Overlay::None; - discardOverlayPage(); + if (action == MA::GO_TO_PERCENT && overlayPageStored) { + // The percent dialog is a popup over the current frame: wipe the toolbar + // chrome back to the clean page first so the dialog draws over the page, + // not the sheet. No refresh push — the dialog's first frame carries it. + RenderLock lock; + settleOverlayRefresh(); + renderer.restoreBwBuffer(/*resyncPanelBaseline=*/false); + overlayPageStored = false; + } else { + discardOverlayPage(); + } if (action == MA::TOGGLE_BOOKMARK) { // No child activity here to trigger the re-render the list menu relies on: // show the same confirmation popup the long-press path does. diff --git a/src/activities/reader/EpubReaderPercentSelectionActivity.cpp b/src/activities/reader/EpubReaderPercentSelectionActivity.cpp index bc2f8f3f1d8..54010758b37 100644 --- a/src/activities/reader/EpubReaderPercentSelectionActivity.cpp +++ b/src/activities/reader/EpubReaderPercentSelectionActivity.cpp @@ -16,8 +16,8 @@ namespace fui = freeink::ui; namespace { constexpr fui::ActionId ACTION_SLIDER = 1; constexpr fui::ActionId ACTION_STEP = 2; -constexpr fui::ActionId ACTION_CANCEL = 3; constexpr fui::ActionId ACTION_OK = 4; +constexpr fui::ActionId ACTION_CHROME = 5; // absorbs taps on the dialog body; no handler // Fine/coarse step sizes for percent adjustments (buttons and -/+ tap zones). constexpr int kSmallStep = 1; constexpr int kLargeStep = 10; @@ -33,7 +33,6 @@ void EpubReaderPercentSelectionActivity::onEnter() { resetUi(); app.on(ACTION_SLIDER, &EpubReaderPercentSelectionActivity::onSliderEvent, this); app.on(ACTION_STEP, &EpubReaderPercentSelectionActivity::onStepEvent, this); - app.on(ACTION_CANCEL, &EpubReaderPercentSelectionActivity::onCancelEvent, this); app.on(ACTION_OK, &EpubReaderPercentSelectionActivity::onOkEvent, this); app.setScreen(&EpubReaderPercentSelectionActivity::percentScreen, this); // Set up rendering task and mark first frame dirty. @@ -71,12 +70,6 @@ void EpubReaderPercentSelectionActivity::onStepEvent(const fui::ActionEvent& eve static_cast(user)->adjustPercent(event.value * kSmallStep); } -void EpubReaderPercentSelectionActivity::onCancelEvent(const fui::ActionEvent&, void* user) { - auto* self = static_cast(user); - self->app.clearTapFlash(); // the tap leaves this screen - self->cancel(); -} - void EpubReaderPercentSelectionActivity::onOkEvent(const fui::ActionEvent&, void* user) { auto* self = static_cast(user); self->app.clearTapFlash(); // the tap leaves this screen @@ -112,6 +105,13 @@ void EpubReaderPercentSelectionActivity::loop() { if (!route.snap.touchHeld) draggingSlider = false; return; } + // Tap released outside the dialog (inside-taps are absorbed by the chrome + // guard): cancel. Swipe-end releases arrive with -1,-1 coords and fall + // through — same rule as OptionPopup. + if (route.routed && route.snap.touchReleased && route.snap.touchX >= 0) { + cancel(); + return; + } // Back cancels, confirm selects, arrows adjust the percent. if (mappedInput.wasReleased(MappedInputManager::Button::Back)) { @@ -160,30 +160,25 @@ void EpubReaderPercentSelectionActivity::buildPercentScreen(UiScreen& screen) { snprintf(hint2, sizeof(hint2), "%s %d%%", I18N.get(StrId::STR_STEP_HINT_SIDE), kLargeStep); UiSliderDialogSpec spec; + spec.title = tr(STR_GO_TO_PERCENT); spec.readout = readout; spec.value = percent; spec.max = 100; + spec.minLabel = "0%"; + spec.maxLabel = "100%"; spec.sliderAction = ACTION_SLIDER; spec.stepAction = ACTION_STEP; - spec.cancelAction = ACTION_CANCEL; spec.okAction = ACTION_OK; + spec.chromeAction = ACTION_CHROME; spec.hintLine1 = hint1; spec.hintLine2 = hint2; - buildSliderDialogScreen(screen, renderer, mappedInput, spec); + buildSliderDialogScreen(screen, uiTarget, mappedInput, spec); } void EpubReaderPercentSelectionActivity::render(RenderLock&&) { - renderer.clearScreen(); - - auto& theme = UITheme::getInstance(); - auto metrics = theme.getMetrics(); - Rect screen = theme.getScreenSafeArea(renderer, true, false); - - GUI.drawHeader(renderer, Rect{screen.x, screen.y + metrics.topPadding, screen.width, metrics.headerHeight}, - tr(STR_GO_TO_PERCENT)); - - // Percent readout, slider, and hints render through the app so the slider and its - // -/+ zones register touch hit rects. + // No clearScreen: the dialog is a popup — it dims the frame it opened over + // and draws the card on top. Everything renders through the app so the + // slider, -/+ zones, and Cancel/OK register touch hit rects. renderUi(); // Button hints follow the current front button layout. diff --git a/src/activities/reader/EpubReaderPercentSelectionActivity.h b/src/activities/reader/EpubReaderPercentSelectionActivity.h index 2bf82a2a832..90eb0459574 100644 --- a/src/activities/reader/EpubReaderPercentSelectionActivity.h +++ b/src/activities/reader/EpubReaderPercentSelectionActivity.h @@ -17,12 +17,11 @@ class EpubReaderPercentSelectionActivity final : public Activity, private UiAppH void render(RenderLock&&) override; private: - // The UiAppHost app hosts the shared slider dialog (drag slider, -/+ zones, - // touch Cancel/OK); the header stays on GUI.drawHeader. + // The UiAppHost app hosts the shared slider dialog popup (capsule slider, + // [-] [+] [Confirm] buttons) drawn over the screen underneath. static void percentScreen(UiScreen& screen, void* user); static void onSliderEvent(const freeink::ui::ActionEvent& event, void* user); static void onStepEvent(const freeink::ui::ActionEvent& event, void* user); - static void onCancelEvent(const freeink::ui::ActionEvent& event, void* user); static void onOkEvent(const freeink::ui::ActionEvent& event, void* user); void buildPercentScreen(UiScreen& screen); void cancel(); diff --git a/src/activities/util/IntervalSelectionActivity.cpp b/src/activities/util/IntervalSelectionActivity.cpp index 93f258b390c..9b6b30ae33c 100644 --- a/src/activities/util/IntervalSelectionActivity.cpp +++ b/src/activities/util/IntervalSelectionActivity.cpp @@ -17,8 +17,8 @@ namespace fui = freeink::ui; namespace { constexpr fui::ActionId ACTION_SLIDER = 1; constexpr fui::ActionId ACTION_STEP = 2; -constexpr fui::ActionId ACTION_CANCEL = 3; constexpr fui::ActionId ACTION_OK = 4; +constexpr fui::ActionId ACTION_CHROME = 5; // absorbs taps on the dialog body; no handler } // namespace IntervalSelectionActivity::IntervalSelectionActivity(GfxRenderer& renderer, MappedInputManager& mappedInput, @@ -49,7 +49,6 @@ void IntervalSelectionActivity::onEnter() { resetUi(); app.on(ACTION_SLIDER, &IntervalSelectionActivity::onSliderEvent, this); app.on(ACTION_STEP, &IntervalSelectionActivity::onStepEvent, this); - app.on(ACTION_CANCEL, &IntervalSelectionActivity::onCancelEvent, this); app.on(ACTION_OK, &IntervalSelectionActivity::onOkEvent, this); app.setScreen(&IntervalSelectionActivity::intervalScreen, this); requestUpdate(); @@ -91,12 +90,6 @@ void IntervalSelectionActivity::onStepEvent(const fui::ActionEvent& event, void* self->adjustValue(event.value * self->smallStep); } -void IntervalSelectionActivity::onCancelEvent(const fui::ActionEvent&, void* user) { - auto* self = static_cast(user); - self->app.clearTapFlash(); // the tap leaves this screen - self->cancel(); -} - void IntervalSelectionActivity::onOkEvent(const fui::ActionEvent&, void* user) { auto* self = static_cast(user); self->app.clearTapFlash(); // the tap leaves this screen @@ -120,6 +113,13 @@ void IntervalSelectionActivity::loop() { if (!route.snap.touchHeld) draggingSlider = false; return; } + // Tap released outside the dialog (inside-taps are absorbed by the chrome + // guard): cancel. Swipe-end releases arrive with -1,-1 coords and fall + // through — same rule as OptionPopup. + if (route.routed && route.snap.touchReleased && route.snap.touchX >= 0) { + cancel(); + return; + } if (mappedInput.wasReleased(MappedInputManager::Button::Back)) { cancel(); @@ -179,26 +179,31 @@ void IntervalSelectionActivity::buildIntervalScreen(UiScreen& screen) { hintIndex++; } + char minText[32]; + formatValue(minText, sizeof(minText), minValue); + char maxText[32]; + formatValue(maxText, sizeof(maxText), maxValue); + UiSliderDialogSpec spec; + spec.title = I18N.get(titleId); spec.readout = readout; spec.value = value - minValue; spec.max = std::max(1, maxValue - minValue); + spec.minLabel = minText; + spec.maxLabel = maxText; spec.sliderAction = ACTION_SLIDER; spec.stepAction = ACTION_STEP; - spec.cancelAction = ACTION_CANCEL; spec.okAction = ACTION_OK; + spec.chromeAction = ACTION_CHROME; spec.hintLine1 = hints[0]; spec.hintLine2 = hints[1]; - buildSliderDialogScreen(screen, renderer, mappedInput, spec); + buildSliderDialogScreen(screen, uiTarget, mappedInput, spec); } void IntervalSelectionActivity::render(RenderLock&&) { - renderer.clearScreen(); - - renderer.drawCenteredText(UI_12_FONT_ID, 15, I18N.get(titleId), true, EpdFontFamily::BOLD); - - // Value readout, slider, hints, and the touch Cancel/OK pair render through the - // app so the interactive elements register touch hit rects. + // No clearScreen: the dialog is a popup — it dims the frame it opened over + // and draws the card on top. Everything renders through the app so the + // interactive elements register touch hit rects. renderUi(); const auto labels = mappedInput.mapLabels(tr(STR_BACK), tr(STR_SELECT), "-", "+"); diff --git a/src/activities/util/IntervalSelectionActivity.h b/src/activities/util/IntervalSelectionActivity.h index dfe7d82a7b1..6c529a60b25 100644 --- a/src/activities/util/IntervalSelectionActivity.h +++ b/src/activities/util/IntervalSelectionActivity.h @@ -24,12 +24,11 @@ class IntervalSelectionActivity final : public Activity, private UiAppHost { bool isReaderActivity() const override { return readerActivity; } private: - // The UiAppHost app hosts the shared slider dialog (drag slider, -/+ zones, - // touch Cancel/OK); the title stays on the legacy draw. + // The UiAppHost app hosts the shared slider dialog popup (capsule slider, + // [-] [+] [Confirm] buttons) drawn over the screen underneath. static void intervalScreen(UiScreen& screen, void* user); static void onSliderEvent(const freeink::ui::ActionEvent& event, void* user); static void onStepEvent(const freeink::ui::ActionEvent& event, void* user); - static void onCancelEvent(const freeink::ui::ActionEvent& event, void* user); static void onOkEvent(const freeink::ui::ActionEvent& event, void* user); void buildIntervalScreen(UiScreen& screen); diff --git a/src/components/UiSliderDialog.h b/src/components/UiSliderDialog.h index 613d0eadfd7..4ebfd444641 100644 --- a/src/components/UiSliderDialog.h +++ b/src/components/UiSliderDialog.h @@ -1,87 +1,158 @@ #pragma once #include +#include + +#include #include "MappedInputManager.h" #include "components/UITheme.h" #include "components/UiAppHelpers.h" #include "components/UiAppHost.h" +#include "fontIds.h" -// Shared slider-dialog screen: a centered value readout above a drag slider -// with -/+ fine-step tap zones at the row ends, a Cancel/OK pair on touch -// boards, and two step-hint lines on button boards. Used by the interval and -// percent selection dialogs, which differ only in how they format the readout -// and hints and what the actions do. +// Shared slider-dialog popup, drawn over the current screen (no clear) via +// fui::optionDialog with the same chrome OptionPopup uses: title caption, a +// large value readout as the headline, a full-width capsule slider with the +// range endpoints above it, and a [-] [+] [Confirm] option-button row on +// touch boards or two step-hint lines on button boards. Cancel is Back or a +// tap outside the dialog. Used by the interval and percent selection +// dialogs, which differ only in how they format the readout, endpoints, and +// hints and what the actions do. struct UiSliderDialogSpec { + const char* title = nullptr; // dialog caption const char* readout = nullptr; // preformatted current-value text int value = 0; // slider position (0-based within max) int max = 1; // slider range + // Range endpoints, drawn small over the slider band's ends ("0%" / "100%"). + const char* minLabel = nullptr; + const char* maxLabel = nullptr; freeink::ui::ActionId sliderAction = 0; freeink::ui::ActionId stepAction = 0; // dispatched with value -1 / +1 - freeink::ui::ActionId cancelAction = 0; freeink::ui::ActionId okAction = 0; + // Absorbs taps on the dialog body (needs no handler); the activity treats + // an unrouted release with real coordinates as tap-outside = cancel. + freeink::ui::ActionId chromeAction = 0; // Step hints for button boards (small step, large step); skipped on touch. const char* hintLine1 = nullptr; const char* hintLine2 = nullptr; }; -inline void buildSliderDialogScreen(UiAppHost::UiScreen& screen, const GfxRenderer& renderer, +inline void buildSliderDialogScreen(UiAppHost::UiScreen& screen, freeink::ui::GfxRendererTarget& uiTarget, const MappedInputManager& mappedInput, const UiSliderDialogSpec& spec) { namespace fui = freeink::ui; const auto& metrics = UITheme::getInstance().getMetrics(); const auto& theme = screen.theme(); - const Rect safe = UITheme::getInstance().getScreenSafeArea(renderer, true, false); - // Content: the safe area minus the title band render() paints, dropped down - // to where the legacy fixed layout placed the readout. - screen.setContentMarginFromScreen(fui::Insets{ - static_cast(safe.y + metrics.topPadding + metrics.headerHeight + metrics.verticalSpacing * 4), - static_cast(renderer.getScreenWidth() - (safe.x + safe.width)), - static_cast(renderer.getScreenHeight() - (safe.y + safe.height)), static_cast(safe.x)}); - // Value readout, centered above the slider. - fui::TextStyle readout = theme.titleText; - readout.align = fui::TextAlign::Center; - const int16_t readoutLh = screen.target().lineHeight(readout.font); - screen.target().text(screen.takeTop(readoutLh, theme.spaceLg), spec.readout, readout); + // The readout is the dialog's hero: remap the title slot to the large sans + // so it reads at a glance. + uiTarget.setFont(fui::GfxRendererTarget::FONT_TITLE, NOTOSANS_18_FONT_ID); + + const bool touch = mappedInput.hasTouch(); + // Touch boards get the finger-sized pill (same as the frontlight rows); on + // button boards the capsule is a read-only gauge driven by the keys, so a + // slim band is enough. + const int16_t bandHeight = touch ? 56 : 24; + const int16_t capLh = screen.target().lineHeight(theme.smallText.font); + const int16_t sliderH = static_cast(capLh + theme.spaceMd + bandHeight); + const int16_t hintsH = touch ? 0 : static_cast(capLh * 2 + theme.spaceSm + theme.spaceMd); - // Slider row: -/+ tap zones at the row ends for fine steps (a full-row-height - // square each, comfortable touch targets), with the drag slider between them. - // A small gap keeps the slider's (min-touch-expanded) hit rect from overlapping. - const fui::Insets sideInset{0, static_cast(theme.spaceLg * 2), 0, static_cast(theme.spaceLg * 2)}; - const fui::Rect row = screen.takeTop(theme.rowHeight, theme.spaceLg).inset(sideInset); - const int16_t stepW = row.height; - const fui::Rect minusHit{row.x, row.y, stepW, row.height}; - const fui::Rect plusHit{static_cast(row.right() - stepW), row.y, stepW, row.height}; + // Dialog chrome and slots, styled exactly like OptionPopup: bordered popup + // panel per theme frame metrics, body-font caption, [-] [+] [Confirm] as + // one bottom row of dialog buttons ("-"/"+" fire stepAction with -/+1, so + // the slider itself stays a bare capsule). Static because the props embed + // StyleSets, well past the 256-byte local stack budget; every field is + // reassigned each build. + fui::DialogOption options[3]; + options[0].label = "-"; + options[0].action = spec.stepAction; + options[0].value = -1; + options[1].label = "+"; + options[1].action = spec.stepAction; + options[1].value = +1; + options[2].label = tr(STR_CONFIRM); + options[2].action = spec.okAction; - fui::TextStyle glyph = theme.bodyText; - glyph.align = fui::TextAlign::Center; - const int16_t glyphLh = screen.target().lineHeight(glyph.font); - const int16_t glyphY = static_cast(row.y + (row.height - glyphLh) / 2); - screen.target().text(fui::Rect{minusHit.x, glyphY, stepW, glyphLh}, "-", glyph); - screen.target().text(fui::Rect{plusHit.x, glyphY, stepW, glyphLh}, "+", glyph); - screen.frame().hit(minusHit, spec.stepAction, -1, fui::InputTouch); - screen.frame().hit(plusHit, spec.stepAction, +1, fui::InputTouch); + static fui::OptionDialogProps props; + props.title = spec.title; + props.headline = spec.readout; + props.options = touch ? options : nullptr; + props.optionCount = touch ? 3 : 0; + props.inputMask = fui::InputTouch; + props.titleText = theme.smallText; + props.titleText.bold = true; + props.titleText.align = fui::TextAlign::Center; + props.headlineText = theme.titleText; + props.headlineText.bold = true; + props.headlineText.align = fui::TextAlign::Center; + props.buttonText = theme.bodyText; + const int16_t innerPadding = static_cast(metrics.optionPopupInnerPadding); + props.padding = fui::Insets{innerPadding, innerPadding, innerPadding, innerPadding}; + props.gap = static_cast(metrics.optionPopupItemSpacing); + // defaultPopupStyles() has no border, so opt in using the per-theme frame metrics. + props.styles = fui::defaultPopupStyles(); + props.styles.normal.border = fui::Paint::solid(fui::Color::Black); + props.styles.normal.borderWidth = static_cast(metrics.popupFrameThickness); + props.styles.normal.radius = static_cast(metrics.popupCornerRadius); + props.styles.selected = props.styles.normal; + props.styles.focused = props.styles.normal; + props.styles.active = props.styles.normal; + props.styles.disabled = props.styles.normal; + props.buttonHeight = + fui::clampI16(screen.target().lineHeight(theme.bodyText.font) + metrics.optionPopupSelectionVPadding * 2); + // Square -/+ buttons; Confirm flexes into the remaining row width so its + // label never truncates on large-font themes. + options[0].width = props.buttonHeight; + options[1].width = props.buttonHeight; - fui::SliderProps props; - props.value = spec.value; - props.max = spec.max; - props.action = spec.sliderAction; - props.inputMask = fui::InputTouch | fui::InputDrag; - const int16_t sideGap = static_cast(stepW + theme.spaceSm); - fui::slider(screen.frame(), row.inset(fui::Insets{0, sideGap, 0, sideGap}), props); + // Reserve the slider band (and hint lines) as the dialog's content band; + // optionDialog returns its rect. Same width rule as OptionPopup. + props.contentHeight = static_cast(sliderH + hintsH); + const fui::Rect screenRect = screen.frame().screen(); + const int16_t width = fui::clampI16( + std::min(screenRect.width * 3 / 4, screenRect.width - metrics.optionPopupDialogSideMargin * 2)); + // Measured height minus one spaceMd: texts anchor to the top and the band/ + // buttons to the bottom, so the trim comes out of the air between the + // readout and the slider band (which already carries its endpoints line). + const int16_t height = + fui::clampI16(fui::optionDialogHeight(screen.target(), props, width) - theme.spaceMd, 0, screenRect.height); + const fui::Rect dialog = fui::centeredRect(screenRect, fui::Size{width, height}); + // Chrome guard first, controls after: routing scans newest-first, so the + // buttons and slider win inside the dialog and the guard absorbs the rest — + // same pattern as OptionPopup. + screen.frame().hit(dialog, spec.chromeAction, 0, fui::InputTouch); + const fui::Rect band = fui::optionDialog(screen.frame(), dialog, props); + + if (!touch) { + // Two-line step hint (front buttons = fine step, side buttons = coarse + // step) under the slider, preformatted by the caller so the layout + // doesn't depend on a separator hidden in translated text. + fui::TextStyle hint = theme.smallText; + hint.align = fui::TextAlign::Center; + const int16_t hintY = static_cast(band.y + sliderH + theme.spaceMd); + if (spec.hintLine1) screen.target().text(fui::Rect{band.x, hintY, band.width, capLh}, spec.hintLine1, hint); + if (spec.hintLine2) { + screen.target().text(fui::Rect{band.x, static_cast(hintY + capLh + theme.spaceSm), band.width, capLh}, + spec.hintLine2, hint); + } + } - if (mappedInput.hasTouch()) { - // Touch devices drive the slider directly and confirm/cancel on screen; the - // physical-button step hints are hidden there — same rule as GUI.drawButtonHints. - addDialogCancelOk(screen, spec.cancelAction, spec.okAction); - return; + // The slider at the top of the content band: the range endpoints on a small + // caption line, then a full-width capsule (no step buttons of its own — the + // dialog's [-]/[+] cover exact values). Drag/tap sends dragPermille. + const fui::Rect caption{band.x, band.y, band.width, capLh}; + fui::TextStyle endpoint = theme.smallText; + if (spec.minLabel) screen.target().text(caption, spec.minLabel, endpoint); + if (spec.maxLabel) { + endpoint.align = fui::TextAlign::Right; + screen.target().text(caption, spec.maxLabel, endpoint); } - // Two-line step hint (front buttons = fine step, side buttons = coarse step), - // preformatted by the caller so the layout doesn't depend on a separator - // hidden in translated text. - fui::TextStyle hint = theme.smallText; - hint.align = fui::TextAlign::Center; - const int16_t hintLh = screen.target().lineHeight(hint.font); - if (spec.hintLine1) screen.target().text(screen.takeTop(hintLh, theme.spaceSm), spec.hintLine1, hint); - if (spec.hintLine2) screen.target().text(screen.takeTop(hintLh), spec.hintLine2, hint); + fui::CapsuleSliderProps capsule; + capsule.value = spec.value; + capsule.max = spec.max; + capsule.action = spec.sliderAction; + capsule.radius = theme.capsuleRadius; + fui::capsuleSlider(screen.frame(), + fui::Rect{band.x, static_cast(band.y + capLh + theme.spaceMd), band.width, bandHeight}, + capsule); } From 6f7b3e4fede8d481fe13c2849328461d77fc167e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ph=E1=BA=A1m=20B=C3=ACnh=20An?= <111893501+brianhuster@users.noreply.github.com> Date: Wed, 23 Sep 2026 01:29:24 +0700 Subject: [PATCH 10/62] fix(settings): fix list scrolling (#3668) --- src/activities/UiListActivity.cpp | 18 +++++++++++++++++- src/activities/settings/SettingsActivity.cpp | 16 +++++++--------- src/activities/settings/SettingsActivity.h | 5 ++++- .../settings/TextSettingsActivity.cpp | 16 +++++++--------- src/activities/settings/TextSettingsActivity.h | 2 ++ 5 files changed, 37 insertions(+), 20 deletions(-) diff --git a/src/activities/UiListActivity.cpp b/src/activities/UiListActivity.cpp index 7e772ae6ba9..651f76edf53 100644 --- a/src/activities/UiListActivity.cpp +++ b/src/activities/UiListActivity.cpp @@ -112,7 +112,23 @@ void UiListActivity::navigateButtons() { void UiListActivity::syncListViewport(UiScreen& screen, fui::ListProps& props, const int selectionOffset) { props.partialTrailingRow = true; - screen.syncListViewport(activeNav(), props, listCount(), selectionOffset); + auto& n = activeNav(); + const int prevTop = n.top; + const bool trusted = n.trusts(listCount()); + const int drawn = n.drawnRows; + + screen.syncListViewport(n, props, listCount(), selectionOffset); + + // When the selection is already visible in the current viewport (based on + // the measured drawnRows rather than the unweighted visibleRows estimate), + // keep the viewport anchored instead of jumping to top. + if (trusted && drawn > 0) { + const int sel = props.selectedIndex; + if (sel >= prevTop && sel < prevTop + drawn) { + n.top = prevTop; + props.topIndex = static_cast(prevTop); + } + } } void UiListActivity::drawChrome() { diff --git a/src/activities/settings/SettingsActivity.cpp b/src/activities/settings/SettingsActivity.cpp index c8ed3babd76..7ddf0d5fd09 100644 --- a/src/activities/settings/SettingsActivity.cpp +++ b/src/activities/settings/SettingsActivity.cpp @@ -571,11 +571,7 @@ void SettingsActivity::buildScreen(UiScreen& screen) { screen.list(props); } -void SettingsActivity::render(RenderLock&&) { - if (optionPopup.processRender(renderer, mappedInput)) return; - - renderer.clearScreen(); - +void SettingsActivity::drawChrome() { const auto pageWidth = renderer.getScreenWidth(); const auto& metrics = UITheme::getInstance().getMetrics(); @@ -585,9 +581,9 @@ void SettingsActivity::render(RenderLock&&) { // conflicts with button hints on non-touch devices. GUI.drawHeader(renderer, Rect{0, metrics.topPadding, pageWidth, metrics.headerHeight}, tr(STR_SETTINGS_TITLE), CROSSPOINT_VERSION); +} - renderUi(); - +void SettingsActivity::drawFooter() { const int ring = ringPos(); const auto confirmLabel = (ring == 0) ? I18N.get(categoryNames[(selectedCategoryIndex + 1) % categoryCount]) @@ -596,7 +592,9 @@ void SettingsActivity::render(RenderLock&&) { const auto labels = mappedInput.mapLabels(tr(STR_BACK), confirmLabel, tr(STR_DIR_UP), tr(STR_DIR_DOWN)); GUI.drawButtonHints(renderer, labels.btn1, labels.btn2, labels.btn3, labels.btn4); +} - // Always use standard refresh for settings screen - renderer.displayBuffer(); +void SettingsActivity::render(RenderLock&& lock) { + if (optionPopup.processRender(renderer, mappedInput)) return; + UiListActivity::render(std::move(lock)); } diff --git a/src/activities/settings/SettingsActivity.h b/src/activities/settings/SettingsActivity.h index a46025cb138..aa50b1088bc 100644 --- a/src/activities/settings/SettingsActivity.h +++ b/src/activities/settings/SettingsActivity.h @@ -223,9 +223,12 @@ class SettingsActivity final : public UiTabListActivity { void rebuildSettingsLists(); void syncQuickResumeTimeoutForSleepScreen(bool sleepScreenChanged, bool quickResumeTimeoutChanged); + void drawChrome() override; + void drawFooter() override; + public: explicit SettingsActivity(GfxRenderer& renderer, MappedInputManager& mappedInput); void onEnter() override; void onExit() override; - void render(RenderLock&&) override; + void render(RenderLock&& lock) override; }; diff --git a/src/activities/settings/TextSettingsActivity.cpp b/src/activities/settings/TextSettingsActivity.cpp index b24ac93465c..6c97512dfdc 100644 --- a/src/activities/settings/TextSettingsActivity.cpp +++ b/src/activities/settings/TextSettingsActivity.cpp @@ -270,11 +270,7 @@ const char* TextSettingsActivity::confirmLabelText() const { } } -void TextSettingsActivity::render(RenderLock&&) { - if (optionPopup_.processRender(renderer, mappedInput)) return; // picker draws over everything - - renderer.clearScreen(); - +void TextSettingsActivity::drawChrome() { const auto pageWidth = renderer.getScreenWidth(); GUI.drawHeader(renderer, Rect{0, metrics_.topPadding, pageWidth, metrics_.headerHeight}, tr(STR_TEXT_SETTINGS)); @@ -287,10 +283,9 @@ void TextSettingsActivity::render(RenderLock&&) { : ""; textsettings::renderPreview(renderer, previewLayout_, metrics_.previewPadding, metrics_.verticalSpacing, afterHeader, previewHeight, familyName, sizeName); +} - // Tab bar + active tab's list draw inside the screen builder. - renderUi(); - +void TextSettingsActivity::drawFooter() { if (focusedRowHasNoPreview()) { const int captionHeight = renderer.getTextHeight(UI_10_FONT_ID) + metrics_.verticalSpacing; const int capY = afterHeader + usableHeight - captionHeight + metrics_.verticalSpacing; @@ -299,8 +294,11 @@ void TextSettingsActivity::render(RenderLock&&) { const auto labels = mappedInput.mapLabels(tr(STR_BACK), confirmLabelText(), tr(STR_DIR_UP), tr(STR_DIR_DOWN)); GUI.drawButtonHints(renderer, labels.btn1, labels.btn2, labels.btn3, labels.btn4); +} - renderer.displayBuffer(); +void TextSettingsActivity::render(RenderLock&& lock) { + if (optionPopup_.processRender(renderer, mappedInput)) return; // picker draws over everything + UiListActivity::render(std::move(lock)); } // Font switching runs on the main task from loop(), which deliberately holds no diff --git a/src/activities/settings/TextSettingsActivity.h b/src/activities/settings/TextSettingsActivity.h index 38db5c5ae67..b3419f0c212 100644 --- a/src/activities/settings/TextSettingsActivity.h +++ b/src/activities/settings/TextSettingsActivity.h @@ -41,6 +41,8 @@ class TextSettingsActivity final : public UiTabListActivity { void activateIndex(int index) override; void onTabAction(int index) override; void stepTab(int direction) override { switchTab(direction); } + void drawChrome() override; + void drawFooter() override; bool handleButtons() override; bool handleCustomInput() override; From d8da03ef47d8ae47f49f68489cdf9f3bf78b3da8 Mon Sep 17 00:00:00 2001 From: Justin Mitchell Date: Tue, 22 Sep 2026 16:37:49 -0400 Subject: [PATCH 11/62] fix: Split navigation modes for cover grid & add selected states (#3670) Cover grid home now uses directional button navigation (Up/Down for books, Left/Right for tabs) instead of flat next/previous cycling. Added visual selection indicators: vertical accent bar for featured card on button boards, thick dithered rings for grid thumbnails. Classic list home retains original flat navigation. --- src/activities/home/HomeActivity.cpp | 56 ++++++++++++++++++++++------ src/components/CoverGridHomeUi.cpp | 34 +++++++++++++++-- 2 files changed, 76 insertions(+), 14 deletions(-) diff --git a/src/activities/home/HomeActivity.cpp b/src/activities/home/HomeActivity.cpp index 675118900da..cac801c004f 100644 --- a/src/activities/home/HomeActivity.cpp +++ b/src/activities/home/HomeActivity.cpp @@ -327,15 +327,19 @@ void HomeActivity::loop() { } }; - buttonNavigator.onNext([this, menuCount] { - selectorIndex = ButtonNavigator::nextIndex(selectorIndex, menuCount); - requestUpdate(); - }); + // Cover grid home splits navigation by button group (see below); the flat + // next/previous cycle is for the classic list home only. + if (!coverGridUi) { + buttonNavigator.onNext([this, menuCount] { + selectorIndex = ButtonNavigator::nextIndex(selectorIndex, menuCount); + requestUpdate(); + }); - buttonNavigator.onPrevious([this, menuCount] { - selectorIndex = ButtonNavigator::previousIndex(selectorIndex, menuCount); - requestUpdate(); - }); + buttonNavigator.onPrevious([this, menuCount] { + selectorIndex = ButtonNavigator::previousIndex(selectorIndex, menuCount); + requestUpdate(); + }); + } const auto swipe = mappedInput.wasSwipe(); if (swipe == MappedInputManager::SwipeDir::Up) { @@ -364,7 +368,35 @@ void HomeActivity::loop() { activateSelection(); return; } - if (mappedInput.wasReleased(MappedInputManager::Button::Confirm)) activateSelection(); + if (mappedInput.wasReleased(MappedInputManager::Button::Confirm)) { + activateSelection(); + return; + } + // Side page buttons walk the covers, front Left/Right walk the tabs + // (selectorIndex is flat: books first, then the tab items). A press while + // selection sits in the other band jumps into this band first. + const int bookCount = static_cast(recentBooks.size()); + const auto cycleBand = [this](const int base, const int count, const int dir) { + if (count <= 0) return; + int idx = selectorIndex - base; + if (idx < 0 || idx >= count) { + idx = dir > 0 ? 0 : count - 1; + } else { + idx = (idx + count + dir) % count; + } + selectorIndex = base + idx; + requestUpdate(); + }; + buttonNavigator.onPressAndContinuous({MappedInputManager::Button::Up}, + [&cycleBand, bookCount] { cycleBand(0, bookCount, -1); }); + buttonNavigator.onPressAndContinuous({MappedInputManager::Button::Down}, + [&cycleBand, bookCount] { cycleBand(0, bookCount, +1); }); + buttonNavigator.onPressAndContinuous({MappedInputManager::Button::Left}, [&cycleBand, bookCount, menuCount] { + cycleBand(bookCount, menuCount - bookCount, -1); + }); + buttonNavigator.onPressAndContinuous({MappedInputManager::Button::Right}, [&cycleBand, bookCount, menuCount] { + cycleBand(bookCount, menuCount - bookCount, +1); + }); return; } @@ -426,8 +458,10 @@ void HomeActivity::render(RenderLock&&) { if (coverGridUi) { coverGridUi->setSelection(selectorIndex); UITheme::getInstance().drawCoverGridHome(*coverGridUi); - const auto labels = mappedInput.mapLabels(hasContinueReading ? tr(STR_RESUME) : "", tr(STR_SELECT), tr(STR_DIR_UP), - tr(STR_DIR_DOWN)); + // Front Left/Right walk the tabs, so their hints read Left/Right; the + // side page buttons (unhinted) walk the covers. + const auto labels = mappedInput.mapLabels(hasContinueReading ? tr(STR_RESUME) : "", tr(STR_SELECT), + tr(STR_DIR_LEFT), tr(STR_DIR_RIGHT)); GUI.drawButtonHints(renderer, labels.btn1, labels.btn2, labels.btn3, labels.btn4); renderer.displayBuffer(cleanInitialRefresh && !firstRenderDone ? HalDisplay::HALF_REFRESH : HalDisplay::FAST_REFRESH); diff --git a/src/components/CoverGridHomeUi.cpp b/src/components/CoverGridHomeUi.cpp index 01e244d064e..638a27f2bb3 100644 --- a/src/components/CoverGridHomeUi.cpp +++ b/src/components/CoverGridHomeUi.cpp @@ -1,5 +1,6 @@ #include "CoverGridHomeUi.h" +#include #include #include @@ -115,6 +116,10 @@ void CoverGridHomeUi::draw(UiScreen& screen) { const auto& gridRect = gridBounds; tabRect.x = gridRect.x + grid.cellInset.left; tabRect.width = gridRect.width - grid.cellInset.left - grid.cellInset.right; + // Heading shares the clock's alignment line (drawHeaderBand lands the + // status content on the outer cover columns, i.e. this same x). The + // selection ring extends left of this line by design — it reads as a frame + // around the cover, not as the column edge. headingRect.x = tabRect.x; headingRect.width = tabRect.width; screen.target().text(headingRect, hasContinueReading ? tr(STR_CONTINUE_READING) : tr(STR_START_READING), headingText); @@ -165,10 +170,16 @@ void CoverGridHomeUi::drawCurrent(UiScreen& screen, fui::Rect rect) { card.progress = std::max(0, progress); card.progressMax = progress >= 0 ? 100 : 0; card.action = SELECT; - card.state = selected == 0 ? fui::StateSelected : fui::StateNormal; - card.selectionIndicator = fui::BookCardSelectionIndicator::CoverFrame; + // The featured card's selected state is a slim accent bar drawn after the + // card (see below), not a bookCard indicator: every ring/outline treatment + // tried here either overwhelmed the large cover or made the heading above + // read as misaligned. + card.state = fui::StateNormal; card.styles = theme.listRow; card.styles.selected.background = fui::Paint::dither(fui::Color::LightGray); + // The grid thumbs' selection ring draws with this border: gray like Lyra's + // selection box, not solid black. + card.styles.selected.border = fui::Paint::dither(fui::Color::LightGray); card.styles.selected.foreground = fui::Paint::solid(fui::Color::Black); card.styles.selected.radius = theme.listRowRadius; card.styles.active = card.styles.selected; @@ -197,6 +208,17 @@ void CoverGridHomeUi::drawCurrent(UiScreen& screen, fui::Rect rect) { return static_cast(user)->paintFramedCover(target, cover, 0); }; fui::bookCard(screen.frame(), rect, card); + + if (selected == 0 && !BoardConfig::hasTouch()) { + // Button boards only: a vertical accent bar left of the card, cover-height + // and vertically centered on it, marks the featured card as the button + // cursor without framing the cover. Touch boards tap directly and need no + // cursor on the hero card. + const int16_t barH = card.coverSize.height; + screen.target().fill( + fui::Rect{static_cast(rect.x - 9), static_cast(rect.y + (rect.height - barH) / 2), 3, barH}, + fui::Paint::dither(fui::Color::LightGray)); + } } fui::Rect CoverGridHomeUi::layoutGrid(UiScreen& screen, fui::Rect rect) { @@ -224,7 +246,13 @@ void CoverGridHomeUi::drawGrid(UiScreen& screen) { grid.action = SELECT; grid.inputMask = fui::InputTouch; grid.selectedIndex = selected > 0 && selected < static_cast(books->size()) ? selected - 1 : -1; - grid.selectionIndicator = fui::CoverGridSelectionIndicator::Cell; + // Same thick cover ring as the featured card; the dithered Cell background + // was easy to miss behind a dark cover. + grid.selectionIndicator = fui::CoverGridSelectionIndicator::CoverFrame; + // Thick dithered ring sized for the small thumbs: 6px outside the cover, + // 2px over its edge. + grid.selectedCoverFrameGap = 6; + grid.selectedCoverFrameWidth = 8; grid.cellStyles = card.styles; grid.labelHeight = 0; grid.labelGap = 0; From 038d615f2a7d9b01b47890cb0cc9128bc0deb979 Mon Sep 17 00:00:00 2001 From: Eszter Schuffert <30467951+eszter007@users.noreply.github.com> Date: Tue, 22 Sep 2026 23:27:29 +0200 Subject: [PATCH 12/62] fix: stop duplicating identical interval tables across font styles (#3616) --- lib/EpdFont/SdCardFont.cpp | 61 +++++++++++++++++++++++++++++++++++--- lib/EpdFont/SdCardFont.h | 5 ++++ 2 files changed, 62 insertions(+), 4 deletions(-) diff --git a/lib/EpdFont/SdCardFont.cpp b/lib/EpdFont/SdCardFont.cpp index 7ab5a83025d..6c3a7937674 100644 --- a/lib/EpdFont/SdCardFont.cpp +++ b/lib/EpdFont/SdCardFont.cpp @@ -163,10 +163,15 @@ void SdCardFont::freeStyleMiniKern(PerStyle& s) { void SdCardFont::freeStyleAll(PerStyle& s) { freeStyleMiniData(s); - delete[] s.fullIntervals; + // A shared table is owned by the style it was aliased from -- that style's own + // freeStyleAll() call frees it. Deleting it here too would double-free. + if (!s.intervalsShared) { + delete[] s.fullIntervals; + delete[] s.bmpIntervals; + } s.fullIntervals = nullptr; - delete[] s.bmpIntervals; s.bmpIntervals = nullptr; + s.intervalsShared = false; s.intervalsAreBmp16 = false; freeStyleKernLigatureData(s); s.present = false; @@ -633,6 +638,22 @@ bool SdCardFont::load(const char* path) { uint32_t expectedOffset = 0; uint32_t prevLast = 0; EpdUnicodeInterval iv{}; + + // Regular/bold/italic weights of the same family almost always cover the identical codepoint + // set, so a later style's table is usually a byte-for-byte copy of an earlier one's. Sharing + // it saves a full table per style, which on a broad CJK font is tens of KB, and saves the + // PEAK rather than just the residency: allocating first and de-duplicating afterwards still + // needs both tables at once, and that peak is what fails on a tight heap. + // The decision rides along with the validation read below -- every record is already being + // read here -- so it costs no second pass over the table and no buffer to hold one. + // A style stays a candidate only while its table has matched every record so far. + uint8_t shareCandidates = 0; + for (uint8_t k = 0; k < i; k++) { + const auto& owner = styles_[k]; + if (!owner.present || owner.header.intervalCount != s.header.intervalCount) continue; + if (!owner.bmpIntervals && !owner.fullIntervals) continue; + shareCandidates |= static_cast(1u << k); + } for (uint32_t j = 0; j < s.header.intervalCount; ++j) { if (file.read(reinterpret_cast(&iv), sizeof(iv)) != sizeof(iv)) { LOG_ERR("SDCF", "Failed to read interval %u for style %u", j, i); @@ -661,17 +682,49 @@ bool SdCardFont::load(const char* path) { if (iv.first > UINT16_MAX || iv.last > UINT16_MAX || iv.offset > UINT16_MAX) { canUseBmp16 = false; } + for (uint8_t k = 0; k < i && shareCandidates != 0; k++) { + if ((shareCandidates & (1u << k)) == 0) continue; + const auto& owner = styles_[k]; + // Compared by value, so an above-BMP record never equals a compact one and drops out here. + const bool same = owner.intervalsAreBmp16 + ? (owner.bmpIntervals[j].first == iv.first && owner.bmpIntervals[j].last == iv.last && + owner.bmpIntervals[j].offset == iv.offset) + : (owner.fullIntervals[j].first == iv.first && owner.fullIntervals[j].last == iv.last && + owner.fullIntervals[j].offset == iv.offset); + if (!same) shareCandidates &= static_cast(~(1u << k)); + } expectedOffset += span; prevLast = iv.last; } - if (!file.seekSet(s.intervalsFileOffset)) { + // Survived every record: alias the earlier style's table instead of allocating a copy. + // freeStyleAll() skips delete[] when intervalsShared is set, so only the owner frees. + for (uint8_t k = 0; k < i && shareCandidates != 0; k++) { + if ((shareCandidates & (1u << k)) == 0) continue; + auto& owner = styles_[k]; + // Identical content can still be held in the other resident form when the two styles + // disagree on glyph count; aliasing across forms would misread the table. + if (owner.intervalsAreBmp16 != canUseBmp16) continue; + s.bmpIntervals = owner.bmpIntervals; + s.fullIntervals = owner.fullIntervals; + s.intervalsAreBmp16 = owner.intervalsAreBmp16; + s.intervalsShared = true; + LOG_DBG("SDCF", "Style %u: sharing style %u's %u-interval table (%u B not allocated)", i, k, + s.header.intervalCount, + s.header.intervalCount * (canUseBmp16 ? 6u : static_cast(sizeof(EpdUnicodeInterval)))); + break; + } + + // Only the allocate-and-read path below needs the records again; a shared style is done. + if (!s.intervalsShared && !file.seekSet(s.intervalsFileOffset)) { LOG_ERR("SDCF", "Failed to seek back to intervals for style %u", i); freeAll(); return false; } - if (canUseBmp16) { + if (s.intervalsShared) { + // Aliased above; fall through to the stub/metadata setup without touching the table. + } else if (canUseBmp16) { s.bmpIntervals = new (std::nothrow) PerStyle::BmpInterval16[s.header.intervalCount]; if (!s.bmpIntervals) { LOG_ERR("SDCF", "Failed to allocate compact intervals for style %u", i); diff --git a/lib/EpdFont/SdCardFont.h b/lib/EpdFont/SdCardFont.h index 598282dca7c..e97657cd8b5 100644 --- a/lib/EpdFont/SdCardFont.h +++ b/lib/EpdFont/SdCardFont.h @@ -184,6 +184,11 @@ class SdCardFont { static_assert(sizeof(BmpInterval16) == 6, "BmpInterval16 must remain compact"); BmpInterval16* bmpIntervals = nullptr; bool intervalsAreBmp16 = false; + // True when bmpIntervals/fullIntervals above points at another style's table rather than + // this style's own allocation. Regular/bold/italic weights of the same family almost always + // cover the identical codepoint set, so a CJK font's multi-KB-per-style table is otherwise + // paid for once per style. Only the owning style frees it -- see freeStyleAll(). + bool intervalsShared = false; // Persistent kern-class + ligature tables (lazy-loaded on first prewarm). // The full kern MATRIX is NOT resident — on Literata-class fonts a single From 0914aa1abad4631d76878944743e8b487e18c066 Mon Sep 17 00:00:00 2001 From: Leopoldo Pla Sempere Date: Wed, 23 Sep 2026 07:42:46 +0200 Subject: [PATCH 13/62] fix: wrap OTA completion power-on hint (#3632) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## Summary The OTA completion screen draws the power-on instruction on one line. The Catalan translation is 575 pixels wide with the firmware UI font, so it extends beyond the X4 portrait screen. Use `UITheme::drawCenteredWrappedText` with theme side padding and top alignment below the completion heading, matching the existing SD-update success screen. Allow up to three lines, bounded by the remaining screen height. ## Scope Check - [x] I have read SCOPE.md and ROADMAP.md. - [x] This PR is not a new built-in theme. - [x] This PR is not a new external network connector. - [x] This PR is not an interactive app, writing tool, RSS/news/browser, media playback, or PDF feature. - [ ] Stock firmware / popular fork comparison: not investigated; this corrects an existing CrossPoint UI regression/defect. - [ ] Maintainer coordination: pending. This touches the OTA activity's completion-screen rendering only; update installation and boot handling are unchanged. ## Additional Context Only `src/activities/settings/OtaUpdateActivity.cpp` changes. Translation strings and the shared wrapping implementation are unchanged. The new local state is an integer and a Rect. Long hints use the existing helper's temporary string/vector allocations for at most three output lines; hints that fit use its single-line fast path. No persistent buffers are added. Heap and flash deltas have not been measured against a baseline. Validation: - Formatting wrapper and `git diff --check`: passed. - Host font/layout probe using the actual Ubuntu 10 UI font, EpdFont measurement code, and extracted firmware wrapping implementation: English, Catalan, Valencian, and Spanish preserved all text within 280, 360, 440, and 760 pixel content widths. At 440 pixels, Catalan lines measure 411 and 159 pixels, Valencian 404 and 159 pixels. English and Spanish remain single-line. - `pio run -e default`: passed (ESP32-C3/X4); no compiler warnings or errors reported. - Simulator screenshots are attached below; no physical-device validation performed. Device verification: select Catalan or Valencian, complete an OTA update, and confirm the full power-on instruction wraps below the completion heading without clipping. Repeat in the supported orientations; English/Spanish should remain single-line where they fit. ### AI Usage YES — AI assisted with implementation and local validation. ## Simulator screenshots The X4 portrait simulator was held in the OTA completion state for these captures; no OTA download or device flashing was performed. The before image uses the parent of this PR (`8c84ef3268fd56147ae1625e04f773f0f50b7720`); the after images use this PR head (`880cb12389f6aff82332c797b99f6988604efbab`). A simulator-only fixture held the completion state and is not part of the firmware change.
      Before: Catalan, one-line hint clipped at both edgesAfter: Catalan, centered two-line hint
      Before fix: Catalan OTA completion hint is clipped on
one line After fix: Catalan OTA completion hint wraps to two
centered lines
      Valencian also wraps cleanly in the same layout: After fix: Valencian OTA completion hint wraps to two
centered lines --- src/activities/settings/OtaUpdateActivity.cpp | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/src/activities/settings/OtaUpdateActivity.cpp b/src/activities/settings/OtaUpdateActivity.cpp index f8c08793774..b3e54ada42b 100644 --- a/src/activities/settings/OtaUpdateActivity.cpp +++ b/src/activities/settings/OtaUpdateActivity.cpp @@ -162,7 +162,11 @@ void OtaUpdateActivity::render(RenderLock&&) { GUI.drawButtonHints(renderer, labels.btn1, labels.btn2, labels.btn3, labels.btn4); } else if (state == FINISHED) { renderer.drawCenteredText(UI_10_FONT_ID, top, tr(STR_UPDATE_COMPLETE), true, EpdFontFamily::BOLD); - renderer.drawCenteredText(UI_10_FONT_ID, top + height + metrics.verticalSpacing, tr(STR_POWER_ON_HINT)); + const int hintY = top + height + metrics.verticalSpacing; + const Rect hintBounds{metrics.contentSidePadding, hintY, pageWidth - metrics.contentSidePadding * 2, + pageHeight - hintY}; + UITheme::drawCenteredWrappedText(renderer, hintBounds, UI_10_FONT_ID, tr(STR_POWER_ON_HINT), 3, true, + EpdFontFamily::REGULAR, UITheme::TextVerticalAlignment::TOP); } renderer.displayBuffer(); From aaf6837988a0d8d4b27a409c520e328f54a574f8 Mon Sep 17 00:00:00 2001 From: Justin Mitchell Date: Wed, 23 Sep 2026 03:25:48 -0400 Subject: [PATCH 14/62] feat: Add TrueType fonts on PSRAM boards (#3646) Co-authored-by: Uri Tauber --- README.md | 8 +- USER_GUIDE.md | 12 +- docs/sd-card-fonts.md | 46 +- docs/webserver-endpoints.md | 17 +- docs/webserver.md | 8 + lib/EpdFont/EpdFont.cpp | 3 + lib/EpdFont/EpdFontData.h | 15 + lib/EpdFont/FontDecompressor.cpp | 32 +- lib/EpdFont/SdCardFont.cpp | 96 ++-- lib/EpdFont/SdCardFont.h | 11 +- lib/EpdFont/SdCardFontRegistry.cpp | 235 ++++++++- lib/EpdFont/SdCardFontRegistry.h | 34 +- lib/EpdFont/TtfEpdFont.cpp | 469 ++++++++++++++++++ lib/EpdFont/TtfEpdFont.h | 161 ++++++ lib/EpdFont/VectorFontSupport.h | 16 + lib/Epub/Epub/ParsedText.cpp | 217 +++++--- lib/Epub/Epub/ParsedText.h | 31 +- lib/Epub/Epub/Section.cpp | 8 +- lib/Epub/Epub/WordStore.cpp | 93 ++++ lib/Epub/Epub/WordStore.h | 80 +++ lib/Epub/Epub/blocks/TextBlock.cpp | 7 + .../Epub/parsers/ChapterHtmlSlimParser.cpp | 72 ++- lib/Epub/Epub/parsers/ChapterHtmlSlimParser.h | 4 + lib/GfxRenderer/FontCacheManager.cpp | 33 +- lib/GfxRenderer/FontCacheManager.h | 17 +- lib/GfxRenderer/GfxRenderer.cpp | 70 ++- lib/GfxRenderer/GfxRenderer.h | 21 +- platformio.ini | 17 + src/CrossPointSettings.cpp | 10 +- src/ReaderFontSizes.cpp | 8 +- src/SdCardFontSystem.cpp | 343 ++++++++++++- src/SdCardFontSystem.h | 66 ++- src/activities/ActivityManager.cpp | 19 +- src/activities/reader/EpubReaderActivity.cpp | 13 + src/activities/reader/ReaderActivity.cpp | 6 + src/main.cpp | 13 +- test/chapter_html_slim_parser/CMakeLists.txt | 1 + .../ChapterHtmlSlimParserTest.cpp | 4 +- .../stubs/GfxRenderer.h | 2 +- .../stubs/MemoryManager.h | 26 + .../FontCacheManagerTest.cpp | 16 +- test/font_cache_manager/stubs/TtfEpdFont.h | 9 + test/sd_card_font/stubs/FontPsram.h | 27 + 43 files changed, 2161 insertions(+), 235 deletions(-) create mode 100644 lib/EpdFont/TtfEpdFont.cpp create mode 100644 lib/EpdFont/TtfEpdFont.h create mode 100644 lib/EpdFont/VectorFontSupport.h create mode 100644 lib/Epub/Epub/WordStore.cpp create mode 100644 lib/Epub/Epub/WordStore.h create mode 100644 test/chapter_html_slim_parser/stubs/MemoryManager.h create mode 100644 test/font_cache_manager/stubs/TtfEpdFont.h create mode 100644 test/sd_card_font/stubs/FontPsram.h diff --git a/README.md b/README.md index 13f04715300..f6f68770dfc 100644 --- a/README.md +++ b/README.md @@ -6,7 +6,7 @@ CrossPoint is open-source e-reader firmware - community-built, fully hackable, f ### Now running on: - **ESP32C3-based** Xteink X4 and X3. -- **ESP32S3-based** Xteink X4Pro, Seeed reTerminal Sticky, M5PaperMono +- **ESP32S3-based** Xteink X4Pro and X4Classic, Seeed reTerminal Sticky, M5PaperMono Check [our Devices page](https://crosspointreader.com/devices) for the full list. @@ -133,7 +133,11 @@ See [Development quick start](#development-quick-start) below. ## Custom SD-card fonts -Convert your own TTF/OTF files into `.cpfont` files that load from the SD card. No firmware reflash is needed. +On devices with external RAM enabled in CrossPoint, copy `.ttf`, `.otf`, or `.ttc` files to the SD card and select them as reader fonts. Put one file in `/fonts/` or `/.fonts/`, or put one family's files in a subfolder. See the [SD card font guide](./docs/sd-card-fonts.md) for the folder layout and styles. + +On other devices, convert the font to `.cpfont` first. `.cpfont` files also work on devices with external RAM enabled and have better performance. No firmware reflash is needed to add fonts. + +To make `.cpfont` files: 1. Go to https://crosspointreader.com/fonts and open the "SD-card font builder" form. 2. Upload up to four styles (regular, bold, italic, bold-italic), set the family name, point sizes, and Unicode range. diff --git a/USER_GUIDE.md b/USER_GUIDE.md index 11be0246421..c4bfb147fa4 100644 --- a/USER_GUIDE.md +++ b/USER_GUIDE.md @@ -260,17 +260,15 @@ The Settings screen allows you to configure the device's behavior. There are a f - "OFF" (default) - Disable the fix - "ON" - Enable the fix -> [!NOTE] -> A battery charging indicator is shown on the battery icon whenever the device is actively charging. - #### 3.6.2 Reader - **Reader Font Family**: Choose the font used for reading: - "Noto Serif" (default) - Google's serif font - "Noto Sans" - Google's sans-serif font + - Installed SD card families -- **Reader Font Size**: Adjust the text size for reading; options are "Small", "Medium" (default), "Large", or "X Large". +- **Reader Font Size**: Choose a point size. Built-in and direct TTF/OTF/TTC fonts offer 12, 14, 16, and 18 pt. A `.cpfont` family offers the sizes installed for that family. - **Reader Line Spacing**: Adjust the spacing between lines; options are "Tight", "Normal" (default), or "Wide". @@ -576,13 +574,13 @@ Transparent overlay files are intentionally separate from normal sleep images. R ### 3.8 Custom Fonts (SD Card) -CrossPoint supports loading additional fonts from the SD card, extending beyond the two built-in families (Noto Serif, Noto Sans). Custom fonts can include extended Unicode coverage, enabling CJK (Chinese, Japanese, Korean) and other scripts. +CrossPoint loads additional fonts from the SD card. Custom fonts can add Chinese, Japanese, Korean, and other scripts that the built-in reader fonts lack. If your device have external RAM, you can copy `.ttf`, `.otf`, and `.ttc` files directly. Otherwise, use `.cpfont` files made from those fonts. There are three ways to install fonts: 1. **Download from device (recommended):** Go to **Settings -> System -> Manage Fonts**, browse the available font families, and select one to download over Wi-Fi. -2. **Upload via web interface:** While in **File Transfer** mode, open the web UI in a browser and navigate to the **Fonts** tab to upload `.cpfont` files. -3. **Manual SD card copy:** Download font files from the [crosspoint-fonts repository](https://github.com/crosspoint-reader/crosspoint-fonts) and copy them to `/.fonts/` (preferred) or `/fonts/` on your SD card. +2. **Upload via web interface:** While in **File Transfer** mode, open the web UI and use the **Fonts** tab to upload `.cpfont` files. The Fonts tab does not accept TTF/OTF/TTC files. +3. **Manual SD card copy:** Copy `.cpfont` families from the [crosspoint-fonts repository](https://github.com/crosspoint-reader/crosspoint-fonts) to `/.fonts/` or `/fonts/`. If your device have external RAM, you can also copy TTF/OTF/TTC files there without conversion. Once installed, custom fonts appear in **Settings → Reader → Font Family** alongside the built-in fonts. diff --git a/docs/sd-card-fonts.md b/docs/sd-card-fonts.md index 1f0e72638a1..b583b166d76 100644 --- a/docs/sd-card-fonts.md +++ b/docs/sd-card-fonts.md @@ -3,9 +3,14 @@ CrossPoint supports loading additional fonts from the SD card, including fonts with extended Unicode coverage (CJK, Cyrillic, Greek, etc.). +If CrossPoint enables external RAM on your device, you can load `.ttf`, `.otf`, +and `.ttc` files directly. Otherwise, convert them to `.cpfont` files first. +All devices can use `.cpfont` files. + ## Installing Fonts -There are three ways to install fonts: +There are three ways to install `.cpfont` fonts. Direct TTF/OTF/TTC loading uses +the manual SD card copy method below. ### Option 1: Download from device (recommended) @@ -52,6 +57,32 @@ There are three ways to install fonts: 3. Insert the SD card and power on your CrossPoint reader +### Direct TTF/OTF/TTC fonts + +If CrossPoint enables external RAM on your device, copy a `.ttf`, `.otf`, or +`.ttc` file directly into `/fonts/` or `/.fonts/`. The filename without its +extension becomes the family name. For example, `/fonts/Bookerly.ttf` appears as +`Bookerly` in **Settings > Reader > Font Family**. + +For a family with separate styles, put its files in one subfolder. The folder +name becomes the family name. For example: + + /fonts/Bookerly/Bookerly-Regular.ttf + /fonts/Bookerly/Bookerly-Bold.ttf + /fonts/Bookerly/Bookerly-Italic.ttf + /fonts/Bookerly/Bookerly-BoldItalic.ttf + +The reader selects regular, bold, italic, and bold italic faces from the font +files. One regular file is enough; the reader derives missing styles. A `.cpfont` +file in the same family folder takes priority over TTF/OTF/TTC files, so keep +the two formats in separate folders. If both font roots contain the same family +name, the copy in `/.fonts/` takes priority. + +Direct fonts use the standard reader sizes: 12, 14, 16, and 18 pt. The **Fonts** +tab in File Transfer accepts `.cpfont` files only. Copy direct fonts to the SD +card instead. To remove a loose font file, delete that file from the SD card. +The Fonts page can delete families stored in subfolders. + ## CJK in the User Interface The built-in UI fonts are Latin-only, so by default the interface (book titles @@ -68,11 +99,12 @@ The fallback is **size-matched**. The built-in UI fonts render at 8 pt (small/author lines), 10 pt (list rows) and 12 pt (book-cover titles, headers), so CrossPoint loads your SD family at those sizes too and maps each UI font to its same-size SD font. CJK book names therefore appear at the same size as the -Latin text around them. For this to work the family must contain `.cpfont` -files at sizes **8, 10 and 12** (in addition to the reader sizes 12–18); any UI -size missing from the family simply keeps showing boxes for CJK at that size. +Latin text around them. A `.cpfont` family must contain files at sizes +**8, 10 and 12** for this UI fallback (in addition to the reader sizes 12–18). +Any missing UI size keeps showing boxes for CJK at that size. Direct +TTF/OTF/TTC families use the same file at all three UI sizes. -Note that **Settings > Reader > Font Size** lists every size the family ships, +For `.cpfont` families, **Settings > Reader > Font Size** lists every size the family ships, so a family built at 8,10,12,14,16,18 offers all six as reading sizes — the UI sizes are not hidden from the list. Reading at 8 pt is your call; if you would rather not see the small sizes there, convert two families (one with the UI @@ -92,7 +124,7 @@ What this means in practice: - Select a CJK-capable SD font under **Settings > Reader > Font Family** (see [Installing Fonts](#installing-fonts) and the `cjk` / `hangul` presets - under [Converting Custom Fonts](#converting-custom-fonts)). That single + for `.cpfont` under [Converting Custom Fonts](#converting-custom-fonts)). That single selection drives both book content *and* size-matched CJK fallback in the UI. - Pure-Latin UI strings keep the crisp built-in font; only strings that actually contain CJK are routed to the SD font. @@ -111,7 +143,7 @@ The current list of pre-built fonts is maintained in the ## Converting Custom Fonts -To convert your own TrueType/OpenType fonts: +To make `.cpfont` files for any device, convert your TrueType/OpenType fonts: ### Prerequisites diff --git a/docs/webserver-endpoints.md b/docs/webserver-endpoints.md index 7a230931967..6c3431424a0 100644 --- a/docs/webserver-endpoints.md +++ b/docs/webserver-endpoints.md @@ -219,11 +219,11 @@ Example item: } ``` -`value` is always an index into `options`, never the option's text. `fontSize` -is one of the settings whose `options` are built at request time — they are the -point sizes the selected font family actually ships, so a family installed at -10/12/14 offers three options. (`fontFamily` and `dictionaryName` vary the same -way, from the SD card contents.) +`value` is always an index into `options`, never the option's text. The +`fontSize` options depend on the selected family. A `.cpfont` family installed +at 10/12/14 pt offers those three sizes. TTF/OTF/TTC families offer the +standard 12/14/16/18 pt sizes. The `fontFamily` and `dictionaryName` options +also depend on the SD card contents. Types: @@ -257,7 +257,9 @@ Applied 2 setting(s) ### `GET /api/fonts` -Lists installed SD-card font families. +Lists installed SD-card font families. On devices that load TTF/OTF/TTC files, +these families appear with `sizes: [0]`. The `0` means that the font file has +no fixed point size; the reader offers 12, 14, 16, and 18 pt. ```bash curl http://crosspoint.local/api/fonts @@ -304,6 +306,9 @@ Successful response: Deletes an installed font family. +This endpoint removes family subfolders. To remove a loose TTF/OTF/TTC file +from a font root, delete the file from the SD card instead. + ```bash curl -X POST \ -H "Content-Type: application/json" \ diff --git a/docs/webserver.md b/docs/webserver.md index e42f3237872..afd6ed37a27 100644 --- a/docs/webserver.md +++ b/docs/webserver.md @@ -121,6 +121,14 @@ upload. Installed fonts appear in **Settings > Reader > Font Family** after the font registry refreshes. +On devices with external RAM enabled in CrossPoint, you can use TTF/OTF/TTC +fonts directly. +Copy them into `/fonts/` or `/.fonts/` on the SD card. The Fonts page and +`/api/fonts/upload` accept `.cpfont` files only. See the +[SD card font guide](./sd-card-fonts.md) for the direct font folder layout. +To remove a loose direct font, delete its file from the SD card. The Fonts page +can delete a family stored in a subfolder. + ## Command Line Use Power users can use `curl`, WebDAV clients, or WebSocket clients while the web diff --git a/lib/EpdFont/EpdFont.cpp b/lib/EpdFont/EpdFont.cpp index d94333a72e8..ce574b1ec4a 100644 --- a/lib/EpdFont/EpdFont.cpp +++ b/lib/EpdFont/EpdFont.cpp @@ -117,6 +117,9 @@ int8_t EpdFont::getKerning(const uint32_t leftCp, const uint32_t rightCp) const if (utf8IsCjkBreakable(leftCp) || utf8IsCjkBreakable(rightCp)) { return 0; } + if (data->kernHandler) { + return data->kernHandler(data->glyphMissCtx, leftCp, rightCp); + } if (!data->kernMatrix && !data->kernRowOffsets) { return 0; } diff --git a/lib/EpdFont/EpdFontData.h b/lib/EpdFont/EpdFontData.h index 0a0380c88dd..40a1e190542 100644 --- a/lib/EpdFont/EpdFontData.h +++ b/lib/EpdFont/EpdFontData.h @@ -240,4 +240,19 @@ typedef struct { /// answer from RAM-resident data without storage I/O. Shares glyphMissCtx. /// nullptr for fonts whose interval table is already complete (built-ins). bool (*coverageHandler)(void* ctx, uint32_t codepoint); + + /// Vector-font bitmap accessor (FreeInkFont / TtfEpdFont). When non-null, + /// GfxRenderer::getGlyphBitmap() returns vectorBitmapHandler(glyphMissCtx, glyph) + /// instead of indexing ->bitmap or going through the SdCardFont overflow path. + /// This lets a runtime-rasterized TTF fault + cache glyphs on ANY draw path + /// (via glyphMissHandler) without pre-warming. nullptr for every other font, + /// so the SD/built-in bitmap paths are unaffected (all fonts zero-init this). + const uint8_t* (*vectorBitmapHandler)(void* ctx, const EpdGlyph* glyph); + + /// Dynamic kerning for handler-backed fonts (TTF via FreeInkFont): returns + /// the 4.4 fixed-point pixel adjustment for the pair, 0 when none. Checked + /// by getKerning() before the static class tables (handler fonts carry + /// none). Shares glyphMissCtx. nullptr for built-in and SD fonts, whose + /// kerning is baked into the tables above (all fonts zero-init this). + int8_t (*kernHandler)(void* ctx, uint32_t leftCp, uint32_t rightCp); } EpdFontData; diff --git a/lib/EpdFont/FontDecompressor.cpp b/lib/EpdFont/FontDecompressor.cpp index 786f7d3e70a..b9e33496f92 100644 --- a/lib/EpdFont/FontDecompressor.cpp +++ b/lib/EpdFont/FontDecompressor.cpp @@ -1,11 +1,17 @@ #include "FontDecompressor.h" #include +#include // PSRAM-preferring font allocator (fiFontMalloc/Free) #include #include #include +// Decompressed-glyph page slots and the hot-group buffers are placed in PSRAM +// when the board has it (fiFontMalloc), falling back to the internal heap +// otherwise — so the built-in compressed fonts get the same lift as the SD and +// vector paths. fiFontFree releases either region. + FontDecompressor::~FontDecompressor() { deinit(); } bool FontDecompressor::init() { @@ -25,20 +31,20 @@ void FontDecompressor::clearCache() { void FontDecompressor::freePageBuffer() { for (uint8_t s = 0; s < pageSlotCount; s++) { - free(pageSlots[s].buffer); - free(pageSlots[s].glyphs); + fiFontFree(pageSlots[s].buffer); + fiFontFree(pageSlots[s].glyphs); pageSlots[s] = {}; } pageSlotCount = 0; } void FontDecompressor::freeHotGroup() { - free(hotGroup); + fiFontFree(hotGroup); hotGroup = nullptr; hotGroupCapacity = 0; hotGroupFont = nullptr; hotGroupIndex = UINT16_MAX; - free(hotGlyphBuf); + fiFontFree(hotGlyphBuf); hotGlyphBuf = nullptr; hotGlyphBufCapacity = 0; } @@ -47,8 +53,8 @@ bool FontDecompressor::ensureCapacity(uint8_t*& buf, uint32_t& capacity, uint32_ if (capacity >= needed) return true; // Grow-only, free-then-malloc: every caller fully rewrites the buffer after a grow, so the // old contents are dead -- freeing first gives the allocator its best shot on a tight heap. - free(buf); - buf = static_cast(malloc(needed)); // owned by FontDecompressor, freed in freeHotGroup() + fiFontFree(buf); + buf = static_cast(fiFontMalloc(needed)); // owned by FontDecompressor, freed in freeHotGroup() capacity = buf ? needed : 0; return buf != nullptr; } @@ -358,12 +364,12 @@ int FontDecompressor::prewarmCache(const EpdFontData* fontData, const char* utf8 stats.uniqueGroupsAccessed = groupCount; // Step 3: Allocate page buffer and lookup table for this slot - slot.buffer = static_cast(malloc(totalBytes)); - slot.glyphs = static_cast(malloc(glyphCount * sizeof(PageGlyphEntry))); + slot.buffer = static_cast(fiFontMalloc(totalBytes)); + slot.glyphs = static_cast(fiFontMalloc(glyphCount * sizeof(PageGlyphEntry))); if (!slot.buffer || !slot.glyphs) { LOG_ERR("FDC", "Failed to allocate page buffer (%u bytes, %u glyphs)", totalBytes, glyphCount); - free(slot.buffer); - free(slot.glyphs); + fiFontFree(slot.buffer); + fiFontFree(slot.glyphs); slot = {}; return glyphCount; } @@ -468,7 +474,7 @@ int FontDecompressor::prewarmCache(const EpdFontData* fontData, const char* utf8 uint16_t groupIdx = neededGroups[g]; const EpdFontGroup& group = fontData->groups[groupIdx]; - auto* tempBuf = static_cast(malloc(group.uncompressedSize)); + auto* tempBuf = static_cast(fiFontMalloc(group.uncompressedSize)); if (!tempBuf) { LOG_ERR("FDC", "Failed to allocate temp buffer (%u bytes) for group %u", group.uncompressedSize, groupIdx); missed++; @@ -479,7 +485,7 @@ int FontDecompressor::prewarmCache(const EpdFontData* fontData, const char* utf8 } if (!decompressGroup(fontData, groupIdx, tempBuf, group.uncompressedSize)) { - free(tempBuf); + fiFontFree(tempBuf); missed++; continue; } @@ -496,7 +502,7 @@ int FontDecompressor::prewarmCache(const EpdFontData* fontData, const char* utf8 writeOffset += glyph.dataLength; } - free(tempBuf); + fiFontFree(tempBuf); } LOG_DBG("FDC", "Prewarm: %u glyphs in %u bytes from %u groups (%d missed)", glyphCount, writeOffset, groupCount, diff --git a/lib/EpdFont/SdCardFont.cpp b/lib/EpdFont/SdCardFont.cpp index 6c3a7937674..8760ba02d74 100644 --- a/lib/EpdFont/SdCardFont.cpp +++ b/lib/EpdFont/SdCardFont.cpp @@ -1,5 +1,6 @@ #include "SdCardFont.h" +#include // PSRAM-preferring resident buffers (font memory lift) #include #include #include @@ -12,6 +13,15 @@ #include "EpdFontFamily.h" +// Resident SD-font buffers (glyph/kern arenas, interval + advance tables, the +// overflow ring) are placed in PSRAM when the board has it — freeing scarce +// internal SRAM — via these helpers. On a no-PSRAM board they fall back to the +// same internal heap `new[]`/`delete[]` used before, so behavior (and the +// fragmentation-avoidance logic below) is unchanged there. Every buffer routed +// through psramNewArray MUST be released with psramDeleteArray. +using freeink::font::psramDeleteArray; +using freeink::font::psramNewArray; + static_assert(sizeof(EpdGlyph) == 16, "EpdGlyph must be 16 bytes to match .cpfont file layout"); static_assert(sizeof(EpdUnicodeInterval) == 12, "EpdUnicodeInterval must be 12 bytes to match .cpfont file layout"); static_assert(sizeof(EpdKernClassEntry) == 3, "EpdKernClassEntry must be 3 bytes to match .cpfont file layout"); @@ -66,9 +76,6 @@ bool collectUniqueCodepoints(const char* text, uint32_t* codepoints, uint32_t& c return false; } -const char* asCStr(const std::string& s) { return s.c_str(); } -const char* asCStr(const char* s) { return s; } - // resetStyleMiniData retention bounds (see the PerStyle comment in the header). constexpr size_t MINI_RETAIN_MIN_FREE_HEAP = 40 * 1024; constexpr uint8_t MINI_UNDERUSE_RUNS_BEFORE_FREE = 3; @@ -85,8 +92,8 @@ constexpr uint32_t PREWARM_MAX_ALLOC_RESERVE = 4 * 1024; template bool ensureArrayCapacity(T*& buf, CapT& capacity, const uint32_t needed) { if (buf && capacity >= needed) return true; - delete[] buf; - buf = new (std::nothrow) T[needed > 0 ? needed : 1]; + psramDeleteArray(buf); + buf = psramNewArray(needed > 0 ? needed : 1); capacity = buf ? static_cast(needed) : 0; return buf != nullptr; } @@ -98,11 +105,11 @@ SdCardFont::~SdCardFont() { freeAll(); } // --- Per-style free/cleanup --- void SdCardFont::freeStyleMiniData(PerStyle& s) { - delete[] s.miniIntervals; + psramDeleteArray(s.miniIntervals); s.miniIntervals = nullptr; - delete[] s.miniGlyphs; + psramDeleteArray(s.miniGlyphs); s.miniGlyphs = nullptr; - delete[] s.miniBitmap; + psramDeleteArray(s.miniBitmap); s.miniBitmap = nullptr; s.miniIntervalCount = 0; s.miniGlyphCount = 0; @@ -136,21 +143,21 @@ void SdCardFont::freeStyleKernLigatureData(PerStyle& s) { s.stubData.ligaturePairCount = 0; s.miniData.ligaturePairs = nullptr; s.miniData.ligaturePairCount = 0; - delete[] s.kernLeftClasses; + psramDeleteArray(s.kernLeftClasses); s.kernLeftClasses = nullptr; - delete[] s.kernRightClasses; + psramDeleteArray(s.kernRightClasses); s.kernRightClasses = nullptr; - delete[] s.ligaturePairs; + psramDeleteArray(s.ligaturePairs); s.ligaturePairs = nullptr; s.kernLigLoaded = false; } void SdCardFont::freeStyleMiniKern(PerStyle& s) { - delete[] s.miniKernLeftClasses; + psramDeleteArray(s.miniKernLeftClasses); s.miniKernLeftClasses = nullptr; - delete[] s.miniKernRightClasses; + psramDeleteArray(s.miniKernRightClasses); s.miniKernRightClasses = nullptr; - delete[] s.miniKernMatrix; + psramDeleteArray(s.miniKernMatrix); s.miniKernMatrix = nullptr; s.miniKernLeftEntryCount = 0; s.miniKernRightEntryCount = 0; @@ -163,11 +170,10 @@ void SdCardFont::freeStyleMiniKern(PerStyle& s) { void SdCardFont::freeStyleAll(PerStyle& s) { freeStyleMiniData(s); - // A shared table is owned by the style it was aliased from -- that style's own - // freeStyleAll() call frees it. Deleting it here too would double-free. + // An earlier style owns any shared interval table. if (!s.intervalsShared) { - delete[] s.fullIntervals; - delete[] s.bmpIntervals; + psramDeleteArray(s.fullIntervals); + psramDeleteArray(s.bmpIntervals); } s.fullIntervals = nullptr; s.bmpIntervals = nullptr; @@ -203,7 +209,7 @@ void SdCardFont::freeAll() { void SdCardFont::clearOverflow() { for (uint32_t i = 0; i < overflowCount_; i++) { - delete[] overflow_[i].bitmap; + psramDeleteArray(overflow_[i].bitmap); overflow_[i].bitmap = nullptr; overflow_[i].codepoint = 0; } @@ -257,8 +263,8 @@ bool SdCardFont::loadStyleKernLigatureData(PerStyle& s) { // Load only the small class-lookup tables (~3KB each). The full matrix // (~36KB contiguous for Literata) is built per-page from SD in // buildMiniKernMatrix(). - s.kernLeftClasses = new (std::nothrow) EpdKernClassEntry[s.header.kernLeftEntryCount]; - s.kernRightClasses = new (std::nothrow) EpdKernClassEntry[s.header.kernRightEntryCount]; + s.kernLeftClasses = psramNewArray(s.header.kernLeftEntryCount); + s.kernRightClasses = psramNewArray(s.header.kernRightEntryCount); if (!s.kernLeftClasses || !s.kernRightClasses) { LOG_ERR("SDCF", "Failed to allocate kern classes (%u+%u bytes)", s.header.kernLeftEntryCount * 3u, @@ -283,7 +289,7 @@ bool SdCardFont::loadStyleKernLigatureData(PerStyle& s) { } if (hasLig) { - s.ligaturePairs = new (std::nothrow) EpdLigaturePair[s.header.ligaturePairCount]; + s.ligaturePairs = psramNewArray(s.header.ligaturePairCount); if (!s.ligaturePairs) { LOG_ERR("SDCF", "Failed to allocate ligature pairs"); freeStyleKernLigatureData(s); @@ -698,7 +704,7 @@ bool SdCardFont::load(const char* path) { } // Survived every record: alias the earlier style's table instead of allocating a copy. - // freeStyleAll() skips delete[] when intervalsShared is set, so only the owner frees. + // freeStyleAll() releases the table through its owning style. for (uint8_t k = 0; k < i && shareCandidates != 0; k++) { if ((shareCandidates & (1u << k)) == 0) continue; auto& owner = styles_[k]; @@ -725,7 +731,7 @@ bool SdCardFont::load(const char* path) { if (s.intervalsShared) { // Aliased above; fall through to the stub/metadata setup without touching the table. } else if (canUseBmp16) { - s.bmpIntervals = new (std::nothrow) PerStyle::BmpInterval16[s.header.intervalCount]; + s.bmpIntervals = psramNewArray(s.header.intervalCount); if (!s.bmpIntervals) { LOG_ERR("SDCF", "Failed to allocate compact intervals for style %u", i); freeAll(); @@ -742,7 +748,7 @@ bool SdCardFont::load(const char* path) { } s.intervalsAreBmp16 = true; } else { - s.fullIntervals = new (std::nothrow) EpdUnicodeInterval[s.header.intervalCount]; + s.fullIntervals = psramNewArray(s.header.intervalCount); if (!s.fullIntervals) { LOG_ERR("SDCF", "Failed to allocate %u intervals for style %u", s.header.intervalCount, i); freeAll(); @@ -1234,7 +1240,7 @@ int SdCardFont::prewarmStyle(uint8_t styleIdx, const uint32_t* codepoints, uint3 // holes can coalesce with the old arena. Recreate only the small read order. if (totalBitmapSize > s.miniBitmapCapacity) { readOrder.reset(); - delete[] s.miniBitmap; + psramDeleteArray(s.miniBitmap); s.miniBitmap = nullptr; s.miniBitmapCapacity = 0; if (ESP.getMaxAllocHeap() < totalBitmapSize) { @@ -1379,7 +1385,7 @@ void SdCardFont::clearCache() { void SdCardFont::clearPersistentCache() { for (uint8_t i = 0; i < MAX_STYLES; i++) { - delete[] advanceTable_[i]; + psramDeleteArray(advanceTable_[i]); advanceTable_[i] = nullptr; advanceTableSize_[i] = 0; } @@ -1416,7 +1422,7 @@ void SdCardFont::mergeIntoAdvanceTable(uint8_t styleIdx, const AdvanceEntry* sor uint32_t mergedCap = oldSize + newCount; if (mergedCap > ADVANCE_CACHE_LIMIT) mergedCap = ADVANCE_CACHE_LIMIT; - AdvanceEntry* merged = new (std::nothrow) AdvanceEntry[mergedCap]; + AdvanceEntry* merged = psramNewArray(mergedCap); if (!merged) { LOG_ERR("SDCF", "mergeIntoAdvanceTable: alloc failed (%u entries) style %u", mergedCap, styleIdx); return; @@ -1433,7 +1439,7 @@ void SdCardFont::mergeIntoAdvanceTable(uint8_t styleIdx, const AdvanceEntry* sor } } - delete[] advanceTable_[styleIdx]; + psramDeleteArray(advanceTable_[styleIdx]); advanceTable_[styleIdx] = merged; advanceTableSize_[styleIdx] = k; } @@ -1571,9 +1577,9 @@ int SdCardFont::fetchAdvancesForCodepoints(uint32_t* codepoints, uint32_t cpCoun return totalMissed; } -template -int SdCardFont::buildAdvanceTableRange(Iter begin, Iter end, bool includeSpace, bool includeHyphen, uint8_t styleMask, - const char* extraText) { +int SdCardFont::buildAdvanceTablePacked(const char* const* segments, const size_t* segmentLens, + const size_t segmentCount, const bool includeSpace, const bool includeHyphen, + uint8_t styleMask, const char* extraText) { if (!loaded_) return -1; styleMask = resolveStyleMask(styleMask); if (styleMask == 0) return 0; @@ -1590,8 +1596,14 @@ int SdCardFont::buildAdvanceTableRange(Iter begin, Iter end, bool includeSpace, uint32_t cpCount = 0; bool hitCap = false; - for (auto it = begin; it != end && !hitCap; ++it) { - hitCap = collectUniqueCodepoints(asCStr(*it), codepoints, cpCount, MAX_UNIQUE_CODEPOINTS); + // Each segment holds consecutive NUL-terminated words; walk word by word. + for (size_t seg = 0; seg < segmentCount && !hitCap; ++seg) { + const char* p = segments[seg]; + const char* const end = p + segmentLens[seg]; + while (p < end && !hitCap) { + hitCap = collectUniqueCodepoints(p, codepoints, cpCount, MAX_UNIQUE_CODEPOINTS); + p += strlen(p) + 1; + } } if (extraText && !hitCap) { hitCap = collectUniqueCodepoints(extraText, codepoints, cpCount, MAX_UNIQUE_CODEPOINTS); @@ -1614,12 +1626,8 @@ int SdCardFont::buildAdvanceTableRange(Iter begin, Iter end, bool includeSpace, } int SdCardFont::buildAdvanceTable(const char* utf8Text, uint8_t styleMask, const char* extraText) { - return buildAdvanceTableRange(&utf8Text, &utf8Text + 1, false, false, styleMask, extraText); -} - -int SdCardFont::buildAdvanceTable(const std::deque& words, bool includeHyphen, uint8_t styleMask, - const char* extraText) { - return buildAdvanceTableRange(words.begin(), words.end(), words.size() > 1, includeHyphen, styleMask, extraText); + const size_t len = strlen(utf8Text); + return buildAdvanceTablePacked(&utf8Text, &len, 1, false, false, styleMask, extraText); } // --- Stats --- @@ -1720,27 +1728,27 @@ const EpdGlyph* SdCardFont::onGlyphMiss(void* ctx, uint32_t codepoint) { // Read bitmap data into temporary (if any) uint8_t* tempBitmap = nullptr; if (tempGlyph.dataLength > 0) { - tempBitmap = new (std::nothrow) uint8_t[tempGlyph.dataLength]; + tempBitmap = psramNewArray(tempGlyph.dataLength); if (!tempBitmap) { LOG_ERR("SDCF", "Overflow: failed to allocate %u bytes for U+%04X bitmap", tempGlyph.dataLength, codepoint); return nullptr; } if (!file.seekSet(s.bitmapFileOffset + tempGlyph.dataOffset)) { LOG_ERR("SDCF", "Overflow: failed to seek to bitmap for U+%04X", codepoint); - delete[] tempBitmap; + psramDeleteArray(tempBitmap); file.close(); return nullptr; } if (file.read(tempBitmap, tempGlyph.dataLength) != static_cast(tempGlyph.dataLength)) { LOG_ERR("SDCF", "Overflow: failed to read bitmap for U+%04X", codepoint); - delete[] tempBitmap; + psramDeleteArray(tempBitmap); return nullptr; } } // All reads succeeded — commit to slot and advance ring buffer if (wasAtCapacity) { - delete[] self->overflow_[slot].bitmap; + psramDeleteArray(self->overflow_[slot].bitmap); } else { self->overflowCount_++; } diff --git a/lib/EpdFont/SdCardFont.h b/lib/EpdFont/SdCardFont.h index e97657cd8b5..3411c993094 100644 --- a/lib/EpdFont/SdCardFont.h +++ b/lib/EpdFont/SdCardFont.h @@ -1,7 +1,6 @@ #pragma once #include -#include #include #include @@ -73,8 +72,11 @@ class SdCardFont { // (e.g. shaped Arabic presentation forms the measurement path will look up). // Returns number of codepoints not found in font coverage. int buildAdvanceTable(const char* utf8Text, uint8_t styleMask = 0x0F, const char* extraText = nullptr); - int buildAdvanceTable(const std::deque& words, bool includeHyphen, uint8_t styleMask = 0x0F, - const char* extraText = nullptr); + // Packed variant: each segment holds consecutive NUL-terminated words + // (paragraph word-arena chunks), scanned without per-word string objects. + int buildAdvanceTablePacked(const char* const* segments, const size_t* segmentLens, size_t segmentCount, + bool includeSpace, bool includeHyphen, uint8_t styleMask = 0x0F, + const char* extraText = nullptr); // Look up advanceX for a codepoint from the advance table. // Returns the 12.4 fixed-point advance, or 0 if not found. @@ -329,9 +331,6 @@ class SdCardFont { void applyGlyphMissCallback(uint8_t styleIdx); int32_t findGlobalGlyphIndex(const PerStyle& s, uint32_t codepoint) const; int fetchAdvancesForCodepoints(uint32_t* codepoints, uint32_t cpCount, uint8_t styleMask); - template - int buildAdvanceTableRange(Iter begin, Iter end, bool includeSpace, bool includeHyphen, uint8_t styleMask, - const char* extraText = nullptr); int prewarmStyle(uint8_t styleIdx, const uint32_t* codepoints, uint32_t cpCount, bool metadataOnly, bool loadKernLig, bool accumulate); diff --git a/lib/EpdFont/SdCardFontRegistry.cpp b/lib/EpdFont/SdCardFontRegistry.cpp index 6b154c2f818..df9c136c765 100644 --- a/lib/EpdFont/SdCardFontRegistry.cpp +++ b/lib/EpdFont/SdCardFontRegistry.cpp @@ -1,9 +1,14 @@ #include "SdCardFontRegistry.h" +#if CROSSPOINT_VECTOR_FONTS +#include +#endif #include #include +#include // strcasecmp #include +#include #include // --- SdCardFontFamilyInfo helpers --- @@ -95,10 +100,149 @@ bool SdCardFontRegistry::parseFilename(const char* filename, uint8_t& size, uint return true; } +#if CROSSPOINT_VECTOR_FONTS + +bool SdCardFontRegistry::parseVectorFontName(const char* filename, size_t& baseLen) { + static constexpr const char* kExts[] = {".ttf", ".otf", ".ttc"}; + const size_t nameLen = strlen(filename); + for (const char* ext : kExts) { + const size_t extLen = strlen(ext); + if (nameLen <= extLen) continue; + const char* tail = filename + nameLen - extLen; + if (strcasecmp(tail, ext) == 0) { + baseLen = nameLen - extLen; + return baseLen > 0 && baseLen <= 127; + } + } + return false; +} + +uint8_t SdCardFontRegistry::parseVectorStyle(const char* baseName, size_t baseLen) { + // Case-insensitive token scan. "bold" (incl. semibold/demibold) → bold bit; + // "italic"/"oblique" → italic bit. Anything else is regular. + bool bold = false; + bool ital = false; + const size_t n = baseLen; + for (size_t i = 0; i < n; ++i) { + if ((n - i) >= 4 && strncasecmp(baseName + i, "bold", 4) == 0) bold = true; + if ((n - i) >= 6 && strncasecmp(baseName + i, "italic", 6) == 0) ital = true; + if ((n - i) >= 7 && strncasecmp(baseName + i, "oblique", 7) == 0) ital = true; + } + return static_cast((bold ? 1 : 0) | (ital ? 2 : 0)); +} + +// FtFont::ReadFn over a HalFile (absolute-offset reads; count 0 is a seek probe). +unsigned long SdCardFontRegistry::halFileRead(void* ctx, const unsigned long offset, unsigned char* buffer, + const unsigned long count) { + auto* f = static_cast(ctx); + if (f == nullptr || !*f) return 0; + if (!f->seek(static_cast(offset))) return 0; + if (count == 0) return 0; + const int n = f->read(buffer, count); + return n < 0 ? 0 : static_cast(n); +} + +void SdCardFontRegistry::refineVectorStyles(const char* dirPath, std::vector& files) { + using freeink::font::FtFont; + // Read each face's real weight + italic flag (inspectStream reads only the + // sfnt header tables, no face is retained), then pick the four roles + // DETERMINISTICALLY by design weight: the upright face nearest 400 is + // regular, nearest 700 is bold; same for the italics. This is independent of + // SD directory order — a Regular/Medium/Semibold/Bold/Black family always + // resolves to Regular + Bold, not to whichever file happened to enumerate + // first. An unreadable face falls back to its filename-derived role + // (Regular/Bold tokens → 400/700). + struct Candidate { + size_t index; // into files + uint16_t weight; + bool italic; + }; + std::vector cands; + cands.reserve(files.size()); + for (size_t i = 0; i < files.size(); ++i) { + Candidate c{i, static_cast((files[i].style & 1) ? 700 : 400), (files[i].style & 2) != 0}; + HalFile f = Storage.open(files[i].path.c_str()); + if (f && !f.isDirectory()) { + FtFont::FaceInfo face; + if (FtFont::inspectStream(&halFileRead, &f, static_cast(f.size()), face) == + FtFont::InspectResult::Ok) { + c.weight = face.weight; + c.italic = face.italic; + } + } + cands.push_back(c); + } + + // Nearest target weight within the upright/italic bucket; ties break to the + // lower weight, then the lexicographically smaller path — never enumeration + // order. `exclude` keeps bold from re-picking the regular file. + const auto pick = [&](const bool italic, const int target, const Candidate* exclude) -> const Candidate* { + const Candidate* best = nullptr; + for (const auto& c : cands) { + if (c.italic != italic || &c == exclude) continue; + if (!best) { + best = &c; + continue; + } + const int dc = std::abs(static_cast(c.weight) - target); + const int db = std::abs(static_cast(best->weight) - target); + if (dc < db || (dc == db && (c.weight < best->weight || + (c.weight == best->weight && files[c.index].path < files[best->index].path)))) { + best = &c; + } + } + return best; + }; + + const Candidate* regular = pick(false, 400, nullptr); + if (!regular) { + // All faces italic: the italic nearest 400 anchors the family as regular + // (TtfEpdFont needs a regular source; it derives the rest). + regular = pick(true, 400, nullptr); + if (regular) LOG_DBG("SDREG", "No upright face in %s — promoting %s", dirPath, files[regular->index].path.c_str()); + if (!regular) return; // no usable files at all + } + // Bold must be a genuinely heavier face than the regular pick; otherwise the + // synthesizer derives it (a same-or-lighter file would render identically). + const Candidate* bold = pick(false, 700, regular); + if (bold && bold->weight <= regular->weight) bold = nullptr; + const Candidate* italic = regular->italic ? nullptr : pick(true, 400, nullptr); + const Candidate* boldItalic = pick(true, 700, italic ? italic : regular); + if (boldItalic && italic && boldItalic->weight <= italic->weight) boldItalic = nullptr; + if (boldItalic && !boldItalic->italic) boldItalic = nullptr; + + std::vector selected; + selected.reserve(4); + const auto add = [&](const Candidate* c, const uint8_t role) { + if (!c) return; + SdCardFontFileInfo info = files[c->index]; + info.style = role; + selected.push_back(std::move(info)); + }; + add(regular, 0); + add(bold, 1); + add(italic, 2); + add(boldItalic, 3); + if (selected.size() < files.size()) { + LOG_DBG("SDREG", "%s: %u of %u faces selected by weight", dirPath, static_cast(selected.size()), + static_cast(files.size())); + } + files = std::move(selected); +} + +#endif // CROSSPOINT_VECTOR_FONTS + void SdCardFontRegistry::scanDirectory(const char* dirPath, SdCardFontFamilyInfo& family) { HalFile dir = Storage.open(dirPath); if (!dir || !dir.isDirectory()) return; + // Collect .cpfont and vector (.ttf/.otf/.ttc) candidates separately in one + // pass (the dir handle is forward-only), then commit whichever kind the folder + // holds. .cpfont wins if a folder somehow contains both, since a pre-rasterized + // bitmap family is the more specific artifact. + std::vector cpfontFiles; + std::vector vectorFiles; + char nameBuffer[128]; while (true) { HalFile entry = dir.openNextFile(); @@ -115,30 +259,54 @@ void SdCardFontRegistry::scanDirectory(const char* dirPath, SdCardFontFamilyInfo if (nameBuffer[0] == '.' || nameBuffer[0] == '_') continue; uint8_t size, style; - if (!parseFilename(nameBuffer, size, style)) continue; - - // Reject duplicate (pointSize, style) entries in the same family. With - // v4's bundle-everything design parseFilename always returns style=0, so - // two files at the same size in the same family would silently shadow - // each other in findFile(). Skip the duplicate and warn. - bool duplicate = false; - for (const auto& existing : family.files) { - if (existing.pointSize == size && existing.style == style) { - duplicate = true; - break; + if (parseFilename(nameBuffer, size, style)) { + // .cpfont: reject duplicate (pointSize, style) — style is always 0 in v4, + // so two files at the same size would silently shadow each other. + bool duplicate = false; + for (const auto& existing : cpfontFiles) { + if (existing.pointSize == size && existing.style == style) { + duplicate = true; + break; + } } - } - if (duplicate) { - LOG_ERR("SDREG", "Duplicate font %s in %s — skipping", nameBuffer, dirPath); + if (duplicate) { + LOG_ERR("SDREG", "Duplicate font %s in %s — skipping", nameBuffer, dirPath); + continue; + } + SdCardFontFileInfo info; + info.path = std::string(dirPath) + "/" + nameBuffer; + info.pointSize = size; + info.style = style; + cpfontFiles.push_back(std::move(info)); continue; } - SdCardFontFileInfo info; - info.path = std::string(dirPath) + "/" + nameBuffer; - info.pointSize = size; - info.style = style; - family.files.push_back(std::move(info)); +#if CROSSPOINT_VECTOR_FONTS + size_t baseLen = 0; + if (parseVectorFontName(nameBuffer, baseLen)) { + // Vector file in a family folder: seed the style role from the filename + // (e.g. Merriweather/Merriweather-Italic.ttf → italic); refineVectorStyles + // upgrades it from the face's own metadata and dedups by role afterwards. + SdCardFontFileInfo info; + info.path = std::string(dirPath) + "/" + nameBuffer; + info.pointSize = 0; // size-free + info.style = parseVectorStyle(nameBuffer, baseLen); + vectorFiles.push_back(std::move(info)); + } +#endif + } + + if (!cpfontFiles.empty()) { + family.vector = false; + family.files = std::move(cpfontFiles); + } +#if CROSSPOINT_VECTOR_FONTS + else if (!vectorFiles.empty()) { + refineVectorStyles(dirPath, vectorFiles); + family.vector = true; + family.files = std::move(vectorFiles); } +#endif } // Scan a single root (e.g. "/.fonts") and append its families to `out`. @@ -187,7 +355,36 @@ void SdCardFontRegistry::scanRoot(const char* rootPath, std::vector(out.back().files.size()), rootPath); } } else { +#if CROSSPOINT_VECTOR_FONTS + // Loose TrueType/OpenType file directly under the root (e.g. + // /fonts/Bookerly.ttf). Rendered at any size via the FreeInkFont engine. + entry.getName(nameBuffer, sizeof(nameBuffer)); entry.close(); + if (nameBuffer[0] == '.' || nameBuffer[0] == '_') continue; + size_t baseLen = 0; + if (!parseVectorFontName(nameBuffer, baseLen)) continue; + + std::string famName(nameBuffer, baseLen); // filename without extension + bool exists = false; + for (const auto& fam : out) { + if (fam.name == famName) { + exists = true; + break; + } + } + if (exists) continue; + + SdCardFontFamilyInfo family; + family.name = famName; + family.vector = true; + SdCardFontFileInfo info; + info.path = std::string(rootPath) + "/" + nameBuffer; + info.pointSize = 0; // size-free + info.style = 0; + family.files.push_back(std::move(info)); + out.push_back(std::move(family)); + LOG_DBG("SDREG", "Found vector font: %s in %s", famName.c_str(), rootPath); +#endif // CROSSPOINT_VECTOR_FONTS — loose .ttf/.otf files are ignored without the engine } } } diff --git a/lib/EpdFont/SdCardFontRegistry.h b/lib/EpdFont/SdCardFontRegistry.h index de81dddf151..3bf38f5d27f 100644 --- a/lib/EpdFont/SdCardFontRegistry.h +++ b/lib/EpdFont/SdCardFontRegistry.h @@ -4,18 +4,27 @@ #include #include +#include "VectorFontSupport.h" + struct SdCardFontFileInfo { std::string path; // v4 on-disk naming: "///_.cpfont" // where is "/.fonts" (preferred, hidden) or "/fonts" (visible). // e.g. "/.fonts/NotoSansCJK/NotoSansCJK_14.cpfont" - uint8_t pointSize; // parsed from filename: 14 - uint8_t style; // always 0 in v4 (all 4 styles bundled in one file); - // kept for potential future formats + uint8_t pointSize; // parsed from filename: 14 (0 for size-free vector fonts) + uint8_t style; // .cpfont: always 0 (all 4 styles bundled in one file). + // Vector family in a folder: the style ROLE of this file — + // 0=regular, 1=bold, 2=italic, 3=bold-italic (parsed from + // the filename). A loose vector file is always role 0. }; struct SdCardFontFamilyInfo { std::string name; // directory name, e.g. "NotoSansCJK" std::vector files; + // true for a loose TrueType/OpenType file (.ttf/.otf/.ttc) rendered at any + // size via the FreeInkFont engine (see TtfEpdFont / SdCardFontSystem). For a + // vector family `files` holds a single entry — the font path, pointSize 0 + // (size-free). false = a directory of pre-rasterized .cpfont files. + bool vector = false; const SdCardFontFileInfo* findFile(uint8_t size, uint8_t style = 0) const; // Installed file closest to `pointSize` (ties → smaller). nullptr when the @@ -51,10 +60,29 @@ class SdCardFontRegistry { int getFamilyIndex(const std::string& name) const; int getFamilyCount() const { return static_cast(families_.size()); } +#if CROSSPOINT_VECTOR_FONTS + // FtFont::ReadFn over a HalFile* ctx (absolute-offset reads; count 0 is a + // seek probe). Shared by face inspection here and streamed TTF sources + // (SdCardFontSystem). + static unsigned long halFileRead(void* ctx, unsigned long offset, unsigned char* buffer, unsigned long count); +#endif + private: std::vector families_; // sorted alphabetically static bool parseFilename(const char* filename, uint8_t& size, uint8_t& style); +#if CROSSPOINT_VECTOR_FONTS + // Match a loose vector font filename (.ttf/.otf/.ttc, case-insensitive) and + // return the length of the base name (extension stripped) in `baseLen`. + static bool parseVectorFontName(const char* filename, size_t& baseLen); + // Style role (0=regular, 1=bold, 2=italic, 3=bold-italic) inferred from a + // vector font's base name (case-insensitive "bold"/"italic"/"oblique" tokens). + static uint8_t parseVectorStyle(const char* baseName, size_t baseLen); + // Refine each vector file's style role from its real face metadata + // (FtFont::inspectStream: OS/2 weight + italic flag), keeping the + // filename-derived role when the face can't be read. Then dedup by role. + static void refineVectorStyles(const char* dirPath, std::vector& files); +#endif static void scanDirectory(const char* dirPath, SdCardFontFamilyInfo& family); // Scan one root (e.g. "/.fonts"), append families to `out`, dedup by name. static void scanRoot(const char* rootPath, std::vector& out); diff --git a/lib/EpdFont/TtfEpdFont.cpp b/lib/EpdFont/TtfEpdFont.cpp new file mode 100644 index 00000000000..c09cd100859 --- /dev/null +++ b/lib/EpdFont/TtfEpdFont.cpp @@ -0,0 +1,469 @@ +#include "TtfEpdFont.h" + +#if CROSSPOINT_VECTOR_FONTS + +#include +#include +#include +#include + +#include + +namespace { + +// Cache growth policy: PsramAlloc ABORTS when fiFontMalloc fails, and vector +// doubling transiently needs old+new blocks, so every cache expansion must be +// an exact, heap-checked reserve. On no-PSRAM boards the block lands in +// internal DRAM and must leave working headroom for the rest of the system. +bool canGrow(const size_t bytes) { + if (heap_caps_get_largest_free_block(MALLOC_CAP_SPIRAM) >= bytes) return true; + return heap_caps_get_largest_free_block(MALLOC_CAP_INTERNAL | MALLOC_CAP_8BIT) >= bytes && + heap_caps_get_free_size(MALLOC_CAP_INTERNAL | MALLOC_CAP_8BIT) >= bytes + 12 * 1024; +} + +// Exact reserve to `need` rounded up to `step`, capped at `ceil`. Returns +// false (leaving the vector untouched) when the heap cannot fund it. +template +bool reserveChecked(V& v, const size_t need, const size_t step, const size_t ceil) { + if (v.capacity() >= need) return true; + size_t newCap = ((need + step - 1) / step) * step; + if (newCap > ceil) newCap = ceil; + if (newCap < need) newCap = need; // need may legitimately exceed ceil rounding + if (!canGrow(newCap * sizeof(typename V::value_type))) return false; + v.reserve(newCap); + return true; +} +} // namespace + +void TtfEpdFont::addResidentSource(const uint8_t style, const uint8_t* data, const uint32_t len) { + if (style >= 4 || data == nullptr || len == 0) return; + Source& s = sources_[style]; + s = Source{}; + s.present = true; + s.streamed = false; + s.data = data; + s.len = len; +} + +void TtfEpdFont::addStreamSource(const uint8_t style, const freeink::font::FtFont::ReadFn read, void* ctx, + const unsigned long fileSize) { + if (style >= 4 || read == nullptr || fileSize == 0) return; + Source& s = sources_[style]; + s = Source{}; + s.present = true; + s.streamed = true; + s.read = read; + s.ctx = ctx; + s.fileSize = fileSize; +} + +void TtfEpdFont::resolveFaces() { + // Map each style face onto the best available source. Regular (0) anchors the + // family; the others prefer an explicit file, then fall back to synthesizing + // from a related source (wght axis / faux bold for weight, ital axis / oblique + // for slant — all handled inside FtFont). + const bool haveB = sources_[Bold].present; + const bool haveI = sources_[Italic].present; + const bool haveBI = sources_[BoldItalic].present; + + // regular + faces_[Regular].srcIndex = Regular; + faces_[Regular].weight = 400; + faces_[Regular].wantItalic = false; + + // bold + faces_[Bold].srcIndex = haveB ? Bold : Regular; + faces_[Bold].weight = haveB ? 400 : 700; + faces_[Bold].wantItalic = false; + + // italic + faces_[Italic].srcIndex = haveI ? Italic : Regular; + faces_[Italic].weight = 400; + faces_[Italic].wantItalic = !haveI; // oblique/axis only when using the roman source + + // bold-italic: dedicated file > bold-of-italic-file > italic-of-bold-file > roman + Face& bi = faces_[BoldItalic]; + if (haveBI) { + bi.srcIndex = BoldItalic; + bi.weight = 400; + bi.wantItalic = false; + } else if (haveI) { + bi.srcIndex = Italic; + bi.weight = 700; // wght axis / faux bold on the italic design + bi.wantItalic = false; + } else if (haveB) { + bi.srcIndex = Bold; + bi.weight = 400; + bi.wantItalic = true; // oblique on the bold design + } else { + bi.srcIndex = Regular; + bi.weight = 700; + bi.wantItalic = true; + } +} + +bool TtfEpdFont::load(const uint16_t pointSize, const bool twoBit, const size_t glyphCacheBytes, + const uint16_t maxGlyphs) { + loaded_ = false; + if (!sources_[Regular].present) return false; + // CrossPoint speaks point-size-at-150-DPI (matching the .cpfont converter's + // FT_Set_Char_Size(size, size, 150, 150)); FreeInkFont speaks pixels. Convert + // so vector fonts match the on-glyph size and metrics of the bitmap fonts: + // ppem = pointSize * 150 / 72, kept in 26.6 so the fractional part survives + // (glyphs raster and advance at the exact ppem; the per-face sizePx is the + // rounded form for the integer-pixel Font API). + size26_6_ = (static_cast(pointSize) * 150u * 64u + 36u) / 72u; + const uint16_t sizePx = static_cast((size26_6_ + 32u) >> 6); + resolveFaces(); + for (int i = 0; i < 4; ++i) { + Face& f = faces_[i]; + f.owner = this; + f.twoBit = twoBit; + f.sizePx = sizePx; + f.cap = glyphCacheBytes; + f.maxGlyphs = maxGlyphs; + f.inited = false; + f.ready = false; + // Flush the glyph/kern caches, not just the arena cursor: faultGlyph + // consults the lookup tables before anything else, so entries rasterized + // at a previous size would serve stale metrics — and their bitmaps get + // overwritten as new-size glyphs refill the arena from offset 0. + flushFace(f); + f.ligPairCount = 0; // re-resolved in initFace; stale pairs must not leak + for (uint32_t& g : f.ligGid) g = 0; // across a reload with new sources + } + initFace(faces_[0]); // regular eagerly: validates the font + gives metrics + if (!faces_[0].ready) return false; + for (int i = 1; i < 4; ++i) setupFace(faces_[i]); // handlers + placeholder metrics (regular's) + loaded_ = true; + return true; +} + +void TtfEpdFont::initFace(Face& f) { + if (f.inited) return; + f.inited = true; + const Source& s = sources_[f.srcIndex]; + if (!s.present) { + f.ready = false; + } else if (s.streamed) { + f.ready = f.ft.initStream(s.read, s.ctx, s.fileSize, f.sizePx, f.weight, f.wantItalic); + } else { + f.ready = f.ft.init(s.data, s.len, f.sizePx, f.weight, f.wantItalic); + } + if (f.ready) { + // Full auto-hinting + stem darkening pair with the BW-aware quantizer in + // faultGlyph: the 50% ink threshold needs stems snapped to full-coverage + // pixels or strokes break up / vary per letter on BW page turns (see + // platformio.ini's FREEINK_FONT_ENABLE_AUTOHINT note). Auto (both axes) + // rather than Light (vertical only): Light leaves stem WIDTHS fractional, + // so the ink threshold prints the same-width stem as 1px or 2px depending + // on each glyph's horizontal phase — visibly uneven letter weights. Auto + // equalizes stem widths across the face. Darkening nudges borderline-thin + // strokes over the threshold. + freeink::font::FtFont::RenderOptions ro; + ro.hinting = freeink::font::FtFont::HintingMode::Auto; + ro.stemDarkening = true; + if (!f.ft.setRenderOptions(ro)) { + LOG_ERR("TTF", "Auto hinting unavailable (FREEINK_FONT_ENABLE_AUTOHINT not compiled)"); + } + // GPOS kerning for RESIDENT faces only (they borrow a view into the font + // bytes — free). Unlike GSUB, GPOS must stay resident for render-time + // pair queries, and a STREAMED face would need an owned DRAM copy exactly + // when the C3 is poorest (a font big enough to stream, i.e. CJK, where + // Latin pair kerning barely matters). Budget 0 = streamed faces skip it. + f.ft.setGposByteBudget(0); + resolveLigatures(f); + } + // Caches are NOT pre-reserved: the byte arena (f.bmp) and the glyph tables + // grow on demand in faultGlyph and converge on the book's page needs (see the + // header's memory note). f.cap is only the hard ceiling that triggers a flush. + setupFace(f); +} + +void TtfEpdFont::resolveLigatures(Face& f) { + // One GSUB pass per face init: resolve the standard Latin ligatures to glyph + // IDs, then drop the table — nothing else queries GSUB, so a streamed face + // never keeps its (budgeted) table copy resident. + static constexpr uint32_t kComps[5][3] = { + {'f', 'f', 0}, {'f', 'i', 0}, {'f', 'l', 0}, {'f', 'f', 'i'}, {'f', 'f', 'l'}}; + f.ft.setGsubByteBudget(48 * 1024); // C3 DRAM discipline: oversized GSUB → no ligatures, not OOM + for (int i = 0; i < 5; ++i) f.ligGid[i] = f.ft.ligatureGlyphId(kComps[i], kComps[i][2] ? 3 : 2); + f.ft.releaseLigatureTable(); + + // Pair table for EpdFont::applyLigatures(), keyed on the U+FB00–FB04 + // presentation codepoints. A ligature is usable if GSUB named a glyph or the + // cmap maps the presentation codepoint directly. Entries are appended in + // ascending key order (the ff/fi/fl keys sort below the FB00-chained ones). + auto avail = [&f](const int i) { return f.ligGid[i] != 0 || f.ft.hasGlyph(0xFB00u + i); }; + f.ligPairCount = 0; + auto add = [&f](const uint32_t left, const uint32_t right, const uint32_t out) { + f.ligPairs[f.ligPairCount++] = EpdLigaturePair{(left << 16) | right, out}; + }; + if (avail(0)) add('f', 'f', 0xFB00); + if (avail(1)) add('f', 'i', 0xFB01); + if (avail(2)) add('f', 'l', 0xFB02); + if (avail(0)) { // ffi/ffl chain through the ff result as the new left + if (avail(3)) add(0xFB00, 'i', 0xFB03); + if (avail(4)) add(0xFB00, 'l', 0xFB04); + } +} + +void TtfEpdFont::setupFace(Face& f) { + // Metrics from this face once live, else borrow the (always-live) regular + // face's — same font/size, so a fine placeholder until this face is faulted. + freeink::font::FtFont& src = f.ready ? f.ft : faces_[0].ft; + // Fractional-ppem line metrics, rounded once here rather than per-call. + freeink::font::FtFont::LineMetrics lm; + int ascent, lineHeight; + if (src.lineMetrics26_6(f.owner->size26_6_, lm)) { + ascent = static_cast((lm.ascender26_6 + 32) >> 6); + lineHeight = static_cast((lm.height26_6 + 32) >> 6); + } else { + ascent = src.ascent(f.sizePx); + lineHeight = src.lineHeight(f.sizePx); + } + f.data = EpdFontData{}; + f.data.advanceY = static_cast(lineHeight > 255 ? 255 : (lineHeight < 0 ? 0 : lineHeight)); + f.data.ascender = ascent; + f.data.descender = lineHeight - ascent > 0 ? lineHeight - ascent : 0; + f.data.is2Bit = f.twoBit; + f.data.glyphMissHandler = &TtfEpdFont::missThunk; + f.data.glyphMissCtx = &f; + f.data.coverageHandler = &TtfEpdFont::coverageThunk; + f.data.vectorBitmapHandler = &TtfEpdFont::bitmapThunk; + // GSUB-derived ligature pairs (resolveLigatures). Empty until this face + // inits — a lazy style renders its first pass ligature-free, then picks + // them up once its glyphs fault the face in. + f.data.ligaturePairs = f.ligPairCount ? f.ligPairs : nullptr; + f.data.ligaturePairCount = f.ligPairCount; + f.data.kernHandler = &TtfEpdFont::kernThunk; +} + +void TtfEpdFont::flushFace(Face& f) { + f.glyphs.clear(); + f.cps.clear(); + f.slot.clear(); + f.kernKeys.clear(); + f.kernVals.clear(); + f.used = 0; +} + +void TtfEpdFont::clearCache() { + // Drop cached page glyphs on every inited face but keep the allocations: the + // vectors keep capacity (clear() does not free) and f.bmp keeps its buffer, so + // the next prewarm re-faults into buffers already sized to the book. + for (Face& f : faces_) { + if (f.inited) flushFace(f); + } +} + +void TtfEpdFont::releaseResidentCaches() { + if (evictionLocked_) return; // mid-fault: this font's faces are live + for (int i = 0; i < 4; ++i) { + Face& f = faces_[i]; + // Actually RELEASE the caches (swap-with-empty frees capacity; clear() alone + // would not). + flushFace(f); + freeink::font::PsramVector().swap(f.bmp); + freeink::font::PsramVector().swap(f.glyphs); + freeink::font::PsramVector().swap(f.cps); + freeink::font::PsramVector().swap(f.slot); + freeink::font::PsramVector().swap(f.kernKeys); + freeink::font::PsramVector().swap(f.kernVals); + // Keep the regular face's FreeType face live so coverage()/metrics still + // answer without a reload (mirrors SD keeping its interval table resident). + // Shed the lazy bold/italic/bold-italic faces entirely; they re-init on the + // next glyph fault for that style. + if (i == 0) continue; + if (f.inited) { + f.ft.deinit(); + f.inited = false; + f.ready = false; + } + } +} + +const EpdGlyph* TtfEpdFont::faultGlyph(Face& f, const uint32_t cp) { + if (!f.inited) initFace(f); + if (!f.ready) return nullptr; + { + const auto it = std::lower_bound(f.cps.begin(), f.cps.end(), cp); + if (it != f.cps.end() && *it == cp) return &f.glyphs[f.slot[static_cast(it - f.cps.begin())]]; + } + + // Resolve to a glyph ID: cmap first, then the GSUB result for the ligature + // presentation codepoints (whose glyphs commonly have no cmap entry at all). + freeink::font::FtFont::GlyphId gid = f.ft.glyphId(cp); + if (gid == 0 && cp >= 0xFB00u && cp <= 0xFB04u) gid = f.ligGid[cp - 0xFB00u]; + if (gid == 0) return nullptr; + + // Glyph-ID render at the exact fractional ppem. The metrics pass costs a + // second glyph load per MISS only (hits come from the cache) and yields the + // 26.6 advance, which the 12.4 EpdGlyph.advanceX preserves for the + // renderer's differential rounding — GlyphBitmap.advance is whole pixels. + const uint32_t size26_6 = f.owner->size26_6_; + freeink::font::FtFont::GlyphMetrics gm; + const bool haveMetrics = f.ft.metricsGlyph26_6(gid, size26_6, gm); + const freeink::font::GlyphBitmap* g = f.ft.rasterizeGlyph26_6(gid, size26_6); + uint32_t px = (g && g->pixels) ? static_cast(g->width) * g->height : 0; + size_t bytes = px ? (f.twoBit ? (px + 3) / 4 : (px + 7) / 8) : 0; + if (bytes > f.cap) { + bytes = 0; + px = 0; + } + if (f.glyphs.size() >= f.maxGlyphs || f.used + bytes > f.cap) flushFace(f); + + // All growth below is exact and heap-checked (see reserveChecked). On a + // failed grow, ask the memory manager to evict rebuildable caches (render + // glyph cache, other fonts' arenas — evictionLocked_ keeps THIS font's + // faces alive) and retry once. After that: a failed table grow is an + // uncached miss (caller skips the glyph this pass); a failed arena grow + // degrades to an advance-only glyph so layout survives. + static constexpr size_t kTableStep = 64; + const auto growTables = [&]() { + return reserveChecked(f.glyphs, f.glyphs.size() + 1, kTableStep, f.maxGlyphs) && + reserveChecked(f.cps, f.cps.size() + 1, kTableStep, f.maxGlyphs) && + reserveChecked(f.slot, f.slot.size() + 1, kTableStep, f.maxGlyphs); + }; + const auto evictOthers = [&]() { + evictionLocked_ = true; + freeink::MemoryManager::instance().ensureFree(16 * 1024); + evictionLocked_ = false; + }; + if (!growTables()) { + evictOthers(); + if (!growTables()) return nullptr; + } + if (px && !reserveChecked(f.bmp, f.used + bytes, 4096, f.cap)) { + evictOthers(); + if (!reserveChecked(f.bmp, f.used + bytes, 4096, f.cap)) { + bytes = 0; + px = 0; + } + } + + EpdGlyph eg{}; + const int32_t adv12_4 = haveMetrics ? (gm.advance26_6 + 2) >> 2 : (g ? g->advance << 4 : 0); + eg.advanceX = static_cast(adv12_4 < 0 ? 0 : adv12_4); + if (px && g && g->pixels) { + // Grow the byte arena on demand toward the book's page high-water mark. + // flush above guarantees f.used + bytes <= f.cap, so this never exceeds the + // ceiling; capacity is retained across page turns (clearCache keeps it). + if (f.bmp.size() < f.used + bytes) f.bmp.resize(f.used + bytes, 0); + uint8_t* dst = f.bmp.data() + f.used; + for (size_t i = 0; i < bytes; ++i) dst[i] = 0; + for (uint32_t i = 0; i < px; ++i) { + const uint8_t a = g->pixels[i]; + if (f.twoBit) { + // Same 25/50/75% level thresholds as the .cpfont converter + // (fontconvert_sdcard.py's 4-bit >=4/8/12 downsample), so a TTF face + // prints at the same stroke weight as its .cpfont rendition. The BW + // page-turn pass inks ANY nonzero level, so the 25% start is what + // gives CJK strokes their full weight; starting at 50% rendered + // visibly thinner than cpfont. + const uint8_t v = a < 64 ? 0 : a < 128 ? 1 : a < 192 ? 2 : 3; + dst[i >> 2] |= static_cast(v << ((3 - (i & 3)) * 2)); + } else if (a >= 128) { + dst[i >> 3] |= static_cast(1u << (7 - (i & 7))); + } + } + eg.width = static_cast(g->width); + eg.height = static_cast(g->height); + eg.left = g->xoff; + // FreeInkFont's yoff is negative-above (top offset from baseline); CrossPoint's + // EpdGlyph.top is positive-above (renderer computes screen Y = cursorY - top, + // matching the .cpfont converter's top = FreeType bitmap_top). Flip the sign. + eg.top = static_cast(-g->yoff); + eg.dataOffset = static_cast(f.used); + eg.dataLength = static_cast(bytes); + f.used += bytes; + } else { + eg.dataOffset = static_cast(f.used); + eg.dataLength = 0; + } + + f.glyphs.push_back(eg); + const uint16_t newIdx = static_cast(f.glyphs.size() - 1); + const auto it = std::lower_bound(f.cps.begin(), f.cps.end(), cp); + const size_t pos = static_cast(it - f.cps.begin()); + f.cps.insert(it, cp); + f.slot.insert(f.slot.begin() + pos, newIdx); + return &f.glyphs[newIdx]; +} + +int8_t TtfEpdFont::faultKern(Face& f, const uint32_t leftCp, const uint32_t rightCp) { + if (leftCp == 0 || rightCp == 0) return 0; + if (!f.inited) initFace(f); + if (!f.ready) return 0; + const uint64_t key = (uint64_t(leftCp) << 32) | rightCp; + { + const auto it = std::lower_bound(f.kernKeys.begin(), f.kernKeys.end(), key); + if (it != f.kernKeys.end() && *it == key) return f.kernVals[static_cast(it - f.kernKeys.begin())]; + } + + // 26.6 → 4.4 (round-half-away), clamped to the int8 contract (±8px, far + // beyond any real kern at reader sizes). + const int32_t k26 = f.ft.kerning26_6(leftCp, rightCp, f.owner->size26_6_); + int32_t k4 = (k26 + (k26 < 0 ? -2 : 2)) / 4; + if (k4 < -128) k4 = -128; + if (k4 > 127) k4 = 127; + + // Text uses few distinct pairs; the cap only guards against pathological + // content churning the cache without bound. + static constexpr size_t kKernCap = 512; + if (f.kernKeys.size() >= kKernCap) { + f.kernKeys.clear(); + f.kernVals.clear(); + } + // One-time exact reserve: with capacity pinned at the cap, the sorted + // inserts below never touch the allocator. Uncached result if the heap + // cannot fund the cache at all. + if (!reserveChecked(f.kernKeys, kKernCap, kKernCap, kKernCap) || + !reserveChecked(f.kernVals, kKernCap, kKernCap, kKernCap)) { + return static_cast(k4); + } + const auto it = std::lower_bound(f.kernKeys.begin(), f.kernKeys.end(), key); + const size_t pos = static_cast(it - f.kernKeys.begin()); + f.kernKeys.insert(it, key); + f.kernVals.insert(f.kernVals.begin() + pos, static_cast(k4)); + return static_cast(k4); +} + +const EpdGlyph* TtfEpdFont::missThunk(void* ctx, const uint32_t codepoint) { + Face* f = static_cast(ctx); + return f->owner->faultGlyph(*f, codepoint); +} +int8_t TtfEpdFont::kernThunk(void* ctx, const uint32_t leftCp, const uint32_t rightCp) { + Face* f = static_cast(ctx); + return f->owner->faultKern(*f, leftCp, rightCp); +} +const uint8_t* TtfEpdFont::bitmapThunk(void* ctx, const EpdGlyph* glyph) { + if (glyph == nullptr || glyph->dataLength == 0) return nullptr; + return static_cast(ctx)->bmp.data() + glyph->dataOffset; +} +bool TtfEpdFont::coverageThunk(void* ctx, const uint32_t codepoint) { + // All styles share one file → coverage comes from the always-live regular face. + Face& reg = static_cast(ctx)->owner->faces_[0]; + if (reg.ft.hasGlyph(codepoint)) return true; + // Ligature presentation codepoints resolvable through GSUB despite no cmap + // entry (some EPUBs carry literal U+FB01/U+FB02 in their text). + return codepoint >= 0xFB00u && codepoint <= 0xFB04u && reg.ligGid[codepoint - 0xFB00u] != 0; +} + +EpdFontFamily TtfEpdFont::family() const { + return EpdFontFamily(&faces_[0].font, &faces_[1].font, &faces_[2].font, &faces_[3].font); +} + +bool TtfEpdFont::build(const char* utf8) { + if (!loaded_) return false; + flushFace(faces_[0]); + return addCoverage(utf8); +} +bool TtfEpdFont::addCoverage(const char* utf8) { + if (!loaded_ || utf8 == nullptr) return false; + const auto* p = reinterpret_cast(utf8); + while (*p != '\0') faultGlyph(faces_[0], utf8NextCodepoint(&p)); + return true; +} + +#endif // CROSSPOINT_VECTOR_FONTS diff --git a/lib/EpdFont/TtfEpdFont.h b/lib/EpdFont/TtfEpdFont.h new file mode 100644 index 00000000000..f036e49aa02 --- /dev/null +++ b/lib/EpdFont/TtfEpdFont.h @@ -0,0 +1,161 @@ +#pragma once + +// CrossPoint <- FreeInkFont adapter (FreeType, 4-style, lazy, streaming). +// +// Renders one or more TrueType/OpenType files — including OpenType VARIABLE +// fonts — as a CrossPoint EpdFontFamily with regular / bold / italic / +// bold-italic via the FreeInkFont FreeType backend. +// +// STYLE SOURCES. A family can be built from up to four source files, one per +// style role (0=regular, 1=bold, 2=italic, 3=bold-italic); only regular is +// required. Each face resolves to the best available source: +// * an explicit file for that style, if supplied (e.g. a separate +// Family-Italic.ttf → true italic letterforms, or a static Family-Bold.ttf); +// * else the regular source with the wght axis pushed to bold (variable +// fonts) or a per-glyph outline embolden (static) for bold; +// * else the regular source with an ital/slnt axis or an oblique shear for +// italic. Bold-italic combines the two, preferring an italic source's bold. +// So a single variable file yields all four styles, and dropping in dedicated +// Bold/Italic files upgrades those styles to the real designs. +// +// Memory-lean: +// * STREAMING (addStreamSource) — FreeType pulls bytes from SD on demand, so a +// multi-MB variable/CJK file never sits in RAM. Use addResidentSource() only +// for small fonts already held in RAM. +// * LAZY per-style faces — only the regular face is built up front; bold / +// italic / bold-italic (and their glyph caches) are created on first use, so +// a book with no bold pays nothing for it. +// * per-face glyph caches that GROW TO CONVERGE (no worst-case pre-reserve): +// the byte arena and glyph tables start empty and grow only to the book's +// actual page needs, then stop touching the allocator (clearCache keeps the +// capacity across page turns). Bounded by a hard byte cap that flushes when +// full, and shed entirely by releaseResidentCaches() on heap-critical +// transitions — on par with the SD (.cpfont) font system's discipline. +// * caches live in PSRAM when the board has it (FontPsram / FontAlloc), so +// glyph arenas don't consume scarce internal SRAM. +// +// Lifetime: every configured source (resident bytes or streamed read source, +// e.g. an open SD file) is BORROWED and must outlive this object, which must +// outlive any GfxRenderer registration. + +#include "VectorFontSupport.h" + +#if CROSSPOINT_VECTOR_FONTS + +#include +#include + +#include +#include + +#include "EpdFont.h" +#include "EpdFontData.h" +#include "EpdFontFamily.h" + +class TtfEpdFont { + public: + // Style roles for source slots. + enum Style : uint8_t { Regular = 0, Bold = 1, Italic = 2, BoldItalic = 3 }; + + // Configure a source file for a style role BEFORE calling load(). Regular is + // required; the rest are optional and upgrade their style to a real design. + // Resident: bytes borrowed. Streamed: read source borrowed. + void addResidentSource(uint8_t style, const uint8_t* data, uint32_t len); + void addStreamSource(uint8_t style, freeink::font::FtFont::ReadFn read, void* ctx, unsigned long fileSize); + + // Build the family at the given reader point size from the configured sources. + // Returns false if the regular source is missing or unparseable. + bool load(uint16_t pointSize, bool twoBit = true, size_t glyphCacheBytes = 32 * 1024, uint16_t maxGlyphs = 768); + + EpdFontFamily family() const; + + // Per-scope reset (mirrors SdCardFont::clearCache): drop every face's cached + // page glyphs but KEEP the allocations (byte arena + vector capacity), so a + // page turn re-faults into buffers already sized to the book and stops touching + // the allocator once converged. Driven by FontCacheManager::clearCache() / + // PrewarmScope, symmetrically with the SD fonts. + void clearCache(); + + // Heap-critical teardown (mirrors SdCardFont::releaseResidentCaches): free + // every rebuildable cache — the byte arenas, glyph tables, and the lazy bold/ + // italic/bold-italic FreeType faces — keeping only the regular face live so + // coverage() still answers without a reload. Everything faults back in on + // demand. Driven by FontCacheManager::releaseSdFontCaches() before heap-hungry + // transitions (WiFi + web server, image decode, dictionary, sleep). + void releaseResidentCaches(); + + // Optional batch pre-warm of the REGULAR face (other styles fault lazily). + bool build(const char* utf8); + bool addCoverage(const char* utf8); + + private: + // A borrowed source file (one per style role that the caller supplies). + struct Source { + bool present = false; + bool streamed = false; + const uint8_t* data = nullptr; // resident form + uint32_t len = 0; + freeink::font::FtFont::ReadFn read = nullptr; // streamed form + void* ctx = nullptr; + unsigned long fileSize = 0; + }; + + struct Face { + TtfEpdFont* owner = nullptr; + freeink::font::FtFont ft; + freeink::font::PsramVector bmp; + size_t used = 0; + size_t cap = 0; + uint16_t maxGlyphs = 0; + bool twoBit = true; + uint16_t sizePx = 0; + uint8_t srcIndex = 0; // which Source this face initializes from + int weight = 400; // design weight requested (wght axis / faux bold) + bool wantItalic = false; // request italic from the source (axis or oblique) + bool inited = false; // init attempted (lazy) + bool ready = false; // FreeType face live + // GSUB 'liga' resolution (fi fl ff ffi ffl), resolved once at initFace(): + // ligGid[i] is the raw glyph ID for U+FB00+i (0 = none), and ligPairs is + // the EpdFont pair table (sorted by key) that routes applyLigatures() to + // those presentation codepoints; faultGlyph() then rasterizes them by + // glyph ID even when the face's cmap has no entry for them. + uint32_t ligGid[5] = {0, 0, 0, 0, 0}; + EpdLigaturePair ligPairs[5] = {}; + uint8_t ligPairCount = 0; + freeink::font::PsramVector glyphs; + freeink::font::PsramVector cps; + freeink::font::PsramVector slot; + // Kern pair cache ((left<<32)|right → 4.4 value), sorted by key. Kerning + // resolves through FtFont (legacy 'kern' table, then the GPOS 'kern' + // feature) once per pair, then serves from here — getKerning runs for + // every adjacent glyph pair on every draw/measure pass. + freeink::font::PsramVector kernKeys; + freeink::font::PsramVector kernVals; + EpdFontData data{}; + EpdFont font{&data}; + }; + + static const EpdGlyph* missThunk(void* ctx, uint32_t codepoint); + static const uint8_t* bitmapThunk(void* ctx, const EpdGlyph* glyph); + static bool coverageThunk(void* ctx, uint32_t codepoint); + static int8_t kernThunk(void* ctx, uint32_t leftCp, uint32_t rightCp); + + void resolveFaces(); // map the 4 faces onto the configured sources + void initFace(Face& f); // lazy: create the FT face on first use + void setupFace(Face& f); // wire data handlers + metrics + void resolveLigatures(Face& f); // query GSUB once, build ligGid/ligPairs + const EpdGlyph* faultGlyph(Face& f, uint32_t codepoint); + int8_t faultKern(Face& f, uint32_t leftCp, uint32_t rightCp); + static void flushFace(Face& f); + + Source sources_[4]; // indexed by Style role + Face faces_[4]; // 0=regular 1=bold 2=italic 3=bold-italic + uint32_t size26_6_ = 0; // exact 26.6 ppem (pt @150DPI), no whole-pixel rounding + bool loaded_ = false; + // Set while a glyph fault runs MemoryManager::ensureFree(): the eviction + // sink calls releaseResidentCaches() on every TTF font, and tearing down + // the very faces mid-fault would be use-after-free. + bool evictionLocked_ = false; +}; + +#endif // CROSSPOINT_VECTOR_FONTS diff --git a/lib/EpdFont/VectorFontSupport.h b/lib/EpdFont/VectorFontSupport.h new file mode 100644 index 00000000000..5e6f96e59fe --- /dev/null +++ b/lib/EpdFont/VectorFontSupport.h @@ -0,0 +1,16 @@ +#pragma once + +// Runtime TTF/OTF rendering (FreeInkFont/FreeType via TtfEpdFont) is a +// PSRAM-boards-only feature. On the no-PSRAM ESP32-C3 devices the engine's +// working set (FreeType face + table state, per-style glyph arenas, resident +// or GPOS font bytes) competes with the reader's section build for the same +// ~380KB of internal DRAM, and the ~110KB of FreeType flash rides along. +// Gating on BOARD_HAS_PSRAM (defined by every PSRAM env in platformio.ini) +// compiles the whole path out: FreeType never links, .ttf/.otf files are +// invisible to the font registry, and .cpfont SD fonts remain the only +// sideloaded-font route on those boards. +#ifdef BOARD_HAS_PSRAM +#define CROSSPOINT_VECTOR_FONTS 1 +#else +#define CROSSPOINT_VECTOR_FONTS 0 +#endif diff --git a/lib/Epub/Epub/ParsedText.cpp b/lib/Epub/Epub/ParsedText.cpp index a203e6f8f9e..b697a877581 100644 --- a/lib/Epub/Epub/ParsedText.cpp +++ b/lib/Epub/Epub/ParsedText.cpp @@ -11,6 +11,7 @@ #include #include #include +#include #include #include "TokenBoundary.h" @@ -40,28 +41,30 @@ bool mayContainRtlBytes(const char* str) { } // Returns the first rendered codepoint of a word (skipping leading soft hyphens). -uint32_t firstCodepoint(const std::string& word) { - const auto* ptr = reinterpret_cast(word.c_str()); - while (true) { +uint32_t firstCodepoint(const std::string_view word) { + const auto* ptr = reinterpret_cast(word.data()); + const auto* const end = ptr + word.size(); + while (ptr < end) { const uint32_t cp = utf8NextCodepoint(&ptr); if (cp == 0) return 0; if (cp != 0x00AD) return cp; // skip soft hyphens } + return 0; } // Returns the last codepoint of a word by scanning backward for the start of the last UTF-8 sequence. -uint32_t lastCodepoint(const std::string& word) { +uint32_t lastCodepoint(const std::string_view word) { if (word.empty()) return 0; // UTF-8 continuation bytes start with 10xxxxxx; scan backward to find the leading byte. size_t i = word.size() - 1; while (i > 0 && (static_cast(word[i]) & 0xC0) == 0x80) { --i; } - const auto* ptr = reinterpret_cast(word.c_str() + i); + const auto* ptr = reinterpret_cast(word.data() + i); return utf8NextCodepoint(&ptr); } -bool containsSoftHyphen(const std::string& word) { return word.find(SOFT_HYPHEN_UTF8) != std::string::npos; } +bool containsSoftHyphen(const std::string_view word) { return word.find(SOFT_HYPHEN_UTF8) != std::string_view::npos; } bool isNoBreakBeforeCjkPunctuation(const uint32_t cp) { switch (cp) { @@ -217,18 +220,24 @@ constexpr int scaleSpace(const int advance, const uint8_t wordSpacingPercent) { // Returns the advance width for a word while ignoring soft hyphen glyphs and optionally appending a visible hyphen. // Uses advance width (sum of glyph advances + kerning) rather than bounding box width so that italic glyph overhangs // don't inflate inter-word spacing. -uint16_t measureWordWidth(const GfxRenderer& renderer, const int fontId, const std::string& word, +// `word` is usually NUL-terminated at data()[size()] (arena entries and +// std::string arguments), but focus-split candidates pass PREFIX views of a +// stored word. Probing data()[size()] is in-bounds for every caller (it is +// either the word's own NUL or a mid-word byte of the same arena entry) and +// routes unterminated views through the copying path so the C API never +// measures past the view. +uint16_t measureWordWidth(const GfxRenderer& renderer, const int fontId, const std::string_view word, const EpdFontFamily::Style style, const int8_t characterSpacing, const uint8_t wordSpacingPercent, const bool appendHyphen = false) { if (word.size() == 1 && word[0] == ' ' && !appendHyphen) { return scaleSpace(renderer.getSpaceWidth(fontId, style), wordSpacingPercent); } const bool hasSoftHyphen = containsSoftHyphen(word); - if (!hasSoftHyphen && !appendHyphen) { - return renderer.getTextAdvanceX(fontId, word.c_str(), style, characterSpacing); + if (!hasSoftHyphen && !appendHyphen && word.data()[word.size()] == '\0') { + return renderer.getTextAdvanceX(fontId, word.data(), style, characterSpacing); } - std::string sanitized = word; + std::string sanitized(word); if (hasSoftHyphen) { stripSoftHyphensInPlace(sanitized); } @@ -241,7 +250,7 @@ uint16_t measureWordWidth(const GfxRenderer& renderer, const int fontId, const s // True when a token ends with a hyphen or dash that allows a line break after it. U+00AD is // excluded because it renders as nothing, so only a hyphenation break -- which substitutes a // visible '-' -- may use it; U+2011 because forbidding that break is its purpose. -bool endsWithBreakableHyphen(const std::string& token) { +bool endsWithBreakableHyphen(const std::string_view token) { if (token.empty()) return false; return TokenBoundary::allowsBreakAfterExplicitHyphen(lastCodepoint(token)); } @@ -254,7 +263,7 @@ constexpr size_t FOCUS_PREFIX_BUF_SIZE = 40; // Advance from the token origin to the start of its regular-weight suffix: the bold prefix plus // the kerning across the weight change. Doubles as the suffix's x offset inside the word. -uint16_t measureFocusPrefixAdvance(const GfxRenderer& renderer, const int fontId, const std::string& word, +uint16_t measureFocusPrefixAdvance(const GfxRenderer& renderer, const int fontId, const std::string_view word, const EpdFontFamily::Style style, const uint8_t focusBoundary, const int8_t characterSpacing) { char prefixBuf[FOCUS_PREFIX_BUF_SIZE]; @@ -263,14 +272,14 @@ uint16_t measureFocusPrefixAdvance(const GfxRenderer& renderer, const int fontId prefixBuf[prefixLen] = '\0'; const auto boldStyle = static_cast(style | EpdFontFamily::BOLD); - const auto* suffixPtr = reinterpret_cast(word.c_str() + focusBoundary); + const auto* suffixPtr = reinterpret_cast(word.data() + focusBoundary); const int kerning = renderer.getKerning(fontId, lastCodepoint(prefixBuf), utf8NextCodepoint(&suffixPtr), boldStyle, characterSpacing); return static_cast(renderer.getTextAdvanceX(fontId, prefixBuf, boldStyle, characterSpacing) + kerning); } // Advance width of a whole token, accounting for a bold focus prefix when it has one. -uint16_t measureFocusWordWidth(const GfxRenderer& renderer, const int fontId, const std::string& word, +uint16_t measureFocusWordWidth(const GfxRenderer& renderer, const int fontId, const std::string_view word, const EpdFontFamily::Style style, const uint8_t focusBoundary, const int8_t characterSpacing, const uint8_t wordSpacingPercent, const bool appendHyphen = false) { @@ -282,10 +291,10 @@ uint16_t measureFocusWordWidth(const GfxRenderer& renderer, const int fontId, co return measureWordWidth(renderer, fontId, word, static_cast(style | EpdFontFamily::BOLD), characterSpacing, wordSpacingPercent, appendHyphen); } - const uint16_t suffixWidth = appendHyphen ? measureWordWidth(renderer, fontId, word.substr(focusBoundary), style, - characterSpacing, wordSpacingPercent, true) - : static_cast(renderer.getTextAdvanceX( - fontId, word.c_str() + focusBoundary, style, characterSpacing)); + // Through measureWordWidth (not getTextAdvanceX directly): `word` itself can + // be an unterminated prefix view here, and the suffix view inherits that. + const uint16_t suffixWidth = measureWordWidth(renderer, fontId, word.substr(focusBoundary), style, characterSpacing, + wordSpacingPercent, appendHyphen); return measureFocusPrefixAdvance(renderer, fontId, word, style, focusBoundary, characterSpacing) + suffixWidth; } @@ -397,6 +406,15 @@ void ParsedText::eraseVisibleOffsetPrefix(const size_t count) { visibleOffsetBase = newBase; } +bool ParsedText::storeWord(const std::string_view text, WordStore::StoredWord& out) { + if (wordStore.append(text.data(), text.size(), out)) return true; + if (!droppedWords) { + LOG_ERR("PTX", "OOM: dropping paragraph text (arena chunk alloc failed)"); + } + droppedWords = true; + return false; +} + void ParsedText::addWord(std::string word, const EpdFontFamily::Style fontStyle, const bool underline, const bool attachToPrevious, const uint32_t visibleTextOffset, const uint8_t linkId) { if (word.empty()) return; @@ -416,20 +434,31 @@ void ParsedText::addWord(std::string word, const EpdFontFamily::Style fontStyle, const bool wordStartsRtl = !hasRtlWord && mayContainRtlBytes(word.c_str()) && BidiUtils::startsWithRtl(word.c_str(), RTL_PER_WORD_PROBE_DEPTH); - const auto pushToken = [&](std::string token, const bool continues, const bool noSpaceBefore, - const uint8_t focusBoundary, const uint32_t tokenOffset) { - words.push_back(std::move(token)); - wordStyles.push_back(baseStyle); + // All token pushes funnel through here: the arena append is the only + // fallible step, and a failed append drops the token without touching the + // parallel arrays (they must stay in lockstep with words). + const auto pushStyledToken = [&](std::string_view token, const EpdFontFamily::Style style, const bool continues, + const bool noSpaceBefore, const uint8_t focusBoundary, const uint32_t tokenOffset, + const bool padRuby) { + WordStore::StoredWord stored; + if (!storeWord(token, stored)) return; + words.push_back(stored); + wordStyles.push_back(style); wordContinues.push_back(continues); wordNoSpaceBefore.push_back(noSpaceBefore); wordFocusBoundary.push_back(focusBoundary); wordLinkIds.push_back(linkId); pushVisibleOffset(tokenOffset); - if (!rubyTexts.empty()) { + if (padRuby && !rubyTexts.empty()) { rubyTexts.push_back(""); } }; + const auto pushToken = [&](std::string_view token, const bool continues, const bool noSpaceBefore, + const uint8_t focusBoundary, const uint32_t tokenOffset) { + pushStyledToken(token, baseStyle, continues, noSpaceBefore, focusBoundary, tokenOffset, true); + }; + bool effectiveAttachToPrevious = attachToPrevious; bool effectiveNoSpaceBefore = false; // Only a glued token (attachToPrevious == true, i.e. no whitespace separated it from the @@ -437,7 +466,7 @@ void ParsedText::addWord(std::string word, const EpdFontFamily::Style fontStyle, // whitespace separated the two words, that space is content and must be rendered: Korean // is a space-delimited script written in Hangul, which utf8IsCjkBreakable() covers. if (attachToPrevious && !words.empty() && - hasCjkBreakOpportunityBetween(lastCodepoint(words.back()), firstCodepoint(word))) { + hasCjkBreakOpportunityBetween(lastCodepoint(wordStore.view(words.back())), firstCodepoint(word))) { effectiveAttachToPrevious = false; effectiveNoSpaceBefore = true; } @@ -475,14 +504,14 @@ void ParsedText::addWord(std::string word, const EpdFontFamily::Style fontStyle, for (const size_t breakOffset : breakOffsets) { if (breakOffset <= tokenStart || breakOffset > word.size()) continue; const std::string_view token(word.data() + tokenStart, breakOffset - tokenStart); - pushToken(std::string(token), firstToken ? effectiveAttachToPrevious : false, - firstToken ? effectiveNoSpaceBefore : true, /*focusBoundary=*/0, tokenVisibleOffset); + pushToken(token, firstToken ? effectiveAttachToPrevious : false, firstToken ? effectiveNoSpaceBefore : true, + /*focusBoundary=*/0, tokenVisibleOffset); tokenVisibleOffset += countCodepoints(token); firstToken = false; tokenStart = breakOffset; } if (tokenStart < word.size()) { - pushToken(word.substr(tokenStart), firstToken ? effectiveAttachToPrevious : false, + pushToken(std::string_view(word).substr(tokenStart), firstToken ? effectiveAttachToPrevious : false, firstToken ? effectiveNoSpaceBefore : true, /*focusBoundary=*/0, tokenVisibleOffset); } if (wordStartsRtl) { @@ -492,8 +521,7 @@ void ParsedText::addWord(std::string word, const EpdFontFamily::Style fontStyle, } if (containsCjkBreakableCodepoint(word)) { - pushToken(std::move(word), effectiveAttachToPrevious, effectiveNoSpaceBefore, /*focusBoundary=*/0, - visibleTextOffset); + pushToken(word, effectiveAttachToPrevious, effectiveNoSpaceBefore, /*focusBoundary=*/0, visibleTextOffset); if (wordStartsRtl) { hasRtlWord = true; } @@ -502,8 +530,7 @@ void ParsedText::addWord(std::string word, const EpdFontFamily::Style fontStyle, // Already-bold text should stay fully bold; focus splitting would make its suffix regular later. if (!this->focusReadingEnabled || (baseStyle & EpdFontFamily::BOLD) != 0) { - pushToken(std::move(word), effectiveAttachToPrevious, effectiveNoSpaceBefore, /*focusBoundary=*/0, - visibleTextOffset); + pushToken(word, effectiveAttachToPrevious, effectiveNoSpaceBefore, /*focusBoundary=*/0, visibleTextOffset); if (wordStartsRtl) { hasRtlWord = true; } @@ -528,13 +555,7 @@ void ParsedText::addWord(std::string word, const EpdFontFamily::Style fontStyle, } if (!isWord) { // Punctuation and Numbers stay regular - words.emplace_back(segment); - wordStyles.push_back(baseStyle); - wordContinues.push_back(attach); - wordNoSpaceBefore.push_back(noSpaceBefore); - wordFocusBoundary.push_back(0); - wordLinkIds.push_back(linkId); - pushVisibleOffset(segmentOffset); + pushStyledToken(segment, baseStyle, attach, noSpaceBefore, /*focusBoundary=*/0, segmentOffset, false); } else { size_t charCount = 0; const unsigned char* countPtr = reinterpret_cast(segment.data()); @@ -552,13 +573,8 @@ void ParsedText::addWord(std::string word, const EpdFontFamily::Style fontStyle, if (targetBoldChars >= charCount) { // Whole segment is bold - no suffix split needed - words.emplace_back(segment); - wordStyles.push_back(static_cast(baseStyle | EpdFontFamily::BOLD)); - wordContinues.push_back(attach); - wordNoSpaceBefore.push_back(noSpaceBefore); - wordFocusBoundary.push_back(0); - wordLinkIds.push_back(linkId); - pushVisibleOffset(segmentOffset); + pushStyledToken(segment, static_cast(baseStyle | EpdFontFamily::BOLD), attach, + noSpaceBefore, /*focusBoundary=*/0, segmentOffset, false); } else { countPtr = reinterpret_cast(segment.data()); for (size_t i = 0; i < targetBoldChars; ++i) { @@ -568,13 +584,8 @@ void ParsedText::addWord(std::string word, const EpdFontFamily::Style fontStyle, // One token carrying the emphasis as a byte boundary, so the word stays whole for the // hyphenator and the line breaker. The renderer applies BOLD to bytes [0, splitByteOffset). - words.emplace_back(segment); - wordStyles.push_back(baseStyle); - wordContinues.push_back(attach); - wordNoSpaceBefore.push_back(noSpaceBefore); - wordFocusBoundary.push_back(static_cast(std::min(splitByteOffset, 255))); - wordLinkIds.push_back(linkId); - pushVisibleOffset(segmentOffset); + pushStyledToken(segment, baseStyle, attach, noSpaceBefore, + static_cast(std::min(splitByteOffset, 255)), segmentOffset, false); } } }; @@ -603,7 +614,8 @@ void ParsedText::addWord(std::string word, const EpdFontFamily::Style fontStyle, // Every subsequent segment glues seamlessly to the prefix. After a visible explicit-hyphen // character, continues=true + noSpaceBefore=true records a breakable attachment: it may wrap, // but when it stays on the line it receives kerning only, never a space or justification. - const bool breakAfterPrev = !isFirstSegment && !words.empty() && endsWithBreakableHyphen(words.back()); + const bool breakAfterPrev = + !isFirstSegment && !words.empty() && endsWithBreakableHyphen(wordStore.view(words.back())); processSegment(segment, inWordSegment, isFirstSegment ? effectiveAttachToPrevious : true, isFirstSegment ? effectiveNoSpaceBefore : breakAfterPrev); @@ -617,7 +629,8 @@ void ParsedText::addWord(std::string word, const EpdFontFamily::Style fontStyle, // Process the final remaining segment size_t segmentLen = end - segmentStart; std::string_view segment(reinterpret_cast(segmentStart), segmentLen); - const bool breakAfterPrev = !isFirstSegment && !words.empty() && endsWithBreakableHyphen(words.back()); + const bool breakAfterPrev = + !isFirstSegment && !words.empty() && endsWithBreakableHyphen(wordStore.view(words.back())); processSegment(segment, inWordSegment, isFirstSegment ? effectiveAttachToPrevious : true, isFirstSegment ? effectiveNoSpaceBefore : breakAfterPrev); if (wordStartsRtl) { @@ -704,7 +717,7 @@ void ParsedText::layoutAndExtractLines(const GfxRenderer& renderer, const int fo // Check the first few words for RTL letter codepoints (no heap allocation). const size_t wordsToScan = std::min(words.size(), RTL_PARAGRAPH_PROBE_WORDS); for (size_t i = 0; i < wordsToScan; ++i) { - if (BidiUtils::startsWithRtl(words[i].c_str(), BidiUtils::RTL_PARAGRAPH_PROBE_DEPTH)) { + if (BidiUtils::startsWithRtl(wordStore.cstr(words[i]), BidiUtils::RTL_PARAGRAPH_PROBE_DEPTH)) { blockStyle.isRtl = true; break; } @@ -729,7 +742,20 @@ void ParsedText::layoutAndExtractLines(const GfxRenderer& renderer, const int fo styleMask |= static_cast(1u << (static_cast(s) & 0x03)); } if (styleMask == 0) styleMask = 0x01; // defensive: regular only - renderer.ensureSdCardFontReady(fontId, words, hyphenationEnabled, styleMask); + // Hand the arena chunks over as packed NUL-separated word runs. Two small + // pointer tables (~8 B per live chunk) instead of per-word iteration. + std::vector segments; + std::vector segmentLens; + segments.reserve(wordStore.chunkCount()); + segmentLens.reserve(wordStore.chunkCount()); + for (size_t i = 0; i < wordStore.chunkCount(); ++i) { + const char* data = wordStore.chunkData(i); + if (!data) continue; // retired chunk + segments.push_back(data); + segmentLens.push_back(wordStore.chunkUsed(i)); + } + renderer.ensureSdCardFontReady(fontId, segments.data(), segmentLens.data(), segments.size(), words.size() > 1, + hyphenationEnabled, styleMask); } const int pageWidth = viewportWidth; @@ -753,6 +779,9 @@ void ParsedText::layoutAndExtractLines(const GfxRenderer& renderer, const int fo // Remove consumed words so size() reflects only remaining words if (lineCount > 0) { const size_t consumed = lineBreakIndices[lineCount - 1]; + for (size_t i = 0; i < consumed; ++i) { + wordStore.release(words[i]); // retires arena chunks as lines are consumed + } words.erase(words.begin(), words.begin() + consumed); wordStyles.erase(wordStyles.begin(), wordStyles.begin() + consumed); wordContinues.erase(wordContinues.begin(), wordContinues.begin() + consumed); @@ -789,7 +818,7 @@ int ParsedText::calculateRubyExtraStartOffset(const size_t wordIdx, const size_t } int groupActualWidth = 0; for (size_t k = 0; k < groupWordCount; ++k) { - groupActualWidth += measureWordWidth(renderer, fontId, words[wordIdx + k], wordStyles[wordIdx + k], + groupActualWidth += measureWordWidth(renderer, fontId, wordAt(wordIdx + k), wordStyles[wordIdx + k], blockStyle.characterSpacing, wordSpacingPercent); } const int rubyWidth = @@ -835,7 +864,7 @@ int ParsedText::calculateRubyExtraEndOffset(const size_t lineStartIdx, const siz int groupActualWidth = 0; for (size_t k = leaderIdx; k < lineBreakIdx; ++k) { groupActualWidth += - measureWordWidth(renderer, fontId, words[k], wordStyles[k], blockStyle.characterSpacing, wordSpacingPercent); + measureWordWidth(renderer, fontId, wordAt(k), wordStyles[k], blockStyle.characterSpacing, wordSpacingPercent); } const int rubyWidth = renderer.getTextAdvanceX(fontId, rubyTexts[leaderIdx].c_str(), EpdFontFamily::SUP, blockStyle.characterSpacing); @@ -851,7 +880,7 @@ std::vector ParsedText::calculateWordWidths(const GfxRenderer& rendere wordWidths.reserve(words.size()); for (size_t i = 0; i < words.size(); ++i) { - wordWidths.push_back(measureFocusWordWidth(renderer, fontId, words[i], wordStyles[i], wordFocusBoundary[i], + wordWidths.push_back(measureFocusWordWidth(renderer, fontId, wordAt(i), wordStyles[i], wordFocusBoundary[i], blockStyle.characterSpacing, wordSpacingPercent)); } @@ -892,7 +921,7 @@ std::vector ParsedText::calculateWordWidths(const GfxRenderer& rendere // 1. Preceding character (left overhang) if (g.start > 0) { - const uint32_t cpPrev = lastCodepoint(words[g.start - 1]); + const uint32_t cpPrev = lastCodepoint(wordAt(g.start - 1)); if (isCjkIdeograph(cpPrev)) { wordWidths[g.start - 1] += g.leftOverlap; } else { @@ -913,7 +942,7 @@ std::vector ParsedText::calculateWordWidths(const GfxRenderer& rendere } } else { // Regular character following: check if it's Kanji - const uint32_t cpNext = firstCodepoint(words[nextIdx]); + const uint32_t cpNext = firstCodepoint(wordAt(nextIdx)); if (isCjkIdeograph(cpNext)) { wordWidths[g.start + g.count - 1] += g.rightOverlap; } else { @@ -927,7 +956,7 @@ std::vector ParsedText::calculateWordWidths(const GfxRenderer& rendere bool onlyNonIdeographsInBetween = true; int gapWidth = 0; for (size_t k = nextIdx; k < nextG.start; ++k) { - const uint32_t cp = firstCodepoint(words[k]); + const uint32_t cp = firstCodepoint(wordAt(k)); if (isCjkIdeograph(cp)) { onlyNonIdeographsInBetween = false; break; @@ -996,14 +1025,14 @@ std::vector ParsedText::computeLineBreaks(const GfxRenderer& renderer, c int gap = 0; if (j > static_cast(i) && continuesVec[j]) { // Attached and breakable-attached boundaries both use kerning when kept on one line. - gap = renderer.getKerning(fontId, lastCodepoint(words[j - 1]), firstCodepoint(words[j]), wordStyles[j - 1], + gap = renderer.getKerning(fontId, lastCodepoint(wordAt(j - 1)), firstCodepoint(wordAt(j)), wordStyles[j - 1], blockStyle.characterSpacing); } else if (j > static_cast(i) && noSpaceBeforeVec[j]) { gap = blockStyle.characterSpacing; } else if (j > static_cast(i)) { - gap = scaleSpace( - renderer.getSpaceAdvance(fontId, lastCodepoint(words[j - 1]), firstCodepoint(words[j]), wordStyles[j - 1]), - wordSpacingPercent); + gap = scaleSpace(renderer.getSpaceAdvance(fontId, lastCodepoint(wordAt(j - 1)), firstCodepoint(wordAt(j)), + wordStyles[j - 1]), + wordSpacingPercent); } // Calculate extraStartOffset for the first word on the line (i) (protect left margin) @@ -1108,14 +1137,14 @@ std::vector ParsedText::computeHyphenatedLineBreaks(const GfxRenderer& r if (!isFirstWord && continuesVec[currentIndex]) { // Attached and breakable-attached boundaries both use kerning when kept on one line. spacing = - renderer.getKerning(fontId, lastCodepoint(words[currentIndex - 1]), firstCodepoint(words[currentIndex]), + renderer.getKerning(fontId, lastCodepoint(wordAt(currentIndex - 1)), firstCodepoint(wordAt(currentIndex)), wordStyles[currentIndex - 1], blockStyle.characterSpacing); } else if (!isFirstWord && noSpaceBeforeVec[currentIndex]) { spacing = blockStyle.characterSpacing; } else if (!isFirstWord) { spacing = - scaleSpace(renderer.getSpaceAdvance(fontId, lastCodepoint(words[currentIndex - 1]), - firstCodepoint(words[currentIndex]), wordStyles[currentIndex - 1]), + scaleSpace(renderer.getSpaceAdvance(fontId, lastCodepoint(wordAt(currentIndex - 1)), + firstCodepoint(wordAt(currentIndex)), wordStyles[currentIndex - 1]), wordSpacingPercent); } const int candidateWidth = spacing + wordWidths[currentIndex]; @@ -1171,7 +1200,9 @@ bool ParsedText::hyphenateWordAtIndex(const size_t wordIndex, const int availabl return false; } - const std::string& word = words[wordIndex]; + // Stable copy: Hyphenator and the prefix measurements below want std::string + // semantics. Bounded by one word. + const std::string word{wordAt(wordIndex)}; // brace-init: `word(` is an Arduino macro const auto style = wordStyles[wordIndex]; const uint8_t focusBoundary = wordFocusBoundary[wordIndex]; @@ -1220,15 +1251,33 @@ bool ParsedText::hyphenateWordAtIndex(const size_t wordIndex, const int availabl remainderOffset++; } - // Split the word at the selected breakpoint and append a hyphen if required. - std::string remainder = word.substr(chosenOffset); - words[wordIndex].resize(chosenOffset); + // Split the word at the selected breakpoint. The prefix is materialized as a + // fresh arena entry (with its visible hyphen, so it stays NUL-terminated); + // the remainder aliases the original word's tail bytes and inherits the + // original entry's release obligation via WordStore::suffix(). + // Stack buffer, not std::string: this runs mid-pagination, exactly when the + // heap is under section-build pressure. The parser caps words at + // MAX_WORD_SIZE (200) bytes; anything larger skips the split rather than + // overflow (the word then breaks whole, as when no breakpoint fits). + char prefixBuf[208]; + if (chosenOffset + 1 > sizeof(prefixBuf)) { + return false; + } + memcpy(prefixBuf, word.data(), chosenOffset); + size_t prefixLen = chosenOffset; if (chosenNeedsHyphen) { - words[wordIndex].push_back('-'); + prefixBuf[prefixLen++] = '-'; + } + WordStore::StoredWord prefixStored; + if (!wordStore.append(prefixBuf, prefixLen, prefixStored)) { + // OOM: skip the split; the word stays whole and the line breaks without it. + return false; } + const WordStore::StoredWord remainderStored = WordStore::suffix(words[wordIndex], chosenOffset); + words[wordIndex] = prefixStored; // Insert the remainder word (with matching style and continuation flag) directly after the prefix. - words.insert(words.begin() + wordIndex + 1, remainder); + words.insert(words.begin() + wordIndex + 1, remainderStored); wordStyles.insert(wordStyles.begin() + wordIndex + 1, style); insertVisibleOffset(wordIndex + 1, remainderOffset); // Emphasis follows the text across the split, so a break at or after the boundary leaves the @@ -1238,7 +1287,11 @@ bool ParsedText::hyphenateWordAtIndex(const size_t wordIndex, const int availabl wordFocusBoundary[wordIndex] = focusBoundaryBefore(focusBoundary, chosenOffset); // Invariant: a boundary is always strictly inside its token, so an all-bold part carries BOLD in // its style with boundary 0 and nothing downstream special-cases boundary == size. - if (wordFocusBoundary[wordIndex] >= words[wordIndex].size()) { + // An inserted '-' counts as covered: measureFocusWordWidth() measured the + // hyphen bold whenever the boundary spans the whole text before it, so the + // rendered styling must match or justified spacing drifts by the bold/ + // regular hyphen advance delta. + if (wordFocusBoundary[wordIndex] + (chosenNeedsHyphen ? 1u : 0u) >= words[wordIndex].len) { wordStyles[wordIndex] = static_cast(wordStyles[wordIndex] | EpdFontFamily::BOLD); wordFocusBoundary[wordIndex] = 0; } @@ -1272,7 +1325,7 @@ bool ParsedText::hyphenateWordAtIndex(const size_t wordIndex, const int availabl // Update cached widths to reflect the new prefix/remainder pairing. wordWidths[wordIndex] = static_cast(chosenWidth); const uint16_t remainderWidth = - measureFocusWordWidth(renderer, fontId, remainder, style, wordFocusBoundary[wordIndex + 1], + measureFocusWordWidth(renderer, fontId, wordStore.view(remainderStored), style, wordFocusBoundary[wordIndex + 1], blockStyle.characterSpacing, wordSpacingPercent); wordWidths.insert(wordWidths.begin() + wordIndex + 1, remainderWidth); return true; @@ -1305,7 +1358,10 @@ void ParsedText::extractLine(const size_t breakIndex, const int pageWidth, const lineWordStyles.reserve(lineWordCount); for (size_t i = 0; i < lineWordCount; ++i) { - std::string word = std::move(words[lastBreakAt + i]); + // Copy out of the arena: TextBlock construction and bidi reorder below + // want owning strings. Bounded by one line; the arena bytes are released + // by the consumed-prefix pass in layoutAndExtractLines. + std::string word{wordAt(lastBreakAt + i)}; // brace-init: `word(` is an Arduino macro if (containsSoftHyphen(word)) { stripSoftHyphensInPlace(word); } @@ -1645,6 +1701,10 @@ void ParsedText::extractLine(const size_t breakIndex, const int pageWidth, const std::move(lineLinks)); if (!block || !block->valid()) { LOG_ERR("PTX", "Dropping line: TextBlock or arena allocation failed"); + // Latch through the same flag as addWord() OOM: the caller releases the + // consumed words right after this returns, so without it the section + // would commit with this line silently missing. + droppedWords = true; return; } processLine(std::move(block), lineVisibleOffset); @@ -1669,6 +1729,7 @@ void ParsedText::extractLine(const size_t breakIndex, const int pageWidth, const std::move(lineRubyTexts), std::move(lineLinks)); if (!block || !block->valid()) { LOG_ERR("PTX", "Dropping line: TextBlock or arena allocation failed"); + droppedWords = true; // see the non-focus branch above return; } processLine(std::move(block), lineVisibleOffset); diff --git a/lib/Epub/Epub/ParsedText.h b/lib/Epub/Epub/ParsedText.h index d46ce7a2177..a277aa01a0c 100644 --- a/lib/Epub/Epub/ParsedText.h +++ b/lib/Epub/Epub/ParsedText.h @@ -8,22 +8,27 @@ #include #include +#include "WordStore.h" #include "blocks/BlockStyle.h" #include "blocks/TextBlock.h" class GfxRenderer; class ParsedText { - // words/rubyTexts are std::deque, not std::vector: a paragraph can hold thousands - // of tokens (CJK splits every character), and a vector grows by reallocating its - // whole element array into one contiguous block (32 B/std::string -> 64-128 KB at - // a few thousand tokens). On the ESP32-C3 that single large contiguous request - // fails under a fragmented, BLE-resident heap and the throwing operator new - // abort()s the firmware (fresh-open CJK crash). A deque grows in fixed ~512 B nodes - // (largest contiguous alloc stays ~2 KB regardless of token count), so it never - // triggers that. The per-token parallel arrays below stay vectors: 1 byte / 1 bit - // each, they never approach the contiguous-block ceiling. - std::deque words; + // Word text lives in wordStore (chunked bump arena, NUL-terminated entries); + // words holds 8-byte handles into it. This replaces the former + // std::deque: per-word string objects, their SSO spills, and + // every hyphenation/NFC temporary were the layout path's dominant + // small-allocation churn, and any failed implicit allocation abort()s under + // -fno-exceptions. Handles stay in a std::deque for the #2814 reason: no + // large contiguous reallocation at CJK token counts (deque grows in fixed + // ~512 B nodes). On arena OOM the word is dropped and hadDroppedWords() + // latches so the section build can fail readably instead of aborting. + // rubyTexts stays a deque of strings: ruby is rare and per-block small. + // The per-token parallel arrays below stay vectors: 1 byte / 1 bit each, + // they never approach the contiguous-block ceiling. + WordStore wordStore; + std::deque words; std::vector wordStyles; // Boundary flags use all four combinations: // continues=false, noSpace=false: ordinary breakable word gap @@ -60,6 +65,7 @@ class ParsedText { bool focusReadingEnabled; bool isNaturalAlign; bool hasRtlWord; + bool droppedWords = false; std::vector reorderedWordsScratch; std::vector reorderedStylesScratch; std::vector reorderedWidthsScratch; @@ -68,6 +74,8 @@ class ParsedText { std::vector reorderedFocusBoundaryScratch; std::vector visualOrderScratch; + std::string_view wordAt(const size_t i) const { return wordStore.view(words[i]); } + bool storeWord(std::string_view text, WordStore::StoredWord& out); uint32_t visibleOffsetBaseAt(size_t wordIndex) const; uint32_t visibleOffsetAt(size_t wordIndex) const; void pushVisibleOffset(uint32_t offset); @@ -118,6 +126,9 @@ class ParsedText { BlockStyle& getBlockStyle() { return blockStyle; } size_t size() const { return words.size(); } bool isEmpty() const { return words.empty(); } + // True once any word was dropped because the text arena could not allocate. + // Callers must treat the block as incomplete and fail the section build. + bool hadDroppedWords() const { return droppedWords; } void layoutAndExtractLines(const GfxRenderer& renderer, int fontId, uint16_t viewportWidth, const std::function, uint32_t)>& processLine, bool includeLastLine = true, int8_t characterSpacing = 0, diff --git a/lib/Epub/Epub/Section.cpp b/lib/Epub/Epub/Section.cpp index a6b1ee2df2c..368519584e7 100644 --- a/lib/Epub/Epub/Section.cpp +++ b/lib/Epub/Epub/Section.cpp @@ -645,7 +645,13 @@ bool Section::commitBuildFile(const uint8_t version, const uint32_t bytesConsume bool Section::finalizeBuild() { // Flush the trailing page (emits the last page via the completePageFn into the LUT). - build_->parser->finishParse(); + // A false return means layout dropped content (OOM); committing would persist a + // section cache with holes in the text, so abandon the build instead. + if (!build_->parser->finishParse()) { + LOG_ERR("SCT", "Parse finalize failed; abandoning section build"); + abandonBuild(); + return false; + } if (!build_->reusedHtml) { // Parse succeeded: promote the freshly unzipped HTML to the persistent cache so future diff --git a/lib/Epub/Epub/WordStore.cpp b/lib/Epub/Epub/WordStore.cpp new file mode 100644 index 00000000000..5244eb9f35d --- /dev/null +++ b/lib/Epub/Epub/WordStore.cpp @@ -0,0 +1,93 @@ +#include "WordStore.h" + +#include +#include + +namespace { +// Extra bytes beyond the failed request when asking the SDK memory manager to +// evict caches: leaves room for the parallel-array growth and TextBlock arena +// that immediately follow a chunk allocation, instead of evicting to an exact +// fit and failing on the very next allocation. +constexpr size_t RECLAIM_HEADROOM = 8 * 1024; + +// Nothrow allocation with one evict-and-retry pass. ensureFree() asks the +// registered cache sinks (SD-font mini data, render glyph cache) to release +// rebuildable memory; only when even that cannot satisfy the request does the +// caller's drop/fail path run. +std::unique_ptr allocWithReclaim(const size_t bytes) { + auto data = makeUniqueNoThrow(bytes); + if (data) return data; + freeink::MemoryManager::instance().ensureFree(bytes + RECLAIM_HEADROOM); + data = makeUniqueNoThrow(bytes); + if (data) { + LOG_DBG("WST", "Cache eviction rescued a %u-byte chunk allocation", static_cast(bytes)); + } + return data; +} +} // namespace + +bool WordStore::ensureChunkSlot() { + if (chunkCount_ < chunkCapacity_) return true; + // Manual nothrow growth: std::vector would abort on OOM under -fno-exceptions. + const size_t newCapacity = chunkCapacity_ == 0 ? 8 : chunkCapacity_ * 2; + auto grown = makeUniqueNoThrow(newCapacity); + if (!grown) { + freeink::MemoryManager::instance().ensureFree(newCapacity * sizeof(Chunk) + RECLAIM_HEADROOM); + grown = makeUniqueNoThrow(newCapacity); + if (!grown) return false; + } + for (size_t i = 0; i < chunkCount_; i++) { + grown[i] = std::move(chunks_[i]); + } + chunks_ = std::move(grown); + chunkCapacity_ = newCapacity; + return true; +} + +bool WordStore::append(const char* text, size_t len, StoredWord& out) { + if (len > MAX_WORD_BYTES) len = MAX_WORD_BYTES; + const size_t need = len + 1; + + Chunk* target = nullptr; + if (chunkCount_ > 0) { + Chunk& last = chunks_[chunkCount_ - 1]; + if (last.data && static_cast(last.capacity) - last.used >= need) target = &last; + } + if (!target) { + if (!ensureChunkSlot()) return false; + // Words larger than a chunk get a dedicated exact-fit chunk so the offset + // arithmetic stays uniform; everything else shares 2KB chunks. + const size_t cap = need > CHUNK_SIZE ? need : CHUNK_SIZE; + auto data = allocWithReclaim(cap); + if (!data) return false; + Chunk& fresh = chunks_[chunkCount_]; + fresh.data = std::move(data); + fresh.capacity = static_cast(cap); + fresh.used = 0; + fresh.live = 0; + chunkCount_++; + target = &fresh; + } + + // Typed locals: unique_ptr::get() is T*, but cppcheck reads the + // array-form template's get() as void* and flags the arithmetic below. + const Chunk* chunkBase = chunks_.get(); + char* dst = target->data.get(); + out.chunk = static_cast(target - chunkBase); + out.off = target->used; + out.len = static_cast(len); + memcpy(dst + target->used, text, len); + target->data[target->used + len] = '\0'; + target->used = static_cast(target->used + need); + target->live++; + return true; +} + +void WordStore::release(const StoredWord& w) { + Chunk& c = chunks_[w.chunk]; + if (c.live > 0) c.live--; + // The tail chunk is still accepting appends; keep it even when drained. + if (c.live == 0 && c.data && w.chunk + 1 != chunkCount_) { + c.data.reset(); + } +} diff --git a/lib/Epub/Epub/WordStore.h b/lib/Epub/Epub/WordStore.h new file mode 100644 index 00000000000..ce8747745b9 --- /dev/null +++ b/lib/Epub/Epub/WordStore.h @@ -0,0 +1,80 @@ +#pragma once + +#include + +#include +#include +#include + +// Bump arena for layout-time word text. Words are copied in NUL-terminated so +// views can feed C string APIs (drawText, startsWithRtl) directly. +// +// Text accumulates into fixed 2KB chunks; a chunk is freed once every word in +// it has been released. Words are consumed FIFO as lines are extracted, so +// chunks retire in step with layout even when one paragraph spans a whole +// chapter. Uniform 2KB requests are deliberate: they stay allocatable under +// the fragmentation that defeats per-word variable-size allocations. +// +// Every allocation is nothrow. append() reports failure instead of aborting +// (-fno-exceptions turns a failed implicit allocation into abort()), giving +// the layout path a single detectable OOM choke point. +class WordStore { + public: + struct StoredWord { + uint32_t chunk = 0; + uint16_t off = 0; + uint16_t len = 0; + }; + + // Copies [text, text+len) plus a NUL terminator into the arena. + // Returns false on OOM; out is untouched in that case. + bool append(const char* text, size_t len, StoredWord& out); + + // NUL-terminated view of a stored word. + std::string_view view(const StoredWord& w) const { return {charsAt(w), w.len}; } + const char* cstr(const StoredWord& w) const { return charsAt(w); } + + // A suffix of an existing word (shares its bytes and trailing NUL). The + // suffix inherits the original's release obligation: release exactly one of + // the two, never both. + static StoredWord suffix(const StoredWord& w, size_t byteOffset) { + return {w.chunk, static_cast(w.off + byteOffset), static_cast(w.len - byteOffset)}; + } + + // Declares a stored word consumed. Frees its chunk once no other live word + // points into it. + void release(const StoredWord& w); + + // Chunk enumeration for whole-paragraph codepoint scans (SD font advance + // prewarm). Chunks hold consecutive NUL-terminated words; data may be null + // for retired chunks. + size_t chunkCount() const { return chunkCount_; } + const char* chunkData(size_t i) const { return chunks_[i].data.get(); } + size_t chunkUsed(size_t i) const { return chunks_[i].used; } + + private: + struct Chunk { + std::unique_ptr data; + uint16_t capacity = 0; + uint16_t used = 0; + uint16_t live = 0; + }; + + const char* charsAt(const StoredWord& w) const { + // Bind to a typed local first: unique_ptr::get() is char*, but + // cppcheck can't resolve the array-form template and reads it as void*, + // then flags the pointer arithmetic (arithOperationsOnVoidPointer). + const char* base = chunks_[w.chunk].data.get(); + return base + w.off; + } + bool ensureChunkSlot(); + + static constexpr size_t CHUNK_SIZE = 2048; + // The uint16_t off/used fields force this bound; parser tokens are capped + // far below it (MAX_WORD_SIZE), so truncation is a theoretical safety net. + static constexpr size_t MAX_WORD_BYTES = 0xFFFE; + + std::unique_ptr chunks_; + size_t chunkCount_ = 0; + size_t chunkCapacity_ = 0; +}; diff --git a/lib/Epub/Epub/blocks/TextBlock.cpp b/lib/Epub/Epub/blocks/TextBlock.cpp index cda40aabcd0..3219b85dc92 100644 --- a/lib/Epub/Epub/blocks/TextBlock.cpp +++ b/lib/Epub/Epub/blocks/TextBlock.cpp @@ -4,6 +4,7 @@ #include #include #include +#include #include #include @@ -85,6 +86,12 @@ TextBlock::TextBlock(const std::vector& words, const std::vector(size); + if (!arena) { + // Evict rebuildable caches (SD-font mini data, render glyph cache) and + // retry once before declaring the line lost. + freeink::MemoryManager::instance().ensureFree(size + 4 * 1024); + arena = makeUniqueNoThrow(size); + } if (!arena) { LOG_ERR("TXB", "OOM: arena %u bytes", static_cast(size)); numWords = 0; diff --git a/lib/Epub/Epub/parsers/ChapterHtmlSlimParser.cpp b/lib/Epub/Epub/parsers/ChapterHtmlSlimParser.cpp index 5ec38ecbeeb..e379e57e977 100644 --- a/lib/Epub/Epub/parsers/ChapterHtmlSlimParser.cpp +++ b/lib/Epub/Epub/parsers/ChapterHtmlSlimParser.cpp @@ -5,6 +5,7 @@ #include #include #include +#include #include #include #include @@ -323,6 +324,8 @@ void ChapterHtmlSlimParser::setCurrentPageVisibleOffset(const uint32_t offset) { // flush the contents of partWordBuffer to currentTextBlock void ChapterHtmlSlimParser::flushPartWordBuffer() { + // Block creation failed (OOM): drop the buffered text; parseStep() is about + // to fail the build via layoutOom. if (!currentTextBlock) { partWordBufferIndex = 0; nextWordContinues = false; @@ -417,7 +420,18 @@ void ChapterHtmlSlimParser::startNewTextBlock(const BlockStyle& blockStyle) { // If the pending anchor is a TOC chapter boundary, force a page break after the previous // block is flushed so the chapter starts on a fresh page. flushPendingAnchor(); - currentTextBlock.reset(new ParsedText(extraParagraphSpacing, hyphenationEnabled, focusReadingEnabled, blockStyle)); + currentTextBlock = + makeUniqueNoThrow(extraParagraphSpacing, hyphenationEnabled, focusReadingEnabled, blockStyle); + if (!currentTextBlock) { + // Evict rebuildable caches and retry once before failing the build. + freeink::MemoryManager::instance().ensureFree(4 * 1024); + currentTextBlock = + makeUniqueNoThrow(extraParagraphSpacing, hyphenationEnabled, focusReadingEnabled, blockStyle); + } + if (!currentTextBlock) { + LOG_ERR("EHP", "OOM: ParsedText"); + layoutOom = true; // parseStep() turns this into ParseStatus::Error + } wordsExtractedInBlock = 0; listItemBulletOnly = false; } @@ -516,6 +530,13 @@ void ChapterHtmlSlimParser::closeTableCell() { return; } + // Latch before the cell leaves currentTextBlock: parseStep()'s dropped-word + // check only inspects currentTextBlock, so a cell parsed and moved (or reset + // while empty) within one XML buffer would otherwise lose its OOM flag. + if (currentTextBlock->hadDroppedWords()) { + layoutOom = true; + } + if (!tableRowStacked && (tableRowCells.size() >= MAX_GRID_TABLE_COLUMNS || currentTextBlock->size() > MAX_GRID_TABLE_CELL_WORDS)) { fallbackTableRowToStacked(); @@ -608,6 +629,11 @@ void ChapterHtmlSlimParser::finishTableRow() { true, characterSpacing, wordSpacingPercent); maxLineCount = std::max(maxLineCount, lines.size()); } + // Cell layout itself can drop lines (TextBlock arena OOM in extractLine); + // latch that before the cells are destroyed. + for (const auto& cell : tableRowCells) { + if (cell && cell->hadDroppedWords()) layoutOom = true; + } tableRowCells.clear(); const auto clearLayoutLines = [this]() { for (auto& lines : tableCellLines) { @@ -1389,6 +1415,11 @@ void XMLCALL ChapterHtmlSlimParser::startElement(void* userData, const XML_Char* BlockStyle::CombineAxis::Horizontal); self->blockStyleStack.push_back(accumulated); self->startNewTextBlock(accumulated.withoutBottom()); + if (!self->currentTextBlock) { + // OOM: layoutOom is latched; bail before the
    1. marker path below + // dereferences the missing block. parseStep() fails the build. + return; + } self->updateEffectiveInlineStyle(); if (strcmp(name, "li") == 0) { @@ -1739,6 +1770,11 @@ void XMLCALL ChapterHtmlSlimParser::characterData(void* userData, const XML_Char self->partWordBuffer[self->partWordBufferIndex++] = s[i]; } + // Block creation failed (OOM): nothing to soft-flush. + if (!self->currentTextBlock) { + return; + } + // Keep token growth bounded: CSS-heavy spans can fragment text into many tiny // words, so flush earlier when embedded CSS is active. We still keep the // "exclude last line" behavior to preserve paragraph flow across chunks. @@ -2065,6 +2101,13 @@ bool ChapterHtmlSlimParser::beginParse() { } ChapterHtmlSlimParser::ParseStatus ChapterHtmlSlimParser::parseStep() { + // Layout OOM latched during the previous buffer's callbacks: fail the build + // instead of emitting pages with silently missing text. + if (layoutOom || (currentTextBlock && currentTextBlock->hadDroppedWords())) { + LOG_ERR("EHP", "Text layout dropped content (OOM); failing section build"); + return ParseStatus::Error; + } + void* const buf = XML_GetBuffer(xmlParser_, PARSE_BUFFER_SIZE); if (!buf) { LOG_ERR("EHP", "Couldn't allocate memory for buffer"); @@ -2105,6 +2148,13 @@ void ChapterHtmlSlimParser::abortParse() { } bool ChapterHtmlSlimParser::finishParse() { + // Same check as parseStep(): drops in the final buffer would otherwise slip + // through because Done is returned before the next step's check runs. + if (layoutOom || (currentTextBlock && currentTextBlock->hadDroppedWords())) { + LOG_ERR("EHP", "Text layout dropped content (OOM); failing section build"); + return false; + } + if (xmlParser_) { LOG_DBG("EHP", "Time to parse and build pages: %lu ms", millis() - parseStartTime_); destroyXmlParser(xmlParser_); @@ -2115,6 +2165,12 @@ bool ChapterHtmlSlimParser::finishParse() { // Process last page if there is still text if (currentTextBlock) { makePages(); + // Re-check: makePages() latches layoutOom for lines dropped DURING this + // final layout, which the entry check above cannot have seen. + if (layoutOom) { + LOG_ERR("EHP", "Text layout dropped content (OOM); failing section build"); + return false; + } if (!pendingAnchorId.empty()) { anchorData.push_back({std::move(pendingAnchorId), static_cast(completedPageCount)}); pendingAnchorId.clear(); @@ -2201,6 +2257,13 @@ void ChapterHtmlSlimParser::makePages() { return; } + // Latch before layout: startNewTextBlock() replaces the block right after + // this returns, which would otherwise lose its dropped-words flag before + // parseStep()/finishParse() get to check it. + if (currentTextBlock->hadDroppedWords()) { + layoutOom = true; + } + if (!currentPage) { currentPage.reset(new Page()); currentPageNextY = 0; @@ -2230,6 +2293,13 @@ void ChapterHtmlSlimParser::makePages() { }, true, characterSpacing, wordSpacingPercent); + // Latch again after layout: extractLine can drop a whole line (TextBlock + // arena OOM) during the call above, after the pre-layout latch ran, and the + // block is replaced right after this returns. + if (currentTextBlock->hadDroppedWords()) { + layoutOom = true; + } + // Fallback: transfer any remaining pending footnotes to current page. // Normally addLineToPage handles this via word-index tracking, but this catches // edge cases where a footnote's word index equals the exact block size. diff --git a/lib/Epub/Epub/parsers/ChapterHtmlSlimParser.h b/lib/Epub/Epub/parsers/ChapterHtmlSlimParser.h index 7c81055f829..b1c5be536ca 100644 --- a/lib/Epub/Epub/parsers/ChapterHtmlSlimParser.h +++ b/lib/Epub/Epub/parsers/ChapterHtmlSlimParser.h @@ -146,6 +146,10 @@ class ChapterHtmlSlimParser { int currentFootnoteLinkTextLen = 0; std::vector> pendingFootnotes; // int wordsExtractedInBlock = 0; + // Latched when a ParsedText could not be created (OOM). Together with + // ParsedText::hadDroppedWords() this turns layout OOM into ParseStatus::Error + // so the section build fails readably instead of emitting pages with holes. + bool layoutOom = false; // Resumable parse state. The one-shot parseAndBuildPages() drives these // internally; the incremental section builder drives them across render ticks diff --git a/lib/GfxRenderer/FontCacheManager.cpp b/lib/GfxRenderer/FontCacheManager.cpp index 5064864aa2a..c721ee83d37 100644 --- a/lib/GfxRenderer/FontCacheManager.cpp +++ b/lib/GfxRenderer/FontCacheManager.cpp @@ -3,6 +3,7 @@ #include #include #include +#include #include #include @@ -32,8 +33,9 @@ char* appendUtf8Codepoint(char* output, const uint32_t codepoint) { } // namespace FontCacheManager::FontCacheManager(const std::map& fontMap, - const std::map& sdCardFonts) - : fontMap_(fontMap), sdCardFonts_(sdCardFonts) {} + const std::map& sdCardFonts, + const std::map& ttfFonts) + : fontMap_(fontMap), sdCardFonts_(sdCardFonts), ttfFonts_(ttfFonts) {} void FontCacheManager::setFontDecompressor(FontDecompressor* d) { fontDecompressor_ = d; } @@ -42,6 +44,11 @@ void FontCacheManager::clearCache() { for (auto& [id, font] : sdCardFonts_) { font->clearCache(); } +#if CROSSPOINT_VECTOR_FONTS + for (auto& [id, font] : ttfFonts_) { + if (font) font->clearCache(); + } +#endif } void FontCacheManager::releaseSdFontCaches() { @@ -49,9 +56,31 @@ void FontCacheManager::releaseSdFontCaches() { for (auto& [id, font] : sdCardFonts_) { font->releaseResidentCaches(); } +#if CROSSPOINT_VECTOR_FONTS + for (auto& [id, font] : ttfFonts_) { + if (font) font->releaseResidentCaches(); + } +#endif } void FontCacheManager::prewarmCache(int fontId, const char* utf8Text, uint8_t styleMask, bool accumulate) { + // TTF (vector) font prewarm path. This is the single dispatch every draw path + // funnels through (reader endScanAndPrewarm, the settings preview, UI text), + // so building here covers them all. accumulate=false means "this is the whole + // glyph set for this render" → replace; accumulate=true → add incrementally. + // styleMask is ignored: a TTF face has no synthesized bold/italic here. +#if CROSSPOINT_VECTOR_FONTS + auto tit = ttfFonts_.find(fontId); + if (tit != ttfFonts_.end() && tit->second) { + if (accumulate) { + tit->second->addCoverage(utf8Text); + } else { + tit->second->build(utf8Text); + } + return; + } +#endif + // SD card font prewarm path: prewarm all requested styles in one call auto it = sdCardFonts_.find(fontId); if (it != sdCardFonts_.end()) { diff --git a/lib/GfxRenderer/FontCacheManager.h b/lib/GfxRenderer/FontCacheManager.h index ee178660ce3..c538f3daef2 100644 --- a/lib/GfxRenderer/FontCacheManager.h +++ b/lib/GfxRenderer/FontCacheManager.h @@ -7,18 +7,23 @@ class FontDecompressor; class SdCardFont; +class TtfEpdFont; class FontCacheManager { public: - FontCacheManager(const std::map& fontMap, const std::map& sdCardFonts); + FontCacheManager(const std::map& fontMap, const std::map& sdCardFonts, + const std::map& ttfFonts); void setFontDecompressor(FontDecompressor* d); void clearCache(); - // Release every rebuildable SD-font cache (mini glyph/kern arenas, kern/lig - // class tables, overflow rings, advance tables) while keeping the fonts - // loaded. Everything faults back in on demand. For heap-critical transitions - // (e.g. web-server + WiFi startup); see SdCardFont::releaseResidentCaches(). + // Release every rebuildable font cache while keeping the fonts loaded: + // * SD (.cpfont): mini glyph/kern arenas, kern/lig class tables, overflow + // rings, advance tables (SdCardFont::releaseResidentCaches). + // * TTF (vector): byte arenas, glyph tables, and the lazy bold/italic + // FreeType faces (TtfEpdFont::releaseResidentCaches). + // Everything faults back in on demand. For heap-critical transitions (e.g. + // web-server + WiFi startup, image decode, dictionary, sleep). void releaseSdFontCaches(); void prewarmCache(int fontId, const char* utf8Text, uint8_t styleMask = 0x0F, bool accumulate = true); void logStats(const char* label = "render"); @@ -51,6 +56,8 @@ class FontCacheManager { private: const std::map& fontMap_; const std::map& sdCardFonts_; + // Read only when CROSSPOINT_VECTOR_FONTS is on (PSRAM boards). + [[maybe_unused]] const std::map& ttfFonts_; FontDecompressor* fontDecompressor_ = nullptr; enum class ScanMode : uint8_t { None, Scanning }; diff --git a/lib/GfxRenderer/GfxRenderer.cpp b/lib/GfxRenderer/GfxRenderer.cpp index c97f01bcaf2..7b25e96fb49 100644 --- a/lib/GfxRenderer/GfxRenderer.cpp +++ b/lib/GfxRenderer/GfxRenderer.cpp @@ -6,7 +6,9 @@ #include #include #include +#include #include +#include #include #include @@ -77,6 +79,12 @@ void appendShapedRtlTokens(const char* text, std::string& shapedOut) { } // namespace const uint8_t* GfxRenderer::getGlyphBitmap(const EpdFontData* fontData, const EpdGlyph* glyph) const { + // Vector (TTF) fonts: the glyph bitmap lives in the font's own cache, keyed by + // the EpdGlyph the miss handler returned. Checked first so it never reaches the + // SdCardFont overflow cast below. nullptr = zero-width glyph (e.g. space). + if (fontData->vectorBitmapHandler != nullptr) { + return fontData->vectorBitmapHandler(fontData->glyphMissCtx, glyph); + } if (fontData->groups != nullptr) { auto* fd = fontCacheManager_ ? fontCacheManager_->getDecompressor() : nullptr; if (!fd) { @@ -113,26 +121,40 @@ void GfxRenderer::ensureSdCardFontReady(int fontId, const char* utf8Text, uint8_ if (missed > 0) { LOG_DBG("GFX", "ensureSdCardFontReady: %d glyph(s) not found", missed); } + return; } + // TTF (vector) fonts need nothing here: getGlyph faults glyphs in on demand + // (glyphMissHandler), and the page's set is batch-warmed by the render scan's + // prewarmCache(). Pre-faulting a whole chapter's text during layout would just + // thrash the page-bounded cache, so it is intentionally omitted. } -void GfxRenderer::ensureSdCardFontReady(int fontId, const std::deque& words, bool includeHyphen, - uint8_t styleMask) const { +void GfxRenderer::ensureSdCardFontReady(const int fontId, const char* const* segments, const size_t* segmentLens, + const size_t segmentCount, const bool includeSpace, const bool includeHyphen, + const uint8_t styleMask) const { auto it = sdCardFonts_.find(fontId); if (it != sdCardFonts_.end()) { // Augment the persistent advance-only table for layout measurement. // The table survives across paragraphs/sections (capped per font), so // repeated indexing of the same SD font amortizes glyph-metric SD reads. std::string shaped; - for (const auto& w : words) { - appendShapedRtlTokens(w.c_str(), shaped); + for (size_t seg = 0; seg < segmentCount; seg++) { + const char* p = segments[seg]; + const char* const end = p + segmentLens[seg]; + while (p < end) { + appendShapedRtlTokens(p, shaped); + p += strlen(p) + 1; + } } - int missed = - it->second->buildAdvanceTable(words, includeHyphen, styleMask, shaped.empty() ? nullptr : shaped.c_str()); + int missed = it->second->buildAdvanceTablePacked(segments, segmentLens, segmentCount, includeSpace, includeHyphen, + styleMask, shaped.empty() ? nullptr : shaped.c_str()); if (missed > 0) { LOG_DBG("GFX", "ensureSdCardFontReady: %d glyph(s) not found", missed); } + return; } + // TTF (vector) fonts: nothing to do — glyphs fault in on demand at getGlyph + // and the page is batch-warmed by the render scan (see the other overload). } void GfxRenderer::begin() { @@ -146,6 +168,42 @@ void GfxRenderer::begin() { panelWidthBytes = display.getDisplayWidthBytes(); frameBufferSize = display.getBufferSize(); bwBufferChunks.assign((frameBufferSize + BW_BUFFER_CHUNK_SIZE - 1) / BW_BUFFER_CHUNK_SIZE, nullptr); + + // Evictable-cache sinks for the SDK memory manager: layout's OOM choke + // points (WordStore/TextBlock) call ensureFree() to flush these and retry + // before failing a section build. Both caches rebuild transparently from SD + // or flash on the next glyph access. The SD-font persistent advance table is + // deliberately NOT a sink: evicting it mid-paragraph would make later + // measurements in the same layout pass return 0-width advances. + auto& memoryManager = freeink::MemoryManager::instance(); + memoryManager.registerSink({"gfx.renderGlyphCache", 50, [this](size_t) -> size_t { + if (!fontCacheManager_ || fontCacheManager_->isScanning()) return 0; + const size_t before = freeink::MemoryManager::instance().freeBytes(); + fontCacheManager_->clearCache(); + const size_t after = freeink::MemoryManager::instance().freeBytes(); + return after > before ? after - before : 0; + }}); + memoryManager.registerSink({"gfx.sdFontMini", 60, [this](size_t) -> size_t { + const size_t before = freeink::MemoryManager::instance().freeBytes(); + for (auto& entry : sdCardFonts_) { + if (entry.second) entry.second->clearCache(); + } + const size_t after = freeink::MemoryManager::instance().freeBytes(); + return after > before ? after - before : 0; + }}); +#if CROSSPOINT_VECTOR_FONTS + // TTF glyph arenas are safe to shed mid-layout, unlike the SD-font advance + // table: metrics re-fault through FreeType with identical values, so layout + // cannot silently corrupt — the cost is re-rasterizing on the next draw. + memoryManager.registerSink({"gfx.ttfGlyphArenas", 70, [this](size_t) -> size_t { + const size_t before = freeink::MemoryManager::instance().freeBytes(); + for (auto& entry : ttfFonts_) { + if (entry.second) entry.second->releaseResidentCaches(); + } + const size_t after = freeink::MemoryManager::instance().freeBytes(); + return after > before ? after - before : 0; + }}); +#endif } void GfxRenderer::releaseFrameBufferForBuild() { diff --git a/lib/GfxRenderer/GfxRenderer.h b/lib/GfxRenderer/GfxRenderer.h index e4eb0683df2..6a948279137 100644 --- a/lib/GfxRenderer/GfxRenderer.h +++ b/lib/GfxRenderer/GfxRenderer.h @@ -13,10 +13,10 @@ enum class BidiBaseDir : signed char { AUTO = -1, LTR = 0, RTL = 1 }; class FontCacheManager; class SdCardFont; +class TtfEpdFont; #include #include -#include #include #include #include @@ -64,6 +64,9 @@ class GfxRenderer { // fontCacheManager_ below. mutable std::map sdCardFonts_; mutable std::map sdCardFontScales_; // fontId -> 8.8 fixed point scale (256=1.0x) + // TTF (vector) fonts: rebuilt per page by ensureSdCardFontReady(). Mutable for + // the same reason as sdCardFonts_ (const layout path triggers a rebuild). + mutable std::map ttfFonts_; // Mutable because drawText() is const but needs to delegate scan-mode // recording to the (non-const) FontCacheManager. Same pragmatic compromise @@ -181,6 +184,13 @@ class GfxRenderer { } const std::map& getSdCardFonts() const { return sdCardFonts_; } bool isSdCardFont(int fontId) const { return sdCardFonts_.count(fontId) > 0; } + // TTF (vector) fonts rendered via TtfEpdFont/FreeInkFont. Registered like an + // ordinary EpdFontFamily (insertFont), plus tracked here so ensureSdCardFontReady() + // rebuilds their per-page glyph set on demand — the eager analogue of the SD + // font prewarm. The TtfEpdFont is owned by the caller (SdCardFontSystem). + void registerTtfFont(int fontId, TtfEpdFont* font) { ttfFonts_[fontId] = font; } + void unregisterTtfFont(int fontId) { ttfFonts_.erase(fontId); } + const std::map& getTtfFonts() const { return ttfFonts_; } // Register/clear size-matched CJK UI fallbacks (see fallbackFontMap_). // setFallbackFont maps a primary UI font id to an SD font id of the same size. void setFallbackFont(int primaryFontId, int fallbackFontId) { fallbackFontMap_[primaryFontId] = fallbackFontId; } @@ -189,8 +199,11 @@ class GfxRenderer { // (which holds a const GfxRenderer&) before measuring word widths. Safe to call on non-SD fonts (no-op). // styleMask: bitmask of styles to prepare (bit 0=regular, 1=bold, 2=italic, 3=bold-italic). void ensureSdCardFontReady(int fontId, const char* utf8Text, uint8_t styleMask = 0x0F) const; - void ensureSdCardFontReady(int fontId, const std::deque& words, bool includeHyphen, - uint8_t styleMask = 0x0F) const; + // Packed variant for the paragraph layout path: each segment holds + // consecutive NUL-terminated words (WordStore chunks), so a whole paragraph + // is scanned without materializing per-word strings. + void ensureSdCardFontReady(int fontId, const char* const* segments, const size_t* segmentLens, size_t segmentCount, + bool includeSpace, bool includeHyphen, uint8_t styleMask = 0x0F) const; // Orientation control (affects logical width/height and coordinate transforms) void setOrientation(const Orientation o) { orientation = o; } @@ -273,7 +286,7 @@ class GfxRenderer { clipBottom_ = y + height; } void drawPixel(int x, int y, bool state = true) const; - // Fast path for unrotated glyphs: same result as drawPixel() per ink pixel, clipped and rotated once per glyph. + // Draw glyph ink with clipping and orientation resolved once per glyph. void drawGlyphBitmap(const uint8_t* bitmap, int width, int height, const glyphBitmap::Frame& frame, bool twoBit, RenderMode mode, bool state) const; void drawLine(int x1, int y1, int x2, int y2, bool state = true) const; diff --git a/platformio.ini b/platformio.ini index c1ad917635b..83089bb457e 100644 --- a/platformio.ini +++ b/platformio.ini @@ -57,6 +57,18 @@ build_flags = # Increase PNG scanline buffer to support up to 2048px wide images # Default is (320*4+1)*2=2562, we need more for larger images -DPNG_MAX_BUFFERED_PIXELS=16416 +# FreeInkFont optional FreeType modules. The auto-hinter (~50KB text, PSRAM +# boards only via the vector-font gate) is REQUIRED by the BW-aware 2-bit +# quantizer in TtfEpdFont: with the ink threshold at 50% coverage, unhinted +# thin strokes that straddle pixel columns drop below threshold and break up; +# Light hinting snaps stems to full-coverage pixels so they survive. (An +# earlier "no improvement" A/B was confounded by the old always-ink quantizer +# flooding the differences.) Still OFF: the B/W raster (_MONOCHROME, ~5KB) is +# dead while every TtfEpdFont::load() is 2-bit; psnames (_PSNAMES, ~64KB) +# only serves glyph-NAME lookups a codepoint-driven renderer never makes; +# gzip (_GZIP, ~13KB) only decompresses .gz/WOFF, and the registry accepts +# .ttf/.otf/.ttc. + -DFREEINK_FONT_ENABLE_AUTOHINT=1 -DFREEINK_NET_WOLFSSL=1 -DWOLFSSL_USER_SETTINGS -DWOLFSSL_OPTIONS_H @@ -119,12 +131,17 @@ lib_deps = BoardConfig=symlink://freeink-sdk/libs/hardware/BoardConfig XteinkDetect=symlink://freeink-sdk/libs/hardware/XteinkDetect PowerManager=symlink://freeink-sdk/libs/hardware/PowerManager + MemoryManager=symlink://freeink-sdk/libs/hardware/MemoryManager FrontlightManager=symlink://freeink-sdk/libs/hardware/FrontlightManager Rtc=symlink://freeink-sdk/libs/hardware/Rtc Imu=symlink://freeink-sdk/libs/hardware/Imu SecureNet=symlink://freeink-sdk/libs/network/SecureNet FreeInkUI=symlink://freeink-sdk/libs/ui/FreeInkUI Icons=symlink://freeink-sdk/libs/assets/Icons + ; Standalone FreeType/stb TTF engine backing lib/EpdFont/TtfEpdFont (runtime + ; .ttf/.otf rendering). Vendors only stb_truetype + FreeType (no expat), so it + ; does not hit the FreeInkBook duplicate-XML_* symbol problem. + FreeInkFont=symlink://freeink-sdk/libs/font/FreeInkFont bblanchon/ArduinoJson @ 7.4.2 ricmoo/QRCode @ 0.0.1 bitbank2/PNGdec @ 1.1.6 diff --git a/src/CrossPointSettings.cpp b/src/CrossPointSettings.cpp index 616cdf0f407..edb8844cad0 100644 --- a/src/CrossPointSettings.cpp +++ b/src/CrossPointSettings.cpp @@ -304,7 +304,11 @@ ReaderRenderSpec CrossPointSettings::readerRenderSpec(const uint16_t viewportWid } float CrossPointSettings::getReaderLineCompression() const { - // SD card fonts use same compression as Bookerly (the most neutral values) + // SD card and vector fonts get a wider scale than the built-ins: their + // faces carry their own (often generous) natural line height, so the old + // Bookerly-tuned 1.1/1.2 steps were visually near-indistinguishable. At + // 12pt in portrait (~760px viewport) this scale spans ~26/24/19/15 lines + // per page — each step reads as a clearly different density. if (sdFontFamilyName[0] != '\0') { switch (lineSpacing) { case TIGHT: @@ -313,9 +317,9 @@ float CrossPointSettings::getReaderLineCompression() const { default: return 1.0f; case WIDE: - return 1.1f; + return 1.3f; case EXTRA_WIDE: - return 1.2f; + return 1.6f; } } diff --git a/src/ReaderFontSizes.cpp b/src/ReaderFontSizes.cpp index b78dcf91e13..b75acbb2f7c 100644 --- a/src/ReaderFontSizes.cpp +++ b/src/ReaderFontSizes.cpp @@ -5,8 +5,12 @@ std::vector readerFontPointSizes(const SdCardFontRegistry* registry, const char* sdFamilyName) { if (registry && sdFamilyName && sdFamilyName[0] != '\0') { if (const auto* family = registry->findFamily(sdFamilyName)) { - auto sizes = family->availableSizes(); - if (!sizes.empty()) return sizes; + // Vector (.ttf/.otf) fonts render at any size — offer the standard reader + // sizes instead of the placeholder size-0 entry in the family record. + if (!family->vector) { + auto sizes = family->availableSizes(); + if (!sizes.empty()) return sizes; + } } } return {std::begin(BUILTIN_READER_POINT_SIZES), std::end(BUILTIN_READER_POINT_SIZES)}; diff --git a/src/SdCardFontSystem.cpp b/src/SdCardFontSystem.cpp index 5886be16406..a8a5099b134 100644 --- a/src/SdCardFontSystem.cpp +++ b/src/SdCardFontSystem.cpp @@ -1,7 +1,11 @@ #include "SdCardFontSystem.h" #include +#include #include +#include +#include +#include #include @@ -11,6 +15,33 @@ namespace { +#if CROSSPOINT_VECTOR_FONTS +// Stable, non-zero renderer font id for a vector family at a size (FNV-1a of +// name + size). 0 is the "not found" sentinel, so bump collisions to 1. +int computeTtfFontId(const char* familyName, uint8_t pointSize) { + uint32_t hash = 2166136261u; + for (const char* p = familyName; p && *p; ++p) { + hash ^= static_cast(*p); + hash *= 16777619u; + } + hash ^= pointSize; + hash *= 16777619u; + hash ^= 0x54544600u; // "TTF\0" salt to avoid colliding with cpfont ids + const int id = static_cast(hash); + return id != 0 ? id : 1; +} +#endif // CROSSPOINT_VECTOR_FONTS + +} // namespace + +// Out-of-line ctor/dtor: TtfEpdFont is complete here, so unique_ptr +// can be constructed/destroyed. (Declared in the header where it is only +// forward-declared.) +SdCardFontSystem::SdCardFontSystem() = default; +SdCardFontSystem::~SdCardFontSystem() = default; + +namespace { + // Point the reader font size at a size the given family actually ships, and // persist the change so the settings UI and the loaded font never disagree. // Guarded by the value-change check: a no-op snap must not write SPIFFS. @@ -51,7 +82,16 @@ void SdCardFontSystem::begin(GfxRenderer& renderer) { if (SETTINGS.sdFontFamilyName[0] != '\0') { const auto* family = registry_.findFamily(SETTINGS.sdFontFamilyName); if (family) { - if (manager_.loadFamily(*family, renderer, SETTINGS.fontPointSize)) { +#if CROSSPOINT_VECTOR_FONTS + if (family->vector) { + // Vector (.ttf/.otf) families load through the FreeInkFont path; the + // .cpfont manager below rejects them ("Invalid magic bytes") and would + // wipe the user's selection on every boot. loadTtfFamily keeps the + // selection on transient failures and registers UI fallbacks itself. + loadTtfFamily(*family, renderer, /*registryWasDirty=*/false); + } else +#endif + if (manager_.loadFamily(*family, renderer, SETTINGS.fontPointSize)) { snapFontPointSizeTo(manager_.currentPointSize()); setupUiFallbacks(renderer); LOG_DBG("SDFS", "Loaded SD card font family: %s", SETTINGS.sdFontFamilyName); @@ -80,6 +120,23 @@ void SdCardFontSystem::ensureLoaded(GfxRenderer& renderer) { } const char* wantedFamily = SETTINGS.sdFontFamilyName; + +#if CROSSPOINT_VECTOR_FONTS + // Vector (.ttf/.otf) family selected: route through the FreeInkFont path and + // drop any pre-rasterized (.cpfont) font that was loaded. + if (wantedFamily[0] != '\0') { + const auto* wantedFam = registry_.findFamily(wantedFamily); + if (wantedFam && wantedFam->vector) { + if (!manager_.currentFamilyName().empty()) manager_.unloadAll(renderer); + loadTtfFamily(*wantedFam, renderer, registryWasDirty); + return; + } + } + // Not on a vector family — ensure any previously-loaded TTF font is released + // before the pre-rasterized/built-in path below takes over. + if (!ttfFamily_.empty()) unloadTtf(renderer); +#endif + const std::string& currentFamily = manager_.currentFamilyName(); if (wantedFamily[0] == '\0') { @@ -176,8 +233,292 @@ void SdCardFontSystem::setupUiFallbacks(GfxRenderer& renderer) { } int SdCardFontSystem::resolveFontId(const char* familyName, uint8_t /*pointSize*/) const { +#if CROSSPOINT_VECTOR_FONTS + // A loaded vector (.ttf) family answers first — it isn't in the .cpfont manager. + if (ttfFontId_ != 0 && familyName && ttfFamily_ == familyName) return ttfFontId_; +#endif // The manager holds exactly one reader-size font, already selected for // SETTINGS.fontPointSize, so the size argument is implicit — always return // that font's ID. ensureLoaded() must have run for the current settings first. return manager_.getFontId(familyName); } + +#if CROSSPOINT_VECTOR_FONTS + +void SdCardFontSystem::freeTtfSources() { + for (auto& s : ttfSources_) { + s.bytes.clear(); + freeink::font::PsramVector().swap(s.bytes); // actually release + if (s.file) s.file.close(); + s.streamed = false; + s.size = 0; + s.present = false; + } +} + +void SdCardFontSystem::unloadTtf(GfxRenderer& renderer) { + if (ttfFamily_.empty() && ttfFontId_ == 0 && ttfUiIds_.empty()) return; + // UI-size fallbacks first (they borrow ttfSources_). + for (const int id : ttfUiIds_) { + renderer.unregisterTtfFont(id); + renderer.removeFont(id); + } + ttfUiIds_.clear(); + ttfUi_.clear(); + renderer.clearFallbackFonts(); + if (ttfFontId_ != 0) { + renderer.unregisterTtfFont(ttfFontId_); + renderer.removeFont(ttfFontId_); // drop from the renderer's fontMap + } + ttf_.reset(); // frees the FT faces first (they read ttfSources_) + freeTtfSources(); + ttfFamily_.clear(); + ttfFontId_ = 0; + ttfPointSize_ = 0; +} + +bool SdCardFontSystem::openTtfSource(const uint8_t style, const std::string& path) { + if (style >= 4) return false; + // Small fonts are read fully into RAM (fastest, fewest SD reads; PSRAM when + // present). Large fonts (e.g. multi-MB variable/CJK) STREAM from SD so the + // whole file never sits in RAM — the handle is kept open for the font's life. + // PSRAM boards only (this whole path is vector-font gated), so size the + // resident cap for the 8MB parts: a 4.5MB variable font held resident gets + // GPOS kerning (streamed faces skip it, and GPOS-only fonts like + // Merriweather VF lose ALL kerning when streamed) and skips per-glyph SD + // reads. The heap gate below still falls back to streaming when PSRAM + // can't fund the buffer. + static constexpr size_t kResidentMax = 6 * 1024 * 1024; + // Working headroom that must remain in internal DRAM after a resident load + // (FreeType face setup, glyph caches, and the rest of the system). + static constexpr size_t kInternalHeadroom = 96 * 1024; + HalFile f = Storage.open(path.c_str()); + if (!f) { + LOG_ERR("SDFS", "Failed to open TTF: %s", path.c_str()); + return false; + } + const size_t len = f.size(); + if (len == 0) { + LOG_ERR("SDFS", "Empty TTF: %s", path.c_str()); + f.close(); + return false; + } + TtfSource& s = ttfSources_[style]; + // A resident buffer lands in PSRAM when fiFontMalloc can place it there; + // otherwise it competes with everything else in internal DRAM. PsramAlloc + // aborts on OOM, so this gate is load-bearing on no-PSRAM boards (X4/C3): + // fall back to streaming instead of attempting an allocation that can fail. + bool resident = len <= kResidentMax; + if (resident && heap_caps_get_largest_free_block(MALLOC_CAP_SPIRAM) < len) { + const size_t internalFree = heap_caps_get_largest_free_block(MALLOC_CAP_INTERNAL | MALLOC_CAP_8BIT); + if (internalFree < len + kInternalHeadroom) { + LOG_DBG("SDFS", "TTF %s (%u KB) too large for DRAM (largest block %u KB), streaming", path.c_str(), + static_cast(len / 1024), static_cast(internalFree / 1024)); + resident = false; + } + } + if (resident) { + s.bytes.resize(len); + const int got = f.read(s.bytes.data(), len); + f.close(); + if (static_cast(got) != len) { + LOG_ERR("SDFS", "Short read on TTF %s (%d/%u)", path.c_str(), got, static_cast(len)); + s.bytes.clear(); + return false; + } + s.streamed = false; + } else { + s.file = std::move(f); // kept open; prefixRead() reads it on demand + s.streamed = true; + // Cache the file's head in PSRAM: an sfnt's per-glyph-fault tables (cmap, + // loca, hmtx) sit before the multi-MB glyf table, so serving the first + // 1 MB from RAM turns each glyph fault's 4-6 scattered SD seeks into one + // glyf read. Gated per source so a small-PSRAM board takes what fits. + static constexpr size_t kStreamPrefix = 1024 * 1024; + const size_t prefix = len < kStreamPrefix ? len : kStreamPrefix; + if (heap_caps_get_largest_free_block(MALLOC_CAP_SPIRAM) > prefix + 256 * 1024) { + s.bytes.resize(prefix); + if (s.file.seek(0) && static_cast(s.file.read(s.bytes.data(), prefix)) == prefix) { + LOG_DBG("SDFS", "Cached %u KB TTF prefix in PSRAM", static_cast(prefix / 1024)); + } else { + s.bytes.clear(); // fall back to pure streaming + } + } + LOG_DBG("SDFS", "Streaming TTF %s (%u KB) from SD", path.c_str(), static_cast(len / 1024)); + } + s.size = static_cast(len); + s.present = true; + return true; +} + +// Streamed-source read: serve from the PSRAM prefix cache when the range is +// there, hit SD only for the tail (glyf outlines). A read straddling the +// boundary splits across both. +unsigned long SdCardFontSystem::prefixRead(void* ctx, const unsigned long offset, unsigned char* buffer, + const unsigned long count) { + auto* s = static_cast(ctx); + const unsigned long cached = s->bytes.size(); + if (offset < cached) { + const unsigned long fromCache = (offset + count <= cached) ? count : cached - offset; + if (count == 0) return 0; // seek probe + memcpy(buffer, s->bytes.data() + offset, fromCache); + if (fromCache == count) return count; + return fromCache + + SdCardFontRegistry::halFileRead(&s->file, offset + fromCache, buffer + fromCache, count - fromCache); + } + return SdCardFontRegistry::halFileRead(&s->file, offset, buffer, count); +} + +void SdCardFontSystem::addTtfSources(TtfEpdFont& font) { + for (uint8_t st = 0; st < 4; ++st) { + TtfSource& s = ttfSources_[st]; + if (!s.present) continue; + if (s.streamed) { + font.addStreamSource(st, &SdCardFontSystem::prefixRead, &s, s.size); + } else { + font.addResidentSource(st, s.bytes.data(), static_cast(s.bytes.size())); + } + } +} + +void SdCardFontSystem::setupTtfUiFallbacks(GfxRenderer& renderer) { + if (ttfFamily_.empty()) return; + // Small caches: UI strings (titles/rows) are short. Each UI family is 4-style + // but LAZY, so only the regular face is ever built for UI text — the bold/ + // italic faces cost nothing. All faces share the reader's sources (streamed + // handles or resident bytes), so no extra copy of any font file. + // Each fallback instance carries its own FreeType face and lazy glyph + // arena. Without PSRAM those compete with the reader's section build for + // internal DRAM, and the build must win: below this floor, skip the + // fallback (built-in bitmap UI fonts keep covering Latin UI text). + static constexpr size_t kUiFallbackMinInternalHeap = 160 * 1024; + for (const auto& ui : kUiFontSizes) { + if (heap_caps_get_largest_free_block(MALLOC_CAP_SPIRAM) == 0) { + const size_t internalFree = heap_caps_get_free_size(MALLOC_CAP_INTERNAL | MALLOC_CAP_8BIT); + if (internalFree < kUiFallbackMinInternalHeap) { + LOG_DBG("SDFS", "Skipping TTF UI fallback @%upt (%u KB internal free)", ui.pointSize, + static_cast(internalFree / 1024)); + continue; + } + } + auto f = makeUniqueNoThrow(); + if (!f) { + LOG_ERR("SDFS", "OOM: TtfEpdFont for UI fallback @%upt", ui.pointSize); + continue; // built-in bitmap UI fonts keep covering this size + } + addTtfSources(*f); + const bool ok = f->load(ui.pointSize, /*twoBit=*/true, /*glyphCacheBytes=*/16 * 1024, /*maxGlyphs=*/384); + if (!ok) continue; + LOG_DBG("SDFS", "TTF UI fallback @%upt loaded (heap free %u)", ui.pointSize, (unsigned)ESP.getFreeHeap()); + // Distinct id from the reader-size font: a UI size can equal the reader size + // (e.g. both 12pt), which would collide on computeTtfFontId and be dropped + // as a duplicate. Salt the UI family name to separate the id spaces. + const int id = computeTtfFontId((ttfFamily_ + "\x01ui").c_str(), ui.pointSize); + renderer.insertFont(id, f->family()); + renderer.registerTtfFont(id, f.get()); + renderer.setFallbackFont(ui.fontId, id); + ttfUiIds_.push_back(id); + ttfUi_.push_back(std::move(f)); + } +} + +void SdCardFontSystem::loadTtfFamily(const SdCardFontFamilyInfo& family, GfxRenderer& renderer, + const bool registryWasDirty) { + // Vector fonts render at any size; snap the reader size into the standard set. + snapFontPointSizeTo(snapToNearestPointSize(BUILTIN_READER_POINT_SIZES, std::size(BUILTIN_READER_POINT_SIZES), + SETTINGS.fontPointSize)); + const uint8_t size = SETTINGS.fontPointSize; + + // Already loaded, same family + size, and disk unchanged → nothing to do. + if (!registryWasDirty && ttf_ && ttfFamily_ == family.name && ttfPointSize_ == size) return; + + // Reader-face glyph-cache budget (used by both the resize fast path and the + // full load below): the default 32 KB holds ~90 CJK glyphs, but a CJK page + // uses 300+, so the cache flush-cycles mid-page and every page turn + // re-rasterizes the whole page through streamed SD reads (multi-second + // turns). The arenas are PSRAM-backed (FontPsram); 1 MB / 4096 glyphs holds + // a whole Japanese novel's working set (~3000 unique kanji+kana at ~350 B + // each), so the flush-everything ceiling is never hit and warm page turns + // are pure cache hits. Without PSRAM keep the internal-DRAM-safe default. + const bool havePsram = heap_caps_get_largest_free_block(MALLOC_CAP_SPIRAM) > 0; + const size_t cacheBytes = havePsram ? 1024 * 1024 : 32 * 1024; + const uint16_t maxGlyphs = havePsram ? 4096 : 768; + + // Same family, only the reader size changed (size preview): the open style + // sources and the size-independent UI fallbacks don't need rebuilding — just + // re-drive the reader face at the new size, reusing the already-open files + // instead of reopening all four and rebuilding every UI fallback. + if (!registryWasDirty && ttf_ && ttfFamily_ == family.name) { + renderer.unregisterTtfFont(ttfFontId_); + renderer.removeFont(ttfFontId_); + if (ttf_->load(size, /*twoBit=*/true, cacheBytes, maxGlyphs)) { + ttf_->build(" "); + ttfFontId_ = computeTtfFontId(family.name.c_str(), size); + renderer.insertFont(ttfFontId_, ttf_->family()); + renderer.registerTtfFont(ttfFontId_, ttf_.get()); + ttfPointSize_ = size; + return; + } + // Resize failed: fall through to a clean full reload. + } + + unloadTtf(renderer); + + if (family.files.empty()) { + LOG_ERR("SDFS", "Vector family %s has no file", family.name.c_str()); + SETTINGS.clearSdFontFamily(); + return; + } + + // Open each style source the family ships (0=regular, 1=bold, 2=italic, + // 3=bold-italic). A single-file family (loose .ttf, or a folder with one file) + // supplies only regular; TtfEpdFont then derives bold/italic from the wght axis + // or an oblique shear. Extra files upgrade those styles to the real designs. + for (const auto& file : family.files) { + const uint8_t role = file.style < 4 ? file.style : 0; + if (ttfSources_[role].present) continue; // registry already deduped by role + openTtfSource(role, file.path); + } + if (!ttfSources_[0].present) { + // Possibly a transient SD read failure: keep the user's selection so the + // next ensureLoaded() retries; this session falls back to the built-in. + LOG_ERR("SDFS", "Vector family %s: regular file failed to open (keeping selection)", family.name.c_str()); + freeTtfSources(); + return; + } + + ttf_ = makeUniqueNoThrow(); + if (!ttf_) { + // Transient OOM: keep the user's selection (unlike a parse failure) so the + // next ensureLoaded() can retry once heap pressure passes. + LOG_ERR("SDFS", "OOM: TtfEpdFont for %s", family.name.c_str()); + freeTtfSources(); + return; + } + addTtfSources(*ttf_); + const bool ok = ttf_->load(size, /*twoBit=*/true, cacheBytes, maxGlyphs); + if (!ok) { + // init failure is ambiguous (corrupt font vs. transient OOM inside + // FreeType): keep the selection and retry next ensureLoaded() rather than + // silently reverting the user to the built-in font. A genuinely broken + // font costs one failed load per reader entry, visible in the log. + LOG_ERR("SDFS", "FreeInkFont could not parse %s (keeping selection)", family.name.c_str()); + ttf_.reset(); + freeTtfSources(); + return; + } + // Seed the regular face's glyph cache; other styles + glyphs fault on demand. + ttf_->build(" "); + + ttfFontId_ = computeTtfFontId(family.name.c_str(), size); + renderer.insertFont(ttfFontId_, ttf_->family()); + renderer.registerTtfFont(ttfFontId_, ttf_.get()); + ttfFamily_ = family.name; + ttfPointSize_ = size; + LOG_DBG("SDFS", "Reader TTF face loaded (heap free %u, max block %u)", (unsigned)ESP.getFreeHeap(), + (unsigned)ESP.getMaxAllocHeap()); + setupTtfUiFallbacks(renderer); // CJK/script UI fallback at the built-in UI sizes + LOG_DBG("SDFS", "Loaded TTF font: %s @ %upt (id %d)", family.name.c_str(), size, ttfFontId_); +} + +#endif // CROSSPOINT_VECTOR_FONTS diff --git a/src/SdCardFontSystem.h b/src/SdCardFontSystem.h index 5ca7e437f24..20713fda407 100644 --- a/src/SdCardFontSystem.h +++ b/src/SdCardFontSystem.h @@ -1,17 +1,32 @@ #pragma once +#include // HalFile (kept open for streamed TTFs) #include #include +#include + +#if CROSSPOINT_VECTOR_FONTS +#include // PsramVector for resident TTF bytes +#endif #include +#include +#include +#include +#include class GfxRenderer; +class TtfEpdFont; /// Facade that owns the SD card font registry, manager, and resolver logic. /// Hides implementation details behind a single begin() + ensureLoaded() API. class SdCardFontSystem { public: - SdCardFontSystem() = default; + // Constructor and destructor are out-of-line (defined in the .cpp where + // TtfEpdFont is a complete type) so the std::unique_ptr member + // can be constructed/destroyed with only a forward declaration visible here. + SdCardFontSystem(); + ~SdCardFontSystem(); SdCardFontSystem(const SdCardFontSystem&) = delete; SdCardFontSystem& operator=(const SdCardFontSystem&) = delete; /// Discover SD card fonts and load user's saved selection. Call once during setup. @@ -54,9 +69,58 @@ class SdCardFontSystem { // reused). void setupUiFallbacks(GfxRenderer& renderer); +#if CROSSPOINT_VECTOR_FONTS + // --- Vector (.ttf/.otf) font path (FreeInkFont via TtfEpdFont) ------------- + // Load/refresh the selected TTF family at the current reader size, register + // it with the renderer, and track it so ensureSdCardFontReady() rebuilds its + // glyph set per page. registryWasDirty forces a reload even if unchanged. + void loadTtfFamily(const SdCardFontFamilyInfo& family, GfxRenderer& renderer, bool registryWasDirty); + // Unregister + free the active TTF font (and its UI-size fallbacks), if any. + void unloadTtf(GfxRenderer& renderer); + // Register the loaded TTF at each built-in UI size as a script fallback, so UI + // text (book titles, list rows, menus, status bar) in scripts the built-in + // fonts lack renders in the chosen TTF. Mirrors setupUiFallbacks for .cpfont. + void setupTtfUiFallbacks(GfxRenderer& renderer); + // Open one style source file (resident if small, streamed if large) into + // ttfSources_[style]. Returns false on open/read failure. + bool openTtfSource(uint8_t style, const std::string& path); + // Register every present source with `font` (shared bytes / file handles). + void addTtfSources(TtfEpdFont& font); + // Close/free all style sources. + void freeTtfSources(); + // ReadFn for streamed sources: serves the PSRAM prefix cache first, SD after. + static unsigned long prefixRead(void* ctx, unsigned long offset, unsigned char* buffer, unsigned long count); +#endif // CROSSPOINT_VECTOR_FONTS + SdCardFontRegistry registry_; SdCardFontManager manager_; std::atomic registryDirty_{false}; + +#if CROSSPOINT_VECTOR_FONTS + // One style source file. SMALL files are read fully into `bytes` (resident, + // PSRAM when present); LARGE files stream from `file` (kept open) so a multi-MB + // file never sits in RAM. All faces (reader + UI sizes) share these sources. + struct TtfSource { + // Resident form: the whole file. Streamed form: a PSRAM prefix cache of the + // file head (cmap/loca/hmtx) — empty when PSRAM couldn't fund it. + freeink::font::PsramVector bytes; + HalFile file; // open handle (streamed form) + bool streamed = false; + unsigned long size = 0; + bool present = false; + }; + + // Active TTF font (at most one reader-size vector family loaded at a time). + std::unique_ptr ttf_; + // Up to 4 style sources: 0=regular (required), 1=bold, 2=italic, 3=bold-italic. + TtfSource ttfSources_[4]; + std::string ttfFamily_; // loaded vector family name ("" = none) + int ttfFontId_ = 0; // renderer font id for ttf_ (0 = none) + uint8_t ttfPointSize_ = 0; // size ttf_ was built at + // UI-size TTF fallbacks (share ttfSources_); parallel to their renderer font ids. + std::vector> ttfUi_; + std::vector ttfUiIds_; +#endif // CROSSPOINT_VECTOR_FONTS }; // Global SD card font system instance (defined in main.cpp). diff --git a/src/activities/ActivityManager.cpp b/src/activities/ActivityManager.cpp index 2bb95d07f89..beedeb44c48 100644 --- a/src/activities/ActivityManager.cpp +++ b/src/activities/ActivityManager.cpp @@ -6,6 +6,7 @@ #include #include #include +#include #include @@ -34,12 +35,22 @@ void ActivityManager::begin() { constexpr BaseType_t renderTaskCore = 1; #else constexpr BaseType_t renderTaskCore = 0; +#endif +#if CROSSPOINT_VECTOR_FONTS + // FreeType rasterization runs on this task, and the deepest observed chain + // is a glyph fault DURING LAYOUT: expat + parser + line-layout frames + // (~3.5KB on Xtensa) with the scan converter's FT_RENDER_POOL_SIZE (4KB) + // stack-resident band pool on top — a measured ~8KB peak that trips the + // canary on an 8KB stack. Vector-font boards all have PSRAM-class RAM. + constexpr uint32_t renderTaskStackBytes = 16384; +#else + constexpr uint32_t renderTaskStackBytes = 8192; #endif xTaskCreatePinnedToCore(&renderTaskTrampoline, "ActivityManagerRender", - 8192, // Stack size - this, // Parameters - 1, // Priority - &renderTaskHandle, // Task handle + renderTaskStackBytes, // Stack size + this, // Parameters + 1, // Priority + &renderTaskHandle, // Task handle renderTaskCore // Keep long renders/cover decodes off CPU 0's idle watchdog when available ); assert(renderTaskHandle != nullptr && "Failed to create render task"); diff --git a/src/activities/reader/EpubReaderActivity.cpp b/src/activities/reader/EpubReaderActivity.cpp index 330edc5bee9..b5456be34b8 100644 --- a/src/activities/reader/EpubReaderActivity.cpp +++ b/src/activities/reader/EpubReaderActivity.cpp @@ -1239,6 +1239,19 @@ void EpubReaderActivity::renderBook() { pagesUntilFullRefresh = 1; } buildPopupPending = !showPopup; + // Section (re)builds are the heap-hungriest path (per-word + // allocations for the whole section). Under TTF heap pressure, shed + // every rebuildable font cache first — dropped glyphs re-fault on + // demand after the build. Skipped for cpfont/builtin reading: the + // release drops the persistent advance table and mini tables, which + // would force SD metric re-reads on every fresh chapter for no gain. + if (!renderer.getTtfFonts().empty()) { + if (auto* fcm = renderer.getFontCacheManager()) { + fcm->releaseSdFontCaches(); + } + } + LOG_DBG("ERS", "Heap before section build: %u (max block %u)", (unsigned)ESP.getFreeHeap(), + (unsigned)ESP.getMaxAllocHeap()); const unsigned long buildStartMs = millis(); bool started; { diff --git a/src/activities/reader/ReaderActivity.cpp b/src/activities/reader/ReaderActivity.cpp index cf97c243da2..8682af17520 100644 --- a/src/activities/reader/ReaderActivity.cpp +++ b/src/activities/reader/ReaderActivity.cpp @@ -49,6 +49,10 @@ void ReaderActivity::disableFastInitialRefresh() { pagesUntilFullRefresh = 0; } void ReaderActivity::onEnter() { Activity::onEnter(); + // Heap ledger for field crash reports: free vs largest block distinguishes a + // leak (free falls) from fragmentation (free stable, largest collapses). + LOG_INF("MEM", "reader enter: free=%u max_block=%u", (unsigned)ESP.getFreeHeap(), (unsigned)ESP.getMaxAllocHeap()); + if (!Storage.exists(bookPath.c_str())) { LOG_ERR("READER", "File does not exist: %s", bookPath.c_str()); finish(); @@ -72,6 +76,8 @@ void ReaderActivity::onEnter() { void ReaderActivity::onExit() { Activity::onExit(); + LOG_INF("MEM", "reader exit: free=%u max_block=%u", (unsigned)ESP.getFreeHeap(), (unsigned)ESP.getMaxAllocHeap()); + renderer.setOrientation(GfxRenderer::Orientation::Portrait); APP_STATE.readerActivityLoadCount = 0; APP_STATE.saveToFile(); diff --git a/src/main.cpp b/src/main.cpp index 0753a36175a..6738cbee25e 100644 --- a/src/main.cpp +++ b/src/main.cpp @@ -16,6 +16,7 @@ #include #include #include +#include #include #include #include @@ -40,12 +41,22 @@ #include "util/ScreenshotUtil.h" #include "util/Timezones.h" +#if CROSSPOINT_VECTOR_FONTS +// Rendering (incl. FreeType TTF rasterization) runs on the Arduino loop task. +// The default 8 KB stack overflows inside FreeType's FT_Open_Face / variable-font +// parsing. This runtime override applies even with the prebuilt (dio_opi) core, +// where CONFIG_ARDUINO_LOOP_STACK_SIZE from sdkconfig is baked in and ignored. +// Vector-font boards only: without TTF the stock loop stack has always sufficed, +// and non-PSRAM boards need the 16KB back in DRAM. +SET_LOOP_TASK_STACK_SIZE(24 * 1024) +#endif + GfxRenderer renderer(display); MappedInputManager mappedInputManager(gpio, renderer); ActivityManager activityManager(renderer, mappedInputManager); FontDecompressor fontDecompressor; SdCardFontSystem sdFontSystem; -FontCacheManager fontCacheManager(renderer.getFontMap(), renderer.getSdCardFonts()); +FontCacheManager fontCacheManager(renderer.getFontMap(), renderer.getSdCardFonts(), renderer.getTtfFonts()); static unsigned long allowSleepAt = 0; static unsigned long lastX4ProPowerClickAt = 0; diff --git a/test/chapter_html_slim_parser/CMakeLists.txt b/test/chapter_html_slim_parser/CMakeLists.txt index 73867c10bd2..d8e72aefc3b 100644 --- a/test/chapter_html_slim_parser/CMakeLists.txt +++ b/test/chapter_html_slim_parser/CMakeLists.txt @@ -8,6 +8,7 @@ add_executable(ChapterHtmlSlimParserTest ${REPO_ROOT}/lib/Epub/Epub/parsers/ChapterHtmlSlimParser.cpp ${REPO_ROOT}/lib/Epub/Epub/css/CssParser.cpp ${REPO_ROOT}/lib/Epub/Epub/ParsedText.cpp + ${REPO_ROOT}/lib/Epub/Epub/WordStore.cpp ${REPO_ROOT}/lib/Epub/Epub/converters/ImageDimsProbe.cpp ${REPO_ROOT}/lib/Utf8/Utf8.cpp ) diff --git a/test/chapter_html_slim_parser/ChapterHtmlSlimParserTest.cpp b/test/chapter_html_slim_parser/ChapterHtmlSlimParserTest.cpp index 11b3d1fac06..adbe1635702 100644 --- a/test/chapter_html_slim_parser/ChapterHtmlSlimParserTest.cpp +++ b/test/chapter_html_slim_parser/ChapterHtmlSlimParserTest.cpp @@ -183,8 +183,8 @@ TEST_F(ChapterHtmlSlimParserTest, SpanWithHiddenAttributeShouldBeSkipped) { ChapterHtmlSlimParser::characterData(&parser, " After ", 7); ASSERT_EQ(parser.currentTextBlock->size(), 2); - ASSERT_EQ(parser.currentTextBlock->words[0], "Before"); - ASSERT_EQ(parser.currentTextBlock->words[1], "After"); + ASSERT_EQ(parser.currentTextBlock->wordAt(0), "Before"); + ASSERT_EQ(parser.currentTextBlock->wordAt(1), "After"); } TEST_F(ChapterHtmlSlimParserTest, DivWithHiddenAttributeContentShouldBeSkipped) { diff --git a/test/chapter_html_slim_parser/stubs/GfxRenderer.h b/test/chapter_html_slim_parser/stubs/GfxRenderer.h index d47edf7aa40..3a423f8fdf1 100644 --- a/test/chapter_html_slim_parser/stubs/GfxRenderer.h +++ b/test/chapter_html_slim_parser/stubs/GfxRenderer.h @@ -47,5 +47,5 @@ class GfxRenderer { } int getSpaceAdvance(int, uint32_t, uint32_t, EpdFontFamily::Style) const { return 4; } bool isSdCardFont(int) const { return false; } - void ensureSdCardFontReady(int, const std::deque&, bool, uint8_t) const {} + void ensureSdCardFontReady(int, const char* const*, const size_t*, size_t, bool, bool, uint8_t) const {} }; diff --git a/test/chapter_html_slim_parser/stubs/MemoryManager.h b/test/chapter_html_slim_parser/stubs/MemoryManager.h new file mode 100644 index 00000000000..86349572696 --- /dev/null +++ b/test/chapter_html_slim_parser/stubs/MemoryManager.h @@ -0,0 +1,26 @@ +#pragma once + +// Host-test stub for the SDK MemoryManager. The parser/layout path calls +// ensureFree() to evict rebuildable caches before large allocations; on the +// host there are no caches and no pressure, so it is a no-op that reports +// success. Matches only the surface the layout code uses. + +#include + +namespace freeink { + +enum class MemPool : unsigned char { Internal, Psram, Default }; + +class MemoryManager { + public: + static MemoryManager& instance() { + static MemoryManager inst; + return inst; + } + bool ensureFree(size_t, MemPool = MemPool::Default) { return true; } + + private: + MemoryManager() = default; +}; + +} // namespace freeink diff --git a/test/font_cache_manager/FontCacheManagerTest.cpp b/test/font_cache_manager/FontCacheManagerTest.cpp index f2368ede416..03661503fe0 100644 --- a/test/font_cache_manager/FontCacheManagerTest.cpp +++ b/test/font_cache_manager/FontCacheManagerTest.cpp @@ -13,6 +13,10 @@ namespace { bool countHeapAllocations = false; size_t heapAllocationCount = 0; +// TTF fonts are PSRAM-gated and stubbed out on the host (see stubs/TtfEpdFont.h); +// every manager gets an empty map for that slot. +const std::map kNoTtfFonts; + const SdCardFont::PrewarmCall* findCall(const SdCardFont& font, const uint8_t styleMask) { for (int i = 0; i < font.prewarmCallCount; i++) { if (font.prewarmCalls[i].styleMask == styleMask) return &font.prewarmCalls[i]; @@ -37,7 +41,7 @@ TEST(FontCacheManagerTest, PrewarmScopeBatchesEachFontAndResolvedStyleSeparately SdCardFont fallbackFont; const std::map noBuiltinFonts; const std::map sdFonts{{-17, &readerFont}, {23, &fallbackFont}}; - FontCacheManager manager(noBuiltinFonts, sdFonts); + FontCacheManager manager(noBuiltinFonts, sdFonts, kNoTtfFonts); auto scope = manager.createPrewarmScope(); manager.recordText("A", -17, EpdFontFamily::REGULAR); @@ -65,7 +69,7 @@ TEST(FontCacheManagerTest, PrewarmScopeMergesStylesThatResolveToTheSameSdFontDat font.resolvedStyles[EpdFontFamily::BOLD] = EpdFontFamily::REGULAR; const std::map noBuiltinFonts; const std::map sdFonts{{7, &font}}; - FontCacheManager manager(noBuiltinFonts, sdFonts); + FontCacheManager manager(noBuiltinFonts, sdFonts, kNoTtfFonts); auto scope = manager.createPrewarmScope(); manager.recordText("A", 7, EpdFontFamily::REGULAR); @@ -84,7 +88,7 @@ TEST(FontCacheManagerTest, PrewarmScopeMergesBuiltInStylesThatShareFontData) { const std::map builtinFonts{{5, family}}; const std::map noSdFonts; FontDecompressor decompressor; - FontCacheManager manager(builtinFonts, noSdFonts); + FontCacheManager manager(builtinFonts, noSdFonts, kNoTtfFonts); manager.setFontDecompressor(&decompressor); auto scope = manager.createPrewarmScope(); @@ -106,7 +110,7 @@ TEST(FontCacheManagerTest, PrewarmScopePreservesUniqueMultibyteCodepoints) { SdCardFont font; const std::map noBuiltinFonts; const std::map sdFonts{{7, &font}}; - FontCacheManager manager(noBuiltinFonts, sdFonts); + FontCacheManager manager(noBuiltinFonts, sdFonts, kNoTtfFonts); auto scope = manager.createPrewarmScope(); manager.recordText("\xC3\xA9\xE4\xB8\xAD\xF0\x9F\x98\x80\xC3\xA9", 7, EpdFontFamily::REGULAR); @@ -120,7 +124,7 @@ TEST(FontCacheManagerTest, PrewarmScanDoesNotAllocateHeapMemory) { SdCardFont font; const std::map noBuiltinFonts; const std::map sdFonts{{7, &font}}; - FontCacheManager manager(noBuiltinFonts, sdFonts); + FontCacheManager manager(noBuiltinFonts, sdFonts, kNoTtfFonts); heapAllocationCount = 0; countHeapAllocations = true; @@ -136,7 +140,7 @@ TEST(FontCacheManagerTest, IncrementalPrewarmRequestsAccumulation) { SdCardFont font; const std::map noBuiltinFonts; const std::map sdFonts{{7, &font}}; - FontCacheManager manager(noBuiltinFonts, sdFonts); + FontCacheManager manager(noBuiltinFonts, sdFonts, kNoTtfFonts); manager.prewarmCache(7, "title", 1); ASSERT_EQ(1, font.prewarmCallCount); EXPECT_TRUE(font.prewarmCalls[0].accumulate); diff --git a/test/font_cache_manager/stubs/TtfEpdFont.h b/test/font_cache_manager/stubs/TtfEpdFont.h new file mode 100644 index 00000000000..b8464eaa9f7 --- /dev/null +++ b/test/font_cache_manager/stubs/TtfEpdFont.h @@ -0,0 +1,9 @@ +#pragma once + +// Host-test stub. The real header self-gates on VectorFontSupport.h's +// CROSSPOINT_VECTOR_FONTS (PSRAM boards only); host tests build with the +// gate off, so FontCacheManager.cpp compiles none of its TTF dispatch and +// only needs the macro and the forward-declared pointer type. +#define CROSSPOINT_VECTOR_FONTS 0 + +class TtfEpdFont; diff --git a/test/sd_card_font/stubs/FontPsram.h b/test/sd_card_font/stubs/FontPsram.h new file mode 100644 index 00000000000..1d0071b98ad --- /dev/null +++ b/test/sd_card_font/stubs/FontPsram.h @@ -0,0 +1,27 @@ +#pragma once + +// Host-test stub for FreeInkFont's PSRAM-preferring helpers (FontPsram.h): +// plain heap on the host, same contracts (psramNewArray is nothrow, nullptr +// on OOM). Allocation goes through nothrow operator new[] so the test's +// fail-next-allocation override (see SdCardFontTest.cpp) can inject OOM. +#include +#include + +namespace freeink { +namespace font { + +template +using PsramVector = std::vector; + +template +T* psramNewArray(std::size_t n) { + return new (std::nothrow) T[n ? n : 1]; +} + +template +void psramDeleteArray(T* p) { + delete[] p; +} + +} // namespace font +} // namespace freeink From 3f70f5dfc6254a6d5e308218841fe21106a9aa11 Mon Sep 17 00:00:00 2001 From: Uri Tauber Date: Wed, 23 Sep 2026 11:49:45 +0300 Subject: [PATCH 15/62] fix: Sticky env missing ram stats (#3683) --- .github/workflows/ci.yml | 4 ++-- lib/EpdFont/VectorFontSupport.h | 2 +- lib/Logging/Logging.cpp | 4 +--- platformio.ini | 4 ++-- src/main.cpp | 3 ++- 5 files changed, 8 insertions(+), 9 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index f1b00782706..c222132e565 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -126,8 +126,8 @@ jobs: uses: actions/cache@v4 with: path: ~/.platformio - key: pio-v2-${{ runner.os }}-${{ hashFiles('platformio.ini') }} - restore-keys: pio-v2-${{ runner.os }}- + key: pio-v3-${{ runner.os }}-${{ matrix.env }}-${{ hashFiles('platformio.ini') }} + restore-keys: pio-v3-${{ runner.os }}-${{ matrix.env }}- - name: Pin pioarduino core inside the platform penv run: | diff --git a/lib/EpdFont/VectorFontSupport.h b/lib/EpdFont/VectorFontSupport.h index 5e6f96e59fe..48b527b6a5b 100644 --- a/lib/EpdFont/VectorFontSupport.h +++ b/lib/EpdFont/VectorFontSupport.h @@ -5,7 +5,7 @@ // working set (FreeType face + table state, per-style glyph arenas, resident // or GPOS font bytes) competes with the reader's section build for the same // ~380KB of internal DRAM, and the ~110KB of FreeType flash rides along. -// Gating on BOARD_HAS_PSRAM (defined by every PSRAM env in platformio.ini) +// Gating on BOARD_HAS_PSRAM (from the board definition or platformio.ini) // compiles the whole path out: FreeType never links, .ttf/.otf files are // invisible to the font registry, and .cpfont SD fonts remain the only // sideloaded-font route on those boards. diff --git a/lib/Logging/Logging.cpp b/lib/Logging/Logging.cpp index 52837f45a7f..b6aa9726738 100644 --- a/lib/Logging/Logging.cpp +++ b/lib/Logging/Logging.cpp @@ -63,9 +63,7 @@ void logPrintf(const char* level, const char* origin, const char* format, ...) { } va_end(args); #if FREEINK_LOG_TRANSPORT == FREEINK_LOG_TRANSPORT_ROM_PRINTF - // IDF/ROM console path for boards monitored over USB-Serial-JTAG, where the - // HWCDC `operator bool` reads false under `pio device monitor` and logs would - // otherwise be silently dropped (e.g. Sticky). + // Sticky's USB serial bridge uses UART0; ROM output also works before Serial0.begin(). esp_rom_printf("%s", buf); #else if (logSerial) { diff --git a/platformio.ini b/platformio.ini index 83089bb457e..e6b5d0f7d00 100644 --- a/platformio.ini +++ b/platformio.ini @@ -266,8 +266,8 @@ build_flags = ; --- Seeed Sticky — ESP32-S3R8, 3.97" 800x480 SSD1677 + GT911 touch ----------- ; Different MCU family than the C3 envs (one binary per family). The SDK -; auto-enables CAP_TOUCH (GT911) and the BQ27220 gauge for this device; PSRAM is -; intentionally left off (48KB framebuffer fits in DRAM, same as X4). +; auto-enables CAP_TOUCH (GT911) and the BQ27220 gauge for this device. The +; N16R8 board definition enables octal PSRAM. ; pio run -e sticky -t upload [env:sticky] extends = base, firmware_tuned diff --git a/src/main.cpp b/src/main.cpp index 6738cbee25e..c93983044a3 100644 --- a/src/main.cpp +++ b/src/main.cpp @@ -620,7 +620,8 @@ void loop() { renderer.setFadingFix(SETTINGS.fadingFix); - if (Serial && millis() - lastMemPrint >= 10000) { + // The ROM console does not depend on Arduino USB CDC's connection state. + if ((Serial || FREEINK_LOG_TRANSPORT == FREEINK_LOG_TRANSPORT_ROM_PRINTF) && millis() - lastMemPrint >= 10000) { const auto heap = HalMemory::getInternalHeap(); LOG_INF("MEM", "Free: %zu bytes, Total: %zu bytes, Min Free: %zu bytes, MaxAlloc: %zu bytes", heap.freeBytes, heap.totalBytes, heap.minFreeBytes, heap.largestBlockBytes); From fca8f55f5d93d55cfde9af78f23cb22bff695229 Mon Sep 17 00:00:00 2001 From: Justin Mitchell Date: Wed, 23 Sep 2026 16:56:49 -0400 Subject: [PATCH 16/62] fix: Reader list not scrollable with touch (#3693) --- src/activities/UiListActivity.cpp | 5 ++- test/test_list_viewport.py | 73 +++++++++++++++++++++++++++++++ 2 files changed, 76 insertions(+), 2 deletions(-) create mode 100644 test/test_list_viewport.py diff --git a/src/activities/UiListActivity.cpp b/src/activities/UiListActivity.cpp index 651f76edf53..9590a1e3b8d 100644 --- a/src/activities/UiListActivity.cpp +++ b/src/activities/UiListActivity.cpp @@ -121,8 +121,9 @@ void UiListActivity::syncListViewport(UiScreen& screen, fui::ListProps& props, c // When the selection is already visible in the current viewport (based on // the measured drawnRows rather than the unweighted visibleRows estimate), - // keep the viewport anchored instead of jumping to top. - if (trusted && drawn > 0) { + // keep selection-follow anchored instead of jumping to top. Explicit swipe + // scrolling clears followPending and must retain its new viewport. + if (n.followPending && trusted && drawn > 0) { const int sel = props.selectedIndex; if (sel >= prevTop && sel < prevTop + drawn) { n.top = prevTop; diff --git a/test/test_list_viewport.py b/test/test_list_viewport.py new file mode 100644 index 00000000000..a6c59db967d --- /dev/null +++ b/test/test_list_viewport.py @@ -0,0 +1,73 @@ +"""Run with python3 test/test_list_viewport.py (requires a host C++ compiler).""" + +from pathlib import Path +import subprocess +import tempfile + +ROOT = Path(__file__).resolve().parents[1] +# Compile the production method without the activity's hardware dependencies. +source = (ROOT / "src/activities/UiListActivity.cpp").read_text() +method = source.split("void UiListActivity::syncListViewport(", 1)[1] +method = "void UiListActivity::syncListViewport(" + method.split( + "\nvoid UiListActivity::drawChrome", 1 +)[0] +harness = r""" +#include +#include +namespace fui = freeink::ui; +struct UiScreen { + int16_t height = 80; + void syncListViewport(fui::ListNav& nav, fui::ListProps& props, int count, int offset) { + nav.syncToProps({0, 0, 200, height}, 20, 0, count, props, offset); + } +}; +struct UiListActivity { + fui::ListNav nav; + int count = 16; + fui::ListNav& activeNav() { return nav; } + int listCount() const { return count; } + void syncListViewport(UiScreen&, fui::ListProps&, int = 0); +}; +""" + method + r""" +int main() { + for (int offset : {0, 1}) { + UiListActivity activity; + UiScreen screen; + fui::ListProps props; + auto& nav = activity.nav; + nav.reset(offset); + activity.syncListViewport(screen, props, offset); + nav.onListRendered(0, 4, true); + nav.requestScroll(4); + activity.syncListViewport(screen, props, offset); + assert(nav.top == 4 && props.topIndex == 4 && nav.selected == offset); + nav.onListRendered(4, 4, false); + nav.selected = 5 + offset; + nav.requestScroll(-4); + activity.syncListViewport(screen, props, offset); + assert(nav.top == 0 && props.topIndex == 0 && nav.selected == 5 + offset); + + // Wrapped settings rows: button follow must preserve the measured viewport + // even when the fixed-height estimate says the entire list fits. + activity.count = 6; + screen.height = 160; + nav.onListRendered(2, 3, true); + nav.drawnCount = 6; + nav.requestSelection(3 + offset); + activity.syncListViewport(screen, props, offset); + assert(nav.top == 2 && props.topIndex == 2); + } +} +""" +with tempfile.TemporaryDirectory() as directory: + cpp = Path(directory) / "viewport.cpp" + executable = Path(directory) / "viewport" + cpp.write_text("#include \n" + harness) + subprocess.run([ + "c++", "-std=c++17", "-I", + str(ROOT / "freeink-sdk/libs/ui/FreeInkUI/include"), + str(ROOT / "freeink-sdk/libs/ui/FreeInkUI/src/FreeInkUI.cpp"), + str(cpp), "-o", str(executable), + ], check=True) + subprocess.run([str(executable)], check=True) +print("List viewport regression checks passed") From 0f01106ad6e9eacdd107a04b9128d15cd0ef02e6 Mon Sep 17 00:00:00 2001 From: Mauricio Juba Date: Wed, 23 Sep 2026 19:10:19 -0300 Subject: [PATCH 17/62] feat: add Portuguese hyphenation support (#3643) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Portuguese was the only major Romance language missing from the hyphenation registry. Books tagged `pt` / `pt-BR` resolve to `nullptr` in `getLanguageHyphenatorForPrimaryTag`, so they render with no hyphenation at all — very visible on a 3.7" X3 with the default justified alignment. Related: #709 (someone mentions working on a Portuguese dictionary there; this uses the trie already published upstream instead). Closes #3656 ## What this does - Adds `hyph-pt.trie.h`, generated with the existing `scripts/generate_hyphenation_trie.py` from `typst/hypher/tries/pt.bin` (1065 bytes payload — Portuguese syllabification is very regular, comparable to Italian at 1555 bytes). - Registers `portuguese` / `pt` in `LanguageRegistry.cpp` as a Latin-script hyphenator. - Adds `process pt` to `scripts/update_hyphenation.sh` so it stays in sync on future refreshes. - Adds an eval case with test data generated by the repo's own `generate_hyphenation_test_data.py`, using 5000 words from Machado de Assis' *Dom Casmurro* — following the existing convention of one canonical work per language (Quijote for Spanish, Manzoni for Italian). ## Evaluation ``` portuguese: F1=99.2143% (threshold 98.21%, 5000 cases) ``` In line with the other Romance languages already supported: | Language | F1 | |---|---| | French | 100% | | Italian | 99.9956% | | **Portuguese** | **99.2143%** | | Spanish | 99.0236% | All 9 tests in `HyphenationEvaluationTest` pass. ## Notes The same patterns serve both pt-BR and pt-PT — the two variants differ in orthography, not in syllabification rules — so the single `pt` primary-tag lookup is correct for both. 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-authored-by: type0labs-dev Co-authored-by: Claude Opus 5 (1M context) --- .../Epub/hyphenation/LanguageRegistry.cpp | 7 +- .../Epub/hyphenation/generated/hyph-pt.trie.h | 83 + scripts/update_hyphenation.sh | 1 + .../HyphenationEvaluationTest.cpp | 1 + .../portuguese_hyphenation_tests.txt | 5012 +++++++++++++++++ 5 files changed, 5102 insertions(+), 2 deletions(-) create mode 100644 lib/Epub/Epub/hyphenation/generated/hyph-pt.trie.h create mode 100644 test/hyphenation_eval/resources/portuguese_hyphenation_tests.txt diff --git a/lib/Epub/Epub/hyphenation/LanguageRegistry.cpp b/lib/Epub/Epub/hyphenation/LanguageRegistry.cpp index 2b0b1d706b3..5c41401a419 100644 --- a/lib/Epub/Epub/hyphenation/LanguageRegistry.cpp +++ b/lib/Epub/Epub/hyphenation/LanguageRegistry.cpp @@ -11,6 +11,7 @@ #include "generated/hyph-fr.trie.h" #include "generated/hyph-it.trie.h" #include "generated/hyph-pl.trie.h" +#include "generated/hyph-pt.trie.h" #include "generated/hyph-ru.trie.h" #include "generated/hyph-sv.trie.h" #include "generated/hyph-uk.trie.h" @@ -28,8 +29,9 @@ LanguageHyphenator swedishHyphenator(sv_patterns, isLatinLetter, toLowerLatin); LanguageHyphenator ukrainianHyphenator(uk_patterns, isCyrillicLetter, toLowerCyrillic); LanguageHyphenator polishHyphenator(pl_patterns, isLatinLetter, toLowerLatin); LanguageHyphenator finnishHyphenator(fi_patterns, isLatinLetter, toLowerLatin); +LanguageHyphenator portugueseHyphenator(pt_patterns, isLatinLetter, toLowerLatin); -using EntryArray = std::array; +using EntryArray = std::array; const EntryArray& entries() { static const EntryArray kEntries = {{{"english", "en", &englishHyphenator}, @@ -41,7 +43,8 @@ const EntryArray& entries() { {"polish", "pl", &polishHyphenator}, {"swedish", "sv", &swedishHyphenator}, {"ukrainian", "uk", &ukrainianHyphenator}, - {"finnish", "fi", &finnishHyphenator}}}; + {"finnish", "fi", &finnishHyphenator}, + {"portuguese", "pt", &portugueseHyphenator}}}; return kEntries; } diff --git a/lib/Epub/Epub/hyphenation/generated/hyph-pt.trie.h b/lib/Epub/Epub/hyphenation/generated/hyph-pt.trie.h new file mode 100644 index 00000000000..bf64d7facd3 --- /dev/null +++ b/lib/Epub/Epub/hyphenation/generated/hyph-pt.trie.h @@ -0,0 +1,83 @@ +#pragma once + +#include +#include + +#include "Epub/hyphenation/SerializedHyphenationTrie.h" + +// Auto-generated by generate_hyphenation_trie.py. Do not edit manually. +alignas(4) constexpr uint8_t pt_trie_data[] = { + 0x01, 0x0C, 0x01, 0x18, 0x0D, 0x16, 0x16, 0x0D, 0x21, 0x0E, 0x0B, 0x15, 0x01, 0x16, 0x0B, 0x0B, + 0x04, 0x01, 0x0C, 0x1F, 0x22, 0x0C, 0x0B, 0x1F, 0x0B, 0xA0, 0x00, 0x42, 0xA0, 0x00, 0x41, 0xA0, + 0x00, 0xF1, 0xA1, 0x00, 0x41, 0x69, 0xFD, 0x2A, 0xA1, 0xA2, 0xA3, 0xA9, 0xAD, 0xB3, 0xBA, 0xAA, + 0xB5, 0xB4, 0xF5, 0xF5, 0xF5, 0xF5, 0xF5, 0xF5, 0xF5, 0xF5, 0xF5, 0xF5, 0x28, 0x6C, 0x72, 0x61, + 0x65, 0x69, 0x6F, 0x75, 0xC3, 0xDD, 0xDD, 0xE0, 0xE0, 0xE0, 0xE0, 0xE6, 0xEB, 0xA0, 0x00, 0x91, + 0x21, 0x61, 0xFD, 0x21, 0x6D, 0xFD, 0xA1, 0x00, 0x41, 0x69, 0xFD, 0xA0, 0x00, 0x81, 0xA0, 0x00, + 0x51, 0x21, 0x61, 0xFD, 0x2B, 0x68, 0x6C, 0x72, 0x61, 0x65, 0x69, 0x6F, 0x75, 0xC3, 0x63, 0x7A, + 0xB5, 0xB5, 0xB5, 0xB8, 0xB8, 0xB8, 0xF2, 0xB8, 0xC3, 0xF7, 0xFD, 0x46, 0x61, 0x65, 0x69, 0x6F, + 0x75, 0xC3, 0xFF, 0xA1, 0xFF, 0xA1, 0xFF, 0xA1, 0xFF, 0xA1, 0xFF, 0xA1, 0xFF, 0xAC, 0x41, 0x6F, + 0xFF, 0x91, 0x43, 0x61, 0x6F, 0x65, 0xFF, 0x8D, 0xFF, 0x8D, 0xFF, 0x8D, 0x24, 0xA7, 0xA9, 0xAD, + 0xBA, 0xDF, 0xF2, 0xF6, 0xF2, 0x41, 0x6D, 0xFF, 0x7A, 0xA1, 0x00, 0x41, 0x69, 0xFC, 0x48, 0x6C, + 0x72, 0x61, 0x65, 0x69, 0x6F, 0x75, 0xC3, 0xFF, 0x6B, 0xFF, 0x6B, 0xFF, 0x6E, 0xFF, 0x6E, 0xFF, + 0x6E, 0xFF, 0xFB, 0xFF, 0x74, 0xFF, 0x79, 0xA0, 0x00, 0x62, 0xA0, 0x01, 0x02, 0x25, 0xAD, 0xA1, + 0xA3, 0xAA, 0xA9, 0xFD, 0xFD, 0xFD, 0xFD, 0xFD, 0xA5, 0x00, 0x41, 0x61, 0x65, 0x69, 0x6F, 0xC3, + 0xEF, 0xEF, 0xEF, 0xEF, 0xF5, 0x48, 0x6C, 0x72, 0x61, 0x65, 0x69, 0x6F, 0x75, 0xC3, 0xFF, 0x34, + 0xFF, 0x34, 0xFF, 0x37, 0xFF, 0x37, 0xFF, 0x37, 0xFF, 0x37, 0xFF, 0xF3, 0xFF, 0x42, 0x49, 0xA1, + 0xA2, 0xA3, 0xA9, 0xAD, 0xB3, 0xBA, 0xAA, 0xB5, 0xFF, 0x1E, 0xFF, 0x1E, 0xFF, 0x1E, 0xFF, 0x1E, + 0xFF, 0x1E, 0xFF, 0x1E, 0xFF, 0x1E, 0xFF, 0x1E, 0xFF, 0x1E, 0x46, 0x61, 0x65, 0x69, 0x6F, 0x75, + 0xC3, 0xFF, 0x02, 0xFF, 0x02, 0xFF, 0x02, 0xFF, 0x02, 0xFF, 0x08, 0xFF, 0xE4, 0x48, 0x6C, 0x72, + 0x61, 0x65, 0x69, 0x6F, 0x75, 0xC3, 0xFE, 0xEC, 0xFE, 0xEC, 0xFE, 0xEF, 0xFE, 0xEF, 0xFE, 0xEF, + 0xFE, 0xEF, 0xFE, 0xEF, 0xFF, 0xD1, 0x47, 0x68, 0x61, 0x65, 0x69, 0x6F, 0x75, 0xC3, 0xFE, 0xD3, + 0xFE, 0xD6, 0xFE, 0xD6, 0xFE, 0xD6, 0xFE, 0xD6, 0xFE, 0xD6, 0xFE, 0xE1, 0x47, 0x68, 0x61, 0x65, + 0x69, 0x6F, 0x75, 0xC3, 0xFE, 0xBD, 0xFE, 0xC0, 0xFE, 0xC0, 0xFE, 0xC0, 0xFE, 0xC0, 0xFE, 0xC6, + 0xFE, 0xCB, 0xA0, 0x01, 0xB2, 0x21, 0x62, 0xFD, 0x21, 0x69, 0xFD, 0xA1, 0x00, 0x42, 0x6F, 0xFD, + 0x41, 0x75, 0xFE, 0x99, 0x21, 0x65, 0xFC, 0xA0, 0x01, 0x53, 0x21, 0x64, 0xFD, 0x21, 0x75, 0xFD, + 0x21, 0x65, 0xFD, 0x4A, 0x6C, 0x72, 0x61, 0x65, 0x69, 0x6F, 0x75, 0xC3, 0x6E, 0x73, 0xFE, 0x86, + 0xFF, 0xE8, 0xFE, 0x89, 0xFE, 0x89, 0xFE, 0x89, 0xFE, 0x89, 0xFE, 0x89, 0xFE, 0x94, 0xFF, 0xF1, + 0xFF, 0xFD, 0x46, 0xA1, 0xA2, 0xAD, 0xA3, 0xAA, 0xA9, 0xFF, 0x18, 0xFF, 0x18, 0xFF, 0x18, 0xFF, + 0x18, 0xFF, 0x18, 0xFF, 0x18, 0xC5, 0x00, 0x41, 0x61, 0x65, 0x69, 0x6F, 0xC3, 0xFF, 0x02, 0xFF, + 0x02, 0xFF, 0x02, 0xFF, 0x02, 0xFF, 0xED, 0x21, 0x75, 0xEE, 0x47, 0x61, 0x65, 0x69, 0x6F, 0x75, + 0xC3, 0x72, 0xFE, 0x42, 0xFE, 0x42, 0xFE, 0x42, 0xFE, 0x42, 0xFE, 0x42, 0xFE, 0x4D, 0xFE, 0x81, + 0xA0, 0x00, 0xA2, 0x22, 0x72, 0x6C, 0xFD, 0xFD, 0xC2, 0x00, 0x41, 0x62, 0x69, 0xFF, 0xFB, 0xFE, + 0x27, 0x47, 0x61, 0x65, 0x69, 0x6F, 0x75, 0xC3, 0x73, 0xFE, 0x1B, 0xFE, 0x1B, 0xFE, 0x1B, 0xFE, + 0x1B, 0xFF, 0xF7, 0xFE, 0x26, 0xFE, 0x5A, 0xC2, 0x00, 0xF1, 0x74, 0x64, 0xFE, 0x36, 0xFE, 0x36, + 0xA1, 0x00, 0x41, 0x69, 0xF7, 0x49, 0x6C, 0x72, 0x61, 0x65, 0x69, 0x6F, 0x75, 0xC3, 0x63, 0xFD, + 0xF4, 0xFD, 0xF4, 0xFD, 0xF7, 0xFD, 0xF7, 0xFD, 0xF7, 0xFD, 0xF7, 0xFF, 0xFB, 0xFE, 0x02, 0xFE, + 0x39, 0x48, 0x6C, 0x72, 0x61, 0x65, 0x69, 0x6F, 0x75, 0xC3, 0xFD, 0xD8, 0xFD, 0xD8, 0xFD, 0xDB, + 0xFD, 0xDB, 0xFD, 0xDB, 0xFD, 0xDB, 0xFD, 0xDB, 0xFD, 0xE6, 0x42, 0x6C, 0x72, 0xFD, 0xBF, 0xFD, + 0xBF, 0xA0, 0x00, 0xE1, 0x21, 0x2E, 0xFD, 0xA0, 0x01, 0x22, 0x22, 0x64, 0x68, 0xF7, 0xFD, 0x23, + 0x72, 0x6E, 0x7A, 0xF5, 0xFB, 0xF5, 0x27, 0xA9, 0xAD, 0xB3, 0xBA, 0xA2, 0xA3, 0xB4, 0xEB, 0xEB, + 0xEB, 0xEB, 0xEB, 0xEB, 0xEB, 0xA0, 0x01, 0x41, 0x41, 0x69, 0xFD, 0x97, 0x47, 0x61, 0x65, 0x6F, + 0x69, 0xC3, 0x2E, 0x75, 0xFD, 0xCF, 0xFD, 0xCF, 0xFD, 0xCF, 0xFF, 0xE3, 0xFF, 0xEA, 0xFF, 0xF9, + 0xFF, 0xFC, 0x29, 0xA1, 0xA2, 0xA3, 0xA9, 0xAA, 0xAD, 0xB3, 0xBA, 0xB4, 0xBF, 0xBF, 0xBF, 0xBF, + 0xBF, 0xBF, 0xBF, 0xBF, 0xBF, 0x41, 0x70, 0xFF, 0xAC, 0x46, 0x63, 0x66, 0x67, 0x73, 0x74, 0x76, + 0xFF, 0xA8, 0xFF, 0xA8, 0xFF, 0xA8, 0xFF, 0xA8, 0xFF, 0xA8, 0xFF, 0xA8, 0x22, 0x6D, 0x6E, 0xE9, + 0xED, 0x46, 0x61, 0x65, 0x6F, 0xC3, 0x69, 0x2E, 0xFD, 0x8A, 0xFD, 0x8A, 0xFD, 0x8A, 0xFF, 0xD1, + 0xFF, 0xFB, 0xFF, 0xB4, 0x49, 0xA2, 0xAA, 0xB4, 0xA1, 0xA3, 0xA9, 0xAD, 0xB3, 0xBA, 0xFD, 0x77, + 0xFD, 0x77, 0xFD, 0x77, 0xFF, 0x7D, 0xFF, 0x7D, 0xFF, 0x7D, 0xFF, 0x7D, 0xFF, 0x7D, 0xFF, 0x7D, + 0x46, 0x61, 0x65, 0x69, 0x6F, 0xC3, 0x75, 0xFD, 0x5B, 0xFD, 0x5B, 0xFD, 0x5B, 0xFD, 0x5B, 0xFF, + 0xE4, 0xFF, 0x61, 0x46, 0xA1, 0xA9, 0xAD, 0xB3, 0xA3, 0xAA, 0xFF, 0x4E, 0xFF, 0x4E, 0xFF, 0x4E, + 0xFF, 0x4E, 0xFF, 0x4E, 0xFF, 0x4E, 0xA0, 0x01, 0x92, 0xA1, 0x00, 0xE1, 0x61, 0xFD, 0x42, 0x6E, + 0x6D, 0xFF, 0xFB, 0xFF, 0x33, 0x46, 0x61, 0x65, 0x6F, 0xC3, 0x2E, 0x69, 0xFD, 0x26, 0xFD, 0x26, + 0xFD, 0x26, 0xFF, 0xDE, 0xFF, 0x50, 0xFF, 0xF9, 0x41, 0xA7, 0xFF, 0x1F, 0x44, 0x72, 0x7A, 0x6E, + 0xC3, 0xFF, 0x18, 0xFF, 0x18, 0xFF, 0x15, 0xFF, 0xFC, 0x46, 0xA1, 0xA3, 0xA2, 0xAD, 0xAA, 0xA9, + 0xFF, 0x08, 0xFF, 0x08, 0xFF, 0x08, 0xFF, 0x08, 0xFF, 0x08, 0xFF, 0x08, 0x46, 0x61, 0x65, 0x6F, + 0x75, 0x69, 0xC3, 0xFC, 0xEF, 0xFC, 0xEF, 0xFC, 0xEF, 0xFC, 0xEF, 0xFF, 0xE0, 0xFF, 0xED, 0x41, + 0xAD, 0xFC, 0xCE, 0x42, 0x69, 0xC3, 0xFC, 0xCA, 0xFF, 0xFC, 0x42, 0x73, 0x74, 0xFF, 0xF9, 0xFC, + 0xC3, 0x42, 0xB3, 0xB4, 0xFC, 0xBC, 0xFC, 0xBC, 0x42, 0x6F, 0xC3, 0xFC, 0xB5, 0xFF, 0xF9, 0x21, + 0x6E, 0xF9, 0xC1, 0x00, 0x91, 0x6D, 0xFC, 0xAB, 0xA0, 0x00, 0xC2, 0x21, 0x69, 0xFD, 0x21, 0x6C, + 0xFD, 0x21, 0x62, 0xFD, 0xA1, 0x00, 0x91, 0x75, 0xFD, 0x41, 0x6E, 0xFC, 0x94, 0xA0, 0x01, 0x81, + 0x21, 0x6F, 0xFD, 0x21, 0x65, 0xFD, 0x26, 0x70, 0x67, 0x74, 0x73, 0x6D, 0x6E, 0xC4, 0xD9, 0xDC, + 0xEE, 0xF3, 0xFD, 0x5B, 0x62, 0x63, 0xC3, 0x64, 0x66, 0x67, 0x6A, 0x6B, 0x6C, 0x6D, 0x6E, 0x70, + 0x71, 0x72, 0x73, 0x74, 0x76, 0x77, 0x78, 0x7A, 0x61, 0x65, 0x69, 0x6F, 0x75, 0x2E, 0x2D, 0xFC, + 0x69, 0xFC, 0x91, 0xFC, 0xC9, 0xFC, 0xDB, 0xFC, 0x69, 0xFD, 0x12, 0xFD, 0x47, 0xFD, 0x5A, 0xFD, + 0x73, 0xFC, 0xA8, 0xFD, 0x89, 0xFD, 0xC0, 0xFE, 0x04, 0xFE, 0x07, 0xFE, 0x2E, 0xFE, 0x52, 0xFE, + 0x6E, 0xFE, 0x87, 0xFC, 0xA8, 0xFC, 0xA8, 0xFE, 0xB9, 0xFE, 0xFE, 0xFF, 0x2D, 0xFF, 0x62, 0xFF, + 0x99, 0xFF, 0xF3, 0xFC, 0x49, +}; + +constexpr SerializedHyphenationPatterns pt_patterns = { + 0x3D3u, + pt_trie_data, + sizeof(pt_trie_data), +}; diff --git a/scripts/update_hyphenation.sh b/scripts/update_hyphenation.sh index 0a52669f2da..065fe20c4fc 100755 --- a/scripts/update_hyphenation.sh +++ b/scripts/update_hyphenation.sh @@ -24,5 +24,6 @@ process ru process it process uk process pl +process pt process sv process fi diff --git a/test/hyphenation_eval/HyphenationEvaluationTest.cpp b/test/hyphenation_eval/HyphenationEvaluationTest.cpp index 45715ed87ef..80016fbf9a4 100644 --- a/test/hyphenation_eval/HyphenationEvaluationTest.cpp +++ b/test/hyphenation_eval/HyphenationEvaluationTest.cpp @@ -232,3 +232,4 @@ TEST(HyphenationEval, Spanish) { runLanguageEval("spanish", "es", "spanish_hyphe TEST(HyphenationEval, Italian) { runLanguageEval("italian", "it", "italian_hyphenation_tests.txt", 98.99); } TEST(HyphenationEval, Polish) { runLanguageEval("polish", "pl", "polish_hyphenation_tests.txt", 98.92); } TEST(HyphenationEval, Swedish) { runLanguageEval("swedish", "sv", "swedish_hyphenation_tests.txt", 94.01); } +TEST(HyphenationEval, Portuguese) { runLanguageEval("portuguese", "pt", "portuguese_hyphenation_tests.txt", 98.21); } diff --git a/test/hyphenation_eval/resources/portuguese_hyphenation_tests.txt b/test/hyphenation_eval/resources/portuguese_hyphenation_tests.txt new file mode 100644 index 00000000000..0bc8a4253fc --- /dev/null +++ b/test/hyphenation_eval/resources/portuguese_hyphenation_tests.txt @@ -0,0 +1,5012 @@ +# Hyphenation Test Data +# Source: machado_de_assis_dom_casmurro.epub +# Language: pt_BR +# Min prefix: 2 +# Min suffix: 2 +# Total words: 5000 +# Format: word | hyphenated_form | frequency_in_source +# +# Hyphenation points are marked with '=' +# Example: Silbentrennung -> Sil=ben=tren=nung +# + +Capitú|Ca=pi=tú|349 +tambem|tam=bem|150 +depois|de=pois|136 +quando|quan=do|114 +Escobar|Es=co=bar|112 +seminario|se=mi=na=rio|85 +Gutenberg|Gu=ten=berg|84 +Project|Pro=ject|84 +melhor|me=lhor|82 +estava|es=ta=va|81 +porque|por=que|81 +palavras|pa=la=vras|79 +Quando|Quan=do|76 +alguma|al=gu=ma|72 +outros|ou=tros|72 +verdade|ver=da=de|69 +cabeça|ca=be=ça|64 +commigo|com=mi=go|63 +grande|gran=de|61 +alguns|al=guns|60 +Bentinho|Ben=ti=nho|58 +Justina|Jus=ti=na|57 +Gloria|Glo=ria|54 +Ezequiel|Eze=qui=el|52 +outras|ou=tras|52 +primeiro|pri=mei=ro|50 +palavra|pa=la=vra|49 +coração|co=ra=ção|45 +sempre|sem=pre|45 +capitulo|ca=pi=tu=lo|44 +depressa|de=pres=sa|44 +pessoa|pes=soa|41 +primeira|pri=mei=ra|41 +queria|que=ria|41 +senhor|se=nhor|40 +Sancha|San=cha|39 +Tambem|Tam=bem|39 +perguntou|per=gun=tou|38 +senhora|se=nho=ra|38 +algumas|al=gu=mas|35 +dentro|den=tro|35 +pessoas|pes=so=as|35 +preciso|pre=ci=so|35 +aquella|aquel=la|33 +entrar|en=trar|32 +naturalmente|na=tu=ral=men=te|32 +aggregado|ag=gre=ga=do|31 +Cabral|Ca=bral|31 +lembra|lem=bra|31 +minutos|mi=nu=tos|30 +mulher|mu=lher|30 +minhas|mi=nhas|29 +promessa|pro=mes=sa|29 +vocação|vo=ca=ção|29 +costume|cos=tu=me|28 +falava|fa=la=va|28 +ninguem|nin=guem|28 +simples|sim=ples|28 +acabou|aca=bou|27 +electronic|elec=tro=nic|27 +livros|li=vros|27 +vontade|von=ta=de|27 +cousas|cou=sas|26 +trazia|tra=zia|26 +apenas|ape=nas|25 +contrario|con=tra=rio|25 +dizendo|di=zen=do|25 +janella|ja=nel=la|25 +parece|pa=re=ce|25 +respondeu|res=pon=deu|25 +contar|con=tar|24 +Foundation|Foun=da=ti=on|24 +memoria|me=mo=ria|24 +nossas|nos=sas|24 +protonotario|pro=to=no=ta=rio|24 +qualquer|qual=quer|24 +egreja|egre=ja|23 +Matacavallos|Ma=ta=ca=val=los|23 +continuou|con=ti=nuou|22 +Europa|Eu=ro=pa|22 +fiquei|fi=quei|22 +grandes|gran=des|22 +nenhuma|ne=nhu=ma|22 +particular|par=ti=cu=lar|22 +principio|prin=ci=pio|22 +braços|bra=ços|21 +dellas|del=las|21 +familia|fa=mi=lia|21 +leitor|lei=tor|21 +Manduca|Man=du=ca|21 +pequeno|pe=que=no|21 +retrato|re=tra=to|21 +vespera|ves=pe=ra|21 +amigos|ami=gos|20 +cabellos|ca=bel=los|20 +corredor|cor=re=dor|20 +delles|del=les|20 +discurso|dis=cur=so|20 +instantes|ins=tan=tes|20 +nossos|nos=sos|20 +pernas|per=nas|20 +quarto|quar=to|20 +seguinte|se=guin=te|20 +sentido|sen=ti=do|20 +acabar|aca=bar|19 +achava|acha=va|19 +aquelle|aquel=le|19 +copyright|copy=right|19 +daquella|da=quel=la|19 +effeito|ef=fei=to|19 +entrou|en=trou|19 +escrever|es=cre=ver|19 +gestos|ges=tos|19 +lagrimas|la=gri=mas|19 +natural|na=tu=ral|19 +occasião|oc=ca=si=ão|19 +parecia|pa=re=cia|19 +peccado|pec=ca=do|19 +possivel|pos=si=vel|19 +segundo|se=gun=do|19 +alguem|al=guem|18 +amanhã|ama=nhã|18 +defuncto|de=func=to|18 +desejo|de=se=jo|18 +felicidade|fe=li=ci=da=de|18 +ideias|idei=as|18 +instante|ins=tan=te|18 +jantar|jan=tar|18 +marido|ma=ri=do|18 +nenhum|ne=nhum|18 +opinião|opi=ni=ão|18 +proprio|pro=prio|18 +pudesse|pu=des=se|18 +quintal|quin=tal|18 +ressaca|res=sa=ca|18 +acabava|aca=ba=va|17 +agreement|agree=ment|17 +apesar|ape=sar|17 +contava|con=ta=va|17 +daquelle|da=quel=le|17 +Depois|De=pois|17 +domingo|do=min=go|17 +historia|his=to=ria|17 +imperador|im=pe=ra=dor|17 +inteiramente|in=tei=ra=men=te|17 +muitos|mui=tos|17 +pensei|pen=sei|17 +silencio|si=len=cio|17 +Talvez|Tal=vez|17 +titulo|ti=tu=lo|17 +ultimo|ul=ti=mo|17 +versos|ver=sos|17 +bonito|bo=ni=to|16 +caminho|ca=mi=nho|16 +deixou|dei=xou|16 +embora|em=bo=ra|16 +lembrou|lem=brou|16 +lingua|lin=gua|16 +quatro|qua=tro|16 +segunda|se=gun=da|16 +sentia|sen=tia|16 +talvez|tal=vez|16 +theatro|the=a=tro|16 +tivesse|ti=ves=se|16 +visitas|vi=si=tas|16 +amizade|ami=za=de|15 +antiga|an=ti=ga|15 +bonita|bo=ni=ta|15 +concluiu|con=cluiu|15 +enterro|en=ter=ro|15 +entrei|en=trei|15 +ficava|fi=ca=va|15 +muitas|mui=tas|15 +novamente|no=va=men=te|15 +numero|nu=me=ro|15 +Panegyrico|Pa=negy=ri=co|15 +passado|pas=sa=do|15 +passar|pas=sar|15 +passou|pas=sou|15 +Quanto|Quan=to|15 +States|Sta=tes|15 +United|Uni=ted|15 +alegria|ale=gria|14 +comnosco|com=nos=co|14 +costas|cos=tas|14 +deante|de=an=te|14 +deixei|dei=xei|14 +falando|fa=lan=do|14 +gostava|gos=ta=va|14 +licção|lic=ção|14 +materia|ma=te=ria|14 +morrer|mor=rer|14 +necessidade|ne=ces=si=da=de|14 +noticia|no=ti=cia|14 +parede|pa=re=de|14 +perguntei|per=gun=tei|14 +propria|pro=pria|14 +quizesse|qui=zes=se|14 +realmente|re=al=men=te|14 +seminarista|se=mi=na=ris=ta|14 +separação|se=pa=ra=ção|14 +sonhos|so=nhos|14 +sorriu|sor=riu|14 +ultima|ul=ti=ma|14 +velhas|ve=lhas|14 +voltando|vol=tan=do|14 +andando|an=dan=do|13 +Archive|Ar=chi=ve|13 +cemiterio|ce=mi=te=rio|13 +chegou|che=gou|13 +cheguei|che=guei|13 +ciumes|ciu=mes|13 +creança|cre=an=ça|13 +emquanto|em=quan=to|13 +especie|es=pe=cie|13 +estavam|es=ta=vam|13 +exemplo|exem=plo|13 +fizesse|fi=zes=se|13 +lembrança|lem=bran=ça|13 +Literary|Li=te=rary|13 +maneira|ma=nei=ra|13 +naquella|na=quel=la|13 +pensar|pen=sar|13 +pequena|pe=que=na|13 +perder|per=der|13 +perguntar|per=gun=tar|13 +podiam|po=di=am|13 +prazer|pra=zer|13 +primeiros|pri=mei=ros|13 +quinze|quin=ze|13 +quizer|qui=zer|13 +reflexão|re=fle=xão|13 +segredo|se=gre=do|13 +soneto|so=ne=to|13 +terceiro|ter=cei=ro|13 +trademark|tra=de=mark|13 +viagem|vi=a=gem|13 +afinal|afi=nal|12 +andava|an=da=va|12 +apostolico|apos=to=li=co|12 +bellas|bel=las|12 +cavallo|ca=val=lo|12 +chamava|cha=ma=va|12 +cidade|ci=da=de|12 +contei|con=tei|12 +contra|con=tra|12 +deixar|dei=xar|12 +devéras|de=vé=ras|12 +donations|do=na=ti=ons|12 +estivesse|es=ti=ves=se|12 +Fortunata|For=tu=na=ta|12 +Gurgel|Gur=gel|12 +homens|ho=mens|12 +juntos|jun=tos|12 +medicina|me=di=ci=na|12 +mesmos|mes=mos|12 +morreu|mor=reu|12 +musica|mu=si=ca|12 +naquelle|na=quel=le|12 +olhando|olhan=do|12 +pareceu|pa=re=ceu|12 +poucos|pou=cos|12 +principal|prin=ci=pal|12 +proposito|pro=po=si=to|12 +provavelmente|pro=va=vel=men=te|12 +resposta|res=pos=ta|12 +saudades|sau=da=des|12 +ternura|ter=nu=ra|12 +tinham|ti=nham|12 +vinham|vi=nham|12 +visita|vi=si=ta|12 +acudiu|acu=diu|11 +buscar|bus=car|11 +carreira|car=rei=ra|11 +cartas|car=tas|11 +comsigo|com=si=go|11 +confessar|con=fes=sar|11 +Entretanto|En=tre=tan=to|11 +esperar|es=pe=rar|11 +esquecer|es=que=cer|11 +esquerda|es=quer=da|11 +fizeram|fi=ze=ram|11 +houvesse|hou=ves=se|11 +infancia|in=fan=cia|11 +intenção|in=ten=ção|11 +interesse|in=te=res=se|11 +lettras|let=tras|11 +levava|le=va=va|11 +menino|me=ni=no|11 +negocio|ne=go=cio|11 +noites|noi=tes|11 +penteado|pen=te=a=do|11 +phrase|ph=ra=se|11 +rapazes|ra=pa=zes|11 +Realmente|Re=al=men=te|11 +repeti|re=pe=ti|11 +replicou|re=pli=cou|11 +sabbado|sab=ba=do|11 +sensação|sen=sa=ção|11 +visinho|vi=si=nho|11 +voltar|vol=tar|11 +access|ac=cess|10 +assumpto|as=sump=to|10 +canapé|ca=na=pé|10 +chicara|chi=ca=ra|10 +commum|com=mum|10 +confessou|con=fes=sou|10 +conhecia|co=nhe=cia|10 +costumes|cos=tu=mes|10 +creatura|cre=a=tu=ra|10 +denuncia|de=nun=cia|10 +dinheiro|di=nhei=ro|10 +doente|do=en=te|10 +doutor|dou=tor|10 +ecclesiastica|ec=cle=si=as=ti=ca|10 +figura|fi=gu=ra|10 +Fiquei|Fi=quei|10 +fizera|fi=ze=ra|10 +Flamengo|Fla=men=go|10 +gabinete|ga=bi=ne=te|10 +gutenberg|gu=ten=berg|10 +imitar|imi=tar|10 +libras|li=bras|10 +License|Li=cen=se|10 +mostrar|mos=trar|10 +natureza|na=tu=re=za|10 +officio|of=fi=cio|10 +papeis|pa=peis|10 +paragraph|pa=ra=graph|10 +pensamento|pen=sa=men=to|10 +poderia|po=de=ria|10 +respondi|res=pon=di|10 +sacristão|sa=cris=tão|10 +Senhor|Se=nhor|10 +signal|sig=nal|10 +tornou|tor=nou|10 +trabalho|tra=ba=lho|10 +triste|tris=te|10 +vaidade|vai=da=de|10 +varanda|va=ran=da|10 +velhos|ve=lhos|10 +vieram|vi=e=ram|10 +visinhança|vi=si=nhan=ça|10 +antigo|an=ti=go|9 +attenção|at=ten=ção|9 +Casmurro|Cas=mur=ro|9 +chamar|cha=mar|9 +collegio|col=le=gio|9 +começou|co=me=çou|9 +companhia|com=pa=nhia|9 +contou|con=tou|9 +correr|cor=rer|9 +Demais|De=mais|9 +descer|des=cer|9 +devagar|de=va=gar|9 +difficuldade|dif=fi=cul=da=de|9 +dissesse|dis=ses=se|9 +distancia|dis=tan=cia|9 +dormir|dor=mir|9 +emoção|emo=ção|9 +Engenho|En=ge=nho|9 +entrada|en=tra=da|9 +esperanças|es=pe=ran=ças|9 +espirito|es=pi=ri=to|9 +estudos|es=tu=dos|9 +eterna|eter=na|9 +fazendo|fa=zen=do|9 +fossem|fos=sem|9 +futuro|fu=tu=ro|9 +janellas|ja=nel=las|9 +justamente|jus=ta=men=te|9 +lembrar|lem=brar|9 +maneiras|ma=nei=ras|9 +mesmas|mes=mas|9 +mocidade|mo=ci=da=de|9 +momento|mo=men=to|9 +noticias|no=ti=ci=as|9 +omnibus|om=ni=bus|9 +Parece|Pa=re=ce|9 +pareciam|pa=re=ci=am|9 +peguei|pe=guei|9 +perdão|per=dão|9 +pergunta|per=gun=ta|9 +perguntava|per=gun=ta=va|9 +primeiras|pri=mei=ras|9 +provavel|pro=va=vel|9 +quanto|quan=to|9 +querendo|que=ren=do|9 +receio|re=ceio|9 +relações|re=la=ções|9 +sangue|san=gue|9 +situação|si=tu=a=ção|9 +sómente|só=men=te|9 +tornei|tor=nei|9 +ultimos|ul=ti=mos|9 +vestido|ves=ti=do|9 +virtude|vir=tu=de|9 +administrador|ad=mi=nis=tra=dor|8 +anterior|an=te=ri=or|8 +antigas|an=ti=gas|8 +calado|ca=la=do|8 +casada|ca=sa=da|8 +chacara|cha=ca=ra|8 +Cheguei|Che=guei|8 +consentiu|con=sen=tiu|8 +creanças|cre=an=ças|8 +cumprir|cum=prir|8 +dessas|des=sas|8 +differença|dif=fe=ren=ça|8 +direita|di=rei=ta|8 +doença|do=en=ça|8 +entender|en=ten=der|8 +escripto|es=crip=to|8 +esperando|es=pe=ran=do|8 +esperança|es=pe=ran=ça|8 +Estava|Es=ta=va|8 +explicar|ex=pli=car|8 +explicou|ex=pli=cou|8 +ficámos|fi=cá=mos|8 +finalmente|fi=nal=men=te|8 +havendo|ha=ven=do|8 +illusão|il=lu=são|8 +imagina|ima=gi=na|8 +imaginação|ima=gi=na=ção|8 +including|in=clu=ding|8 +interior|in=te=ri=or|8 +juramento|ju=ra=men=to|8 +justiça|jus=ti=ça|8 +labios|la=bi=os|8 +marias|ma=ri=as|8 +medico|me=di=co|8 +metter|met=ter|8 +Monica|Mo=ni=ca|8 +mulheres|mu=lhe=res|8 +Naturalmente|Na=tu=ral=men=te|8 +olhava|olha=va|8 +ouvidos|ou=vi=dos|8 +ouvira|ou=vi=ra|8 +paginas|pa=gi=nas|8 +passava|pas=sa=va|8 +pensando|pen=san=do|8 +peralta|pe=ral=ta|8 +photographia|pho=to=grap=hia|8 +Porque|Por=que|8 +prompto|promp=to|8 +prégão|pré=gão|8 +querer|que=rer|8 +queriam|que=ri=am|8 +refund|re=fund|8 +Santiago|San=ti=a=go|8 +santos|san=tos|8 +sapatos|sa=pa=tos|8 +semana|se=ma=na|8 +sensações|sen=sa=ções|8 +sentir|sen=tir|8 +seriam|se=ri=am|8 +servir|ser=vir|8 +suspirou|sus=pi=rou|8 +tardes|tar=des|8 +theologia|the=o=lo=gia|8 +tocava|to=ca=va|8 +virtudes|vir=tu=des|8 +voltou|vol=tou|8 +without|wi=thout|8 +accrescentou|ac=cres=cen=tou|7 +adeante|ade=an=te|7 +alegre|ale=gre|7 +algarismos|al=ga=ris=mos|7 +alheias|alhei=as|7 +almoço|al=mo=ço|7 +alvoroço|al=vo=ro=ço|7 +apertar|aper=tar|7 +apprender|ap=pren=der|7 +associated|as=so=ci=a=ted|7 +audiencia|au=di=en=cia|7 +batalha|ba=ta=lha|7 +belleza|bel=le=za|7 +benção|ben=ção|7 +brincando|brin=can=do|7 +certamente|cer=ta=men=te|7 +claros|cla=ros|7 +comecei|co=me=cei|7 +comparação|com=pa=ra=ção|7 +conclusão|con=clu=são|7 +confesso|con=fes=so|7 +consciencia|cons=ci=en=cia|7 +conselho|con=se=lho|7 +copies|co=pi=es|7 +cuidados|cui=da=dos|7 +curiosidades|cu=ri=o=si=da=des|7 +debaixo|de=bai=xo|7 +defesa|de=fe=sa|7 +deixava|dei=xa=va|7 +desespero|de=ses=pe=ro|7 +despedir|des=pe=dir|7 +destino|des=ti=no|7 +distributing|dis=tri=bu=ting|7 +divida|di=vi=da|7 +durante|du=ran=te|7 +duvidas|du=vi=das|7 +eBooks|eBo=oks|7 +embargos|em=bar=gos|7 +eramos|era=mos|7 +ergueu|er=gueu|7 +Escola|Es=co=la|7 +Escriptura|Es=crip=tu=ra|7 +espaço|es=pa=ço|7 +espera|es=pe=ra|7 +esperava|es=pe=ra=va|7 +estudar|es=tu=dar|7 +explica|ex=pli=ca|7 +explicado|ex=pli=ca=do|7 +faziam|fa=zi=am|7 +francamente|fran=ca=men=te|7 +Homero|Ho=me=ro|7 +impressão|im=pres=são|7 +impulso|im=pul=so|7 +infinito|in=fi=ni=to|7 +intimidade|in=ti=mi=da=de|7 +Invalidos|In=va=li=dos|7 +levasse|le=vas=se|7 +located|lo=ca=ted|7 +loteria|lo=te=ria|7 +maiores|mai=o=res|7 +manhãs|ma=nhãs|7 +menina|me=ni=na|7 +mentir|men=tir|7 +mentira|men=ti=ra|7 +mestre|mes=tre|7 +metade|me=ta=de|7 +narração|nar=ra=ção|7 +opusculo|opus=cu=lo|7 +Othello|Othel=lo|7 +ouvido|ou=vi=do|7 +parecendo|pa=re=cen=do|7 +parecer|pa=re=cer|7 +paredes|pa=re=des|7 +Passeio|Pas=seio|7 +pedaço|pe=da=ço|7 +pequenos|pe=que=nos|7 +poderá|po=de=rá|7 +polemica|po=le=mi=ca|7 +politica|po=li=ti=ca|7 +pontas|pon=tas|7 +precisa|pre=ci=sa|7 +pretexto|pre=tex=to|7 +professor|pro=fes=sor|7 +promessas|pro=mes=sas|7 +propriamente|pro=pri=a=men=te|7 +provide|pro=vi=de|7 +qualidades|qua=li=da=des|7 +reflexões|re=fle=xões|7 +remedio|re=me=dio|7 +resolução|re=so=lu=ção|7 +responder|res=pon=der|7 +sabbados|sab=ba=dos|7 +Section|Sec=ti=on|7 +seminaristas|se=mi=na=ris=tas|7 +signaes|sig=na=es|7 +tranças|tran=ças|7 +vencer|ven=cer|7 +viesse|vi=es=se|7 +vigario|vi=ga=rio|7 +acabei|aca=bei|6 +accender|ac=cen=der|6 +acudiram|acu=di=ram|6 +admiração|ad=mi=ra=ção|6 +advogado|ad=vo=ga=do|6 +afflicção|af=flic=ção|6 +Afinal|Afi=nal|6 +alugueis|alu=gueis|6 +Andarahy|An=da=ra=hy|6 +andorinhas|an=do=ri=nhas|6 +apertou|aper=tou|6 +aquillo|aquil=lo|6 +arithmeticas|ari=thme=ti=cas|6 +aspecto|as=pec=to|6 +atalhou|ata=lhou|6 +ausencia|au=sen=cia|6 +barbeiro|bar=bei=ro|6 +bastava|bas=ta=va|6 +beijar|bei=jar|6 +bilhete|bi=lhe=te|6 +buscava|bus=ca=va|6 +cadeira|ca=dei=ra|6 +calças|cal=ças|6 +casamento|ca=sa=men=to|6 +charge|char=ge|6 +chegar|che=gar|6 +chorando|cho=ran=do|6 +cocheiro|co=chei=ro|6 +comply|com=ply|6 +conhecido|co=nhe=ci=do|6 +consola|con=so=la|6 +Contei|Con=tei|6 +continuava|con=ti=nu=a=va|6 +conversa|con=ver=sa|6 +conversação|con=ver=sa=ção|6 +daquellas|da=quel=las|6 +daquelles|da=quel=les|6 +definição|de=fi=ni=ção|6 +deitar|dei=tar|6 +deixasse|dei=xas=se|6 +demais|de=mais|6 +despedida|des=pe=di=da|6 +direito|di=rei=to|6 +dissimulação|dis=si=mu=la=ção|6 +distincção|dis=tinc=ção|6 +distribution|dis=tri=bu=ti=on|6 +diziam|di=zi=am|6 +effectivamente|ef=fec=ti=va=men=te|6 +enfiei|en=fi=ei|6 +enxugou|en=xu=gou|6 +escada|es=ca=da|6 +escrevendo|es=cre=ven=do|6 +escrupulo|es=cru=pu=lo|6 +escura|es=cu=ra|6 +espantado|es=pan=ta=do|6 +especial|es=pe=ci=al|6 +esposa|es=po=sa|6 +esqueceu|es=que=ceu|6 +esqueça|es=que=ça|6 +estado|es=ta=do|6 +estivera|es=ti=ve=ra|6 +exposição|ex=po=si=ção|6 +falado|fa=la=do|6 +falavam|fa=la=vam|6 +ficaram|fi=ca=ram|6 +férias|fé=rias|6 +Grande|Gran=de|6 +graças|gra=ças|6 +GUTENBERG|GU=TEN=BERG|6 +imagem|ima=gem|6 +impossivel|im=pos=si=vel|6 +inteira|in=tei=ra|6 +inveja|in=ve=ja|6 +Janeiro|Ja=nei=ro|6 +jornaes|jor=na=es|6 +Justamente|Jus=ta=men=te|6 +leitora|lei=to=ra|6 +lembrava|lem=bra=va|6 +license|li=cen=se|6 +licença|li=cen=ça|6 +mandar|man=dar|6 +mettia|met=tia|6 +Morreu|Mor=reu|6 +mostrou|mos=trou|6 +motivo|mo=ti=vo|6 +motivos|mo=ti=vos|6 +namoro|na=mo=ro|6 +obrigação|obri=ga=ção|6 +padres|pa=dres|6 +paixão|pai=xão|6 +pallio|pal=lio|6 +parecido|pa=re=ci=do|6 +partes|par=tes|6 +particularmente|par=ti=cu=lar=men=te|6 +passagem|pas=sa=gem|6 +passos|pas=sos|6 +Peguei|Pe=guei|6 +perdeu|per=deu|6 +permission|per=mis=si=on|6 +principalmente|prin=ci=pal=men=te|6 +PROJECT|PRO=JECT|6 +projecto|pro=jec=to|6 +propoz|pro=poz|6 +proprios|pro=pri=os|6 +Publico|Pu=bli=co|6 +quarenta|qua=ren=ta|6 +quatorze|qua=tor=ze|6 +questão|ques=tão|6 +quizera|qui=ze=ra|6 +receber|re=ce=ber|6 +recusa|re=cu=sa|6 +relogio|re=lo=gio|6 +repentina|re=pen=ti=na|6 +repetia|re=pe=tia|6 +repetiu|re=pe=tiu|6 +repetição|re=pe=ti=ção|6 +sabendo|sa=ben=do|6 +saindo|sain=do|6 +Sanchinha|San=chi=nha|6 +Santissimo|San=tis=si=mo|6 +Satanaz|Sa=ta=naz|6 +secreta|se=cre=ta|6 +seculos|se=cu=los|6 +semanas|se=ma=nas|6 +sentimento|sen=ti=men=to|6 +sepultura|se=pul=tu=ra|6 +sombra|som=bra|6 +suspeita|sus=pei=ta|6 +tinhamos|ti=nha=mos|6 +trazer|tra=zer|6 +trecho|tre=cho|6 +trouxe|trou=xe|6 +visinha|vi=si=nha|6 +voltei|vol=tei|6 +abertos|aber=tos|5 +aborrecido|abor=re=ci=do|5 +absolutamente|ab=so=lu=ta=men=te|5 +acabasse|aca=bas=se|5 +achasse|achas=se|5 +Adiemos|Adi=e=mos|5 +administração|ad=mi=nis=tra=ção|5 +adolescencia|ado=les=cen=cia|5 +affeição|af=fei=ção|5 +alcova|al=co=va|5 +alliados|al=li=a=dos|5 +amarissimo|ama=ris=si=mo|5 +ameaça|ame=a=ça|5 +amigas|ami=gas|5 +amores|amo=res|5 +antigos|an=ti=gos|5 +anyone|anyo=ne|5 +apezar|ape=zar|5 +apprendeu|ap=pren=deu|5 +aquelles|aquel=les|5 +bastante|bas=tan=te|5 +bastou|bas=tou|5 +beiços|bei=ços|5 +beneficio|be=ne=fi=cio|5 +cadaver|ca=da=ver|5 +cancella|can=cel=la|5 +candida|can=di=da|5 +cantando|can=tan=do|5 +cantos|can=tos|5 +caridade|ca=ri=da=de|5 +CASMURRO|CAS=MUR=RO|5 +Cattete|Cat=te=te|5 +cerebro|ce=re=bro|5 +cerimonia|ce=ri=mo=nia|5 +chamou|cha=mou|5 +cocadas|co=ca=das|5 +composto|com=pos=to|5 +compoz|com=poz|5 +concordou|con=cor=dou|5 +confusão|con=fu=são|5 +conhecer|co=nhe=cer|5 +contando|con=tan=do|5 +contas|con=tas|5 +Contava|Con=ta=va|5 +continuei|con=ti=nu=ei|5 +contracto|con=trac=to|5 +convicção|con=vic=ção|5 +correu|cor=reu|5 +corria|cor=ria|5 +creaturas|cre=a=tu=ras|5 +crescer|cres=cer|5 +cuidar|cui=dar|5 +cumpria|cum=pria|5 +debuxo|de=bu=xo|5 +demora|de=mo=ra|5 +dentes|den=tes|5 +desappareceu|de=sap=pa=re=ceu|5 +descobrir|des=co=brir|5 +despezas|des=pe=zas|5 +destas|des=tas|5 +destes|des=tes|5 +dezesete|de=ze=se=te|5 +differente|dif=fe=ren=te|5 +dissera|dis=se=ra|5 +disseram|dis=se=ram|5 +dissimulada|dis=si=mu=la=da|5 +distribute|dis=tri=bu=te|5 +divina|di=vi=na|5 +divino|di=vi=no|5 +donate|do=na=te|5 +doutrina|dou=tri=na|5 +duvida|du=vi=da|5 +egualmente|egual=men=te|5 +elogios|elo=gi=os|5 +embaixo|em=bai=xo|5 +escola|es=co=la|5 +escolher|es=co=lher|5 +esperei|es=pe=rei|5 +esperou|es=pe=rou|5 +esquece|es=que=ce|5 +estaria|es=ta=ria|5 +esterlinas|es=ter=li=nas|5 +estima|es=ti=ma|5 +exacto|exac=to|5 +exclamação|ex=cla=ma=ção|5 +exclamou|ex=cla=mou|5 +existencia|exis=ten=cia|5 +explicação|ex=pli=ca=ção|5 +expliquei|ex=pli=quei|5 +expressão|ex=pres=são|5 +falaria|fa=la=ria|5 +febres|fe=bres|5 +fechar|fe=char|5 +fechava|fe=cha=va|5 +feições|fei=ções|5 +felizes|fe=li=zes|5 +festas|fes=tas|5 +folhas|fo=lhas|5 +forças|for=ças|5 +gloria|glo=ria|5 +gostou|gos=tou|5 +guardar|guar=dar|5 +guerra|guer=ra|5 +haveria|ha=ve=ria|5 +hontem|hon=tem|5 +hypothese|hy=po=the=se|5 +impede|im=pe=de|5 +impeto|im=pe=to|5 +importa|im=por=ta|5 +indagar|in=da=gar|5 +inferno|in=fer=no|5 +Information|In=for=ma=ti=on|5 +inscripção|ins=crip=ção|5 +interrompeu|in=ter=rom=peu|5 +invejas|in=ve=jas|5 +italiano|ita=li=a=no|5 +joelhos|jo=e=lhos|5 +juntas|jun=tas|5 +lettra|let=tra|5 +levantou|le=van=tou|5 +licções|lic=ções|5 +louvores|lou=vo=res|5 +medium|me=dium|5 +melancolia|me=lan=co=lia|5 +memorias|me=mo=ri=as|5 +merece|me=re=ce|5 +minuto|mi=nu=to|5 +mirando|mi=ran=do|5 +modestia|mo=des=tia|5 +mortos|mor=tos|5 +mudança|mu=dan=ça|5 +murmurou|mur=mu=rou|5 +mysterio|mys=te=rio|5 +mysterios|mys=te=ri=os|5 +Naquelle|Na=quel=le|5 +nascido|nas=ci=do|5 +negocios|ne=go=ci=os|5 +obliqua|obli=qua|5 +obsequio|ob=se=quio|5 +obstante|obs=tan=te|5 +online|on=li=ne|5 +orações|ora=ções|5 +ouvindo|ou=vin=do|5 +parentes|pa=ren=tes|5 +passando|pas=san=do|5 +passaros|pas=sa=ros|5 +passavam|pas=sa=vam|5 +pensava|pen=sa=va|5 +perguntas|per=gun=tas|5 +pintura|pin=tu=ra|5 +podendo|po=den=do|5 +portas|por=tas|5 +portão|por=tão|5 +possuia|pos=suia|5 +preparação|pre=pa=ra=ção|5 +presas|pre=sas|5 +presença|pre=sen=ça|5 +presilha|pre=si=lha|5 +projectos|pro=jec=tos|5 +protected|pro=tec=ted|5 +proximo|pro=xi=mo|5 +publico|pu=bli=co|5 +recebeu|re=ce=beu|5 +recebi|re=ce=bi|5 +received|re=cei=ved|5 +recente|re=cen=te|5 +recordar|re=cor=dar|5 +recordações|re=cor=da=ções|5 +referi|re=fe=ri|5 +reflectindo|re=flec=tin=do|5 +religioso|re=li=gi=o=so|5 +reminiscencias|re=mi=nis=cen=ci=as|5 +repartição|re=par=ti=ção|5 +repente|re=pen=te|5 +russos|rus=sos|5 +sairam|sai=ram|5 +saisse|sais=se|5 +Sempre|Sem=pre|5 +sequer|se=quer|5 +solução|so=lu=ção|5 +sorrindo|sor=rin=do|5 +superlativo|su=per=la=ti=vo|5 +sympathia|sym=pa=thia|5 +sósinho|só=si=nho|5 +tamanho|ta=ma=nho|5 +tempos|tem=pos|5 +terceira|ter=cei=ra|5 +termos|ter=mos|5 +textos|tex=tos|5 +theoria|the=o=ria|5 +tiveram|ti=ve=ram|5 +tornava|tor=na=va|5 +tristeza|tris=te=za|5 +trombeta|trom=be=ta|5 +ventura|ven=tu=ra|5 +verdadeira|ver=da=dei=ra|5 +vertigem|ver=ti=gem|5 +voltas|vol=tas|5 +abaixo|abai=xo|4 +aberto|aber=to|4 +aborrecer|abor=re=cer|4 +acceito|ac=cei=to|4 +accendeu|ac=cen=deu|4 +accordo|ac=cor=do|4 +achado|acha=do|4 +acompanhar|acom=pa=nhar|4 +acções|ac=ções|4 +adoravel|ado=ra=vel|4 +agradeceu|agra=de=ceu|4 +Alguns|Al=guns|4 +altura|al=tu=ra|4 +alvitre|al=vi=tre|4 +andavam|an=da=vam|4 +animou|ani=mou|4 +apagou|apa=gou|4 +apanhar|apa=nhar|4 +apertando|aper=tan=do|4 +appareceu|ap=pa=re=ceu|4 +apparecia|ap=pa=re=cia|4 +astronomia|as=tro=no=mia|4 +autoridade|au=to=ri=da=de|4 +bacharel|ba=cha=rel|4 +bairro|bair=ro|4 +bastaria|bas=ta=ria|4 +beijava|bei=ja=va|4 +cabello|ca=bel=lo|4 +cannot|can=not|4 +cantava|can=ta=va|4 +casado|ca=sa=do|4 +cavalleiro|ca=val=lei=ro|4 +certeza|cer=te=za|4 +Chamava|Cha=ma=va|4 +chapéo|cha=péo|4 +chaves|cha=ves|4 +chegando|che=gan=do|4 +chegava|che=ga=va|4 +chorou|cho=rou|4 +circumstancias|cir=cums=tan=ci=as|4 +Ciumes|Ciu=mes|4 +collection|col=lec=ti=on|4 +collega|col=le=ga|4 +começava|co=me=ça=va|4 +commercio|com=mer=cio|4 +companheiro|com=pa=nhei=ro|4 +companheiros|com=pa=nhei=ros|4 +compliance|com=pli=an=ce|4 +comquanto|com=quan=to|4 +conego|co=ne=go|4 +confirmou|con=fir=mou|4 +confusas|con=fu=sas|4 +conselhos|con=se=lhos|4 +consequencia|con=se=quen=cia|4 +Constantinopla|Cons=tan=ti=no=pla|4 +consultar|con=sul=tar|4 +contal|con=tal|4 +contente|con=ten=te|4 +contos|con=tos|4 +copying|copying|4 +correndo|cor=ren=do|4 +country|coun=try|4 +crescendo|cres=cen=do|4 +cuidado|cui=da=do|4 +cuidava|cui=da=va|4 +Cuidei|Cui=dei|4 +cuidei|cui=dei|4 +curioso|cu=ri=o=so|4 +custou|cus=tou|4 +declarou|de=cla=rou|4 +deitei|dei=tei|4 +Deixei|Dei=xei|4 +deixára|dei=xá=ra|4 +desastre|de=sas=tre|4 +Desdemona|Des=de=mo=na|4 +desfazer|des=fa=zer|4 +despediu|des=pe=diu|4 +destinado|des=ti=na=do|4 +desvanecimento|des=va=ne=ci=men=to|4 +displaying|dis=playing|4 +dissimular|dis=si=mu=lar|4 +distributed|dis=tri=bu=ted|4 +dormia|dor=mia|4 +editions|edi=ti=ons|4 +elogio|elo=gio|4 +embarque|em=bar=que|4 +emendar|emen=dar|4 +emoções|emo=ções|4 +enchia|en=chia|4 +encoberto|en=co=ber=to|4 +enganar|en=ga=nar|4 +entendi|en=ten=di|4 +entendido|en=ten=di=do|4 +entendo|en=ten=do|4 +entrava|en=tra=va|4 +erguia|er=guia|4 +escolha|es=co=lha|4 +escravos|es=cra=vos|4 +escrevi|es=cre=vi|4 +escripta|es=crip=ta|4 +escuro|es=cu=ro|4 +esforço|es=for=ço|4 +esforços|es=for=ços|4 +espada|es=pa=da|4 +espectaculo|es=pec=ta=cu=lo|4 +espelho|es=pe=lho|4 +espiritual|es=pi=ri=tu=al|4 +esquecido|es=que=ci=do|4 +estamos|es=ta=mos|4 +estando|es=tan=do|4 +Estavamos|Es=ta=va=mos|4 +estavamos|es=ta=va=mos|4 +esteve|es=te=ve|4 +estive|es=ti=ve|4 +estiver|es=ti=ver|4 +estrellas|es=trel=las|4 +estudante|es=tu=dan=te|4 +evitar|evi=tar|4 +exacta|exac=ta|4 +exactamente|exac=ta=men=te|4 +examinar|exa=mi=nar|4 +explicações|ex=pli=ca=ções|4 +explico|ex=pli=co|4 +Expliquei|Ex=pli=quei|4 +extraordinario|ex=tra=or=di=na=rio|4 +facilidade|fa=ci=li=da=de|4 +facilmente|fa=cil=men=te|4 +falasse|fa=las=se|4 +fechando|fe=chan=do|4 +feição|fei=ção|4 +ferida|fe=ri=da|4 +ficasse|fi=cas=se|4 +figuras|fi=gu=ras|4 +finado|fi=na=do|4 +fitava|fi=ta=va|4 +flores|flo=res|4 +following|fol=lowing|4 +format|for=mat|4 +formula|for=mu=la|4 +fortuna|for=tu=na|4 +freely|freely|4 +frente|fren=te|4 +fresca|fres=ca|4 +gostando|gos=tan=do|4 +gostei|gos=tei|4 +habitual|ha=bi=tu=al|4 +hesitou|he=si=tou|4 +holder|hol=der|4 +honesto|ho=nes=to|4 +imitações|imi=ta=ções|4 +impaciencia|im=pa=ci=en=cia|4 +imperio|im=pe=rio|4 +importante|im=por=tan=te|4 +incidente|in=ci=den=te|4 +individual|in=di=vi=du=al|4 +information|in=for=ma=ti=on|4 +insistisse|in=sis=tis=se|4 +interessante|in=te=res=san=te|4 +interino|in=te=ri=no|4 +interno|in=ter=no|4 +intervenção|in=ter=ven=ção|4 +intima|in=ti=ma|4 +Intimidade|In=ti=mi=da=de|4 +Itaguahy|Ita=gua=hy|4 +jogando|jo=gan=do|4 +lances|lan=ces|4 +lembrando|lem=bran=do|4 +lentamente|len=ta=men=te|4 +levantar|le=van=tar|4 +liberdade|li=ber=da=de|4 +LICENSE|LI=CEN=SE|4 +longos|lon=gos|4 +maestro|ma=es=tro|4 +malicia|ma=li=cia|4 +marcha|mar=cha|4 +materias|ma=te=ri=as|4 +medida|me=di=da|4 +metaphora|me=tap=ho=ra|4 +metteu|met=teu|4 +missas|mis=sas|4 +molestias|mo=les=ti=as|4 +moraes|mo=ra=es|4 +morria|mor=ria|4 +movimentos|mo=vi=men=tos|4 +namorar|na=mo=rar|4 +naquelles|na=quel=les|4 +nascer|nas=cer|4 +naturaes|na=tu=ra=es|4 +obedeci|obe=de=ci|4 +obrigado|obri=ga=do|4 +observou|ob=ser=vou|4 +officios|of=fi=ci=os|4 +ordenado|or=de=na=do|4 +ordens|or=dens|4 +orelhas|ore=lhas|4 +orgulho|or=gu=lho|4 +Outrosim|Ou=tro=sim|4 +paciencia|pa=ci=en=cia|4 +Palavra|Pa=la=vra|4 +pancada|pan=ca=da|4 +parenta|pa=ren=ta|4 +pasmado|pas=ma=do|4 +passear|pas=se=ar|4 +passei|pas=sei|4 +pedido|pe=di=do|4 +pedindo|pe=din=do|4 +pensasse|pen=sas=se|4 +Pensei|Pen=sei|4 +pequenino|pe=que=ni=no|4 +peraltas|pe=ral=tas|4 +Perfeitamente|Per=fei=ta=men=te|4 +perfeição|per=fei=ção|4 +pergunto|per=gun=to|4 +perigo|pe=ri=go|4 +person|per=son|4 +persuasão|per=su=a=são|4 +pescoço|pes=co=ço|4 +phenomeno|phe=no=me=no|4 +piston|pis=ton|4 +possiveis|pos=si=veis|4 +posted|pos=ted|4 +pratica|pra=ti=ca|4 +precisava|pre=ci=sa=va|4 +preferia|pre=fe=ria|4 +presilhas|pre=si=lhas|4 +presta|pres=ta|4 +prometto|pro=met=to|4 +proprias|pro=pri=as|4 +providing|pro=vi=ding|4 +proxima|pro=xi=ma|4 +proximos|pro=xi=mos|4 +pupillas|pu=pil=las|4 +quadro|qua=dro|4 +querida|que=ri=da|4 +querido|que=ri=do|4 +Querido|Que=ri=do|4 +rapida|ra=pi=da|4 +rapido|ra=pi=do|4 +razões|ra=zões|4 +realidade|re=a=li=da=de|4 +recado|re=ca=do|4 +recebe|re=ce=be|4 +recordação|re=cor=da=ção|4 +recuar|re=cu=ar|4 +reflectia|re=flec=tia|4 +regresso|re=gres=so|4 +reitor|rei=tor|4 +religiosos|re=li=gi=o=sos|4 +remorso|re=mor=so|4 +repetiam|re=pe=ti=am|4 +repetindo|re=pe=tin=do|4 +repliquei|re=pli=quei|4 +requirements|re=qui=re=ments|4 +resistencia|re=sis=ten=cia|4 +respostas|res=pos=tas|4 +retorquiu|re=tor=quiu|4 +retratos|re=tra=tos|4 +retrospectiva|re=tros=pec=ti=va|4 +revelou|re=ve=lou|4 +roemos|ro=e=mos|4 +saimos|sai=mos|4 +segredinhos|se=gre=di=nhos|4 +Segredo|Se=gre=do|4 +semelhança|se=me=lhan=ça|4 +sentimentos|sen=ti=men=tos|4 +separar|se=pa=rar|4 +sereno|se=re=no|4 +servia|ser=via|4 +serviço|ser=vi=ço|4 +serviços|ser=vi=ços|4 +Sirius|Si=rius|4 +sorriso|sor=ri=so|4 +states|sta=tes|4 +status|sta=tus|4 +substituto|subs=ti=tu=to|4 +succedeu|suc=ce=deu|4 +successos|suc=ces=sos|4 +sujeito|su=jei=to|4 +superlativos|su=per=la=ti=vos|4 +support|sup=port|4 +tantas|tan=tas|4 +tantos|tan=tos|4 +taquara|ta=qua=ra|4 +tardia|tar=dia|4 +teimasse|tei=mas=se|4 +teimava|tei=ma=va|4 +timidez|ti=mi=dez|4 +tirasse|ti=ras=se|4 +trabalhar|tra=ba=lhar|4 +tratado|tra=ta=do|4 +tratava|tra=ta=va|4 +Trazia|Tra=zia|4 +traziam|tra=zi=am|4 +trepadas|tre=pa=das|4 +trinta|trin=ta|4 +tristes|tris=tes|4 +ultimas|ul=ti=mas|4 +vantagem|van=ta=gem|4 +Venhamos|Ve=nha=mos|4 +verdadeiras|ver=da=dei=ras|4 +vermes|ver=mes|4 +vestir|ves=tir|4 +volunteers|vo=lun=teers|4 +website|web=si=te|4 +within|wi=thin|4 +abanava|aba=na=va|3 +abanou|aba=nou|3 +abatido|aba=ti=do|3 +aborrecida|abor=re=ci=da|3 +abraçou|abra=çou|3 +absolvição|ab=sol=vi=ção|3 +acabado|aca=ba=do|3 +acabando|aca=ban=do|3 +acceitar|ac=cei=tar|3 +Acceitei|Ac=cei=tei|3 +Acceito|Ac=cei=to|3 +acceitou|ac=cei=tou|3 +Achado|Acha=do|3 +acharia|acha=ria|3 +acontecia|acon=te=cia|3 +acontecimentos|acon=te=ci=men=tos|3 +acostumado|acos=tu=ma=do|3 +acudir|acu=dir|3 +adagio|ada=gio|3 +adivinhando|adi=vi=nhan=do|3 +admira|ad=mi=ra|3 +adolescente|ado=les=cen=te|3 +adolescentes|ado=les=cen=tes|3 +adverti|ad=ver=ti|3 +affectuosas|af=fec=tuo=sas|3 +affligir|af=fli=gir|3 +alcançar|al=can=çar|3 +alegres|ale=gres|3 +allopathia|al=lo=pa=thia|3 +almoçar|al=mo=çar|3 +Amanhã|Ama=nhã|3 +amarellas|ama=rel=las|3 +ambição|am=bi=ção|3 +Amigos|Ami=gos|3 +ancioso|an=ci=o=so|3 +anecdota|anec=do=ta|3 +Anterior|An=te=ri=or|3 +anteriores|an=te=ri=o=res|3 +Antonio|An=to=nio|3 +apertal|aper=tal|3 +Apesar|Ape=sar|3 +apontando|apon=tan=do|3 +apostolos|apos=to=los|3 +apparencia|ap=pa=ren=cia|3 +applicable|ap=pli=ca=ble|3 +approvação|ap=pro=va=ção|3 +Aquella|Aquel=la|3 +aquellas|aquel=las|3 +archanjo|ar=chan=jo|3 +armazem|ar=ma=zem|3 +arrependimento|ar=re=pen=di=men=to|3 +Ashaverus|Asha=ve=rus|3 +atirar|ati=rar|3 +atordoado|ator=do=a=do|3 +atrevidas|atre=vi=das|3 +atropello|atro=pel=lo|3 +attenta|at=ten=ta|3 +attento|at=ten=to|3 +available|avai=la=ble|3 +aventura|aven=tu=ra|3 +aventuras|aven=tu=ras|3 +baixinho|bai=xi=nho|3 +Barbonos|Bar=bo=nos|3 +batendo|ba=ten=do|3 +before|be=fo=re|3 +beijei|bei=jei|3 +bellos|bel=los|3 +bilhetes|bi=lhe=tes|3 +boceta|bo=ce=ta|3 +bondade|bon=da=de|3 +bonitos|bo=ni=tos|3 +bradava|bra=da=va|3 +brancos|bran=cos|3 +brincar|brin=car|3 +brincava|brin=ca=va|3 +buscal|bus=cal|3 +buscasse|bus=cas=se|3 +busquei|bus=quei|3 +cabeças|ca=be=ças|3 +calados|ca=la=dos|3 +calculo|cal=cu=lo|3 +campanhas|cam=pa=nhas|3 +cantar|can=tar|3 +Cantico|Can=ti=co|3 +cançado|can=ça=do|3 +capitulos|ca=pi=tu=los|3 +caracter|ca=rac=ter|3 +caricias|ca=ri=ci=as|3 +carnes|car=nes|3 +casados|ca=sa=dos|3 +casando|ca=san=do|3 +catastrophe|ca=tas=trop=he|3 +causas|cau=sas|3 +celebre|ce=le=bre|3 +celebres|ce=le=bres|3 +Central|Cen=tral|3 +centro|cen=tro|3 +Chamado|Cha=ma=do|3 +chamando|cha=man=do|3 +chamei|cha=mei|3 +chegaram|che=ga=ram|3 +chegámos|che=gá=mos|3 +cheios|chei=os|3 +chinellas|chi=nel=las|3 +chorei|cho=rei|3 +chovia|cho=via|3 +cigana|ci=ga=na|3 +cincoenta|cin=co=en=ta|3 +cintura|cin=tu=ra|3 +circumstancia|cir=cums=tan=cia|3 +coberto|co=ber=to|3 +colera|co=le=ra|3 +colorido|co=lo=ri=do|3 +companheira|com=pa=nhei=ra|3 +completa|com=ple=ta|3 +complying|com=plying|3 +comprar|com=prar|3 +comprido|com=pri=do|3 +comprimentar|com=pri=men=tar|3 +Comtudo|Com=tu=do|3 +concluir|con=cluir|3 +confiança|con=fi=an=ça|3 +confidencia|con=fi=den=cia|3 +confissão|con=fis=são|3 +confuso|con=fu=so|3 +conhece|co=nhe=ce|3 +conhecidas|co=nhe=ci=das|3 +consegui|con=se=gui|3 +consultou|con=sul=tou|3 +contact|con=tact|3 +contado|con=ta=do|3 +Contado|Con=ta=do|3 +contasse|con=tas=se|3 +contemplação|con=tem=pla=ção|3 +contraria|con=tra=ria|3 +conversando|con=ver=san=do|3 +conversações|con=ver=sa=ções|3 +convidar|con=vi=dar|3 +Convivas|Con=vi=vas|3 +coqueiro|co=quei=ro|3 +corações|co=ra=ções|3 +correspondente|cor=res=pon=den=te|3 +cosido|co=si=do|3 +costura|cos=tu=ra|3 +creançolas|cre=an=ço=las|3 +creating|cre=a=ting|3 +creação|cre=a=ção|3 +credor|cre=dor|3 +cuides|cui=des|3 +culpadas|cul=pa=das|3 +cumpre|cum=pre|3 +cunhadinha|cu=nha=di=nha|3 +curiosa|cu=ri=o=sa|3 +curtos|cur=tos|3 +defective|de=fec=ti=ve|3 +definitiva|de=fi=ni=ti=va|3 +deitado|dei=ta=do|3 +deixal|dei=xal|3 +deixando|dei=xan=do|3 +deixavam|dei=xa=vam|3 +demanda|de=man=da|3 +derivative|de=ri=va=ti=ve|3 +desceram|des=ce=ram|3 +desciam|des=ci=am|3 +desejos|de=se=jos|3 +desenho|de=se=nho|3 +despedi|des=pe=di|3 +despedidas|des=pe=di=das|3 +despeito|des=pei=to|3 +desprezo|des=pre=zo|3 +desses|des=ses|3 +destinada|des=ti=na=da|3 +deteve|de=te=ve|3 +devedor|de=ve=dor|3 +devesse|de=ves=se|3 +devota|de=vo=ta|3 +dezeseis|de=ze=seis|3 +digamos|di=ga=mos|3 +dimensões|di=men=sões|3 +dinheiros|di=nhei=ros|3 +direcção|di=rec=ção|3 +direitos|di=rei=tos|3 +disfarçar|dis=far=çar|3 +disposição|dis=po=si=ção|3 +disposto|dis=pos=to|3 +divertir|di=ver=tir|3 +dominava|do=mi=na=va|3 +dramatica|dra=ma=ti=ca|3 +duvidar|du=vi=dar|3 +Duvidas|Du=vi=das|3 +Effectivamente|Ef=fec=ti=va=men=te|3 +effectivo|ef=fec=ti=vo|3 +efforts|ef=forts|3 +embarcar|em=bar=car|3 +Embargos|Em=bar=gos|3 +emenda|emen=da|3 +emendou|emen=dou|3 +emprego|em=pre=go|3 +empreguei|em=pre=guei|3 +Emquanto|Em=quan=to|3 +encheram|en=che=ram|3 +encontrei|en=con=trei|3 +enferma|en=fer=ma|3 +enfermeira|en=fer=mei=ra|3 +enfiado|en=fi=a=do|3 +enfiados|en=fi=a=dos|3 +enfiou|en=fi=ou|3 +engano|en=ga=no|3 +enorme|enor=me|3 +ensinar|en=si=nar|3 +entendeu|en=ten=deu|3 +entity|en=tity|3 +entrado|en=tra=do|3 +entrando|en=tran=do|3 +entraram|en=tra=ram|3 +entretanto|en=tre=tan=to|3 +enviuvou|en=viu=vou|3 +Enxuguei|En=xu=guei|3 +equipment|equip=ment|3 +escapei|es=ca=pei|3 +escapou|es=ca=pou|3 +escrevel|es=cre=vel|3 +escriptor|es=crip=tor|3 +escriptorio|es=crip=to=rio|3 +esculptor|es=culp=tor|3 +escuras|es=cu=ras|3 +escutava|es=cu=ta=va|3 +espantada|es=pan=ta=da|3 +espanto|es=pan=to|3 +esperasse|es=pe=ras=se|3 +espiar|es=pi=ar|3 +espiava|es=pi=a=va|3 +esquina|es=qui=na|3 +essencia|es=sen=cia|3 +estariam|es=ta=ri=am|3 +estremecer|es=tre=me=cer|3 +estrepito|es=tre=pi=to|3 +estylo|esty=lo|3 +eternidade|eter=ni=da=de|3 +evidentemente|evi=den=te=men=te|3 +evocava|evo=ca=va|3 +exageração|exa=ge=ra=ção|3 +excellente|ex=cel=len=te|3 +Excellentissima|Ex=cel=len=tis=si=ma|3 +exclusivamente|ex=clu=si=va=men=te|3 +exemplar|exem=plar|3 +experimentar|ex=pe=ri=men=tar|3 +explicasse|ex=pli=cas=se|3 +Explicação|Ex=pli=ca=ção|3 +expressões|ex=pres=sões|3 +exquisita|ex=qui=si=ta|3 +externo|ex=ter=no|3 +extranho|ex=tra=nho|3 +extraordinariamente|ex=tra=or=di=na=ri=a=men=te|3 +faceis|fa=ceis|3 +falará|fa=la=rá|3 +Falava|Fa=la=va|3 +faltar|fal=tar|3 +faltava|fal=ta=va|3 +faltou|fal=tou|3 +familiares|fa=mi=li=a=res|3 +familias|fa=mi=li=as|3 +fariam|fa=ri=am|3 +fechei|fe=chei|3 +fechou|fe=chou|3 +feitas|fei=tas|3 +feitos|fei=tos|3 +ficára|fi=cá=ra|3 +fiquemos|fi=que=mos|3 +fitavam|fi=ta=vam|3 +fizemos|fi=ze=mos|3 +fluido|flui=do|3 +formam|for=mam|3 +fortaleza|for=ta=le=za|3 +fortes|for=tes|3 +franca|fran=ca|3 +fugindo|fu=gin=do|3 +fugisse|fu=gis=se|3 +fundos|fun=dos|3 +future|fu=tu=re|3 +garganta|gar=gan=ta|3 +General|Ge=ne=ral|3 +genero|ge=ne=ro|3 +gostar|gos=tar|3 +gravata|gra=va=ta|3 +graves|gra=ves|3 +gravissimo|gra=vis=si=mo|3 +grosso|gros=so|3 +grossos|gros=sos|3 +guardal|guar=dal|3 +guardei|guar=dei|3 +haverá|ha=ve=rá|3 +haviam|ha=vi=am|3 +haviamos|ha=vi=a=mos|3 +hesitar|he=si=tar|3 +hesitação|he=si=ta=ção|3 +historias|his=to=ri=as|3 +honras|hon=ras|3 +hostia|hos=tia|3 +humana|hu=ma=na|3 +humanas|hu=ma=nas|3 +Ideias|Idei=as|3 +impaciente|im=pa=ci=en=te|3 +imperial|im=pe=ri=al|3 +imprimil|im=pri=mil|3 +impulsos|im=pul=sos|3 +inclinação|in=cli=na=ção|3 +included|in=clu=ded|3 +incommodo|in=com=mo=do|3 +inglez|in=glez|3 +injuria|in=ju=ria|3 +innocente|in=no=cen=te|3 +inquieto|in=qui=e=to|3 +insinuou|in=si=nuou|3 +interrogar|in=ter=ro=gar|3 +interromper|in=ter=rom=per|3 +interrompida|in=ter=rom=pi=da|3 +inuteis|inu=teis|3 +investidura|in=ves=ti=du=ra|3 +iremos|ire=mos|3 +Juramento|Ju=ra=men=to|3 +jurava|ju=ra=va|3 +juremos|ju=re=mos|3 +ladeira|la=dei=ra|3 +lampião|lam=pi=ão|3 +lavadouro|la=va=dou=ro|3 +legado|le=ga=do|3 +leitura|lei=tu=ra|3 +Lembra|Lem=bra|3 +lembranças|lem=bran=ças|3 +lembravam|lem=bra=vam|3 +lentes|len=tes|3 +lençóes|len=çóes|3 +levantei|le=van=tei|3 +Levantou|Le=van=tou|3 +libretto|li=bret=to|3 +ligado|li=ga=do|3 +limitation|li=mi=ta=ti=on|3 +LIMITED|LI=MI=TED|3 +livida|li=vi=da|3 +louvor|lou=vor|3 +lustre|lus=tre|3 +madrugada|ma=dru=ga=da|3 +Majestade|Ma=jes=ta=de|3 +mandei|man=dei|3 +mangas|man=gas|3 +manifesta|ma=ni=fes=ta|3 +manuscripto|ma=nus=crip=to|3 +marcado|mar=ca=do|3 +Massinissa|Mas=si=nis=sa|3 +matava|ma=ta=va|3 +mediante|me=di=an=te|3 +Medicina|Me=di=ci=na|3 +melhores|me=lho=res|3 +meninas|me=ni=nas|3 +Metades|Me=ta=des|3 +mettel|met=tel|3 +mettesse|met=tes=se|3 +mettida|met=ti=da|3 +mettido|met=ti=do|3 +mission|mis=si=on|3 +mister|mis=ter|3 +mocinho|mo=ci=nho|3 +modelo|mo=de=lo|3 +modesta|mo=des=ta|3 +molhados|mo=lha=dos|3 +montar|mon=tar|3 +morava|mo=ra=va|3 +mostra|mos=tra|3 +mostrava|mos=tra=va|3 +mostrei|mos=trei|3 +moveis|mo=veis|3 +movimento|mo=vi=men=to|3 +mudado|mu=da=do|3 +namorada|na=mo=ra=da|3 +namorados|na=mo=ra=dos|3 +nasceu|nas=ceu|3 +naufragio|nau=fra=gio|3 +necessaria|ne=ces=sa=ria|3 +necessario|ne=ces=sa=rio|3 +nellas|nel=las|3 +Nenhum|Ne=nhum|3 +Novembro|No=vem=bro|3 +number|num=ber|3 +nympha|nymp=ha|3 +objecto|ob=jec=to|3 +objectos|ob=jec=tos|3 +obtain|ob=tain|3 +occasiões|oc=ca=si=ões|3 +official|of=fi=ci=al|3 +olhavam|olha=vam|3 +opinar|opi=nar|3 +opinou|opi=nou|3 +ordenação|or=de=na=ção|3 +ordene|or=de=ne|3 +origem|ori=gem|3 +Outros|Ou=tros|3 +ouviram|ou=vi=ram|3 +padecer|pa=de=cer|3 +padrinho|pa=dri=nho|3 +pagamento|pa=ga=men=to|3 +pagina|pa=gi=na|3 +pallida|pal=li=da|3 +palpebras|pal=pe=bras|3 +paragraphs|pa=ra=graphs|3 +parava|pa=ra=va|3 +parceiro|par=cei=ro|3 +partitura|par=ti=tu=ra|3 +passarinhos|pas=sa=ri=nhos|3 +paying|paying|3 +payments|pay=ments|3 +peccados|pec=ca=dos|3 +pedaços|pe=da=ços|3 +pegasse|pe=gas=se|3 +pegava|pe=ga=va|3 +pendente|pen=den=te|3 +people|pe=o=ple|3 +perdia|per=dia|3 +Perdão|Per=dão|3 +perfeito|per=fei=to|3 +performing|per=for=ming|3 +pergunte|per=gun=te|3 +permittia|per=mit=tia|3 +personagens|per=so=na=gens|3 +phrases|ph=ra=ses|3 +piedade|pi=e=da=de|3 +pintado|pin=ta=do|3 +piscando|pis=can=do|3 +podemos|po=de=mos|3 +politico|po=li=ti=co|3 +porquanto|por=quan=to|3 +porção|por=ção|3 +possibilidade|pos=si=bi=li=da=de|3 +poucas|pou=cas|3 +Prazer|Pra=zer|3 +predicção|pre=dic=ção|3 +premio|pre=mio|3 +prender|pren=der|3 +presente|pre=sen=te|3 +pressa|pres=sa|3 +pretas|pre=tas|3 +promettia|pro=met=tia|3 +prompta|promp=ta|3 +Protonotario|Pro=to=no=ta=rio|3 +provided|pro=vi=ded|3 +public|pu=blic|3 +Punhado|Pu=nha=do|3 +pyramides|py=ra=mi=des|3 +Quantas|Quan=tas|3 +quantos|quan=tos|3 +quarta|quar=ta|3 +queira|quei=ra|3 +queixo|quei=xo|3 +quente|quen=te|3 +Queria|Que=ria|3 +quizessem|qui=zes=sem|3 +Rasgos|Ras=gos|3 +recebendo|re=ce=ben=do|3 +recolhi|re=co=lhi|3 +recommendações|re=com=men=da=ções|3 +reconciliação|re=con=ci=li=a=ção|3 +recordando|re=cor=dan=do|3 +recreios|re=crei=os|3 +recuei|re=cu=ei|3 +recusou|re=cu=sou|3 +referir|re=fe=rir|3 +reflectir|re=flec=tir|3 +reflexo|re=fle=xo|3 +reforma|re=for=ma|3 +reformar|re=for=mar|3 +refutal|re=fu=tal|3 +religião|re=li=gi=ão|3 +repetir|re=pe=tir|3 +replacement|re=pla=ce=ment|3 +respeito|res=pei=to|3 +Respondi|Res=pon=di|3 +resumo|re=su=mo|3 +return|re=turn|3 +risonho|ri=so=nho|3 +royalties|royal=ti=es|3 +sabido|sa=bi=do|3 +sacerdote|sa=cer=do=te|3 +sacudi|sa=cu=di|3 +satisfeito|sa=tis=fei=to|3 +saudade|sau=da=de|3 +Scismando|Scis=man=do|3 +secreto|se=cre=to|3 +seculo|se=cu=lo|3 +seguido|se=gui=do|3 +seguiu|se=guiu|3 +Segundo|Se=gun=do|3 +semelhanças|se=me=lhan=ças|3 +Sensações|Sen=sa=ções|3 +Sentia|Sen=tia|3 +sentiu|sen=tiu|3 +sincera|sin=ce=ra|3 +Sinhásinha|Si=nhá=si=nha|3 +socegado|so=ce=ga=do|3 +sombras|som=bras|3 +sonetos|so=ne=tos|3 +sorria|sor=ria|3 +soubesse|sou=bes=se|3 +sublime|su=bli=me|3 +sufficiente|suf=fi=ci=en=te|3 +Supponha|Sup=po=nha|3 +suspirando|sus=pi=ran=do|3 +talento|ta=len=to|3 +teimosos|tei=mo=sos|3 +teimou|tei=mou|3 +temente|te=men=te|3 +tentei|ten=tei|3 +teriam|te=ri=am|3 +terras|ter=ras|3 +terraço|ter=ra=ço|3 +terror|ter=ror|3 +testemunhas|tes=te=mu=nhas|3 +Tijuca|Ti=ju=ca|3 +tivera|ti=ve=ra|3 +tochas|to=chas|3 +tolerava|to=le=ra=va|3 +tornando|tor=nan=do|3 +tornar|tor=nar|3 +tornavam|tor=na=vam|3 +tragico|tra=gi=co|3 +tratar|tra=tar|3 +tratei|tra=tei|3 +travessuras|tra=ves=su=ras|3 +tremulo|tre=mu=lo|3 +tristezas|tris=te=zas|3 +trocou|tro=cou|3 +truncado|trun=ca=do|3 +Turquia|Tur=quia|3 +unicamente|uni=ca=men=te|3 +universidade|uni=ver=si=da=de|3 +vagamente|va=ga=men=te|3 +varias|va=ri=as|3 +veneno|ve=ne=no|3 +Verdade|Ver=da=de|3 +vergonha|ver=go=nha|3 +vexame|ve=xa=me|3 +viagens|vi=a=gens|3 +victima|vic=ti=ma|3 +viessem|vi=es=sem|3 +visinhas|vi=si=nhas|3 +visinhos|vi=si=nhos|3 +Visita|Vi=si=ta|3 +vivesse|vi=ves=se|3 +voltado|vol=ta=do|3 +voltava|vol=ta=va|3 +vulgar|vul=gar|3 +writing|wri=ting|3 +written|writ=ten|3 +zangado|zan=ga=do|3 +zangava|zan=ga=va|3 +éramos|éra=mos|3 +Abaixo|Abai=xo|2 +abatida|aba=ti=da|2 +aberta|aber=ta|2 +aborrecimento|abor=re=ci=men=to|2 +aborrecivel|abor=re=ci=vel|2 +abraçal|abra=çal|2 +abrindo|abrin=do|2 +abrira|abri=ra|2 +acabam|aca=bam|2 +acabaram|aca=ba=ram|2 +acabassem|aca=bas=sem|2 +ACADEMIA|ACA=DE=MIA|2 +acanhada|aca=nha=da|2 +acanhamento|aca=nha=men=to|2 +acceital|ac=cei=tal|2 +acceitando|ac=cei=tan=do|2 +acceitei|ac=cei=tei|2 +accentuando|ac=cen=tu=an=do|2 +accepted|ac=cep=ted|2 +accesas|ac=ce=sas|2 +accordado|ac=cor=da=do|2 +accordance|ac=cor=dan=ce|2 +accordei|ac=cor=dei|2 +achando|achan=do|2 +acharam|acha=ram|2 +Achilles|Achil=les|2 +achára|achá=ra|2 +acodem|aco=dem|2 +acompanhado|acom=pa=nha=do|2 +acompanhal|acom=pa=nhal|2 +acompanhava|acom=pa=nha=va|2 +aconselhou|acon=se=lhou|2 +acostumando|acos=tu=man=do|2 +acostumar|acos=tu=mar|2 +active|ac=ti=ve|2 +acudisse|acu=dis=se|2 +additional|ad=di=ti=o=nal|2 +adequada|ade=qua=da|2 +adequadas|ade=qua=das|2 +adivinhado|adi=vi=nha=do|2 +adivinhavam|adi=vi=nha=vam|2 +admitte|ad=mit=te|2 +adoeciam|ado=e=ci=am|2 +adulação|adu=la=ção|2 +affectação|af=fec=ta=ção|2 +affecto|af=fec=to|2 +affeiçoando|af=fei=ço=an=do|2 +affeições|af=fei=ções|2 +affirmava|af=fir=ma=va|2 +affirmação|af=fir=ma=ção|2 +afflicto|af=flic=to|2 +affligia|af=fli=gia|2 +affligiu|af=fli=giu|2 +afrancezado|afran=ce=za=do|2 +afrouxar|afrou=xar|2 +agarrar|agar=rar|2 +agarrei|agar=rei|2 +agarrou|agar=rou|2 +aggravou|ag=gra=vou|2 +agradar|agra=dar|2 +agradecer|agra=de=cer|2 +agradeci|agra=de=ci|2 +agradecimento|agra=de=ci=men=to|2 +agradecimentos|agra=de=ci=men=tos|2 +agradeço|agra=de=ço|2 +agrado|agra=do|2 +aguenta|aguen=ta|2 +agulha|agu=lha|2 +ajoelhavam|ajo=e=lha=vam|2 +ajudal|aju=dal|2 +ajudar|aju=dar|2 +ajuste|ajus=te|2 +alcancei|al=can=cei|2 +alcançavam|al=can=ça=vam|2 +alcanço|al=can=ço|2 +alcunha|al=cu=nha|2 +Algumas|Al=gu=mas|2 +alheia|alheia|2 +allegações|al=le=ga=ções|2 +allivio|al=li=vio|2 +allopatha|al=lo=pa=tha|2 +allusão|al=lu=são|2 +almost|al=most|2 +alphabeto|alp=ha=be=to|2 +alterar|al=te=rar|2 +alternando|al=ter=nan=do|2 +altitude|al=ti=tu=de|2 +alugadas|alu=ga=das|2 +alçada|al=ça=da|2 +amanheceu|ama=nhe=ceu|2 +amantissima|aman=tis=si=ma|2 +amantissimo|aman=tis=si=mo|2 +amarello|ama=rel=lo|2 +amargo|amar=go|2 +amizades|ami=za=des|2 +amorosas|amo=ro=sas|2 +amuado|amu=a=do|2 +analogo|ana=lo=go|2 +analyse|analy=se|2 +andado|an=da=do|2 +Andando|An=dan=do|2 +andaria|an=da=ria|2 +andasse|an=das=se|2 +andassem|an=das=sem|2 +animal|ani=mal|2 +animação|ani=ma=ção|2 +annunciado|an=nun=ci=a=do|2 +annunciou|an=nun=ci=ou|2 +anywhere|anyw=he=re|2 +apagar|apa=gar|2 +apalpava|apal=pa=va|2 +Apalpei|Apal=pei|2 +apanhado|apa=nha=do|2 +apanhados|apa=nha=dos|2 +apanhou|apa=nhou|2 +apertada|aper=ta=da|2 +apertado|aper=ta=do|2 +apertava|aper=ta=va|2 +apertei|aper=tei|2 +apolice|apo=li=ce|2 +apontou|apon=tou|2 +apostolo|apos=to=lo|2 +apparecer|ap=pa=re=cer|2 +apprende|ap=pren=de|2 +apprenderia|ap=pren=de=ria|2 +aproveitou|apro=vei=tou|2 +Aquelle|Aquel=le|2 +Aquillo|Aquil=lo|2 +argumentos|ar=gu=men=tos|2 +arranjar|ar=ran=jar|2 +arranjou|ar=ran=jou|2 +arrastado|ar=ras=ta=do|2 +arrenegar|ar=re=ne=gar|2 +arrependi|ar=re=pen=di|2 +artista|ar=tis=ta|2 +arvores|ar=vo=res|2 +aspereza|as=pe=re=za|2 +assiduas|as=si=du=as|2 +assistia|as=sis=tia|2 +assusta|as=sus=ta|2 +assustada|as=sus=ta=da|2 +assustado|as=sus=ta=do|2 +atalhei|ata=lhei|2 +ataque|ata=que|2 +ataúde|ataú=de|2 +atinava|ati=na=va|2 +atirou|ati=rou|2 +atravessar|atra=ves=sar|2 +atrevia|atre=via|2 +attender|at=ten=der|2 +attendeu|at=ten=deu|2 +attendia|at=ten=dia|2 +attribuir|at=tri=buir|2 +attribuo|at=tri=buo|2 +aturdido|atur=di=do|2 +Augusto|Au=gus=to|2 +aventurou|aven=tu=rou|2 +bailes|bai=les|2 +banalidade|ba=na=li=da=de|2 +baptisado|bap=ti=sa=do|2 +bastar|bas=tar|2 +batinas|ba=ti=nas|2 +beijal|bei=jal|2 +beijos|bei=jos|2 +beijou|bei=jou|2 +bellezas|bel=le=zas|2 +bengala|ben=ga=la|2 +bestas|bes=tas|2 +Biblia|Bi=blia|2 +bigode|bi=go=de|2 +bondades|bon=da=des|2 +bradando|bra=dan=do|2 +bradar|bra=dar|2 +bradou|bra=dou|2 +branco|bran=co|2 +Brasil|Bra=sil|2 +BRAZILEIRA|BRA=ZI=LEI=RA|2 +BREACH|BRE=A=CH|2 +breves|bre=ves|2 +brincos|brin=cos|2 +brinquedos|brin=que=dos|2 +cabelleireiro|ca=bel=lei=rei=ro|2 +cabisbaixo|ca=bis=bai=xo|2 +cairam|cai=ram|2 +caixão|cai=xão|2 +caladas|ca=la=das|2 +calando|ca=lan=do|2 +calasse|ca=las=se|2 +calcular|cal=cu=lar|2 +calculos|cal=cu=los|2 +calçada|cal=ça=da|2 +camarote|ca=ma=ro=te|2 +caminhos|ca=mi=nhos|2 +camisas|ca=mi=sas|2 +campainha|cam=pai=nha|2 +Camões|Camões|2 +Candelaria|Can=de=la=ria|2 +canoas|ca=no=as|2 +cançadas|can=ça=das|2 +cançou|can=çou|2 +Capitolina|Ca=pi=to=li=na|2 +capote|ca=po=te|2 +carola|ca=ro=la|2 +carros|car=ros|2 +cartorio|car=to=rio|2 +cartão|car=tão|2 +casaes|ca=sa=es|2 +casmurro|cas=mur=ro|2 +castello|cas=tel=lo|2 +castigada|cas=ti=ga=da|2 +casuarina|ca=su=a=ri=na|2 +catalogo|ca=ta=lo=go|2 +catholico|ca=tho=li=co|2 +cautela|cau=te=la|2 +cavalheiro|ca=va=lhei=ro|2 +cedendo|ce=den=do|2 +cerradas|cer=ra=das|2 +cerrava|cer=ra=va|2 +certain|cer=tain|2 +certas|cer=tas|2 +certos|cer=tos|2 +chamado|cha=ma=do|2 +chamassem|cha=mas=sem|2 +chegada|che=ga=da|2 +chegam|che=gam|2 +chegara|che=ga=ra|2 +chegaremos|che=ga=re=mos|2 +chegasse|che=gas=se|2 +chegavam|che=ga=vam|2 +Chegou|Che=gou|2 +chegue|che=gue|2 +cheiro|chei=ro|2 +chicote|chi=co=te|2 +chorar|cho=rar|2 +chorasse|cho=ras=se|2 +chorava|cho=ra=va|2 +choravam|cho=ra=vam|2 +chupado|chu=pa=do|2 +claramente|cla=ra=men=te|2 +claras|cla=ras|2 +cocegas|co=ce=gas|2 +cocheira|co=chei=ra|2 +cochilando|co=chi=lan=do|2 +cochilar|co=chi=lar|2 +Coitado|Coi=ta=do|2 +colcha|col=cha|2 +collar|col=lar|2 +comborço|com=bor=ço|2 +comece|co=me=ce|2 +Comecei|Co=me=cei|2 +comendo|co=men=do|2 +começa|co=me=ça|2 +começado|co=me=ça=do|2 +commandante|com=man=dan=te|2 +commerciante|com=mer=ci=an=te|2 +Commigo|Com=mi=go|2 +commovido|com=mo=vi=do|2 +comparal|com=pa=ral|2 +comparavel|com=pa=ra=vel|2 +compensar|com=pen=sar|2 +competir|com=pe=tir|2 +complemento|com=ple=men=to|2 +complicava|com=pli=ca=va|2 +compor|com=por|2 +composição|com=po=si=ção|2 +compostura|com=pos=tu=ra|2 +comprado|com=pra=do|2 +comprehensão|com=pre=hen=são|2 +Comprei|Com=prei|2 +comprei|com=prei|2 +compridas|com=pri=das|2 +comprimarios|com=pri=ma=ri=os|2 +comprou|com=prou|2 +computer|com=pu=ter|2 +computers|com=pu=ters|2 +comtanto|com=tan=to|2 +concebido|con=ce=bi=do|2 +conceito|con=cei=to|2 +Conceição|Con=cei=ção|2 +concept|con=cept|2 +concerning|con=cer=ning|2 +conciliar|con=ci=li=ar|2 +concluo|con=cluo|2 +condição|con=di=ção|2 +confessando|con=fes=san=do|2 +confessasse|con=fes=sas=se|2 +confessava|con=fes=sa=va|2 +Confessou|Con=fes=sou|2 +confiou|con=fi=ou|2 +confirmaria|con=fir=ma=ria|2 +confirmava|con=fir=ma=va|2 +conforme|con=for=me|2 +confusos|con=fu=sos|2 +conheceram|co=nhe=ce=ram|2 +conhecesse|co=nhe=ces=se|2 +Conheceu|Co=nhe=ceu|2 +Conheci|Co=nhe=ci|2 +Conhecia|Co=nhe=cia|2 +conheciamos|co=nhe=ci=a=mos|2 +consequencias|con=se=quen=ci=as|2 +conservatorio|con=ser=va=to=rio|2 +considerable|con=si=de=ra=ble|2 +construir|cons=truir|2 +consultei|con=sul=tei|2 +contain|con=tain|2 +contarei|con=ta=rei|2 +contavam|con=ta=vam|2 +contentou|con=ten=tou|2 +contesto|con=tes=to|2 +contiguos|con=ti=guos|2 +continuam|con=ti=nu=am|2 +continuando|con=ti=nu=an=do|2 +continuasse|con=ti=nu=as=se|2 +Continuei|Con=ti=nu=ei|2 +continuámos|con=ti=nu=á=mos|2 +Contou|Con=tou|2 +Contra|Con=tra|2 +contraste|con=tras=te|2 +conversar|con=ver=sar|2 +conversava|con=ver=sa=va|2 +conversavam|con=ver=sa=vam|2 +copied|co=pi=ed|2 +corneta|cor=ne=ta|2 +Coroação|Co=ro=a=ção|2 +correio|cor=reio|2 +corrigiu|cor=ri=giu|2 +cortejar|cor=te=jar|2 +cortina|cor=ti=na|2 +costumava|cos=tu=ma=va|2 +coçava|co=ça=va|2 +creado|cre=a=do|2 +creanca|cre=an=ca|2 +creancas|cre=an=cas|2 +created|cre=a=ted|2 +crentes|cren=tes|2 +crescente|cres=cen=te|2 +crescidos|cres=ci=dos|2 +criado|cri=a=do|2 +Criméa|Cri=méa|2 +crises|cri=ses|2 +cuidou|cui=dou|2 +culpada|cul=pa=da|2 +cumpridas|cum=pri=das|2 +cumpriu|cum=priu|2 +curiosidade|cu=ri=o=si=da=de|2 +curiosos|cu=ri=o=sos|2 +curtido|cur=ti=do|2 +cutello|cu=tel=lo|2 +CXLIII|CX=LI=II|2 +CXLVII|CXL=VII|2 +CXLVIII|CXL=VI=II|2 +CXVIII|CX=VI=II|2 +CXXIII|CX=XI=II|2 +CXXVII|CXX=VII|2 +CXXVIII|CXX=VI=II|2 +CXXXII|CXX=XII|2 +CXXXIII|CXX=XI=II|2 +CXXXIV|CXX=XIV|2 +CXXXIX|CXX=XIX|2 +CXXXVI|CXXX=VI|2 +CXXXVII|CXXX=VII|2 +CXXXVIII|CXXX=VI=II|2 +DAMAGES|DA=MA=GES|2 +damages|da=ma=ges|2 +decisiva|de=ci=si=va|2 +decorou|de=co=rou|2 +dedicatoria|de=di=ca=to=ria|2 +defect|de=fect|2 +defeitos|de=fei=tos|2 +defendendo|de=fen=den=do|2 +definitivamente|de=fi=ni=ti=va=men=te|2 +defunctos|de=func=tos|2 +degrau|de=grau|2 +degraus|de=graus|2 +deitou|dei=tou|2 +deixaria|dei=xa=ria|2 +deixemos|dei=xe=mos|2 +deliciosamente|de=li=ci=o=sa=men=te|2 +demolissem|de=mo=lis=sem|2 +demorada|de=mo=ra=da|2 +demoradamente|de=mo=ra=da=men=te|2 +demorado|de=mo=ra=do|2 +demorando|de=mo=ran=do|2 +demorar|de=mo=rar|2 +deputado|de=pu=ta=do|2 +derreou|der=re=ou|2 +descançar|des=can=çar|2 +descanço|des=can=ço|2 +desceu|des=ceu|2 +descoberta|des=co=ber=ta|2 +descobertas|des=co=ber=tas|2 +descoberto|des=co=ber=to|2 +descobriam|des=co=bri=am|2 +desconfiado|des=con=fi=a=do|2 +desconfiança|des=con=fi=an=ça|2 +desconfiar|des=con=fi=ar|2 +desconhecida|des=co=nhe=ci=da|2 +descorado|des=co=ra=do|2 +descrevem|des=cre=vem|2 +descuido|des=cui=do|2 +Desculpe|Des=cul=pe|2 +desejar|de=se=jar|2 +desejasse|de=se=jas=se|2 +desengano|de=sen=ga=no|2 +desesperadamente|de=ses=pe=ra=da=men=te|2 +desfazendo|des=fa=zen=do|2 +desfecho|des=fe=cho|2 +desgosto|des=gos=to|2 +desgraça|des=gra=ça|2 +desgraçado|des=gra=ça=do|2 +destinos|des=ti=nos|2 +destroy|des=troy|2 +destruir|des=truir|2 +desunião|de=su=ni=ão|2 +desviei|des=vi=ei|2 +desviou|des=vi=ou|2 +determine|de=ter=mi=ne|2 +devagarinho|de=va=ga=ri=nho|2 +deviamos|de=vi=a=mos|2 +devotas|de=vo=tas|2 +devoção|de=vo=ção|2 +dezenove|de=ze=no=ve|2 +dezoito|de=zoi=to|2 +dialogo|di=a=lo=go|2 +diccionarios|dic=ci=o=na=ri=os|2 +differentes|dif=fe=ren=tes|2 +difficil|dif=fi=cil|2 +difficilmente|dif=fi=cil=men=te|2 +dignas|dig=nas|2 +diminuir|di=mi=nuir|2 +disclaimer|dis=clai=mer|2 +discreto|dis=cre=to|2 +discursos|dis=cur=sos|2 +disfarce|dis=far=ce|2 +dispensa|dis=pen=sa|2 +disponivel|dis=po=ni=vel|2 +disposta|dis=pos=ta|2 +dispoz|dis=poz|2 +disser|dis=ser|2 +disserem|dis=se=rem|2 +dissimulado|dis=si=mu=la=do|2 +dissimulou|dis=si=mu=lou|2 +distincta|dis=tinc=ta|2 +distrahido|dis=tra=hi=do|2 +diversões|di=ver=sões|2 +dividir|di=vi=dir|2 +Divina|Di=vi=na|2 +divisão|di=vi=são|2 +dobrado|do=bra=do|2 +dobrava|do=bra=va|2 +documentos|do=cu=men=tos|2 +doentes|do=en=tes|2 +doesse|do=es=se|2 +domain|do=main|2 +domingos|do=min=gos|2 +Donations|Do=na=ti=ons|2 +dormiam|dor=mi=am|2 +dormindo|dor=min=do|2 +dormisse|dor=mis=se|2 +dormiu|dor=miu|2 +doçura|do=çu=ra|2 +Durante|Du=ran=te|2 +duração|du=ra=ção|2 +duvidoso|du=vi=do=so|2 +ecclesiastico|ec=cle=si=as=ti=co|2 +ecclesiasticos|ec=cle=si=as=ti=cos|2 +economia|eco=no=mia|2 +economias|eco=no=mi=as|2 +educational|edu=ca=ti=o=nal|2 +educação|edu=ca=ção|2 +effort|ef=fort|2 +eguaes|egua=es|2 +Egypto|Egyp=to|2 +electronically|elec=tro=ni=cally|2 +elogiado|elo=gi=a=do|2 +elogiar|elo=gi=ar|2 +elogiou|elo=gi=ou|2 +embaraçar|em=ba=ra=çar|2 +embargo|em=bar=go|2 +embrulho|em=bru=lho|2 +emendados|emen=da=dos|2 +emendando|emen=dan=do|2 +emendei|emen=dei|2 +empenho|em=pe=nho|2 +emphatico|emp=ha=ti=co|2 +empregou|em=pre=gou|2 +empregue|em=pre=gue|2 +empresario|em=pre=sa=rio|2 +empurrei|em=pur=rei|2 +encanto|en=can=to|2 +encardido|en=car=di=do|2 +encobrindo|en=co=brin=do|2 +enfeite|en=fei=te|2 +ensaios|en=sai=os|2 +ensinassem|en=si=nas=sem|2 +ensinou|en=si=nou|2 +entenderam|en=ten=de=ram|2 +entendia|en=ten=dia|2 +enterrado|en=ter=ra=do|2 +enthusiasmado|en=thu=si=as=ma=do|2 +enthusiasmo|en=thu=si=as=mo|2 +entramos|en=tra=mos|2 +entrasse|en=tras=se|2 +entregou|en=tre=gou|2 +Entrou|En=trou|2 +envenenadas|en=ve=ne=na=das|2 +envolver|en=vol=ver|2 +envolvia|en=vol=via|2 +enxugava|en=xu=ga=va|2 +erguer|er=guer|2 +Ergueu|Er=gueu|2 +esburacando|es=bu=ra=can=do|2 +escadas|es=ca=das|2 +escapava|es=ca=pa=va|2 +escassa|es=cas=sa|2 +escolas|es=co=las|2 +escolhi|es=co=lhi|2 +escolhia|es=co=lhia|2 +esconder|es=con=der|2 +escondidas|es=con=di=das|2 +escondido|es=con=di=do|2 +escrava|es=cra=va|2 +escravas|es=cra=vas|2 +escravo|es=cra=vo|2 +escrevesse|es=cre=ves=se|2 +escreveu|es=cre=veu|2 +escrevia|es=cre=via|2 +escrevo|es=cre=vo|2 +escriptos|es=crip=tos|2 +escuros|es=cu=ros|2 +escutar|es=cu=tar|2 +escute|es=cu=te|2 +espairecer|es=pai=re=cer|2 +espantou|es=pan=tou|2 +espectador|es=pec=ta=dor|2 +espelhinho|es=pe=lhi=nho|2 +espertar|es=per=tar|2 +espontaneamente|es=pon=ta=ne=a=men=te|2 +esquecendo|es=que=cen=do|2 +esquecera|es=que=ce=ra|2 +esquecia|es=que=cia|2 +esquecidas|es=que=ci=das|2 +esquecimentos|es=que=ci=men=tos|2 +esquerdo|es=quer=do|2 +essence|es=sen=ce|2 +essencial|es=sen=ci=al|2 +Estados|Es=ta=dos|2 +estará|es=ta=rá|2 +estendendo|es=ten=den=do|2 +estendeu|es=ten=deu|2 +esticadas|es=ti=ca=das|2 +estimava|es=ti=ma=va|2 +estimei|es=ti=mei|2 +estirado|es=ti=ra=do|2 +Estive|Es=ti=ve|2 +estivessem|es=ti=ves=sem|2 +estomago|es=to=ma=go|2 +estreitas|es=trei=tas|2 +estudando|es=tu=dan=do|2 +estudo|es=tu=do|2 +estupefacção|es=tu=pe=fac=ção|2 +estupido|es=tu=pi=do|2 +eternas|eter=nas|2 +eterno|eter=no|2 +eternos|eter=nos|2 +examinei|exa=mi=nei|2 +Except|Ex=cept|2 +exclamava|ex=cla=ma=va|2 +exclamei|ex=cla=mei|2 +executada|exe=cu=ta=da|2 +executar|exe=cu=tar|2 +execução|exe=cu=ção|2 +exemplos|exem=plos|2 +exempt|exempt|2 +existe|exis=te|2 +expansivo|ex=pan=si=vo|2 +expenses|ex=pen=ses|2 +explanation|ex=pla=na=ti=on|2 +explicam|ex=pli=cam|2 +explicava|ex=pli=ca=va|2 +extincto|ex=tinc=to|2 +extranhos|ex=tra=nhos|2 +fadiga|fa=di=ga|2 +falara|fa=la=ra|2 +falavamos|fa=la=va=mos|2 +falemos|fa=le=mos|2 +faltam|fal=tam|2 +faltando|fal=tan=do|2 +faltasse|fal=tas=se|2 +falámos|fa=lá=mos|2 +falára|fa=lá=ra|2 +familiaridade|fa=mi=li=a=ri=da=de|2 +fantasia|fan=ta=sia|2 +fariamos|fa=ri=a=mos|2 +favores|fa=vo=res|2 +fechadas|fe=cha=das|2 +fechal|fe=chal|2 +federal|fe=de=ral|2 +Fernandes|Fer=nan=des|2 +ficando|fi=can=do|2 +Ficando|Fi=can=do|2 +ficará|fi=ca=rá|2 +ficassem|fi=cas=sem|2 +Ficámos|Fi=cá=mos|2 +fidelidade|fi=de=li=da=de|2 +filhinha|fi=lhi=nha|2 +fineza|fi=ne=za|2 +finezas|fi=ne=zas|2 +fitando|fi=tan=do|2 +flôres|flô=res|2 +folhear|fo=lhe=ar|2 +folheto|fo=lhe=to|2 +formats|for=mats|2 +fortalecer|for=ta=le=cer|2 +fossemos|fos=se=mos|2 +francez|fran=cez|2 +franqueza|fran=que=za|2 +fructo|fruc=to|2 +fugitivos|fu=gi=ti=vos|2 +fundidas|fun=di=das|2 +furiosas|fu=ri=o=sas|2 +fórmas|fór=mas|2 +gaiola|gai=o=la|2 +ganhar|ga=nhar|2 +gastar|gas=tar|2 +gastaria|gas=ta=ria|2 +gastámos|gas=tá=mos|2 +generations|ge=ne=ra=ti=ons|2 +gerado|ge=ra=do|2 +gestes|ges=tes|2 +gostaria|gos=ta=ria|2 +gostasse|gos=tas=se|2 +gostavas|gos=ta=vas|2 +grades|gra=des|2 +graduação|gra=du=a=ção|2 +gravador|gra=va=dor|2 +gravemente|gra=ve=men=te|2 +gravura|gra=vu=ra|2 +gravuras|gra=vu=ras|2 +Grieux|Gri=eux|2 +grossas|gros=sas|2 +guardada|guar=da=da|2 +guarde|guar=de|2 +habitos|ha=bi=tos|2 +havemos|ha=ve=mos|2 +havido|ha=vi=do|2 +Historia|His=to=ria|2 +hombros|hom=bros|2 +homeopatha|ho=me=o=pa=tha|2 +homeopathia|ho=me=o=pa=thia|2 +honestidade|ho=nes=ti=da=de|2 +honorarios|ho=no=ra=ri=os|2 +horrivel|hor=ri=vel|2 +hospedarias|hos=pe=da=ri=as|2 +houver|hou=ver|2 +houvessem|hou=ves=sem|2 +humano|hu=ma=no|2 +humidos|hu=mi=dos|2 +Imagina|Ima=gi=na|2 +imaginei|ima=gi=nei|2 +imital|imi=tal|2 +Imitar|Imi=tar|2 +immediate|im=me=di=a=te|2 +immensa|im=men=sa|2 +importancia|im=por=tan=cia|2 +imprimir|im=pri=mir|2 +improprias|im=pro=pri=as|2 +incerto|in=cer=to|2 +incidentes|in=ci=den=tes|2 +inclinava|in=cli=na=va|2 +Inclinei|In=cli=nei|2 +inclinou|in=cli=nou|2 +inconciliaveis|in=con=ci=li=a=veis|2 +inconscientemente|in=cons=ci=en=te=men=te|2 +incutir|in=cu=tir|2 +INDICE|IN=DI=CE|2 +indignação|in=dig=na=ção|2 +inexplicaveis|inex=pli=ca=veis|2 +infecta|in=fec=ta|2 +ingenuidade|in=ge=nui=da=de|2 +iniciou|ini=ci=ou|2 +inimigos|ini=mi=gos|2 +inquieta|in=qui=e=ta|2 +insistencia|in=sis=ten=cia|2 +inspiração|ins=pi=ra=ção|2 +inteiro|in=tei=ro|2 +intellectual|in=tel=lec=tu=al|2 +intensos|in=ten=sos|2 +interessantes|in=te=res=san=tes|2 +interina|in=te=ri=na|2 +interinidade|in=te=ri=ni=da=de|2 +interlocutor|in=ter=lo=cu=tor|2 +internuncio|in=ter=nun=cio|2 +interrogativamente|in=ter=ro=ga=ti=va=men=te|2 +interrogação|in=ter=ro=ga=ção|2 +interrompesse|in=ter=rom=pes=se|2 +interrompi|in=ter=rom=pi|2 +interrupção|in=ter=rup=ção|2 +intervallo|in=ter=val=lo|2 +intervallos|in=ter=val=los|2 +interveiu|in=ter=veiu|2 +intimas|in=ti=mas|2 +intimo|in=ti=mo|2 +intimou|in=ti=mou|2 +intrigas|in=tri=gas|2 +intuito|in=tui=to|2 +inutil|inu=til|2 +inventou|in=ven=tou|2 +iriamos|iri=a=mos|2 +irmãos|ir=mãos|2 +ironia|iro=nia|2 +irritada|ir=ri=ta=da|2 +Italia|Ita=lia|2 +jantares|jan=ta=res|2 +jogava|jo=ga=va|2 +juramentos|ju=ra=men=tos|2 +Jurámos|Ju=rá=mos|2 +juventude|ju=ven=tu=de|2 +keeping|kee=ping|2 +lacuna|la=cu=na|2 +lacunas|la=cu=nas|2 +largos|lar=gos|2 +latino|la=ti=no|2 +Leitor|Lei=tor|2 +lembrado|lem=bra=do|2 +lembras|lem=bras|2 +lençol|len=çol|2 +lepida|le=pi=da|2 +lepido|le=pi=do|2 +letras|le=tras|2 +levado|le=va=do|2 +levando|le=van=do|2 +levanta|le=van=ta|2 +levantava|le=van=ta=va|2 +levaram|le=va=ram|2 +levemente|le=ve=men=te|2 +liability|li=a=bi=lity|2 +limited|li=mi=ted|2 +limpas|lim=pas|2 +lindissima|lin=dis=si=ma|2 +lindos|lin=dos|2 +linguagem|lin=gua=gem|2 +linhas|li=nhas|2 +logares|lo=ga=res|2 +logica|lo=gi=ca|2 +longamente|lon=ga=men=te|2 +louvar|lou=var|2 +Luciano|Lu=ci=a=no|2 +LXVIII|LX=VI=II|2 +LXXIII|LX=XI=II|2 +LXXVII|LXX=VII|2 +LXXVIII|LXX=VI=II|2 +LXXXII|LXX=XII|2 +LXXXIII|LXX=XI=II|2 +LXXXIV|LXX=XIV|2 +LXXXIX|LXX=XIX|2 +LXXXVI|LXXX=VI|2 +LXXXVII|LXXX=VII|2 +LXXXVIII|LXXX=VI=II|2 +MACHADO|MA=CHA=DO|2 +maduro|ma=du=ro|2 +magnifico|mag=ni=fi=co|2 +maluca|ma=lu=ca|2 +maluquice|ma=lu=qui=ce|2 +mandasse|man=das=se|2 +Mandei|Man=dei|2 +Marcolini|Mar=co=li=ni|2 +maridos|ma=ri=dos|2 +marked|marked|2 +marmore|mar=mo=re|2 +mataria|ma=ta=ria|2 +materna|ma=ter=na|2 +matrimonio|ma=tri=mo=nio|2 +maxima|ma=xi=ma|2 +Melhor|Me=lhor|2 +mendigo|men=di=go|2 +meninice|me=ni=ni=ce|2 +meninos|me=ni=nos|2 +mental|men=tal|2 +mentiroso|men=ti=ro=so|2 +merecem|me=re=cem|2 +metaphoras|me=tap=ho=ras|2 +metaphysica|me=taphy=si=ca|2 +metteram|met=te=ram|2 +milhares|mi=lha=res|2 +mineira|mi=nei=ra|2 +minucias|mi=nu=ci=as|2 +misericordia|mi=se=ri=cor=dia|2 +miudas|miu=das|2 +miudos|miu=dos|2 +moderação|mo=de=ra=ção|2 +moldura|mol=du=ra|2 +molestia|mo=les=tia|2 +momentos|mo=men=tos|2 +monotonia|mo=no=to=nia|2 +montanhas|mon=ta=nhas|2 +morando|mo=ran=do|2 +moravam|mo=ra=vam|2 +mordendo|mor=den=do|2 +mordeu|mor=deu|2 +morresse|mor=res=se|2 +morressem|mor=res=sem|2 +mostrador|mos=tra=dor|2 +mostrasse|mos=tras=se|2 +mostravam|mos=tra=vam|2 +mostre|mos=tre|2 +Mostrei|Mos=trei|2 +Muitos|Mui=tos|2 +murmurando|mur=mu=ran=do|2 +musculos|mus=cu=los|2 +musico|mu=si=co|2 +mysteriosa|mys=te=ri=o=sa|2 +mystica|mys=ti=ca|2 +namorado|na=mo=ra=do|2 +namorava|na=mo=ra=va|2 +naquellas|na=quel=las|2 +nascidos|nas=ci=dos|2 +necessário|ne=ces=sá=rio|2 +negando|ne=gan=do|2 +negativa|ne=ga=ti=va|2 +negociante|ne=go=ci=an=te|2 +nervos|ner=vos|2 +Ninguem|Nin=guem|2 +notaram|no=ta=ram|2 +notice|no=ti=ce|2 +Novamente|No=va=men=te|2 +novelle|no=vel=le|2 +novidade|no=vi=da=de|2 +numeros|nu=me=ros|2 +numerosa|nu=me=ro=sa|2 +numerosas|nu=me=ro=sas|2 +nupcial|nup=ci=al|2 +obedecesse|obe=de=ces=se|2 +obedecia|obe=de=cia|2 +obriga|obri=ga|2 +Obrigado|Obri=ga=do|2 +obrigava|obri=ga=va|2 +obrigações|obri=ga=ções|2 +obrigou|obri=gou|2 +obscuro|obs=cu=ro|2 +obtaining|ob=tai=ning|2 +October|Oc=to=ber|2 +offereceu|of=fe=re=ceu|2 +offerecia|of=fe=re=cia|2 +Olhava|Olha=va|2 +olhámos|olhá=mos|2 +olvido|ol=vi=do|2 +opposto|op=pos=to|2 +optimo|op=ti=mo|2 +orador|ora=dor|2 +oratorio|ora=to=rio|2 +oratorios|ora=to=ri=os|2 +ordena|or=de=na|2 +ordenar|or=de=nar|2 +ordenou|or=de=nou|2 +orphão|orp=hão|2 +orthographia|or=tho=grap=hia|2 +osculo|os=cu=lo|2 +outside|out=si=de|2 +ouvida|ou=vi=da|2 +Ouvimos|Ou=vi=mos|2 +padecia|pa=de=cia|2 +padecido|pa=de=ci=do|2 +padecimentos|pa=de=ci=men=tos|2 +pagaria|pa=ga=ria|2 +pagasse|pa=gas=se|2 +paizagens|pai=za=gens|2 +pallido|pal=li=do|2 +palmas|pal=mas|2 +palpitar|pal=pi=tar|2 +pancadas|pan=ca=das|2 +Pandora|Pan=do=ra|2 +panegyrico|pa=negy=ri=co|2 +papelinho|pa=pe=li=nho|2 +paquete|pa=que=te|2 +parado|pa=ra=do|2 +parando|pa=ran=do|2 +Paraná|Pa=ra=ná|2 +pararam|pa=ra=ram|2 +parecida|pa=re=ci=da|2 +pareça|pa=re=ça|2 +parochia|pa=ro=chia|2 +partia|par=tia|2 +partida|par=ti=da|2 +passados|pas=sa=dos|2 +passagens|pas=sa=gens|2 +passam|pas=sam|2 +passaro|pas=sa=ro|2 +passasse|pas=sas=se|2 +passavamos|pas=sa=va=mos|2 +passeadores|pas=se=a=do=res|2 +Passei|Pas=sei|2 +passeio|pas=seio|2 +passeios|pas=sei=os|2 +Passou|Pas=sou|2 +patamar|pa=ta=mar|2 +paternidade|pa=ter=ni=da=de|2 +pedacinho|pe=da=ci=nho|2 +pediam|pe=di=am|2 +pediria|pe=di=ria|2 +pedisse|pe=dis=se|2 +pegado|pe=ga=do|2 +pegando|pe=gan=do|2 +pendulo|pen=du=lo|2 +pensado|pen=sa=do|2 +pensamentos|pen=sa=men=tos|2 +pentear|pen=te=ar|2 +peores|pe=o=res|2 +pequenas|pe=que=nas|2 +perdendo|per=den=do|2 +perderam|per=de=ram|2 +perdoa|per=doa|2 +perdoai|per=do=ai|2 +perdoe|per=doe|2 +Pereira|Pe=rei=ra|2 +perfeitissimo|per=fei=tis=si=mo|2 +perfil|per=fil|2 +perfis|per=fis|2 +perguntando|per=gun=tan=do|2 +Perguntei|Per=gun=tei|2 +Perguntou|Per=gun=tou|2 +periodo|pe=ri=o=do|2 +permitted|per=mit=ted|2 +pernadas|per=na=das|2 +perola|pe=ro=la|2 +perversa|per=ver=sa|2 +pessoaes|pes=so=a=es|2 +Petropolis|Pe=tro=po=lis|2 +pharmacia|phar=ma=cia|2 +physical|phy=si=cal|2 +physicas|phy=si=cas|2 +pintou|pin=tou|2 +pinturas|pin=tu=ras|2 +pirueta|pi=ru=e=ta|2 +placida|pla=ci=da|2 +Platão|Pla=tão|2 +Plutarcho|Plu=tar=cho|2 +Pobres|Po=bres|2 +podeis|po=deis|2 +podiamos|po=di=a=mos|2 +podridão|po=dri=dão|2 +poetas|po=e=tas|2 +polidez|po=li=dez|2 +politicos|po=li=ti=cos|2 +ponderou|pon=de=rou|2 +portador|por=ta=dor|2 +portanto|por=tan=to|2 +posição|po=si=ção|2 +possue|pos=sue|2 +poupada|pou=pa=da|2 +pousou|pou=sou|2 +pranto|pran=to|2 +Preciso|Pre=ci=so|2 +precisos|pre=ci=sos|2 +premios|pre=mi=os|2 +premissa|pre=mis=sa|2 +prendia|pren=dia|2 +prepare|pre=pa=re|2 +pressas|pres=sas|2 +Priamo|Pri=a=mo|2 +principaes|prin=ci=pa=es|2 +printed|prin=ted|2 +procural|pro=cu=ral|2 +proferi|pro=fe=ri|2 +proferir|pro=fe=rir|2 +Promette|Pro=met=te|2 +Prometti|Pro=met=ti|2 +prominently|pro=mi=nen=tly|2 +promoting|pro=mo=ting|2 +promptamente|promp=ta=men=te|2 +property|pro=perty|2 +protect|pro=tect|2 +protecção|pro=tec=ção|2 +provar|pro=var|2 +proximas|pro=xi=mas|2 +pudessem|pu=des=sem|2 +purgatorio|pur=ga=to=rio|2 +puxando|pu=xan=do|2 +quadros|qua=dros|2 +qualidade|qua=li=da=de|2 +Qualquer|Qual=quer|2 +quantia|quan=tia|2 +quaresma|qua=res=ma|2 +quartel|quar=tel|2 +querem|que=rem|2 +queres|que=res|2 +quieta|qui=e=ta|2 +Quitanda|Qui=tan=da|2 +rabeca|ra=be=ca|2 +ralhava|ra=lha=va|2 +rapada|ra=pa=da|2 +rapagão|ra=pa=gão|2 +rasgos|ras=gos|2 +readable|re=a=da=ble|2 +recebedor|re=ce=be=dor|2 +recebem|re=ce=bem|2 +recebera|re=ce=be=ra|2 +recebidas|re=ce=bi=das|2 +receiava|re=cei=a=va|2 +receioso|re=cei=o=so|2 +receipt|re=ceipt|2 +receive|re=cei=ve|2 +recitando|re=ci=tan=do|2 +recitou|re=ci=tou|2 +reclusão|re=clu=são|2 +reconciliar|re=con=ci=li=ar|2 +reconhecer|re=co=nhe=cer|2 +recreio|re=creio|2 +recuou|re=cuou|2 +recurso|re=cur=so|2 +recusar|re=cu=sar|2 +recusaria|re=cu=sa=ria|2 +redarguiu|re=dar=guiu|2 +refazel|re=fa=zel|2 +references|re=fe=ren=ces|2 +reflecti|re=flec=ti|2 +reflectiu|re=flec=tiu|2 +registered|re=gis=te=red|2 +rejeitar|re=jei=tar|2 +rejeitei|re=jei=tei|2 +rejeitou|re=jei=tou|2 +relata|re=la=ta|2 +relativamente|re=la=ti=va=men=te|2 +relembrar|re=lem=brar|2 +relevação|re=le=va=ção|2 +remedios|re=me=di=os|2 +remoto|re=mo=to|2 +removed|re=mo=ved|2 +rendas|ren=das|2 +rendeu|ren=deu|2 +reparava|re=pa=ra=va|2 +reparação|re=pa=ra=ção|2 +reparou|re=pa=rou|2 +Repeti|Re=pe=ti|2 +repetiria|re=pe=ti=ria|2 +reprehendeu|re=pre=hen=deu|2 +reproducção|re=pro=duc=ção|2 +reproduziam|re=pro=du=zi=am|2 +repugnancia|re=pug=nan=cia|2 +research|re=se=ar=ch|2 +resentimentos|re=sen=ti=men=tos|2 +resolver|re=sol=ver|2 +resolvido|re=sol=vi=do|2 +respirava|res=pi=ra=va|2 +respiração|res=pi=ra=ção|2 +respondendo|res=pon=den=do|2 +respondia|res=pon=dia|2 +restituiu|res=ti=tuiu|2 +restrictions|res=tric=ti=ons|2 +resurreição|re=sur=rei=ção|2 +retalhos|re=ta=lhos|2 +retirou|re=ti=rou|2 +retorquia|re=tor=quia|2 +ridiculas|ri=di=cu=las|2 +riscando|ris=can=do|2 +riscava|ris=ca=va|2 +risonha|ri=so=nha|2 +risquei|ris=quei|2 +ritual|ri=tu=al|2 +rivalidade|ri=va=li=da=de|2 +robusto|ro=bus=to|2 +rodaque|ro=da=que|2 +roidos|roi=dos|2 +rolava|ro=la=va|2 +romances|ro=man=ces|2 +romper|rom=per|2 +rompeu|rom=peu|2 +Royalty|Royalty|2 +roçavam|ro=ça=vam|2 +ruidosa|rui=do=sa|2 +Russia|Rus=sia|2 +Sabbado|Sab=ba=do|2 +saltou|sal=tou|2 +salvar|sal=var|2 +salvação|sal=va=ção|2 +Sandia|San=dia|2 +satisfazer|sa=tis=fa=zer|2 +satisfação|sa=tis=fa=ção|2 +scenas|sce=nas|2 +sciencia|sci=en=cia|2 +scismar|scis=mar|2 +second|se=cond|2 +segredou|se=gre=dou|2 +seguida|se=gui=da|2 +seguintes|se=guin=tes|2 +Seguiu|Se=guiu|2 +segundos|se=gun=dos|2 +Seguramente|Se=gu=ra=men=te|2 +seguro|se=gu=ro|2 +Senado|Se=na=do|2 +senhoras|se=nho=ras|2 +sentada|sen=ta=da|2 +sentado|sen=ta=do|2 +sentados|sen=ta=dos|2 +sentence|sen=ten=ce|2 +sentidos|sen=ti=dos|2 +separados|se=pa=ra=dos|2 +serenas|se=re=nas|2 +sermos|ser=mos|2 +servil|ser=vil|2 +sestercio|ses=ter=cio|2 +sexual|se=xu=al|2 +silencios|si=len=ci=os|2 +simplesmente|sim=ples=men=te|2 +sobresalto|so=bre=sal=to|2 +socegada|so=ce=ga=da|2 +sociedade|so=ci=e=da=de|2 +soldado|sol=da=do|2 +solemne|so=lem=ne|2 +solicit|so=li=cit|2 +sombrios|som=bri=os|2 +somente|so=men=te|2 +sommando|som=man=do|2 +sommar|som=mar|2 +somnos|som=nos|2 +sonhei|so=nhei|2 +sonhára|so=nhá=ra|2 +soprano|so=pra=no|2 +Sorriu|Sor=riu|2 +sortes|sor=tes|2 +souberam|sou=be=ram|2 +specified|spe=ci=fi=ed|2 +substancia|subs=tan=cia|2 +Succedeu|Suc=ce=deu|2 +suggestão|sug=ges=tão|2 +Suissa|Suis=sa|2 +supplicas|sup=pli=cas|2 +supplices|sup=pli=ces|2 +supponho|sup=po=nho|2 +surdas|sur=das|2 +surdos|sur=dos|2 +suspeitar|sus=pei=tar|2 +suspeitas|sus=pei=tas|2 +Suspendamos|Sus=pen=da=mos|2 +suspensorios|sus=pen=so=ri=os|2 +suspirava|sus=pi=ra=va|2 +suspiro|sus=pi=ro|2 +Suspirou|Sus=pi=rou|2 +sussurrava|sus=sur=ra=va|2 +séstro|sés=tro|2 +sósinhos|só=si=nhos|2 +tamanha|ta=ma=nha|2 +tardou|tar=dou|2 +Tartaruga|Tar=ta=ru=ga|2 +teimarem|tei=ma=rem|2 +temendo|te=men=do|2 +tenhas|te=nhas|2 +tentar|ten=tar|2 +tentação|ten=ta=ção|2 +Tentei|Ten=tei|2 +ternos|ter=nos|2 +terriveis|ter=ri=veis|2 +testamento|tes=ta=men=to|2 +therapeutica|the=ra=peu=ti=ca|2 +Theresa|The=re=sa|2 +Thetis|The=tis|2 +things|things|2 +Thomaz|Tho=maz|2 +through|through|2 +timida|ti=mi=da|2 +Tinham|Ti=nham|2 +Tinhamos|Ti=nha=mos|2 +tirada|ti=ra=da|2 +tirando|ti=ran=do|2 +tiraram|ti=ra=ram|2 +tirava|ti=ra=va|2 +tocado|to=ca=do|2 +tocando|to=can=do|2 +Todavia|To=da=via|2 +tomando|to=man=do|2 +tornaram|tor=na=ram|2 +trabalhador|tra=ba=lha=dor|2 +trabalhava|tra=ba=lha=va|2 +tragar|tra=gar|2 +transcrever|trans=cre=ver|2 +traria|tra=ria|2 +tratal|tra=tal|2 +tratamento|tra=ta=men=to|2 +travesseiro|tra=ves=sei=ro|2 +tremula|tre=mu=la|2 +triumphalmente|triump=hal=men=te|2 +trocar|tro=car|2 +trocara|tro=ca=ra|2 +trocariam|tro=ca=ri=am|2 +tronco|tron=co|2 +tropel|tro=pel|2 +trouxeram|trou=xe=ram|2 +trouxesse|trou=xes=se|2 +turvação|tur=va=ção|2 +ultimamente|ul=ti=ma=men=te|2 +ungido|un=gi=do|2 +unindo|unin=do|2 +universal|uni=ver=sal|2 +universidades|uni=ver=si=da=des|2 +unless|un=less|2 +utilidade|uti=li=da=de|2 +vadiação|va=di=a=ção|2 +vagaroso|va=ga=ro=so|2 +Vanilla|Va=nil=la|2 +variar|va=ri=ar|2 +varios|va=ri=os|2 +velhice|ve=lhi=ce|2 +vencel|ven=cel|2 +Venceu|Ven=ceu|2 +vendendo|ven=den=do|2 +vender|ven=der|2 +vendia|ven=dia|2 +veneração|ve=ne=ra=ção|2 +verdadeiramente|ver=da=dei=ra=men=te|2 +veremos|ve=re=mos|2 +verificar|ve=ri=fi=car|2 +verniz|ver=niz|2 +version|ver=si=on|2 +vestia|ves=tia|2 +Vestia|Ves=tia|2 +vestidos|ves=ti=dos|2 +vexada|ve=xa=da|2 +vexado|ve=xa=do|2 +victoria|vic=to=ria|2 +viemos|vi=e=mos|2 +Vieram|Vi=e=ram|2 +vinculo|vin=cu=lo|2 +vindouras|vin=dou=ras|2 +vingança|vin=gan=ça|2 +vingasse|vin=gas=se|2 +Virgem|Vir=gem|2 +viriam|vi=ri=am|2 +vissem|vis=sem|2 +visões|vi=sões|2 +Vivemos|Vi=ve=mos|2 +viviam|vi=vi=am|2 +voltada|vol=ta=da|2 +voltarete|vol=ta=re=te|2 +voltavam|vol=ta=vam|2 +voltavamos|vol=ta=va=mos|2 +Voltei|Vol=tei|2 +voltámos|vol=tá=mos|2 +voltára|vol=tá=ra|2 +WARRANTIES|WAR=RAN=TI=ES|2 +WARRANTY|WAR=RANTY|2 +whatsoever|what=so=e=ver|2 +widest|wi=dest|2 +XCVIII|XC=VI=II|2 +XLVIII|XL=VI=II|2 +XXVIII|XX=VI=II|2 +XXXIII|XX=XI=II|2 +XXXVII|XXX=VII|2 +XXXVIII|XXX=VI=II|2 +áquelle|áquel=le|2 +abafadas|aba=fa=das|1 +abafado|aba=fa=do|1 +abafando|aba=fan=do|1 +abafava|aba=fa=va|1 +abahuladas|aba=hu=la=das|1 +abaixou|abai=xou|1 +abalou|aba=lou|1 +abanando|aba=nan=do|1 +abandone|aban=do=ne|1 +abarcar|abar=car|1 +abatimento|aba=ti=men=to|1 +abençoa|aben=çoa|1 +abençoar|aben=ço=ar|1 +aberração|aber=ra=ção|1 +abertas|aber=tas|1 +abobada|abo=ba=da|1 +abolir|abo=lir|1 +abominaveis|abo=mi=na=veis|1 +abominação|abo=mi=na=ção|1 +abominações|abo=mi=na=ções|1 +aborrece|abor=re=ce|1 +aborrecem|abor=re=cem|1 +aborreciamos|abor=re=ci=a=mos|1 +abortado|abor=ta=do|1 +abotoada|abo=to=a=da|1 +abotoado|abo=to=a=do|1 +abotoe|abo=toe|1 +Abrahão|Abra=hão|1 +abraçados|abra=ça=dos|1 +abraçará|abra=ça=rá|1 +abraço|abra=ço|1 +abraços|abra=ços|1 +abreviado|abre=vi=a=do|1 +abrigado|abri=ga=do|1 +Abrimos|Abri=mos|1 +abrissemos|abris=se=mos|1 +abrochado|abro=cha=do|1 +abrochou|abro=chou|1 +abríamos|abrí=a=mos|1 +absoluta|ab=so=lu=ta|1 +absolve|ab=sol=ve|1 +absolvido|ab=sol=vi=do|1 +abusava|abu=sa=va|1 +abysmo|abys=mo|1 +acabada|aca=ba=da|1 +acabadas|aca=ba=das|1 +acabaria|aca=ba=ria|1 +acabas|aca=bas|1 +acabavam|aca=ba=vam|1 +Acabei|Aca=bei|1 +Acabemos|Aca=be=mos|1 +Acabou|Aca=bou|1 +acabámos|aca=bá=mos|1 +acalcanhados|acal=ca=nha=dos|1 +acanhado|aca=nha=do|1 +acceita|ac=cei=ta|1 +acceitasse|ac=cei=tas=se|1 +acceitámos|ac=cei=tá=mos|1 +accendesse|ac=cen=des=se|1 +accendi|ac=cen=di|1 +accendia|ac=cen=dia|1 +accendou|ac=cen=dou|1 +accentuar|ac=cen=tu=ar|1 +accentuou|ac=cen=tuou|1 +accept|ac=cept|1 +accepting|ac=cep=ting|1 +accepção|ac=cep=ção|1 +accessed|ac=ces=sed|1 +accessible|ac=ces=si=ble|1 +accessoriamente|ac=ces=so=ri=a=men=te|1 +accidente|ac=ci=den=te|1 +accionados|ac=ci=o=na=dos|1 +Acclamação|Ac=cla=ma=ção|1 +accommodava|ac=com=mo=da=va|1 +accordando|ac=cor=dan=do|1 +accordar|ac=cor=dar|1 +accordasse|ac=cor=das=se|1 +accordava|ac=cor=da=va|1 +Accordei|Ac=cor=dei|1 +accresce|ac=cres=ce|1 +accrescentando|ac=cres=cen=tan=do|1 +accrescentar|ac=cres=cen=tar|1 +accrescentasse|ac=cres=cen=tas=se|1 +accrescentava|ac=cres=cen=ta=va|1 +accrescentei|ac=cres=cen=tei|1 +accressimo|ac=cres=si=mo|1 +accusada|ac=cu=sa=da|1 +accusou|ac=cu=sou|1 +acertado|acer=ta=do|1 +achacada|acha=ca=da|1 +achados|acha=dos|1 +achardes|achar=des|1 +achares|acha=res|1 +acharás|acha=rás|1 +achaste|achas=te|1 +achatando|acha=tan=do|1 +achavam|acha=vam|1 +Achavam|Acha=vam|1 +acocorou|aco=co=rou|1 +acompanham|acom=pa=nham|1 +acompanhando|acom=pa=nhan=do|1 +acompanhara|acom=pa=nha=ra|1 +acompanhei|acom=pa=nhei|1 +acompanhou|acom=pa=nhou|1 +aconselhados|acon=se=lha=dos|1 +acontecer|acon=te=cer|1 +aconteceu|acon=te=ceu|1 +acontecido|acon=te=ci=do|1 +acontecimento|acon=te=ci=men=to|1 +aconteça|acon=te=ça|1 +acostumada|acos=tu=ma=da|1 +acostumados|acos=tu=ma=dos|1 +acostumara|acos=tu=ma=ra|1 +acostumaria|acos=tu=ma=ria|1 +acostumasse|acos=tu=mas=se|1 +acredita|acre=di=ta|1 +acreditam|acre=di=tam|1 +acredite|acre=di=te|1 +activo|ac=ti=vo|1 +actriz|ac=triz|1 +ACTUAL|AC=TU=AL|1 +acudia|acu=dia|1 +acudiamos|acu=di=a=mos|1 +acudido|acu=di=do|1 +acudissem|acu=dis=sem|1 +addition|ad=di=ti=on|1 +Additional|Ad=di=ti=o=nal|1 +additions|ad=di=ti=ons|1 +address|ad=dress|1 +addresses|ad=dres=ses|1 +adeantemos|ade=an=te=mos|1 +adeantou|ade=an=tou|1 +adequado|ade=qua=do|1 +adereço|ade=re=ço|1 +adeuses|adeu=ses|1 +adiadas|adi=a=das|1 +adiando|adi=an=do|1 +adiante|adi=an=te|1 +adiava|adi=a=va|1 +adivinha|adi=vi=nha|1 +adivinhai|adi=vi=nhai|1 +adivinham|adi=vi=nham|1 +adivinhar|adi=vi=nhar|1 +adivinhassem|adi=vi=nhas=sem|1 +adivinhei|adi=vi=nhei|1 +administrativa|ad=mi=nis=tra=ti=va|1 +administrava|ad=mi=nis=tra=va|1 +admirar|ad=mi=rar|1 +Admirava|Ad=mi=ra=va|1 +admiraveis|ad=mi=ra=veis|1 +admire|ad=mi=re|1 +admitisse|ad=mi=tis=se|1 +admitti|ad=mit=ti|1 +adoecesse|ado=e=ces=se|1 +adoecido|ado=e=ci=do|1 +adoptar|adop=tar|1 +adorava|ado=ra=va|1 +adornar|ador=nar|1 +adorno|ador=no|1 +adornos|ador=nos|1 +adoçar|ado=çar|1 +adquiriu|ad=qui=riu|1 +aduladores|adu=la=do=res|1 +adversa|ad=ver=sa|1 +adversario|ad=ver=sa=rio|1 +adverso|ad=ver=so|1 +advertindo|ad=ver=tin=do|1 +advertir|ad=ver=tir|1 +afagou|afa=gou|1 +afastaram|afas=ta=ram|1 +afastassem|afas=tas=sem|1 +affagou|af=fa=gou|1 +affagára|af=fa=gá=ra|1 +affectos|af=fec=tos|1 +affiançar|af=fi=an=çar|1 +affirma|af=fir=ma|1 +affirmar|af=fir=mar|1 +affirmativamente|af=fir=ma=ti=va=men=te|1 +affirmações|af=fir=ma=ções|1 +affirmo|af=fir=mo|1 +Affirmou|Af=fir=mou|1 +affirmou|af=fir=mou|1 +afflicta|af=flic=ta|1 +afflictos|af=flic=tos|1 +afflige|af=fli=ge|1 +affligiam|af=fli=gi=am|1 +affligiamos|af=fli=gi=a=mos|1 +affligira|af=fli=gi=ra|1 +afflijo|af=fli=jo|1 +affluencia|af=flu=en=cia|1 +affrontar|af=fron=tar|1 +afiliação|afi=li=a=ção|1 +afinadas|afi=na=das|1 +afugental|afu=gen=tal|1 +against|against|1 +agarrado|agar=ra=do|1 +Agarrei|Agar=rei|1 +agastada|agas=ta=da|1 +aggrava|ag=gra=va|1 +aggravando|ag=gra=van=do|1 +aggravar|ag=gra=var|1 +aggravára|ag=gra=vá=ra|1 +agitado|agi=ta=do|1 +agitava|agi=ta=va|1 +agitações|agi=ta=ções|1 +agonia|ago=nia|1 +agoniada|ago=ni=a=da|1 +agoniado|ago=ni=a=do|1 +Agostinho|Agos=ti=nho|1 +agouro|agou=ro|1 +agradado|agra=da=do|1 +agradassem|agra=das=sem|1 +agradaveis|agra=da=veis|1 +agradeceram|agra=de=ce=ram|1 +Agradeceu|Agra=de=ceu|1 +agradecida|agra=de=ci=da|1 +agradecidos|agra=de=ci=dos|1 +agradou|agra=dou|1 +agreed|agreed|1 +AGREEMENT|AGREE=MENT|1 +Aguardei|Aguar=dei|1 +agudeza|agu=de=za|1 +agudezas|agu=de=zas|1 +Aguente|Aguen=te|1 +aguerril|aguer=ril|1 +Ajoelhei|Ajo=e=lhei|1 +Ajudava|Aju=da=va|1 +ajudava|aju=da=va|1 +ajuntamento|ajun=ta=men=to|1 +ajuntar|ajun=tar|1 +ajusta|ajus=ta|1 +ajustado|ajus=ta=do|1 +ajustam|ajus=tam|1 +ajustando|ajus=tan=do|1 +alargando|alar=gan=do|1 +alarido|ala=ri=do|1 +alazão|ala=zão|1 +albores|al=bo=res|1 +Albuquerque|Al=bu=quer=que|1 +Albuquerques|Al=bu=quer=ques|1 +alcançado|al=can=ça=do|1 +alcançava|al=can=ça=va|1 +Alcançou|Al=can=çou|1 +alcançou|al=can=çou|1 +alcovas|al=co=vas|1 +alcunhando|al=cu=nhan=do|1 +alegra|ale=gra|1 +alegral|ale=gral|1 +alegrou|ale=grou|1 +Alembra|Alem=bra|1 +Alencar|Alen=car|1 +alevantado|ale=van=ta=do|1 +Alexandre|Ale=xan=dre|1 +alfaias|al=fai=as|1 +alheio|alheio|1 +alisal|ali=sal|1 +alisar|ali=sar|1 +alisou|ali=sou|1 +aliviasse|ali=vi=as=se|1 +Aljube|Al=ju=be|1 +aljube|al=ju=be|1 +allegar|al=le=gar|1 +allegava|al=le=ga=va|1 +Allegou|Al=le=gou|1 +alliança|al=li=an=ça|1 +alliar|al=li=ar|1 +alliviada|al=li=vi=a=da|1 +allopathicas|al=lo=pa=thi=cas|1 +allucinado|al=lu=ci=na=do|1 +allucinações|al=lu=ci=na=ções|1 +alludir|al=lu=dir|1 +allumia|al=lu=mia|1 +almanaks|al=ma=naks|1 +almirante|al=mi=ran=te|1 +almocei|al=mo=cei|1 +almofada|al=mo=fa=da|1 +almoçando|al=mo=çan=do|1 +almoços|al=mo=ços|1 +alongar|alon=gar|1 +alpendres|al=pen=dres|1 +already|al=re=ady|1 +altares|al=ta=res|1 +altenção|al=ten=ção|1 +alterando|al=te=ran=do|1 +alteraram|al=te=ra=ram|1 +alterasse|al=te=ras=se|1 +alteration|al=te=ra=ti=on|1 +alteravamos|al=te=ra=va=mos|1 +alteração|al=te=ra=ção|1 +alterações|al=te=ra=ções|1 +alternate|al=ter=na=te|1 +alternativamente|al=ter=na=ti=va=men=te|1 +alternava|al=ter=na=va|1 +altitudes|al=ti=tu=des|1 +alturas|al=tu=ras|1 +alugados|alu=ga=dos|1 +alugara|alu=ga=ra|1 +alugasse|alu=gas=se|1 +alugou|alu=gou|1 +alumiada|alu=mi=a=da|1 +alumiou|alu=mi=ou|1 +Alvares|Al=va=res|1 +alvoroçado|al=vo=ro=ça=do|1 +alvoroçara|al=vo=ro=ça=ra|1 +alçapões|al=çapões|1 +amador|ama=dor|1 +amadores|ama=do=res|1 +amados|ama=dos|1 +amamentação|ama=men=ta=ção|1 +amamos|ama=mos|1 +Amanheceu|Ama=nhe=ceu|1 +amarella|ama=rel=la|1 +amarelladas|ama=rel=la=das|1 +amargamente|amar=ga=men=te|1 +amarguras|amar=gu=ras|1 +ambições|am=bi=ções|1 +ameacei|ame=a=cei|1 +ameaçando|ame=a=çan=do|1 +ameaçar|ame=a=çar|1 +ameaças|ame=a=ças|1 +ameaçava|ame=a=ça=va|1 +America|Ame=ri=ca|1 +americanas|ame=ri=ca=nas|1 +americanos|ame=ri=ca=nos|1 +amiguinha|ami=gui=nha|1 +amiguinho|ami=gui=nho|1 +amofina|amo=fi=na|1 +amofinal|amo=fi=nal|1 +amontoavam|amon=to=a=vam|1 +amorosa|amo=ro=sa|1 +amparal|am=pa=ral|1 +amparando|am=pa=ran=do|1 +amparar|am=pa=rar|1 +amuada|amu=a=da|1 +anatomia|ana=to=mia|1 +anceio|an=ceio|1 +ancianidade|an=ci=a=ni=da=de|1 +ancias|an=ci=as|1 +anciava|an=ci=a=va|1 +Anciava|An=ci=a=va|1 +anciosos|an=ci=o=sos|1 +andaram|an=da=ram|1 +andarilhas|an=da=ri=lhas|1 +andavamos|an=da=va=mos|1 +andaço|an=da=ço|1 +andára|an=dá=ra|1 +anecdotas|anec=do=tas|1 +angustia|an=gus=tia|1 +animado|ani=ma=do|1 +animaes|ani=ma=es|1 +animando|ani=man=do|1 +animar|ani=mar|1 +animava|ani=ma=va|1 +anjissimo|an=jis=si=mo|1 +anniquilação|an=ni=qui=la=ção|1 +annuncia|an=nun=cia|1 +annunciava|an=nun=ci=a=va|1 +anonyma|anony=ma|1 +anonymo|anony=mo|1 +antecipadamente|an=te=ci=pa=da=men=te|1 +antecipado|an=te=ci=pa=do|1 +antepôl|an=te=pôl|1 +antiguidade|an=ti=gui=da=de|1 +anything|any=thing|1 +ANYTHING|ANY=THING|1 +apagado|apa=ga=do|1 +apagam|apa=gam|1 +apaixonadamente|apai=xo=na=da=men=te|1 +apaixonar|apai=xo=nar|1 +apalpa|apal=pa|1 +apalpar|apal=par|1 +apalpei|apal=pei|1 +Apanhar|Apa=nhar|1 +apanhara|apa=nha=ra|1 +apanhava|apa=nha=va|1 +apanhára|apa=nhá=ra|1 +aparentado|apa=ren=ta=do|1 +apavorado|apa=vo=ra=do|1 +apeava|ape=a=va|1 +apertadas|aper=ta=das|1 +apertavam|aper=ta=vam|1 +aperto|aper=to|1 +aphorismo|ap=ho=ris=mo|1 +aphorismos|ap=ho=ris=mos|1 +apoiando|apoi=an=do|1 +apoiou|apoi=ou|1 +apolices|apo=li=ces|1 +apologias|apo=lo=gi=as|1 +aponta|apon=ta|1 +apontada|apon=ta=da|1 +apontar|apon=tar|1 +apontei|apon=tei|1 +aposentados|apo=sen=ta=dos|1 +aposentou|apo=sen=tou|1 +aposentára|apo=sen=tá=ra|1 +aposlolicos|apos=lo=li=cos|1 +aposta|apos=ta|1 +apostolado|apos=to=la=do|1 +apparece|ap=pa=re=ce|1 +apparecem|ap=pa=re=cem|1 +apparecera|ap=pa=re=ce=ra|1 +appareco|ap=pa=re=co|1 +apparente|ap=pa=ren=te|1 +Apparentemente|Ap=pa=ren=te=men=te|1 +apparentemente|ap=pa=ren=te=men=te|1 +appareçam|ap=pa=re=çam|1 +appear|ap=pe=ar|1 +appearing|ap=pe=a=ring|1 +appears|ap=pe=ars|1 +appellido|ap=pel=li=do|1 +appetecivel|ap=pe=te=ci=vel|1 +applaudiu|ap=plau=diu|1 +applauso|ap=plau=so|1 +applausos|ap=plau=sos|1 +applicar|ap=pli=car|1 +Applique|Ap=pli=que|1 +apprendem|ap=pren=dem|1 +apprendera|ap=pren=de=ra|1 +approach|ap=pro=a=ch|1 +approva|ap=pro=va|1 +approvado|ap=pro=va=do|1 +approvando|ap=pro=van=do|1 +approvar|ap=pro=var|1 +approvasse|ap=pro=vas=se|1 +approvava|ap=pro=va=va|1 +approvo|ap=pro=vo|1 +approximava|ap=pro=xi=ma=va|1 +approximação|ap=pro=xi=ma=ção|1 +Approximei|Ap=pro=xi=mei|1 +approximou|ap=pro=xi=mou|1 +aprazia|apra=zia|1 +apreciam|apre=ci=am|1 +apreciavel|apre=ci=a=vel|1 +apregoando|apre=go=an=do|1 +apresentado|apre=sen=ta=do|1 +apresentaram|apre=sen=ta=ram|1 +apressada|apres=sa=da|1 +apressado|apres=sa=do|1 +apressal|apres=sal|1 +apreço|apre=ço|1 +aprimorada|apri=mo=ra=da|1 +apropriada|apro=pri=a=da|1 +aproveitaria|apro=vei=ta=ria|1 +Aproveitei|Apro=vei=tei|1 +aproveitei|apro=vei=tei|1 +apurando|apu=ran=do|1 +aquarella|aqua=rel=la|1 +Aquelles|Aquel=les|1 +aquietou|aqui=e=tou|1 +arbusto|ar=bus=to|1 +archeologia|ar=che=o=lo=gia|1 +archeologias|ar=che=o=lo=gi=as|1 +architecto|ar=chi=tec=to|1 +ardente|ar=den=te|1 +ardiam|ar=di=am|1 +argolas|ar=go=las|1 +argolinha|ar=go=li=nha|1 +argumentação|ar=gu=men=ta=ção|1 +Ariosto|Ari=os=to|1 +arithmetica|ari=thme=ti=ca|1 +armando|ar=man=do|1 +aroeira|aro=ei=ra|1 +arquearam|ar=que=a=ram|1 +arrabalde|ar=ra=bal=de|1 +arrancal|ar=ran=cal|1 +arrancando|ar=ran=can=do|1 +arrancar|ar=ran=car|1 +arranhado|ar=ra=nha=do|1 +arranhados|ar=ra=nha=dos|1 +arranjado|ar=ran=ja=do|1 +Arranjava|Ar=ran=ja=va|1 +Arranjavamos|Ar=ran=ja=va=mos|1 +arranjavamos|ar=ran=ja=va=mos|1 +arranjos|ar=ran=jos|1 +arranquei|ar=ran=quei|1 +arrastava|ar=ras=ta=va|1 +arrebaldes|ar=re=bal=des|1 +arredado|ar=re=da=do|1 +arredei|ar=re=dei|1 +arredia|ar=re=dia|1 +arredondavam|ar=re=don=da=vam|1 +arregalados|ar=re=ga=la=dos|1 +arregalar|ar=re=ga=lar|1 +arrependia|ar=re=pen=dia|1 +arrependida|ar=re=pen=di=da|1 +arrependido|ar=re=pen=di=do|1 +arrepiar|ar=re=pi=ar|1 +arrepios|ar=re=pi=os|1 +arriscou|ar=ris=cou|1 +arrufámos|ar=ru=fá=mos|1 +articulou|ar=ti=cu=lou|1 +artificialmente|ar=ti=fi=ci=al=men=te|1 +artigo|ar=ti=go|1 +artilharia|ar=ti=lha=ria|1 +artinha|ar=ti=nha|1 +arvorecer|ar=vo=re=cer|1 +asperos|as=pe=ros|1 +aspirasse|as=pi=ras=se|1 +assassinato|as=sas=si=na=to|1 +assassinatos|as=sas=si=na=tos|1 +assassino|as=sas=si=no|1 +asseio|as=seio|1 +assentada|as=sen=ta=da|1 +assentarei|as=sen=ta=rei|1 +assentei|as=sen=tei|1 +assentiram|as=sen=ti=ram|1 +assersões|as=ser=sões|1 +assidua|as=si=dua|1 +assiduidade|as=si=dui=da=de|1 +assignei|as=sig=nei|1 +assistance|as=sis=tan=ce|1 +assistem|as=sis=tem|1 +assistir|as=sis=tir|1 +assistiu|as=sis=tiu|1 +assobia|as=so=bia|1 +assobiando|as=so=bi=an=do|1 +assobio|as=so=bio|1 +assobradada|as=so=bra=da=da|1 +assobradado|as=so=bra=da=do|1 +associal|as=so=ci=al|1 +assombrava|as=som=bra=va|1 +assombro|as=som=bro|1 +assombrou|as=som=brou|1 +assomos|as=so=mos|1 +assoou|as=so=ou|1 +assustal|as=sus=tal|1 +assustava|as=sus=ta=va|1 +assustou|as=sus=tou|1 +astronomica|as=tro=no=mi=ca|1 +astucia|as=tu=cia|1 +atadas|ata=das|1 +atados|ata=dos|1 +atalhar|ata=lhar|1 +aterrava|ater=ra=va|1 +atinai|ati=nai|1 +atinar|ati=nar|1 +atirasse|ati=ras=se|1 +atirava|ati=ra=va|1 +ativer|ati=ver|1 +Atlantico|Atlan=ti=co|1 +atopetadas|ato=pe=ta=das|1 +atordoaram|ator=do=a=ram|1 +atrapalhada|atra=pa=lha=da|1 +atrapalhados|atra=pa=lha=dos|1 +atrapálhado|atra=pá=lha=do|1 +atravessado|atra=ves=sa=do|1 +atravessal|atra=ves=sal|1 +atravessavam|atra=ves=sa=vam|1 +atravessei|atra=ves=sei|1 +atravessou|atra=ves=sou|1 +atrazados|atra=za=dos|1 +atrevi|atre=vi|1 +atrevido|atre=vi=do|1 +atrevimento|atre=vi=men=to|1 +atrevo|atre=vo|1 +atropelladamente|atro=pel=la=da=men=te|1 +atropelo|atro=pe=lo|1 +attached|at=ta=ched|1 +attendido|at=ten=di=do|1 +attentamente|at=ten=ta=men=te|1 +attentando|at=ten=tan=do|1 +attentas|at=ten=tas|1 +attenuada|at=te=nu=a=da|1 +attenuando|at=te=nu=an=do|1 +attenuar|at=te=nu=ar|1 +attenções|at=ten=ções|1 +attinge|at=tin=ge|1 +attitude|at=ti=tu=de|1 +attitudes|at=ti=tu=des|1 +attracção|at=trac=ção|1 +attrahia|at=tra=hia|1 +attribuira|at=tri=bui=ra|1 +augmentar|aug=men=tar|1 +augmentará|aug=men=ta=rá|1 +augmentem|aug=men=tem|1 +augmentára|aug=men=tá=ra|1 +augustissimo|au=gus=tis=si=mo|1 +Aurora|Au=ro=ra|1 +aurora|au=ro=ra|1 +ausente|au=sen=te|1 +austera|aus=te=ra|1 +authentico|au=then=ti=co|1 +Author|Au=thor|1 +autopsias|au=top=si=as|1 +autoraes|au=to=ra=es|1 +autores|au=to=res|1 +auxilio|au=xi=lio|1 +avaliasse|ava=li=as=se|1 +avaliação|ava=li=a=ção|1 +avancei|avan=cei|1 +avante|avan=te|1 +avarenta|ava=ren=ta|1 +aventurados|aven=tu=ra=dos|1 +aventurei|aven=tu=rei|1 +aversão|aver=são|1 +avigoravam|avi=go=ra=vam|1 +avisar|avi=sar|1 +avisasse|avi=sas=se|1 +avivar|avi=var|1 +avoasse|avo=as=se|1 +avoaçando|avo=a=çan=do|1 +avultava|avul=ta=va|1 +azedume|aze=du=me|1 +Azevedo|Aze=ve=do|1 +babados|ba=ba=dos|1 +Babylonia|Baby=lo=nia|1 +baculo|ba=cu=lo|1 +bailados|bai=la=dos|1 +bailarina|bai=la=ri=na|1 +bailarino|bai=la=ri=no|1 +bailarinos|bai=la=ri=nos|1 +bainha|bai=nha|1 +baixos|bai=xos|1 +bajulador|ba=ju=la=dor|1 +balbuciei|bal=bu=ci=ei|1 +balladas|bal=la=das|1 +balsamo|bal=sa=mo|1 +bambas|bam=bas|1 +bambeavam|bam=be=a=vam|1 +bancos|ban=cos|1 +bandós|ban=dós|1 +banqueiro|ban=quei=ro|1 +baptisar|bap=ti=sar|1 +Baptista|Bap=tis=ta|1 +barafunda|ba=ra=fun=da|1 +barata|ba=ra=ta|1 +barateza|ba=ra=te=za|1 +barbaro|bar=ba=ro|1 +barbas|bar=bas|1 +barriga|bar=ri=ga|1 +Barros|Bar=ros|1 +barulho|ba=ru=lho|1 +barytono|bary=to=no|1 +bastam|bas=tam|1 +bastando|bas=tan=do|1 +bastantes|bas=tan=tes|1 +Bastava|Bas=ta=va|1 +bastavam|bas=ta=vam|1 +bastidor|bas=ti=dor|1 +Bastos|Bas=tos|1 +batalhador|ba=ta=lha=dor|1 +batalhas|ba=ta=lhas|1 +batalhões|ba=ta=lhões|1 +batedores|ba=te=do=res|1 +batesse|ba=tes=se|1 +batiam|ba=ti=am|1 +Batiam|Ba=ti=am|1 +batina|ba=ti=na|1 +bazares|ba=za=res|1 +beberia|be=be=ria|1 +bebiamos|be=bi=a=mos|1 +because|be=cau=se|1 +BEFORE|BE=FO=RE|1 +beginning|be=gin=ning|1 +beijando|bei=jan=do|1 +Beijou|Bei=jou|1 +beiramos|bei=ra=mos|1 +bellissimas|bel=lis=si=mas|1 +bemaventurados|be=ma=ven=tu=ra=dos|1 +benevolencia|be=ne=vo=len=cia|1 +benevolo|be=ne=vo=lo|1 +bengalão|ben=ga=lão|1 +Benguella|Ben=guel=la|1 +Benção|Ben=ção|1 +bezerro|be=zer=ro|1 +biblico|bi=bli=co|1 +Bibliotheca|Bi=bli=o=the=ca|1 +bigodes|bi=go=des|1 +binary|bi=nary|1 +biocos|bi=o=cos|1 +biombo|bi=om=bo|1 +bispos|bis=pos|1 +blaspheme|blasp=he=me|1 +blasphemias|blasp=he=mi=as|1 +bocadinho|bo=ca=di=nho|1 +boccas|boc=cas|1 +bochecha|bo=che=cha|1 +bochechas|bo=che=chas|1 +bonança|bo=nan=ça|1 +Bondade|Bon=da=de|1 +boneca|bo=ne=ca|1 +bonecas|bo=ne=cas|1 +bonitas|bo=ni=tas|1 +borboletas|bor=bo=le=tas|1 +bordão|bor=dão|1 +Bordéos|Bor=dé=os|1 +Borges|Bor=ges|1 +botica|bo=ti=ca|1 +botinas|bo=ti=nas|1 +bradaria|bra=da=ria|1 +branca|bran=ca|1 +brancas|bran=cas|1 +Branco|Bran=co|1 +brandamente|bran=da=men=te|1 +brando|bran=do|1 +brandos|bran=dos|1 +brandura|bran=du=ra|1 +branqueavam|bran=que=a=vam|1 +brasileira|bra=si=lei=ra|1 +bravia|bra=via|1 +bravio|bra=vio|1 +Brazil|Bra=zil|1 +brevidade|bre=vi=da=de|1 +brevissimas|bre=vis=si=mas|1 +brigar|bri=gar|1 +brigas|bri=gas|1 +brilhantes|bri=lhan=tes|1 +brilharam|bri=lha=ram|1 +brilharia|bri=lha=ria|1 +brilhavam|bri=lha=vam|1 +brincadeira|brin=ca=dei=ra|1 +brincavamos|brin=ca=va=mos|1 +brincára|brin=cá=ra|1 +bronze|bron=ze|1 +buscado|bus=ca=do|1 +buscam|bus=cam|1 +buscando|bus=can=do|1 +Buscasse|Bus=cas=se|1 +buscou|bus=cou|1 +business|bu=si=ness|1 +bustos|bus=tos|1 +cabelleira|ca=bel=lei=ra|1 +cabelleireiros|ca=bel=lei=rei=ros|1 +cabimento|ca=bi=men=to|1 +cachinho|ca=chi=nho|1 +cachorro|ca=chor=ro|1 +cadeiras|ca=dei=ras|1 +caderno|ca=der=no|1 +caidos|cai=dos|1 +Caimos|Cai=mos|1 +caindo|cain=do|1 +caixao|cai=xao|1 +caixeiro|cai=xei=ro|1 +calamidades|ca=la=mi=da=des|1 +calassemos|ca=las=se=mos|1 +calcante|cal=can=te|1 +calculado|cal=cu=la=do|1 +calculando|cal=cu=lan=do|1 +calculas|cal=cu=las|1 +calculate|cal=cu=la=te|1 +calculated|cal=cu=la=ted|1 +callida|cal=li=da|1 +callidas|cal=li=das|1 +calligraphicas|cal=li=grap=hi=cas|1 +calumnia|ca=lum=nia|1 +calundús|ca=lun=dús|1 +calçar|cal=çar|1 +Calçava|Cal=ça=va|1 +calçava|cal=ça=va|1 +camafeu|ca=ma=feu|1 +camaras|ca=ma=ras|1 +camarotes|ca=ma=ro=tes|1 +caminha|ca=mi=nha|1 +caminhando|ca=mi=nhan=do|1 +caminharam|ca=mi=nha=ram|1 +caminhavam|ca=mi=nha=vam|1 +Caminhei|Ca=mi=nhei|1 +caminhei|ca=mi=nhei|1 +Caminhou|Ca=mi=nhou|1 +caminhou|ca=mi=nhou|1 +Caminhámos|Ca=mi=nhá=mos|1 +camisa|ca=mi=sa|1 +camisola|ca=mi=so=la|1 +campos|cam=pos|1 +canarios|ca=na=ri=os|1 +cancei|can=cei|1 +canceira|can=cei=ra|1 +canceiras|can=cei=ras|1 +candidamente|can=di=da=men=te|1 +candidas|can=di=das|1 +canonisada|ca=no=ni=sa=da|1 +canonista|ca=no=nis=ta|1 +cantado|can=ta=do|1 +Cantagallo|Can=ta=gal=lo|1 +cantarolava|can=ta=ro=la=va|1 +Cantei|Can=tei|1 +cantiga|can=ti=ga|1 +cantilena|can=ti=le=na|1 +cantoras|can=to=ras|1 +cançada|can=ça=da|1 +Cançado|Can=ça=do|1 +cançam|can=çam|1 +cançar|can=çar|1 +cançativa|can=ça=ti=va|1 +cançava|can=ça=va|1 +capacidade|ca=pa=ci=da=de|1 +Capella|Ca=pel=la|1 +capellas|ca=pel=las|1 +capinha|ca=pi=nha|1 +capitaes|ca=pi=ta=es|1 +capitalismo|ca=pi=ta=lis=mo|1 +CAPITOLINA|CA=PI=TO=LI=NA|1 +Capitúsinha|Ca=pi=tú=si=nha|1 +capotes|ca=po=tes|1 +capotinho|ca=po=ti=nho|1 +Capricho|Ca=pri=cho|1 +Caprichos|Ca=pri=chos|1 +caprichosa|ca=pri=cho=sa|1 +caprichosas|ca=pri=cho=sas|1 +captara|cap=ta=ra|1 +captiva|cap=ti=va|1 +captivava|cap=ti=va=va|1 +capuchinhos|ca=pu=chi=nhos|1 +cardeal|car=de=al|1 +carecesse|ca=re=ces=se|1 +careciam|ca=re=ci=am|1 +caridoso|ca=ri=do=so|1 +carinho|ca=ri=nho|1 +carinhos|ca=ri=nhos|1 +carinhosos|ca=ri=nho=sos|1 +carrancismo|car=ran=cis=mo|1 +carregado|car=re=ga=do|1 +carregar|car=re=gar|1 +carreiras|car=rei=ras|1 +carroça|car=ro=ça|1 +carroças|car=ro=ças|1 +cartaz|car=taz|1 +casaca|ca=sa=ca|1 +casacas|ca=sa=cas|1 +casadas|ca=sa=das|1 +casarei|ca=sa=rei|1 +caseira|ca=sei=ra|1 +casemo|ca=se=mo|1 +casmurrice|cas=mur=ri=ce|1 +castanha|cas=ta=nha|1 +castellos|cas=tel=los|1 +castigar|cas=ti=gar|1 +castigo|cas=ti=go|1 +castissima|cas=tis=si=ma|1 +castos|cas=tos|1 +casualidade|ca=su=a=li=da=de|1 +casára|ca=sá=ra|1 +catholica|ca=tho=li=ca|1 +Catholica|Ca=tho=li=ca|1 +catholicismo|ca=tho=li=cis=mo|1 +catholicos|ca=tho=li=cos|1 +Catumby|Ca=tumby|1 +causaria|cau=sa=ria|1 +cautelas|cau=te=las|1 +cauteloso|cau=te=lo=so|1 +cautelosos|cau=te=lo=sos|1 +cavallaria|ca=val=la=ria|1 +cavallos|ca=val=los|1 +cavatina|ca=va=ti=na|1 +caverna|ca=ver=na|1 +caçamba|ca=çam=ba|1 +cederá|ce=de=rá|1 +cedesse|ce=des=se|1 +cegava|ce=ga=va|1 +cegueira|ce=guei=ra|1 +celeste|ce=les=te|1 +celestial|ce=les=ti=al|1 +censuras|cen=su=ras|1 +censurava|cen=su=ra=va|1 +censures|cen=su=res|1 +censurou|cen=su=rou|1 +centelhantes|cen=te=lhan=tes|1 +centenas|cen=te=nas|1 +center|cen=ter|1 +centesimo|cen=te=si=mo|1 +centraes|cen=tra=es|1 +cercada|cer=ca=da|1 +ceremonia|ce=re=mo=nia|1 +cerimonias|ce=ri=mo=ni=as|1 +cerrada|cer=ra=da|1 +cerrar|cer=rar|1 +Cerrava|Cer=ra=va|1 +certidão|cer=ti=dão|1 +certificar|cer=ti=fi=car|1 +Certos|Cer=tos|1 +Certíssima|Cer=tís=si=ma|1 +cessando|ces=san=do|1 +cessaram|ces=sa=ram|1 +cessavam|ces=sa=vam|1 +chacarinha|cha=ca=ri=nha|1 +chalaça|cha=la=ça|1 +chamada|cha=ma=da|1 +chamados|cha=ma=dos|1 +chamal|cha=mal|1 +chamam|cha=mam|1 +chamaram|cha=ma=ram|1 +chamasse|cha=mas=se|1 +Chamem|Cha=mem|1 +chames|cha=mes|1 +change|chan=ge|1 +chapeu|cha=peu|1 +charada|cha=ra=da|1 +charges|char=ges|1 +charitable|cha=ri=ta=ble|1 +charities|cha=ri=ti=es|1 +charlatão|char=la=tão|1 +charuto|cha=ru=to|1 +checks|checks|1 +chegado|che=ga=do|1 +Chegam|Che=gam|1 +Chegando|Che=gan=do|1 +Chegara|Che=ga=ra|1 +chegarão|che=ga=rão|1 +Chegue|Che=gue|1 +cheias|chei=as|1 +cheiram|chei=ram|1 +Cheiram|Chei=ram|1 +cheirando|chei=ran=do|1 +cheirar|chei=rar|1 +cheiravam|chei=ra=vam|1 +Chianti|Chi=an=ti|1 +chilrear|chil=re=ar|1 +chitas|chi=tas|1 +chocarreira|cho=car=rei=ra|1 +chocha|cho=cha|1 +choose|cho=o=se|1 +choraes|cho=ra=es|1 +choraria|cho=ra=ria|1 +chorem|cho=rem|1 +Chorou|Cho=rou|1 +chorão|cho=rão|1 +chovendo|cho=ven=do|1 +chovesse|cho=ves=se|1 +choveu|cho=veu|1 +choviam|cho=vi=am|1 +christão|ch=ris=tão|1 +christãs|ch=ris=tãs|1 +chronologia|ch=ro=no=lo=gia|1 +chronologicamente|ch=ro=no=lo=gi=ca=men=te|1 +chumbo|chum=bo|1 +chupando|chu=pan=do|1 +ciborio|ci=bo=rio|1 +Cidade|Ci=da=de|1 +cigarra|ci=gar=ra|1 +cincada|cin=ca=da|1 +circulo|cir=cu=lo|1 +circulos|cir=cu=los|1 +cirzido|cir=zi=do|1 +citadas|ci=ta=das|1 +citados|ci=ta=dos|1 +citando|ci=tan=do|1 +citações|ci=ta=ções|1 +clarearam|cla=re=a=ram|1 +clarins|cla=rins|1 +clarissima|cla=ris=si=ma|1 +clarão|cla=rão|1 +classico|clas=si=co|1 +classicos|clas=si=cos|1 +claustro|claus=tro|1 +clearly|cle=arly|1 +clientes|cli=en=tes|1 +coadjutor|co=ad=ju=tor|1 +coaxavam|co=a=xa=vam|1 +cobres|co=bres|1 +cobria|co=bria|1 +cobril|co=bril|1 +cobrir|co=brir|1 +cocada|co=ca=da|1 +Cocadinha|Co=ca=di=nha|1 +cocaina|co=cai=na|1 +cochichavam|co=chi=cha=vam|1 +cochichos|co=chi=chos|1 +cochillasse|co=chil=las=se|1 +cochilo|co=chi=lo|1 +cogitando|co=gi=tan=do|1 +cogitar|co=gi=tar|1 +Cogitei|Co=gi=tei|1 +cogitei|co=gi=tei|1 +coincidencia|co=in=ci=den=cia|1 +coitada|coi=ta=da|1 +coitado|coi=ta=do|1 +collaboração|col=la=bo=ra=ção|1 +collegas|col=le=gas|1 +collete|col=le=te|1 +colligindo|col=li=gin=do|1 +collocou|col=lo=cou|1 +Colombo|Co=lom=bo|1 +colorindo|co=lo=rin=do|1 +columnas|co=lum=nas|1 +comadre|co=ma=dre|1 +combatel|com=ba=tel|1 +combatendo|com=ba=ten=do|1 +combater|com=ba=ter|1 +combates|com=ba=tes|1 +Combati|Com=ba=ti|1 +combinam|com=bi=nam|1 +combinar|com=bi=nar|1 +combinação|com=bi=na=ção|1 +comecemos|co=me=ce=mos|1 +comeram|co=me=ram|1 +começada|co=me=ça=da|1 +começando|co=me=çan=do|1 +começar|co=me=çar|1 +Começar|Co=me=çar|1 +começassem|co=me=ças=sem|1 +começassemos|co=me=ças=se=mos|1 +Começava|Co=me=ça=va|1 +começámos|co=me=çá=mos|1 +começára|co=me=çá=ra|1 +comida|co=mi=da|1 +comido|co=mi=do|1 +commemorativo|com=me=mo=ra=ti=vo|1 +commentando|com=men=tan=do|1 +commentario|com=men=ta=rio|1 +commentarios|com=men=ta=ri=os|1 +commentou|com=men=tou|1 +commerciaes|com=mer=ci=a=es|1 +commercial|com=mer=ci=al|1 +commercialmente|com=mer=ci=al=men=te|1 +commettia|com=met=tia|1 +commettido|com=met=ti=do|1 +commissão|com=mis=são|1 +committed|com=mit=ted|1 +commoda|com=mo=da|1 +commodidades|com=mo=di=da=des|1 +commover|com=mo=ver|1 +commoveram|com=mo=ve=ram|1 +commoviam|com=mo=vi=am|1 +commovida|com=mo=vi=da|1 +Commovido|Com=mo=vi=do|1 +commovidos|com=mo=vi=dos|1 +commoção|com=mo=ção|1 +communhão|com=mu=nhão|1 +communicativa|com=mu=ni=ca=ti=va|1 +communicativo|com=mu=ni=ca=ti=vo|1 +communicação|com=mu=ni=ca=ção|1 +communs|com=muns|1 +compadrio|com=pa=drio|1 +companheiras|com=pa=nhei=ras|1 +companhias|com=pa=nhi=as|1 +Compara|Com=pa=ra|1 +comparada|com=pa=ra=da|1 +comparado|com=pa=ra=do|1 +comparando|com=pa=ran=do|1 +comparativamente|com=pa=ra=ti=va=men=te|1 +comparou|com=pa=rou|1 +compassado|com=pas=sa=do|1 +compassivo|com=pas=si=vo|1 +compelliu|com=pel=liu|1 +compendios|com=pen=di=os|1 +compensam|com=pen=sam|1 +compensarem|com=pen=sa=rem|1 +compensação|com=pen=sa=ção|1 +compiladores|com=pi=la=do=res|1 +compilation|com=pi=la=ti=on|1 +compleição|com=plei=ção|1 +completando|com=ple=tan=do|1 +completas|com=ple=tas|1 +completasse|com=ple=tas=se|1 +completo|com=ple=to|1 +completou|com=ple=tou|1 +complexo|com=ple=xo|1 +Compliance|Com=pli=an=ce|1 +complicada|com=pli=ca=da|1 +complicar|com=pli=car|1 +compondo|com=pon=do|1 +componha|com=po=nha|1 +comporia|com=po=ria|1 +comprando|com=pran=do|1 +compras|com=pras|1 +comprasse|com=pras=se|1 +comprava|com=pra=va|1 +Comprehende|Com=pre=hen=de|1 +Comprehendeis|Com=pre=hen=deis|1 +comprehenderás|com=pre=hen=de=rás|1 +comprehendesse|com=pre=hen=des=se|1 +Comprehendi|Com=pre=hen=di|1 +comprehendido|com=pre=hen=di=do|1 +Comprehendo|Com=pre=hen=do|1 +comprehensiva|com=pre=hen=si=va|1 +compressed|com=pres=sed|1 +compridamente|com=pri=da=men=te|1 +Comprido|Com=pri=do|1 +compridos|com=pri=dos|1 +comprimarias|com=pri=ma=ri=as|1 +comprimental|com=pri=men=tal|1 +comprimentos|com=pri=men=tos|1 +Comprimentou|Com=pri=men=tou|1 +compromisso|com=pro=mis=so|1 +Comprou|Com=prou|1 +comprára|com=prá=ra|1 +compuzessemos|com=pu=zes=se=mos|1 +compòr|compòr|1 +compôr|com=pôr|1 +comtigo|com=ti=go|1 +comtudo|com=tu=do|1 +comvosco|com=vos=co|1 +concebiam|con=ce=bi=am|1 +concedeu|con=ce=deu|1 +conceitos|con=cei=tos|1 +conceituosos|con=cei=tuo=sos|1 +concentrada|con=cen=tra=da|1 +concentrarão|con=cen=tra=rão|1 +concentração|con=cen=tra=ção|1 +concepção|con=cep=ção|1 +concertadamente|con=cer=ta=da=men=te|1 +concertal|con=cer=tal|1 +concertou|con=cer=tou|1 +Concertou|Con=cer=tou|1 +concessão|con=ces=são|1 +concilial|con=ci=li=al|1 +conciliando|con=ci=li=an=do|1 +conciliava|con=ci=li=a=va|1 +conclui|con=clui|1 +Conclui|Con=clui|1 +concluia|con=cluia|1 +concluiam|con=clui=am|1 +concluil|con=cluil|1 +concluindo|con=clu=in=do|1 +concluiria|con=clui=ria|1 +concluirá|con=clui=rá|1 +Concluo|Con=cluo|1 +concluí|con=cluí|1 +concluía|con=clu=ía|1 +concordar|con=cor=dar|1 +concordarás|con=cor=da=rás|1 +Concordava|Con=cor=da=va|1 +concordo|con=cor=do|1 +Concordou|Con=cor=dou|1 +concurrencia|con=cur=ren=cia|1 +condemnados|con=dem=na=dos|1 +condemnar|con=dem=nar|1 +condiscipulos|con=dis=ci=pu=los|1 +conducção|con=duc=ção|1 +conegos|co=ne=gos|1 +conferia|con=fe=ria|1 +confessadas|con=fes=sa=das|1 +confessado|con=fes=sa=do|1 +confessal|con=fes=sal|1 +confessarei|con=fes=sa=rei|1 +confessei|con=fes=sei|1 +confessionario|con=fes=si=o=na=rio|1 +confessor|con=fes=sor|1 +confiado|con=fi=a=do|1 +confiando|con=fi=an=do|1 +confiar|con=fi=ar|1 +confiaria|con=fi=a=ria|1 +confiasse|con=fi=as=se|1 +confiava|con=fi=a=va|1 +confidencias|con=fi=den=ci=as|1 +confiei|con=fi=ei|1 +Confiei|Con=fi=ei|1 +confirmal|con=fir=mal|1 +confirmaram|con=fir=ma=ram|1 +confirmasses|con=fir=mas=ses|1 +confirmation|con=fir=ma=ti=on|1 +confirmed|con=fir=med|1 +Confissão|Con=fis=são|1 +conflictos|con=flic=tos|1 +conforto|con=for=to|1 +confundam|con=fun=dam|1 +confundem|con=fun=dem|1 +confundi|con=fun=di|1 +confusa|con=fu=sa|1 +confusões|con=fu=sões|1 +congestão|con=ges=tão|1 +congratulações|con=gra=tu=la=ções|1 +conheceis|co=nhe=ceis|1 +conhecel|co=nhe=cel|1 +conhecendo|co=nhe=cen=do|1 +conheceriam|co=nhe=ce=ri=am|1 +conhecessem|co=nhe=ces=sem|1 +conheceu|co=nhe=ceu|1 +conheci|co=nhe=ci|1 +conheciam|co=nhe=ci=am|1 +conhecida|co=nhe=ci=da|1 +conhecidos|co=nhe=ci=dos|1 +conheço|co=nhe=ço|1 +Conheço|Co=nhe=ço|1 +conjugal|con=ju=gal|1 +conjugassem|con=ju=gas=sem|1 +conjuge|con=ju=ge|1 +conjuges|con=ju=ges|1 +conjuncção|con=junc=ção|1 +consciência|cons=ciên=cia|1 +conseguira|con=se=gui=ra|1 +conseguiu|con=se=guiu|1 +Conselho|Con=se=lho|1 +consente|con=sen=te|1 +consentimento|con=sen=ti=men=to|1 +consentindo|con=sen=tin=do|1 +consentir|con=sen=tir|1 +consentirei|con=sen=ti=rei|1 +Consentiu|Con=sen=tiu|1 +CONSEQUENTIAL|CON=SE=QUEN=TI=AL|1 +conserva|con=ser=va|1 +conservado|con=ser=va=do|1 +conserval|con=ser=val|1 +conservar|con=ser=var|1 +conservava|con=ser=va=va|1 +Conservei|Con=ser=vei|1 +conservo|con=ser=vo|1 +Conservou|Con=ser=vou|1 +conservou|con=ser=vou|1 +conservára|con=ser=vá=ra|1 +Considera|Con=si=de=ra|1 +consideradas|con=si=de=ra=das|1 +Considerai|Con=si=de=rai|1 +considerando|con=si=de=ran=do|1 +considerar|con=si=de=rar|1 +considerares|con=si=de=ra=res|1 +consideração|con=si=de=ra=ção|1 +Considere|Con=si=de=re|1 +consoantes|con=so=an=tes|1 +consolar|con=so=lar|1 +consolassem|con=so=las=sem|1 +consolava|con=so=la=va|1 +Consolava|Con=so=la=va|1 +consolação|con=so=la=ção|1 +consolações|con=so=la=ções|1 +Consolei|Con=so=lei|1 +consorcio|con=sor=cio|1 +conspirou|cons=pi=rou|1 +constancia|cons=tan=cia|1 +constant|cons=tant|1 +constantes|cons=tan=tes|1 +consternasse|cons=ter=nas=se|1 +consternou|cons=ter=nou|1 +constipado|cons=ti=pa=do|1 +constipação|cons=ti=pa=ção|1 +Constituinte|Cons=ti=tu=in=te|1 +constituição|cons=ti=tui=ção|1 +constrangido|cons=tran=gi=do|1 +Constructor|Cons=truc=tor|1 +construcções|cons=truc=ções|1 +construção|cons=tru=ção|1 +consultada|con=sul=ta=da|1 +consultado|con=sul=ta=do|1 +consultasse|con=sul=tas=se|1 +consultava|con=sul=ta=va|1 +Consulte|Con=sul=te|1 +consultes|con=sul=tes|1 +consulto|con=sul=to|1 +consumida|con=su=mi=da|1 +consumisse|con=su=mis=se|1 +Contact|Con=tact|1 +contacto|con=tac=to|1 +containing|con=tai=ning|1 +Contam|Con=tam|1 +Contando|Con=tan=do|1 +contara|con=ta=ra|1 +Contarei|Con=ta=rei|1 +Contaria|Con=ta=ria|1 +contaria|con=ta=ria|1 +contavamos|con=ta=va=mos|1 +contel|con=tel|1 +contemplam|con=tem=plam|1 +contemplar|con=tem=plar|1 +contemplaveis|con=tem=pla=veis|1 +contemporanea|con=tem=po=ra=nea|1 +contendo|con=ten=do|1 +contentamento|con=ten=ta=men=to|1 +Contentei|Con=ten=tei|1 +contentes|con=ten=tes|1 +contentissimo|con=ten=tis=si=mo|1 +contestado|con=tes=ta=do|1 +contestal|con=tes=tal|1 +contestação|con=tes=ta=ção|1 +conteve|con=te=ve|1 +continentes|con=ti=nen=tes|1 +continha|con=ti=nha|1 +continua|con=ti=nua|1 +continuada|con=ti=nu=a=da|1 +continuar|con=ti=nu=ar|1 +continuaram|con=ti=nu=a=ram|1 +continuaria|con=ti=nu=a=ria|1 +continuavam|con=ti=nu=a=vam|1 +Continue|Con=ti=nue|1 +continue|con=ti=nue|1 +continuidade|con=ti=nui=da=de|1 +continuos|con=ti=nuos|1 +CONTRACT|CON=TRACT|1 +contractante|con=trac=tan=te|1 +contractar|con=trac=tar|1 +contracção|con=trac=ção|1 +contradança|con=tra=dan=ça|1 +contradicções|con=tra=dic=ções|1 +contradiga|con=tra=di=ga|1 +contrahida|con=tra=hi=da|1 +contralto|con=tral=to|1 +Contrario|Con=tra=rio|1 +contrarios|con=tra=ri=os|1 +contrastes|con=tras=tes|1 +contribuira|con=tri=bui=ra|1 +Contributions|Con=tri=bu=ti=ons|1 +contributions|con=tri=bu=ti=ons|1 +contára|con=tá=ra|1 +convalescente|con=va=les=cen=te|1 +convencidos|con=ven=ci=dos|1 +conveniencia|con=ve=ni=en=cia|1 +conventos|con=ven=tos|1 +convergia|con=ver=gia|1 +conversamos|con=ver=sa=mos|1 +Conversava|Con=ver=sa=va|1 +conversavamos|con=ver=sa=va=mos|1 +conversámos|con=ver=sá=mos|1 +conversão|con=ver=são|1 +convert|con=vert|1 +converto|con=ver=to|1 +convidando|con=vi=dan=do|1 +convidava|con=vi=da=va|1 +convidavam|con=vi=da=vam|1 +convidou|con=vi=dou|1 +convindo|con=vin=do|1 +convinha|con=vi=nha|1 +conviva|con=vi=va|1 +convivas|con=vi=vas|1 +convivencia|con=vi=ven=cia|1 +convulso|con=vul=so|1 +copeiro|co=pei=ro|1 +copiado|co=pi=a=do|1 +copioso|co=pi=o=so|1 +Copiou|Co=pi=ou|1 +Copyright|Copy=right|1 +coqueiros|co=quei=ros|1 +coragem|co=ra=gem|1 +Corcovado|Cor=co=va=do|1 +cordavão|cor=da=vão|1 +Corityba|Co=rity=ba|1 +cornetinha|cor=ne=ti=nha|1 +corporation|cor=po=ra=ti=on|1 +correcta|cor=rec=ta|1 +correcção|cor=rec=ção|1 +Correcção|Cor=rec=ção|1 +corredores|cor=re=do=res|1 +correia|cor=reia|1 +correias|cor=rei=as|1 +corrente|cor=ren=te|1 +correria|cor=re=ria|1 +correriam|cor=re=ri=am|1 +correspondencia|cor=res=pon=den=cia|1 +correspondentes|cor=res=pon=den=tes|1 +Correspondia|Cor=res=pon=dia|1 +corresse|cor=res=se|1 +corressem|cor=res=sem|1 +corretor|cor=re=tor|1 +corriam|cor=ri=am|1 +corrigia|cor=ri=gia|1 +corrigil|cor=ri=gil|1 +corrigir|cor=ri=gir|1 +corrompeu|cor=rom=peu|1 +corrupt|cor=rupt|1 +corrupção|cor=rup=ção|1 +cortados|cor=ta=dos|1 +cortar|cor=tar|1 +cortejou|cor=te=jou|1 +cortezia|cor=te=zia|1 +cortezias|cor=te=zi=as|1 +cortinas|cor=ti=nas|1 +cosendo|co=sen=do|1 +cosida|co=si=da|1 +cosidas|co=si=das|1 +cosinha|co=si=nha|1 +costumada|cos=tu=ma=da|1 +coubesse|cou=bes=se|1 +countries|coun=tri=es|1 +course|cour=se|1 +Cousas|Cou=sas|1 +cravar|cra=var|1 +creada|cre=a=da|1 +creadas|cre=a=das|1 +creancices|cre=an=ci=ces|1 +creançola|cre=an=ço=la|1 +Creating|Cre=a=ting|1 +creation|cre=a=ti=on|1 +creaturinha|cre=a=tu=ri=nha|1 +credit|cre=dit|1 +Credits|Cre=dits|1 +credos|cre=dos|1 +crendice|cren=di=ce|1 +crendo|cren=do|1 +cresce|cres=ce|1 +cresceu|cres=ceu|1 +crescia|cres=cia|1 +crespo|cres=po|1 +criadas|cri=a=das|1 +Criado|Cri=a=do|1 +criados|cri=a=dos|1 +crianças|cri=an=ças|1 +criminoso|cri=mi=no=so|1 +critica|cri=ti=ca|1 +critical|cri=ti=cal|1 +criticas|cri=ti=cas|1 +cruzavam|cru=za=vam|1 +cuidadosa|cui=da=do=sa|1 +cuidadosamente|cui=da=do=sa=men=te|1 +Cuidaram|Cui=da=ram|1 +cuidaremos|cui=da=re=mos|1 +cuidasse|cui=das=se|1 +Cuidava|Cui=da=va|1 +culpas|cul=pas|1 +culpem|cul=pem|1 +cultores|cul=to=res|1 +cumplicidade|cum=pli=ci=da=de|1 +cumprem|cum=prem|1 +cumpri|cum=pri|1 +Cumpria|Cum=pria|1 +cumprida|cum=pri=da|1 +cumpril|cum=pril|1 +cumprirei|cum=pri=rei|1 +cumpriria|cum=pri=ria|1 +cumprisse|cum=pris=se|1 +curadas|cu=ra=das|1 +curial|cu=ri=al|1 +curiosas|cu=ri=o=sas|1 +curiosissimo|cu=ri=o=sis=si=mo|1 +current|cur=rent|1 +curtas|cur=tas|1 +custando|cus=tan=do|1 +custava|cus=ta=va|1 +Custou|Cus=tou|1 +Dalloz|Dal=loz|1 +damage|da=ma=ge|1 +DAMAGE|DA=MA=GE|1 +damaged|da=ma=ged|1 +Damião|Da=mi=ão|1 +dansar|dan=sar|1 +dansas|dan=sas|1 +dansavamos|dan=sa=va=mos|1 +dansára|dan=sá=ra|1 +daquillo|da=quil=lo|1 +dariam|da=ri=am|1 +darias|da=ri=as|1 +datadas|da=ta=das|1 +datava|da=ta=va|1 +Davamos|Da=va=mos|1 +debate|de=ba=te|1 +debater|de=ba=ter|1 +debates|de=ba=tes|1 +debilidade|de=bi=li=da=de|1 +debruçado|de=bru=ça=do|1 +decennio|de=cen=nio|1 +decidida|de=ci=di=da|1 +decimo|de=ci=mo|1 +decisivas|de=ci=si=vas|1 +declarar|de=cla=rar|1 +decorada|de=co=ra=da|1 +decorar|de=co=rar|1 +Decorei|De=co=rei|1 +decoro|de=co=ro|1 +decrescente|de=cres=cen=te|1 +decreto|de=cre=to|1 +dedical|de=di=cal|1 +dedicar|de=di=car|1 +dedicação|de=di=ca=ção|1 +dedicou|de=di=cou|1 +deductible|de=duc=ti=ble|1 +deductiva|de=duc=ti=va|1 +deduzido|de=du=zi=do|1 +deduzir|de=du=zir|1 +Defect|De=fect|1 +Defects|De=fects|1 +defeito|de=fei=to|1 +defeitosinho|de=fei=to=si=nho|1 +defenda|de=fen=da|1 +defendel|de=fen=del|1 +Defendi|De=fen=di|1 +deferencias|de=fe=ren=ci=as|1 +defesas|de=fe=sas|1 +deficit|de=fi=cit|1 +definitivo|de=fi=ni=ti=vo|1 +definiu|de=fi=niu|1 +definições|de=fi=ni=ções|1 +defuncta|de=func=ta|1 +deitados|dei=ta=dos|1 +deixado|dei=xa=do|1 +deixados|dei=xa=dos|1 +deixam|dei=xam|1 +deixara|dei=xa=ra|1 +deixaram|dei=xa=ram|1 +deixas|dei=xas|1 +deixassem|dei=xas=sem|1 +Deixára|Dei=xá=ra|1 +deleite|de=lei=te|1 +deleitem|de=lei=tem|1 +deletions|de=le=ti=ons|1 +delgado|del=ga=do|1 +delgados|del=ga=dos|1 +delicado|de=li=ca=do|1 +delicias|de=li=ci=as|1 +deliciosa|de=li=ci=o=sa|1 +deliciosas|de=li=ci=o=sas|1 +deliciosissima|de=li=ci=o=sis=si=ma|1 +Deliciosissima|De=li=ci=o=sis=si=ma|1 +delicioso|de=li=ci=o=so|1 +deliradas|de=li=ra=das|1 +delirio|de=li=rio|1 +Delles|Del=les|1 +demand|de=mand|1 +demasia|de=ma=sia|1 +demasiada|de=ma=si=a=da|1 +demasiado|de=ma=si=a=do|1 +demissão|de=mis=são|1 +demolido|de=mo=li=do|1 +demonstrou|de=mons=trou|1 +demorarem|de=mo=ra=rem|1 +demorará|de=mo=ra=rá|1 +demorasse|de=mo=ras=se|1 +Demorei|De=mo=rei|1 +demorei|de=mo=rei|1 +demorem|de=mo=rem|1 +demorou|de=mo=rou|1 +denominações|de=no=mi=na=ções|1 +Dentro|Den=tro|1 +denunciara|de=nun=ci=a=ra|1 +denunciou|de=nun=ci=ou|1 +dependente|de=pen=den=te|1 +dependia|de=pen=dia|1 +depends|de=pends|1 +deprimir|de=pri=mir|1 +derive|de=ri=ve|1 +derived|de=ri=ved|1 +derradeiros|der=ra=dei=ros|1 +derramava|der=ra=ma=va|1 +derrubaria|der=ru=ba=ria|1 +desabotoada|de=sa=bo=to=a=da|1 +desaffectos|de=saf=fec=tos|1 +desafiar|de=sa=fi=ar|1 +desaforo|de=sa=fo=ro|1 +desagradou|de=sa=gra=dou|1 +desalinho|de=sa=li=nho|1 +desamo|de=sa=mo|1 +desamparado|de=sam=pa=ra=do|1 +desandavam|de=san=da=vam|1 +desapparecam|de=sap=pa=re=cam|1 +desapparecer|de=sap=pa=re=cer|1 +desapparecimento|de=sap=pa=re=ci=men=to|1 +desapprenderam|de=sap=pren=de=ram|1 +desapprendi|de=sap=pren=di|1 +desarmado|de=sar=ma=do|1 +desaso|de=sa=so|1 +desastrada|de=sas=tra=da|1 +desatar|de=sa=tar|1 +desatasse|de=sa=tas=se|1 +desatou|de=sa=tou|1 +desattenção|de=sat=ten=ção|1 +desbaratava|des=ba=ra=ta=va|1 +desbotado|des=bo=ta=do|1 +descair|des=cair|1 +descalçava|des=cal=ça=va|1 +Descança|Des=can=ça|1 +descança|des=can=ça|1 +descançam|des=can=çam|1 +descançasse|des=can=ças=se|1 +descargo|des=car=go|1 +descem|des=cem|1 +descemos|des=ce=mos|1 +descendente|des=cen=den=te|1 +descendo|des=cen=do|1 +desces|des=ces|1 +desciamos|des=ci=a=mos|1 +Descido|Des=ci=do|1 +descobre|des=co=bre|1 +descobri|des=co=bri|1 +descobria|des=co=bria|1 +descobrindo|des=co=brin=do|1 +descobriria|des=co=bri=ria|1 +descobriu|des=co=briu|1 +descolasse|des=co=las=se|1 +descompunha|des=com=pu=nha|1 +desconcertos|des=con=cer=tos|1 +desconfiara|des=con=fi=a=ra|1 +desconfiasse|des=con=fi=as=se|1 +desconfiava|des=con=fi=a=va|1 +desconfio|des=con=fio|1 +desconfiou|des=con=fi=ou|1 +desconheceu|des=co=nhe=ceu|1 +desconhecido|des=co=nhe=ci=do|1 +desconheço|des=co=nhe=ço|1 +desconsoladas|des=con=so=la=das|1 +descontai|des=con=tai|1 +descostume|des=cos=tu=me|1 +descrer|des=crer|1 +descrevendo|des=cre=ven=do|1 +descrevo|des=cre=vo|1 +described|des=cri=bed|1 +descripção|des=crip=ção|1 +descubro|des=cu=bro|1 +descuidos|des=cui=dos|1 +desculpa|des=cul=pa|1 +desculpasse|des=cul=pas=se|1 +desculpava|des=cul=pa=va|1 +descôr|des=côr|1 +desdem|des=dem|1 +desdiga|des=di=ga|1 +deseja|de=se=ja|1 +desejado|de=se=ja=do|1 +desejaria|de=se=ja=ria|1 +desejavamos|de=se=ja=va=mos|1 +desejei|de=se=jei|1 +desejosos|de=se=jo=sos|1 +desembaraça|de=sem=ba=ra=ça|1 +desempregados|de=sem=pre=ga=dos|1 +desencontro|de=sen=con=tro|1 +desenfiaram|de=sen=fi=a=ram|1 +desenganados|de=sen=ga=na=dos|1 +desenhando|de=se=nhan=do|1 +desenhar|de=se=nhar|1 +desentadar|de=sen=ta=dar|1 +deserta|de=ser=ta|1 +desesperado|de=ses=pe=ra=do|1 +desessete|de=ses=se=te|1 +desestimasse|de=ses=ti=mas=se|1 +desfastio|des=fas=tio|1 +desfaz|des=faz|1 +desfechou|des=fe=chou|1 +desfez|des=fez|1 +desfitavam|des=fi=ta=vam|1 +desfizeram|des=fi=ze=ram|1 +desforrar|des=for=rar|1 +desforço|des=for=ço|1 +desgostos|des=gos=tos|1 +designa|de=sig=na|1 +designação|de=sig=na=ção|1 +desinencia|de=si=nen=cia|1 +desisto|de=sis=to|1 +deslavadamente|des=la=va=da=men=te|1 +desleal|des=le=al|1 +deslealdade|des=le=al=da=de|1 +desligar|des=li=gar|1 +desligou|des=li=gou|1 +deslumbrado|des=lum=bra=do|1 +deslumbrava|des=lum=bra=va|1 +deslumbrou|des=lum=brou|1 +desmaiou|des=mai=ou|1 +desmentia|des=men=tia|1 +desmentimos|des=men=ti=mos|1 +desmentir|des=men=tir|1 +desmentiriam|des=men=ti=ri=am|1 +desmentisse|des=men=tis=se|1 +desmiolada|des=mi=o=la=da|1 +Desmosthenes|Des=mos=the=nes|1 +desnortear|des=nor=te=ar|1 +desobriga|de=so=bri=ga|1 +desoccupado|de=soc=cu=pa=do|1 +desordenadamente|de=sor=de=na=da=men=te|1 +despedia|des=pe=dia|1 +despedido|des=pe=di=do|1 +despedimos|des=pe=di=mos|1 +despega|des=pe=ga|1 +despegaram|des=pe=ga=ram|1 +despegará|des=pe=ga=rá|1 +despejar|des=pe=jar|1 +despenteados|des=pen=te=a=dos|1 +despesas|des=pe=sas|1 +despeza|des=pe=za|1 +Despeça|Des=pe=ça|1 +despia|des=pia|1 +despida|des=pi=da|1 +Despite|Des=pi=te|1 +desprezes|des=pre=zes|1 +dessem|des=sem|1 +dessorriu|des=sor=riu|1 +destemido|des=te=mi=do|1 +Destino|Des=ti=no|1 +destruido|des=trui=do|1 +desusado|de=su=sa=do|1 +desusados|de=su=sa=dos|1 +desuso|de=su=so|1 +desvairado|des=vai=ra=do|1 +desvairar|des=vai=rar|1 +desvaneceu|des=va=ne=ceu|1 +desviar|des=vi=ar|1 +desviasse|des=vi=as=se|1 +desviava|des=vi=a=va|1 +desviavam|des=vi=a=vam|1 +detach|de=ta=ch|1 +determinei|de=ter=mi=nei|1 +detestamos|de=tes=ta=mos|1 +detestar|de=tes=tar|1 +detivemos|de=ti=ve=mos|1 +detiveram|de=ti=ve=ram|1 +devagarzinho|de=va=gar=zi=nho|1 +devedora|de=ve=do=ra|1 +devemos|de=ve=mos|1 +deveras|de=ve=ras|1 +deviam|de=vi=am|1 +devida|de=vi=da|1 +devidas|de=vi=das|1 +devido|de=vi=do|1 +devoto|de=vo=to|1 +devotos|de=vo=tos|1 +Devéras|De=vé=ras|1 +dezenas|de=ze=nas|1 +diabos|di=a=bos|1 +diabrete|di=a=bre=te|1 +diadema|di=a=de=ma|1 +dialogos|di=a=lo=gos|1 +diferia|di=fe=ria|1 +different|dif=fe=rent|1 +differençal|dif=fe=ren=çal|1 +differia|dif=fe=ria|1 +differiam|dif=fe=ri=am|1 +difficeis|dif=fi=ceis|1 +diffusas|dif=fu=sas|1 +diffuso|dif=fu=so|1 +Digamos|Di=ga=mos|1 +digestão|di=ges=tão|1 +Digital|Di=gi=tal|1 +dignidade|dig=ni=da=de|1 +dignificados|dig=ni=fi=ca=dos|1 +dignos|dig=nos|1 +dignus|dig=nus|1 +diligencias|di=li=gen=ci=as|1 +diluir|di=luir|1 +diminutas|di=mi=nu=tas|1 +diminuto|di=mi=nu=to|1 +diploma|di=plo=ma|1 +DIRECT|DI=RECT|1 +directly|di=rec=tly|1 +directo|di=rec=to|1 +direitamente|di=rei=ta=men=te|1 +dirigida|di=ri=gi=da|1 +disciplina|dis=ci=pli=na|1 +disclaim|dis=claim|1 +DISCLAIMER|DIS=CLAI=MER|1 +disclaimers|dis=clai=mers|1 +discontinue|dis=con=ti=nue|1 +discordar|dis=cor=dar|1 +discover|dis=co=ver|1 +discovered|dis=co=ve=red|1 +discreta|dis=cre=ta|1 +discretamente|dis=cre=ta=men=te|1 +discutia|dis=cu=tia|1 +discutira|dis=cu=ti=ra|1 +disfarçadamente|dis=far=ça=da=men=te|1 +disfarçado|dis=far=ça=do|1 +disfarçamente|dis=far=ça=men=te|1 +Disfarças|Dis=far=ças|1 +disfarçava|dis=far=ça=va|1 +disforme|dis=for=me|1 +dispensal|dis=pen=sal|1 +dispensar|dis=pen=sar|1 +dispensasse|dis=pen=sas=se|1 +dispensava|dis=pen=sa=va|1 +dispensaveis|dis=pen=sa=veis|1 +dispenso|dis=pen=so|1 +dispersava|dis=per=sa=va|1 +display|dis=play|1 +displayed|dis=pla=yed|1 +disposições|dis=po=si=ções|1 +dispostas|dis=pos=tas|1 +dispostos|dis=pos=tos|1 +dispuz|dis=puz|1 +dissertação|dis=ser=ta=ção|1 +dissessem|dis=ses=sem|1 +disseste|dis=ses=te|1 +dissipar|dis=si=par|1 +dissolução|dis=so=lu=ção|1 +dissolver|dis=sol=ver|1 +dissolvido|dis=sol=vi=do|1 +dissuadir|dis=su=a=dir|1 +disséra|dis=sé=ra|1 +distantes|dis=tan=tes|1 +distilava|dis=ti=la=va|1 +distinctamente|dis=tinc=ta=men=te|1 +distinctas|dis=tinc=tas|1 +distincto|dis=tinc=to|1 +distingui|dis=tin=gui|1 +distinguindo|dis=tin=guin=do|1 +distinguir|dis=tin=guir|1 +Distracções|Dis=trac=ções|1 +distracções|dis=trac=ções|1 +distrae|dis=trae|1 +distrahiam|dis=tra=hi=am|1 +distraí|dis=traí|1 +distraía|dis=tra=ía|1 +distribue|dis=tri=bue|1 +distribuidas|dis=tri=bui=das|1 +distribuindo|dis=tri=bu=in=do|1 +distribuir|dis=tri=buir|1 +distribuição|dis=tri=bui=ção|1 +DISTRIBUTE|DIS=TRI=BU=TE|1 +DISTRIBUTOR|DIS=TRI=BU=TOR|1 +diuturna|diu=tur=na|1 +divergindo|di=ver=gin=do|1 +diversa|di=ver=sa|1 +diverso|di=ver=so|1 +diversos|di=ver=sos|1 +diverte|di=ver=te|1 +divertem|di=ver=tem|1 +divertiamo|di=ver=ti=a=mo|1 +dividas|di=vi=das|1 +dividi|di=vi=di|1 +dividia|di=vi=dia|1 +Dividil|Di=vi=dil|1 +dividirmos|di=vi=dir=mos|1 +divindade|di=vin=da=de|1 +divinos|di=vi=nos|1 +divirto|di=vir=to|1 +diziamos|di=zi=a=mos|1 +diário|diá=rio|1 +dobradas|do=bra=das|1 +dobradiço|do=bra=di=ço|1 +dobrar|do=brar|1 +dobras|do=bras|1 +docilidade|do=ci=li=da=de|1 +Doente|Do=en=te|1 +domesticos|do=mes=ti=cos|1 +dominar|do=mi=nar|1 +dominem|do=mi=nem|1 +dominou|do=mi=nou|1 +Dominus|Do=mi=nus|1 +donation|do=na=ti=on|1 +DONATIONS|DO=NA=TI=ONS|1 +donors|do=nors|1 +dorminhocos|dor=mi=nho=cos|1 +dormira|dor=mi=ra|1 +dormirá|dor=mi=rá|1 +dormitorio|dor=mi=to=rio|1 +dotado|do=ta=do|1 +doudamente|dou=da=men=te|1 +downloading|down=lo=a=ding|1 +doçuras|do=çu=ras|1 +dramas|dra=mas|1 +dramaturgo|dra=ma=tur=go|1 +dramaturgos|dra=ma=tur=gos|1 +duello|du=el=lo|1 +dulcissima|dul=cis=si=ma|1 +dulcissimos|dul=cis=si=mos|1 +duraque|du=ra=que|1 +durará|du=ra=rá|1 +durava|du=ra=va|1 +duvidaria|du=vi=da=ria|1 +Duvido|Du=vi=do|1 +duvido|du=vi=do|1 +duzentas|du=zen=tas|1 +duzentos|du=zen=tos|1 +easily|ea=sily|1 +ebooks|ebo=oks|1 +ecclesiasticas|ec=cle=si=as=ti=cas|1 +economico|eco=no=mi=co|1 +economisar|eco=no=mi=sar|1 +edition|edi=ti=on|1 +EDITOR|EDI=TOR|1 +editor|edi=tor|1 +edição|edi=ção|1 +educado|edu=ca=do|1 +educarão|edu=ca=rão|1 +education|edu=ca=ti=on|1 +effeitos|ef=fei=tos|1 +effusão|ef=fu=são|1 +egoismo|ego=is=mo|1 +egrejas|egre=jas|1 +eguala|egua=la|1 +either|ei=ther|1 +elegancia|ele=gan=cia|1 +eleito|elei=to|1 +eleição|elei=ção|1 +Eliphaz|Elip=haz|1 +elogia|elo=gia|1 +elogial|elo=gi=al|1 +Elogiavam|Elo=gi=a=vam|1 +eloquencia|elo=quen=cia|1 +embarcam|em=bar=cam|1 +embarcou|em=bar=cou|1 +Embarquei|Em=bar=quei|1 +embaçada|em=ba=ça=da|1 +embebidos|em=be=bi=dos|1 +Embora|Em=bo=ra|1 +embriagou|em=bri=a=gou|1 +embrulhada|em=bru=lha=da|1 +embrulhado|em=bru=lha=do|1 +embruscar|em=brus=car|1 +embuçados|em=bu=ça=dos|1 +emendai|emen=dai|1 +emendal|emen=dal|1 +emende|emen=de|1 +empacar|em=pa=car|1 +empallidecer|em=pal=li=de=cer|1 +empallideci|em=pal=li=de=ci|1 +empanava|em=pa=na=va|1 +empenhar|em=pe=nhar|1 +empina|em=pi=na|1 +employee|em=plo=yee|1 +employees|em=plo=yees|1 +emplumando|em=plu=man=do|1 +empregado|em=pre=ga=do|1 +empresta|em=pres=ta|1 +empurral|em=pur=ral|1 +empurrando|em=pur=ran=do|1 +empurrou|em=pur=rou|1 +encaixilhou|en=cai=xi=lhou|1 +encaminhasse|en=ca=mi=nhas=se|1 +encaminhassemos|en=ca=mi=nhas=se=mos|1 +encantados|en=can=ta=dos|1 +encantos|en=can=tos|1 +encarava|en=ca=ra=va|1 +encarei|en=ca=rei|1 +encarnações|en=car=na=ções|1 +encarquilhar|en=car=qui=lhar|1 +encerrem|en=cer=rem|1 +enchendo|en=chen=do|1 +enchente|en=chen=te|1 +encher|en=cher|1 +enchesse|en=ches=se|1 +encheu|en=cheu|1 +encobertas|en=co=ber=tas|1 +encobria|en=co=bria|1 +encobrir|en=co=brir|1 +encobrisse|en=co=bris=se|1 +encommendação|en=com=men=da=ção|1 +encontradiço|en=con=tra=di=ço|1 +encontrado|en=con=tra=do|1 +encontrar|en=con=trar|1 +encontrara|en=con=tra=ra|1 +encontraremos|en=con=tra=re=mos|1 +Encontrei|En=con=trei|1 +encontros|en=con=tros|1 +encontrámos|en=con=trá=mos|1 +encostada|en=cos=ta=da|1 +encostado|en=cos=ta=do|1 +encostou|en=cos=tou|1 +encravada|en=cra=va=da|1 +encyclica|ency=cli=ca|1 +endireitando|en=di=rei=tan=do|1 +endireitei|en=di=rei=tei|1 +endireitou|en=di=rei=tou|1 +endossantes|en=dos=san=tes|1 +energia|ener=gia|1 +energico|ener=gi=co|1 +enfadamento|en=fa=da=men=to|1 +enfadem|en=fa=dem|1 +enfarruscado|en=far=rus=ca=do|1 +enfastiada|en=fas=ti=a=da|1 +enfeitava|en=fei=ta=va|1 +enfeixava|en=fei=xa=va|1 +enfermidade|en=fer=mi=da=de|1 +enfiadas|en=fi=a=das|1 +enfiava|en=fi=a=va|1 +enforque|en=for=que|1 +enganadas|en=ga=na=das|1 +enganado|en=ga=na=do|1 +enganando|en=ga=nan=do|1 +enganas|en=ga=nas|1 +engasgada|en=gas=ga=da|1 +engenhosas|en=ge=nho=sas|1 +engolir|en=go=lir|1 +engommadas|en=gom=ma=das|1 +engrolavamos|en=gro=la=va=mos|1 +engrossar|en=gros=sar|1 +enlevado|en=le=va=do|1 +enojou|eno=jou|1 +enredo|en=re=do|1 +enriquecia|en=ri=que=cia|1 +enrolado|en=ro=la=do|1 +enroscavam|en=ros=ca=vam|1 +Enruguei|En=ru=guei|1 +ensaiava|en=sai=a=va|1 +ensaio|en=saio|1 +ensinadas|en=si=na=das|1 +ensinamentos|en=si=na=men=tos|1 +ensinando|en=si=nan=do|1 +ensinasse|en=si=nas=se|1 +ensinava|en=si=na=va|1 +ensine|en=si=ne|1 +ensurdecia|en=sur=de=cia|1 +ensuring|en=su=ring|1 +entenda|en=ten=da|1 +Entenda|En=ten=da|1 +entendel|en=ten=del|1 +entendem|en=ten=dem|1 +entendendo|en=ten=den=do|1 +entendera|en=ten=de=ra|1 +entenderia|en=ten=de=ria|1 +entendermos|en=ten=der=mos|1 +entenderá|en=ten=de=rá|1 +entendesse|en=ten=des=se|1 +Entendo|En=ten=do|1 +entendí|en=ten=dí|1 +enternecia|en=ter=ne=cia|1 +enterra|en=ter=ra|1 +enterrada|en=ter=ra=da|1 +enterrados|en=ter=ra=dos|1 +enterral|en=ter=ral|1 +enterrar|en=ter=rar|1 +enterrou|en=ter=rou|1 +entoaram|en=to=a=ram|1 +entornei|en=tor=nei|1 +Entrando|En=tran=do|1 +entranhas|en=tra=nhas|1 +entraria|en=tra=ria|1 +entrarmos|en=trar=mos|1 +entrará|en=tra=rá|1 +entrarão|en=tra=rão|1 +entrassem|en=tras=sem|1 +Entrava|En=tra=va|1 +entravam|en=tra=vam|1 +entravamos|en=tra=va=mos|1 +entregal|en=tre=gal|1 +entregar|en=tre=gar|1 +Entrei|En=trei|1 +entrelaçassem|en=tre=la=ças=sem|1 +entrevista|en=tre=vis=ta|1 +entrámos|en=trá=mos|1 +Entrámos|En=trá=mos|1 +entrára|en=trá=ra|1 +envelhecendo|en=ve=lhe=cen=do|1 +envelhecer|en=ve=lhe=cer|1 +envelheceu|en=ve=lhe=ceu|1 +envergonhadissimo|en=ver=go=nha=dis=si=mo|1 From fc4feed7eabdd9c74953803ee28c246944cb4374 Mon Sep 17 00:00:00 2001 From: Leopoldo Pla Sempere Date: Thu, 24 Sep 2026 13:09:26 +0200 Subject: [PATCH 18/62] chore: address Python Sonar warnings (#2515) ## Summary * **What is the goal of this PR?** Address the actionable Python Sonar findings in helper scripts without changing firmware runtime behavior. * **What changes are included?** * Clean up duplicate literals, needless f-strings, and regex style issues in the HTML and i18n helper scripts. * Replace bare font-loading exceptions with explicit `OSError` handling in the EPUB test generator. * Centralize repeated test image filenames in the EPUB test generator to reduce duplication. * Fix the fallback `ImageFont.load_default()` call in the kerning/ligature EPUB generator. * Add a clarifying no-op stub implementation in the PlatformIO git version helper. * Fix the two valid review findings: keep the icon header preamble outside the pixel loop and interpolate all generated EPUB image paths. * Keep the requested exclusions unchanged: Cognitive Complexity findings were intentionally not addressed, and environment-only import diagnostics were left alone. ## Additional Context * Validation performed: * `python3 -m py_compile scripts/build_html.py scripts/convert_icon.py scripts/gen_i18n.py scripts/generate_kerning_ligature_epub.py scripts/generate_test_bmps.py scripts/generate_test_epub.py scripts/git_branch.py` * Generated the JPEG, PNG, and mixed-format test EPUBs in a temporary directory; verified no literal `{image_src(...)}` placeholders remain and every image reference resolves to an archived asset. * Validated in-memory icon conversion output. * `pio run -t clean && pio run` * `pio check` * GitHub build, unit tests, cppcheck, clang-format, title, and CodeRabbit checks all pass. --- ### AI Usage While CrossPoint does not have restrictions on AI tools in contributing, please be transparent about their usage as it helps set the right context for reviewers. Did you use AI tools to help write this code? _**PARTIALLY**_ --- scripts/build_html.py | 22 ++-- scripts/convert_icon.py | 2 +- scripts/gen_i18n.py | 46 ++++---- scripts/generate_test_bmps.py | 3 +- scripts/generate_test_epub.py | 190 +++++++++++++++++++--------------- scripts/git_branch.py | 4 +- 6 files changed, 152 insertions(+), 115 deletions(-) diff --git a/scripts/build_html.py b/scripts/build_html.py index c9615cdf774..9580eeeb034 100644 --- a/scripts/build_html.py +++ b/scripts/build_html.py @@ -4,6 +4,11 @@ import hashlib SRC_DIR = "src" +HTML_EXT = ".html" +JS_EXT = ".js" +HEADER_PREAMBLE = "// THIS FILE IS AUTOGENERATED, DO NOT EDIT MANUALLY\n\n" +HEADER_PRAGMA = "#pragma once\n" +HEADER_INCLUDE = "#include \n\n" def minify_html(html: str) -> str: # Tags where whitespace should be preserved @@ -41,7 +46,7 @@ def sanitize_identifier(name: str) -> str: - Contain only letters, digits, and underscores """ # Replace non-alphanumeric characters (including hyphens) with underscores - sanitized = re.sub(r'[^a-zA-Z0-9_]', '_', name) + sanitized = re.sub(r'\W', '_', name, flags=re.ASCII) # Prefix with underscore if starts with a digit if sanitized and sanitized[0].isdigit(): sanitized = f"_{sanitized}" @@ -49,13 +54,14 @@ def sanitize_identifier(name: str) -> str: for root, _, files in os.walk(SRC_DIR): for file in files: - if file.endswith(".html") or file.endswith(".js"): + if file.endswith((HTML_EXT, JS_EXT)): file_path = os.path.join(root, file) with open(file_path, "r", encoding="utf-8") as f: content = f.read() # Only minify HTML files; JS files are typically pre-minified (e.g., jszip.min.js) - if file.endswith(".html"): + is_html = file.endswith(HTML_EXT) + if is_html: processed = minify_html(content) else: processed = content @@ -67,14 +73,14 @@ def sanitize_identifier(name: str) -> str: # Create valid C identifier from filename # Use appropriate suffix based on file type - suffix = "Html" if file.endswith(".html") else "Js" + suffix = "Html" if is_html else "Js" base_name = sanitize_identifier(f"{os.path.splitext(file)[0]}{suffix}") header_path = os.path.join(root, f"{base_name}.generated.h") with open(header_path, "w", encoding="utf-8") as h: - h.write(f"// THIS FILE IS AUTOGENERATED, DO NOT EDIT MANUALLY\n\n") - h.write(f"#pragma once\n") - h.write(f"#include \n\n") + h.write(HEADER_PREAMBLE) + h.write(HEADER_PRAGMA) + h.write(HEADER_INCLUDE) # Write the compressed data as a byte array h.write(f"constexpr char {base_name}[] PROGMEM = {{\n") @@ -85,7 +91,7 @@ def sanitize_identifier(name: str) -> str: hex_values = ', '.join(f'0x{b:02x}' for b in chunk) h.write(f" {hex_values},\n") - h.write(f"}};\n\n") + h.write("};\n\n") h.write(f"constexpr size_t {base_name}CompressedSize = {len(compressed)};\n") h.write(f"constexpr size_t {base_name}OriginalSize = {len(processed)};\n") diff --git a/scripts/convert_icon.py b/scripts/convert_icon.py index 6e20fc8cc80..cea702688c7 100644 --- a/scripts/convert_icon.py +++ b/scripts/convert_icon.py @@ -50,7 +50,7 @@ def image_to_c_array(img, array_name): byte |= (bit << (7 - b)) packed.append(byte) # Format as C array - c = f'#pragma once\n#include \n\n' + c = '#pragma once\n#include \n\n' c += f'// size: {width}x{height}\n' c += f'static const uint8_t {array_name}[] = {{\n ' for i, v in enumerate(packed): diff --git a/scripts/gen_i18n.py b/scripts/gen_i18n.py index 51c6f78d575..663367f7c21 100755 --- a/scripts/gen_i18n.py +++ b/scripts/gen_i18n.py @@ -37,6 +37,14 @@ from pathlib import Path from typing import Dict, List, Optional, Set, Tuple +AUTO_GENERATED_COMMENT = "// THIS FILE IS AUTO-GENERATED BY gen_i18n.py. DO NOT EDIT." +CLANG_FORMAT_OFF_COMMENT = "// clang-format off" +I18N_NAMESPACE_OPEN = "namespace i18n_strings {" +I18N_NAMESPACE_CLOSE = "} // namespace i18n_strings" +YAML_KEY_RE = re.compile(r'^([A-Za-z_]\w*)\s*:\s*"(.*)"$', flags=re.ASCII) +CPP_IDENTIFIER_RE = re.compile(r"^[A-Za-z_]\w*$", flags=re.ASCII) +STR_KEY_RE = re.compile(r"\bSTR_\w+\b", flags=re.ASCII) + # --------------------------------------------------------------------------- # YAML file reading (simple key: "value" format, no PyYAML dependency) @@ -85,7 +93,7 @@ def parse_yaml_file(filepath: str) -> Dict[str, str]: if not line.strip(): continue - match = re.match(r'^([A-Za-z_][A-Za-z0-9_]*)\s*:\s*"(.*)"$', line) + match = YAML_KEY_RE.match(line) if not match: raise ValueError( f"{filepath}:{line_num}: bad format: {line!r}\n" @@ -210,7 +218,7 @@ def sort_key(fname: str) -> Tuple[int, str]: # Validate all keys are valid C++ identifiers for key in string_keys: - if not re.match(r"^[a-zA-Z_][a-zA-Z0-9_]*$", key): + if not CPP_IDENTIFIER_RE.match(key): raise ValueError(f"Invalid C++ identifier in English file: '{key}'") # Build translations dict, filling missing keys from English @@ -271,7 +279,6 @@ def find_used_string_keys( if skip_filenames is None: skip_filenames = _GENERATED_FILENAMES - pattern = re.compile(r"\bSTR_[A-Za-z0-9_]+\b") used: Set[str] = set() for src_dir in src_dirs: @@ -287,7 +294,7 @@ def find_used_string_keys( text = f.read_text(encoding="utf-8", errors="replace") except OSError: continue - for m in pattern.finditer(text): + for m in STR_KEY_RE.finditer(text): used.add(m.group(0)) return used @@ -462,18 +469,18 @@ def generate_keys_header( "#pragma once", "#include ", "", - "// THIS FILE IS AUTO-GENERATED BY gen_i18n.py. DO NOT EDIT.", - "// clang-format off", + AUTO_GENERATED_COMMENT, + CLANG_FORMAT_OFF_COMMENT, "", "// Forward declarations for flat string data blobs and offset tables", - "namespace i18n_strings {", + I18N_NAMESPACE_OPEN, ] for code in compiled: lines.append(f"extern const char STRINGS_{code}_DATA[];") lines.append(f"extern const uint16_t OFFSETS_{code}[];") - lines.append("} // namespace i18n_strings") + lines.append(I18N_NAMESPACE_CLOSE) lines.append("") # Language enum @@ -588,7 +595,6 @@ def generate_keys_header( def generate_strings_header( languages: List[str], - language_names: List[str], output_path: str, verbose: bool = False, builtin: Optional[Set[str]] = None, @@ -598,10 +604,10 @@ def generate_strings_header( "#pragma once", '#include "I18nKeys.h"', "", - "// THIS FILE IS AUTO-GENERATED BY gen_i18n.py. DO NOT EDIT.", - "// clang-format off", + AUTO_GENERATED_COMMENT, + CLANG_FORMAT_OFF_COMMENT, "", - "namespace i18n_strings {", + I18N_NAMESPACE_OPEN, "", ] @@ -610,7 +616,7 @@ def generate_strings_header( lines.append(f"extern const uint16_t OFFSETS_{code}[];") lines.append("") - lines.append("} // namespace i18n_strings") + lines.append(I18N_NAMESPACE_CLOSE) _write_file(output_path, lines, verbose) @@ -626,8 +632,8 @@ def generate_strings_cpp( """Generate I18nStrings.cpp.""" compiled = [c for c in languages if builtin is None or c in builtin] lines: List[str] = [ - "// THIS FILE IS AUTO-GENERATED BY gen_i18n.py. DO NOT EDIT.", - "// clang-format off", + AUTO_GENERATED_COMMENT, + CLANG_FORMAT_OFF_COMMENT, '#include "I18nStrings.h"', "", "#include ", @@ -664,7 +670,7 @@ def generate_strings_cpp( # Per-language flat string blobs and offset tables. # Non-English languages skip strings identical to English; their offset # tables use bit 15 (0x8000) to flag "use English blob at offset & 0x7FFF". - lines.append("namespace i18n_strings {") + lines.append(I18N_NAMESPACE_OPEN) lines.append("") en_strings = [translations[key][0] for key in string_keys] @@ -725,7 +731,7 @@ def generate_strings_cpp( lines.append("};") lines.append("") - lines.append("} // namespace i18n_strings") + lines.append(I18N_NAMESPACE_CLOSE) lines.append("") # Compile-time size checks @@ -982,7 +988,7 @@ def main( builtin, ) generate_strings_header( - languages, language_names, str(out / "I18nStrings.h"), verbose, builtin + languages, str(out / "I18nStrings.h"), verbose, builtin ) generate_strings_cpp( languages, @@ -1063,11 +1069,9 @@ def main( args.builtin_langs, ) else: - try: + if "Import" in globals(): Import("env") main( strip_unused=True, builtin_langs=env.GetProjectOption("custom_i18n_builtin_langs", "all"), ) - except NameError: - pass diff --git a/scripts/generate_test_bmps.py b/scripts/generate_test_bmps.py index 2fbdd630744..30e11cb8959 100644 --- a/scripts/generate_test_bmps.py +++ b/scripts/generate_test_bmps.py @@ -43,7 +43,8 @@ def write_bmp_file_header(f, pixel_data_offset, file_size): def write_bmp_dib_header(f, width, height, bpp, colors_used=0): f.write(struct.pack('Cache performance
"""), []), - ("1. JPEG Format", make_chapter("JPEG Format Test", """ + ("1. JPEG Format", make_chapter("JPEG Format Test", f"""

Basic JPEG decoding test.

-JPEG format test +JPEG format test

If the image above is visible, JPEG decoding works.

-"""), [('jpeg_format.jpg', images['jpeg_format.jpg'])]), - ("2. Grayscale", make_chapter("Grayscale Test", """ +"""), single_image_asset(JPEG_FORMAT_JPG, images)), + ("2. Grayscale", make_chapter("Grayscale Test", f"""

Verify 4 distinct gray levels are visible.

-Grayscale test -"""), [('grayscale_test.jpg', images['grayscale_test.jpg'])]), - ("3. Gradient", make_chapter("Gradient Test", """ +Grayscale test +"""), single_image_asset(GRAYSCALE_TEST_JPG, images)), + ("3. Gradient", make_chapter("Gradient Test", f"""

Verify gradient quantizes to 4 bands.

-Gradient test -"""), [('gradient_test.jpg', images['gradient_test.jpg'])]), - ("4. Centering", make_chapter("Centering Test", """ +Gradient test +"""), single_image_asset(GRADIENT_TEST_JPG, images)), + ("4. Centering", make_chapter("Centering Test", f"""

Verify image is centered horizontally.

-Centering test -"""), [('centering_test.jpg', images['centering_test.jpg'])]), - ("5. Scaling", make_chapter("Scaling Test", """ +Centering test +"""), single_image_asset(CENTERING_TEST_JPG, images)), + ("5. Scaling", make_chapter("Scaling Test", f"""

This image is 1200x1500 pixels - larger than the screen.

It should be scaled down to fit.

-Scaling test -"""), [('scaling_test.jpg', images['scaling_test.jpg'])]), - ("6. Wide Scaling", make_chapter("Wide Scaling Test", """ +Scaling test +"""), single_image_asset(SCALING_TEST_JPG, images)), + ("6. Wide Scaling", make_chapter("Wide Scaling Test", f"""

This image is 1807x736 pixels - a wide landscape format.

Tests scaling with dimensions that can cause cache mismatches.

-Wide scaling test -"""), [('wide_scaling_test.jpg', images['wide_scaling_test.jpg'])]), - ("7. Cache Test A", make_chapter("Cache Test - Page A", """ +Wide scaling test +"""), single_image_asset(WIDE_SCALING_TEST_JPG, images)), + ("7. Cache Test A", make_chapter("Cache Test - Page A", f"""

First cache test page. Note the load time.

-Cache test 1 +Cache test 1

Navigate to next page, then come back.

-"""), [('cache_test_1.jpg', images['cache_test_1.jpg'])]), - ("8. Cache Test B", make_chapter("Cache Test - Page B", """ +"""), single_image_asset(CACHE_TEST_1_JPG, images)), + ("8. Cache Test B", make_chapter("Cache Test - Page B", f"""

Second cache test page.

-Cache test 2 +Cache test 2

Navigate back to Page A - it should load faster from cache.

-"""), [('cache_test_2.jpg', images['cache_test_2.jpg'])]), - ("9. Alignment Bleed", make_chapter("Image Centering Bleed Test", """ +"""), single_image_asset(CACHE_TEST_2_JPG, images)), + ("9. Alignment Bleed", make_chapter("Image Centering Bleed Test", f"""

Tests that image centering does not bleed into following text blocks (issue #1026).

Set Paragraph Alignment to Justify and Embedded Style to OFF before testing.

All paragraphs below the images should be justified, not centered.

-

Test image

+

Test image

FIRST PARAGRAPH after image. This paragraph follows an empty heading and an image-only paragraph. With the bug present, this text appears centered instead of justified because the empty heading's default Center alignment bleeds through the chain of empty text blocks. Lorem ipsum dolor sit amet, consectetur adipiscing elit sed do eiusmod tempor.

SECOND PARAGRAPH in the same div. This paragraph should always be justified because the first paragraph's text block was flushed. Duis aute irure dolor in reprehenderit in voluptate velit esse cillum dolore eu fugiat nulla pariatur. Excepteur sint occaecat cupidatat non proident sunt in culpa qui officia.

-"""), []), # centering_test.jpg already included by chapter 4 +"""), []), # CENTERING_TEST_JPG already included by chapter 4 ] create_epub(OUTPUT_DIR / 'test_jpeg_images.epub', 'JPEG Image Tests', jpeg_chapters) @@ -640,54 +664,54 @@ def main():
  • Large image scaling
  • """), []), - ("1. PNG Format", make_chapter("PNG Format Test", """ + ("1. PNG Format", make_chapter("PNG Format Test", f"""

    Basic PNG decoding test.

    -PNG format test +PNG format test

    If the image above is visible and no crash occurred, PNG decoding works.

    -"""), [('png_format.png', images['png_format.png'])]), - ("2. Grayscale", make_chapter("Grayscale Test", """ +"""), single_image_asset(PNG_FORMAT_PNG, images)), + ("2. Grayscale", make_chapter("Grayscale Test", f"""

    Verify 4 distinct gray levels are visible.

    -Grayscale test -"""), [('grayscale_test.png', images['grayscale_test.png'])]), - ("3. Gradient", make_chapter("Gradient Test", """ +Grayscale test +"""), single_image_asset(GRAYSCALE_TEST_PNG, images)), + ("3. Gradient", make_chapter("Gradient Test", f"""

    Verify gradient quantizes to 4 bands.

    -Gradient test -"""), [('gradient_test.png', images['gradient_test.png'])]), - ("4. Centering", make_chapter("Centering Test", """ +Gradient test +"""), single_image_asset(GRADIENT_TEST_PNG, images)), + ("4. Centering", make_chapter("Centering Test", f"""

    Verify image is centered horizontally.

    -Centering test -"""), [('centering_test.png', images['centering_test.png'])]), - ("5. Scaling", make_chapter("Scaling Test", """ +Centering test +"""), single_image_asset(CENTERING_TEST_PNG, images)), + ("5. Scaling", make_chapter("Scaling Test", f"""

    This image is 1200x1500 pixels - larger than the screen.

    It should be scaled down to fit.

    -Scaling test -"""), [('scaling_test.png', images['scaling_test.png'])]), - ("6. Wide Scaling", make_chapter("Wide Scaling Test", """ +Scaling test +"""), single_image_asset(SCALING_TEST_PNG, images)), + ("6. Wide Scaling", make_chapter("Wide Scaling Test", f"""

    This image is 1807x736 pixels - a wide landscape format.

    Tests scaling with dimensions that can cause cache mismatches.

    -Wide scaling test -"""), [('wide_scaling_test.png', images['wide_scaling_test.png'])]), - ("7. Cache Test A", make_chapter("Cache Test - Page A", """ +Wide scaling test +"""), single_image_asset(WIDE_SCALING_TEST_PNG, images)), + ("7. Cache Test A", make_chapter("Cache Test - Page A", f"""

    First cache test page. Note the load time.

    -Cache test 1 +Cache test 1

    Navigate to next page, then come back.

    -"""), [('cache_test_1.png', images['cache_test_1.png'])]), - ("8. Cache Test B", make_chapter("Cache Test - Page B", """ +"""), single_image_asset(CACHE_TEST_1_PNG, images)), + ("8. Cache Test B", make_chapter("Cache Test - Page B", f"""

    Second cache test page.

    -Cache test 2 +Cache test 2

    Navigate back to Page A - it should load faster from cache.

    -"""), [('cache_test_2.png', images['cache_test_2.png'])]), - ("9. Alignment Bleed", make_chapter("Image Centering Bleed Test", """ +"""), single_image_asset(CACHE_TEST_2_PNG, images)), + ("9. Alignment Bleed", make_chapter("Image Centering Bleed Test", f"""

    Tests that image centering does not bleed into following text blocks (issue #1026).

    Set Paragraph Alignment to Justify and Embedded Style to OFF before testing.

    All paragraphs below the images should be justified, not centered.

    -

    Test image

    +

    Test image

    FIRST PARAGRAPH after image. This paragraph follows an empty heading and an image-only paragraph. With the bug present, this text appears centered instead of justified because the empty heading's default Center alignment bleeds through the chain of empty text blocks. Lorem ipsum dolor sit amet, consectetur adipiscing elit sed do eiusmod tempor.

    SECOND PARAGRAPH in the same div. This paragraph should always be justified because the first paragraph's text block was flushed. Duis aute irure dolor in reprehenderit in voluptate velit esse cillum dolore eu fugiat nulla pariatur. Excepteur sint occaecat cupidatat non proident sunt in culpa qui officia.

    -"""), []), # centering_test.png already included by chapter 4 +"""), []), # CENTERING_TEST_PNG already included by chapter 4 ] create_epub(OUTPUT_DIR / 'test_png_images.epub', 'PNG Image Tests', png_chapters) @@ -698,22 +722,22 @@ def main():

    This EPUB contains both JPEG and PNG images.

    Tests format detection and mixed rendering.

    """), []), - ("1. JPEG Image", make_chapter("JPEG in Mixed EPUB", """ + ("1. JPEG Image", make_chapter("JPEG in Mixed EPUB", f"""

    This is a JPEG image:

    -JPEG -"""), [('jpeg_format.jpg', images['jpeg_format.jpg'])]), - ("2. PNG Image", make_chapter("PNG in Mixed EPUB", """ +JPEG +"""), single_image_asset(JPEG_FORMAT_JPG, images)), + ("2. PNG Image", make_chapter("PNG in Mixed EPUB", f"""

    This is a PNG image:

    -PNG -"""), [('png_format.png', images['png_format.png'])]), - ("3. Both Formats", make_chapter("Both Formats on One Page", """ +PNG +"""), single_image_asset(PNG_FORMAT_PNG, images)), + ("3. Both Formats", make_chapter("Both Formats on One Page", f"""

    JPEG image:

    -JPEG grayscale +JPEG grayscale

    PNG image:

    -PNG grayscale +PNG grayscale

    Both should render with proper grayscale.

    -"""), [('grayscale_test.jpg', images['grayscale_test.jpg']), - ('grayscale_test.png', images['grayscale_test.png'])]), +"""), [(GRAYSCALE_TEST_JPG, images[GRAYSCALE_TEST_JPG]), + (GRAYSCALE_TEST_PNG, images[GRAYSCALE_TEST_PNG])]), ] create_epub(OUTPUT_DIR / 'test_mixed_images.epub', 'Mixed Format Tests', mixed_chapters) diff --git a/scripts/git_branch.py b/scripts/git_branch.py index ec56467378e..35896f7ce1b 100644 --- a/scripts/git_branch.py +++ b/scripts/git_branch.py @@ -100,7 +100,9 @@ def inject_version(env): inject_version(env) # noqa: F821 # type: ignore[name-defined] except NameError: class _Env(dict): - def Append(self, **_): pass + def Append(self, **_): + # Validation mode does not need SCons state; this stub only satisfies inject_version(). + return None _project_dir = os.path.dirname(os.path.dirname(os.path.abspath(__file__))) inject_version(_Env({'PIOENV': 'default', 'PROJECT_DIR': _project_dir})) From b88b653a797c9952c68df5bb373e560d65682f67 Mon Sep 17 00:00:00 2001 From: Uri Tauber Date: Thu, 24 Sep 2026 17:24:45 +0300 Subject: [PATCH 19/62] feat: better footnotes navigation (#3682) ## Summary Replace the flat list of footnotes/links with intuitive navigation. Similar to how dictionary lookup works. --- ### AI Usage While CrossPoint doesn't have restrictions on AI tools in contributing, please be transparent about their usage as it helps set the right context for reviewers. Did you use AI tools to help write this code? _**< PARTIALLY >**_ --- lib/I18n/translations/arabic.yaml | 1 - lib/I18n/translations/belarusian.yaml | 1 - lib/I18n/translations/bosnian.yaml | 1 - lib/I18n/translations/bulgarian.yaml | 1 - lib/I18n/translations/catalan.yaml | 1 - lib/I18n/translations/czech.yaml | 1 - lib/I18n/translations/danish.yaml | 1 - lib/I18n/translations/dutch.yaml | 1 - lib/I18n/translations/english.yaml | 1 - lib/I18n/translations/french.yaml | 1 - lib/I18n/translations/german.yaml | 1 - lib/I18n/translations/hebrew.yaml | 1 - lib/I18n/translations/hungarian.yaml | 1 - lib/I18n/translations/indonesia.yaml | 1 - lib/I18n/translations/italian.yaml | 1 - lib/I18n/translations/kazakh.yaml | 1 - lib/I18n/translations/lithuanian.yaml | 1 - lib/I18n/translations/norwegian.yaml | 1 - lib/I18n/translations/orangutan.yaml | 1 - lib/I18n/translations/persian.yaml | 1 - lib/I18n/translations/polish.yaml | 1 - lib/I18n/translations/portuguese-BR.yaml | 1 - lib/I18n/translations/portuguese-PT.yaml | 1 - lib/I18n/translations/romanian.yaml | 1 - lib/I18n/translations/russian.yaml | 1 - lib/I18n/translations/slovak.yaml | 1 - lib/I18n/translations/slovenian.yaml | 1 - lib/I18n/translations/spanish.yaml | 1 - lib/I18n/translations/swedish.yaml | 1 - lib/I18n/translations/turkish.yaml | 1 - lib/I18n/translations/ukrainian.yaml | 1 - lib/I18n/translations/valencian.yaml | 1 - lib/I18n/translations/vietnamese.yaml | 1 - src/activities/reader/EpubReaderActivity.cpp | 63 ++++--- src/activities/reader/EpubReaderActivity.h | 1 + .../EpubReaderFootnoteSelectActivity.cpp | 155 ++++++++++++++++++ .../reader/EpubReaderFootnoteSelectActivity.h | 57 +++++++ .../reader/EpubReaderFootnotesActivity.cpp | 96 ----------- .../reader/EpubReaderFootnotesActivity.h | 31 ---- 39 files changed, 252 insertions(+), 184 deletions(-) create mode 100644 src/activities/reader/EpubReaderFootnoteSelectActivity.cpp create mode 100644 src/activities/reader/EpubReaderFootnoteSelectActivity.h delete mode 100644 src/activities/reader/EpubReaderFootnotesActivity.cpp delete mode 100644 src/activities/reader/EpubReaderFootnotesActivity.h diff --git a/lib/I18n/translations/arabic.yaml b/lib/I18n/translations/arabic.yaml index a599417c565..a5cee63a61c 100644 --- a/lib/I18n/translations/arabic.yaml +++ b/lib/I18n/translations/arabic.yaml @@ -384,7 +384,6 @@ STR_PWR_BTN_FOOTNOTE_BACK: "عودة سريعة من الحواشي" STR_BACK_SHORT_TO_FILE_BROWSER: "عودة سريعة إلى متصفح الملفات" STR_SET_SLEEP_COVER: "تعيين الغلاف" STR_FOOTNOTES: "الحواشي" -STR_NO_FOOTNOTES: "لا توجد حواش في هذه الصفحة" STR_LINK: "[رابط]" STR_SCREENSHOT_BUTTON: "التقاط لقطة شاشة" STR_SLEEP_TIMER_VALUE_FORMAT: "%u دقيقة" diff --git a/lib/I18n/translations/belarusian.yaml b/lib/I18n/translations/belarusian.yaml index 04d4eb25012..5ec8f9fe36e 100644 --- a/lib/I18n/translations/belarusian.yaml +++ b/lib/I18n/translations/belarusian.yaml @@ -374,7 +374,6 @@ STR_DELETE: "Выдаліць" STR_CONFIRM_DELETE_BOOKMARK: "Выдаліць гэтую закладку?" STR_DISPLAY_QR: "Паказаць старонку як QR" STR_FOOTNOTES: "Зноскі" -STR_NO_FOOTNOTES: "На гэтай старонцы няма зносак" STR_LINK: "[спасылка]" STR_SLEEP_TIMER_VALUE_FORMAT: "%u хв" STR_SLEEP_NEVER: "Ніколі" diff --git a/lib/I18n/translations/bosnian.yaml b/lib/I18n/translations/bosnian.yaml index 8df21cd3563..be236925c8d 100644 --- a/lib/I18n/translations/bosnian.yaml +++ b/lib/I18n/translations/bosnian.yaml @@ -374,7 +374,6 @@ STR_PWR_BTN_FOOTNOTE_BACK: "Brzi povratak iz fusnota" STR_BACK_SHORT_TO_FILE_BROWSER: "Brzi povratak na pregled datoteka" STR_SET_SLEEP_COVER: "Postavi korice" STR_FOOTNOTES: "Fusnote" -STR_NO_FOOTNOTES: "Nema fusnota na ovoj stranici" STR_LINK: "[link]" STR_SCREENSHOT_BUTTON: "Snimi ekran" STR_SLEEP_TIMER_VALUE_FORMAT: "%u min" diff --git a/lib/I18n/translations/bulgarian.yaml b/lib/I18n/translations/bulgarian.yaml index 626fdcaf72f..0acab62e727 100644 --- a/lib/I18n/translations/bulgarian.yaml +++ b/lib/I18n/translations/bulgarian.yaml @@ -365,7 +365,6 @@ STR_PWR_BTN_FOOTNOTE_BACK: "Бързо връщане от бележки под STR_BACK_SHORT_TO_FILE_BROWSER: "Натиск. назад към файловия браузър" STR_SET_SLEEP_COVER: "Задай корица" STR_FOOTNOTES: "Бележки под линия" -STR_NO_FOOTNOTES: "Няма бележки под линия на тази страница" STR_LINK: "[връзка]" STR_SCREENSHOT_BUTTON: "Направи снимка на екрана" STR_SLEEP_TIMER_VALUE_FORMAT: "%u мин" diff --git a/lib/I18n/translations/catalan.yaml b/lib/I18n/translations/catalan.yaml index 2a752996441..2154ecf7670 100644 --- a/lib/I18n/translations/catalan.yaml +++ b/lib/I18n/translations/catalan.yaml @@ -368,7 +368,6 @@ STR_OPDS_SERVER_URL: "URL del servidor OPDS" STR_PWR_BTN_FOOTNOTE_BACK: "Retorn ràpid des de les notes al peu" STR_BACK_SHORT_TO_FILE_BROWSER: "Enrere (pulsació curta): fitxers" STR_FOOTNOTES: "Notes al peu" -STR_NO_FOOTNOTES: "No hi ha notes al peu en aquesta pàgina" STR_LINK: "[enllaç]" STR_SLEEP_TIMER_VALUE_FORMAT: "%u min" STR_SLEEP_NEVER: "Mai" diff --git a/lib/I18n/translations/czech.yaml b/lib/I18n/translations/czech.yaml index 0c52e8ebe63..63bc98b61d1 100644 --- a/lib/I18n/translations/czech.yaml +++ b/lib/I18n/translations/czech.yaml @@ -401,7 +401,6 @@ STR_MANAGE_FONTS: "Správa písem" STR_NEXT_PAGE: "Další stránka »" STR_NO_BIN_FILES: "Nenalezeny žádné soubory .bin" STR_NO_FONTS_AVAILABLE: "Žádná písma nejsou k dispozici" -STR_NO_FOOTNOTES: "Na této stránce nejsou žádné poznámky pod čarou" STR_NO_SERVERS: "Nejsou nakonfigurovány žádné OPDS servery" STR_OPDS_SD_ROOT: "Kořen SD karty" STR_OPDS_SERVERS: "OPDS servery" diff --git a/lib/I18n/translations/danish.yaml b/lib/I18n/translations/danish.yaml index ddd8492ab3b..6974018d7b9 100644 --- a/lib/I18n/translations/danish.yaml +++ b/lib/I18n/translations/danish.yaml @@ -309,7 +309,6 @@ STR_EMBEDDED_STYLE: "Indlejret stil" STR_FOCUS_READING: "Fokuslæsning" STR_OPDS_SERVER_URL: "OPDS Server URL" STR_FOOTNOTES: "Fodnoter" -STR_NO_FOOTNOTES: "Ingen fodnoter på denne side" STR_LINK: "[link]" STR_SLEEP_TIMER_VALUE_FORMAT: "%u min" STR_SLEEP_NEVER: "Aldrig" diff --git a/lib/I18n/translations/dutch.yaml b/lib/I18n/translations/dutch.yaml index 0562172b06f..fbcecd71860 100644 --- a/lib/I18n/translations/dutch.yaml +++ b/lib/I18n/translations/dutch.yaml @@ -309,7 +309,6 @@ STR_EMBEDDED_STYLE: "Ingebedde stijl" STR_FOCUS_READING: "Gefocust lezen" STR_OPDS_SERVER_URL: "OPDS-server URL" STR_FOOTNOTES: "Voetnoten" -STR_NO_FOOTNOTES: "Geen voetnoten op deze pagina" STR_LINK: "[link]" STR_SLEEP_TIMER_VALUE_FORMAT: "%u min" STR_SLEEP_NEVER: "Nooit" diff --git a/lib/I18n/translations/english.yaml b/lib/I18n/translations/english.yaml index 3e9b776e1f9..4567821f853 100644 --- a/lib/I18n/translations/english.yaml +++ b/lib/I18n/translations/english.yaml @@ -431,7 +431,6 @@ STR_PWR_BTN_FOOTNOTE_BACK: "Quick-Return from Footnotes" STR_BACK_SHORT_TO_FILE_BROWSER: "Short Back to File Browser" STR_SET_SLEEP_COVER: "Set Cover" STR_FOOTNOTES: "Footnotes" -STR_NO_FOOTNOTES: "No footnotes on this page" STR_LINK: "[link]" STR_SCREENSHOT_BUTTON: "Take screenshot" STR_SLEEP_TIMER_VALUE_FORMAT: "%u min" diff --git a/lib/I18n/translations/french.yaml b/lib/I18n/translations/french.yaml index 69372374878..268590c1615 100644 --- a/lib/I18n/translations/french.yaml +++ b/lib/I18n/translations/french.yaml @@ -395,7 +395,6 @@ STR_PWR_BTN_FOOTNOTE_BACK: "Retour rapide depuis notes de bas de page" STR_BACK_SHORT_TO_FILE_BROWSER: "Retour court vers fichiers" STR_SET_SLEEP_COVER: "Définir comme couverture" STR_FOOTNOTES: "Notes de bas de page" -STR_NO_FOOTNOTES: "Aucune note sur cette page" STR_LINK: "[lien]" STR_SCREENSHOT_BUTTON: "Capture d'écran" STR_SLEEP_TIMER_VALUE_FORMAT: "%u min" diff --git a/lib/I18n/translations/german.yaml b/lib/I18n/translations/german.yaml index f2a1f34fc61..e054abbb2eb 100644 --- a/lib/I18n/translations/german.yaml +++ b/lib/I18n/translations/german.yaml @@ -333,7 +333,6 @@ STR_BACK_SHORT_TO_FILE_BROWSER: "Kurz zurück drücken zum Datei-Browser" STR_SET_SLEEP_COVER: "Wähle Cover" STR_SCREENSHOT_BUTTON: "Screenshot aufnehmen" STR_FOOTNOTES: "Fußnoten" -STR_NO_FOOTNOTES: "Keine Fußnoten auf dieser Seite" STR_LINK: "[Link]" STR_SLEEP_TIMER_VALUE_FORMAT: "%u Min." STR_SLEEP_NEVER: "Nie" diff --git a/lib/I18n/translations/hebrew.yaml b/lib/I18n/translations/hebrew.yaml index cb9080daa70..6b865065206 100644 --- a/lib/I18n/translations/hebrew.yaml +++ b/lib/I18n/translations/hebrew.yaml @@ -361,7 +361,6 @@ STR_OPDS_SERVER_URL: "כתובת שרת OPDS" STR_BACK_SHORT_TO_FILE_BROWSER: "חזרה קצרה לסייר הקבצים" STR_SET_SLEEP_COVER: "הגדר כריכה" STR_FOOTNOTES: "הערות שוליים" -STR_NO_FOOTNOTES: "אין הערות שוליים בדף זה" STR_LINK: "[קישור]" STR_SCREENSHOT_BUTTON: "צלם מסך" STR_ADD_SERVER: "הוסף שרת" diff --git a/lib/I18n/translations/hungarian.yaml b/lib/I18n/translations/hungarian.yaml index 9e6ba43ad31..ec2d1ec69a4 100644 --- a/lib/I18n/translations/hungarian.yaml +++ b/lib/I18n/translations/hungarian.yaml @@ -375,7 +375,6 @@ STR_PWR_BTN_FOOTNOTE_BACK: "Gyors visszatérés lábjegyzetből" STR_BACK_SHORT_TO_FILE_BROWSER: "Rövid Vissza a fájlböngészőhöz" STR_SET_SLEEP_COVER: "Borító beállítása" STR_FOOTNOTES: "Lábjegyzetek" -STR_NO_FOOTNOTES: "Nincsenek lábjegyzetek ezen az oldalon" STR_LINK: "[link]" STR_SCREENSHOT_BUTTON: "Képernyőkép készítése" STR_SLEEP_TIMER_VALUE_FORMAT: "%u perc" diff --git a/lib/I18n/translations/indonesia.yaml b/lib/I18n/translations/indonesia.yaml index 42c9521207e..e552e81acbe 100644 --- a/lib/I18n/translations/indonesia.yaml +++ b/lib/I18n/translations/indonesia.yaml @@ -375,7 +375,6 @@ STR_PWR_BTN_FOOTNOTE_BACK: "Kembali cepat dari catatan kaki" STR_BACK_SHORT_TO_FILE_BROWSER: "Tekan Singkat Kembali ke Peramban" STR_SET_SLEEP_COVER: "Atur Sampul" STR_FOOTNOTES: "Catatan Kaki" -STR_NO_FOOTNOTES: "Tidak ada catatan kaki di halaman ini" STR_LINK: "[tautan]" STR_SCREENSHOT_BUTTON: "Ambil tangkapan layar" STR_SLEEP_TIMER_VALUE_FORMAT: "%u mnt" diff --git a/lib/I18n/translations/italian.yaml b/lib/I18n/translations/italian.yaml index a5dde67b534..c1745959166 100644 --- a/lib/I18n/translations/italian.yaml +++ b/lib/I18n/translations/italian.yaml @@ -283,7 +283,6 @@ STR_NO_CREDENTIALS_MSG: "Nessuna credenziale impostata" STR_NO_ENTRIES: "Nessuna voce trovata" STR_NO_FILES_FOUND: "Nessun file trovato" STR_NO_FONTS_AVAILABLE: "Nessun font disponibile" -STR_NO_FOOTNOTES: "Nessuna nota in questa pagina" STR_NO_NETWORKS: "Nessuna rete trovata" STR_NO_OPEN_BOOK: "Nessun libro aperto" STR_NO_REMOTE_MSG: "Nessuna posizione remota trovata" diff --git a/lib/I18n/translations/kazakh.yaml b/lib/I18n/translations/kazakh.yaml index 4a63f81fedf..410e86781b0 100644 --- a/lib/I18n/translations/kazakh.yaml +++ b/lib/I18n/translations/kazakh.yaml @@ -305,7 +305,6 @@ STR_BATTERY: "Батарея" STR_DELETE: "Жою" STR_DISPLAY_QR: "Бетті QR ретінде көрсету" STR_FOOTNOTES: "Түсіндірме жазбалар" -STR_NO_FOOTNOTES: "Бұл бетте түсіндірме жазбалар жоқ" STR_LINK: "[сілтеме]" STR_SLEEP_TIMER_VALUE_FORMAT: "%u мин" STR_SLEEP_NEVER: "Ешқашан" diff --git a/lib/I18n/translations/lithuanian.yaml b/lib/I18n/translations/lithuanian.yaml index 0a4e4b7e736..46fb2e29f8d 100644 --- a/lib/I18n/translations/lithuanian.yaml +++ b/lib/I18n/translations/lithuanian.yaml @@ -375,7 +375,6 @@ STR_PWR_BTN_FOOTNOTE_BACK: "Greitas grįžimas iš išnašų" STR_BACK_SHORT_TO_FILE_BROWSER: "Trumpas Atgal į failų sąrašą" STR_SET_SLEEP_COVER: "Nustatyti viršelį" STR_FOOTNOTES: "Išnašos" -STR_NO_FOOTNOTES: "Šiame psl. išnašų nėra" STR_LINK: "[nuoroda]" STR_SCREENSHOT_BUTTON: "Ekrano nuotrauka" STR_SLEEP_TIMER_VALUE_FORMAT: "%u min." diff --git a/lib/I18n/translations/norwegian.yaml b/lib/I18n/translations/norwegian.yaml index 35cc3bfbfa2..ce309a682de 100644 --- a/lib/I18n/translations/norwegian.yaml +++ b/lib/I18n/translations/norwegian.yaml @@ -376,7 +376,6 @@ STR_PWR_BTN_FOOTNOTE_BACK: "Hurtig retur fra fotnoter" STR_BACK_SHORT_TO_FILE_BROWSER: "Kort Tilbake-trykk til filutforsker" STR_SET_SLEEP_COVER: "Velg omslag" STR_FOOTNOTES: "Fotnoter" -STR_NO_FOOTNOTES: "Ingen fotnoter på denne siden" STR_LINK: "[lenke]" STR_SCREENSHOT_BUTTON: "Ta skjermbilde" STR_SLEEP_TIMER_VALUE_FORMAT: "%u min" diff --git a/lib/I18n/translations/orangutan.yaml b/lib/I18n/translations/orangutan.yaml index 7a33c02a3ec..466d9757005 100644 --- a/lib/I18n/translations/orangutan.yaml +++ b/lib/I18n/translations/orangutan.yaml @@ -304,7 +304,6 @@ STR_PWR_BTN_FOOTNOTE_BACK: "Oook OOK" STR_BACK_SHORT_TO_FILE_BROWSER: "OOk OOK" STR_SET_SLEEP_COVER: "Oook OOK" STR_FOOTNOTES: "Ook! Oook OOK" -STR_NO_FOOTNOTES: "Oook?" STR_LINK: "[Ook]" STR_SCREENSHOT_BUTTON: "Oook! OOK? Ook!" STR_SLEEP_TIMER_VALUE_FORMAT: "%u OOK" diff --git a/lib/I18n/translations/persian.yaml b/lib/I18n/translations/persian.yaml index 2d95e36b9ad..3cca398613e 100644 --- a/lib/I18n/translations/persian.yaml +++ b/lib/I18n/translations/persian.yaml @@ -394,7 +394,6 @@ STR_PWR_BTN_FOOTNOTE_BACK: "بازگشت از پاورقی با دکمه‌ی پ STR_BACK_SHORT_TO_FILE_BROWSER: "فشار کوتاه دکمه‌ی برگشت: مرور فایل‌ها" STR_SET_SLEEP_COVER: "انتخاب جلد" STR_FOOTNOTES: "پاورقی‌ها" -STR_NO_FOOTNOTES: "در این صفحه پاورقی‌ای نیست" STR_LINK: "[لینک]" STR_SCREENSHOT_BUTTON: "گرفتن اسکرین‌شات" STR_SLEEP_TIMER_VALUE_FORMAT: "%u دقیقه" diff --git a/lib/I18n/translations/polish.yaml b/lib/I18n/translations/polish.yaml index 9cc29cb69ff..9161d4b83da 100644 --- a/lib/I18n/translations/polish.yaml +++ b/lib/I18n/translations/polish.yaml @@ -369,7 +369,6 @@ STR_PWR_BTN_FOOTNOTE_BACK: "Szybki powrót z przypisów" STR_BACK_SHORT_TO_FILE_BROWSER: "Wróć do przeglądarki plików" STR_SET_SLEEP_COVER: "Ustaw okładkę" STR_FOOTNOTES: "Przypisy" -STR_NO_FOOTNOTES: "Brak przypisów na tej stronie" STR_LINK: "[link]" STR_SLEEP_TIMER_VALUE_FORMAT: "%u min" STR_SLEEP_NEVER: "Nigdy" diff --git a/lib/I18n/translations/portuguese-BR.yaml b/lib/I18n/translations/portuguese-BR.yaml index a254fa78837..4083454ac09 100644 --- a/lib/I18n/translations/portuguese-BR.yaml +++ b/lib/I18n/translations/portuguese-BR.yaml @@ -375,7 +375,6 @@ STR_PWR_BTN_FOOTNOTE_BACK: "Retorno rápido de notas de rodapé" STR_BACK_SHORT_TO_FILE_BROWSER: "Clique curto em Voltar para Arquivos" STR_SET_SLEEP_COVER: "Definir capa" STR_FOOTNOTES: "Notas de rodapé" -STR_NO_FOOTNOTES: "Nenhuma nota de rodapé nesta página" STR_LINK: "[link]" STR_SCREENSHOT_BUTTON: "Capturar tela" STR_SLEEP_TIMER_VALUE_FORMAT: "%u min" diff --git a/lib/I18n/translations/portuguese-PT.yaml b/lib/I18n/translations/portuguese-PT.yaml index d74389bd3b0..873a5ea3f99 100644 --- a/lib/I18n/translations/portuguese-PT.yaml +++ b/lib/I18n/translations/portuguese-PT.yaml @@ -375,7 +375,6 @@ STR_PWR_BTN_FOOTNOTE_BACK: "Retorno rápido das notas de rodapé" STR_BACK_SHORT_TO_FILE_BROWSER: "Toque curto em Voltar para o Explorador de Ficheiros" STR_SET_SLEEP_COVER: "Definir capa" STR_FOOTNOTES: "Notas de rodapé" -STR_NO_FOOTNOTES: "Nenhuma nota de rodapé nesta página" STR_LINK: "[ligação]" STR_SCREENSHOT_BUTTON: "Captura de ecrã" STR_SLEEP_TIMER_VALUE_FORMAT: "%u min" diff --git a/lib/I18n/translations/romanian.yaml b/lib/I18n/translations/romanian.yaml index 6ef52f69b64..69f83493d0f 100644 --- a/lib/I18n/translations/romanian.yaml +++ b/lib/I18n/translations/romanian.yaml @@ -309,7 +309,6 @@ STR_EMBEDDED_STYLE: "Stil încorporat" STR_FOCUS_READING: "Lectură concentrată" STR_OPDS_SERVER_URL: "URL server OPDS" STR_FOOTNOTES: "Note de subsol" -STR_NO_FOOTNOTES: "Nicio notă de subsol" STR_LINK: "[link]" STR_SLEEP_TIMER_VALUE_FORMAT: "%u min" STR_SLEEP_NEVER: "Niciodată" diff --git a/lib/I18n/translations/russian.yaml b/lib/I18n/translations/russian.yaml index 9e1c8574680..409cf85355c 100644 --- a/lib/I18n/translations/russian.yaml +++ b/lib/I18n/translations/russian.yaml @@ -393,7 +393,6 @@ STR_PWR_BTN_FOOTNOTE_BACK: "Быстрый возврат из примечан STR_BACK_SHORT_TO_FILE_BROWSER: "Быстрый возврат к файлам" STR_SET_SLEEP_COVER: "На экран сна" STR_FOOTNOTES: "Примечания" -STR_NO_FOOTNOTES: "На данной странице нет примечаний" STR_LINK: "[ссылка]" STR_SCREENSHOT_BUTTON: "Сделать снимок экрана" STR_SLEEP_TIMER_VALUE_FORMAT: "%u мин" diff --git a/lib/I18n/translations/slovak.yaml b/lib/I18n/translations/slovak.yaml index e44d218f255..9a0faa23b9c 100644 --- a/lib/I18n/translations/slovak.yaml +++ b/lib/I18n/translations/slovak.yaml @@ -375,7 +375,6 @@ STR_PWR_BTN_FOOTNOTE_BACK: "Quick-return from footnotes" STR_BACK_SHORT_TO_FILE_BROWSER: "Short Back to File Browser" STR_SET_SLEEP_COVER: "Nastav obal" STR_FOOTNOTES: "Poznámky pod čiarou" -STR_NO_FOOTNOTES: "Žiadne poznámky pod čiarou" STR_LINK: "[link]" STR_SCREENSHOT_BUTTON: "Urobiť snímku obrazovky" STR_SLEEP_TIMER_VALUE_FORMAT: "%u min" diff --git a/lib/I18n/translations/slovenian.yaml b/lib/I18n/translations/slovenian.yaml index 96c32d7664e..a497f8a2e8e 100644 --- a/lib/I18n/translations/slovenian.yaml +++ b/lib/I18n/translations/slovenian.yaml @@ -375,7 +375,6 @@ STR_PWR_BTN_FOOTNOTE_BACK: "Hitra vrnitev iz opomb" STR_BACK_SHORT_TO_FILE_BROWSER: "Kratek pritisk Nazaj v brskalnik datotek" STR_SET_SLEEP_COVER: "Nastavi naslovnico" STR_FOOTNOTES: "Opombe" -STR_NO_FOOTNOTES: "Na tej strani ni opomb" STR_LINK: "[povezava]" STR_SCREENSHOT_BUTTON: "Zajemi posnetek zaslona" STR_SLEEP_TIMER_VALUE_FORMAT: "%u min" diff --git a/lib/I18n/translations/spanish.yaml b/lib/I18n/translations/spanish.yaml index cfc06ea17af..d0555a411aa 100644 --- a/lib/I18n/translations/spanish.yaml +++ b/lib/I18n/translations/spanish.yaml @@ -392,7 +392,6 @@ STR_PWR_BTN_FOOTNOTE_BACK: "Retorno rápido desde las notas al pie" STR_BACK_SHORT_TO_FILE_BROWSER: "Atrás (pulsación corta): archivos" STR_SET_SLEEP_COVER: "Fondo" STR_FOOTNOTES: "Notas al pie" -STR_NO_FOOTNOTES: "No hay notas al pie de esta página" STR_LINK: "[enlace]" STR_SLEEP_TIMER_VALUE_FORMAT: "%u min." STR_SLEEP_NEVER: "Nunca" diff --git a/lib/I18n/translations/swedish.yaml b/lib/I18n/translations/swedish.yaml index 1fad734e543..e98e7f3f476 100644 --- a/lib/I18n/translations/swedish.yaml +++ b/lib/I18n/translations/swedish.yaml @@ -393,7 +393,6 @@ STR_PWR_BTN_FOOTNOTE_BACK: "Snabbåtergång från fotnoter" STR_BACK_SHORT_TO_FILE_BROWSER: "Snabbt tillbaka till filbläddraren" STR_SET_SLEEP_COVER: "Ställ in omslag" STR_FOOTNOTES: "Fotnoter" -STR_NO_FOOTNOTES: "Inga fotnoter på den här sidan" STR_LINK: "[länk]" STR_SCREENSHOT_BUTTON: "Ta en skärmdump" STR_SLEEP_TIMER_VALUE_FORMAT: "%u min" diff --git a/lib/I18n/translations/turkish.yaml b/lib/I18n/translations/turkish.yaml index 39b159a1471..c6b018ecf70 100644 --- a/lib/I18n/translations/turkish.yaml +++ b/lib/I18n/translations/turkish.yaml @@ -340,7 +340,6 @@ STR_FMT_TITLE_AUTHOR: "Başlık - Yazar" STR_FMT_TITLE: "Başlık" STR_OPDS_SD_ROOT: "SD kart kökü" STR_NO_FILES_FOUND: "Dosya bulunamadı" -STR_NO_FOOTNOTES: "Bu sayfada dipnot yok" STR_PREVIEW: "Önizleme" STR_PROGRESS_BAR: "İlerleme Çubuğu" STR_PROGRESS_BAR_MEDIUM: "Orta" diff --git a/lib/I18n/translations/ukrainian.yaml b/lib/I18n/translations/ukrainian.yaml index b7f1d136c15..62f9024df75 100644 --- a/lib/I18n/translations/ukrainian.yaml +++ b/lib/I18n/translations/ukrainian.yaml @@ -374,7 +374,6 @@ STR_PWR_BTN_FOOTNOTE_BACK: "Швидке повернення з приміто STR_BACK_SHORT_TO_FILE_BROWSER: "Назад відкриватиме файл. провід." STR_SET_SLEEP_COVER: "Як обкл." STR_FOOTNOTES: "Примітки" -STR_NO_FOOTNOTES: "На цій сторінці немає приміток" STR_LINK: "[посилання]" STR_SCREENSHOT_BUTTON: "Знімок екрана" STR_SLEEP_TIMER_VALUE_FORMAT: "%u хв" diff --git a/lib/I18n/translations/valencian.yaml b/lib/I18n/translations/valencian.yaml index ce45d6e96b8..d32a7b757b1 100644 --- a/lib/I18n/translations/valencian.yaml +++ b/lib/I18n/translations/valencian.yaml @@ -373,7 +373,6 @@ STR_OPDS_SERVER_URL: "URL del servidor OPDS" STR_PWR_BTN_FOOTNOTE_BACK: "Retorn ràpid des de les notes al peu" STR_BACK_SHORT_TO_FILE_BROWSER: "Arrere (pulsació curta): arxius" STR_FOOTNOTES: "Notes al peu" -STR_NO_FOOTNOTES: "No hi ha notes al peu en esta pàgina" STR_LINK: "[enllaç]" STR_SCREENSHOT_BUTTON: "Fes una captura de pantalla" STR_AUTO_TURN_ENABLED: "Pas automàtic de pàgina activat" diff --git a/lib/I18n/translations/vietnamese.yaml b/lib/I18n/translations/vietnamese.yaml index 4d7791a2b7b..ffce3a35739 100644 --- a/lib/I18n/translations/vietnamese.yaml +++ b/lib/I18n/translations/vietnamese.yaml @@ -384,7 +384,6 @@ STR_PWR_BTN_FOOTNOTE_BACK: "Quay lại nhanh từ chú thích" STR_BACK_SHORT_TO_FILE_BROWSER: "Nhấn ngắn Quay lại về Duyệt tập tin" STR_SET_SLEEP_COVER: "Đặt ảnh màn hình ngủ" STR_FOOTNOTES: "Chú thích" -STR_NO_FOOTNOTES: "Trang này không có chú thích" STR_LINK: "[liên kết]" STR_SCREENSHOT_BUTTON: "Chụp màn hình" STR_SLEEP_TIMER_VALUE_FORMAT: "%u phút" diff --git a/src/activities/reader/EpubReaderActivity.cpp b/src/activities/reader/EpubReaderActivity.cpp index b5456be34b8..69b8fce1f96 100644 --- a/src/activities/reader/EpubReaderActivity.cpp +++ b/src/activities/reader/EpubReaderActivity.cpp @@ -26,7 +26,7 @@ #include "DictionaryWordSelectActivity.h" #include "EpubReaderBookmarksActivity.h" #include "EpubReaderChapterSelectionActivity.h" -#include "EpubReaderFootnotesActivity.h" +#include "EpubReaderFootnoteSelectActivity.h" #include "EpubReaderPercentSelectionActivity.h" #include "EpubReaderUtils.h" #include "KOReaderCredentialStore.h" @@ -333,6 +333,42 @@ void EpubReaderActivity::openDictionaryWordSelect() { [this](const ActivityResult&) { requestUpdate(); }); } +void EpubReaderActivity::openFootnoteSelect(const bool reopenMenuOnCancel) { + if (!section || currentPageFootnotes.empty()) return; + if (currentPageFootnotes.size() == 1) { + navigateToHref(currentPageFootnotes[0].href, true); + return; + } + + auto page = section->loadPage(section->currentPage); + if (!page) return; + + int orientedMarginTop, orientedMarginRight, orientedMarginBottom, orientedMarginLeft; + renderer.getOrientedViewableTRBL(&orientedMarginTop, &orientedMarginRight, &orientedMarginBottom, + &orientedMarginLeft); + orientedMarginTop += SETTINGS.screenMargin; + orientedMarginLeft += SETTINGS.screenMargin; + auto selector = makeUniqueNoThrow(renderer, mappedInput, std::move(page), + orientedMarginLeft, orientedMarginTop); + if (!selector) { + LOG_ERR("ERA", "OOM: EpubReaderFootnoteSelectActivity"); + return; + } + startActivityForResult(std::move(selector), [this, reopenMenuOnCancel](const ActivityResult& result) { + if (result.isCancelled) { + if (reopenMenuOnCancel) { + openReaderMenu(); + } else { + requestUpdate(); + } + return; + } + const auto& footnoteResult = std::get(result.data); + navigateToHref(footnoteResult.href, true); + requestUpdate(); + }); +} + void EpubReaderActivity::loop() { if (!epub) { finish(); @@ -597,19 +633,7 @@ void EpubReaderActivity::loop() { if (footnoteDepth > 0) { restoreSavedPosition(); } else { - if (currentPageFootnotes.size() == 1) { - navigateToHref(currentPageFootnotes[0].href, true); - } else if (currentPageFootnotes.size() > 1) { - startActivityForResult( - std::make_unique(renderer, mappedInput, currentPageFootnotes), - [this](const ActivityResult& result) { - if (!result.isCancelled) { - const auto& footnoteResult = std::get(result.data); - navigateToHref(footnoteResult.href, true); - } - requestUpdate(); - }); - } + openFootnoteSelect(false); } return; } @@ -816,16 +840,7 @@ void EpubReaderActivity::onReaderMenuConfirm(EpubReaderMenuActivity::MenuAction break; } case EpubReaderMenuActivity::MenuAction::FOOTNOTES: { - startActivityForResult(std::make_unique(renderer, mappedInput, currentPageFootnotes), - [this](const ActivityResult& result) { - if (result.isCancelled) { - openReaderMenu(); - return; - } - const auto& footnoteResult = std::get(result.data); - navigateToHref(footnoteResult.href, true); - requestUpdate(); - }); + openFootnoteSelect(true); break; } case EpubReaderMenuActivity::MenuAction::TEXT_SETTINGS: { diff --git a/src/activities/reader/EpubReaderActivity.h b/src/activities/reader/EpubReaderActivity.h index 64bc23e0dac..8e27c47cc7f 100644 --- a/src/activities/reader/EpubReaderActivity.h +++ b/src/activities/reader/EpubReaderActivity.h @@ -160,6 +160,7 @@ class EpubReaderActivity final : public ReaderActivity { std::string moreRowName(int row) const; std::string moreRowValue(int row) const; void activateMoreRow(int row); + void openFootnoteSelect(bool reopenMenuOnCancel); void openDictionaryWordSelect(); bool launchKOReaderSync(); unsigned long confirmLongPressThreshold() const; diff --git a/src/activities/reader/EpubReaderFootnoteSelectActivity.cpp b/src/activities/reader/EpubReaderFootnoteSelectActivity.cpp new file mode 100644 index 00000000000..95c19c95421 --- /dev/null +++ b/src/activities/reader/EpubReaderFootnoteSelectActivity.cpp @@ -0,0 +1,155 @@ +#include "EpubReaderFootnoteSelectActivity.h" + +#include +#include + +#include + +#include "CrossPointSettings.h" +#include "MappedInputManager.h" +#include "components/UITheme.h" + +namespace { +constexpr unsigned long WORD_REPEAT_START_MS = 500; +constexpr unsigned long WORD_REPEAT_INTERVAL_MS = 500; +} // namespace + +void EpubReaderFootnoteSelectActivity::onEnter() { + Activity::onEnter(); + snapshot = makeUniqueNoThrow(SNAPSHOT_CAPACITY); + buildFootnoteLinks(); + requestUpdate(); +} + +void EpubReaderFootnoteSelectActivity::buildFootnoteLinks() { + footnoteLinks.clear(); + footnoteLinks.reserve(page->footnotes.size()); + + // Match each footnote entry to its PageLink on this page. Footnote links + // and footnote entries share the same href, so we can cross-reference them. + for (const auto& footnote : page->footnotes) { + const auto link = std::find_if(page->links.begin(), page->links.end(), [&footnote](const PageLink& candidate) { + return strcmp(candidate.href, footnote.href) == 0; + }); + if (link != page->links.end()) { + footnoteLinks.push_back({&*link, &footnote}); + } + } +} + +void EpubReaderFootnoteSelectActivity::performJump() { + if (footnoteLinks.empty()) return; + ActivityResult result; + result.data = FootnoteResult{footnoteLinks[selected].footnote->href}; + setResult(std::move(result)); + finish(); +} + +void EpubReaderFootnoteSelectActivity::drawHints() const { + const auto labels = mappedInput.mapDirectionalLabels(tr(STR_BACK), tr(STR_OPEN), tr(STR_DIR_LEFT), tr(STR_DIR_RIGHT), + tr(STR_DIR_UP), tr(STR_DIR_DOWN)); + GUI.drawButtonHints(renderer, labels.btn1, labels.btn2, labels.btn3, labels.btn4); +} + +void EpubReaderFootnoteSelectActivity::loop() { + if (mappedInput.wasReleased(MappedInputManager::Button::Back)) { + ActivityResult result; + result.isCancelled = true; + setResult(std::move(result)); + finish(); + return; + } + + if (mappedInput.wasReleased(MappedInputManager::Button::Confirm) && !footnoteLinks.empty()) { + performJump(); + return; + } + + if (footnoteLinks.empty()) return; + + const bool hasNext = selected + 1 < static_cast(footnoteLinks.size()); + const unsigned long now = millis(); + const bool repeat = + mappedInput.getHeldTime() >= WORD_REPEAT_START_MS && now - lastHorizontalMoveTime >= WORD_REPEAT_INTERVAL_MS; + const bool moveLeft = mappedInput.wasPressed(MappedInputManager::Button::ScreenLeft) || + (repeat && mappedInput.isPressed(MappedInputManager::Button::ScreenLeft)); + const bool moveRight = mappedInput.wasPressed(MappedInputManager::Button::ScreenRight) || + (repeat && mappedInput.isPressed(MappedInputManager::Button::ScreenRight)); + if ((moveLeft || mappedInput.wasPressed(MappedInputManager::Button::ScreenUp)) && selected > 0) { + selected--; + if (moveLeft) lastHorizontalMoveTime = now; + requestUpdate(); + } else if ((moveRight || mappedInput.wasPressed(MappedInputManager::Button::ScreenDown)) && hasNext) { + selected++; + if (moveRight) lastHorizontalMoveTime = now; + requestUpdate(); + } +} + +bool EpubReaderFootnoteSelectActivity::drawHighlightWithSnapshot() { + if (footnoteLinks.empty()) return false; + const PageLink* link = footnoteLinks[selected].link; + + const int x = link->x + marginLeft; + const int y = link->y + marginTop; + int hx = x - 2; + int hy = y - 2; + int hw = link->width + 4; + int hh = link->height + 4; + + if (hx < 0) { + hw += hx; + hx = 0; + } + if (hy < 0) { + hh += hy; + hy = 0; + } + + bool saved = false; + if (snapshot && hw > 0 && hh > 0) { + saved = renderer.readFramebufferRegion(hx, hy, hw, hh, snapshot.get(), SNAPSHOT_CAPACITY) > 0; + } + snapshotX = static_cast(hx); + snapshotY = static_cast(hy); + snapshotW = static_cast(hw); + snapshotH = static_cast(hh); + snapshotIdx = saved ? selected : -1; + + renderer.fillRect(hx, hy, hw, hh, true); + // Draw a small marker (the footnote number) at the highlight position + const char* number = footnoteLinks[selected].footnote->number; + if (number[0]) { + renderer.drawText(SETTINGS.getReaderFontId(), x, y - 1, number, false); + } + return saved; +} + +void EpubReaderFootnoteSelectActivity::render(RenderLock&&) { + // Differential fast path: only the highlight moved and the framebuffer still + // holds a clean page. + if (snapshotIdx >= 0 && !footnoteLinks.empty() && selected != snapshotIdx) { + renderer.writeFramebufferRegion(snapshotX, snapshotY, snapshotW, snapshotH, snapshot.get()); + if (drawHighlightWithSnapshot()) { + drawHints(); + renderer.displayBuffer(HalDisplay::FAST_REFRESH); + return; + } + // Snapshot failed — fall through to a full repaint. + } + + renderer.clearScreen(); + + // Render the page underneath + if (page) { + page->render(renderer, SETTINGS.getReaderFontId(), marginLeft, marginTop); + } + + if (!footnoteLinks.empty()) { + drawHighlightWithSnapshot(); + } + + drawHints(); + + renderer.displayBuffer(HalDisplay::FAST_REFRESH); +} diff --git a/src/activities/reader/EpubReaderFootnoteSelectActivity.h b/src/activities/reader/EpubReaderFootnoteSelectActivity.h new file mode 100644 index 00000000000..3e8852b2edb --- /dev/null +++ b/src/activities/reader/EpubReaderFootnoteSelectActivity.h @@ -0,0 +1,57 @@ +#pragma once + +#include +#include +#include + +#include +#include + +#include "activities/Activity.h" + +// Footnote reference selection overlaid on the current reader page, modeled on +// DictionaryWordSelectActivity: the page renders underneath and each footnote +// reference marker gets a highlight box. Up/Down/Left/Right move between +// markers, Confirm jumps to that footnote, Back returns to the reader. +class EpubReaderFootnoteSelectActivity final : public Activity { + public: + explicit EpubReaderFootnoteSelectActivity(GfxRenderer& renderer, MappedInputManager& mappedInput, + std::unique_ptr page, int marginLeft, int marginTop) + : Activity("EpubReaderFootnoteSelect", renderer, mappedInput), + page(std::move(page)), + marginLeft(marginLeft), + marginTop(marginTop) {} + + private: + void onEnter() override; + void loop() override; + void render(RenderLock&&) override; + + private: + void buildFootnoteLinks(); + void performJump(); + bool drawHighlightWithSnapshot(); + void drawHints() const; + + std::unique_ptr page; + const int marginLeft; + const int marginTop; + + struct FootnoteLink { + const PageLink* link; + const FootnoteEntry* footnote; + }; + std::vector footnoteLinks; + int selected = 0; + + // Differential highlight repaint: pixels under the current highlight box. + static constexpr size_t SNAPSHOT_CAPACITY = 4096; + std::unique_ptr snapshot; + int16_t snapshotX = 0; + int16_t snapshotY = 0; + int16_t snapshotW = 0; + int16_t snapshotH = 0; + int snapshotIdx = -1; + + unsigned long lastHorizontalMoveTime = 0; +}; diff --git a/src/activities/reader/EpubReaderFootnotesActivity.cpp b/src/activities/reader/EpubReaderFootnotesActivity.cpp deleted file mode 100644 index e31fa19f299..00000000000 --- a/src/activities/reader/EpubReaderFootnotesActivity.cpp +++ /dev/null @@ -1,96 +0,0 @@ -#include "EpubReaderFootnotesActivity.h" - -#include -#include - -#include "MappedInputManager.h" -#include "components/UITheme.h" - -namespace fui = freeink::ui; - -EpubReaderFootnotesActivity::EpubReaderFootnotesActivity(GfxRenderer& renderer, MappedInputManager& mappedInput, - const std::vector& footnotes) - : UiListActivity("EpubReaderFootnotes", renderer, mappedInput), footnotes(footnotes) { - buildRowItems(); -} - -// footnotes never changes after construction (no reload path), so this only -// needs to run once here rather than on every buildScreen() call. -void EpubReaderFootnotesActivity::buildRowItems() { - rowItems.clear(); - rowItems.reserve(footnotes.size()); - for (const auto& footnote : footnotes) { - fui::ListItem item; - item.label = footnote.number[0] ? footnote.number : tr(STR_LINK); - item.actionValue = static_cast(rowItems.size()); - rowItems.push_back(item); - } -} - -void EpubReaderFootnotesActivity::activateIndex(const int index) { - if (index < 0 || index >= listCount()) return; - // The activated row leaves this screen; a lingering flash would gray an - // unrelated element on the next render. - app.clearTapFlash(); - setResult(FootnoteResult{footnotes[index].href}); - finish(); -} - -bool EpubReaderFootnotesActivity::handleButtons() { - if (mappedInput.wasReleased(MappedInputManager::Button::Back)) { - ActivityResult result; - result.isCancelled = true; - setResult(std::move(result)); - finish(); - return true; - } - - if (mappedInput.wasReleased(MappedInputManager::Button::Confirm) || - mappedInput.wasReleased(MappedInputManager::Button::Power)) { - activateIndex(nav.selected); - return true; - } - - return false; -} - -void EpubReaderFootnotesActivity::buildScreen(UiScreen& screen) { - const auto& metrics = UITheme::getInstance().getMetrics(); - const Rect safe = UITheme::getInstance().getScreenSafeArea(renderer, true, false); - // Content: the safe area minus the header band GUI.drawHeader paints. - screen.setContentMarginFromScreen(fui::Insets{ - static_cast(safe.y + metrics.topPadding + metrics.headerHeight), - static_cast(renderer.getScreenWidth() - (safe.x + safe.width)), - static_cast(renderer.getScreenHeight() - (safe.y + safe.height)), static_cast(safe.x)}); - screen.spacer(static_cast(metrics.verticalSpacing)); - - if (footnotes.empty()) { - screen.centeredText(tr(STR_NO_FOOTNOTES), screen.theme().bodyText); - return; - } - - // rowItems is built once in the constructor (see buildRowItems()) and - // reused here on every repaint. - fui::ListProps props; - props.items = rowItems.data(); - props.count = static_cast(rowItems.size()); - props.action = ACTION_ROW; - props.inputMask = fui::InputTouch; // physical buttons stay in loop() - syncListViewport(screen, props); - screen.list(props); -} - -void EpubReaderFootnotesActivity::drawChrome() { - const auto& metrics = UITheme::getInstance().getMetrics(); - const Rect safe = UITheme::getInstance().getScreenSafeArea(renderer, true, false); - // Header via GUI.drawHeader (already FreeInkUI-themed); the rest of the - // screen renders through the app. - GUI.drawHeader(renderer, Rect{safe.x, safe.y + metrics.topPadding, safe.width, metrics.headerHeight}, - tr(STR_FOOTNOTES)); -} - -void EpubReaderFootnotesActivity::drawFooter() { - const auto labels = footnotes.empty() ? mappedInput.mapLabels(tr(STR_BACK), "", "", "") - : mappedInput.mapLabels(tr(STR_BACK), tr(STR_SELECT), "", ""); - GUI.drawButtonHints(renderer, labels.btn1, labels.btn2, labels.btn3, labels.btn4); -} diff --git a/src/activities/reader/EpubReaderFootnotesActivity.h b/src/activities/reader/EpubReaderFootnotesActivity.h deleted file mode 100644 index 6ce49fc2523..00000000000 --- a/src/activities/reader/EpubReaderFootnotesActivity.h +++ /dev/null @@ -1,31 +0,0 @@ -#pragma once - -#include - -#include - -#include "activities/UiListActivity.h" - -class EpubReaderFootnotesActivity final : public UiListActivity { - public: - explicit EpubReaderFootnotesActivity(GfxRenderer& renderer, MappedInputManager& mappedInput, - const std::vector& footnotes); - - private: - int listCount() const override { return static_cast(footnotes.size()); } - void buildScreen(UiScreen& screen) override; - void activateIndex(int index) override; - // Back cancels with a result, and Confirm (or Power) activates on RELEASE here. - bool handleButtons() override; - // Header is drawn inside the safe area (not full-width like the base). - void drawChrome() override; - // Hints show Back only (empty list) or Back/Select; no Up/Down labels. - void drawFooter() override; - - const std::vector& footnotes; - // Built once in the constructor (footnotes is fixed for this activity's - // lifetime, no reload path) and reused by buildScreen() on every repaint - // instead of rebuilding a ListItem vector per render. - std::vector rowItems; - void buildRowItems(); -}; From 637ad4a5e8b01d60adfdbbaa175c709697a4ece6 Mon Sep 17 00:00:00 2001 From: Uri Tauber Date: Thu, 24 Sep 2026 23:19:19 +0300 Subject: [PATCH 20/62] fix: Switch tap zones for RTL books (#3709) --- src/activities/reader/EpubReaderActivity.cpp | 3 ++- src/activities/reader/ReaderUtils.h | 28 +++++++++++++++----- 2 files changed, 23 insertions(+), 8 deletions(-) diff --git a/src/activities/reader/EpubReaderActivity.cpp b/src/activities/reader/EpubReaderActivity.cpp index 69b8fce1f96..1e132a4700c 100644 --- a/src/activities/reader/EpubReaderActivity.cpp +++ b/src/activities/reader/EpubReaderActivity.cpp @@ -456,7 +456,8 @@ void EpubReaderActivity::loop() { pendingReadFolderMove = false; } - const auto touch = ReaderUtils::detectTouchPageTurn(renderer, mappedInput); + const auto touch = + ReaderUtils::detectTouchPageTurn(renderer, mappedInput, ReaderUtils::isRtlBookLanguage(epub->getLanguage())); if (showBookmarkMessage && (millis() - bookmarkMessageTime) >= ReaderUtils::BOOKMARK_MESSAGE_DURATION_MS) { showBookmarkMessage = false; diff --git a/src/activities/reader/ReaderUtils.h b/src/activities/reader/ReaderUtils.h index ff23401bb57..e2d5e467ede 100644 --- a/src/activities/reader/ReaderUtils.h +++ b/src/activities/reader/ReaderUtils.h @@ -6,6 +6,9 @@ #include #include +#include +#include + #include "MappedInputManager.h" #include "activities/ActivityManager.h" @@ -26,6 +29,14 @@ inline bool gestureAllowsTap(const uint8_t gesture) { gesture == CrossPointSettings::INVERTED_TAP; } +inline bool isRtlBookLanguage(std::string_view tag) { + if (tag.size() < 2 || (tag.size() > 2 && tag[2] != '-' && tag[2] != '_')) return false; + const auto first = std::tolower(static_cast(tag[0])); + const auto second = std::tolower(static_cast(tag[1])); + return (first == 'h' && second == 'e') || (first == 'i' && second == 'w') || (first == 'a' && second == 'r') || + (first == 'f' && second == 'a'); +} + inline void applyOrientation(GfxRenderer& renderer, const uint8_t orientation) { switch (orientation) { case CrossPointSettings::ORIENTATION::PORTRAIT: @@ -78,7 +89,8 @@ struct TouchPageTurn { unsigned long heldMs; }; -inline TouchPageTurn detectTouchPageTurn(const GfxRenderer& renderer, const MappedInputManager& input) { +inline TouchPageTurn detectTouchPageTurn(const GfxRenderer& renderer, const MappedInputManager& input, + const bool rtlBook = false) { TouchPageTurn result{false, false, 0}; if (!SETTINGS.touchReaderControls || !input.hasTouch()) { return result; @@ -87,8 +99,10 @@ inline TouchPageTurn detectTouchPageTurn(const GfxRenderer& renderer, const Mapp // A slow swipe never becomes a long-press chapter skip. const auto dir = input.wasSwipe(); if (dir != MappedInputManager::SwipeDir::None) { - result.next = dir == MappedInputManager::SwipeDir::Left && gestureAllowsSwipe(SETTINGS.pageTurnGesture); - result.prev = dir == MappedInputManager::SwipeDir::Right && gestureAllowsSwipe(SETTINGS.previousPageGesture); + result.next = dir == (rtlBook ? MappedInputManager::SwipeDir::Right : MappedInputManager::SwipeDir::Left) && + gestureAllowsSwipe(SETTINGS.pageTurnGesture); + result.prev = dir == (rtlBook ? MappedInputManager::SwipeDir::Left : MappedInputManager::SwipeDir::Right) && + gestureAllowsSwipe(SETTINGS.previousPageGesture); return result; } @@ -114,10 +128,10 @@ inline TouchPageTurn detectTouchPageTurn(const GfxRenderer& renderer, const Mapp } // Give the whole page to the sole tap-enabled direction. When both accept - // taps, split at the left third; either Inverted Tap setting swaps the - // shared zones. - const bool inverted = SETTINGS.pageTurnGesture == CrossPointSettings::INVERTED_TAP || - SETTINGS.previousPageGesture == CrossPointSettings::INVERTED_TAP; + // taps, split at the left third. RTL books and Inverted Tap each reverse + // the shared zones. + const bool inverted = (SETTINGS.pageTurnGesture == CrossPointSettings::INVERTED_TAP || + SETTINGS.previousPageGesture == CrossPointSettings::INVERTED_TAP) != rtlBook; const bool nextZone = inverted ? x < (width * 2) / 3 : x >= width / 3; result.next = nextTaps && (!prevTaps || nextZone); result.prev = prevTaps && (!nextTaps || !nextZone); From 794ac97f4946cb56e993a75ba9b2efca772ae7db Mon Sep 17 00:00:00 2001 From: Sung-jin Brian Hong Date: Fri, 25 Sep 2026 09:57:32 +0900 Subject: [PATCH 21/62] fix: justify Korean text by word spaces only (#3700) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## Summary * **What is the goal of this PR?** Justified Korean text currently widens the gaps between syllables inside a word as well as the spaces between words, so every word looks letter-spaced. English only widens the spaces. This PR makes Korean behave like English: only word spaces stretch. * **Root cause** #2288 (Korean line breaking) split every Hangul word into one token per syllable, joined by zero-width break opportunities (`continues=false, noSpaceBefore=true`). `TokenBoundary::isJustifiableGap()` treats those boundaries as stretchable, so `extractLine` divides the spare width across every syllable boundary as well as the real spaces. That fits Chinese and Japanese, which don't use spaces between words, but Korean does. #2768 later restored the real spaces between Hangul words but left the syllable boundaries stretchable. * **What changes are included?** - **Hangul words are no longer split into syllables.** `hasCjkBreakOpportunityBetween()` returns false when either side is Hangul, so a Korean word is one token like a Latin word. The same function covers words glued across inline tags (`한국어`). Chinese and Japanese are unchanged. - **Korean wraps at spaces by default.** With hyphenation off (the default), a Korean word moves to the next line whole, like CSS `word-break: keep-all`. - **Hyphenation ON can split Korean words at a line end.** The existing CJK boundary rule (the body of `hasCjkBreakOpportunityBetween()` before this PR, now `cjkBoundaryAllowsBreak()`) still defines where a Hangul word may break. `hyphenateWordAtIndex()` offers those points as line-end split candidates, ahead of the Hyphenator's own. `Hyphenator` itself is unchanged, and no Korean language entry is needed. - Allowed: between syllables, and where Hangul meets digits, Latin letters, or brackets (`12|월`, `소신|(conviction)|이`). - Never before closing punctuation or after opening punctuation, so `했|다.` keeps its period. - No hyphen is drawn. - **Cache:** `SECTION_FILE_VERSION` 47 → 48. Line breaks and word positions change; the binary layout does not. | Setting | Chinese / Japanese | Korean before | Korean after | |---|---|---|---| | Hyphenation OFF | unchanged | breaks between any syllables; syllable gaps stretch | wraps at spaces only; only spaces stretch | | Hyphenation ON | unchanged | same as OFF | mostly the same line breaks (a whole word can measure a few px narrower than its syllables did); only spaces stretch | ### Screenshots (X3, Pretendard 16 pt, justified) Chapter 1 of a Korean translation of *Demian*. The next line starts with `해?”`, which cannot be split, so the line above has a lot of spare width to distribute. That makes the difference easy to see. pr-visible-letterspacing-zoom - **develop:** the spare width goes into every syllable gap, so words look letter-spaced (`무 슨 일 이 야?`). - **This PR, hyphenation ON:** the same line breaks in this passage, but syllables keep their natural spacing and only the word spaces stretch. pr-visible-letterspacing-pages Full pages. develop is identical with hyphenation OFF or ON. With hyphenation OFF, this PR wraps only at spaces, so the same dialogue moves to the next page and some lines get wide word gaps. ## Tradeoffs for review - **Korean line breaking is now a reader choice.** With hyphenation OFF, Korean wraps only at spaces, so words are never split. This reads well when a line holds many words (small fonts, landscape). With few words per line (large fonts, portrait), justified lines can show wide word spaces, and hyphenation ON gives tighter lines by splitting words at a line end. The setting already exists, so no new option is added. Hyphenation is a single global setting, so it also switches English hyphenation on or off. A Korean-specific option (or choosing the behaviour from the book's language) would need a new setting and is out of scope here. - **ASCII quotes and hyphens are not punctuation-guarded.** The CJK lists have no entries for `"`, `'`, or `-`, so a line-end split can put one at the start or end of a line (`이라구|"라고`, `대한민국|-서울`), exactly as develop does. Adding them would also change Chinese and Japanese breaking, so that is left for a follow-up. - **Hanja inside a Korean word can still break.** A boundary between two Han characters (e.g. inside `韓國語는`) still counts as a break opportunity, because treating it differently would change Chinese and Japanese layout. This is rare in Korean body text. - **This changes Korean line-breaking policy, not only justification.** Korean lines used to break between any two syllables (browser `word-break: normal`). With this PR they break only at spaces when hyphenation is off, and still between syllables when it is on. ## Testing - **Host tests:** `ctest` passes 371/371. - `KoreanLayout.*` (chapter parser suite): whole-word tokens, spaces-only justification, glued inline styles stay unbreakable. - New `KoreanLineBreakingTest` target: runs `ParsedText` with the real `Hyphenator`. The existing parser suite stubs the Hyphenator out, so it can't test this path. Covers hyphenation OFF/ON, trailing punctuation staying with its syllable, splits at digit and bracket boundaries, and short words splitting. - Three existing letter-spacing tests used Korean as their sample CJK script. They now use Han characters so they keep testing per-character tracking. - **Build:** `pio run` (default) succeeds. - **Device (X3):** develop and this PR were built with the same serial-control instrumentation, and every capture came from a fresh section build. - Korean: see the screenshots above. The final revision renders this page pixel-identically to the capture. - English regression: a justified *Aesop's Fables* page with hyphenation ON is pixel-identical to develop in the text body. The only differing pixels are the status-bar battery percentage. - Not tested on a Chinese or Japanese book. The host tests cover per-character breaking for Han text. ## Additional Context - **Size:** Flash +682 B, static RAM unchanged (X3 `default` build, compared with develop). - **Performance:** no measured change, from single samples of first-page render time (measured on an earlier revision of this PR; later changes only affect which line-end split points a Korean word offers): 3500 → 3500 ms opening the Korean page cold, 1342 → 1334 ms re-laying it out after a settings change, 1198 → 1203 ms for the English page. Korean paragraphs now create fewer tokens, and a word is scanned for split points only when it overflows a line end. - **Memory:** no new persistent allocation. Korean paragraphs create fewer tokens (one per word instead of one per syllable), so the per-token vectors and word arena shrink. Line-end split points are collected only when a word overflows a line end, the same as the existing hyphenation path. ## Scope Check - [x] I have read SCOPE.md and ROADMAP.md. - [x] This PR is **not** a new built-in theme. - [x] This PR is **not** a new external network connector. - [x] This PR is **not** an interactive app, writing tool, RSS/news/browser, media playback, or PDF feature. - [x] The stock firmware does not already handle this well, **and** no other popular CrossPoint fork already does. - [x] This PR does not touch `freeink-sdk/`, `lib/hal/`, the bootloader, OTA, or recovery code. --- ### AI Usage Did you use AI tools to help write this code? _**YES**_ — root-cause investigation, implementation, and tests were done with Claude Code, and the design was iterated on with the author. 🤖 Generated with [Claude Code](https://claude.com/claude-code) https://claude.ai/code/session_01J3qbDbhkJZXFEGRDgRbd8n --- USER_GUIDE.md | 2 +- docs/file-formats.md | 11 ++- lib/Epub/Epub/ParsedText.cpp | 32 ++++++- lib/Epub/Epub/Section.cpp | 4 +- lib/Utf8/Utf8.h | 17 +++- test/CMakeLists.txt | 1 + .../ChapterHtmlSlimParserTest.cpp | 79 +++++++++++++-- test/korean_line_breaking/CMakeLists.txt | 33 +++++++ .../KoreanLineBreakingTest.cpp | 95 +++++++++++++++++++ 9 files changed, 257 insertions(+), 17 deletions(-) create mode 100644 test/korean_line_breaking/CMakeLists.txt create mode 100644 test/korean_line_breaking/KoreanLineBreakingTest.cpp diff --git a/USER_GUIDE.md b/USER_GUIDE.md index c4bfb147fa4..9a4afdc4cad 100644 --- a/USER_GUIDE.md +++ b/USER_GUIDE.md @@ -278,7 +278,7 @@ The Settings screen allows you to configure the device's behavior. There are a f - **Embedded Style**: Whether to use the EPUB file's embedded HTML and CSS stylisation and formatting; options are "ON" or "OFF". -- **Hyphenation**: Whether to hyphenate text in Reading Mode; options are "ON" or "OFF". +- **Hyphenation**: Whether to hyphenate text in Reading Mode; options are "ON" or "OFF". Korean text wraps only at spaces when "OFF"; when "ON", a Korean word may also wrap at the end of a line between syllables or where it meets digits, Latin letters, or brackets (no hyphen is drawn). - **Reading Orientation**: Set the screen orientation for reading EPUB files: diff --git a/docs/file-formats.md b/docs/file-formats.md index 2ebe830db48..644db2f1d1d 100644 --- a/docs/file-formats.md +++ b/docs/file-formats.md @@ -90,6 +90,15 @@ if (parsedSize != fileSize) { ## `section.bin` +### Version 48 + +Version 48 keeps the version 47 serialized layout unchanged. It was bumped +because Hangul text no longer has implicit line-break opportunities between +syllables: Korean words wrap at spaces (like CSS `word-break: keep-all`), and +with hyphenation enabled a word may also split at the end of a line wherever the +CJK line-breaking rules allow, without an inserted hyphen. Justification stretches only word spaces. Cached line breaks and word +positions from version 47 no longer match. + ### Version 47 The section header adds signed `characterSpacing` (pixels) and unsigned @@ -191,7 +200,7 @@ import std.mem; import std.string; import std.core; -#define EXPECTED_VERSION 47 +#define EXPECTED_VERSION 48 #define MAX_STRING_LENGTH 65535 #define FOOTNOTE_NUMBER_LEN 32 #define FOOTNOTE_HREF_LEN 256 diff --git a/lib/Epub/Epub/ParsedText.cpp b/lib/Epub/Epub/ParsedText.cpp index b697a877581..fd0a49d10eb 100644 --- a/lib/Epub/Epub/ParsedText.cpp +++ b/lib/Epub/Epub/ParsedText.cpp @@ -154,13 +154,38 @@ uint32_t countCodepoints(const std::string_view text) { return count; } -bool hasCjkBreakOpportunityBetween(const uint32_t leftCp, const uint32_t rightCp) { +bool cjkBoundaryAllowsBreak(const uint32_t leftCp, const uint32_t rightCp) { if (!utf8IsCjkBreakable(leftCp) && !utf8IsCjkBreakable(rightCp)) return false; if (isNoBreakAfterCjkPunctuation(leftCp) || isNoBreakBeforeCjkPunctuation(rightCp)) return false; if (utf8IsCombiningMark(rightCp)) return false; return true; } +// Korean separates words with spaces, so a boundary touching Hangul is not a gap-less break inside +// a line. hangulLineEndBreaks() still lets a Hangul word split there at a line end. +bool hasCjkBreakOpportunityBetween(const uint32_t leftCp, const uint32_t rightCp) { + if (utf8IsHangul(leftCp) || utf8IsHangul(rightCp)) return false; + return cjkBoundaryAllowsBreak(leftCp, rightCp); +} + +// Line-end split points inside a Hangul word, using the CJK boundary rules (no hyphen is drawn). +std::vector hangulLineEndBreaks(const std::string& word) { + std::vector breaks; + if (word.empty()) return breaks; + const auto* const start = reinterpret_cast(word.c_str()); + const auto* ptr = start; + uint32_t prev = utf8NextCodepoint(&ptr); + while (*ptr) { + const size_t offset = static_cast(ptr - start); + const uint32_t cur = utf8NextCodepoint(&ptr); + if ((utf8IsHangul(prev) || utf8IsHangul(cur)) && cjkBoundaryAllowsBreak(prev, cur)) { + breaks.push_back({offset, false}); + } + prev = cur; + } + return breaks; +} + std::vector cjkCharacterBreakByteOffsets(const std::string& text) { struct CodepointBoundary { uint32_t cp; @@ -1209,7 +1234,10 @@ bool ParsedText::hyphenateWordAtIndex(const size_t wordIndex, const int availabl // Collect candidate breakpoints (byte offsets and hyphen requirements). Focus emphasis is a byte // annotation, so the hyphenator sees the whole word and every legal break is reachable. - auto breakInfos = Hyphenator::breakOffsets(word, allowFallbackBreaks); + // Hangul breaks come first so they win a tie against a hyphenated break at the same width. + auto breakInfos = hangulLineEndBreaks(word); + const auto hyphenBreaks = Hyphenator::breakOffsets(word, allowFallbackBreaks); + breakInfos.insert(breakInfos.end(), hyphenBreaks.begin(), hyphenBreaks.end()); if (breakInfos.empty()) { return false; } diff --git a/lib/Epub/Epub/Section.cpp b/lib/Epub/Epub/Section.cpp index 368519584e7..24385c4b6e4 100644 --- a/lib/Epub/Epub/Section.cpp +++ b/lib/Epub/Epub/Section.cpp @@ -52,7 +52,9 @@ namespace { // v46: Ordered lists number their items, list-style-type: none suppresses markers, // and
      /
        containers contribute their own margins/padding to child insets. // v47: Word and character spacing in the header (cache validation); cached BlockStyle stores only character spacing. -constexpr uint8_t SECTION_FILE_VERSION = 47; +// v48: Hangul words wrap at spaces; with hyphenation on they may also split at a line end. +// Justification no longer stretches between syllables. +constexpr uint8_t SECTION_FILE_VERSION = 48; // Written into the version field while a build is in progress; patched to // SECTION_FILE_VERSION only when the build is finalized. An abandoned / // crash-interrupted .bin therefore carries version 0, which loadSectionFile rejects diff --git a/lib/Utf8/Utf8.h b/lib/Utf8/Utf8.h index 10ea4730a68..ec79ecc515d 100644 --- a/lib/Utf8/Utf8.h +++ b/lib/Utf8/Utf8.h @@ -41,9 +41,10 @@ uint32_t utf8DecomposedBase(uint32_t cp); // incomplete trailing bytes are excluded. int utf8SafeTruncateBuffer(const char* buf, int len); -// Returns true for CJK characters that allow line breaks on either side without hyphenation. -// Covers CJK Unified Ideographs, Hiragana, Katakana, Hangul Syllables, CJK punctuation, -// and fullwidth forms — the ranges where word boundaries are implicit per character. +// Returns true for CJK characters that break without hyphenation. Covers CJK Unified Ideographs, +// Hiragana, Katakana, Hangul, CJK punctuation, and fullwidth forms. Han and Kana have implicit +// word boundaries per character; Hangul is space-delimited, so layout keeps Hangul words whole +// (see utf8IsHangul) and splits one without a hyphen only through the hyphenation path. inline bool utf8IsCjkBreakable(const uint32_t cp) { return (cp >= 0x1100 && cp <= 0x11FF) // Hangul Jamo || (cp >= 0x3000 && cp <= 0x303F) // CJK Symbols and Punctuation @@ -62,6 +63,16 @@ inline bool utf8IsCjkBreakable(const uint32_t cp) { || (cp >= 0x2A700 && cp <= 0x2B73F); // CJK Extension C } +// Returns true for Hangul letters. Korean separates words with spaces, so a boundary touching +// Hangul is never an implicit line-break opportunity (CSS word-break: keep-all). +inline bool utf8IsHangul(const uint32_t cp) { + return (cp >= 0x1100 && cp <= 0x11FF) // Hangul Jamo + || (cp >= 0x3130 && cp <= 0x318F) // Hangul Compatibility Jamo + || (cp >= 0xA960 && cp <= 0xA97F) // Hangul Jamo Extended-A + || (cp >= 0xAC00 && cp <= 0xD7FF) // Hangul Syllables, Hangul Jamo Extended-B + || (cp >= 0xFFA0 && cp <= 0xFFDC); // Halfwidth Hangul +} + // Returns true for any codepoint in a CJK script block (Han, Kana, Hangul, Bopomofo, // radicals, and CJK punctuation/compatibility/enclosed forms). Used for fallback font // selection — deliberately broader than utf8IsCjkBreakable, whose ranges are tuned to diff --git a/test/CMakeLists.txt b/test/CMakeLists.txt index 4cdffef1965..184721f52db 100644 --- a/test/CMakeLists.txt +++ b/test/CMakeLists.txt @@ -59,6 +59,7 @@ add_subdirectory(progress_comparison) add_subdirectory(font_cache_manager) add_subdirectory(css_parser) add_subdirectory(chapter_html_slim_parser) +add_subdirectory(korean_line_breaking) add_subdirectory(ligature_guard) add_subdirectory(page_link) add_subdirectory(content_opf_parser) diff --git a/test/chapter_html_slim_parser/ChapterHtmlSlimParserTest.cpp b/test/chapter_html_slim_parser/ChapterHtmlSlimParserTest.cpp index adbe1635702..2c66ecd96b9 100644 --- a/test/chapter_html_slim_parser/ChapterHtmlSlimParserTest.cpp +++ b/test/chapter_html_slim_parser/ChapterHtmlSlimParserTest.cpp @@ -6,6 +6,7 @@ #include #include #include +#include #define class struct #define private public @@ -207,8 +208,8 @@ TEST(TextSpacingLayout, TrackingSeparatesCjkTokensAndScalesWordSpaces) { style.alignment = CssTextAlign::Left; style.textIndentDefined = true; ParsedText text(false, hyphenation, false, style); - text.addWord("가나다", EpdFontFamily::REGULAR); - text.addWord("라마", EpdFontFamily::REGULAR); + text.addWord("一二三", EpdFontFamily::REGULAR); + text.addWord("四五", EpdFontFamily::REGULAR); unsigned lines = 0; text.layoutAndExtractLines( renderer, 0, 200, @@ -216,9 +217,9 @@ TEST(TextSpacingLayout, TrackingSeparatesCjkTokensAndScalesWordSpaces) { ++lines; ASSERT_EQ(line->wordCount(), 5); EXPECT_EQ(line->wordXpos(0), 0); - EXPECT_EQ(line->wordXpos(1), 7); // 8 px syllable, -1 px tracking + EXPECT_EQ(line->wordXpos(1), 7); // 8 px glyph, -1 px tracking EXPECT_EQ(line->wordXpos(2), 14); - EXPECT_EQ(line->wordXpos(3), 28); // 8 px syllable plus 150% of a 4 px space, no tracking + EXPECT_EQ(line->wordXpos(3), 28); // 8 px glyph plus 150% of a 4 px space, no tracking EXPECT_EQ(line->wordXpos(4), 35); }, true, -1, 150); @@ -249,8 +250,8 @@ TEST(TextSpacingLayout, CachedPageRestoresSpacing) { style.alignment = CssTextAlign::Left; style.textIndentDefined = true; ParsedText text(false, false, false, style); - text.addWord("가나다", EpdFontFamily::REGULAR); - text.addWord("라마", EpdFontFamily::REGULAR); + text.addWord("一二三", EpdFontFamily::REGULAR); + text.addWord("四五", EpdFontFamily::REGULAR); const auto path = (std::filesystem::temp_directory_path() / "crosspoint-text-spacing.bin").string(); unsigned lines = 0; text.layoutAndExtractLines( @@ -288,7 +289,7 @@ TEST_F(ChapterHtmlSlimParserTest, ParserAppliesTextSpacingToParagraphs) { parser.setTextSpacing(-1, 150); parser.beginParse(); ChapterHtmlSlimParser::startElement(&parser, "p", nullptr); - const std::string text = "\xea\xb0\x80\xeb\x82\x98\xeb\x8b\xa4 \xeb\x9d\xbc\xeb\xa7\x88"; // 가나다 라마 + const std::string text = "\xe4\xb8\x80\xe4\xba\x8c\xe4\xb8\x89 \xe5\x9b\x9b\xe4\xba\x94"; // 一二三 四五 ChapterHtmlSlimParser::characterData(&parser, text.c_str(), static_cast(text.size())); ChapterHtmlSlimParser::endElement(&parser, "p"); parser.makePages(); @@ -300,8 +301,68 @@ TEST_F(ChapterHtmlSlimParserTest, ParserAppliesTextSpacingToParagraphs) { ++lines; ASSERT_EQ(block.wordCount(), 5); EXPECT_EQ(block.getBlockStyle().characterSpacing, -1); - EXPECT_EQ(block.wordXpos(1) - block.wordXpos(0), 7); // 8 px syllable, -1 px tracking - EXPECT_EQ(block.wordXpos(3) - block.wordXpos(2), 14); // syllable plus 150% of a 4 px space + EXPECT_EQ(block.wordXpos(1) - block.wordXpos(0), 7); // 8 px glyph, -1 px tracking + EXPECT_EQ(block.wordXpos(3) - block.wordXpos(2), 14); // glyph plus 150% of a 4 px space } EXPECT_EQ(lines, 1u); } + +TEST(KoreanLayout, HangulWordsStayWholeAndWrapAtSpaces) { + GfxRenderer renderer; + { + BlockStyle style; + style.alignment = CssTextAlign::Left; + style.textIndentDefined = true; + ParsedText text(false, false, false, style); + text.addWord("가나다", EpdFontFamily::REGULAR); + text.addWord("라마", EpdFontFamily::REGULAR); + text.addWord("3개를", EpdFontFamily::REGULAR); + text.addWord("iPhone을", EpdFontFamily::REGULAR); + std::vector> lines; + text.layoutAndExtractLines(renderer, 0, 60, [&](std::unique_ptr line, auto) { + auto& words = lines.emplace_back(); + for (uint16_t i = 0; i < line->wordCount(); ++i) words.emplace_back(line->wordText(i)); + }); + // 가나다 라마 is 24 + 4 + 16 px; adding 3개를 would need 72 px, and no break exists inside it. + const std::vector> expected{{"가나다", "라마"}, {"3개를"}, {"iPhone을"}}; + EXPECT_EQ(lines, expected); + } +} + +TEST(KoreanLayout, JustifiedHangulStretchesOnlyWordSpaces) { + GfxRenderer renderer; + BlockStyle style; + style.alignment = CssTextAlign::Justify; + style.textIndentDefined = true; + ParsedText text(false, false, false, style); + for (const char* word : {"가나", "다라", "마바", "사아"}) text.addWord(word, EpdFontFamily::REGULAR); + unsigned lines = 0; + text.layoutAndExtractLines(renderer, 0, 60, [&](std::unique_ptr line, auto) { + if (lines++ != 0) return; + // 3 x 16 px words + 2 x 4 px spaces leave 4 px, split across the two spaces only. + ASSERT_EQ(line->wordCount(), 3); + EXPECT_EQ(line->wordXpos(0), 0); + EXPECT_EQ(line->wordXpos(1), 22); + EXPECT_EQ(line->wordXpos(2), 44); + }); + EXPECT_EQ(lines, 2u); +} + +TEST(KoreanLayout, HangulGluedAcrossInlineStyleIsUnbreakable) { + GfxRenderer renderer; + BlockStyle style; + style.alignment = CssTextAlign::Justify; + style.textIndentDefined = true; + ParsedText text(false, false, false, style); + text.addWord("가나", EpdFontFamily::REGULAR); + text.addWord("한국", EpdFontFamily::REGULAR); + text.addWord("어", EpdFontFamily::BOLD, false, /*attachToPrevious=*/true); + std::vector> lines; + text.layoutAndExtractLines(renderer, 0, 40, [&](std::unique_ptr line, auto) { + auto& words = lines.emplace_back(); + for (uint16_t i = 0; i < line->wordCount(); ++i) words.emplace_back(line->wordText(i)); + }); + // 가나 한국 fits in 36 px, but 어 is glued to 한국, so the whole word moves down. + const std::vector> expected{{"가나"}, {"한국", "어"}}; + EXPECT_EQ(lines, expected); +} diff --git a/test/korean_line_breaking/CMakeLists.txt b/test/korean_line_breaking/CMakeLists.txt new file mode 100644 index 00000000000..380a36d32b4 --- /dev/null +++ b/test/korean_line_breaking/CMakeLists.txt @@ -0,0 +1,33 @@ +add_executable(KoreanLineBreakingTest + KoreanLineBreakingTest.cpp + ${REPO_ROOT}/lib/Epub/Epub/ParsedText.cpp + ${REPO_ROOT}/lib/Epub/Epub/WordStore.cpp + ${REPO_ROOT}/lib/Epub/Epub/blocks/TextBlock.cpp + ${REPO_ROOT}/lib/Epub/Epub/hyphenation/Hyphenator.cpp + ${REPO_ROOT}/lib/Epub/Epub/hyphenation/LanguageRegistry.cpp + ${REPO_ROOT}/lib/Epub/Epub/hyphenation/LiangHyphenation.cpp + ${REPO_ROOT}/lib/Epub/Epub/hyphenation/HyphenationCommon.cpp + ${REPO_ROOT}/lib/MiniBidi/BidiUtils.cpp + ${REPO_ROOT}/lib/MiniBidi/minibidi.c + ${REPO_ROOT}/lib/Utf8/Utf8.cpp +) + +target_include_directories(KoreanLineBreakingTest PRIVATE + ${REPO_ROOT}/test/chapter_html_slim_parser/stubs + ${REPO_ROOT}/lib + ${REPO_ROOT}/lib/Epub + ${REPO_ROOT}/lib/Epub/Epub + ${REPO_ROOT}/lib/EpdFont + ${REPO_ROOT}/lib/Utf8 + ${REPO_ROOT}/lib/Memory + ${REPO_ROOT}/lib/Serialization + ${REPO_ROOT}/lib/MiniBidi + ${REPO_ROOT}/lib/FsHelpers +) + +target_link_libraries(KoreanLineBreakingTest PRIVATE + crosspoint_test_common + GTest::gtest_main +) + +gtest_discover_tests(KoreanLineBreakingTest) diff --git a/test/korean_line_breaking/KoreanLineBreakingTest.cpp b/test/korean_line_breaking/KoreanLineBreakingTest.cpp new file mode 100644 index 00000000000..ba1279c1526 --- /dev/null +++ b/test/korean_line_breaking/KoreanLineBreakingTest.cpp @@ -0,0 +1,95 @@ +#include +#include +#include +#include +#include + +#include +#include +#include + +namespace { + +struct Line { + std::vector words; + std::vector xpos; +}; + +// Fixture metrics (stub renderer): every glyph is 8 px wide and a space is 4 px. +std::vector layout(const std::vector& words, const bool hyphenation, const uint16_t width) { + GfxRenderer renderer; + BlockStyle style; + style.alignment = CssTextAlign::Justify; + style.textIndentDefined = true; + ParsedText text(false, hyphenation, false, style); + for (const char* word : words) text.addWord(word, EpdFontFamily::REGULAR); + std::vector lines; + text.layoutAndExtractLines(renderer, 0, width, [&](std::unique_ptr block, auto) { + auto& line = lines.emplace_back(); + for (uint16_t i = 0; i < block->wordCount(); ++i) { + line.words.emplace_back(block->wordText(i)); + line.xpos.push_back(block->wordXpos(i)); + } + }); + return lines; +} + +std::vector> wordsOf(const std::vector& lines) { + std::vector> result; + result.reserve(lines.size()); + for (const auto& line : lines) result.push_back(line.words); + return result; +} + +} // namespace + +TEST(KoreanLineBreaking, HyphenationOffWrapsAtSpacesOnly) { + Hyphenator::setPreferredLanguage("ko"); + // 가나 다라마바 needs 52 px; with hyphenation off the Hangul word moves down whole. + const auto lines = layout({"가나", "다라마바", "사"}, false, 50); + const std::vector> expected{{"가나"}, {"다라마바", "사"}}; + EXPECT_EQ(wordsOf(lines), expected); +} + +TEST(KoreanLineBreaking, HyphenationOnSplitsBetweenSyllablesWithoutHyphen) { + Hyphenator::setPreferredLanguage("ko"); + // 가나 + space leaves 30 px: the widest syllable prefix that fits is 다라마 (24 px). + const auto lines = layout({"가나", "다라마바사아", "자"}, true, 50); + const std::vector> expected{{"가나", "다라마"}, {"바사아", "자"}}; + ASSERT_EQ(wordsOf(lines), expected); + // 16 + 4 + 24 = 44 px leaves 6 px, all of it on the one word space. + EXPECT_EQ(lines[0].xpos, (std::vector{0, 26})); +} + +TEST(KoreanLineBreaking, HyphenationOnKeepsTrailingPunctuationWithSyllable) { + Hyphenator::setPreferredLanguage("ko"); + // 가나다 + space leaves 36 px. 라마바사 (32 px) would fit, but 사. stays together, so 라마바 is used. + const auto lines = layout({"가나다", "라마바사.", "자"}, true, 64); + const std::vector> expected{{"가나다", "라마바"}, {"사.", "자"}}; + EXPECT_EQ(wordsOf(lines), expected); +} + +TEST(KoreanLineBreaking, HyphenationOnSplitsShortWords) { + Hyphenator::setPreferredLanguage("ko"); + // 가나다 + space leaves 22 px, so the three-syllable word splits after 라마 (16 px). + const auto lines = layout({"가나다", "라마바", "자"}, true, 50); + const std::vector> expected{{"가나다", "라마"}, {"바", "자"}}; + EXPECT_EQ(wordsOf(lines), expected); +} + +TEST(KoreanLineBreaking, HyphenationOnSplitsWhereHangulMeetsOtherScriptsOrBrackets) { + Hyphenator::setPreferredLanguage("ko"); + // 가 + space leaves 52 px. 소신(ab) (48 px) fits; the split may fall before "(" or after ")", + // never inside the brackets. + const auto lines = layout({"가", "소신(ab)이"}, true, 64); + const std::vector> expected{{"가", "소신(ab)"}, {"이"}}; + EXPECT_EQ(wordsOf(lines), expected); +} + +TEST(KoreanLineBreaking, HyphenationOnSplitsBetweenDigitAndHangul) { + Hyphenator::setPreferredLanguage("ko"); + // 가나다 + space leaves 22 px: 12 (16 px) fits, 12월 (24 px) does not. + const auto lines = layout({"가나다", "12월부터", "자"}, true, 50); + const std::vector> expected{{"가나다", "12"}, {"월부터", "자"}}; + EXPECT_EQ(wordsOf(lines), expected); +} From 720b995a5dfc922badfe89551a88b81bb5cb7afb Mon Sep 17 00:00:00 2001 From: Justin Mitchell Date: Thu, 24 Sep 2026 21:16:06 -0400 Subject: [PATCH 22/62] feat: add back buttons to the header and unify all theme headers (#3689) Implements a header back button that can be tapped to navigate back. The button is rendered in the header's leading area on applicable screens and routes to the existing Back button handling. Excludes the back button from root screens (Home, Crash) where navigation back is not applicable. Also prevents header back button taps from being interpreted as status bar taps. --- freeink-sdk | 2 +- src/MappedInputManager.cpp | 10 + src/activities/ActivityManager.cpp | 10 +- .../browser/OpdsBookBrowserActivity.cpp | 53 ++++- .../browser/OpdsBookBrowserActivity.h | 1 + src/activities/home/CrashActivity.cpp | 4 +- src/activities/home/HomeActivity.cpp | 12 +- .../library/LibraryListActivity.cpp | 57 +++-- src/activities/library/LibraryListActivity.h | 4 + src/components/CoverGridHomeUi.cpp | 157 +++++++------ src/components/CoverGridHomeUi.h | 23 +- src/components/HeaderBackTapTarget.h | 27 +++ src/components/HomeCoverCache.cpp | 39 ++-- src/components/HomeCoverCache.h | 1 - src/components/UiAppHelpers.h | 3 + src/components/icons/headerIcons.h | 17 ++ src/components/icons/headerIcons.manifest | 6 + src/components/themes/BaseTheme.cpp | 213 ++++++++---------- src/components/themes/BaseTheme.h | 33 ++- src/components/themes/lyra/LyraTheme.h | 10 +- .../themes/roundedraff/RoundedRaffTheme.cpp | 6 +- .../themes/roundedraff/RoundedRaffTheme.h | 21 +- 22 files changed, 416 insertions(+), 293 deletions(-) create mode 100644 src/components/HeaderBackTapTarget.h create mode 100644 src/components/icons/headerIcons.h create mode 100644 src/components/icons/headerIcons.manifest diff --git a/freeink-sdk b/freeink-sdk index deb62ab7e02..111fdcc7f01 160000 --- a/freeink-sdk +++ b/freeink-sdk @@ -1 +1 @@ -Subproject commit deb62ab7e02b8216c5ee86a0721bf99f985c6f23 +Subproject commit 111fdcc7f0176c3ee38391a160ee296bf492dbd8 diff --git a/src/MappedInputManager.cpp b/src/MappedInputManager.cpp index bc47b0e9377..c6a1b908b6e 100644 --- a/src/MappedInputManager.cpp +++ b/src/MappedInputManager.cpp @@ -9,6 +9,7 @@ #include #include "CrossPointSettings.h" +#include "components/HeaderBackTapTarget.h" #include "components/UITheme.h" namespace fui = freeink::ui; @@ -294,6 +295,15 @@ bool MappedInputManager::wasEdgeSwipe(const freeink::ui::ScreenEdge edge) const } bool MappedInputManager::wasBackGesture() const { + // Tap on the header back button (rect recorded by BaseTheme::drawHeader; + // empty on screens without one). Folded into Button::Back alongside the + // swipe so every activity's existing Back handling picks it up. + int tapX = 0; + int tapY = 0; + if (wasScreenTapped(tapX, tapY) && HeaderBackTapTarget::contains(tapX, tapY)) { + rememberTouchHeldTime(); + return true; + } // Back = left-to-right swipe starting near the left edge. Edge-anchored so that // mid-screen horizontal swipes stay available to activities that consume // SwipeDir::Left/Right (e.g. percent selection, image viewer). diff --git a/src/activities/ActivityManager.cpp b/src/activities/ActivityManager.cpp index beedeb44c48..1fb1b7b6289 100644 --- a/src/activities/ActivityManager.cpp +++ b/src/activities/ActivityManager.cpp @@ -15,6 +15,7 @@ #include "boot_sleep/BootActivity.h" #include "boot_sleep/SleepActivity.h" #include "browser/OpdsBookBrowserActivity.h" +#include "components/HeaderBackTapTarget.h" #include "home/CrashActivity.h" #include "home/FileBrowserActivity.h" #include "home/HomeActivity.h" @@ -119,7 +120,8 @@ void ActivityManager::loop() { currentActivity->name == "Settings" || currentActivity->name == "NetworkModeSelection")) { int tx = 0; int ty = 0; - statusBarTap = mappedInput.wasScreenTapped(tx, ty) && ty < 44; + // The header back button shares this band; its taps stay Back. + statusBarTap = mappedInput.wasScreenTapped(tx, ty) && ty < 44 && !HeaderBackTapTarget::contains(tx, ty); } if (currentActivity->name != "FrontlightPanel" && (statusBarTap || mappedInput.wasLightPanelGesture())) { pushActivity(std::make_unique(renderer, mappedInput)); @@ -192,6 +194,9 @@ void ActivityManager::loop() { } else if (pendingAction == PendingAction::Push) { // Move current activity to stack stackActivities.push_back(std::move(currentActivity)); + // The parent's header back rect must not route taps on the pushed + // screen (which may draw no header of its own). + HeaderBackTapTarget::clear(); LOG_DBG("ACT", "Pushed to activity stack, new size = %zu", stackActivities.size()); } pendingAction = PendingAction::None; @@ -220,6 +225,9 @@ void ActivityManager::exitActivity(const RenderLock& lock) { currentActivity->onExit(); currentActivity.reset(); } + // The outgoing screen's header back button must not eat taps on the next + // screen; the next header draw re-records it. + HeaderBackTapTarget::clear(); } void ActivityManager::replaceActivity(std::unique_ptr&& newActivity) { diff --git a/src/activities/browser/OpdsBookBrowserActivity.cpp b/src/activities/browser/OpdsBookBrowserActivity.cpp index 762e4cf7c7f..2feac65d615 100644 --- a/src/activities/browser/OpdsBookBrowserActivity.cpp +++ b/src/activities/browser/OpdsBookBrowserActivity.cpp @@ -18,6 +18,7 @@ #include "activities/util/KeyboardEntryActivity.h" #include "components/UIScale.h" #include "components/UITheme.h" +#include "components/icons/headerIcons.h" #include "components/icons/search32.h" #include "fontIds.h" #include "network/HttpDownloader.h" @@ -32,6 +33,7 @@ namespace { constexpr fui::ActionId ACTION_ROW = 1; constexpr fui::ActionId ACTION_SEARCH = 2; constexpr fui::ActionId ACTION_CANCEL = 3; +constexpr fui::ActionId ACTION_BACK = 4; constexpr int DOWNLOAD_PROGRESS_STEP_PERCENT = 5; constexpr unsigned long DOWNLOAD_PROGRESS_MIN_UPDATE_MS = 5000; @@ -61,6 +63,7 @@ void OpdsBookBrowserActivity::onEnter() { app.on(ACTION_ROW, &OpdsBookBrowserActivity::onRowEvent, this); app.on(ACTION_SEARCH, &OpdsBookBrowserActivity::onSearchEvent, this); app.on(ACTION_CANCEL, &OpdsBookBrowserActivity::onCancelEvent, this); + app.on(ACTION_BACK, &OpdsBookBrowserActivity::onBackEvent, this); app.setScreen(&OpdsBookBrowserActivity::rootScreen, this); requestUpdate(); @@ -103,6 +106,13 @@ void OpdsBookBrowserActivity::onSearchEvent(const fui::ActionEvent&, void* user) self->launchSearch(); } +void OpdsBookBrowserActivity::onBackEvent(const fui::ActionEvent&, void* user) { + auto* self = static_cast(user); + if (self->state != BrowserState::BROWSING) return; + self->app.clearTapFlash(); + self->navigateBack(); +} + void OpdsBookBrowserActivity::onCancelEvent(const fui::ActionEvent&, void* user) { auto* self = static_cast(user); if (self->state != BrowserState::DOWNLOADING) return; @@ -227,25 +237,44 @@ void OpdsBookBrowserActivity::rootScreen(UiScreen& screen, void* user) { // Shared chrome for every state: reserve the firmware's button-hint band and // draw the themed header (padding, centering, and rule come from the theme). void OpdsBookBrowserActivity::screenHeader(UiScreen& screen, const bool withSearch) { - screen.takeBottom(static_cast(UITheme::getInstance().getMetrics().buttonHintsHeight)); - // Same top offset as every GUI.drawHeader caller, so the band lines up with - // the rest of the firmware's screens. - screen.spacer(static_cast(UITheme::getInstance().getMetrics().topPadding)); + const auto& metrics = UITheme::getInstance().getMetrics(); + const auto& theme = screen.theme(); fui::HeaderProps header; header.title = server.name.empty() ? tr(STR_OPDS_BROWSER) : server.name.c_str(); header.borderEdges = fui::EdgeBottom; + // Same battery/clock band as every GUI.drawHeader screen; the header + // heights are unified across themes, so the buttons derive from the band. + GUI.applyHeaderStatus(renderer, header); + // Only the browsing state routes header taps (and only there does back mean + // anything); the loading/downloading/status headers stay passive. + if (state == BrowserState::BROWSING && mappedInput.hasTouch()) { + header.leadingIcon = fui::bitmapFromIcon(icon_header_back_32); + header.leadingAction = ACTION_BACK; + } if (withSearch && !searchTemplate.empty()) { header.trailingIcon = fui::bitmapFromIcon(icon_search_32); header.trailingAction = ACTION_SEARCH; - // Optically align the icon with the title glyphs: text hangs low in its - // line cell by the font's internal leading; drop the button to match. - const int titleFontId = uiScaleSpec().titleFontId; - header.actionOffsetY = - static_cast((renderer.getLineHeight(titleFontId) - renderer.getTextHeight(titleFontId)) / 2); + // Vertical placement comes from applyHeaderStatus: buttons center on the + // unified band. + } + header.titleText = theme.titleText; + header.titleText.align = theme.headerTitleAlign; + header.styles = theme.popup; + if (header.styles.normal.border.kind == fui::PaintKind::None && theme.headerUnderline > 0) { + header.styles.normal.border = fui::Paint::solid(fui::Color::Black); + header.styles.normal.borderWidth = theme.headerUnderline; } - screen.header(header); - // Same breathing room between header and content as the legacy screens. - screen.spacer(static_cast(UITheme::getInstance().getMetrics().verticalSpacing)); + header.trailingStyles = fui::plainStyles(fui::Paint::solid(fui::Color::Black)); + header.sidePadding = theme.headerSidePadding; + header.minTouchSize = theme.minTouchSize; + const auto frameRect = screen.frame().screen(); + fui::header(screen.frame(), + fui::Rect{frameRect.x, static_cast(metrics.topPadding), frameRect.width, + static_cast(metrics.headerHeight)}, + header); + screen.setContentMarginFromScreen( + fui::Insets{static_cast(metrics.topPadding + metrics.headerHeight + metrics.verticalSpacing), 0, + static_cast(metrics.buttonHintsHeight), 0}); } void OpdsBookBrowserActivity::buildBrowsingScreen(UiScreen& screen) { diff --git a/src/activities/browser/OpdsBookBrowserActivity.h b/src/activities/browser/OpdsBookBrowserActivity.h index 6974ede8a1c..4c28ea88bb3 100644 --- a/src/activities/browser/OpdsBookBrowserActivity.h +++ b/src/activities/browser/OpdsBookBrowserActivity.h @@ -61,6 +61,7 @@ class OpdsBookBrowserActivity final : public Activity, private UiAppHost { static void onRowEvent(const freeink::ui::ActionEvent& event, void* user); static void onSearchEvent(const freeink::ui::ActionEvent& event, void* user); static void onCancelEvent(const freeink::ui::ActionEvent& event, void* user); + static void onBackEvent(const freeink::ui::ActionEvent& event, void* user); void screenHeader(UiScreen& screen, bool withSearch); void buildBrowsingScreen(UiScreen& screen); void buildDownloadScreen(UiScreen& screen); diff --git a/src/activities/home/CrashActivity.cpp b/src/activities/home/CrashActivity.cpp index df8839ad1ee..9af4ad85f2f 100644 --- a/src/activities/home/CrashActivity.cpp +++ b/src/activities/home/CrashActivity.cpp @@ -35,7 +35,9 @@ void CrashActivity::render(RenderLock&&) { const auto x = metrics.contentSidePadding; const auto lineHeight = renderer.getLineHeight(UI_10_FONT_ID); - GUI.drawHeader(renderer, Rect{0, metrics.topPadding, pageWidth, metrics.headerHeight}, tr(STR_CRASH_TITLE)); + // Crash report is a dead end, not a pushed screen: no back button. + GUI.drawHeader(renderer, Rect{0, metrics.topPadding, pageWidth, metrics.headerHeight}, tr(STR_CRASH_TITLE), nullptr, + false); int y = metrics.topPadding + metrics.headerHeight + metrics.verticalSpacing; diff --git a/src/activities/home/HomeActivity.cpp b/src/activities/home/HomeActivity.cpp index cac801c004f..83df634d099 100644 --- a/src/activities/home/HomeActivity.cpp +++ b/src/activities/home/HomeActivity.cpp @@ -166,9 +166,9 @@ void HomeActivity::loadRecentCovers(int coverHeight) { int progress = 0; for (RecentBook& book : recentBooks) { - // The cover grid draws each slot at its own size; generating at any other - // height would rescale the dithered thumb at draw time and alias badly. - const int thumbHeight = coverGridUi ? coverGridUi->thumbHeightFor(progress) : coverHeight; + // The cover grid shares one slot size; generating at any other height + // would rescale the dithered thumb at draw time and alias badly. + const int thumbHeight = coverGridUi ? coverGridUi->thumbHeightFor() : coverHeight; if (coverGridUi) { loadGridCover(book, thumbHeight, showingLoading, popupRect); ++progress; @@ -469,7 +469,7 @@ void HomeActivity::render(RenderLock&&) { // pass, orientation switch) means the paths must point at those sizes and // any missing thumbs must be generated. Refreshing the paths right away // lets the next pass draw already-cached thumbs before generation runs. - const bool coverSpecChanged = coverGridUi->takeThumbHeightsChanged(); + const bool coverSpecChanged = coverGridUi->takeThumbHeightChanged(); if (coverSpecChanged) { coverGridUi->refreshCoverPaths(); recentsLoaded = false; @@ -489,8 +489,10 @@ void HomeActivity::render(RenderLock&&) { // Band spans topPadding..homeTopPadding: the cover tile starts at the fixed // homeTopPadding, so the height must shrink by topPadding or the band (and a // centered title, e.g. RoundedRaff's book title) sinks into the tile. + // Home is the stack root: no back button in its header. GUI.drawHeader(renderer, Rect{0, metrics.topPadding, pageWidth, metrics.homeTopPadding - metrics.topPadding}, - metrics.homeContinueReadingInMenu && !recentBooks.empty() ? recentBooks[0].title.c_str() : nullptr); + metrics.homeContinueReadingInMenu && !recentBooks.empty() ? recentBooks[0].title.c_str() : nullptr, + nullptr, false); // Record the tile rect so storeCoverBuffer (called from the theme) knows // which sub-region of the framebuffer to snapshot. ~16 KB in Portrait diff --git a/src/activities/library/LibraryListActivity.cpp b/src/activities/library/LibraryListActivity.cpp index 1973fc651f5..ac12d4775d1 100644 --- a/src/activities/library/LibraryListActivity.cpp +++ b/src/activities/library/LibraryListActivity.cpp @@ -20,6 +20,7 @@ #include "activities/util/KeyboardEntryActivity.h" #include "components/UIScale.h" #include "components/UITheme.h" +#include "components/icons/headerIcons.h" #include "components/icons/listIcons.h" #include "components/icons/search32.h" #include "fontIds.h" @@ -81,6 +82,7 @@ void LibraryListActivity::onEnter() { UiTabListActivity::onEnter(); app.on(ACTION_SEARCH, &LibraryListActivity::searchActionTrampoline, this); app.on(ACTION_REBUILD, &LibraryListActivity::rebuildActionTrampoline, this); + app.on(ACTION_BACK, &LibraryListActivity::backActionTrampoline, this); // Recent is backed by the resident store. Prune before opening the index so // its persistence write never overlaps the long-lived index reader. @@ -646,10 +648,36 @@ void LibraryListActivity::applyFilter() { filteredCount = matchCount; } +// Staged back-out, shared by the Back button and the header's back arrow: +// clear the search, expand collapsed groups, return focus to the tabs, then +// leave for home. +void LibraryListActivity::handleBackAction() { + auto& nav = activeNav(); + if (!query.empty()) { + query.clear(); + applyFilter(); + nav.selected = 0; + nav.top = 0; + requestUpdate(); + } else if (groupsCollapsed) { + restoreExpandedList(); + } else if (!tabsFocused() && !degraded) { + // Keep the current list and viewport while returning focus to the tabs. + nav.selected = 0; + requestUpdate(); + } else { + onGoHome(); + } +} + void LibraryListActivity::searchActionTrampoline(const fui::ActionEvent&, void* user) { static_cast(user)->openSearch(); } +void LibraryListActivity::backActionTrampoline(const fui::ActionEvent&, void* user) { + static_cast(user)->handleBackAction(); +} + void LibraryListActivity::rebuildActionTrampoline(const fui::ActionEvent&, void* user) { static_cast(user)->promptRebuildIndex(); } @@ -703,7 +731,6 @@ bool LibraryListActivity::handleCustomInput() { bool LibraryListActivity::handleButtons() { const int count = listCount(); - auto& nav = activeNav(); // Every hold action fires at the threshold, mid-hold, including the ones // that open a dialog (remove-recent, delete). The release that follows is @@ -726,21 +753,7 @@ bool LibraryListActivity::handleButtons() { } if (mappedInput.wasReleased(MappedInputManager::Button::Back)) { - if (!query.empty()) { - query.clear(); - applyFilter(); - nav.selected = 0; - nav.top = 0; - requestUpdate(); - } else if (groupsCollapsed) { - restoreExpandedList(); - } else if (!tabsFocused() && !degraded) { - // Keep the current list and viewport while returning focus to the tabs. - nav.selected = 0; - requestUpdate(); - } else { - onGoHome(); - } + handleBackAction(); return true; } @@ -912,6 +925,13 @@ void LibraryListActivity::buildHeader(UiScreen& screen) { } header.trailingStyles = fui::plainStyles(fui::Paint::solid(fui::Color::Black)); header.borderEdges = fui::EdgeBottom; + // Same battery/clock band as every GUI.drawHeader screen; the header + // heights are unified across themes, so the buttons derive from the band. + GUI.applyHeaderStatus(renderer, header); + if (mappedInput.hasTouch()) { + header.leadingIcon = fui::bitmapFromIcon(icon_header_back_32); + header.leadingAction = ACTION_BACK; + } if (!degraded) { // Keep both touch actions together on the right; button boards reach // rebuild through the row options menu. @@ -921,9 +941,8 @@ void LibraryListActivity::buildHeader(UiScreen& screen) { header.trailingAdjacentIcon = fui::bitmapFromIcon(icon_refresh_cw_32); header.trailingAdjacentAction = ACTION_REBUILD; } - const int titleFontId = uiScaleSpec().titleFontId; - header.actionOffsetY = - static_cast((renderer.getLineHeight(titleFontId) - renderer.getTextHeight(titleFontId)) / 2); + // Vertical placement comes from applyHeaderStatus: buttons center on the + // unified band. } const auto frameRect = screen.frame().screen(); // Header and tabs share a screen-relative boundary, independent of bezel insets. diff --git a/src/activities/library/LibraryListActivity.h b/src/activities/library/LibraryListActivity.h index 6b63c7d5329..bec37470d73 100644 --- a/src/activities/library/LibraryListActivity.h +++ b/src/activities/library/LibraryListActivity.h @@ -62,6 +62,7 @@ class LibraryListActivity final : public UiTabListActivity { // The screen's own actions, after the base's ACTION_ROW / ACTION_TAB. static constexpr freeink::ui::ActionId ACTION_SEARCH = ACTION_TAB_USER; static constexpr freeink::ui::ActionId ACTION_REBUILD = ACTION_SEARCH + 1; + static constexpr freeink::ui::ActionId ACTION_BACK = ACTION_REBUILD + 1; // Walk the card and write a fresh index. Blocking, with a popup: at ~70 books // it is well under a second, and it only runs when the index is missing or the @@ -92,8 +93,11 @@ class LibraryListActivity final : public UiTabListActivity { // Sub-screens act on button press, so a button still held when we resume must // not also act here. Records what to swallow on the next release. void swallowHeldReleases(); + // Staged back-out shared by Button::Back and the header back arrow. + void handleBackAction(); static void searchActionTrampoline(const freeink::ui::ActionEvent& event, void* user); static void rebuildActionTrampoline(const freeink::ui::ActionEvent& event, void* user); + static void backActionTrampoline(const freeink::ui::ActionEvent& event, void* user); // Data void applyFilter(); diff --git a/src/components/CoverGridHomeUi.cpp b/src/components/CoverGridHomeUi.cpp index 638a27f2bb3..579425885b5 100644 --- a/src/components/CoverGridHomeUi.cpp +++ b/src/components/CoverGridHomeUi.cpp @@ -21,6 +21,10 @@ namespace fui = freeink::ui; namespace { constexpr fui::ActionId SELECT = 1; +// Grid cell padding around each cover; also feeds the screen's horizontal +// inset so the cover columns land on the header chrome's inset line. +constexpr int16_t COVER_CELL_INSET = 6; +constexpr int COVER_ROW_EXTRA_HEIGHT = 8; } // namespace CoverGridHomeUi::CoverGridHomeUi(GfxRenderer& renderer) @@ -47,28 +51,24 @@ void CoverGridHomeUi::refreshCoverPaths() { void CoverGridHomeUi::refreshCoverPath(size_t index) { if (index >= books->size() || index >= coverPaths.size()) return; coverCache.invalidate(index); - coverPaths[index] = thumbHeights[index] > 0 - ? UITheme::getCoverThumbPath((*books)[index].coverBmpPath, thumbHeights[index]) - : std::string(); - if (index != 0) return; - coverCache.readSize(coverPaths[0], featuredCoverWidth, featuredCoverHeight); + coverPaths[index] = + thumbHeight > 0 ? UITheme::getCoverThumbPath((*books)[index].coverBmpPath, thumbHeight) : std::string(); } -int CoverGridHomeUi::thumbHeightFor(size_t index) const { - return index < thumbHeights.size() && thumbHeights[index] > 0 ? thumbHeights[index] : THUMB_HEIGHT; -} +int CoverGridHomeUi::thumbHeightFor() const { return thumbHeight > 0 ? thumbHeight : THUMB_HEIGHT; } -bool CoverGridHomeUi::takeThumbHeightsChanged() { return std::exchange(thumbHeightsChanged, false); } +bool CoverGridHomeUi::takeThumbHeightChanged() { return std::exchange(thumbHeightChanged, false); } -void CoverGridHomeUi::noteThumbHeight(size_t index, int slotWidth, int slotHeight) { - if (index >= thumbHeights.size()) return; - // Thumbs cover a (0.6*h, h) target box, so a height of max(h, w*5/3) makes - // every cover overfill the slot; the cover renderer crops the overflow (full bleed). - const int height = std::max({1, slotHeight, slotWidth * 5 / 3 + 2}); - if (thumbHeights[index] != height) { - thumbHeights[index] = height; - thumbHeightsChanged = true; - refreshCoverPath(index); +void CoverGridHomeUi::noteThumbHeight(int slotHeight) { + // One shared height for every slot (hero and grid covers are the same + // size). The +8 surplus gives the paint clip a little bleed so the art's + // rightward nudge never exposes the left edge; covers narrower than the + // 2:3 slot may show a thin sliver at the sides rather than being cropped. + const int height = std::max(1, slotHeight + 8); + if (thumbHeight != height) { + thumbHeight = height; + thumbHeightChanged = true; + refreshCoverPaths(); } } @@ -93,50 +93,56 @@ void CoverGridHomeUi::draw(UiScreen& screen) { screen.setContentMarginFromScreen(fui::Insets{ static_cast(safe.y), static_cast(renderer.getScreenWidth() - safe.x - safe.width), static_cast(renderer.getScreenHeight() - safe.y - safe.height), static_cast(safe.x)}); - screen.insetContent(fui::Insets{theme.spaceSm, theme.spaceLg, theme.spaceSm, theme.spaceLg}); + // Horizontal inset sized so the outer cover columns (content x plus the + // grid's cell inset) sit on the theme's side-padding line: the heading and + // tabs then align with the clock and battery, which tuck a few px further + // in (headerStatusInset's optical bias). + const int16_t hInset = std::max( + 0, static_cast(UITheme::getInstance().getMetrics().headerSidePadding - COVER_CELL_INSET - safe.x)); + // Drop the status band, hero, and grid down: touch boards get a full step + // (no button-hint band to fit), button boards a modest nudge. The tabs stay + // put (bottom-anchored below). + const int16_t topInset = + BoardConfig::hasTouch() ? static_cast(theme.spaceLg) : static_cast(theme.spaceSm + 4); + screen.insetContent(fui::Insets{topInset, hInset, theme.spaceSm, hInset}); const bool landscape = renderer.getScreenWidth() > renderer.getScreenHeight(); - const auto header = screen.takeTop(UITheme::getInstance().getMetrics().batteryBarHeight); - auto tabRect = screen.takeBottom(UITheme::getInstance().getMetrics().coverGridTabBarHeight, theme.spaceMd); + // Reserve the band's slot in the flow (its content draws at a fixed screen + // position in drawHeaderBand); the slot doubles as padding above the heading. + screen.takeTop(UITheme::getInstance().getMetrics().batteryBarHeight, theme.spaceSm); + // Button boards run tighter above the tabs to make room for the top step. + const int16_t tabGap = BoardConfig::hasTouch() ? theme.spaceSm : static_cast(4); + auto tabRect = screen.takeBottom(UITheme::getInstance().getMetrics().coverGridTabBarHeight, tabGap); if (books->empty()) { - drawTabs(screen, tabRect.inset(fui::Insets{0, 6, 0, 6})); + drawTabs(screen, tabRect.inset(fui::Insets{0, COVER_CELL_INSET, 0, COVER_CELL_INSET})); drawEmpty(screen); - drawHeaderBand(header, tabRect.x + 6, tabRect.x + tabRect.width - 6); + drawHeaderBand(); return; } - auto headingText = theme.titleText; - headingText.bold = true; - auto headingRect = screen.takeTop(screen.target().lineHeight(headingText.font), theme.spaceSm); - // Bound the featured section while leaving room for its metadata. + // Hero cover matches the grid covers: the body splits into three equal + // cover rows (hero + two grid rows), so the featured section is one row + // tall and every cover on screen shares one size. The metadata floor still + // applies when the rows would be too short for the hero's text lines. + const fui::Rect body = screen.body(); + const int rowGap = std::max(4, body.width / 100); + grid.gap = grid.rowGap = rowGap; + const int coverRowHeight = std::max(1, (body.height - theme.spaceSm - 2 * rowGap) / 3 + COVER_ROW_EXTRA_HEIGHT); const int16_t featuredHeight = std::min( - screen.body().height, std::max(std::min(240, screen.body().height * 3 / 10), - screen.target().lineHeight(theme.bodyText.font) * (landscape ? 1 : 2) + - screen.target().lineHeight(theme.smallText.font) * 2 + 32)); - drawCurrent(screen, screen.takeTop(featuredHeight, theme.spaceMd)); + body.height, std::max(coverRowHeight, screen.target().lineHeight(theme.bodyText.font) * (landscape ? 1 : 2) + + screen.target().lineHeight(theme.smallText.font) * 2 + 32)); + drawCurrent(screen, screen.takeTop(featuredHeight, theme.spaceSm), coverRowHeight); drawGrid(screen); - const auto& gridRect = gridBounds; - tabRect.x = gridRect.x + grid.cellInset.left; - tabRect.width = gridRect.width - grid.cellInset.left - grid.cellInset.right; - // Heading shares the clock's alignment line (drawHeaderBand lands the - // status content on the outer cover columns, i.e. this same x). The - // selection ring extends left of this line by design — it reads as a frame - // around the cover, not as the column edge. - headingRect.x = tabRect.x; - headingRect.width = tabRect.width; - screen.target().text(headingRect, hasContinueReading ? tr(STR_CONTINUE_READING) : tr(STR_START_READING), headingText); + tabRect.x = gridBounds.x + grid.cellInset.left; + tabRect.width = gridBounds.width - grid.cellInset.left - grid.cellInset.right; drawTabs(screen, tabRect); - // Drawn last so it can borrow the grid geometry, like the tab bar above. - drawHeaderBand(header, tabRect.x, tabRect.x + tabRect.width); + drawHeaderBand(); } -void CoverGridHomeUi::drawHeaderBand(fui::Rect header, int coverLeft, int coverRight) { - // Same alignment trick as the tabs: the clock's left edge and the battery's - // right edge sit on the outer cover columns. drawHeader anchors both at - // headerStatusInset() from the band edges, and the clock text is - // left-anchored, so 1- vs 2-digit hours never move it. - const int inset = GUI.headerStatusInset(); - const int headerX = std::max(0, coverLeft - inset); - const int headerRight = std::min(renderer.getScreenWidth(), coverRight + inset); - GUI.drawHeader(renderer, Rect{headerX, header.y, headerRight - headerX, header.height}, nullptr); +void CoverGridHomeUi::drawHeaderBand() { + // The stock full-width band at the theme's topPadding, exactly like every + // pushed screen's header: the battery/clock hold one position across the + // whole UI, and the grid is widened to meet them (see the hInset above). + const ThemeMetrics& metrics = UITheme::getInstance().getMetrics(); + GUI.drawHeader(renderer, Rect{0, metrics.topPadding, renderer.getScreenWidth(), metrics.batteryBarHeight}, nullptr); } void CoverGridHomeUi::drawEmpty(UiScreen& screen) { @@ -159,7 +165,7 @@ void CoverGridHomeUi::drawEmpty(UiScreen& screen) { screen.target().text(fui::Rect{body.x, y, body.width, messageHeight}, tr(STR_START_READING), message); } -void CoverGridHomeUi::drawCurrent(UiScreen& screen, fui::Rect rect) { +void CoverGridHomeUi::drawCurrent(UiScreen& screen, fui::Rect rect, const int coverRowHeight) { const auto& theme = screen.theme(); const auto& book = books->front(); card.title = book.title.c_str(); @@ -190,17 +196,14 @@ void CoverGridHomeUi::drawCurrent(UiScreen& screen, fui::Rect rect) { card.progressHeight = 6; card.padding = fui::Insets{6, 6, 6, 6}; card.gap = theme.spaceLg + theme.spaceSm; - card.coverSize.height = std::max(1, std::min(rect.height - 12, (rect.width / 3) * 5 / 3)); - card.coverSize.width = std::max(1, card.coverSize.height * 3 / 5); - noteThumbHeight(0, card.coverSize.width, card.coverSize.height); - // Generation bounds stay stable; the displayed cover follows the actual image. - if (featuredCoverWidth > 0 && featuredCoverHeight > 0) { - const float scale = std::min(1.0f, std::min(float(card.coverSize.width) / featuredCoverWidth, - float(card.coverSize.height) / featuredCoverHeight)); - card.coverSize.width = std::max(1, static_cast(featuredCoverWidth * scale)); - card.coverSize.height = std::max(1, static_cast(featuredCoverHeight * scale)); - } - gridBounds = layoutGrid(screen, screen.body()); + // Fit the shared 2:3 cover box within both the row and a grid column. No + // gap reserve: the cell insets already keep adjacent covers apart, and the + // SpaceBetween layout absorbs whatever slack remains. + const int maxCoverWidth = rect.width / GRID_COLUMNS - 2 * COVER_CELL_INSET; + card.coverSize.height = std::max(1, std::min(std::min(rect.height, coverRowHeight) - 12, maxCoverWidth * 3 / 2)); + card.coverSize.width = std::max(1, card.coverSize.height * 2 / 3); + noteThumbHeight(card.coverSize.height); + gridBounds = layoutGrid(screen.body()); rect.x = gridBounds.x; rect.width = gridBounds.width; card.coverPainterUserData = this; @@ -216,24 +219,20 @@ void CoverGridHomeUi::drawCurrent(UiScreen& screen, fui::Rect rect) { // cursor on the hero card. const int16_t barH = card.coverSize.height; screen.target().fill( - fui::Rect{static_cast(rect.x - 9), static_cast(rect.y + (rect.height - barH) / 2), 3, barH}, + fui::Rect{static_cast(rect.x - 5), static_cast(rect.y + (rect.height - barH) / 2), 3, barH}, fui::Paint::dither(fui::Color::LightGray)); } } -fui::Rect CoverGridHomeUi::layoutGrid(UiScreen& screen, fui::Rect rect) { - const auto& theme = screen.theme(); - grid.gap = std::max(theme.spaceSm, rect.width * 2 / 100); - grid.rowGap = grid.gap; - grid.cellInset = fui::Insets{6, 6, 6, 6}; - const int maxCoverWidth = std::max(1, (rect.width - (GRID_COLUMNS - 1) * grid.gap) / GRID_COLUMNS - 12); - const int maxCoverHeight = std::max(1, (rect.height - (GRID_ROWS - 1) * grid.rowGap) / GRID_ROWS - 12); - grid.coverSize.height = std::max(1, std::min({maxCoverHeight, maxCoverWidth * 5 / 3, card.coverSize.height * 3 / 2})); - grid.coverSize.width = std::max(1, grid.coverSize.height * 3 / 5); +fui::Rect CoverGridHomeUi::layoutGrid(fui::Rect rect) { + // Exactly the hero's box: the three-equal-rows split in draw() already + // guarantees it fits, and sharing the size keeps one cached thumb per book. + grid.cellInset = fui::Insets{COVER_CELL_INSET, COVER_CELL_INSET, COVER_CELL_INSET, COVER_CELL_INSET}; + grid.coverSize = card.coverSize; grid.rowHeight = grid.coverSize.height + 12; - const int gridWidth = GRID_COLUMNS * (grid.coverSize.width + 12) + (GRID_COLUMNS - 1) * grid.gap; - rect.x += (rect.width - gridWidth) / 2; - rect.width = gridWidth; + // Full content width: the SpaceBetween column layout pins the outer covers + // to the rect edges, so the grid reaches the chrome's inset line instead of + // centering at its natural width. rect.height = GRID_ROWS * grid.rowHeight + (GRID_ROWS - 1) * grid.rowGap; return rect; } @@ -256,7 +255,6 @@ void CoverGridHomeUi::drawGrid(UiScreen& screen) { grid.cellStyles = card.styles; grid.labelHeight = 0; grid.labelGap = 0; - for (size_t i = 1; i < thumbHeights.size(); ++i) noteThumbHeight(i, grid.coverSize.width, grid.coverSize.height); grid.scrollIndicator = false; grid.itemProvider = [](uint16_t index, void*) { return fui::coverGridItem(nullptr, index + 1); }; grid.coverPainterUserData = this; @@ -304,7 +302,8 @@ bool CoverGridHomeUi::paintFramedCover(fui::DrawTarget& target, fui::Rect rect, const auto ink = fui::Paint::solid(fui::Color::Black); target.fill(fui::Rect{rect.right(), static_cast(rect.y + SHADOW_OFFSET), SHADOW_OFFSET, rect.height}, ink); target.fill(fui::Rect{static_cast(rect.x + SHADOW_OFFSET), rect.bottom(), rect.width, SHADOW_OFFSET}, ink); + // The false spine draws inside the cover paint (HomeCoverCache), glued to + // the art's left edge, so it stays aligned whatever each cover's margin is. const bool drawn = index < coverPaths.size() && coverCache.paint(rect, index, coverPaths[index]); - target.stroke(rect, ink, 1, 0); return drawn; } diff --git a/src/components/CoverGridHomeUi.h b/src/components/CoverGridHomeUi.h index d77364dfb90..181e0acb619 100644 --- a/src/components/CoverGridHomeUi.h +++ b/src/components/CoverGridHomeUi.h @@ -23,33 +23,32 @@ class CoverGridHomeUi final : public UiAppHost { void refreshCoverPaths(); void setSelection(int selection) { selected = selection; } int selectedAction(const MappedInputManager& input); - // Exact generation height for a slot, recorded during draw. Thumbs must be - // generated at the drawn size: rescaling a dithered 1-bit image aliases badly. - int thumbHeightFor(size_t index) const; - bool takeThumbHeightsChanged(); + // Exact generation height, shared by every slot and recorded during draw. + // Thumbs must be generated at the drawn size: rescaling a dithered 1-bit + // image aliases badly. + int thumbHeightFor() const; + bool takeThumbHeightChanged(); private: static void screenFn(UiScreen& screen, void* user); static void onAction(const freeink::ui::ActionEvent& event, void* user); void draw(UiScreen& screen); - void drawHeaderBand(freeink::ui::Rect header, int coverLeft, int coverRight); + void drawHeaderBand(); void drawEmpty(UiScreen& screen); - void drawCurrent(UiScreen& screen, freeink::ui::Rect rect); + void drawCurrent(UiScreen& screen, freeink::ui::Rect rect, int coverRowHeight); void drawGrid(UiScreen& screen); - freeink::ui::Rect layoutGrid(UiScreen& screen, freeink::ui::Rect rect); + freeink::ui::Rect layoutGrid(freeink::ui::Rect rect); void drawTabs(UiScreen& screen, freeink::ui::Rect rect); bool paintFramedCover(freeink::ui::DrawTarget& target, freeink::ui::Rect rect, size_t index); void refreshCoverPath(size_t index); - void noteThumbHeight(size_t index, int slotWidth, int slotHeight); + void noteThumbHeight(int slotHeight); HomeCoverCache coverCache; GfxRenderer& renderer; const std::vector* books = nullptr; std::array coverPaths; - int featuredCoverWidth = 0; - int featuredCoverHeight = 0; - std::array thumbHeights{}; - bool thumbHeightsChanged = false; + int thumbHeight = 0; + bool thumbHeightChanged = false; int selected = 0; int pending = -1; int progress = -1; diff --git a/src/components/HeaderBackTapTarget.h b/src/components/HeaderBackTapTarget.h new file mode 100644 index 00000000000..32297e32232 --- /dev/null +++ b/src/components/HeaderBackTapTarget.h @@ -0,0 +1,27 @@ +#pragma once + +// Tap rect of the back button BaseTheme::drawHeader paints on touch boards. +// The header's FreeInkUI frame is non-interactive, so the rect is recorded +// here at draw time and consumed by MappedInputManager's Back mapping; every +// screen that draws a titled header gets tap-to-go-back without its own +// routing. Cleared on activity exit (and by headerless draws) so a stale rect +// never eats taps on a new screen. Written by the render task and read by the +// loop task; a torn int read at worst misroutes one tap on a band that is +// being redrawn, so no lock is taken. +namespace HeaderBackTapTarget { +inline int x = 0; +inline int y = 0; +inline int w = 0; +inline int h = 0; + +inline void set(const int newX, const int newY, const int newW, const int newH) { + x = newX; + y = newY; + w = newW; + h = newH; +} + +inline void clear() { w = 0; } + +inline bool contains(const int tx, const int ty) { return w > 0 && tx >= x && tx < x + w && ty >= y && ty < y + h; } +} // namespace HeaderBackTapTarget diff --git a/src/components/HomeCoverCache.cpp b/src/components/HomeCoverCache.cpp index 28b5ea873e7..06445f9290c 100644 --- a/src/components/HomeCoverCache.cpp +++ b/src/components/HomeCoverCache.cpp @@ -37,35 +37,40 @@ void HomeCoverCache::prepare() { } } -void HomeCoverCache::readSize(const std::string& path, int& width, int& height) { - width = height = 0; - if (!path.empty() && Storage.exists(path.c_str()) && Storage.openFileForRead("HOME", path, coverFile)) { - if (coverBitmap.parseHeaders() == BmpReaderError::Ok) { - width = coverBitmap.getWidth(); - height = coverBitmap.getHeight(); - } - coverFile.close(); - } -} - bool HomeCoverCache::paint(fui::Rect rect, size_t index, const std::string& path) { if (index >= cachedCovers.size()) return false; auto& cached = cachedCovers[index]; + uint8_t* const cacheData = coverCache.get(); if (coverCache && cached.valid && cached.rect.x == rect.x && cached.rect.y == rect.y && cached.rect.width == rect.width && cached.rect.height == rect.height && - renderer.copyBufferToRegion(rect.x, rect.y, rect.width, rect.height, coverCache.get() + cached.offset, - cached.bytes)) { + renderer.copyBufferToRegion(rect.x, rect.y, rect.width, rect.height, cacheData + cached.offset, cached.bytes)) { return true; } cached.valid = false; bool drawn = false; if (!path.empty() && Storage.openFileForRead("HOME", path, coverFile)) { if (coverBitmap.parseHeaders() == BmpReaderError::Ok && coverBitmap.getWidth() > 0 && coverBitmap.getHeight() > 0) { - drawn = GUI.drawCoverThumbFill(renderer, coverBitmap, Rect{rect.x, rect.y, rect.width, rect.height}); + // The art nudges a few px right of center; the spine below hugs its + // left edge either way. + constexpr int ART_SHIFT = 3; + drawn = GUI.drawCoverThumbFill(renderer, coverBitmap, Rect{rect.x, rect.y, rect.width, rect.height}, ART_SHIFT); + if (drawn) { + renderer.drawRect(rect.x, rect.y, rect.width, rect.height); + // False spine glued to the art's left edge (centered art means that + // edge moves with each cover's fit margin): a dark band with a + // dithered crease makes every cover read as a bound book. Drawn here + // so the PSRAM snapshot below captures it. + const int artLeft = std::max(rect.x, rect.x + (rect.width - coverBitmap.getWidth()) / 2 + ART_SHIFT); + renderer.fillRect(artLeft, rect.y, 3, rect.height); + renderer.fillRectDither(artLeft + 3, rect.y, 2, rect.height, Color::LightGray); + } } coverFile.close(); } - if (!drawn) GUI.drawCoverPlaceholder(renderer, Rect{rect.x, rect.y, rect.width, rect.height}); + if (!drawn) { + GUI.drawCoverPlaceholder(renderer, Rect{rect.x, rect.y, rect.width, rect.height}); + renderer.drawRect(rect.x, rect.y, rect.width, rect.height); + } if (coverCache) { const size_t needed = renderer.getRegionByteSize(rect.x, rect.y, rect.width, rect.height); if (needed > cached.bytes && needed <= coverCacheCapacity - coverCacheUsed) { @@ -75,8 +80,8 @@ bool HomeCoverCache::paint(fui::Rect rect, size_t index, const std::string& path } if (needed > 0 && needed <= cached.bytes) { cached.rect = rect; - cached.valid = renderer.copyRegionToBuffer(rect.x, rect.y, rect.width, rect.height, - coverCache.get() + cached.offset, cached.bytes); + cached.valid = + renderer.copyRegionToBuffer(rect.x, rect.y, rect.width, rect.height, cacheData + cached.offset, cached.bytes); } } return true; // The cover slot is painted, including fallback art. diff --git a/src/components/HomeCoverCache.h b/src/components/HomeCoverCache.h index 8659028cbf8..15e4ececfa9 100644 --- a/src/components/HomeCoverCache.h +++ b/src/components/HomeCoverCache.h @@ -19,7 +19,6 @@ class HomeCoverCache { void prepare(); void invalidate(); void invalidate(size_t index); - void readSize(const std::string& path, int& width, int& height); bool paint(freeink::ui::Rect rect, size_t index, const std::string& path); private: diff --git a/src/components/UiAppHelpers.h b/src/components/UiAppHelpers.h index 56bbe7a95c0..919d81998ed 100644 --- a/src/components/UiAppHelpers.h +++ b/src/components/UiAppHelpers.h @@ -68,6 +68,9 @@ inline freeink::ui::GfxRendererTarget makeUiTarget(const GfxRenderer& renderer) target.setFont(freeink::ui::GfxRendererTarget::FONT_SMALL, spec.smallFontId); target.setFont(freeink::ui::GfxRendererTarget::FONT_BODY, spec.bodyFontId); target.setFont(freeink::ui::GfxRendererTarget::FONT_TITLE, spec.titleFontId); + // Status chrome (header battery percent, clock) stays at the fixed small + // font; the uiScale FONT_SMALL is for list subtitles. + target.setFont(freeink::ui::GfxRendererTarget::FONT_LABEL, SMALL_FONT_ID); return target; } diff --git a/src/components/icons/headerIcons.h b/src/components/icons/headerIcons.h new file mode 100644 index 00000000000..97516cc0552 --- /dev/null +++ b/src/components/icons/headerIcons.h @@ -0,0 +1,17 @@ +#pragma once + +#include "Icon.h" + +// Generated by libs/assets/Icons/tools/gen_icons.py from Lucide SVGs. Do not edit. +// Sizes: 32px. Icons: 1. + +// header-back (lucide: arrow-left) +static const uint8_t icon_header_back_32_bits[] = { + 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, + 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFC, 0x7F, 0xFF, 0xFF, 0xF8, 0x7F, 0xFF, 0xFF, 0xF0, 0xFF, 0xFF, 0xFF, 0xE1, + 0xFF, 0xFF, 0xFF, 0xC3, 0xFF, 0xFF, 0xFF, 0x87, 0xFF, 0xFF, 0xFF, 0x0F, 0xFF, 0xFF, 0xFE, 0x1F, 0xFF, 0xFF, 0xFC, + 0x3F, 0xFF, 0xFF, 0xF8, 0x00, 0x00, 0x3F, 0xF8, 0x00, 0x00, 0x3F, 0xFC, 0x3F, 0xFF, 0xFF, 0xFE, 0x1F, 0xFF, 0xFF, + 0xFF, 0x0F, 0xFF, 0xFF, 0xFF, 0x87, 0xFF, 0xFF, 0xFF, 0xC3, 0xFF, 0xFF, 0xFF, 0xE1, 0xFF, 0xFF, 0xFF, 0xF0, 0xFF, + 0xFF, 0xFF, 0xF8, 0x7F, 0xFF, 0xFF, 0xFC, 0x7F, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, + 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF}; +static const freeink::Icon icon_header_back_32 = {32, 32, 16, icon_header_back_32_bits}; diff --git a/src/components/icons/headerIcons.manifest b/src/components/icons/headerIcons.manifest new file mode 100644 index 00000000000..0da218d58e7 --- /dev/null +++ b/src/components/icons/headerIcons.manifest @@ -0,0 +1,6 @@ +# Regenerate from the repository root with: +# python3 freeink-sdk/libs/assets/Icons/tools/gen_icons.py \ +# --manifest src/components/icons/headerIcons.manifest \ +# --svgdir freeink-sdk/libs/assets/Icons/lucide/icons \ +# --sizes 32 --out src/components/icons/headerIcons.h +header-back = arrow-left diff --git a/src/components/themes/BaseTheme.cpp b/src/components/themes/BaseTheme.cpp index b4b05a3824f..5b1327909d0 100644 --- a/src/components/themes/BaseTheme.cpp +++ b/src/components/themes/BaseTheme.cpp @@ -14,12 +14,14 @@ #include "I18n.h" #include "RecentBooksStore.h" +#include "components/HeaderBackTapTarget.h" #include "components/UIScale.h" #include "components/UITheme.h" #include "components/UIThemeTokens.h" #include "components/UiAppHelpers.h" #include "components/icons/bookmark.h" #include "components/icons/cover.h" +#include "components/icons/headerIcons.h" #include "fontIds.h" // Internal constants @@ -127,9 +129,10 @@ void BaseTheme::drawCoverPlaceholder(const GfxRenderer& renderer, Rect rect) { } } -bool BaseTheme::drawCoverThumbFill(const GfxRenderer& renderer, const Bitmap& bitmap, Rect slot) { +bool BaseTheme::drawCoverThumbFill(const GfxRenderer& renderer, const Bitmap& bitmap, Rect slot, const int xOffset) { if (slot.width <= 0 || slot.height <= 0) return false; - const int x = slot.x + (slot.width - bitmap.getWidth()) / 2; + // xOffset nudges the centered art sideways; the clip stays on the slot. + const int x = slot.x + (slot.width - bitmap.getWidth()) / 2 + xOffset; const int y = slot.y + (slot.height - bitmap.getHeight()) / 2; const auto clip = renderer.getClipRect(); const int left = std::max(slot.x, clip[0]); @@ -302,12 +305,74 @@ void BaseTheme::drawSideButtonHints(const GfxRenderer& renderer, const char* top } } -int BaseTheme::headerStatusInset() { +// Slightly inside the side padding: the battery's boxed glyph and the clock +// digits read wider than text/cover ink on the same line, so flush placement +// looks like it overhangs the content columns. +int BaseTheme::headerStatusInset() { return UITheme::getInstance().getMetrics().headerSidePadding + 4; } + +void BaseTheme::applyHeaderStatus(const GfxRenderer& renderer, freeink::ui::HeaderProps& props) { const ThemeMetrics& metrics = UITheme::getInstance().getMetrics(); - return metrics.headerBatteryDetached ? 12 : metrics.headerSidePadding; + auto& status = props.status; + + // Status text stays at the fixed small font on every screen: FONT_LABEL is + // bound to SMALL_FONT_ID by makeUiTarget() (FUI screens) and drawHeader() + // (passive frames), while the uiScale FONT_SMALL is for list subtitles. + status.battery.text.font = freeink::ui::GfxRendererTarget::FONT_LABEL; + + status.showBattery = true; + const uint16_t percentage = powerManager.getBatteryPercentage(); + status.battery.percent = static_cast(percentage > 100 ? 100 : percentage); + status.battery.charging = gpio.isUsbConnected(); + // Static label buffers: headers draw on the single render task, and the + // strings only need to outlive the fui::header() call that consumes them. + static char percentText[8]; + if (SETTINGS.hideBatteryPercentage != CrossPointSettings::HIDE_BATTERY_PERCENTAGE::HIDE_ALWAYS) { + snprintf(percentText, sizeof(percentText), "%u%%", static_cast(percentage)); + status.battery.label = percentText; + } + status.battery.glyphWidth = static_cast(metrics.batteryWidth); + status.battery.glyphHeight = static_cast(metrics.batteryHeight); + status.battery.gap = batteryPercentSpacing; + status.batteryLeft = metrics.headerBatterySide == 1; + status.edgeInset = static_cast(headerStatusInset()); + + // Header chrome geometry. Status lives on the theme's thin top strip in + // fixed corners — battery top-right, a corner clock (headerClockCentered = + // false) top-left, a centered clock top-center — and never repositions. + // The content row (title, back arrow, trailing action buttons) centers on + // the region between the strip and the band bottom, so its padding reads + // as balanced under the status line rather than against the full band. + // Text hangs low in its line cell by the font's internal leading; the icon + // buttons drop by that amount to align with the glyphs the user sees. + // Buttons keep a standard square (a band-8 button dwarfs its 24px icon); + // the boxes are invisible and minTouchSize pads the tap target. + constexpr int16_t headerButtonSize = 48; + const int16_t bandHeight = static_cast(metrics.headerHeight); + const int16_t strip = static_cast(metrics.batteryBarHeight); + const int titleFontId = uiScaleSpec().titleFontId; + const int16_t opticalDrop = + static_cast((renderer.getLineHeight(titleFontId) - renderer.getTextHeight(titleFontId)) / 2); + props.leadingSize = headerButtonSize; + props.trailingSize = headerButtonSize; + props.actionOffsetY = static_cast(strip + (bandHeight - strip - headerButtonSize) / 2 - 4 + opticalDrop); + // Shift the title's band-centered box down by half the strip: its center + // lands on the below-strip region's midline with the buttons. + props.titleOffsetY = static_cast(strip / 2); + status.stripHeight = strip; + status.clockCentered = metrics.headerClockCentered; + + // Header clock, opposite the battery, on every screen that draws this band + // (SETTINGS.clockShowInHeader). Themes whose title layout has no room for + // the clock's left reserve opt out via headerShowsClock. + static char clockText[10]; + if (metrics.headerShowsClock && SETTINGS.clockShowInHeader && halClock.isAvailable() && + halClock.formatTime(clockText, sizeof(clockText), SETTINGS.clockFormat == 1)) { + status.clockText = clockText; + } } -void BaseTheme::drawHeader(const GfxRenderer& renderer, Rect rect, const char* title, const char* subtitle) const { +void BaseTheme::drawHeader(const GfxRenderer& renderer, Rect rect, const char* title, const char* subtitle, + const bool backButton) const { // Every activity header renders through the FreeInkUI header + battery // indicator components, styled by the active theme's tokens (padding, // centering, underline). Non-interactive frame: no hit rects registered. @@ -325,51 +390,35 @@ void BaseTheme::drawHeader(const GfxRenderer& renderer, Rect rect, const char* t // small font like the legacy headers; the uiScale small font is for list // subtitles. ui.target.setFont(fui::GfxRendererTarget::FONT_SMALL, SMALL_FONT_ID); - const ThemeMetrics& metrics = UITheme::getInstance().getMetrics(); + ui.target.setFont(fui::GfxRendererTarget::FONT_LABEL, SMALL_FONT_ID); const fui::Rect band{static_cast(rect.x), static_cast(rect.y), static_cast(rect.width), static_cast(rect.height)}; - const bool showBatteryPercentage = - SETTINGS.hideBatteryPercentage != CrossPointSettings::HIDE_BATTERY_PERCENTAGE::HIDE_ALWAYS; - const uint16_t percentage = powerManager.getBatteryPercentage(); - char percentText[8]; - snprintf(percentText, sizeof(percentText), "%u%%", static_cast(percentage)); - // The icon glyph extends 2px past glyphWidth (terminal nub); reserve it or - // the percent label's rect comes up short and the text truncates. - constexpr int16_t batteryNubWidth = 2; - int16_t batteryReserve = static_cast(metrics.batteryWidth + batteryNubWidth); - if (showBatteryPercentage) { - batteryReserve = static_cast( - batteryReserve + batteryPercentSpacing + - ui.target.measureText(fui::GfxRendererTarget::FONT_SMALL, percentText, tokens.smallText).width); - } - - // Header clock, opposite the battery, on every screen that draws this - // header band (SETTINGS.clockShowInHeader). Themes whose title layout has no - // room for the clock's left reserve opt out via headerShowsClock. - char clockText[10] = {0}; - int16_t clockWidth = 0; - if (metrics.headerShowsClock && SETTINGS.clockShowInHeader && halClock.isAvailable() && - halClock.formatTime(clockText, sizeof(clockText), SETTINGS.clockFormat == 1)) { - clockWidth = ui.target.measureText(fui::GfxRendererTarget::FONT_SMALL, clockText, tokens.smallText).width; - } else { - clockText[0] = '\0'; - } - fui::HeaderProps props; props.title = title; props.rightLabel = subtitle; // firmware headers right-align the secondary text - const bool batteryLeft = metrics.headerBatterySide == 1; - const bool batteryDetached = metrics.headerBatteryDetached; - // Shared-line headers with the battery on the right: the header component - // places rightLabel inside the battery reserve, so it sits mid-band next to - // the icon and shifts with the percent label's width. Draw it manually below - // instead, pinned at the fixed side inset in the band's lower half — the - // same corner the detached (Lyra) layout puts it — so the label holds one - // position across themes and battery states. - const bool manualRightLabel = subtitle != nullptr && !batteryDetached && !batteryLeft; - if (manualRightLabel) { - props.rightLabel = nullptr; + // Battery + clock chrome and their title reserves live in the FreeInkUI + // header component; this only fills the values from settings and metrics. + applyHeaderStatus(renderer, props); + if (rect.height < UITheme::getInstance().getMetrics().headerHeight) { + // Short bands (home) are not split into strip + content row: the title + // centers on the band, clear of the band's bottom edge. + props.titleOffsetY = 0; + } + // Tappable back button leading the band on touch boards, so every pushed + // screen offers a visible way out beside the edge-swipe gesture. This frame + // registers no hit rects, so the rect is recorded in HeaderBackTapTarget and + // MappedInputManager folds taps on it into Button::Back. Same geometry as + // the FUI header's leading slot (applyHeaderStatus set the size/offset) so + // the recorded rect matches the drawn button. + const int16_t backBtnSize = props.leadingSize; + const bool showBackButton = backButton && title != nullptr && gpio.hasTouch(); + if (showBackButton) { + props.leadingIcon = fui::bitmapFromIcon(icon_header_back_32); + props.leadingAction = 1; // any non-NO_ACTION id: paints the button, routing is via HeaderBackTapTarget + HeaderBackTapTarget::set(band.x + 4, band.y + 4 + props.actionOffsetY, backBtnSize, backBtnSize); + } else { + HeaderBackTapTarget::clear(); } props.borderEdges = fui::EdgeBottom; props.titleText = tokens.titleText; @@ -377,33 +426,6 @@ void BaseTheme::drawHeader(const GfxRenderer& renderer, Rect rect, const char* t props.subtitleText = tokens.smallText; props.styles = tokens.popup; props.sidePadding = tokens.headerSidePadding; - if (batteryDetached) { - // Battery in its own corner strip; the title owns the full width of the - // lower sub-band, so long book titles span the header (Lyra layout). - // Anchor the title with explicit clearance above the band's bottom rule - // instead of naive sub-band centering, which left the glyphs nearly - // touching it. - const int titleLineHeight = ui.target.lineHeight(fui::GfxRendererTarget::FONT_TITLE); - const int titleTop = static_cast(band.height) - tokens.headerUnderline - tokens.spaceMd - titleLineHeight; - props.titleOffsetY = static_cast(titleTop - (static_cast(band.height) - titleLineHeight) / 2); - } else { - const int16_t reserve = static_cast(batteryReserve + tokens.spaceMd); - if (batteryLeft) { - props.leftReserve = reserve; - } else { - props.rightReserve = reserve; - } - // The clock sits opposite the battery on the shared line; keep the title - // clear of it too. - if (clockText[0] != '\0') { - const int16_t clockReserve = static_cast(clockWidth + tokens.spaceMd); - if (batteryLeft) { - props.rightReserve = static_cast(props.rightReserve + clockReserve); - } else { - props.leftReserve = static_cast(props.leftReserve + clockReserve); - } - } - } // Underline only under a titled header: an untitled band (Lyra home screen) // historically drew no rule, and the old themes keyed the line on the title. if (title != nullptr && props.styles.normal.border.kind == fui::PaintKind::None && tokens.headerUnderline > 0) { @@ -411,55 +433,6 @@ void BaseTheme::drawHeader(const GfxRenderer& renderer, Rect rect, const char* t props.styles.normal.borderWidth = tokens.headerUnderline; } fui::header(ui.frame, band, props); - - fui::BatteryIndicatorProps battery; - battery.percent = static_cast(percentage > 100 ? 100 : percentage); - battery.charging = gpio.isUsbConnected(); - battery.label = showBatteryPercentage ? percentText : nullptr; - battery.text = tokens.smallText; - battery.glyphWidth = static_cast(metrics.batteryWidth); - battery.glyphHeight = static_cast(metrics.batteryHeight); - battery.gap = batteryPercentSpacing; - // Detached: hug the corner (12px, the legacy inset) within the battery - // strip; shared line: sit on the content grid. Both anchor to the band's top - // strip (batteryBarHeight) — the legacy shared-line headers drew the battery - // at the top edge, and it keeps the lower-right corner free for the manual - // right label below. - const int16_t batteryEdgeInset = static_cast(headerStatusInset()); - const int16_t batteryX = batteryLeft ? static_cast(band.x + batteryEdgeInset) - : static_cast(band.right() - batteryEdgeInset - batteryReserve); - const int16_t batteryH = static_cast(metrics.batteryBarHeight); - fui::batteryIndicator(ui.frame, fui::Rect{batteryX, band.y, batteryReserve, batteryH}, battery); - - if (clockText[0] != '\0') { - // Ink-align a left-anchored clock: the first digit's ink starts its side - // bearing right of the pen ('1' bears more than '9'), so shift the pen - // left by that bearing to keep the visible edge fixed on the inset. - int16_t clockBearing = 0; - if (!batteryLeft) { - const auto& fonts = renderer.getFontMap(); - const auto it = fonts.find(SMALL_FONT_ID); - if (it != fonts.end()) { - if (const EpdGlyph* glyph = it->second.getGlyph(static_cast(clockText[0]))) { - clockBearing = static_cast(glyph->left); - } - } - } - // Same top strip and edge inset as the battery, mirrored to the other - // side, so the two read as one balanced status line. - const int16_t clockX = batteryLeft ? static_cast(band.right() - batteryEdgeInset - clockWidth) - : static_cast(band.x + batteryEdgeInset - clockBearing); - ui.target.text(fui::Rect{clockX, band.y, clockWidth, batteryH}, clockText, tokens.smallText); - } - - if (manualRightLabel) { - const fui::Size labelSize = ui.target.measureText(fui::GfxRendererTarget::FONT_SMALL, subtitle, tokens.smallText); - const int16_t labelH = ui.target.lineHeight(fui::GfxRendererTarget::FONT_SMALL); - const fui::Rect labelRect{static_cast(band.right() - tokens.headerSidePadding - labelSize.width), - static_cast(band.bottom() - tokens.headerUnderline - tokens.spaceSm - labelH), - labelSize.width, labelH}; - ui.target.text(labelRect, subtitle, tokens.smallText); - } } void BaseTheme::drawSubHeader(const GfxRenderer& renderer, Rect rect, const char* label, const char* rightLabel) const { diff --git a/src/components/themes/BaseTheme.h b/src/components/themes/BaseTheme.h index 17f93ab2f0e..c7e20b4166d 100644 --- a/src/components/themes/BaseTheme.h +++ b/src/components/themes/BaseTheme.h @@ -9,6 +9,11 @@ class Bitmap; class GfxRenderer; struct RecentBook; +namespace freeink { +namespace ui { +struct HeaderProps; +} // namespace ui +} // namespace freeink struct Rect { int x; @@ -51,13 +56,11 @@ struct ThemeMetrics { int headerUnderlineSize; // bottom rule thickness (Lyra), 0 = none int headerTitleAlign; // 0 = left, 1 = center, 2 = right (fui::TextAlign order) int headerBatterySide; // 0 = right edge, 1 = left edge - // Battery in its own corner strip (batteryBarHeight tall) with the title on - // the lower sub-band spanning the full width (Lyra), vs sharing the title - // line with a width reserve (Classic, RoundedRaff). - bool headerBatteryDetached; // Header clock opt-out for themes whose title layout can't spare the left // reserve (RoundedRaff); the user setting still governs the themes that can. bool headerShowsClock = true; + // Clock slot: centered on the band, or on the left after the back arrow. + bool headerClockCentered = true; int menuRowHeight; int menuSpacing; @@ -151,7 +154,7 @@ constexpr ThemeMetrics values = {.batteryWidth = 15, .batteryHeight = 12, .topPadding = 5, .batteryBarHeight = 20, - .headerHeight = 45, + .headerHeight = 84, .verticalSpacing = 10, .previewPadding = 12, .previewHeightPercent = 30, @@ -170,7 +173,8 @@ constexpr ThemeMetrics values = {.batteryWidth = 15, .headerUnderlineSize = 0, .headerTitleAlign = 1, // centered .headerBatterySide = 0, - .headerBatteryDetached = false, + // Corner clock: a centered clock would collide with the centered title. + .headerClockCentered = false, .menuRowHeight = 45, .menuSpacing = 8, .tabSpacing = 10, @@ -229,7 +233,7 @@ class BaseTheme { static void drawCoverPlaceholder(const GfxRenderer& renderer, Rect rect); // Draws a pre-dithered cover thumb 1:1, centered and clipped to fill the // slot. Rescaling a dithered bitmap aliases badly, so overflow is cropped. - static bool drawCoverThumbFill(const GfxRenderer& renderer, const Bitmap& bitmap, Rect slot); + static bool drawCoverThumbFill(const GfxRenderer& renderer, const Bitmap& bitmap, Rect slot, int xOffset = 0); static void drawProgressBar(const GfxRenderer& renderer, Rect rect, size_t current, size_t total); void drawBatteryLeft(const GfxRenderer& renderer, Rect rect, bool showPercentage = true) const; // Left aligned (reader mode) @@ -246,9 +250,18 @@ class BaseTheme { // its row height from the font, not the metrics table). virtual int getMenuRowHeight(const GfxRenderer& renderer) const; // Also draws the wall clock opposite the battery when the user enabled - // SETTINGS.clockShowInHeader and an RTC is present. - virtual void drawHeader(const GfxRenderer& renderer, Rect rect, const char* title, - const char* subtitle = nullptr) const; + // SETTINGS.clockShowInHeader and an RTC is present. On touch boards a + // tappable back button leads the band (see HeaderBackTapTarget); root + // screens that own their stack bottom pass backButton = false. + virtual void drawHeader(const GfxRenderer& renderer, Rect rect, const char* title, const char* subtitle = nullptr, + bool backButton = true) const; + // Fill the battery/clock status chrome (settings + theme metrics) into + // header props, so FUI-native screens drawing their own interactive header + // carry the same band as drawHeader. Status text is styled with the + // FONT_LABEL slot (bound to the fixed small font by makeUiTarget and + // drawHeader). The label strings point at internal static buffers refreshed + // per call (headers draw on the single render task). + static void applyHeaderStatus(const GfxRenderer& renderer, freeink::ui::HeaderProps& props); // Edge inset drawHeader uses for the clock/battery status line (detached // layouts hug the corner with a legacy 12px inset instead of the padding). static int headerStatusInset(); diff --git a/src/components/themes/lyra/LyraTheme.h b/src/components/themes/lyra/LyraTheme.h index e8977f82808..4fc194ad3a1 100644 --- a/src/components/themes/lyra/LyraTheme.h +++ b/src/components/themes/lyra/LyraTheme.h @@ -8,8 +8,10 @@ class GfxRenderer; namespace LyraMetrics { constexpr ThemeMetrics values = {.batteryWidth = 16, .batteryHeight = 12, - .topPadding = 5, - .batteryBarHeight = 40, + // Shared anchor for every header band, including the cover-grid + // home's status band; roomy enough that the strip clears the edge. + .topPadding = 10, + .batteryBarHeight = 20, .headerHeight = 84, .verticalSpacing = 16, .previewPadding = 12, @@ -29,7 +31,7 @@ constexpr ThemeMetrics values = {.batteryWidth = 16, .headerUnderlineSize = 3, .headerTitleAlign = 0, // left .headerBatterySide = 0, - .headerBatteryDetached = true, + .headerClockCentered = false, .menuRowHeight = 64, .menuSpacing = 8, .tabSpacing = 8, @@ -78,7 +80,7 @@ constexpr ThemeMetrics values = {.batteryWidth = 16, .controlRadius = 6, .sheetRadius = 6, .capsuleRadius = 6}; -} +} // namespace LyraMetrics class LyraTheme : public BaseTheme { public: diff --git a/src/components/themes/roundedraff/RoundedRaffTheme.cpp b/src/components/themes/roundedraff/RoundedRaffTheme.cpp index 167637e89f9..6e211c269fa 100644 --- a/src/components/themes/roundedraff/RoundedRaffTheme.cpp +++ b/src/components/themes/roundedraff/RoundedRaffTheme.cpp @@ -46,13 +46,13 @@ void drawScrollBar(const GfxRenderer& renderer, Rect rect, int itemCount, int pa } // namespace int coverWidth = 0; -void RoundedRaffTheme::drawHeader(const GfxRenderer& renderer, Rect rect, const char* title, - const char* subtitle) const { +void RoundedRaffTheme::drawHeader(const GfxRenderer& renderer, Rect rect, const char* title, const char* subtitle, + const bool backButton) const { // Home screen header is custom-rendered in drawRecentBookCover. if (title == nullptr) { return; } - BaseTheme::drawHeader(renderer, rect, title, subtitle); + BaseTheme::drawHeader(renderer, rect, title, subtitle, backButton); } void RoundedRaffTheme::drawRecentBookCover(GfxRenderer& renderer, Rect rect, const std::vector& recentBooks, diff --git a/src/components/themes/roundedraff/RoundedRaffTheme.h b/src/components/themes/roundedraff/RoundedRaffTheme.h index 2b23072d37a..9837aabadd5 100644 --- a/src/components/themes/roundedraff/RoundedRaffTheme.h +++ b/src/components/themes/roundedraff/RoundedRaffTheme.h @@ -11,7 +11,7 @@ constexpr ThemeMetrics values = {.batteryWidth = 15, // keeping the 12px battery glyph at y=19, aligned with Lyra. .topPadding = 13, .batteryBarHeight = 24, - .headerHeight = 45, + .headerHeight = 84, .verticalSpacing = 10, .previewPadding = 12, .previewHeightPercent = 30, @@ -30,8 +30,9 @@ constexpr ThemeMetrics values = {.batteryWidth = 15, .headerUnderlineSize = 0, .headerTitleAlign = 0, // left .headerBatterySide = 0, - .headerBatteryDetached = false, - .headerShowsClock = false, + // A centered clock needs no left title reserve, so RoundedRaff + // can show one after all. + .headerShowsClock = true, .menuRowHeight = 42, // not authoritative: getMenuRowHeight() derives the drawn height .menuSpacing = 6, .tabSpacing = 10, @@ -39,10 +40,14 @@ constexpr ThemeMetrics values = {.batteryWidth = 15, .tabPillFullSlot = true, .scrollBarWidth = 4, .scrollBarRightOffset = 5, - .homeTopPadding = 55, - // Smaller cover tile so the home menu sits higher (fits 5 items without overlap). + // Tall enough that the home band's centered book title clears the + // battery strip above and keeps padding below. + .homeTopPadding = 64, + // Smaller cover tile so the home menu sits higher (fits 5 items + // without overlap); shrunk by the homeTopPadding growth above so + // the menu keeps its position. .homeCoverHeight = 300, - .homeCoverTileHeight = 350, + .homeCoverTileHeight = 341, .homeRecentBooksCount = 1, .homeContinueReadingInMenu = true, .homeMenuTopOffset = 20, @@ -86,8 +91,8 @@ constexpr ThemeMetrics values = {.batteryWidth = 15, class RoundedRaffTheme : public BaseTheme { public: - void drawHeader(const GfxRenderer& renderer, Rect rect, const char* title, - const char* subtitle = nullptr) const override; + void drawHeader(const GfxRenderer& renderer, Rect rect, const char* title, const char* subtitle = nullptr, + bool backButton = true) const override; void drawRecentBookCover(GfxRenderer& renderer, Rect rect, const std::vector& recentBooks, int selectorIndex, bool& coverRendered, bool& coverBufferStored, bool& bufferRestored, std::function storeCoverBuffer) const override; From f7eedbae18776fab725676c5565f8bc2b76d8e9e Mon Sep 17 00:00:00 2001 From: Justin Mitchell Date: Thu, 24 Sep 2026 21:38:58 -0400 Subject: [PATCH 23/62] fix: Remove loading icon display during startup (#3671) --- src/images/LoadingIcon.h | 24 ------------------------ src/main.cpp | 17 +++-------------- 2 files changed, 3 insertions(+), 38 deletions(-) delete mode 100644 src/images/LoadingIcon.h diff --git a/src/images/LoadingIcon.h b/src/images/LoadingIcon.h deleted file mode 100644 index c54c857daac..00000000000 --- a/src/images/LoadingIcon.h +++ /dev/null @@ -1,24 +0,0 @@ -#pragma once -#include - -// Image dimensions: 48x48, diagonal dots -#define LOADINGICON_WIDTH 48 -#define LOADINGICON_HEIGHT 48 - -static const uint8_t LoadingIcon[] = { - 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, - 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, - 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xfe, 0x1f, 0xff, 0xff, 0xff, 0xff, 0xfc, 0x0f, 0xff, - 0xff, 0xff, 0xff, 0xf8, 0x07, 0xff, 0xff, 0xff, 0xff, 0xf8, 0x07, 0xff, 0xff, 0xff, 0xff, 0xf8, 0x07, 0xff, - 0xff, 0xff, 0xff, 0xf8, 0x07, 0xff, 0xff, 0xff, 0xff, 0xfc, 0x0f, 0xff, 0xff, 0xff, 0xff, 0xfe, 0x1f, 0xff, - 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, - 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xfe, 0x1f, 0xff, 0xff, 0xff, 0xff, 0xfc, 0x0f, 0xff, - 0xff, 0xff, 0xff, 0xf8, 0x07, 0xff, 0xff, 0xff, 0xff, 0xf8, 0x07, 0xff, 0xff, 0xff, 0xff, 0xf8, 0x07, 0xff, - 0xff, 0xff, 0xff, 0xf8, 0x07, 0xff, 0xff, 0xff, 0xff, 0xfc, 0x0f, 0xff, 0xff, 0xff, 0xff, 0xfe, 0x1f, 0xff, - 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, - 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xfe, 0x1f, 0xff, 0xff, 0xff, 0xff, 0xfc, 0x0f, 0xff, - 0xff, 0xff, 0xff, 0xf8, 0x07, 0xff, 0xff, 0xff, 0xff, 0xf8, 0x07, 0xff, 0xff, 0xff, 0xff, 0xf8, 0x07, 0xff, - 0xff, 0xff, 0xff, 0xf8, 0x07, 0xff, 0xff, 0xff, 0xff, 0xfc, 0x0f, 0xff, 0xff, 0xff, 0xff, 0xfe, 0x1f, 0xff, - 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, - 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, - 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff}; diff --git a/src/main.cpp b/src/main.cpp index c93983044a3..d1eb028b374 100644 --- a/src/main.cpp +++ b/src/main.cpp @@ -35,7 +35,6 @@ #include "activities/settings/SdFirmwareUpdateActivity.h" #include "components/UITheme.h" #include "fontIds.h" -#include "images/LoadingIcon.h" #include "platform/UsbSerialJtagHandoff.h" #include "util/ButtonNavigator.h" #include "util/ScreenshotUtil.h" @@ -511,24 +510,14 @@ void setup() { APP_STATE.showBootScreen = true; APP_STATE.saveToFile(); if (Storage.exists(SLEEP_FRAME_FILE) && loadSleepFrameBuffer()) { - const bool useDifferentialRefresh = gpio.deviceIsX3(); - if (useDifferentialRefresh) { + if (gpio.deviceIsX3()) { // begin() clears the X3 controller RAM, so restore the saved frame as - // the baseline before replacing the moon with the loading icon. + // the baseline for the first reader paint without refreshing the panel. renderer.cleanupGrayscaleWithFrameBuffer(); - } - - const auto pageHeight = renderer.getScreenHeight(); - renderer.drawImage(LoadingIcon, 0, pageHeight - LOADINGICON_HEIGHT, LOADINGICON_WIDTH, LOADINGICON_HEIGHT); - if (useDifferentialRefresh) { - renderer.displayGrayscaleBase(HalDisplay::FAST_REFRESH); allowFastInitialReaderRefresh = true; - } else { - renderer.displayBuffer(HalDisplay::HALF_REFRESH); } } else { - // The first Home/Reader paint is followed by an explicit clean refresh - // because the panel still physically shows the sleep image. + // Clean the retained sleep image as part of the first Home paint. needsWakeRefresh = true; } break; From 8bae59d7877009a5ddfcc6778ebd291583f62bca Mon Sep 17 00:00:00 2001 From: Justin Mitchell Date: Thu, 24 Sep 2026 21:51:26 -0400 Subject: [PATCH 24/62] Preserve display inversion for Quick Resume sleep Quick Resume now keeps the current frame's inversion state, allowing night-mode pages to sleep in night polarity. The moon icon will invert with the frame during transfer. Other sleep screens continue to draw fresh content in normal polarity. --- src/activities/boot_sleep/SleepActivity.cpp | 18 +++++++++++------- 1 file changed, 11 insertions(+), 7 deletions(-) diff --git a/src/activities/boot_sleep/SleepActivity.cpp b/src/activities/boot_sleep/SleepActivity.cpp index 112faff5c09..a9522037b9e 100644 --- a/src/activities/boot_sleep/SleepActivity.cpp +++ b/src/activities/boot_sleep/SleepActivity.cpp @@ -504,22 +504,26 @@ void releaseSdFontCachesForDecode(const GfxRenderer& renderer) { void SleepActivity::onEnter() { Activity::onEnter(); - const bool frameWasInverted = display.isInverted(); - - // Sleep screens always use normal polarity. This activity draws directly - // from onEnter (outside ActivityManager's per-render polarity resolution), - // so clear any inversion left over from a night-mode reader render. - display.setInverted(false); - const bool renderQuickResume = SETTINGS.sleepScreen == CrossPointSettings::SLEEP_SCREEN_MODE::QUICK_RESUME || (fromTimeout && SETTINGS.quickResumeSleepScreen == CrossPointSettings::QUICK_RESUME_SLEEP_SCREEN::QUICK_RESUME_AFTER_TIMEOUT); if (renderQuickResume) { + // Quick Resume keeps the current frame as-is, so the driver's inversion + // state stays too: a night-mode page sleeps in night polarity, and the + // moon icon inverts with it at transfer like any other draw. return renderLastScreenSleepScreen(); } + const bool frameWasInverted = display.isInverted(); + + // The remaining sleep screens draw fresh content in normal polarity. This + // activity draws directly from onEnter (outside ActivityManager's + // per-render polarity resolution), so clear any inversion left over from a + // night-mode reader render. + display.setInverted(false); + if (SETTINGS.sleepScreen == CrossPointSettings::SLEEP_SCREEN_MODE::TRANSPARENT_CUSTOM) { // Transparent mode retains the current framebuffer. Materialize any // output-level inversion first so the retained content keeps its visible From 5bfaf7bdbd847c475cad96771603b23866e83ac2 Mon Sep 17 00:00:00 2001 From: Justin Mitchell Date: Thu, 24 Sep 2026 22:08:31 -0400 Subject: [PATCH 25/62] Use FAST refresh for moon icon in night mode In night mode, the display is inverted and a HALF refresh would cause a full white flash. Since only the moon icon differs from the previous frame, use FAST refresh to add it without flashing. --- src/activities/boot_sleep/SleepActivity.cpp | 7 ++++--- 1 file changed, 4 insertions(+), 3 deletions(-) diff --git a/src/activities/boot_sleep/SleepActivity.cpp b/src/activities/boot_sleep/SleepActivity.cpp index a9522037b9e..8e6bd496d3b 100644 --- a/src/activities/boot_sleep/SleepActivity.cpp +++ b/src/activities/boot_sleep/SleepActivity.cpp @@ -878,12 +878,13 @@ void SleepActivity::renderCoverSleepScreen() const { void SleepActivity::renderLastScreenSleepScreen() const { const auto pageHeight = renderer.getScreenHeight(); renderer.drawImage(MoonIcon, 0, pageHeight - MOONICON_HEIGHT, MOONICON_WIDTH, MOONICON_HEIGHT); + // Only the moon differs from the displayed frame, so a differential FAST + // update adds it without the flashing clean pass (which sweeps the panel + // through the inverse — a full white flash on a night-mode page). if (gpio.deviceIsX3()) { - // The controller still holds the displayed page, so its differential base - // waveform can add the moon without a full-screen flash. renderer.displayGrayscaleBase(HalDisplay::FAST_REFRESH); } else { - renderer.displayBuffer(HalDisplay::HALF_REFRESH); + renderer.displayBuffer(HalDisplay::FAST_REFRESH); } } From 1d61100f90d2e7e32965c14301320d9989a7ab71 Mon Sep 17 00:00:00 2001 From: Sung-jin Brian Hong Date: Fri, 25 Sep 2026 17:11:23 +0900 Subject: [PATCH 26/62] fix: release SD-font caches on reader exit (#3699) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## Summary Returning Home after reading with an SD font leaves rebuildable font caches owned by the global font system. Those allocations can split large free heap regions after the reader's temporary buffers have been freed. Release those caches from `ReaderActivity::onExit()`, preserving loaded font metadata and glyph-miss callbacks so the font data can be rebuilt when needed. The final diff contains only this reader-exit change; `SdCardFont.cpp` matches the baseline. The existing 40 KiB mini-cache retention policy between pages remains in place, and the change adds no heap allocations. ## Scope Check - [x] Read SCOPE.md and ROADMAP.md; this addresses the Phase 1 heap-fragmentation focus. - [x] Changes are limited to existing reader/font-cache behavior; no new theme, connector, app, media or PDF feature. - Stock/fork feature comparison: not applicable to this fix for CrossPoint's existing cache lifetime. - SDK/HAL/bootloader/OTA/recovery coordination: not applicable; those paths are untouched. ## Additional Context Compared with `origin/develop` at `0f01106a` on an Xteink X3 using `default` / LOG_LEVEL=2 builds with identical serial-control instrumentation and SDK. Three interleaved cold baseline/candidate pairs used `/CP-Test/scheduling-demian.epub`, Pretendard 16 pt, anti-aliasing enabled, and **hyphenation ON**, starting at spine 4 page 1. Each boot had six warmup and six measured turns, then post-page and Home heap samples after 13-second waits, followed by reopening the book. These are 18 measured turns per build. **Known cost:** median total page rendering increased from 1,507.5 to 1,514.5 ms, **+7 ms (+0.46%)**. Per-pair median increases were **+3.5 / +11.5 / +8 ms**. This remains an unexplained cost; timing parity is not established. No extra kerning/ligature-table reloads were observed during reading: both builds logged zero full-table reloads during warmup and measured turns, with matching mini-matrix rebuild and advance-table fetch logs. The captures do not provide a complete SD-read timing breakdown. | Median measurement | Baseline | Candidate | Difference | |---|---:|---:|---:| | Free heap on Home | 82,680 B | 110,536 B | +27,856 B (+27.9 KB) | | Largest free block on Home | 36,852 B | 94,196 B | +57,344 B (+57.3 KB) | | Largest free block after page turns | 53,236 B | 59,380 B | +6,144 B | | Free heap after page turns | 68,464 B | 68,468 B | +4 B | | Since-boot Min Free at post-page sample | 20,276 B | 20,288 B | +12 B | | Drawing including grayscale | 358 ms | 361 ms | +3 ms | | Total page render | 1,507.5 ms | 1,514.5 ms | +7 ms | | Cold preparation including first render | 4,398 ms | 4,408 ms | +10 ms | | First render after reopening | 4,019 ms | 4,033 ms | +14 ms | | Static RAM | 58,064 B | 58,064 B | unchanged | | Flash | 5,610,791 B | 5,610,803 B | +12 B | Releasing the resident caches allows adjacent free heap regions to coalesce. The recovery includes mini buffers and persistent advance caches as well as full kerning/ligature tables. The observed post-page largest-block gain does not establish that during-reading kerning-table pinning is fixed. Advance-table allocation/merge code is outside this diff. Hyphenation was OFF in the earlier comparison and ON in this round, following the current reader setting. Both builds used the same settings within this round, but the changed setting prevents a clean causal comparison with the earlier timing result. Cold-preparation differences also varied by pair (+14 / -9 / +1 ms). Both builds reloaded tables on reopening because resuming a section build already releases caches, so the full reopen difference cannot be attributed to newly required table reads. Validation: - `pio run -e default` passed for the revised candidate; reused the unchanged baseline build. Formatting and `git diff --check` passed, as did all 12 existing SdCardFont / FontCacheManager host tests. - All nine paired page-content captures were pixel-identical. Seven full frames were identical; two differed only in the footer. - Zero advance-cache bitmap retries across all six boots. No errors, power transitions, crashes or unexpected resets during measured turns. - Both builds logged one thumbnail-cache error per boot during deliberate cache deletion and one CSS low-heap warning per boot outside measured turns. - One read-only STATE timeout before the third candidate boot's first warmup recovered without input replay, reconnect or reset. All six warmups and six measured turns then completed; no paired boot was discarded. The 150-turn heaptrace workload has not been repeated with this revision, so the longer-run bitmap retries remain unverified. Test code, instrumentation and local evidence are excluded from the PR. ### AI Usage YES — AI tools assisted with implementation, analysis, device testing, and this description. --- src/activities/reader/ReaderActivity.cpp | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/src/activities/reader/ReaderActivity.cpp b/src/activities/reader/ReaderActivity.cpp index 8682af17520..aaf2b21970e 100644 --- a/src/activities/reader/ReaderActivity.cpp +++ b/src/activities/reader/ReaderActivity.cpp @@ -1,5 +1,6 @@ #include "ReaderActivity.h" +#include #include #include #include @@ -76,6 +77,11 @@ void ReaderActivity::onEnter() { void ReaderActivity::onExit() { Activity::onExit(); + // Keep rebuildable font buffers from pinning the heap between reading sessions. + if (auto* fontCache = renderer.getFontCacheManager()) { + fontCache->releaseSdFontCaches(); + } + LOG_INF("MEM", "reader exit: free=%u max_block=%u", (unsigned)ESP.getFreeHeap(), (unsigned)ESP.getMaxAllocHeap()); renderer.setOrientation(GfxRenderer::Orientation::Portrait); From 10cb2c3b29214533486227bdb8b95ebcecf0ea15 Mon Sep 17 00:00:00 2001 From: Sung-jin Brian Hong Date: Sat, 26 Sep 2026 22:16:36 +0900 Subject: [PATCH 27/62] perf: avoid main-loop contention during EPUB rendering (#3652) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## Summary Reduce main-loop contention during EPUB rendering. While background pagination is active, the main loop can keep running even though rendering holds the section lock and pagination cannot advance. The main loop now tries the render lock without waiting and sleeps for 10 ms if it is busy, giving rendering more CPU time. The reader's background-work checks also acquire the same lock before accessing section state, without waiting behind a render. No allocations, tasks, mutexes or task-priority changes are added. On the tested X3 workloads, median total page-render time decreases **12.8% for Korean** and **1.0% for English** against the updated `develop`. The baseline already includes the glyph optimizations in #3633; these results measure the additional effect of this PR. ## Scope Check - [x] I have read SCOPE.md and ROADMAP.md. - [x] This PR is not a new built-in theme. - [x] This PR is not a new external network connector. - [x] This PR is not an interactive app, writing tool, RSS/news/browser, media playback, or PDF feature. - [ ] Stock firmware / popular-fork comparison has not been established. This fixes contention in CrossPoint's existing core EPUB reader; its value is demonstrated against `develop` below. - [x] SDK/HAL/bootloader/OTA/recovery coordination is not applicable: none of those components are changed. This is an optimization of the existing reading engine, consistent with the reading-performance and code-cleanup priorities in SCOPE.md and ROADMAP.md. Changes are limited to the main-loop scheduling decision, the reader's background-work gates and scheduling hint, and the existing render-lock wrapper. ## Additional Context ### Locking and background work The existing background-work checks read the current section before taking the render lock, even though the render task can replace or delete that section. They also check whether the lock is free before acquiring it with a blocking wait; rendering can take the lock between those steps and stall the main loop until the render finishes. This PR addresses those problems in the paths that prepare the next page, extend a partially built section, and continue background pagination: - **Acquire the lock before reading section state.** Each path makes one nonblocking lock attempt. If rendering owns the lock, the path skips its work for that iteration without touching the section or waiting for rendering to finish. The main loop also reads the activity's scheduling hint under the lock. - **Share the decision about whether pagination has more work.** The scheduling hint and pagination loop use the same condition. The pagination loop still checks available heap before doing more work, so it can resume after a low-memory pause. - **Keep lock acquisition consistent.** Both blocking constructors use the same implementation, with an assertion for an unexpected acquisition failure. Most of the added diff moves existing conditions inside lock-protected scopes. The fixes and small refactors are separate commits so they can be reviewed individually. The section-access race and check-then-wait gap were identified by code inspection; no crash from them was reproduced in device testing. ### Performance These measurements compare `develop` at `038d615f` with the complete rebased PR at `0f1b86ee`. The rebase preserved all eight commit patches. Both builds use SDK `deb62ab7`, the `default` environment, and identical serial-control instrumentation. Each workload used three interleaved baseline/PR pairs on X3 in portrait with antialiasing enabled. Each boot included six warmup turns followed by six measured turns: **18 measured turns per firmware per workload**. Dedicated, byte-identical book copies had their render caches reset before each boot, with settings and font identity verified. Background pagination was active on both sides. **Korean: Demian, Pretendard 16 pt from SD** | Median time | Baseline | Rebased PR | Time reduction | |---|---:|---:|---:| | Font preparation | 205 ms | 136.5 ms | 33.4% | | Black-and-white drawing | 44 ms | 30 ms | 31.8% | | Black-and-white display refresh | 604.5 ms | 586 ms | 3.1% | | Grayscale LSB drawing | 138.5 ms | 102.5 ms | 26.0% | | Grayscale MSB drawing | 139 ms | 103 ms | 25.9% | | Grayscale display refresh | 229 ms | 229 ms | 0.0% | | Cleanup | 118.5 ms | 100 ms | 15.6% | | **All drawing subtotal** | **321.5 ms** | **235.5 ms** | **26.7%** | | **Total page rendering** | **1,476.5 ms** | **1,287 ms** | **12.8%** | | Cold section preparation + first render | 4,599 ms | 4,323 ms | 6.0% | **English: Aesop's Fables, built-in Noto Serif 16 pt** | Median time | Baseline | Rebased PR | Time reduction | |---|---:|---:|---:| | Font preparation | 32.5 ms | 25.5 ms | 21.5% | | Black-and-white drawing | 26 ms | 23 ms | 11.5% | | Black-and-white display refresh | 586 ms | 586 ms | 0.0% | | Grayscale LSB drawing | 94 ms | 89 ms | 5.3% | | Grayscale MSB drawing | 94 ms | 88.5 ms | 5.9% | | Grayscale display refresh | 229 ms | 229 ms | 0.0% | | Cleanup | 99 ms | 99 ms | 0.0% | | **All drawing subtotal** | **213.5 ms** | **200.5 ms** | **6.1%** | | **Total page rendering** | **1,160 ms** | **1,148.5 ms** | **1.0%** | | Cold section preparation + first render | 4,005 ms | 3,945 ms | 1.5% | Time reduction is `(Baseline - PR) / Baseline`. The drawing subtotal is calculated for each page as black-and-white drawing plus both grayscale passes, then summarized by its median. Each row is calculated independently, so stage medians need not sum to the total median. Grayscale drawing stages include strip uploads. These are elapsed firmware timings, not button-to-visible-page measurements. Cold preparation excludes earlier book metadata preparation. Per-pair total-render median reductions were **11.7-12.9% for Korean** and **1.0-1.1% for English**. The English benefit is concentrated on two of the six measured page positions: summed rendering time across all 18 samples decreases 7.0%, while the median matched per-page difference is 0 ms. The 1.0% headline compares the two firmware medians; these statistics describe different aspects of the same samples.
        Comparison with the original measurement series The original series compared `develop` at `8c84ef32` with the initial scheduling commit, `79b2d773`. It predates the glyph optimizations in #3633. The rerun above covers the complete PR on the updated baseline. | Metric | Original baseline → PR | Rebased baseline → PR | |---|---:|---:| | Korean total-render median | 1,623 → 1,416 ms (12.8%) | 1,476.5 → 1,287 ms (12.8%) | | Korean drawing subtotal median | 487 → 367.5 ms (24.5%) | 321.5 → 235.5 ms (26.7%) | | English total-render median | 1,238 → 1,214 ms (1.9%) | 1,160 → 1,148.5 ms (1.0%) | | English drawing subtotal median | 289 → 267 ms (7.6%) | 213.5 → 200.5 ms (6.1%) | The Korean relative improvement is reproduced; the English median improvement is smaller. Both the baseline and candidate now render faster in absolute terms. Cross-revision changes cannot be attributed solely to this PR, and percentage gains from separate PRs should not be added together.
        ### Resource impact - No new heap allocation, task, mutex, framebuffer or task-priority change. - Instrumented static RAM is unchanged at **57,424 bytes**. Application flash is **5,600,815 → 5,600,987 bytes (+172)**, including differing build identifiers. - Median post-page free heap is equal between baseline and PR: **45,512 bytes for Korean**, **111,200 bytes for English**. - Median largest free block is also equal: **25,588 bytes for Korean**, **102,388 bytes for English**. Korean baseline samples include both 21,492 and 25,588 bytes; candidate samples are 25,588. The cause of that variation is not established, and no fragmentation improvement is claimed. - Lowest since-boot free heap is **15,212 → 15,216 bytes for Korean** and **67,128 bytes on both builds for English**. These minima include startup and cold preparation. No memory-saving claim is made. ### Validation and limitations - Fresh instrumented baseline and candidate builds passed without compiler warnings. The repository formatting wrapper, diff whitespace check, frozen-image checks, and 26 host helper tests passed. Instrumentation and test helpers are outside the production diff. - All 12 boots completed the expected page sequences. All 12 paired checkpoint comparisons have identical black-and-white reading-area pixels. Full-frame differences, where present, are confined to the battery/charging footer. These captures do not establish grayscale-plane or physical-display equality. - No unexpected reset, panic, assertion or watchdog failure occurred during reading. Background pagination was observed on both builds; no logged power-mode transition overlapped the measured renders. - Every Korean boot logged the same low-heap CSS fallback during the measured window. Returning Home also logged inflate-window/JPEG thumbnail allocation failures on both builds, resulting in a placeholder cover for the test copy. The affected samples are retained in the results. - Both workloads logged a thumbnail-cache error before opening after the cache reset. Both builds also emitted the same early I²C probe errors. English cold-open-through-restoration windows had no error or low-heap CSS entries. - Two English screenshot transfers required a read-only retry outside the measured turns. Neither retry reset the device or discarded a measured boot; incomplete captures and timeout records were retained. The transfer stall remains unresolved. - Earlier testing found a settings-read failure that selected the wrong font and malformed entries in the original Demian cache. Those failures remain unresolved; the accepted comparisons use dedicated test copies with verified settings and font identity. - Coverage is these two workloads on X3 with background pagination active. Fully cached-only reading, physical-button latency, other devices/orientations, and release builds without instrumentation were not measured. The author's manual smoke test covered the initial scheduling commit; the rebased candidate was tested through serial control. - Original device firmware and saved SD metadata were restored and verified after testing. --- ### AI Usage **YES** — AI tools assisted with implementation, tests, performance analysis, and this PR description. The author also smoke-tested the initial scheduling firmware on hardware. --- src/activities/ActivityManager.cpp | 11 ++--- src/activities/RenderLock.h | 6 ++- src/activities/reader/EpubReaderActivity.cpp | 52 +++++++++++--------- src/activities/reader/EpubReaderActivity.h | 2 + src/main.cpp | 13 ++++- 5 files changed, 50 insertions(+), 34 deletions(-) diff --git a/src/activities/ActivityManager.cpp b/src/activities/ActivityManager.cpp index 1fb1b7b6289..bef68c5e738 100644 --- a/src/activities/ActivityManager.cpp +++ b/src/activities/ActivityManager.cpp @@ -426,15 +426,12 @@ void ActivityManager::requestUpdateAndWait() { // RenderLock -RenderLock::RenderLock() { - xSemaphoreTake(activityManager.renderingMutex, portMAX_DELAY); - isLocked = true; +RenderLock::RenderLock(Mode mode) { + isLocked = xSemaphoreTake(activityManager.renderingMutex, mode == Mode::Try ? 0 : portMAX_DELAY) == pdTRUE; + assert((mode == Mode::Try || isLocked) && "Blocking render lock acquisition failed"); } -RenderLock::RenderLock([[maybe_unused]] Activity&) { - xSemaphoreTake(activityManager.renderingMutex, portMAX_DELAY); - isLocked = true; -} +RenderLock::RenderLock(Activity&) : RenderLock(Mode::Blocking) {} RenderLock::~RenderLock() { if (isLocked) { diff --git a/src/activities/RenderLock.h b/src/activities/RenderLock.h index 5fb7c3df9eb..29127e49a81 100644 --- a/src/activities/RenderLock.h +++ b/src/activities/RenderLock.h @@ -7,11 +7,13 @@ class RenderLock { bool isLocked = false; public: - explicit RenderLock(); - explicit RenderLock(Activity&); // unused for now, but keep for compatibility + enum class Mode { Blocking, Try }; + explicit RenderLock(Mode mode = Mode::Blocking); + explicit RenderLock(Activity&); // Activity argument retained for compatibility. RenderLock(const RenderLock&) = delete; RenderLock& operator=(const RenderLock&) = delete; ~RenderLock(); + bool ownsLock() const { return isLocked; } void unlock(); static bool peek(); }; diff --git a/src/activities/reader/EpubReaderActivity.cpp b/src/activities/reader/EpubReaderActivity.cpp index 1e132a4700c..fa535b8e3ff 100644 --- a/src/activities/reader/EpubReaderActivity.cpp +++ b/src/activities/reader/EpubReaderActivity.cpp @@ -385,12 +385,11 @@ void EpubReaderActivity::loop() { } constexpr unsigned long IDLE_PREWARM_DEBOUNCE_MS = 400; - if (section && !section->isBuilding() && !RenderLock::peek() && renderer.hasFrameBuffer() && - lastRenderCompleteMs != 0 && millis() - lastRenderCompleteMs > IDLE_PREWARM_DEBOUNCE_MS && - ESP.getFreeHeap() > RENDER_MIN_FREE_HEAP && ESP.getMaxAllocHeap() > BACKGROUND_BUILD_MIN_MAX_ALLOC && - (idlePrewarmSpine != currentSpineIndex || idlePrewarmPage != section->currentPage)) { - RenderLock lock; - if (section && !section->isBuilding() && + { + RenderLock lock(RenderLock::Mode::Try); + if (lock.ownsLock() && section && !section->isBuilding() && renderer.hasFrameBuffer() && + lastRenderCompleteMs != 0 && millis() - lastRenderCompleteMs > IDLE_PREWARM_DEBOUNCE_MS && + ESP.getFreeHeap() > RENDER_MIN_FREE_HEAP && ESP.getMaxAllocHeap() > BACKGROUND_BUILD_MIN_MAX_ALLOC && (idlePrewarmSpine != currentSpineIndex || idlePrewarmPage != section->currentPage)) { idlePrewarmSpine = currentSpineIndex; idlePrewarmPage = section->currentPage; @@ -409,25 +408,25 @@ void EpubReaderActivity::loop() { } } - if (section && !section->isBuilding() && section->isPartial() && !RenderLock::peek() && buildViewportWidth > 0 && - !partialRebuildStartFailed && - section->currentPage + PARTIAL_REBUILD_START_MARGIN >= static_cast(section->pageCount)) { - RenderLock lock; - const ReaderRenderSpec buildSpec = SETTINGS.readerRenderSpec(buildViewportWidth, buildViewportHeight); - if (!section->startBuild(buildSpec)) { - partialRebuildStartFailed = true; - LOG_ERR("ERS", "Failed to start deferred partial extension build"); - } else { - LOG_DBG("ERS", "Reader near partial watermark (%d/%d), resuming extension build", section->currentPage, - section->pageCount); + { + RenderLock lock(RenderLock::Mode::Try); + if (lock.ownsLock() && section && !section->isBuilding() && section->isPartial() && buildViewportWidth > 0 && + !partialRebuildStartFailed && + section->currentPage + PARTIAL_REBUILD_START_MARGIN >= static_cast(section->pageCount)) { + const ReaderRenderSpec buildSpec = SETTINGS.readerRenderSpec(buildViewportWidth, buildViewportHeight); + if (!section->startBuild(buildSpec)) { + partialRebuildStartFailed = true; + LOG_ERR("ERS", "Failed to start deferred partial extension build"); + } else { + LOG_DBG("ERS", "Reader near partial watermark (%d/%d), resuming extension build", section->currentPage, + section->pageCount); + } } } - if (section && section->isBuilding() && !RenderLock::peek() && - (section->isPartial() || static_cast(section->pageCount) < section->currentPage + BUILD_WINDOW_AHEAD) && - buildTickHeapGate()) { - RenderLock lock; - if (section->isBuilding() && buildTickHeapGate()) { + { + RenderLock lock(RenderLock::Mode::Try); + if (lock.ownsLock() && backgroundBuildWanted() && buildTickHeapGate()) { if (!section->buildSomeMore(BACKGROUND_BUILD_PAGES_PER_TICK)) { LOG_ERR("ERS", "Background section build failed"); section.reset(); @@ -1132,11 +1131,16 @@ void EpubReaderActivity::onReturnFromEndOfBook() { } } -bool EpubReaderActivity::skipLoopDelay() { - return section && section->isBuilding() && !buildHeapPaused && +bool EpubReaderActivity::backgroundBuildWanted() const { + return section && section->isBuilding() && (section->isPartial() || static_cast(section->pageCount) < section->currentPage + BUILD_WINDOW_AHEAD); } +bool EpubReaderActivity::skipLoopDelay() { + // The main loop holds the render lock while querying this hint. + return !buildHeapPaused && backgroundBuildWanted(); +} + void EpubReaderActivity::renderBook() { currentPageLinks.clear(); if (!epub) return; diff --git a/src/activities/reader/EpubReaderActivity.h b/src/activities/reader/EpubReaderActivity.h index 8e27c47cc7f..a728476577c 100644 --- a/src/activities/reader/EpubReaderActivity.h +++ b/src/activities/reader/EpubReaderActivity.h @@ -116,6 +116,8 @@ class EpubReaderActivity final : public ReaderActivity { static constexpr int BACKGROUND_BUILD_PAGES_PER_TICK = 2; static constexpr size_t BACKGROUND_BUILD_MIN_FREE_HEAP = 32 * 1024; static constexpr size_t BACKGROUND_BUILD_MIN_MAX_ALLOC = 16 * 1024; + // Requires the render lock; heap admission is checked separately by the build tick. + bool backgroundBuildWanted() const; bool buildTickHeapGate(); bool buildHeapPaused = false; static constexpr size_t RENDER_MIN_FREE_HEAP = 24 * 1024; diff --git a/src/main.cpp b/src/main.cpp index d1eb028b374..c9094d4bb6c 100644 --- a/src/main.cpp +++ b/src/main.cpp @@ -799,10 +799,21 @@ void loop() { } } + bool skipLoopDelay = false; + { + RenderLock lock(RenderLock::Mode::Try); + if (!lock.ownsLock()) { + // Let rendering advance without treating lock contention as idle. + delay(10); + return; + } + skipLoopDelay = activityManager.skipLoopDelay(); + } + // Add delay at the end of the loop to prevent tight spinning // When an activity requests skip loop delay (e.g., webserver running), use yield() for faster response // Otherwise, use longer delay to save power - if (activityManager.skipLoopDelay()) { + if (skipLoopDelay) { powerManager.setPowerSaving(false); // Make sure we're at full performance when skipLoopDelay is requested yield(); // Give FreeRTOS a chance to run tasks, but return immediately } else { From 022dfed60ac45894a2ca322bb30d08fe274cd549 Mon Sep 17 00:00:00 2001 From: Uri Tauber Date: Sat, 26 Sep 2026 23:01:38 +0300 Subject: [PATCH 28/62] fix: Toolbar menu in landscape mode + TTF font sizes (#3748) ## Summary Two fixes in one PR: 1. Toolbar menu in landscape mode Before: ![before.bmp](https://github.com/user-attachments/files/32688541/before.bmp) After: ![after.bmp](https://github.com/user-attachments/files/32688545/after.bmp) 2. TTF font sizes. Extend the four sizes to fifteen. ![screenshot-28906.bmp](https://github.com/user-attachments/files/32688551/screenshot-28906.bmp) --- ### AI Usage While CrossPoint doesn't have restrictions on AI tools in contributing, please be transparent about their usage as it helps set the right context for reviewers. Did you use AI tools to help write this code? _**< PARTIALLY >**_ --- src/ReaderFontSizes.cpp | 9 ++++----- src/ReaderFontSizes.h | 13 ++++++++----- src/SdCardFontSystem.cpp | 6 +++--- src/activities/reader/ReaderToolbarUi.cpp | 21 ++++++++++++++------- 4 files changed, 29 insertions(+), 20 deletions(-) diff --git a/src/ReaderFontSizes.cpp b/src/ReaderFontSizes.cpp index b75acbb2f7c..d271d1e0c02 100644 --- a/src/ReaderFontSizes.cpp +++ b/src/ReaderFontSizes.cpp @@ -5,12 +5,11 @@ std::vector readerFontPointSizes(const SdCardFontRegistry* registry, const char* sdFamilyName) { if (registry && sdFamilyName && sdFamilyName[0] != '\0') { if (const auto* family = registry->findFamily(sdFamilyName)) { - // Vector (.ttf/.otf) fonts render at any size — offer the standard reader - // sizes instead of the placeholder size-0 entry in the family record. - if (!family->vector) { - auto sizes = family->availableSizes(); - if (!sizes.empty()) return sizes; + if (family->vector) { + return {std::begin(VECTOR_READER_POINT_SIZES), std::end(VECTOR_READER_POINT_SIZES)}; } + auto sizes = family->availableSizes(); + if (!sizes.empty()) return sizes; } } return {std::begin(BUILTIN_READER_POINT_SIZES), std::end(BUILTIN_READER_POINT_SIZES)}; diff --git a/src/ReaderFontSizes.h b/src/ReaderFontSizes.h index b0f240f7172..0b8a8dbdf2e 100644 --- a/src/ReaderFontSizes.h +++ b/src/ReaderFontSizes.h @@ -8,16 +8,19 @@ // Reader font size is stored as an actual point size (see CrossPointSettings:: // fontPointSize), not an abstract Small/Medium/Large slot. The selectable sizes -// therefore come from whichever family is active: the built-in set below, or the -// .cpfont files a user installed for an SD family. +// therefore come from whichever family is active: the built-in or vector set +// below, or the .cpfont files a user installed for an SD family. // The built-in Noto Serif / Noto Sans families are compiled in at exactly these // point sizes (see the global font objects in main.cpp). inline constexpr uint8_t BUILTIN_READER_POINT_SIZES[] = {12, 14, 16, 18}; -// Point sizes selectable for the active reader font, ascending: the SD family's -// installed sizes when `sdFamilyName` names one the registry knows, otherwise -// the built-in set. Never returns empty. +// Vector (.ttf/.otf) fonts offer every whole point size from 8 through 22. +inline constexpr uint8_t VECTOR_READER_POINT_SIZES[] = {8, 9, 10, 11, 12, 13, 14, 15, 16, 17, 18, 19, 20, 21, 22}; + +// Point sizes selectable for the active reader font, ascending: the vector set +// or installed .cpfont sizes for a known SD family, otherwise the built-in set. +// Never returns empty. std::vector readerFontPointSizes(const SdCardFontRegistry* registry, const char* sdFamilyName); // Closest entry in `sizes` (ascending, `count` > 0) to `pt`; ties resolve to the diff --git a/src/SdCardFontSystem.cpp b/src/SdCardFontSystem.cpp index a8a5099b134..3adbcfb731d 100644 --- a/src/SdCardFontSystem.cpp +++ b/src/SdCardFontSystem.cpp @@ -424,9 +424,9 @@ void SdCardFontSystem::setupTtfUiFallbacks(GfxRenderer& renderer) { void SdCardFontSystem::loadTtfFamily(const SdCardFontFamilyInfo& family, GfxRenderer& renderer, const bool registryWasDirty) { - // Vector fonts render at any size; snap the reader size into the standard set. - snapFontPointSizeTo(snapToNearestPointSize(BUILTIN_READER_POINT_SIZES, std::size(BUILTIN_READER_POINT_SIZES), - SETTINGS.fontPointSize)); + // Keep sizes inherited from other families within the selectable vector range. + snapFontPointSizeTo( + snapToNearestPointSize(VECTOR_READER_POINT_SIZES, std::size(VECTOR_READER_POINT_SIZES), SETTINGS.fontPointSize)); const uint8_t size = SETTINGS.fontPointSize; // Already loaded, same family + size, and disk unchanged → nothing to do. diff --git a/src/activities/reader/ReaderToolbarUi.cpp b/src/activities/reader/ReaderToolbarUi.cpp index c8b1c560335..b8d549eb7c2 100644 --- a/src/activities/reader/ReaderToolbarUi.cpp +++ b/src/activities/reader/ReaderToolbarUi.cpp @@ -38,6 +38,8 @@ constexpr int kToolCount = 3; constexpr int kPanelHeightPercent = 62; // Cap the sheet may grow to when rounding the list area up to a whole row. constexpr int kPanelHeightMaxPercent = 72; +// Landscape has less vertical room; leave a narrow page strip for tap-to-dismiss. +constexpr int kLandscapePanelHeightPercent = 88; } // namespace ReaderToolbarUi::ReaderToolbarUi(GfxRenderer& renderer) : UiAppHost(renderer) {} @@ -229,18 +231,22 @@ void ReaderToolbarUi::buildPanel(UiScreen& screen) { const int16_t titleH = screen.target().lineHeight(tokens.titleText.font); const int16_t rowH = model_.denseRows ? static_cast(UITheme::getInstance().getMetrics().listRowHeight) : tokens.rowHeight; - const int16_t rowStride = static_cast(rowH + tokens.listRowGap); + const int16_t rowGap = model_.denseRows ? tokens.listRowGap : std::max(tokens.listRowGap, tokens.listTouchRowGap); + const int16_t rowStride = static_cast(rowH + rowGap); const int16_t grabberBand = static_cast(sheetProps.grabberMargin + sheetProps.grabberHeight + sheetProps.grabberInset); const int16_t chrome = static_cast(grabberBand + titleH + tokens.spaceMd + tokens.spaceSm + kToolRowH + tokens.spaceSm); - const int16_t target = static_cast((safe.height * kPanelHeightPercent) / 100); - const int16_t cap = static_cast((safe.height * kPanelHeightMaxPercent) / 100); - int sheetRows = (target - chrome + tokens.listRowGap) / rowStride; - if (static_cast(chrome + (sheetRows + 1) * rowStride - tokens.listRowGap) <= cap) ++sheetRows; + const bool landscape = safe.width > safe.height; + const int16_t target = + static_cast((safe.height * (landscape ? kLandscapePanelHeightPercent : kPanelHeightPercent)) / 100); + const int16_t cap = + static_cast((safe.height * (landscape ? kLandscapePanelHeightPercent : kPanelHeightMaxPercent)) / 100); + int sheetRows = (target - chrome + rowGap) / rowStride; + if (static_cast(chrome + (sheetRows + 1) * rowStride - rowGap) <= cap) ++sheetRows; if (model_.itemCount > 0 && sheetRows > model_.itemCount) sheetRows = model_.itemCount; if (sheetRows < 1) sheetRows = 1; - screen.sheet(sheetProps, static_cast(chrome + sheetRows * rowStride - tokens.listRowGap)); + screen.sheet(sheetProps, static_cast(chrome + sheetRows * rowStride - rowGap)); // No blanket side inset: Screen::list() draws in the content band, and the // scroll track must reach the sheet's edge like a full-screen list's does. // The title insets itself; the rows inset via rowInset below. @@ -265,6 +271,7 @@ void ReaderToolbarUi::buildPanel(UiScreen& screen) { listProps_.action = ACTION_ROW; listProps_.inputMask = fui::InputTouch; // physical buttons stay with the reader listProps_.rowHeight = rowH; + listProps_.rowGap = rowGap; // The label column starts flush with the panel title (no list-side padding // on top of the sheet's own inset). Body-size text: small reads condensed // and the taller row doubles as the tap target. @@ -282,7 +289,7 @@ void ReaderToolbarUi::buildPanel(UiScreen& screen) { nav_.selected = std::clamp(model_.selectedIndex, -1, count - 1); nav_.followOnBuild = nav_.selected >= 0; nav_.followPending = false; - nav_.syncToProps(listRect, listProps_.rowHeight, tokens.listRowGap, count, listProps_); + nav_.syncToProps(listRect, listProps_.rowHeight, rowGap, count, listProps_); // Materialise only the visible window of rows. const int windowCount = std::min({nav_.visibleRows, count - nav_.top, kMaxWindow}); From a70ade72e7acfad43c4c366e5c1170214a21fb2b Mon Sep 17 00:00:00 2001 From: Ninos Yomo <32492083+SurayaAtouraya@users.noreply.github.com> Date: Sat, 26 Sep 2026 16:28:09 -0400 Subject: [PATCH 29/62] fix: don't reopen unreadable EPUB on wake (#3724) --- src/activities/reader/EpubReaderActivity.cpp | 5 +++++ src/activities/reader/ReaderActivity.cpp | 15 ++++++++++++++- src/activities/reader/ReaderActivity.h | 4 ++++ src/activities/reader/TxtReaderActivity.cpp | 11 ++++++++++- src/activities/reader/XtcReaderActivity.cpp | 2 ++ 5 files changed, 35 insertions(+), 2 deletions(-) diff --git a/src/activities/reader/EpubReaderActivity.cpp b/src/activities/reader/EpubReaderActivity.cpp index fa535b8e3ff..2bde8919bec 100644 --- a/src/activities/reader/EpubReaderActivity.cpp +++ b/src/activities/reader/EpubReaderActivity.cpp @@ -375,6 +375,8 @@ void EpubReaderActivity::loop() { return; } + rememberBookOnceRendered(); + // Someone else turned the screen while this reader was stacked (the control // center's orientation tile). Reflow before the next render, or the page // would be drawn with a layout built for the previous frame size. @@ -1156,6 +1158,8 @@ void EpubReaderActivity::renderBook() { const auto showBuildError = [this]() { renderer.clearScreen(); + const auto labels = mappedInput.mapLabels(tr(STR_BACK), "", "", ""); + GUI.drawButtonHints(renderer, labels.btn1, labels.btn2, labels.btn3, labels.btn4); GUI.drawPopup(renderer, tr(STR_INDEX_FAILED)); automaticPageTurnActive = false; }; @@ -1444,6 +1448,7 @@ void EpubReaderActivity::renderBook() { renderContents(std::move(p), orientedMarginTop, orientedMarginRight, orientedMarginBottom, orientedMarginLeft); LOG_DBG("ERS", "Rendered page in %dms", millis() - start); lastRenderCompleteMs = millis(); + markPageRendered(); } if (currentSpineIndex != lastSavedSpineIndex || section->currentPage != lastSavedPage || diff --git a/src/activities/reader/ReaderActivity.cpp b/src/activities/reader/ReaderActivity.cpp index aaf2b21970e..83ebfd610f7 100644 --- a/src/activities/reader/ReaderActivity.cpp +++ b/src/activities/reader/ReaderActivity.cpp @@ -60,6 +60,12 @@ void ReaderActivity::onEnter() { return; } + // Clear remembered book after opening it + if (!APP_STATE.openEpubPath.empty()) { + APP_STATE.openEpubPath.clear(); + APP_STATE.saveToFile(); + } + sdFontSystem.ensureLoaded(renderer); applyInitialOrientation(); @@ -68,10 +74,15 @@ void ReaderActivity::onEnter() { return; } + requestUpdate(); +} + +void ReaderActivity::rememberBookOnceRendered() { + if (bookRemembered || !pageRendered.load(std::memory_order_acquire)) return; + bookRemembered = true; APP_STATE.openEpubPath = bookPath; APP_STATE.saveToFile(); RECENT_BOOKS.addBook(bookPath, getBookTitle(), getBookAuthor(), getBookThumbBmpPath()); - requestUpdate(); } void ReaderActivity::onExit() { @@ -152,6 +163,7 @@ bool ReaderActivity::handleEndOfBookPageTurn(const bool prevTriggered, const boo } void ReaderActivity::loop() { + rememberBookOnceRendered(); clearEndOfBookOptionsIfNeeded(); if (handleEndOfBookMenu()) return; if (handleFormatInput()) return; @@ -200,6 +212,7 @@ void ReaderActivity::render(RenderLock&&) { } renderer.displayBuffer(); onEndOfBookRendered(); + markPageRendered(); return; } diff --git a/src/activities/reader/ReaderActivity.h b/src/activities/reader/ReaderActivity.h index 6862f9a4f40..0a2dfeff3e0 100644 --- a/src/activities/reader/ReaderActivity.h +++ b/src/activities/reader/ReaderActivity.h @@ -16,6 +16,10 @@ class ReaderActivity : public Activity { std::unique_ptr endOfBookOptions; std::atomic endOfBookOptionsReady{false}; + std::atomic pageRendered{false}; + bool bookRemembered = false; + void markPageRendered() { pageRendered.store(true, std::memory_order_release); } + void rememberBookOnceRendered(); explicit ReaderActivity(const char* name, GfxRenderer& renderer, MappedInputManager& mappedInput, std::string bookPath, bool allowFastInitialRefresh); diff --git a/src/activities/reader/TxtReaderActivity.cpp b/src/activities/reader/TxtReaderActivity.cpp index a746014894e..623b4a9a656 100644 --- a/src/activities/reader/TxtReaderActivity.cpp +++ b/src/activities/reader/TxtReaderActivity.cpp @@ -261,10 +261,19 @@ void TxtReaderActivity::renderBook() { size_t offset = pageOffsets[currentPage]; size_t nextOffset; currentPageLines.clear(); - loadPageAtOffset(renderer, offset, currentPageLines, nextOffset); + if (!loadPageAtOffset(renderer, offset, currentPageLines, nextOffset)) { + LOG_ERR("TRS", "Failed to load page at offset %zu", offset); + renderer.clearScreen(); + renderer.drawCenteredText(UI_12_FONT_ID, 300, tr(STR_PAGE_LOAD_ERROR), true, EpdFontFamily::BOLD); + const auto labels = mappedInput.mapLabels(tr(STR_BACK), "", "", ""); + GUI.drawButtonHints(renderer, labels.btn1, labels.btn2, labels.btn3, labels.btn4); + renderer.displayBuffer(); + return; + } renderer.clearScreen(); renderPage(renderer); + markPageRendered(); // Save progress saveProgress(); diff --git a/src/activities/reader/XtcReaderActivity.cpp b/src/activities/reader/XtcReaderActivity.cpp index 5b2218c4f5c..44bba17f706 100644 --- a/src/activities/reader/XtcReaderActivity.cpp +++ b/src/activities/reader/XtcReaderActivity.cpp @@ -255,6 +255,7 @@ void XtcReaderActivity::renderPage() { free(pageBuffer); LOG_DBG("XTR", "Rendered page %lu/%lu (2-bit grayscale)", currentPage + 1, xtc->getPageCount()); + markPageRendered(); return; } else { const size_t srcRowBytes = (pageWidth + 7) / 8; @@ -285,6 +286,7 @@ void XtcReaderActivity::renderPage() { ReaderUtils::displayWithRefreshCycle(renderer, pagesUntilFullRefresh); LOG_DBG("XTR", "Rendered page %lu/%lu (%u-bit)", currentPage + 1, xtc->getPageCount(), bitDepth); + markPageRendered(); } bool XtcReaderActivity::pageTurn(bool isForward) { From 4a6283db9c9692e059eaf2d57d5b7a36d85a2f4d Mon Sep 17 00:00:00 2001 From: Uri Tauber Date: Sun, 27 Sep 2026 02:25:10 +0300 Subject: [PATCH 30/62] fix: Remove article stripping logic (#3749) ## Summary Fix #3703. Suppress #3707 image Test epubs [crosspoint-3703-test-books.zip](https://github.com/user-attachments/files/32689451/crosspoint-3703-test-books.zip) --- ### AI Usage While CrossPoint doesn't have restrictions on AI tools in contributing, please be transparent about their usage as it helps set the right context for reviewers. Did you use AI tools to help write this code? _**< PARTIALLY >**_ --- docs/file-formats.md | 3 +- lib/LibraryIndex/LibraryBuilder.cpp | 2 +- lib/LibraryIndex/LibraryFormat.h | 4 +- lib/LibraryIndex/LibraryText.cpp | 18 +-------- lib/LibraryIndex/LibraryText.h | 5 +-- .../library/LibraryListActivity.cpp | 4 +- test/library_text/LibraryTextTest.cpp | 39 ++++++++++++------- 7 files changed, 33 insertions(+), 42 deletions(-) diff --git a/docs/file-formats.md b/docs/file-formats.md index 644db2f1d1d..8e0c4d2529c 100644 --- a/docs/file-formats.md +++ b/docs/file-formats.md @@ -433,6 +433,7 @@ modification time (when the file landed on the card); `firstSeen` — the build-assigned discovery counter — breaks ties and carries books whose filesystem reports no time. Fold version 3 introduced the timestamp key; a fold bump rebuilds ranks while preserving `firstSeen`. +Fold version 4 preserves leading articles in title sort and search keys. Sections are 512-byte aligned so each starts on an SD block boundary. @@ -442,7 +443,7 @@ A fixed stride is what lets the reader seek straight to record *n* without an offset table, and read a screenful in one 4 KB block. `static_assert` enforces it. Each record carries `fold[96]`, the title normalised for search and sorting — -accents stripped, case dropped, leading articles removed — and `authorKey[12]`, +accents stripped, case dropped, leading articles preserved — and `authorKey[12]`, the author's words folded and sorted so that "Victor Hugo" and "Hugo Victor" group as one person. `authorKey` is a GROUPING key, not an ordering one: the shelf orders by surname, derived separately from the display name. diff --git a/lib/LibraryIndex/LibraryBuilder.cpp b/lib/LibraryIndex/LibraryBuilder.cpp index 16538c14f6f..90b44b0b5aa 100644 --- a/lib/LibraryIndex/LibraryBuilder.cpp +++ b/lib/LibraryIndex/LibraryBuilder.cpp @@ -372,7 +372,7 @@ int findPrior(WalkState& st, const uint64_t pathHash) { // An absent author is a fact, not a gap to fill: the row joins the Unknown // group rather than borrowing a name from its surroundings. - const std::string folded = reuseMetadata ? std::string() : fold(title, true); + const std::string folded = reuseMetadata ? std::string() : fold(title); const std::string key = reuseMetadata ? std::string() : authorKey(author); entry.record.fileSize = fileSize; diff --git a/lib/LibraryIndex/LibraryFormat.h b/lib/LibraryIndex/LibraryFormat.h index b2a81a1b35b..2431d0cf2c7 100644 --- a/lib/LibraryIndex/LibraryFormat.h +++ b/lib/LibraryIndex/LibraryFormat.h @@ -30,10 +30,10 @@ inline constexpr char CLIX_MAGIC[4] = {'C', 'L', 'X', '1'}; // validation and is rebuilt. No previous development format is accepted. inline constexpr uint8_t CLIX_FORMAT_VERSION = 2; -// Bump when the fold, the article table, or a permutation's sort key changes. +// Bump when the fold or a permutation's sort key changes. // Forces fold and ranks to be rebuilt while firstSeen values are preserved, so // arrival history survives. -inline constexpr uint8_t CLIX_FOLD_VERSION = 3; +inline constexpr uint8_t CLIX_FOLD_VERSION = 4; inline constexpr uint32_t CLIX_ALIGN = 512; inline constexpr size_t CLIX_FOLD_BYTES = 96; diff --git a/lib/LibraryIndex/LibraryText.cpp b/lib/LibraryIndex/LibraryText.cpp index 5b6278cd4b2..9fb9a85a973 100644 --- a/lib/LibraryIndex/LibraryText.cpp +++ b/lib/LibraryIndex/LibraryText.cpp @@ -3,7 +3,6 @@ #include #include -#include namespace library { @@ -143,12 +142,6 @@ bool isUnicodeLetter(const uint32_t cp) { return inRanges(cp, LETTER_RANGES, sizeof(LETTER_RANGES) / sizeof(LETTER_RANGES[0])); } -// Articles stripped from the head of sort and search keys. Display text never -// goes through this. -constexpr const char* ARTICLES[] = {"the ", "a ", "an ", "le ", "la ", "les ", "l'", "un ", - "une ", "de ", "du ", "des ", "der ", "die ", "das ", "el ", - "los ", "las ", "il ", "lo ", "gli ", "i ", "o ", "os "}; - // Views into `folded`, not copies: the caller keeps that string alive for as // long as the tokens, and a std::string per token costs an allocation each plus // 24 bytes of stack apiece -- 288 B for the twelve, over the 256 B this repo @@ -173,7 +166,7 @@ bool isSingleCodepoint(const std::string_view text) { } // namespace -std::string fold(const std::string_view text, const bool stripArticle) { +std::string fold(const std::string_view text) { std::string out; out.reserve(text.size()); @@ -226,15 +219,6 @@ std::string fold(const std::string_view text, const bool stripArticle) { if (!out.empty()) pendingSpace = true; } - if (stripArticle) { - for (const char* article : ARTICLES) { - const size_t len = strlen(article); - if (out.size() > len && out.compare(0, len, article) == 0) { - out.erase(0, len); - break; - } - } - } return out; } diff --git a/lib/LibraryIndex/LibraryText.h b/lib/LibraryIndex/LibraryText.h index 4405fc64771..90928cf6981 100644 --- a/lib/LibraryIndex/LibraryText.h +++ b/lib/LibraryIndex/LibraryText.h @@ -48,10 +48,7 @@ std::string joinLibraryPath(std::string_view folder, std::string_view name); // numbers, and turns punctuation into a single space. Combining marks are // dropped. Apostrophes survive as ASCII '\'' so names and elisions keep their // shape. -// -// `stripArticle` additionally removes one leading article ("the ", "le ", "la ", -// ...) — correct for sort keys and search text, wrong for anything displayed. -std::string fold(std::string_view text, bool stripArticle = false); +std::string fold(std::string_view text); // First letter of an already-folded sort key, or 0 when the key starts with a // number/non-letter. The Library renders 0 as its shared '#' group. diff --git a/src/activities/library/LibraryListActivity.cpp b/src/activities/library/LibraryListActivity.cpp index ac12d4775d1..a0b69f2e21f 100644 --- a/src/activities/library/LibraryListActivity.cpp +++ b/src/activities/library/LibraryListActivity.cpp @@ -612,9 +612,7 @@ void LibraryListActivity::applyFilter() { headerSearchTitle = query.empty() ? std::string() : "“" + query + "”"; if (query.empty()) return; - // Folded the same way the stored folds were, articles removed included — - // otherwise "the hobbit" searches for a word no record contains. - const std::string needle = library::fold(query, /*stripArticle=*/true); + const std::string needle = library::fold(query); const int total = static_cast(index.bookCount()); if (total <= 0) return; diff --git a/test/library_text/LibraryTextTest.cpp b/test/library_text/LibraryTextTest.cpp index 60f5f585864..d82fc905f66 100644 --- a/test/library_text/LibraryTextTest.cpp +++ b/test/library_text/LibraryTextTest.cpp @@ -1,6 +1,7 @@ #include #include +#include #include "LibraryText.h" @@ -95,22 +96,32 @@ TEST(LibraryFold, PreservesHebrewLettersAndDropsNiqqud) { } TEST(LibraryFold, GroupInitialUsesUnicodeLettersAndBucketsNumbers) { - EXPECT_EQ(library::foldedGroupInitial(fold("Alpha", true)), static_cast('a')); - EXPECT_EQ(library::foldedGroupInitial(fold("\u05E9\u05DC\u05D5\u05DD", true)), 0x05E9u); - EXPECT_EQ(library::foldedGroupInitial(fold("\u041A\u043D\u0438\u0433\u0430", true)), 0x041Au); - EXPECT_EQ(library::foldedGroupInitial(fold("\u4E66", true)), 0x4E66u); - EXPECT_EQ(library::foldedGroupInitial(fold("2085", true)), 0u); - EXPECT_EQ(library::foldedGroupInitial(fold("\u0662\u0660\u0668\u0665", true)), 0u); - EXPECT_EQ(library::foldedGroupInitial(fold("!!!", true)), 0u); + EXPECT_EQ(library::foldedGroupInitial(fold("Alpha")), static_cast('a')); + EXPECT_EQ(library::foldedGroupInitial(fold("\u05E9\u05DC\u05D5\u05DD")), 0x05E9u); + EXPECT_EQ(library::foldedGroupInitial(fold("\u041A\u043D\u0438\u0433\u0430")), 0x041Au); + EXPECT_EQ(library::foldedGroupInitial(fold("\u4E66")), 0x4E66u); + EXPECT_EQ(library::foldedGroupInitial(fold("2085")), 0u); + EXPECT_EQ(library::foldedGroupInitial(fold("\u0662\u0660\u0668\u0665")), 0u); + EXPECT_EQ(library::foldedGroupInitial(fold("!!!")), 0u); } -TEST(LibraryFold, ArticleStrippingOnlyWhenAsked) { - EXPECT_EQ(fold("The Iliad"), "the iliad"); - EXPECT_EQ(fold("The Iliad", true), "iliad"); - EXPECT_EQ(fold("Les Mis\xC3\xA9rables", true), "miserables"); - EXPECT_EQ(fold("L\xE2\x80\x99\xC3\x89n\xC3\xA9ide", true), "eneide"); - // A title that IS an article-like word must not vanish. - EXPECT_EQ(fold("The", true), "the"); +TEST(LibraryFold, LeadingWordsArePreservedForSortingAndSearch) { + for (const auto& [title, expected] : { + std::pair{"The Iliad", "the iliad"}, + std::pair{"A Tale of Two Cities", "a tale of two cities"}, + std::pair{"An Ideal Husband", "an ideal husband"}, + std::pair{"I Am Number Four", "i am number four"}, + std::pair{"I, Robot", "i robot"}, + std::pair{"O Pioneers!", "o pioneers"}, + std::pair{"Die Trying", "die trying"}, + std::pair{"Les Misérables", "les miserables"}, + std::pair{"L’Énéide", "l'eneide"}, + std::pair{"The", "the"}, + }) { + const std::string folded = fold(title); + EXPECT_EQ(folded, expected) << title; + EXPECT_EQ(library::foldedGroupInitial(folded), static_cast(expected[0])) << title; + } } TEST(LibraryAuthorKey, OrderAndPunctuationDoNotMatter) { From 6410894cdaf5d7ac57e977cd744f3e73e28339bd Mon Sep 17 00:00:00 2001 From: Uri Tauber Date: Sun, 27 Sep 2026 09:48:35 +0300 Subject: [PATCH 31/62] chore: Update translations (#3396) Co-authored-by: Rodrigo Lanes Co-authored-by: Leopoldo Pla Sempere Co-authored-by: winst0niuss Co-authored-by: Spigaw <73850535+Spigaw@users.noreply.github.com> Co-authored-by: Leopoldo Pla Co-authored-by: Matteo Scopel Co-authored-by: brianhuster Co-authored-by: Rodrigo Matsuura Co-authored-by: BlueDragon Co-authored-by: Tiago <66181828+tiagofilipenunes@users.noreply.github.com> --- docs/translators.md | 1 + lib/I18n/translations/catalan.yaml | 63 +- lib/I18n/translations/french.yaml | 44 +- lib/I18n/translations/german.yaml | 885 ++++++++++++----------- lib/I18n/translations/hebrew.yaml | 63 ++ lib/I18n/translations/italian.yaml | 61 ++ lib/I18n/translations/portuguese-BR.yaml | 85 ++- lib/I18n/translations/portuguese-PT.yaml | 89 ++- lib/I18n/translations/russian.yaml | 63 +- lib/I18n/translations/spanish.yaml | 65 +- lib/I18n/translations/valencian.yaml | 63 +- lib/I18n/translations/vietnamese.yaml | 94 ++- 12 files changed, 1135 insertions(+), 441 deletions(-) diff --git a/docs/translators.md b/docs/translators.md index a5592bd15bf..71af64b35dd 100644 --- a/docs/translators.md +++ b/docs/translators.md @@ -21,6 +21,7 @@ If you'd like to add your name to this list, please open a PR adding yourself an ## Portuguese (Brazil) - [yagofarias](https://github.com/yagofarias) +- [Rodrigo-Matsuura](https://github.com/Rodrigo-Matsuura) ## Portuguese (Portugal) - [victordomingos](https://github.com/victordomingos) diff --git a/lib/I18n/translations/catalan.yaml b/lib/I18n/translations/catalan.yaml index 2154ecf7670..dd1ba265d0c 100644 --- a/lib/I18n/translations/catalan.yaml +++ b/lib/I18n/translations/catalan.yaml @@ -132,6 +132,27 @@ STR_PARA_ALIGNMENT: "Alineació de paràgraf" STR_HYPHENATION: "Partició de mots" STR_TIME_TO_SLEEP: "Temps per entrar en repòs" STR_SHOW_HIDDEN_FILES: "Mostra fitxers ocults" +STR_LIBRARY: "Biblioteca" +STR_LIBRARY_EMPTY: "No s'han trobat llibres a la targeta SD" +STR_LIBRARY_NO_RESULTS: "Cap llibre coincideix amb la cerca" +STR_LIBRARY_SEARCH_UNAVAILABLE: "Cerca no disponible: falta memòria" +STR_LIBRARY_UNKNOWN_TITLE: "(sense títol)" +STR_LIBRARY_UNKNOWN_AUTHOR: "Autor desconegut" +STR_LIBRARY_TITLE_UNSORTED: "Biblioteca (sense ordenar)" +STR_LIBRARY_TAB_RECENT: "Recents" +STR_LIBRARY_TAB_TITLE: "Títol" +STR_LIBRARY_TAB_AUTHOR: "Autor" +STR_LIBRARY_SEARCH: "Cerca" +STR_LIBRARY_HOLD_SORT: "Mantén: ordre invers" +STR_LIBRARY_HOLD_GROUPS: "Mantén: grups" +STR_LIBRARY_HOLD_OPTIONS: "Mantén: opcions" +STR_LIBRARY_POSITION: "%d/%d llibres" +STR_LIBRARY_GROUP_POSITION: "%d/%d grups" +STR_LIBRARY_USE_METADATA: "Usa les metadades dels llibres" +STR_LIBRARY_REBUILD: "Actualitza la biblioteca" +STR_LIBRARY_REBUILDING: "S'estan indexant els llibres..." +STR_LIBRARY_REBUILD_DONE: "Índex de la biblioteca reconstruït" +STR_LIBRARY_REBUILD_FAILED: "No s'ha pogut reconstruir l'índex" STR_REMOVE_READ_FROM_RECENTS: "Esborra els llibres llegits de la llista de recents" STR_MOVE_FINISHED_TO_READ: "Mou els llibres acabats a la carpeta Read" STR_REFRESH_FREQ: "Freqüència d'actualització" @@ -196,6 +217,14 @@ STR_ORIENTATION_INVERTED: "Vertical 180°" STR_LANDSCAPE_CCW: "Horitzontal antihorari" STR_PREV_NEXT: "Anterior/Següent" STR_NEXT_PREV: "Següent/Anterior" +STR_NEXT_NEXT: "Següent/Següent" +STR_PREV_PREV: "Anterior/Anterior" +STR_NEXT_PAGE_GESTURE: "Pàgina següent" +STR_PREV_PAGE_GESTURE: "Pàgina anterior" +STR_TAP_AND_SWIPE: "Toc i lliscament" +STR_TAP_ONLY: "Només toc" +STR_SWIPE_ONLY: "Només lliscament" +STR_INVERTED_TAP: "Toc invertit" STR_KOSYNC: "KOSync" STR_BOOKMARK_OPTION: "Punt de llibre" STR_DICTIONARY: "Diccionari" @@ -232,6 +261,7 @@ STR_DOWNLOAD_FAILED: "Ha fallat la baixada" STR_ERROR_MSG: "Error:" STR_UNNAMED: "Sense nom" STR_HOLD_OPEN_TO_DELETE: "Mantén premut Obre per esborrar" +STR_HOLD_OPEN_TO_REMOVE: "Mantén Obre: treu" STR_NO_SERVER_URL: "No s'ha configurat cap URL de servidor" STR_FETCH_FEED_FAILED: "Ha fallat l'obtenció del canal de continguts" STR_PARSE_FEED_FAILED: "Ha fallat l'anàlisi del canal de continguts" @@ -309,7 +339,7 @@ STR_COVER_CUSTOM: "Portada + Personalitzat" STR_QUICK_RESUME: "Represa ràpida" STR_TRANSPARENT: "Transparent" STR_MENU_RECENT_BOOKS: "Llibres recents" -STR_REMOVE_FROM_RECENTS: "Vols suprimir-lo de Llibres recents?" +STR_REMOVE_FROM_RECENTS: "Treu de recents" STR_CALIBRE_DESC: "Usa les transferències sense fils de Calibre" STR_FORGET_AND_REMOVE: "Vols oblidar la xarxa i suprimir la contrasenya desada?" STR_FORGET_BUTTON: "Oblida" @@ -398,6 +428,7 @@ STR_CLOCK_SYNC_NO_WIFI: "Wi-Fi no connectat" STR_CLOCK_SYNC_NO_WIFI_HINT: "Connecta't al Wi-Fi primer i torna-ho a provar." STR_CLOCK_SYNCED: "Rellotge sincronitzat" STR_THEME_ROUNDEDRAFF: "RoundedRaff" +STR_THEME_COVER_GRID: "Graella de portades" STR_SEARCH: "Cerca" STR_SET_SLEEP_COVER: "Fixa fons" STR_ADD_SERVER: "Afegeix servidor" @@ -419,6 +450,7 @@ STR_NO_FONTS_AVAILABLE: "No hi ha fonts disponibles" STR_FONT_PREVIEW_TEXT: "Jove xef, porti whisky amb quinze glaçons d'hidrogen, coi!" STR_FONT_INSTALLED: "Font instal·lada!" STR_FONT_INSTALL_FAILED: "Ha fallat la instal·lació de la font" +STR_INVALID_FONT_MANIFEST: "Manifest de fonts no vàlid" STR_INSTALLED: "Instal·lat" STR_DOWNLOAD_ALL: "Descarrega-ho tot" STR_UPDATE_ALL: "Actualitza-ho tot" @@ -458,3 +490,32 @@ STR_RECOVERY_MODE: "Mode de recuperació" STR_RECOVERY_MODE_HINT: "Posa firmware.bin a l'arrel de la targeta SD i selecciona'l" STR_ADD_HIDDEN_NETWORK: "Afegeix una xarxa oculta..." STR_ENTER_WIFI_SSID: "Introdueix el nom de la xarxa (SSID)" +STR_DBL_CLICK_PWR_LIGHT: "Doble pulsació d'encesa: llum" +STR_ABOUT: "Quant a" +STR_HOLD_OPEN_FOR_ACTIONS: "Mantén Obre: opcions" +STR_TIMEZONE: "Fus horari" +STR_CLOCK_IN_HEADER: "Mostra a la capçalera" +STR_CLOCK_DST: "Horari d'estiu" +STR_CLOCK_DST_AUTO: "Automàtic" +STR_RENAME: "Canvia el nom" +STR_HOME_SHORTCUT: "Inici" +STR_HOME_BUTTON: "Gestos del botó d'inici" +STR_CONFIGURE: "Configura" +STR_HOME_BUTTON_TAP: "Pulsació" +STR_HOME_BUTTON_DOUBLE_TAP: "Doble pulsació" +STR_HOME_BUTTON_LONG_PRESS: "Pulsació llarga" +STR_TOGGLE_FRONTLIGHT: "Encén/apaga la llum frontal" +STR_WORD_SPACING: "Espai entre paraules" +STR_CHARACTER_SPACING: "Espai entre caràcters" +STR_SPACING_MINUS_2: "-2 px" +STR_SPACING_MINUS_1: "-1 px" +STR_SPACING_ZERO: "0 px" +STR_SPACING_PLUS_1: "+1 px" +STR_SPACING_PLUS_2: "+2 px" +STR_SPACING_50_PERCENT: "50%" +STR_SPACING_75_PERCENT: "75%" +STR_SPACING_100_PERCENT: "100%" +STR_SPACING_125_PERCENT: "125%" +STR_SPACING_150_PERCENT: "150%" +STR_SPACING_175_PERCENT: "175%" +STR_SPACING_200_PERCENT: "200%" diff --git a/lib/I18n/translations/french.yaml b/lib/I18n/translations/french.yaml index 268590c1615..b3e94b7563f 100644 --- a/lib/I18n/translations/french.yaml +++ b/lib/I18n/translations/french.yaml @@ -16,6 +16,9 @@ STR_NO_OPEN_BOOK: "Aucun livre ouvert" STR_START_READING: "Commencer la lecture" STR_NO_FILES_FOUND: "Aucun fichier trouvé" STR_SELECT_CHAPTER: "Choix du chapitre" +STR_TOOL_CONTENTS: "Sommaire" +STR_TOOL_TEXT: "Texte" +STR_TOOL_MORE: "Plus" STR_NO_CHAPTERS: "Aucun chapitre" STR_END_OF_BOOK: "Fin du livre" STR_EMPTY_CHAPTER: "Chapitre vide" @@ -58,6 +61,7 @@ STR_USB_DRIVE_DESC: "Gérez la carte SD depuis l'ordinateur" STR_USB_DRIVE_PREPARING: "Préparation du lecteur USB..." STR_USB_DRIVE_WAITING: "Connectez la liseuse à l'ordinateur" STR_USB_DRIVE_CONNECTED: "Lecteur USB connecté" +STR_USB_DRIVE_CONNECT_DELAY: "La connexion peut prendre jusqu'à 30 secondes" STR_USB_DRIVE_EJECT_HINT: "Éjectez le lecteur sur l'ordinateur ou débranchez le câble pour revenir à l'accueil." STR_USB_DRIVE_ERROR: "Erreur du lecteur USB. Débranchez le câble pour revenir à l'accueil." STR_USB_DRIVE_START_ERROR: "Impossible de démarrer le lecteur USB. Appuyez sur Retour pour l'accueil." @@ -88,6 +92,7 @@ STR_CAT_READER: "Lecteur" STR_CAT_CONTROLS: "Commandes" STR_CAT_SYSTEM: "Système" STR_SLEEP_SCREEN: "Écran de veille" +STR_TOUCH_TOGGLE: "Tactile" STR_NIGHT_MODE: "Mode nuit" STR_QUICK_RESUME_TIMEOUT: "Reprise rapide après délai" STR_SLEEP_COVER_MODE: "Mode écran de veille" @@ -101,9 +106,11 @@ STR_IMAGES_SUPPRESS: "Masquer" STR_EOB_HOME: "Accueil" STR_EOB_CONTINUE_WITH: "Poursuivre avec" STR_SHORT_PWR_BTN: "Appui court bouton alim." +STR_DBL_CLICK_PWR_LIGHT: "Double-clic Power pour l'éclairage" STR_ORIENTATION: "Orientation de lecture" STR_SIDE_BTN_LAYOUT: "Boutons latéraux (lecteur)" STR_TOUCH_READER_CONTROLS: "Commandes tactiles lecteur" +STR_SHOW_READER_MENU: "Afficher le menu de lecture" STR_FRONT_BTN_FOLLOW_ORIENTATION: "Orienter boutons avant" STR_LONG_PRESS_BEHAVIOR: "Comportement lors d'un appui long" STR_LONG_PRESS_BEHAVIOR_OFF: "Désactivé" @@ -119,6 +126,9 @@ STR_SIZE: "Taille" STR_LAYOUT: "Disposition" STR_ALIGNMENT: "Alignement" STR_STYLE: "Style" +STR_READER_MENU_STYLE: "Style du menu de lecture" +STR_MENU_STYLE_LIST: "Liste" +STR_MENU_STYLE_TOOLBAR: "Barre d'outils" STR_NOT_IN_PREVIEW: "Absent de l'aperçu" STR_LINE_SPACING: "Interligne" STR_SCREEN_MARGIN: "Marges" @@ -137,7 +147,7 @@ STR_LIBRARY_TAB_RECENT: "Récents" STR_LIBRARY_TAB_TITLE: "Titre" STR_LIBRARY_TAB_AUTHOR: "Auteur" STR_LIBRARY_SEARCH: "Rechercher" -STR_LIBRARY_HOLD_SEARCH: "Maintenir : Recherche" +STR_LIBRARY_HOLD_SORT: "Maintenir : Inverser le tri" STR_LIBRARY_HOLD_GROUPS: "Maintenir : Groupes" STR_LIBRARY_POSITION: "%d/%d livres" STR_LIBRARY_GROUP_POSITION: "%d/%d groupes" @@ -153,6 +163,7 @@ STR_KOREADER_SYNC: "Synchro KOReader" STR_CHECK_UPDATES: "Mise à jour" STR_LANGUAGE: "Langue" STR_KEYBOARD_LAYOUTS: "Dispositions du clavier" +STR_ABOUT: "À propos" STR_CLEAR_READING_CACHE: "Vider le cache de lecture" STR_USERNAME: "Nom d’utilisateur" STR_PASSWORD: "Mot de passe" @@ -214,6 +225,7 @@ STR_NEXT_PREV: "Suiv/Préc" STR_KOSYNC: "KOSync" STR_BOOKMARK_OPTION: "Marque-page" STR_DICTIONARY: "Dictionnaire" +STR_READER_MENU: "Menu de lecture" STR_DISABLED: "Désactivé" STR_NOTO_SERIF: "Noto Serif" STR_NOTO_SANS: "Noto Sans" @@ -247,6 +259,8 @@ STR_DOWNLOAD_FAILED: "Échec du téléchargement" STR_ERROR_MSG: "Erreur : " STR_UNNAMED: "Sans titre" STR_HOLD_OPEN_TO_DELETE: "Maintenir Ouvrir pour supprimer" +STR_HOLD_OPEN_TO_REMOVE: "Maintenir Ouvrir pour retirer" +STR_HOLD_OPEN_FOR_ACTIONS: "Maintenir Ouvrir pour les actions" STR_NO_SERVER_URL: "URL serveur non configurée" STR_FETCH_FEED_FAILED: "Échec récupération flux" STR_PARSE_FEED_FAILED: "Échec analyse flux" @@ -278,6 +292,14 @@ STR_SHOW: "Afficher" STR_HIDE: "Masquer" STR_STATE_ON: "ON" STR_STATE_OFF: "OFF" +STR_STATE_TAP: "Appui" +STR_STATE_SWIPE: "Glissement" +STR_STATE_SWIPE_UP: "Glissement vers le haut" +STR_STATE_INVERTED_TAP: "Appui inversé" +STR_FRONTLIGHT: "Éclairage" +STR_BRIGHTNESS: "Luminosité" +STR_WARMTH: "Chaleur" +STR_RESTORE_LIGHT_ON_WAKE: "Rétablir l'éclairage au réveil" STR_NOT_SET: "Non défini" STR_DIR_LEFT: "Gauche" STR_DIR_RIGHT: "Droite" @@ -311,6 +333,10 @@ STR_CLOCK: "Horloge" STR_CLOCK_FORMAT: "Format de l'heure" STR_CLOCK_FORMAT_24H: "24 heures" STR_CLOCK_FORMAT_12H: "12 heures" +STR_TIMEZONE: "Fuseau horaire" +STR_CLOCK_IN_HEADER: "Afficher dans l'en-tête" +STR_CLOCK_DST: "Heure d'été" +STR_CLOCK_DST_AUTO: "Auto" STR_CURRENT_TIME: "Heure actuelle :" STR_CLOCK_SYNC: "Synchro horloge" STR_CLOCK_SYNC_NOW: "Synchroniser l'heure" @@ -328,12 +354,14 @@ STR_THEME_LYRA_EXTENDED: "Lyra Extended" STR_SUNLIGHT_FADING_FIX: "Correction lisibilité au soleil" STR_REMAP_FRONT_BUTTONS: "Configurer boutons façade" STR_BOOKMARKS: "Marque-pages" +STR_NO_BOOKMARKS: "Aucun marque-page" STR_BOOKMARK_ADDED: "Marque-page ajouté." STR_BOOKMARK_REMOVED: "Marque-page supprimé." STR_OPDS_BROWSER: "Navigateur OPDS" STR_SEARCH: "Rechercher" STR_COVER_CUSTOM: "Couverture + Perso" STR_QUICK_RESUME: "Reprise rapide" +STR_TRANSPARENT: "Transparent" STR_MENU_RECENT_BOOKS: "Livres récents" STR_REMOVE_FROM_RECENTS: "Retirer des Livres récents ?" STR_CALIBRE_DESC: "Utiliser transfert sans fil Calibre" @@ -358,6 +386,7 @@ STR_GO_HOME_BUTTON: "Retour Accueil" STR_SYNC_PROGRESS: "Synchro progression" STR_DELETE_CACHE: "Supprimer cache livre" STR_DELETE: "Supprimer" +STR_RENAME: "Renommer" STR_CONFIRM_DELETE_BOOKMARK: "Supprimer ce marque-page ?" STR_DISPLAY_QR: "Afficher la page en QR" STR_CHAPTER_PREFIX: "Chapitre : " @@ -416,10 +445,12 @@ STR_AUTO_TURN_ENABLED: "Tourne-page auto : " STR_AUTO_TURN_PAGES_PER_MIN: "Tourne-page auto (pages par minute)" STR_MANAGE_FONTS: "Gérer les polices" STR_FONT_BROWSER: "Navigateur de polices" +STR_ALL_FONTS: "Toutes les polices" STR_LOADING_FONT_LIST: "Chargement des polices…" STR_NO_FONTS_AVAILABLE: "Aucune police disponible" STR_FONT_INSTALLED: "Police installée !" STR_FONT_INSTALL_FAILED: "Échec installation police" +STR_INVALID_FONT_MANIFEST: "Manifeste de police invalide" STR_INSTALLED: "Installée" STR_DOWNLOAD_ALL: "Tout télécharger" STR_UPDATE_ALL: "Tout mettre à jour" @@ -451,9 +482,18 @@ STR_VALIDATING_FIRMWARE: "Validation du firmware…" STR_INVALID_FIRMWARE: "Fichier firmware invalide" STR_FIRMWARE_TOO_LARGE: "Firmware trop volumineux pour la partition" STR_FIRMWARE_TOO_SMALL: "Fichier firmware trop petit" +STR_FIRMWARE_WRONG_DEVICE: "Firmware destiné à un autre appareil" STR_FIRMWARE_UPDATE_PROMPT: "Mettre à jour le firmware ?" STR_FIRMWARE_FILE_OPEN_FAILED: "Impossible d'ouvrir le fichier" STR_FIRMWARE_WRITE_FAILED: "Échec de l'écriture du firmware" STR_FIRMWARE_UPDATE_DO_NOT_POWER_OFF: "Ne pas éteindre l'appareil !" -STR_RECOVERY_MODE: "Mode Récupération" +STR_RECOVERY_MODE: "Mode récupération" STR_RECOVERY_MODE_HINT: "Placez firmware.bin à la racine de la carte SD et sélectionnez-le" + +STR_HOME_SHORTCUT: "Accueil" +STR_HOME_BUTTON: "Gestes du bouton Accueil" +STR_CONFIGURE: "Configurer" +STR_HOME_BUTTON_TAP: "Appui simple" +STR_HOME_BUTTON_DOUBLE_TAP: "Double appui" +STR_HOME_BUTTON_LONG_PRESS: "Appui long" +STR_TOGGLE_FRONTLIGHT: "Basculer l'éclairage" diff --git a/lib/I18n/translations/german.yaml b/lib/I18n/translations/german.yaml index e054abbb2eb..2f1593014ba 100644 --- a/lib/I18n/translations/german.yaml +++ b/lib/I18n/translations/german.yaml @@ -1,472 +1,519 @@ _language_name: "Deutsch" +_bcp47: "de" _language_code: "DE" _order: "3" -_bcp47: "de" - -STR_CROSSPOINT: "CrossPoint" +STR_ABOUT: "Über" +STR_ACCOUNT_CREATED: "Konto erstellt" +STR_ADD_HIDDEN_NETWORK: "Verstecktes Netzwerk hinzufügen…" +STR_ADD_SERVER: "Server hinzufügen" +STR_ALIGN_LEFT: "Links" +STR_ALIGN_RIGHT: "Rechts" +STR_ALIGNMENT: "Ausrichtung" +STR_ALL_FONTS: "Alle Schriftarten" +STR_ALREADY_ASSIGNED: "Bereits zugeordnet" +STR_ALREADY_SYNCED: "Bereits synchronisiert" +STR_ALWAYS: "Immer" +STR_APPLY_REMOTE: "Externen Fortschritt übernehmen" +STR_ASK_EVERY_TIME: "Immer fragen" +STR_AUTH_FAILED: "Authentifizierung fehlg." +STR_AUTH_SUCCESS: "Erfolgreich authentifiziert!" +STR_AUTHENTICATE: "Authentifizieren" +STR_AUTHENTICATING: "Authentifizieren…" +STR_AUTO_TURN_ENABLED: "Auto-Umblättern aktiv: " +STR_AUTO_TURN_PAGES_PER_MIN: "Auto-Umblättern (Seiten/Min.)" +STR_BACK_SHORT_TO_FILE_BROWSER: "Kurz zurück drücken zum Datei-Browser" +STR_BACK: "« Zurück" +STR_BATTERY: "Batterie" +STR_BINARY: "Binärdatei" +STR_BOOK_PREFIX: "Buch: " +STR_BOOK_PROGRESS_PERCENTAGE: "Buchfortschritt in %" +STR_BOOK_S_STYLE: "Buch-Stil" +STR_BOOK: "Buch" +STR_BOOKMARK_ADDED: "Lesezeichen hinzugefügt." +STR_BOOKMARK_OPTION: "Lesezeichen" +STR_BOOKMARK_REMOVED: "Lesezeichen entfernt." +STR_BOOKMARKS: "Lesezeichen" STR_BOOTING: "STARTE" -STR_SLEEPING: "STANDBY" -STR_ENTERING_SLEEP: "Standby" +STR_BOTTOM: "Unten" +STR_BRIGHTNESS: "Helligkeit" STR_BROWSE_FILES: "Durchsuchen" -STR_FILE_TRANSFER: "Datentransfer" -STR_SETTINGS_TITLE: "Einstellungen" -STR_CONTINUE_READING: "Weiterlesen" -STR_RESUME: "Fortsetzen" -STR_NO_OPEN_BOOK: "Aktuell kein Buch" -STR_START_READING: "Lesen beginnen" -STR_NO_FILES_FOUND: "Keine Dateien gefunden" -STR_SELECT_CHAPTER: "Kapitel auswählen" -STR_NO_CHAPTERS: "Keine Kapitel" -STR_END_OF_BOOK: "Buchende" -STR_EMPTY_CHAPTER: "Kapitelende" -STR_INDEXING: "Indexieren" -STR_MEMORY_ERROR: "Speicherfehler" -STR_PAGE_LOAD_ERROR: "Seitenladefehler" -STR_EMPTY_FILE: "Leere Datei" -STR_OUT_OF_BOUNDS: "Zu groß" -STR_LOADING: "Laden…" -STR_LOADING_POPUP: "Lade…" -STR_WIFI_NETWORKS: "WLAN-Netzwerke" -STR_NO_NETWORKS: "Kein WLAN gefunden" -STR_NETWORKS_FOUND: "%zu WLAN-Netzwerke gefunden" -STR_SCANNING: "Suchen…" -STR_CONNECTING: "Verbinden…" -STR_CONNECTED: "Verbunden!" -STR_CONNECTION_FAILED: "Verbindungsfehler" -STR_FORGET_NETWORK: "WLAN vergessen?" -STR_SAVE_PASSWORD: "Passwort speichern?" -STR_PRESS_OK_SCAN: "OK für neue Suche" -STR_JOIN_NETWORK: "Netzwerk beitreten" -STR_CREATE_HOTSPOT: "Hotspot erstellen" -STR_USB_DRIVE: "USB-Laufwerk" -STR_USB_DRIVE_DESC: "SD-Karte am Computer verwalten" -STR_USB_DRIVE_PREPARING: "USB-Laufwerk wird vorbereitet..." -STR_USB_DRIVE_WAITING: "Lesegerät mit Computer verbinden" -STR_USB_DRIVE_CONNECTED: "USB-Laufwerk verbunden" -STR_USB_DRIVE_EJECT_HINT: "Laufwerk am Computer auswerfen oder Kabel trennen, um zur Startseite zurückzukehren." -STR_USB_DRIVE_ERROR: "USB-Laufwerkfehler. Kabel trennen, um zur Startseite zurückzukehren." -STR_USB_DRIVE_START_ERROR: "USB-Laufwerk konnte nicht starten. Zurück drücken für Startseite." -STR_JOIN_DESC: "Mit einem bestehenden WLAN verbinden" -STR_HOTSPOT_DESC: "WLAN für andere erstellen" -STR_STARTING_HOTSPOT: "Hotspot starten…" -STR_HOTSPOT_MODE: "Hotspot-Modus" -STR_CONNECT_WIFI_HINT: "Gerät mit diesem WLAN verbinden" -STR_OPEN_URL_HINT: "Diese URL im Browser öffnen" -STR_OR_HTTP_PREFIX: "oder http://" -STR_SCAN_QR_HINT: "oder QR-Code mit dem Handy scannen:" -STR_CALIBRE_WIRELESS: "Calibre Wireless" -STR_NETWORK_LEGEND: "* = Verschlüsselt | + = Gespeichert" -STR_MAC_ADDRESS: "MAC-Adresse:" -STR_CHECKING_WIFI: "WLAN prüfen…" -STR_ENTER_WIFI_PASSWORD: "WLAN-Passwort eingeben" -STR_TO_PREFIX: "mit " -STR_CALIBRE_RECEIVING: "Empfange:" -STR_CALIBRE_RECEIVED: "Empfangen:" +STR_CACHE_CLEARED: "Cache geleert" +STR_CALC_HASH: "Dokument-Hash berechnen…" +STR_CALIBRE_DESC: "Calibre-Übertragung (WLAN)" STR_CALIBRE_INSTRUCTION_1: "1) CrossPoint Reader-Plugin installieren" STR_CALIBRE_INSTRUCTION_2: "2) Mit selbem WLAN verbinden" STR_CALIBRE_INSTRUCTION_3: "3) In Calibre: \"An Gerät senden\"" STR_CALIBRE_INSTRUCTION_4: "Bildschirm beim Senden offenlassen" +STR_CALIBRE_RECEIVED: "Empfangen:" +STR_CALIBRE_RECEIVING: "Empfange:" +STR_CALIBRE_SETUP: "Installation" +STR_CALIBRE_STARTING: "Calibre starten…" +STR_CALIBRE_STATUS: "Status" +STR_CALIBRE_URL_HINT: "Calibre: URL um /opds ergänzen" +STR_CALIBRE_WIRELESS: "Calibre Wireless" +STR_CANCEL: "Abbrechen" +STR_CAT_CONTROLS: "Bedienung" STR_CAT_DISPLAY: "Anzeige" STR_CAT_READER: "Lesen" -STR_CAT_CONTROLS: "Bedienung" STR_CAT_SYSTEM: "System" -STR_SLEEP_SCREEN: "Standby-Bild" -STR_NIGHT_MODE: "Nachtmodus" -STR_SLEEP_COVER_MODE: "Standby-Bildmodus" -STR_HIDE_BATTERY: "Batterie % ausblenden" -STR_EXTRA_SPACING: "Absatzabstand" -STR_TEXT_AA: "Schriftglättung" -STR_SHORT_PWR_BTN: "An-Taste kurz drücken" -STR_ORIENTATION: "Leseausrichtung" -STR_SIDE_BTN_LAYOUT: "Seitliche Tasten (Lesen)" -STR_TOUCH_READER_CONTROLS: "Touch-Steuerung beim Lesen" -STR_FRONT_BTN_FOLLOW_ORIENTATION: "Vordere Tasten ausrichten" -STR_LONG_PRESS_BEHAVIOR: "Verhalten bei langem Tastendruck" -STR_LONG_PRESS_BEHAVIOR_OFF: "Aus" -STR_LONG_PRESS_BEHAVIOR_SKIP: "Kapitel überspringen" -STR_LONG_PRESS_BEHAVIOR_ORIENTATION: "Ausrichtung ändern" -STR_FONT_PREVIEW_TEXT: "Victor jagt zwölf Boxkämpfer quer über den großen Sylter Deich" -STR_FONT_FAMILY: "Lese-Schriftfamilie" -STR_FONT_SIZE: "Schriftgröße" -STR_TEXT_SETTINGS: "Texteinstellungen" -STR_FONT: "Schriftart" -STR_SIZE: "Größe" -STR_LAYOUT: "Layout" -STR_STYLE: "Stil" -STR_NOT_IN_PREVIEW: "In der Vorschau nicht sichtbar" -STR_ALIGNMENT: "Ausrichtung" -STR_LINE_SPACING: "Zeilenabstand" -STR_SCREEN_MARGIN: "Seitenränder" -STR_PARA_ALIGNMENT: "Absatzausrichtung" -STR_LONG_PRESS_MENU: "Menütaste lang drücken" -STR_HYPHENATION: "Silbentrennung" -STR_TIME_TO_SLEEP: "Standby-Modus nach" -STR_REFRESH_FREQ: "Display Aktualisieren nach" -STR_KOREADER_SYNC: "KOReader Sync" +STR_CENTER: "Zentriert" +STR_CHAPTER_PAGE_COUNT: "Kapitel-Seitenanzahl" +STR_CHAPTER_PREFIX: "Kapitel:" +STR_CHAPTER: "Kapitel" +STR_CHARACTER_SPACING: "Zeichenabstand" +STR_CHECK_SERIAL_OUTPUT: "Serielle Ausgabe prüfen" STR_CHECK_UPDATES: "Nach Updates suchen" -STR_LANGUAGE: "Sprache" -STR_KEYBOARD_LAYOUTS: "Tastaturlayouts" -STR_CLEAR_READING_CACHE: "Lese-Cache leeren" -STR_USERNAME: "Benutzername" -STR_PASSWORD: "Passwort" -STR_SYNC_SERVER_URL: "Sync-Server-URL" -STR_DOCUMENT_MATCHING: "Dateizuordnung" -STR_SEND_METADATA: "Metadaten senden" -STR_AUTHENTICATE: "Authentifizieren" -STR_KOREADER_USERNAME: "KOReader-Benutzername" -STR_KOREADER_PASSWORD: "KOReader-Passwort" -STR_FILENAME: "Dateiname" -STR_BINARY: "Binärdatei" -STR_SET_CREDENTIALS_FIRST: "Zuerst anmelden" -STR_WIFI_CONN_FAILED: "WLAN-Verbindung fehlgeschlagen" -STR_AUTHENTICATING: "Authentifizieren…" -STR_AUTH_SUCCESS: "Erfolgreich authentifiziert!" -STR_KOREADER_AUTH: "KOReader-Auth" -STR_SYNC_READY: "KOReader-Synchronisierung bereit" -STR_AUTH_FAILED: "Authentifizierung fehlg." -STR_DONE: "Erledigt" +STR_CHECKING_UPDATE: "Update suchen…" +STR_CHECKING_WIFI: "WLAN prüfen…" +STR_CLEAR_BUTTON: "Leeren" +STR_CLEAR_CACHE_FAILED: "Fehler beim Cache-Leeren" STR_CLEAR_CACHE_WARNING_1: "Alle Buch-Caches werden geleert." STR_CLEAR_CACHE_WARNING_2: "Lesefortschritt wird gelöscht!" STR_CLEAR_CACHE_WARNING_3: "Bücher müssen beim Öffnen" STR_CLEAR_CACHE_WARNING_4: "neu eingelesen werden." +STR_CLEAR_READING_CACHE: "Lese-Cache leeren" STR_CLEARING_CACHE: "Cache leeren…" -STR_CACHE_CLEARED: "Cache geleert" -STR_ITEMS_REMOVED: "Einträge entfernt" -STR_FAILED_LOWER: "fehlgeschlagen" -STR_CLEAR_CACHE_FAILED: "Fehler beim Cache-Leeren" -STR_CHECK_SERIAL_OUTPUT: "Serielle Ausgabe prüfen" -STR_DARK: "Dunkel" -STR_LIGHT: "Hell" -STR_CUSTOM: "Benutzerdefiniert" +STR_CLOCK_DST_AUTO: "Auto" +STR_CLOCK_DST: "Sommerzeit" +STR_CLOCK_FORMAT_12H: "12 Stunden" +STR_CLOCK_FORMAT_24H: "24 Stunden" +STR_CLOCK_FORMAT: "Zeitformat" +STR_CLOCK_IN_HEADER: "Zeige obere Leiste" +STR_CLOCK_SYNC_FAIL: "Synchronisierung fehlgeschlagen" +STR_CLOCK_SYNC_NO_WIFI_HINT: "Verbinde zuerst mit dem WLAN, dann probiere es nochmal." +STR_CLOCK_SYNC_NO_WIFI: "WLAN nicht verbunden" +STR_CLOCK_SYNC_NOW: "Zeit jetzt synchronisieren" +STR_CLOCK_SYNC_OK: "Zeit synchronisiert" +STR_CLOCK_SYNC: "Zeit Synchronisieren" +STR_CLOCK_SYNCED: "Zeit synchronisiert" +STR_CLOCK_SYNCING: "Synchronisiere Zeit über NTP…" +STR_CLOCK: "Zeit" +STR_CONFIGURE: "Konfigurieren" +STR_CONFIRM_DELETE_BOOKMARK: "Dieses Lesezeichen löschen?" +STR_CONFIRM: "Bestätigen" +STR_CONNECT_WIFI_HINT: "Gerät mit diesem WLAN verbinden" +STR_CONNECT: "Verbinden" +STR_CONNECTED: "Verbunden!" +STR_CONNECTING_SAVED_WIFI: "Verbinde mit gespeichertem WLAN-Netzwerk…" +STR_CONNECTING: "Verbinden…" +STR_CONNECTION_FAILED: "Verbindungsfehler" +STR_CONTINUE_READING: "Weiterlesen" +STR_COVER_CUSTOM: "Benutzerdefiniert + Buchcover" STR_COVER: "Buchcover" -STR_NONE_OPT: "Aus" -STR_FIT: "Anpassen" +STR_CRASH_DESCRIPTION: "Ein detaillierter Report wurde in crash_report.txt gespeichert. Bitte hänge die Datei dem Bug Report an" +STR_CRASH_NO_REASON: "(Es wurde kein Grund aufgezeichnet)" +STR_CRASH_REASON: "Grund für Absturz:" +STR_CRASH_TITLE: "Systemabsturz" +STR_CREATE_HOTSPOT: "Hotspot erstellen" +STR_CREATING_ACCOUNT: "Erstelle Konto…" STR_CROP: "Zuschnitt" -STR_NEVER: "Nie" -STR_IN_READER: "Beim Lesen" -STR_ALWAYS: "Immer" -STR_IGNORE: "Ignorieren" -STR_SLEEP: "Standby" -STR_PAGE_TURN: "Umblättern" -STR_FORCE_REFRESH: "Bildschirm regenerieren" -STR_PORTRAIT: "Hochformat" -STR_LANDSCAPE_CW: "Querformat rechts" -STR_INVERTED: "Invertiert" -STR_ORIENTATION_INVERTED: "Hochformat 180°" -STR_LANDSCAPE_CCW: "Querformat links" -STR_PREV_NEXT: "Zurück/Weiter" -STR_NEXT_PREV: "Weiter/Zurück" -STR_DISABLED: "Gesperrt" -STR_NOTO_SERIF: "Noto Serif" -STR_NOTO_SANS: "Noto Sans" -STR_TIGHT: "Eng" -STR_NORMAL: "Normal" -STR_WIDE: "Breit" -STR_EXTRA_WIDE: "Extra breit" -STR_JUSTIFY: "Blocksatz" -STR_ALIGN_LEFT: "Links" -STR_CENTER: "Zentriert" -STR_ALIGN_RIGHT: "Rechts" -STR_PAGES_1: "1 Seite" -STR_PAGES_5: "5 Seiten" -STR_PAGES_10: "10 Seiten" -STR_PAGES_15: "15 Seiten" -STR_PAGES_30: "30 Seiten" -STR_UPDATE: "Update" -STR_CHECKING_UPDATE: "Update suchen…" -STR_NEW_UPDATE: "Neues Update verfügbar!" +STR_CROSSPOINT: "CrossPoint" +STR_CURRENT_TIME: "Aktuelle Zeit:" STR_CURRENT_VERSION: "Aktuelle Version:" -STR_NEW_VERSION: "Neue Version:" -STR_UPDATING: "Aktualisiere…" -STR_NO_UPDATE: "Kein Update verfügbar" -STR_UPDATE_FAILED: "Updatefehler" -STR_UPDATE_COMPLETE: "Update fertig" -STR_POWER_ON_HINT: "An-Knopf lang drücken, um neuzustarten" -STR_RESTARTING_HINT: "Starte neu… Wenn das Gerät nicht neustartet, halte An-Knopf für ein paar Sekunden gedrückt." -STR_NO_ENTRIES: "Keine Einträge" -STR_DOWNLOADING: "Herunterladen…" -STR_DOWNLOAD_FAILED: "Ladefehler" -STR_ERROR_MSG: "Fehler:" -STR_UNNAMED: "Unbenannt" -STR_HOLD_OPEN_TO_DELETE: "Halte Öffnen zum Löschen" -STR_NO_SERVER_URL: "Keine Server-URL konfiguriert" -STR_FETCH_FEED_FAILED: "Feedfehler" -STR_PARSE_FEED_FAILED: "Feed-Format ungültig" -STR_NEXT_PAGE: "Nächste Seite »" -STR_PREV_PAGE: "« Vorherige Seite" -STR_NETWORK_PREFIX: "Netzwerk:" -STR_IP_ADDRESS_PREFIX: "IP-Adresse:" -STR_ERROR_GENERAL_FAILURE: "Fehler: Allgemeiner Fehler" -STR_ERROR_NETWORK_NOT_FOUND: "Fehler: Kein Netzwerk" -STR_ERROR_CONNECTION_TIMEOUT: "Fehler: Zeitüberschreitung" -STR_SD_CARD: "SD-Karte" -STR_BACK: "« Zurück" -STR_EXIT: "« Verlassen" -STR_HOME: "« Start" -STR_SELECT: "Auswahl" -STR_SELECTED: "Ausgewählt" -STR_TOGGLE: "Ändern" -STR_TOGGLE_BOOKMARK: "Lesezeichen hinzufügen/entfernen" -STR_CONFIRM: "Bestätigen" -STR_CANCEL: "Abbrechen" -STR_CONNECT: "Verbinden" -STR_OPEN: "Öffnen" -STR_DOWNLOAD: "Herunterladen" -STR_RETRY: "Wiederh." -STR_YES: "Ja" -STR_NO: "Nein" -STR_SHOW: "Zeigen" -STR_HIDE: "Ausblenden" -STR_STATE_ON: "An" -STR_STATE_OFF: "Aus" -STR_NOT_SET: "Leer" +STR_CUSTOM: "Benutzerdefiniert" +STR_CUSTOMISE_STATUS_BAR: "Statusleiste anpassen" +STR_DARK: "Dunkel" +STR_DBL_CLICK_PWR_LIGHT: "Doppelklick Power für Beleuchtung" +STR_DEFAULT_VALUE: "Standard" +STR_DELETE_CACHE: "Buch-Cache leeren" +STR_DELETE_SERVER: "Server entfernen" +STR_DELETE: "Löschen" +STR_DEVICE_FROM_FORMAT: " Von: %s" +STR_DICT_DECOMPRESS_ERROR: "Konnte Definition nicht entpacken" +STR_DICT_ERROR: "Wörterbuch Fehler" +STR_DICT_INDEXING: "Indexiere Wörterbuch…" +STR_DICT_LOOKING_UP: "Suche…" +STR_DICT_LOW_MEMORY: "Nicht genug Speicher" +STR_DICT_NO_DICT_SET: "Kein Wörterbuch festgelegt" +STR_DICT_NOT_FOUND: "Nicht gefunden" +STR_DICT_READ_FAILED: "Konnte Definition nicht lesen" +STR_DICTIONARY: "Wörterbuch" +STR_DIR_DOWN: "Runter" STR_DIR_LEFT: "Links" STR_DIR_RIGHT: "Rechts" STR_DIR_UP: "Hoch" -STR_DIR_DOWN: "Runter" -STR_OK_BUTTON: "OK" -STR_SLEEP_COVER_FILTER: "Standby-Coverfilter" -STR_FILTER_CONTRAST: "Kontrast" -STR_CUSTOMISE_STATUS_BAR: "Statusleiste anpassen" -STR_CHAPTER_PAGE_COUNT: "Kapitel-Seitenanzahl" -STR_BOOK_PROGRESS_PERCENTAGE: "Buchfortschritt in %" -STR_PROGRESS_BAR: "Fortschrittsbalken" -STR_PROGRESS_BAR_THICKNESS: "Balkenstärke" -STR_PROGRESS_BAR_THIN: "Dünn" -STR_PROGRESS_BAR_MEDIUM: "Mittel" -STR_PROGRESS_BAR_THICK: "Dick" -STR_BOOK: "Buch" -STR_CHAPTER: "Kapitel" -STR_EXAMPLE_CHAPTER: "Kapitel 21" +STR_DISABLED: "Gesperrt" +STR_DISPLAY_QR: "Seite als QR anzeigen" +STR_DOCUMENT_MATCHING: "Dateizuordnung" +STR_DONE: "Erledigt" +STR_DOWNLOAD_ALL: "Alle herunterladen" +STR_DOWNLOAD_FAILED: "Ladefehler" +STR_DOWNLOAD: "Herunterladen" +STR_DOWNLOADING: "Herunterladen…" +STR_EMBEDDED_STYLE: "Eingebetteter Stil" +STR_EMPTY_CHAPTER: "Leeres Kapitel" +STR_EMPTY_FILE: "Leere Datei" +STR_END_OF_BOOK: "Buchende" +STR_ENTER_WIFI_PASSWORD: "WLAN-Passwort eingeben" +STR_ENTER_WIFI_SSID: "Netzwerknamen eingeben (SSID)" +STR_ENTERING_SLEEP: "Standby" +STR_EOB_CONTINUE_WITH: "Weiter mit" +STR_EOB_HOME: "Start" +STR_ERROR_CONNECTION_TIMEOUT: "Fehler: Zeitüberschreitung" +STR_ERROR_GENERAL_FAILURE: "Fehler: Allgemeiner Fehler" +STR_ERROR_MSG: "Fehler:" +STR_ERROR_NETWORK_NOT_FOUND: "Fehler: Kein Netzwerk" STR_EXAMPLE_BOOK: "Buchtitel" -STR_PREVIEW: "Vorschau" -STR_TITLE: "Titel" -STR_BATTERY: "Batterie" -STR_XTC_STATUS_BAR: "XTC-Statuszeile" -STR_BOTTOM: "Unten" -STR_TOP: "Oben" -STR_CLOCK: "Uhr" -STR_CLOCK_FORMAT: "Uhrenformat" -STR_CLOCK_FORMAT_24H: "24 Stunden" -STR_CLOCK_FORMAT_12H: "12 Stunden" -STR_CURRENT_TIME: "Aktuelle Zeit:" -STR_CLOCK_SYNC: "Uhr Synchronisieren" -STR_CLOCK_SYNC_NOW: "Uhr jetzt synchronisieren" -STR_CLOCK_SYNCING: "Synchronisiere Uhrzeit über NTP…" -STR_CLOCK_SYNC_OK: "Uhr synchronisiert" -STR_CLOCK_SYNC_FAIL: "Synchronisierung fehlgeschlagen" -STR_CLOCK_SYNC_NO_WIFI: "WLAN nicht verbunden" -STR_CLOCK_SYNC_NO_WIFI_HINT: "Verbinde zuerst mit dem WLAN, dann probiere es nochmal." -STR_CLOCK_SYNCED: "Uhr synchronisiert" -STR_UI_THEME: "System-Design" -STR_THEME_CLASSIC: "Klassisch" -STR_THEME_LYRA: "Lyra" -STR_THEME_ROUNDEDRAFF: "RoundedRaff" -STR_THEME_LYRA_EXTENDED: "Lyra Extended" -STR_SUNLIGHT_FADING_FIX: "Anti-Verblassen bei Sonnenlicht" -STR_QUICK_RESUME_TIMEOUT: "Schnelles Fortsetzen nach Timeout" -STR_REMAP_FRONT_BUTTONS: "Vordere Tasten belegen" -STR_BOOKMARKS: "Lesezeichen" -STR_BOOKMARK_ADDED: "Lesezeichen hinzugefügt." -STR_BOOKMARK_REMOVED: "Lesezeichen entfernt." -STR_SEARCH: "Suche" -STR_OPDS_BROWSER: "OPDS-Browser" -STR_COVER_CUSTOM: "Benutzerdefiniert + Buchcover" -STR_QUICK_RESUME: "Schnelles Fortsetzen" -STR_MENU_RECENT_BOOKS: "Zuletzt gelesen" -STR_REMOVE_FROM_RECENTS: "Aus Zuletzt gelesen entfernen?" -STR_CALIBRE_DESC: "Calibre-Übertragung (WLAN)" +STR_EXAMPLE_CHAPTER: "Kapitel 21" +STR_EXIT: "« Verlassen" +STR_EXTRA_SPACING: "Absatzabstand" +STR_EXTRA_WIDE: "Extra breit" +STR_FAILED_LOWER: "fehlgeschlagen" +STR_FETCH_FEED_FAILED: "Feedfehler" +STR_FETCH_PROGRESS: "Externen Fortschritt abrufen…" +STR_FILE_OPEN_FAILED: "Kann Datei nicht öffnen" +STR_FILE_TRANSFER: "Datentransfer" +STR_FILENAME: "Dateiname" +STR_FILTER_CONTRAST: "Kontrast" +STR_FINDING_SAVED_WIFI: "Finde gespeichertes WLAN-Netzwerk…" +STR_FIRMWARE_FILE_OPEN_FAILED: "Kann Firmware-Datei nicht öffnen" +STR_FIRMWARE_TOO_LARGE: "Firmware-Datei ist zu groß" +STR_FIRMWARE_TOO_SMALL: "Firmware-Datei ist zu klein" +STR_FIRMWARE_UPDATE_DO_NOT_POWER_OFF: "Gerät nicht ausschalten!" +STR_FIRMWARE_UPDATE_PROMPT: "Firmware updaten?" +STR_FIRMWARE_WRITE_FAILED: "Schreiben der Firmware-Datei ist fehlgeschlagen" +STR_FIRMWARE_WRONG_DEVICE: "Firmware ist für ein anderes Gerät" +STR_FIT: "Anpassen" +STR_FMT_AUTHOR_TITLE: "Autor - Titel" +STR_FMT_TITLE_AUTHOR: "Titel - Autor" +STR_FMT_TITLE: "Titel" +STR_FOCUS_READING: "Fokus-Lesen" +STR_FONT_BROWSER: "Schriftart-Browser" +STR_FONT_FAMILY: "Lese-Schriftfamilie" +STR_FONT_INSTALL_FAILED: "Schriftartinstallation fehlgeschlagen" +STR_FONT_INSTALLED: "Schriftart installiert!" +STR_FONT_PREVIEW_TEXT: "Victor jagt zwölf Boxkämpfer quer über den großen Sylter Deich" +STR_FONT_SIZE: "Schriftgröße" +STR_FONT: "Schriftart" +STR_FOOTNOTES: "Fußnoten" +STR_FORCE_REFRESH: "Bildschirm regenerieren" STR_FORGET_AND_REMOVE: "WLAN entfernen & Passwort löschen?" STR_FORGET_BUTTON: "Entfernen" -STR_CALIBRE_STARTING: "Calibre starten…" -STR_CALIBRE_SETUP: "Installation" -STR_CALIBRE_STATUS: "Status" -STR_CLEAR_BUTTON: "Leeren" -STR_DEFAULT_VALUE: "Standard" -STR_REMAP_PROMPT: "Entsprechende Vordertaste drücken" -STR_UNASSIGNED: "Leer" -STR_ALREADY_ASSIGNED: "Bereits zugeordnet" -STR_REMAP_RESET_HINT: "Seitentaste hoch: Standard" -STR_REMAP_CANCEL_HINT: "Seitentaste runter: Abbrechen" +STR_FORGET_NETWORK: "WLAN vergessen?" +STR_FRONT_BTN_FOLLOW_ORIENTATION: "Vordere Tasten ausrichten" +STR_FRONTLIGHT: "Frontlicht" +STR_GO_HOME_BUTTON: "Zum Anfang" +STR_GO_TO_PERCENT: "Gehe zu %" +STR_HASH_FAILED: "Dokument-Hash fehlgeschlagen" +STR_HIDE_BATTERY: "Batterie % ausblenden" +STR_HIDE: "Ausblenden" +STR_HOLD_OPEN_TO_DELETE: "Halte Öffnen zum Löschen" +STR_HOLD_OPEN_TO_REMOVE: "Halte Öffnen zum Entfernen" +STR_HOME_BUTTON_DOUBLE_TAP: "Doppelklick" +STR_HOME_BUTTON_LONG_PRESS: "Lange drücken" +STR_HOME_BUTTON_TAP: "Tastendruck" +STR_HOME_BUTTON: "Start Tastengeste" +STR_HOME_SHORTCUT: "Start" +STR_HOTSPOT_DESC: "WLAN für andere erstellen" +STR_HOTSPOT_MODE: "Hotspot-Modus" STR_HW_BACK_LABEL: "Zurück (1. Taste)" STR_HW_CONFIRM_LABEL: "Bestätigen (2. Taste)" STR_HW_LEFT_LABEL: "Links (3. Taste)" STR_HW_RIGHT_LABEL: "Rechts (4. Taste)" -STR_GO_TO_PERCENT: "Gehe zu %" -STR_GO_HOME_BUTTON: "Zum Anfang" -STR_SYNC_PROGRESS: "Fortschritt synchronisieren" -STR_DELETE_CACHE: "Buch-Cache leeren" -STR_DISPLAY_QR: "Seite als QR anzeigen" -STR_DELETE: "Löschen" -STR_CONFIRM_DELETE_BOOKMARK: "Dieses Lesezeichen löschen?" -STR_CHAPTER_PREFIX: "Kapitel:" -STR_PAGES_SEPARATOR: " Seiten | " -STR_BOOK_PREFIX: "Buch: " -STR_CALIBRE_URL_HINT: "Calibre: URL um /opds ergänzen" -STR_SYNCING_TIME: "Zeit synchronisieren…" -STR_CALC_HASH: "Dokument-Hash berechnen…" -STR_HASH_FAILED: "Dokument-Hash fehlgeschlagen" -STR_FETCH_PROGRESS: "Externen Fortschritt abrufen…" -STR_UPLOAD_PROGRESS: "Fortschritt hochladen…" -STR_NO_CREDENTIALS_MSG: "Zugangsdaten fehlen" -STR_KOREADER_SETUP_HINT: "KOReader-Konto unter Einst. anlegen" -STR_PROGRESS_FOUND: "Gefunden!" -STR_REMOTE_LABEL: "Extern:" -STR_LOCAL_LABEL: "Lokal:" -STR_PAGE_OVERALL_FORMAT: " Seite %d, %.2f%% insgesamt" -STR_PAGE_TOTAL_OVERALL_FORMAT: " Seite %d/%d, %.2f%% insgesamt" -STR_DEVICE_FROM_FORMAT: " Von: %s" -STR_APPLY_REMOTE: "Externen Fortschritt übernehmen" -STR_UPLOAD_LOCAL: "Lokalen Fortschritt hochladen" -STR_NO_REMOTE_MSG: "Kein externer Fortschritt" -STR_UPLOAD_PROMPT: "Aktuelle Position hochladen?" -STR_UPLOAD_SUCCESS: "Der Fortschritt wurde erfolgreich hochgeladen." -STR_SYNC_FAILED_MSG: "Fehlgeschlagen" -STR_SAVE_PROGRESS_FAILED: "Fortschritt konnte nicht gespeichert werden" -STR_SECTION_PREFIX: "Abschnitt" -STR_UPLOAD: "Hochladen" -STR_BOOK_S_STYLE: "Buch-Stil" -STR_EMBEDDED_STYLE: "Eingebetteter Stil" -STR_FOCUS_READING: "Fokus-Lesen" -STR_OPDS_SERVER_URL: "OPDS-Server-URL" -STR_BACK_SHORT_TO_FILE_BROWSER: "Kurz zurück drücken zum Datei-Browser" -STR_SET_SLEEP_COVER: "Wähle Cover" -STR_SCREENSHOT_BUTTON: "Screenshot aufnehmen" -STR_FOOTNOTES: "Fußnoten" -STR_LINK: "[Link]" -STR_SLEEP_TIMER_VALUE_FORMAT: "%u Min." -STR_SLEEP_NEVER: "Nie" -STR_STEP_HINT_FRONT: "Vordere Tasten:" -STR_STEP_HINT_SIDE: "Seitentasten:" -STR_ADD_SERVER: "Server hinzufügen" -STR_SERVER_NAME: "Servername" -STR_NO_SERVERS: "Keine OPDS-Server konfiguriert" -STR_DELETE_SERVER: "Server entfernen" -STR_OPDS_SERVERS: "OPDS-Server" -STR_OPDS_DOWNLOAD_FOLDER: "Download-Ordner" -STR_OPDS_FILENAME_FORMAT: "Dateinamenformat" -STR_FMT_AUTHOR_TITLE: "Autor - Titel" -STR_FMT_TITLE_AUTHOR: "Titel - Autor" -STR_FMT_TITLE: "Titel" -STR_AUTO_TURN_ENABLED: "Auto-Umblättern aktiv: " -STR_AUTO_TURN_PAGES_PER_MIN: "Auto-Umblättern (Seiten/Min.)" -STR_IMAGES: "Bilder" +STR_HYPHENATION: "Silbentrennung" +STR_IGNORE: "Ignorieren" STR_IMAGES_DISPLAY: "Anzeigen" STR_IMAGES_PLACEHOLDER: "Platzhalter" STR_IMAGES_SUPPRESS: "Unterdrücken" -STR_SHOW_HIDDEN_FILES: "Versteckte Dateien anzeigen" -STR_LIBRARY: "Bibliothek" -STR_LIBRARY_EMPTY: "Keine Bücher auf der SD-Karte gefunden" -STR_LIBRARY_NO_RESULTS: "Keine Buchtreffer bei der Suche" -STR_LIBRARY_SEARCH_UNAVAILABLE: "Suche nicht möglich: zu wenig Speicherplatz" -STR_LIBRARY_UNKNOWN_TITLE: "(ohne Titel)" -STR_LIBRARY_UNKNOWN_AUTHOR: "Unbekannter Autor" -STR_LIBRARY_TITLE_UNSORTED: "Bibliothek (unsortiert)" -STR_LIBRARY_TAB_RECENT: "Neueste" -STR_LIBRARY_TAB_TITLE: "Titel" -STR_LIBRARY_TAB_AUTHOR: "Autor" -STR_LIBRARY_SEARCH: "Suche" -STR_LIBRARY_HOLD_SORT: "Halten: Sortierung umkehren" -STR_LIBRARY_HOLD_GROUPS: "Halten: Gruppen" -STR_LIBRARY_POSITION: "%d/%d Bücher" -STR_LIBRARY_GROUP_POSITION: "%d/%d Gruppen" -STR_LIBRARY_USE_METADATA: "Benutze Metadaten" -STR_LIBRARY_REBUILD: "Erstelle Bibliotheksindex neu" -STR_LIBRARY_REBUILDING: "Lese Bücher ein..." -STR_LIBRARY_REBUILD_DONE: "Bibliotheksindex erstellt" -STR_LIBRARY_REBUILD_FAILED: "Kann Index nicht erstellen" -STR_REMOVE_READ_FROM_RECENTS: "Gelesene Bücher aus der Zuletzt-Liste löschen" -STR_MOVE_FINISHED_TO_READ: "Gelesene Bücher in den Read-Ordner verschieben" -STR_MANAGE_FONTS: "Schriftarten verwalten" -STR_FONT_BROWSER: "Schriftart-Browser" -STR_LOADING_FONT_LIST: "Lade Schriftartenliste…" -STR_NO_FONTS_AVAILABLE: "Keine Schriftarten verfügbar" -STR_FONT_INSTALLED: "Schriftart installiert!" -STR_FONT_INSTALL_FAILED: "Schriftartinstallation fehlgeschlagen" +STR_IMAGES: "Bilder" +STR_IN_READER: "Beim Lesen" +STR_INDEX_FAILED: "Fehler beim Indexieren - ungültiges Buch" +STR_INDEXING: "Indexieren" STR_INSTALLED: "Installiert" -STR_DOWNLOAD_ALL: "Alle downloaden" -STR_UPDATE_ALL: "Alle updaten" -STR_UPDATE_AVAILABLE: "Update" -STR_CRASH_TITLE: "Systemabsturz" -STR_CRASH_DESCRIPTION: "Ein detaillierter Report wurde in crash_report.txt gespeichert. Bitte hänge die Datei dem Bug Report an" -STR_CRASH_REASON: "Grund für Absturz:" -STR_CRASH_NO_REASON: "(Es wurde kein Grund aufgezeichnet)" -STR_TILT_PAGE_TURN: "Neigung zum Blättern" -STR_KB_HINT_MOVE_CURSOR: "Drücke LINKS oder RECHTS um Cursor zu bewegen" -STR_KB_HINT_RETURN_CURSOR: "Drücke LINKS um zur Cursor-Position zurückzugelangen" -STR_KB_HINT_HIDE_PASSWORD: "Halte RECHTS, dann drücke [***] um Passwort zu verstecken" -STR_KB_HINT_SHOW_PASSWORD: "Halte RECHTS, dann drücke [abc] um Passwort anzuzeigen" -STR_KB_HINT_TOGGLE_HIDE_PASSWORD: "Drücke [***] um Passwort zu verstecken" -STR_KB_HINT_TOGGLE_SHOW_PASSWORD: "Drücke [abc] um Passwort anzuzeigen" +STR_INVALID_BMP_FILE: "Ungültige BMP-Datei" +STR_INVALID_FIRMWARE: "Firmware-Datei ist ungültig" +STR_INVALID_FONT_MANIFEST: "Ungültige Schriftdatei" +STR_INVERTED_TAP: "Taste loslassen" +STR_INVERTED: "Invertiert" +STR_IP_ADDRESS_PREFIX: "IP-Adresse:" +STR_ITEMS_REMOVED: "Einträge entfernt" +STR_JOIN_DESC: "Mit einem bestehenden WLAN verbinden" +STR_JOIN_NETWORK: "Netzwerk beitreten" +STR_JUSTIFY: "Blocksatz" +STR_KB_HINT_CLEAR_TEXT: "Halte ← auf der Tastatur um alles zu löschen" STR_KB_HINT_EDIT_ENTRY: "Halte HOCH um Eintrag zu editieren" -STR_KB_TIPS: "Tips:" -STR_KB_HINT_RETURN_KEYBOARD: "Drücke RUNTER um auf Tastatur zurückzugelangen" STR_KB_HINT_EXIT_URL_MODE: "Drücke ABC um URL-Modus zu beenden" -STR_KB_HINT_CLEAR_TEXT: "Halte ← auf der Tastatur um alles zu löschen" +STR_KB_HINT_HIDE_PASSWORD: "Halte RECHTS, dann drücke [***] um Passwort zu verstecken" +STR_KB_HINT_LOWER_SECONDARY: "Halte Auswahl für Kleinschreiben oder Sekundärzeichen" +STR_KB_HINT_MOVE_CURSOR: "Drücke LINKS oder RECHTS um Cursor zu bewegen" +STR_KB_HINT_RETURN_CURSOR: "Drücke LINKS um zur Cursor-Position zurückzugelangen" +STR_KB_HINT_RETURN_KEYBOARD: "Drücke RUNTER um auf Tastatur zurückzugelangen" STR_KB_HINT_SECONDARY_CHAR: "Halte Auswahl für Sekundärzeichen" +STR_KB_HINT_SHOW_PASSWORD: "Halte RECHTS, dann drücke [abc] um Passwort anzuzeigen" +STR_KB_HINT_TOGGLE_HIDE_PASSWORD: "Drücke [***] um Passwort zu verstecken" +STR_KB_HINT_TOGGLE_SHOW_PASSWORD: "Drücke [abc] um Passwort anzuzeigen" STR_KB_HINT_UPPER_SECONDARY: "Halte Auswahl für Großschreiben oder Sekundärzeichen" -STR_KB_HINT_LOWER_SECONDARY: "Halte Auswahl für Kleinschreiben oder Sekundärzeichen" STR_KB_HINT_URL_SNIPPETS: "Drücke URL für Ausschnitte" -STR_SD_FIRMWARE_UPDATE: "SD-Karte Firmware Update" -STR_SELECT_FIRMWARE_FILE: "Wähle Firmware-Datei (.bin)" -STR_NO_BIN_FILES: "Keine .bin-Dateien gefunden" -STR_VALIDATING_FIRMWARE: "Validiere Firmware-Datei…" -STR_INVALID_FIRMWARE: "Firmware-Datei ist ungültig" -STR_FIRMWARE_TOO_LARGE: "Firmware-Datei ist zu groß" -STR_FIRMWARE_TOO_SMALL: "Firmware-Datei ist zu klein" -STR_FIRMWARE_UPDATE_PROMPT: "Firmware updaten?" -STR_FIRMWARE_FILE_OPEN_FAILED: "Kann Firmware-Datei nicht öffnen" -STR_FIRMWARE_WRITE_FAILED: "Schreiben der Firmware-Datei ist fehlgeschlagen" -STR_FIRMWARE_UPDATE_DO_NOT_POWER_OFF: "Gerät nicht ausschalten!" -STR_RECOVERY_MODE: "Wiederherstellungsmodus" -STR_RECOVERY_MODE_HINT: "Lege firmware.bin im SD-Kartenwurzelverzeichnis ab und wähle es aus" -STR_ADD_HIDDEN_NETWORK: "Verstecktes Netzwerk hinzufügen…" -STR_ENTER_WIFI_SSID: "Netzwerknamen eingeben (SSID)" -STR_ACCOUNT_CREATED: "Konto erstellt" -STR_ALREADY_SYNCED: "Bereits synchronisiert" -STR_ASK_EVERY_TIME: "Immer fragen" -STR_BOOKMARK_OPTION: "Lesezeichen" -STR_CONNECTING_SAVED_WIFI: "Verbinde mit gespeichertem WLAN-Netzwerk…" -STR_CREATING_ACCOUNT: "Erstelle Konto…" -STR_DICTIONARY: "Wörterbuch" -STR_DICT_ERROR: "Wörterbuch Fehler" -STR_DICT_INDEXING: "Indexiere Wörterbuch…" -STR_DICT_LOOKING_UP: "Suche…" -STR_DICT_NOT_FOUND: "Nicht gefunden" -STR_DICT_NO_DICT_SET: "Kein Wörterbuch festgelegt" -STR_EOB_CONTINUE_WITH: "Weiter mit" -STR_EOB_HOME: "Home" -STR_FINDING_SAVED_WIFI: "Finde gespeichertes WLAN-Netzwerk…" -STR_INDEX_FAILED: "Fehler beim Indexieren - ungültiges Buch" +STR_KB_TIPS: "Tipps:" STR_KEY_MODE_ABC: "abc" STR_KEY_MODE_SYMBOLS: "?123" STR_KEY_SHIFT: "Shift" +STR_KEYBOARD_LAYOUTS: "Tastaturlayouts" +STR_KOREADER_AUTH: "KOReader Auth" +STR_KOREADER_PASSWORD: "KOReader-Passwort" +STR_KOREADER_SETUP_HINT: "KOReader-Konto unter Einst. anlegen" +STR_KOREADER_SYNC: "KOReader Sync" +STR_KOREADER_USERNAME: "KOReader-Benutzername" STR_KOSYNC: "KOSync" +STR_LANDSCAPE_CCW: "Querformat links" +STR_LANDSCAPE_CW: "Querformat rechts" +STR_LANGUAGE: "Sprache" +STR_LAYOUT: "Layout" +STR_LIBRARY_EMPTY: "Keine Bücher auf der SD-Karte gefunden" +STR_LIBRARY_GROUP_POSITION: "%d/%d Gruppen" +STR_LIBRARY_HOLD_GROUPS: "Halten: Gruppen" +STR_LIBRARY_HOLD_OPTIONS: "Halten: Optionen" +STR_LIBRARY_HOLD_SORT: "Halten: Sortierung umkehren" +STR_LIBRARY_NO_RESULTS: "Keine Buchtreffer bei der Suche" +STR_LIBRARY_POSITION: "%d/%d Bücher" +STR_LIBRARY_REBUILD_DONE: "Bibliotheksindex erstellt" +STR_LIBRARY_REBUILD_FAILED: "Kann Index nicht erstellen" +STR_LIBRARY_REBUILD: "Erstelle Bibliotheksindex neu" +STR_LIBRARY_REBUILDING: "Lese Bücher ein…" +STR_LIBRARY_SEARCH_UNAVAILABLE: "Suche nicht möglich: zu wenig Speicherplatz" +STR_LIBRARY_SEARCH: "Suche" +STR_LIBRARY_TAB_AUTHOR: "Autor" +STR_LIBRARY_TAB_RECENT: "Neueste" +STR_LIBRARY_TAB_TITLE: "Titel" +STR_LIBRARY_TITLE_UNSORTED: "Bibliothek (unsortiert)" +STR_LIBRARY_UNKNOWN_AUTHOR: "Unbekannter Autor" +STR_LIBRARY_UNKNOWN_TITLE: "(ohne Titel)" +STR_LIBRARY_USE_METADATA: "Benutze Metadaten" +STR_LIBRARY: "Bibliothek" +STR_LIGHT: "Hell" +STR_LINE_SPACING: "Zeilenabstand" +STR_LINK: "[Link]" +STR_LOADING_FONT_LIST: "Lade Schriftartenliste…" +STR_LOADING_POPUP: "Lade…" +STR_LOADING: "Laden…" +STR_LOCAL_LABEL: "Lokal:" +STR_LONG_PRESS_BEHAVIOR_OFF: "Aus" +STR_LONG_PRESS_BEHAVIOR_ORIENTATION: "Ausrichtung ändern" +STR_LONG_PRESS_BEHAVIOR_SKIP: "Kapitel überspringen" +STR_LONG_PRESS_BEHAVIOR: "Verhalten bei langem Tastendruck" +STR_LONG_PRESS_MENU: "Menütaste lang drücken" STR_LOOKUP: "Nachschlagen" +STR_MAC_ADDRESS: "MAC-Adresse:" +STR_MANAGE_FONTS: "Schriftarten verwalten" +STR_MEMORY_ERROR: "Speicherfehler" +STR_MENU_RECENT_BOOKS: "Zuletzt gelesen" +STR_MENU_STYLE_LIST: "Liste" +STR_MENU_STYLE_TOOLBAR: "Symbolleiste" +STR_MOVE_FINISHED_TO_READ: "Gelesene Bücher in den Gelesen-Ordner verschieben" +STR_NETWORK_LEGEND: "* = Verschlüsselt | + = Gespeichert" +STR_NETWORK_PREFIX: "Netzwerk:" +STR_NETWORKS_FOUND: "%zu WLAN-Netzwerke gefunden" +STR_NEVER: "Nie" +STR_NEW_UPDATE: "Neues Update verfügbar!" +STR_NEW_VERSION: "Neue Version:" +STR_NEXT_NEXT: "Weiter/Weiter" +STR_NEXT_PAGE_GESTURE: "Nächste Seite" +STR_NEXT_PAGE: "Nächste Seite »" +STR_NEXT_PREV: "Weiter/Zurück" +STR_NIGHT_MODE: "Nachtmodus" +STR_NO_BIN_FILES: "Keine .bin-Dateien gefunden" +STR_NO_BOOKMARKS: "Keine Lesezeichen" +STR_NO_CHAPTERS: "Keine Kapitel" +STR_NO_CREDENTIALS_MSG: "Zugangsdaten fehlen" +STR_NO_ENTRIES: "Keine Einträge" +STR_NO_FILES_FOUND: "Keine Dateien gefunden" +STR_NO_FONTS_AVAILABLE: "Keine Schriftarten verfügbar" +STR_NO_FOOTNOTES: "Keine Fußnoten auf dieser Seite" +STR_NO_NETWORKS: "Kein WLAN gefunden" +STR_NO_OPEN_BOOK: "Aktuell kein Buch" +STR_NO_REMOTE_MSG: "Kein externer Fortschritt" +STR_NO_SERVER_URL: "Keine Server-URL konfiguriert" +STR_NO_SERVERS: "Keine OPDS-Server konfiguriert" +STR_NO_UPDATE: "Kein Update verfügbar" +STR_NO: "Nein" +STR_NONE_OPT: "Aus" +STR_NORMAL: "Normal" +STR_NOT_IN_PREVIEW: "In der Vorschau nicht sichtbar" +STR_NOT_SET: "Leer" +STR_NOTO_SANS: "Noto Sans" +STR_NOTO_SERIF: "Noto Serif" +STR_OK_BUTTON: "OK" +STR_OPDS_BROWSER: "OPDS-Browser" +STR_OPDS_DOWNLOAD_FOLDER: "Download-Ordner" +STR_OPDS_FILENAME_FORMAT: "Dateinamenformat" STR_OPDS_SD_ROOT: "SD-Karten Hauptverzeichnis" +STR_OPDS_SERVER_URL: "OPDS-Server-URL" +STR_OPDS_SERVERS: "OPDS-Server" +STR_OPEN_URL_HINT: "Diese URL im Browser öffnen" +STR_OPEN: "Öffnen" +STR_OR_HTTP_PREFIX: "oder http://" +STR_ORIENTATION_INVERTED: "Hochformat 180°" +STR_ORIENTATION: "Leseausrichtung" +STR_OUT_OF_BOUNDS: "Zu groß" +STR_PAGE_LOAD_ERROR: "Seitenladefehler" +STR_PAGE_OVERALL_FORMAT: " Seite %d, %.2f%% insgesamt" +STR_HOME: "« Start" +STR_PAGE_TOTAL_OVERALL_FORMAT: " Seite %d/%d, %.2f%% insgesamt" +STR_PAGE_TURN: "Umblättern" +STR_PAGES_1: "1 Seite" +STR_PAGES_10: "10 Seiten" +STR_PAGES_15: "15 Seiten" +STR_PAGES_30: "30 Seiten" +STR_PAGES_5: "5 Seiten" +STR_PAGES_SEPARATOR: " Seiten | " +STR_PARA_ALIGNMENT: "Absatzausrichtung" +STR_PARSE_FEED_FAILED: "Feed-Format ungültig" +STR_PASSWORD: "Passwort" +STR_PORTRAIT: "Hochformat" +STR_POWER_ON_HINT: "An-Knopf lang drücken um neuzustarten" +STR_PRESS_OK_SCAN: "OK für neue Suche" +STR_PREV_NEXT: "Zurück/Weiter" +STR_PREV_PAGE_GESTURE: "Vorherige Seite" +STR_PREV_PAGE: "« Vorherige Seite" +STR_PREV_PREV: "Vorige/Vorige" +STR_PREVIEW: "Vorschau" +STR_PROGRESS_BAR_MEDIUM: "Mittel" +STR_PROGRESS_BAR_THICK: "Dick" +STR_PROGRESS_BAR_THICKNESS: "Balkenstärke" +STR_PROGRESS_BAR_THIN: "Dünn" +STR_PROGRESS_BAR: "Fortschrittsbalken" +STR_PROGRESS_FOUND: "Gefunden!" STR_PWR_BTN_FOOTNOTE_BACK: "Schnellrücksprung aus Fußnoten" +STR_QUICK_RESUME_TIMEOUT: "Schnelles Fortsetzen nach Timeout" +STR_QUICK_RESUME: "Schnelles Fortsetzen" +STR_READER_MENU_STYLE: "Lesemenüstil" +STR_READER_MENU: "Lesemenü" +STR_RECOVERY_MODE_HINT: "Lege firmware.bin im SD-Kartenwurzelverzeichnis ab und wähle es aus" +STR_RECOVERY_MODE: "Wiederherstellungsmodus" +STR_REFRESH_FREQ: "Anzeige aktualisieren nach" +STR_REMAP_CANCEL_HINT: "Seitentaste runter: Abbrechen" +STR_REMAP_FRONT_BUTTONS: "Vordere Tasten belegen" +STR_REMAP_PROMPT: "Entsprechende Vordertaste drücken" +STR_REMAP_RESET_HINT: "Seitentaste hoch: Standard" +STR_REMOTE_LABEL: "Extern:" +STR_REMOVE_FROM_RECENTS: "Aus Zuletzt gelesen entfernen?" +STR_REMOVE_READ_FROM_RECENTS: "Gelesene Bücher aus der Zuletzt-Liste löschen" +STR_RENAME: "Umbenennen" +STR_RESTARTING_HINT: "Starte neu… Wenn das Gerät nicht neustartet, halte An-Knopf für ein paar Sekunden gedrückt." +STR_RESTORE_LIGHT_ON_WAKE: "Licht beim Aufwachen wiederherstellen" +STR_RESUME: "Fortsetzen" +STR_RETRY: "Wiederh." +STR_SAVE_PASSWORD: "Passwort speichern?" +STR_SAVE_PROGRESS_FAILED: "Fortschritt konnte nicht gespeichert werden" +STR_SCAN_QR_HINT: "oder QR-Code mit dem Handy scannen:" +STR_SCANNING: "Suchen…" +STR_SCREEN_MARGIN: "Seitenränder" +STR_SCREENSHOT_BUTTON: "Screenshot aufnehmen" +STR_SD_CARD: "SD-Karte" +STR_SD_FIRMWARE_UPDATE: "SD-Karte Firmware Update" +STR_SEARCH: "Suche" +STR_SECTION_PREFIX: "Abschnitt" +STR_SELECT_CHAPTER: "Kapitel auswählen" +STR_SELECT_FIRMWARE_FILE: "Wähle Firmware-Datei (.bin)" +STR_SELECT: "Auswahl" +STR_SELECTED: "Ausgewählt" +STR_SEND_METADATA: "Metadaten senden" +STR_SERVER_NAME: "Servername" +STR_SET_CREDENTIALS_FIRST: "Zuerst anmelden" +STR_SET_SLEEP_COVER: "Wähle Cover" +STR_SETTINGS_TITLE: "Einstellungen" +STR_SHORT_PWR_BTN: "An-Taste kurz drücken" +STR_SHOW_HIDDEN_FILES: "Versteckte Dateien anzeigen" STR_SHOW_NETWORKS: "Zeige Netzwerke" -STR_SIGNUP_FAILED: "Anmeldung fehlgeschlagen" +STR_SHOW_READER_MENU: "Lesemenü anzeigen" +STR_SHOW: "Zeigen" +STR_SIDE_BTN_LAYOUT: "Seitliche Tasten (Lesen)" STR_SIGN_UP: "Anmelden" -STR_SMART_SYNC: "Smart" +STR_SIGNUP_FAILED: "Anmeldung fehlgeschlagen" +STR_SIZE: "Größe" +STR_SLEEP_COVER_FILTER: "Standby-Coverfilter" +STR_SLEEP_COVER_MODE: "Standby-Bildmodus" +STR_SLEEP_NEVER: "Nie" +STR_SLEEP_SCREEN: "Standby-Bild" +STR_SLEEP_TIMER_VALUE_FORMAT: "%u Min." +STR_SLEEP: "Standby" +STR_SLEEPING: "STANDBY" +STR_SMART_SYNC: "Smart Sync" +STR_SPACING_100_PERCENT: "100%" +STR_SPACING_125_PERCENT: "125%" +STR_SPACING_150_PERCENT: "150%" +STR_SPACING_175_PERCENT: "175%" +STR_SPACING_200_PERCENT: "200%" +STR_SPACING_50_PERCENT: "50%" +STR_SPACING_75_PERCENT: "75%" +STR_SPACING_MINUS_1: "-1 px" +STR_SPACING_MINUS_2: "-2 px" +STR_SPACING_PLUS_1: "+1 px" +STR_SPACING_PLUS_2: "+2 px" +STR_SPACING_ZERO: "0 px" +STR_START_READING: "Lesen beginnen" +STR_STARTING_HOTSPOT: "Hotspot starten…" +STR_STATE_OFF: "Aus" +STR_STATE_ON: "An" +STR_STATE_SWIPE_UP: "Nach oben wischen" +STR_STATE_TAP: "Tippen" +STR_STEP_HINT_FRONT: "Vordere Tasten:" +STR_STEP_HINT_SIDE: "Seitentasten:" +STR_STYLE: "Stil" +STR_SUNLIGHT_FADING_FIX: "Anti-Verblassen bei Sonnenlicht" +STR_SWIPE_ONLY: "Nur Wischen" STR_SYNC_BEHAVIOR: "Synchronisierungsverhalten" +STR_SYNC_FAILED_MSG: "Fehlgeschlagen" +STR_SYNC_PROGRESS: "Fortschritt synchronisieren" +STR_SYNC_READY: "KOReader-Synchronisierung bereit" +STR_SYNC_SERVER_URL: "Sync-Server-URL" +STR_SYNCING_TIME: "Zeit synchronisieren…" +STR_TAP_AND_SWIPE: "Tippen & Wischen" +STR_TAP_ONLY: "Nur Tippen" STR_TAP_TO_RETRY: "Tippen zum Wiederholen" -STR_USERNAME_TAKEN: "Benutzername ist bereits registriert" -STR_FILE_OPEN_FAILED: "Kann Datei nicht öffnen" -STR_INVALID_BMP_FILE: "Ungültige BMP-Datei" -STR_DICT_DECOMPRESS_ERROR: "Konnte Definition nicht entpacken" -STR_DICT_LOW_MEMORY: "Nicht genug Speicher" -STR_DICT_READ_FAILED: "Konnte Definition nicht lesen" +STR_TEXT_AA: "Schriftglättung" +STR_TEXT_SETTINGS: "Texteinstellungen" +STR_THEME_CLASSIC: "Klassisch" +STR_THEME_COVER_GRID: "Cover Raster" +STR_THEME_LYRA_EXTENDED: "Lyra Extended" +STR_THEME_LYRA: "Lyra" +STR_THEME_ROUNDEDRAFF: "RoundedRaff" +STR_TIGHT: "Eng" +STR_TILT_PAGE_TURN: "Neigen zum Blättern" +STR_TIME_TO_SLEEP: "Standby-Modus nach" +STR_TIMEZONE: "Zeitzone" +STR_TITLE: "Titel" +STR_TO_PREFIX: "mit " +STR_TOGGLE_BOOKMARK: "Lesezeichen hinzufügen/entfernen" +STR_TOGGLE_FRONTLIGHT: "Ändere Hintergrundbeleuchtung" +STR_TOGGLE: "Ändern" +STR_TOOL_CONTENTS: "Inhalt" +STR_TOOL_MORE: "Mehr" +STR_TOOL_TEXT: "Text" +STR_TOP: "Oben" +STR_TOUCH_READER_CONTROLS: "Touch-Steuerung beim Lesen" STR_TOUCH_TOGGLE: "Touch" -STR_SHOW_READER_MENU: "Lesemenü anzeigen" -STR_READER_MENU: "Lesemenü" -STR_STATE_TAP: "Tippen" -STR_STATE_SWIPE_UP: "Nach oben wischen" -STR_FRONTLIGHT: "Frontlicht" -STR_BRIGHTNESS: "Helligkeit" -STR_WARMTH: "Wärme" -STR_RESTORE_LIGHT_ON_WAKE: "Licht beim Aufwachen wiederherstellen" -STR_NO_BOOKMARKS: "Keine Lesezeichen" STR_TRANSPARENT: "Transparent" -STR_ALL_FONTS: "Alle Schriftarten" -STR_FIRMWARE_WRONG_DEVICE: "Firmware ist für ein anderes Gerät" +STR_UI_THEME: "System-Design" +STR_UNASSIGNED: "Leer" +STR_UNNAMED: "Unbenannt" +STR_UPDATE_ALL: "Alle updaten" +STR_UPDATE_AVAILABLE: "Update" +STR_UPDATE_COMPLETE: "Update fertig" +STR_UPDATE_FAILED: "Updatefehler" +STR_UPDATE: "Update" +STR_UPDATING: "Aktualisiere…" +STR_UPLOAD_LOCAL: "Lokalen Fortschritt hochladen" +STR_UPLOAD_PROGRESS: "Fortschritt hochladen…" +STR_UPLOAD_PROMPT: "Aktuelle Position hochladen?" +STR_UPLOAD_SUCCESS: "Der Fortschritt wurde erfolgreich hochgeladen." +STR_UPLOAD: "Hochladen" +STR_USB_DRIVE_CONNECT_DELAY: "Der Verbindungsaufbau kann bis zu 30 Sekunden dauern" +STR_USB_DRIVE_CONNECTED: "USB-Laufwerk verbunden" +STR_USB_DRIVE_DESC: "SD-Karte am Computer verwalten" +STR_USB_DRIVE_EJECT_HINT: "Laufwerk am Computer auswerfen oder Kabel trennen, um zur Startseite zurückzukehren." +STR_USB_DRIVE_ERROR: "USB-Laufwerkfehler. Kabel trennen, um zur Startseite zurückzukehren." +STR_USB_DRIVE_PREPARING: "USB-Laufwerk wird vorbereitet..." +STR_USB_DRIVE_START_ERROR: "USB-Laufwerk konnte nicht starten. Zurück drücken für Startseite." +STR_USB_DRIVE_WAITING: "Lesegerät mit Computer verbinden" +STR_USB_DRIVE: "USB-Laufwerk" +STR_USERNAME_TAKEN: "Benutzername ist bereits registriert" +STR_USERNAME: "Benutzername" +STR_VALIDATING_FIRMWARE: "Validiere Firmware-Datei…" +STR_WARMTH: "Wärme" +STR_WIDE: "Breit" +STR_WIFI_CONN_FAILED: "WLAN-Verbindung fehlgeschlagen" +STR_WIFI_NETWORKS: "WLAN-Netzwerke" +STR_WORD_SPACING: "Wortabstand" +STR_XTC_STATUS_BAR: "XTC-Statuszeile" +STR_YES: "Ja" diff --git a/lib/I18n/translations/hebrew.yaml b/lib/I18n/translations/hebrew.yaml index 6b865065206..0445860e808 100644 --- a/lib/I18n/translations/hebrew.yaml +++ b/lib/I18n/translations/hebrew.yaml @@ -104,6 +104,7 @@ STR_IMAGES_SUPPRESS: "הסתר תמונות" STR_EOB_HOME: "מסך הבית" STR_EOB_CONTINUE_WITH: "המשך עם" STR_SHORT_PWR_BTN: "לחיצה קצרה על לחצן ההפעלה" +STR_DBL_CLICK_PWR_LIGHT: "לחיצה כפולה על לחצן ההפעלה: תאורה" STR_ORIENTATION: "כיוון מסך" STR_SIDE_BTN_LAYOUT: "פריסת לחצני צד (בקריאה)" STR_TOUCH_READER_CONTROLS: "שליטה במגע (קורא)" @@ -132,11 +133,33 @@ STR_PARA_ALIGNMENT: "יישור פסקה" STR_HYPHENATION: "מיקוף" STR_TIME_TO_SLEEP: "טיימר למצב שינה" STR_SHOW_HIDDEN_FILES: "הצג קבצים נסתרים" +STR_LIBRARY: "ספרייה" +STR_LIBRARY_EMPTY: "לא נמצאו ספרים בכרטיס ה-SD" +STR_LIBRARY_NO_RESULTS: "אין ספרים התואמים לחיפוש זה" +STR_LIBRARY_SEARCH_UNAVAILABLE: "החיפוש אינו זמין: אין מספיק זיכרון" +STR_LIBRARY_UNKNOWN_TITLE: "(ללא כותרת)" +STR_LIBRARY_UNKNOWN_AUTHOR: "מחבר לא ידוע" +STR_LIBRARY_TITLE_UNSORTED: "ספרייה (לא ממוינת)" +STR_LIBRARY_TAB_RECENT: "אחרונים" +STR_LIBRARY_TAB_TITLE: "כותרת" +STR_LIBRARY_TAB_AUTHOR: "מחבר" +STR_LIBRARY_SEARCH: "חיפוש" +STR_LIBRARY_HOLD_SORT: "לחיצה ארוכה: היפוך סדר המיון" +STR_LIBRARY_HOLD_GROUPS: "לחיצה ארוכה: קבוצות" +STR_LIBRARY_HOLD_OPTIONS: "לחיצה ארוכה: אפשרויות" +STR_LIBRARY_POSITION: "%d/%d ספרים" +STR_LIBRARY_GROUP_POSITION: "%d/%d קבוצות" +STR_LIBRARY_USE_METADATA: "השתמש במטא-נתונים של הספר" +STR_LIBRARY_REBUILD: "בנה מחדש את אינדקס הספרייה" +STR_LIBRARY_REBUILDING: "קורא את הספרים שלך..." +STR_LIBRARY_REBUILD_DONE: "אינדקס הספרייה נבנה מחדש" +STR_LIBRARY_REBUILD_FAILED: "לא ניתן לבנות מחדש את האינדקס" STR_REFRESH_FREQ: "תדירות רענון מסך" STR_KOREADER_SYNC: "סנכרון KOReader" STR_CHECK_UPDATES: "בדוק עדכונים" STR_LANGUAGE: "שפה" STR_KEYBOARD_LAYOUTS: "פריסות מקלדת" +STR_ABOUT: "אודות" STR_CLEAR_READING_CACHE: "מחק קבצי מטמון" STR_USERNAME: "שם משתמש" STR_PASSWORD: "סיסמה" @@ -195,6 +218,14 @@ STR_ORIENTATION_INVERTED: "לאורך 180°" STR_LANDSCAPE_CCW: "לרוחב (שמאלה)" STR_PREV_NEXT: "הקודם/הבא" STR_NEXT_PREV: "הבא/הקודם" +STR_NEXT_NEXT: "הבא/הבא" +STR_PREV_PREV: "הקודם/הקודם" +STR_NEXT_PAGE_GESTURE: "מחווה לדף הבא" +STR_PREV_PAGE_GESTURE: "מחווה לדף הקודם" +STR_TAP_AND_SWIPE: "הקשה והחלקה" +STR_TAP_ONLY: "הקשה בלבד" +STR_SWIPE_ONLY: "החלקה בלבד" +STR_INVERTED_TAP: "הקשה הפוכה" STR_STATE_TAP: "הקשה" STR_STATE_SWIPE_UP: "החלקה מעלה" STR_FRONTLIGHT: "תאורה קדמית" @@ -232,6 +263,8 @@ STR_DOWNLOADING: "מוריד..." STR_DOWNLOAD_FAILED: "ההורדה נכשלה" STR_ERROR_MSG: "שגיאה:" STR_UNNAMED: "ללא שם" +STR_HOLD_OPEN_TO_REMOVE: "לחיצה ארוכה על 'פתח' כדי להסיר" +STR_HOLD_OPEN_FOR_ACTIONS: "לחיצה ארוכה על 'פתח' להצגת פעולות" STR_NO_SERVER_URL: "לא הוגדרה כתובת שרת" STR_FETCH_FEED_FAILED: "טעינת הפיד נכשלה" STR_PARSE_FEED_FAILED: "שגיאה בקריאת הפיד" @@ -325,6 +358,7 @@ STR_GO_HOME_BUTTON: "חזור למסך הבית" STR_SYNC_PROGRESS: "סנכרן התקדמות" STR_DELETE_CACHE: "מחק קבצי מטמון (Cache)" STR_DELETE: "מחק" +STR_RENAME: "שנה שם" STR_DISPLAY_QR: "הצג כקוד QR" STR_CHAPTER_PREFIX: "פרק: " STR_PAGES_SEPARATOR: " דפים | " @@ -384,6 +418,7 @@ STR_LOADING_FONT_LIST: "טוען רשימת גופנים..." STR_NO_FONTS_AVAILABLE: "אין גופנים זמינים" STR_FONT_INSTALLED: "הגופן הותקן!" STR_FONT_INSTALL_FAILED: "התקנת הגופן נכשלה" +STR_INVALID_FONT_MANIFEST: "מניפסט הגופנים אינו תקין" STR_INSTALLED: "מותקן" STR_CONFIRM_DOWNLOAD_PROMPT: "להוריד?" STR_SD_CARD_FULL: "אין מספיק מקום בכרטיס ה-SD" @@ -438,6 +473,10 @@ STR_CLOCK: "שעון" STR_CLOCK_FORMAT: "תבנית השעון" STR_CLOCK_FORMAT_24H: "תצוגת 24 שעות" STR_CLOCK_FORMAT_12H: "תצוגת 12 שעות" +STR_TIMEZONE: "אזור זמן" +STR_CLOCK_IN_HEADER: "הצג בכותרת" +STR_CLOCK_DST: "שעון קיץ" +STR_CLOCK_DST_AUTO: "אוטומטי" STR_CURRENT_TIME: "שעה נוכחית:" STR_CLOCK_SYNC: "סנכרון שעון" STR_CLOCK_SYNC_NOW: "סנכרן שעון כעת" @@ -466,3 +505,27 @@ STR_STEP_HINT_FRONT: "לחצנים קדמיים:" STR_STEP_HINT_SIDE: "לחצני צד:" STR_ADD_HIDDEN_NETWORK: "הוסף רשת מוסתרת..." STR_ENTER_WIFI_SSID: "הזן שם רשת (SSID)" + +STR_HOME_SHORTCUT: "מסך הבית" +STR_HOME_BUTTON: "מחוות לחצן הבית" +STR_CONFIGURE: "הגדר" +STR_HOME_BUTTON_TAP: "הקשה" +STR_HOME_BUTTON_DOUBLE_TAP: "הקשה כפולה" +STR_HOME_BUTTON_LONG_PRESS: "לחיצה ארוכה" +STR_TOGGLE_FRONTLIGHT: "הדלק/כבה תאורה קדמית" + +STR_THEME_COVER_GRID: "רשת כריכות" +STR_WORD_SPACING: "מרווח בין מילים" +STR_CHARACTER_SPACING: "מרווח בין תווים" +STR_SPACING_MINUS_2: "-2 px" +STR_SPACING_MINUS_1: "-1 px" +STR_SPACING_ZERO: "0 px" +STR_SPACING_PLUS_1: "+1 px" +STR_SPACING_PLUS_2: "+2 px" +STR_SPACING_50_PERCENT: "50%" +STR_SPACING_75_PERCENT: "75%" +STR_SPACING_100_PERCENT: "100%" +STR_SPACING_125_PERCENT: "125%" +STR_SPACING_150_PERCENT: "150%" +STR_SPACING_175_PERCENT: "175%" +STR_SPACING_200_PERCENT: "200%" diff --git a/lib/I18n/translations/italian.yaml b/lib/I18n/translations/italian.yaml index c1745959166..f0072b46c22 100644 --- a/lib/I18n/translations/italian.yaml +++ b/lib/I18n/translations/italian.yaml @@ -3,6 +3,7 @@ _language_code: "IT" _order: "12" _bcp47: "it" +STR_ABOUT: "Informazioni" STR_ACCOUNT_CREATED: "Account creato" STR_ADD_HIDDEN_NETWORK: "Aggiungi rete nascosta..." STR_ADD_SERVER: "Aggiungi server" @@ -60,6 +61,7 @@ STR_CENTER: "Centrato" STR_CHAPTER: "Capitolo" STR_CHAPTER_PAGE_COUNT: "Conteggio pagine capitolo" STR_CHAPTER_PREFIX: "Capitolo: " +STR_CHARACTER_SPACING: "Spaziatura caratteri" STR_CHECK_SERIAL_OUTPUT: "Controllare l'output seriale per dettagli" STR_CHECK_UPDATES: "Controllo aggiornamenti" STR_CHECKING_UPDATE: "Ricerca aggiornamenti..." @@ -73,9 +75,12 @@ STR_CLEAR_CACHE_WARNING_4: "alla riapertura." STR_CLEAR_READING_CACHE: "Svuota cache di lettura" STR_CLEARING_CACHE: "Svuotamento cache..." STR_CLOCK: "Orologio" +STR_CLOCK_DST: "Ora legale" +STR_CLOCK_DST_AUTO: "Auto" STR_CLOCK_FORMAT: "Formato orologio" STR_CLOCK_FORMAT_12H: "12 ore" STR_CLOCK_FORMAT_24H: "24 ore" +STR_CLOCK_IN_HEADER: "Mostra nell'intestazione" STR_CLOCK_SYNC: "Sincronizza orologio" STR_CLOCK_SYNC_FAIL: "Sincronizzazione non riuscita" STR_CLOCK_SYNC_NO_WIFI: "Wi-Fi non connesso" @@ -84,6 +89,7 @@ STR_CLOCK_SYNC_NOW: "Sincronizza l'orologio adesso" STR_CLOCK_SYNC_OK: "Orologio sincronizzato" STR_CLOCK_SYNCED: "Orologio sincronizzato" STR_CLOCK_SYNCING: "Sincronizzazione con il server NTP..." +STR_CONFIGURE: "Configura" STR_CONFIRM: "Conferma" STR_CONFIRM_DELETE_BOOKMARK: "Eliminare questo segnalibro?" STR_CONNECT: "Connetti" @@ -108,6 +114,7 @@ STR_CURRENT_VERSION: "Versione attuale: " STR_CUSTOM: "Sfondo" STR_CUSTOMISE_STATUS_BAR: "Personalizza barra di stato" STR_DARK: "Scuro" +STR_DBL_CLICK_PWR_LIGHT: "Press. doppia pul. Accens. per luce" STR_DEFAULT_VALUE: "Predefinito" STR_DELETE: "Elimina" STR_DELETE_CACHE: "Elimina cache libro" @@ -191,8 +198,15 @@ STR_GO_TO_PERCENT: "Vai alla percentuale..." STR_HASH_FAILED: "Impossibile calcolare l'hash del documento" STR_HIDE: "Nascondi" STR_HIDE_BATTERY: "Nascondi % batteria" +STR_HOLD_OPEN_FOR_ACTIONS: "Tieni premuto Apri per azioni" STR_HOLD_OPEN_TO_DELETE: "Tieni premuto Apri per eliminare" +STR_HOLD_OPEN_TO_REMOVE: "Tieni premuto Apri per rimuovere" STR_HOME: "« Home" +STR_HOME_BUTTON: "Gesti pulsante Home" +STR_HOME_BUTTON_DOUBLE_TAP: "Doppio tocco" +STR_HOME_BUTTON_LONG_PRESS: "Pressione lunga" +STR_HOME_BUTTON_TAP: "Tocco" +STR_HOME_SHORTCUT: "Home" STR_HOTSPOT_DESC: "Creazione di una rete Wi-Fi" STR_HOTSPOT_MODE: "Modalità Hotspot" STR_HW_BACK_LABEL: "Indietro (1° pul.)" @@ -211,7 +225,9 @@ STR_INDEXING: "Indicizzazione..." STR_INSTALLED: "Installato" STR_INVALID_BMP_FILE: "File BMP non valido" STR_INVALID_FIRMWARE: "File firmware non valido" +STR_INVALID_FONT_MANIFEST: "Font manifest non valido" STR_INVERTED: "Invertito" +STR_INVERTED_TAP: "Tocco invertito" STR_IP_ADDRESS_PREFIX: "Indirizzo IP: " STR_ITEMS_REMOVED: "elementi rimossi" STR_JOIN_DESC: "Connessione a una rete Wi-Fi" @@ -246,6 +262,27 @@ STR_LANDSCAPE_CCW: "Orizzontale Sx" STR_LANDSCAPE_CW: "Orizzontale Dx" STR_LANGUAGE: "Lingua" STR_LAYOUT: "Layout" +STR_LIBRARY: "Libreria" +STR_LIBRARY_EMPTY: "Nessun libro trovato nella scheda SD" +STR_LIBRARY_GROUP_POSITION: "%d/%d gruppi" +STR_LIBRARY_HOLD_GROUPS: "Tieni premuto: Gruppi" +STR_LIBRARY_HOLD_OPTIONS: "Tieni premuto: Opzioni" +STR_LIBRARY_HOLD_SORT: "Tieni premuto: Inverti Ordine" +STR_LIBRARY_NO_RESULTS: "Nessun libro corrisponde alla ricerca" +STR_LIBRARY_POSITION: "%d/%d libri" +STR_LIBRARY_REBUILD: "Ricostruisci libreria" +STR_LIBRARY_REBUILD_DONE: "Indice libreria ricostruito" +STR_LIBRARY_REBUILD_FAILED: "Impossibile ricostruire indice" +STR_LIBRARY_REBUILDING: "Lettura libri in corso..." +STR_LIBRARY_SEARCH: "Ricerca" +STR_LIBRARY_SEARCH_UNAVAILABLE: "Ricerca non disponibile: memoria insufficiente" +STR_LIBRARY_TAB_AUTHOR: "Autore" +STR_LIBRARY_TAB_RECENT: "Recente" +STR_LIBRARY_TAB_TITLE: "Titolo" +STR_LIBRARY_TITLE_UNSORTED: "Libreria (non ordinata)" +STR_LIBRARY_UNKNOWN_AUTHOR: "Autore sconosciuto" +STR_LIBRARY_UNKNOWN_TITLE: "(senza titolo)" +STR_LIBRARY_USE_METADATA: "Usa metadati del libro" STR_LIGHT: "Chiaro" STR_LINE_SPACING: "Interlinea" STR_LINK: "[link]" @@ -272,7 +309,9 @@ STR_NETWORKS_FOUND: "%zu reti trovate" STR_NEVER: "Mai" STR_NEW_UPDATE: "Nuovo aggiornamento disponibile!" STR_NEW_VERSION: "Nuova versione: " +STR_NEXT_NEXT: "Succ/Succ" STR_NEXT_PAGE: "Pag. successiva »" +STR_NEXT_PAGE_GESTURE: "Pag. successiva" STR_NEXT_PREV: "Succ/Prec" STR_NIGHT_MODE: "Modalità notte" STR_NO: "No" @@ -326,6 +365,8 @@ STR_POWER_ON_HINT: "Tenere premuto il tasto di accensione per riavviare" STR_PRESS_OK_SCAN: "Premere OK per ripetere scansione" STR_PREV_NEXT: "Prec/Succ" STR_PREV_PAGE: "« Pag. precedente" +STR_PREV_PAGE_GESTURE: "Pag. precedente" +STR_PREV_PREV: "Prec/prec" STR_PREVIEW: "Anteprima" STR_PROGRESS_BAR: "Barra di avanzamento" STR_PROGRESS_BAR_MEDIUM: "Medio" @@ -348,6 +389,7 @@ STR_REMAP_RESET_HINT: "Laterale Su: ripristina predefiniti" STR_REMOTE_LABEL: "Remoto:" STR_REMOVE_FROM_RECENTS: "Rimuovere da Libri recenti?" STR_REMOVE_READ_FROM_RECENTS: "Cancella libri letti dai recenti" +STR_RENAME: "Rinomina" STR_RESTARTING_HINT: "Riavvio... se il dispositivo non si accende, tenere premuto il tasto per qualche secondo." STR_RESTORE_LIGHT_ON_WAKE: "Ripristina luce al risveglio" STR_RESUME: "Riprendi" @@ -388,6 +430,18 @@ STR_SLEEP_SCREEN: "Schermata sospensione" STR_SLEEP_TIMER_VALUE_FORMAT: "%u min" STR_SLEEPING: "SOSPENSIONE" STR_SMART_SYNC: "Smart sync" +STR_SPACING_100_PERCENT: "100%" +STR_SPACING_125_PERCENT: "125%" +STR_SPACING_150_PERCENT: "150%" +STR_SPACING_175_PERCENT: "175%" +STR_SPACING_200_PERCENT: "200%" +STR_SPACING_50_PERCENT: "50%" +STR_SPACING_75_PERCENT: "75%" +STR_SPACING_MINUS_1: "-1 px" +STR_SPACING_MINUS_2: "-2 px" +STR_SPACING_PLUS_1: "+1 px" +STR_SPACING_PLUS_2: "+2 px" +STR_SPACING_ZERO: "0 px" STR_START_READING: "Inizia a leggere di seguito" STR_STARTING_HOTSPOT: "Avvio Hotspot..." STR_STATE_OFF: "OFF" @@ -398,26 +452,32 @@ STR_STEP_HINT_FRONT: "Pulsanti frontali:" STR_STEP_HINT_SIDE: "Pulsanti laterali:" STR_STYLE: "Stile" STR_SUNLIGHT_FADING_FIX: "Correzione luce solare" +STR_SWIPE_ONLY: "Solo Scorrimento" STR_SYNC_BEHAVIOR: "Modalità sincronizzazione" STR_SYNC_FAILED_MSG: "Sincronizzazione non riuscita" STR_SYNC_PROGRESS: "Sincronizza posizione" STR_SYNC_READY: "Sincronizzazione KOReader pronta" STR_SYNC_SERVER_URL: "URL server di sincronizzazione" STR_SYNCING_TIME: "Sincronizzazione orario..." +STR_TAP_AND_SWIPE: "Tocco & Scorrimento" +STR_TAP_ONLY: "Solo Tocco" STR_TAP_TO_RETRY: "Tocca per riprovare" STR_TEXT_AA: "Anti-aliasing testo" STR_TEXT_SETTINGS: "Impostazioni testo" STR_THEME_CLASSIC: "Classico" +STR_THEME_COVER_GRID: "Griglia copertine" STR_THEME_LYRA: "Lyra" STR_THEME_LYRA_EXTENDED: "Lyra esteso" STR_THEME_ROUNDEDRAFF: "RoundedRaff" STR_TIGHT: "Compatto" STR_TILT_PAGE_TURN: "Inclina per cambio pagina" STR_TIME_TO_SLEEP: "Timeout sospensione" +STR_TIMEZONE: "Fuso orario" STR_TITLE: "Titolo" STR_TO_PREFIX: "a " STR_TOGGLE: "Cambia" STR_TOGGLE_BOOKMARK: "Attiva/disattiva segnalibro" +STR_TOGGLE_FRONTLIGHT: "Attiva/disattiva luce" STR_TOOL_CONTENTS: "Contenuti" STR_TOOL_MORE: "Altro" STR_TOOL_TEXT: "Testo" @@ -455,5 +515,6 @@ STR_WARMTH: "Temperatura" STR_WIDE: "Largo" STR_WIFI_CONN_FAILED: "Connessione Wi-Fi non riuscita" STR_WIFI_NETWORKS: "Reti Wi-Fi" +STR_WORD_SPACING: "Spaziatura parole" STR_XTC_STATUS_BAR: "Barra di stato XTC" STR_YES: "Sì" diff --git a/lib/I18n/translations/portuguese-BR.yaml b/lib/I18n/translations/portuguese-BR.yaml index 4083454ac09..d094b02a965 100644 --- a/lib/I18n/translations/portuguese-BR.yaml +++ b/lib/I18n/translations/portuguese-BR.yaml @@ -16,6 +16,9 @@ STR_NO_OPEN_BOOK: "Nenhum livro aberto" STR_START_READING: "Comece a ler abaixo" STR_NO_FILES_FOUND: "Nenhum arquivo encontrado" STR_SELECT_CHAPTER: "Escolher capítulo" +STR_TOOL_CONTENTS: "Sumário" +STR_TOOL_TEXT: "Texto" +STR_TOOL_MORE: "Mais" STR_NO_CHAPTERS: "Sem capítulos" STR_END_OF_BOOK: "Fim do livro" STR_EMPTY_CHAPTER: "Capítulo vazio" @@ -58,6 +61,7 @@ STR_USB_DRIVE_DESC: "Gerencie o cartão SD pelo computador" STR_USB_DRIVE_PREPARING: "Preparando unidade USB..." STR_USB_DRIVE_WAITING: "Conecte o leitor ao computador" STR_USB_DRIVE_CONNECTED: "Unidade USB conectada" +STR_USB_DRIVE_CONNECT_DELAY: "Pode levar até 30 segundos para conectar" STR_USB_DRIVE_EJECT_HINT: "Ejete a unidade no computador ou desconecte o cabo para voltar ao início." STR_USB_DRIVE_ERROR: "Erro na unidade USB. Desconecte o cabo para voltar ao início." STR_USB_DRIVE_START_ERROR: "Não foi possível iniciar a unidade USB. Pressione Voltar para o início." @@ -76,7 +80,7 @@ STR_CHECKING_WIFI: "Verificando Wi-Fi..." STR_ENTER_WIFI_PASSWORD: "Digite a senha Wi-Fi" STR_ADD_HIDDEN_NETWORK: "Adicionar rede oculta..." STR_ENTER_WIFI_SSID: "Insira o nome da rede (SSID)" -STR_TO_PREFIX: "para" +STR_TO_PREFIX: "para " STR_CALIBRE_RECEIVING: "Recebendo: " STR_CALIBRE_RECEIVED: "Recebido: " STR_CALIBRE_INSTRUCTION_1: "1) Instale o plugin CrossPoint Reader" @@ -88,6 +92,7 @@ STR_CAT_READER: "Leitor" STR_CAT_CONTROLS: "Controles" STR_CAT_SYSTEM: "Sistema" STR_SLEEP_SCREEN: "Tela de repouso" +STR_TOUCH_TOGGLE: "Toque" STR_NIGHT_MODE: "Modo noturno" STR_QUICK_RESUME_TIMEOUT: "Retomada rápida após tempo limite" STR_SLEEP_COVER_MODE: "Modo capa tela repouso" @@ -101,9 +106,11 @@ STR_IMAGES_SUPPRESS: "Ocultar" STR_EOB_HOME: "Início" STR_EOB_CONTINUE_WITH: "Continuar com" STR_SHORT_PWR_BTN: "Clique curto botão ligar" +STR_DBL_CLICK_PWR_LIGHT: "Clique duplo no botão ligar para luz" STR_ORIENTATION: "Orientação de leitura" STR_SIDE_BTN_LAYOUT: "Disposição botões laterais" STR_TOUCH_READER_CONTROLS: "Controles táteis do leitor" +STR_SHOW_READER_MENU: "Mostrar menu do leitor" STR_FRONT_BTN_FOLLOW_ORIENTATION: "Orientar botões frontais" STR_LONG_PRESS_BEHAVIOR: "Comportamento de Pressionar e segurar" STR_LONG_PRESS_BEHAVIOR_OFF: "DESL." @@ -119,6 +126,9 @@ STR_SIZE: "Tamanho" STR_LAYOUT: "Layout" STR_ALIGNMENT: "Alinhamento" STR_STYLE: "Estilo" +STR_READER_MENU_STYLE: "Estilo do menu do leitor" +STR_MENU_STYLE_LIST: "Lista" +STR_MENU_STYLE_TOOLBAR: "Barra de ferramentas" STR_NOT_IN_PREVIEW: "Não exibido na visualização" STR_LINE_SPACING: "Espaçamento entre linhas" STR_SCREEN_MARGIN: "Margens" @@ -126,6 +136,27 @@ STR_PARA_ALIGNMENT: "Alinhamento de parágrafo" STR_HYPHENATION: "Hifenização" STR_TIME_TO_SLEEP: "Tempo para repousar" STR_SHOW_HIDDEN_FILES: "Mostrar arquivos ocultos" +STR_LIBRARY: "Biblioteca" +STR_LIBRARY_EMPTY: "Nenhum livro encontrado no cartão SD" +STR_LIBRARY_NO_RESULTS: "Nenhum livro corresponde a esta busca" +STR_LIBRARY_SEARCH_UNAVAILABLE: "Busca indisponível: memória insuficiente" +STR_LIBRARY_UNKNOWN_TITLE: "(sem título)" +STR_LIBRARY_UNKNOWN_AUTHOR: "Autor desconhecido" +STR_LIBRARY_TITLE_UNSORTED: "Biblioteca (não ordenada)" +STR_LIBRARY_TAB_RECENT: "Recentes" +STR_LIBRARY_TAB_TITLE: "Título" +STR_LIBRARY_TAB_AUTHOR: "Autor" +STR_LIBRARY_SEARCH: "Buscar" +STR_LIBRARY_HOLD_SORT: "Segurar: Inverter ordem" +STR_LIBRARY_HOLD_GROUPS: "Segurar: Grupos" +STR_LIBRARY_HOLD_OPTIONS: "Segurar: Opções" +STR_LIBRARY_POSITION: "%d/%d livros" +STR_LIBRARY_GROUP_POSITION: "%d/%d grupos" +STR_LIBRARY_USE_METADATA: "Usar metadados dos livros" +STR_LIBRARY_REBUILD: "Atualizar biblioteca" +STR_LIBRARY_REBUILDING: "Lendo seus livros..." +STR_LIBRARY_REBUILD_DONE: "Índice da biblioteca reconstruído" +STR_LIBRARY_REBUILD_FAILED: "Não foi possível reconstruir o índice" STR_REMOVE_READ_FROM_RECENTS: "Limpar livros lidos da lista de recentes" STR_MOVE_FINISHED_TO_READ: "Mover livros lidos para a pasta Read" STR_REFRESH_FREQ: "Frequência atualização" @@ -133,6 +164,7 @@ STR_KOREADER_SYNC: "Sincronização KOReader" STR_CHECK_UPDATES: "Verificar atualizações" STR_LANGUAGE: "Idioma" STR_KEYBOARD_LAYOUTS: "Layouts de teclado" +STR_ABOUT: "Sobre" STR_CLEAR_READING_CACHE: "Limpar cache de leitura" STR_USERNAME: "Nome de usuário" STR_PASSWORD: "Senha" @@ -191,9 +223,18 @@ STR_ORIENTATION_INVERTED: "Retrato 180°" STR_LANDSCAPE_CCW: "Paisagem AH" STR_PREV_NEXT: "Ant/Próx" STR_NEXT_PREV: "Próx/Ant" +STR_NEXT_NEXT: "Próx/Próx" +STR_PREV_PREV: "Ant/Ant" +STR_NEXT_PAGE_GESTURE: "Próxima página" +STR_PREV_PAGE_GESTURE: "Página anterior" +STR_TAP_AND_SWIPE: "Toque e deslize" +STR_TAP_ONLY: "Apenas toque" +STR_SWIPE_ONLY: "Apenas deslizar" +STR_INVERTED_TAP: "Toque invertido" STR_KOSYNC: "KOSync" STR_BOOKMARK_OPTION: "Marcador" STR_DICTIONARY: "Dicionário" +STR_READER_MENU: "Menu do leitor" STR_DISABLED: "Desabilitado" STR_NOTO_SERIF: "Noto Serif" STR_NOTO_SANS: "Noto Sans" @@ -227,6 +268,8 @@ STR_DOWNLOAD_FAILED: "Falha no download" STR_ERROR_MSG: "Erro:" STR_UNNAMED: "Sem nome" STR_HOLD_OPEN_TO_DELETE: "Segure 'Abrir' para excluir" +STR_HOLD_OPEN_TO_REMOVE: "Mantenha Abrir pressionado para remover" +STR_HOLD_OPEN_FOR_ACTIONS: "Mantenha Abrir pressionado para ações" STR_NO_SERVER_URL: "Nenhum URL servidor configurado" STR_FETCH_FEED_FAILED: "Falha ao buscar o feed" STR_PARSE_FEED_FAILED: "Falha ao interpretar o feed" @@ -258,6 +301,12 @@ STR_SHOW: "Mostrar" STR_HIDE: "Ocultar" STR_STATE_ON: "LIG." STR_STATE_OFF: "DESL." +STR_STATE_TAP: "Toque" +STR_STATE_SWIPE_UP: "Deslizar para cima" +STR_FRONTLIGHT: "Luz frontal" +STR_BRIGHTNESS: "Brilho" +STR_WARMTH: "Temperatura" +STR_RESTORE_LIGHT_ON_WAKE: "Restaurar iluminação ao despertar" STR_NOT_SET: "Não definido" STR_DIR_LEFT: "Esquerda" STR_DIR_RIGHT: "Direita" @@ -291,6 +340,10 @@ STR_CLOCK: "Relógio" STR_CLOCK_FORMAT: "Formato do Relógio" STR_CLOCK_FORMAT_24H: "24 horas" STR_CLOCK_FORMAT_12H: "12 horas" +STR_TIMEZONE: "Fuso horário" +STR_CLOCK_IN_HEADER: "Exibir no cabeçalho" +STR_CLOCK_DST: "Horário de verão" +STR_CLOCK_DST_AUTO: "Automático" STR_CURRENT_TIME: "Hora atual:" STR_CLOCK_SYNC: "Sincronizar Relógio" STR_CLOCK_SYNC_NOW: "Sincronizar relógio agora" @@ -308,12 +361,14 @@ STR_THEME_LYRA_EXTENDED: "Lyra Estendido" STR_SUNLIGHT_FADING_FIX: "Ajuste desbotamento ao sol" STR_REMAP_FRONT_BUTTONS: "Remapear botões frontais" STR_BOOKMARKS: "Marcadores" +STR_NO_BOOKMARKS: "Nenhum marcador" STR_BOOKMARK_ADDED: "Marcador adicionado." STR_BOOKMARK_REMOVED: "Marcador removido." STR_OPDS_BROWSER: "Navegador OPDS" STR_SEARCH: "Busca" STR_COVER_CUSTOM: "Capa + personalizado" STR_QUICK_RESUME: "Retomada rápida" +STR_TRANSPARENT: "Transparente" STR_MENU_RECENT_BOOKS: "Livros recentes" STR_REMOVE_FROM_RECENTS: "Remover dos Livros recentes?" STR_CALIBRE_DESC: "Usar transferências sem fio Calibre" @@ -338,6 +393,7 @@ STR_GO_HOME_BUTTON: "Ir para o início" STR_SYNC_PROGRESS: "Sincronizar progresso" STR_DELETE_CACHE: "Excluir cache do livro" STR_DELETE: "Excluir" +STR_RENAME: "Renomear" STR_CONFIRM_DELETE_BOOKMARK: "Excluir este marcador?" STR_DISPLAY_QR: "Mostrar página como QR" STR_CHAPTER_PREFIX: "Capítulo:" @@ -396,10 +452,12 @@ STR_AUTO_TURN_ENABLED: "Virada automática ativada: " STR_AUTO_TURN_PAGES_PER_MIN: "Virada automática (páginas por minuto)" STR_MANAGE_FONTS: "Gerenciar fontes" STR_FONT_BROWSER: "Navegador de fontes" +STR_ALL_FONTS: "Todas as fontes" STR_LOADING_FONT_LIST: "Carregando lista de fontes..." STR_NO_FONTS_AVAILABLE: "Nenhuma fonte disponível" STR_FONT_INSTALLED: "Fonte instalada!" STR_FONT_INSTALL_FAILED: "Falha na instalação da fonte" +STR_INVALID_FONT_MANIFEST: "Manifesto de fonte inválido" STR_INSTALLED: "Instalada" STR_DOWNLOAD_ALL: "Baixar tudo" STR_UPDATE_ALL: "Atualizar tudo" @@ -431,9 +489,34 @@ STR_VALIDATING_FIRMWARE: "Validando firmware..." STR_INVALID_FIRMWARE: "Arquivo de firmware inválido" STR_FIRMWARE_TOO_LARGE: "Firmware grande demais para a partição" STR_FIRMWARE_TOO_SMALL: "Arquivo de firmware pequeno demais" +STR_FIRMWARE_WRONG_DEVICE: "O firmware é para um dispositivo diferente" STR_FIRMWARE_UPDATE_PROMPT: "Atualizar firmware?" STR_FIRMWARE_FILE_OPEN_FAILED: "Não foi possível abrir o arquivo" STR_FIRMWARE_WRITE_FAILED: "Falha na gravação do firmware" STR_FIRMWARE_UPDATE_DO_NOT_POWER_OFF: "Não desligue o dispositivo!" STR_RECOVERY_MODE: "Modo de Recuperação" STR_RECOVERY_MODE_HINT: "Coloque o arquivo firmware.bin na raiz do cartão SD e selecione-o" + +STR_HOME_SHORTCUT: "Início" +STR_HOME_BUTTON: "Gestos do botão Início" +STR_CONFIGURE: "Configurar" +STR_HOME_BUTTON_TAP: "Toque" +STR_HOME_BUTTON_DOUBLE_TAP: "Toque duplo" +STR_HOME_BUTTON_LONG_PRESS: "Pressionar e segurar" +STR_TOGGLE_FRONTLIGHT: "Alternar luz frontal" + +STR_THEME_COVER_GRID: "Grade de capas" +STR_WORD_SPACING: "Espaçamento entre palavras" +STR_CHARACTER_SPACING: "Espaçamento entre caracteres" +STR_SPACING_MINUS_2: "-2 px" +STR_SPACING_MINUS_1: "-1 px" +STR_SPACING_ZERO: "0 px" +STR_SPACING_PLUS_1: "+1 px" +STR_SPACING_PLUS_2: "+2 px" +STR_SPACING_50_PERCENT: "50%" +STR_SPACING_75_PERCENT: "75%" +STR_SPACING_100_PERCENT: "100%" +STR_SPACING_125_PERCENT: "125%" +STR_SPACING_150_PERCENT: "150%" +STR_SPACING_175_PERCENT: "175%" +STR_SPACING_200_PERCENT: "200%" diff --git a/lib/I18n/translations/portuguese-PT.yaml b/lib/I18n/translations/portuguese-PT.yaml index 873a5ea3f99..840b2c5c69c 100644 --- a/lib/I18n/translations/portuguese-PT.yaml +++ b/lib/I18n/translations/portuguese-PT.yaml @@ -16,6 +16,9 @@ STR_NO_OPEN_BOOK: "Nenhum livro aberto" STR_START_READING: "Comece a ler abaixo" STR_NO_FILES_FOUND: "Nenhum ficheiro encontrado" STR_SELECT_CHAPTER: "Selecionar capítulo" +STR_TOOL_CONTENTS: "Índice" +STR_TOOL_TEXT: "Texto" +STR_TOOL_MORE: "Mais" STR_NO_CHAPTERS: "Sem capítulos" STR_END_OF_BOOK: "Fim do livro" STR_EMPTY_CHAPTER: "Capítulo vazio" @@ -58,6 +61,7 @@ STR_USB_DRIVE_DESC: "Gira o cartão SD no computador" STR_USB_DRIVE_PREPARING: "A preparar unidade USB..." STR_USB_DRIVE_WAITING: "Ligue o leitor ao computador" STR_USB_DRIVE_CONNECTED: "Unidade USB ligada" +STR_USB_DRIVE_CONNECT_DELAY: "Pode demorar até 30 segundos para ligar" STR_USB_DRIVE_EJECT_HINT: "Ejete a unidade no computador ou desligue o cabo para voltar ao início." STR_USB_DRIVE_ERROR: "Erro na unidade USB. Desligue o cabo para voltar ao início." STR_USB_DRIVE_START_ERROR: "Não foi possível iniciar a unidade USB. Prima Voltar para o início." @@ -88,6 +92,7 @@ STR_CAT_READER: "Leitor" STR_CAT_CONTROLS: "Controlos" STR_CAT_SYSTEM: "Sistema" STR_SLEEP_SCREEN: "Ecrã de repouso" +STR_TOUCH_TOGGLE: "Toque" STR_NIGHT_MODE: "Modo noturno" STR_QUICK_RESUME_TIMEOUT: "Retoma rápida após tempo limite" STR_SLEEP_COVER_MODE: "Modo de capa no ecrã de repouso" @@ -101,15 +106,17 @@ STR_IMAGES_SUPPRESS: "Suprimir" STR_EOB_HOME: "Início" STR_EOB_CONTINUE_WITH: "Continuar com" STR_SHORT_PWR_BTN: "Clique curto no botão de energia" +STR_DBL_CLICK_PWR_LIGHT: "Clique duplo no botão de energia para alternar a luz frontal" STR_ORIENTATION: "Orientação de leitura" STR_SIDE_BTN_LAYOUT: "Disposição dos botões laterais (leitor)" STR_TOUCH_READER_CONTROLS: "Controlos táteis do leitor" +STR_SHOW_READER_MENU: "Mostrar menu do leitor" STR_FRONT_BTN_FOLLOW_ORIENTATION: "Orientar botões frontais" -STR_LONG_PRESS_BEHAVIOR: "Comportamento de pressão longa" +STR_LONG_PRESS_BEHAVIOR: "Comportamento de toque longo" STR_LONG_PRESS_BEHAVIOR_OFF: "DESL." STR_LONG_PRESS_BEHAVIOR_SKIP: "Saltar capítulo" STR_LONG_PRESS_BEHAVIOR_ORIENTATION: "Alterar orientação" -STR_LONG_PRESS_MENU: "Pressão longa no Menu" +STR_LONG_PRESS_MENU: "Toque longo no Menu" STR_FONT_PREVIEW_TEXT: "O juiz zeloso pedia um táxi para o jovem que vendia queijos na praça pública." STR_FONT_FAMILY: "Tipo de letra do leitor" STR_FONT_SIZE: "Tamanho do tipo de letra" @@ -119,6 +126,9 @@ STR_SIZE: "Tamanho" STR_LAYOUT: "Disposição" STR_ALIGNMENT: "Alinhamento" STR_STYLE: "Estilo" +STR_READER_MENU_STYLE: "Estilo do menu do leitor" +STR_MENU_STYLE_LIST: "Lista" +STR_MENU_STYLE_TOOLBAR: "Barra de ferramentas" STR_NOT_IN_PREVIEW: "Não mostrado na pré-visualização" STR_LINE_SPACING: "Espaçamento de linhas" STR_SCREEN_MARGIN: "Margens" @@ -126,13 +136,35 @@ STR_PARA_ALIGNMENT: "Alinhamento de parágrafo" STR_HYPHENATION: "Hifenização" STR_TIME_TO_SLEEP: "Tempo até repousar" STR_SHOW_HIDDEN_FILES: "Mostrar ficheiros ocultos" -STR_REMOVE_READ_FROM_RECENTS: "Limpar livros lidos da lista de recentes" +STR_LIBRARY: "Biblioteca" +STR_LIBRARY_EMPTY: "Nenhum livro encontrado no cartão SD" +STR_LIBRARY_NO_RESULTS: "Nenhum livro corresponde a esta pesquisa" +STR_LIBRARY_SEARCH_UNAVAILABLE: "Pesquisa indisponível: memória insuficiente" +STR_LIBRARY_UNKNOWN_TITLE: "(sem título)" +STR_LIBRARY_UNKNOWN_AUTHOR: "Autor desconhecido" +STR_LIBRARY_TITLE_UNSORTED: "Biblioteca (não ordenada)" +STR_LIBRARY_TAB_RECENT: "Recentes" +STR_LIBRARY_TAB_TITLE: "Título" +STR_LIBRARY_TAB_AUTHOR: "Autor" +STR_LIBRARY_SEARCH: "Pesquisar" +STR_LIBRARY_HOLD_SORT: "Toque longo: Inverter ordem" +STR_LIBRARY_HOLD_GROUPS: "Toque longo: Grupos" +STR_LIBRARY_HOLD_OPTIONS: "Toque longo: Opções" +STR_LIBRARY_POSITION: "%d/%d livros" +STR_LIBRARY_GROUP_POSITION: "%d/%d grupos" +STR_LIBRARY_USE_METADATA: "Usar metadados dos livros" +STR_LIBRARY_REBUILD: "Atualizar biblioteca" +STR_LIBRARY_REBUILDING: "A ler os seus livros..." +STR_LIBRARY_REBUILD_DONE: "Índice da biblioteca reconstruído" +STR_LIBRARY_REBUILD_FAILED: "Não foi possível reconstruir o índice" +STR_REMOVE_READ_FROM_RECENTS: "Limpar os livros lidos da lista de recentes" STR_MOVE_FINISHED_TO_READ: "Mover livros concluídos para a pasta Lidos" STR_REFRESH_FREQ: "Frequência de atualização" STR_KOREADER_SYNC: "Sincronização KOReader" STR_CHECK_UPDATES: "Procurar atualizações" STR_LANGUAGE: "Idioma" STR_KEYBOARD_LAYOUTS: "Esquemas de teclado" +STR_ABOUT: "Sobre" STR_CLEAR_READING_CACHE: "Limpar cache de leitura" STR_USERNAME: "Nome de utilizador" STR_PASSWORD: "Palavra-passe" @@ -191,9 +223,18 @@ STR_ORIENTATION_INVERTED: "Retrato 180°" STR_LANDSCAPE_CCW: "Paisagem (Esq.)" STR_PREV_NEXT: "Ant/Seg" STR_NEXT_PREV: "Seg/Ant" +STR_NEXT_NEXT: "Seg/Seg" +STR_PREV_PREV: "Ant/Ant" +STR_NEXT_PAGE_GESTURE: "Página seguinte" +STR_PREV_PAGE_GESTURE: "Página anterior" +STR_TAP_AND_SWIPE: "Toque e deslize" +STR_TAP_ONLY: "Apenas toque" +STR_SWIPE_ONLY: "Apenas deslizar" +STR_INVERTED_TAP: "Toque invertido" STR_KOSYNC: "KOSync" STR_BOOKMARK_OPTION: "Marcador" STR_DICTIONARY: "Dicionário" +STR_READER_MENU: "Menu do leitor" STR_DISABLED: "Desativado" STR_NOTO_SERIF: "Noto Serif" STR_NOTO_SANS: "Noto Sans" @@ -227,6 +268,8 @@ STR_DOWNLOAD_FAILED: "Falha na transferência" STR_ERROR_MSG: "Erro:" STR_UNNAMED: "Sem nome" STR_HOLD_OPEN_TO_DELETE: "Mantenha premido Abrir para eliminar" +STR_HOLD_OPEN_TO_REMOVE: "Mantenha premido Abrir para remover" +STR_HOLD_OPEN_FOR_ACTIONS: "Mantenha premido Abrir para ações" STR_NO_SERVER_URL: "Nenhum URL de servidor configurado" STR_FETCH_FEED_FAILED: "Falha ao obter o feed" STR_PARSE_FEED_FAILED: "Falha ao analisar o feed" @@ -258,6 +301,12 @@ STR_SHOW: "Mostrar" STR_HIDE: "Ocultar" STR_STATE_ON: "LIG." STR_STATE_OFF: "DESL." +STR_STATE_TAP: "Toque" +STR_STATE_SWIPE_UP: "Deslizar para cima" +STR_FRONTLIGHT: "Luz frontal" +STR_BRIGHTNESS: "Brilho" +STR_WARMTH: "Temperatura" +STR_RESTORE_LIGHT_ON_WAKE: "Restaurar a iluminação ao acordar" STR_NOT_SET: "Não definido" STR_DIR_LEFT: "Esquerda" STR_DIR_RIGHT: "Direita" @@ -291,6 +340,10 @@ STR_CLOCK: "Relógio" STR_CLOCK_FORMAT: "Formato do relógio" STR_CLOCK_FORMAT_24H: "24 horas" STR_CLOCK_FORMAT_12H: "12 horas" +STR_TIMEZONE: "Fuso horário" +STR_CLOCK_IN_HEADER: "Mostrar no cabeçalho" +STR_CLOCK_DST: "Horário de verão" +STR_CLOCK_DST_AUTO: "Automático" STR_CURRENT_TIME: "Hora atual:" STR_CLOCK_SYNC: "Sincronizar relógio" STR_CLOCK_SYNC_NOW: "Sincronizar relógio agora" @@ -308,12 +361,14 @@ STR_THEME_LYRA_EXTENDED: "Lyra Extended" STR_SUNLIGHT_FADING_FIX: "Ajuste de desbotamento ao sol" STR_REMAP_FRONT_BUTTONS: "Remapear botões frontais" STR_BOOKMARKS: "Marcadores" +STR_NO_BOOKMARKS: "Nenhum marcador" STR_BOOKMARK_ADDED: "Marcador adicionado." STR_BOOKMARK_REMOVED: "Marcador removido." STR_OPDS_BROWSER: "Navegador OPDS" STR_SEARCH: "Pesquisar" STR_COVER_CUSTOM: "Capa + Personalizado" STR_QUICK_RESUME: "Retoma rápida" +STR_TRANSPARENT: "Transparente" STR_MENU_RECENT_BOOKS: "Livros recentes" STR_REMOVE_FROM_RECENTS: "Remover dos Livros recentes?" STR_CALIBRE_DESC: "Usar transferências sem fios de dispositivos do Calibre" @@ -338,6 +393,7 @@ STR_GO_HOME_BUTTON: "Ir para o Início" STR_SYNC_PROGRESS: "Sincronizar progresso" STR_DELETE_CACHE: "Eliminar cache do livro" STR_DELETE: "Eliminar" +STR_RENAME: "Renomear" STR_CONFIRM_DELETE_BOOKMARK: "Eliminar este marcador?" STR_DISPLAY_QR: "Mostrar página como QR" STR_CHAPTER_PREFIX: "Capítulo: " @@ -396,10 +452,12 @@ STR_AUTO_TURN_ENABLED: "Virar página automático ativado: " STR_AUTO_TURN_PAGES_PER_MIN: "Virar página automático (Páginas por minuto)" STR_MANAGE_FONTS: "Gerir tipos de letra" STR_FONT_BROWSER: "Navegador de tipos de letra" +STR_ALL_FONTS: "Todos os tipos de letra" STR_LOADING_FONT_LIST: "A carregar a lista de tipos de letra..." STR_NO_FONTS_AVAILABLE: "Nenhum tipo de letra disponível" STR_FONT_INSTALLED: "Tipo de letra instalado!" STR_FONT_INSTALL_FAILED: "Falha na instalação do tipo de letra" +STR_INVALID_FONT_MANIFEST: "Manifesto de tipo de letra inválido" STR_INSTALLED: "Instalado" STR_DOWNLOAD_ALL: "Transferir tudo" STR_UPDATE_ALL: "Atualizar tudo" @@ -431,9 +489,34 @@ STR_VALIDATING_FIRMWARE: "A validar o firmware..." STR_INVALID_FIRMWARE: "Ficheiro de firmware inválido" STR_FIRMWARE_TOO_LARGE: "Firmware demasiado grande para a partição" STR_FIRMWARE_TOO_SMALL: "Ficheiro de firmware demasiado pequeno" +STR_FIRMWARE_WRONG_DEVICE: "O firmware é para um dispositivo diferente" STR_FIRMWARE_UPDATE_PROMPT: "Atualizar firmware?" STR_FIRMWARE_FILE_OPEN_FAILED: "Não foi possível abrir o ficheiro" STR_FIRMWARE_WRITE_FAILED: "Falha na gravação do firmware" STR_FIRMWARE_UPDATE_DO_NOT_POWER_OFF: "Não desligue o dispositivo!" STR_RECOVERY_MODE: "Modo de Recuperação" STR_RECOVERY_MODE_HINT: "Coloque o ficheiro firmware.bin na raiz do cartão SD e selecione-o" + +STR_HOME_SHORTCUT: "Início" +STR_HOME_BUTTON: "Gestos do botão Início" +STR_CONFIGURE: "Configurar" +STR_HOME_BUTTON_TAP: "Toque" +STR_HOME_BUTTON_DOUBLE_TAP: "Toque duplo" +STR_HOME_BUTTON_LONG_PRESS: "Toque longo" +STR_TOGGLE_FRONTLIGHT: "Alternar luz frontal" + +STR_THEME_COVER_GRID: "Grade de capas" +STR_WORD_SPACING: "Espaçamento entre palavras" +STR_CHARACTER_SPACING: "Espaçamento entre caracteres" +STR_SPACING_MINUS_2: "-2 px" +STR_SPACING_MINUS_1: "-1 px" +STR_SPACING_ZERO: "0 px" +STR_SPACING_PLUS_1: "+1 px" +STR_SPACING_PLUS_2: "+2 px" +STR_SPACING_50_PERCENT: "50%" +STR_SPACING_75_PERCENT: "75%" +STR_SPACING_100_PERCENT: "100%" +STR_SPACING_125_PERCENT: "125%" +STR_SPACING_150_PERCENT: "150%" +STR_SPACING_175_PERCENT: "175%" +STR_SPACING_200_PERCENT: "200%" diff --git a/lib/I18n/translations/russian.yaml b/lib/I18n/translations/russian.yaml index 409cf85355c..72fb56467e1 100644 --- a/lib/I18n/translations/russian.yaml +++ b/lib/I18n/translations/russian.yaml @@ -106,6 +106,7 @@ STR_IMAGES_SUPPRESS: "Скрыть" STR_EOB_HOME: "Домой" STR_EOB_CONTINUE_WITH: "Продолжить с" STR_SHORT_PWR_BTN: "Короткое нажатие PWR" +STR_DBL_CLICK_PWR_LIGHT: "Двойной клик питания: подсветка" STR_ORIENTATION: "Ориентация чтения" STR_SIDE_BTN_LAYOUT: "Боковые кнопки" STR_TOUCH_READER_CONTROLS: "Сенсорное управление чтением" @@ -135,6 +136,27 @@ STR_PARA_ALIGNMENT: "Выравнивание абзаца" STR_HYPHENATION: "Перенос слов" STR_TIME_TO_SLEEP: "Сон через" STR_SHOW_HIDDEN_FILES: "Показать скрытые файлы" +STR_LIBRARY: "Библиотека" +STR_LIBRARY_EMPTY: "Книг на SD-карте нет" +STR_LIBRARY_NO_RESULTS: "Книги не найдены" +STR_LIBRARY_SEARCH_UNAVAILABLE: "Поиск недоступен: мало памяти" +STR_LIBRARY_UNKNOWN_TITLE: "(без названия)" +STR_LIBRARY_UNKNOWN_AUTHOR: "Автор неизвестен" +STR_LIBRARY_TITLE_UNSORTED: "Библиотека (без сортировки)" +STR_LIBRARY_TAB_RECENT: "Недавние" +STR_LIBRARY_TAB_TITLE: "Название" +STR_LIBRARY_TAB_AUTHOR: "Автор" +STR_LIBRARY_SEARCH: "Поиск" +STR_LIBRARY_HOLD_SORT: "Удерж.: обратный порядок" +STR_LIBRARY_HOLD_GROUPS: "Удерж.: группы" +STR_LIBRARY_HOLD_OPTIONS: "Удерж.: действия" +STR_LIBRARY_POSITION: "Книги: %d/%d" +STR_LIBRARY_GROUP_POSITION: "Группы: %d/%d" +STR_LIBRARY_USE_METADATA: "Использовать метаданные книг" +STR_LIBRARY_REBUILD: "Перестроить индекс библиотеки" +STR_LIBRARY_REBUILDING: "Чтение книг..." +STR_LIBRARY_REBUILD_DONE: "Индекс библиотеки перестроен" +STR_LIBRARY_REBUILD_FAILED: "Не удалось перестроить индекс" STR_REMOVE_READ_FROM_RECENTS: "Убирать прочитанные из недавних" STR_MOVE_FINISHED_TO_READ: "Прочитанные — в папку Read" STR_REFRESH_FREQ: "Частота обновления" @@ -142,6 +164,7 @@ STR_KOREADER_SYNC: "Синхронизация KOReader" STR_CHECK_UPDATES: "Проверить обновления" STR_LANGUAGE: "Язык" STR_KEYBOARD_LAYOUTS: "Раскладки клавиатуры" +STR_ABOUT: "Об устройстве" STR_CLEAR_READING_CACHE: "Очистить кэш чтения" STR_USERNAME: "Имя пользователя" STR_PASSWORD: "Пароль" @@ -200,6 +223,14 @@ STR_ORIENTATION_INVERTED: "Книжная 180°" STR_LANDSCAPE_CCW: "Альбомная 180°" STR_PREV_NEXT: "Назад/Вперёд" STR_NEXT_PREV: "Вперёд/Назад" +STR_NEXT_NEXT: "Обе вперёд" +STR_PREV_PREV: "Обе назад" +STR_NEXT_PAGE_GESTURE: "Следующая страница" +STR_PREV_PAGE_GESTURE: "Предыдущая страница" +STR_TAP_AND_SWIPE: "Тап и свайп" +STR_TAP_ONLY: "Только тап" +STR_SWIPE_ONLY: "Только свайп" +STR_INVERTED_TAP: "Обратный тап" STR_KOSYNC: "KOSync" STR_BOOKMARK_OPTION: "Закладка" STR_DICTIONARY: "Словарь" @@ -237,6 +268,8 @@ STR_DOWNLOAD_FAILED: "Ошибка загрузки" STR_ERROR_MSG: "Ошибка:" STR_UNNAMED: "Без имени" STR_HOLD_OPEN_TO_DELETE: "Удерживайте Открыть для удаления" +STR_HOLD_OPEN_TO_REMOVE: "Удерж. Открыть: убрать" +STR_HOLD_OPEN_FOR_ACTIONS: "Удерж. Открыть: действия" STR_NO_SERVER_URL: "URL сервера не настроен" STR_FETCH_FEED_FAILED: "Не удалось получить ленту" STR_PARSE_FEED_FAILED: "Не удалось обработать ленту" @@ -255,7 +288,7 @@ STR_SELECT: "Выбрать" STR_SELECTED: "Выбран" STR_TOGGLE: "Выбор" STR_TOGGLE_BOOKMARK: "Переключить закладку" -STR_CONFIRM: "Подтв." +STR_CONFIRM: "Подтвердить" STR_CANCEL: "Отмена" STR_CONNECT: "Подкл." STR_OPEN: "Открыть" @@ -307,6 +340,10 @@ STR_CLOCK: "Часы" STR_CLOCK_FORMAT: "Формат времени" STR_CLOCK_FORMAT_24H: "24-часовой" STR_CLOCK_FORMAT_12H: "12-часовой" +STR_TIMEZONE: "Часовой пояс" +STR_CLOCK_IN_HEADER: "Показывать в заголовке" +STR_CLOCK_DST: "Летнее время" +STR_CLOCK_DST_AUTO: "Авто" STR_CURRENT_TIME: "Текущее время:" STR_CLOCK_SYNC: "Синхронизация часов" STR_CLOCK_SYNC_NOW: "Синхр. часов" @@ -356,6 +393,7 @@ STR_GO_HOME_BUTTON: "На главную" STR_SYNC_PROGRESS: "Синхронизировать прогресс" STR_DELETE_CACHE: "Удалить кэш книги" STR_DELETE: "Удалить" +STR_RENAME: "Переименовать" STR_CONFIRM_DELETE_BOOKMARK: "Удалить эту закладку?" STR_DISPLAY_QR: "Показать страницу в виде QR-кода" STR_CHAPTER_PREFIX: "Глава: " @@ -419,6 +457,7 @@ STR_LOADING_FONT_LIST: "Загрузка списка шрифтов..." STR_NO_FONTS_AVAILABLE: "Шрифты недоступны" STR_FONT_INSTALLED: "Шрифт установлен!" STR_FONT_INSTALL_FAILED: "Не удалось установить шрифт" +STR_INVALID_FONT_MANIFEST: "Некорректный манифест шрифтов" STR_INSTALLED: "Установлен" STR_DOWNLOAD_ALL: "Загрузить всё" STR_UPDATE_ALL: "Обновить всё" @@ -457,3 +496,25 @@ STR_FIRMWARE_WRITE_FAILED: "Ошибка записи прошивки" STR_FIRMWARE_UPDATE_DO_NOT_POWER_OFF: "Не выключайте питание!" STR_RECOVERY_MODE: "Режим восстановления" STR_RECOVERY_MODE_HINT: "Выберите firmware.bin в корне SD-карты" +STR_HOME_SHORTCUT: "Домой" +STR_HOME_BUTTON: "Жесты кнопки «Домой»" +STR_CONFIGURE: "Настроить" +STR_HOME_BUTTON_TAP: "Нажатие" +STR_HOME_BUTTON_DOUBLE_TAP: "Двойное нажатие" +STR_HOME_BUTTON_LONG_PRESS: "Долгое нажатие" +STR_TOGGLE_FRONTLIGHT: "Вкл./выкл. подсветку" +STR_THEME_COVER_GRID: "Сетка обложек" +STR_WORD_SPACING: "Межсловный интервал" +STR_CHARACTER_SPACING: "Межбуквенный интервал" +STR_SPACING_MINUS_2: "-2 px" +STR_SPACING_MINUS_1: "-1 px" +STR_SPACING_ZERO: "0 px" +STR_SPACING_PLUS_1: "+1 px" +STR_SPACING_PLUS_2: "+2 px" +STR_SPACING_50_PERCENT: "50%" +STR_SPACING_75_PERCENT: "75%" +STR_SPACING_100_PERCENT: "100%" +STR_SPACING_125_PERCENT: "125%" +STR_SPACING_150_PERCENT: "150%" +STR_SPACING_175_PERCENT: "175%" +STR_SPACING_200_PERCENT: "200%" diff --git a/lib/I18n/translations/spanish.yaml b/lib/I18n/translations/spanish.yaml index d0555a411aa..f059d473682 100644 --- a/lib/I18n/translations/spanish.yaml +++ b/lib/I18n/translations/spanish.yaml @@ -87,7 +87,7 @@ STR_CALIBRE_INSTRUCTION_3: "3) Desde Calibre, seleccione \"Enviar a dispositivo\ STR_CALIBRE_INSTRUCTION_4: "\"Permanezca en esta pantalla mientras se envía\"" STR_CAT_DISPLAY: "Pantalla" STR_CAT_READER: "Lector" -STR_CAT_CONTROLS: "Mandos" +STR_CAT_CONTROLS: "Controles" STR_CAT_SYSTEM: "Sistema" STR_SLEEP_SCREEN: "Fondo de pantalla de suspensión" STR_TOUCH_TOGGLE: "Táctil" @@ -133,6 +133,27 @@ STR_PARA_ALIGNMENT: "Alineación de párrafo" STR_HYPHENATION: "División de palabras" STR_TIME_TO_SLEEP: "Auto suspensión" STR_SHOW_HIDDEN_FILES: "Mostrar archivos ocultos" +STR_LIBRARY: "Biblioteca" +STR_LIBRARY_EMPTY: "No se encontraron libros en la tarjeta SD" +STR_LIBRARY_NO_RESULTS: "No hay libros que coincidan con la búsqueda" +STR_LIBRARY_SEARCH_UNAVAILABLE: "Búsqueda no disponible: falta memoria" +STR_LIBRARY_UNKNOWN_TITLE: "(sin título)" +STR_LIBRARY_UNKNOWN_AUTHOR: "Autor desconocido" +STR_LIBRARY_TITLE_UNSORTED: "Biblioteca (sin ordenar)" +STR_LIBRARY_TAB_RECENT: "Recientes" +STR_LIBRARY_TAB_TITLE: "Título" +STR_LIBRARY_TAB_AUTHOR: "Autor" +STR_LIBRARY_SEARCH: "Buscar" +STR_LIBRARY_HOLD_SORT: "Mantén: invertir orden" +STR_LIBRARY_HOLD_GROUPS: "Mantén: grupos" +STR_LIBRARY_HOLD_OPTIONS: "Mantén: opciones" +STR_LIBRARY_POSITION: "%d/%d libros" +STR_LIBRARY_GROUP_POSITION: "%d/%d grupos" +STR_LIBRARY_USE_METADATA: "Usar metadatos de los libros" +STR_LIBRARY_REBUILD: "Actualizar biblioteca" +STR_LIBRARY_REBUILDING: "Indexando los libros..." +STR_LIBRARY_REBUILD_DONE: "Índice de la biblioteca reconstruido" +STR_LIBRARY_REBUILD_FAILED: "No se pudo reconstruir el índice" STR_REMOVE_READ_FROM_RECENTS: "Borrar libros leídos de la lista de recientes" STR_MOVE_FINISHED_TO_READ: "Mover libros terminados a la carpeta Read" STR_REFRESH_FREQ: "Frecuencia de refresco" @@ -198,6 +219,14 @@ STR_ORIENTATION_INVERTED: "Al revés" STR_LANDSCAPE_CCW: "Horizontal (antihorario)" STR_PREV_NEXT: "Ant./Sig." STR_NEXT_PREV: "Sig./Ant." +STR_NEXT_NEXT: "Sig./Sig." +STR_PREV_PREV: "Ant./Ant." +STR_NEXT_PAGE_GESTURE: "Página siguiente" +STR_PREV_PAGE_GESTURE: "Página anterior" +STR_TAP_AND_SWIPE: "Toque y deslizamiento" +STR_TAP_ONLY: "Solo toque" +STR_SWIPE_ONLY: "Solo deslizamiento" +STR_INVERTED_TAP: "Toque invertido" STR_KOSYNC: "KOSync" STR_BOOKMARK_OPTION: "Marcador" STR_DICTIONARY: "Diccionario" @@ -235,6 +264,7 @@ STR_DOWNLOAD_FAILED: "Fallo de descarga" STR_ERROR_MSG: "Error:" STR_UNNAMED: "Sin nombre" STR_HOLD_OPEN_TO_DELETE: "Mantenga pulsado Abrir para borrar" +STR_HOLD_OPEN_TO_REMOVE: "Mantén Abrir: quitar" STR_NO_SERVER_URL: "No se configuró URL de servidor" STR_FETCH_FEED_FAILED: "Fallo al obtener el feed" STR_PARSE_FEED_FAILED: "Fallo al procesar el feed" @@ -318,6 +348,7 @@ STR_UI_THEME: "Interfaz" STR_THEME_CLASSIC: "Clásico" STR_THEME_LYRA: "Lyra" STR_THEME_ROUNDEDRAFF: "RoundedRaff" +STR_THEME_COVER_GRID: "Cuadrícula de portadas" STR_THEME_LYRA_EXTENDED: "Lyra Extendido" STR_SUNLIGHT_FADING_FIX: "Corrección de desvanecimiento" STR_QUICK_RESUME_TIMEOUT: "Reanudación rápida tras tiempo" @@ -332,7 +363,7 @@ STR_COVER_CUSTOM: "Portada + Pers." STR_QUICK_RESUME: "Reanudación rápida" STR_TRANSPARENT: "Transparente" STR_MENU_RECENT_BOOKS: "Libros recientes" -STR_REMOVE_FROM_RECENTS: "¿Eliminar de Libros recientes?" +STR_REMOVE_FROM_RECENTS: "Quitar de recientes" STR_CALIBRE_DESC: "Transferir contenido a este dispositivo" STR_FORGET_AND_REMOVE: "¿Desea olvidar la red y la contraseña guardada?" STR_FORGET_BUTTON: "Olvidar" @@ -418,6 +449,7 @@ STR_LOADING_FONT_LIST: "Cargando lista de tipografías..." STR_NO_FONTS_AVAILABLE: "No hay tipografías disponibles" STR_FONT_INSTALLED: "¡Se instaló la tipografía!" STR_FONT_INSTALL_FAILED: "Falló la instalación de tipografía" +STR_INVALID_FONT_MANIFEST: "Manifiesto de tipografías no válido" STR_INSTALLED: "Instalada" STR_DOWNLOAD_ALL: "Descargar todas" STR_UPDATE_ALL: "Actualizar todas" @@ -458,3 +490,32 @@ STR_RECOVERY_MODE: "Modo de recuperación" STR_RECOVERY_MODE_HINT: "Ponga firmware.bin en la raíz de la tarj. SD y selecciónelo" STR_ADD_HIDDEN_NETWORK: "Añadir red oculta..." STR_ENTER_WIFI_SSID: "Introduce el nombre de la red (SSID)" +STR_DBL_CLICK_PWR_LIGHT: "Doble pulsación de encendido: luz" +STR_ABOUT: "Acerca de" +STR_HOLD_OPEN_FOR_ACTIONS: "Mantén Abrir: opciones" +STR_TIMEZONE: "Zona horaria" +STR_CLOCK_IN_HEADER: "Mostrar en cabecera" +STR_CLOCK_DST: "Horario de verano" +STR_CLOCK_DST_AUTO: "Automático" +STR_RENAME: "Renombrar" +STR_HOME_SHORTCUT: "Inicio" +STR_HOME_BUTTON: "Gestos del botón de inicio" +STR_CONFIGURE: "Configurar" +STR_HOME_BUTTON_TAP: "Pulsación" +STR_HOME_BUTTON_DOUBLE_TAP: "Doble pulsación" +STR_HOME_BUTTON_LONG_PRESS: "Pulsación larga" +STR_TOGGLE_FRONTLIGHT: "Encender/apagar luz frontal" +STR_WORD_SPACING: "Espaciado entre palabras" +STR_CHARACTER_SPACING: "Espaciado entre caracteres" +STR_SPACING_MINUS_2: "-2 px" +STR_SPACING_MINUS_1: "-1 px" +STR_SPACING_ZERO: "0 px" +STR_SPACING_PLUS_1: "+1 px" +STR_SPACING_PLUS_2: "+2 px" +STR_SPACING_50_PERCENT: "50%" +STR_SPACING_75_PERCENT: "75%" +STR_SPACING_100_PERCENT: "100%" +STR_SPACING_125_PERCENT: "125%" +STR_SPACING_150_PERCENT: "150%" +STR_SPACING_175_PERCENT: "175%" +STR_SPACING_200_PERCENT: "200%" diff --git a/lib/I18n/translations/valencian.yaml b/lib/I18n/translations/valencian.yaml index d32a7b757b1..820fe30cd09 100644 --- a/lib/I18n/translations/valencian.yaml +++ b/lib/I18n/translations/valencian.yaml @@ -138,6 +138,27 @@ STR_SLEEP_NEVER: "Mai" STR_STEP_HINT_FRONT: "Botons frontals:" STR_STEP_HINT_SIDE: "Botons laterals:" STR_SHOW_HIDDEN_FILES: "Mostra arxius ocults" +STR_LIBRARY: "Biblioteca" +STR_LIBRARY_EMPTY: "No s'han trobat llibres a la targeta SD" +STR_LIBRARY_NO_RESULTS: "Cap llibre coincidix amb la busca" +STR_LIBRARY_SEARCH_UNAVAILABLE: "Busca no disponible: falta memòria" +STR_LIBRARY_UNKNOWN_TITLE: "(sense títol)" +STR_LIBRARY_UNKNOWN_AUTHOR: "Autor desconegut" +STR_LIBRARY_TITLE_UNSORTED: "Biblioteca (sense ordenar)" +STR_LIBRARY_TAB_RECENT: "Recents" +STR_LIBRARY_TAB_TITLE: "Títol" +STR_LIBRARY_TAB_AUTHOR: "Autor" +STR_LIBRARY_SEARCH: "Busca" +STR_LIBRARY_HOLD_SORT: "Mantín: orde invers" +STR_LIBRARY_HOLD_GROUPS: "Mantín: grups" +STR_LIBRARY_HOLD_OPTIONS: "Mantín: opcions" +STR_LIBRARY_POSITION: "%d/%d llibres" +STR_LIBRARY_GROUP_POSITION: "%d/%d grups" +STR_LIBRARY_USE_METADATA: "Usa les metadades dels llibres" +STR_LIBRARY_REBUILD: "Actualitza la biblioteca" +STR_LIBRARY_REBUILDING: "S'estan indexant els llibres..." +STR_LIBRARY_REBUILD_DONE: "Índex de la biblioteca reconstruït" +STR_LIBRARY_REBUILD_FAILED: "No s'ha pogut reconstruir l'índex" STR_REMOVE_READ_FROM_RECENTS: "Esborra els llibres llegits de la llista de recents" STR_MOVE_FINISHED_TO_READ: "Mou els llibres acabats a la carpeta Read" STR_REFRESH_FREQ: "Freqüència d'actualització" @@ -202,6 +223,14 @@ STR_ORIENTATION_INVERTED: "Vertical 180°" STR_LANDSCAPE_CCW: "Horitzontal antihorari" STR_PREV_NEXT: "Anterior/Següent" STR_NEXT_PREV: "Següent/Anterior" +STR_NEXT_NEXT: "Següent/Següent" +STR_PREV_PREV: "Anterior/Anterior" +STR_NEXT_PAGE_GESTURE: "Pàgina següent" +STR_PREV_PAGE_GESTURE: "Pàgina anterior" +STR_TAP_AND_SWIPE: "Toc i lliscament" +STR_TAP_ONLY: "Només toc" +STR_SWIPE_ONLY: "Només lliscament" +STR_INVERTED_TAP: "Toc invertit" STR_KOSYNC: "KOSync" STR_BOOKMARK_OPTION: "Punt de llibre" STR_DICTIONARY: "Diccionari" @@ -238,6 +267,7 @@ STR_DOWNLOAD_FAILED: "Ha fallat la baixada" STR_ERROR_MSG: "Error:" STR_UNNAMED: "Sense nom" STR_HOLD_OPEN_TO_DELETE: "Mantín premut Obri per esborrar" +STR_HOLD_OPEN_TO_REMOVE: "Mantín Obri: trau" STR_NO_SERVER_URL: "No s'ha configurat cap URL de servidor" STR_FETCH_FEED_FAILED: "Ha fallat l'obtenció del canal de continguts" STR_PARSE_FEED_FAILED: "Ha fallat l'anàlisi del canal de continguts" @@ -314,7 +344,7 @@ STR_COVER_CUSTOM: "Portada + Personalitzat" STR_QUICK_RESUME: "Represa ràpida" STR_TRANSPARENT: "Transparent" STR_MENU_RECENT_BOOKS: "Llibres recents" -STR_REMOVE_FROM_RECENTS: "Vols eliminar-lo de Llibres recents?" +STR_REMOVE_FROM_RECENTS: "Lleva de recents" STR_CALIBRE_DESC: "Utilitza les transferències sense fils de Calibre" STR_FORGET_AND_REMOVE: "Vols oblidar la xarxa i eliminar la contrasenya guardada?" STR_FORGET_BUTTON: "Oblida" @@ -399,6 +429,7 @@ STR_CLOCK_SYNC_NO_WIFI: "Wi-Fi no connectat" STR_CLOCK_SYNC_NO_WIFI_HINT: "Connecta't al Wi-Fi primer i torna-ho a provar." STR_CLOCK_SYNCED: "Rellotge sincronitzat" STR_THEME_ROUNDEDRAFF: "RoundedRaff" +STR_THEME_COVER_GRID: "Graella de portades" STR_SEARCH: "Busca" STR_SET_SLEEP_COVER: "Fixa fons" STR_ADD_SERVER: "Afig servidor" @@ -419,6 +450,7 @@ STR_LOADING_FONT_LIST: "S'està carregant la llista de fonts..." STR_NO_FONTS_AVAILABLE: "No hi ha fonts disponibles" STR_FONT_INSTALLED: "Font instal·lada!" STR_FONT_INSTALL_FAILED: "Ha fallat la instal·lació de la font" +STR_INVALID_FONT_MANIFEST: "Manifest de fonts no vàlid" STR_INSTALLED: "Instal·lat" STR_DOWNLOAD_ALL: "Descarrega-ho tot" STR_UPDATE_ALL: "Actualitza-ho tot" @@ -458,3 +490,32 @@ STR_RECOVERY_MODE: "Mode de recuperació" STR_RECOVERY_MODE_HINT: "Posa firmware.bin a l'arrel de la targeta SD i selecciona'l" STR_ADD_HIDDEN_NETWORK: "Afig una xarxa oculta..." STR_ENTER_WIFI_SSID: "Introduïx el nom de la xarxa (SSID)" +STR_DBL_CLICK_PWR_LIGHT: "Doble pulsació d'encesa: llum" +STR_ABOUT: "Quant a" +STR_HOLD_OPEN_FOR_ACTIONS: "Mantín Obri: opcions" +STR_TIMEZONE: "Fus horari" +STR_CLOCK_IN_HEADER: "Mostra a la capçalera" +STR_CLOCK_DST: "Horari d'estiu" +STR_CLOCK_DST_AUTO: "Automàtic" +STR_RENAME: "Canvia el nom" +STR_HOME_SHORTCUT: "Inici" +STR_HOME_BUTTON: "Gestos del botó d'inici" +STR_CONFIGURE: "Configura" +STR_HOME_BUTTON_TAP: "Pulsació" +STR_HOME_BUTTON_DOUBLE_TAP: "Doble pulsació" +STR_HOME_BUTTON_LONG_PRESS: "Pulsació llarga" +STR_TOGGLE_FRONTLIGHT: "Encén/apaga la llum frontal" +STR_WORD_SPACING: "Espai entre paraules" +STR_CHARACTER_SPACING: "Espai entre caràcters" +STR_SPACING_MINUS_2: "-2 px" +STR_SPACING_MINUS_1: "-1 px" +STR_SPACING_ZERO: "0 px" +STR_SPACING_PLUS_1: "+1 px" +STR_SPACING_PLUS_2: "+2 px" +STR_SPACING_50_PERCENT: "50%" +STR_SPACING_75_PERCENT: "75%" +STR_SPACING_100_PERCENT: "100%" +STR_SPACING_125_PERCENT: "125%" +STR_SPACING_150_PERCENT: "150%" +STR_SPACING_175_PERCENT: "175%" +STR_SPACING_200_PERCENT: "200%" diff --git a/lib/I18n/translations/vietnamese.yaml b/lib/I18n/translations/vietnamese.yaml index ffce3a35739..7dc011542b3 100644 --- a/lib/I18n/translations/vietnamese.yaml +++ b/lib/I18n/translations/vietnamese.yaml @@ -59,11 +59,12 @@ STR_CREATE_HOTSPOT: "Tạo điểm phát" STR_USB_DRIVE: "Ổ USB" STR_USB_DRIVE_DESC: "Quản lý thẻ SD từ máy tính" STR_USB_DRIVE_PREPARING: "Đang chuẩn bị ổ USB..." -STR_USB_DRIVE_WAITING: "Kết nối máy đọc với máy tính" +STR_USB_DRIVE_WAITING: "Kết nối máy đọc sách với máy tính" STR_USB_DRIVE_CONNECTED: "Ổ USB đã kết nối" +STR_USB_DRIVE_CONNECT_DELAY: "Có thể mất tới 30 giây để kết nối" STR_USB_DRIVE_EJECT_HINT: "Tháo ổ trên máy tính hoặc rút cáp để về Trang chủ." STR_USB_DRIVE_ERROR: "Lỗi ổ USB. Rút cáp để về Trang chủ." -STR_USB_DRIVE_START_ERROR: "Không thể khởi động ổ USB. Nhấn Quay lại để về Trang chủ." +STR_USB_DRIVE_START_ERROR: "Không thể khởi động ổ USB. Nhấn Quay lại để về màn hình chính." STR_JOIN_DESC: "Kết nối tới mạng Wi-Fi có sẵn" STR_HOTSPOT_DESC: "Tạo mạng Wi-Fi để máy khác kết nối" STR_STARTING_HOTSPOT: "Đang tạo điểm phát..." @@ -77,6 +78,8 @@ STR_NETWORK_LEGEND: "* = Mã hóa | + = Đã lưu" STR_MAC_ADDRESS: "Địa chỉ MAC:" STR_CHECKING_WIFI: "Đang kiểm tra Wi-Fi..." STR_ENTER_WIFI_PASSWORD: "Nhập mật khẩu Wi-Fi" +STR_ADD_HIDDEN_NETWORK: "Thêm mạng ẩn..." +STR_ENTER_WIFI_SSID: "Nhập tên mạng (SSID)" STR_TO_PREFIX: "tới " STR_CALIBRE_RECEIVING: "Đang nhận: " STR_CALIBRE_RECEIVED: "Đã nhận: " @@ -103,9 +106,11 @@ STR_IMAGES_SUPPRESS: "Ẩn đi" STR_EOB_HOME: "Màn hình chính" STR_EOB_CONTINUE_WITH: "Tiếp tục với" STR_SHORT_PWR_BTN: "Nhấn nhanh nút nguồn" +STR_DBL_CLICK_PWR_LIGHT: "Nhấp đúp nút nguồn để bật đèn" STR_ORIENTATION: "Hướng đọc" STR_SIDE_BTN_LAYOUT: "Bố trí nút bên (trình đọc)" STR_TOUCH_READER_CONTROLS: "Điều khiển đọc bằng cảm ứng" +STR_SHOW_READER_MENU: "Hiện menu đọc" STR_FRONT_BTN_FOLLOW_ORIENTATION: "Xoay nút trước theo hướng" STR_LONG_PRESS_BEHAVIOR: "Hành vi nhấn giữ nút" STR_LONG_PRESS_BEHAVIOR_OFF: "TẮT" @@ -119,25 +124,47 @@ STR_TEXT_SETTINGS: "Cài đặt văn bản" STR_FONT: "Phông chữ" STR_SIZE: "Cỡ chữ" STR_LAYOUT: "Bố cục" +STR_ALIGNMENT: "Căn chỉnh" STR_STYLE: "Kiểu" STR_READER_MENU_STYLE: "Kiểu menu đọc" STR_MENU_STYLE_LIST: "Danh sách" STR_MENU_STYLE_TOOLBAR: "Thanh công cụ" STR_NOT_IN_PREVIEW: "Không hiển thị trong bản xem trước" -STR_ALIGNMENT: "Căn chỉnh" STR_LINE_SPACING: "Giãn dòng" STR_SCREEN_MARGIN: "Lề" STR_PARA_ALIGNMENT: "Căn chỉnh đoạn" STR_HYPHENATION: "Ngắt từ bằng gạch nối" STR_TIME_TO_SLEEP: "Thời gian chờ ngủ" STR_SHOW_HIDDEN_FILES: "Hiện tập tin ẩn" +STR_LIBRARY: "Thư viện" +STR_LIBRARY_EMPTY: "Không tìm thấy sách trên thẻ SD" +STR_LIBRARY_NO_RESULTS: "Không có sách nào khớp với tìm kiếm" +STR_LIBRARY_SEARCH_UNAVAILABLE: "Không thể tìm kiếm: không đủ bộ nhớ" +STR_LIBRARY_UNKNOWN_TITLE: "(không có tiêu đề)" +STR_LIBRARY_UNKNOWN_AUTHOR: "Tác giả không rõ" +STR_LIBRARY_TITLE_UNSORTED: "Thư viện (chưa sắp xếp)" +STR_LIBRARY_TAB_RECENT: "Gần đây" +STR_LIBRARY_TAB_TITLE: "Tựa đề" +STR_LIBRARY_TAB_AUTHOR: "Tác giả" +STR_LIBRARY_SEARCH: "Tìm kiếm" +STR_LIBRARY_HOLD_SORT: "Giữ: Đảo thứ tự" +STR_LIBRARY_HOLD_GROUPS: "Giữ: Nhóm" +STR_LIBRARY_HOLD_OPTIONS: "Giữ: Tùy chọn" +STR_LIBRARY_POSITION: "%d/%d sách" +STR_LIBRARY_GROUP_POSITION: "%d/%d nhóm" +STR_LIBRARY_USE_METADATA: "Dùng siêu dữ liệu sách" +STR_LIBRARY_REBUILD: "Làm mới thư viện" +STR_LIBRARY_REBUILDING: "Đang đọc sách của bạn..." +STR_LIBRARY_REBUILD_DONE: "Đã tạo lại chỉ mục thư viện" +STR_LIBRARY_REBUILD_FAILED: "Không thể tạo lại chỉ mục" STR_REMOVE_READ_FROM_RECENTS: "Xóa sách đã đọc khỏi danh sách gần đây" STR_MOVE_FINISHED_TO_READ: "Chuyển sách đọc xong vào thư mục Đã đọc" -STR_REFRESH_FREQ: "Tần suất làm tươi" +STR_REFRESH_FREQ: "Tần suất làm mới" STR_KOREADER_SYNC: "Đồng bộ KOReader" STR_CHECK_UPDATES: "Kiểm tra cập nhật" STR_LANGUAGE: "Ngôn ngữ" STR_KEYBOARD_LAYOUTS: "Bố cục bàn phím" +STR_ABOUT: "Giới thiệu" STR_CLEAR_READING_CACHE: "Xóa bộ nhớ đệm đọc" STR_USERNAME: "Tên đăng nhập" STR_PASSWORD: "Mật khẩu" @@ -167,8 +194,8 @@ STR_AUTH_FAILED: "Xác thực thất bại" STR_DONE: "Xong" STR_CLEAR_CACHE_WARNING_1: "Thao tác này sẽ xóa toàn bộ dữ liệu sách đã lưu đệm." STR_CLEAR_CACHE_WARNING_2: "Toàn bộ tiến độ đọc sẽ bị mất!" -STR_CLEAR_CACHE_WARNING_3: "Sách sẽ cần lập chỉ mục lại" -STR_CLEAR_CACHE_WARNING_4: "khi mở lại." +STR_CLEAR_CACHE_WARNING_3: "Sách sẽ cần được lập chỉ mục lại" +STR_CLEAR_CACHE_WARNING_4: "khi mở lần sau." STR_CLEARING_CACHE: "Đang xóa bộ nhớ đệm..." STR_CACHE_CLEARED: "Đã xóa bộ nhớ đệm" STR_ITEMS_REMOVED: "mục đã xóa" @@ -196,9 +223,18 @@ STR_ORIENTATION_INVERTED: "Dọc 180°" STR_LANDSCAPE_CCW: "Ngang (ngược)" STR_PREV_NEXT: "Trước/Sau" STR_NEXT_PREV: "Sau/Trước" +STR_NEXT_NEXT: "Sau/Sau" +STR_PREV_PREV: "Trước/Trước" +STR_NEXT_PAGE_GESTURE: "Trang sau" +STR_PREV_PAGE_GESTURE: "Trang trước" +STR_TAP_AND_SWIPE: "Chạm & Vuốt" +STR_TAP_ONLY: "Chỉ chạm" +STR_SWIPE_ONLY: "Chỉ vuốt" +STR_INVERTED_TAP: "Chạm đảo ngược" STR_KOSYNC: "KOSync" STR_BOOKMARK_OPTION: "Dấu trang" STR_DICTIONARY: "Từ điển" +STR_READER_MENU: "Menu đọc" STR_DISABLED: "Tắt" STR_NOTO_SERIF: "Noto Serif" STR_NOTO_SANS: "Noto Sans" @@ -232,6 +268,8 @@ STR_DOWNLOAD_FAILED: "Tải về thất bại" STR_ERROR_MSG: "Lỗi:" STR_UNNAMED: "Không tên" STR_HOLD_OPEN_TO_DELETE: "Giữ Mở để xóa" +STR_HOLD_OPEN_TO_REMOVE: "Giữ Mở để gỡ" +STR_HOLD_OPEN_FOR_ACTIONS: "Giữ Mở để xem thao tác" STR_NO_SERVER_URL: "Chưa cấu hình URL máy chủ" STR_FETCH_FEED_FAILED: "Không tải được nguồn cấp" STR_PARSE_FEED_FAILED: "Không phân tích được nguồn cấp" @@ -239,13 +277,13 @@ STR_NEXT_PAGE: "Trang sau »" STR_PREV_PAGE: "« Trang trước" STR_NETWORK_PREFIX: "Mạng: " STR_IP_ADDRESS_PREFIX: "Địa chỉ IP: " -STR_ERROR_GENERAL_FAILURE: "Lỗi: Sự cố chung" +STR_ERROR_GENERAL_FAILURE: "Lỗi: Sự cố không xác định" STR_ERROR_NETWORK_NOT_FOUND: "Lỗi: Không tìm thấy mạng" STR_ERROR_CONNECTION_TIMEOUT: "Lỗi: Kết nối quá thời gian" STR_SD_CARD: "Thẻ SD" STR_BACK: "« Quay lại" STR_EXIT: "« Thoát" -STR_HOME: "« Thư viện" +STR_HOME: "« Màn hình chính" STR_SELECT: "Chọn" STR_SELECTED: "Đã chọn" STR_TOGGLE: "Bật/Tắt" @@ -263,6 +301,12 @@ STR_SHOW: "Hiện" STR_HIDE: "Ẩn" STR_STATE_ON: "BẬT" STR_STATE_OFF: "TẮT" +STR_STATE_TAP: "Chạm" +STR_STATE_SWIPE_UP: "Vuốt lên" +STR_FRONTLIGHT: "Đèn nền" +STR_BRIGHTNESS: "Độ sáng" +STR_WARMTH: "Độ ấm" +STR_RESTORE_LIGHT_ON_WAKE: "Bật lại đèn khi mở máy" STR_NOT_SET: "Chưa đặt" STR_DIR_LEFT: "Trái" STR_DIR_RIGHT: "Phải" @@ -296,11 +340,11 @@ STR_CLOCK: "Đồng hồ" STR_CLOCK_FORMAT: "Định dạng giờ" STR_CLOCK_FORMAT_24H: "24 giờ" STR_CLOCK_FORMAT_12H: "12 giờ" -STR_CURRENT_TIME: "Giờ hiện tại:" STR_TIMEZONE: "Múi giờ" STR_CLOCK_IN_HEADER: "Hiển thị ở đầu trang" STR_CLOCK_DST: "Giờ tiết kiệm ánh sáng ban ngày" STR_CLOCK_DST_AUTO: "Tự động" +STR_CURRENT_TIME: "Giờ hiện tại:" STR_CLOCK_SYNC: "Đồng bộ đồng hồ" STR_CLOCK_SYNC_NOW: "Đồng bộ đồng hồ ngay" STR_CLOCK_SYNCING: "Đang đồng bộ từ NTP..." @@ -317,12 +361,14 @@ STR_THEME_LYRA_EXTENDED: "Lyra mở rộng" STR_SUNLIGHT_FADING_FIX: "Khắc phục mờ dưới nắng" STR_REMAP_FRONT_BUTTONS: "Gán lại nút mặt trước" STR_BOOKMARKS: "Dấu trang" +STR_NO_BOOKMARKS: "Không có dấu trang" STR_BOOKMARK_ADDED: "Đã thêm dấu trang." STR_BOOKMARK_REMOVED: "Đã xóa dấu trang." STR_OPDS_BROWSER: "Trình duyệt OPDS" STR_SEARCH: "Tìm kiếm" STR_COVER_CUSTOM: "Ảnh bìa + Tùy chỉnh" STR_QUICK_RESUME: "Khôi phục nhanh" +STR_TRANSPARENT: "Trong suốt" STR_MENU_RECENT_BOOKS: "Sách gần đây" STR_REMOVE_FROM_RECENTS: "Xóa khỏi sách gần đây?" STR_CALIBRE_DESC: "Dùng truyền không dây của Calibre" @@ -347,6 +393,7 @@ STR_GO_HOME_BUTTON: "Về trang chủ" STR_SYNC_PROGRESS: "Đồng bộ tiến độ" STR_DELETE_CACHE: "Xóa bộ nhớ đệm sách" STR_DELETE: "Xóa" +STR_RENAME: "Đổi tên" STR_CONFIRM_DELETE_BOOKMARK: "Xóa dấu trang này?" STR_DISPLAY_QR: "Hiện trang dạng QR" STR_CHAPTER_PREFIX: "Chương: " @@ -405,10 +452,12 @@ STR_AUTO_TURN_ENABLED: "Tự lật trang: " STR_AUTO_TURN_PAGES_PER_MIN: "Tự lật (số trang mỗi phút)" STR_MANAGE_FONTS: "Quản lý phông chữ" STR_FONT_BROWSER: "Trình duyệt phông chữ" +STR_ALL_FONTS: "Tất cả phông chữ" STR_LOADING_FONT_LIST: "Đang tải danh sách phông..." STR_NO_FONTS_AVAILABLE: "Không có phông chữ" STR_FONT_INSTALLED: "Đã cài phông chữ!" STR_FONT_INSTALL_FAILED: "Cài phông chữ thất bại" +STR_INVALID_FONT_MANIFEST: "Manifest phông không hợp lệ" STR_INSTALLED: "Đã cài" STR_DOWNLOAD_ALL: "Tải tất cả" STR_UPDATE_ALL: "Cập nhật tất cả" @@ -440,11 +489,34 @@ STR_VALIDATING_FIRMWARE: "Đang kiểm tra firmware..." STR_INVALID_FIRMWARE: "Tập tin firmware không hợp lệ" STR_FIRMWARE_TOO_LARGE: "Firmware quá lớn so với phân vùng" STR_FIRMWARE_TOO_SMALL: "Tập tin firmware quá nhỏ" +STR_FIRMWARE_WRONG_DEVICE: "Firmware dành cho thiết bị khác" STR_FIRMWARE_UPDATE_PROMPT: "Cập nhật firmware?" STR_FIRMWARE_FILE_OPEN_FAILED: "Không mở được tập tin" STR_FIRMWARE_WRITE_FAILED: "Ghi firmware thất bại" STR_FIRMWARE_UPDATE_DO_NOT_POWER_OFF: "Không được tắt nguồn!" STR_RECOVERY_MODE: "Chế độ phục hồi" STR_RECOVERY_MODE_HINT: "Đặt firmware.bin ở thư mục gốc thẻ SD rồi chọn" -STR_ADD_HIDDEN_NETWORK: "Thêm mạng ẩn..." -STR_ENTER_WIFI_SSID: "Nhập tên mạng (SSID)" + +STR_HOME_SHORTCUT: "Màn hình chính" +STR_HOME_BUTTON: "Cử chỉ nút Home" +STR_CONFIGURE: "Cấu hình" +STR_HOME_BUTTON_TAP: "Chạm" +STR_HOME_BUTTON_DOUBLE_TAP: "Chạm đúp" +STR_HOME_BUTTON_LONG_PRESS: "Nhấn giữ" +STR_TOGGLE_FRONTLIGHT: "Bật/Tắt đèn nền" + +STR_THEME_COVER_GRID: "Lưới bìa sách" +STR_WORD_SPACING: "Khoảng cách từ" +STR_CHARACTER_SPACING: "Khoảng cách ký tự" +STR_SPACING_MINUS_2: "-2 px" +STR_SPACING_MINUS_1: "-1 px" +STR_SPACING_ZERO: "0 px" +STR_SPACING_PLUS_1: "+1 px" +STR_SPACING_PLUS_2: "+2 px" +STR_SPACING_50_PERCENT: "50%" +STR_SPACING_75_PERCENT: "75%" +STR_SPACING_100_PERCENT: "100%" +STR_SPACING_125_PERCENT: "125%" +STR_SPACING_150_PERCENT: "150%" +STR_SPACING_175_PERCENT: "175%" +STR_SPACING_200_PERCENT: "200%" From 6743b68353dcc692e87826f5320c5480392f9e5b Mon Sep 17 00:00:00 2001 From: Ninos Yomo <32492083+SurayaAtouraya@users.noreply.github.com> Date: Sun, 27 Sep 2026 02:55:27 -0400 Subject: [PATCH 32/62] fix: don't cut screenshot folder names mid-letter (#3750) --- lib/FsHelpers/FsHelpers.cpp | 4 ++++ test/fs_helpers/CMakeLists.txt | 2 ++ test/fs_helpers/FsHelpersTest.cpp | 34 +++++++++++++++++++++++++++++++ 3 files changed, 40 insertions(+) diff --git a/lib/FsHelpers/FsHelpers.cpp b/lib/FsHelpers/FsHelpers.cpp index 87fabe12e80..5305cbdd9d8 100644 --- a/lib/FsHelpers/FsHelpers.cpp +++ b/lib/FsHelpers/FsHelpers.cpp @@ -1,5 +1,7 @@ #include "FsHelpers.h" +#include + #include #include #include @@ -203,6 +205,8 @@ void sanitizePathComponentForFat32(const char* input, char* output, size_t maxLe output[i] = c; } } + // If the last character was cut in half, drop its leftover bytes. + i = static_cast(utf8SafeTruncateBuffer(output, static_cast(i))); output[i] = '\0'; } diff --git a/test/fs_helpers/CMakeLists.txt b/test/fs_helpers/CMakeLists.txt index f6f604ff3ad..0502a3e90ca 100644 --- a/test/fs_helpers/CMakeLists.txt +++ b/test/fs_helpers/CMakeLists.txt @@ -1,11 +1,13 @@ add_executable(FsHelpersTest FsHelpersTest.cpp ${REPO_ROOT}/lib/FsHelpers/FsHelpers.cpp + ${REPO_ROOT}/lib/Utf8/Utf8.cpp ) target_include_directories(FsHelpersTest PRIVATE ${CMAKE_CURRENT_SOURCE_DIR}/stubs ${REPO_ROOT}/lib/FsHelpers + ${REPO_ROOT}/lib/Utf8 ) target_link_libraries(FsHelpersTest PRIVATE diff --git a/test/fs_helpers/FsHelpersTest.cpp b/test/fs_helpers/FsHelpersTest.cpp index f441e006863..6c3d376c170 100644 --- a/test/fs_helpers/FsHelpersTest.cpp +++ b/test/fs_helpers/FsHelpersTest.cpp @@ -1,5 +1,8 @@ #include +#include +#include + #include "FsHelpers.h" namespace { @@ -33,4 +36,35 @@ TEST(NormalisePath, CollapsesParentReferenceWithinPath) { TEST(NormalisePath, DropsLeadingParentReferencesPastRoot) { EXPECT_EQ(FsHelpers::normalisePath("/../../etc"), "etc"); } +// Sanitizes into a buffer of `size` bytes (at most 64, the size ScreenshotUtil uses). +std::string sanitize(const char* input, const size_t size = 64) { + char out[64]; + FsHelpers::sanitizePathComponentForFat32(input, out, size); + return out; +} + +// Book titles from the EPUBs attached to #2103 and #2199. +constexpr char kTitle2103[] = "Богиня глюкозы. Нормализуйте уровень сахара в крови, чтобы изменить свою жизнь"; +constexpr char kTitle2199[] = "Вглядываясь в солнце. Жизнь без страха смерти"; + +TEST(SanitizePathComponentForFat32, KeepsTitleThatFits) { + EXPECT_EQ(sanitize("Эдем (полный перевод)"), "Эдем-(полный-перевод)"); +} + +// The readers copy the title into ScreenshotInfo::title (char[64]) with snprintf, which can +// end the copy partway through a Cyrillic letter. +TEST(SanitizePathComponentForFat32, DropsLetterCutOffByCaller) { + char title[64]; + snprintf(title, sizeof(title), "%s", kTitle2103); + EXPECT_EQ(sanitize(title), "Богиня-глюкозы.-Нормализуйте-уров"); + snprintf(title, sizeof(title), "%s", kTitle2199); + EXPECT_EQ(sanitize(title), "Вглядываясь-в-солнце.-Жизнь-без-ст"); +} + +TEST(SanitizePathComponentForFat32, DoesNotSplitLetterAtBufferLimit) { + // Each letter of "Жизнь" is 2 bytes. 7 bytes of room holds "Жиз" and half of "н". + EXPECT_EQ(sanitize("Жизнь", 8), "Жиз"); + EXPECT_EQ(sanitize(kTitle2103), "Богиня-глюкозы.-Нормализуйте-уров"); +} + } // namespace From ef08c3ad19787fecda7b8b4ab1ad1e1d17e10862 Mon Sep 17 00:00:00 2001 From: Uri Tauber Date: Sun, 27 Sep 2026 11:12:54 +0300 Subject: [PATCH 33/62] fix: speed up long status titles (#3573) --- lib/GfxRenderer/GfxRenderer.cpp | 33 ++++++++++++++++++++++++++++++--- 1 file changed, 30 insertions(+), 3 deletions(-) diff --git a/lib/GfxRenderer/GfxRenderer.cpp b/lib/GfxRenderer/GfxRenderer.cpp index 7b25e96fb49..cadb99ce88e 100644 --- a/lib/GfxRenderer/GfxRenderer.cpp +++ b/lib/GfxRenderer/GfxRenderer.cpp @@ -1809,11 +1809,38 @@ std::string GfxRenderer::truncatedText(const int fontId, const char* text, const return item; } - while (!item.empty() && getTextWidth(fontId, (item + ellipsis).c_str(), style) >= maxWidth) { - utf8RemoveLastChar(item); + size_t charCount = 0; + for (const unsigned char c : item) { + if ((c & 0xC0) != 0x80) ++charCount; } - return item.empty() ? ellipsis : item + ellipsis; + std::string candidate; + candidate.reserve(item.size() + 3); + const auto setCandidate = [&](const size_t characterCount) { + size_t end = 0; + for (size_t count = 0; end < item.size() && count < characterCount; ++count) { + ++end; + while (end < item.size() && (static_cast(item[end]) & 0xC0) == 0x80) ++end; + } + candidate.assign(item.data(), end); + candidate += ellipsis; + }; + + // Binary search avoids repeatedly measuring nearly the entire long title. + size_t low = 0; + size_t high = charCount; + while (low < high) { + const size_t mid = low + (high - low + 1) / 2; + setCandidate(mid); + if (getTextWidth(fontId, candidate.c_str(), style) < maxWidth) { + low = mid; + } else { + high = mid - 1; + } + } + + setCandidate(low); + return candidate; } std::vector GfxRenderer::wrappedText(const int fontId, const char* text, const int maxWidth, From 93e98bb78702e29868a16a13b80c40e6b36ccdff Mon Sep 17 00:00:00 2001 From: Uri Tauber Date: Sun, 27 Sep 2026 12:16:01 +0300 Subject: [PATCH 34/62] fix: pin pioarduino in release.yml too --- .github/workflows/release.yml | 12 ++++++++++++ 1 file changed, 12 insertions(+) diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 78afa390b68..f923756edc5 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -71,6 +71,18 @@ jobs: "cryptography>=45.0.3" "certifi>=2025.8.3" "ecdsa>=0.19.1" "bitstring>=4.3.1" \ "reedsolo>=1.5.3,<1.8" "esp-idf-size>=2.0.0" "esp-coredump>=1.14.0" + - name: Pin pioarduino core inside the platform penv + env: + PIO_ENV: ${{ github.event.release.prerelease && matrix.rc_env || matrix.release_env }} + run: | + set -euo pipefail + # Envs with custom_sdkconfig run a nested `pio run` from + # ~/.platformio/penv. Keep that core on the same version as the outer core. + # See https://github.com/pioarduino/platform-espressif32/issues/529 + pio pkg install -e "$PIO_ENV" + uv pip install --python ~/.platformio/penv/bin/python "pioarduino==6.1.19" + ~/.platformio/penv/bin/pio --version + - name: Build CrossPoint env: PIO_ENV: ${{ github.event.release.prerelease && matrix.rc_env || matrix.release_env }} From b145c14313fa9fa0349ca018c9f98514649b823e Mon Sep 17 00:00:00 2001 From: Julia Nguyen Date: Mon, 28 Sep 2026 03:58:49 -0400 Subject: [PATCH 35/62] fix: update keyboard layout and look (#3755) --- freeink-sdk | 2 +- src/activities/util/KeyboardEntryActivity.cpp | 72 ++++++++++++------- src/components/themes/BaseTheme.h | 2 +- src/components/themes/lyra/LyraTheme.h | 2 +- .../themes/roundedraff/RoundedRaffTheme.h | 2 +- 5 files changed, 49 insertions(+), 31 deletions(-) diff --git a/freeink-sdk b/freeink-sdk index 111fdcc7f01..225c097cfb6 160000 --- a/freeink-sdk +++ b/freeink-sdk @@ -1 +1 @@ -Subproject commit 111fdcc7f0176c3ee38391a160ee296bf492dbd8 +Subproject commit 225c097cfb6d5ecd4ca556041746123faeb4bc79 diff --git a/src/activities/util/KeyboardEntryActivity.cpp b/src/activities/util/KeyboardEntryActivity.cpp index d1579ed6a0d..5ccbcdf593e 100644 --- a/src/activities/util/KeyboardEntryActivity.cpp +++ b/src/activities/util/KeyboardEntryActivity.cpp @@ -17,6 +17,17 @@ namespace fui = freeink::ui; namespace { constexpr fui::ActionId ACTION_KEY = 1; +constexpr int KEYBOARD_MIN_GAP = 6; +constexpr int KEYBOARD_PANEL_PADDING = 4; +constexpr int BUTTON_KEY_HEIGHT = 42; +constexpr int BUTTON_KEYBOARD_HINT_GAP = 4; + +int keyboardGap(const ThemeMetrics& metrics) { return std::max(metrics.keyboardKeySpacing, KEYBOARD_MIN_GAP); } + +int keyboardKeysHeight(const ThemeMetrics& metrics, const int rows, const bool hasTouch) { + const int keyHeight = hasTouch ? metrics.keyboardKeyHeight : BUTTON_KEY_HEIGHT; + return rows * keyHeight + (rows > 1 ? (rows - 1) * keyboardGap(metrics) : 0) + KEYBOARD_PANEL_PADDING * 2; +} // --------------------------------------------------------------------------- // URL layers. The SDK builtin layouts have no URL variant (":", "/", ".", the @@ -25,14 +36,19 @@ constexpr fui::ActionId ACTION_KEY = 1; // regardless of UI language. // --------------------------------------------------------------------------- +constexpr uint8_t URL_KEY_WIDTH = 2; +constexpr uint8_t URL_WIDE_CONTROL_WIDTH = 3; + #define UK(label, output, value) \ - fui::KeyboardKey { label, output, fui::KeyKind::Normal, fui::StateNormal, value, 1, true, nullptr } + fui::KeyboardKey { label, output, fui::KeyKind::Normal, fui::StateNormal, value, URL_KEY_WIDTH, true, nullptr } #define UKA(label, output, value, alt) \ - fui::KeyboardKey { label, output, fui::KeyKind::Normal, fui::StateNormal, value, 1, true, alt } + fui::KeyboardKey { label, output, fui::KeyKind::Normal, fui::StateNormal, value, URL_KEY_WIDTH, true, alt } #define UKW(label, output, value, units) \ - fui::KeyboardKey { label, output, fui::KeyKind::Normal, fui::StateNormal, value, units, true, nullptr } + fui::KeyboardKey { label, output, fui::KeyKind::Normal, fui::StateNormal, value, units *URL_KEY_WIDTH, true, nullptr } #define UKS(label, kind, value, units) \ - fui::KeyboardKey { label, nullptr, kind, fui::StateNormal, value, units, true, nullptr } + fui::KeyboardKey { label, nullptr, kind, fui::StateNormal, value, units *URL_KEY_WIDTH, true, nullptr } +#define UK15(label, kind, value) \ + fui::KeyboardKey { label, nullptr, kind, fui::StateNormal, value, URL_WIDE_CONTROL_WIDTH, true, nullptr } constexpr int16_t URL_PANEL_VALUE = -3; // mirrors KeyboardEntryActivity::URL_PANEL_KEY @@ -47,7 +63,7 @@ const fui::KeyboardKey URL_ROW1[] = {UK("q", "q", 'q'), UK("w", "w", 'w'), UK("e const fui::KeyboardKey URL_ROW2[] = {UK("a", "a", 'a'), UK("s", "s", 's'), UK("d", "d", 'd'), UK("f", "f", 'f'), UK("g", "g", 'g'), UK("h", "h", 'h'), UK("j", "j", 'j'), UK("k", "k", 'k'), UK("l", "l", 'l')}; -const fui::KeyboardKey URL_ROW3[] = {UKS("Shift", fui::KeyKind::Shift, fui::QWERTY_KEY_SHIFT, 2), +const fui::KeyboardKey URL_ROW3[] = {UK15(nullptr, fui::KeyKind::Shift, fui::QWERTY_KEY_SHIFT), UK("z", "z", 'z'), UK("x", "x", 'x'), UK("c", "c", 'c'), @@ -55,7 +71,7 @@ const fui::KeyboardKey URL_ROW3[] = {UKS("Shift", fui::KeyKind::Shift, fui::QWER UK("b", "b", 'b'), UK("n", "n", 'n'), UK("m", "m", 'm'), - UKS("Del", fui::KeyKind::Delete, fui::QWERTY_KEY_BACKSPACE, 2)}; + UK15("Del", fui::KeyKind::Delete, fui::QWERTY_KEY_BACKSPACE)}; // URLs have no spaces, so the URL bottom row spends the space slot on ":", // "/", "." and the snippet-panel toggle instead (the legacy keyboard did the // same with its "URL" key). @@ -63,7 +79,7 @@ const fui::KeyboardKey URL_BOTTOM[] = {UKS("?123", fui::KeyKind::Mode, fui::QWER UK(":", ":", ':'), UK("/", "/", '/'), UK(".", ".", '.'), - UKW("URL", nullptr, URL_PANEL_VALUE, 2), + UKW("URL", nullptr, URL_PANEL_VALUE, 3), UKS("OK", fui::KeyKind::Ok, fui::QWERTY_KEY_ENTER, 2)}; const fui::KeyboardKey URL_SHIFT_ROW1[] = {UK("Q", "Q", 'Q'), UK("W", "W", 'W'), UK("E", "E", 'E'), UK("R", "R", 'R'), @@ -72,7 +88,7 @@ const fui::KeyboardKey URL_SHIFT_ROW1[] = {UK("Q", "Q", 'Q'), UK("W", "W", 'W'), const fui::KeyboardKey URL_SHIFT_ROW2[] = {UK("A", "A", 'A'), UK("S", "S", 'S'), UK("D", "D", 'D'), UK("F", "F", 'F'), UK("G", "G", 'G'), UK("H", "H", 'H'), UK("J", "J", 'J'), UK("K", "K", 'K'), UK("L", "L", 'L')}; -const fui::KeyboardKey URL_SHIFT_ROW3[] = {UKS("Shift", fui::KeyKind::Shift, fui::QWERTY_KEY_SHIFT, 2), +const fui::KeyboardKey URL_SHIFT_ROW3[] = {UK15(nullptr, fui::KeyKind::Shift, fui::QWERTY_KEY_SHIFT), UK("Z", "Z", 'Z'), UK("X", "X", 'X'), UK("C", "C", 'C'), @@ -80,7 +96,7 @@ const fui::KeyboardKey URL_SHIFT_ROW3[] = {UKS("Shift", fui::KeyKind::Shift, fui UK("B", "B", 'B'), UK("N", "N", 'N'), UK("M", "M", 'M'), - UKS("Del", fui::KeyKind::Delete, fui::QWERTY_KEY_BACKSPACE, 2)}; + UK15("Del", fui::KeyKind::Delete, fui::QWERTY_KEY_BACKSPACE)}; // Snippet keys: multi-character outputs, stable ids above the localized-key // range so they never collide with layout key ids. @@ -91,14 +107,15 @@ const fui::KeyboardKey URL_SNIP_ROW2[] = {UK("http://", "http://", 2004), UK("19 const fui::KeyboardKey URL_SNIP_ROW3[] = {UK("/opds", "/opds", 2007), UK(":8080", ":8080", 2008), UK(".net", ".net", 2009)}; const fui::KeyboardKey URL_SNIP_BOTTOM[] = {UKS("abc", fui::KeyKind::Mode, fui::QWERTY_KEY_MODE, 2), - UKW("URL", nullptr, URL_PANEL_VALUE, 2), - UKS("Del", fui::KeyKind::Delete, fui::QWERTY_KEY_BACKSPACE, 2), + UKW("URL", nullptr, URL_PANEL_VALUE, 3), + UK15("Del", fui::KeyKind::Delete, fui::QWERTY_KEY_BACKSPACE), UKS("OK", fui::KeyKind::Ok, fui::QWERTY_KEY_ENTER, 2)}; #undef UK #undef UKA #undef UKW #undef UKS +#undef UK15 const fui::KeyboardRow URL_ROWS[] = { {URL_NUM_ROW, 10, 0}, {URL_ROW1, 10, 0}, {URL_ROW2, 9, 1}, {URL_ROW3, 9, 0}, {URL_BOTTOM, 6, 0}}; @@ -511,14 +528,12 @@ fui::Rect KeyboardEntryActivity::keyboardRect() const { const int pageWidth = renderer.getScreenWidth(); const int pageHeight = renderer.getScreenHeight(); const int rows = currentLayout().rowCount; - const int gap = metrics.keyboardKeySpacing; - const int height = rows * metrics.keyboardKeyHeight + (rows > 1 ? (rows - 1) * gap : 0); - const int width = pageWidth * metrics.keyboardWidthPercent / 100; - const int x = (pageWidth - width) / 2; - const int y = - pageHeight - metrics.buttonHintsHeight - metrics.verticalSpacing - height + metrics.keyboardVerticalOffset; - return fui::Rect{static_cast(x), static_cast(y), static_cast(width), - static_cast(height)}; + const bool hasTouch = mappedInput.hasTouch(); + const int height = keyboardKeysHeight(metrics, rows, hasTouch); + const int hintGap = hasTouch ? metrics.verticalSpacing - metrics.keyboardVerticalOffset : BUTTON_KEYBOARD_HINT_GAP; + const int y = pageHeight - metrics.buttonHintsHeight - height - hintGap; + return fui::Rect{0, static_cast(y), static_cast(pageWidth), + static_cast(hasTouch ? pageHeight - y : height)}; } void KeyboardEntryActivity::loop() { @@ -897,6 +912,9 @@ void KeyboardEntryActivity::render(RenderLock&&) { } const fui::Rect kbRect = keyboardRect(); + const int keysHeight = keyboardKeysHeight(metrics, currentLayout().rowCount, mappedInput.hasTouch()); + const fui::Rect keysRect{kbRect.x, static_cast(kbRect.y + (kbRect.height - keysHeight) / 2), kbRect.width, + static_cast(keysHeight)}; const int tipsLh = renderer.getLineHeight(SMALL_FONT_ID); const int underlineBottom = inputStartY + inputHeight + lineHeight + metrics.verticalSpacing + 4; @@ -969,7 +987,6 @@ void KeyboardEntryActivity::render(RenderLock&&) { props.layout = &layout; props.keyAction = ACTION_KEY; // one action id; loop() dispatches on key value props.okLabel = tr(STR_OK_BUTTON); - props.shiftLabel = tr(STR_KEY_SHIFT); // Match the label to the layer the mode key leads back from: the symbols // layer and the URL snippet panel both label it "abc" in the static tables. props.modeLabel = @@ -980,13 +997,14 @@ void KeyboardEntryActivity::render(RenderLock&&) { props.labelText.font = layoutId == fui::KeyboardLayoutId::ArabicAr && !symbols ? fui::GfxRendererTarget::FONT_SMALL : fui::GfxRendererTarget::FONT_BODY; props.altText.font = fui::GfxRendererTarget::FONT_SMALL; - props.gap = static_cast(metrics.keyboardKeySpacing); - props.padding = fui::Insets{0, 0, 0, 0}; - // Fingers land low on the bottom row (occlusion) and there is no key below - // to catch the miss — extend its hit band down to the button hints bar. - const int hintsTop = renderer.getScreenHeight() - metrics.buttonHintsHeight; - props.bottomHitOverflow = static_cast(std::max(0, hintsTop - (kbRect.y + kbRect.height))); - fui::keyboard(frame, kbRect, props); + props.gap = props.rowGap = static_cast(keyboardGap(metrics)); + if (urlPanel) props.uniformKeyWidth = false; // Shortcut rows stretch independently. + if (!mappedInput.hasTouch()) props.background = fui::Paint::none(); + frame.target().fill(kbRect, props.background); + const int bottomEdge = + mappedInput.hasTouch() ? renderer.getScreenHeight() : renderer.getScreenHeight() - metrics.buttonHintsHeight; + props.bottomHitOverflow = static_cast(std::max(0, bottomEdge - keysRect.bottom())); + fui::keyboard(frame, keysRect, props); interactions.publish(); interactionsReady = true; diff --git a/src/components/themes/BaseTheme.h b/src/components/themes/BaseTheme.h index c7e20b4166d..80455a41841 100644 --- a/src/components/themes/BaseTheme.h +++ b/src/components/themes/BaseTheme.h @@ -193,7 +193,7 @@ constexpr ThemeMetrics values = {.batteryWidth = 15, .progressBarMarginTop = 1, .statusBarHorizontalMargin = 5, .statusBarVerticalMargin = 19, - .keyboardKeyHeight = 48, + .keyboardKeyHeight = 56, .keyboardKeySpacing = 0, .keyboardCenteredText = false, .keyboardVerticalOffset = -13, diff --git a/src/components/themes/lyra/LyraTheme.h b/src/components/themes/lyra/LyraTheme.h index 4fc194ad3a1..5dae6063fe0 100644 --- a/src/components/themes/lyra/LyraTheme.h +++ b/src/components/themes/lyra/LyraTheme.h @@ -50,7 +50,7 @@ constexpr ThemeMetrics values = {.batteryWidth = 16, .progressBarMarginTop = 1, .statusBarHorizontalMargin = 5, .statusBarVerticalMargin = 19, - .keyboardKeyHeight = 48, + .keyboardKeyHeight = 56, .keyboardKeySpacing = 0, .keyboardCenteredText = false, .keyboardVerticalOffset = -7, diff --git a/src/components/themes/roundedraff/RoundedRaffTheme.h b/src/components/themes/roundedraff/RoundedRaffTheme.h index 9837aabadd5..f86634d3bf2 100644 --- a/src/components/themes/roundedraff/RoundedRaffTheme.h +++ b/src/components/themes/roundedraff/RoundedRaffTheme.h @@ -57,7 +57,7 @@ constexpr ThemeMetrics values = {.batteryWidth = 15, .progressBarMarginTop = 1, .statusBarHorizontalMargin = 5, .statusBarVerticalMargin = 19, - .keyboardKeyHeight = 36, + .keyboardKeyHeight = 56, .keyboardKeySpacing = 10, .keyboardCenteredText = false, .keyboardVerticalOffset = 0, From 28132052f2378bb3a82cff9bef2173c958d1fe64 Mon Sep 17 00:00:00 2001 From: suhsoohong <114119600+suhsoohong@users.noreply.github.com> Date: Mon, 28 Sep 2026 18:00:28 +1000 Subject: [PATCH 36/62] test: include in library builder storage stub (#3765) --- test/library_builder/stubs/HalStorage.h | 1 + 1 file changed, 1 insertion(+) diff --git a/test/library_builder/stubs/HalStorage.h b/test/library_builder/stubs/HalStorage.h index cf4fd1bc524..2be1a030aba 100644 --- a/test/library_builder/stubs/HalStorage.h +++ b/test/library_builder/stubs/HalStorage.h @@ -1,6 +1,7 @@ #pragma once #include +#include #include #include #include From 1f08ce9342b820d1bd898a4fc4c410adaef06097 Mon Sep 17 00:00:00 2001 From: Uri Tauber Date: Mon, 28 Sep 2026 11:14:51 +0300 Subject: [PATCH 37/62] feat: navigate lists on button press (#3754) --- src/activities/UiListActivity.cpp | 4 +- src/activities/UiListActivity.h | 2 +- src/activities/UiTabListActivity.cpp | 26 ++++++- src/activities/UiTabListActivity.h | 3 +- .../browser/OpdsBookBrowserActivity.cpp | 17 +++- .../browser/OpdsBookBrowserActivity.h | 1 + .../library/LibraryListActivity.cpp | 22 ++++-- src/util/ButtonNavigator.cpp | 10 +++ test/CMakeLists.txt | 1 + .../ButtonPressNavigationTest.cpp | 77 +++++++++++++++++++ test/button_press_navigation/CMakeLists.txt | 4 + .../stubs/MappedInputManager.h | 21 +++++ 12 files changed, 169 insertions(+), 19 deletions(-) create mode 100644 test/button_press_navigation/ButtonPressNavigationTest.cpp create mode 100644 test/button_press_navigation/CMakeLists.txt create mode 100644 test/button_press_navigation/stubs/MappedInputManager.h diff --git a/src/activities/UiListActivity.cpp b/src/activities/UiListActivity.cpp index 9590a1e3b8d..95507d308d5 100644 --- a/src/activities/UiListActivity.cpp +++ b/src/activities/UiListActivity.cpp @@ -96,8 +96,8 @@ void UiListActivity::loop() { void UiListActivity::navigateButtons() { const int count = listCount(); auto& n = activeNav(); - buttonNavigator.onNextRelease([this, count, &n] { moveSelectionTo(ButtonNavigator::nextIndex(n.selected, count)); }); - buttonNavigator.onPreviousRelease( + buttonNavigator.onNextPress([this, count, &n] { moveSelectionTo(ButtonNavigator::nextIndex(n.selected, count)); }); + buttonNavigator.onPreviousPress( [this, count, &n] { moveSelectionTo(ButtonNavigator::previousIndex(n.selected, count)); }); // Page by the rows the last build actually drew (pageRows), not the // fixed-height visibleRows estimate: with wrapped labels the estimate diff --git a/src/activities/UiListActivity.h b/src/activities/UiListActivity.h index 6b2480a1805..bbe93b14917 100644 --- a/src/activities/UiListActivity.h +++ b/src/activities/UiListActivity.h @@ -48,7 +48,7 @@ class UiListActivity : public Activity, protected UiAppHost { // Bounds-checked ACTION_ROW dispatch. Default: selection follows the tapped // row, then long-press/activate. UiTabListActivity remaps row -> ring. virtual void onRowAction(const freeink::ui::ActionEvent& event); - // The button-navigation tail of loop(): release steps the selection, hold + // The button-navigation tail of loop(): press steps the selection, hold // jumps by page. UiTabListActivity replaces it with the ring walk. virtual void navigateButtons(); // First hook in loop(); return true when the pass is consumed (popups, extra diff --git a/src/activities/UiTabListActivity.cpp b/src/activities/UiTabListActivity.cpp index 368163a9a4f..eac727ba5aa 100644 --- a/src/activities/UiTabListActivity.cpp +++ b/src/activities/UiTabListActivity.cpp @@ -60,13 +60,31 @@ void UiTabListActivity::moveRingTo(const int ringIndex) { } void UiTabListActivity::navigateButtons() { + if (mappedInput.wasPressed(MappedInputManager::Button::NavNext) || + mappedInput.wasPressed(MappedInputManager::Button::NavPrevious)) { + navigationStartedOnTabs = ringPos() == 0; + } // Buttons walk the tab band (index 0) plus the rows (1..listCount). const int ringSize = listCount() + 1; - buttonNavigator.onNextRelease([this, ringSize] { moveRingTo(ButtonNavigator::nextIndex(ringPos(), ringSize)); }); - buttonNavigator.onPreviousRelease( + buttonNavigator.onNextPress([this, ringSize] { moveRingTo(ButtonNavigator::nextIndex(ringPos(), ringSize)); }); + buttonNavigator.onPreviousPress( [this, ringSize] { moveRingTo(ButtonNavigator::previousIndex(ringPos(), ringSize)); }); - buttonNavigator.onNextContinuous([this] { stepTab(1); }); - buttonNavigator.onPreviousContinuous([this] { stepTab(-1); }); + buttonNavigator.onNextContinuous([this] { + if (navigationStartedOnTabs) { + activeNav().selected = 0; + } else if (ringPos() == 0 && listCount() > 0) { + activeNav().selected = 1; + } + stepTab(1); + }); + buttonNavigator.onPreviousContinuous([this] { + if (navigationStartedOnTabs) { + activeNav().selected = 0; + } else if (ringPos() == 0 && listCount() > 0) { + activeNav().selected = 1; + } + stepTab(-1); + }); } void UiTabListActivity::syncTabListViewport(UiScreen& screen, fui::ListProps& props) { diff --git a/src/activities/UiTabListActivity.h b/src/activities/UiTabListActivity.h index b22e5c830bc..09dcd9d526a 100644 --- a/src/activities/UiTabListActivity.h +++ b/src/activities/UiTabListActivity.h @@ -49,7 +49,7 @@ class UiTabListActivity : public UiListActivity { int ringPos() const; // ACTION_ROW lands as ring = row + 1, then activateIndex(row). void onRowAction(const freeink::ui::ActionEvent& event) override; - // Release walks the ring; continuous hold steps the tab. + // Press walks the ring; continuous hold steps the tab. void navigateButtons() override; // Move to a ring position: tab bar rewinds the viewport, a row pulls the // viewport to itself. @@ -67,6 +67,7 @@ class UiTabListActivity : public UiListActivity { // Per-tab selection/viewport state, sized in onEnter. Protected so subclass // tab-switch code can seed the target tab's ring/viewport. std::vector tabNavs; + bool navigationStartedOnTabs = false; // When > 0, each tab pill is capped at its label width plus this padding // per side, centered in its unchanged equal-width slot. With few tabs the diff --git a/src/activities/browser/OpdsBookBrowserActivity.cpp b/src/activities/browser/OpdsBookBrowserActivity.cpp index 2feac65d615..efd57748ec7 100644 --- a/src/activities/browser/OpdsBookBrowserActivity.cpp +++ b/src/activities/browser/OpdsBookBrowserActivity.cpp @@ -153,12 +153,19 @@ void OpdsBookBrowserActivity::loop() { if (state == BrowserState::DOWNLOADING) return; if (state == BrowserState::BROWSING) { + if (mappedInput.wasPressed(MappedInputManager::Button::Left)) { + leftSearchPending = !searchTemplate.empty() && selectorIndex == 0; + } if (mappedInput.wasReleased(MappedInputManager::Button::Confirm)) { activateSelected(); } else if (mappedInput.wasReleased(MappedInputManager::Button::Back)) { navigateBack(); } else if (mappedInput.wasReleased(MappedInputManager::Button::Left)) { - if (!searchTemplate.empty() && selectorIndex == 0) launchSearch(); + if (leftSearchPending) { + leftSearchPending = false; + launchSearch(); + return; + } } // Touch goes through the FreeInkApp: render() registered every tap target @@ -190,14 +197,16 @@ void OpdsBookBrowserActivity::loop() { listNav.follow(static_cast(entries.size())); requestUpdate(); }; - buttonNavigator.onNextRelease( + buttonNavigator.onNextPress( [this, &moveSelection] { moveSelection(ButtonNavigator::nextIndex(selectorIndex, entries.size())); }); - buttonNavigator.onPreviousRelease( - [this, &moveSelection] { moveSelection(ButtonNavigator::previousIndex(selectorIndex, entries.size())); }); + buttonNavigator.onPreviousPress([this, &moveSelection] { + if (!leftSearchPending) moveSelection(ButtonNavigator::previousIndex(selectorIndex, entries.size())); + }); buttonNavigator.onNextContinuous([this, &moveSelection] { moveSelection(ButtonNavigator::nextPageIndex(selectorIndex, entries.size(), listNav.visibleRows)); }); buttonNavigator.onPreviousContinuous([this, &moveSelection] { + leftSearchPending = false; moveSelection(ButtonNavigator::previousPageIndex(selectorIndex, entries.size(), listNav.visibleRows)); }); } diff --git a/src/activities/browser/OpdsBookBrowserActivity.h b/src/activities/browser/OpdsBookBrowserActivity.h index 4c28ea88bb3..38d241af24a 100644 --- a/src/activities/browser/OpdsBookBrowserActivity.h +++ b/src/activities/browser/OpdsBookBrowserActivity.h @@ -38,6 +38,7 @@ class OpdsBookBrowserActivity final : public Activity, private UiAppHost { std::string currentPath; std::string searchTemplate; int selectorIndex = 0; + bool leftSearchPending = false; std::string errorMessage; std::string statusMessage; size_t downloadProgress = 0; diff --git a/src/activities/library/LibraryListActivity.cpp b/src/activities/library/LibraryListActivity.cpp index a0b69f2e21f..2b83dcb1ef7 100644 --- a/src/activities/library/LibraryListActivity.cpp +++ b/src/activities/library/LibraryListActivity.cpp @@ -770,28 +770,36 @@ bool LibraryListActivity::handleButtons() { void LibraryListActivity::navigateButtons() { const int count = listCount(); auto& nav = activeNav(); - buttonNavigator.onNextRelease([this, count] { + if (mappedInput.wasPressed(MappedInputManager::Button::NavNext) || + mappedInput.wasPressed(MappedInputManager::Button::NavPrevious)) { + navigationStartedOnTabs = tabsFocused(); + } + buttonNavigator.onNextPress([this, count] { if (count > 0) moveRingTo(ringPos() == count ? 1 : ringPos() + 1); }); - buttonNavigator.onPreviousRelease([this, count] { - if (tabsFocused() && !degraded) { - openSearch(); - } else if (count > 0) { + buttonNavigator.onPreviousPress([this, count] { + if ((!navigationStartedOnTabs || degraded) && count > 0) { moveRingTo(ringPos() <= 1 ? count : ringPos() - 1); } }); + // Search is an activation: defer it so holding Previous can still step tabs. + buttonNavigator.onPreviousRelease([this] { + if (navigationStartedOnTabs && tabsFocused() && !degraded) openSearch(); + }); // A held button steps tabs while the strip has focus (the base behaviour // Settings keeps) and page-jumps once the selection is down in the rows, // where fast travel through a long shelf is what a hold means. buttonNavigator.onNextContinuous([this, count, &nav] { - if (tabsFocused()) { + if (navigationStartedOnTabs) { + activeNav().selected = 0; stepTab(1); } else if (count > 0) { moveRingTo(ButtonNavigator::nextPageIndex(selectedEntry(), count, nav.pageRows()) + 1); } }); buttonNavigator.onPreviousContinuous([this, count, &nav] { - if (tabsFocused()) { + if (navigationStartedOnTabs) { + activeNav().selected = 0; stepTab(-1); } else if (count > 0) { moveRingTo(ButtonNavigator::previousPageIndex(selectedEntry(), count, nav.pageRows()) + 1); diff --git a/src/util/ButtonNavigator.cpp b/src/util/ButtonNavigator.cpp index d984413875b..6947851e5d1 100644 --- a/src/util/ButtonNavigator.cpp +++ b/src/util/ButtonNavigator.cpp @@ -1,5 +1,7 @@ #include "ButtonNavigator.h" +#include + const MappedInputManager* ButtonNavigator::mappedInput = nullptr; void ButtonNavigator::onNext(const Callback& callback) { @@ -54,6 +56,14 @@ void ButtonNavigator::onRelease(const Buttons& buttons, const Callback& callback } void ButtonNavigator::onContinuous(const Buttons& buttons, const Callback& callback) { + const bool wasPressedOrReleased = + std::any_of(buttons.begin(), buttons.end(), [](const MappedInputManager::Button button) { + return mappedInput != nullptr && (mappedInput->wasPressed(button) || mappedInput->wasReleased(button)); + }); + if (wasPressedOrReleased) { + lastContinuousNavTime = 0; + return; // A press already stepped once; a release must never repeat. + } const bool isPressed = std::any_of(buttons.begin(), buttons.end(), [this](const MappedInputManager::Button button) { return mappedInput != nullptr && mappedInput->isPressed(button) && shouldNavigateContinuously(); }); diff --git a/test/CMakeLists.txt b/test/CMakeLists.txt index 184721f52db..f0c31825ca5 100644 --- a/test/CMakeLists.txt +++ b/test/CMakeLists.txt @@ -65,6 +65,7 @@ add_subdirectory(page_link) add_subdirectory(content_opf_parser) add_subdirectory(chapter_position) add_subdirectory(home_button) +add_subdirectory(button_press_navigation) add_subdirectory(inflate_stream) add_subdirectory(fs_helpers) diff --git a/test/button_press_navigation/ButtonPressNavigationTest.cpp b/test/button_press_navigation/ButtonPressNavigationTest.cpp new file mode 100644 index 00000000000..444b6809ccf --- /dev/null +++ b/test/button_press_navigation/ButtonPressNavigationTest.cpp @@ -0,0 +1,77 @@ +#include + +#include "util/ButtonNavigator.h" + +unsigned long testNowMs = 1000; + +namespace { +constexpr uint8_t PREVIOUS = 1; +constexpr uint8_t NEXT = 2; + +class ButtonPressNavigationTest : public ::testing::Test { + protected: + MappedInputManager input; + ButtonNavigator navigator; + int selected = 0; + int pages = 0; + + void SetUp() override { + testNowMs = 1000; + ButtonNavigator::setMappedInputManager(input); + } + + // The shared list call order, with the production navigator. + void navigate() { + navigator.onNextPress([this] { selected = ButtonNavigator::nextIndex(selected, 10); }); + navigator.onPreviousPress([this] { selected = ButtonNavigator::previousIndex(selected, 10); }); + navigator.onNextContinuous([this] { ++pages; }); + navigator.onPreviousContinuous([this] { --pages; }); + } +}; + +TEST_F(ButtonPressNavigationTest, PressStepsImmediatelyAndReleaseDoesNotStep) { + input.frame = {0, NEXT, 0, NEXT}; + navigate(); + EXPECT_EQ(selected, 1); + input.frame = {90, 0, NEXT, 0}; + navigate(); + EXPECT_EQ(selected, 1); + EXPECT_EQ(pages, 0); +} + +TEST_F(ButtonPressNavigationTest, HoldPagesAfterThresholdAndStopsOnRelease) { + input.frame = {0, NEXT, 0, NEXT}; + navigate(); + testNowMs += 501; + input.frame = {501, 0, 0, NEXT}; + navigate(); + EXPECT_EQ(selected, 1); + EXPECT_EQ(pages, 1); + testNowMs += 100; + input.frame.heldMs += 100; + navigate(); + EXPECT_EQ(pages, 1); + testNowMs += 1000; + input.frame = {1601, 0, NEXT, 0}; + navigate(); + EXPECT_EQ(pages, 1); +} + +TEST_F(ButtonPressNavigationTest, PressNeverAlsoRepeatsEvenWithOldHeldDuration) { + input.frame = {2000, NEXT, 0, NEXT}; + navigate(); + EXPECT_EQ(selected, 1); + EXPECT_EQ(pages, 0); +} + +TEST_F(ButtonPressNavigationTest, FastDirectionChangeStartsANewRepeatInterval) { + input.frame = {501, 0, 0, NEXT}; + navigate(); + EXPECT_EQ(pages, 1); + input.frame = {0, PREVIOUS, NEXT, PREVIOUS}; + navigate(); + EXPECT_EQ(selected, 9); + EXPECT_EQ(pages, 1); +} + +} // namespace diff --git a/test/button_press_navigation/CMakeLists.txt b/test/button_press_navigation/CMakeLists.txt new file mode 100644 index 00000000000..724b032a04b --- /dev/null +++ b/test/button_press_navigation/CMakeLists.txt @@ -0,0 +1,4 @@ +add_executable(ButtonPressNavigationTest ButtonPressNavigationTest.cpp ../../src/util/ButtonNavigator.cpp) +target_include_directories(ButtonPressNavigationTest PRIVATE stubs ../../src) +target_link_libraries(ButtonPressNavigationTest PRIVATE crosspoint_test_common GTest::gtest_main) +gtest_discover_tests(ButtonPressNavigationTest) diff --git a/test/button_press_navigation/stubs/MappedInputManager.h b/test/button_press_navigation/stubs/MappedInputManager.h new file mode 100644 index 00000000000..580b0511901 --- /dev/null +++ b/test/button_press_navigation/stubs/MappedInputManager.h @@ -0,0 +1,21 @@ +#pragma once + +#include + +extern unsigned long testNowMs; +inline unsigned long millis() { return testNowMs; } + +class MappedInputManager { + public: + enum class Button { NavPrevious, NavNext, Left = NavPrevious, Right = NavNext }; + struct Frame { + uint32_t heldMs = 0; + uint8_t pressed = 0; + uint8_t released = 0; + uint8_t held = 0; + } frame; + bool wasPressed(Button button) const { return frame.pressed & (1u << static_cast(button)); } + bool wasReleased(Button button) const { return frame.released & (1u << static_cast(button)); } + bool isPressed(Button button) const { return frame.held & (1u << static_cast(button)); } + unsigned long getHeldTime() const { return frame.heldMs; } +}; From e6af0c95110a66a0b7a087df2d95e7598dc79594 Mon Sep 17 00:00:00 2001 From: Uri Tauber Date: Mon, 28 Sep 2026 11:19:38 +0300 Subject: [PATCH 38/62] fix: Footnote selection with power button (#3766) --- src/activities/reader/EpubReaderFootnoteSelectActivity.cpp | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/src/activities/reader/EpubReaderFootnoteSelectActivity.cpp b/src/activities/reader/EpubReaderFootnoteSelectActivity.cpp index 95c19c95421..b27359cdbf0 100644 --- a/src/activities/reader/EpubReaderFootnoteSelectActivity.cpp +++ b/src/activities/reader/EpubReaderFootnoteSelectActivity.cpp @@ -60,7 +60,9 @@ void EpubReaderFootnoteSelectActivity::loop() { return; } - if (mappedInput.wasReleased(MappedInputManager::Button::Confirm) && !footnoteLinks.empty()) { + if ((mappedInput.wasReleased(MappedInputManager::Button::Confirm) || + mappedInput.wasReleased(MappedInputManager::Button::Power)) && + !footnoteLinks.empty()) { performJump(); return; } From b4181956c0a47adab54ab00ecef69c4e0e5f90f9 Mon Sep 17 00:00:00 2001 From: Julia Nguyen Date: Mon, 28 Sep 2026 12:17:21 -0400 Subject: [PATCH 39/62] fix: preserve reader status bar settings when upgrading --- src/util/ReaderStatusBarJson.h | 9 ++- .../ReaderStatusBarConfigTest.cpp | 80 ++++++++++++++++++- 2 files changed, 84 insertions(+), 5 deletions(-) diff --git a/src/util/ReaderStatusBarJson.h b/src/util/ReaderStatusBarJson.h index 15006906945..6fdb05ad37b 100644 --- a/src/util/ReaderStatusBarJson.h +++ b/src/util/ReaderStatusBarJson.h @@ -65,12 +65,15 @@ inline bool repairReaderStatusBarJson(const JsonVariantConst source, ReaderStatu return false; } const JsonArrayConst slots = source["slots"].as(); - if (slots.size() != ReaderStatusBarConfig::SLOT_COUNT) return true; + // The previous layout had two left slots, one center slot, and three right slots. + constexpr unsigned LEGACY_SLOT_COUNT = ReaderStatusBarConfig::SLOT_COUNT - 1; + if (slots.size() != LEGACY_SLOT_COUNT && slots.size() != ReaderStatusBarConfig::SLOT_COUNT) return true; ReaderStatusBarConfig repaired; - for (unsigned i = 0; i < ReaderStatusBarConfig::SLOT_COUNT; ++i) { + for (unsigned i = 0; i < slots.size(); ++i) { const int item = slots[i].as(); if (slots[i].is() && validReaderStatusBarItemValue(item, clockAvailable)) { - repaired.slots[i] = static_cast(item); + const unsigned target = slots.size() == LEGACY_SLOT_COUNT && i >= 2 ? i + 1 : i; + repaired.slots[target] = static_cast(item); } } const auto choice = [](const JsonVariantConst value, const int count, const uint8_t fallback) { diff --git a/test/reader_status_bar_config/ReaderStatusBarConfigTest.cpp b/test/reader_status_bar_config/ReaderStatusBarConfigTest.cpp index cf5ad9f4585..074d479a9f5 100644 --- a/test/reader_status_bar_config/ReaderStatusBarConfigTest.cpp +++ b/test/reader_status_bar_config/ReaderStatusBarConfigTest.cpp @@ -174,10 +174,86 @@ TEST(ReaderStatusBarConfig, RepairsDamagedSavedValuesWithoutDroppingValidSlots) EXPECT_EQ(repaired.progressBarThickness, 1); } -TEST(ReaderStatusBarConfig, WrongLengthSavedBarKeepsCurrentDefault) { +TEST(ReaderStatusBarConfig, MigratesSavedSixSlotBarsAndResavesSevenSlots) { + JsonDocument saved; + JsonArray topSlots = saved["top"]["slots"].to(); + for (const auto item : {ReaderStatusBarItem::Clock, ReaderStatusBarItem::Battery, ReaderStatusBarItem::TitleBook, + ReaderStatusBarItem::StablePageNumber, ReaderStatusBarItem::TimeLeftChapter, + ReaderStatusBarItem::BookProgressPercentage}) { + topSlots.add(static_cast(item)); + } + saved["top"]["percentageFormat"] = 2; + saved["top"]["progressBar"] = 0; + saved["top"]["thickness"] = 2; + + JsonArray bottomSlots = saved["bottom"]["slots"].to(); + for (const auto item : {ReaderStatusBarItem::Battery, ReaderStatusBarItem::TimeLeftBook, + ReaderStatusBarItem::TitleChapter, ReaderStatusBarItem::ChapterPageCount, + ReaderStatusBarItem::StablePageNumber, ReaderStatusBarItem::BookProgressPercentage}) { + bottomSlots.add(static_cast(item)); + } + saved["bottom"]["percentageFormat"] = 1; + saved["bottom"]["progressBar"] = 1; + saved["bottom"]["thickness"] = 0; + + ReaderStatusBarConfig top; + ReaderStatusBarConfig bottom; + EXPECT_TRUE(repairReaderStatusBarJson(saved["top"], top, true, 3, 3, 3)); + EXPECT_TRUE(repairReaderStatusBarJson(saved["bottom"], bottom, true, 3, 3, 3)); + const std::array expectedTop{ReaderStatusBarItem::Clock, + ReaderStatusBarItem::Battery, + ReaderStatusBarItem::Empty, + ReaderStatusBarItem::TitleBook, + ReaderStatusBarItem::StablePageNumber, + ReaderStatusBarItem::TimeLeftChapter, + ReaderStatusBarItem::BookProgressPercentage}; + const std::array expectedBottom{ReaderStatusBarItem::Battery, + ReaderStatusBarItem::TimeLeftBook, + ReaderStatusBarItem::Empty, + ReaderStatusBarItem::TitleChapter, + ReaderStatusBarItem::ChapterPageCount, + ReaderStatusBarItem::StablePageNumber, + ReaderStatusBarItem::BookProgressPercentage}; + EXPECT_EQ(top.slots, expectedTop); + EXPECT_EQ(bottom.slots, expectedBottom); + EXPECT_EQ(top.percentageFormat, 2); + EXPECT_EQ(top.progressBar, 0); + EXPECT_EQ(top.progressBarThickness, 2); + EXPECT_EQ(bottom.percentageFormat, 1); + EXPECT_EQ(bottom.progressBar, 1); + EXPECT_EQ(bottom.progressBarThickness, 0); + + JsonDocument resaved; + writeReaderStatusBarJson(resaved["top"].to(), top); + writeReaderStatusBarJson(resaved["bottom"].to(), bottom); + EXPECT_EQ(resaved["top"]["slots"].as().size(), ReaderStatusBarConfig::SLOT_COUNT); + EXPECT_EQ(resaved["bottom"]["slots"].as().size(), ReaderStatusBarConfig::SLOT_COUNT); + EXPECT_FALSE(repairReaderStatusBarJson(resaved["top"], top, true, 3, 3, 3)); + EXPECT_FALSE(repairReaderStatusBarJson(resaved["bottom"], bottom, true, 3, 3, 3)); +} + +TEST(ReaderStatusBarConfig, MigratesValidSixSlotItemsWhenAnotherItemIsInvalid) { + JsonDocument saved; + JsonArray slots = saved["slots"].to(); + for (const int item : {2, 1, 9, 5, 99, 7}) slots.add(item); + saved["progressBar"] = 0; + + ReaderStatusBarConfig config; + EXPECT_TRUE(repairReaderStatusBarJson(saved.as(), config, false, 3, 3, 3)); + EXPECT_EQ(config.slots[0], ReaderStatusBarItem::Battery); + EXPECT_EQ(config.slots[1], ReaderStatusBarItem::Empty); + EXPECT_EQ(config.slots[2], ReaderStatusBarItem::Empty); + EXPECT_EQ(config.slots[3], ReaderStatusBarItem::TitleChapter); + EXPECT_EQ(config.slots[4], ReaderStatusBarItem::ChapterPageCount); + EXPECT_EQ(config.slots[5], ReaderStatusBarItem::Empty); + EXPECT_EQ(config.slots[6], ReaderStatusBarItem::BookProgressPercentage); + EXPECT_EQ(config.progressBar, 0); +} + +TEST(ReaderStatusBarConfig, UnknownLengthSavedBarKeepsCurrentDefault) { JsonDocument saved; JsonArray slots = saved["slots"].to(); - for (int i = 0; i < 6; ++i) slots.add(static_cast(ReaderStatusBarItem::Battery)); + for (int i = 0; i < 5; ++i) slots.add(static_cast(ReaderStatusBarItem::Battery)); ReaderStatusBarConfig config; config.slots[ReaderStatusBarConfig::CENTER] = ReaderStatusBarItem::TitleChapter; From cab9c3734e4f8fe47f6acfdf36312de558ff14fa Mon Sep 17 00:00:00 2001 From: Julia Nguyen Date: Mon, 28 Sep 2026 12:26:29 -0400 Subject: [PATCH 40/62] fix: keep Library in portrait orientation --- src/activities/library/LibraryActivity.cpp | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/src/activities/library/LibraryActivity.cpp b/src/activities/library/LibraryActivity.cpp index ce6813af325..fce4c63db38 100644 --- a/src/activities/library/LibraryActivity.cpp +++ b/src/activities/library/LibraryActivity.cpp @@ -57,6 +57,10 @@ void LibraryActivity::onEnter() { { RenderLock lock; Activity::onEnter(); + renderer.setOrientation(GfxRenderer::Orientation::Portrait); + // The activity can be constructed while a landscape reader is still active. + // Refresh FreeInkUI's captured screen size and safe area after rotating. + app.setDevice(uiTarget.deviceContext()); if (RECENT_BOOKS.pruneMissing()) RECENT_BOOKS.saveToFile(); applySharedUiTheme(app, uiTarget); seriesScratch.reserve(128); From a7853fb44d39f7dca6d6cef9305a6f6fdd24065b Mon Sep 17 00:00:00 2001 From: Julia Nguyen Date: Mon, 28 Sep 2026 16:02:05 -0400 Subject: [PATCH 41/62] feat: allow toggling reading stats on/off --- CHANGELOG.md | 1 + docs/data-cache.md | 1 + platformio.ini | 3 - src/CrossPointSettings.h | 12 +- src/QuickActions.h | 1 + src/SettingsList.h | 26 ++++- src/activities/ActivityManager.cpp | 22 +++- src/activities/boot_sleep/SleepActivity.cpp | 17 ++- src/activities/home/BookActions.cpp | 49 +++++--- src/activities/home/BookActions.h | 2 + .../home/FileBrowserActionActivity.cpp | 10 +- .../home/FileBrowserActionActivity.h | 3 + src/activities/home/FileBrowserActivity.cpp | 10 ++ src/activities/home/HomeActivity.cpp | 106 ++++++++++++------ src/activities/library/LibraryActivity.cpp | 9 ++ .../network/NetworkModeSelectionActivity.cpp | 28 ++--- src/activities/reader/BookStatsTracking.cpp | 54 +++++++++ src/activities/reader/BookStatsTracking.h | 11 ++ src/activities/reader/EpubReaderActivity.cpp | 103 ++++++++++++----- src/activities/reader/EpubReaderActivity.h | 5 + .../reader/EpubReaderDrawerActivity.cpp | 21 +++- .../reader/EpubReaderDrawerActivity.h | 6 +- src/activities/reader/EpubReaderMenuModel.h | 10 +- src/activities/reader/XtcReaderActivity.cpp | 88 +++++++++++---- src/activities/reader/XtcReaderActivity.h | 5 + .../reader/XtcReaderMenuActivity.cpp | 23 +++- src/activities/reader/XtcReaderMenuActivity.h | 11 +- src/activities/util/BmpViewerActivity.cpp | 1 + .../util/FrontlightPanelActivity.cpp | 13 ++- src/activities/util/FrontlightPanelModel.h | 1 + .../themes/dashboard/DashboardTheme.cpp | 35 +++++- src/main.cpp | 3 +- src/network/CrossPointWebServer.cpp | 14 +++ src/simulator/SimulatorSmokeTest.cpp | 2 +- src/util/BookCacheUtils.cpp | 3 + test/library_builder/BookCacheUtilsTest.cpp | 34 ++++++ test/library_builder/CMakeLists.txt | 1 + .../stubs/CrossPointSettings.h | 10 ++ .../ReaderDrawerModelTest.cpp | 26 ++++- web/pages/settings.js | 20 ++-- 40 files changed, 622 insertions(+), 178 deletions(-) create mode 100644 src/activities/reader/BookStatsTracking.cpp create mode 100644 src/activities/reader/BookStatsTracking.h create mode 100644 test/library_builder/stubs/CrossPointSettings.h diff --git a/CHANGELOG.md b/CHANGELOG.md index 30500e6fbc9..4cb833b9f14 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,7 @@ ### Added +- Turn reading stats tracking on or off for the whole device or individual EPUB and XTC books, while keeping saved history and Time Left estimates. - Assign Library to power, long-press, button-chord, Home-button, or Quick Actions shortcuts to open the book list directly. - Customize the top and bottom reader status bars separately, including item positions and progress bars, in EPUB, TXT, and XTC books. Each bar can be previewed where it appears while reading. - View a selected book's reading stats from its Library or File Browser action menu. diff --git a/docs/data-cache.md b/docs/data-cache.md index 5f2680d75ed..9cc848ea584 100644 --- a/docs/data-cache.md +++ b/docs/data-cache.md @@ -35,6 +35,7 @@ The main data directory is `.crosspoint` on the SD card. It stores render caches │ ├── progress.bin # Reading position (chapter, page, etc.) │ ├── stats.bin # Legacy per-book reading stats │ ├── stats_v5.bin # Version 5 per-book reading stats +│ ├── reading_stats_off # Present when stats tracking is disabled for this book │ ├── reader_settings.bin # Per-book reader settings, render mode, and auto-page-turn interval │ ├── cover.bmp # Book cover image, once generated │ ├── cover_absolute.bmp # Four-tone image-mode cover (generated separately) diff --git a/platformio.ini b/platformio.ini index a36c267607e..5a057891ef2 100644 --- a/platformio.ini +++ b/platformio.ini @@ -184,7 +184,6 @@ build_flags = ; (uses $CROSSINK_RC_HASH env var in CI, or git short hash locally) -DCROSSINK_FIRMWARE_DEVICE_TYPE=\"x3-x4\" -DCROSSINK_BUILD_ENV=\"debug\" - -DCROSSINK_ENABLE_READING_STATS_TOGGLE -DCROSSINK_SHOW_SLEEP_BUILD_INFO ; Appends battery telemetry to /battery_log.csv on wake and sleep, for ; diagnosing a gauge that misreports state of charge. Opt in for debug builds. @@ -378,7 +377,6 @@ build_flags = -DCROSSINK_FIRMWARE_DEVICE_TYPE=\"sticky\" -DENABLE_SERIAL_LOG -DCROSSPOINT_WAIT_FOR_USB_SERIAL - -DCROSSINK_ENABLE_READING_STATS_TOGGLE -DCROSSINK_SHOW_SLEEP_BUILD_INFO -DLOG_LEVEL=2 @@ -448,7 +446,6 @@ build_flags = -DUSB_PRODUCT=\"CrossInk_X4_Pro\" -DUSB_MANUFACTURER=\"CrossInk\" -DFREEINK_FB_PSRAM=1 - -DCROSSINK_ENABLE_READING_STATS_TOGGLE -DCROSSINK_SHOW_SLEEP_BUILD_INFO ; X4 Pro uses native SDMMC. Select SdFat's generic block-device seam so the ; SDK's SDMMC adapter can back the normal FsFile/FsVolume API. diff --git a/src/CrossPointSettings.h b/src/CrossPointSettings.h index 019ca78abe9..082daba8aaf 100644 --- a/src/CrossPointSettings.h +++ b/src/CrossPointSettings.h @@ -665,10 +665,8 @@ class CrossPointSettings : public PersistableStore { char deviceName[21] = ""; // Quick Resume: keep current content visible with moon icon instead of showing a static sleep screen. uint8_t quickResumeSleepScreen = QUICK_RESUME_NEVER; -#ifdef CROSSINK_ENABLE_READING_STATS_TOGGLE - // Debug/test builds can disable stat writes so navigation tests do not affect personal reading stats. + // Master switch for automatic reading statistics; Time Left pace remains independent. uint8_t trackReadingStats = 1; -#endif ~CrossPointSettings() = default; @@ -709,13 +707,7 @@ class CrossPointSettings : public PersistableStore { bottomReaderStatusBar.contains(ReaderStatusBarItem::Clock); } bool shouldShowClockOutsideReader() const { return showClockOutsideReader != 0; } - bool shouldTrackReadingStats() const { -#ifdef CROSSINK_ENABLE_READING_STATS_TOGGLE - return trackReadingStats != 0; -#else - return true; -#endif - } + bool shouldTrackReadingStats() const { return trackReadingStats != 0; } static const char* getDefaultDeviceName(); const char* getEffectiveDeviceName() const; uint16_t getReadingIdleTimeThresholdSeconds() const; diff --git a/src/QuickActions.h b/src/QuickActions.h index 6d1a1699381..5cfd19c1d77 100644 --- a/src/QuickActions.h +++ b/src/QuickActions.h @@ -94,6 +94,7 @@ inline constexpr std::array shortcutAction inline bool supportsTiltPageTurn() { return halTiltSensor.isAvailable(); } inline bool isActionAvailable(const uint8_t action) { + if (action == CrossPointSettings::READING_STATS && !SETTINGS.shouldTrackReadingStats()) return false; if (action == CrossPointSettings::PREVIOUS_PAGE || action == CrossPointSettings::NEARBY_POSITION_SYNC || action == CrossPointSettings::LIBRARY) return true; diff --git a/src/SettingsList.h b/src/SettingsList.h index 45a651def64..5491aa2397a 100644 --- a/src/SettingsList.h +++ b/src/SettingsList.h @@ -531,7 +531,7 @@ inline SettingInfo buildHomeButtonActionSetting(const StrId nameId, uint8_t Cros // can use it directly; mutable device UI lists use getSettingsList(), which // returns an owned copy and can add SD-card font and dictionary options. // Four edge gesture entries are compiled only for touch devices. -inline constexpr size_t BASE_SETTINGS_CAPACITY = 102 + (CROSSINK_APP_CAP_TOUCH ? 4 : 0); +inline constexpr size_t BASE_SETTINGS_CAPACITY = 103 + (CROSSINK_APP_CAP_TOUCH ? 4 : 0); inline const std::vector& getBaseSettingsList() { static const std::vector baseList = [] { @@ -801,10 +801,8 @@ inline const std::vector& getBaseSettingsList() { {CrossPointSettings::MIN_READING_IDLE_TIME_THRESHOLD_UNITS, CrossPointSettings::MAX_READING_IDLE_TIME_THRESHOLD_UNITS, 1}, "readingIdleTimeThresholdUnits", StrId::STR_CAT_SYSTEM)); -#ifdef CROSSINK_ENABLE_READING_STATS_TOGGLE add(SettingInfo::Toggle(StrId::STR_TRACK_READING_STATS, &CrossPointSettings::trackReadingStats, "trackReadingStats", StrId::STR_CAT_SYSTEM)); -#endif // Frontlight quick-panel state: persisted + web-exposed, category-less so // it stays off the Settings screen (edited from the swipe-down panel). @@ -965,6 +963,27 @@ inline const std::vector& getBaseSettingsList() { inline std::vector getSettingsList(const SdCardFontRegistry* registry = nullptr, const DictionaryRegistry* dictRegistry = nullptr) { std::vector v = getBaseSettingsList(); + if (!SETTINGS.shouldTrackReadingStats()) { + for (auto& setting : v) { + if (setting.valuePtr == &CrossPointSettings::sleepScreen) { + removeEnumRawValue(setting, static_cast(CrossPointSettings::READING_STATS_SLEEP)); + removeEnumRawValue(setting, static_cast(CrossPointSettings::MINIMAL_STATS_SLEEP)); + } else if (setting.valuePtr == &CrossPointSettings::shortPwrBtn || + setting.valuePtr == &CrossPointSettings::longPwrBtn || + setting.valuePtr == &CrossPointSettings::homeButtonTapAction || + setting.valuePtr == &CrossPointSettings::homeButtonDoubleTapAction || + setting.valuePtr == &CrossPointSettings::homeButtonLongPressAction) { + removeEnumRawValue(setting, CrossPointSettings::READING_STATS); + } else if (setting.valuePtr == &CrossPointSettings::powerChordAction || + setting.valuePtr == &CrossPointSettings::sideButtonChordAction) { + removeEnumRawValue(setting, + shortcutRawValue(ShortcutOptionCatalog::ButtonChord, CrossPointSettings::READING_STATS)); + } else if (setting.valuePtr == &CrossPointSettings::longPressMenuAction || + setting.valuePtr == &CrossPointSettings::longPressBackAction) { + removeEnumRawValue(setting, static_cast(CrossPointSettings::LONG_MENU_READING_STATS)); + } + } + } const bool hasTouch = gpio.hasTouch(); if (!hasTouch) { v.erase(std::remove_if(v.begin(), v.end(), @@ -1433,6 +1452,7 @@ inline std::vector buildSystemReadingStatsSettingsList(const std::v std::vector settings; settings.reserve(3); addSettingByName(settings, allSettings, StrId::STR_TRACK_READING_STATS); + if (!SETTINGS.shouldTrackReadingStats()) return settings; settings.push_back(SettingInfo::Submenu(StrId::STR_ALL_TIME_STATS, SettingAction::SystemGlobalStats)); addSettingByName(settings, allSettings, StrId::STR_IDLE_TIME_THRESHOLD); return settings; diff --git a/src/activities/ActivityManager.cpp b/src/activities/ActivityManager.cpp index 7d431aa79a5..30b7d8262df 100644 --- a/src/activities/ActivityManager.cpp +++ b/src/activities/ActivityManager.cpp @@ -12,6 +12,7 @@ #include #include #include +#include #include @@ -36,6 +37,7 @@ #include "network/UsbDriveActivity.h" #include "reader/BookReadingStats.h" #include "reader/BookStatsActivity.h" +#include "reader/BookStatsTracking.h" #include "reader/GlobalReadingStats.h" #include "reader/ReaderActivity.h" #include "settings/OpdsServerListActivity.h" @@ -84,7 +86,14 @@ FrontlightPanelContext buildFrontlightPanelContext(Activity& activity, GfxRender const bool lastValid = !APP_STATE.openEpubPath.empty() && FsHelpers::hasEpubExtension(APP_STATE.openEpubPath) && Storage.exists(APP_STATE.openEpubPath.c_str()); context.activeReaderBook = hasFrontlightActiveReaderBook(activity.isReaderActivity(), currentBookValid); + context.showReadingStatsAction = SETTINGS.shouldTrackReadingStats(); if (context.activeReaderBook) { + if (FsHelpers::hasEpubExtension(currentPath)) { + context.showReadingStatsAction = + BookStatsTracking::isEnabled(Epub::cachePathForFilePath(currentPath, "/.crosspoint")); + } else if (FsHelpers::hasXtcExtension(currentPath)) { + context.showReadingStatsAction = BookStatsTracking::isEnabled(Xtc(currentPath, "/.crosspoint").getCachePath()); + } context.bookTitle = activity.getCurrentBookTitle(); context.bookPath = currentPath; context.activeEpub = activity.isEpubReaderActivity() && currentEpubValid; @@ -93,7 +102,7 @@ FrontlightPanelContext buildFrontlightPanelContext(Activity& activity, GfxRender shouldShowStickyReaderDetails(hasStickyReaderDetailsPanel(), Frontlight.present(), context.activeReaderBook); context.bookTitle = context.bookDetails.title; } - context.readingStatsActivity = activity.createFrontlightReadingStatsActivity(); + if (context.showReadingStatsAction) context.readingStatsActivity = activity.createFrontlightReadingStatsActivity(); if (context.activeEpub) { context.bookPath = currentPath; return context; @@ -101,8 +110,9 @@ FrontlightPanelContext buildFrontlightPanelContext(Activity& activity, GfxRender if (context.readingStatsActivity) return context; } - const FrontlightBookSource source = chooseFrontlightBookSource(false, false, lastValid); + const FrontlightBookSource source = chooseFrontlightBookSource(false, false, lastValid && !context.activeReaderBook); + if (!SETTINGS.shouldTrackReadingStats()) return context; const GlobalReadingStats global = GlobalReadingStats::load(); std::string cachePath; std::string statsTitle; @@ -113,7 +123,12 @@ FrontlightPanelContext buildFrontlightPanelContext(Activity& activity, GfxRender context.bookTitle = fileNameFromPath(context.bookPath); statsTitle = context.bookTitle; cachePath = Epub::cachePathForFilePath(context.bookPath, "/.crosspoint"); - bookStats = BookReadingStats::load(cachePath); + if (BookStatsTracking::isBookEnabled(cachePath)) + bookStats = BookReadingStats::load(cachePath); + else { + cachePath.clear(); + statsTitle = tr(STR_READING_STATS); + } const RecentBook book{context.bookPath, context.bookTitle, {}, {}}; progress = RecentBookProgress::loadCachedEpubPercent(book); } else { @@ -901,6 +916,7 @@ bool ActivityManager::resumeFileTransferFromNetworkBoot(const uint32_t payload) } void ActivityManager::goToNearbyStatsSync() { + if (!SETTINGS.shouldTrackReadingStats()) return; replaceActivity(std::make_unique(renderer, mappedInput)); } diff --git a/src/activities/boot_sleep/SleepActivity.cpp b/src/activities/boot_sleep/SleepActivity.cpp index 4b1f9fbbbd0..80dd9fc7cb1 100644 --- a/src/activities/boot_sleep/SleepActivity.cpp +++ b/src/activities/boot_sleep/SleepActivity.cpp @@ -23,6 +23,7 @@ #include #include "../home/RecentBookProgress.h" +#include "../reader/BookStatsTracking.h" #include "../reader/BookStatsView.h" #include "../reader/EpubReaderActivity.h" #include "../reader/EpubReaderUtils.h" @@ -268,7 +269,15 @@ BookReadingStats loadBookStatsForPath(const std::string& path) { if (cachePath.empty()) { return BookReadingStats{}; } - return BookReadingStats::load(cachePath); + BookReadingStats stats = BookReadingStats::load(cachePath); + if (!BookStatsTracking::isEnabled(cachePath)) { + BookReadingStats paceOnly; + paceOnly.avgSecondsPerForwardPage = stats.avgSecondsPerForwardPage; + paceOnly.paceSampleCount = stats.paceSampleCount; + paceOnly.estimatedTimeLeftSeconds = stats.estimatedTimeLeftSeconds; + return paceOnly; + } + return stats; } std::string loadChapterTitleForPath(const std::string& path) { @@ -563,10 +572,12 @@ void SleepActivity::onEnter() { case (CrossPointSettings::SLEEP_SCREEN_MODE::OVERLAY): return renderOverlaySleepScreen(); case (CrossPointSettings::SLEEP_SCREEN_MODE::READING_STATS_SLEEP): + if (!BookStatsTracking::isEnabled(bookStatsCachePathFor(recentBookPath))) return renderMinimalSleepScreen(); return renderReadingStatsSleepScreen(); case (CrossPointSettings::SLEEP_SCREEN_MODE::MINIMAL_SLEEP): return renderMinimalSleepScreen(); case (CrossPointSettings::SLEEP_SCREEN_MODE::MINIMAL_STATS_SLEEP): + if (!BookStatsTracking::isEnabled(bookStatsCachePathFor(recentBookPath))) return renderMinimalSleepScreen(); return renderMinimalStatsSleepScreen(); case (CrossPointSettings::SLEEP_SCREEN_MODE::DASHBOARD_SLEEP): return renderDashboardSleepScreen(); @@ -850,7 +861,9 @@ void SleepActivity::renderMinimalSleepScreen() const { const BookReadingStats bookStats = loadBookStatsForPath(path); const float progressPercent = RecentBookProgress::loadPercent(book); MinimalTheme theme; - theme.drawSleepScreen(renderer, book, &bookStats, progressPercent, sleepCoverFilterInvertsGeneratedScreen()); + theme.drawSleepScreen(renderer, book, + BookStatsTracking::isEnabled(bookStatsCachePathFor(path)) ? &bookStats : nullptr, + progressPercent, sleepCoverFilterInvertsGeneratedScreen()); renderer.displayBuffer(HalDisplay::HALF_REFRESH, TURN_OFF_SCREEN_AFTER_SLEEP_REFRESH); } diff --git a/src/activities/home/BookActions.cpp b/src/activities/home/BookActions.cpp index b8684ba31e6..599e26ac380 100644 --- a/src/activities/home/BookActions.cpp +++ b/src/activities/home/BookActions.cpp @@ -20,6 +20,7 @@ #include "RecentBooksStore.h" #include "activities/reader/BookReadingStats.h" #include "activities/reader/BookStatsActivity.h" +#include "activities/reader/BookStatsTracking.h" #include "activities/reader/EpubReaderActivity.h" #include "activities/reader/GlobalReadingStats.h" #include "components/UITheme.h" @@ -49,7 +50,7 @@ std::string bookStatsCachePath(const std::string& path) { std::vector buildBookActionItems(const std::string& fullPath, const bool includeRemoveFromRecents) { std::vector items; - items.reserve(includeRemoveFromRecents ? 8 : 7); + items.reserve(includeRemoveFromRecents ? 9 : 8); items.push_back({FileBrowserAction::Delete, StrId::STR_DELETE}); if (hasClearableBookCache(fullPath)) { items.push_back({FileBrowserAction::DeleteCache, StrId::STR_DELETE_CACHE}); @@ -59,8 +60,15 @@ std::vector buildBookActionItems(const std: items.push_back({FileBrowserAction::ResetReaderSettings, StrId::STR_RESET_BOOK_READER_SETTINGS}); } if (hasReadingStats(fullPath)) { - items.push_back({FileBrowserAction::ReadingStats, StrId::STR_READING_STATS}); - items.push_back({FileBrowserAction::DeleteStats, StrId::STR_DELETE_BOOK_STATS}); + if (SETTINGS.shouldTrackReadingStats()) { + const bool bookEnabled = isBookStatsTrackingEnabled(fullPath); + items.push_back({FileBrowserAction::ToggleBookStatsTracking, StrId::STR_TRACK_READING_STATS, + bookEnabled ? StrId::STR_STATE_ON : StrId::STR_STATE_OFF}); + if (bookEnabled) { + items.push_back({FileBrowserAction::ReadingStats, StrId::STR_READING_STATS}); + items.push_back({FileBrowserAction::DeleteStats, StrId::STR_DELETE_BOOK_STATS}); + } + } items.push_back({FileBrowserAction::ToggleCompleted, isBookCompleted(fullPath) ? StrId::STR_MARK_UNFINISHED : StrId::STR_MARK_FINISHED}); } @@ -109,7 +117,7 @@ bool deleteBookStats(const std::string& fullPath) { std::unique_ptr createReadingStatsActivity(GfxRenderer& renderer, MappedInputManager& mappedInput, const std::string& fullPath, const std::string& title) { const std::string cachePath = bookStatsCachePath(fullPath); - if (cachePath.empty()) { + if (cachePath.empty() || !BookStatsTracking::isEnabled(cachePath)) { LOG_ERR("BookActions", "No reading stats for: %s", fullPath.c_str()); return {}; } @@ -176,6 +184,19 @@ bool isBookCompleted(const std::string& fullPath) { return !cachePath.empty() && BookReadingStats::load(cachePath).isCompleted; } +bool isBookStatsTrackingEnabled(const std::string& fullPath) { + return BookStatsTracking::isBookEnabled(bookStatsCachePath(fullPath)); +} + +bool toggleBookStatsTracking(const std::string& fullPath, bool& enabled) { + if (!hasReadingStats(fullPath) || !SETTINGS.shouldTrackReadingStats()) return false; + const std::string cachePath = bookStatsCachePath(fullPath); + enabled = !BookStatsTracking::isBookEnabled(cachePath); + if (BookStatsTracking::setBookEnabled(cachePath, enabled)) return true; + enabled = BookStatsTracking::isBookEnabled(cachePath); + return false; +} + bool toggleBookCompleted(const std::string& fullPath, const std::string& displayName, bool& completed) { const bool isEpub = FsHelpers::hasEpubExtension(fullPath); const bool isXtc = FsHelpers::hasXtcExtension(fullPath); @@ -206,25 +227,27 @@ bool toggleBookCompleted(const std::string& fullPath, const std::string& display BookReadingStats stats = BookReadingStats::load(cachePath); completed = !stats.isCompleted; stats.isCompleted = completed; - if (completed && !stats.finishedDateManual) { + const bool trackStats = BookStatsTracking::isEnabled(cachePath); + if (completed && trackStats && !stats.finishedDateManual) { ReadingStatsDateTime now; if (getCurrentLocalReadingStatsDateTime(now)) { stats.finishedDate = now.date; } } - GlobalReadingStats globalStats = GlobalReadingStats::load(); - if (completed) { - globalStats.completedBooks++; - } else if (globalStats.completedBooks > 0) { - globalStats.completedBooks--; - } - if (!stats.save(cachePath)) { LOG_ERR("BookActions", "Could not save completion for: %s", fullPath.c_str()); return false; } - globalStats.save(); + if (trackStats) { + GlobalReadingStats globalStats = GlobalReadingStats::load(); + if (completed) { + globalStats.completedBooks++; + } else if (globalStats.completedBooks > 0) { + globalStats.completedBooks--; + } + globalStats.save(); + } // Changing completion status does not open a book. The reader adds it to // recents if it is opened again after being marked unfinished. diff --git a/src/activities/home/BookActions.h b/src/activities/home/BookActions.h index 5a657e128c6..84dbf00cc36 100644 --- a/src/activities/home/BookActions.h +++ b/src/activities/home/BookActions.h @@ -27,6 +27,8 @@ uint8_t epubRenderModeDisplayIndex(uint8_t renderMode); uint8_t epubRenderModeForDisplayIndex(uint8_t displayIndex); std::string confirmationHeading(StrId actionLabelId); bool isBookCompleted(const std::string& fullPath); +bool isBookStatsTrackingEnabled(const std::string& fullPath); +bool toggleBookStatsTracking(const std::string& fullPath, bool& enabled); bool toggleBookCompleted(const std::string& fullPath, const std::string& displayName, bool& completed); void drawToast(const GfxRenderer& renderer, const char* msg); diff --git a/src/activities/home/FileBrowserActionActivity.cpp b/src/activities/home/FileBrowserActionActivity.cpp index 93902d34079..25c6c7b8fb5 100644 --- a/src/activities/home/FileBrowserActionActivity.cpp +++ b/src/activities/home/FileBrowserActionActivity.cpp @@ -28,8 +28,14 @@ void FileBrowserActionActivity::onEnter() { ignoreTouchRelease = mappedInput.isScreenTouchHeld(touchX, touchY); } optionLabels.resize(items.size()); - std::transform(items.begin(), items.end(), optionLabels.begin(), - [](const MenuItem& item) { return std::string(I18N.get(item.labelId)); }); + std::transform(items.begin(), items.end(), optionLabels.begin(), [](const MenuItem& item) { + std::string label = I18N.get(item.labelId); + if (item.valueId) { + label += ": "; + label += I18N.get(*item.valueId); + } + return label; + }); optionPopup.show(title.c_str(), optionLabels, 0, [this](const int index) { if (index < 0 || index >= static_cast(items.size())) return; selectionMade = true; diff --git a/src/activities/home/FileBrowserActionActivity.h b/src/activities/home/FileBrowserActionActivity.h index 0247dd32d3a..bae21e086a4 100644 --- a/src/activities/home/FileBrowserActionActivity.h +++ b/src/activities/home/FileBrowserActionActivity.h @@ -2,6 +2,7 @@ #include +#include #include #include @@ -29,6 +30,7 @@ enum class FileBrowserAction : int { UnpinBootFavorite = 17, Rename = 18, ReadingStats = 19, + ToggleBookStatsTracking = 20, }; class FileBrowserActionActivity final : public Activity { @@ -36,6 +38,7 @@ class FileBrowserActionActivity final : public Activity { struct MenuItem { FileBrowserAction action; StrId labelId; + std::optional valueId; }; FileBrowserActionActivity(GfxRenderer& renderer, MappedInputManager& mappedInput, std::string title, diff --git a/src/activities/home/FileBrowserActivity.cpp b/src/activities/home/FileBrowserActivity.cpp index dd620878887..194bf217373 100644 --- a/src/activities/home/FileBrowserActivity.cpp +++ b/src/activities/home/FileBrowserActivity.cpp @@ -551,6 +551,7 @@ void FileBrowserActivity::showDirectoryActionMenu(const std::string& entry, bool clearPreferredSleepFolder(); return; case FileBrowserAction::DeleteCache: + case FileBrowserAction::ToggleBookStatsTracking: case FileBrowserAction::ReadingStats: case FileBrowserAction::DeleteStats: case FileBrowserAction::ToggleCompleted: @@ -711,6 +712,15 @@ void FileBrowserActivity::showFileActionMenu(const std::string& entry, bool igno const auto action = static_cast(std::get(result.data).action); switch (action) { + case FileBrowserAction::ToggleBookStatsTracking: { + bool enabled = false; + if (!BookActions::toggleBookStatsTracking(fullPath, enabled)) { + const std::string error = std::string(tr(STR_TRACK_READING_STATS)) + " " + tr(STR_FAILED_LOWER); + BookActions::drawToast(renderer, error.c_str()); + } + requestUpdate(); + return; + } case FileBrowserAction::ReadingStats: if (auto statsActivity = BookActions::createReadingStatsActivity(renderer, mappedInput, fullPath, getFileName(entry))) { diff --git a/src/activities/home/HomeActivity.cpp b/src/activities/home/HomeActivity.cpp index 441c2576d59..36c916c974a 100644 --- a/src/activities/home/HomeActivity.cpp +++ b/src/activities/home/HomeActivity.cpp @@ -24,6 +24,7 @@ #include "../reader/BookReadingStats.h" #include "../reader/BookStatsActivity.h" +#include "../reader/BookStatsTracking.h" #include "../reader/EpubReaderUtils.h" #include "BookmarkStore.h" #include "ClippingStore.h" @@ -169,13 +170,24 @@ std::string getRecentBookCachePath(const RecentBook& book) { return ""; } +BookReadingStats visibleRecentBookStats(const RecentBook& book, BookReadingStats stats) { + const std::string cachePath = getRecentBookCachePath(book); + if (!BookStatsTracking::isEnabled(cachePath)) { + BookReadingStats paceOnly; + paceOnly.avgSecondsPerForwardPage = stats.avgSecondsPerForwardPage; + paceOnly.paceSampleCount = stats.paceSampleCount; + paceOnly.estimatedTimeLeftSeconds = stats.estimatedTimeLeftSeconds; + return paceOnly; + } + return stats; +} + BookReadingStats loadRecentBookStats(const RecentBook& book) { if (!FsHelpers::hasEpubExtension(book.path) && !FsHelpers::hasXtcExtension(book.path)) { return BookReadingStats{}; } - const std::string cachePath = getRecentBookCachePath(book); - return BookReadingStats::load(cachePath); + return visibleRecentBookStats(book, BookReadingStats::load(getRecentBookCachePath(book))); } float loadRecentBookProgress(const RecentBook& book) { @@ -421,6 +433,7 @@ void appendCarouselCoverStateToKey(std::string& key, const RecentBook& book) { appendHashedFileStateToKey(key, cachePath + "/progress.bin"); if (FsHelpers::hasEpubExtension(book.path) || FsHelpers::hasXtcExtension(book.path)) { appendHashedFileStateToKey(key, cachePath + "/stats_v5.bin"); + appendHashedFileStateToKey(key, cachePath + "/reading_stats_off"); } } else { key += "no-cache-path"; @@ -481,6 +494,8 @@ void buildCarouselCacheKey(const std::vector& recentBooks, const boo // versa). key += SETTINGS.screenInverted ? "dark:1" : "dark:0"; key += '\0'; + key += SETTINGS.shouldTrackReadingStats() ? "tracking:1" : "tracking:0"; + key += '\0'; // The carousel cache stores the bottom icon row too, so menu visibility must // be part of the key alongside book covers/progress. appendCarouselMenuStateToKey(key, hasOpdsServers, hasReadingStats, hasBookmarks, hasClippings); @@ -983,14 +998,21 @@ std::unique_ptr HomeActivity::createFrontlightReadingStatsActivity() { } } const std::string cachePath = validEpub ? Epub::cachePathForFilePath(path, "/.crosspoint") : std::string{}; - const BookReadingStats bookStats = validEpub ? BookReadingStats::load(cachePath) : BookReadingStats{}; + const bool showBookStats = validEpub && BookStatsTracking::isEnabled(cachePath); + const BookReadingStats bookStats = showBookStats ? BookReadingStats::load(cachePath) : BookReadingStats{}; + if (!SETTINGS.shouldTrackReadingStats()) return {}; + if (!showBookStats) { + title = tr(STR_READING_STATS); + progress = -1.0f; + } const GlobalReadingStats deviceStats = GlobalReadingStats::load(); if (GlobalReadingStats::hasSyncedStats()) { - return makeUniqueNoThrow(renderer, mappedInput, title, cachePath, bookStats, progress, false, 0, - deviceStats, GlobalReadingStats::loadAggregated(deviceStats)); + return makeUniqueNoThrow(renderer, mappedInput, title, showBookStats ? cachePath : std::string{}, + bookStats, progress, false, 0, deviceStats, + GlobalReadingStats::loadAggregated(deviceStats)); } - return makeUniqueNoThrow(renderer, mappedInput, title, cachePath, bookStats, progress, false, 0, - deviceStats); + return makeUniqueNoThrow(renderer, mappedInput, title, showBookStats ? cachePath : std::string{}, + bookStats, progress, false, 0, deviceStats); } void HomeActivity::onFrontlightPanelClosed() { @@ -1043,7 +1065,7 @@ void HomeActivity::updateHighlightedBookContext(const bool allowChapterTitleRead const bool isEpub = FsHelpers::hasEpubExtension(book.path); const bool loadChapterTitle = isDashboardTheme(); if (useCachedStats) { - currentBookStats = cachedBookStats[idx]; + currentBookStats = visibleRecentBookStats(book, cachedBookStats[idx]); currentBookProgressPercent = cachedBookProgress[idx]; if (allowChapterTitleRead && loadChapterTitle && isEpub) { currentBookChapterTitle = loadEpubHighlightedChapterTitle(book); @@ -1057,8 +1079,9 @@ void HomeActivity::updateHighlightedBookContext(const bool allowChapterTitleRead } } - hasReadingStats = hasAnyBookStats(currentBookStats) || hasAnyGlobalStats(globalStats) || - (showAllDevicesStats && hasAnyGlobalStats(allDevicesGlobalStats)); + hasReadingStats = + SETTINGS.shouldTrackReadingStats() && (hasAnyBookStats(currentBookStats) || hasAnyGlobalStats(globalStats) || + (showAllDevicesStats && hasAnyGlobalStats(allDevicesGlobalStats))); } void HomeActivity::onExit() { @@ -1202,7 +1225,7 @@ void HomeActivity::renderCarouselFrameToCurrentBuffer(int bookIdx, BookReadingSt if (bookIdx >= 0 && bookIdx < bookCount) { if (bookStatsCached && bookIdx < kMaxCachedBooks) { usedCachedStats = true; - frameStats = cachedBookStats[bookIdx]; + frameStats = visibleRecentBookStats(recentBooks[bookIdx], cachedBookStats[bookIdx]); frameProgressPercent = cachedBookProgress[bookIdx]; } else { frameStats = loadRecentBookStats(recentBooks[bookIdx]); @@ -1218,8 +1241,9 @@ void HomeActivity::renderCarouselFrameToCurrentBuffer(int bookIdx, BookReadingSt renderer, Rect{0, metrics.homeTopPadding, pageWidth, metrics.homeCoverTileHeight}, recentBooks, bookCount, dummy1, dummy2, dummy3, []() { return true; }, frameStatsPtr, frameProgressPercent); - const bool frameHasReadingStats = hasAnyBookStats(frameStats) || hasAnyGlobalStats(globalStats) || - (showAllDevicesStats && hasAnyGlobalStats(allDevicesGlobalStats)); + const bool frameHasReadingStats = + SETTINGS.shouldTrackReadingStats() && (hasAnyBookStats(frameStats) || hasAnyGlobalStats(globalStats) || + (showAllDevicesStats && hasAnyGlobalStats(allDevicesGlobalStats))); const auto menuItems = buildHomeMenuItems(hasOpdsServers, frameHasReadingStats, hasBookmarks, hasClippings); GUI.drawButtonMenu( renderer, @@ -2136,8 +2160,8 @@ void HomeActivity::render(RenderLock&&) { GUI.drawRecentBookCover(renderer, Rect{0, metrics.homeTopPadding, pageWidth, metrics.homeCoverTileHeight}, recentBooks, selectorIndex, coverRendered, coverBufferStored, bufferRestored, std::bind(&HomeActivity::storeCoverBuffer, this), - hasAnyBookStats(currentBookStats) ? ¤tBookStats : nullptr, currentBookProgressPercent, - &globalStats, currentBookChapterTitle.c_str()); + (isDashboardTheme() || hasAnyBookStats(currentBookStats)) ? ¤tBookStats : nullptr, + currentBookProgressPercent, &globalStats, currentBookChapterTitle.c_str()); const int homeNavCount = minimalHomeNavCount(!recentBooks.empty()); if (minimalHomeNavIndex >= homeNavCount) { @@ -2356,16 +2380,40 @@ void HomeActivity::onFileTransferOpen() { activityManager.goToFileTransfer(); } void HomeActivity::onOpdsBrowserOpen() { activityManager.goToBrowser(); } void HomeActivity::onReadingStatsOpen() { + if (!SETTINGS.shouldTrackReadingStats()) return; const int highlightedBookIdx = getHighlightedBookIndex(); - const std::string bookTitle = + std::string bookTitle = highlightedBookIdx >= 0 ? recentBooks[highlightedBookIdx].title : std::string(tr(STR_READING_STATS)); - const std::string bookPath = getCurrentBookPath(); - const std::string cachePath = - FsHelpers::hasEpubExtension(bookPath) ? Epub::cachePathForFilePath(bookPath, "/.crosspoint") : std::string{}; + const bool hasBookStats = + highlightedBookIdx >= 0 && (FsHelpers::hasEpubExtension(recentBooks[highlightedBookIdx].path) || + FsHelpers::hasXtcExtension(recentBooks[highlightedBookIdx].path)); + std::string cachePath = hasBookStats ? getRecentBookCachePath(recentBooks[highlightedBookIdx]) : std::string{}; + const bool showBookStats = !cachePath.empty() && BookStatsTracking::isBookEnabled(cachePath); + if (!showBookStats) { + cachePath.clear(); + bookTitle = tr(STR_READING_STATS); + } + const BookReadingStats displayBookStats = showBookStats ? currentBookStats : BookReadingStats{}; + const float progress = showBookStats ? currentBookProgressPercent : -1.0f; if (showAllDevicesStats) { + startActivityForResult( + std::make_unique(renderer, mappedInput, bookTitle, cachePath, displayBookStats, progress, + false, 0, globalStats, allDevicesGlobalStats, true), + [this](const ActivityResult& result) { + mappedInput.suppressNextConfirmRelease(); + const auto* statsResult = std::get_if(&result.data); + if (statsResult && statsResult->changed) { + globalStats = GlobalReadingStats::load(); + showAllDevicesStats = GlobalReadingStats::hasSyncedStats(); + allDevicesGlobalStats = showAllDevicesStats ? GlobalReadingStats::loadAggregated(globalStats) : globalStats; + bookStatsCached = false; + updateHighlightedBookContext(); + } + requestUpdate(); + }); + } else { startActivityForResult(std::make_unique(renderer, mappedInput, bookTitle, cachePath, - currentBookStats, currentBookProgressPercent, false, 0, - globalStats, allDevicesGlobalStats, true), + displayBookStats, progress, false, 0, globalStats, true), [this](const ActivityResult& result) { mappedInput.suppressNextConfirmRelease(); const auto* statsResult = std::get_if(&result.data); @@ -2379,22 +2427,6 @@ void HomeActivity::onReadingStatsOpen() { } requestUpdate(); }); - } else { - startActivityForResult( - std::make_unique(renderer, mappedInput, bookTitle, cachePath, currentBookStats, - currentBookProgressPercent, false, 0, globalStats, true), - [this](const ActivityResult& result) { - mappedInput.suppressNextConfirmRelease(); - const auto* statsResult = std::get_if(&result.data); - if (statsResult && statsResult->changed) { - globalStats = GlobalReadingStats::load(); - showAllDevicesStats = GlobalReadingStats::hasSyncedStats(); - allDevicesGlobalStats = showAllDevicesStats ? GlobalReadingStats::loadAggregated(globalStats) : globalStats; - bookStatsCached = false; - updateHighlightedBookContext(); - } - requestUpdate(); - }); } } diff --git a/src/activities/library/LibraryActivity.cpp b/src/activities/library/LibraryActivity.cpp index fce4c63db38..42edf4a1206 100644 --- a/src/activities/library/LibraryActivity.cpp +++ b/src/activities/library/LibraryActivity.cpp @@ -990,6 +990,15 @@ void LibraryActivity::showBookActionMenu(const size_t bookIndex, const bool igno } switch (static_cast(actionResult->action)) { + case FileBrowserAction::ToggleBookStatsTracking: { + bool enabled = false; + if (!BookActions::toggleBookStatsTracking(book.path, enabled)) { + const std::string error = std::string(tr(STR_TRACK_READING_STATS)) + " " + tr(STR_FAILED_LOWER); + BookActions::drawToast(renderer, error.c_str()); + } + reloadAfterBookAction(); + return; + } case FileBrowserAction::ReadingStats: openDialog(BookActions::createReadingStatsActivity(renderer, mappedInput, book.path, book.title), [this](const ActivityResult&) { requestUpdate(); }); diff --git a/src/activities/network/NetworkModeSelectionActivity.cpp b/src/activities/network/NetworkModeSelectionActivity.cpp index ae2cb1d7610..c11a5c1208b 100644 --- a/src/activities/network/NetworkModeSelectionActivity.cpp +++ b/src/activities/network/NetworkModeSelectionActivity.cpp @@ -3,6 +3,7 @@ #include #include +#include "CrossPointSettings.h" #include "MappedInputManager.h" #include "components/TouchHeaderBackButton.h" #include "components/UITheme.h" @@ -41,8 +42,9 @@ constexpr StrId menuDescs[] = {StrId::STR_JOIN_DESC, StrId::STR_CALIBRE_DESC, St constexpr UIIcon menuIcons[] = {UIIcon::Wifi, UIIcon::Library, UIIcon::Hotspot, UIIcon::Transfer, UIIcon::Transfer}; #endif -constexpr int MENU_ITEM_COUNT = sizeof(menuModes) / sizeof(menuModes[0]); -constexpr int LIST_ITEM_COUNT = MENU_ITEM_COUNT + 1; +constexpr int ALL_MENU_ITEM_COUNT = sizeof(menuModes) / sizeof(menuModes[0]); +int menuItemCount() { return ALL_MENU_ITEM_COUNT - (SETTINGS.shouldTrackReadingStats() ? 0 : 1); } +int listItemCount() { return menuItemCount() + 1; } constexpr int NEARBY_SECTION_INDEX = CROSSINK_APP_CAP_USB_DRIVE ? 4 : 3; int listIndexForMenuIndex(const int menuIndex) { return menuIndex < NEARBY_SECTION_INDEX ? menuIndex : menuIndex + 1; } @@ -52,12 +54,12 @@ NetworkModeSelectionActivity::NetworkModeSelectionActivity(GfxRenderer& renderer : Activity("NetworkModeSelection", renderer, mappedInput), ui(renderer) {} void NetworkModeSelectionActivity::selectCurrent() { - if (selectedIndex >= 0 && selectedIndex < MENU_ITEM_COUNT) onModeSelected(menuModes[selectedIndex]); + if (selectedIndex >= 0 && selectedIndex < menuItemCount()) onModeSelected(menuModes[selectedIndex]); } void NetworkModeSelectionActivity::onRowEvent(const fui::ActionEvent& event, void* user) { auto* self = static_cast(user); - if (event.value < 0 || event.value >= MENU_ITEM_COUNT) return; + if (event.value < 0 || event.value >= menuItemCount()) return; self->selectedIndex = event.value; // Selection leaves this screen; a lingering flash would gray an unrelated // element on the next render. @@ -123,8 +125,8 @@ void NetworkModeSelectionActivity::loop() { // Page by the rows the last layout measured, not the fixed-height // estimate: wrapped subtitles fit fewer rows, and paging by the larger // estimate would skip the ones in between. - const int page = listNav.pageRowsFor(LIST_ITEM_COUNT); - moved = listNav.scrollBy(swipe == MappedInputManager::SwipeDir::Up ? page : -page, LIST_ITEM_COUNT); + const int page = listNav.pageRowsFor(listItemCount()); + moved = listNav.scrollBy(swipe == MappedInputManager::SwipeDir::Up ? page : -page, listItemCount()); topIndex = listNav.top; } if (moved) { @@ -137,12 +139,12 @@ void NetworkModeSelectionActivity::loop() { buttonNavigator.onNext([this] { { RenderLock lock(*this); - selectedIndex = ButtonNavigator::nextIndex(selectedIndex, MENU_ITEM_COUNT); + selectedIndex = ButtonNavigator::nextIndex(selectedIndex, menuItemCount()); buttonSelectionActive = true; listNav.selected = listIndexForMenuIndex(selectedIndex); listNav.top = topIndex; listNav.visibleRows = visibleRows; - listNav.follow(LIST_ITEM_COUNT); + listNav.follow(listItemCount()); topIndex = listNav.top; } requestUpdate(); @@ -151,12 +153,12 @@ void NetworkModeSelectionActivity::loop() { buttonNavigator.onPrevious([this] { { RenderLock lock(*this); - selectedIndex = ButtonNavigator::previousIndex(selectedIndex, MENU_ITEM_COUNT); + selectedIndex = ButtonNavigator::previousIndex(selectedIndex, menuItemCount()); buttonSelectionActive = true; listNav.selected = listIndexForMenuIndex(selectedIndex); listNav.top = topIndex; listNav.visibleRows = visibleRows; - listNav.follow(LIST_ITEM_COUNT); + listNav.follow(listItemCount()); topIndex = listNav.top; } requestUpdate(); @@ -176,8 +178,8 @@ void NetworkModeSelectionActivity::buildListScreen(UiApp::ScreenType& screen) { screen.spacer(static_cast(metrics.verticalSpacing)); std::vector items; - items.reserve(LIST_ITEM_COUNT); - for (int i = 0; i < MENU_ITEM_COUNT; i++) { + items.reserve(listItemCount()); + for (int i = 0; i < menuItemCount(); i++) { if (i == NEARBY_SECTION_INDEX) { fui::ListItem header; header.label = I18N.get(StrId::STR_NEARBY_DEVICE); @@ -222,7 +224,7 @@ void NetworkModeSelectionActivity::buildListScreen(UiApp::ScreenType& screen) { listNav.selected = buttonSelectionActive ? listIndexForMenuIndex(selectedIndex) : -1; listNav.top = topIndex; listNav.visibleRows = visibleRows; - listNav.syncToProps(screen.body(), props.rowHeight, props.rowGap, LIST_ITEM_COUNT, props); + listNav.syncToProps(screen.body(), props.rowHeight, props.rowGap, listItemCount(), props); topIndex = listNav.top; screen.list(props); topIndex = listNav.top; diff --git a/src/activities/reader/BookStatsTracking.cpp b/src/activities/reader/BookStatsTracking.cpp new file mode 100644 index 00000000000..e42104afac2 --- /dev/null +++ b/src/activities/reader/BookStatsTracking.cpp @@ -0,0 +1,54 @@ +#include "BookStatsTracking.h" + +#include +#include + +#include "CrossPointSettings.h" + +namespace { +constexpr char MARKER_NAME[] = "/reading_stats_off"; + +std::string markerPath(const std::string& cachePath) { return cachePath + MARKER_NAME; } +} // namespace + +namespace BookStatsTracking { +bool isBookEnabled(const std::string& cachePath) { + return cachePath.empty() || !Storage.exists(markerPath(cachePath).c_str()); +} + +bool isEnabled(const std::string& cachePath) { return SETTINGS.shouldTrackReadingStats() && isBookEnabled(cachePath); } + +bool setBookEnabled(const std::string& cachePath, const bool enabled) { + if (cachePath.empty()) { + LOG_ERR("BSTAT", "Missing cache path for book tracking setting"); + return false; + } + const std::string path = markerPath(cachePath); + if (enabled) { + if (!Storage.exists(path.c_str())) return true; + if (Storage.remove(path.c_str())) return true; + LOG_ERR("BSTAT", "Could not enable book stats: %s", path.c_str()); + return false; + } + if (Storage.exists(path.c_str())) return true; + if (!Storage.exists(cachePath.c_str()) && !Storage.mkdir(cachePath.c_str())) { + LOG_ERR("BSTAT", "Could not create book cache: %s", cachePath.c_str()); + return false; + } + const std::string tempPath = path + ".tmp"; + FsFile file; + if (!Storage.openFileForWrite("BSTAT", tempPath, file)) { + LOG_ERR("BSTAT", "Could not disable book stats: %s", path.c_str()); + return false; + } + const uint8_t marker = 1; + const bool written = file.write(&marker, sizeof(marker)) == sizeof(marker) && file.sync(); + const bool closed = file.close(); + if (!written || !closed || !Storage.rename(tempPath.c_str(), path.c_str())) { + Storage.remove(tempPath.c_str()); + LOG_ERR("BSTAT", "Could not finish book tracking setting: %s", path.c_str()); + return false; + } + return true; +} +} // namespace BookStatsTracking diff --git a/src/activities/reader/BookStatsTracking.h b/src/activities/reader/BookStatsTracking.h new file mode 100644 index 00000000000..170a4049791 --- /dev/null +++ b/src/activities/reader/BookStatsTracking.h @@ -0,0 +1,11 @@ +#pragma once + +#include + +// A book is enabled by default. The marker is separate from stats_v5.bin so +// clearing statistics or EPUB reader settings does not reset the user's choice. +namespace BookStatsTracking { +bool isBookEnabled(const std::string& cachePath); +bool isEnabled(const std::string& cachePath); +bool setBookEnabled(const std::string& cachePath, bool enabled); +} // namespace BookStatsTracking diff --git a/src/activities/reader/EpubReaderActivity.cpp b/src/activities/reader/EpubReaderActivity.cpp index 7cae7fb0d7b..a6107207791 100644 --- a/src/activities/reader/EpubReaderActivity.cpp +++ b/src/activities/reader/EpubReaderActivity.cpp @@ -28,6 +28,7 @@ #include "../settings/DictionarySelectActivity.h" #include "../settings/KOReaderSettingsActivity.h" #include "BookStatsActivity.h" +#include "BookStatsTracking.h" #include "ClipSelectionActivity.h" #include "ClippingStore.h" #include "CrossPointSettings.h" @@ -1518,6 +1519,23 @@ void EpubReaderActivity::pauseReadingPaceTimer(const char* reason) { paceSampleWarmupPending = true; } +void EpubReaderActivity::syncStatsTrackingState() { + const bool enabled = SETTINGS.shouldTrackReadingStats() && bookStatsEnabled; + if (enabled == statsTrackingActive) return; + if (statsTrackingActive && !enabled && epub) { + pendingStatsCommit = true; + if (stats.save(epub->getCachePath())) { + globalStats.save(); + pendingStatsCommit = false; + } + } + statsTrackingActive = enabled; + sessionReadingSeconds = 0; + hasSessionStartLocalDateTime = getCurrentLocalReadingStatsDateTime(sessionStartLocalDateTime); + pageShownAtMs = section && !activeFootnotePreview ? millis() : 0UL; + armReadingPaceWarmup("stats_toggle"); +} + void EpubReaderActivity::resumeReadingPaceTimer(const char*) { if (activeFootnotePreview) { pageShownAtMs = 0UL; @@ -1585,7 +1603,7 @@ void EpubReaderActivity::armReadingPaceWarmup(const char*) { paceSampleWarmupPen bool EpubReaderActivity::forwardPageReadElapsed(uint32_t& seconds, const char*) const { seconds = 0; - if (activeFootnotePreview || !SETTINGS.shouldTrackReadingStats() || pageShownAtMs == 0UL) { + if (activeFootnotePreview || pageShownAtMs == 0UL) { return false; } @@ -1600,7 +1618,7 @@ bool EpubReaderActivity::forwardPageReadElapsed(uint32_t& seconds, const char*) bool EpubReaderActivity::currentPageReadingSecondsForStats(uint32_t& seconds, const char* source) const { seconds = 0; - if (activeFootnotePreview || !SETTINGS.shouldTrackReadingStats() || pageShownAtMs == 0UL) { + if (activeFootnotePreview || !statsTrackingActive || pageShownAtMs == 0UL) { return false; } @@ -1652,6 +1670,7 @@ void EpubReaderActivity::recordForwardPagePaceSample(uint32_t seconds, const cha } stats.recordForwardPageRead(seconds); + paceDirty = true; recoverStoredPaceFromSession("pace_sample"); } @@ -1779,8 +1798,7 @@ bool EpubReaderActivity::estimateProgressTimeLeftSeconds(uint32_t& seconds) cons const float progressPercent = epub->calculateSizeProgress(currentSpineIndex, chapterProgress) * 100.0f; uint32_t currentPageSeconds = 0; uint32_t sessionSeconds = sessionReadingSeconds; - if (SETTINGS.shouldTrackReadingStats() && - currentPageReadingSecondsForStats(currentPageSeconds, "time_left_preview")) { + if (statsTrackingActive && currentPageReadingSecondsForStats(currentPageSeconds, "time_left_preview")) { sessionSeconds = sessionSeconds > UINT32_MAX - currentPageSeconds ? UINT32_MAX : sessionSeconds + currentPageSeconds; } @@ -1821,7 +1839,7 @@ bool EpubReaderActivity::estimateTimeLeftSeconds(const bool bookEstimate, uint32 uint32_t progressEstimateSeconds = 0; bool hasProgressEstimate = false; - if (bookEstimate && hasPace) { + if (bookEstimate && hasPace && statsTrackingActive) { hasProgressEstimate = estimateProgressTimeLeftSeconds(progressEstimateSeconds); } if (!hasPaceEstimate && !hasProgressEstimate) { @@ -2383,6 +2401,10 @@ void EpubReaderActivity::onEnter() { // Load reading stats and record session start time. // Session count and reading time are committed on exit once thresholds are met. stats = BookReadingStats::load(epub->getCachePath()); + bookStatsEnabled = BookStatsTracking::isBookEnabled(epub->getCachePath()); + statsTrackingActive = SETTINGS.shouldTrackReadingStats() && bookStatsEnabled; + paceDirty = false; + pendingStatsCommit = false; #if defined(ENABLE_SERIAL_LOG) && LOG_LEVEL >= 2 const uint32_t cumulativeAvgSeconds = stats.totalPagesTurned > 0 ? stats.totalReadingSeconds / stats.totalPagesTurned : 0; @@ -2446,7 +2468,8 @@ void EpubReaderActivity::onExit() { APP_STATE.readerActivityLoadCount = 0; APP_STATE.saveToFile(); - if (SETTINGS.shouldTrackReadingStats()) { + syncStatsTrackingState(); + if (statsTrackingActive) { recordCurrentPageReadingTime("reader_exit"); // Commit session stats based on active reading time. Page intervals longer @@ -2469,12 +2492,14 @@ void EpubReaderActivity::onExit() { stats.startDate = sessionStartLocalDateTime.date; } } - if (epub) { - recoverStoredPaceFromSession("reader_exit"); - refreshCachedTimeLeftEstimate(); - stats.save(epub->getCachePath()); + } + if (epub) { + recoverStoredPaceFromSession("reader_exit"); + const uint32_t previousEstimate = stats.estimatedTimeLeftSeconds; + refreshCachedTimeLeftEstimate(); + if (statsTrackingActive || paceDirty || pendingStatsCommit || stats.estimatedTimeLeftSeconds != previousEstimate) { + if (stats.save(epub->getCachePath()) && (statsTrackingActive || pendingStatsCommit)) globalStats.save(); } - globalStats.save(); } // Leaving mid-footnote loses the in-RAM return stack on deep sleep; persist the @@ -2572,8 +2597,9 @@ void EpubReaderActivity::openReaderMenu() { SETTINGS.topReaderStatusBar.contains(ReaderStatusBarItem::TimeLeftChapter) || SETTINGS.bottomReaderStatusBar.contains(ReaderStatusBarItem::TimeLeftBook) || SETTINGS.bottomReaderStatusBar.contains(ReaderStatusBarItem::TimeLeftChapter)), - stableCurrentPage, stablePageCount, getAutoPageTurnIntervalSeconds(), automaticPageTurnActive, - saveReaderOptionsForBook, this, saveGlobalSettingsForBookReader, this, beginGlobalSettingsEditForBookReader, this, + SETTINGS.shouldTrackReadingStats(), bookStatsEnabled, stableCurrentPage, stablePageCount, + getAutoPageTurnIntervalSeconds(), automaticPageTurnActive, saveReaderOptionsForBook, this, + saveGlobalSettingsForBookReader, this, beginGlobalSettingsEditForBookReader, this, endGlobalSettingsEditForBookReader, this, bookSettings.dictionarySdFontFamilyName, bookSettings.dictionaryFontPointSize, bookSettings.hasDictionaryFontOverride, saveDictionaryFontForBookReader, this, touchReaderDrawerState, std::move(buttonPreviewModel)); @@ -2673,7 +2699,8 @@ void EpubReaderActivity::openReaderMenu() { } if (menu->reopenDrawer && (action == EpubReaderMenuAction::BOOKMARK_TOGGLE || action == EpubReaderMenuAction::TOGGLE_COMPLETED || - action == EpubReaderMenuAction::RESET_READING_PACE)) { + action == EpubReaderMenuAction::RESET_READING_PACE || + action == EpubReaderMenuAction::TOGGLE_BOOK_STATS_TRACKING)) { openReaderMenu(); } } @@ -2769,6 +2796,7 @@ bool EpubReaderActivity::transientFeedbackDismissed(const unsigned long showTime } void EpubReaderActivity::loop() { + syncStatsTrackingState(); bool rawTouchInput = false; #if CROSSINK_APP_CAP_TOUCH int touchDownX = 0; @@ -3947,6 +3975,7 @@ void EpubReaderActivity::onReaderMenuConfirm(EpubReaderMenuAction action, const return; } case EpubReaderMenuAction::DELETE_STATS: { + if (!statsTrackingActive) break; pauseReadingPaceTimer("delete_stats_confirm"); startActivityForResult(std::make_unique(renderer, mappedInput, confirmationHeading(StrId::STR_DELETE_BOOK_STATS), @@ -4098,9 +4127,10 @@ void EpubReaderActivity::onReaderMenuConfirm(EpubReaderMenuAction action, const break; } case EpubReaderMenuAction::READING_STATS: { + if (!statsTrackingActive) break; // Include elapsed time from the current session in the display stats. BookReadingStats displayStats = stats; - if (SETTINGS.shouldTrackReadingStats()) { + if (statsTrackingActive) { uint32_t currentPageSeconds = 0; displayStats.totalReadingSeconds = displayStats.totalReadingSeconds > UINT32_MAX - sessionReadingSeconds ? UINT32_MAX @@ -4133,6 +4163,18 @@ void EpubReaderActivity::onReaderMenuConfirm(EpubReaderMenuAction action, const } break; } + case EpubReaderMenuAction::TOGGLE_BOOK_STATS_TRACKING: { + if (!epub || !SETTINGS.shouldTrackReadingStats()) break; + const bool enabled = !bookStatsEnabled; + if (!BookStatsTracking::setBookEnabled(epub->getCachePath(), enabled)) { + const std::string error = std::string(tr(STR_TRACK_READING_STATS)) + " " + tr(STR_FAILED_LOWER); + drawToast(renderer, error.c_str()); + } + bookStatsEnabled = BookStatsTracking::isBookEnabled(epub->getCachePath()); + syncStatsTrackingState(); + requestUpdate(); + break; + } case EpubReaderMenuAction::TOGGLE_COMPLETED: { const bool markCompleted = !stats.isCompleted; setBookCompleted(markCompleted); @@ -4395,10 +4437,11 @@ void EpubReaderActivity::onReaderMenuConfirm(EpubReaderMenuAction action, const } std::unique_ptr EpubReaderActivity::createFrontlightReadingStatsActivity() { + if (!statsTrackingActive) return {}; if (!epub) return {}; BookReadingStats displayStats = stats; - if (SETTINGS.shouldTrackReadingStats()) { + if (statsTrackingActive) { displayStats.totalReadingSeconds = displayStats.totalReadingSeconds > UINT32_MAX - sessionReadingSeconds ? UINT32_MAX : displayStats.totalReadingSeconds + sessionReadingSeconds; @@ -5257,6 +5300,9 @@ void EpubReaderActivity::openQuickActionsPopup() { return; } dispatchShortcutAction(action); + }, + [this](const auto action) { + return action != CrossPointSettings::SHORT_PWRBTN::READING_STATS || statsTrackingActive; }); if (quickActionsPopup.isActive()) { mappedInput.setReaderTouchscreenOverride(true); @@ -5503,7 +5549,7 @@ void EpubReaderActivity::setBookCompleted(bool isCompleted) { } stats.isCompleted = isCompleted; - if (isCompleted && !stats.finishedDateManual) { + if (isCompleted && statsTrackingActive && !stats.finishedDateManual) { ReadingStatsDateTime now; if (getCurrentLocalReadingStatsDateTime(now)) { stats.finishedDate = now.date; @@ -5524,15 +5570,16 @@ void EpubReaderActivity::setBookCompleted(bool isCompleted) { recentsEntryRemoved = false; pendingReadFolderMove = false; } - if (isCompleted) { - globalStats.completedBooks++; - } else if (globalStats.completedBooks > 0) { - globalStats.completedBooks--; - } - refreshCachedTimeLeftEstimate(); - stats.save(epub->getCachePath()); - globalStats.save(); + if (!stats.save(epub->getCachePath())) return; + if (statsTrackingActive) { + if (isCompleted) { + globalStats.completedBooks++; + } else if (globalStats.completedBooks > 0) { + globalStats.completedBooks--; + } + globalStats.save(); + } } void EpubReaderActivity::showCompletedFeedback(bool isCompleted) { @@ -5836,8 +5883,10 @@ void EpubReaderActivity::pageTurn(bool isForwardTurn, const char* source) { if (!exitingChapter) { recordForwardPagePaceSample(forwardReadSeconds, source); } - stats.totalPagesTurned++; - globalStats.totalPagesTurned++; + if (statsTrackingActive) { + stats.totalPagesTurned++; + globalStats.totalPagesTurned++; + } } } else { recordCurrentPageReadingTime(source); diff --git a/src/activities/reader/EpubReaderActivity.h b/src/activities/reader/EpubReaderActivity.h index a534cf475a0..12e503b79b7 100644 --- a/src/activities/reader/EpubReaderActivity.h +++ b/src/activities/reader/EpubReaderActivity.h @@ -174,8 +174,13 @@ class EpubReaderActivity final : public Activity { ReaderSettingsSnapshot suspendedBookReaderSettings; BookReadingStats stats; GlobalReadingStats globalStats; + bool bookStatsEnabled = true; + bool statsTrackingActive = true; + bool paceDirty = false; + bool pendingStatsCommit = false; ReadingStatsDateTime sessionStartLocalDateTime; bool hasSessionStartLocalDateTime = false; + void syncStatsTrackingState(); // Signals that the next render should reposition within the newly loaded section // based on a cross-book percentage jump. bool pendingPercentJump = false; diff --git a/src/activities/reader/EpubReaderDrawerActivity.cpp b/src/activities/reader/EpubReaderDrawerActivity.cpp index 5f5a08b09cd..71ce6f2ee39 100644 --- a/src/activities/reader/EpubReaderDrawerActivity.cpp +++ b/src/activities/reader/EpubReaderDrawerActivity.cpp @@ -378,8 +378,9 @@ EpubReaderDrawerActivity::EpubReaderDrawerActivity( const EpubReaderPreviewModel* previewModel, const float bookProgressPercent, const uint32_t chapterPage, const uint32_t chapterPageCount, const bool chapterPageCountEstimated, const bool hasFootnotes, const bool hasDictionary, const bool hasBookmarks, const bool hasClippings, const bool isCurrentPageBookmarked, - const bool isBookCompleted, const bool showReadingPaceReset, const uint32_t stableCurrentPage, - const uint32_t stablePageCount, const uint16_t autoPageTurnIntervalSeconds, const bool automaticPageTurnActive, + const bool isBookCompleted, const bool showReadingPaceReset, const bool globalStatsEnabled, + const bool bookStatsEnabled, const uint32_t stableCurrentPage, const uint32_t stablePageCount, + const uint16_t autoPageTurnIntervalSeconds, const bool automaticPageTurnActive, ReaderOptionsActivity::SaveSettingsCallback saveReaderSettingsCallback, void* saveReaderSettingsContext, ReaderOptionsActivity::SaveGlobalSettingsCallback saveGlobalSettingsCallback, void* saveGlobalSettingsContext, ReaderOptionsActivity::GlobalSettingsEditCallback beginGlobalSettingsEditCallback, @@ -408,6 +409,8 @@ EpubReaderDrawerActivity::EpubReaderDrawerActivity( hasDictionary(hasDictionary), hasBookmarks(hasBookmarks), hasClippings(hasClippings), + globalStatsEnabled(globalStatsEnabled), + bookStatsEnabled(bookStatsEnabled), isCurrentPageBookmarked(isCurrentPageBookmarked), isBookCompleted(isBookCompleted), showReadingPaceReset(showReadingPaceReset), @@ -445,9 +448,9 @@ void EpubReaderDrawerActivity::onEnter() { Activity::onEnter(); if (mappedInput.hasTouchHardware()) mappedInput.setReaderTouchscreenOverride(true); - const ReaderDrawerCatalog catalog = - makeReaderDrawerCatalog({hasFootnotes, hasDictionary, hasBookmarks, hasClippings, showReadingPaceReset, - stablePageCount > 0, !mappedInput.hasTouchHardware()}); + const ReaderDrawerCatalog catalog = makeReaderDrawerCatalog( + {hasFootnotes, hasDictionary, hasBookmarks, hasClippings, showReadingPaceReset, stablePageCount > 0, + !mappedInput.hasTouchHardware(), globalStatsEnabled, bookStatsEnabled}); for (size_t tab = 0; tab < rootRows.size(); ++tab) { rootRows[tab].reserve(catalog[tab].count); rootRows[tab].assign(catalog[tab].items.begin(), catalog[tab].items.begin() + catalog[tab].count); @@ -1753,6 +1756,9 @@ void EpubReaderDrawerActivity::activateRow(const RowId row) { isBookCompleted = !isBookCompleted; closeAndReturn(false, EpubReaderMenuAction::TOGGLE_COMPLETED); return; + case RowId::TrackBookStats: + closeAndReturn(false, EpubReaderMenuAction::TOGGLE_BOOK_STATS_TRACKING, true); + return; case RowId::DeleteBookmarks: closeAndReturn(false, EpubReaderMenuAction::DELETE_BOOKMARKS); return; @@ -2970,6 +2976,8 @@ const char* EpubReaderDrawerActivity::rowLabel(const RowId row) const { return tr(STR_INDEXING_METHOD); case RowId::ToggleCompleted: return isBookCompleted ? tr(STR_MARK_UNFINISHED) : tr(STR_MARK_FINISHED); + case RowId::TrackBookStats: + return tr(STR_TRACK_READING_STATS); case RowId::Controls: return tr(STR_CAT_CONTROLS); case RowId::ResetReadingPace: @@ -3090,6 +3098,7 @@ const char* EpubReaderDrawerActivity::rowValue(const RowId row, char* buffer, co bool EpubReaderDrawerActivity::rowIsToggle(const RowId row) const { switch (row) { + case RowId::TrackBookStats: case RowId::TextAa: case RowId::Focus: case RowId::GuideDots: @@ -3134,6 +3143,8 @@ bool EpubReaderDrawerActivity::rowShowsNavigationCaret(const RowId row) const { bool EpubReaderDrawerActivity::rowToggleValue(const RowId row) const { switch (row) { + case RowId::TrackBookStats: + return bookStatsEnabled; case RowId::TextAa: return draft.textAntiAliasing; case RowId::Focus: diff --git a/src/activities/reader/EpubReaderDrawerActivity.h b/src/activities/reader/EpubReaderDrawerActivity.h index 7e5977efe88..e2feda78c68 100644 --- a/src/activities/reader/EpubReaderDrawerActivity.h +++ b/src/activities/reader/EpubReaderDrawerActivity.h @@ -30,8 +30,8 @@ class EpubReaderDrawerActivity final : public Activity { const EpubReaderPreviewModel* previewModel, float bookProgressPercent, uint32_t chapterPage, uint32_t chapterPageCount, bool chapterPageCountEstimated, bool hasFootnotes, bool hasDictionary, bool hasBookmarks, bool hasClippings, bool isCurrentPageBookmarked, bool isBookCompleted, - bool showReadingPaceReset, uint32_t stableCurrentPage, uint32_t stablePageCount, - uint16_t autoPageTurnIntervalSeconds, bool automaticPageTurnActive, + bool showReadingPaceReset, bool globalStatsEnabled, bool bookStatsEnabled, uint32_t stableCurrentPage, + uint32_t stablePageCount, uint16_t autoPageTurnIntervalSeconds, bool automaticPageTurnActive, ReaderOptionsActivity::SaveSettingsCallback saveReaderSettingsCallback = nullptr, void* saveReaderSettingsContext = nullptr, ReaderOptionsActivity::SaveGlobalSettingsCallback saveGlobalSettingsCallback = nullptr, @@ -119,6 +119,8 @@ class EpubReaderDrawerActivity final : public Activity { bool hasDictionary = false; bool hasBookmarks = false; bool hasClippings = false; + bool globalStatsEnabled = true; + bool bookStatsEnabled = true; bool isCurrentPageBookmarked = false; bool isBookCompleted = false; bool showReadingPaceReset = false; diff --git a/src/activities/reader/EpubReaderMenuModel.h b/src/activities/reader/EpubReaderMenuModel.h index e376aa85b2b..246e3da951f 100644 --- a/src/activities/reader/EpubReaderMenuModel.h +++ b/src/activities/reader/EpubReaderMenuModel.h @@ -21,6 +21,7 @@ enum class EpubReaderMenuAction : uint8_t { DELETE_CACHE, RESET_READING_PACE, READING_STATS, + TOGGLE_BOOK_STATS_TRACKING, TOGGLE_COMPLETED, READER_OPTIONS, CONTROLS_OPTIONS, @@ -145,6 +146,7 @@ enum class ReaderDrawerCatalogItem : uint8_t { TtfReset, ResetBookReaderSettings, ReadingStats, + TrackBookStats, SyncProgress, NearbyPositionSync, SendNearbyBook, @@ -158,6 +160,8 @@ struct ReaderDrawerAvailability { bool showReadingPaceReset = false; bool hasStablePageNumbers = false; bool buttonDevice = false; + bool globalStatsEnabled = true; + bool bookStatsEnabled = true; }; struct ReaderDrawerTabCatalog { @@ -199,7 +203,8 @@ constexpr ReaderDrawerCatalog makeReaderDrawerCatalog(const ReaderDrawerAvailabi more.add(ReaderDrawerCatalogItem::GoToPercent); if (available.hasStablePageNumbers) more.add(ReaderDrawerCatalogItem::GoToStablePage); more.add(ReaderDrawerCatalogItem::AutoPageTurn); - if (available.buttonDevice) more.add(ReaderDrawerCatalogItem::ReadingStats); + if (available.buttonDevice && available.globalStatsEnabled && available.bookStatsEnabled) + more.add(ReaderDrawerCatalogItem::ReadingStats); if (available.hasFootnotes) more.add(ReaderDrawerCatalogItem::Footnotes); auto& location = catalog[static_cast(ReaderDrawerTab::Location)]; @@ -225,9 +230,10 @@ constexpr ReaderDrawerCatalog makeReaderDrawerCatalog(const ReaderDrawerAvailabi settings.add(ReaderDrawerCatalogItem::RenderMode); settings.add(ReaderDrawerCatalogItem::IndexingMethod); settings.add(ReaderDrawerCatalogItem::ToggleCompleted); + if (available.globalStatsEnabled) settings.add(ReaderDrawerCatalogItem::TrackBookStats); if (available.showReadingPaceReset) settings.add(ReaderDrawerCatalogItem::ResetReadingPace); settings.add(ReaderDrawerCatalogItem::DeleteCache); - settings.add(ReaderDrawerCatalogItem::DeleteStats); + if (available.globalStatsEnabled && available.bookStatsEnabled) settings.add(ReaderDrawerCatalogItem::DeleteStats); settings.add(ReaderDrawerCatalogItem::ResetBookReaderSettings); return catalog; } diff --git a/src/activities/reader/XtcReaderActivity.cpp b/src/activities/reader/XtcReaderActivity.cpp index c102e5e0d66..acdd20350d2 100644 --- a/src/activities/reader/XtcReaderActivity.cpp +++ b/src/activities/reader/XtcReaderActivity.cpp @@ -16,6 +16,7 @@ #include #include "BookStatsActivity.h" +#include "BookStatsTracking.h" #include "CrossPointSettings.h" #include "CrossPointState.h" #include "GlobalActions.h" @@ -123,6 +124,10 @@ void XtcReaderActivity::onEnter() { stats = BookReadingStats::load(xtc->getCachePath()); globalStats = GlobalReadingStats::load(); + bookStatsEnabled = BookStatsTracking::isBookEnabled(xtc->getCachePath()); + statsTrackingActive = SETTINGS.shouldTrackReadingStats() && bookStatsEnabled; + paceDirty = false; + pendingStatsCommit = false; sessionReadingSeconds = 0; hasSessionStartLocalDateTime = getCurrentLocalReadingStatsDateTime(sessionStartLocalDateTime); @@ -151,6 +156,7 @@ void XtcReaderActivity::onExit() { APP_STATE.readerActivityLoadCount = 0; APP_STATE.saveToFile(); + syncStatsTrackingState(); commitReadingStats(); // Generate carousel thumbnails while XTC is still loaded so the home screen @@ -178,7 +184,8 @@ void XtcReaderActivity::openReaderMenu() { pauseReadingStatsTimer("reader_menu"); startActivityForResult( - std::make_unique(renderer, mappedInput, std::move(title), hasChapters, stats.isCompleted), + std::make_unique(renderer, mappedInput, std::move(title), hasChapters, stats.isCompleted, + SETTINGS.shouldTrackReadingStats(), bookStatsEnabled), [this](const ActivityResult& result) { const auto* menu = std::get_if(&result.data); if (result.isCancelled || menu == nullptr) { @@ -194,6 +201,7 @@ void XtcReaderActivity::loop() { if (!xtc) { return; } + syncStatsTrackingState(); if (quickActionsPopup.handleInput(mappedInput, [this] { requestUpdate(); })) return; const bool shortcutPageTurn = shortcutPageTurnPending; @@ -601,6 +609,23 @@ void XtcReaderActivity::pauseReadingStatsTimer(const char* source) { pageShownAtMs = 0UL; } +void XtcReaderActivity::syncStatsTrackingState() { + if (!xtc) return; + const bool active = SETTINGS.shouldTrackReadingStats() && bookStatsEnabled; + if (active == statsTrackingActive) return; + if (statsTrackingActive && !active) { + pendingStatsCommit = true; + if (stats.save(xtc->getCachePath())) { + globalStats.save(); + pendingStatsCommit = false; + } + } + statsTrackingActive = active; + sessionReadingSeconds = 0; + hasSessionStartLocalDateTime = getCurrentLocalReadingStatsDateTime(sessionStartLocalDateTime); + pageShownAtMs = millis(); +} + void XtcReaderActivity::resumeReadingStatsTimer(const char*) { if (xtc && currentPage < xtc->getPageCount()) { pageShownAtMs = millis(); @@ -660,7 +685,7 @@ bool XtcReaderActivity::handleQuickLockUnlock(const QuickLockTrigger trigger) { bool XtcReaderActivity::currentPageReadingSecondsForStats(uint32_t& seconds, const char* source) const { seconds = 0; - if (!SETTINGS.shouldTrackReadingStats() || pageShownAtMs == 0UL) { + if (!statsTrackingActive || pageShownAtMs == 0UL) { return false; } @@ -681,7 +706,7 @@ bool XtcReaderActivity::currentPageReadingSecondsForStats(uint32_t& seconds, con bool XtcReaderActivity::forwardPageReadElapsed(uint32_t& seconds, const char*) const { seconds = 0; - if (!SETTINGS.shouldTrackReadingStats() || pageShownAtMs == 0UL) { + if (pageShownAtMs == 0UL) { return false; } @@ -710,9 +735,12 @@ void XtcReaderActivity::recordCurrentPageReadingTime(const char* source) { void XtcReaderActivity::recordForwardPageTurn(const uint32_t seconds, const bool recordPace) { if (recordPace) { stats.recordForwardPageRead(seconds); + paceDirty = true; + } + if (statsTrackingActive) { + stats.totalPagesTurned++; + globalStats.totalPagesTurned++; } - stats.totalPagesTurned++; - globalStats.totalPagesTurned++; } bool XtcReaderActivity::formatTimeLeftLabel(char* buf, const size_t len, const uint32_t pageToRender, @@ -739,17 +767,16 @@ bool XtcReaderActivity::formatTimeLeftLabel(char* buf, const size_t len, const u } void XtcReaderActivity::commitReadingStats() { - if (!xtc || !SETTINGS.shouldTrackReadingStats()) { + if (!xtc) { return; } - - recordCurrentPageReadingTime("reader_exit"); + if (statsTrackingActive) recordCurrentPageReadingTime("reader_exit"); const uint32_t elapsedSecs = sessionReadingSeconds; - if (elapsedSecs >= 60) { + if (statsTrackingActive && elapsedSecs >= 60) { stats.sessionCount++; globalStats.totalSessions++; } - if (elapsedSecs >= 10) { + if (statsTrackingActive && elapsedSecs >= 10) { stats.totalReadingSeconds += elapsedSecs; globalStats.totalReadingSeconds += elapsedSecs; if (hasSessionStartLocalDateTime) { @@ -760,8 +787,9 @@ void XtcReaderActivity::commitReadingStats() { stats.startDate = sessionStartLocalDateTime.date; } } - stats.save(xtc->getCachePath()); - globalStats.save(); + if (statsTrackingActive || paceDirty || pendingStatsCommit) { + if (stats.save(xtc->getCachePath()) && (statsTrackingActive || pendingStatsCommit)) globalStats.save(); + } } void XtcReaderActivity::resetCurrentBookStatsAfterDelete() { @@ -776,21 +804,21 @@ void XtcReaderActivity::setBookCompleted(const bool isCompleted) { } stats.isCompleted = isCompleted; - if (isCompleted && !stats.finishedDateManual) { + if (isCompleted && statsTrackingActive && !stats.finishedDateManual) { ReadingStatsDateTime now; if (getCurrentLocalReadingStatsDateTime(now)) { stats.finishedDate = now.date; } } - if (isCompleted) { - globalStats.completedBooks++; - } else if (globalStats.completedBooks > 0) { - globalStats.completedBooks--; + if (stats.save(xtc->getCachePath()) && statsTrackingActive) { + if (isCompleted) { + globalStats.completedBooks++; + } else if (globalStats.completedBooks > 0) { + globalStats.completedBooks--; + } + globalStats.save(); } - - stats.save(xtc->getCachePath()); - globalStats.save(); } float XtcReaderActivity::getCurrentBookProgressPercent() const { @@ -818,10 +846,10 @@ bool XtcReaderActivity::getFrontlightPanelBookDetails(FrontlightPanelBookDetails } std::unique_ptr XtcReaderActivity::createFrontlightReadingStatsActivity() { - if (!xtc) return {}; + if (!xtc || !statsTrackingActive) return {}; BookReadingStats displayStats = stats; - if (SETTINGS.shouldTrackReadingStats()) { + if (statsTrackingActive) { displayStats.totalReadingSeconds = displayStats.totalReadingSeconds > UINT32_MAX - sessionReadingSeconds ? UINT32_MAX : displayStats.totalReadingSeconds + sessionReadingSeconds; @@ -878,6 +906,7 @@ void XtcReaderActivity::openChapterSelection() { } void XtcReaderActivity::openReadingStats() { + if (!statsTrackingActive) return; auto bookStats = createFrontlightReadingStatsActivity(); if (!bookStats) { resumeReadingStatsTimer("book_stats_no_book"); @@ -952,13 +981,26 @@ void XtcReaderActivity::onReaderMenuConfirm(const int action) { case XtcReaderMenuActivity::MenuAction::READING_STATS: openReadingStats(); break; + case XtcReaderMenuActivity::MenuAction::TOGGLE_BOOK_STATS_TRACKING: + if (xtc && SETTINGS.shouldTrackReadingStats()) { + const bool enabled = !bookStatsEnabled; + if (!BookStatsTracking::setBookEnabled(xtc->getCachePath(), enabled)) { + drawToast(renderer, (std::string(tr(STR_TRACK_READING_STATS)) + " " + tr(STR_FAILED_LOWER)).c_str()); + delay(1000); + } + bookStatsEnabled = BookStatsTracking::isBookEnabled(xtc->getCachePath()); + syncStatsTrackingState(); + } + resumeReadingStatsTimer("toggle_book_stats_return"); + requestUpdate(); + break; case XtcReaderMenuActivity::MenuAction::TOGGLE_COMPLETED: setBookCompleted(!stats.isCompleted); resumeReadingStatsTimer("toggle_completed_return"); requestUpdate(); break; case XtcReaderMenuActivity::MenuAction::DELETE_STATS: - deleteBookStats(); + if (statsTrackingActive) deleteBookStats(); break; case XtcReaderMenuActivity::MenuAction::DELETE_CACHE: deleteBookCache(); diff --git a/src/activities/reader/XtcReaderActivity.h b/src/activities/reader/XtcReaderActivity.h index 6d047611de8..97120874e77 100644 --- a/src/activities/reader/XtcReaderActivity.h +++ b/src/activities/reader/XtcReaderActivity.h @@ -33,6 +33,10 @@ class XtcReaderActivity final : public Activity { GlobalReadingStats globalStats; ReadingStatsDateTime sessionStartLocalDateTime; bool hasSessionStartLocalDateTime = false; + bool bookStatsEnabled = true; + bool statsTrackingActive = true; + bool paceDirty = false; + bool pendingStatsCommit = false; bool longPowerPageTurnHandled = false; // Home-key shortcuts are dispatched before this activity's normal input loop. // Queue the turn so it follows the same guarded XTC page-turn path. @@ -64,6 +68,7 @@ class XtcReaderActivity final : public Activity { bool flushQueuedProgress(); void loadProgress(); void pauseReadingStatsTimer(const char* source = "unknown"); + void syncStatsTrackingState(); void resumeReadingStatsTimer(const char* source = "unknown"); bool currentPageReadingSecondsForStats(uint32_t& seconds, const char* source) const; bool forwardPageReadElapsed(uint32_t& seconds, const char* source) const; diff --git a/src/activities/reader/XtcReaderMenuActivity.cpp b/src/activities/reader/XtcReaderMenuActivity.cpp index 4b33183df18..9e7c36eb562 100644 --- a/src/activities/reader/XtcReaderMenuActivity.cpp +++ b/src/activities/reader/XtcReaderMenuActivity.cpp @@ -26,24 +26,33 @@ constexpr fui::ActionId kActionRow = 1; } // namespace XtcReaderMenuActivity::XtcReaderMenuActivity(GfxRenderer& renderer, MappedInputManager& mappedInput, std::string title, - const bool hasChapters, const bool isBookCompleted) + const bool hasChapters, const bool isBookCompleted, + const bool globalStatsEnabled, const bool bookStatsEnabled) : Activity("XtcReaderMenu", renderer, mappedInput), title(std::move(title)), - items(buildMenuItems(hasChapters, isBookCompleted, mappedInput.hasTouchHardware())), + items(buildMenuItems(hasChapters, isBookCompleted, mappedInput.hasTouchHardware(), globalStatsEnabled, + bookStatsEnabled)), + bookStatsEnabled(bookStatsEnabled), ui(renderer) {} std::vector XtcReaderMenuActivity::buildMenuItems(const bool hasChapters, const bool isBookCompleted, - const bool hasTouch) { + const bool hasTouch, + const bool globalStatsEnabled, + const bool bookStatsEnabled) { std::vector menuItems; - menuItems.reserve(6 + (hasTouch ? 1u : 0u)); + menuItems.reserve(7 + (hasTouch ? 1u : 0u)); if (hasChapters) { menuItems.push_back({MenuAction::SELECT_CHAPTER, StrId::STR_SELECT_CHAPTER}); } - menuItems.push_back({MenuAction::READING_STATS, StrId::STR_READING_STATS}); + if (globalStatsEnabled) { + menuItems.push_back({MenuAction::TOGGLE_BOOK_STATS_TRACKING, StrId::STR_TRACK_READING_STATS}); + if (bookStatsEnabled) menuItems.push_back({MenuAction::READING_STATS, StrId::STR_READING_STATS}); + } menuItems.push_back( {MenuAction::TOGGLE_COMPLETED, isBookCompleted ? StrId::STR_MARK_UNFINISHED : StrId::STR_MARK_FINISHED}); - menuItems.push_back({MenuAction::DELETE_STATS, StrId::STR_DELETE_BOOK_STATS}); + if (globalStatsEnabled && bookStatsEnabled) + menuItems.push_back({MenuAction::DELETE_STATS, StrId::STR_DELETE_BOOK_STATS}); menuItems.push_back({MenuAction::DELETE_CACHE, StrId::STR_DELETE_CACHE}); menuItems.push_back({MenuAction::SEND_NEARBY_BOOK, StrId::STR_SEND_NEARBY_BOOK}); if (hasTouch) { @@ -179,6 +188,8 @@ void XtcReaderMenuActivity::refreshListItems() { listItems[index].actionValue = static_cast(index); if (items[index].action == MenuAction::DISABLE_TOUCHSCREEN) { listItems[index].value = I18N.get(SETTINGS.disableReaderTouchscreen ? StrId::STR_ON : StrId::STR_OFF); + } else if (items[index].action == MenuAction::TOGGLE_BOOK_STATS_TRACKING) { + listItems[index].value = I18N.get(bookStatsEnabled ? StrId::STR_ON : StrId::STR_OFF); } } } diff --git a/src/activities/reader/XtcReaderMenuActivity.h b/src/activities/reader/XtcReaderMenuActivity.h index bffea92aa42..17ef060c351 100644 --- a/src/activities/reader/XtcReaderMenuActivity.h +++ b/src/activities/reader/XtcReaderMenuActivity.h @@ -16,6 +16,7 @@ class XtcReaderMenuActivity final : public Activity { enum class MenuAction { SELECT_CHAPTER, READING_STATS, + TOGGLE_BOOK_STATS_TRACKING, TOGGLE_COMPLETED, DELETE_STATS, DELETE_CACHE, @@ -24,7 +25,7 @@ class XtcReaderMenuActivity final : public Activity { }; XtcReaderMenuActivity(GfxRenderer& renderer, MappedInputManager& mappedInput, std::string title, bool hasChapters, - bool isBookCompleted); + bool isBookCompleted, bool globalStatsEnabled, bool bookStatsEnabled); void onEnter() override; void onExit() override; @@ -35,21 +36,23 @@ class XtcReaderMenuActivity final : public Activity { bool allowGlobalHomeGesture() const override { return false; } private: - using UiHost = UiAppHost<8, 2>; + using UiHost = UiAppHost<9, 2>; using UiApp = UiHost::App; - static constexpr size_t kMaxMenuItems = 7; + static constexpr size_t kMaxMenuItems = 8; struct MenuItem { MenuAction action; StrId labelId; }; - static std::vector buildMenuItems(bool hasChapters, bool isBookCompleted, bool hasTouch); + static std::vector buildMenuItems(bool hasChapters, bool isBookCompleted, bool hasTouch, + bool globalStatsEnabled, bool bookStatsEnabled); void finishCancelled(); ButtonNavigator buttonNavigator; std::string title; std::vector items; + bool bookStatsEnabled = true; std::array listItems{}; int selectedIndex = 0; UiHost ui; diff --git a/src/activities/util/BmpViewerActivity.cpp b/src/activities/util/BmpViewerActivity.cpp index ca8672dfee9..e3f5c44c002 100644 --- a/src/activities/util/BmpViewerActivity.cpp +++ b/src/activities/util/BmpViewerActivity.cpp @@ -399,6 +399,7 @@ void BmpViewerActivity::showContextMenu() { return; case FileBrowserAction::DeleteCache: case FileBrowserAction::ReadingStats: + case FileBrowserAction::ToggleBookStatsTracking: case FileBrowserAction::SetSleepFolder: case FileBrowserAction::ClearSleepFolder: case FileBrowserAction::ToggleCompleted: diff --git a/src/activities/util/FrontlightPanelActivity.cpp b/src/activities/util/FrontlightPanelActivity.cpp index 30f4ee86a7d..8d79b4536b4 100644 --- a/src/activities/util/FrontlightPanelActivity.cpp +++ b/src/activities/util/FrontlightPanelActivity.cpp @@ -214,6 +214,7 @@ void FrontlightPanelActivity::close() { } void FrontlightPanelActivity::openReadingStats() { + if (!context.showReadingStatsAction || !SETTINGS.shouldTrackReadingStats()) return; if (!context.readingStatsActivity && context.sourceActivity) { context.readingStatsActivity = context.sourceActivity->createFrontlightReadingStatsActivity(); } @@ -270,6 +271,7 @@ void FrontlightPanelActivity::closeSyncDialog() { void FrontlightPanelActivity::activateQuickAction(const int index) { if (index < 0 || index >= 5) return; + if (index == 0 && !context.showReadingStatsAction) return; drawerState.selectedAction = static_cast(index); switch (index) { case 0: @@ -459,14 +461,17 @@ void FrontlightPanelActivity::buildPanelScreen(UiApp::ScreenType& screen) { 0, bottomInset, 0}); const fui::Rect actionBar = screen.takeBottom(ACTION_BAR_HEIGHT); - const int16_t slotWidth = static_cast(actionBar.width / 5); + const bool showStats = context.showReadingStatsAction; + const int16_t slotCount = showStats ? 5 : 4; + const int16_t slotWidth = static_cast(actionBar.width / slotCount); const std::array icons = { fui::bitmapFromIcon(icon_reading_stats_24), fui::bitmapFromIcon(icon_transfer_24), fui::bitmapFromIcon(icon_tabler_moon_filled_24), fui::bitmapFromIcon(icon_sliders_horizontal_24), fui::bitmapFromIcon(pendingTouchscreenDisabled ? icon_device_tablet_off_24 : icon_device_tablet_24)}; - for (int16_t i = 0; i < 5; ++i) { - const int16_t x = static_cast(actionBar.x + i * slotWidth); - const int16_t width = i == 4 ? static_cast(actionBar.right() - x) : slotWidth; + for (int16_t slotIndex = 0; slotIndex < slotCount; ++slotIndex) { + const int16_t i = showStats ? slotIndex : slotIndex + 1; + const int16_t x = static_cast(actionBar.x + slotIndex * slotWidth); + const int16_t width = slotIndex == slotCount - 1 ? static_cast(actionBar.right() - x) : slotWidth; const fui::Rect slot{x, actionBar.y, width, actionBar.height}; screen.frame().hit(slot, ACTION_QUICK, i); screen.target().bitmap(slot, icons[static_cast(i)], fui::BitmapMode::Center); diff --git a/src/activities/util/FrontlightPanelModel.h b/src/activities/util/FrontlightPanelModel.h index 23975b2ff32..cc48f2c6dca 100644 --- a/src/activities/util/FrontlightPanelModel.h +++ b/src/activities/util/FrontlightPanelModel.h @@ -66,6 +66,7 @@ struct FrontlightPanelContext { bool activeReaderBook = false; bool activeEpub = false; bool showReaderDetails = false; + bool showReadingStatsAction = true; std::string bookTitle; std::string bookPath; FrontlightPanelBookDetails bookDetails; diff --git a/src/components/themes/dashboard/DashboardTheme.cpp b/src/components/themes/dashboard/DashboardTheme.cpp index 9fc4439d93f..7ce1e68afc4 100644 --- a/src/components/themes/dashboard/DashboardTheme.cpp +++ b/src/components/themes/dashboard/DashboardTheme.cpp @@ -8,6 +8,7 @@ #include #include #include +#include #include #include @@ -16,8 +17,10 @@ #include #include +#include "CrossPointSettings.h" #include "RecentBooksStore.h" #include "activities/reader/BookReadingStats.h" +#include "activities/reader/BookStatsTracking.h" #include "activities/reader/GlobalReadingStats.h" #include "activities/reader/ReadingStatsUtils.h" #include "components/TouchRegistry.h" @@ -247,7 +250,7 @@ void drawStatsRow(const GfxRenderer& renderer, const int rightX, const int y, co } void drawDashboardStats(const GfxRenderer& renderer, const Rect& coverRect, const BookReadingStats* stats, - const float progressPercent, const bool black = true) { + const float progressPercent, const bool showStats, const bool black = true) { const int rightX = renderer.getScreenWidth() - contentInset(renderer) - (gpio.deviceIsX3() ? kPairInwardShiftX3 : 0); const int blockH = statsBlockHeight(renderer); const bool showRtcStats = halClock.isAvailable(); @@ -260,6 +263,19 @@ void drawDashboardStats(const GfxRenderer& renderer, const Rect& coverRect, cons char finishDate[24]; uint32_t estimatedSeconds = 0; const bool hasEstimate = estimatedTimeLeft(bookStats, progressPercent, estimatedSeconds); + if (!showStats) { + if (hasEstimate) { + formatCompactDuration(estimatedSeconds, value, sizeof(value)); + drawStatsRow(renderer, rightX, coverRect.y + coverRect.height / 2 - blockH, value, tr(STR_TIME_LEFT_SHORT), + black); + } + if (progressPercent >= 0.0f) { + snprintf(value, sizeof(value), "%d%%", static_cast(progressPercent + 0.5f)); + const int progressY = hasEstimate ? coverRect.y + coverRect.height / 2 + blockH : coverRect.y; + drawStatsRow(renderer, rightX, progressY, value, tr(STR_STATS_PROGRESS_LBL), black); + } + return; + } ReadingStatsDateTime today; const bool hasToday = showRtcStats && getCurrentLocalReadingStatsDateTime(today); const ReadingStatsDate endDate = bookStats.isCompleted && bookStats.finishedDate.isValid() @@ -342,6 +358,15 @@ void drawDashboardStats(const GfxRenderer& renderer, const Rect& coverRect, cons bookStats.isCompleted ? tr(STR_STATS_FINISHED_DATE) : tr(STR_STATS_EST_FINISH_DATE), black); } +bool showBookStatsForPath(const std::string& path) { + if (!SETTINGS.shouldTrackReadingStats()) return false; + if (FsHelpers::hasEpubExtension(path)) + return BookStatsTracking::isBookEnabled(Epub::cachePathForFilePath(path, "/.crosspoint")); + if (FsHelpers::hasXtcExtension(path)) + return BookStatsTracking::isBookEnabled(Xtc(path, "/.crosspoint").getCachePath()); + return false; +} + bool dominantReaderTypeBucket(const GlobalReadingStats& globalStats, ReadingTimeBucket& bucketOut) { const auto& values = globalStats.timeOfDaySeconds; const uint32_t totalSeconds = std::accumulate(values.begin(), values.end(), 0u); @@ -562,9 +587,9 @@ void DashboardTheme::drawRecentBookCover(GfxRenderer& renderer, Rect rect, const } TouchRegistry::getInstance().add(coverRect, 0, TouchRegistry::Cover); - drawDashboardStats(renderer, coverRect, stats, progressPercent); + drawDashboardStats(renderer, coverRect, stats, progressPercent, showBookStatsForPath(recentBooks[0].path)); drawBookText(renderer, coverRect, recentBooks[0], currentChapterTitle); - drawFooterStats(renderer, coverRect, globalStats); + if (SETTINGS.shouldTrackReadingStats()) drawFooterStats(renderer, coverRect, globalStats); } void DashboardTheme::drawSleepScreen(const GfxRenderer& renderer, const RecentBook& book, const BookReadingStats* stats, @@ -576,7 +601,7 @@ void DashboardTheme::drawSleepScreen(const GfxRenderer& renderer, const RecentBo DashboardMetrics::values.homeCoverTileHeight}; const Rect coverRect = coverRectForScreen(renderer, contentRect); drawBookCover(renderer, coverRect, book, inverted ? Color::White : Color::Black); - drawDashboardStats(renderer, coverRect, stats, progressPercent, inverted); + drawDashboardStats(renderer, coverRect, stats, progressPercent, showBookStatsForPath(book.path), inverted); drawBookText(renderer, coverRect, book, currentChapterTitle, inverted); - drawFooterStats(renderer, coverRect, globalStats, !inverted); + if (SETTINGS.shouldTrackReadingStats()) drawFooterStats(renderer, coverRect, globalStats, !inverted); } diff --git a/src/main.cpp b/src/main.cpp index 7e208f00d41..511d38280dc 100644 --- a/src/main.cpp +++ b/src/main.cpp @@ -677,6 +677,7 @@ bool handleGlobalPowerButtonAction(const CrossPointSettings::SHORT_PWRBTN action } bool dispatchShortcutAction(const CrossPointSettings::SHORT_PWRBTN action) { + if (action == CrossPointSettings::SHORT_PWRBTN::READING_STATS && !SETTINGS.shouldTrackReadingStats()) return false; // An EPUB reader may have a per-book orientation that is restored during // teardown. Let it hand off Sync Progress before the global restart drops // that transient setting. @@ -1096,7 +1097,7 @@ void enterDeepSleep(bool fromTimeout) { delay(POST_SLEEP_SCREEN_SETTLE_MS); } - if (halClock.isAvailable() && SETTINGS.autoBackupStats != 0) { + if (halClock.isAvailable() && SETTINGS.shouldTrackReadingStats() && SETTINGS.autoBackupStats != 0) { ReadingStatsDateTime now; if (getCurrentLocalReadingStatsDateTime(now) && !backupGlobalStats(false)) { LOG_ERR("MAIN", "Automatic reading-stats backup failed before deep sleep"); diff --git a/src/network/CrossPointWebServer.cpp b/src/network/CrossPointWebServer.cpp index 494c5299f46..06e0f5dae2f 100644 --- a/src/network/CrossPointWebServer.cpp +++ b/src/network/CrossPointWebServer.cpp @@ -77,6 +77,14 @@ bool isWebEnumOptionAvailable(const SettingInfo& setting, size_t optionIndex) { if (optionIndex >= setting.enumValues.size()) return true; const StrId option = setting.enumValues[optionIndex]; + if (!SETTINGS.shouldTrackReadingStats()) { + if (option == StrId::STR_READING_STATS) return false; + if (setting.valuePtr == &CrossPointSettings::sleepScreen && optionIndex < setting.enumRawValues.size()) { + const uint8_t raw = setting.enumRawValues[optionIndex]; + if (raw == CrossPointSettings::READING_STATS_SLEEP || raw == CrossPointSettings::MINIMAL_STATS_SLEEP) + return false; + } + } if (option == StrId::STR_TOGGLE_TOUCHSCREEN && !gpio.hasTouch()) return false; if (!Frontlight.present()) { @@ -1537,6 +1545,12 @@ void CrossPointWebServer::handleGetSettings() const { } case SettingType::ENUM: { doc["type"] = "enum"; + if (s.valuePtr == &CrossPointSettings::shortPwrBtn || s.valuePtr == &CrossPointSettings::longPwrBtn) { + doc["footnotesIndex"] = enumDisplayIndexForWeb(s, CrossPointSettings::FOOTNOTES); + } else if (s.valuePtr == &CrossPointSettings::longPressMenuAction || + s.valuePtr == &CrossPointSettings::longPressBackAction) { + doc["footnotesIndex"] = enumDisplayIndexForWeb(s, CrossPointSettings::LONG_MENU_FOOTNOTES); + } if (s.nameId == StrId::STR_FONT_FAMILY && !fontFamilies.empty()) { uint8_t selected = SETTINGS.fontFamily < CrossPointSettings::BUILTIN_FONT_COUNT ? SETTINGS.fontFamily : 0; if (selectedSdFamily) { diff --git a/src/simulator/SimulatorSmokeTest.cpp b/src/simulator/SimulatorSmokeTest.cpp index c56e06b7a6f..4e0102d68e3 100644 --- a/src/simulator/SimulatorSmokeTest.cpp +++ b/src/simulator/SimulatorSmokeTest.cpp @@ -851,7 +851,7 @@ class SimulatorSmokeTest { case SmokeStep::ReaderOptions: activityManager.replaceActivity(std::make_unique( renderer, mappedInputManager, std::shared_ptr{}, nullptr, 0.0f, 0, 0, false, false, false, false, - false, false, false, false, 0, 0, 5, false)); + false, false, false, false, true, true, 0, 0, 5, false)); queueStep("Reader Menu", SmokeStep::ReaderMenu); break; diff --git a/src/util/BookCacheUtils.cpp b/src/util/BookCacheUtils.cpp index 11c780e9271..4dd79f76e33 100644 --- a/src/util/BookCacheUtils.cpp +++ b/src/util/BookCacheUtils.cpp @@ -23,14 +23,17 @@ constexpr PreservedCacheFile EPUB_USER_STATE_FILES[] = { {"progress.bin.bak", "upload_preserve_progress.bin.bak"}, {"reader_settings.bin", "upload_preserve_reader_settings.bin"}, {"dictionary_history.txt", "upload_preserve_dictionary_history.txt"}, + {"reading_stats_off", "upload_preserve_reading_stats_off"}, }; constexpr PreservedCacheFile PAGE_PROGRESS_FILES[] = { {"progress.bin", "upload_preserve_progress.bin"}, + {"reading_stats_off", "upload_preserve_reading_stats_off"}, }; constexpr PreservedCacheFile CACHE_CLEAR_USER_STATE_FILES[] = { {"dictionary_history.txt", "clear_preserve_dictionary_history.txt"}, + {"reading_stats_off", "clear_preserve_reading_stats_off"}, }; struct ResolvedPreservedCacheFile { diff --git a/test/library_builder/BookCacheUtilsTest.cpp b/test/library_builder/BookCacheUtilsTest.cpp index 3a6212ceb68..51efeb2e796 100644 --- a/test/library_builder/BookCacheUtilsTest.cpp +++ b/test/library_builder/BookCacheUtilsTest.cpp @@ -2,7 +2,9 @@ #include +#include "../../src/activities/reader/BookStatsTracking.h" #include "../../src/util/BookCacheUtils.h" +#include "CrossPointSettings.h" #include "Epub.h" #include "HalStorage.h" @@ -14,10 +16,12 @@ class BookCacheUtilsTest : public ::testing::Test { void SetUp() override { fake::reset(); + SETTINGS.trackReadingStats = 1; cachePath = Epub("/a.epub", "/.crosspoint").getCachePath(); fake::add(cachePath + "/progress.bin", "progress"); fake::add(cachePath + "/stats_v5.bin", "stats"); fake::add(cachePath + "/reader_settings.bin", "settings"); + fake::add(cachePath + "/reading_stats_off", "off"); fake::add(cachePath + "/book.bin", "derived"); } @@ -30,6 +34,7 @@ class BookCacheUtilsTest : public ::testing::Test { expectFile(cachePath + "/progress.bin", "progress"); expectFile(cachePath + "/stats_v5.bin", "stats"); expectFile(cachePath + "/reader_settings.bin", "settings"); + expectFile(cachePath + "/reading_stats_off", "off"); } }; @@ -62,4 +67,33 @@ TEST_F(BookCacheUtilsTest, RetryRecoversProgressAfterFailedRestore) { EXPECT_FALSE(Storage.exists((cachePath + ".upload_preserve_progress.bin").c_str())); } +TEST_F(BookCacheUtilsTest, BookTrackingDefaultsOnAndGlobalOffTakesPrecedence) { + fake::files.erase(cachePath + "/reading_stats_off"); + EXPECT_TRUE(BookStatsTracking::isEnabled(cachePath)); + SETTINGS.trackReadingStats = 0; + EXPECT_FALSE(BookStatsTracking::isEnabled(cachePath)); + SETTINGS.trackReadingStats = 1; + EXPECT_TRUE(BookStatsTracking::isEnabled(cachePath)); +} + +TEST_F(BookCacheUtilsTest, BookChoiceSurvivesCacheClearAndStatsDeletion) { + ASSERT_FALSE(BookStatsTracking::isBookEnabled(cachePath)); + ASSERT_TRUE(clearBookCachePreservingUserState("/a.epub")); + ASSERT_FALSE(BookStatsTracking::isBookEnabled(cachePath)); + ASSERT_TRUE(Storage.remove((cachePath + "/stats_v5.bin").c_str())); + EXPECT_FALSE(BookStatsTracking::isBookEnabled(cachePath)); + ASSERT_TRUE(BookStatsTracking::setBookEnabled(cachePath, true)); + EXPECT_TRUE(BookStatsTracking::isEnabled(cachePath)); +} + +TEST_F(BookCacheUtilsTest, FailedBookToggleKeepsPreviousChoice) { + ASSERT_TRUE(BookStatsTracking::setBookEnabled(cachePath, true)); + fake::failRenameToPath = cachePath + "/reading_stats_off"; + EXPECT_FALSE(BookStatsTracking::setBookEnabled(cachePath, false)); + EXPECT_TRUE(BookStatsTracking::isBookEnabled(cachePath)); + EXPECT_FALSE(Storage.exists((cachePath + "/reading_stats_off.tmp").c_str())); + ASSERT_TRUE(BookStatsTracking::setBookEnabled(cachePath, false)); + EXPECT_FALSE(BookStatsTracking::isEnabled(cachePath)); +} + } // namespace diff --git a/test/library_builder/CMakeLists.txt b/test/library_builder/CMakeLists.txt index f30413c65b7..4fc3b3738df 100644 --- a/test/library_builder/CMakeLists.txt +++ b/test/library_builder/CMakeLists.txt @@ -18,6 +18,7 @@ gtest_discover_tests(LibraryBuilderTest) add_executable(BookCacheUtilsTest BookCacheUtilsTest.cpp ${REPO_ROOT}/src/util/BookCacheUtils.cpp + ${REPO_ROOT}/src/activities/reader/BookStatsTracking.cpp ) target_include_directories(BookCacheUtilsTest PRIVATE ${CMAKE_CURRENT_SOURCE_DIR}/stubs diff --git a/test/library_builder/stubs/CrossPointSettings.h b/test/library_builder/stubs/CrossPointSettings.h new file mode 100644 index 00000000000..b90db044b14 --- /dev/null +++ b/test/library_builder/stubs/CrossPointSettings.h @@ -0,0 +1,10 @@ +#pragma once + +#include + +struct CrossPointSettings { + uint8_t trackReadingStats = 1; + bool shouldTrackReadingStats() const { return trackReadingStats != 0; } +}; + +inline CrossPointSettings SETTINGS; diff --git a/test/reader_drawer_model/ReaderDrawerModelTest.cpp b/test/reader_drawer_model/ReaderDrawerModelTest.cpp index a09a3a65e36..3366081566e 100644 --- a/test/reader_drawer_model/ReaderDrawerModelTest.cpp +++ b/test/reader_drawer_model/ReaderDrawerModelTest.cpp @@ -152,11 +152,31 @@ TEST(ReaderDrawerModel, CatalogOrderAndConditionalRowsMatchTouchDesign) { EXPECT_EQ(location.items[6], ReaderDrawerCatalogItem::DisplayQr); const auto& settings = complete[static_cast(ReaderDrawerTab::Settings)]; - EXPECT_EQ(settings.count, 10); + EXPECT_EQ(settings.count, 11); EXPECT_EQ(settings.items[0], ReaderDrawerCatalogItem::StatusBar); EXPECT_EQ(settings.items[1], ReaderDrawerCatalogItem::Controls); - EXPECT_EQ(settings.items[6], ReaderDrawerCatalogItem::ResetReadingPace); - EXPECT_EQ(settings.items[9], ReaderDrawerCatalogItem::ResetBookReaderSettings); + EXPECT_EQ(settings.items[6], ReaderDrawerCatalogItem::TrackBookStats); + EXPECT_EQ(settings.items[7], ReaderDrawerCatalogItem::ResetReadingPace); + EXPECT_EQ(settings.items[10], ReaderDrawerCatalogItem::ResetBookReaderSettings); +} + +TEST(ReaderDrawerModel, StatsRowsFollowGlobalAndBookTrackingChoices) { + ReaderDrawerAvailability available{}; + available.buttonDevice = true; + available.bookStatsEnabled = false; + auto catalog = makeReaderDrawerCatalog(available); + EXPECT_EQ(catalog[static_cast(ReaderDrawerTab::More)].count, 3); + const auto& settings = catalog[static_cast(ReaderDrawerTab::Settings)]; + EXPECT_EQ(settings.items[6], ReaderDrawerCatalogItem::TrackBookStats); + EXPECT_EQ(settings.items[settings.count - 1], ReaderDrawerCatalogItem::ResetBookReaderSettings); + + available.globalStatsEnabled = false; + catalog = makeReaderDrawerCatalog(available); + const auto& globalOffSettings = catalog[static_cast(ReaderDrawerTab::Settings)]; + for (uint8_t i = 0; i < globalOffSettings.count; ++i) { + EXPECT_NE(globalOffSettings.items[i], ReaderDrawerCatalogItem::TrackBookStats); + EXPECT_NE(globalOffSettings.items[i], ReaderDrawerCatalogItem::DeleteStats); + } } TEST(ReaderDrawerModel, ButtonDevicesRestoreStatsAndTransfersInTheirTabs) { diff --git a/web/pages/settings.js b/web/pages/settings.js index a9d0f6289fc..6af237aea7f 100644 --- a/web/pages/settings.js +++ b/web/pages/settings.js @@ -5,8 +5,6 @@ let allSettings = []; const SLEEP_SCREEN_MODE = { QUICK_RESUME: 6 }; - const POWER_BUTTON_FOOTNOTES_DISPLAY_INDEX = 15; - const LONG_PRESS_MENU_FOOTNOTES_DISPLAY_INDEX = 14; function escapeHtml(unsafe) { return unsafe @@ -170,14 +168,14 @@ let allSettings = []; } function updateSettingsVisibility() { - const shortPwrBtnVal = getControlValue('shortPwrBtn'); - const longPwrBtnVal = getControlValue('longPwrBtn'); - const longPressMenuActionVal = getControlValue('longPressMenuAction'); const pwrBtnFootnoteBackRow = document.getElementById('row-setting-pwrBtnFootnoteBack'); if (pwrBtnFootnoteBackRow) { - if (shortPwrBtnVal === POWER_BUTTON_FOOTNOTES_DISPLAY_INDEX || - longPwrBtnVal === POWER_BUTTON_FOOTNOTES_DISPLAY_INDEX || - longPressMenuActionVal === LONG_PRESS_MENU_FOOTNOTES_DISPLAY_INDEX) { + const footnoteActionSelected = ['shortPwrBtn', 'longPwrBtn', 'longPressMenuAction', 'longPressBackAction'] + .some(key => { + const setting = allSettings.find(item => item.key === key); + return setting && getControlValue(key) === setting.footnotesIndex; + }); + if (footnoteActionSelected) { pwrBtnFootnoteBackRow.style.display = ''; } else { pwrBtnFootnoteBackRow.style.display = 'none'; @@ -187,7 +185,8 @@ let allSettings = []; function handleSettingChanged(key) { syncQuickResumeTimeoutForSleepScreen(key === 'sleepScreen', key === 'quickResumeSleepScreen'); - if (key === 'shortPwrBtn' || key === 'longPwrBtn' || key === 'longPressMenuAction') { + if (key === 'shortPwrBtn' || key === 'longPwrBtn' || key === 'longPressMenuAction' || + key === 'longPressBackAction') { updateSettingsVisibility(); } markChanged(); @@ -280,6 +279,9 @@ let allSettings = []; for (const key in changes) { originalValues[key] = changes[key]; } + if (Object.prototype.hasOwnProperty.call(changes, 'trackReadingStats')) { + await loadSettings(); + } showMessage('Settings saved successfully!', false); } catch (e) { From 0dc13d752b89794fcb32cc15605c1b46e33292d7 Mon Sep 17 00:00:00 2001 From: suhsoohong <114119600+suhsoohong@users.noreply.github.com> Date: Mon, 28 Sep 2026 12:44:03 -0400 Subject: [PATCH 42/62] test: include integer types in library builder stub Ported from crosspoint-reader/crosspoint-reader@28132052f2378bb3a82cff9bef2173c958d1fe64. --- test/library_builder/stubs/HalStorage.h | 1 + 1 file changed, 1 insertion(+) diff --git a/test/library_builder/stubs/HalStorage.h b/test/library_builder/stubs/HalStorage.h index 4b41abc4867..43773526bca 100644 --- a/test/library_builder/stubs/HalStorage.h +++ b/test/library_builder/stubs/HalStorage.h @@ -1,6 +1,7 @@ #pragma once #include +#include #include #include #include From 799187d05c1f5e66d491f4f2ea29add93af9383e Mon Sep 17 00:00:00 2001 From: Uri Tauber Date: Mon, 28 Sep 2026 12:44:43 -0400 Subject: [PATCH 43/62] fix: pin the nested PlatformIO core in release builds Ported from crosspoint-reader/crosspoint-reader@93e98bb78702e29868a16a13b80c40e6b36ccdff. Adapted to the CrossInk release environment matrix. --- .github/workflows/release.yml | 12 ++++++++++++ CHANGELOG.md | 1 + 2 files changed, 13 insertions(+) diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 24dc1c49a34..cfbcac505ff 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -82,6 +82,18 @@ jobs: - name: Install PlatformIO Core run: uv pip install --system -U https://github.com/pioarduino/platformio-core/archive/refs/tags/v6.1.19.zip + - name: Pin pioarduino core inside the platform penv + env: + PIO_ENV: ${{ matrix.environment }} + run: | + set -euo pipefail + # custom_sdkconfig runs a nested `pio run` from ~/.platformio/penv. + # Keep that core on the same version as the outer core. + # See https://github.com/pioarduino/platform-espressif32/issues/529 + pio pkg install -e "$PIO_ENV" + uv pip install --python ~/.platformio/penv/bin/python "pioarduino==6.1.19" + ~/.platformio/penv/bin/pio --version + - name: Configure release version env: RELEASE_VERSION: ${{ needs.prepare-release.outputs.release_version }} diff --git a/CHANGELOG.md b/CHANGELOG.md index 4cb833b9f14..1c48eafa5c0 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -39,6 +39,7 @@ - Keep clipped text, exported excerpts, and chapter titles on complete characters when shortened. - Keep clipping-selection button hints from covering book text. - Changing a reader font with incremental indexing now returns after the current reading position is ready, instead of waiting for the whole chapter to be re-indexed. +- Release builds use the pinned PlatformIO core during nested ESP-IDF configuration. ## [v1.6.0] - 2026-09-21 From 7d447c62d1aead3641c0f53891a12208ec74b271 Mon Sep 17 00:00:00 2001 From: Justin Mitchell Date: Mon, 28 Sep 2026 12:45:09 -0400 Subject: [PATCH 44/62] fix: avoid white flash when adding the sleep moon Ported from crosspoint-reader/crosspoint-reader@5bfaf7bdbd847c475cad96771603b23866e83ac2. Preserves CrossInk dark-mode icon inversion. --- CHANGELOG.md | 1 + src/activities/boot_sleep/SleepActivity.cpp | 6 +++--- 2 files changed, 4 insertions(+), 3 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 1c48eafa5c0..a096361a7f5 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -40,6 +40,7 @@ - Keep clipping-selection button hints from covering book text. - Changing a reader font with incremental indexing now returns after the current reading position is ready, instead of waiting for the whole chapter to be re-indexed. - Release builds use the pinned PlatformIO core during nested ESP-IDF configuration. +- Adding the sleep moon to the last screen no longer flashes white in night mode. ## [v1.6.0] - 2026-09-21 diff --git a/src/activities/boot_sleep/SleepActivity.cpp b/src/activities/boot_sleep/SleepActivity.cpp index 80dd9fc7cb1..adc4170d8f4 100644 --- a/src/activities/boot_sleep/SleepActivity.cpp +++ b/src/activities/boot_sleep/SleepActivity.cpp @@ -921,12 +921,12 @@ void SleepActivity::renderLastScreenSleepScreen() const { } else { renderer.drawImage(MoonIcon, 0, pageHeight - MOONICON_HEIGHT, MOONICON_WIDTH, MOONICON_HEIGHT); } + // Only the moon differs from the displayed frame, so a differential FAST + // update avoids the flashing clean pass on an inverted night-mode page. if (gpio.deviceIsX3()) { - // The controller still holds the displayed page, so its differential base - // waveform can add the moon without a full-screen flash. renderer.displayGrayscaleBase(HalDisplay::FAST_REFRESH); } else { - renderer.displayBuffer(HalDisplay::HALF_REFRESH); + renderer.displayBuffer(HalDisplay::FAST_REFRESH); } } From 891a06f7f6c00efaed2cdb0508c7dc19a5b91f5b Mon Sep 17 00:00:00 2001 From: Justin Mitchell Date: Mon, 28 Sep 2026 12:46:37 -0400 Subject: [PATCH 45/62] fix: skip the loading icon refresh on wake Ported from crosspoint-reader/crosspoint-reader@f7eedbae18776fab725676c5565f8bc2b76d8e9e. CrossInk keeps X3 frame restoration and moves X4 ghost cleanup into the first Home paint. Co-authored-by: Julia Nguyen --- CHANGELOG.md | 1 + src/images/LoadingIcon.h | 24 ------------------------ src/main.cpp | 30 +++++++++++------------------- 3 files changed, 12 insertions(+), 43 deletions(-) delete mode 100644 src/images/LoadingIcon.h diff --git a/CHANGELOG.md b/CHANGELOG.md index a096361a7f5..d07a58a4854 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -41,6 +41,7 @@ - Changing a reader font with incremental indexing now returns after the current reading position is ready, instead of waiting for the whole chapter to be re-indexed. - Release builds use the pinned PlatformIO core during nested ESP-IDF configuration. - Adding the sleep moon to the last screen no longer flashes white in night mode. +- Waking the reader skips the intermediate loading icon refresh. ## [v1.6.0] - 2026-09-21 diff --git a/src/images/LoadingIcon.h b/src/images/LoadingIcon.h deleted file mode 100644 index c54c857daac..00000000000 --- a/src/images/LoadingIcon.h +++ /dev/null @@ -1,24 +0,0 @@ -#pragma once -#include - -// Image dimensions: 48x48, diagonal dots -#define LOADINGICON_WIDTH 48 -#define LOADINGICON_HEIGHT 48 - -static const uint8_t LoadingIcon[] = { - 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, - 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, - 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xfe, 0x1f, 0xff, 0xff, 0xff, 0xff, 0xfc, 0x0f, 0xff, - 0xff, 0xff, 0xff, 0xf8, 0x07, 0xff, 0xff, 0xff, 0xff, 0xf8, 0x07, 0xff, 0xff, 0xff, 0xff, 0xf8, 0x07, 0xff, - 0xff, 0xff, 0xff, 0xf8, 0x07, 0xff, 0xff, 0xff, 0xff, 0xfc, 0x0f, 0xff, 0xff, 0xff, 0xff, 0xfe, 0x1f, 0xff, - 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, - 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xfe, 0x1f, 0xff, 0xff, 0xff, 0xff, 0xfc, 0x0f, 0xff, - 0xff, 0xff, 0xff, 0xf8, 0x07, 0xff, 0xff, 0xff, 0xff, 0xf8, 0x07, 0xff, 0xff, 0xff, 0xff, 0xf8, 0x07, 0xff, - 0xff, 0xff, 0xff, 0xf8, 0x07, 0xff, 0xff, 0xff, 0xff, 0xfc, 0x0f, 0xff, 0xff, 0xff, 0xff, 0xfe, 0x1f, 0xff, - 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, - 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xfe, 0x1f, 0xff, 0xff, 0xff, 0xff, 0xfc, 0x0f, 0xff, - 0xff, 0xff, 0xff, 0xf8, 0x07, 0xff, 0xff, 0xff, 0xff, 0xf8, 0x07, 0xff, 0xff, 0xff, 0xff, 0xf8, 0x07, 0xff, - 0xff, 0xff, 0xff, 0xf8, 0x07, 0xff, 0xff, 0xff, 0xff, 0xfc, 0x0f, 0xff, 0xff, 0xff, 0xff, 0xfe, 0x1f, 0xff, - 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, - 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, - 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff}; diff --git a/src/main.cpp b/src/main.cpp index 511d38280dc..231560822bf 100644 --- a/src/main.cpp +++ b/src/main.cpp @@ -110,7 +110,6 @@ inline esp_sleep_wakeup_cause_t esp_sleep_get_wakeup_cause() { return ESP_SLEEP_ #include "simulator/SimulatorHomeKeyInput.h" #include "simulator/SimulatorSmokeTest.h" #endif -#include "images/LoadingIcon.h" #include "util/BatteryDiagnosticLog.h" #include "util/ButtonNavigator.h" #include "util/ButtonShortcutController.h" @@ -1404,6 +1403,7 @@ void setup() { const bool shouldRestoreSleepFrame = resume == BootResume::SplashlessWake && (isUc8279X3 ? hasValidSleepFrame : Storage.exists(SLEEP_FRAME_FILE)); bool allowFastInitialReaderRefresh = false; + bool x4WakeFrameAlreadyCleaned = false; setupDisplayAndFonts(SleepWakePolicy::shouldInitializeSeamlessly(resume, isUc8279X3, hasValidSleepFrame), resume != BootResume::Network, useReaderRenderStack); @@ -1425,25 +1425,11 @@ void setup() { APP_STATE.showBootScreen = true; APP_STATE.saveToFile(); if (shouldRestoreSleepFrame && loadSleepFrameBuffer()) { - const bool useDifferentialRefresh = gpio.deviceIsX3(); - if (useDifferentialRefresh) { - // begin() clears the X3 controller RAM, so restore the saved frame as - // the baseline before replacing the moon with the loading icon. + if (gpio.deviceIsX3()) { + // begin() clears the X3 controller RAM. Restore the saved frame as + // the baseline for the first reader paint without refreshing the panel. renderer.cleanupGrayscaleWithFrameBuffer(); - } - - const auto pageHeight = renderer.getScreenHeight(); - renderer.drawImage(LoadingIcon, 0, pageHeight - LOADINGICON_HEIGHT, LOADINGICON_WIDTH, LOADINGICON_HEIGHT); - if (useDifferentialRefresh) { - renderer.displayGrayscaleBase(HalDisplay::FAST_REFRESH); allowFastInitialReaderRefresh = true; - } else { - renderer.displayBuffer(HalDisplay::HALF_REFRESH); - if (shouldClearX4WakeGhosting()) { - // The X4's explicit wake refresh has already cleaned the retained - // frame, so the reader can use its fast initial cycle as well. - allowFastInitialReaderRefresh = true; - } } } else if (isUc8279X3 && hasValidSleepFrame) { // The frame passed the size preflight but could not be read after display @@ -1461,6 +1447,7 @@ void setup() { // baseline, so the reader's first page can use its fast initial cycle // instead of repeating the cleanup waveform. allowFastInitialReaderRefresh = true; + x4WakeFrameAlreadyCleaned = true; } break; case BootResume::Splash: @@ -1542,7 +1529,12 @@ void setup() { mappedInputManager.isPressed(MappedInputManager::Button::Back) || APP_STATE.readerActivityLoadCount > 0) { // Boot to home screen if no book is open, last sleep was not from reader, back button is held, or reader activity // crashed (indicated by readerActivityLoadCount > 0) - activityManager.goHome(); + // On X4, use the first Home paint to clean the retained sleep image. + const auto homeRefreshMode = + resume == BootResume::SplashlessWake && shouldClearX4WakeGhosting() && !x4WakeFrameAlreadyCleaned + ? HalDisplay::HALF_REFRESH + : HalDisplay::FAST_REFRESH; + activityManager.goHome(HomeMenuItem::NONE, homeRefreshMode); } else { // Clear app state to avoid getting into a boot loop if the epub doesn't load const auto path = APP_STATE.openEpubPath; From 7cdaef4733b27913a4b49b715664ab6603d03060 Mon Sep 17 00:00:00 2001 From: Ninos Yomo <32492083+SurayaAtouraya@users.noreply.github.com> Date: Mon, 28 Sep 2026 12:47:29 -0400 Subject: [PATCH 46/62] fix: keep screenshot folder names on complete characters Ported from crosspoint-reader/crosspoint-reader@6743b68353dcc692e87826f5320c5480392f9e5b. Keeps CrossInk filesystem helpers and adds the upstream regression tests. --- CHANGELOG.md | 1 + lib/FsHelpers/FsHelpers.cpp | 3 +++ test/fs_helpers/CMakeLists.txt | 2 ++ test/fs_helpers/FsHelpersTest.cpp | 40 +++++++++++++++++++++++++++++++ 4 files changed, 46 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index d07a58a4854..335a8f3898b 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -42,6 +42,7 @@ - Release builds use the pinned PlatformIO core during nested ESP-IDF configuration. - Adding the sleep moon to the last screen no longer flashes white in night mode. - Waking the reader skips the intermediate loading icon refresh. +- Screenshot folder names keep complete non-English characters when shortened. ## [v1.6.0] - 2026-09-21 diff --git a/lib/FsHelpers/FsHelpers.cpp b/lib/FsHelpers/FsHelpers.cpp index 386a1eeb3da..39b7701c438 100644 --- a/lib/FsHelpers/FsHelpers.cpp +++ b/lib/FsHelpers/FsHelpers.cpp @@ -2,6 +2,7 @@ #include #include +#include #include #include @@ -268,6 +269,8 @@ void sanitizePathComponentForFat32(const char* input, char* output, size_t maxLe output[i] = c; } } + // If the last character was cut in half, drop its leftover bytes. + i = static_cast(utf8SafeTruncateBuffer(output, static_cast(i))); output[i] = '\0'; } diff --git a/test/fs_helpers/CMakeLists.txt b/test/fs_helpers/CMakeLists.txt index 435d76fce29..28e839804d6 100644 --- a/test/fs_helpers/CMakeLists.txt +++ b/test/fs_helpers/CMakeLists.txt @@ -2,11 +2,13 @@ add_executable(FsHelpersTest FsHelpersTest.cpp ${REPO_ROOT}/lib/FsHelpers/FsHelpers.cpp ${REPO_ROOT}/lib/FsHelpers/NaturalSort.cpp + ${REPO_ROOT}/lib/Utf8/Utf8.cpp ) target_include_directories(FsHelpersTest PRIVATE ${CMAKE_CURRENT_SOURCE_DIR}/stubs ${REPO_ROOT}/lib/FsHelpers + ${REPO_ROOT}/lib/Utf8 ) target_link_libraries(FsHelpersTest PRIVATE diff --git a/test/fs_helpers/FsHelpersTest.cpp b/test/fs_helpers/FsHelpersTest.cpp index 82fc0aedd2b..38248aa89d0 100644 --- a/test/fs_helpers/FsHelpersTest.cpp +++ b/test/fs_helpers/FsHelpersTest.cpp @@ -1,5 +1,8 @@ #include +#include +#include + #include "FsHelpers.h" namespace { @@ -22,4 +25,41 @@ TEST(IsSafePathComponent, RejectsEmptyDotAndPathSeparators) { EXPECT_FALSE(FsHelpers::isSafePathComponent("../x"sv)); } +TEST(NormalisePath, CollapsesParentReferenceWithinPath) { + EXPECT_EQ(FsHelpers::normalisePath("/Books/../.crosspoint/x"), ".crosspoint/x"); +} + +TEST(NormalisePath, DropsLeadingParentReferencesPastRoot) { EXPECT_EQ(FsHelpers::normalisePath("/../../etc"), "etc"); } + +// Sanitizes into a buffer of `size` bytes (at most 64, the size ScreenshotUtil uses). +std::string sanitize(const char* input, const size_t size = 64) { + char out[64]; + FsHelpers::sanitizePathComponentForFat32(input, out, size); + return out; +} + +// Book titles from the EPUBs attached to #2103 and #2199. +constexpr char kTitle2103[] = "Богиня глюкозы. Нормализуйте уровень сахара в крови, чтобы изменить свою жизнь"; +constexpr char kTitle2199[] = "Вглядываясь в солнце. Жизнь без страха смерти"; + +TEST(SanitizePathComponentForFat32, KeepsTitleThatFits) { + EXPECT_EQ(sanitize("Эдем (полный перевод)"), "Эдем-(полный-перевод)"); +} + +// The readers copy the title into ScreenshotInfo::title (char[64]) with snprintf, which can +// end the copy partway through a Cyrillic letter. +TEST(SanitizePathComponentForFat32, DropsLetterCutOffByCaller) { + char title[64]; + snprintf(title, sizeof(title), "%s", kTitle2103); + EXPECT_EQ(sanitize(title), "Богиня-глюкозы.-Нормализуйте-уров"); + snprintf(title, sizeof(title), "%s", kTitle2199); + EXPECT_EQ(sanitize(title), "Вглядываясь-в-солнце.-Жизнь-без-ст"); +} + +TEST(SanitizePathComponentForFat32, DoesNotSplitLetterAtBufferLimit) { + // Each letter of "Жизнь" is 2 bytes. 7 bytes of room holds "Жиз" and half of "н". + EXPECT_EQ(sanitize("Жизнь", 8), "Жиз"); + EXPECT_EQ(sanitize(kTitle2103), "Богиня-глюкозы.-Нормализуйте-уров"); +} + } // namespace From e10fb1f7a43f3f97a7ea1a8283e0a824415109f0 Mon Sep 17 00:00:00 2001 From: Uri Tauber Date: Mon, 28 Sep 2026 12:47:50 -0400 Subject: [PATCH 47/62] perf: shorten long status titles with fewer measurements Ported from crosspoint-reader/crosspoint-reader@ef08c3ad19787fecda7b8b4ab1ad1e1d17e10862. --- CHANGELOG.md | 1 + lib/GfxRenderer/GfxRenderer.cpp | 33 ++++++++++++++++++++++++++++++--- 2 files changed, 31 insertions(+), 3 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 335a8f3898b..d7ef058792c 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -24,6 +24,7 @@ - EPUB reader menus now share five tabs across devices. Button devices gain live font and margin previews, Reading Stats, and in-book transfer options. - Brightness and warmth gestures now adjust in one-point steps for finer control. - The on-screen keyboard now uses wider outlined keys with clearer spacing on touch and button devices. +- Long status titles shorten faster when they do not fit the screen. ### Fixed diff --git a/lib/GfxRenderer/GfxRenderer.cpp b/lib/GfxRenderer/GfxRenderer.cpp index 1129df3c534..319464a8de1 100644 --- a/lib/GfxRenderer/GfxRenderer.cpp +++ b/lib/GfxRenderer/GfxRenderer.cpp @@ -2437,11 +2437,38 @@ std::string GfxRenderer::truncatedText(const int fontId, const char* text, const return item; } - while (!item.empty() && getTextWidth(fontId, (item + ellipsis).c_str(), style) >= maxWidth) { - utf8RemoveLastChar(item); + size_t charCount = 0; + for (const unsigned char c : item) { + if ((c & 0xC0) != 0x80) ++charCount; } - return item.empty() ? ellipsis : item + ellipsis; + std::string candidate; + candidate.reserve(item.size() + 3); + const auto setCandidate = [&](const size_t characterCount) { + size_t end = 0; + for (size_t count = 0; end < item.size() && count < characterCount; ++count) { + ++end; + while (end < item.size() && (static_cast(item[end]) & 0xC0) == 0x80) ++end; + } + candidate.assign(item.data(), end); + candidate += ellipsis; + }; + + // Binary search avoids repeatedly measuring nearly the entire long title. + size_t low = 0; + size_t high = charCount; + while (low < high) { + const size_t mid = low + (high - low + 1) / 2; + setCandidate(mid); + if (getTextWidth(fontId, candidate.c_str(), style) < maxWidth) { + low = mid; + } else { + high = mid - 1; + } + } + + setCandidate(low); + return candidate; } std::vector GfxRenderer::wrappedText(const int fontId, const char* text, const int maxWidth, From 74399e918821489154304dc112604e9cd1a6e644 Mon Sep 17 00:00:00 2001 From: Leopoldo Pla Sempere Date: Mon, 28 Sep 2026 12:48:17 -0400 Subject: [PATCH 48/62] fix: wrap long update completion instructions Ported from crosspoint-reader/crosspoint-reader@0914aa1abad4631d76878944743e8b487e18c066. Uses CrossInk's existing centered text wrapper. --- CHANGELOG.md | 1 + src/activities/settings/OtaUpdateActivity.cpp | 5 ++++- 2 files changed, 5 insertions(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index d7ef058792c..e66d795eef1 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -44,6 +44,7 @@ - Adding the sleep moon to the last screen no longer flashes white in night mode. - Waking the reader skips the intermediate loading icon refresh. - Screenshot folder names keep complete non-English characters when shortened. +- Longer power-on instructions wrap on the finished update screen. ## [v1.6.0] - 2026-09-21 diff --git a/src/activities/settings/OtaUpdateActivity.cpp b/src/activities/settings/OtaUpdateActivity.cpp index b65c3a8c593..23073411c07 100644 --- a/src/activities/settings/OtaUpdateActivity.cpp +++ b/src/activities/settings/OtaUpdateActivity.cpp @@ -190,7 +190,10 @@ void OtaUpdateActivity::render(RenderLock&&) { GUI.drawButtonHints(renderer, labels.btn1, labels.btn2, labels.btn3, labels.btn4); } else if (state == FINISHED) { renderer.drawCenteredText(UI_10_FONT_ID, top, tr(STR_UPDATE_COMPLETE), true, EpdFontFamily::BOLD); - renderer.drawCenteredText(UI_10_FONT_ID, top + height + metrics.verticalSpacing, tr(STR_POWER_ON_HINT)); + const int hintY = top + height + metrics.verticalSpacing; + const Rect hintBounds{metrics.contentSidePadding, hintY, pageWidth - metrics.contentSidePadding * 2, + pageHeight - hintY}; + UITheme::drawCenteredWrappedText(renderer, hintBounds, UI_10_FONT_ID, hintY, tr(STR_POWER_ON_HINT), 3); } renderer.displayBuffer(screenTransitionRefresh.modeFor(static_cast(state))); From 2ecd13e2238e892555e53068a2bfc2fde26311e2 Mon Sep 17 00:00:00 2001 From: Uri Tauber Date: Mon, 28 Sep 2026 12:48:45 -0400 Subject: [PATCH 49/62] fix: restore periodic memory logs on Sticky Ported from crosspoint-reader/crosspoint-reader@3f70f5dfc6254a6d5e308218841fe21106a9aa11. CrossInk already logs internal heap and PSRAM together; only the Serial gate needed changing. --- CHANGELOG.md | 1 + src/main.cpp | 3 ++- 2 files changed, 3 insertions(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index e66d795eef1..5def697bd06 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -45,6 +45,7 @@ - Waking the reader skips the intermediate loading icon refresh. - Screenshot folder names keep complete non-English characters when shortened. - Longer power-on instructions wrap on the finished update screen. +- Sticky now records periodic heap and PSRAM statistics over its ROM logging path. ## [v1.6.0] - 2026-09-21 diff --git a/src/main.cpp b/src/main.cpp index 231560822bf..42a653bf2f8 100644 --- a/src/main.cpp +++ b/src/main.cpp @@ -1600,7 +1600,8 @@ void loop() { renderer.setFadingFix(SETTINGS.fadingFix); - if (Serial && millis() - lastMemPrint >= 10000) { + // ROM logging works even when Arduino Serial reports no connected listener. + if ((Serial || FREEINK_LOG_TRANSPORT == FREEINK_LOG_TRANSPORT_ROM_PRINTF) && millis() - lastMemPrint >= 10000) { logMemoryStats("Periodic"); lastMemPrint = millis(); } From 821eedc81f88bf9c7504db90c61dff33405cbb98 Mon Sep 17 00:00:00 2001 From: Uri Tauber Date: Mon, 28 Sep 2026 12:49:25 -0400 Subject: [PATCH 50/62] fix: follow reading direction for RTL EPUB touch turns Ported from crosspoint-reader/crosspoint-reader@637ad4a5e8b01d60adfdbbaa175c709697a4ece6. Inverted Tap remains an explicit reversal, and TXT/XTC keep their current directions. --- CHANGELOG.md | 1 + src/activities/reader/EpubReaderActivity.cpp | 3 ++- src/activities/reader/ReaderUtils.h | 26 +++++++++++++++----- 3 files changed, 23 insertions(+), 7 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 5def697bd06..56c94700d49 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -46,6 +46,7 @@ - Screenshot folder names keep complete non-English characters when shortened. - Longer power-on instructions wrap on the finished update screen. - Sticky now records periodic heap and PSRAM statistics over its ROM logging path. +- RTL EPUBs use reading-order swipe and tap directions. ## [v1.6.0] - 2026-09-21 diff --git a/src/activities/reader/EpubReaderActivity.cpp b/src/activities/reader/EpubReaderActivity.cpp index a6107207791..1d8a858d732 100644 --- a/src/activities/reader/EpubReaderActivity.cpp +++ b/src/activities/reader/EpubReaderActivity.cpp @@ -2826,7 +2826,8 @@ void EpubReaderActivity::loop() { } #endif - const auto touch = ReaderUtils::detectTouchPageTurn(renderer, mappedInput); + const auto touch = ReaderUtils::detectTouchPageTurn(renderer, mappedInput, + epub && ReaderUtils::isRtlBookLanguage(epub->getLanguage())); const int bottomTapHeight = automaticPageTurnActive ? std::max(UITheme::getStatusBarHeight(), diff --git a/src/activities/reader/ReaderUtils.h b/src/activities/reader/ReaderUtils.h index 11b669d8890..67ac7a5d456 100644 --- a/src/activities/reader/ReaderUtils.h +++ b/src/activities/reader/ReaderUtils.h @@ -7,6 +7,8 @@ #include #include +#include +#include #include "GlobalActions.h" #include "MappedInputManager.h" @@ -24,6 +26,14 @@ constexpr uint8_t STATUS_BAR_TEXT_PADDING = 3; // Gap between the top reader bar and the first line of book text. constexpr int8_t TOP_STATUS_BAR_TEXT_PADDING = 0; +inline bool isRtlBookLanguage(std::string_view tag) { + if (tag.size() < 2 || (tag.size() > 2 && tag[2] != '-' && tag[2] != '_')) return false; + const auto first = std::tolower(static_cast(tag[0])); + const auto second = std::tolower(static_cast(tag[1])); + return (first == 'h' && second == 'e') || (first == 'i' && second == 'w') || (first == 'a' && second == 'r') || + (first == 'f' && second == 'a'); +} + inline GfxRenderer::Orientation toRendererOrientation(const uint8_t orientation) { switch (orientation) { case CrossPointSettings::ORIENTATION::PORTRAIT: @@ -108,10 +118,12 @@ struct TouchPageTurn { unsigned long heldMs; }; -inline TouchPageTurn detectTouchPageTurn(const GfxRenderer& renderer, const MappedInputManager& input) { +inline TouchPageTurn detectTouchPageTurn(const GfxRenderer& renderer, const MappedInputManager& input, + const bool rtlBook = false) { #if !CROSSINK_APP_CAP_TOUCH (void)renderer; (void)input; + (void)rtlBook; return {false, false, false, 0, 0, 0}; #else TouchPageTurn result{false, false, false, 0, 0, 0}; @@ -134,8 +146,10 @@ inline TouchPageTurn detectTouchPageTurn(const GfxRenderer& renderer, const Mapp const auto swipe = input.wasSwipe(); if (swipe != MappedInputManager::SwipeDir::None) { - result.prev = swipe == MappedInputManager::SwipeDir::Right && allowsSwipe(SETTINGS.previousPageGesture); - result.next = swipe == MappedInputManager::SwipeDir::Left && allowsSwipe(SETTINGS.pageTurnGesture); + result.prev = swipe == (rtlBook ? MappedInputManager::SwipeDir::Left : MappedInputManager::SwipeDir::Right) && + allowsSwipe(SETTINGS.previousPageGesture); + result.next = swipe == (rtlBook ? MappedInputManager::SwipeDir::Right : MappedInputManager::SwipeDir::Left) && + allowsSwipe(SETTINGS.pageTurnGesture); return result; } @@ -155,11 +169,11 @@ inline TouchPageTurn detectTouchPageTurn(const GfxRenderer& renderer, const Mapp } // Give the entire page tap area to the sole tap-enabled direction. When - // both accept taps, either Inverted Tap setting swaps their shared zones. + // both accept taps, RTL books and Inverted Tap each swap their shared zones. const bool nextTaps = allowsTap(SETTINGS.pageTurnGesture); const bool previousTaps = allowsTap(SETTINGS.previousPageGesture); - const bool invertedTaps = SETTINGS.pageTurnGesture == CrossPointSettings::INVERTED_TAP || - SETTINGS.previousPageGesture == CrossPointSettings::INVERTED_TAP; + const bool invertedTaps = (SETTINGS.pageTurnGesture == CrossPointSettings::INVERTED_TAP || + SETTINGS.previousPageGesture == CrossPointSettings::INVERTED_TAP) != rtlBook; const bool nextZone = invertedTaps ? x < (width * 2) / 3 : x >= width / 3; result.next = nextTaps && (!previousTaps || nextZone); result.prev = previousTaps && (!nextTaps || !nextZone); From bc1ef410cfeaded280746d5a796906a6e5d7380e Mon Sep 17 00:00:00 2001 From: Sung-jin Brian Hong Date: Mon, 28 Sep 2026 12:50:17 -0400 Subject: [PATCH 51/62] fix: free rebuildable font caches after reading Ported from crosspoint-reader/crosspoint-reader@1d61100f90d2e7e32965c14301320d9989a7ab71. CrossInk transfers from a short-lived ReaderActivity into separate EPUB/TXT activities, so their exits own the cleanup. Co-authored-by: Julia Nguyen --- CHANGELOG.md | 1 + src/activities/reader/EpubReaderActivity.cpp | 8 +++++--- src/activities/reader/TxtReaderActivity.cpp | 3 +++ 3 files changed, 9 insertions(+), 3 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 56c94700d49..c75d1511bf8 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -25,6 +25,7 @@ - Brightness and warmth gestures now adjust in one-point steps for finer control. - The on-screen keyboard now uses wider outlined keys with clearer spacing on touch and button devices. - Long status titles shorten faster when they do not fit the screen. +- Leaving an EPUB or TXT reader releases rebuildable font buffers for other screens. ### Fixed diff --git a/src/activities/reader/EpubReaderActivity.cpp b/src/activities/reader/EpubReaderActivity.cpp index 1d8a858d732..f87f04c25f3 100644 --- a/src/activities/reader/EpubReaderActivity.cpp +++ b/src/activities/reader/EpubReaderActivity.cpp @@ -2449,9 +2449,11 @@ void EpubReaderActivity::onExit() { releaseGrayscaleStripScratch(true); ImageBlock::releaseSessionPixelCache(); - // SD-font caches live in the renderer singleton, so leaving them resident after - // the reader exits can fragment the contiguous heap needed for Home cover images. - releaseReaderSdFontCachesForLowMemory(renderer, "ERS", "reader exit"); + // SD-font caches live in the renderer singleton and can fragment the Home heap. + // Release every rebuildable cache, including fonts used before the current one. + if (auto* fontCache = renderer.getFontCacheManager()) { + fontCache->releaseSdFontCaches(); + } MemoryBudget::logEpubHeapPools("reader exit after caches"); Activity::onExit(); diff --git a/src/activities/reader/TxtReaderActivity.cpp b/src/activities/reader/TxtReaderActivity.cpp index 851ba5c1b2b..967c2de08a4 100644 --- a/src/activities/reader/TxtReaderActivity.cpp +++ b/src/activities/reader/TxtReaderActivity.cpp @@ -149,6 +149,9 @@ void TxtReaderActivity::onEnter() { void TxtReaderActivity::onExit() { mappedInput.setReaderTouchscreenOverride(false); Activity::onExit(); + if (auto* fontCache = renderer.getFontCacheManager()) { + fontCache->releaseSdFontCaches(); + } // Deactivate reader-specific front button mapping. mappedInput.setReaderMode(false); From 6cb7e7573757713bb783d206e825beb3fab5e9d1 Mon Sep 17 00:00:00 2001 From: Sung-jin Brian Hong Date: Mon, 28 Sep 2026 12:53:41 -0400 Subject: [PATCH 52/62] fix: keep Korean word spacing natural when justified Ported from crosspoint-reader/crosspoint-reader@794ac97f4. Adapted to CrossInk layout APIs and bumped both full and suspended section cache versions (79 and 0xF4). Eight focused Korean layout tests pass. Co-authored-by: Julia Nguyen --- CHANGELOG.md | 1 + docs/file-formats.md | 15 ++- docs/user-guide.md | 2 +- lib/Epub/Epub/ParsedText.cpp | 33 ++++- lib/Epub/Epub/Section.cpp | 5 +- lib/Utf8/Utf8.h | 17 ++- test/CMakeLists.txt | 1 + test/korean_line_breaking/CMakeLists.txt | 33 +++++ .../KoreanLineBreakingTest.cpp | 123 ++++++++++++++++++ test/korean_line_breaking/stubs/GfxRenderer.h | 49 +++++++ 10 files changed, 269 insertions(+), 10 deletions(-) create mode 100644 test/korean_line_breaking/CMakeLists.txt create mode 100644 test/korean_line_breaking/KoreanLineBreakingTest.cpp create mode 100644 test/korean_line_breaking/stubs/GfxRenderer.h diff --git a/CHANGELOG.md b/CHANGELOG.md index c75d1511bf8..504e6aac749 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -48,6 +48,7 @@ - Longer power-on instructions wrap on the finished update screen. - Sticky now records periodic heap and PSRAM statistics over its ROM logging path. - RTL EPUBs use reading-order swipe and tap directions. +- Korean text keeps natural syllable spacing when justified and wraps by word. ## [v1.6.0] - 2026-09-21 diff --git a/docs/file-formats.md b/docs/file-formats.md index 38562b53821..5611e2d9ab2 100644 --- a/docs/file-formats.md +++ b/docs/file-formats.md @@ -447,6 +447,19 @@ Binary layout: ## `section.bin` +### Version 79 + +Version 79 keeps the version 78 serialized layout. Korean words now wrap at +spaces by default; with hyphenation enabled, they can also split at a legal +CJK boundary at a line end without a visible hyphen. Justification stretches +word spaces only. Full caches (byte `79`) and suspended partial caches +(`0xF4`) both rebuild because earlier page positions are no longer valid. + +### Version 78 + +Version 78 changed layout for inline CSS padding. Full and suspended partial +section caches rebuild together. + ### Version 77 Version 77 keeps the serialized layout unchanged. It was bumped because ordered @@ -570,7 +583,7 @@ import std.mem; import std.string; import std.core; -#define EXPECTED_VERSION 77 +#define EXPECTED_VERSION 79 #define MAX_STRING_LENGTH 65535 #define FOOTNOTE_NUMBER_LEN 32 #define FOOTNOTE_HREF_LEN 96 diff --git a/docs/user-guide.md b/docs/user-guide.md index 53d3ac3ab0d..4df48066fa6 100644 --- a/docs/user-guide.md +++ b/docs/user-guide.md @@ -291,7 +291,7 @@ Book Cache** does not reset these choices. Note: This reserves 5px of left margin to your screen to provide space for the page numbers. This is only noticeable if your `Left/Right` margins are set to `5`. If the page has no publisher page number, your margins may appear uneven. -- **Hyphenation**: Whether to hyphenate text in Reading Mode; options are "ON" or "OFF". +- **Hyphenation**: Whether to hyphenate text in Reading Mode; options are "ON" or "OFF". Korean text wraps at spaces when "OFF"; when "ON", a Korean word may also split at a line end without drawing a hyphen. - **Reading Orientation**: Set the screen orientation for reading EPUB files: - "Portrait" (default) - Standard portrait orientation diff --git a/lib/Epub/Epub/ParsedText.cpp b/lib/Epub/Epub/ParsedText.cpp index 2fc70895cb6..e09b5fee6fb 100644 --- a/lib/Epub/Epub/ParsedText.cpp +++ b/lib/Epub/Epub/ParsedText.cpp @@ -161,13 +161,38 @@ uint32_t countCodepoints(const std::string_view text) { return count; } -bool hasCjkBreakOpportunityBetween(const uint32_t leftCp, const uint32_t rightCp) { +bool cjkBoundaryAllowsBreak(const uint32_t leftCp, const uint32_t rightCp) { if (!utf8IsCjkBreakable(leftCp) && !utf8IsCjkBreakable(rightCp)) return false; if (isNoBreakAfterCjkPunctuation(leftCp) || isNoBreakBeforeCjkPunctuation(rightCp)) return false; if (utf8IsCombiningMark(rightCp) || utf8IsVariationSelector(rightCp)) return false; return true; } +// Korean separates words with spaces, so a boundary touching Hangul is not a gap-less break inside +// a line. hangulLineEndBreaks() still lets a Hangul word split there at a line end. +bool hasCjkBreakOpportunityBetween(const uint32_t leftCp, const uint32_t rightCp) { + if (utf8IsHangul(leftCp) || utf8IsHangul(rightCp)) return false; + return cjkBoundaryAllowsBreak(leftCp, rightCp); +} + +// Line-end split points inside a Hangul word, using the CJK boundary rules (no hyphen is drawn). +std::vector hangulLineEndBreaks(const std::string& word) { + std::vector breaks; + if (word.empty()) return breaks; + const auto* const start = reinterpret_cast(word.c_str()); + const auto* ptr = start; + uint32_t prev = utf8NextCodepoint(&ptr); + while (*ptr) { + const size_t offset = static_cast(ptr - start); + const uint32_t cur = utf8NextCodepoint(&ptr); + if ((utf8IsHangul(prev) || utf8IsHangul(cur)) && cjkBoundaryAllowsBreak(prev, cur)) { + breaks.push_back({offset, false}); + } + prev = cur; + } + return breaks; +} + std::vector cjkCharacterBreakByteOffsets(const std::string& text) { struct CodepointBoundary { uint32_t cp; @@ -1356,8 +1381,10 @@ bool ParsedText::hyphenateWordAtIndex(const size_t wordIndex, const int availabl return splitTokenAtCodepointBoundary(wordIndex, availableWidth, renderer, fontId, wordWidths); } - // Collect candidate breakpoints (byte offsets and hyphen requirements). - auto breakInfos = Hyphenator::breakOffsets(word, allowFallbackBreaks); + // Hangul splits at legal CJK boundaries only at a line end when hyphenation is on. + auto breakInfos = hangulLineEndBreaks(word); + const auto hyphenBreaks = Hyphenator::breakOffsets(word, allowFallbackBreaks); + breakInfos.insert(breakInfos.end(), hyphenBreaks.begin(), hyphenBreaks.end()); if (breakInfos.empty()) { if (allowFallbackBreaks && allowCharacterBreaks_) { return splitTokenAtCodepointBoundary(wordIndex, availableWidth, renderer, fontId, wordWidths); diff --git a/lib/Epub/Epub/Section.cpp b/lib/Epub/Epub/Section.cpp index 027970d8f0e..f808349dca8 100644 --- a/lib/Epub/Epub/Section.cpp +++ b/lib/Epub/Epub/Section.cpp @@ -28,11 +28,12 @@ constexpr uint32_t SECTION_CACHE_MAGIC = 0x535843FF; // bytes: 0xFF, "CXS" // v76: Paragraphs without source CSS indentation no longer receive a synthetic indent. // v77: Ordered lists, marker suppression, and list-container insets affect page layout. // v78: Inline CSS padding affects dialogue and other styled text positions. -constexpr uint8_t SECTION_FILE_VERSION = 78; +// v79: Hangul word boundaries and line-end splits change cached page positions. +constexpr uint8_t SECTION_FILE_VERSION = 79; // Suspended incremental build: valid pages plus LUTs and a parse-watermark trailer. // Change this with layout or payload changes so stale partial pages cannot resume // under a different layout contract. -constexpr uint8_t SECTION_FILE_PARTIAL_VERSION = 0xF3; +constexpr uint8_t SECTION_FILE_PARTIAL_VERSION = 0xF4; constexpr uint32_t HEADER_SIZE = sizeof(SECTION_CACHE_MAGIC) + sizeof(uint8_t) + sizeof(int) + sizeof(float) + sizeof(bool) + sizeof(bool) + sizeof(uint8_t) + sizeof(uint16_t) + sizeof(uint16_t) + sizeof(bool) + sizeof(bool) + sizeof(uint8_t) + diff --git a/lib/Utf8/Utf8.h b/lib/Utf8/Utf8.h index 2575367ff79..0e1f4787f39 100644 --- a/lib/Utf8/Utf8.h +++ b/lib/Utf8/Utf8.h @@ -45,9 +45,10 @@ std::string utf8CleanLookupWord(const std::string& text); // incomplete trailing bytes are excluded. int utf8SafeTruncateBuffer(const char* buf, int len); -// Returns true for CJK characters that allow line breaks on either side without hyphenation. -// Covers CJK Unified Ideographs, Hiragana, Katakana, Hangul Syllables, CJK punctuation, -// and fullwidth forms — the ranges where word boundaries are implicit per character. +// Returns true for CJK characters that break without hyphenation. Covers CJK Unified Ideographs, +// Hiragana, Katakana, Hangul, CJK punctuation, and fullwidth forms. Han and Kana have implicit +// word boundaries per character; Hangul is space-delimited, so layout keeps Hangul words whole +// (see utf8IsHangul) and splits one without a hyphen only through the hyphenation path. inline bool utf8IsCjkBreakable(const uint32_t cp) { return (cp >= 0x1100 && cp <= 0x11FF) // Hangul Jamo || (cp >= 0x3000 && cp <= 0x303F) // CJK Symbols and Punctuation @@ -66,6 +67,16 @@ inline bool utf8IsCjkBreakable(const uint32_t cp) { || (cp >= 0x2A700 && cp <= 0x2B73F); // CJK Extension C } +// Returns true for Hangul letters. Korean separates words with spaces, so a boundary touching +// Hangul is never an implicit line-break opportunity (CSS word-break: keep-all). +inline bool utf8IsHangul(const uint32_t cp) { + return (cp >= 0x1100 && cp <= 0x11FF) // Hangul Jamo + || (cp >= 0x3130 && cp <= 0x318F) // Hangul Compatibility Jamo + || (cp >= 0xA960 && cp <= 0xA97F) // Hangul Jamo Extended-A + || (cp >= 0xAC00 && cp <= 0xD7FF) // Hangul Syllables, Hangul Jamo Extended-B + || (cp >= 0xFFA0 && cp <= 0xFFDC); // Halfwidth Hangul +} + // Returns true for any codepoint in a CJK script block (Han, Kana, Hangul, Bopomofo, // radicals, and CJK punctuation/compatibility/enclosed forms). Used for fallback font // selection — deliberately broader than utf8IsCjkBreakable, whose ranges are tuned to diff --git a/test/CMakeLists.txt b/test/CMakeLists.txt index 195cde10f77..839d7ef6f31 100644 --- a/test/CMakeLists.txt +++ b/test/CMakeLists.txt @@ -77,6 +77,7 @@ add_subdirectory(touch_reader_preview) add_subdirectory(dictionary_word_parts) add_subdirectory(ligature_guard) add_subdirectory(chapter_html_slim_parser) +add_subdirectory(korean_line_breaking) add_subdirectory(input_release_guard) add_subdirectory(release_suppression) add_subdirectory(bitmap_resample) diff --git a/test/korean_line_breaking/CMakeLists.txt b/test/korean_line_breaking/CMakeLists.txt new file mode 100644 index 00000000000..616710a4a6e --- /dev/null +++ b/test/korean_line_breaking/CMakeLists.txt @@ -0,0 +1,33 @@ +add_executable(KoreanLineBreakingTest + KoreanLineBreakingTest.cpp + ${REPO_ROOT}/lib/Epub/Epub/ParsedText.cpp + ${REPO_ROOT}/lib/Epub/Epub/blocks/TextBlock.cpp + ${REPO_ROOT}/lib/Epub/Epub/hyphenation/Hyphenator.cpp + ${REPO_ROOT}/lib/Epub/Epub/hyphenation/LanguageRegistry.cpp + ${REPO_ROOT}/lib/Epub/Epub/hyphenation/LiangHyphenation.cpp + ${REPO_ROOT}/lib/Epub/Epub/hyphenation/HyphenationCommon.cpp + ${REPO_ROOT}/lib/MiniBidi/BidiUtils.cpp + ${REPO_ROOT}/lib/MiniBidi/minibidi.c + ${REPO_ROOT}/lib/Utf8/Utf8.cpp +) + +target_include_directories(KoreanLineBreakingTest PRIVATE + ${CMAKE_CURRENT_SOURCE_DIR}/stubs + ${REPO_ROOT}/test/memory_policy/stubs + ${REPO_ROOT}/lib + ${REPO_ROOT}/lib/Epub + ${REPO_ROOT}/lib/Epub/Epub + ${REPO_ROOT}/lib/EpdFont + ${REPO_ROOT}/lib/Utf8 + ${REPO_ROOT}/lib/Memory + ${REPO_ROOT}/lib/Serialization + ${REPO_ROOT}/lib/MiniBidi + ${REPO_ROOT}/lib/FsHelpers +) + +target_link_libraries(KoreanLineBreakingTest PRIVATE + crosspoint_test_common + GTest::gtest_main +) + +gtest_discover_tests(KoreanLineBreakingTest) diff --git a/test/korean_line_breaking/KoreanLineBreakingTest.cpp b/test/korean_line_breaking/KoreanLineBreakingTest.cpp new file mode 100644 index 00000000000..b9e1c5e4020 --- /dev/null +++ b/test/korean_line_breaking/KoreanLineBreakingTest.cpp @@ -0,0 +1,123 @@ +#include +#include +#include +#include +#include + +#include +#include +#include + +namespace { + +struct Line { + std::vector words; + std::vector xpos; +}; + +// Fixture metrics (stub renderer): every glyph is 8 px wide and a space is 4 px. +std::vector layout(const std::vector& words, const bool hyphenation, const uint16_t width) { + GfxRenderer renderer; + BlockStyle style; + style.alignment = CssTextAlign::Justify; + style.textIndentDefined = true; + ParsedText text(false, false, hyphenation, false, false, 0, style); + for (const char* word : words) text.addWord(word, EpdFontFamily::REGULAR); + std::vector lines; + EXPECT_TRUE(text.layoutAndExtractLines(renderer, 0, width, [&](std::shared_ptr block, uint32_t, uint32_t) { + auto& line = lines.emplace_back(); + for (uint16_t i = 0; i < block->wordCount(); ++i) { + line.words.emplace_back(block->wordText(i)); + line.xpos.push_back(block->wordXpos(i)); + } + })); + return lines; +} + +std::vector> wordsOf(const std::vector& lines) { + std::vector> result; + result.reserve(lines.size()); + for (const auto& line : lines) result.push_back(line.words); + return result; +} + +} // namespace + +TEST(KoreanLineBreaking, HyphenationOffWrapsAtSpacesOnly) { + Hyphenator::setPreferredLanguage("ko"); + // 가나 다라마바 needs 52 px; with hyphenation off the Hangul word moves down whole. + const auto lines = layout({"가나", "다라마바", "사"}, false, 50); + const std::vector> expected{{"가나"}, {"다라마바", "사"}}; + EXPECT_EQ(wordsOf(lines), expected); +} + +TEST(KoreanLineBreaking, HyphenationOnSplitsBetweenSyllablesWithoutHyphen) { + Hyphenator::setPreferredLanguage("ko"); + // 가나 + space leaves 30 px: the widest syllable prefix that fits is 다라마 (24 px). + const auto lines = layout({"가나", "다라마바사아", "자"}, true, 50); + const std::vector> expected{{"가나", "다라마"}, {"바사아", "자"}}; + ASSERT_EQ(wordsOf(lines), expected); + // 16 + 4 + 24 = 44 px leaves 6 px, all of it on the one word space. + EXPECT_EQ(lines[0].xpos, (std::vector{0, 26})); +} + +TEST(KoreanLineBreaking, HyphenationOnKeepsTrailingPunctuationWithSyllable) { + Hyphenator::setPreferredLanguage("ko"); + // 가나다 + space leaves 36 px. 라마바사 (32 px) would fit, but 사. stays together, so 라마바 is used. + const auto lines = layout({"가나다", "라마바사.", "자"}, true, 64); + const std::vector> expected{{"가나다", "라마바"}, {"사.", "자"}}; + EXPECT_EQ(wordsOf(lines), expected); +} + +TEST(KoreanLineBreaking, HyphenationOnSplitsShortWords) { + Hyphenator::setPreferredLanguage("ko"); + // 가나다 + space leaves 22 px, so the three-syllable word splits after 라마 (16 px). + const auto lines = layout({"가나다", "라마바", "자"}, true, 50); + const std::vector> expected{{"가나다", "라마"}, {"바", "자"}}; + EXPECT_EQ(wordsOf(lines), expected); +} + +TEST(KoreanLineBreaking, HyphenationOnSplitsWhereHangulMeetsOtherScriptsOrBrackets) { + Hyphenator::setPreferredLanguage("ko"); + // 가 + space leaves 52 px. 소신(ab) (48 px) fits; the split may fall before "(" or after ")", + // never inside the brackets. + const auto lines = layout({"가", "소신(ab)이"}, true, 64); + const std::vector> expected{{"가", "소신(ab)"}, {"이"}}; + EXPECT_EQ(wordsOf(lines), expected); +} + +TEST(KoreanLineBreaking, HyphenationOnSplitsBetweenDigitAndHangul) { + Hyphenator::setPreferredLanguage("ko"); + // 가나다 + space leaves 22 px: 12 (16 px) fits, 12월 (24 px) does not. + const auto lines = layout({"가나다", "12월부터", "자"}, true, 50); + const std::vector> expected{{"가나다", "12"}, {"월부터", "자"}}; + EXPECT_EQ(wordsOf(lines), expected); +} + +TEST(KoreanLineBreaking, JustifiedWordsKeepNaturalSyllableSpacing) { + Hyphenator::setPreferredLanguage("ko"); + const auto lines = layout({"가나", "다라", "마바", "사아"}, false, 60); + const std::vector> expected{{"가나", "다라", "마바"}, {"사아"}}; + EXPECT_EQ(wordsOf(lines), expected); + ASSERT_FALSE(lines.empty()); + EXPECT_EQ(lines[0].xpos, (std::vector{0, 22, 44})); +} + +TEST(KoreanLineBreaking, HangulGluedAcrossInlineStyleStaysTogether) { + Hyphenator::setPreferredLanguage("ko"); + GfxRenderer renderer; + BlockStyle style; + style.alignment = CssTextAlign::Left; + style.textIndentDefined = true; + ParsedText text(false, false, false, false, false, 0, style); + text.addWord("가나", EpdFontFamily::REGULAR); + text.addWord("한국", EpdFontFamily::REGULAR); + text.addWord("어", EpdFontFamily::BOLD, false, /*attachToPrevious=*/true); + std::vector> lines; + ASSERT_TRUE(text.layoutAndExtractLines(renderer, 0, 40, [&](std::shared_ptr block, uint32_t, uint32_t) { + auto& words = lines.emplace_back(); + for (uint16_t i = 0; i < block->wordCount(); ++i) words.emplace_back(block->wordText(i)); + })); + const std::vector> expected{{"가나"}, {"한국", "어"}}; + EXPECT_EQ(lines, expected); +} diff --git a/test/korean_line_breaking/stubs/GfxRenderer.h b/test/korean_line_breaking/stubs/GfxRenderer.h new file mode 100644 index 00000000000..b6447e3b735 --- /dev/null +++ b/test/korean_line_breaking/stubs/GfxRenderer.h @@ -0,0 +1,49 @@ +#pragma once + +#include + +#include +#include +#include + +namespace BidiUtils { +enum class BidiBaseDir : signed char { AUTO = -1, LTR = 0, RTL = 1 }; +} + +class GfxRenderer { + public: + int getFontAscenderSize(int id) const { return 12 + id; } + int getLineHeight(int id) const { return 16 + id; } + static int characters(const char* text) { + int n = 0; + for (; *text; ++text) + if ((static_cast(*text) & 0xC0) != 0x80) ++n; + return n; + } + int getTextWidth(int, const char* text, EpdFontFamily::Style = EpdFontFamily::REGULAR, + BidiUtils::BidiBaseDir = BidiUtils::BidiBaseDir::AUTO) const { + return characters(text) * 8; + } + int getTextAdvanceX(int id, const char* text, EpdFontFamily::Style style, uint32_t = 0) const { + return getTextWidth(id, text, style); + } + int getSpaceWidth(int, EpdFontFamily::Style) const { return 4; } + int getKerning(int, uint32_t, uint32_t, EpdFontFamily::Style) const { return 0; } + int getSpaceAdvance(int, uint32_t, uint32_t, EpdFontFamily::Style) const { return 4; } + bool isFontCacheScanning() const { return false; } + template + void drawLine(Args...) const {} + template + void drawText(Args...) const {} + template + void fillRect(Args...) const {} + bool isSdCardFont(int) const { return false; } + bool releaseSdCardFontForLowMemory(int, bool = false) { return false; } + bool ensureSdCardFontReady(int, const uint32_t*, size_t, bool, bool, uint8_t) const { return true; } + bool ensureSdCardFontReady(int, const std::deque&, bool, uint8_t) const { return true; } + bool ensureSdCardFontReady(int, const char*, uint8_t) const { return true; } + std::vector wrappedText(int, const char*, int, int, + EpdFontFamily::Style = EpdFontFamily::REGULAR) const { + return {}; + } +}; From 169c1e275975581dc8ed5f410aa1a0c73d097381 Mon Sep 17 00:00:00 2001 From: Uri Tauber Date: Mon, 28 Sep 2026 13:04:38 -0400 Subject: [PATCH 53/62] feat: select footnotes on the reading page Adapted from crosspoint-reader/crosspoint-reader@b88b653a7. CrossInk stores link IDs in page words instead of PageLink rectangles, so this port shares the existing touch geometry. The old list remains available for pages without complete link targets. Co-authored-by: Julia Nguyen --- CHANGELOG.md | 1 + src/activities/reader/EpubReaderActivity.cpp | 169 +++++++-------- src/activities/reader/EpubReaderActivity.h | 10 +- .../EpubReaderFootnoteSelectActivity.cpp | 192 ++++++++++++++++++ .../reader/EpubReaderFootnoteSelectActivity.h | 58 ++++++ src/activities/reader/FootnoteLinkTargets.cpp | 61 ++++++ src/activities/reader/FootnoteLinkTargets.h | 23 +++ 7 files changed, 424 insertions(+), 90 deletions(-) create mode 100644 src/activities/reader/EpubReaderFootnoteSelectActivity.cpp create mode 100644 src/activities/reader/EpubReaderFootnoteSelectActivity.h create mode 100644 src/activities/reader/FootnoteLinkTargets.cpp create mode 100644 src/activities/reader/FootnoteLinkTargets.h diff --git a/CHANGELOG.md b/CHANGELOG.md index 504e6aac749..cee478bf7e4 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -49,6 +49,7 @@ - Sticky now records periodic heap and PSRAM statistics over its ROM logging path. - RTL EPUBs use reading-order swipe and tap directions. - Korean text keeps natural syllable spacing when justified and wraps by word. +- Footnote choices can be selected directly on the reading page, with a list fallback for links without a visible target. ## [v1.6.0] - 2026-09-21 diff --git a/src/activities/reader/EpubReaderActivity.cpp b/src/activities/reader/EpubReaderActivity.cpp index f87f04c25f3..cbd606978b3 100644 --- a/src/activities/reader/EpubReaderActivity.cpp +++ b/src/activities/reader/EpubReaderActivity.cpp @@ -39,6 +39,7 @@ #include "EpubReaderChapterSelectionActivity.h" #include "EpubReaderClippingListActivity.h" #include "EpubReaderDrawerActivity.h" +#include "EpubReaderFootnoteSelectActivity.h" #include "EpubReaderFootnotesActivity.h" #include "EpubReaderPercentSelectionActivity.h" #include "EpubReaderUtils.h" @@ -3840,18 +3841,7 @@ void EpubReaderActivity::onReaderMenuConfirm(EpubReaderMenuAction action, const break; } case EpubReaderMenuAction::FOOTNOTES: { - pauseReadingPaceTimer("footnotes"); - startActivityForResult(std::make_unique(renderer, mappedInput, currentPageFootnotes), - [this, returnToReaderMenu](const ActivityResult& result) { - if (!result.isCancelled) { - const auto& footnoteResult = std::get(result.data); - navigateToHref(footnoteResult.href, true); - } else if (returnToReaderMenu) { - openReaderMenu(); - return; - } - requestUpdate(); - }); + openFootnoteSelect(returnToReaderMenu); break; } case EpubReaderMenuAction::GO_TO_PERCENT: { @@ -5313,36 +5303,96 @@ void EpubReaderActivity::openQuickActionsPopup() { } } -void EpubReaderActivity::executeFootnoteQuickAction(const bool suppressInitialPowerRelease) { - clearPendingManualPageTurns(); - if (footnoteDepth > 0 && SETTINGS.pwrBtnFootnoteBack) { - restoreSavedPosition(); - return; - } - +void EpubReaderActivity::openFootnoteSelect(const bool returnToReaderMenu) { + if (currentPageFootnotes.empty()) return; if (currentPageFootnotes.size() == 1) { navigateToHref(currentPageFootnotes[0].href, true); + requestUpdate(); return; } - if (currentPageFootnotes.size() > 1) { - if (suppressInitialPowerRelease) { - suppressPowerShortcutRelease(); + std::unique_ptr page; + FootnoteLinkTargets targets{}; + ReaderViewportLayout layout{}; + const int fontId = activeSectionFontId != 0 ? activeSectionFontId : SETTINGS.getReaderFontId(); + { + RenderLock lock(*this); + if (section) { + page = section->loadPageFromSectionFile(); + if (page) { + layout = computeReaderViewportLayout(renderer, automaticPageTurnActive); + targets = + buildFootnoteLinkTargets(*page, page->footnotes, renderer, fontId, layout.marginTop, layout.marginLeft); + } + } + } + + pauseReadingPaceTimer("footnotes"); + const auto onResult = [this, returnToReaderMenu](const ActivityResult& result) { + if (result.isCancelled) { + if (returnToReaderMenu) { + openReaderMenu(); + } else { + resumeReadingPaceTimer("footnotes_cancel"); + requestUpdate(); + } + return; + } + const auto* selected = std::get_if(&result.data); + if (selected) { + navigateToHref(selected->href, true); + } else { + resumeReadingPaceTimer("footnotes_invalid_result"); + } + requestUpdate(); + }; + + bool allTargetsVisible = + page && page->footnotes.size() == currentPageFootnotes.size() && page->footnotes.size() <= targets.size(); + if (allTargetsVisible) { + for (size_t i = 0; i < page->footnotes.size(); ++i) { + if (targets[i].width <= 0 || targets[i].height <= 0) { + allTargetsVisible = false; + break; + } + } + } + if (allTargetsVisible) { + auto selector = makeUniqueNoThrow(renderer, mappedInput, std::move(page), targets, + fontId, layout.marginLeft, layout.marginTop); + if (selector) { + startActivityForResult(std::move(selector), onResult); + return; + } + LOG_ERR("FNS", "OOM allocating footnote selector; falling back to list"); + } + + // Older or malformed page records can lack a drawable link ID. Keep every + // destination reachable through the existing list in that case. + auto list = makeUniqueNoThrow(renderer, mappedInput, currentPageFootnotes); + if (list) { + startActivityForResult(std::move(list), onResult); + } else { + LOG_ERR("FNS", "OOM allocating footnote fallback list"); + if (returnToReaderMenu) { + openReaderMenu(); + } else { + resumeReadingPaceTimer("footnotes_alloc_failed"); + requestUpdate(); } - pauseReadingPaceTimer("footnotes"); - startActivityForResult(std::make_unique(renderer, mappedInput, currentPageFootnotes), - [this](const ActivityResult& result) { - if (!result.isCancelled) { - const auto& footnoteResult = std::get(result.data); - navigateToHref(footnoteResult.href, true); - } else { - resumeReadingPaceTimer("footnotes_cancel"); - } - requestUpdate(); - }); } } +void EpubReaderActivity::executeFootnoteQuickAction(const bool suppressInitialPowerRelease) { + clearPendingManualPageTurns(); + if (footnoteDepth > 0 && SETTINGS.pwrBtnFootnoteBack) { + restoreSavedPosition(); + return; + } + if (currentPageFootnotes.size() > 1 && suppressInitialPowerRelease) suppressPowerShortcutRelease(); + openFootnoteSelect(false); +} + bool EpubReaderActivity::executeShortPowerButtonAction() { if (!mappedInput.wasReleased(MappedInputManager::Button::Power) || mappedInput.getHeldTime() >= SETTINGS.getPowerButtonLongPressDuration()) { @@ -7455,56 +7505,9 @@ bool EpubReaderActivity::renderContents(std::unique_ptr page, const int fo void EpubReaderActivity::buildFootnoteTouchTargets(const Page& page, const int fontId, const int orientedMarginTop, const int orientedMarginLeft) { currentPageFootnoteTouchTargets.fill({}); - if (activeFootnotePreview || currentPageFootnotes.empty()) { - return; - } - - const int lineHeight = renderer.getLineHeight(fontId); - for (const auto& element : page.elements) { - if (!element || element->getTag() != TAG_PageLine) continue; - const auto& line = static_cast(*element); - if (!line.getBlock()) continue; - - const auto& block = *line.getBlock(); - for (uint16_t wordIndex = 0; wordIndex < block.wordCount(); ++wordIndex) { - const uint8_t linkId = block.wordLinkId(wordIndex); - if (linkId == 0) continue; - - const auto footnoteIt = std::find_if(currentPageFootnotes.begin(), currentPageFootnotes.end(), - [linkId](const FootnoteEntry& entry) { return entry.linkId == linkId; }); - if (footnoteIt == currentPageFootnotes.end()) continue; - const size_t footnoteIndex = static_cast(footnoteIt - currentPageFootnotes.begin()); - if (footnoteIndex >= currentPageFootnoteTouchTargets.size()) continue; - - const auto style = static_cast(block.wordStyle(wordIndex) & ~EpdFontFamily::UNDERLINE); - const int wordX = orientedMarginLeft + line.xPos + block.wordXpos(wordIndex); - int wordY = orientedMarginTop + line.yPos; - if ((style & EpdFontFamily::SUP) != 0) { - wordY -= renderer.getFontAscenderSize(fontId) * 2 / 5; - } else if ((style & EpdFontFamily::SUB) != 0) { - wordY += renderer.getFontAscenderSize(fontId) / 4; - } - int wordWidth = renderer.getTextAdvanceX(fontId, block.wordText(wordIndex), style); - if (wordIndex + 1 < block.wordCount() && block.wordXpos(wordIndex + 1) > block.wordXpos(wordIndex)) { - wordWidth = std::min(wordWidth, static_cast(block.wordXpos(wordIndex + 1) - block.wordXpos(wordIndex))); - } - if (wordWidth <= 0) continue; - - auto& target = currentPageFootnoteTouchTargets[footnoteIndex]; - if (target.width <= 0 || target.height <= 0) { - target = {static_cast(wordX), static_cast(wordY), static_cast(wordWidth), - static_cast(lineHeight)}; - continue; - } - - const int left = std::min(target.x, wordX); - const int top = std::min(target.y, wordY); - const int right = std::max(target.x + target.width, wordX + wordWidth); - const int bottom = std::max(target.y + target.height, wordY + lineHeight); - target = {static_cast(left), static_cast(top), static_cast(right - left), - static_cast(bottom - top)}; - } - } + if (activeFootnotePreview || currentPageFootnotes.empty()) return; + currentPageFootnoteTouchTargets = + buildFootnoteLinkTargets(page, currentPageFootnotes, renderer, fontId, orientedMarginTop, orientedMarginLeft); } bool EpubReaderActivity::handleTouchFootnoteLink(const int touchX, const int touchY) { diff --git a/src/activities/reader/EpubReaderActivity.h b/src/activities/reader/EpubReaderActivity.h index 12e503b79b7..395b2474d97 100644 --- a/src/activities/reader/EpubReaderActivity.h +++ b/src/activities/reader/EpubReaderActivity.h @@ -16,6 +16,7 @@ #include "BookmarkStore.h" #include "EndOfBookOptions.h" #include "EpubReaderMenuModel.h" +#include "FootnoteLinkTargets.h" #include "GlobalReadingStats.h" #include "ManualPageTurnQueue.h" #include "ReaderProgressSaveDebouncer.h" @@ -257,13 +258,7 @@ class EpubReaderActivity final : public Activity { std::vector currentPageFootnotes; #if CROSSINK_APP_CAP_TOUCH ReaderPinchGesture pinchFontGesture; - struct FootnoteTouchTarget { - int16_t x = 0; - int16_t y = 0; - int16_t width = 0; - int16_t height = 0; - }; - std::array currentPageFootnoteTouchTargets{}; + FootnoteLinkTargets currentPageFootnoteTouchTargets{}; #endif struct SavedPosition { int spineIndex; @@ -428,6 +423,7 @@ class EpubReaderActivity final : public Activity { QuickLockTrigger quickLockTrigger = QuickLockTrigger::LongMenu); void openQuickActionsPopup(); void executeFootnoteQuickAction(bool suppressInitialPowerRelease = false); + void openFootnoteSelect(bool returnToReaderMenu); #if CROSSINK_APP_CAP_TOUCH bool handlePinchFontResize(); void resetPinchFontGesture(); diff --git a/src/activities/reader/EpubReaderFootnoteSelectActivity.cpp b/src/activities/reader/EpubReaderFootnoteSelectActivity.cpp new file mode 100644 index 00000000000..d7267967d82 --- /dev/null +++ b/src/activities/reader/EpubReaderFootnoteSelectActivity.cpp @@ -0,0 +1,192 @@ +#include "EpubReaderFootnoteSelectActivity.h" + +#include +#include +#include +#include +#include +#include + +#include +#include + +#include "MappedInputManager.h" +#include "ReaderUtils.h" +#include "components/TouchHeaderBackButton.h" +#include "components/UITheme.h" +#include "util/InputReleaseGuard.h" + +void EpubReaderFootnoteSelectActivity::onEnter() { + Activity::onEnter(); + ignoreInitialBackRelease = mappedInput.isPressed(MappedInputManager::Button::Back); + ignoreInitialConfirmRelease = mappedInput.isPressed(MappedInputManager::Button::Confirm); + ignoreInitialPowerRelease = mappedInput.isPressed(MappedInputManager::Button::Power); + for (size_t i = 0; i < page->footnotes.size() && i < targets.size(); ++i) { + if (targets[i].width > 0 && targets[i].height > 0) selectable[selectableCount++] = static_cast(i); + } + snapshot = makeUniqueNoThrow(SNAPSHOT_CAPACITY); + if (!snapshot) LOG_ERR("FNS", "OOM allocating footnote highlight snapshot; using full repaint"); + requestUpdate(); +} + +void EpubReaderFootnoteSelectActivity::cancel() { + ActivityResult result; + result.isCancelled = true; + setResult(std::move(result)); + finish(); +} + +void EpubReaderFootnoteSelectActivity::performJump() { + if (selected >= selectableCount) return; + setResult(FootnoteResult{page->footnotes[selectable[selected]].href}); + finish(); +} + +void EpubReaderFootnoteSelectActivity::loop() { + if (InputReleaseGuard::consumeInitialRelease(mappedInput, MappedInputManager::Button::Back, + ignoreInitialBackRelease) || + InputReleaseGuard::consumeInitialRelease(mappedInput, MappedInputManager::Button::Power, + ignoreInitialPowerRelease) || + InputReleaseGuard::consumeInitialRelease(mappedInput, MappedInputManager::Button::Confirm, + ignoreInitialConfirmRelease)) { + return; + } + + if (TouchHeaderBackButton::wasTapped(mappedInput, renderer) || + mappedInput.wasReleased(MappedInputManager::Button::Back)) { + cancel(); + return; + } + + int touchX = 0; + int touchY = 0; + if (mappedInput.hasTouch() && mappedInput.wasScreenTapped(touchX, touchY)) { + int bestIndex = -1; + int64_t bestDistance = INT64_MAX; + for (uint8_t i = 0; i < selectableCount; ++i) { + const auto& target = targets[selectable[i]]; + if (touchX < target.x - 4 || touchX >= target.x + target.width + 4 || touchY < target.y - 4 || + touchY >= target.y + target.height + 4) { + continue; + } + const int64_t dx = static_cast(touchX) - (target.x + target.width / 2); + const int64_t dy = static_cast(touchY) - (target.y + target.height / 2); + const int64_t distance = dx * dx + dy * dy; + if (distance < bestDistance) { + bestDistance = distance; + bestIndex = i; + } + } + if (bestIndex >= 0) { + selected = static_cast(bestIndex); + performJump(); + return; + } + } + + if (mappedInput.wasReleased(MappedInputManager::Button::Confirm) || + mappedInput.wasReleased(MappedInputManager::Button::Power)) { + performJump(); + return; + } + if (selectableCount == 0) return; + + const unsigned long now = millis(); + const bool repeat = mappedInput.getHeldTime() >= REPEAT_START_MS && now - lastMoveTime >= REPEAT_INTERVAL_MS; + const bool left = mappedInput.wasPressed(MappedInputManager::Button::Left) || + (repeat && mappedInput.isPressed(MappedInputManager::Button::Left)); + const bool right = mappedInput.wasPressed(MappedInputManager::Button::Right) || + (repeat && mappedInput.isPressed(MappedInputManager::Button::Right)); + if ((left || mappedInput.wasPressed(MappedInputManager::Button::Up)) && selected > 0) { + --selected; + lastMoveTime = now; + requestUpdate(); + } else if ((right || mappedInput.wasPressed(MappedInputManager::Button::Down)) && selected + 1 < selectableCount) { + ++selected; + lastMoveTime = now; + requestUpdate(); + } +} + +bool EpubReaderFootnoteSelectActivity::drawHighlightWithSnapshot() { + if (selected >= selectableCount) return false; + const auto& target = targets[selectable[selected]]; + const int screenW = renderer.getScreenWidth(); + const int screenH = renderer.getScreenHeight(); + const int x = std::max(0, static_cast(target.x) - 2); + const int y = std::max(0, static_cast(target.y) - 2); + const int w = std::min(screenW, static_cast(target.x) + target.width + 2) - x; + const int h = std::min(screenH, static_cast(target.y) + target.height + 2) - y; + if (w <= 0 || h <= 0) return false; + + const bool saved = snapshot && renderer.readFramebufferRegion(x, y, w, h, snapshot.get(), SNAPSHOT_CAPACITY) > 0; + snapshotX = static_cast(x); + snapshotY = static_cast(y); + snapshotW = static_cast(w); + snapshotH = static_cast(h); + snapshotIdx = saved ? selected : -1; + + // Invert the already rendered marker. This preserves SD-font glyph pixels + // without trying to rasterize text after the page prewarm scope has ended. + renderer.invertRect(x, y, w, h); + return saved; +} + +void EpubReaderFootnoteSelectActivity::drawChrome() const { + if (mappedInput.hasTouchHardware()) { + TouchHeaderBackButton::draw(renderer, TouchHeaderBackButton::headerRect(renderer, mappedInput), tr(STR_FOOTNOTES), + true); + } + const auto labels = mappedInput.mapLabels(mappedInput.withBackArrow(tr(STR_BACK)), tr(STR_SELECT), "", ""); + GUI.drawButtonHints(renderer, labels.btn1, labels.btn2, labels.btn3, labels.btn4, true); +} + +void EpubReaderFootnoteSelectActivity::render(RenderLock&&) { + if (snapshotIdx >= 0 && selected != snapshotIdx) { + renderer.writeFramebufferRegion(snapshotX, snapshotY, snapshotW, snapshotH, snapshot.get()); + if (drawHighlightWithSnapshot()) { + drawChrome(); + renderer.displayBuffer(HalDisplay::FAST_REFRESH); + return; + } + } + + snapshotIdx = -1; + // The selector repaints the page after the reader has paused. SD font glyphs + // may have been evicted, so use the same scan and prewarm sequence as reading. + std::optional pageRenderScope; + if (auto* fcm = renderer.getFontCacheManager()) { + const auto prewarmVisibleText = [&]() { + pageRenderScope.emplace(*fcm, FontCacheManager::PreparationPolicy::Normal); + page->renderText(renderer, fontId, marginLeft, marginTop); + drawChrome(); + if (!pageRenderScope->endScanAndPrewarm()) { + pageRenderScope.reset(); + return false; + } + return true; + }; + + bool prewarmSucceeded = prewarmVisibleText(); + if (!prewarmSucceeded && renderer.isSdCardFont(fontId)) { + LOG_ERR("FNS", "SD-font page prewarm failed (font=%d free=%u maxAlloc=%u); releasing caches and retrying", fontId, + ESP.getFreeHeap(), ESP.getMaxAllocHeap()); + renderer.releaseSdCardFontForLowMemory(fontId, /*preserveAdvanceTable=*/true); + prewarmSucceeded = prewarmVisibleText(); + } + if (!prewarmSucceeded) { + LOG_ERR("FNS", "Font page prewarm failed after retry (font=%d free=%u maxAlloc=%u)", fontId, ESP.getFreeHeap(), + ESP.getMaxAllocHeap()); + renderer.clearScreen(ReaderUtils::readerBackgroundColor()); + GUI.drawPopup(renderer, tr(STR_MEMORY_ERROR)); + renderer.displayBuffer(HalDisplay::FAST_REFRESH); + return; + } + } + + renderer.clearScreen(ReaderUtils::readerBackgroundColor()); + page->render(renderer, fontId, marginLeft, marginTop, ReaderUtils::readerForegroundBlack()); + drawHighlightWithSnapshot(); + drawChrome(); + renderer.displayBuffer(HalDisplay::FAST_REFRESH); +} diff --git a/src/activities/reader/EpubReaderFootnoteSelectActivity.h b/src/activities/reader/EpubReaderFootnoteSelectActivity.h new file mode 100644 index 00000000000..c8503708dd6 --- /dev/null +++ b/src/activities/reader/EpubReaderFootnoteSelectActivity.h @@ -0,0 +1,58 @@ +#pragma once + +#include + +#include +#include + +#include "FootnoteLinkTargets.h" +#include "activities/Activity.h" + +// Select a footnote reference on the page itself. The page is owned by this +// activity so the source reader can remain paused until a jump or cancellation. +class EpubReaderFootnoteSelectActivity final : public Activity { + public: + EpubReaderFootnoteSelectActivity(GfxRenderer& renderer, MappedInputManager& mappedInput, std::unique_ptr page, + FootnoteLinkTargets targets, int fontId, int marginLeft, int marginTop) + : Activity("EpubReaderFootnoteSelect", renderer, mappedInput), + page(std::move(page)), + targets(targets), + fontId(fontId), + marginLeft(marginLeft), + marginTop(marginTop) {} + + bool isReaderActivity() const override { return true; } + bool allowPowerAsConfirmInReaderMode() const override { return true; } + void onEnter() override; + void loop() override; + void render(RenderLock&&) override; + + private: + static constexpr size_t SNAPSHOT_CAPACITY = 4096; + static constexpr unsigned long REPEAT_START_MS = 500; + static constexpr unsigned long REPEAT_INTERVAL_MS = 500; + + void cancel(); + void performJump(); + bool drawHighlightWithSnapshot(); + void drawChrome() const; + + std::unique_ptr page; + FootnoteLinkTargets targets; + const int fontId; + const int marginLeft; + const int marginTop; + std::array selectable{}; + uint8_t selectableCount = 0; + uint8_t selected = 0; + std::unique_ptr snapshot; + int16_t snapshotX = 0; + int16_t snapshotY = 0; + int16_t snapshotW = 0; + int16_t snapshotH = 0; + int snapshotIdx = -1; + unsigned long lastMoveTime = 0; + bool ignoreInitialBackRelease = false; + bool ignoreInitialConfirmRelease = false; + bool ignoreInitialPowerRelease = false; +}; diff --git a/src/activities/reader/FootnoteLinkTargets.cpp b/src/activities/reader/FootnoteLinkTargets.cpp new file mode 100644 index 00000000000..ca3a6971d2b --- /dev/null +++ b/src/activities/reader/FootnoteLinkTargets.cpp @@ -0,0 +1,61 @@ +#include "FootnoteLinkTargets.h" + +#include +#include + +#include + +FootnoteLinkTargets buildFootnoteLinkTargets(const Page& page, const std::vector& footnotes, + const GfxRenderer& renderer, const int fontId, const int marginTop, + const int marginLeft) { + FootnoteLinkTargets targets{}; + if (footnotes.empty()) return targets; + + const int lineHeight = renderer.getLineHeight(fontId); + for (const auto& element : page.elements) { + if (!element || element->getTag() != TAG_PageLine) continue; + const auto& line = static_cast(*element); + if (!line.getBlock()) continue; + + const auto& block = *line.getBlock(); + for (uint16_t wordIndex = 0; wordIndex < block.wordCount(); ++wordIndex) { + const uint8_t linkId = block.wordLinkId(wordIndex); + if (linkId == 0) continue; + + const auto footnoteIt = std::find_if(footnotes.begin(), footnotes.end(), + [linkId](const FootnoteEntry& entry) { return entry.linkId == linkId; }); + if (footnoteIt == footnotes.end()) continue; + const size_t footnoteIndex = static_cast(footnoteIt - footnotes.begin()); + if (footnoteIndex >= targets.size()) continue; + + const auto style = static_cast(block.wordStyle(wordIndex) & ~EpdFontFamily::UNDERLINE); + const int wordX = marginLeft + line.xPos + block.wordXpos(wordIndex); + int wordY = marginTop + line.yPos; + if ((style & EpdFontFamily::SUP) != 0) { + wordY -= renderer.getFontAscenderSize(fontId) * 2 / 5; + } else if ((style & EpdFontFamily::SUB) != 0) { + wordY += renderer.getFontAscenderSize(fontId) / 4; + } + int wordWidth = renderer.getTextAdvanceX(fontId, block.wordText(wordIndex), style); + if (wordIndex + 1 < block.wordCount() && block.wordXpos(wordIndex + 1) > block.wordXpos(wordIndex)) { + wordWidth = std::min(wordWidth, static_cast(block.wordXpos(wordIndex + 1) - block.wordXpos(wordIndex))); + } + if (wordWidth <= 0) continue; + + auto& target = targets[footnoteIndex]; + if (target.width <= 0 || target.height <= 0) { + target = {static_cast(wordX), static_cast(wordY), static_cast(wordWidth), + static_cast(lineHeight)}; + continue; + } + + const int left = std::min(target.x, wordX); + const int top = std::min(target.y, wordY); + const int right = std::max(target.x + target.width, wordX + wordWidth); + const int bottom = std::max(target.y + target.height, wordY + lineHeight); + target = {static_cast(left), static_cast(top), static_cast(right - left), + static_cast(bottom - top)}; + } + } + return targets; +} diff --git a/src/activities/reader/FootnoteLinkTargets.h b/src/activities/reader/FootnoteLinkTargets.h new file mode 100644 index 00000000000..11a1df4b888 --- /dev/null +++ b/src/activities/reader/FootnoteLinkTargets.h @@ -0,0 +1,23 @@ +#pragma once + +#include + +#include +#include +#include + +class GfxRenderer; +class Page; + +struct FootnoteLinkTarget { + int16_t x = 0; + int16_t y = 0; + int16_t width = 0; + int16_t height = 0; +}; + +using FootnoteLinkTargets = std::array; + +// Match serialized link IDs to visible words without a heap allocation. +FootnoteLinkTargets buildFootnoteLinkTargets(const Page& page, const std::vector& footnotes, + const GfxRenderer& renderer, int fontId, int marginTop, int marginLeft); From 7a0fc6f73e1f6924b96a146404e7807da553d2c3 Mon Sep 17 00:00:00 2001 From: Justin Mitchell Date: Mon, 28 Sep 2026 13:38:40 -0400 Subject: [PATCH 54/62] feat: add Cover Grid Home on PSRAM devices Adapted from crosspoint-reader/crosspoint-reader@362c88112. CrossInk reuses its EPUB cover cache, Library index, and Lyra theme while limiting the grid to PSRAM devices. Home checks index freshness and shows the Library scan message before rebuilding. Co-authored-by: Uri Tauber Co-authored-by: Julia Nguyen --- CHANGELOG.md | 1 + lib/Epub/Epub.cpp | 24 +- lib/Epub/Epub.h | 4 +- lib/I18n/translations/arabic.yaml | 1 + lib/I18n/translations/belarusian.yaml | 1 + lib/I18n/translations/catalan.yaml | 1 + lib/I18n/translations/czech.yaml | 1 + lib/I18n/translations/danish.yaml | 1 + lib/I18n/translations/dutch.yaml | 1 + lib/I18n/translations/english.yaml | 1 + lib/I18n/translations/finnish.yaml | 1 + lib/I18n/translations/french.yaml | 1 + lib/I18n/translations/german.yaml | 1 + lib/I18n/translations/hebrew.yaml | 1 + lib/I18n/translations/hungarian.yaml | 1 + lib/I18n/translations/italian.yaml | 1 + lib/I18n/translations/kazakh.yaml | 1 + lib/I18n/translations/lithuanian.yaml | 1 + lib/I18n/translations/polish.yaml | 1 + lib/I18n/translations/portuguese-BR.yaml | 1 + lib/I18n/translations/portuguese-PT.yaml | 1 + lib/I18n/translations/romanian.yaml | 1 + lib/I18n/translations/russian.yaml | 1 + lib/I18n/translations/slovak.yaml | 1 + lib/I18n/translations/slovenian.yaml | 1 + lib/I18n/translations/spanish.yaml | 1 + lib/I18n/translations/swedish.yaml | 1 + lib/I18n/translations/turkish.yaml | 1 + lib/I18n/translations/ukrainian.yaml | 1 + lib/I18n/translations/valencian.yaml | 1 + lib/I18n/translations/vietnamese.yaml | 1 + scripts/run_simulator_smoke_test.py | 1 + src/CrossPointSettings.h | 3 +- src/SettingsList.h | 22 +- src/activities/home/HomeActivity.cpp | 244 ++++++++++++++++++++- src/activities/home/HomeActivity.h | 6 + src/components/CoverGridHomeUi.cpp | 265 +++++++++++++++++++++++ src/components/CoverGridHomeUi.h | 63 ++++++ src/components/HomeCoverCache.cpp | 88 ++++++++ src/components/HomeCoverCache.h | 41 ++++ src/components/UITheme.cpp | 22 ++ src/components/UITheme.h | 2 + 42 files changed, 796 insertions(+), 18 deletions(-) create mode 100644 src/components/CoverGridHomeUi.cpp create mode 100644 src/components/CoverGridHomeUi.h create mode 100644 src/components/HomeCoverCache.cpp create mode 100644 src/components/HomeCoverCache.h diff --git a/CHANGELOG.md b/CHANGELOG.md index cee478bf7e4..326bf9603d3 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -12,6 +12,7 @@ - TTF font support on ESP32-S3 devices. Whole-point sizes from 8pt to 22pt will be automatically available. - In-reader menu for X3/X4/X4 Classic have been updated to a modified version of the in-reader menu for touch devices - Chapter pages and book progress information is displayed in the frontlight drawer when in the reader for X4 Pro +- Add a Cover Grid Home theme on devices with PSRAM, showing the current book and six library covers. ### Changed diff --git a/lib/Epub/Epub.cpp b/lib/Epub/Epub.cpp index 36da991ae63..92138e6813f 100644 --- a/lib/Epub/Epub.cpp +++ b/lib/Epub/Epub.cpp @@ -1318,6 +1318,24 @@ bool Epub::generateThumbBmp(int width, int height, const GfxRenderer* renderer, return generateThumbBmpInternal(width, height, false, renderer, readerFontId); } +bool Epub::generateThumbBmpFromSource(int height, const GfxRenderer* renderer, const int readerFontId) { + int width = 0; + normalizeThumbDimensions(width, height); + const std::string thumbPath = getThumbBmpPathForDimensions(cachePath, width, height); + if (cachedBmpMatchesDimensions(thumbPath, width, height)) return true; + + auto metadata = makeUniqueNoThrow(); + if (!metadata) { + LOG_ERR("EBP", "Cannot allocate cover metadata"); + return false; + } + setupCacheDir(); + if (!parseContentOpf(*metadata, /*writeSpineEntries=*/false, /*collectCssFiles=*/false)) { + return false; + } + return generateThumbBmpInternal(width, height, false, renderer, readerFontId, &metadata->coverItemHref); +} + bool Epub::generateAdaptiveThumbBmp(int width, int height, const GfxRenderer* renderer, const int readerFontId) const { return generateThumbBmpInternal(width, height, true, renderer, readerFontId); } @@ -1372,7 +1390,7 @@ bool Epub::ensureCachedCoverImage(const std::string& coverImageHref, std::string } bool Epub::generateThumbBmpInternal(int width, int height, const bool adaptiveContain, const GfxRenderer* renderer, - const int readerFontId) const { + const int readerFontId, const std::string* coverHrefOverride) const { if (height <= 0) { LOG_DBG("EBP", "Using default thumb BMP height for requested dimensions: %dx%d", width, height); } @@ -1385,12 +1403,12 @@ bool Epub::generateThumbBmpInternal(int width, int height, const bool adaptiveCo return true; } - if (!bookMetadataCache || !bookMetadataCache->isLoaded()) { + if (!coverHrefOverride && (!bookMetadataCache || !bookMetadataCache->isLoaded())) { LOG_ERR("EBP", "Cannot generate thumb BMP, cache not loaded"); return false; } - const auto coverImageHref = bookMetadataCache->coreMetadata.coverItemHref; + const auto& coverImageHref = coverHrefOverride ? *coverHrefOverride : bookMetadataCache->coreMetadata.coverItemHref; if (coverImageHref.empty()) { LOG_DBG("EBP", "No known cover image for thumbnail"); } else if (FsHelpers::hasJpgExtension(coverImageHref)) { diff --git a/lib/Epub/Epub.h b/lib/Epub/Epub.h index a3be73dfc67..9602ccb3bdb 100644 --- a/lib/Epub/Epub.h +++ b/lib/Epub/Epub.h @@ -169,6 +169,8 @@ class Epub { // thumbnail height. // Returns false on missing cache/cover, unsupported image format, or conversion failure. bool generateThumbBmp(int width, int height, const GfxRenderer* renderer = nullptr, int readerFontId = 0) const; + // Generate a Home thumbnail from the EPUB cover without building reader pages or indexes. + bool generateThumbBmpFromSource(int height, const GfxRenderer* renderer = nullptr, int readerFontId = 0); // Writes a thumbnail that can either crop-to-fill or contain unusual cover // ratios, depending on the source image dimensions. bool generateAdaptiveThumbBmp(int width, int height, const GfxRenderer* renderer = nullptr, @@ -223,5 +225,5 @@ class Epub { std::string getCachedCoverImagePath(const std::string& coverImageHref) const; bool ensureCachedCoverImage(const std::string& coverImageHref, std::string& outPath) const; bool generateThumbBmpInternal(int width, int height, bool adaptiveContain, const GfxRenderer* renderer, - int readerFontId) const; + int readerFontId, const std::string* coverHrefOverride = nullptr) const; }; diff --git a/lib/I18n/translations/arabic.yaml b/lib/I18n/translations/arabic.yaml index 1aee80af151..bd746c86968 100644 --- a/lib/I18n/translations/arabic.yaml +++ b/lib/I18n/translations/arabic.yaml @@ -392,6 +392,7 @@ STR_UI_THEME: "مظهر الواجهة" STR_THEME_CLASSIC: "كلاسيكي" STR_THEME_LYRA: "Lyra" STR_THEME_ROUNDEDRAFF: "RoundedRaff" +STR_THEME_COVER_GRID: "Cover Grid" STR_THEME_LYRA_EXTENDED: "Lyra موسع" STR_THEME_LYRA_CAROUSEL: "ليرا كاروسيل" STR_THEME_MINIMAL: "الحد الأدنى" diff --git a/lib/I18n/translations/belarusian.yaml b/lib/I18n/translations/belarusian.yaml index ea509a58ca4..a11deba26d3 100644 --- a/lib/I18n/translations/belarusian.yaml +++ b/lib/I18n/translations/belarusian.yaml @@ -498,6 +498,7 @@ STR_THEME_LYRA: "Lyra" STR_THEME_LYRA_CAROUSEL: "Lyra Carousel" STR_THEME_MINIMAL: "Minimal" STR_THEME_ROUNDEDRAFF: "RoundedRaff" +STR_THEME_COVER_GRID: "Cover Grid" STR_TINY: "Малюсенькі" STR_TOP: "Зверху" STR_XTC_STATUS_BAR: "Радок стану XTC" diff --git a/lib/I18n/translations/catalan.yaml b/lib/I18n/translations/catalan.yaml index 07f10f697e9..7697632c359 100644 --- a/lib/I18n/translations/catalan.yaml +++ b/lib/I18n/translations/catalan.yaml @@ -465,6 +465,7 @@ STR_CLOCK_SYNC_NO_WIFI: "Wi-Fi no connectat" STR_CLOCK_SYNC_NO_WIFI_HINT: "Connecta't al Wi-Fi primer i torna-ho a provar." STR_CLOCK_SYNCED: "Rellotge sincronitzat" STR_THEME_ROUNDEDRAFF: "RoundedRaff" +STR_THEME_COVER_GRID: "Cover Grid" STR_SEARCH: "Cerca" STR_SET_SLEEP_COVER: "Fixa portada" STR_ADD_SERVER: "Afegeix servidor" diff --git a/lib/I18n/translations/czech.yaml b/lib/I18n/translations/czech.yaml index d054b4d6fcc..c7295ab4a3d 100644 --- a/lib/I18n/translations/czech.yaml +++ b/lib/I18n/translations/czech.yaml @@ -290,6 +290,7 @@ STR_FILTER_CONTRAST: "Kontrast" STR_UI_THEME: "Motiv rozhraní" STR_THEME_CLASSIC: "Klasický" STR_THEME_ROUNDEDRAFF: "RoundedRaff" +STR_THEME_COVER_GRID: "Cover Grid" STR_THEME_LYRA_EXTENDED: "Lyra Extended" STR_SUNLIGHT_FADING_FIX: "Oprava blednutí na slunci" STR_QUICK_RESUME_TIMEOUT: "Rychlé navázání po vypršení" diff --git a/lib/I18n/translations/danish.yaml b/lib/I18n/translations/danish.yaml index 748b19e1ac8..f21a86a46db 100644 --- a/lib/I18n/translations/danish.yaml +++ b/lib/I18n/translations/danish.yaml @@ -494,6 +494,7 @@ STR_THEME_LYRA: "Lyra" STR_THEME_LYRA_CAROUSEL: "Lyra Carousel" STR_THEME_MINIMAL: "Minimal" STR_THEME_ROUNDEDRAFF: "RoundedRaff" +STR_THEME_COVER_GRID: "Cover Grid" STR_TINY: "Lille" STR_TOGGLE_FOCUS_READING: "Fokuslæsning" STR_TOGGLE_BOOKMARK: "Skift bogmærke" diff --git a/lib/I18n/translations/dutch.yaml b/lib/I18n/translations/dutch.yaml index 94acffbe250..4091783e088 100644 --- a/lib/I18n/translations/dutch.yaml +++ b/lib/I18n/translations/dutch.yaml @@ -493,6 +493,7 @@ STR_THEME_LYRA: "Lyra" STR_THEME_LYRA_CAROUSEL: "Lyra Carousel" STR_THEME_MINIMAL: "Minimal" STR_THEME_ROUNDEDRAFF: "RoundedRaff" +STR_THEME_COVER_GRID: "Cover Grid" STR_TINY: "Klein" STR_TOGGLE_FOCUS_READING: "Gefocust lezen" STR_TOGGLE_BOOKMARK: "Schakel bladwijzer in" diff --git a/lib/I18n/translations/english.yaml b/lib/I18n/translations/english.yaml index bb147ca7aea..056f1629de8 100644 --- a/lib/I18n/translations/english.yaml +++ b/lib/I18n/translations/english.yaml @@ -520,6 +520,7 @@ STR_UI_SCALE: "UI Scale" STR_THEME_CLASSIC: "Classic" STR_THEME_LYRA: "Lyra" STR_THEME_ROUNDEDRAFF: "RoundedRaff" +STR_THEME_COVER_GRID: "Cover Grid" STR_THEME_LYRA_EXTENDED: "Lyra Extended" STR_THEME_LYRA_CAROUSEL: "Lyra Carousel" STR_THEME_MINIMAL: "Minimal" diff --git a/lib/I18n/translations/finnish.yaml b/lib/I18n/translations/finnish.yaml index 9a99466bcab..f98deb4c6da 100644 --- a/lib/I18n/translations/finnish.yaml +++ b/lib/I18n/translations/finnish.yaml @@ -482,6 +482,7 @@ STR_THEME_LYRA: "Lyra" STR_THEME_LYRA_CAROUSEL: "Lyra Carousel" STR_THEME_MINIMAL: "Minimal" STR_THEME_ROUNDEDRAFF: "RoundedRaff" +STR_THEME_COVER_GRID: "Cover Grid" STR_TINY: "Pieni" STR_TITLE: "Otsikko" STR_TOGGLE_FOCUS_READING: "Keskittynyt lukeminen" diff --git a/lib/I18n/translations/french.yaml b/lib/I18n/translations/french.yaml index 4ba6334b7f2..26733861fc5 100644 --- a/lib/I18n/translations/french.yaml +++ b/lib/I18n/translations/french.yaml @@ -323,6 +323,7 @@ STR_RECENT_BOOKS_VIEW: "Vue des livres récents" STR_LIST_VIEW: "Liste" STR_GRID_VIEW: "Grille" STR_THEME_ROUNDEDRAFF: "RoundedRaff" +STR_THEME_COVER_GRID: "Cover Grid" STR_THEME_LYRA_EXTENDED: "Lyra Extended" STR_SUNLIGHT_FADING_FIX: "Correction lisibilité au soleil" STR_QUICK_RESUME_TIMEOUT: "Reprise rapide après délai" diff --git a/lib/I18n/translations/german.yaml b/lib/I18n/translations/german.yaml index 66e0302c498..13c8cdd8345 100644 --- a/lib/I18n/translations/german.yaml +++ b/lib/I18n/translations/german.yaml @@ -400,6 +400,7 @@ STR_UI_SCALE: "Größe Nutzeroberfläche" STR_THEME_CLASSIC: "Klassisch" STR_THEME_LYRA: "Lyra" STR_THEME_ROUNDEDRAFF: "RoundedRaff" +STR_THEME_COVER_GRID: "Cover Grid" STR_THEME_LYRA_EXTENDED: "Lyra Extended" STR_THEME_LYRA_CAROUSEL: "Lyra Carousel" STR_THEME_MINIMAL: "Minimal" diff --git a/lib/I18n/translations/hebrew.yaml b/lib/I18n/translations/hebrew.yaml index e82bd4c4abc..d576e2427cd 100644 --- a/lib/I18n/translations/hebrew.yaml +++ b/lib/I18n/translations/hebrew.yaml @@ -310,6 +310,7 @@ STR_UI_THEME: "ערכת נושא" STR_THEME_CLASSIC: "קלאסי" STR_THEME_LYRA: "Lyra" STR_THEME_ROUNDEDRAFF: "RoundedRaff" +STR_THEME_COVER_GRID: "Cover Grid" STR_THEME_LYRA_EXTENDED: "Lyra מורחב" STR_SUNLIGHT_FADING_FIX: "תיקון דהיית מסך בשמש" STR_REMAP_FRONT_BUTTONS: "שנה תפקיד לכפתורים קדמיים" diff --git a/lib/I18n/translations/hungarian.yaml b/lib/I18n/translations/hungarian.yaml index 094af4fbaa5..8b1f73fce9e 100644 --- a/lib/I18n/translations/hungarian.yaml +++ b/lib/I18n/translations/hungarian.yaml @@ -496,6 +496,7 @@ STR_THEME_LYRA: "Lyra" STR_THEME_LYRA_CAROUSEL: "Lyra Carousel" STR_THEME_MINIMAL: "Minimal" STR_THEME_ROUNDEDRAFF: "RoundedRaff" +STR_THEME_COVER_GRID: "Cover Grid" STR_TINY: "Apró" STR_TOGGLE_FOCUS_READING: "Fókuszált olvasás" STR_TOGGLE_BOOKMARK: "Kapcsolja be a Könyvjelzőt" diff --git a/lib/I18n/translations/italian.yaml b/lib/I18n/translations/italian.yaml index e473fc17e55..cb5866422b7 100644 --- a/lib/I18n/translations/italian.yaml +++ b/lib/I18n/translations/italian.yaml @@ -327,6 +327,7 @@ STR_UI_THEME: "Tema interfaccia" STR_THEME_CLASSIC: "Classico" STR_THEME_LYRA: "Lyra" STR_THEME_ROUNDEDRAFF: "RoundedRaff" +STR_THEME_COVER_GRID: "Cover Grid" STR_THEME_LYRA_EXTENDED: "Lyra esteso" STR_SUNLIGHT_FADING_FIX: "Correzione luce solare" STR_QUICK_RESUME_TIMEOUT: "Ripresa rapida dopo timeout" diff --git a/lib/I18n/translations/kazakh.yaml b/lib/I18n/translations/kazakh.yaml index 2108af27e81..99ebcc4e3e8 100644 --- a/lib/I18n/translations/kazakh.yaml +++ b/lib/I18n/translations/kazakh.yaml @@ -494,6 +494,7 @@ STR_THEME_LYRA: "Lyra" STR_THEME_LYRA_CAROUSEL: "Lyra Carousel" STR_THEME_MINIMAL: "Minimal" STR_THEME_ROUNDEDRAFF: "RoundedRaff" +STR_THEME_COVER_GRID: "Cover Grid" STR_TINY: "Кішкентай" STR_TOGGLE_FOCUS_READING: "Зейінді оқу" STR_TOGGLE_BOOKMARK: "Бетбелгі қосу/өшіру" diff --git a/lib/I18n/translations/lithuanian.yaml b/lib/I18n/translations/lithuanian.yaml index 4262752b9fb..67804a144b7 100644 --- a/lib/I18n/translations/lithuanian.yaml +++ b/lib/I18n/translations/lithuanian.yaml @@ -494,6 +494,7 @@ STR_THEME_LYRA: "Lyra" STR_THEME_LYRA_CAROUSEL: "Lyra Carousel" STR_THEME_MINIMAL: "Minimal" STR_THEME_ROUNDEDRAFF: "RoundedRaff" +STR_THEME_COVER_GRID: "Cover Grid" STR_TINY: "Mažytis" STR_TOGGLE_FOCUS_READING: "Sufokusuotas skaitymas" STR_TOGGLE_BOOKMARK: "Perjungti žymę" diff --git a/lib/I18n/translations/polish.yaml b/lib/I18n/translations/polish.yaml index 413170e5dbc..fce41b36868 100644 --- a/lib/I18n/translations/polish.yaml +++ b/lib/I18n/translations/polish.yaml @@ -514,6 +514,7 @@ STR_THEME_LYRA: "Lyra" STR_THEME_LYRA_CAROUSEL: "Lyra Carousel" STR_THEME_MINIMAL: "Minimal" STR_THEME_ROUNDEDRAFF: "RoundedRaff" +STR_THEME_COVER_GRID: "Cover Grid" STR_TINY: "Malutki" STR_TOGGLE_FOCUS_READING: "Czytanie skupione" STR_TOGGLE_BOOKMARK: "Przełącz zakładkę" diff --git a/lib/I18n/translations/portuguese-BR.yaml b/lib/I18n/translations/portuguese-BR.yaml index 4bcd72b5f91..8d5f2c462f9 100644 --- a/lib/I18n/translations/portuguese-BR.yaml +++ b/lib/I18n/translations/portuguese-BR.yaml @@ -386,6 +386,7 @@ STR_UI_THEME: "Tema da interface" STR_THEME_CLASSIC: "Clássico" STR_THEME_LYRA: "Lyra" STR_THEME_ROUNDEDRAFF: "RoundedRaff" +STR_THEME_COVER_GRID: "Cover Grid" STR_THEME_LYRA_EXTENDED: "Lyra Extended" STR_THEME_LYRA_CAROUSEL: "Lyra Carousel" STR_THEME_MINIMAL: "Minimal" diff --git a/lib/I18n/translations/portuguese-PT.yaml b/lib/I18n/translations/portuguese-PT.yaml index 49e33dc91a5..b0cf5913529 100644 --- a/lib/I18n/translations/portuguese-PT.yaml +++ b/lib/I18n/translations/portuguese-PT.yaml @@ -479,6 +479,7 @@ STR_UI_SCALE: "Escala da interface" STR_THEME_CLASSIC: "Clássico" STR_THEME_LYRA: "Lyra" STR_THEME_ROUNDEDRAFF: "RoundedRaff" +STR_THEME_COVER_GRID: "Cover Grid" STR_THEME_LYRA_EXTENDED: "Lyra Extended" STR_THEME_LYRA_CAROUSEL: "Lyra Carousel" STR_THEME_MINIMAL: "Minimal" diff --git a/lib/I18n/translations/romanian.yaml b/lib/I18n/translations/romanian.yaml index 2a5fa150119..dfdb3bcf9a5 100644 --- a/lib/I18n/translations/romanian.yaml +++ b/lib/I18n/translations/romanian.yaml @@ -493,6 +493,7 @@ STR_THEME_LYRA: "Lyra" STR_THEME_LYRA_CAROUSEL: "Lyra Carousel" STR_THEME_MINIMAL: "Minimal" STR_THEME_ROUNDEDRAFF: "RoundedRaff" +STR_THEME_COVER_GRID: "Cover Grid" STR_TINY: "Micut" STR_TOGGLE_FOCUS_READING: "Lectură concentrată" STR_TOGGLE_BOOKMARK: "Comutați Marcaj" diff --git a/lib/I18n/translations/russian.yaml b/lib/I18n/translations/russian.yaml index 7e1d91515e1..5d35eecc375 100644 --- a/lib/I18n/translations/russian.yaml +++ b/lib/I18n/translations/russian.yaml @@ -349,6 +349,7 @@ STR_RECENT_BOOKS_VIEW: "Вид недавних книг" STR_LIST_VIEW: "Список" STR_GRID_VIEW: "Сетка" STR_THEME_ROUNDEDRAFF: "RoundedRaff" +STR_THEME_COVER_GRID: "Cover Grid" STR_THEME_LYRA_EXTENDED: "Lyra Extended" STR_SUNLIGHT_FADING_FIX: "Компенсация выцветания" STR_REMAP_FRONT_BUTTONS: "Переназначить передние кнопки" diff --git a/lib/I18n/translations/slovak.yaml b/lib/I18n/translations/slovak.yaml index 73e8a9152ce..f6dd54f1c92 100644 --- a/lib/I18n/translations/slovak.yaml +++ b/lib/I18n/translations/slovak.yaml @@ -554,6 +554,7 @@ STR_THEME_LYRA: "Lyra" STR_THEME_LYRA_CAROUSEL: "Lyra Carousel" STR_THEME_MINIMAL: "Minimal" STR_THEME_ROUNDEDRAFF: "RoundedRaff" +STR_THEME_COVER_GRID: "Cover Grid" STR_TOP: "Hore" STR_XTC_STATUS_BAR: "Stavový panel XTC" STR_NEARBY_STATS_VERSION_MISMATCH: "Nesúlad verzií. Štatistiky sa nesynchronizovali" diff --git a/lib/I18n/translations/slovenian.yaml b/lib/I18n/translations/slovenian.yaml index 1a2d3b5a888..4803eacdf93 100644 --- a/lib/I18n/translations/slovenian.yaml +++ b/lib/I18n/translations/slovenian.yaml @@ -494,6 +494,7 @@ STR_THEME_LYRA: "Lyra" STR_THEME_LYRA_CAROUSEL: "Lyra Carousel" STR_THEME_MINIMAL: "Minimal" STR_THEME_ROUNDEDRAFF: "RoundedRaff" +STR_THEME_COVER_GRID: "Cover Grid" STR_TINY: "Majhen" STR_TOGGLE_FOCUS_READING: "Fokusirano branje" STR_TOGGLE_BOOKMARK: "Preklopi med zaznamke" diff --git a/lib/I18n/translations/spanish.yaml b/lib/I18n/translations/spanish.yaml index dadab4af71f..f175535d0be 100644 --- a/lib/I18n/translations/spanish.yaml +++ b/lib/I18n/translations/spanish.yaml @@ -387,6 +387,7 @@ STR_UI_THEME: "Interfaz" STR_THEME_CLASSIC: "Clásico" STR_THEME_LYRA: "Lyra" STR_THEME_ROUNDEDRAFF: "RoundedRaff" +STR_THEME_COVER_GRID: "Cover Grid" STR_THEME_LYRA_EXTENDED: "Lyra Extended" STR_THEME_LYRA_CAROUSEL: "Lyra Carousel" STR_THEME_MINIMAL: "Minimal" diff --git a/lib/I18n/translations/swedish.yaml b/lib/I18n/translations/swedish.yaml index 60255d6c405..b4a49318ebe 100644 --- a/lib/I18n/translations/swedish.yaml +++ b/lib/I18n/translations/swedish.yaml @@ -345,6 +345,7 @@ STR_RECENT_BOOKS_VIEW: "Vy för senaste böcker" STR_LIST_VIEW: "Lista" STR_GRID_VIEW: "Rutnät" STR_THEME_ROUNDEDRAFF: "RoundedRaff" +STR_THEME_COVER_GRID: "Cover Grid" STR_THEME_LYRA_EXTENDED: "Lyra utökad" STR_SUNLIGHT_FADING_FIX: "Fix för solskensmattning" STR_QUICK_RESUME_TIMEOUT: "Snabb återupptagning efter timeout" diff --git a/lib/I18n/translations/turkish.yaml b/lib/I18n/translations/turkish.yaml index a16a531e769..1e5f9b6efca 100644 --- a/lib/I18n/translations/turkish.yaml +++ b/lib/I18n/translations/turkish.yaml @@ -493,6 +493,7 @@ STR_THEME_LYRA: "Lyra" STR_THEME_LYRA_CAROUSEL: "Lyra Carousel" STR_THEME_MINIMAL: "Minimal" STR_THEME_ROUNDEDRAFF: "RoundedRaff" +STR_THEME_COVER_GRID: "Cover Grid" STR_TINY: "minik" STR_TOGGLE_FOCUS_READING: "Odaklanmış Okuma" STR_TOGGLE_BOOKMARK: "Yer İmini Değiştir" diff --git a/lib/I18n/translations/ukrainian.yaml b/lib/I18n/translations/ukrainian.yaml index 24b9a2133a9..e5d5bdaf8e7 100644 --- a/lib/I18n/translations/ukrainian.yaml +++ b/lib/I18n/translations/ukrainian.yaml @@ -326,6 +326,7 @@ STR_UI_THEME: "Тема інтерфейсу" STR_THEME_CLASSIC: "Класична" STR_THEME_LYRA: "Lyra" STR_THEME_ROUNDEDRAFF: "RoundedRaff" +STR_THEME_COVER_GRID: "Cover Grid" STR_THEME_LYRA_EXTENDED: "Lyra Extended" STR_RECENT_BOOKS_VIEW: "Вигляд нещодавніх книг" STR_LIST_VIEW: "Список" diff --git a/lib/I18n/translations/valencian.yaml b/lib/I18n/translations/valencian.yaml index c6c6ce454fd..c4a9ed34088 100644 --- a/lib/I18n/translations/valencian.yaml +++ b/lib/I18n/translations/valencian.yaml @@ -435,6 +435,7 @@ STR_CLOCK_SYNC_NO_WIFI: "Wi-Fi no connectat" STR_CLOCK_SYNC_NO_WIFI_HINT: "Connecta't al Wi-Fi primer i torna-ho a provar." STR_CLOCK_SYNCED: "Rellotge sincronitzat" STR_THEME_ROUNDEDRAFF: "RoundedRaff" +STR_THEME_COVER_GRID: "Cover Grid" STR_SEARCH: "Busca" STR_SET_SLEEP_COVER: "Fixa portada" STR_ADD_SERVER: "Afig servidor" diff --git a/lib/I18n/translations/vietnamese.yaml b/lib/I18n/translations/vietnamese.yaml index 12a47705e56..b3ad6fd312e 100644 --- a/lib/I18n/translations/vietnamese.yaml +++ b/lib/I18n/translations/vietnamese.yaml @@ -560,6 +560,7 @@ STR_THEME_LYRA: "Lyra" STR_THEME_LYRA_CAROUSEL: "Lyra Carousel" STR_THEME_MINIMAL: "Minimal" STR_THEME_ROUNDEDRAFF: "RoundedRaff" +STR_THEME_COVER_GRID: "Cover Grid" STR_TOP: "Trên cùng" STR_XTC_STATUS_BAR: "Thanh trạng thái XTC" STR_NEARBY_STATS_VERSION_MISMATCH: "Phiên bản không khớp. Chưa đồng bộ thống kê" diff --git a/scripts/run_simulator_smoke_test.py b/scripts/run_simulator_smoke_test.py index 81955cef8f1..4d592892b91 100755 --- a/scripts/run_simulator_smoke_test.py +++ b/scripts/run_simulator_smoke_test.py @@ -35,6 +35,7 @@ "lyra_carousel": 4, "carousel": 4, "dashboard": 6, + "cover-grid": 7, } diff --git a/src/CrossPointSettings.h b/src/CrossPointSettings.h index 082daba8aaf..395464962b5 100644 --- a/src/CrossPointSettings.h +++ b/src/CrossPointSettings.h @@ -341,7 +341,8 @@ class CrossPointSettings : public PersistableStore { LYRA_CAROUSEL = 4, MINIMAL = 5, DASHBOARD = 6, - UI_THEME_COUNT = 7 + COVER_GRID = 7, + UI_THEME_COUNT = 8 }; enum RECENT_BOOKS_VIEW { RECENT_BOOKS_LIST = 0, RECENT_BOOKS_GRID = 1, RECENT_BOOKS_VIEW_COUNT }; diff --git a/src/SettingsList.h b/src/SettingsList.h index 5491aa2397a..8a271e09372 100644 --- a/src/SettingsList.h +++ b/src/SettingsList.h @@ -19,6 +19,7 @@ #include "KOReaderCredentialStore.h" #include "QuickActions.h" #include "activities/settings/SettingsActivity.h" +#include "components/UITheme.h" #include "util/Dictionary.h" #include "util/DictionaryRegistry.h" #include "util/FontFamilyLabel.h" @@ -569,16 +570,16 @@ inline const std::vector& getBaseSettingsList() { CrossPointSettings::REFRESH_30, CrossPointSettings::REFRESH_NEVER})); add(SettingInfo::Toggle(StrId::STR_NIGHT_MODE, &CrossPointSettings::screenInverted, "screenInverted", StrId::STR_CAT_DISPLAY)); - add(SettingInfo::Enum( - StrId::STR_UI_THEME, &CrossPointSettings::uiTheme, - {StrId::STR_THEME_CLASSIC, StrId::STR_THEME_MINIMAL, StrId::STR_THEME_DASHBOARD, StrId::STR_THEME_LYRA, - StrId::STR_THEME_LYRA_EXTENDED, StrId::STR_THEME_LYRA_CAROUSEL, StrId::STR_THEME_ROUNDEDRAFF}, - "uiTheme", StrId::STR_CAT_DISPLAY) + add(SettingInfo::Enum(StrId::STR_UI_THEME, &CrossPointSettings::uiTheme, + {StrId::STR_THEME_CLASSIC, StrId::STR_THEME_MINIMAL, StrId::STR_THEME_DASHBOARD, + StrId::STR_THEME_LYRA, StrId::STR_THEME_LYRA_EXTENDED, StrId::STR_THEME_LYRA_CAROUSEL, + StrId::STR_THEME_ROUNDEDRAFF, StrId::STR_THEME_COVER_GRID}, + "uiTheme", StrId::STR_CAT_DISPLAY) .withEnumRawValues({CrossPointSettings::UI_THEME::CLASSIC, CrossPointSettings::UI_THEME::MINIMAL, CrossPointSettings::UI_THEME::DASHBOARD, CrossPointSettings::UI_THEME::LYRA, CrossPointSettings::UI_THEME::LYRA_3_COVERS, - CrossPointSettings::UI_THEME::LYRA_CAROUSEL, - CrossPointSettings::UI_THEME::ROUNDEDRAFF})); + CrossPointSettings::UI_THEME::LYRA_CAROUSEL, CrossPointSettings::UI_THEME::ROUNDEDRAFF, + CrossPointSettings::UI_THEME::COVER_GRID})); add(SettingInfo::Enum(StrId::STR_UI_SCALE, &CrossPointSettings::uiScale, {StrId::STR_SMALL, StrId::STR_LARGE}, "uiScale", StrId::STR_CAT_DISPLAY) .withEnumRawValues({CrossPointSettings::UI_SCALE_SMALL, CrossPointSettings::UI_SCALE_LARGE})); @@ -1031,6 +1032,13 @@ inline std::vector getSettingsList(const SdCardFontRegistry* regist removeEnumRawValue(setting, CrossPointSettings::TWO_FINGER_SWIPE_DECREASE_WARMTH); } } + if (!UITheme::supportsCoverGrid()) { + const auto themeIt = + std::find_if(v.begin(), v.end(), [](const SettingInfo& s) { return s.nameId == StrId::STR_UI_THEME; }); + if (themeIt != v.end()) { + removeEnumRawValue(*themeIt, static_cast(CrossPointSettings::UI_THEME::COVER_GRID)); + } + } if (hasTouch) { v.erase(std::remove_if(v.begin(), v.end(), [](const SettingInfo& s) { diff --git a/src/activities/home/HomeActivity.cpp b/src/activities/home/HomeActivity.cpp index 36c916c974a..89e86ac2e8b 100644 --- a/src/activities/home/HomeActivity.cpp +++ b/src/activities/home/HomeActivity.cpp @@ -7,6 +7,8 @@ #include #include #include +#include +#include #include #include #include @@ -606,6 +608,7 @@ static_assert(HomeActivity::kMaxCachedBooks >= LyraCarouselMetrics::values.homeR "kMaxCachedBooks must cover all carousel slots"); int HomeActivity::getMenuItemCount() const { + if (coverGridUi) return static_cast(recentBooks.size()) + (hasOpdsServers ? 5 : 4); const auto& metrics = UITheme::getInstance().getMetrics(); int count = 4; // File Browser, Library, File transfer, Settings if (!metrics.homeContinueReadingInMenu && !recentBooks.empty()) { @@ -628,7 +631,7 @@ int HomeActivity::getMenuItemCount() const { void HomeActivity::loadRecentBooks(int maxBooks) { recentBooks.clear(); const auto& books = RECENT_BOOKS.getBooks(); - recentBooks.reserve(std::min(static_cast(books.size()), maxBooks)); + recentBooks.reserve(coverGridUi ? maxBooks : std::min(static_cast(books.size()), maxBooks)); for (const RecentBook& storedBook : books) { // Limit to maximum number of recent books @@ -646,6 +649,104 @@ void HomeActivity::loadRecentBooks(int maxBooks) { } } +void HomeActivity::fillCoverGridFromLibrary() { + if (recentBooks.size() >= CoverGridHomeUi::MAX_BOOKS) return; + + struct LibraryReader { + library::LibraryIndexFile index; + library::ClixRecord record; + }; + auto reader = makeUniqueNoThrow(); + if (!reader) { + LOG_ERR("HOME", "Cannot allocate library index reader for cover grid"); + return; + } + auto& index = reader->index; + const bool indexOpen = index.open(library::libraryIndexPath()); + const bool needsRefresh = library::libraryIndexNeedsRefresh() || !indexOpen || + index.header().metadataEnabled != static_cast(SETTINGS.libraryUseMetadata != 0); + if (needsRefresh) { + index.close(); + // Home has not painted yet. Give the same visible scan feedback as Library + // before rebuilding an index for a large SD card. + { + RenderLock lock; + renderer.clearScreen(); + GUI.drawPopup(renderer, tr(STR_LIBRARY_SCANNING)); + renderer.displayBuffer(initialRefreshMode); + initialRefreshMode = HalDisplay::FAST_REFRESH; + } + library::BuildStats stats; + if (!library::buildLibraryIndex("/", stats, SETTINGS.libraryUseMetadata != 0) || + !index.open(library::libraryIndexPath())) { + LOG_ERR("HOME", "Cannot populate cover grid from library index"); + return; + } + } + + for (uint16_t row = 0; row < index.bookCount() && recentBooks.size() < CoverGridHomeUi::MAX_BOOKS; ++row) { + RecentBook book; + const uint16_t ordinal = index.ordinalForRow(library::SortOrder::RecentDesc, row); + if (ordinal == 0xffff || !index.readRecord(ordinal, reader->record) || !index.readPath(reader->record, book.path)) { + continue; + } + if (std::any_of(recentBooks.begin(), recentBooks.end(), + [&book](const RecentBook& existing) { return existing.path == book.path; }) || + RecentBooksStore::isMissing(book)) { + continue; + } + if (!index.readDisplayText(reader->record, book.title, book.author)) continue; + recentBooks.push_back(std::move(book)); + } +} + +void HomeActivity::loadCoverGridThumbnails() { + recentsLoading = true; + bool showingLoading = false; + Rect popupRect; + for (size_t i = 0; i < recentBooks.size(); ++i) { + auto& book = recentBooks[i]; + if (book.coverState == RecentBook::CoverState::Missing || !Storage.exists(book.path.c_str())) continue; + const int height = coverGridUi->thumbHeightFor(i); + if (book.coverBmpPath.empty()) { + if (FsHelpers::hasEpubExtension(book.path)) { + auto epub = makeUniqueNoThrow(book.path, "/.crosspoint"); + if (epub) book.coverBmpPath = epub->getThumbBmpPath(); + } else if (FsHelpers::hasXtcExtension(book.path)) { + auto xtc = makeUniqueNoThrow(book.path, "/.crosspoint"); + if (xtc) book.coverBmpPath = xtc->getThumbBmpPath(); + } + } + const std::string thumbPath = UITheme::getCoverThumbPath(book.coverBmpPath, height); + if (thumbPath.empty() || Storage.exists(thumbPath.c_str())) continue; + if (!showingLoading) { + showingLoading = true; + popupRect = GUI.drawPopup(renderer, tr(STR_LOADING_POPUP)); + } + GUI.fillPopupProgress(renderer, popupRect, static_cast(100 * i / std::max(1, recentBooks.size()))); + renderer.displayBuffer(); + if (FsHelpers::hasEpubExtension(book.path)) { + auto epub = makeUniqueNoThrow(book.path, "/.crosspoint"); + if (!epub) { + LOG_ERR("HOME", "Cannot allocate EPUB for cover grid thumbnail"); + continue; + } + if (!epub->generateThumbBmpFromSource(height, &renderer, SETTINGS.getReaderFontId())) { + LOG_ERR("HOME", "Cannot create cover grid thumbnail: %s", book.path.c_str()); + } + } else if (FsHelpers::hasXtcExtension(book.path)) { + auto xtc = makeUniqueNoThrow(book.path, "/.crosspoint"); + if (!xtc) { + LOG_ERR("HOME", "Cannot allocate XTC for cover grid thumbnail"); + continue; + } + if (xtc->load()) xtc->generateThumbBmp(height); + } + } + recentsLoaded = true; + recentsLoading = false; +} + void HomeActivity::loadAllBookStats() { const auto start = millis(); const int count = std::min(static_cast(recentBooks.size()), kMaxCachedBooks); @@ -864,6 +965,10 @@ void HomeActivity::onEnter() { Activity::onEnter(); hasOpdsServers = OPDS_STORE.hasServers(); + if (UITheme::hasCoverGridHome()) { + coverGridUi = makeUniqueNoThrow(renderer); + if (!coverGridUi) LOG_ERR("HOME", "Cannot allocate cover grid UI; using standard Home"); + } const bool isCarouselTheme = static_cast(SETTINGS.uiTheme) == CrossPointSettings::UI_THEME::LYRA_CAROUSEL; @@ -886,9 +991,12 @@ void HomeActivity::onEnter() { const auto& metrics = UITheme::getInstance().getMetrics(); const int recentBooksToLoad = - std::min(kMaxCachedBooks, std::max(metrics.homeRecentBooksCount, HOME_BOOK_SWAP_RECENT_COUNT)); + coverGridUi ? CoverGridHomeUi::MAX_BOOKS + : std::min(kMaxCachedBooks, std::max(metrics.homeRecentBooksCount, HOME_BOOK_SWAP_RECENT_COUNT)); RECENT_BOOKS.ensureLoaded(); loadRecentBooks(recentBooksToLoad); + gridHasContinueReading = !recentBooks.empty(); + if (coverGridUi) fillCoverGridFromLibrary(); const auto selectInitialBook = [this, &metrics](const std::string& path) { if (path.empty()) { @@ -897,7 +1005,7 @@ void HomeActivity::onEnter() { for (int i = 0; i < static_cast(recentBooks.size()); ++i) { if (recentBooks[i].path == path) { - if (metrics.homeRecentBooksCount == 1 && i > 0) { + if (metrics.homeRecentBooksCount == 1 && i > 0 && !coverGridUi) { std::rotate(recentBooks.begin(), recentBooks.begin() + i, recentBooks.end()); selectorIndex = 0; lastCarouselBookIndex = 0; @@ -923,7 +1031,30 @@ void HomeActivity::onEnter() { } updateHighlightedBookContext(false); - if (initialMenuItem != HomeMenuItem::NONE) { + if (coverGridUi) { + const int base = static_cast(recentBooks.size()); + switch (initialMenuItem) { + case HomeMenuItem::FILE_BROWSER: + selectorIndex = base; + break; + case HomeMenuItem::LIBRARY: + selectorIndex = base + 1; + break; + case HomeMenuItem::OPDS_BROWSER: + selectorIndex = base + 2; + break; + case HomeMenuItem::FILE_TRANSFER: + selectorIndex = base + (hasOpdsServers ? 3 : 2); + break; + case HomeMenuItem::SETTINGS_MENU: + selectorIndex = base + (hasOpdsServers ? 4 : 3); + break; + case HomeMenuItem::NONE: + break; + } + coverGridUi->begin(recentBooks, hasOpdsServers, gridHasContinueReading, + gridHasContinueReading ? loadRecentBookProgress(recentBooks.front()) : -1.0f); + } else if (initialMenuItem != HomeMenuItem::NONE) { const bool includeContinueReading = metrics.homeContinueReadingInMenu && !recentBooks.empty(); const auto menuItems = buildSelectableHomeMenuItems(hasOpdsServers, hasReadingStats, hasBookmarks, hasClippings, includeContinueReading); @@ -946,15 +1077,21 @@ int HomeActivity::getHighlightedBookIndex() const { return -1; } + if (coverGridUi) { + return selectorIndex < static_cast(recentBooks.size()) ? selectorIndex : 0; + } + const int visibleBookCount = getVisibleRecentBookCount(); const int highlightedBookIdx = (selectorIndex < visibleBookCount) ? selectorIndex : lastCarouselBookIndex; return std::clamp(highlightedBookIdx, 0, visibleBookCount - 1); } -int HomeActivity::getVisibleRecentBookCount() const { return ::getVisibleRecentBookCount(recentBooks); } +int HomeActivity::getVisibleRecentBookCount() const { + return coverGridUi ? static_cast(recentBooks.size()) : ::getVisibleRecentBookCount(recentBooks); +} bool HomeActivity::canSwapHomeBook() const { - return UITheme::getInstance().getMetrics().homeRecentBooksCount == 1 && recentBooks.size() > 1; + return !coverGridUi && UITheme::getInstance().getMetrics().homeRecentBooksCount == 1 && recentBooks.size() > 1; } void HomeActivity::showNextRecentBookOnHome() { @@ -1087,6 +1224,7 @@ void HomeActivity::updateHighlightedBookContext(const bool allowChapterTitleRead void HomeActivity::onExit() { Activity::onExit(); + coverGridUi.reset(); carouselMenuTouchDownIndex = -1; freeCoverBuffer(); gCarouselCache.invalidate(); @@ -1526,6 +1664,42 @@ void HomeActivity::loop() { if (quickActionsPopup.handleInput(mappedInput, [this] { requestUpdate(); })) return; + if (coverGridUi) { + const int touched = coverGridUi->selectedAction(mappedInput); + if (coverGridUi->app.invalidated()) requestUpdate(); + if (touched >= 0 && touched < getMenuItemCount()) { + selectorIndex = touched; + activateCoverGridSelection(); + return; + } + if (mappedInput.wasPressed(MappedInputManager::Button::Back)) backPressSeen = true; + if (mappedInput.wasReleased(MappedInputManager::Button::Back) && backPressSeen && gridHasContinueReading) { + onSelectBook(recentBooks.front().path); + return; + } + if (mappedInput.wasReleased(MappedInputManager::Button::Confirm)) { + activateCoverGridSelection(); + return; + } + + const int bookCount = static_cast(recentBooks.size()); + const int tabCount = hasOpdsServers ? 5 : 4; + const auto cycleBand = [this](const int base, const int count, const int dir) { + if (count <= 0) return; + const int current = selectorIndex - base; + selectorIndex = + base + (current < 0 || current >= count ? (dir > 0 ? 0 : count - 1) : (current + count + dir) % count); + requestUpdate(); + }; + buttonNavigator.onPressAndContinuous({MappedInputManager::Button::Up}, [&] { cycleBand(0, bookCount, -1); }); + buttonNavigator.onPressAndContinuous({MappedInputManager::Button::Down}, [&] { cycleBand(0, bookCount, 1); }); + buttonNavigator.onPressAndContinuous({MappedInputManager::Button::Left}, + [&] { cycleBand(bookCount, tabCount, -1); }); + buttonNavigator.onPressAndContinuous({MappedInputManager::Button::Right}, + [&] { cycleBand(bookCount, tabCount, 1); }); + return; + } + if (usesMinimalHomeInteraction()) { const int pressedFrontButton = mappedInput.getPressedFrontButton(); const int releasedFrontButton = mappedInput.getReleasedFrontButton(); @@ -2080,6 +2254,40 @@ void HomeActivity::loop() { } } +void HomeActivity::activateCoverGridSelection() { + if (selectorIndex < 0) return; + if (selectorIndex < static_cast(recentBooks.size())) { + onSelectBook(recentBooks[selectorIndex].path); + return; + } + const int tab = selectorIndex - static_cast(recentBooks.size()); + switch (tab) { + case 0: + onFileBrowserOpen(); + break; + case 1: + onLibraryOpen(); + break; + case 2: + if (hasOpdsServers) { + onOpdsBrowserOpen(); + } else { + onFileTransferOpen(); + } + break; + case 3: + if (hasOpdsServers) { + onFileTransferOpen(); + } else { + onSettingsOpen(); + } + break; + case 4: + if (hasOpdsServers) onSettingsOpen(); + break; + } +} + bool HomeActivity::handleShortcutAction(const CrossPointSettings::SHORT_PWRBTN action) { if (action == CrossPointSettings::SHORT_PWRBTN::FILE_BROWSER) { onFileBrowserOpen(); @@ -2129,6 +2337,30 @@ void HomeActivity::render(RenderLock&&) { initialRefreshMode = HalDisplay::FAST_REFRESH; }; + if (coverGridUi) { + renderer.clearScreen(); + coverGridUi->setSelection(selectorIndex); + coverGridUi->render(); + const auto labels = mappedInput.mapLabels(gridHasContinueReading ? tr(STR_READ) : "", tr(STR_SELECT), + tr(STR_DIR_LEFT), tr(STR_DIR_RIGHT)); + GUI.drawButtonHints(renderer, labels.btn1, labels.btn2, labels.btn3, labels.btn4); + displayHomeBuffer(); + + if (coverGridUi->takeThumbHeightsChanged()) { + coverGridUi->refreshCoverPaths(); + recentsLoaded = false; + } + if (!firstRenderDone) { + firstRenderDone = true; + requestUpdate(); + } else if (!recentsLoaded && !recentsLoading) { + loadCoverGridThumbnails(); + coverGridUi->refreshCoverPaths(); + requestUpdate(); + } + return; + } + if (usesMinimalHomeInteraction()) { renderer.clearScreen(); diff --git a/src/activities/home/HomeActivity.h b/src/activities/home/HomeActivity.h index 90941864be5..8212b495957 100644 --- a/src/activities/home/HomeActivity.h +++ b/src/activities/home/HomeActivity.h @@ -13,6 +13,7 @@ #include "activities/Activity.h" #include "activities/reader/BookReadingStats.h" #include "activities/reader/GlobalReadingStats.h" +#include "components/CoverGridHomeUi.h" #include "components/OptionPopup.h" #include "util/ButtonNavigator.h" @@ -29,6 +30,8 @@ class HomeActivity final : public Activity { private: ButtonNavigator buttonNavigator; + std::unique_ptr coverGridUi; + bool gridHasContinueReading = false; int selectorIndex = 0; int lastCarouselBookIndex = 0; // remembered position when leaving carousel row int carouselCoverTouchDownIndex = -1; @@ -125,6 +128,9 @@ class HomeActivity final : public Activity { void showNextRecentBookOnHome(); void updateHighlightedBookContext(bool allowChapterTitleRead = true); void loadRecentBooks(int maxBooks); + void fillCoverGridFromLibrary(); + void loadCoverGridThumbnails(); + void activateCoverGridSelection(); void loadAllBookStats(); void loadRecentCovers(int coverHeight); diff --git a/src/components/CoverGridHomeUi.cpp b/src/components/CoverGridHomeUi.cpp new file mode 100644 index 00000000000..6d02c346f54 --- /dev/null +++ b/src/components/CoverGridHomeUi.cpp @@ -0,0 +1,265 @@ +#include "CoverGridHomeUi.h" + +#include +#include +#include + +#include +#include +#include + +#include "MappedInputManager.h" +#include "UITheme.h" +#include "components/icons/listIcons.h" + +namespace fui = freeink::ui; +namespace { +constexpr fui::ActionId SELECT = 1; +constexpr int16_t COVER_CELL_INSET = 6; +} // namespace + +CoverGridHomeUi::CoverGridHomeUi(GfxRenderer& renderer) + : UiAppHost(renderer), coverCache(renderer), renderer(renderer) {} + +void CoverGridHomeUi::begin(const std::vector& recent, bool opds, bool continuing, float featuredProgress) { + books = &recent; + hasOpds = opds; + if (!recent.empty()) coverCache.begin(); + reset(); + app.on(SELECT, &CoverGridHomeUi::onAction, this); + app.setScreen(&CoverGridHomeUi::screenFn, this); + refreshCoverPaths(); + progress = continuing && featuredProgress >= 0 ? static_cast(featuredProgress + 0.5f) : -1; + if (progress >= 0) snprintf(progressText, sizeof(progressText), "%d%%", progress); +} + +void CoverGridHomeUi::refreshCoverPaths() { + coverCache.invalidate(); + for (size_t i = 0; i < books->size() && i < coverPaths.size(); ++i) refreshCoverPath(i); +} + +void CoverGridHomeUi::refreshCoverPath(size_t index) { + if (index >= books->size() || index >= coverPaths.size()) return; + coverCache.invalidate(index); + coverPaths[index] = thumbHeights[index] > 0 + ? UITheme::getCoverThumbPath((*books)[index].coverBmpPath, thumbHeights[index]) + : std::string(); +} + +int CoverGridHomeUi::thumbHeightFor(size_t index) const { + return index < thumbHeights.size() && thumbHeights[index] > 0 ? thumbHeights[index] : THUMB_HEIGHT; +} + +bool CoverGridHomeUi::takeThumbHeightsChanged() { return std::exchange(thumbHeightsChanged, false); } + +void CoverGridHomeUi::noteThumbHeight(size_t index, int slotWidth, int slotHeight) { + if (index >= thumbHeights.size()) return; + // Thumbs cover a (0.6*h, h) target box, so a height of max(h, w*5/3) makes + // every cover overfill the slot; the cover renderer crops the overflow (full bleed). + const int height = std::max({1, slotHeight, slotWidth * 5 / 3 + 2}); + if (thumbHeights[index] != height) { + thumbHeights[index] = height; + thumbHeightsChanged = true; + refreshCoverPath(index); + } +} + +void CoverGridHomeUi::onAction(const fui::ActionEvent& event, void* user) { + auto& self = *static_cast(user); + self.pending = event.value; + self.app.clearTapFlash(); +} + +int CoverGridHomeUi::selectedAction(const MappedInputManager& input) { + pending = -1; + fui::ActionEvent event{}; + return routeTouch(input, event) ? pending : -1; +} + +void CoverGridHomeUi::screenFn(UiScreen& screen, void* user) { static_cast(user)->draw(screen); } + +void CoverGridHomeUi::draw(UiScreen& screen) { + coverCache.prepare(); + const auto& theme = screen.theme(); + const auto safe = UITheme::getInstance().getScreenSafeArea(renderer, !BoardConfig::hasTouch()); + screen.setContentMarginFromScreen(fui::Insets{ + static_cast(safe.y), static_cast(renderer.getScreenWidth() - safe.x - safe.width), + static_cast(renderer.getScreenHeight() - safe.y - safe.height), static_cast(safe.x)}); + const int16_t hInset = std::max( + 0, static_cast(UITheme::getInstance().getMetrics().headerSidePadding - COVER_CELL_INSET - safe.x)); + const int16_t topInset = + BoardConfig::hasTouch() ? static_cast(theme.spaceLg) : static_cast(theme.spaceSm + 4); + screen.insetContent(fui::Insets{topInset, hInset, theme.spaceSm, hInset}); + const bool landscape = renderer.getScreenWidth() > renderer.getScreenHeight(); + screen.takeTop(UITheme::getInstance().getMetrics().batteryBarHeight, theme.spaceSm); + const int16_t tabGap = BoardConfig::hasTouch() ? theme.spaceSm : static_cast(4); + auto tabRect = screen.takeBottom(72, tabGap); + if (books->empty()) { + drawTabs(screen, tabRect.inset(fui::Insets{0, COVER_CELL_INSET, 0, COVER_CELL_INSET})); + drawEmpty(screen); + drawHeaderBand(); + return; + } + const fui::Rect body = screen.body(); + const int rowGap = std::max(4, body.width / 100); + grid.gap = grid.rowGap = rowGap; + const int coverRowHeight = std::max(1, (body.height - theme.spaceSm - rowGap) / 3); + const int16_t featuredHeight = std::min( + body.height, std::max(coverRowHeight, screen.target().lineHeight(theme.bodyText.font) * (landscape ? 1 : 2) + + screen.target().lineHeight(theme.smallText.font) * 2 + 32)); + const auto featuredRect = screen.takeTop(featuredHeight, theme.spaceSm); + const int gridRowHeight = std::max(1, (screen.body().height - rowGap) / GRID_ROWS); + drawCurrent(screen, featuredRect, std::min(coverRowHeight, gridRowHeight)); + drawGrid(screen); + tabRect.x = gridBounds.x + grid.cellInset.left; + tabRect.width = gridBounds.width - grid.cellInset.left - grid.cellInset.right; + drawTabs(screen, tabRect); + drawHeaderBand(); +} + +void CoverGridHomeUi::drawHeaderBand() { + const auto& metrics = UITheme::getInstance().getMetrics(); + GUI.drawHeader(renderer, Rect{0, metrics.topPadding, renderer.getScreenWidth(), metrics.homeTopPadding}, nullptr); +} + +void CoverGridHomeUi::drawEmpty(UiScreen& screen) { + const auto& theme = screen.theme(); + const auto body = screen.body(); + auto title = theme.titleText; + title.bold = true; + title.align = fui::TextAlign::Center; + auto message = theme.bodyText; + message.align = fui::TextAlign::Center; + constexpr int16_t ICON_SIZE = 32; + const int16_t titleHeight = screen.target().lineHeight(title.font); + const int16_t messageHeight = screen.target().lineHeight(message.font); + const int16_t contentHeight = ICON_SIZE + theme.spaceLg + titleHeight + theme.spaceSm + messageHeight; + int16_t y = body.y + std::max(0, (body.height - contentHeight) / 2); + drawLucideIcon(renderer, icon_book_32, body.x + (body.width - ICON_SIZE) / 2, y); + y += ICON_SIZE + theme.spaceLg; + screen.target().text(fui::Rect{body.x, y, body.width, titleHeight}, tr(STR_NO_OPEN_BOOK), title); + y += titleHeight + theme.spaceSm; + screen.target().text(fui::Rect{body.x, y, body.width, messageHeight}, tr(STR_START_READING), message); +} + +void CoverGridHomeUi::drawCurrent(UiScreen& screen, fui::Rect rect, const int coverRowHeight) { + const auto& theme = screen.theme(); + const auto& book = books->front(); + card.title = book.title.c_str(); + card.author = book.author.empty() ? nullptr : book.author.c_str(); + card.meta = nullptr; + card.progressLabel = progress >= 0 ? progressText : nullptr; + card.centerTextOnCover = true; + card.progress = std::max(0, progress); + card.progressMax = progress >= 0 ? 100 : 0; + card.action = SELECT; + card.state = fui::StateNormal; + card.styles = theme.listRow; + card.styles.selected.background = fui::Paint::dither(fui::Color::LightGray); + card.styles.selected.border = fui::Paint::dither(fui::Color::LightGray); + card.styles.selected.foreground = fui::Paint::solid(fui::Color::Black); + card.styles.selected.radius = theme.listRowRadius; + card.styles.active = card.styles.selected; + card.titleText = theme.bodyText; + card.titleText.maxLines = renderer.getScreenWidth() > renderer.getScreenHeight() ? 1 : 2; + card.authorText = theme.smallText; + card.progressText = theme.smallText; + card.progressHeight = 6; + card.padding = fui::Insets{6, 6, 6, 6}; + card.gap = theme.spaceLg + theme.spaceSm; + const int maxCoverWidth = rect.width / GRID_COLUMNS - 2 * COVER_CELL_INSET; + card.coverSize.height = std::max(1, std::min(std::min(rect.height, coverRowHeight) - 12, maxCoverWidth * 3 / 2)); + card.coverSize.width = std::max(1, card.coverSize.height * 2 / 3); + noteThumbHeight(0, card.coverSize.width, card.coverSize.height); + gridBounds = layoutGrid(screen.body()); + rect.x = gridBounds.x; + rect.width = gridBounds.width; + card.coverPainterUserData = this; + card.coverPainter = [](fui::DrawTarget& target, fui::Rect cover, const fui::BookCardProps&, void* user) { + return static_cast(user)->paintFramedCover(target, cover, 0); + }; + fui::bookCard(screen.frame(), rect, card); + + if (selected == 0 && !BoardConfig::hasTouch()) { + const int16_t barHeight = card.coverSize.height; + screen.target().fill(fui::Rect{static_cast(rect.x - 9), + static_cast(rect.y + (rect.height - barHeight) / 2), 3, barHeight}, + fui::Paint::dither(fui::Color::LightGray)); + } +} + +fui::Rect CoverGridHomeUi::layoutGrid(fui::Rect rect) { + grid.cellInset = fui::Insets{COVER_CELL_INSET, COVER_CELL_INSET, COVER_CELL_INSET, COVER_CELL_INSET}; + grid.coverSize = card.coverSize; + grid.rowHeight = grid.coverSize.height + 12; + rect.height = GRID_ROWS * grid.rowHeight + (GRID_ROWS - 1) * grid.rowGap; + return rect; +} + +void CoverGridHomeUi::drawGrid(UiScreen& screen) { + const auto rect = gridBounds; + grid.count = books->size() > 1 ? books->size() - 1 : 0; + grid.columns = GRID_COLUMNS; + grid.columnLayout = fui::CoverGridColumnLayout::SpaceBetween; + grid.action = SELECT; + grid.inputMask = fui::InputTouch; + grid.selectedIndex = selected > 0 && selected < static_cast(books->size()) ? selected - 1 : -1; + grid.selectionIndicator = fui::CoverGridSelectionIndicator::CoverFrame; + grid.selectedCoverFrameGap = 6; + grid.selectedCoverFrameWidth = 8; + grid.cellStyles = card.styles; + grid.labelHeight = 0; + grid.labelGap = 0; + for (size_t i = 1; i < thumbHeights.size(); ++i) noteThumbHeight(i, grid.coverSize.width, grid.coverSize.height); + grid.scrollIndicator = false; + grid.itemProvider = [](uint16_t index, void*) { return fui::coverGridItem(nullptr, index + 1); }; + grid.coverPainterUserData = this; + grid.coverPainter = [](fui::DrawTarget& target, fui::Rect cover, const fui::CoverGridItem&, uint16_t index, + void* user) { + return static_cast(user)->paintFramedCover(target, cover, index + 1); + }; + fui::coverGrid(screen.frame(), rect, grid); +} + +void CoverGridHomeUi::drawTabs(UiScreen& screen, fui::Rect rect) { + static constexpr const freeink::Icon* ICONS[] = {&icon_folder_32, &icon_landmark_32, &icon_lyra_library_32, + &icon_lyra_transfer_32, &icon_lyra_settings_32}; + int count = 0; + for (int i = 0; i < 5; ++i) { + if (i == 2 && !hasOpds) continue; + auto& tab = tabItems[count]; + tab.value = books->size() + count; + tab.selected = selected == tab.value; + tab.label = nullptr; + ++count; + } + tabs.tabs = tabItems.data(); + tabs.count = count; + tabs.layout = fui::TabBarLayout::SpaceBetween; + tabs.action = SELECT; + tabs.inputMask = fui::InputTouch; + tabs.iconSize = 32; + tabs.iconPainterUserData = this; + tabs.iconPainter = [](fui::DrawTarget&, fui::Rect iconRect, const fui::TabItem& tab, uint8_t, void* user) { + const auto& self = *static_cast(user); + const int index = tab.value - static_cast(self.books->size()); + const int icon = !self.hasOpds && index >= 2 ? index + 1 : index; + drawLucideIcon(self.renderer, *ICONS[icon], iconRect.x, iconRect.y); + return true; + }; + tabs.tabStyles.normal.background = fui::Paint::solid(fui::Color::White); + tabs.tabStyles.selected.background = fui::Paint::solid(fui::Color::White); + tabs.selectedUnderline = 2; + tabs.distributedSlotWidth = 0; + fui::tabBar(screen.frame(), rect, tabs); +} + +bool CoverGridHomeUi::paintFramedCover(fui::DrawTarget& target, fui::Rect rect, size_t index) { + constexpr int16_t SHADOW_OFFSET = 2; + const auto ink = fui::Paint::solid(fui::Color::Black); + target.fill(fui::Rect{rect.right(), static_cast(rect.y + SHADOW_OFFSET), SHADOW_OFFSET, rect.height}, ink); + target.fill(fui::Rect{static_cast(rect.x + SHADOW_OFFSET), rect.bottom(), rect.width, SHADOW_OFFSET}, ink); + const bool drawn = index < coverPaths.size() && coverCache.paint(rect, index, coverPaths[index]); + target.stroke(rect, ink, 1, 0); + return drawn; +} diff --git a/src/components/CoverGridHomeUi.h b/src/components/CoverGridHomeUi.h new file mode 100644 index 00000000000..f246a03e61e --- /dev/null +++ b/src/components/CoverGridHomeUi.h @@ -0,0 +1,63 @@ +#pragma once + +#include +#include +#include + +#include "HomeCoverCache.h" +#include "RecentBooksStore.h" +#include "UiAppHost.h" +#include "components/bars/tab-bar.h" +#include "components/media/book-card.h" +#include "components/media/cover-grid.h" + +class CoverGridHomeUi final : public UiAppHost<16, 1> { + public: + using UiScreen = UiAppHost<16, 1>::Screen; + static constexpr int THUMB_HEIGHT = 400; + static constexpr int GRID_COLUMNS = 3; + static constexpr int GRID_ROWS = 2; + static constexpr int MAX_BOOKS = 1 + GRID_COLUMNS * GRID_ROWS; + static_assert(MAX_BOOKS <= HomeCoverCache::MAX_COVERS); + explicit CoverGridHomeUi(GfxRenderer& renderer); + void begin(const std::vector& books, bool hasOpds, bool hasContinueReading, float featuredProgress); + void refreshCoverPaths(); + void setSelection(int selection) { selected = selection; } + int selectedAction(const MappedInputManager& input); + // Exact generation height for a slot, recorded during draw. Thumbs must be + // generated at the drawn size: rescaling a dithered 1-bit image aliases badly. + int thumbHeightFor(size_t index) const; + bool takeThumbHeightsChanged(); + + private: + static void screenFn(UiScreen& screen, void* user); + static void onAction(const freeink::ui::ActionEvent& event, void* user); + void draw(UiScreen& screen); + void drawHeaderBand(); + void drawEmpty(UiScreen& screen); + void drawCurrent(UiScreen& screen, freeink::ui::Rect rect, int coverRowHeight); + void drawGrid(UiScreen& screen); + freeink::ui::Rect layoutGrid(freeink::ui::Rect rect); + void drawTabs(UiScreen& screen, freeink::ui::Rect rect); + bool paintFramedCover(freeink::ui::DrawTarget& target, freeink::ui::Rect rect, size_t index); + void refreshCoverPath(size_t index); + void noteThumbHeight(size_t index, int slotWidth, int slotHeight); + + HomeCoverCache coverCache; + GfxRenderer& renderer; + const std::vector* books = nullptr; + std::array coverPaths; + std::array thumbHeights{}; + bool thumbHeightsChanged = false; + int selected = 0; + int pending = -1; + int progress = -1; + bool hasOpds = false; + char progressText[12]{}; + // Component styles and interaction tables stay off the render task's stack. + freeink::ui::BookCardProps card; + freeink::ui::CoverGridProps grid; + freeink::ui::Rect gridBounds{}; + freeink::ui::TabBarProps tabs; + std::array tabItems; +}; diff --git a/src/components/HomeCoverCache.cpp b/src/components/HomeCoverCache.cpp new file mode 100644 index 00000000000..0852f435b34 --- /dev/null +++ b/src/components/HomeCoverCache.cpp @@ -0,0 +1,88 @@ +#include "HomeCoverCache.h" + +#include +#include + +#include + +#include "UITheme.h" + +namespace fui = freeink::ui; + +void HomeCoverCache::begin() { + // Cover regions do not overlap. Each may widen by one physical byte per row. + coverCacheCapacity = renderer.getRegionByteSize(0, 0, renderer.getScreenWidth(), renderer.getScreenHeight()) + + MAX_COVERS * std::max(renderer.getScreenWidth(), renderer.getScreenHeight()); + coverCache = makePsramByteBufferNoThrow(coverCacheCapacity); + if (!coverCache) { +#if defined(SIMULATOR) + LOG_DBG("HOME", "PSRAM cover cache unavailable in simulator; rendering uncached"); +#else + LOG_ERR("HOME", "PSRAM cover cache unavailable (%u bytes); rendering uncached", unsigned(coverCacheCapacity)); +#endif + coverCacheCapacity = 0; + } +} + +void HomeCoverCache::invalidate() { + coverCacheUsed = 0; + cachedCovers.fill(CachedCover{}); +} + +void HomeCoverCache::invalidate(size_t index) { + if (index < cachedCovers.size()) cachedCovers[index].valid = false; +} + +void HomeCoverCache::prepare() { + const int orientation = static_cast(renderer.getOrientation()); + if (coverCacheOrientation != orientation || coverCacheInverted != bool(SETTINGS.screenInverted)) { + invalidate(); + coverCacheOrientation = orientation; + coverCacheInverted = SETTINGS.screenInverted; + } +} + +bool HomeCoverCache::paint(fui::Rect rect, size_t index, const std::string& path) { + if (index >= cachedCovers.size()) return false; + auto& cached = cachedCovers[index]; + if (coverCache && cached.valid && cached.rect.x == rect.x && cached.rect.y == rect.y && + cached.rect.width == rect.width && cached.rect.height == rect.height && + renderer.copyBufferToRegion(rect.x, rect.y, rect.width, rect.height, coverCache.get() + cached.offset, + cached.bytes)) { + return true; + } + cached.valid = false; + bool drawn = false; + if (!path.empty() && Storage.openFileForRead("HOME", path, coverFile)) { + if (coverBitmap.parseHeaders() == BmpReaderError::Ok && coverBitmap.rewindToData() == BmpReaderError::Ok && + coverBitmap.getWidth() > 0 && coverBitmap.getHeight() > 0) { + const int width = coverBitmap.getWidth(); + const int height = coverBitmap.getHeight(); + const float cropX = width > rect.width ? 1.0f - static_cast(rect.width) / width : 0.0f; + const float cropY = height > rect.height ? 1.0f - static_cast(rect.height) / height : 0.0f; + const int x = rect.x + std::max(0, (rect.width - width) / 2); + const int y = rect.y + std::max(0, (rect.height - height) / 2); + drawn = renderer.drawBitmap(coverBitmap, x, y, rect.width, rect.height, cropX, cropY); + } + coverFile.close(); + } + if (!drawn) { + renderer.fillRect(rect.x, rect.y, rect.width, rect.height, false); + renderer.fillRect(rect.x, rect.y + rect.height / 3, rect.width, rect.height - rect.height / 3, true); + renderer.drawRect(rect.x, rect.y, rect.width, rect.height, true); + } + if (coverCache) { + const size_t needed = renderer.getRegionByteSize(rect.x, rect.y, rect.width, rect.height); + if (needed > cached.bytes && needed <= coverCacheCapacity - coverCacheUsed) { + cached.offset = coverCacheUsed; + cached.bytes = needed; + coverCacheUsed += needed; + } + if (needed > 0 && needed <= cached.bytes) { + cached.rect = rect; + cached.valid = renderer.copyRegionToBuffer(rect.x, rect.y, rect.width, rect.height, + coverCache.get() + cached.offset, cached.bytes); + } + } + return true; // The cover slot is painted, including fallback art. +} diff --git a/src/components/HomeCoverCache.h b/src/components/HomeCoverCache.h new file mode 100644 index 00000000000..57b6599b9e9 --- /dev/null +++ b/src/components/HomeCoverCache.h @@ -0,0 +1,41 @@ +#pragma once + +#include +#include +#include +#include + +#include +#include + +class GfxRenderer; + +// Screen-lifetime thumbnail decoding and PSRAM region snapshots. +class HomeCoverCache { + public: + static constexpr size_t MAX_COVERS = 7; + explicit HomeCoverCache(GfxRenderer& renderer) : renderer(renderer) {} + void begin(); + void prepare(); + void invalidate(); + void invalidate(size_t index); + bool paint(freeink::ui::Rect rect, size_t index, const std::string& path); + + private: + struct CachedCover { + freeink::ui::Rect rect{}; + size_t offset = 0; + size_t bytes = 0; + bool valid = false; + }; + HeapByteBuffer coverCache; + std::array cachedCovers{}; + size_t coverCacheCapacity = 0; + size_t coverCacheUsed = 0; + int coverCacheOrientation = -1; + bool coverCacheInverted = false; + + HalFile coverFile; + Bitmap coverBitmap{coverFile}; + GfxRenderer& renderer; +}; diff --git a/src/components/UITheme.cpp b/src/components/UITheme.cpp index 6dfe2424241..dcaf88aa969 100644 --- a/src/components/UITheme.cpp +++ b/src/components/UITheme.cpp @@ -6,6 +6,7 @@ #include #include #include +#include #include #include @@ -66,13 +67,34 @@ void UITheme::reload() { setTheme(themeType); } +bool UITheme::supportsCoverGrid() { +#if defined(SIMULATOR) + return +#if defined(SIMULATOR_DEVICE_STICKY) || defined(SIMULATOR_DEVICE_X4_PRO) || defined(SIMULATOR_DEVICE_X4_CLASSIC) + true; +#else + false; +#endif +#else + return psramHeapAvailable(); +#endif +} + +bool UITheme::hasCoverGridHome() { + return SETTINGS.uiTheme == CrossPointSettings::UI_THEME::COVER_GRID && supportsCoverGrid(); +} + void UITheme::setTheme(CrossPointSettings::UI_THEME type) { + if (type == CrossPointSettings::UI_THEME::COVER_GRID && !supportsCoverGrid()) { + type = CrossPointSettings::UI_THEME::LYRA; + } switch (type) { case CrossPointSettings::UI_THEME::CLASSIC: LOG_DBG("UI", "Using Classic theme"); currentTheme = std::make_unique(); currentMetrics = &BaseMetrics::values; break; + case CrossPointSettings::UI_THEME::COVER_GRID: case CrossPointSettings::UI_THEME::LYRA: LOG_DBG("UI", "Using Lyra theme"); currentTheme = std::make_unique(); diff --git a/src/components/UITheme.h b/src/components/UITheme.h index 11cbb8a3f3b..29eee353f8e 100644 --- a/src/components/UITheme.h +++ b/src/components/UITheme.h @@ -32,6 +32,8 @@ class UITheme { EpdFontFamily::Style style = EpdFontFamily::REGULAR, int lineSpacing = 0); void reload(); void setTheme(CrossPointSettings::UI_THEME type); + static bool supportsCoverGrid(); + static bool hasCoverGridHome(); static int getNumberOfItemsPerPage(const GfxRenderer& renderer, bool hasHeader, bool hasTabBar, bool hasButtonHints, bool hasSubtitle, int extraReservedHeight = 0); // Returns the cache path for a generated thumbnail using the default 3:5 From 01121366ff09ec73e9b74a256fe718c438088a97 Mon Sep 17 00:00:00 2001 From: Julia Nguyen Date: Mon, 28 Sep 2026 16:29:59 -0400 Subject: [PATCH 55/62] feat: allow independent customization of each side button --- CHANGELOG.md | 1 + src/CrossPointSettings.cpp | 69 ++++++++- src/CrossPointSettings.h | 19 ++- src/MappedInputManager.cpp | 71 +++++---- src/MappedInputManager.h | 4 + src/QuickActions.h | 36 +++++ src/SettingsList.h | 87 ++++++++--- .../reader/ControlsOptionsActivity.cpp | 16 +- src/activities/reader/EndOfBookOptions.cpp | 12 +- src/activities/reader/EpubReaderActivity.cpp | 118 ++++++-------- src/activities/reader/EpubReaderActivity.h | 3 +- src/activities/reader/ReaderUtils.h | 12 +- src/activities/reader/SideButtonShortcuts.h | 83 ++++++++++ src/activities/reader/TxtReaderActivity.cpp | 69 +++++---- src/activities/reader/TxtReaderActivity.h | 3 +- src/activities/reader/XtcReaderActivity.cpp | 146 +++++++++--------- src/activities/reader/XtcReaderActivity.h | 5 +- .../settings/QuickActionsActivity.cpp | 18 ++- src/activities/settings/SettingsActivity.cpp | 18 ++- src/main.cpp | 14 ++ src/network/CrossPointWebServer.cpp | 9 +- src/simulator/SimulatorSmokeTest.cpp | 128 +++++++++++++++ src/util/ButtonShortcutController.h | 40 +++++ src/util/DictionaryActivityUtils.h | 12 +- src/util/QuickLockTrigger.h | 4 + 25 files changed, 724 insertions(+), 273 deletions(-) create mode 100644 src/activities/reader/SideButtonShortcuts.h diff --git a/CHANGELOG.md b/CHANGELOG.md index 326bf9603d3..b14730e86cd 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -3,6 +3,7 @@ ### Added - Turn reading stats tracking on or off for the whole device or individual EPUB and XTC books, while keeping saved history and Time Left estimates. +- Assign separate short-press and long-press actions to the Left/Up and Right/Down side buttons; existing side-button layouts migrate to matching individual actions. - Assign Library to power, long-press, button-chord, Home-button, or Quick Actions shortcuts to open the book list directly. - Customize the top and bottom reader status bars separately, including item positions and progress bars, in EPUB, TXT, and XTC books. Each bar can be previewed where it appears while reading. - View a selected book's reading stats from its Library or File Browser action menu. diff --git a/src/CrossPointSettings.cpp b/src/CrossPointSettings.cpp index f116312e17f..ad6a91b9f60 100644 --- a/src/CrossPointSettings.cpp +++ b/src/CrossPointSettings.cpp @@ -609,6 +609,49 @@ bool CrossPointSettings::fromJson(JsonVariantConst doc, bool importingCrossPoint this->*(info.valuePtr) = value; } + // Old settings assigned one layout and one long-press mode to both side + // buttons. Populate only missing new keys so partially upgraded settings + // keep the user's individual choices. + const uint8_t legacySideLayout = + clamp(doc["sideButtonLayout"] | static_cast(PREV_NEXT), SIDE_BUTTON_LAYOUT_COUNT, PREV_NEXT); + const uint8_t legacySideLong = clamp(doc["sideButtonLongPress"] | static_cast(SIDE_LONG_CHAPTER_SKIP), + SIDE_LONG_PRESS_COUNT, SIDE_LONG_CHAPTER_SKIP); + const uint8_t legacyUpShort = legacySideLayout == SIDE_BUTTONS_DISABLED ? IGNORE + : legacySideLayout == PREV_NEXT ? PREVIOUS_PAGE + : PAGE_TURN; + const uint8_t legacyDownShort = legacySideLayout == SIDE_BUTTONS_DISABLED ? IGNORE + : legacySideLayout == NEXT_PREV ? PREVIOUS_PAGE + : PAGE_TURN; + const auto legacyLongAction = [&](const bool up) -> uint8_t { + switch (legacySideLong) { + case SIDE_LONG_CHAPTER_SKIP: + if (legacySideLayout == SIDE_BUTTONS_DISABLED) return IGNORE; + return (up ? legacyUpShort : legacyDownShort) == PREVIOUS_PAGE ? SIDE_PREVIOUS_CHAPTER : SIDE_NEXT_CHAPTER; + case SIDE_LONG_FONT_SIZE: + return up ? SIDE_INCREASE_FONT : SIDE_DECREASE_FONT; + case SIDE_LONG_ORIENTATION_CHANGE: + return up ? SIDE_ROTATE_COUNTERCLOCKWISE : SIDE_ROTATE_CLOCKWISE; + default: + return IGNORE; + } + }; + if (doc["sideButtonUpShort"].isNull()) { + sideButtonUpShort = legacyUpShort; + needsResave = true; + } + if (doc["sideButtonDownShort"].isNull()) { + sideButtonDownShort = legacyDownShort; + needsResave = true; + } + if (doc["sideButtonUpLong"].isNull()) { + sideButtonUpLong = legacyLongAction(true); + needsResave = true; + } + if (doc["sideButtonDownLong"].isNull()) { + sideButtonDownLong = legacyLongAction(false); + needsResave = true; + } + const auto readLibraryChoice = [&](const char* key, uint8_t& choice, const int optionCount) { const int stored = doc[key] | static_cast(choice); if (stored < 0 || stored >= optionCount) { @@ -818,7 +861,8 @@ bool CrossPointSettings::fromJson(JsonVariantConst doc, bool importingCrossPoint const bool unavailableHomeTrigger = !gpio.hasHomeKey() && persistedQuickActionsTrigger >= static_cast(QuickActions::Trigger::TapHome) && persistedQuickActionsTrigger <= static_cast(QuickActions::Trigger::DoubleTapHome); - if (persistedQuickActionsTrigger <= static_cast(QuickActions::Trigger::UpDown) && !unavailableHomeTrigger) { + if (persistedQuickActionsTrigger <= static_cast(QuickActions::Trigger::SideDownLong) && + !unavailableHomeTrigger) { quickActionsTrigger = persistedQuickActionsTrigger; } else { quickActionsTrigger = static_cast(QuickActions::Trigger::None); @@ -1060,6 +1104,29 @@ bool CrossPointSettings::loadFromBinaryFile() { applyLegacyFrontButtonLayout(*this); } + switch (sideButtonLayout) { + case NEXT_PREV: + sideButtonUpShort = PAGE_TURN; + sideButtonDownShort = PREVIOUS_PAGE; + sideButtonUpLong = SIDE_NEXT_CHAPTER; + sideButtonDownLong = SIDE_PREVIOUS_CHAPTER; + break; + case SIDE_BUTTONS_DISABLED: + sideButtonUpShort = IGNORE; + sideButtonDownShort = IGNORE; + sideButtonUpLong = IGNORE; + sideButtonDownLong = IGNORE; + break; + case NEXT_NEXT: + sideButtonUpShort = PAGE_TURN; + sideButtonDownShort = PAGE_TURN; + sideButtonUpLong = SIDE_NEXT_CHAPTER; + sideButtonDownLong = SIDE_NEXT_CHAPTER; + break; + default: + break; + } + lineHeightPercent = legacyLineSpacingToPercent(lineSpacing, fontFamily, sdFontFamilyName[0] != '\0'); return true; diff --git a/src/CrossPointSettings.h b/src/CrossPointSettings.h index 395464962b5..6c4168de650 100644 --- a/src/CrossPointSettings.h +++ b/src/CrossPointSettings.h @@ -177,6 +177,17 @@ class CrossPointSettings : public PersistableStore { SIDE_LONG_PRESS_COUNT }; + // Side-button actions share shortcut IDs with Power. These extra IDs are + // reader-only actions; keep their persisted values separate and stable. + enum SIDE_BUTTON_ACTION : uint8_t { + SIDE_PREVIOUS_CHAPTER = 64, + SIDE_NEXT_CHAPTER, + SIDE_INCREASE_FONT, + SIDE_DECREASE_FONT, + SIDE_ROTATE_COUNTERCLOCKWISE, + SIDE_ROTATE_CLOCKWISE, + }; + // Font family options (built-in fonts only; SD card fonts use sdFontFamilyName) enum FONT_FAMILY { LEXENDDECA = 0, BITTER = 1, FONT_FAMILY_COUNT }; static constexpr uint8_t BUILTIN_FONT_COUNT = FONT_FAMILY_COUNT; @@ -519,13 +530,17 @@ class CrossPointSettings : public PersistableStore { // EPUB reading orientation settings // 0 = portrait (default), 1 = landscape clockwise, 2 = inverted, 3 = landscape counter-clockwise uint8_t orientation = PORTRAIT; - // Button layouts (front layout retained for migration only) + // Legacy layouts are retained for migration only. uint8_t frontButtonLayout = BACK_CONFIRM_LEFT_RIGHT; uint8_t sideButtonLayout = PREV_NEXT; uint8_t frontButtonOrientationAware = FRONT_ORIENTATION_AWARE_OFF; uint8_t sideButtonOrientationAware = 0; - // Action performed when side buttons are long-pressed in reader + // Legacy shared side-button long action, retained for migration only. uint8_t sideButtonLongPress = SIDE_LONG_CHAPTER_SKIP; + uint8_t sideButtonUpShort = PREVIOUS_PAGE; + uint8_t sideButtonUpLong = SIDE_PREVIOUS_CHAPTER; + uint8_t sideButtonDownShort = PAGE_TURN; + uint8_t sideButtonDownLong = SIDE_NEXT_CHAPTER; // Front button remap (logical -> hardware) // Used by MappedInputManager to translate logical buttons into physical front buttons. uint8_t frontButtonBack = FRONT_HW_BACK; diff --git a/src/MappedInputManager.cpp b/src/MappedInputManager.cpp index 64287286a8e..8eddd813429 100644 --- a/src/MappedInputManager.cpp +++ b/src/MappedInputManager.cpp @@ -38,14 +38,6 @@ struct SideLayoutMap { ButtonIndex pageForwardSecondary; }; -// Order matches CrossPointSettings::SIDE_BUTTON_LAYOUT. -constexpr SideLayoutMap kSideLayouts[] = { - {HalGPIO::BTN_UP, kNoButton, HalGPIO::BTN_DOWN, kNoButton}, - {HalGPIO::BTN_DOWN, kNoButton, HalGPIO::BTN_UP, kNoButton}, - {kNoButton, kNoButton, kNoButton, kNoButton}, - {kNoButton, kNoButton, HalGPIO::BTN_UP, HalGPIO::BTN_DOWN}, -}; - bool shouldSwapReaderSideButtons(const bool readerMode) { return readerMode && SETTINGS.sideButtonOrientationAware && SETTINGS.orientation != CrossPointSettings::PORTRAIT; } @@ -101,18 +93,6 @@ ButtonIndex mapFrontButtonForReaderOrientation(const ButtonIndex button, const B return button; } -SideLayoutMap mapSideLayoutForReaderOrientation(SideLayoutMap side, const bool readerMode) { - if (shouldSwapReaderSideButtons(readerMode)) { - const bool hasPageBack = side.pageBackPrimary != kNoButton || side.pageBackSecondary != kNoButton; - const bool hasPageForward = side.pageForwardPrimary != kNoButton || side.pageForwardSecondary != kNoButton; - if (hasPageBack && hasPageForward) { - std::swap(side.pageBackPrimary, side.pageForwardPrimary); - std::swap(side.pageBackSecondary, side.pageForwardSecondary); - } - } - return side; -} - ButtonIndex mapSideButtonForReaderOrientation(const ButtonIndex button, const bool readerMode) { if (!shouldSwapReaderSideButtons(readerMode)) { return button; @@ -126,11 +106,6 @@ ButtonIndex mapSideButtonForReaderOrientation(const ButtonIndex button, const bo return button; } -bool readMappedSideButtons(const HalGPIO& gpio, bool (HalGPIO::*fn)(uint8_t) const, const ButtonIndex primary, - const ButtonIndex secondary) { - return (primary != kNoButton && (gpio.*fn)(primary)) || (secondary != kNoButton && (gpio.*fn)(secondary)); -} - #ifdef SIMULATOR size_t buttonIndex(MappedInputManager::Button button) { return static_cast(button); } #endif @@ -140,6 +115,17 @@ size_t buttonIndex(MappedInputManager::Button button) { return static_cast(SETTINGS.sideButtonLayout); - const auto side = mapSideLayoutForReaderOrientation(kSideLayouts[sideLayout], readerMode); + const ButtonIndex up = mapSideButtonForReaderOrientation(HalGPIO::BTN_UP, readerMode); + const ButtonIndex down = mapSideButtonForReaderOrientation(HalGPIO::BTN_DOWN, readerMode); + const SideLayoutMap side = { + SETTINGS.sideButtonUpShort == CrossPointSettings::PREVIOUS_PAGE ? up : kNoButton, + SETTINGS.sideButtonDownShort == CrossPointSettings::PREVIOUS_PAGE ? down : kNoButton, + SETTINGS.sideButtonUpShort == CrossPointSettings::PAGE_TURN ? up : kNoButton, + SETTINGS.sideButtonDownShort == CrossPointSettings::PAGE_TURN ? down : kNoButton, + }; + const auto sideEvent = [&](const ButtonIndex physical) { + if (physical == kNoButton) return false; + if (fn == &HalGPIO::wasReleased && ((physical == HalGPIO::BTN_UP && suppressPhysicalUpRelease) || + (physical == HalGPIO::BTN_DOWN && suppressPhysicalDownRelease))) + return false; + return (gpio.*fn)(physical); + }; const ButtonIndex frontButton = mappedFrontButtonFor(button); @@ -176,19 +175,19 @@ bool MappedInputManager::mapButton(const Button button, bool (HalGPIO::*fn)(uint return frontButton != kNoButton && (gpio.*fn)(frontButton); case Button::Up: // Reader menus should follow the same top/bottom side-button orientation as reader page turns. - return (gpio.*fn)(mapSideButtonForReaderOrientation(HalGPIO::BTN_UP, readerMode)); + return sideEvent(up); case Button::Down: // Reader menus should follow the same top/bottom side-button orientation as reader page turns. - return (gpio.*fn)(mapSideButtonForReaderOrientation(HalGPIO::BTN_DOWN, readerMode)); + return sideEvent(down); case Button::Power: // Power button bypasses remapping. return (gpio.*fn)(HalGPIO::BTN_POWER); case Button::PageBack: // Reader page navigation uses side buttons and can be swapped via settings. - return readMappedSideButtons(gpio, fn, side.pageBackPrimary, side.pageBackSecondary); + return sideEvent(side.pageBackPrimary) || sideEvent(side.pageBackSecondary); case Button::PageForward: // Reader page navigation uses side buttons and can be swapped via settings. - return readMappedSideButtons(gpio, fn, side.pageForwardPrimary, side.pageForwardSecondary); + return sideEvent(side.pageForwardPrimary) || sideEvent(side.pageForwardSecondary); } return false; @@ -941,6 +940,16 @@ bool MappedInputManager::wasReleased(const Button button) const { return true; } +#ifdef SIMULATOR + if (simulatedRelease && (button == Button::Up || button == Button::Down)) { + const ButtonIndex physical = + mapSideButtonForReaderOrientation(button == Button::Up ? HalGPIO::BTN_UP : HalGPIO::BTN_DOWN, readerMode); + if ((physical == HalGPIO::BTN_UP && suppressPhysicalUpRelease) || + (physical == HalGPIO::BTN_DOWN && suppressPhysicalDownRelease)) + return false; + } +#endif + const uint8_t frontButton = mappedFrontButtonFor(button); return simulatedRelease || mapButton(button, &HalGPIO::wasReleased) || (frontButton != kNoButton && wasFrontButtonHintTapped(frontButton)); diff --git a/src/MappedInputManager.h b/src/MappedInputManager.h index 4c32a92dbe6..f79ff84b2ff 100644 --- a/src/MappedInputManager.h +++ b/src/MappedInputManager.h @@ -85,11 +85,13 @@ class MappedInputManager { void suppressNextBackRelease() { releaseSuppression.suppressBack(); } void suppressNextConfirmRelease() { releaseSuppression.suppressConfirm(); } void suppressNextPowerRelease() { releaseSuppression.suppressPower(); } + void suppressNextSideRelease(Button button); void suppressNextPowerConfirmRelease() { releaseSuppression.suppressPowerConfirm(); } bool isPowerReleaseSuppressed() const { return releaseSuppression.isPowerReleaseSuppressed(); } bool wasPressed(Button button) const; bool wasReleased(Button button) const; void injectRelease(Button button) const { injectedReleases[static_cast(button)] = true; } + bool hasInjectedRelease(Button button) const { return injectedReleases[static_cast(button)]; } void clearInjectedReleases() const { injectedReleases.fill(false); } bool isPressed(Button button) const; // Physical state is intentionally independent of release suppression. Use it @@ -276,6 +278,8 @@ class MappedInputManager { bool edgeSlideQualified = false; #endif mutable ReleaseSuppression releaseSuppression; + mutable bool suppressPhysicalUpRelease = false; + mutable bool suppressPhysicalDownRelease = false; static constexpr size_t LABEL_BUFFER_SIZE = 128; mutable std::array, 4> labelBuffers{}; // One-frame synthetic releases let a chord route through the existing diff --git a/src/QuickActions.h b/src/QuickActions.h index 5cfd19c1d77..fdab0682187 100644 --- a/src/QuickActions.h +++ b/src/QuickActions.h @@ -28,6 +28,10 @@ enum class Trigger : uint8_t { LongPressHome, DoubleTapHome, UpDown, + SideUpShort, + SideUpLong, + SideDownShort, + SideDownLong, }; inline constexpr std::array actionLabels = { @@ -138,6 +142,10 @@ inline void synchronize(CrossPointSettings& settings, Trigger preferred = Trigge const bool tapHome = settings.homeButtonTapAction == CrossPointSettings::QUICK_ACTIONS; const bool longPressHome = settings.homeButtonLongPressAction == CrossPointSettings::QUICK_ACTIONS; const bool doubleTapHome = settings.homeButtonDoubleTapAction == CrossPointSettings::QUICK_ACTIONS; + const bool sideUpShort = settings.sideButtonUpShort == CrossPointSettings::QUICK_ACTIONS; + const bool sideUpLong = settings.sideButtonUpLong == CrossPointSettings::QUICK_ACTIONS; + const bool sideDownShort = settings.sideButtonDownShort == CrossPointSettings::QUICK_ACTIONS; + const bool sideDownLong = settings.sideButtonDownLong == CrossPointSettings::QUICK_ACTIONS; Trigger owner = preferred; if (owner == Trigger::None) { @@ -159,6 +167,14 @@ inline void synchronize(CrossPointSettings& settings, Trigger preferred = Trigge owner = Trigger::LongPressHome; else if (doubleTapHome) owner = Trigger::DoubleTapHome; + else if (sideUpShort) + owner = Trigger::SideUpShort; + else if (sideUpLong) + owner = Trigger::SideUpLong; + else if (sideDownShort) + owner = Trigger::SideDownShort; + else if (sideDownLong) + owner = Trigger::SideDownLong; } if (owner != Trigger::ShortPower && shortPower) settings.shortPwrBtn = CrossPointSettings::IGNORE; @@ -174,6 +190,10 @@ inline void synchronize(CrossPointSettings& settings, Trigger preferred = Trigge if (owner != Trigger::DoubleTapHome && doubleTapHome) { settings.homeButtonDoubleTapAction = CrossPointSettings::HOME_BUTTON_TOGGLE_FRONTLIGHT; } + if (owner != Trigger::SideUpShort && sideUpShort) settings.sideButtonUpShort = CrossPointSettings::IGNORE; + if (owner != Trigger::SideUpLong && sideUpLong) settings.sideButtonUpLong = CrossPointSettings::IGNORE; + if (owner != Trigger::SideDownShort && sideDownShort) settings.sideButtonDownShort = CrossPointSettings::IGNORE; + if (owner != Trigger::SideDownLong && sideDownLong) settings.sideButtonDownLong = CrossPointSettings::IGNORE; settings.quickActionsTrigger = static_cast(owner); } @@ -201,6 +221,14 @@ inline void applyTrigger(CrossPointSettings& settings, const Trigger trigger) { if (settings.homeButtonDoubleTapAction == CrossPointSettings::QUICK_ACTIONS) { settings.homeButtonDoubleTapAction = CrossPointSettings::HOME_BUTTON_TOGGLE_FRONTLIGHT; } + if (settings.sideButtonUpShort == CrossPointSettings::QUICK_ACTIONS) + settings.sideButtonUpShort = CrossPointSettings::IGNORE; + if (settings.sideButtonUpLong == CrossPointSettings::QUICK_ACTIONS) + settings.sideButtonUpLong = CrossPointSettings::IGNORE; + if (settings.sideButtonDownShort == CrossPointSettings::QUICK_ACTIONS) + settings.sideButtonDownShort = CrossPointSettings::IGNORE; + if (settings.sideButtonDownLong == CrossPointSettings::QUICK_ACTIONS) + settings.sideButtonDownLong = CrossPointSettings::IGNORE; if (trigger == Trigger::ShortPower) settings.shortPwrBtn = CrossPointSettings::QUICK_ACTIONS; if (trigger == Trigger::LongPower) settings.longPwrBtn = CrossPointSettings::QUICK_ACTIONS; @@ -211,6 +239,10 @@ inline void applyTrigger(CrossPointSettings& settings, const Trigger trigger) { if (trigger == Trigger::TapHome) settings.homeButtonTapAction = CrossPointSettings::QUICK_ACTIONS; if (trigger == Trigger::LongPressHome) settings.homeButtonLongPressAction = CrossPointSettings::QUICK_ACTIONS; if (trigger == Trigger::DoubleTapHome) settings.homeButtonDoubleTapAction = CrossPointSettings::QUICK_ACTIONS; + if (trigger == Trigger::SideUpShort) settings.sideButtonUpShort = CrossPointSettings::QUICK_ACTIONS; + if (trigger == Trigger::SideUpLong) settings.sideButtonUpLong = CrossPointSettings::QUICK_ACTIONS; + if (trigger == Trigger::SideDownShort) settings.sideButtonDownShort = CrossPointSettings::QUICK_ACTIONS; + if (trigger == Trigger::SideDownLong) settings.sideButtonDownLong = CrossPointSettings::QUICK_ACTIONS; settings.quickActionsTrigger = static_cast(trigger); } @@ -224,6 +256,10 @@ inline Trigger triggerForSetting(uint8_t CrossPointSettings::* member) { if (member == &CrossPointSettings::homeButtonTapAction) return Trigger::TapHome; if (member == &CrossPointSettings::homeButtonLongPressAction) return Trigger::LongPressHome; if (member == &CrossPointSettings::homeButtonDoubleTapAction) return Trigger::DoubleTapHome; + if (member == &CrossPointSettings::sideButtonUpShort) return Trigger::SideUpShort; + if (member == &CrossPointSettings::sideButtonUpLong) return Trigger::SideUpLong; + if (member == &CrossPointSettings::sideButtonDownShort) return Trigger::SideDownShort; + if (member == &CrossPointSettings::sideButtonDownLong) return Trigger::SideDownLong; return Trigger::None; } diff --git a/src/SettingsList.h b/src/SettingsList.h index 8a271e09372..7d61f4df20e 100644 --- a/src/SettingsList.h +++ b/src/SettingsList.h @@ -79,6 +79,27 @@ inline bool settingKeyIs(const SettingInfo& setting, const char* key) { return setting.key && std::strcmp(setting.key, key) == 0; } +inline bool isSideButtonActionSetting(const SettingInfo& setting) { + return settingKeyIs(setting, "sideButtonUpShort") || settingKeyIs(setting, "sideButtonUpLong") || + settingKeyIs(setting, "sideButtonDownShort") || settingKeyIs(setting, "sideButtonDownLong"); +} + +inline std::string sideButtonOptionLabel(const SettingInfo& setting, const uint8_t displayIndex) { + if (isSideButtonActionSetting(setting) && displayIndex < setting.enumRawValues.size()) { + const uint8_t action = setting.enumRawValues[displayIndex]; + if (action == CrossPointSettings::SIDE_ROTATE_COUNTERCLOCKWISE || + action == CrossPointSettings::SIDE_ROTATE_CLOCKWISE) { + return std::string(tr(STR_ORIENTATION)) + " (" + + (action == CrossPointSettings::SIDE_ROTATE_CLOCKWISE ? tr(STR_DIR_RIGHT) : tr(STR_DIR_LEFT)) + ")"; + } + } + return settingEnumOptionLabel(setting, displayIndex); +} + +inline std::string sideButtonGroupLabel(const bool up) { + return std::string(up ? tr(STR_DIR_LEFT) : tr(STR_DIR_RIGHT)) + "/" + (up ? tr(STR_DIR_UP) : tr(STR_DIR_DOWN)); +} + inline SettingInfo buildFontSizeSetting(const SdCardFontRegistry* registry) { if (registry && SETTINGS.sdFontFamilyName[0] != '\0') { const SdCardFontFamilyInfo* family = registry->findFamily(SETTINGS.sdFontFamilyName); @@ -327,7 +348,7 @@ inline SettingInfo buildSleepScreenSetting() { return s; } -enum class ShortcutOptionCatalog { PowerButton, ButtonChord, LongPress, HomeButton }; +enum class ShortcutOptionCatalog { PowerButton, ButtonChord, LongPress, HomeButton, SideButton }; constexpr uint8_t SHORTCUT_OPTION_UNAVAILABLE = UINT8_MAX; @@ -338,6 +359,7 @@ inline uint8_t shortcutRawValue(const ShortcutOptionCatalog catalog, const Cross switch (catalog) { case ShortcutOptionCatalog::PowerButton: + case ShortcutOptionCatalog::SideButton: return static_cast(action); case ShortcutOptionCatalog::ButtonChord: switch (action) { @@ -523,6 +545,24 @@ inline SettingInfo buildHomeButtonActionSetting(const StrId nameId, uint8_t Cros return buildShortcutSetting(nameId, valuePtr, key, ShortcutOptionCatalog::HomeButton); } +inline SettingInfo buildSideButtonActionSetting(const StrId nameId, uint8_t CrossPointSettings::* const valuePtr, + const char* const key) { + SettingInfo setting = buildShortcutSetting(nameId, valuePtr, key, ShortcutOptionCatalog::SideButton); + constexpr std::pair readerActions[] = { + {CrossPointSettings::SIDE_PREVIOUS_CHAPTER, StrId::STR_PREVIOUS_CHAPTER}, + {CrossPointSettings::SIDE_NEXT_CHAPTER, StrId::STR_NEXT_CHAPTER}, + {CrossPointSettings::SIDE_INCREASE_FONT, StrId::STR_INCREASE_FONT_SIZE}, + {CrossPointSettings::SIDE_DECREASE_FONT, StrId::STR_DECREASE_FONT_SIZE}, + {CrossPointSettings::SIDE_ROTATE_COUNTERCLOCKWISE, StrId::STR_LONG_PRESS_BEHAVIOR_ORIENTATION}, + {CrossPointSettings::SIDE_ROTATE_CLOCKWISE, StrId::STR_LONG_PRESS_BEHAVIOR_ORIENTATION}, + }; + for (const auto& [action, label] : readerActions) { + setting.enumValues.push_back(label); + setting.enumRawValues.push_back(action); + } + return setting; +} + // Shared settings list used by both the device settings UI and the web settings API. // Each entry has a key (for JSON API) and category (for grouping). // ACTION-type entries and entries without a key are device-only. @@ -532,7 +572,7 @@ inline SettingInfo buildHomeButtonActionSetting(const StrId nameId, uint8_t Cros // can use it directly; mutable device UI lists use getSettingsList(), which // returns an owned copy and can add SD-card font and dictionary options. // Four edge gesture entries are compiled only for touch devices. -inline constexpr size_t BASE_SETTINGS_CAPACITY = 103 + (CROSSINK_APP_CAP_TOUCH ? 4 : 0); +inline constexpr size_t BASE_SETTINGS_CAPACITY = 104 + (CROSSINK_APP_CAP_TOUCH ? 4 : 0); inline const std::vector& getBaseSettingsList() { static const std::vector baseList = [] { @@ -711,20 +751,16 @@ inline const std::vector& getBaseSettingsList() { "rightEdgeDown", StrId::STR_CAT_CONTROLS) .withEnumRawValues(twoFingerSwipeActionValues)); #endif - add(SettingInfo::Enum(StrId::STR_SIDE_BTN_LAYOUT, &CrossPointSettings::sideButtonLayout, - {StrId::STR_DISABLED, StrId::STR_PREV_NEXT, StrId::STR_NEXT_PREV, StrId::STR_NEXT_NEXT}, - "sideButtonLayout", StrId::STR_CAT_CONTROLS) - .withEnumRawValues({CrossPointSettings::SIDE_BUTTONS_DISABLED, CrossPointSettings::PREV_NEXT, - CrossPointSettings::NEXT_PREV, CrossPointSettings::NEXT_NEXT})); add(SettingInfo::Enum(StrId::STR_ORIENTATION_AWARE, &CrossPointSettings::sideButtonOrientationAware, {StrId::STR_NO, StrId::STR_YES}, "sideButtonOrientationAware", StrId::STR_CAT_CONTROLS)); - add(SettingInfo::Enum(StrId::STR_LONG_PRESS_ACTION, &CrossPointSettings::sideButtonLongPress, - {StrId::STR_IGNORE, StrId::STR_CHAPTER_SKIP_OPT, StrId::STR_CHANGE_FONT_SIZE, - StrId::STR_LONG_PRESS_BEHAVIOR_ORIENTATION}, - "sideButtonLongPress", StrId::STR_CAT_CONTROLS) - .withEnumRawValues({CrossPointSettings::SIDE_LONG_OFF, CrossPointSettings::SIDE_LONG_CHAPTER_SKIP, - CrossPointSettings::SIDE_LONG_FONT_SIZE, - CrossPointSettings::SIDE_LONG_ORIENTATION_CHANGE})); + add(buildSideButtonActionSetting(StrId::STR_SHORT_PWR_BTN, &CrossPointSettings::sideButtonUpShort, + "sideButtonUpShort")); + add(buildSideButtonActionSetting(StrId::STR_LONG_PRESS_ACTION, &CrossPointSettings::sideButtonUpLong, + "sideButtonUpLong")); + add(buildSideButtonActionSetting(StrId::STR_SHORT_PWR_BTN, &CrossPointSettings::sideButtonDownShort, + "sideButtonDownShort")); + add(buildSideButtonActionSetting(StrId::STR_LONG_PRESS_ACTION, &CrossPointSettings::sideButtonDownLong, + "sideButtonDownLong")); add(SettingInfo::Enum(StrId::STR_ORIENTATION_AWARE, &CrossPointSettings::frontButtonOrientationAware, {StrId::STR_NO, StrId::STR_NAV_BUTTONS, StrId::STR_ALL_BUTTONS}, "frontButtonOrientationAware", StrId::STR_CAT_CONTROLS)); @@ -918,7 +954,7 @@ inline const std::vector& getBaseSettingsList() { // Only show tilt page turn settings when the active device has a supported IMU. if (QuickActions::supportsTiltPageTurn()) { auto shortPowerButtonIt = std::find_if( - v.begin(), v.end(), [](const SettingInfo& setting) { return setting.nameId == StrId::STR_SHORT_PWR_BTN; }); + v.begin(), v.end(), [](const SettingInfo& setting) { return settingKeyIs(setting, "shortPwrBtn"); }); if (shortPowerButtonIt != v.end()) { auto insertPos = v.insert(shortPowerButtonIt + 1, SettingInfo::Toggle(StrId::STR_TILT_PAGE_TURN, &CrossPointSettings::tiltPageTurn, @@ -939,7 +975,7 @@ inline const std::vector& getBaseSettingsList() { } } else { for (auto& setting : v) { - if (setting.nameId == StrId::STR_SHORT_PWR_BTN || settingKeyIs(setting, "longPwrBtn")) { + if (settingKeyIs(setting, "shortPwrBtn") || settingKeyIs(setting, "longPwrBtn")) { removeEnumRawValue(setting, static_cast(CrossPointSettings::TOGGLE_TILT_PAGE_TURN)); } else if (setting.nameId == StrId::STR_LONG_PRESS_MENU_ACTION || setting.nameId == StrId::STR_LONG_PRESS_BACK_ACTION) { @@ -973,7 +1009,8 @@ inline std::vector getSettingsList(const SdCardFontRegistry* regist setting.valuePtr == &CrossPointSettings::longPwrBtn || setting.valuePtr == &CrossPointSettings::homeButtonTapAction || setting.valuePtr == &CrossPointSettings::homeButtonDoubleTapAction || - setting.valuePtr == &CrossPointSettings::homeButtonLongPressAction) { + setting.valuePtr == &CrossPointSettings::homeButtonLongPressAction || + isSideButtonActionSetting(setting)) { removeEnumRawValue(setting, CrossPointSettings::READING_STATS); } else if (setting.valuePtr == &CrossPointSettings::powerChordAction || setting.valuePtr == &CrossPointSettings::sideButtonChordAction) { @@ -1063,7 +1100,7 @@ inline std::vector getSettingsList(const SdCardFontRegistry* regist }), v.end()); for (auto& setting : v) { - if (setting.nameId == StrId::STR_SHORT_PWR_BTN || settingKeyIs(setting, "longPwrBtn")) { + if (settingKeyIs(setting, "shortPwrBtn") || settingKeyIs(setting, "longPwrBtn")) { removeEnumRawValue(setting, CrossPointSettings::TOGGLE_HOME_BUTTON_IN_READER); } else if (settingKeyIs(setting, "powerChordAction") || settingKeyIs(setting, "sideButtonChordAction")) { removeEnumRawValue(setting, shortcutRawValue(ShortcutOptionCatalog::ButtonChord, @@ -1073,7 +1110,7 @@ inline std::vector getSettingsList(const SdCardFontRegistry* regist } if (!Frontlight.present() || !gpio.hasTouch()) { for (auto& setting : v) { - if (setting.nameId != StrId::STR_SHORT_PWR_BTN && !settingKeyIs(setting, "longPwrBtn") && + if (!settingKeyIs(setting, "shortPwrBtn") && !settingKeyIs(setting, "longPwrBtn") && !settingKeyIs(setting, "powerChordAction") && !settingKeyIs(setting, "sideButtonChordAction")) { continue; } @@ -1296,7 +1333,7 @@ inline std::vector buildControlsHomeButtonSettingsList(const std::v inline std::vector buildControlsPowerSettingsList(const std::vector& allSettings) { std::vector settings; settings.reserve(4); - addSettingByName(settings, allSettings, StrId::STR_SHORT_PWR_BTN); + addSettingByKey(settings, allSettings, "shortPwrBtn"); addSettingByKey(settings, allSettings, "longPwrBtn"); if (SETTINGS.shortPwrBtn == CrossPointSettings::SHORT_PWRBTN::FOOTNOTES || SETTINGS.longPwrBtn == CrossPointSettings::SHORT_PWRBTN::FOOTNOTES || @@ -1324,13 +1361,17 @@ inline std::vector buildControlsFrontButtonSettingsList(const std:: inline std::vector buildControlsSideButtonSettingsList(const std::vector& allSettings) { std::vector settings; const bool hasChord = hasSideButtonChordSetting(allSettings); - settings.reserve(3 + (hasChord ? 1u : 0u)); - addSettingByName(settings, allSettings, StrId::STR_SIDE_BTN_LAYOUT); + settings.reserve(8 + (hasChord ? 1u : 0u)); addSettingByKey(settings, allSettings, "sideButtonOrientationAware"); - addSettingByKey(settings, allSettings, "sideButtonLongPress"); if (hasChord) { addSettingByName(settings, allSettings, StrId::STR_SIDE_BUTTON_CHORD); } + settings.push_back(SettingInfo::SectionHeader(StrId::STR_DIR_LEFT)); + addSettingByKey(settings, allSettings, "sideButtonUpShort"); + addSettingByKey(settings, allSettings, "sideButtonUpLong"); + settings.push_back(SettingInfo::SectionHeader(StrId::STR_DIR_RIGHT)); + addSettingByKey(settings, allSettings, "sideButtonDownShort"); + addSettingByKey(settings, allSettings, "sideButtonDownLong"); return settings; } diff --git a/src/activities/reader/ControlsOptionsActivity.cpp b/src/activities/reader/ControlsOptionsActivity.cpp index a82be279a4a..8589cd31118 100644 --- a/src/activities/reader/ControlsOptionsActivity.cpp +++ b/src/activities/reader/ControlsOptionsActivity.cpp @@ -207,7 +207,7 @@ void ControlsOptionsActivity::openEnumOptionPicker(const SettingInfo& setting) { std::vector options; options.reserve(optionCount); for (uint8_t i = 0; i < optionCount; i++) { - options.push_back(settingEnumOptionLabel(setting, i)); + options.push_back(sideButtonOptionLabel(setting, i)); } uint8_t currentIndex = 0; @@ -393,6 +393,8 @@ void ControlsOptionsActivity::buildOptionsScreen(UiApp::ScreenType& screen) { } const auto& currentSettingsList = *currentSettings; + const std::string leftUpLabel = sideButtonGroupLabel(true); + const std::string rightDownLabel = sideButtonGroupLabel(false); std::vector values(currentSettingsList.size()); std::vector items; items.reserve(currentSettingsList.size()); @@ -404,7 +406,7 @@ void ControlsOptionsActivity::buildOptionsScreen(UiApp::ScreenType& screen) { values[i] = SETTINGS.*(setting.valuePtr) ? tr(STR_STATE_ON) : tr(STR_STATE_OFF); } else if (setting.type == SettingType::ENUM && setting.valuePtr != nullptr) { const uint8_t displayValue = enumDisplayIndexForRawValue(setting, SETTINGS.*(setting.valuePtr)); - values[i] = settingEnumOptionLabel(setting, displayValue < settingEnumOptionCount(setting) ? displayValue : 0); + values[i] = sideButtonOptionLabel(setting, displayValue < settingEnumOptionCount(setting) ? displayValue : 0); } else if (setting.type == SettingType::VALUE && setting.valuePtr != nullptr) { values[i] = std::to_string(SETTINGS.*(setting.valuePtr)); } @@ -412,7 +414,11 @@ void ControlsOptionsActivity::buildOptionsScreen(UiApp::ScreenType& screen) { const bool isSectionHeader = setting.type == SettingType::SECTION_HEADER; fui::ListItem item; const fui::BitmapRef directionIcon = swipeActionIcon(setting.nameId); - item.label = isSectionHeader ? uiListSectionHeaderLabel(values[i], I18N.get(setting.nameId)) + const char* sectionLabel = + activeSubmenu == SettingAction::ControlsSideButtons + ? (setting.nameId == StrId::STR_DIR_LEFT ? leftUpLabel.c_str() : rightDownLabel.c_str()) + : I18N.get(setting.nameId); + item.label = isSectionHeader ? uiListSectionHeaderLabel(values[i], sectionLabel) : (directionIcon ? "" : I18N.get(setting.nameId)); item.icon = directionIcon; if (!isSectionHeader && !values[i].empty()) item.value = values[i].c_str(); @@ -435,6 +441,10 @@ void ControlsOptionsActivity::buildOptionsScreen(UiApp::ScreenType& screen) { props.valueInset = 8; props.labelText = screen.theme().bodyText; props.labelText.maxLines = 2; + if (activeSubmenu == SettingAction::ControlsSideButtons) { + props.headerText = screen.theme().smallText; + props.headerText.bold = true; + } configureUiListSectionHeaders(props, screen.theme()); const auto rows = configureUiList(props, screen.theme(), screen.body()); visibleRows = rows > 0 ? rows : 1; diff --git a/src/activities/reader/EndOfBookOptions.cpp b/src/activities/reader/EndOfBookOptions.cpp index 7d8375da963..d389e60fb34 100644 --- a/src/activities/reader/EndOfBookOptions.cpp +++ b/src/activities/reader/EndOfBookOptions.cpp @@ -118,19 +118,15 @@ EndOfBookOptions::Action EndOfBookOptions::handleMenuInput(const MappedInputMana return Action::LastPage; } - // Selection movement follows reader page-turn buttons: side buttons honor the - // configured side-button layout, while front Left/Right move on release. - const bool sideUsePress = SETTINGS.sideButtonLongPress == CrossPointSettings::SIDE_LONG_PRESS::SIDE_LONG_OFF; - const auto sideTriggered = [&](const MappedInputManager::Button button) { - return sideUsePress ? input.wasPressed(button) : input.wasReleased(button); - }; + // Side-button navigation follows each button's configured short action. const int itemCount = static_cast(names.size()) + 1; // + "Home" entry - if (sideTriggered(MappedInputManager::Button::PageBack) || input.wasReleased(MappedInputManager::Button::Left)) { + if (input.wasReleased(MappedInputManager::Button::PageBack) || input.wasReleased(MappedInputManager::Button::Left)) { selector.store(ButtonNavigator::previousIndex(selectedIndex, itemCount), std::memory_order_relaxed); // wraps to the bottom return Action::Redraw; } - if (sideTriggered(MappedInputManager::Button::PageForward) || input.wasReleased(MappedInputManager::Button::Right)) { + if (input.wasReleased(MappedInputManager::Button::PageForward) || + input.wasReleased(MappedInputManager::Button::Right)) { selector.store(ButtonNavigator::nextIndex(selectedIndex, itemCount), std::memory_order_relaxed); // wraps to the top return Action::Redraw; diff --git a/src/activities/reader/EpubReaderActivity.cpp b/src/activities/reader/EpubReaderActivity.cpp index cbd606978b3..dab582d0c54 100644 --- a/src/activities/reader/EpubReaderActivity.cpp +++ b/src/activities/reader/EpubReaderActivity.cpp @@ -3177,74 +3177,58 @@ void EpubReaderActivity::loop() { return; } - // Side button long-press actions use raw Up/Down so the direction stays - // physical regardless of the Prev/Next side layout setting. - const bool sideLongPressSkipsChapter = - SETTINGS.sideButtonLongPress == CrossPointSettings::SIDE_LONG_PRESS::SIDE_LONG_CHAPTER_SKIP; - const bool sideLongPressChangesFont = - SETTINGS.sideButtonLongPress == CrossPointSettings::SIDE_LONG_PRESS::SIDE_LONG_FONT_SIZE; - const bool sideLongPressChangesOrientation = - SETTINGS.sideButtonLongPress == CrossPointSettings::SIDE_LONG_PRESS::SIDE_LONG_ORIENTATION_CHANGE; - if (sideLongPressSkipsChapter || sideLongPressChangesFont || sideLongPressChangesOrientation) { - const bool topReleased = mappedInput.wasReleased(MappedInputManager::Button::Up); - const bool bottomReleased = mappedInput.wasReleased(MappedInputManager::Button::Down); - const bool sidePrevReleased = mappedInput.wasReleased(MappedInputManager::Button::PageBack); - const bool sideNextReleased = mappedInput.wasReleased(MappedInputManager::Button::PageForward); - const bool sideLongPressReleased = - sideLongPressSkipsChapter ? (sidePrevReleased || sideNextReleased) : (topReleased || bottomReleased); - if (sideButtonLongPressHandled && sideLongPressReleased) { - sideButtonLongPressHandled = false; - return; - } - - const bool longPressReady = mappedInput.getHeldTime() > ReaderUtils::SKIP_HOLD_MS; - const bool prevLongPressed = longPressReady && mappedInput.isPressed(MappedInputManager::Button::PageBack); - const bool nextLongPressed = longPressReady && mappedInput.isPressed(MappedInputManager::Button::PageForward); - const bool topLongPressed = - longPressReady && (mappedInput.isPressed(MappedInputManager::Button::Up) || topReleased); - const bool bottomLongPressed = - longPressReady && (mappedInput.isPressed(MappedInputManager::Button::Down) || bottomReleased); - - if (sideLongPressSkipsChapter && !sideButtonLongPressHandled && (prevLongPressed || nextLongPressed)) { - sideButtonLongPressHandled = true; - clearPendingManualPageTurns(); - if (!nextLongPressed && section && section->currentPage > 0) { - section->currentPage = 0; - requestUpdate(); - return; - } - - // We don't want to delete the section mid-render, so grab the semaphore. - { - RenderLock lock(*this); - nextPageNumber = 0; - if (nextLongPressed) { - currentSpineIndex++; - } else if (currentSpineIndex > 0) { - currentSpineIndex--; + const bool chordPageTurn = mappedInput.hasInjectedRelease(MappedInputManager::Button::Right); + const auto side = sideButtonShortcuts.update(mappedInput, millis()); + if (SideButtonShortcuts::shouldConsume(side, mappedInput)) { + if (side.triggered) { + if (side.longPress) + mappedInput.suppressNextSideRelease(side.up ? MappedInputManager::Button::Up + : MappedInputManager::Button::Down); + switch (side.action) { + case CrossPointSettings::SIDE_PREVIOUS_CHAPTER: + case CrossPointSettings::SIDE_NEXT_CHAPTER: { + const bool next = side.action == CrossPointSettings::SIDE_NEXT_CHAPTER; + clearPendingManualPageTurns(); + if (!next && section && section->currentPage > 0) { + section->currentPage = 0; + } else { + RenderLock lock(*this); + nextPageNumber = 0; + if (next) + currentSpineIndex++; + else if (currentSpineIndex > 0) + currentSpineIndex--; + section.reset(); + } + requestUpdate(); + break; } - section.reset(); - } - requestUpdate(); - return; - } - - if ((sideLongPressChangesFont || sideLongPressChangesOrientation) && !sideButtonLongPressHandled && - (topLongPressed || bottomLongPressed)) { - // Top grows the font and rotates counter-clockwise; bottom is its inverse. - // Top wins when both are held, matching the previous ordered checks. - const bool isTop = topLongPressed; - sideButtonLongPressHandled = !(isTop ? topReleased : bottomReleased); - if (sideLongPressChangesFont) { - if (ReaderUtils::changeReaderFontSizeWithFeedback(renderer, /*larger=*/isTop)) { - reindexCurrentSection(); + case CrossPointSettings::SIDE_INCREASE_FONT: + case CrossPointSettings::SIDE_DECREASE_FONT: + if (ReaderUtils::changeReaderFontSizeWithFeedback(renderer, + side.action == CrossPointSettings::SIDE_INCREASE_FONT)) + reindexCurrentSection(); + break; + case CrossPointSettings::SIDE_ROTATE_COUNTERCLOCKWISE: + case CrossPointSettings::SIDE_ROTATE_CLOCKWISE: + applyOrientation(ReaderUtils::rotatedOrientation(SETTINGS.orientation, + side.action == CrossPointSettings::SIDE_ROTATE_CLOCKWISE)); + requestUpdate(); + break; + case CrossPointSettings::IGNORE: + break; + default: { + const auto action = static_cast(side.action); + if (action == CrossPointSettings::QUICK_LOCK) + handleGlobalPowerButtonAction(action, SideButtonShortcuts::quickLockTrigger(side)); + else if (!handleShortcutAction(action)) + handleGlobalPowerButtonAction(action); + break; } - } else { - applyOrientation(ReaderUtils::rotatedOrientation(SETTINGS.orientation, /*clockwise=*/!isTop)); - requestUpdate(); } - return; } + if (!side.triggered) drainPendingManualPageTurn(); + return; } if (consumeLongPowerButtonRelease()) { @@ -3386,9 +3370,7 @@ void EpubReaderActivity::loop() { const unsigned long heldMs = fromTouch ? touch.heldMs : mappedInput.getHeldTime(); const bool longPress = !fromTilt && heldMs > ReaderUtils::SKIP_HOLD_MS; const bool skipChapter = - !fromTouch && longPress && - (fromSideBtn ? SETTINGS.sideButtonLongPress == CrossPointSettings::SIDE_LONG_PRESS::SIDE_LONG_CHAPTER_SKIP - : SETTINGS.longPressButtonBehavior == CrossPointSettings::CHAPTER_SKIP); + !chordPageTurn && !fromTouch && longPress && SETTINGS.longPressButtonBehavior == CrossPointSettings::CHAPTER_SKIP; // Don't skip chapter after screenshot if (gpio.wasReleased(HalGPIO::BTN_POWER) && gpio.wasReleased(HalGPIO::BTN_DOWN)) { @@ -3418,7 +3400,7 @@ void EpubReaderActivity::loop() { return; } - if (!fromTouch && longPress && !fromSideBtn && + if (!chordPageTurn && !fromTouch && longPress && !fromSideBtn && SETTINGS.longPressButtonBehavior == CrossPointSettings::ORIENTATION_CHANGE) { const uint8_t newOrientation = nextTriggered ? (SETTINGS.orientation - 1 + SETTINGS.ORIENTATION_COUNT) % SETTINGS.ORIENTATION_COUNT diff --git a/src/activities/reader/EpubReaderActivity.h b/src/activities/reader/EpubReaderActivity.h index 395b2474d97..d42ce13bca3 100644 --- a/src/activities/reader/EpubReaderActivity.h +++ b/src/activities/reader/EpubReaderActivity.h @@ -20,6 +20,7 @@ #include "GlobalReadingStats.h" #include "ManualPageTurnQueue.h" #include "ReaderProgressSaveDebouncer.h" +#include "SideButtonShortcuts.h" #include "activities/Activity.h" #include "activities/reader/TouchReaderPreviewModel.h" #include "components/OptionPopup.h" @@ -208,7 +209,7 @@ class EpubReaderActivity final : public Activity { bool longPressBackHandled = false; bool longPowerButtonHandled = false; OptionPopup quickActionsPopup; - bool sideButtonLongPressHandled = false; + SideButtonShortcuts sideButtonShortcuts; bool frontButtonLongPressHandled = false; bool touchDictionaryLookupHandled = false; int pageLoadRetryCount = 0; diff --git a/src/activities/reader/ReaderUtils.h b/src/activities/reader/ReaderUtils.h index 67ac7a5d456..8f7027b7f89 100644 --- a/src/activities/reader/ReaderUtils.h +++ b/src/activities/reader/ReaderUtils.h @@ -210,15 +210,8 @@ inline bool isTouchMenuDismissGesture(const MappedInputManager& input) { } inline PageTurnResult detectPageTurn(const MappedInputManager& input) { - // Side buttons fire on press only when long-press action is OFF (nothing to detect). - const bool sideUsePress = SETTINGS.sideButtonLongPress == CrossPointSettings::SIDE_LONG_PRESS::SIDE_LONG_OFF; - const bool tiltNext = SETTINGS.tiltPageTurn && halTiltSensor.wasTiltedForward(); const bool tiltPrev = SETTINGS.tiltPageTurn && halTiltSensor.wasTiltedBack(); - const bool sidePrev = sideUsePress ? input.wasPressed(MappedInputManager::Button::PageBack) - : input.wasReleased(MappedInputManager::Button::PageBack); - const bool sideNext = sideUsePress ? input.wasPressed(MappedInputManager::Button::PageForward) - : input.wasReleased(MappedInputManager::Button::PageForward); const bool frontPrev = input.wasReleased(MappedInputManager::Button::Left); const bool powerReleased = input.wasReleased(MappedInputManager::Button::Power); @@ -229,9 +222,8 @@ inline PageTurnResult detectPageTurn(const MappedInputManager& input) { const bool powerTurn = shortPowerTurn || longPowerTurn; const bool frontNext = input.wasReleased(MappedInputManager::Button::Right) || powerTurn; - // fromSideBtn is true when only side buttons contributed to this page turn. - const bool fromSide = (sidePrev || sideNext) && !(frontPrev || frontNext); - return {tiltPrev || sidePrev || frontPrev, tiltNext || sideNext || frontNext, fromSide, tiltPrev || tiltNext}; + // Side-button actions are resolved by SideButtonShortcuts in each reader. + return {tiltPrev || frontPrev, tiltNext || frontNext, false, tiltPrev || tiltNext}; } // One helper, blocking or deferred: the async form starts the refresh and diff --git a/src/activities/reader/SideButtonShortcuts.h b/src/activities/reader/SideButtonShortcuts.h new file mode 100644 index 00000000000..14e9c8b4320 --- /dev/null +++ b/src/activities/reader/SideButtonShortcuts.h @@ -0,0 +1,83 @@ +#pragma once + +#include + +#include "CrossPointSettings.h" +#include "MappedInputManager.h" +#include "ReaderUtils.h" +#include "util/QuickLockTrigger.h" + +// A reader owns one tracker for each logical side button. Short actions fire +// on release; long actions fire once when the hold threshold is reached. +// Tracking only starts on a press edge, so a button already held while a +// reader activity opens cannot turn into an accidental shortcut. +class SideButtonShortcuts { + public: + struct Result { + bool consumed = false; + bool triggered = false; + bool longPress = false; + bool up = false; + uint8_t action = CrossPointSettings::IGNORE; + }; + + static QuickLockTrigger quickLockTrigger(const Result& result) { + if (result.up) return result.longPress ? QuickLockTrigger::SideUpLong : QuickLockTrigger::SideUpShort; + return result.longPress ? QuickLockTrigger::SideDownLong : QuickLockTrigger::SideDownShort; + } + + static bool shouldConsume(const Result& result, const MappedInputManager& input) { + // Chord Page Turn arrives as an injected front-button release while a side + // button may still be held. Let that release reach the reader page handler. + return result.consumed && !input.hasInjectedRelease(MappedInputManager::Button::Right); + } + + Result update(const MappedInputManager& input, const unsigned long now) { + Result upResult = updateOne(input, MappedInputManager::Button::Up, true, up_, now); + Result downResult = updateOne(input, MappedInputManager::Button::Down, false, down_, now); + if (upResult.triggered) return upResult; + if (downResult.triggered) return downResult; + upResult.consumed = upResult.consumed || downResult.consumed; + return upResult; + } + + private: + struct ButtonState { + unsigned long pressedAt = 0; + bool tracking = false; + bool longFired = false; + }; + ButtonState up_; + ButtonState down_; + + static Result updateOne(const MappedInputManager& input, const MappedInputManager::Button button, const bool up, + ButtonState& state, const unsigned long now) { + Result result; + result.up = up; + if (input.wasPressed(button)) { + state.pressedAt = now; + state.tracking = true; + state.longFired = false; + } + if (!state.tracking) return result; + + result.consumed = true; + const bool pressed = input.isPressed(button); + const bool released = input.wasReleased(button); + const uint8_t longAction = up ? SETTINGS.sideButtonUpLong : SETTINGS.sideButtonDownLong; + if (!state.longFired && longAction != CrossPointSettings::IGNORE && + now - state.pressedAt >= ReaderUtils::SKIP_HOLD_MS && (pressed || released)) { + state.longFired = true; + result.triggered = true; + result.longPress = true; + result.action = longAction; + } else if (released) { + if (!state.longFired) { + result.triggered = true; + result.action = up ? SETTINGS.sideButtonUpShort : SETTINGS.sideButtonDownShort; + } + } + if (!pressed) state.tracking = false; + return result; + } +}; diff --git a/src/activities/reader/TxtReaderActivity.cpp b/src/activities/reader/TxtReaderActivity.cpp index 967c2de08a4..fd97568b788 100644 --- a/src/activities/reader/TxtReaderActivity.cpp +++ b/src/activities/reader/TxtReaderActivity.cpp @@ -273,42 +273,43 @@ void TxtReaderActivity::loop() { return; } - const bool sideLongPressChangesFont = - SETTINGS.sideButtonLongPress == CrossPointSettings::SIDE_LONG_PRESS::SIDE_LONG_FONT_SIZE; - const bool sideLongPressChangesOrientation = - SETTINGS.sideButtonLongPress == CrossPointSettings::SIDE_LONG_PRESS::SIDE_LONG_ORIENTATION_CHANGE; - if (sideLongPressChangesFont || sideLongPressChangesOrientation) { - const bool topReleased = mappedInput.wasReleased(MappedInputManager::Button::Up); - const bool bottomReleased = mappedInput.wasReleased(MappedInputManager::Button::Down); - if (sideButtonLongPressHandled && (topReleased || bottomReleased)) { - sideButtonLongPressHandled = false; - return; - } - - const bool longPressReady = mappedInput.getHeldTime() > ReaderUtils::SKIP_HOLD_MS; - const bool topLongPressed = - longPressReady && (mappedInput.isPressed(MappedInputManager::Button::Up) || topReleased); - const bool bottomLongPressed = - longPressReady && (mappedInput.isPressed(MappedInputManager::Button::Down) || bottomReleased); - - if (!sideButtonLongPressHandled && (topLongPressed || bottomLongPressed)) { - sideButtonLongPressHandled = !(topReleased || bottomReleased); - if (sideLongPressChangesFont) { - changeReaderFontSize(/*larger=*/topLongPressed); - return; - } - SETTINGS.orientation = ReaderUtils::rotatedOrientation(SETTINGS.orientation, /*clockwise=*/bottomLongPressed); - SETTINGS.saveToFile(); - { - RenderLock lock(*this); - ReaderUtils::applyOrientation(renderer, SETTINGS.orientation); - pageOffsets.clear(); - currentPageLines.clear(); - initialized = false; + const auto side = sideButtonShortcuts.update(mappedInput, millis()); + if (SideButtonShortcuts::shouldConsume(side, mappedInput)) { + if (side.triggered) { + if (side.longPress) + mappedInput.suppressNextSideRelease(side.up ? MappedInputManager::Button::Up + : MappedInputManager::Button::Down); + switch (side.action) { + case CrossPointSettings::PAGE_TURN: + if (currentPage < totalPages - 1) { + currentPage++; + requestUpdate(); + } + break; + case CrossPointSettings::SIDE_INCREASE_FONT: + case CrossPointSettings::SIDE_DECREASE_FONT: + changeReaderFontSize(side.action == CrossPointSettings::SIDE_INCREASE_FONT); + break; + case CrossPointSettings::SIDE_ROTATE_COUNTERCLOCKWISE: + case CrossPointSettings::SIDE_ROTATE_CLOCKWISE: + handleTwoFingerRotation(side.action == CrossPointSettings::SIDE_ROTATE_CLOCKWISE); + break; + case CrossPointSettings::SIDE_PREVIOUS_CHAPTER: + case CrossPointSettings::SIDE_NEXT_CHAPTER: + case CrossPointSettings::IGNORE: + // Plain text has no chapter model. + break; + default: { + const auto action = static_cast(side.action); + if (action == CrossPointSettings::QUICK_LOCK) + handleGlobalPowerButtonAction(action, SideButtonShortcuts::quickLockTrigger(side)); + else if (!handleShortcutAction(action)) + handleGlobalPowerButtonAction(action); + break; + } } - requestUpdate(); - return; } + return; } const bool frontLongPressChangesFont = SETTINGS.longPressButtonBehavior == CrossPointSettings::FONT_SIZE_CHANGE; diff --git a/src/activities/reader/TxtReaderActivity.h b/src/activities/reader/TxtReaderActivity.h index bf850e9ce3e..f002c0287af 100644 --- a/src/activities/reader/TxtReaderActivity.h +++ b/src/activities/reader/TxtReaderActivity.h @@ -6,6 +6,7 @@ #include "CrossPointSettings.h" #include "ReaderProgressSaveDebouncer.h" +#include "SideButtonShortcuts.h" #include "activities/Activity.h" #include "components/OptionPopup.h" #if CROSSINK_APP_CAP_TOUCH @@ -21,7 +22,7 @@ class TxtReaderActivity final : public Activity { int pagesUntilFullRefresh = 0; // Session-only display toggle; cached page layout remains unchanged. bool statusBarVisible = true; - bool sideButtonLongPressHandled = false; + SideButtonShortcuts sideButtonShortcuts; bool frontButtonLongPressHandled = false; bool longPowerButtonHandled = false; bool longPressBackHandled = false; diff --git a/src/activities/reader/XtcReaderActivity.cpp b/src/activities/reader/XtcReaderActivity.cpp index acdd20350d2..75b8206809d 100644 --- a/src/activities/reader/XtcReaderActivity.cpp +++ b/src/activities/reader/XtcReaderActivity.cpp @@ -204,10 +204,14 @@ void XtcReaderActivity::loop() { syncStatsTrackingState(); if (quickActionsPopup.handleInput(mappedInput, [this] { requestUpdate(); })) return; - const bool shortcutPageTurn = shortcutPageTurnPending; + bool shortcutPageTurn = shortcutPageTurnPending; + bool shortcutPageTurnFromSide = shortcutPageTurnPendingFromSide; shortcutPageTurnPending = false; - const bool shortcutPreviousPage = shortcutPreviousPagePending; + shortcutPageTurnPendingFromSide = false; + bool shortcutPreviousPage = shortcutPreviousPagePending; + bool shortcutPreviousPageFromSide = shortcutPreviousPagePendingFromSide; shortcutPreviousPagePending = false; + shortcutPreviousPagePendingFromSide = false; const auto touch = ReaderUtils::detectTouchPageTurn(renderer, mappedInput); const int bottomHeight = UITheme::getReaderStatusBarHeight(ReaderStatusBarPosition::Bottom); @@ -334,73 +338,72 @@ void XtcReaderActivity::loop() { return; } - const bool sideLongPressSkipsChapter = - SETTINGS.sideButtonLongPress == CrossPointSettings::SIDE_LONG_PRESS::SIDE_LONG_CHAPTER_SKIP; - if (sideLongPressSkipsChapter) { - const bool sidePrevReleased = mappedInput.wasReleased(MappedInputManager::Button::PageBack); - const bool sideNextReleased = mappedInput.wasReleased(MappedInputManager::Button::PageForward); - if (sideButtonLongPressHandled && (sidePrevReleased || sideNextReleased)) { - sideButtonLongPressHandled = false; - return; - } - - const bool longPressReady = mappedInput.getHeldTime() > ReaderUtils::SKIP_HOLD_MS; - const bool prevLongPressed = longPressReady && mappedInput.isPressed(MappedInputManager::Button::PageBack); - const bool nextLongPressed = longPressReady && mappedInput.isPressed(MappedInputManager::Button::PageForward); - if (!sideButtonLongPressHandled && (prevLongPressed || nextLongPressed)) { - sideButtonLongPressHandled = true; - - bool goHome = false; - bool needsUpdate = false; - { - RenderLock lock(*this); - const uint32_t pageCount = xtc->getPageCount(); - if (currentPage >= pageCount) { - if (nextLongPressed) { - goHome = true; - } else { - currentPage = pageCount > 0 ? pageCount - 1 : 0; - needsUpdate = true; - } - } else { - uint32_t forwardReadSeconds = 0; - const bool shouldRecordForwardRead = - nextLongPressed && forwardPageReadElapsed(forwardReadSeconds, "side_long_press"); - recordCurrentPageReadingTime("side_long_press"); - if (prevLongPressed) { - currentPage = currentPage >= 10 ? currentPage - 10 : 0; - } else { - currentPage += 10; + const bool chordPageTurn = mappedInput.hasInjectedRelease(MappedInputManager::Button::Right); + const auto side = sideButtonShortcuts.update(mappedInput, millis()); + if (SideButtonShortcuts::shouldConsume(side, mappedInput)) { + if (side.triggered) { + if (side.longPress) + mappedInput.suppressNextSideRelease(side.up ? MappedInputManager::Button::Up + : MappedInputManager::Button::Down); + switch (side.action) { + case CrossPointSettings::PAGE_TURN: + shortcutPageTurn = true; + shortcutPageTurnFromSide = true; + break; + case CrossPointSettings::PREVIOUS_PAGE: + shortcutPreviousPage = true; + shortcutPreviousPageFromSide = true; + break; + case CrossPointSettings::SIDE_PREVIOUS_CHAPTER: + case CrossPointSettings::SIDE_NEXT_CHAPTER: { + // Fixed-page books keep the established ten-page chapter-skip step. + const bool next = side.action == CrossPointSettings::SIDE_NEXT_CHAPTER; + bool goHome = false; + { + RenderLock lock(*this); + const uint32_t pageCount = xtc->getPageCount(); if (currentPage >= pageCount) { - currentPage = pageCount; - } - if (shouldRecordForwardRead) { - recordForwardPageTurn(forwardReadSeconds, false); + if (next) + goHome = true; + else + currentPage = pageCount > 0 ? pageCount - 1 : 0; + } else { + uint32_t forwardReadSeconds = 0; + const bool recordForward = next && forwardPageReadElapsed(forwardReadSeconds, "side_long_press"); + recordCurrentPageReadingTime("side_long_press"); + currentPage = + next ? std::min(currentPage + 10, pageCount) : (currentPage >= 10 ? currentPage - 10 : 0); + if (recordForward) recordForwardPageTurn(forwardReadSeconds, false); } } - needsUpdate = true; + if (goHome) + onGoHome(); + else + requestUpdate(); + break; + } + case CrossPointSettings::SIDE_INCREASE_FONT: + case CrossPointSettings::SIDE_DECREASE_FONT: + case CrossPointSettings::SIDE_ROTATE_COUNTERCLOCKWISE: + case CrossPointSettings::SIDE_ROTATE_CLOCKWISE: + case CrossPointSettings::IGNORE: + // XTC pages are pre-rendered; font size and rotation do not apply. + break; + default: { + const auto action = static_cast(side.action); + if (action == CrossPointSettings::QUICK_LOCK) + handleGlobalPowerButtonAction(action, SideButtonShortcuts::quickLockTrigger(side)); + else if (!handleShortcutAction(action)) + handleGlobalPowerButtonAction(action); + break; } } - if (goHome) { - onGoHome(); - return; - } - if (needsUpdate) { - requestUpdate(); - } - return; } + if (!shortcutPageTurn && !shortcutPreviousPage) return; } - // Side buttons fire on press only when long-press action is OFF. - const bool sideUsePress = SETTINGS.sideButtonLongPress == CrossPointSettings::SIDE_LONG_PRESS::SIDE_LONG_OFF; - const bool tiltNext = SETTINGS.tiltPageTurn && halTiltSensor.wasTiltedForward(); const bool tiltPrev = SETTINGS.tiltPageTurn && halTiltSensor.wasTiltedBack(); - const bool sidePrev = sideUsePress ? mappedInput.wasPressed(MappedInputManager::Button::PageBack) - : mappedInput.wasReleased(MappedInputManager::Button::PageBack); - const bool sideNext = sideUsePress ? mappedInput.wasPressed(MappedInputManager::Button::PageForward) - : mappedInput.wasReleased(MappedInputManager::Button::PageForward); const bool frontPrev = mappedInput.wasReleased(MappedInputManager::Button::Left); const bool powerReleased = mappedInput.wasReleased(MappedInputManager::Button::Power); if (longPowerPageTurnHandled && (powerReleased || !mappedInput.isPressed(MappedInputManager::Button::Power))) { @@ -496,10 +499,9 @@ void XtcReaderActivity::loop() { } } - const bool fromSideBtn = (sidePrev || sideNext) && !(frontPrev || frontNext); const bool fromTilt = tiltPrev || tiltNext; - bool prevTriggered = tiltPrev || sidePrev || frontPrev; - bool nextTriggered = tiltNext || sideNext || frontNext; + bool prevTriggered = tiltPrev || frontPrev; + bool nextTriggered = tiltNext || frontNext; prevTriggered = prevTriggered || touch.prev || shortcutPreviousPage; nextTriggered = nextTriggered || touch.next || shortcutPageTurn; @@ -513,28 +515,24 @@ void XtcReaderActivity::loop() { const bool fromTouch = touch.prev || touch.next; const unsigned long heldMs = fromTouch ? touch.heldMs : mappedInput.getHeldTime(); - // XTC pages are fixed-size bitmaps, so the orientation long-press action is - // consumed here instead of rotating/clipping the pre-rendered page image. - if (fromSideBtn && - SETTINGS.sideButtonLongPress == CrossPointSettings::SIDE_LONG_PRESS::SIDE_LONG_ORIENTATION_CHANGE && - heldMs > ReaderUtils::SKIP_HOLD_MS) { - return; - } - // Mirror EPUB's manual-turn guard: the render task updates the panel // concurrently, so accepting another turn before it owns RenderLock can mix // two pages. The short time gap covers that request-to-render startup window. constexpr unsigned long kMinManualTurnGapMs = 200; const unsigned long now = millis(); if (RenderLock::peek() || (now - lastPageTurnTime) < kMinManualTurnGapMs) { + shortcutPageTurnPending = shortcutPageTurnPending || shortcutPageTurn; + shortcutPageTurnPendingFromSide = shortcutPageTurnPendingFromSide || shortcutPageTurnFromSide; + shortcutPreviousPagePending = shortcutPreviousPagePending || shortcutPreviousPage; + shortcutPreviousPagePendingFromSide = shortcutPreviousPagePendingFromSide || shortcutPreviousPageFromSide; return; } lastPageTurnTime = now; - const bool skipPages = - !fromTouch && !fromTilt && !powerPageTurn && heldMs > ReaderUtils::SKIP_HOLD_MS && - (fromSideBtn ? SETTINGS.sideButtonLongPress == CrossPointSettings::SIDE_LONG_PRESS::SIDE_LONG_CHAPTER_SKIP - : SETTINGS.longPressButtonBehavior == CrossPointSettings::CHAPTER_SKIP); + const bool sideShortcutPageTurn = shortcutPageTurnFromSide || shortcutPreviousPageFromSide; + const bool skipPages = !chordPageTurn && !sideShortcutPageTurn && !fromTouch && !fromTilt && !powerPageTurn && + heldMs > ReaderUtils::SKIP_HOLD_MS && + SETTINGS.longPressButtonBehavior == CrossPointSettings::CHAPTER_SKIP; const int skipAmount = skipPages ? 10 : 1; bool goHome = false; diff --git a/src/activities/reader/XtcReaderActivity.h b/src/activities/reader/XtcReaderActivity.h index 97120874e77..912b75d5eaf 100644 --- a/src/activities/reader/XtcReaderActivity.h +++ b/src/activities/reader/XtcReaderActivity.h @@ -17,6 +17,7 @@ #include "EndOfBookOptions.h" #include "GlobalReadingStats.h" #include "ReaderProgressSaveDebouncer.h" +#include "SideButtonShortcuts.h" #include "activities/Activity.h" #include "components/OptionPopup.h" @@ -41,11 +42,13 @@ class XtcReaderActivity final : public Activity { // Home-key shortcuts are dispatched before this activity's normal input loop. // Queue the turn so it follows the same guarded XTC page-turn path. bool shortcutPageTurnPending = false; + bool shortcutPageTurnPendingFromSide = false; bool shortcutPreviousPagePending = false; + bool shortcutPreviousPagePendingFromSide = false; // Session-only display toggle; fixed-layout XTC pages are never regenerated. bool statusBarVisible = true; bool longPressMenuHandled = false; - bool sideButtonLongPressHandled = false; + SideButtonShortcuts sideButtonShortcuts; bool frontButtonLongPressHandled = false; bool longPressBackHandled = false; bool skipRecentBookUpdateOnEntry = false; diff --git a/src/activities/settings/QuickActionsActivity.cpp b/src/activities/settings/QuickActionsActivity.cpp index 824d36aa433..11dfd1b323e 100644 --- a/src/activities/settings/QuickActionsActivity.cpp +++ b/src/activities/settings/QuickActionsActivity.cpp @@ -18,6 +18,15 @@ constexpr StrId triggerLabels[] = { StrId::STR_TAP_HOME_SHORTCUT, StrId::STR_LONG_PRESS_HOME_SHORTCUT, StrId::STR_DOUBLE_TAP_HOME_SHORTCUT, StrId::STR_SIDE_BUTTON_CHORD}; +std::string triggerLabel(const QuickActions::Trigger trigger) { + const auto raw = static_cast(trigger); + if (raw < std::size(triggerLabels)) return I18N.get(triggerLabels[raw]); + const bool up = trigger == QuickActions::Trigger::SideUpShort || trigger == QuickActions::Trigger::SideUpLong; + const bool longPress = trigger == QuickActions::Trigger::SideUpLong || trigger == QuickActions::Trigger::SideDownLong; + return std::string(up ? tr(STR_DIR_LEFT) : tr(STR_DIR_RIGHT)) + "/" + (up ? tr(STR_DIR_UP) : tr(STR_DIR_DOWN)) + " " + + (longPress ? tr(STR_LONG_PRESS_ACTION) : tr(STR_SHORT_PWR_BTN)); +} + std::vector availableTriggers() { std::vector triggers = {QuickActions::Trigger::None, QuickActions::Trigger::ShortPower, QuickActions::Trigger::LongPower, QuickActions::Trigger::PowerUp}; @@ -26,6 +35,10 @@ std::vector availableTriggers() { triggers.push_back(QuickActions::Trigger::UpDown); } #endif + triggers.push_back(QuickActions::Trigger::SideUpShort); + triggers.push_back(QuickActions::Trigger::SideUpLong); + triggers.push_back(QuickActions::Trigger::SideDownShort); + triggers.push_back(QuickActions::Trigger::SideDownLong); if (gpio.hasHomeKey()) { triggers.push_back(QuickActions::Trigger::TapHome); triggers.push_back(QuickActions::Trigger::LongPressHome); @@ -79,8 +92,7 @@ void QuickActionsActivity::showOverview() { if (std::find(triggers.begin(), triggers.end(), draftTrigger) == triggers.end()) { draftTrigger = QuickActions::Trigger::None; } - rows.emplace_back(std::string(I18N.get(StrId::STR_SHORTCUT)) + ": " + - I18N.get(triggerLabels[static_cast(draftTrigger)])); + rows.emplace_back(std::string(I18N.get(StrId::STR_SHORTCUT)) + ": " + triggerLabel(draftTrigger)); for (uint8_t i = 0; i < 5; ++i) { const uint8_t action = draftSlots[i]; const char* label = @@ -105,7 +117,7 @@ void QuickActionsActivity::editShortcut() { labels.reserve(triggers.size()); uint8_t current = 0; for (uint8_t i = 0; i < triggers.size(); ++i) { - labels.emplace_back(I18N.get(triggerLabels[static_cast(triggers[i])])); + labels.emplace_back(triggerLabel(triggers[i])); if (triggers[i] == draftTrigger) current = i; } popup.show(StrId::STR_SHORTCUT, labels, current, [this, triggers](int selected) { diff --git a/src/activities/settings/SettingsActivity.cpp b/src/activities/settings/SettingsActivity.cpp index 6cd9b6e7089..c2d81d87c15 100644 --- a/src/activities/settings/SettingsActivity.cpp +++ b/src/activities/settings/SettingsActivity.cpp @@ -72,7 +72,7 @@ constexpr size_t controlsHomeButtonCount = 4; constexpr size_t controlsPowerMinCount = 2; constexpr size_t controlsPowerMaxCount = 3; constexpr size_t controlsFrontButtonCount = 6; -constexpr size_t controlsSideButtonBaseCount = 3; +constexpr size_t controlsSideButtonBaseCount = 7; void formatFrontlightScheduleTime(const uint16_t timeOfDay, char* const buf, const size_t len) { const FrontlightSchedule::TimeOfDay time = FrontlightSchedule::timeOfDayFromMinutes(timeOfDay); @@ -557,7 +557,7 @@ void SettingsActivity::openEnumOptionPicker(const SettingInfo& setting) { std::vector options; options.reserve(optionCount); for (uint8_t i = 0; i < optionCount; i++) { - options.push_back(settingEnumOptionLabel(setting, i)); + options.push_back(sideButtonOptionLabel(setting, i)); } uint8_t currentIndex = 0; @@ -1326,7 +1326,7 @@ std::string SettingsActivity::settingValueText(const SettingInfo& setting) { } if (setting.type == SettingType::ENUM && setting.valuePtr != nullptr) { const uint8_t displayIndex = enumDisplayIndexForRawValue(setting, SETTINGS.*(setting.valuePtr)); - return settingEnumOptionLabel(setting, displayIndex); + return sideButtonOptionLabel(setting, displayIndex); } if (setting.type == SettingType::ENUM && setting.valueGetter) { return settingEnumOptionLabel(setting, setting.valueGetter()); @@ -1507,6 +1507,8 @@ void SettingsActivity::buildSettingsScreen(UiApp::ScreenType& screen) { // Settings rows. Values are built per render and owned for the draw only. const auto& settings = *currentSettings; + const std::string leftUpLabel = sideButtonGroupLabel(true); + const std::string rightDownLabel = sideButtonGroupLabel(false); std::vector values(settings.size()); std::vector items; items.reserve(settings.size()); @@ -1517,7 +1519,11 @@ void SettingsActivity::buildSettingsScreen(UiApp::ScreenType& screen) { const fui::BitmapRef directionIcon = swipeActionIcon(settings[i].nameId); const fui::BitmapRef endpointIcon = frontlightScheduleEndpointIcon(settings[i]); const fui::BitmapRef itemIcon = directionIcon ? directionIcon : endpointIcon; - item.label = isSectionHeader ? uiListSectionHeaderLabel(values[i], I18N.get(settings[i].nameId)) + const char* sectionLabel = + activeSubmenu == SettingAction::ControlsSideButtons + ? (settings[i].nameId == StrId::STR_DIR_LEFT ? leftUpLabel.c_str() : rightDownLabel.c_str()) + : I18N.get(settings[i].nameId); + item.label = isSectionHeader ? uiListSectionHeaderLabel(values[i], sectionLabel) : (directionIcon ? "" : I18N.get(settings[i].nameId)); item.icon = itemIcon; if (!isSectionHeader && !values[i].empty()) item.value = values[i].c_str(); @@ -1542,6 +1548,10 @@ void SettingsActivity::buildSettingsScreen(UiApp::ScreenType& screen) { // File Browser, reader menus, and the other list-style screens. props.labelText = screen.theme().bodyText; props.labelText.maxLines = 2; + if (activeSubmenu == SettingAction::ControlsSideButtons) { + props.headerText = screen.theme().smallText; + props.headerText.bold = true; + } configureUiListSectionHeaders(props, screen.theme()); const auto rows = configureUiList(props, screen.theme(), screen.body()); visibleRows = rows > 0 ? rows : 1; diff --git a/src/main.cpp b/src/main.cpp index 42a653bf2f8..eb33f5106fd 100644 --- a/src/main.cpp +++ b/src/main.cpp @@ -93,6 +93,7 @@ inline esp_sleep_wakeup_cause_t esp_sleep_get_wakeup_cause() { return ESP_SLEEP_ #include "activities/boot_sleep/ImageFolderIndex.h" #include "activities/home/BookActions.h" #include "activities/reader/KOReaderSyncActivity.h" +#include "activities/reader/ReaderUtils.h" #include "activities/reader/ReadingStatsUtils.h" #include "activities/reader/StatsBackup.h" #include "activities/settings/FontDownloadActivity.h" @@ -1689,6 +1690,19 @@ void loop() { lastActivityTime = millis(); return; } + if (buttonShortcutController.tryUnlockSide(millis(), mappedInputManager.isPressed(MappedInputManager::Button::Up), + mappedInputManager.wasPressed(MappedInputManager::Button::Up), + mappedInputManager.wasReleased(MappedInputManager::Button::Up), + SETTINGS.sideButtonUpLong != CrossPointSettings::IGNORE, + mappedInputManager.isPressed(MappedInputManager::Button::Down), + mappedInputManager.wasPressed(MappedInputManager::Button::Down), + mappedInputManager.wasReleased(MappedInputManager::Button::Down), + SETTINGS.sideButtonDownLong != CrossPointSettings::IGNORE, + ReaderUtils::SKIP_HOLD_MS)) { + notifyQuickLockChanged(); + lastActivityTime = millis(); + return; + } if (handleX4ProHomeKeyQuickLockUnlock()) { lastActivityTime = millis(); return; diff --git a/src/network/CrossPointWebServer.cpp b/src/network/CrossPointWebServer.cpp index 06e0f5dae2f..fe2af862657 100644 --- a/src/network/CrossPointWebServer.cpp +++ b/src/network/CrossPointWebServer.cpp @@ -1532,7 +1532,12 @@ void CrossPointWebServer::handleGetSettings() const { doc.clear(); doc["key"] = s.key; - doc["name"] = I18N.get(s.nameId); + if (isSideButtonActionSetting(s)) { + const bool up = settingKeyIs(s, "sideButtonUpShort") || settingKeyIs(s, "sideButtonUpLong"); + doc["name"] = sideButtonGroupLabel(up) + " " + I18N.get(s.nameId); + } else { + doc["name"] = I18N.get(s.nameId); + } doc["category"] = I18N.get(s.category); switch (s.type) { @@ -1595,7 +1600,7 @@ void CrossPointWebServer::handleGetSettings() const { } else { for (size_t optionIndex = 0; optionIndex < s.enumValues.size(); ++optionIndex) { if (isWebEnumOptionAvailable(s, optionIndex)) { - options.add(I18N.get(s.enumValues[optionIndex])); + options.add(sideButtonOptionLabel(s, static_cast(optionIndex))); } } } diff --git a/src/simulator/SimulatorSmokeTest.cpp b/src/simulator/SimulatorSmokeTest.cpp index 4e0102d68e3..dd601960c1a 100644 --- a/src/simulator/SimulatorSmokeTest.cpp +++ b/src/simulator/SimulatorSmokeTest.cpp @@ -35,10 +35,12 @@ #include "activities/reader/ReaderFontLoading.h" #include "activities/reader/ReaderOptionsActivity.h" #include "activities/reader/ReaderUtils.h" +#include "activities/reader/SideButtonShortcuts.h" #include "activities/settings/QuickActionsActivity.h" #include "components/TouchHeaderBackButton.h" #include "components/UITheme.h" #include "simulator/SimulatorHomeKeyInput.h" +#include "util/ButtonShortcutController.h" extern ActivityManager activityManager; extern GfxRenderer renderer; @@ -53,6 +55,7 @@ enum class SmokeStep : uint8_t { FileBrowserSettings, Library, Settings, + SideButtons, ReaderOptions, ReaderMenu, Sleep, @@ -459,6 +462,9 @@ class SimulatorSmokeTest { }; verifyLibraryChoice("shortPwrBtn", ShortcutOptionCatalog::PowerButton); verifyLibraryChoice("longPwrBtn", ShortcutOptionCatalog::PowerButton); + for (const char* key : {"sideButtonUpShort", "sideButtonUpLong", "sideButtonDownShort", "sideButtonDownLong"}) { + verifyLibraryChoice(key, ShortcutOptionCatalog::SideButton); + } verifyLibraryChoice("powerChordAction", ShortcutOptionCatalog::ButtonChord); verifyLibraryChoice("longPressMenuAction", ShortcutOptionCatalog::LongPress); verifyLibraryChoice("longPressBackAction", ShortcutOptionCatalog::LongPress); @@ -479,6 +485,111 @@ class SimulatorSmokeTest { QuickActions::actionLabel(CrossPointSettings::LIBRARY) != StrId::STR_LIBRARY) { fail("Library is missing from Quick Actions choices"); } + + const uint8_t savedTrackReadingStats = SETTINGS.trackReadingStats; + SETTINGS.trackReadingStats = 0; + const auto statsDisabledSettings = getSettingsList(); + SETTINGS.trackReadingStats = savedTrackReadingStats; + for (const char* key : {"sideButtonUpShort", "sideButtonUpLong", "sideButtonDownShort", "sideButtonDownLong"}) { + const auto setting = std::find_if(statsDisabledSettings.begin(), statsDisabledSettings.end(), + [key](const SettingInfo& candidate) { return settingKeyIs(candidate, key); }); + if (setting == statsDisabledSettings.end() || + std::find(setting->enumRawValues.begin(), setting->enumRawValues.end(), CrossPointSettings::READING_STATS) != + setting->enumRawValues.end()) { + fail("Reading Stats remains available in %s while tracking is disabled", key); + } + } + } + + static void verifySideButtonMigrationAndInput() { + JsonDocument original; + SETTINGS.toJson(original); + JsonDocument legacy; + SETTINGS.toJson(legacy); + for (const char* key : {"sideButtonUpShort", "sideButtonUpLong", "sideButtonDownShort", "sideButtonDownLong"}) { + legacy[key] = nullptr; + } + legacy["sideButtonLayout"] = CrossPointSettings::NEXT_PREV; + legacy["sideButtonLongPress"] = CrossPointSettings::SIDE_LONG_CHAPTER_SKIP; + SETTINGS.fromJson(legacy.as()); + if (SETTINGS.sideButtonUpShort != CrossPointSettings::PAGE_TURN || + SETTINGS.sideButtonDownShort != CrossPointSettings::PREVIOUS_PAGE || + SETTINGS.sideButtonUpLong != CrossPointSettings::SIDE_NEXT_CHAPTER || + SETTINGS.sideButtonDownLong != CrossPointSettings::SIDE_PREVIOUS_CHAPTER) + fail("Swapped side-button layout migration failed"); + + legacy["sideButtonLayout"] = CrossPointSettings::SIDE_BUTTONS_DISABLED; + legacy["sideButtonLongPress"] = CrossPointSettings::SIDE_LONG_FONT_SIZE; + SETTINGS.fromJson(legacy.as()); + if (SETTINGS.sideButtonUpShort != CrossPointSettings::IGNORE || + SETTINGS.sideButtonDownShort != CrossPointSettings::IGNORE || + SETTINGS.sideButtonUpLong != CrossPointSettings::SIDE_INCREASE_FONT || + SETTINGS.sideButtonDownLong != CrossPointSettings::SIDE_DECREASE_FONT) + fail("Disabled layout with font hold migration failed"); + + legacy["sideButtonUpShort"] = CrossPointSettings::LIBRARY; + SETTINGS.fromJson(legacy.as()); + if (SETTINGS.sideButtonUpShort != CrossPointSettings::LIBRARY || + SETTINGS.sideButtonDownShort != CrossPointSettings::IGNORE) + fail("Partially migrated side-button choices were overwritten"); + + SETTINGS.fromJson(original.as()); + const auto sideSettings = buildControlsSideButtonSettingsList(getSettingsList()); + const size_t expected = 7 + (deviceSupportsSideButtonChord(gpio) ? 1u : 0u); + if (sideSettings.size() != expected) fail("Side-button menu row count mismatch"); + for (const char* key : {"sideButtonUpShort", "sideButtonUpLong", "sideButtonDownShort", "sideButtonDownLong"}) { + if (std::find_if(sideSettings.begin(), sideSettings.end(), + [key](const SettingInfo& setting) { return settingKeyIs(setting, key); }) == sideSettings.end()) + fail("Side-button menu is missing an individual shortcut"); + } + + SideButtonShortcuts shortcuts; + mappedInputManager.simulatorInjectPress(MappedInputManager::Button::Up); + const auto heldUp = shortcuts.update(mappedInputManager, 1000); + if (!heldUp.consumed) fail("Side-button press was not captured"); + mappedInputManager.injectRelease(MappedInputManager::Button::Right); + if (SideButtonShortcuts::shouldConsume(heldUp, mappedInputManager)) + fail("Side-button hold swallowed a chord page turn"); + mappedInputManager.clearInjectedReleases(); + mappedInputManager.simulatorClearInputFrame(); + mappedInputManager.simulatorInjectRelease(MappedInputManager::Button::Up); + const auto shortResult = shortcuts.update(mappedInputManager, 1200); + if (!shortResult.triggered || shortResult.longPress || shortResult.action != SETTINGS.sideButtonUpShort) + fail("Side-button short action did not dispatch once"); + mappedInputManager.simulatorClearInputFrame(); + if (shortcuts.update(mappedInputManager, 1300).triggered) fail("Side-button release dispatched twice"); + + mappedInputManager.simulatorInjectPress(MappedInputManager::Button::Down); + shortcuts.update(mappedInputManager, 2000); + mappedInputManager.simulatorClearInputFrame(); + const auto longResult = shortcuts.update(mappedInputManager, 2800); + if (!longResult.triggered || !longResult.longPress || longResult.action != SETTINGS.sideButtonDownLong) + fail("Side-button long action did not dispatch"); + mappedInputManager.suppressNextSideRelease(MappedInputManager::Button::Down); + mappedInputManager.simulatorInjectRelease(MappedInputManager::Button::Down); + if (mappedInputManager.wasReleased(MappedInputManager::Button::Down) || + shortcuts.update(mappedInputManager, 2900).triggered) + fail("Side-button long release leaked into the next activity"); + mappedInputManager.simulatorClearInputFrame(); + if (shortcuts.update(mappedInputManager, 3000).triggered) fail("Side-button long action dispatched twice"); + + ButtonShortcutController lock; + lock.toggleQuickLock(4000, QuickLockTrigger::SideUpShort); + if (lock.tryUnlockSide(4100, true, true, false, true, false, false, false, true, ReaderUtils::SKIP_HOLD_MS) || + !lock.isQuickLocked()) + fail("Side-button Quick Lock unlocked on press instead of release"); + if (!lock.tryUnlockSide(4200, false, false, true, true, false, false, false, true, ReaderUtils::SKIP_HOLD_MS) || + lock.isQuickLocked()) + fail("Side-button short Quick Lock did not unlock on a fresh release"); + lock.toggleQuickLock(5000, QuickLockTrigger::SideDownLong); + if (lock.tryUnlockSide(5100, false, false, false, true, true, false, false, true, ReaderUtils::SKIP_HOLD_MS)) + fail("An inherited side-button hold unlocked Quick Lock"); + lock.tryUnlockSide(5200, false, false, false, true, false, false, true, true, ReaderUtils::SKIP_HOLD_MS); + lock.tryUnlockSide(5300, false, false, false, true, true, true, false, true, ReaderUtils::SKIP_HOLD_MS); + if (!lock.tryUnlockSide(6000, false, false, false, true, true, false, false, true, ReaderUtils::SKIP_HOLD_MS) || + lock.isQuickLocked()) + fail("Side-button long Quick Lock did not unlock on a fresh hold"); + LOG_INF("SMOKE", "Side-button migration and press/release checks passed"); } [[noreturn]] static void fail(const char* message) { @@ -576,6 +687,7 @@ class SimulatorSmokeTest { fail("Simulator Home key timing contract failed"); } verifyUpDownShortcutAvailability(); + verifySideButtonMigrationAndInput(); verifyReaderControlsSettings(); verifyMixedPageGestures(); #if CROSSINK_SCALABLE_FONTS @@ -844,6 +956,22 @@ class SimulatorSmokeTest { case SmokeStep::Settings: renderer.setOrientation(GfxRenderer::Orientation::Portrait); + if (!mappedInputManager.hasTouchHardware()) { + inputScript.clear(); + scriptIndex = 0; + inputCompletionStep = SmokeStep::SideButtons; + addTap(MappedInputManager::Button::Confirm); // Reader tab + addTap(MappedInputManager::Button::Confirm); // Controls tab + addTap(MappedInputManager::Button::Down); // Power Button + addTap(MappedInputManager::Button::Down); // Front Buttons + addTap(MappedInputManager::Button::Down); // Side Buttons + addTap(MappedInputManager::Button::Confirm); + inputScript.push_back(render("Side Button Settings", 250)); + step = SmokeStep::ReaderInput; + break; + } + [[fallthrough]]; + case SmokeStep::SideButtons: activityManager.replaceActivity(std::make_unique(renderer, mappedInputManager)); queueStep("Reader Options", SmokeStep::ReaderOptions); break; diff --git a/src/util/ButtonShortcutController.h b/src/util/ButtonShortcutController.h index 1bc932885e2..3cf572ab5b9 100644 --- a/src/util/ButtonShortcutController.h +++ b/src/util/ButtonShortcutController.h @@ -119,6 +119,17 @@ class ButtonShortcutController { const bool locked = quickLockState_.toggle(nowMs); quickLockTrigger_ = locked ? trigger : QuickLockTrigger::None; unlockTriggerReleased_ = !locked || !requireRelease; + sideUpUnlock_ = {}; + sideDownUnlock_ = {}; + } + bool tryUnlockSide(uint32_t nowMs, bool upPressed, bool upPressEdge, bool upReleaseEdge, bool upHasLongAction, + bool downPressed, bool downPressEdge, bool downReleaseEdge, bool downHasLongAction, + uint32_t longPressMs) { + if (!quickLockState_.isLocked()) return false; + return tryUnlockOneSide(nowMs, upPressed, upPressEdge, upReleaseEdge, upHasLongAction, longPressMs, + QuickLockTrigger::SideUpShort, QuickLockTrigger::SideUpLong, sideUpUnlock_) || + tryUnlockOneSide(nowMs, downPressed, downPressEdge, downReleaseEdge, downHasLongAction, longPressMs, + QuickLockTrigger::SideDownShort, QuickLockTrigger::SideDownLong, sideDownUnlock_); } bool tryUnlockLongPower(uint32_t nowMs, bool longPowerPressed) { if (!quickLockState_.isLocked() || quickLockTrigger_ != QuickLockTrigger::LongPower) return false; @@ -143,6 +154,33 @@ class ButtonShortcutController { } private: + struct SideUnlockState { + uint32_t pressedAt = 0; + bool tracking = false; + bool longHandled = false; + }; + + bool tryUnlockOneSide(uint32_t nowMs, bool pressed, bool pressEdge, bool releaseEdge, bool hasLongAction, + uint32_t longPressMs, QuickLockTrigger shortTrigger, QuickLockTrigger longTrigger, + SideUnlockState& state) { + if (pressEdge) { + state = {nowMs, true, false}; + } + if (!state.tracking) return false; + if (!state.longHandled && hasLongAction && nowMs - state.pressedAt >= longPressMs && (pressed || releaseEdge)) { + state.longHandled = true; + if (quickLockTrigger_ == longTrigger) { + toggleQuickLock(nowMs, longTrigger); + return true; + } + } else if (releaseEdge && !state.longHandled && quickLockTrigger_ == shortTrigger) { + toggleQuickLock(nowMs, shortTrigger); + return true; + } + if (!pressed) state.tracking = false; + return false; + } + bool canRunChordWhileQuickLocked(ChordAction action, QuickLockTrigger trigger) const { return action == ChordAction::QuickLock && quickLockTrigger_ == trigger; } @@ -169,4 +207,6 @@ class ButtonShortcutController { bool powerDownChordActive_ = false; bool upDownChordActive_ = false; bool unlockTriggerReleased_ = true; + SideUnlockState sideUpUnlock_; + SideUnlockState sideDownUnlock_; }; diff --git a/src/util/DictionaryActivityUtils.h b/src/util/DictionaryActivityUtils.h index 23cf2af8a25..24682c30762 100644 --- a/src/util/DictionaryActivityUtils.h +++ b/src/util/DictionaryActivityUtils.h @@ -18,17 +18,15 @@ inline void cancelAndFinish(Activity& act) { act.finish(); } -// Dictionary screens normally follow the configured reader side-button layout. -// Next/Next has no PageBack binding, so reserve Up/Left for previous navigation -// and keep Down/Right as next. +// Dictionary screens follow configured short page actions. When both side +// buttons turn forward, reserve Up for previous navigation in the picker. inline bool dictionaryPageButtonTriggered(MappedInputManager& input, const bool previous) { - const bool usePress = SETTINGS.sideButtonLongPress == CrossPointSettings::SIDE_LONG_PRESS::SIDE_LONG_OFF; - const auto sideLayout = static_cast(SETTINGS.sideButtonLayout); const MappedInputManager::Button button = - sideLayout == CrossPointSettings::NEXT_NEXT + SETTINGS.sideButtonUpShort == CrossPointSettings::PAGE_TURN && + SETTINGS.sideButtonDownShort == CrossPointSettings::PAGE_TURN ? (previous ? MappedInputManager::Button::Up : MappedInputManager::Button::Down) : (previous ? MappedInputManager::Button::PageBack : MappedInputManager::Button::PageForward); - return usePress ? input.wasPressed(button) : input.wasReleased(button); + return input.wasReleased(button); } inline void drawWordSelectButtonHints(GfxRenderer& renderer, const MappedInputManager& mappedInput, diff --git a/src/util/QuickLockTrigger.h b/src/util/QuickLockTrigger.h index d538242e6bc..e547d307863 100644 --- a/src/util/QuickLockTrigger.h +++ b/src/util/QuickLockTrigger.h @@ -14,4 +14,8 @@ enum class QuickLockTrigger : uint8_t { HomeDoubleTap, HomeTap, HomeLongPress, + SideUpShort, + SideUpLong, + SideDownShort, + SideDownLong, }; From c364f54758476dc1a1cd61b261b06b008dd4be41 Mon Sep 17 00:00:00 2001 From: Julia Nguyen Date: Mon, 28 Sep 2026 15:54:24 -0400 Subject: [PATCH 56/62] fix: restore reader preview height --- src/activities/reader/EpubReaderDrawerActivity.cpp | 12 +++++++++--- 1 file changed, 9 insertions(+), 3 deletions(-) diff --git a/src/activities/reader/EpubReaderDrawerActivity.cpp b/src/activities/reader/EpubReaderDrawerActivity.cpp index 71ce6f2ee39..60ea10ab9ac 100644 --- a/src/activities/reader/EpubReaderDrawerActivity.cpp +++ b/src/activities/reader/EpubReaderDrawerActivity.cpp @@ -874,10 +874,12 @@ void EpubReaderDrawerActivity::buildDrawer(UiApp::ScreenType& screen) { const fui::Rect sheetContent = screen.sheet(sheet, drawerHeight()); drawerHandleRect = DrawerHandle::registerTap(screen.frame(), sheetContent, sheet, ACTION_DISMISS); } + int16_t buttonHeaderHeight = 0; if (buttonDevice) { const auto& metrics = UITheme::getInstance().getMetrics(); - screen.takeTop(static_cast(metrics.topPadding + TouchHeaderBackButton::height(metrics, mappedInput) + - metrics.tabBarHeight)); + buttonHeaderHeight = static_cast(metrics.topPadding + TouchHeaderBackButton::height(metrics, mappedInput) + + metrics.tabBarHeight); + screen.takeTop(buttonHeaderHeight); } // Give every tab row four pixels of white space above and below its icons. // The tab pill keeps its previous size so the selected background does not @@ -887,7 +889,11 @@ void EpubReaderDrawerActivity::buildDrawer(UiApp::ScreenType& screen) { samplePreviewBounds = {}; if (showsSamplePreview()) { const auto& metrics = UITheme::getInstance().getMetrics(); - int previewHeight = screen.body().height * metrics.previewHeightPercent / 100; + // In portrait, keep the sample at its pre-header height so the new book + // progress row does not remove a line. Landscape needs room for its rows. + const int previewBaseHeight = + screen.body().height + (isLandscapeOrientation(renderer.getOrientation()) ? 0 : buttonHeaderHeight); + int previewHeight = previewBaseHeight * metrics.previewHeightPercent / 100; if (readerDrawerSliderPreviewsText(state.pane)) { // Leave both controls and both translated help lines usable in landscape. ReaderSliderRowProps row; From 7c235dd882b05c145f71a8093daab65648503d58 Mon Sep 17 00:00:00 2001 From: Julia Nguyen Date: Mon, 28 Sep 2026 16:59:52 -0400 Subject: [PATCH 57/62] feat: add the old cover grid view into library for recently opened books --- lib/I18n/translations/arabic.yaml | 1 + lib/I18n/translations/belarusian.yaml | 1 + lib/I18n/translations/catalan.yaml | 1 + lib/I18n/translations/czech.yaml | 1 + lib/I18n/translations/danish.yaml | 1 + lib/I18n/translations/dutch.yaml | 1 + lib/I18n/translations/english.yaml | 1 + lib/I18n/translations/finnish.yaml | 1 + lib/I18n/translations/french.yaml | 1 + lib/I18n/translations/german.yaml | 1 + lib/I18n/translations/hebrew.yaml | 1 + lib/I18n/translations/hungarian.yaml | 1 + lib/I18n/translations/italian.yaml | 1 + lib/I18n/translations/kazakh.yaml | 1 + lib/I18n/translations/lithuanian.yaml | 1 + lib/I18n/translations/polish.yaml | 1 + lib/I18n/translations/portuguese-BR.yaml | 1 + lib/I18n/translations/portuguese-PT.yaml | 1 + lib/I18n/translations/romanian.yaml | 1 + lib/I18n/translations/russian.yaml | 1 + lib/I18n/translations/slovak.yaml | 1 + lib/I18n/translations/slovenian.yaml | 1 + lib/I18n/translations/spanish.yaml | 1 + lib/I18n/translations/swedish.yaml | 1 + lib/I18n/translations/turkish.yaml | 1 + lib/I18n/translations/ukrainian.yaml | 1 + lib/I18n/translations/valencian.yaml | 1 + lib/I18n/translations/vietnamese.yaml | 1 + src/CrossPointSettings.cpp | 4 + src/CrossPointSettings.h | 2 +- src/activities/library/LibraryActivity.cpp | 283 +++++++++++++++++- src/activities/library/LibraryActivity.h | 12 + .../library/LibrarySettingsActivity.cpp | 45 +-- src/simulator/SimulatorSmokeTest.cpp | 14 +- 34 files changed, 354 insertions(+), 34 deletions(-) diff --git a/lib/I18n/translations/arabic.yaml b/lib/I18n/translations/arabic.yaml index bd746c86968..d4c0bbfed06 100644 --- a/lib/I18n/translations/arabic.yaml +++ b/lib/I18n/translations/arabic.yaml @@ -758,6 +758,7 @@ STR_LIBRARY_UNSORTED: "المكتبة (غير مرتبة)" STR_LIBRARY_USE_METADATA: "استخدام بيانات الكتب" STR_LIBRARY_SETTINGS: "إعدادات المكتبة" STR_LIBRARY_LIST_VIEW: "عرض القائمة" +STR_LIBRARY_RECENTLY_OPENED_VIEW: "عرض الكتب المفتوحة مؤخراً" STR_LIBRARY_EXPANDED: "موسع" STR_LIBRARY_SHOW_FILES: "إظهار الملفات" STR_LIBRARY_FILES_COUNT: "%u ملفًا" diff --git a/lib/I18n/translations/belarusian.yaml b/lib/I18n/translations/belarusian.yaml index a11deba26d3..bd98ebaf033 100644 --- a/lib/I18n/translations/belarusian.yaml +++ b/lib/I18n/translations/belarusian.yaml @@ -580,6 +580,7 @@ STR_LIBRARY_UNSORTED: "Бібліятэка (без сартавання)" STR_LIBRARY_USE_METADATA: "Метаданыя кніг" STR_LIBRARY_SETTINGS: "Налады бібліятэкі" STR_LIBRARY_LIST_VIEW: "Выгляд спіса" +STR_LIBRARY_RECENTLY_OPENED_VIEW: "Выгляд нядаўна адкрытых кніг" STR_LIBRARY_EXPANDED: "Разгорнуты" STR_LIBRARY_SHOW_FILES: "Паказваць файлы" STR_LIBRARY_FILES_COUNT: "%u файлаў" diff --git a/lib/I18n/translations/catalan.yaml b/lib/I18n/translations/catalan.yaml index 7697632c359..99a902337e0 100644 --- a/lib/I18n/translations/catalan.yaml +++ b/lib/I18n/translations/catalan.yaml @@ -639,6 +639,7 @@ STR_LIBRARY_UNSORTED: "Biblioteca (sense ordenar)" STR_LIBRARY_USE_METADATA: "Usa les metadades" STR_LIBRARY_SETTINGS: "Configuració de la biblioteca" STR_LIBRARY_LIST_VIEW: "Vista de llista" +STR_LIBRARY_RECENTLY_OPENED_VIEW: "Vista dels llibres oberts recentment" STR_LIBRARY_EXPANDED: "Ampliada" STR_LIBRARY_SHOW_FILES: "Mostra fitxers" STR_LIBRARY_FILES_COUNT: "%u fitxers" diff --git a/lib/I18n/translations/czech.yaml b/lib/I18n/translations/czech.yaml index c7295ab4a3d..16b54ff67ba 100644 --- a/lib/I18n/translations/czech.yaml +++ b/lib/I18n/translations/czech.yaml @@ -766,6 +766,7 @@ STR_LIBRARY_UNSORTED: "Knihovna (neseřazená)" STR_LIBRARY_USE_METADATA: "Použít metadata knih" STR_LIBRARY_SETTINGS: "Nastavení knihovny" STR_LIBRARY_LIST_VIEW: "Zobrazení seznamu" +STR_LIBRARY_RECENTLY_OPENED_VIEW: "Zobrazení nedávno otevřených knih" STR_LIBRARY_EXPANDED: "Rozšířené" STR_LIBRARY_SHOW_FILES: "Zobrazit soubory" STR_LIBRARY_FILES_COUNT: "%u souborů" diff --git a/lib/I18n/translations/danish.yaml b/lib/I18n/translations/danish.yaml index f21a86a46db..f08574621f8 100644 --- a/lib/I18n/translations/danish.yaml +++ b/lib/I18n/translations/danish.yaml @@ -579,6 +579,7 @@ STR_LIBRARY_UNSORTED: "Bibliotek (usorteret)" STR_LIBRARY_USE_METADATA: "Brug bogmetadata" STR_LIBRARY_SETTINGS: "Biblioteksindstillinger" STR_LIBRARY_LIST_VIEW: "Listevisning" +STR_LIBRARY_RECENTLY_OPENED_VIEW: "Visning af senest åbnede bøger" STR_LIBRARY_EXPANDED: "Udvidet" STR_LIBRARY_SHOW_FILES: "Vis filer" STR_LIBRARY_FILES_COUNT: "%u filer" diff --git a/lib/I18n/translations/dutch.yaml b/lib/I18n/translations/dutch.yaml index 4091783e088..569e148e100 100644 --- a/lib/I18n/translations/dutch.yaml +++ b/lib/I18n/translations/dutch.yaml @@ -578,6 +578,7 @@ STR_LIBRARY_UNSORTED: "Bibliotheek (ongesorteerd)" STR_LIBRARY_USE_METADATA: "Boekmetadata gebruiken" STR_LIBRARY_SETTINGS: "Bibliotheekinstellingen" STR_LIBRARY_LIST_VIEW: "Lijstweergave" +STR_LIBRARY_RECENTLY_OPENED_VIEW: "Weergave recent geopende boeken" STR_LIBRARY_EXPANDED: "Uitgebreid" STR_LIBRARY_SHOW_FILES: "Bestanden tonen" STR_LIBRARY_FILES_COUNT: "%u bestanden" diff --git a/lib/I18n/translations/english.yaml b/lib/I18n/translations/english.yaml index 056f1629de8..46d4cd3f47b 100644 --- a/lib/I18n/translations/english.yaml +++ b/lib/I18n/translations/english.yaml @@ -894,6 +894,7 @@ STR_LIBRARY_SEARCH_FAILED: "Search unavailable" STR_LIBRARY_SETTINGS: "Library Settings" STR_LIBRARY_FINISHED_BOOKS: "Finished Books" STR_LIBRARY_LIST_VIEW: "List View" +STR_LIBRARY_RECENTLY_OPENED_VIEW: "Recently Opened View" STR_LIBRARY_EXPANDED: "Expanded" STR_LIBRARY_SHOW_FILES: "Show Files" STR_LIBRARY_FILES_COUNT: "%u files" diff --git a/lib/I18n/translations/finnish.yaml b/lib/I18n/translations/finnish.yaml index f98deb4c6da..35694aa5080 100644 --- a/lib/I18n/translations/finnish.yaml +++ b/lib/I18n/translations/finnish.yaml @@ -568,6 +568,7 @@ STR_LIBRARY_UNSORTED: "Kirjasto (järjestämätön)" STR_LIBRARY_USE_METADATA: "Käytä kirjan metatietoja" STR_LIBRARY_SETTINGS: "Kirjaston asetukset" STR_LIBRARY_LIST_VIEW: "Luettelonäkymä" +STR_LIBRARY_RECENTLY_OPENED_VIEW: "Äskettäin avattujen kirjojen näkymä" STR_LIBRARY_EXPANDED: "Laajennettu" STR_LIBRARY_SHOW_FILES: "Näytä tiedostot" STR_LIBRARY_FILES_COUNT: "%u tiedostoa" diff --git a/lib/I18n/translations/french.yaml b/lib/I18n/translations/french.yaml index 26733861fc5..bc1e4aadbc3 100644 --- a/lib/I18n/translations/french.yaml +++ b/lib/I18n/translations/french.yaml @@ -579,6 +579,7 @@ STR_LIBRARY_UNAVAILABLE: "Livre indisponible" STR_LIBRARY_SEARCH_FAILED: "Recherche indisponible" STR_LIBRARY_SETTINGS: "Paramètres de la bibliothèque" STR_LIBRARY_LIST_VIEW: "Vue en liste" +STR_LIBRARY_RECENTLY_OPENED_VIEW: "Vue des livres ouverts récemment" STR_LIBRARY_EXPANDED: "Développée" STR_LIBRARY_SHOW_FILES: "Afficher les fichiers" STR_LIBRARY_FILES_COUNT: "%u fichiers" diff --git a/lib/I18n/translations/german.yaml b/lib/I18n/translations/german.yaml index 13c8cdd8345..7f978a04ac4 100644 --- a/lib/I18n/translations/german.yaml +++ b/lib/I18n/translations/german.yaml @@ -755,6 +755,7 @@ STR_LIBRARY_UNAVAILABLE: "Buch nicht verfügbar" STR_LIBRARY_SEARCH_FAILED: "Suche nicht verfügbar" STR_LIBRARY_SETTINGS: "Bibliothekseinstellungen" STR_LIBRARY_LIST_VIEW: "Listenansicht" +STR_LIBRARY_RECENTLY_OPENED_VIEW: "Ansicht zuletzt geöffneter Bücher" STR_LIBRARY_EXPANDED: "Erweitert" STR_LIBRARY_SHOW_FILES: "Dateien anzeigen" STR_LIBRARY_FILES_COUNT: "%u Dateien" diff --git a/lib/I18n/translations/hebrew.yaml b/lib/I18n/translations/hebrew.yaml index d576e2427cd..7bbad4a0eb8 100644 --- a/lib/I18n/translations/hebrew.yaml +++ b/lib/I18n/translations/hebrew.yaml @@ -576,6 +576,7 @@ STR_LIBRARY_USE_METADATA: "שימוש במטא־נתונים" STR_REMOVE_FROM_RECENTS_ACTION: "הסרה מההיסטוריה" STR_LIBRARY_SETTINGS: "הגדרות ספרייה" STR_LIBRARY_LIST_VIEW: "תצוגת רשימה" +STR_LIBRARY_RECENTLY_OPENED_VIEW: "תצוגת ספרים שנפתחו לאחרונה" STR_LIBRARY_EXPANDED: "מורחב" STR_LIBRARY_SHOW_FILES: "הצגת קבצים" STR_LIBRARY_FILES_COUNT: "%u קבצים" diff --git a/lib/I18n/translations/hungarian.yaml b/lib/I18n/translations/hungarian.yaml index 8b1f73fce9e..47f4323835d 100644 --- a/lib/I18n/translations/hungarian.yaml +++ b/lib/I18n/translations/hungarian.yaml @@ -579,6 +579,7 @@ STR_LIBRARY_UNSORTED: "Könyvtár (rendezetlen)" STR_LIBRARY_USE_METADATA: "Könyvmetaadatok használata" STR_LIBRARY_SETTINGS: "Könyvtárbeállítások" STR_LIBRARY_LIST_VIEW: "Lista nézet" +STR_LIBRARY_RECENTLY_OPENED_VIEW: "Nemrég megnyitott könyvek nézete" STR_LIBRARY_EXPANDED: "Bővített" STR_LIBRARY_SHOW_FILES: "Fájlok megjelenítése" STR_LIBRARY_FILES_COUNT: "%u fájl" diff --git a/lib/I18n/translations/italian.yaml b/lib/I18n/translations/italian.yaml index cb5866422b7..0b69ab5c611 100644 --- a/lib/I18n/translations/italian.yaml +++ b/lib/I18n/translations/italian.yaml @@ -649,6 +649,7 @@ STR_LIBRARY_UNSORTED: "Biblioteca (non ordinata)" STR_LIBRARY_USE_METADATA: "Usa metadati dei libri" STR_LIBRARY_SETTINGS: "Impostazioni libreria" STR_LIBRARY_LIST_VIEW: "Vista elenco" +STR_LIBRARY_RECENTLY_OPENED_VIEW: "Vista libri aperti di recente" STR_LIBRARY_EXPANDED: "Espansa" STR_LIBRARY_SHOW_FILES: "Mostra file" STR_LIBRARY_FILES_COUNT: "%u file" diff --git a/lib/I18n/translations/kazakh.yaml b/lib/I18n/translations/kazakh.yaml index 99ebcc4e3e8..bccf5bfdc51 100644 --- a/lib/I18n/translations/kazakh.yaml +++ b/lib/I18n/translations/kazakh.yaml @@ -579,6 +579,7 @@ STR_LIBRARY_UNSORTED: "Кітапхана (сұрыпталмаған)" STR_LIBRARY_USE_METADATA: "Кітап метадеректерін қолдану" STR_LIBRARY_SETTINGS: "Кітапхана параметрлері" STR_LIBRARY_LIST_VIEW: "Тізім көрінісі" +STR_LIBRARY_RECENTLY_OPENED_VIEW: "Жақында ашылған кітаптар көрінісі" STR_LIBRARY_EXPANDED: "Кеңейтілген" STR_LIBRARY_SHOW_FILES: "Файлдарды көрсету" STR_LIBRARY_FILES_COUNT: "%u файл" diff --git a/lib/I18n/translations/lithuanian.yaml b/lib/I18n/translations/lithuanian.yaml index 67804a144b7..7c3d2535a9d 100644 --- a/lib/I18n/translations/lithuanian.yaml +++ b/lib/I18n/translations/lithuanian.yaml @@ -579,6 +579,7 @@ STR_LIBRARY_UNSORTED: "Biblioteka (nerikiuota)" STR_LIBRARY_USE_METADATA: "Naudoti knygų metaduomenis" STR_LIBRARY_SETTINGS: "Bibliotekos nustatymai" STR_LIBRARY_LIST_VIEW: "Sąrašo rodinys" +STR_LIBRARY_RECENTLY_OPENED_VIEW: "Neseniai atidarytų knygų rodinys" STR_LIBRARY_EXPANDED: "Išplėstinis" STR_LIBRARY_SHOW_FILES: "Rodyti failus" STR_LIBRARY_FILES_COUNT: "%u failų" diff --git a/lib/I18n/translations/polish.yaml b/lib/I18n/translations/polish.yaml index fce41b36868..5d069feb126 100644 --- a/lib/I18n/translations/polish.yaml +++ b/lib/I18n/translations/polish.yaml @@ -599,6 +599,7 @@ STR_LIBRARY_UNSORTED: "Biblioteka (nieposortowana)" STR_LIBRARY_USE_METADATA: "Używaj metadanych książek" STR_LIBRARY_SETTINGS: "Ustawienia biblioteki" STR_LIBRARY_LIST_VIEW: "Widok listy" +STR_LIBRARY_RECENTLY_OPENED_VIEW: "Widok ostatnio otwartych książek" STR_LIBRARY_EXPANDED: "Rozszerzony" STR_LIBRARY_SHOW_FILES: "Pokaż pliki" STR_LIBRARY_FILES_COUNT: "%u plików" diff --git a/lib/I18n/translations/portuguese-BR.yaml b/lib/I18n/translations/portuguese-BR.yaml index 8d5f2c462f9..120a79ba49a 100644 --- a/lib/I18n/translations/portuguese-BR.yaml +++ b/lib/I18n/translations/portuguese-BR.yaml @@ -754,6 +754,7 @@ STR_LIBRARY_UNSORTED: "Biblioteca (sem ordem)" STR_LIBRARY_USE_METADATA: "Usar metadados dos livros" STR_LIBRARY_SETTINGS: "Configurações da biblioteca" STR_LIBRARY_LIST_VIEW: "Visualização em lista" +STR_LIBRARY_RECENTLY_OPENED_VIEW: "Visualização de livros abertos recentemente" STR_LIBRARY_EXPANDED: "Expandida" STR_LIBRARY_SHOW_FILES: "Mostrar arquivos" STR_LIBRARY_FILES_COUNT: "%u arquivos" diff --git a/lib/I18n/translations/portuguese-PT.yaml b/lib/I18n/translations/portuguese-PT.yaml index b0cf5913529..24906ecd1b9 100644 --- a/lib/I18n/translations/portuguese-PT.yaml +++ b/lib/I18n/translations/portuguese-PT.yaml @@ -849,6 +849,7 @@ STR_LIBRARY_UNSORTED: "Biblioteca (sem ordem)" STR_LIBRARY_USE_METADATA: "Usar metadados dos livros" STR_LIBRARY_SETTINGS: "Definições da biblioteca" STR_LIBRARY_LIST_VIEW: "Vista de lista" +STR_LIBRARY_RECENTLY_OPENED_VIEW: "Vista de livros abertos recentemente" STR_LIBRARY_EXPANDED: "Expandida" STR_LIBRARY_SHOW_FILES: "Mostrar ficheiros" STR_LIBRARY_FILES_COUNT: "%u ficheiros" diff --git a/lib/I18n/translations/romanian.yaml b/lib/I18n/translations/romanian.yaml index dfdb3bcf9a5..8e37108cae2 100644 --- a/lib/I18n/translations/romanian.yaml +++ b/lib/I18n/translations/romanian.yaml @@ -578,6 +578,7 @@ STR_LIBRARY_UNSORTED: "Bibliotecă (nesortată)" STR_LIBRARY_USE_METADATA: "Folosește metadatele cărților" STR_LIBRARY_SETTINGS: "Setări bibliotecă" STR_LIBRARY_LIST_VIEW: "Vizualizare listă" +STR_LIBRARY_RECENTLY_OPENED_VIEW: "Vizualizare cărți deschise recent" STR_LIBRARY_EXPANDED: "Extinsă" STR_LIBRARY_SHOW_FILES: "Afișează fișiere" STR_LIBRARY_FILES_COUNT: "%u fișiere" diff --git a/lib/I18n/translations/russian.yaml b/lib/I18n/translations/russian.yaml index 5d35eecc375..7703ed0b425 100644 --- a/lib/I18n/translations/russian.yaml +++ b/lib/I18n/translations/russian.yaml @@ -646,6 +646,7 @@ STR_LIBRARY_UNSORTED: "Библиотека (без сортировки)" STR_LIBRARY_USE_METADATA: "Метаданные книг" STR_LIBRARY_SETTINGS: "Настройки библиотеки" STR_LIBRARY_LIST_VIEW: "Вид списка" +STR_LIBRARY_RECENTLY_OPENED_VIEW: "Вид недавно открытых книг" STR_LIBRARY_EXPANDED: "Расширенный" STR_LIBRARY_SHOW_FILES: "Показывать файлы" STR_LIBRARY_FILES_COUNT: "%u файлов" diff --git a/lib/I18n/translations/slovak.yaml b/lib/I18n/translations/slovak.yaml index f6dd54f1c92..eb9b74d4ae1 100644 --- a/lib/I18n/translations/slovak.yaml +++ b/lib/I18n/translations/slovak.yaml @@ -675,6 +675,7 @@ STR_LIBRARY_UNSORTED: "Knižnica (nezoradená)" STR_LIBRARY_USE_METADATA: "Použiť metadáta kníh" STR_LIBRARY_SETTINGS: "Nastavenia knižnice" STR_LIBRARY_LIST_VIEW: "Zobrazenie zoznamu" +STR_LIBRARY_RECENTLY_OPENED_VIEW: "Zobrazenie nedávno otvorených kníh" STR_LIBRARY_EXPANDED: "Rozšírené" STR_LIBRARY_SHOW_FILES: "Zobraziť súbory" STR_LIBRARY_FILES_COUNT: "%u súborov" diff --git a/lib/I18n/translations/slovenian.yaml b/lib/I18n/translations/slovenian.yaml index 4803eacdf93..84fc63ffdb1 100644 --- a/lib/I18n/translations/slovenian.yaml +++ b/lib/I18n/translations/slovenian.yaml @@ -579,6 +579,7 @@ STR_LIBRARY_UNSORTED: "Knjižnica (nerazvrščena)" STR_LIBRARY_USE_METADATA: "Uporabi metapodatke knjig" STR_LIBRARY_SETTINGS: "Nastavitve knjižnice" STR_LIBRARY_LIST_VIEW: "Pogled seznama" +STR_LIBRARY_RECENTLY_OPENED_VIEW: "Pogled nedavno odprtih knjig" STR_LIBRARY_EXPANDED: "Razširjen" STR_LIBRARY_SHOW_FILES: "Prikaži datoteke" STR_LIBRARY_FILES_COUNT: "%u datotek" diff --git a/lib/I18n/translations/spanish.yaml b/lib/I18n/translations/spanish.yaml index f175535d0be..7cc45265ae4 100644 --- a/lib/I18n/translations/spanish.yaml +++ b/lib/I18n/translations/spanish.yaml @@ -755,6 +755,7 @@ STR_LIBRARY_UNSORTED: "Biblioteca (sin ordenar)" STR_LIBRARY_USE_METADATA: "Usar metadatos de libros" STR_LIBRARY_SETTINGS: "Ajustes de biblioteca" STR_LIBRARY_LIST_VIEW: "Vista de lista" +STR_LIBRARY_RECENTLY_OPENED_VIEW: "Vista de libros abiertos recientemente" STR_LIBRARY_EXPANDED: "Ampliada" STR_LIBRARY_SHOW_FILES: "Mostrar archivos" STR_LIBRARY_FILES_COUNT: "%u archivos" diff --git a/lib/I18n/translations/swedish.yaml b/lib/I18n/translations/swedish.yaml index b4a49318ebe..0710ec8625c 100644 --- a/lib/I18n/translations/swedish.yaml +++ b/lib/I18n/translations/swedish.yaml @@ -634,6 +634,7 @@ STR_LIBRARY_UNSORTED: "Bibliotek (osorterat)" STR_LIBRARY_USE_METADATA: "Använd bokmetadata" STR_LIBRARY_SETTINGS: "Biblioteksinställningar" STR_LIBRARY_LIST_VIEW: "Listvy" +STR_LIBRARY_RECENTLY_OPENED_VIEW: "Vy för senast öppnade böcker" STR_LIBRARY_EXPANDED: "Utökad" STR_LIBRARY_SHOW_FILES: "Visa filer" STR_LIBRARY_FILES_COUNT: "%u filer" diff --git a/lib/I18n/translations/turkish.yaml b/lib/I18n/translations/turkish.yaml index 1e5f9b6efca..df36e96cd0d 100644 --- a/lib/I18n/translations/turkish.yaml +++ b/lib/I18n/translations/turkish.yaml @@ -578,6 +578,7 @@ STR_LIBRARY_UNSORTED: "Kitaplık (sıralanmamış)" STR_LIBRARY_USE_METADATA: "Kitap üst verilerini kullan" STR_LIBRARY_SETTINGS: "Kitaplık ayarları" STR_LIBRARY_LIST_VIEW: "Liste görünümü" +STR_LIBRARY_RECENTLY_OPENED_VIEW: "Son açılan kitaplar görünümü" STR_LIBRARY_EXPANDED: "Genişletilmiş" STR_LIBRARY_SHOW_FILES: "Dosyaları göster" STR_LIBRARY_FILES_COUNT: "%u dosya" diff --git a/lib/I18n/translations/ukrainian.yaml b/lib/I18n/translations/ukrainian.yaml index e5d5bdaf8e7..cc0cdcca416 100644 --- a/lib/I18n/translations/ukrainian.yaml +++ b/lib/I18n/translations/ukrainian.yaml @@ -632,6 +632,7 @@ STR_LIBRARY_UNSORTED: "Бібліотека (без сортування)" STR_LIBRARY_USE_METADATA: "Метадані книг" STR_LIBRARY_SETTINGS: "Налаштування бібліотеки" STR_LIBRARY_LIST_VIEW: "Вигляд списку" +STR_LIBRARY_RECENTLY_OPENED_VIEW: "Вигляд нещодавно відкритих книг" STR_LIBRARY_EXPANDED: "Розгорнутий" STR_LIBRARY_SHOW_FILES: "Показувати файли" STR_LIBRARY_FILES_COUNT: "%u файлів" diff --git a/lib/I18n/translations/valencian.yaml b/lib/I18n/translations/valencian.yaml index c4a9ed34088..6adb207c240 100644 --- a/lib/I18n/translations/valencian.yaml +++ b/lib/I18n/translations/valencian.yaml @@ -620,6 +620,7 @@ STR_LIBRARY_USE_METADATA: "Usa les metadades" STR_REMOVE_FROM_RECENTS_ACTION: "Elimina de l’historial" STR_LIBRARY_SETTINGS: "Configuració de la biblioteca" STR_LIBRARY_LIST_VIEW: "Vista de llista" +STR_LIBRARY_RECENTLY_OPENED_VIEW: "Vista dels llibres oberts recentment" STR_LIBRARY_EXPANDED: "Ampliada" STR_LIBRARY_SHOW_FILES: "Mostra fitxers" STR_LIBRARY_FILES_COUNT: "%u fitxers" diff --git a/lib/I18n/translations/vietnamese.yaml b/lib/I18n/translations/vietnamese.yaml index b3ad6fd312e..d0a7817aff0 100644 --- a/lib/I18n/translations/vietnamese.yaml +++ b/lib/I18n/translations/vietnamese.yaml @@ -637,6 +637,7 @@ STR_LIBRARY_USE_METADATA: "Dùng siêu dữ liệu sách" STR_REMOVE_FROM_RECENTS: "Xóa khỏi lịch sử?" STR_LIBRARY_SETTINGS: "Cài đặt thư viện" STR_LIBRARY_LIST_VIEW: "Chế độ xem danh sách" +STR_LIBRARY_RECENTLY_OPENED_VIEW: "Chế độ xem sách đã mở gần đây" STR_LIBRARY_EXPANDED: "Mở rộng" STR_LIBRARY_SHOW_FILES: "Hiển thị tệp" STR_LIBRARY_FILES_COUNT: "%u tệp" diff --git a/src/CrossPointSettings.cpp b/src/CrossPointSettings.cpp index ad6a91b9f60..c5866bf5ff1 100644 --- a/src/CrossPointSettings.cpp +++ b/src/CrossPointSettings.cpp @@ -471,6 +471,7 @@ void CrossPointSettings::toJson(JsonDocument& doc) const { doc["librarySortMethod"] = librarySortMethod; doc["librarySortDescending"] = librarySortDescending; doc["libraryListExpanded"] = libraryListExpanded; + doc["recentBooksView"] = recentBooksView; doc["libraryShowSeries"] = libraryShowSeries; doc["libraryShowGenre"] = libraryShowGenre; doc["libraryShowEpub"] = libraryShowEpub; @@ -663,6 +664,9 @@ bool CrossPointSettings::fromJson(JsonVariantConst doc, bool importingCrossPoint readLibraryChoice("librarySortMethod", librarySortMethod, 7); readLibraryChoice("librarySortDescending", librarySortDescending, 2); readLibraryChoice("libraryListExpanded", libraryListExpanded, 2); + // A missing or corrupt legacy choice uses List, even if this object loaded another document earlier. + recentBooksView = RECENT_BOOKS_LIST; + readLibraryChoice("recentBooksView", recentBooksView, RECENT_BOOKS_VIEW_COUNT); readLibraryChoice("libraryShowSeries", libraryShowSeries, 2); readLibraryChoice("libraryShowGenre", libraryShowGenre, 2); readLibraryChoice("libraryShowEpub", libraryShowEpub, 2); diff --git a/src/CrossPointSettings.h b/src/CrossPointSettings.h index 6c4168de650..4ecf8a3e2e1 100644 --- a/src/CrossPointSettings.h +++ b/src/CrossPointSettings.h @@ -593,7 +593,7 @@ class CrossPointSettings : public PersistableStore { uint8_t longPressButtonBehavior = OFF; // UI Theme uint8_t uiTheme = LYRA; - // Legacy Recent Books layout; retained for binary settings compatibility. + // Recently Opened layout in Library; keep the original raw values for older settings. uint8_t recentBooksView = RECENT_BOOKS_LIST; // UI scale (list fonts + row heights); touch boards default one step larger uint8_t uiScale = defaultUiScale(); diff --git a/src/activities/library/LibraryActivity.cpp b/src/activities/library/LibraryActivity.cpp index 42edf4a1206..51b024504b3 100644 --- a/src/activities/library/LibraryActivity.cpp +++ b/src/activities/library/LibraryActivity.cpp @@ -1,7 +1,10 @@ #include "LibraryActivity.h" #include +#include #include +#include +#include #include #include #include @@ -18,6 +21,7 @@ #include "activities/home/BookActions.h" #include "activities/home/FileBrowserActionActivity.h" +#include "activities/home/RecentBookProgress.h" #include "activities/library/LibrarySettingsActivity.h" #include "activities/reader/BookReadingStats.h" #include "activities/reader/EpubReaderActivity.h" @@ -40,6 +44,30 @@ constexpr unsigned long ACTION_FEEDBACK_MS = 1000; constexpr int HEADER_CONTROL_SIZE = 44; constexpr int HEADER_CONTROL_GAP = 10; constexpr int FOOTER_HEIGHT = 28; +constexpr int GRID_COLUMNS = 3; +constexpr int GRID_PAGE_SIZE = 9; +constexpr int GRID_GAP = 8; +constexpr int GRID_SELECTION_PADDING = 4; +constexpr int GRID_SELECTION_OUTLINE_GAP = 2; +constexpr int GRID_SELECTION_OUTER_INSET = GRID_SELECTION_PADDING + GRID_SELECTION_OUTLINE_GAP; +constexpr int GRID_COVER_CORNER_RADIUS = 2; + +const RecentBook* recentBookForPath(const std::string& path) { + const auto& books = RECENT_BOOKS.getBooks(); + const auto it = + std::find_if(books.begin(), books.end(), [&path](const RecentBook& book) { return book.path == path; }); + return it == books.end() ? nullptr : &*it; +} + +bool hasValidGridThumb(const std::string& path, const int width, const int height) { + FsFile file; + if (path.empty() || !Storage.exists(path.c_str()) || !Storage.openFileForRead("LIB", path, file)) return false; + Bitmap bitmap(file); + const bool valid = + bitmap.parseHeaders() == BmpReaderError::Ok && bitmap.getWidth() == width && bitmap.getHeight() == height; + file.close(); + return valid; +} int headerControlRightInset() { const auto& metrics = UITheme::getInstance().getMetrics(); @@ -222,6 +250,27 @@ int LibraryActivity::rowCount() const { return index.bookCount(); } +bool LibraryActivity::gridEnabled() const { + return sort == Sort::RecentlyRead && SETTINGS.recentBooksView == CrossPointSettings::RECENT_BOOKS_GRID; +} + +void LibraryActivity::loadGridProgress() { + const int row = selection - CONTROL_COUNT; + if (!gridEnabled() || row < 0 || row >= rowCount()) { + gridProgressRow = -1; + gridProgress = -1.0f; + return; + } + if (gridProgressRow == row) return; + gridProgressRow = row; + gridProgress = -1.0f; + RecentBook book; + if (readBook(row, book)) { + gridProgress = FsHelpers::hasEpubExtension(book.path) ? RecentBookProgress::loadCachedEpubPercent(book) + : RecentBookProgress::loadPercent(book); + } +} + uint16_t LibraryActivity::ordinalForRow(const int row) { if (row < 0 || row >= rowCount()) return UINT16_MAX; if (hasActiveFilter()) return filtered ? filtered[row] : UINT16_MAX; @@ -361,8 +410,13 @@ void LibraryActivity::resetViewport() { selection = rowCount() || !mappedInput.hasTouchHardware() ? CONTROL_COUNT : 3; showSelection = !mappedInput.hasTouchHardware(); topIndex = 0; + gridPageStart = 0; + loadedGridPageStart = -1; + nextGridCoverRow = -1; + gridProgressRow = -1; listNav.reset(selection - CONTROL_COUNT); uiReady = false; + loadGridProgress(); } void LibraryActivity::reloadAfterBookAction() { @@ -372,6 +426,11 @@ void LibraryActivity::reloadAfterBookAction() { listNav.top = topIndex; listNav.follow(rowCount()); topIndex = listNav.top; + gridPageStart = ((selection - CONTROL_COUNT) / GRID_PAGE_SIZE) * GRID_PAGE_SIZE; + loadedGridPageStart = -1; + nextGridCoverRow = -1; + gridProgressRow = -1; + loadGridProgress(); requestUpdate(); } @@ -493,6 +552,7 @@ void LibraryActivity::activateControl(const int control) { if (!SETTINGS.saveToFile()) LOG_ERR("LIB", "Cannot save Library sort direction"); applyFilter(); topIndex = 0; + if (gridEnabled()) resetViewport(); requestUpdate(); } } @@ -574,12 +634,25 @@ void LibraryActivity::loop() { return; } const auto swipe = mappedInput.wasSwipe(); - if (swipe == MappedInputManager::SwipeDir::Up || swipe == MappedInputManager::SwipeDir::Down) { + if (swipe == MappedInputManager::SwipeDir::Up || swipe == MappedInputManager::SwipeDir::Down || + (gridEnabled() && + (swipe == MappedInputManager::SwipeDir::Left || swipe == MappedInputManager::SwipeDir::Right))) { if (mappedInput.hasTouchHardware()) showSelection = false; - listNav.top = topIndex; - const int page = listNav.pageRowsFor(rowCount()); - listNav.scrollBy(swipe == MappedInputManager::SwipeDir::Up ? page : -page, rowCount()); - topIndex = listNav.top; + if (gridEnabled()) { + const int lastPage = rowCount() > 0 ? ((rowCount() - 1) / GRID_PAGE_SIZE) * GRID_PAGE_SIZE : 0; + const bool nextPage = swipe == MappedInputManager::SwipeDir::Up || swipe == MappedInputManager::SwipeDir::Left; + gridPageStart = std::clamp(gridPageStart + (nextPage ? GRID_PAGE_SIZE : -GRID_PAGE_SIZE), 0, lastPage); + if (rowCount() > 0) selection = CONTROL_COUNT + gridPageStart; + loadedGridPageStart = -1; + nextGridCoverRow = -1; + gridProgressRow = -1; + loadGridProgress(); + } else { + listNav.top = topIndex; + const int page = listNav.pageRowsFor(rowCount()); + listNav.scrollBy(swipe == MappedInputManager::SwipeDir::Up ? page : -page, rowCount()); + topIndex = listNav.top; + } requestUpdate(); return; } @@ -597,11 +670,22 @@ void LibraryActivity::loop() { const auto move = [this](const int next) { if (!showSelection) { showSelection = true; + loadGridProgress(); requestUpdate(); return; } selection = next; - if (selection >= CONTROL_COUNT) { + if (gridEnabled()) { + if (selection >= CONTROL_COUNT) { + const int nextPage = ((selection - CONTROL_COUNT) / GRID_PAGE_SIZE) * GRID_PAGE_SIZE; + if (nextPage != gridPageStart) { + loadedGridPageStart = -1; + nextGridCoverRow = -1; + } + gridPageStart = nextPage; + } + loadGridProgress(); + } else if (selection >= CONTROL_COUNT) { listNav.selected = selection - CONTROL_COUNT; listNav.top = topIndex; listNav.follow(rowCount()); @@ -612,10 +696,14 @@ void LibraryActivity::loop() { if (mappedInput.hasTouchHardware()) { buttonNavigator.onNextRelease([&] { move(ButtonNavigator::nextIndex(selection, count)); }); buttonNavigator.onPreviousRelease([&] { move(ButtonNavigator::previousIndex(selection, count)); }); - buttonNavigator.onNextContinuous( - [&] { move(ButtonNavigator::nextPageIndex(selection, count, listNav.pageRowsFor(rowCount()))); }); - buttonNavigator.onPreviousContinuous( - [&] { move(ButtonNavigator::previousPageIndex(selection, count, listNav.pageRowsFor(rowCount()))); }); + buttonNavigator.onNextContinuous([&] { + move(ButtonNavigator::nextPageIndex(selection, count, + gridEnabled() ? GRID_PAGE_SIZE : listNav.pageRowsFor(rowCount()))); + }); + buttonNavigator.onPreviousContinuous([&] { + move(ButtonNavigator::previousPageIndex(selection, count, + gridEnabled() ? GRID_PAGE_SIZE : listNav.pageRowsFor(rowCount()))); + }); } else if (rowCount() > 0) { const int bookCount = rowCount(); const auto moveBook = [&](const int row) { move(CONTROL_COUNT + row); }; @@ -624,13 +712,17 @@ void LibraryActivity::loop() { buttonNavigator.onRelease({MappedInputManager::Button::Up, MappedInputManager::Button::Up}, [&] { moveBook(ButtonNavigator::previousIndex(selection - CONTROL_COUNT, bookCount)); }); buttonNavigator.onContinuous({MappedInputManager::Button::Down, MappedInputManager::Button::Down}, [&] { - moveBook(ButtonNavigator::nextPageIndex(selection - CONTROL_COUNT, bookCount, listNav.pageRowsFor(bookCount))); + moveBook(ButtonNavigator::nextPageIndex(selection - CONTROL_COUNT, bookCount, + gridEnabled() ? GRID_PAGE_SIZE : listNav.pageRowsFor(bookCount))); }); buttonNavigator.onContinuous({MappedInputManager::Button::Up, MappedInputManager::Button::Up}, [&] { - moveBook( - ButtonNavigator::previousPageIndex(selection - CONTROL_COUNT, bookCount, listNav.pageRowsFor(bookCount))); + moveBook(ButtonNavigator::previousPageIndex(selection - CONTROL_COUNT, bookCount, + gridEnabled() ? GRID_PAGE_SIZE : listNav.pageRowsFor(bookCount))); }); } + // Prepare at most one visible cover per turn, leaving an input check between + // EPUB parses. Redraw as each thumbnail becomes available. + loadGridPageCovers(); } void LibraryActivity::listScreen(UiApp::ScreenType& screen, void* user) { @@ -749,7 +841,7 @@ uint32_t LibraryActivity::groupForRow(const int row) { void LibraryActivity::buildSortHeader(UiApp::ScreenType& screen) { const int16_t bandHeight = std::max(44, screen.theme().rowHeight); const auto band = screen.take(fui::LayoutAnchor::Top, bandHeight); - const int16_t margin = 10; + const int16_t margin = UITheme::getInstance().getMetrics().contentSidePadding; auto labelStyle = screen.theme().bodyText; labelStyle.bold = true; const int16_t labelWidth = std::min( @@ -860,6 +952,10 @@ void LibraryActivity::buildListScreen(UiApp::ScreenType& screen) { } return; } + if (gridEnabled()) { + buildGrid(screen); + return; + } fui::ListProps props; props.rowProvider = &LibraryActivity::provideRow; props.rowProviderCtx = this; @@ -883,6 +979,165 @@ void LibraryActivity::buildListScreen(UiApp::ScreenType& screen) { screen.list(props); } +void LibraryActivity::buildGrid(UiApp::ScreenType& screen) { + const auto& metrics = UITheme::getInstance().getMetrics(); + const int count = rowCount(); + const int pageCount = (count + GRID_PAGE_SIZE - 1) / GRID_PAGE_SIZE; + const auto title = screen.take(fui::LayoutAnchor::Top, + static_cast(uiTarget.lineHeight(screen.theme().bodyText.font) + GRID_GAP)); + fui::Rect pageIndicator{}; + if (pageCount > 1) pageIndicator = screen.take(fui::LayoutAnchor::Bottom, 16, GRID_GAP); + const fui::Rect area = screen.body(); + const int16_t cellWidth = std::max(1, (area.width - 2 * GRID_GAP) / GRID_COLUMNS); + const int16_t cellHeight = std::max(1, (area.height - 2 * GRID_GAP) / GRID_COLUMNS); + const int16_t coverHeight = + std::max(1, std::min(cellHeight - 2 * GRID_SELECTION_OUTER_INSET, + (cellWidth - 2 * GRID_SELECTION_OUTER_INSET) * 3 / 2)); + const int16_t coverWidth = + std::max(1, std::min(cellWidth - 2 * GRID_SELECTION_OUTER_INSET, coverHeight * 2 / 3)); + if (coverWidth != gridCoverWidth || coverHeight != gridCoverHeight) { + gridCoverWidth = coverWidth; + gridCoverHeight = coverHeight; + loadedGridPageStart = -1; + nextGridCoverRow = -1; + } + + const int selectedRow = selection - CONTROL_COUNT; + if (selectedRow >= gridPageStart && selectedRow < gridPageStart + GRID_PAGE_SIZE && selectedRow < count && + readBook(selectedRow, rowScratch)) { + const bool hasProgress = gridProgressRow == selectedRow && RecentBookProgress::hasPercent(gridProgress); + subtitleScratch.clear(); + if (hasProgress) subtitleScratch.assign(" · ").append(RecentBookProgress::formatPercent(gridProgress)); + auto style = screen.theme().bodyText; + style.maxLines = 1; + // Center the whole title row between the sort divider and the first cover. + const int16_t coverTop = static_cast(area.y + (cellHeight - coverHeight) / 2); + const int16_t bandTop = static_cast(title.y - metrics.verticalSpacing); + const fui::Rect titleBand{title.x, bandTop, title.width, static_cast(coverTop - bandTop)}; + const fui::Rect textBand = titleBand.inset(fui::Insets{0, static_cast(metrics.contentSidePadding), 0, + static_cast(metrics.contentSidePadding)}); + const int16_t suffixWidth = + hasProgress ? uiTarget.measureText(style.font, subtitleScratch.c_str(), style).width : 0; + const int16_t titleWidth = std::max(0, textBand.width - suffixWidth); + const std::string visibleTitle = + renderer.truncatedText(uiScaleSpec().bodyFontId, rowScratch.title.c_str(), titleWidth, + style.bold ? EpdFontFamily::BOLD : EpdFontFamily::REGULAR); + const int16_t drawnTitleWidth = uiTarget.measureText(style.font, visibleTitle.c_str(), style).width; + uiTarget.text(fui::Rect{textBand.x, textBand.y, titleWidth, textBand.height}, visibleTitle.c_str(), style); + if (hasProgress) + uiTarget.text( + fui::Rect{static_cast(textBand.x + drawnTitleWidth), textBand.y, suffixWidth, textBand.height}, + subtitleScratch.c_str(), style); + } + + const int visible = std::min(GRID_PAGE_SIZE, count - gridPageStart); + for (int slot = 0; slot < visible; ++slot) { + const int row = gridPageStart + slot; + const int col = slot % GRID_COLUMNS; + const int line = slot / GRID_COLUMNS; + const fui::Rect cell{static_cast(area.x + col * (cellWidth + GRID_GAP)), + static_cast(area.y + line * (cellHeight + GRID_GAP)), cellWidth, cellHeight}; + const int16_t x = static_cast(cell.x + (cell.width - coverWidth) / 2); + const int16_t y = static_cast(cell.y + (cell.height - coverHeight) / 2); + bool drawn = false; + const bool available = readBook(row, rowScratch); + if (available) { + const RecentBook* recent = recentBookForPath(rowScratch.path); + if (recent && !recent->coverBmpPath.empty()) { + const std::string path = UITheme::getCoverThumbPath(recent->coverBmpPath, coverWidth, coverHeight); + if (!path.empty() && Storage.exists(path.c_str())) { + FsFile file; + if (Storage.openFileForRead("LIB", path, file)) { + Bitmap bitmap(file); + if (bitmap.parseHeaders() == BmpReaderError::Ok && bitmap.getWidth() > 0 && bitmap.getHeight() > 0) + drawn = renderer.drawBitmap(bitmap, x, y, coverWidth, coverHeight); + file.close(); + } + } + } + } + if (!drawn) { + renderer.fillRoundedRect(x, y, coverWidth, coverHeight, GRID_COVER_CORNER_RADIUS, Color::White); + if (coverWidth >= 36 && coverHeight >= 36) + drawLucideIcon(renderer, icon_book_marked_32, x + (coverWidth - 32) / 2, y + (coverHeight - 32) / 2); + } + renderer.maskRoundedRectOutsideCorners(x, y, coverWidth, coverHeight, GRID_COVER_CORNER_RADIUS, Color::White); + renderer.drawRoundedRect(x, y, coverWidth, coverHeight, 2, GRID_COVER_CORNER_RADIUS, true); + if (row == selectedRow) { + renderer.drawRoundedRect(x - GRID_SELECTION_PADDING, y - GRID_SELECTION_PADDING, + coverWidth + 2 * GRID_SELECTION_PADDING, coverHeight + 2 * GRID_SELECTION_PADDING, 3, + GRID_COVER_CORNER_RADIUS + GRID_SELECTION_PADDING, true); + renderer.drawRoundedRect( + x - GRID_SELECTION_OUTER_INSET, y - GRID_SELECTION_OUTER_INSET, coverWidth + 2 * GRID_SELECTION_OUTER_INSET, + coverHeight + 2 * GRID_SELECTION_OUTER_INSET, 1, GRID_COVER_CORNER_RADIUS + GRID_SELECTION_OUTER_INSET, true); + } + if (available) + screen.frame().hit(cell, ACTION_ROW, static_cast(row), fui::InputTouch | fui::InputLongPress); + } + + if (pageCount > 1) { + constexpr int dotSize = 6; + constexpr int dotGap = 8; + const int dotsWidth = pageCount * dotSize + (pageCount - 1) * dotGap; + const int startX = pageIndicator.x + (pageIndicator.width - dotsWidth) / 2; + for (int page = 0; page < pageCount; ++page) { + const int x = startX + page * (dotSize + dotGap); + if (page == gridPageStart / GRID_PAGE_SIZE) + renderer.fillRect(x, pageIndicator.y + 4, dotSize, dotSize, true); + else + renderer.drawRect(x, pageIndicator.y + 4, dotSize, dotSize, true); + } + } +} + +void LibraryActivity::loadGridPageCovers() { + if (!gridEnabled() || gridCoverWidth <= 0 || gridCoverHeight <= 0 || gridPageStart == loadedGridPageStart) return; + const int pageEnd = std::min(gridPageStart + GRID_PAGE_SIZE, rowCount()); + if (nextGridCoverRow < 0) nextGridCoverRow = gridPageStart; + if (nextGridCoverRow < pageEnd && loadGridCover(nextGridCoverRow++)) requestUpdate(); + if (nextGridCoverRow >= pageEnd) { + loadedGridPageStart = gridPageStart; + nextGridCoverRow = -1; + } +} + +bool LibraryActivity::loadGridCover(const int row) { + RecentBook book; + if (!readBook(row, book)) return false; + const RecentBook* recent = recentBookForPath(book.path); + if (!recent || recent->coverState == RecentBook::CoverState::Missing) return false; + if (!FsHelpers::hasEpubExtension(book.path) && !FsHelpers::hasXtcExtension(book.path)) return false; + const std::string thumbPath = UITheme::getCoverThumbPath(recent->coverBmpPath, gridCoverWidth, gridCoverHeight); + if (hasValidGridThumb(thumbPath, gridCoverWidth, gridCoverHeight)) return false; + if (FsHelpers::hasEpubExtension(book.path)) { + Epub epub(book.path, "/.crosspoint"); + if (!epub.load(true, true, Epub::XLocationLoadMode::Skip)) { + LOG_ERR("LIB", "Cannot load EPUB cover for %s", book.path.c_str()); + return false; + } + if (epub.generateThumbBmp(gridCoverWidth, gridCoverHeight, &renderer, SETTINGS.getReaderFontId())) { + if (!RECENT_BOOKS.updateBook(book.path, recent->title, recent->author, epub.getThumbBmpPath(), + recent->coverState)) + LOG_ERR("LIB", "Cannot update EPUB cover path for %s", book.path.c_str()); + return true; + } + if (!epub.hasCoverImage()) { + if (!RECENT_BOOKS.updateBook(book.path, recent->title, recent->author, "", RecentBook::CoverState::Missing)) + LOG_ERR("LIB", "Cannot mark missing EPUB cover for %s", book.path.c_str()); + } + return false; + } + Xtc xtc(book.path, "/.crosspoint"); + if (!xtc.load()) { + LOG_ERR("LIB", "Cannot load XTC cover for %s", book.path.c_str()); + return false; + } + if (!xtc.generateThumbBmp(gridCoverWidth, gridCoverHeight)) return false; + if (!RECENT_BOOKS.updateBook(book.path, recent->title, recent->author, xtc.getThumbBmpPath(), recent->coverState)) + LOG_ERR("LIB", "Cannot update XTC cover path for %s", book.path.c_str()); + return true; +} + void LibraryActivity::render(RenderLock&&) { uiReady = false; if (initialScanPending) { diff --git a/src/activities/library/LibraryActivity.h b/src/activities/library/LibraryActivity.h index 04e20ef35b0..ecd73fe4c81 100644 --- a/src/activities/library/LibraryActivity.h +++ b/src/activities/library/LibraryActivity.h @@ -37,6 +37,13 @@ class LibraryActivity final : public Activity { int selection = CONTROL_COUNT; bool showSelection = true; int topIndex = 0; + int gridPageStart = 0; + int loadedGridPageStart = -1; + int nextGridCoverRow = -1; + int16_t gridCoverWidth = 0; + int16_t gridCoverHeight = 0; + int gridProgressRow = -1; + float gridProgress = -1.0f; bool uiReady = false; bool initialScanPending = false; bool longPressFired = false; @@ -67,6 +74,11 @@ class LibraryActivity final : public Activity { static void onControlEvent(const freeink::ui::ActionEvent& event, void* user); static void provideRow(void* user, uint16_t row, freeink::ui::ListItem& item); void buildListScreen(UiApp::ScreenType& screen); + void buildGrid(UiApp::ScreenType& screen); + void loadGridPageCovers(); + bool loadGridCover(int row); + void loadGridProgress(); + bool gridEnabled() const; void buildSortHeader(UiApp::ScreenType& screen); const char* sortLabel() const; library::SortOrder indexOrder() const; diff --git a/src/activities/library/LibrarySettingsActivity.cpp b/src/activities/library/LibrarySettingsActivity.cpp index 21e6da70e0e..7ad9f0118e5 100644 --- a/src/activities/library/LibrarySettingsActivity.cpp +++ b/src/activities/library/LibrarySettingsActivity.cpp @@ -10,7 +10,7 @@ namespace fui = freeink::ui; namespace { constexpr fui::ActionId ACTION_ROW = 1; -constexpr int ROW_COUNT = 9; +constexpr int ROW_COUNT = 10; } // namespace LibrarySettingsActivity::LibrarySettingsActivity(GfxRenderer& renderer, MappedInputManager& mappedInput) @@ -37,24 +37,29 @@ void LibrarySettingsActivity::toggle(const int row) { SETTINGS.libraryListExpanded = !SETTINGS.libraryListExpanded; break; case 2: - SETTINGS.libraryHideFinishedBooks = !SETTINGS.libraryHideFinishedBooks; + SETTINGS.recentBooksView = SETTINGS.recentBooksView == CrossPointSettings::RECENT_BOOKS_GRID + ? CrossPointSettings::RECENT_BOOKS_LIST + : CrossPointSettings::RECENT_BOOKS_GRID; break; case 3: - SETTINGS.libraryShowSeries = !SETTINGS.libraryShowSeries; + SETTINGS.libraryHideFinishedBooks = !SETTINGS.libraryHideFinishedBooks; break; case 4: - SETTINGS.libraryShowGenre = !SETTINGS.libraryShowGenre; + SETTINGS.libraryShowSeries = !SETTINGS.libraryShowSeries; break; case 5: - SETTINGS.libraryShowEpub = !SETTINGS.libraryShowEpub; + SETTINGS.libraryShowGenre = !SETTINGS.libraryShowGenre; break; case 6: - SETTINGS.libraryShowXtc = !SETTINGS.libraryShowXtc; + SETTINGS.libraryShowEpub = !SETTINGS.libraryShowEpub; break; case 7: - SETTINGS.libraryShowTxt = !SETTINGS.libraryShowTxt; + SETTINGS.libraryShowXtc = !SETTINGS.libraryShowXtc; break; case 8: + SETTINGS.libraryShowTxt = !SETTINGS.libraryShowTxt; + break; + case 9: SETTINGS.libraryShowMarkdown = !SETTINGS.libraryShowMarkdown; break; default: @@ -135,41 +140,47 @@ void LibrarySettingsActivity::screen(UiApp::ScreenType& screen, void* user) { void LibrarySettingsActivity::provideRow(void*, const uint16_t row, fui::ListItem& item) { static constexpr StrId labels[] = { - StrId::STR_LIBRARY_USE_METADATA, StrId::STR_LIBRARY_LIST_VIEW, StrId::STR_LIBRARY_FINISHED_BOOKS, - StrId::STR_LIBRARY_SERIES, StrId::STR_LIBRARY_GENRE, StrId::STR_LIBRARY_EPUBS, - StrId::STR_LIBRARY_XTC_XTCH, StrId::STR_LIBRARY_TXT, StrId::STR_LIBRARY_MARKDOWN}; + StrId::STR_LIBRARY_USE_METADATA, StrId::STR_LIBRARY_LIST_VIEW, StrId::STR_LIBRARY_RECENTLY_OPENED_VIEW, + StrId::STR_LIBRARY_FINISHED_BOOKS, StrId::STR_LIBRARY_SERIES, StrId::STR_LIBRARY_GENRE, + StrId::STR_LIBRARY_EPUBS, StrId::STR_LIBRARY_XTC_XTCH, StrId::STR_LIBRARY_TXT, + StrId::STR_LIBRARY_MARKDOWN}; item.label = I18N.get(labels[row]); item.actionValue = static_cast(row); if (row == 1) { item.value = SETTINGS.libraryListExpanded ? tr(STR_LIBRARY_EXPANDED) : tr(STR_COMPACT); return; } + if (row == 2) { + item.value = + SETTINGS.recentBooksView == CrossPointSettings::RECENT_BOOKS_GRID ? tr(STR_GRID_VIEW) : tr(STR_LIST_VIEW); + return; + } item.toggle = true; switch (row) { case 0: item.toggleChecked = SETTINGS.libraryUseMetadata; break; - case 2: + case 3: item.sectionHeading = tr(STR_CAT_DISPLAY); item.toggleChecked = !SETTINGS.libraryHideFinishedBooks; break; - case 3: + case 4: item.toggleChecked = SETTINGS.libraryShowSeries; break; - case 4: + case 5: item.toggleChecked = SETTINGS.libraryShowGenre; break; - case 5: + case 6: item.sectionHeading = tr(STR_LIBRARY_SHOW_FILES); item.toggleChecked = SETTINGS.libraryShowEpub; break; - case 6: + case 7: item.toggleChecked = SETTINGS.libraryShowXtc; break; - case 7: + case 8: item.toggleChecked = SETTINGS.libraryShowTxt; break; - case 8: + case 9: item.toggleChecked = SETTINGS.libraryShowMarkdown; break; } diff --git a/src/simulator/SimulatorSmokeTest.cpp b/src/simulator/SimulatorSmokeTest.cpp index dd601960c1a..c6147b0c059 100644 --- a/src/simulator/SimulatorSmokeTest.cpp +++ b/src/simulator/SimulatorSmokeTest.cpp @@ -376,6 +376,7 @@ class SimulatorSmokeTest { SETTINGS.librarySortMethod = 3; SETTINGS.librarySortDescending = 0; SETTINGS.libraryListExpanded = 1; + SETTINGS.recentBooksView = CrossPointSettings::RECENT_BOOKS_GRID; SETTINGS.libraryShowMarkdown = 0; SETTINGS.libraryHideFinishedBooks = 1; JsonDocument librarySaved; @@ -383,20 +384,29 @@ class SimulatorSmokeTest { SETTINGS.librarySortMethod = 0; SETTINGS.librarySortDescending = 1; SETTINGS.libraryListExpanded = 0; + SETTINGS.recentBooksView = CrossPointSettings::RECENT_BOOKS_LIST; SETTINGS.libraryShowMarkdown = 1; SETTINGS.libraryHideFinishedBooks = 0; SETTINGS.fromJson(librarySaved.as()); if (SETTINGS.librarySortMethod != 3 || SETTINGS.librarySortDescending || !SETTINGS.libraryListExpanded || - SETTINGS.libraryShowMarkdown || !SETTINGS.libraryHideFinishedBooks) { + SETTINGS.recentBooksView != CrossPointSettings::RECENT_BOOKS_GRID || SETTINGS.libraryShowMarkdown || + !SETTINGS.libraryHideFinishedBooks) { fail("Library settings round-trip mismatch"); } librarySaved["librarySortMethod"] = 99; librarySaved["libraryShowTxt"] = 2; librarySaved["libraryHideFinishedBooks"] = 2; + librarySaved["recentBooksView"] = 2; SETTINGS.fromJson(librarySaved.as()); - if (SETTINGS.librarySortMethod != 3 || SETTINGS.libraryShowTxt != 1 || !SETTINGS.libraryHideFinishedBooks) { + if (SETTINGS.librarySortMethod != 3 || SETTINGS.libraryShowTxt != 1 || !SETTINGS.libraryHideFinishedBooks || + SETTINGS.recentBooksView != CrossPointSettings::RECENT_BOOKS_LIST) { fail("Invalid Library settings were not rejected"); } + librarySaved.remove("recentBooksView"); + SETTINGS.recentBooksView = CrossPointSettings::RECENT_BOOKS_GRID; + SETTINGS.fromJson(librarySaved.as()); + if (SETTINGS.recentBooksView != CrossPointSettings::RECENT_BOOKS_LIST) + fail("Missing Recently Opened view did not default to List"); SETTINGS.fromJson(original.as()); } From cd35e8c1ca07d88ef1e3d24b834a5c1de6dcaa36 Mon Sep 17 00:00:00 2001 From: Julia Nguyen Date: Mon, 28 Sep 2026 17:28:54 -0400 Subject: [PATCH 58/62] docs: explain reading stats controls --- docs/reader-features.md | 10 +++++++++- docs/user-guide.md | 26 +++++++++++++++++++------- 2 files changed, 28 insertions(+), 8 deletions(-) diff --git a/docs/reader-features.md b/docs/reader-features.md index adb66ca4e78..61ab2cee24f 100644 --- a/docs/reader-features.md +++ b/docs/reader-features.md @@ -246,7 +246,15 @@ clipping and its highlight, not an entry already exported to ## Reading Stats -CrossInk tracks per-book reading stats automatically and aggregates them into global stats. +CrossInk tracks per-book reading stats automatically and aggregates them into global stats for EPUB and XTC files. + +### Turn tracking on or off + +The device-wide switch is in **Settings > System > Reading Stats > Track Reading Stats**. When it is off, CrossInk stops recording new per-book and all-time stats. It does not delete saved stats or saved Time Left pace estimates. Turning tracking back on makes the saved history available again and resumes recording for books whose own tracking is enabled. Reading time and pages from the period when tracking was off are not added later. + +While device-wide tracking is off, recorded-stat actions and displays are hidden from the menus and home themes that normally show them. The Dashboard can still show a saved Time Left estimate when one is available. The **Reading Stats** and **Minimal Stats** sleep screens fall back to the regular Minimal sleep screen when tracking is off device-wide or for the recent book. The tracking switch remains available in Settings so it can be turned back on. + +You can also pause stats for one EPUB or XTC book while device-wide tracking is on. For an EPUB, use **Track Reading Stats** in the Reader Menu's **Settings** tab; for an XTC book, use **Track Reading Stats** in its Reader Menu. You can also use the book's Library/File Browser action menu when available. This preserves the book's saved stats and stops adding stats for that book, including its contribution to all-time totals. While paused, that book's **Reading Stats** view and book-specific theme display are hidden; its saved Time Left estimate may still appear in the Dashboard. Re-enable tracking to show the saved book history and resume recording. Tracked stats include: diff --git a/docs/user-guide.md b/docs/user-guide.md index 4df48066fa6..513abaa6021 100644 --- a/docs/user-guide.md +++ b/docs/user-guide.md @@ -124,7 +124,7 @@ The Browse Files screen acts as a file and folder browser. The full path to the - **Navigate List:** Use **Left** (or **Up**), or **Right** (or **Down**) to move the selection cursor up and down through folders and books. You can also long-press these buttons to scroll a full page up or down. - **Open Selection:** Press **Confirm** to open a folder or start reading a selected book. Selecting a `.bmp` file will open the image viewer. - **Delete Files or Folders:** Hold and release **Confirm** to open the selected file or folder action menu, then choose **Delete**. You will be given an option to either confirm or cancel. Folder deletion is limited to empty folders. -- **Book Actions:** EPUB and XTC files can also show options such as **Delete Cache** or **Mark Finished** from the same action menu. +- **Book Actions:** EPUB and XTC files can also show options such as **Delete Cache**, **Mark Finished**, and **Track Reading Stats** from the same action menu. Turning tracking off for a book keeps its saved stats; it only stops new tracking for that book. ### 3.4 Recent Books Screen @@ -189,9 +189,9 @@ device model and build. - "None" - A blank screen - "Cover + Custom" - The book cover image while actively reading, falls back to "Custom" behavior otherwise - "Page Overlay" - Uses an image to overlay on top of the current page. Best used with transparent `.png` files or black and white `.bmp` files. - - "Reading Stats" - Recent reading stats on the sleep screen + - "Reading Stats" - Recent reading stats on the sleep screen; falls back to **Minimal** while tracking is off for the device or recent book - "Minimal" - A minimal sleep screen - - "Minimal Stats" - A minimal stats sleep screen on supported devices + - "Minimal Stats" - A minimal stats sleep screen on supported devices; falls back to **Minimal** while tracking is off for the device or recent book - "Dashboard" - A dashboard-style sleep screen based on the Dashboard theme - "Quick Resume" - Keeps the current content visible while sleeping @@ -225,6 +225,8 @@ device model and build. - "Lyra Carousel" - A carousel-based Lyra home layout - "RoundedRaff" - A rounded theme with additional visual styling + Themes that show reading stats hide those stats while **Track Reading Stats** is off. + - **Recent Books View**: Choose whether the Recent Books screen uses a list or grid layout. - **Sunlight Fading Fix**: Configure whether to enable a software-fix for the issue where white X4 models may fade when used in direct sunlight: @@ -395,8 +397,15 @@ which status-bar items are shown. - **Files & Cache**: Configure hidden files, file extensions, file-browser view, finished-book behavior, and clear the reading cache. -- **Reading Stats**: Configure stats tracking and idle-time filtering, and - access all-time stats backup/reset actions. +- **Reading Stats**: Turn device-wide stats tracking on or off, configure + idle-time filtering, and access all-time stats backup/reset actions. Turning + tracking off pauses new per-book and all-time stats but keeps the history + and Time Left pace estimates already saved on the device. Stats entries and + recorded-stat displays in menus and themes are hidden while tracking is off; + the Dashboard can still show a saved Time Left estimate. Turn tracking back + on to see the saved history and resume recording. Time and pages from the off + period are not added later. See [Reader Features](./reader-features.md#turn-tracking-on-or-off) + for per-book controls. - **Wi-Fi Networks**: Connect to Wi-Fi networks for file transfers and firmware updates. @@ -598,7 +607,7 @@ The **Sleep Screen** setting controls what is displayed when the device goes to | **Cover + Custom** | The cover of the currently open book, shown only while actively reading. Falls back to **Custom** behavior when not reading. | | **Page Overlay** | Keeps the current reader page visible and draws a sleep wallpaper over it. If no wallpaper is available, the page remains visible while reading. | | **Minimal** | A compact sleep screen based on the Minimal home layout. | -| **Minimal Stats** | A compact sleep screen with recent reading stats, on supported devices. | +| **Minimal Stats** | A compact sleep screen with recent reading stats, on supported devices; falls back to **Minimal** while tracking is off for the device or recent book. | | **None** | A blank screen. | #### Cover settings @@ -845,7 +854,10 @@ Available options include: - **Show page as QR** – Display a QR code encoding the current reading position. - **Delete Book Cache** – Clear the cached layout data for the current book, forcing a re-index on next open. - **Sync Progress** – Push or pull reading progress with a KOReader sync server (see [KOReader Sync Quick Setup](#367-koreader-sync-quick-setup)). -- **Reading Stats** – Open the current book's reading stats. +- **Reading Stats** – Open the current book's reading stats on supported devices + when tracking is enabled for both the device and this book. Use the Reader + Menu's **Settings** tab in EPUB, or the Reader Menu in XTC, to pause or resume + tracking for this book; saved stats are kept while paused. - **Mark Finished / Mark Unfinished** – Toggle whether the current book is marked as finished. - **Look Up Word / Lookup History** – Select words on the page and revisit recent per-book lookups when a dictionary is active. - **Book Dictionary** – Choose a per-book dictionary override from the reader menu's settings tab. From 78924ff550f39f5f6816252f2a3078ffc3229154 Mon Sep 17 00:00:00 2001 From: Julia Nguyen Date: Mon, 28 Sep 2026 18:30:03 -0400 Subject: [PATCH 59/62] feat: allow more granular sleep/wake settings. Long-press always wakes the device while asleep. --- CHANGELOG.md | 1 + docs/user-guide.md | 37 +++++------ lib/I18n/translations/arabic.yaml | 1 + lib/I18n/translations/belarusian.yaml | 1 + lib/I18n/translations/catalan.yaml | 1 + lib/I18n/translations/czech.yaml | 1 + lib/I18n/translations/danish.yaml | 1 + lib/I18n/translations/dutch.yaml | 1 + lib/I18n/translations/english.yaml | 1 + lib/I18n/translations/finnish.yaml | 1 + lib/I18n/translations/french.yaml | 1 + lib/I18n/translations/german.yaml | 1 + lib/I18n/translations/hebrew.yaml | 1 + lib/I18n/translations/hungarian.yaml | 1 + lib/I18n/translations/italian.yaml | 1 + lib/I18n/translations/kazakh.yaml | 1 + lib/I18n/translations/lithuanian.yaml | 1 + lib/I18n/translations/polish.yaml | 1 + lib/I18n/translations/portuguese-BR.yaml | 1 + lib/I18n/translations/portuguese-PT.yaml | 1 + lib/I18n/translations/romanian.yaml | 1 + lib/I18n/translations/russian.yaml | 1 + lib/I18n/translations/slovak.yaml | 1 + lib/I18n/translations/slovenian.yaml | 1 + lib/I18n/translations/spanish.yaml | 1 + lib/I18n/translations/swedish.yaml | 1 + lib/I18n/translations/turkish.yaml | 1 + lib/I18n/translations/ukrainian.yaml | 1 + lib/I18n/translations/valencian.yaml | 1 + lib/I18n/translations/vietnamese.yaml | 1 + lib/hal/HalGPIO.cpp | 15 ++++- lib/hal/HalGPIO.h | 7 +- src/CrossPointSettings.h | 12 ++-- src/GlobalActions.h | 2 + src/SettingsList.h | 20 ++++-- src/activities/reader/EpubReaderActivity.cpp | 2 + src/main.cpp | 10 ++- src/simulator/SimulatorSmokeTest.cpp | 67 ++++++++++++++++++-- 38 files changed, 160 insertions(+), 41 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index b14730e86cd..b4f928bc1d0 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -17,6 +17,7 @@ ### Changed +- Set Power short-press and long-press to Sleep, Wake, or Sleep/Wake separately; holding Power can always wake the device. Chord shortcuts and the home button can also now sleep the device. - Brightness and warmth gestures now respond while you drag, with longer swipes making larger adjustments. - Text drawing resolves clipping and screen rotation once per glyph, reducing work when painting menus and book pages. - Library reuses its index on return visits and refreshes after file changes, instead of scanning the card every time. diff --git a/docs/user-guide.md b/docs/user-guide.md index 513abaa6021..54c8652e957 100644 --- a/docs/user-guide.md +++ b/docs/user-guide.md @@ -93,8 +93,7 @@ Alternatively, while reading a book, press the **Confirm** button to open the re ### Power On / Off -To turn the device on or off, **press and hold the Power button for approximately half a second**. -In **Settings > Controls > Power Button** you can configure the power button to turn the device off with a short press instead of a long one. +By default, **press and hold the Power button for approximately half a second** to put the device to sleep or wake it. In **Settings > Controls > Power Button**, you can configure the short-press and long-press actions separately. A short press wakes the device only when **Short-press Action** is set to **Sleep/Wake** or **Wake**; otherwise, hold Power for about half a second to wake it. See [Controls](#363-controls) for details. To reboot the device (for example after a firmware update or if it's frozen), press and release the Reset button, and then quickly press and hold the Power button for a few seconds. @@ -339,7 +338,7 @@ which status-bar items are shown. #### 3.6.3 Controls -- **Power Button**: Configure short-press and long-press power button actions. +- **Power Button**: Configure short-press and long-press actions independently. On supported devices, you can also configure the **Power + Up** shortcut. - **Front Buttons**: Configure front-button remapping, orientation awareness, reader-only long-press behavior, Back action, and Menu action. @@ -352,14 +351,16 @@ which status-bar items are shown. - **Side Button Long-press Action**: Set whether long-pressing side buttons does nothing, skips chapters, changes font size, or changes orientation. -- **Short-press Action / Long-press Action**: Controls the effect of a short or long press of the power button. Available actions include: - - "Ignore" (default) - Require a long press to turn off the device - - "Sleep" - A short press puts the device into sleep mode - - "Page Turn" - A short press in reading mode turns to the next page; a long press turns the device off +- **Short-press Action / Long-press Action**: Choose what a short press or a hold of about 0.4 seconds does. Available actions include: + - "Ignore" (default short-press action) - Do nothing for this press length + - "Sleep/Wake" (default long-press action) - Put the awake device to sleep; when selected as **Short-press Action**, also allow a short press to wake it + - "Sleep" - Put the awake device to sleep without enabling short-press wake + - "Wake" - When selected as **Short-press Action**, allow a short press to wake the device; does nothing while the device is awake + - "Page Turn" - Turn to the next page while reading - "Toggle Bookmark", "Reading Stats", "Mark Finished", "Refresh", "Change Font", "Guide Dots", "Focus Reading", "Auto Page Turn", "Sync Progress", "File Transfer", "Calibre Wireless", "Join a Network", "Create Hotspot", "Screenshot", "Dark Mode", "Browse Files", or "Save Clipping" - Run the matching action - "Footnotes" - A short press in reading mode opens the footnotes submenu; if only one footnote is present on the page, the referenced page is opened directly. The short press on the power button can be used to select the footnote in the submenu, and to go back to the original page after finish reading the footnote (like the back button). -- **Quick-return from footnotes**: Toggles on and off the quick return functionality from the footnotes. When the functionality it's active, a short press of the power button will act as the back button from the footnotes page. +- **Quick-return from Footnotes**: This setting appears under **Settings > Controls > Power Button** after you assign **Footnotes** to the short- or long-press Power action, or to the long-press Back or Menu action. When enabled, a short Power press returns from a footnote page to the original reading page. - **Taps & Gestures** (touchscreen devices): Configure the touch interactions available while reading from **Settings > Controls > Taps & Gestures**. The @@ -598,17 +599,17 @@ Once any of the options above is set up, press **Confirm** while reading to open The **Sleep Screen** setting controls what is displayed when the device goes to sleep: -| Mode | Behavior | -| ------------------ | ------------------------------------------------------------------------------------------------------------------------------------------------ | -| **Dark** (default) | The CrossInk logo on a dark background. | -| **Light** | The CrossInk logo on a white background. | -| **Custom** | A custom image from the SD card (see below). Falls back to **Dark** if no custom image is found. | -| **Cover** | The cover of the currently open book. Falls back to **Dark** if no book is open. | -| **Cover + Custom** | The cover of the currently open book, shown only while actively reading. Falls back to **Custom** behavior when not reading. | -| **Page Overlay** | Keeps the current reader page visible and draws a sleep wallpaper over it. If no wallpaper is available, the page remains visible while reading. | -| **Minimal** | A compact sleep screen based on the Minimal home layout. | +| Mode | Behavior | +| ------------------ | ------------------------------------------------------------------------------------------------------------------------------------------------------ | +| **Dark** (default) | The CrossInk logo on a dark background. | +| **Light** | The CrossInk logo on a white background. | +| **Custom** | A custom image from the SD card (see below). Falls back to **Dark** if no custom image is found. | +| **Cover** | The cover of the currently open book. Falls back to **Dark** if no book is open. | +| **Cover + Custom** | The cover of the currently open book, shown only while actively reading. Falls back to **Custom** behavior when not reading. | +| **Page Overlay** | Keeps the current reader page visible and draws a sleep wallpaper over it. If no wallpaper is available, the page remains visible while reading. | +| **Minimal** | A compact sleep screen based on the Minimal home layout. | | **Minimal Stats** | A compact sleep screen with recent reading stats, on supported devices; falls back to **Minimal** while tracking is off for the device or recent book. | -| **None** | A blank screen. | +| **None** | A blank screen. | #### Cover settings diff --git a/lib/I18n/translations/arabic.yaml b/lib/I18n/translations/arabic.yaml index d4c0bbfed06..b6fd664e84d 100644 --- a/lib/I18n/translations/arabic.yaml +++ b/lib/I18n/translations/arabic.yaml @@ -243,6 +243,7 @@ STR_IN_READER: "أثناء القراءة" STR_ALWAYS: "دائما" STR_IGNORE: "تجاهل" STR_SLEEP: "سكون" +STR_WAKE: "إيقاظ" STR_PAGE_TURN: "تقليب الصفحة" STR_FORCE_REFRESH: "تحديث الشاشة" STR_CHANGE_FONT: "تغيير الخط" diff --git a/lib/I18n/translations/belarusian.yaml b/lib/I18n/translations/belarusian.yaml index bd98ebaf033..117349f8f44 100644 --- a/lib/I18n/translations/belarusian.yaml +++ b/lib/I18n/translations/belarusian.yaml @@ -187,6 +187,7 @@ STR_IN_READER: "У рэжыме чытання" STR_ALWAYS: "Заўсёды" STR_IGNORE: "Ігнараваць" STR_SLEEP: "Сон" +STR_WAKE: "Абудзіць" STR_PAGE_TURN: "Перагортванне" STR_NEXT_PAGE: "Наступная старонка" STR_PORTRAIT: "Партрэт" diff --git a/lib/I18n/translations/catalan.yaml b/lib/I18n/translations/catalan.yaml index 99a902337e0..7299a0fcf3b 100644 --- a/lib/I18n/translations/catalan.yaml +++ b/lib/I18n/translations/catalan.yaml @@ -211,6 +211,7 @@ STR_IN_READER: "Al lector" STR_ALWAYS: "Sempre" STR_IGNORE: "Ignora" STR_SLEEP: "Dormir" +STR_WAKE: "Despertar" STR_PAGE_TURN: "Canvi de pàgina" STR_PORTRAIT: "Vertical" STR_LANDSCAPE_CW: "Horitzontal horari" diff --git a/lib/I18n/translations/czech.yaml b/lib/I18n/translations/czech.yaml index 16b54ff67ba..a8b0dab2832 100644 --- a/lib/I18n/translations/czech.yaml +++ b/lib/I18n/translations/czech.yaml @@ -207,6 +207,7 @@ STR_IN_READER: "Ve čtečce" STR_ALWAYS: "Vždy" STR_IGNORE: "Ignorovat" STR_SLEEP: "Spánek" +STR_WAKE: "Probudit" STR_PAGE_TURN: "Otáčení stránek" STR_PORTRAIT: "Na výšku" STR_LANDSCAPE_CW: "Na šířku po směru hod. ručiček" diff --git a/lib/I18n/translations/danish.yaml b/lib/I18n/translations/danish.yaml index f08574621f8..d62a143cd8f 100644 --- a/lib/I18n/translations/danish.yaml +++ b/lib/I18n/translations/danish.yaml @@ -213,6 +213,7 @@ STR_IN_READER: "I læseren" STR_ALWAYS: "Altid" STR_IGNORE: "Ignorer" STR_SLEEP: "Hvile" +STR_WAKE: "Væk" STR_PAGE_TURN: "Sideskift" STR_NEXT_PAGE: "Næste side" STR_PORTRAIT: "Portræt" diff --git a/lib/I18n/translations/dutch.yaml b/lib/I18n/translations/dutch.yaml index 569e148e100..7b2e91db069 100644 --- a/lib/I18n/translations/dutch.yaml +++ b/lib/I18n/translations/dutch.yaml @@ -212,6 +212,7 @@ STR_IN_READER: "In lezer" STR_ALWAYS: "Altijd" STR_IGNORE: "Negeren" STR_SLEEP: "Slaap" +STR_WAKE: "Wekken" STR_PAGE_TURN: "Pagina omslaan" STR_NEXT_PAGE: "Volgende pagina" STR_PORTRAIT: "Staand" diff --git a/lib/I18n/translations/english.yaml b/lib/I18n/translations/english.yaml index 46d4cd3f47b..2e6234b3e47 100644 --- a/lib/I18n/translations/english.yaml +++ b/lib/I18n/translations/english.yaml @@ -334,6 +334,7 @@ STR_IN_READER: "In Reader" STR_ALWAYS: "Always" STR_IGNORE: "Ignore" STR_SLEEP: "Sleep" +STR_WAKE: "Wake" STR_PAGE_TURN: "Page Turn" STR_FORCE_REFRESH: "Refresh Screen" STR_CHANGE_FONT: "Change Font" diff --git a/lib/I18n/translations/finnish.yaml b/lib/I18n/translations/finnish.yaml index 35694aa5080..cd80f6b6d55 100644 --- a/lib/I18n/translations/finnish.yaml +++ b/lib/I18n/translations/finnish.yaml @@ -204,6 +204,7 @@ STR_IN_READER: "Lukijassa" STR_ALWAYS: "Aina" STR_IGNORE: "Ohita" STR_SLEEP: "Lepotila" +STR_WAKE: "Herätä" STR_PAGE_TURN: "Sivunkääntö" STR_NEXT_PAGE: "Seuraava sivu" STR_PORTRAIT: "Pysty" diff --git a/lib/I18n/translations/french.yaml b/lib/I18n/translations/french.yaml index bc1e4aadbc3..bec5e124667 100644 --- a/lib/I18n/translations/french.yaml +++ b/lib/I18n/translations/french.yaml @@ -213,6 +213,7 @@ STR_IN_READER: "Dans le lecteur" STR_ALWAYS: "Toujours" STR_IGNORE: "Ignorer" STR_SLEEP: "Mise en veille" +STR_WAKE: "Réveiller" STR_PAGE_TURN: "Page suivante" STR_NEXT_PAGE: "Page suivante" STR_PORTRAIT: "Portrait" diff --git a/lib/I18n/translations/german.yaml b/lib/I18n/translations/german.yaml index 7f978a04ac4..48409736709 100644 --- a/lib/I18n/translations/german.yaml +++ b/lib/I18n/translations/german.yaml @@ -248,6 +248,7 @@ STR_IN_READER: "Beim Lesen" STR_ALWAYS: "Immer" STR_IGNORE: "Ignorieren" STR_SLEEP: "Standby" +STR_WAKE: "Aufwecken" STR_PAGE_TURN: "Umblättern" STR_FORCE_REFRESH: "Anzeige aktualisieren" STR_CHANGE_FONT: "Schriftart ändern" diff --git a/lib/I18n/translations/hebrew.yaml b/lib/I18n/translations/hebrew.yaml index 7bbad4a0eb8..68578bc0c2a 100644 --- a/lib/I18n/translations/hebrew.yaml +++ b/lib/I18n/translations/hebrew.yaml @@ -200,6 +200,7 @@ STR_IN_READER: "בזמן קריאה" STR_ALWAYS: "תמיד" STR_IGNORE: "התעלם" STR_SLEEP: "מצב שינה" +STR_WAKE: "להעיר" STR_PAGE_TURN: "העברת דף" STR_FORCE_REFRESH: "רענון מסך מלא" STR_PORTRAIT: "לאורך" diff --git a/lib/I18n/translations/hungarian.yaml b/lib/I18n/translations/hungarian.yaml index 47f4323835d..7b8ee7dd0f6 100644 --- a/lib/I18n/translations/hungarian.yaml +++ b/lib/I18n/translations/hungarian.yaml @@ -212,6 +212,7 @@ STR_IN_READER: "Olvasóban" STR_ALWAYS: "Mindig" STR_IGNORE: "Mellőzés" STR_SLEEP: "Alvás" +STR_WAKE: "Ébresztés" STR_PAGE_TURN: "Lapozás" STR_NEXT_PAGE: "Következő oldal" STR_PORTRAIT: "Álló" diff --git a/lib/I18n/translations/italian.yaml b/lib/I18n/translations/italian.yaml index 0b69ab5c611..4ef5365fef9 100644 --- a/lib/I18n/translations/italian.yaml +++ b/lib/I18n/translations/italian.yaml @@ -213,6 +213,7 @@ STR_IN_READER: "Nel lettore" STR_ALWAYS: "Sempre" STR_IGNORE: "Ignora" STR_SLEEP: "Sospendi" +STR_WAKE: "Riattiva" STR_PAGE_TURN: "Cambio pagina" STR_FORCE_REFRESH: "Refresh" STR_PORTRAIT: "Verticale" diff --git a/lib/I18n/translations/kazakh.yaml b/lib/I18n/translations/kazakh.yaml index bccf5bfdc51..7e9629eaed8 100644 --- a/lib/I18n/translations/kazakh.yaml +++ b/lib/I18n/translations/kazakh.yaml @@ -200,6 +200,7 @@ STR_IN_READER: "Оқырманда" STR_ALWAYS: "Әрқашан" STR_IGNORE: "Елемеу" STR_SLEEP: "Ұйқы" +STR_WAKE: "Ояту" STR_PAGE_TURN: "Бет аудару" STR_NEXT_PAGE: "Келесі бет" STR_PORTRAIT: "Тік бағдар" diff --git a/lib/I18n/translations/lithuanian.yaml b/lib/I18n/translations/lithuanian.yaml index 7c3d2535a9d..c6abdb77cbc 100644 --- a/lib/I18n/translations/lithuanian.yaml +++ b/lib/I18n/translations/lithuanian.yaml @@ -210,6 +210,7 @@ STR_IN_READER: "Skaitytuve" STR_ALWAYS: "Visada" STR_IGNORE: "Nepaisyti" STR_SLEEP: "Miegas" +STR_WAKE: "Pažadinti" STR_PAGE_TURN: "Versti psl." STR_NEXT_PAGE: "Kitas psl." STR_PORTRAIT: "Stačias" diff --git a/lib/I18n/translations/polish.yaml b/lib/I18n/translations/polish.yaml index 5d069feb126..5763f149e14 100644 --- a/lib/I18n/translations/polish.yaml +++ b/lib/I18n/translations/polish.yaml @@ -213,6 +213,7 @@ STR_IN_READER: "W czytniku" STR_ALWAYS: "Zawsze" STR_IGNORE: "Ignoruj" STR_SLEEP: "Uśpienie" +STR_WAKE: "Wybudź" STR_PAGE_TURN: "Nast. str." STR_NEXT_PAGE: "Następna strona" STR_PORTRAIT: "Pionowo" diff --git a/lib/I18n/translations/portuguese-BR.yaml b/lib/I18n/translations/portuguese-BR.yaml index 120a79ba49a..8fd3bbf02cc 100644 --- a/lib/I18n/translations/portuguese-BR.yaml +++ b/lib/I18n/translations/portuguese-BR.yaml @@ -237,6 +237,7 @@ STR_IN_READER: "No leitor" STR_ALWAYS: "Sempre" STR_IGNORE: "Ignorar" STR_SLEEP: "Repouso" +STR_WAKE: "Despertar" STR_PAGE_TURN: "Virar página" STR_FORCE_REFRESH: "Atualizar tela" STR_CHANGE_FONT: "Alterar fonte" diff --git a/lib/I18n/translations/portuguese-PT.yaml b/lib/I18n/translations/portuguese-PT.yaml index 24906ecd1b9..aed6b9fba87 100644 --- a/lib/I18n/translations/portuguese-PT.yaml +++ b/lib/I18n/translations/portuguese-PT.yaml @@ -311,6 +311,7 @@ STR_IN_READER: "No leitor" STR_ALWAYS: "Sempre" STR_IGNORE: "Ignorar" STR_SLEEP: "Repouso" +STR_WAKE: "Despertar" STR_PAGE_TURN: "Virar página" STR_FORCE_REFRESH: "Atualizar o ecrã" STR_CHANGE_FONT: "Alterar o tipo de letra" diff --git a/lib/I18n/translations/romanian.yaml b/lib/I18n/translations/romanian.yaml index 8e37108cae2..eb517f6ec28 100644 --- a/lib/I18n/translations/romanian.yaml +++ b/lib/I18n/translations/romanian.yaml @@ -212,6 +212,7 @@ STR_IN_READER: "În lectură" STR_ALWAYS: "Întotdeauna" STR_IGNORE: "Ignoră" STR_SLEEP: "Repaus" +STR_WAKE: "Trezire" STR_PAGE_TURN: "Răsfoire pagină" STR_NEXT_PAGE: "Pagina următoare" STR_PORTRAIT: "Vertical" diff --git a/lib/I18n/translations/russian.yaml b/lib/I18n/translations/russian.yaml index 7703ed0b425..e6ac54eb909 100644 --- a/lib/I18n/translations/russian.yaml +++ b/lib/I18n/translations/russian.yaml @@ -214,6 +214,7 @@ STR_IN_READER: "В режиме чтения" STR_ALWAYS: "Всегда" STR_IGNORE: "Игнорировать" STR_SLEEP: "Сон" +STR_WAKE: "Пробудить" STR_PAGE_TURN: "Перелистывание" STR_FORCE_REFRESH: "Обновление экрана" STR_PORTRAIT: "Портрет" diff --git a/lib/I18n/translations/slovak.yaml b/lib/I18n/translations/slovak.yaml index eb9b74d4ae1..d9236868638 100644 --- a/lib/I18n/translations/slovak.yaml +++ b/lib/I18n/translations/slovak.yaml @@ -200,6 +200,7 @@ STR_IN_READER: "V čítačke" STR_ALWAYS: "Vždy" STR_IGNORE: "Ignorovať" STR_SLEEP: "Spánok" +STR_WAKE: "Prebudiť" STR_PAGE_TURN: "Otáčanie stránok" STR_FORCE_REFRESH: "Obnoviť obrazovku" STR_CHANGE_FONT: "Zmeniť písmo" diff --git a/lib/I18n/translations/slovenian.yaml b/lib/I18n/translations/slovenian.yaml index 84fc63ffdb1..f11ff5569bb 100644 --- a/lib/I18n/translations/slovenian.yaml +++ b/lib/I18n/translations/slovenian.yaml @@ -210,6 +210,7 @@ STR_IN_READER: "V bralniku" STR_ALWAYS: "Vedno" STR_IGNORE: "Prezri" STR_SLEEP: "Spanje" +STR_WAKE: "Prebudi" STR_PAGE_TURN: "Obračanje strani" STR_NEXT_PAGE: "Naslednja stran" STR_PORTRAIT: "Pokončno" diff --git a/lib/I18n/translations/spanish.yaml b/lib/I18n/translations/spanish.yaml index 7cc45265ae4..c29c5bda3be 100644 --- a/lib/I18n/translations/spanish.yaml +++ b/lib/I18n/translations/spanish.yaml @@ -238,6 +238,7 @@ STR_IN_READER: "En el lector" STR_ALWAYS: "Siempre" STR_IGNORE: "Ignorar" STR_SLEEP: "Suspender" +STR_WAKE: "Despertar" STR_PAGE_TURN: "Pasar página" STR_FORCE_REFRESH: "Refrescar pant." STR_CHANGE_FONT: "Cambiar fuente" diff --git a/lib/I18n/translations/swedish.yaml b/lib/I18n/translations/swedish.yaml index 0710ec8625c..28e00a1dcd0 100644 --- a/lib/I18n/translations/swedish.yaml +++ b/lib/I18n/translations/swedish.yaml @@ -213,6 +213,7 @@ STR_IN_READER: "I Eboksläsare" STR_ALWAYS: "Alltid" STR_IGNORE: "Ignorera" STR_SLEEP: "Vila" +STR_WAKE: "Väck" STR_PAGE_TURN: "Sidvändning" STR_FORCE_REFRESH: "Uppdatera skärmen" STR_PORTRAIT: "Porträtt" diff --git a/lib/I18n/translations/turkish.yaml b/lib/I18n/translations/turkish.yaml index df36e96cd0d..7f885ce2b60 100644 --- a/lib/I18n/translations/turkish.yaml +++ b/lib/I18n/translations/turkish.yaml @@ -207,6 +207,7 @@ STR_IN_READER: "Okuyucuda" STR_ALWAYS: "Her Zaman" STR_IGNORE: "Yoksay" STR_SLEEP: "Uyku" +STR_WAKE: "Uyandır" STR_PAGE_TURN: "Sayfa Çevirme" STR_NEXT_PAGE: "Sonraki Sayfa" STR_PORTRAIT: "Dikey" diff --git a/lib/I18n/translations/ukrainian.yaml b/lib/I18n/translations/ukrainian.yaml index cc0cdcca416..2aedb369f5f 100644 --- a/lib/I18n/translations/ukrainian.yaml +++ b/lib/I18n/translations/ukrainian.yaml @@ -212,6 +212,7 @@ STR_IN_READER: "В читачі" STR_ALWAYS: "Завжди" STR_IGNORE: "Ігнорувати" STR_SLEEP: "Сон" +STR_WAKE: "Пробудити" STR_PAGE_TURN: "Наст. сторінка" STR_FORCE_REFRESH: "Оновити екран" STR_PORTRAIT: "Книжкова" diff --git a/lib/I18n/translations/valencian.yaml b/lib/I18n/translations/valencian.yaml index 6adb207c240..6d2dbeac24d 100644 --- a/lib/I18n/translations/valencian.yaml +++ b/lib/I18n/translations/valencian.yaml @@ -212,6 +212,7 @@ STR_IN_READER: "Al lector" STR_ALWAYS: "Sempre" STR_IGNORE: "Ignora" STR_SLEEP: "Dormir" +STR_WAKE: "Despertar" STR_PAGE_TURN: "Canvi de pàgina" STR_PORTRAIT: "Vertical" STR_LANDSCAPE_CW: "Horitzontal horari" diff --git a/lib/I18n/translations/vietnamese.yaml b/lib/I18n/translations/vietnamese.yaml index d0a7817aff0..ac63b295bd8 100644 --- a/lib/I18n/translations/vietnamese.yaml +++ b/lib/I18n/translations/vietnamese.yaml @@ -215,6 +215,7 @@ STR_IN_READER: "Trong trình đọc" STR_ALWAYS: "Luôn luôn" STR_IGNORE: "Bỏ qua" STR_SLEEP: "Ngủ" +STR_WAKE: "Đánh thức" STR_PAGE_TURN: "Lật trang" STR_FORCE_REFRESH: "Làm mới màn hình" STR_CHANGE_FONT: "Đổi phông chữ" diff --git a/lib/hal/HalGPIO.cpp b/lib/hal/HalGPIO.cpp index 8c0126b1302..1f6c3117e51 100644 --- a/lib/hal/HalGPIO.cpp +++ b/lib/hal/HalGPIO.cpp @@ -298,7 +298,7 @@ bool HalGPIO::hasEdgeSideButtons() const { BoardConfig::ACTIVE.board == BoardConfig::Board::XteinkX4Classic; } -bool HalGPIO::verifyPowerButtonWakeup(const bool shortPressWakes) { +bool HalGPIO::verifyPowerButtonWakeup(const bool shortPressWakes, const uint16_t longHoldMs) { // M5Paper v1.1 reaches setup after a normal wheel click has already been // released. Its hardware pull-ups make this ghost-wake debounce unnecessary. if (BoardConfig::isPaperMono() || BoardConfig::isM5PaperV11() || BoardConfig::ACTIVE.input.power < 0) { @@ -313,7 +313,18 @@ bool HalGPIO::verifyPowerButtonWakeup(const bool shortPressWakes) { delay(1); inputMgr.update(); } - return shortPressWakes || (heldAtFirstSample && inputMgr.isPowerButtonPhysicallyPressed()); + if (shortPressWakes) return true; + if (!heldAtFirstSample || !inputMgr.isPowerButtonPhysicallyPressed()) return false; + + // Deep sleep wakes as soon as the GPIO changes. Keep the panel and SD card + // asleep until the held press qualifies as a long Power gesture. millis() + // starts at reset, so time spent reaching this early boot check counts too. + while (millis() < longHoldMs) { + delay(1); + inputMgr.update(); + if (!inputMgr.isPowerButtonPhysicallyPressed()) return false; + } + return true; } #if FREEINK_MCU_S3 diff --git a/lib/hal/HalGPIO.h b/lib/hal/HalGPIO.h index 164186a0c4e..d43ae318ee1 100644 --- a/lib/hal/HalGPIO.h +++ b/lib/hal/HalGPIO.h @@ -136,12 +136,11 @@ class HalGPIO { #endif void setSharedConfirmPowerShortPressEmitsPower(bool enabled); - // Verify that the physical power button remains held through input debounce. - // A device configured to sleep on a short power press can wake on that same - // short press, which has normally ended before firmware reaches this check. + // Verify the physical power button through debounce, then require a long + // hold when the saved shortcut does not permit short-press wake. // Returns true if verification succeeded, false if device should return to sleep. // Should only be called when wakeup reason is PowerButton. - bool verifyPowerButtonWakeup(bool shortPressWakes); + bool verifyPowerButtonWakeup(bool shortPressWakes, uint16_t longHoldMs); // Check if USB is connected bool isUsbConnected() const; diff --git a/src/CrossPointSettings.h b/src/CrossPointSettings.h index 4ecf8a3e2e1..2f7be4895f3 100644 --- a/src/CrossPointSettings.h +++ b/src/CrossPointSettings.h @@ -278,6 +278,9 @@ class CrossPointSettings : public PersistableStore { PREVIOUS_PAGE = 31, NEARBY_POSITION_SYNC = 32, LIBRARY = 33, + // Power-only choices. Keep SLEEP=1 as the existing Sleep/Wake setting. + SLEEP_ONLY = 34, + WAKE_ONLY = 35, SHORT_PWRBTN_COUNT }; @@ -686,9 +689,9 @@ class CrossPointSettings : public PersistableStore { ~CrossPointSettings() = default; - static constexpr uint16_t POWER_BUTTON_WAKE_SHORT_MS = 10; - static constexpr uint16_t POWER_BUTTON_WAKE_LONG_MS = 200; static constexpr uint16_t POWER_BUTTON_LONG_PRESS_MS = 400; + static constexpr uint16_t POWER_BUTTON_WAKE_SHORT_MS = 10; + static constexpr uint16_t POWER_BUTTON_WAKE_LONG_MS = POWER_BUTTON_LONG_PRESS_MS; static constexpr uint8_t MIN_SLEEP_TIMEOUT_MINUTES = 1; static constexpr uint8_t SLEEP_TIMEOUT_NEVER_MINUTES = 31; static constexpr uint8_t MAX_SLEEP_TIMEOUT_MINUTES = SLEEP_TIMEOUT_NEVER_MINUTES; @@ -714,10 +717,11 @@ class CrossPointSettings : public PersistableStore { static constexpr size_t MAX_DEVICE_NAME_LENGTH = sizeof(deviceName) - 1; uint16_t getPowerButtonWakeDuration() const { - return (shortPwrBtn == CrossPointSettings::SHORT_PWRBTN::SLEEP) ? POWER_BUTTON_WAKE_SHORT_MS - : POWER_BUTTON_WAKE_LONG_MS; + return shortPowerPressWakes() ? POWER_BUTTON_WAKE_SHORT_MS : POWER_BUTTON_WAKE_LONG_MS; } + bool shortPowerPressWakes() const { return shortPwrBtn == SLEEP || shortPwrBtn == WAKE_ONLY; } + bool shouldShowClockInReader() const { return topReaderStatusBar.contains(ReaderStatusBarItem::Clock) || bottomReaderStatusBar.contains(ReaderStatusBarItem::Clock); diff --git a/src/GlobalActions.h b/src/GlobalActions.h index 175c6ab59f9..aa047ce1359 100644 --- a/src/GlobalActions.h +++ b/src/GlobalActions.h @@ -22,6 +22,8 @@ inline HalDisplay::RefreshMode manualScreenRefreshMode() { inline bool isPowerButtonActionAvailableOutsideReader(const CrossPointSettings::SHORT_PWRBTN action) { switch (action) { case CrossPointSettings::SHORT_PWRBTN::SLEEP: + case CrossPointSettings::SHORT_PWRBTN::SLEEP_ONLY: + case CrossPointSettings::SHORT_PWRBTN::WAKE_ONLY: case CrossPointSettings::SHORT_PWRBTN::QUICK_LOCK: case CrossPointSettings::SHORT_PWRBTN::FORCE_REFRESH: case CrossPointSettings::SHORT_PWRBTN::SYNC_PROGRESS: diff --git a/src/SettingsList.h b/src/SettingsList.h index 7d61f4df20e..3476c5f1fa0 100644 --- a/src/SettingsList.h +++ b/src/SettingsList.h @@ -85,6 +85,10 @@ inline bool isSideButtonActionSetting(const SettingInfo& setting) { } inline std::string sideButtonOptionLabel(const SettingInfo& setting, const uint8_t displayIndex) { + if ((settingKeyIs(setting, "shortPwrBtn") || settingKeyIs(setting, "longPwrBtn")) && + displayIndex < setting.enumRawValues.size() && setting.enumRawValues[displayIndex] == CrossPointSettings::SLEEP) { + return std::string(tr(STR_SLEEP)) + "/" + tr(STR_WAKE); + } if (isSideButtonActionSetting(setting) && displayIndex < setting.enumRawValues.size()) { const uint8_t action = setting.enumRawValues[displayIndex]; if (action == CrossPointSettings::SIDE_ROTATE_COUNTERCLOCKWISE || @@ -365,10 +369,8 @@ inline uint8_t shortcutRawValue(const ShortcutOptionCatalog catalog, const Cross switch (action) { case Action::IGNORE: return Chord::CHORD_DISABLED; - // Deep sleep wakes from the Power GPIO alone. A chord cannot be used - // as the matching wake gesture, so do not offer a misleading action. case Action::SLEEP: - return SHORTCUT_OPTION_UNAVAILABLE; + return Chord::CHORD_SLEEP; case Action::PAGE_TURN: return Chord::CHORD_PAGE_TURN; case Action::PREVIOUS_PAGE: @@ -496,7 +498,6 @@ inline uint8_t shortcutRawValue(const ShortcutOptionCatalog catalog, const Cross break; case ShortcutOptionCatalog::HomeButton: switch (action) { - case Action::SLEEP: case Action::TOGGLE_TILT_PAGE_TURN: case Action::TOGGLE_HOME_BUTTON_IN_READER: case Action::TOGGLE_FRONTLIGHT: @@ -510,8 +511,9 @@ inline uint8_t shortcutRawValue(const ShortcutOptionCatalog catalog, const Cross } inline void appendShortcutOptions(SettingInfo& setting, const ShortcutOptionCatalog catalog) { - setting.enumValues.reserve(QuickActions::shortcutActionOrder.size() + 3); - setting.enumRawValues.reserve(QuickActions::shortcutActionOrder.size() + 3); + const size_t extraOptions = catalog == ShortcutOptionCatalog::PowerButton ? 2 : 0; + setting.enumValues.reserve(QuickActions::shortcutActionOrder.size() + 3 + extraOptions); + setting.enumRawValues.reserve(QuickActions::shortcutActionOrder.size() + 3 + extraOptions); if (catalog == ShortcutOptionCatalog::HomeButton) { setting.enumValues.push_back(StrId::STR_BACK_HOME); @@ -530,6 +532,12 @@ inline void appendShortcutOptions(SettingInfo& setting, const ShortcutOptionCata if (rawValue == SHORTCUT_OPTION_UNAVAILABLE) continue; setting.enumValues.push_back(QuickActions::actionLabel(static_cast(action))); setting.enumRawValues.push_back(rawValue); + if (catalog == ShortcutOptionCatalog::PowerButton && action == CrossPointSettings::SLEEP) { + setting.enumValues.push_back(StrId::STR_SLEEP); + setting.enumRawValues.push_back(CrossPointSettings::SLEEP_ONLY); + setting.enumValues.push_back(StrId::STR_WAKE); + setting.enumRawValues.push_back(CrossPointSettings::WAKE_ONLY); + } } } diff --git a/src/activities/reader/EpubReaderActivity.cpp b/src/activities/reader/EpubReaderActivity.cpp index dab582d0c54..db5eef00b34 100644 --- a/src/activities/reader/EpubReaderActivity.cpp +++ b/src/activities/reader/EpubReaderActivity.cpp @@ -5166,6 +5166,8 @@ bool EpubReaderActivity::handleShortcutAction(const uint8_t rawAction) { case CrossPointSettings::SHORT_PWRBTN::TOGGLE_TOUCHSCREEN: return handleGlobalPowerButtonAction(action); case CrossPointSettings::SHORT_PWRBTN::SLEEP: + case CrossPointSettings::SHORT_PWRBTN::SLEEP_ONLY: + case CrossPointSettings::SHORT_PWRBTN::WAKE_ONLY: case CrossPointSettings::SHORT_PWRBTN::FORCE_REFRESH: case CrossPointSettings::SHORT_PWRBTN::SHORT_PWRBTN_COUNT: return false; diff --git a/src/main.cpp b/src/main.cpp index eb33f5106fd..e220ebac9f3 100644 --- a/src/main.cpp +++ b/src/main.cpp @@ -588,8 +588,11 @@ bool handleGlobalPowerButtonAction(const CrossPointSettings::SHORT_PWRBTN action const QuickLockTrigger quickLockTrigger) { switch (action) { case CrossPointSettings::SHORT_PWRBTN::SLEEP: + case CrossPointSettings::SHORT_PWRBTN::SLEEP_ONLY: enterDeepSleep(); return true; + case CrossPointSettings::SHORT_PWRBTN::WAKE_ONLY: + return true; case CrossPointSettings::SHORT_PWRBTN::QUICK_LOCK: if (quickLockTrigger == QuickLockTrigger::None) { LOG_ERR("MAIN", "Quick Lock requested without an input trigger"); @@ -1034,7 +1037,7 @@ bool shouldClearX4WakeGhosting() { // Wake validation runs before the SD card and its settings file are available. // Mirror the one setting that changes its behavior while entering sleep, so a -// deliberate short sleep press can wake the device even after the button has +// permitted short wake press can pass verification even after the button has // been released during boot. The write is skipped when the value is unchanged. constexpr char WAKE_NVS_NAMESPACE[] = "crosspoint"; constexpr char WAKE_SHORT_PRESS_KEY[] = "wakeShortPr"; @@ -1054,7 +1057,7 @@ bool readWakeShortPressFromNvs() { void mirrorWakeShortPressToNvs() { #ifndef SIMULATOR - const uint8_t expected = SETTINGS.shortPwrBtn == CrossPointSettings::SHORT_PWRBTN::SLEEP ? 1 : 0; + const uint8_t expected = SETTINGS.shortPowerPressWakes() ? 1 : 0; nvs_handle_t handle; if (nvs_open(WAKE_NVS_NAMESPACE, NVS_READWRITE, &handle) != ESP_OK) return; uint8_t current = 0; @@ -1248,7 +1251,8 @@ void setup() { const auto wakeupReason = gpio.getWakeupReason(); #ifndef SIMULATOR const bool shortPressWakes = readWakeShortPressFromNvs(); - if (wakeupReason == HalGPIO::WakeupReason::PowerButton && !gpio.verifyPowerButtonWakeup(shortPressWakes)) { + if (wakeupReason == HalGPIO::WakeupReason::PowerButton && + !gpio.verifyPowerButtonWakeup(shortPressWakes, CrossPointSettings::POWER_BUTTON_LONG_PRESS_MS)) { LOG_DBG("MAIN", "Power-button wake not held through verification, sleeping"); powerManager.startDeepSleep(gpio); } diff --git a/src/simulator/SimulatorSmokeTest.cpp b/src/simulator/SimulatorSmokeTest.cpp index c6147b0c059..6c16bdb82a9 100644 --- a/src/simulator/SimulatorSmokeTest.cpp +++ b/src/simulator/SimulatorSmokeTest.cpp @@ -429,10 +429,6 @@ class SimulatorSmokeTest { return settingKeyIs(setting, "powerChordAction"); }); if (chordSetting == allSettings.end()) fail("Power chord setting is missing"); - if (std::find(chordSetting->enumRawValues.begin(), chordSetting->enumRawValues.end(), - CrossPointSettings::CHORD_SLEEP) != chordSetting->enumRawValues.end()) { - fail("Sleep is still offered for a chord that cannot wake the device"); - } if (std::find(chordSetting->enumRawValues.begin(), chordSetting->enumRawValues.end(), CrossPointSettings::CHORD_QUICK_ACTIONS) == chordSetting->enumRawValues.end()) { fail("Quick Actions is missing from the Power + Up chord setting"); @@ -478,10 +474,67 @@ class SimulatorSmokeTest { verifyLibraryChoice("powerChordAction", ShortcutOptionCatalog::ButtonChord); verifyLibraryChoice("longPressMenuAction", ShortcutOptionCatalog::LongPress); verifyLibraryChoice("longPressBackAction", ShortcutOptionCatalog::LongPress); + const auto verifySleepChoice = [&](const char* key, const ShortcutOptionCatalog catalog) { + const auto setting = std::find_if(allSettings.begin(), allSettings.end(), + [key](const SettingInfo& candidate) { return settingKeyIs(candidate, key); }); + const auto raw = shortcutRawValue(catalog, CrossPointSettings::SLEEP); + if (setting == allSettings.end() || raw == SHORTCUT_OPTION_UNAVAILABLE) { + fail("Sleep shortcut is missing from %s", key); + } + const auto choice = std::find(setting->enumRawValues.begin(), setting->enumRawValues.end(), raw); + const std::string expected = catalog == ShortcutOptionCatalog::PowerButton + ? std::string(tr(STR_SLEEP)) + "/" + tr(STR_WAKE) + : tr(STR_SLEEP); + if (choice == setting->enumRawValues.end() || + sideButtonOptionLabel(*setting, static_cast(choice - setting->enumRawValues.begin())) != expected) { + fail("Sleep shortcut has the wrong label in %s", key); + } + }; + verifySleepChoice("shortPwrBtn", ShortcutOptionCatalog::PowerButton); + verifySleepChoice("longPwrBtn", ShortcutOptionCatalog::PowerButton); + for (const char* key : {"shortPwrBtn", "longPwrBtn"}) { + const auto setting = std::find_if(allSettings.begin(), allSettings.end(), + [key](const SettingInfo& candidate) { return settingKeyIs(candidate, key); }); + if (setting == allSettings.end()) fail("Power shortcut setting is missing: %s", key); + for (const auto [action, label] : {std::pair{CrossPointSettings::SLEEP_ONLY, StrId::STR_SLEEP}, + std::pair{CrossPointSettings::WAKE_ONLY, StrId::STR_WAKE}}) { + const auto choice = std::find(setting->enumRawValues.begin(), setting->enumRawValues.end(), action); + if (choice == setting->enumRawValues.end() || + sideButtonOptionLabel(*setting, static_cast(choice - setting->enumRawValues.begin())) != + I18N.get(label)) { + fail("Power-only shortcut is missing or mislabeled in %s", key); + } + } + } + const uint8_t savedShortPowerAction = SETTINGS.shortPwrBtn; + for (const auto [action, wakes] : + {std::pair{CrossPointSettings::IGNORE, false}, std::pair{CrossPointSettings::SLEEP_ONLY, false}, + std::pair{CrossPointSettings::WAKE_ONLY, true}, std::pair{CrossPointSettings::SLEEP, true}}) { + SETTINGS.shortPwrBtn = action; + if (SETTINGS.shortPowerPressWakes() != wakes) fail("Short Power wake policy does not match its shortcut"); + } + SETTINGS.shortPwrBtn = savedShortPowerAction; + verifySleepChoice("powerChordAction", ShortcutOptionCatalog::ButtonChord); + verifySleepChoice("longPressMenuAction", ShortcutOptionCatalog::LongPress); + verifySleepChoice("longPressBackAction", ShortcutOptionCatalog::LongPress); + for (const char* key : {"sideButtonUpShort", "sideButtonUpLong", "sideButtonDownShort", "sideButtonDownLong"}) { + const auto side = std::find_if(sideButtonSettings.begin(), sideButtonSettings.end(), + [key](const SettingInfo& candidate) { return settingKeyIs(candidate, key); }); + if (side != sideButtonSettings.end()) { + const auto sleep = std::find(side->enumRawValues.begin(), side->enumRawValues.end(), CrossPointSettings::SLEEP); + if (sleep == side->enumRawValues.end() || + sideButtonOptionLabel(*side, static_cast(sleep - side->enumRawValues.begin())) != tr(STR_SLEEP)) { + fail("Sleep shortcut has the wrong label in %s", key); + } + } + } if (gpio.hasHomeKey()) { verifyLibraryChoice("homeButtonTapAction", ShortcutOptionCatalog::HomeButton); verifyLibraryChoice("homeButtonDoubleTapAction", ShortcutOptionCatalog::HomeButton); verifyLibraryChoice("homeButtonLongPressAction", ShortcutOptionCatalog::HomeButton); + verifySleepChoice("homeButtonTapAction", ShortcutOptionCatalog::HomeButton); + verifySleepChoice("homeButtonDoubleTapAction", ShortcutOptionCatalog::HomeButton); + verifySleepChoice("homeButtonLongPressAction", ShortcutOptionCatalog::HomeButton); } if (hasSideButtonChord) { const auto side = @@ -490,6 +543,12 @@ class SimulatorSmokeTest { if (side == sideButtonSettings.end() || !hasLibrary(*side, ShortcutOptionCatalog::ButtonChord)) { fail("Library shortcut is missing from the filtered Up + Down choices"); } + const auto sleep = + std::find(side->enumRawValues.begin(), side->enumRawValues.end(), CrossPointSettings::CHORD_SLEEP); + if (sleep == side->enumRawValues.end() || + sideButtonOptionLabel(*side, static_cast(sleep - side->enumRawValues.begin())) != tr(STR_SLEEP)) { + fail("Sleep shortcut is missing or mislabeled in the filtered Up + Down choices"); + } } if (!QuickActions::isQuickActionSlotActionAvailable(CrossPointSettings::LIBRARY) || QuickActions::actionLabel(CrossPointSettings::LIBRARY) != StrId::STR_LIBRARY) { From 965b4bd21fe78e2920f1465df672df79ab3db012 Mon Sep 17 00:00:00 2001 From: Julia Nguyen Date: Mon, 28 Sep 2026 18:42:53 -0400 Subject: [PATCH 60/62] fix: apply global font setting changes to open books on exit of settings (fixes #772) --- CHANGELOG.md | 1 + src/activities/reader/EpubReaderActivity.cpp | 25 ++++++++++++++------ 2 files changed, 19 insertions(+), 7 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index b4f928bc1d0..853fbf11649 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -53,6 +53,7 @@ - RTL EPUBs use reading-order swipe and tap directions. - Korean text keeps natural syllable spacing when justified and wraps by word. - Footnote choices can be selected directly on the reading page, with a list fallback for links without a visible target. +- Changing global font or page layout settings from the pull-down panel on touch devices now updates the open book when it inherits those settings. ## [v1.6.0] - 2026-09-21 diff --git a/src/activities/reader/EpubReaderActivity.cpp b/src/activities/reader/EpubReaderActivity.cpp index db5eef00b34..7ca7e7331f3 100644 --- a/src/activities/reader/EpubReaderActivity.cpp +++ b/src/activities/reader/EpubReaderActivity.cpp @@ -1193,6 +1193,8 @@ constexpr std::array READER_SETTING_FIELD constexpr uint32_t SD_FONT_FAMILY_OVERRIDE = 1U << READER_SETTING_FIELDS.size(); constexpr uint32_t ALL_READER_SETTING_OVERRIDES = (SD_FONT_FAMILY_OVERRIDE << 1) - 1; constexpr uint32_t READER_FONT_OVERRIDES = (1U << 0) | (1U << 1) | SD_FONT_FAMILY_OVERRIDE; +// Anti-aliasing changes the page drawing, but not its saved line/page layout. +constexpr uint32_t READER_LAYOUT_SETTING_OVERRIDES = ALL_READER_SETTING_OVERRIDES & ~(1U << 11); constexpr uint32_t SAFE_MODE_SETTING_OVERRIDES = (1U << 9) | (1U << 15) | (1U << 16); uint32_t changedReaderSettingsMask(const ReaderSettingsSnapshot& current, const ReaderSettingsSnapshot& global) { @@ -2213,7 +2215,9 @@ bool EpubReaderActivity::beginGlobalSettingsEdit() { } void EpubReaderActivity::endGlobalSettingsEdit() { - if (bookReaderSettingsSuspendedForGlobalEdit) { + uint32_t effectiveChanges = 0; + const bool restoreBookFont = bookReaderSettingsSuspendedForGlobalEdit; + if (restoreBookFont) { // Global Settings is editing SETTINGS while the book-specific reader values // are suspended. Retain every edited reader default before restoring this // book, otherwise the stale snapshot is written back on a later save or @@ -2237,14 +2241,21 @@ void EpubReaderActivity::endGlobalSettingsEdit() { SETTINGS.epubRenderMode = normalizeRenderModeRaw(suspendedBookReaderSettings.epubRenderMode); } captureReaderSettings(initialBookReaderSettings.readerSettings); + effectiveChanges = changedReaderSettingsMask(initialBookReaderSettings.readerSettings, suspendedBookReaderSettings); bookReaderSettingsSuspendedForGlobalEdit = false; } - // Bar edits change the reading viewport even when the book's font settings do not. - // Rebuild the active section immediately so it cannot keep pages laid out for - // the previous top/bottom reservations. - const ReaderViewportLayout layout = computeReaderViewportLayout(renderer, automaticPageTurnActive); - if (section && (layout.viewportWidth != buildViewportWidth || layout.viewportHeight != buildViewportHeight)) { - RenderLock lock(*this); + + RenderLock lock(*this); + // The global font picker may have unloaded this book's SD font even when + // its override kept the effective font setting unchanged. + if (restoreBookFont) ensureReaderSdFontLoaded(renderer); + ReaderUtils::applyOrientation(renderer, SETTINGS.orientation); + const ReaderViewportLayout layout = computeReaderViewportLayout( + renderer, automaticPageTurnActive, activeFootnotePreview || !pendingFootnotePreviewAnchor.empty()); + // Reflow only when this book's effective layout changed. Its per-book fields + // stay protected, while inherited global fields and status-bar space update. + if (section && ((effectiveChanges & READER_LAYOUT_SETTING_OVERRIDES) || layout.viewportWidth != buildViewportWidth || + layout.viewportHeight != buildViewportHeight)) { prepareCurrentSectionForRelayout(); section.reset(); } From b923769a4a016aa0cfe6cf24d51561e25f943084 Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 29 Sep 2026 20:32:37 +0000 Subject: [PATCH 61/62] fix: build and test fixes after upstream 965b4bd merge - SectionPersistenceTest: expect cache version 79 / partial 0xF4, and treat our old 78 / 0xF2 files as stale. - clang-format LibraryActivity after the conflict resolution. Co-Authored-By: Claude Opus 5.5 (1M context) Claude-Session: https://claude.ai/code/session_01XgLRarbAmBVepTPKCK7mgd --- src/activities/library/LibraryActivity.cpp | 2 +- test/section_persistence/SectionPersistenceTest.cpp | 8 ++++---- 2 files changed, 5 insertions(+), 5 deletions(-) diff --git a/src/activities/library/LibraryActivity.cpp b/src/activities/library/LibraryActivity.cpp index 9d1eb4548b5..84e9b4645a2 100644 --- a/src/activities/library/LibraryActivity.cpp +++ b/src/activities/library/LibraryActivity.cpp @@ -23,8 +23,8 @@ #include "activities/home/BookActions.h" #include "activities/home/FileBrowserActionActivity.h" -#include "activities/library/LibraryPrewarm.h" #include "activities/home/RecentBookProgress.h" +#include "activities/library/LibraryPrewarm.h" #include "activities/library/LibrarySettingsActivity.h" #include "activities/reader/BookReadingStats.h" #include "activities/reader/EpubReaderActivity.h" diff --git a/test/section_persistence/SectionPersistenceTest.cpp b/test/section_persistence/SectionPersistenceTest.cpp index 6fa8b5b89cc..eaa1cf885c0 100644 --- a/test/section_persistence/SectionPersistenceTest.cpp +++ b/test/section_persistence/SectionPersistenceTest.cpp @@ -19,10 +19,10 @@ #include namespace { -constexpr uint8_t kFullVersion = 78; -constexpr uint8_t kPartialVersion = 0xF2; -constexpr uint8_t kPreviousFullVersion = 77; -constexpr uint8_t kPreviousPartialVersion = 0xF3; +constexpr uint8_t kFullVersion = 79; +constexpr uint8_t kPartialVersion = 0xF4; +constexpr uint8_t kPreviousFullVersion = 78; +constexpr uint8_t kPreviousPartialVersion = 0xF2; ReaderRenderSpec renderSpec() { ReaderRenderSpec spec; From 9b0c70dda86cbde8dec598fb3c2f50f16d72690c Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 29 Sep 2026 20:56:56 +0000 Subject: [PATCH 62/62] refactor: clear cppcheck knownCondition findings in upstream XTC stats code Co-Authored-By: Claude Opus 5.5 (1M context) Claude-Session: https://claude.ai/code/session_01XgLRarbAmBVepTPKCK7mgd --- src/activities/reader/XtcReaderActivity.cpp | 18 ++++++++---------- 1 file changed, 8 insertions(+), 10 deletions(-) diff --git a/src/activities/reader/XtcReaderActivity.cpp b/src/activities/reader/XtcReaderActivity.cpp index 1d5f2bde787..6ac2c8c9893 100644 --- a/src/activities/reader/XtcReaderActivity.cpp +++ b/src/activities/reader/XtcReaderActivity.cpp @@ -651,7 +651,7 @@ void XtcReaderActivity::syncStatsTrackingState() { if (!xtc) return; const bool active = SETTINGS.shouldTrackReadingStats() && bookStatsEnabled; if (active == statsTrackingActive) return; - if (statsTrackingActive && !active) { + if (statsTrackingActive) { // active differs, so tracking is turning off pendingStatsCommit = true; if (stats.save(xtc->getCachePath())) { globalStats.save(); @@ -887,16 +887,14 @@ std::unique_ptr XtcReaderActivity::createFrontlightReadingStatsActivit if (!xtc || !statsTrackingActive) return {}; BookReadingStats displayStats = stats; - if (statsTrackingActive) { - displayStats.totalReadingSeconds = displayStats.totalReadingSeconds > UINT32_MAX - sessionReadingSeconds + displayStats.totalReadingSeconds = displayStats.totalReadingSeconds > UINT32_MAX - sessionReadingSeconds + ? UINT32_MAX + : displayStats.totalReadingSeconds + sessionReadingSeconds; + uint32_t currentPageSeconds = 0; + if (currentPageReadingSecondsForStats(currentPageSeconds, "frontlight_stats_preview")) { + displayStats.totalReadingSeconds = displayStats.totalReadingSeconds > UINT32_MAX - currentPageSeconds ? UINT32_MAX - : displayStats.totalReadingSeconds + sessionReadingSeconds; - uint32_t currentPageSeconds = 0; - if (currentPageReadingSecondsForStats(currentPageSeconds, "frontlight_stats_preview")) { - displayStats.totalReadingSeconds = displayStats.totalReadingSeconds > UINT32_MAX - currentPageSeconds - ? UINT32_MAX - : displayStats.totalReadingSeconds + currentPageSeconds; - } + : displayStats.totalReadingSeconds + currentPageSeconds; } const bool hasSyncedStats = GlobalReadingStats::hasSyncedStats();