Compare commits
24 Commits
master
...
c1dfe92ea3
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
c1dfe92ea3
|
||
|
|
82bfbd8fa6
|
||
|
|
6aa0b865c2
|
||
|
|
0c71e0b13f
|
||
|
|
ea11d2f7d3
|
||
|
|
31878a77bc
|
||
|
|
21a75c624d
|
||
|
|
8d4bbf284d
|
||
|
|
905f694576
|
||
|
|
e798065a5c
|
||
|
|
5e269f912f
|
||
|
|
182c236050
|
||
|
|
73cd05827a | ||
|
|
ea32ba0f8d | ||
|
|
f7b1113819 | ||
|
|
228a1cb511 | ||
|
|
b72283d304 | ||
|
|
8cf226613b | ||
|
|
d4f25c44bf | ||
|
|
bc12556da1 | ||
|
|
4e7bb8979c | ||
|
|
4edb14bdd9
|
||
|
|
eb79b98f2b | ||
|
|
a85d5e627b
|
5
.gitignore
vendored
5
.gitignore
vendored
@@ -9,3 +9,8 @@ build
|
|||||||
**/__pycache__/
|
**/__pycache__/
|
||||||
/compile_commands.json
|
/compile_commands.json
|
||||||
/.cache
|
/.cache
|
||||||
|
|
||||||
|
# mod
|
||||||
|
mod/*
|
||||||
|
.cursor/*
|
||||||
|
chat-summaries/*
|
||||||
@@ -28,6 +28,7 @@ class PageLine final : public PageElement {
|
|||||||
public:
|
public:
|
||||||
PageLine(std::shared_ptr<TextBlock> block, const int16_t xPos, const int16_t yPos)
|
PageLine(std::shared_ptr<TextBlock> block, const int16_t xPos, const int16_t yPos)
|
||||||
: PageElement(xPos, yPos), block(std::move(block)) {}
|
: PageElement(xPos, yPos), block(std::move(block)) {}
|
||||||
|
const std::shared_ptr<TextBlock>& getBlock() const { return block; }
|
||||||
void render(GfxRenderer& renderer, int fontId, int xOffset, int yOffset) override;
|
void render(GfxRenderer& renderer, int fontId, int xOffset, int yOffset) override;
|
||||||
bool serialize(FsFile& file) override;
|
bool serialize(FsFile& file) override;
|
||||||
static std::unique_ptr<PageLine> deserialize(FsFile& file);
|
static std::unique_ptr<PageLine> deserialize(FsFile& file);
|
||||||
|
|||||||
@@ -5,7 +5,6 @@
|
|||||||
#include <algorithm>
|
#include <algorithm>
|
||||||
#include <cmath>
|
#include <cmath>
|
||||||
#include <functional>
|
#include <functional>
|
||||||
#include <iterator>
|
|
||||||
#include <limits>
|
#include <limits>
|
||||||
#include <vector>
|
#include <vector>
|
||||||
|
|
||||||
@@ -80,37 +79,26 @@ void ParsedText::layoutAndExtractLines(const GfxRenderer& renderer, const int fo
|
|||||||
const int spaceWidth = renderer.getSpaceWidth(fontId);
|
const int spaceWidth = renderer.getSpaceWidth(fontId);
|
||||||
auto wordWidths = calculateWordWidths(renderer, fontId);
|
auto wordWidths = calculateWordWidths(renderer, fontId);
|
||||||
|
|
||||||
// Build indexed continues vector from the parallel list for O(1) access during layout
|
|
||||||
std::vector<bool> continuesVec(wordContinues.begin(), wordContinues.end());
|
|
||||||
|
|
||||||
std::vector<size_t> lineBreakIndices;
|
std::vector<size_t> lineBreakIndices;
|
||||||
if (hyphenationEnabled) {
|
if (hyphenationEnabled) {
|
||||||
// Use greedy layout that can split words mid-loop when a hyphenated prefix fits.
|
// Use greedy layout that can split words mid-loop when a hyphenated prefix fits.
|
||||||
lineBreakIndices = computeHyphenatedLineBreaks(renderer, fontId, pageWidth, spaceWidth, wordWidths, continuesVec);
|
lineBreakIndices = computeHyphenatedLineBreaks(renderer, fontId, pageWidth, spaceWidth, wordWidths, wordContinues);
|
||||||
} else {
|
} else {
|
||||||
lineBreakIndices = computeLineBreaks(renderer, fontId, pageWidth, spaceWidth, wordWidths, continuesVec);
|
lineBreakIndices = computeLineBreaks(renderer, fontId, pageWidth, spaceWidth, wordWidths, wordContinues);
|
||||||
}
|
}
|
||||||
const size_t lineCount = includeLastLine ? lineBreakIndices.size() : lineBreakIndices.size() - 1;
|
const size_t lineCount = includeLastLine ? lineBreakIndices.size() : lineBreakIndices.size() - 1;
|
||||||
|
|
||||||
for (size_t i = 0; i < lineCount; ++i) {
|
for (size_t i = 0; i < lineCount; ++i) {
|
||||||
extractLine(i, pageWidth, spaceWidth, wordWidths, continuesVec, lineBreakIndices, processLine);
|
extractLine(i, pageWidth, spaceWidth, wordWidths, wordContinues, lineBreakIndices, processLine);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
std::vector<uint16_t> ParsedText::calculateWordWidths(const GfxRenderer& renderer, const int fontId) {
|
std::vector<uint16_t> ParsedText::calculateWordWidths(const GfxRenderer& renderer, const int fontId) {
|
||||||
const size_t totalWordCount = words.size();
|
|
||||||
|
|
||||||
std::vector<uint16_t> wordWidths;
|
std::vector<uint16_t> wordWidths;
|
||||||
wordWidths.reserve(totalWordCount);
|
wordWidths.reserve(words.size());
|
||||||
|
|
||||||
auto wordsIt = words.begin();
|
for (size_t i = 0; i < words.size(); ++i) {
|
||||||
auto wordStylesIt = wordStyles.begin();
|
wordWidths.push_back(measureWordWidth(renderer, fontId, words[i], wordStyles[i]));
|
||||||
|
|
||||||
while (wordsIt != words.end()) {
|
|
||||||
wordWidths.push_back(measureWordWidth(renderer, fontId, *wordsIt, *wordStylesIt));
|
|
||||||
|
|
||||||
std::advance(wordsIt, 1);
|
|
||||||
std::advance(wordStylesIt, 1);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
return wordWidths;
|
return wordWidths;
|
||||||
@@ -135,8 +123,7 @@ std::vector<size_t> ParsedText::computeLineBreaks(const GfxRenderer& renderer, c
|
|||||||
// First word needs to fit in reduced width if there's an indent
|
// First word needs to fit in reduced width if there's an indent
|
||||||
const int effectiveWidth = i == 0 ? pageWidth - firstLineIndent : pageWidth;
|
const int effectiveWidth = i == 0 ? pageWidth - firstLineIndent : pageWidth;
|
||||||
while (wordWidths[i] > effectiveWidth) {
|
while (wordWidths[i] > effectiveWidth) {
|
||||||
if (!hyphenateWordAtIndex(i, effectiveWidth, renderer, fontId, wordWidths, /*allowFallbackBreaks=*/true,
|
if (!hyphenateWordAtIndex(i, effectiveWidth, renderer, fontId, wordWidths, /*allowFallbackBreaks=*/true)) {
|
||||||
&continuesVec)) {
|
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -282,8 +269,8 @@ std::vector<size_t> ParsedText::computeHyphenatedLineBreaks(const GfxRenderer& r
|
|||||||
const int availableWidth = effectivePageWidth - lineWidth - spacing;
|
const int availableWidth = effectivePageWidth - lineWidth - spacing;
|
||||||
const bool allowFallbackBreaks = isFirstWord; // Only for first word on line
|
const bool allowFallbackBreaks = isFirstWord; // Only for first word on line
|
||||||
|
|
||||||
if (availableWidth > 0 && hyphenateWordAtIndex(currentIndex, availableWidth, renderer, fontId, wordWidths,
|
if (availableWidth > 0 &&
|
||||||
allowFallbackBreaks, &continuesVec)) {
|
hyphenateWordAtIndex(currentIndex, availableWidth, renderer, fontId, wordWidths, allowFallbackBreaks)) {
|
||||||
// Prefix now fits; append it to this line and move to next line
|
// Prefix now fits; append it to this line and move to next line
|
||||||
lineWidth += spacing + wordWidths[currentIndex];
|
lineWidth += spacing + wordWidths[currentIndex];
|
||||||
++currentIndex;
|
++currentIndex;
|
||||||
@@ -315,20 +302,14 @@ std::vector<size_t> ParsedText::computeHyphenatedLineBreaks(const GfxRenderer& r
|
|||||||
// available width.
|
// available width.
|
||||||
bool ParsedText::hyphenateWordAtIndex(const size_t wordIndex, const int availableWidth, const GfxRenderer& renderer,
|
bool ParsedText::hyphenateWordAtIndex(const size_t wordIndex, const int availableWidth, const GfxRenderer& renderer,
|
||||||
const int fontId, std::vector<uint16_t>& wordWidths,
|
const int fontId, std::vector<uint16_t>& wordWidths,
|
||||||
const bool allowFallbackBreaks, std::vector<bool>* continuesVec) {
|
const bool allowFallbackBreaks) {
|
||||||
// Guard against invalid indices or zero available width before attempting to split.
|
// Guard against invalid indices or zero available width before attempting to split.
|
||||||
if (availableWidth <= 0 || wordIndex >= words.size()) {
|
if (availableWidth <= 0 || wordIndex >= words.size()) {
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Get iterators to target word and style.
|
const std::string& word = words[wordIndex];
|
||||||
auto wordIt = words.begin();
|
const auto style = wordStyles[wordIndex];
|
||||||
auto styleIt = wordStyles.begin();
|
|
||||||
std::advance(wordIt, wordIndex);
|
|
||||||
std::advance(styleIt, wordIndex);
|
|
||||||
|
|
||||||
const std::string& word = *wordIt;
|
|
||||||
const auto style = *styleIt;
|
|
||||||
|
|
||||||
// Collect candidate breakpoints (byte offsets and hyphen requirements).
|
// Collect candidate breakpoints (byte offsets and hyphen requirements).
|
||||||
auto breakInfos = Hyphenator::breakOffsets(word, allowFallbackBreaks);
|
auto breakInfos = Hyphenator::breakOffsets(word, allowFallbackBreaks);
|
||||||
@@ -365,32 +346,20 @@ bool ParsedText::hyphenateWordAtIndex(const size_t wordIndex, const int availabl
|
|||||||
|
|
||||||
// Split the word at the selected breakpoint and append a hyphen if required.
|
// Split the word at the selected breakpoint and append a hyphen if required.
|
||||||
std::string remainder = word.substr(chosenOffset);
|
std::string remainder = word.substr(chosenOffset);
|
||||||
wordIt->resize(chosenOffset);
|
words[wordIndex].resize(chosenOffset);
|
||||||
if (chosenNeedsHyphen) {
|
if (chosenNeedsHyphen) {
|
||||||
wordIt->push_back('-');
|
words[wordIndex].push_back('-');
|
||||||
}
|
}
|
||||||
|
|
||||||
// Insert the remainder word (with matching style and continuation flag) directly after the prefix.
|
// Insert the remainder word (with matching style and continuation flag) directly after the prefix.
|
||||||
auto insertWordIt = std::next(wordIt);
|
words.insert(words.begin() + wordIndex + 1, remainder);
|
||||||
auto insertStyleIt = std::next(styleIt);
|
wordStyles.insert(wordStyles.begin() + wordIndex + 1, style);
|
||||||
words.insert(insertWordIt, remainder);
|
|
||||||
wordStyles.insert(insertStyleIt, style);
|
|
||||||
|
|
||||||
// The remainder inherits whatever continuation status the original word had with the word after it.
|
// The remainder inherits whatever continuation status the original word had with the word after it.
|
||||||
// Find the continues entry for the original word and insert the remainder's entry after it.
|
const bool originalContinuedToNext = wordContinues[wordIndex];
|
||||||
auto continuesIt = wordContinues.begin();
|
|
||||||
std::advance(continuesIt, wordIndex);
|
|
||||||
const bool originalContinuedToNext = *continuesIt;
|
|
||||||
// The original word (now prefix) does NOT continue to remainder (hyphen separates them)
|
// The original word (now prefix) does NOT continue to remainder (hyphen separates them)
|
||||||
*continuesIt = false;
|
wordContinues[wordIndex] = false;
|
||||||
const auto insertContinuesIt = std::next(continuesIt);
|
wordContinues.insert(wordContinues.begin() + wordIndex + 1, originalContinuedToNext);
|
||||||
wordContinues.insert(insertContinuesIt, originalContinuedToNext);
|
|
||||||
|
|
||||||
// Keep the indexed vector in sync if provided
|
|
||||||
if (continuesVec) {
|
|
||||||
(*continuesVec)[wordIndex] = false;
|
|
||||||
continuesVec->insert(continuesVec->begin() + wordIndex + 1, originalContinuedToNext);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Update cached widths to reflect the new prefix/remainder pairing.
|
// Update cached widths to reflect the new prefix/remainder pairing.
|
||||||
wordWidths[wordIndex] = static_cast<uint16_t>(chosenWidth);
|
wordWidths[wordIndex] = static_cast<uint16_t>(chosenWidth);
|
||||||
@@ -450,7 +419,8 @@ void ParsedText::extractLine(const size_t breakIndex, const int pageWidth, const
|
|||||||
|
|
||||||
// Pre-calculate X positions for words
|
// Pre-calculate X positions for words
|
||||||
// Continuation words attach to the previous word with no space before them
|
// Continuation words attach to the previous word with no space before them
|
||||||
std::list<uint16_t> lineXPos;
|
std::vector<uint16_t> lineXPos;
|
||||||
|
lineXPos.reserve(lineWordCount);
|
||||||
|
|
||||||
for (size_t wordIdx = 0; wordIdx < lineWordCount; wordIdx++) {
|
for (size_t wordIdx = 0; wordIdx < lineWordCount; wordIdx++) {
|
||||||
const uint16_t currentWordWidth = wordWidths[lastBreakAt + wordIdx];
|
const uint16_t currentWordWidth = wordWidths[lastBreakAt + wordIdx];
|
||||||
@@ -463,23 +433,10 @@ void ParsedText::extractLine(const size_t breakIndex, const int pageWidth, const
|
|||||||
xpos += currentWordWidth + (nextIsContinuation ? 0 : spacing);
|
xpos += currentWordWidth + (nextIsContinuation ? 0 : spacing);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Iterators always start at the beginning as we are moving content with splice below
|
// Build line data by moving from the original vectors using index range
|
||||||
auto wordEndIt = words.begin();
|
std::vector<std::string> lineWords(std::make_move_iterator(words.begin() + lastBreakAt),
|
||||||
auto wordStyleEndIt = wordStyles.begin();
|
std::make_move_iterator(words.begin() + lineBreak));
|
||||||
auto wordContinuesEndIt = wordContinues.begin();
|
std::vector<EpdFontFamily::Style> lineWordStyles(wordStyles.begin() + lastBreakAt, wordStyles.begin() + lineBreak);
|
||||||
std::advance(wordEndIt, lineWordCount);
|
|
||||||
std::advance(wordStyleEndIt, lineWordCount);
|
|
||||||
std::advance(wordContinuesEndIt, lineWordCount);
|
|
||||||
|
|
||||||
// *** CRITICAL STEP: CONSUME DATA USING SPLICE ***
|
|
||||||
std::list<std::string> lineWords;
|
|
||||||
lineWords.splice(lineWords.begin(), words, words.begin(), wordEndIt);
|
|
||||||
std::list<EpdFontFamily::Style> lineWordStyles;
|
|
||||||
lineWordStyles.splice(lineWordStyles.begin(), wordStyles, wordStyles.begin(), wordStyleEndIt);
|
|
||||||
|
|
||||||
// Consume continues flags (not passed to TextBlock, but must be consumed to stay in sync)
|
|
||||||
std::list<bool> lineContinues;
|
|
||||||
lineContinues.splice(lineContinues.begin(), wordContinues, wordContinues.begin(), wordContinuesEndIt);
|
|
||||||
|
|
||||||
for (auto& word : lineWords) {
|
for (auto& word : lineWords) {
|
||||||
if (containsSoftHyphen(word)) {
|
if (containsSoftHyphen(word)) {
|
||||||
|
|||||||
@@ -3,7 +3,6 @@
|
|||||||
#include <EpdFontFamily.h>
|
#include <EpdFontFamily.h>
|
||||||
|
|
||||||
#include <functional>
|
#include <functional>
|
||||||
#include <list>
|
|
||||||
#include <memory>
|
#include <memory>
|
||||||
#include <string>
|
#include <string>
|
||||||
#include <vector>
|
#include <vector>
|
||||||
@@ -14,9 +13,9 @@
|
|||||||
class GfxRenderer;
|
class GfxRenderer;
|
||||||
|
|
||||||
class ParsedText {
|
class ParsedText {
|
||||||
std::list<std::string> words;
|
std::vector<std::string> words;
|
||||||
std::list<EpdFontFamily::Style> wordStyles;
|
std::vector<EpdFontFamily::Style> wordStyles;
|
||||||
std::list<bool> wordContinues; // true = word attaches to previous (no space before it)
|
std::vector<bool> wordContinues; // true = word attaches to previous (no space before it)
|
||||||
BlockStyle blockStyle;
|
BlockStyle blockStyle;
|
||||||
bool extraParagraphSpacing;
|
bool extraParagraphSpacing;
|
||||||
bool hyphenationEnabled;
|
bool hyphenationEnabled;
|
||||||
@@ -28,8 +27,7 @@ class ParsedText {
|
|||||||
int spaceWidth, std::vector<uint16_t>& wordWidths,
|
int spaceWidth, std::vector<uint16_t>& wordWidths,
|
||||||
std::vector<bool>& continuesVec);
|
std::vector<bool>& continuesVec);
|
||||||
bool hyphenateWordAtIndex(size_t wordIndex, int availableWidth, const GfxRenderer& renderer, int fontId,
|
bool hyphenateWordAtIndex(size_t wordIndex, int availableWidth, const GfxRenderer& renderer, int fontId,
|
||||||
std::vector<uint16_t>& wordWidths, bool allowFallbackBreaks,
|
std::vector<uint16_t>& wordWidths, bool allowFallbackBreaks);
|
||||||
std::vector<bool>* continuesVec = nullptr);
|
|
||||||
void extractLine(size_t breakIndex, int pageWidth, int spaceWidth, const std::vector<uint16_t>& wordWidths,
|
void extractLine(size_t breakIndex, int pageWidth, int spaceWidth, const std::vector<uint16_t>& wordWidths,
|
||||||
const std::vector<bool>& continuesVec, const std::vector<size_t>& lineBreakIndices,
|
const std::vector<bool>& continuesVec, const std::vector<size_t>& lineBreakIndices,
|
||||||
const std::function<void(std::shared_ptr<TextBlock>)>& processLine);
|
const std::function<void(std::shared_ptr<TextBlock>)>& processLine);
|
||||||
|
|||||||
@@ -12,16 +12,13 @@ void TextBlock::render(const GfxRenderer& renderer, const int fontId, const int
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
auto wordIt = words.begin();
|
|
||||||
auto wordStylesIt = wordStyles.begin();
|
|
||||||
auto wordXposIt = wordXpos.begin();
|
|
||||||
for (size_t i = 0; i < words.size(); i++) {
|
for (size_t i = 0; i < words.size(); i++) {
|
||||||
const int wordX = *wordXposIt + x;
|
const int wordX = wordXpos[i] + x;
|
||||||
const EpdFontFamily::Style currentStyle = *wordStylesIt;
|
const EpdFontFamily::Style currentStyle = wordStyles[i];
|
||||||
renderer.drawText(fontId, wordX, y, wordIt->c_str(), true, currentStyle);
|
renderer.drawText(fontId, wordX, y, words[i].c_str(), true, currentStyle);
|
||||||
|
|
||||||
if ((currentStyle & EpdFontFamily::UNDERLINE) != 0) {
|
if ((currentStyle & EpdFontFamily::UNDERLINE) != 0) {
|
||||||
const std::string& w = *wordIt;
|
const std::string& w = words[i];
|
||||||
const int fullWordWidth = renderer.getTextWidth(fontId, w.c_str(), currentStyle);
|
const int fullWordWidth = renderer.getTextWidth(fontId, w.c_str(), currentStyle);
|
||||||
// y is the top of the text line; add ascender to reach baseline, then offset 2px below
|
// y is the top of the text line; add ascender to reach baseline, then offset 2px below
|
||||||
const int underlineY = y + renderer.getFontAscenderSize(fontId) + 2;
|
const int underlineY = y + renderer.getFontAscenderSize(fontId) + 2;
|
||||||
@@ -41,10 +38,6 @@ void TextBlock::render(const GfxRenderer& renderer, const int fontId, const int
|
|||||||
|
|
||||||
renderer.drawLine(startX, underlineY, startX + underlineWidth, underlineY, true);
|
renderer.drawLine(startX, underlineY, startX + underlineWidth, underlineY, true);
|
||||||
}
|
}
|
||||||
|
|
||||||
std::advance(wordIt, 1);
|
|
||||||
std::advance(wordStylesIt, 1);
|
|
||||||
std::advance(wordXposIt, 1);
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -80,15 +73,15 @@ bool TextBlock::serialize(FsFile& file) const {
|
|||||||
|
|
||||||
std::unique_ptr<TextBlock> TextBlock::deserialize(FsFile& file) {
|
std::unique_ptr<TextBlock> TextBlock::deserialize(FsFile& file) {
|
||||||
uint16_t wc;
|
uint16_t wc;
|
||||||
std::list<std::string> words;
|
std::vector<std::string> words;
|
||||||
std::list<uint16_t> wordXpos;
|
std::vector<uint16_t> wordXpos;
|
||||||
std::list<EpdFontFamily::Style> wordStyles;
|
std::vector<EpdFontFamily::Style> wordStyles;
|
||||||
BlockStyle blockStyle;
|
BlockStyle blockStyle;
|
||||||
|
|
||||||
// Word count
|
// Word count
|
||||||
serialization::readPod(file, wc);
|
serialization::readPod(file, wc);
|
||||||
|
|
||||||
// Sanity check: prevent allocation of unreasonably large lists (max 10000 words per block)
|
// Sanity check: prevent allocation of unreasonably large vectors (max 10000 words per block)
|
||||||
if (wc > 10000) {
|
if (wc > 10000) {
|
||||||
LOG_ERR("TXB", "Deserialization failed: word count %u exceeds maximum", wc);
|
LOG_ERR("TXB", "Deserialization failed: word count %u exceeds maximum", wc);
|
||||||
return nullptr;
|
return nullptr;
|
||||||
|
|||||||
@@ -2,9 +2,9 @@
|
|||||||
#include <EpdFontFamily.h>
|
#include <EpdFontFamily.h>
|
||||||
#include <HalStorage.h>
|
#include <HalStorage.h>
|
||||||
|
|
||||||
#include <list>
|
|
||||||
#include <memory>
|
#include <memory>
|
||||||
#include <string>
|
#include <string>
|
||||||
|
#include <vector>
|
||||||
|
|
||||||
#include "Block.h"
|
#include "Block.h"
|
||||||
#include "BlockStyle.h"
|
#include "BlockStyle.h"
|
||||||
@@ -12,14 +12,14 @@
|
|||||||
// Represents a line of text on a page
|
// Represents a line of text on a page
|
||||||
class TextBlock final : public Block {
|
class TextBlock final : public Block {
|
||||||
private:
|
private:
|
||||||
std::list<std::string> words;
|
std::vector<std::string> words;
|
||||||
std::list<uint16_t> wordXpos;
|
std::vector<uint16_t> wordXpos;
|
||||||
std::list<EpdFontFamily::Style> wordStyles;
|
std::vector<EpdFontFamily::Style> wordStyles;
|
||||||
BlockStyle blockStyle;
|
BlockStyle blockStyle;
|
||||||
|
|
||||||
public:
|
public:
|
||||||
explicit TextBlock(std::list<std::string> words, std::list<uint16_t> word_xpos,
|
explicit TextBlock(std::vector<std::string> words, std::vector<uint16_t> word_xpos,
|
||||||
std::list<EpdFontFamily::Style> word_styles, const BlockStyle& blockStyle = BlockStyle())
|
std::vector<EpdFontFamily::Style> word_styles, const BlockStyle& blockStyle = BlockStyle())
|
||||||
: words(std::move(words)),
|
: words(std::move(words)),
|
||||||
wordXpos(std::move(word_xpos)),
|
wordXpos(std::move(word_xpos)),
|
||||||
wordStyles(std::move(word_styles)),
|
wordStyles(std::move(word_styles)),
|
||||||
@@ -27,6 +27,9 @@ class TextBlock final : public Block {
|
|||||||
~TextBlock() override = default;
|
~TextBlock() override = default;
|
||||||
void setBlockStyle(const BlockStyle& blockStyle) { this->blockStyle = blockStyle; }
|
void setBlockStyle(const BlockStyle& blockStyle) { this->blockStyle = blockStyle; }
|
||||||
const BlockStyle& getBlockStyle() const { return blockStyle; }
|
const BlockStyle& getBlockStyle() const { return blockStyle; }
|
||||||
|
const std::vector<std::string>& getWords() const { return words; }
|
||||||
|
const std::vector<uint16_t>& getWordXpos() const { return wordXpos; }
|
||||||
|
const std::vector<EpdFontFamily::Style>& getWordStyles() const { return wordStyles; }
|
||||||
bool isEmpty() override { return words.empty(); }
|
bool isEmpty() override { return words.empty(); }
|
||||||
void layout(GfxRenderer& renderer) override {};
|
void layout(GfxRenderer& renderer) override {};
|
||||||
// given a renderer works out where to break the words into lines
|
// given a renderer works out where to break the words into lines
|
||||||
|
|||||||
@@ -104,3 +104,20 @@ uint8_t quantize1bit(int gray, int x, int y) {
|
|||||||
const int adjustedThreshold = 128 + ((threshold - 128) / 2); // Range: 64-192
|
const int adjustedThreshold = 128 + ((threshold - 128) / 2); // Range: 64-192
|
||||||
return (gray >= adjustedThreshold) ? 1 : 0;
|
return (gray >= adjustedThreshold) ? 1 : 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Noise dithering for gradient fills - always uses hash-based noise regardless of global dithering config.
|
||||||
|
// Produces smooth-looking gradients on the 4-level e-ink display.
|
||||||
|
uint8_t quantizeNoiseDither(int gray, int x, int y) {
|
||||||
|
uint32_t hash = static_cast<uint32_t>(x) * 374761393u + static_cast<uint32_t>(y) * 668265263u;
|
||||||
|
hash = (hash ^ (hash >> 13)) * 1274126177u;
|
||||||
|
const int threshold = static_cast<int>(hash >> 24);
|
||||||
|
|
||||||
|
const int scaled = gray * 3;
|
||||||
|
if (scaled < 255) {
|
||||||
|
return (scaled + threshold >= 255) ? 1 : 0;
|
||||||
|
} else if (scaled < 510) {
|
||||||
|
return ((scaled - 255) + threshold >= 255) ? 2 : 1;
|
||||||
|
} else {
|
||||||
|
return ((scaled - 510) + threshold >= 255) ? 3 : 2;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -7,6 +7,7 @@ uint8_t quantize(int gray, int x, int y);
|
|||||||
uint8_t quantizeSimple(int gray);
|
uint8_t quantizeSimple(int gray);
|
||||||
uint8_t quantize1bit(int gray, int x, int y);
|
uint8_t quantize1bit(int gray, int x, int y);
|
||||||
int adjustPixel(int gray);
|
int adjustPixel(int gray);
|
||||||
|
uint8_t quantizeNoiseDither(int gray, int x, int y);
|
||||||
|
|
||||||
// 1-bit Atkinson dithering - better quality than noise dithering for thumbnails
|
// 1-bit Atkinson dithering - better quality than noise dithering for thumbnails
|
||||||
// Error distribution pattern (same as 2-bit but quantizes to 2 levels):
|
// Error distribution pattern (same as 2-bit but quantizes to 2 levels):
|
||||||
|
|||||||
@@ -73,6 +73,16 @@ void GfxRenderer::drawPixel(const int x, const int y, const bool state) const {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
void GfxRenderer::drawPixelGray(const int x, const int y, const uint8_t val2bit) const {
|
||||||
|
if (renderMode == BW && val2bit < 3) {
|
||||||
|
drawPixel(x, y);
|
||||||
|
} else if (renderMode == GRAYSCALE_MSB && (val2bit == 1 || val2bit == 2)) {
|
||||||
|
drawPixel(x, y, false);
|
||||||
|
} else if (renderMode == GRAYSCALE_LSB && val2bit == 1) {
|
||||||
|
drawPixel(x, y, false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
int GfxRenderer::getTextWidth(const int fontId, const char* text, const EpdFontFamily::Style style) const {
|
int GfxRenderer::getTextWidth(const int fontId, const char* text, const EpdFontFamily::Style style) const {
|
||||||
if (fontMap.count(fontId) == 0) {
|
if (fontMap.count(fontId) == 0) {
|
||||||
LOG_ERR("GFX", "Font %d not found", fontId);
|
LOG_ERR("GFX", "Font %d not found", fontId);
|
||||||
@@ -423,12 +433,20 @@ void GfxRenderer::drawBitmap(const Bitmap& bitmap, const int x, const int y, con
|
|||||||
LOG_DBG("GFX", "Cropping %dx%d by %dx%d pix, is %s", bitmap.getWidth(), bitmap.getHeight(), cropPixX, cropPixY,
|
LOG_DBG("GFX", "Cropping %dx%d by %dx%d pix, is %s", bitmap.getWidth(), bitmap.getHeight(), cropPixX, cropPixY,
|
||||||
bitmap.isTopDown() ? "top-down" : "bottom-up");
|
bitmap.isTopDown() ? "top-down" : "bottom-up");
|
||||||
|
|
||||||
if (maxWidth > 0 && (1.0f - cropX) * bitmap.getWidth() > maxWidth) {
|
const float effectiveWidth = (1.0f - cropX) * bitmap.getWidth();
|
||||||
scale = static_cast<float>(maxWidth) / static_cast<float>((1.0f - cropX) * bitmap.getWidth());
|
const float effectiveHeight = (1.0f - cropY) * bitmap.getHeight();
|
||||||
|
|
||||||
|
// Calculate scale factor: supports both downscaling and upscaling when both constraints are provided
|
||||||
|
if (maxWidth > 0 && maxHeight > 0) {
|
||||||
|
const float scaleX = static_cast<float>(maxWidth) / effectiveWidth;
|
||||||
|
const float scaleY = static_cast<float>(maxHeight) / effectiveHeight;
|
||||||
|
scale = std::min(scaleX, scaleY);
|
||||||
|
isScaled = (scale < 0.999f || scale > 1.001f);
|
||||||
|
} else if (maxWidth > 0 && effectiveWidth > static_cast<float>(maxWidth)) {
|
||||||
|
scale = static_cast<float>(maxWidth) / effectiveWidth;
|
||||||
isScaled = true;
|
isScaled = true;
|
||||||
}
|
} else if (maxHeight > 0 && effectiveHeight > static_cast<float>(maxHeight)) {
|
||||||
if (maxHeight > 0 && (1.0f - cropY) * bitmap.getHeight() > maxHeight) {
|
scale = static_cast<float>(maxHeight) / effectiveHeight;
|
||||||
scale = std::min(scale, static_cast<float>(maxHeight) / static_cast<float>((1.0f - cropY) * bitmap.getHeight()));
|
|
||||||
isScaled = true;
|
isScaled = true;
|
||||||
}
|
}
|
||||||
LOG_DBG("GFX", "Scaling by %f - %s", scale, isScaled ? "scaled" : "not scaled");
|
LOG_DBG("GFX", "Scaling by %f - %s", scale, isScaled ? "scaled" : "not scaled");
|
||||||
@@ -449,12 +467,17 @@ void GfxRenderer::drawBitmap(const Bitmap& bitmap, const int x, const int y, con
|
|||||||
for (int bmpY = 0; bmpY < (bitmap.getHeight() - cropPixY); bmpY++) {
|
for (int bmpY = 0; bmpY < (bitmap.getHeight() - cropPixY); bmpY++) {
|
||||||
// The BMP's (0, 0) is the bottom-left corner (if the height is positive, top-left if negative).
|
// The BMP's (0, 0) is the bottom-left corner (if the height is positive, top-left if negative).
|
||||||
// Screen's (0, 0) is the top-left corner.
|
// Screen's (0, 0) is the top-left corner.
|
||||||
int screenY = -cropPixY + (bitmap.isTopDown() ? bmpY : bitmap.getHeight() - 1 - bmpY);
|
const int logicalY = -cropPixY + (bitmap.isTopDown() ? bmpY : bitmap.getHeight() - 1 - bmpY);
|
||||||
|
int screenYStart, screenYEnd;
|
||||||
if (isScaled) {
|
if (isScaled) {
|
||||||
screenY = std::floor(screenY * scale);
|
screenYStart = static_cast<int>(std::floor(logicalY * scale)) + y;
|
||||||
|
screenYEnd = static_cast<int>(std::floor((logicalY + 1) * scale)) + y;
|
||||||
|
} else {
|
||||||
|
screenYStart = logicalY + y;
|
||||||
|
screenYEnd = screenYStart + 1;
|
||||||
}
|
}
|
||||||
screenY += y; // the offset should not be scaled
|
|
||||||
if (screenY >= getScreenHeight()) {
|
if (screenYStart >= getScreenHeight()) {
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -465,7 +488,7 @@ void GfxRenderer::drawBitmap(const Bitmap& bitmap, const int x, const int y, con
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (screenY < 0) {
|
if (screenYEnd <= 0) {
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -474,27 +497,42 @@ void GfxRenderer::drawBitmap(const Bitmap& bitmap, const int x, const int y, con
|
|||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const int syStart = std::max(screenYStart, 0);
|
||||||
|
const int syEnd = std::min(screenYEnd, getScreenHeight());
|
||||||
|
|
||||||
for (int bmpX = cropPixX; bmpX < bitmap.getWidth() - cropPixX; bmpX++) {
|
for (int bmpX = cropPixX; bmpX < bitmap.getWidth() - cropPixX; bmpX++) {
|
||||||
int screenX = bmpX - cropPixX;
|
const int outX = bmpX - cropPixX;
|
||||||
|
int screenXStart, screenXEnd;
|
||||||
if (isScaled) {
|
if (isScaled) {
|
||||||
screenX = std::floor(screenX * scale);
|
screenXStart = static_cast<int>(std::floor(outX * scale)) + x;
|
||||||
|
screenXEnd = static_cast<int>(std::floor((outX + 1) * scale)) + x;
|
||||||
|
} else {
|
||||||
|
screenXStart = outX + x;
|
||||||
|
screenXEnd = screenXStart + 1;
|
||||||
}
|
}
|
||||||
screenX += x; // the offset should not be scaled
|
|
||||||
if (screenX >= getScreenWidth()) {
|
if (screenXStart >= getScreenWidth()) {
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
if (screenX < 0) {
|
if (screenXEnd <= 0) {
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
|
|
||||||
const uint8_t val = outputRow[bmpX / 4] >> (6 - ((bmpX * 2) % 8)) & 0x3;
|
const uint8_t val = outputRow[bmpX / 4] >> (6 - ((bmpX * 2) % 8)) & 0x3;
|
||||||
|
|
||||||
if (renderMode == BW && val < 3) {
|
const int sxStart = std::max(screenXStart, 0);
|
||||||
drawPixel(screenX, screenY);
|
const int sxEnd = std::min(screenXEnd, getScreenWidth());
|
||||||
} else if (renderMode == GRAYSCALE_MSB && (val == 1 || val == 2)) {
|
|
||||||
drawPixel(screenX, screenY, false);
|
for (int sy = syStart; sy < syEnd; sy++) {
|
||||||
} else if (renderMode == GRAYSCALE_LSB && val == 1) {
|
for (int sx = sxStart; sx < sxEnd; sx++) {
|
||||||
drawPixel(screenX, screenY, false);
|
if (renderMode == BW && val < 3) {
|
||||||
|
drawPixel(sx, sy);
|
||||||
|
} else if (renderMode == GRAYSCALE_MSB && (val == 1 || val == 2)) {
|
||||||
|
drawPixel(sx, sy, false);
|
||||||
|
} else if (renderMode == GRAYSCALE_LSB && val == 1) {
|
||||||
|
drawPixel(sx, sy, false);
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -507,11 +545,16 @@ void GfxRenderer::drawBitmap1Bit(const Bitmap& bitmap, const int x, const int y,
|
|||||||
const int maxHeight) const {
|
const int maxHeight) const {
|
||||||
float scale = 1.0f;
|
float scale = 1.0f;
|
||||||
bool isScaled = false;
|
bool isScaled = false;
|
||||||
if (maxWidth > 0 && bitmap.getWidth() > maxWidth) {
|
// Calculate scale factor: supports both downscaling and upscaling when both constraints are provided
|
||||||
|
if (maxWidth > 0 && maxHeight > 0) {
|
||||||
|
const float scaleX = static_cast<float>(maxWidth) / static_cast<float>(bitmap.getWidth());
|
||||||
|
const float scaleY = static_cast<float>(maxHeight) / static_cast<float>(bitmap.getHeight());
|
||||||
|
scale = std::min(scaleX, scaleY);
|
||||||
|
isScaled = (scale < 0.999f || scale > 1.001f);
|
||||||
|
} else if (maxWidth > 0 && bitmap.getWidth() > maxWidth) {
|
||||||
scale = static_cast<float>(maxWidth) / static_cast<float>(bitmap.getWidth());
|
scale = static_cast<float>(maxWidth) / static_cast<float>(bitmap.getWidth());
|
||||||
isScaled = true;
|
isScaled = true;
|
||||||
}
|
} else if (maxHeight > 0 && bitmap.getHeight() > maxHeight) {
|
||||||
if (maxHeight > 0 && bitmap.getHeight() > maxHeight) {
|
|
||||||
scale = std::min(scale, static_cast<float>(maxHeight) / static_cast<float>(bitmap.getHeight()));
|
scale = std::min(scale, static_cast<float>(maxHeight) / static_cast<float>(bitmap.getHeight()));
|
||||||
isScaled = true;
|
isScaled = true;
|
||||||
}
|
}
|
||||||
@@ -539,20 +582,37 @@ void GfxRenderer::drawBitmap1Bit(const Bitmap& bitmap, const int x, const int y,
|
|||||||
|
|
||||||
// Calculate screen Y based on whether BMP is top-down or bottom-up
|
// Calculate screen Y based on whether BMP is top-down or bottom-up
|
||||||
const int bmpYOffset = bitmap.isTopDown() ? bmpY : bitmap.getHeight() - 1 - bmpY;
|
const int bmpYOffset = bitmap.isTopDown() ? bmpY : bitmap.getHeight() - 1 - bmpY;
|
||||||
int screenY = y + (isScaled ? static_cast<int>(std::floor(bmpYOffset * scale)) : bmpYOffset);
|
int screenYStart, screenYEnd;
|
||||||
if (screenY >= getScreenHeight()) {
|
if (isScaled) {
|
||||||
|
screenYStart = static_cast<int>(std::floor(bmpYOffset * scale)) + y;
|
||||||
|
screenYEnd = static_cast<int>(std::floor((bmpYOffset + 1) * scale)) + y;
|
||||||
|
} else {
|
||||||
|
screenYStart = bmpYOffset + y;
|
||||||
|
screenYEnd = screenYStart + 1;
|
||||||
|
}
|
||||||
|
if (screenYStart >= getScreenHeight()) {
|
||||||
continue; // Continue reading to keep row counter in sync
|
continue; // Continue reading to keep row counter in sync
|
||||||
}
|
}
|
||||||
if (screenY < 0) {
|
if (screenYEnd <= 0) {
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const int syStart = std::max(screenYStart, 0);
|
||||||
|
const int syEnd = std::min(screenYEnd, getScreenHeight());
|
||||||
|
|
||||||
for (int bmpX = 0; bmpX < bitmap.getWidth(); bmpX++) {
|
for (int bmpX = 0; bmpX < bitmap.getWidth(); bmpX++) {
|
||||||
int screenX = x + (isScaled ? static_cast<int>(std::floor(bmpX * scale)) : bmpX);
|
int screenXStart, screenXEnd;
|
||||||
if (screenX >= getScreenWidth()) {
|
if (isScaled) {
|
||||||
|
screenXStart = static_cast<int>(std::floor(bmpX * scale)) + x;
|
||||||
|
screenXEnd = static_cast<int>(std::floor((bmpX + 1) * scale)) + x;
|
||||||
|
} else {
|
||||||
|
screenXStart = bmpX + x;
|
||||||
|
screenXEnd = screenXStart + 1;
|
||||||
|
}
|
||||||
|
if (screenXStart >= getScreenWidth()) {
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
if (screenX < 0) {
|
if (screenXEnd <= 0) {
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -562,7 +622,13 @@ void GfxRenderer::drawBitmap1Bit(const Bitmap& bitmap, const int x, const int y,
|
|||||||
// For 1-bit source: 0 or 1 -> map to black (0,1,2) or white (3)
|
// For 1-bit source: 0 or 1 -> map to black (0,1,2) or white (3)
|
||||||
// val < 3 means black pixel (draw it)
|
// val < 3 means black pixel (draw it)
|
||||||
if (val < 3) {
|
if (val < 3) {
|
||||||
drawPixel(screenX, screenY, true);
|
const int sxStart = std::max(screenXStart, 0);
|
||||||
|
const int sxEnd = std::min(screenXEnd, getScreenWidth());
|
||||||
|
for (int sy = syStart; sy < syEnd; sy++) {
|
||||||
|
for (int sx = sxStart; sx < sxEnd; sx++) {
|
||||||
|
drawPixel(sx, sy, true);
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
// White pixels (val == 3) are not drawn (leave background)
|
// White pixels (val == 3) are not drawn (leave background)
|
||||||
}
|
}
|
||||||
@@ -840,6 +906,92 @@ void GfxRenderer::drawTextRotated90CW(const int fontId, const int x, const int y
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
void GfxRenderer::drawTextRotated90CCW(const int fontId, const int x, const int y, const char* text, const bool black,
|
||||||
|
const EpdFontFamily::Style style) const {
|
||||||
|
// Cannot draw a NULL / empty string
|
||||||
|
if (text == nullptr || *text == '\0') {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (fontMap.count(fontId) == 0) {
|
||||||
|
LOG_ERR("GFX", "Font %d not found", fontId);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const auto font = fontMap.at(fontId);
|
||||||
|
|
||||||
|
// No printable characters
|
||||||
|
if (!font.hasPrintableChars(text, style)) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// For 90° counter-clockwise rotation:
|
||||||
|
// Mirror of CW: glyphY maps to -X direction, glyphX maps to +Y direction
|
||||||
|
// Text reads from top to bottom
|
||||||
|
|
||||||
|
const int advanceY = font.getData(style)->advanceY;
|
||||||
|
const int ascender = font.getData(style)->ascender;
|
||||||
|
|
||||||
|
int yPos = y; // Current Y position (increases as we draw characters)
|
||||||
|
|
||||||
|
uint32_t cp;
|
||||||
|
while ((cp = utf8NextCodepoint(reinterpret_cast<const uint8_t**>(&text)))) {
|
||||||
|
const EpdGlyph* glyph = font.getGlyph(cp, style);
|
||||||
|
if (!glyph) {
|
||||||
|
glyph = font.getGlyph(REPLACEMENT_GLYPH, style);
|
||||||
|
}
|
||||||
|
if (!glyph) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
const int is2Bit = font.getData(style)->is2Bit;
|
||||||
|
const uint32_t offset = glyph->dataOffset;
|
||||||
|
const uint8_t width = glyph->width;
|
||||||
|
const uint8_t height = glyph->height;
|
||||||
|
const int left = glyph->left;
|
||||||
|
const int top = glyph->top;
|
||||||
|
|
||||||
|
const uint8_t* bitmap = &font.getData(style)->bitmap[offset];
|
||||||
|
|
||||||
|
if (bitmap != nullptr) {
|
||||||
|
for (int glyphY = 0; glyphY < height; glyphY++) {
|
||||||
|
for (int glyphX = 0; glyphX < width; glyphX++) {
|
||||||
|
const int pixelPosition = glyphY * width + glyphX;
|
||||||
|
|
||||||
|
// 90° counter-clockwise rotation transformation:
|
||||||
|
// screenX = mirrored CW X (right-to-left within advanceY span)
|
||||||
|
// screenY = yPos + (left + glyphX) (downward)
|
||||||
|
const int screenX = x + advanceY - 1 - (ascender - top + glyphY);
|
||||||
|
const int screenY = yPos + left + glyphX;
|
||||||
|
|
||||||
|
if (is2Bit) {
|
||||||
|
const uint8_t byte = bitmap[pixelPosition / 4];
|
||||||
|
const uint8_t bit_index = (3 - pixelPosition % 4) * 2;
|
||||||
|
const uint8_t bmpVal = 3 - (byte >> bit_index) & 0x3;
|
||||||
|
|
||||||
|
if (renderMode == BW && bmpVal < 3) {
|
||||||
|
drawPixel(screenX, screenY, black);
|
||||||
|
} else if (renderMode == GRAYSCALE_MSB && (bmpVal == 1 || bmpVal == 2)) {
|
||||||
|
drawPixel(screenX, screenY, false);
|
||||||
|
} else if (renderMode == GRAYSCALE_LSB && bmpVal == 1) {
|
||||||
|
drawPixel(screenX, screenY, false);
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
const uint8_t byte = bitmap[pixelPosition / 8];
|
||||||
|
const uint8_t bit_index = 7 - (pixelPosition % 8);
|
||||||
|
|
||||||
|
if ((byte >> bit_index) & 1) {
|
||||||
|
drawPixel(screenX, screenY, black);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Move to next character position (going down, so increase Y)
|
||||||
|
yPos += glyph->advanceX;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
uint8_t* GfxRenderer::getFrameBuffer() const { return frameBuffer; }
|
uint8_t* GfxRenderer::getFrameBuffer() const { return frameBuffer; }
|
||||||
|
|
||||||
size_t GfxRenderer::getBufferSize() { return HalDisplay::BUFFER_SIZE; }
|
size_t GfxRenderer::getBufferSize() { return HalDisplay::BUFFER_SIZE; }
|
||||||
|
|||||||
@@ -77,6 +77,7 @@ class GfxRenderer {
|
|||||||
|
|
||||||
// Drawing
|
// Drawing
|
||||||
void drawPixel(int x, int y, bool state = true) const;
|
void drawPixel(int x, int y, bool state = true) const;
|
||||||
|
void drawPixelGray(int x, int y, uint8_t val2bit) 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, bool state = true) const;
|
||||||
void drawLine(int x1, int y1, int x2, int y2, int lineWidth, bool state) 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;
|
void drawArc(int maxRadius, int cx, int cy, int xDir, int yDir, int lineWidth, bool state) const;
|
||||||
@@ -110,9 +111,11 @@ class GfxRenderer {
|
|||||||
std::string truncatedText(int fontId, const char* text, int maxWidth,
|
std::string truncatedText(int fontId, const char* text, int maxWidth,
|
||||||
EpdFontFamily::Style style = EpdFontFamily::REGULAR) const;
|
EpdFontFamily::Style style = EpdFontFamily::REGULAR) const;
|
||||||
|
|
||||||
// Helper for drawing rotated text (90 degrees clockwise, for side buttons)
|
// Helpers for drawing rotated text (for side buttons)
|
||||||
void drawTextRotated90CW(int fontId, int x, int y, const char* text, bool black = true,
|
void drawTextRotated90CW(int fontId, int x, int y, const char* text, bool black = true,
|
||||||
EpdFontFamily::Style style = EpdFontFamily::REGULAR) const;
|
EpdFontFamily::Style style = EpdFontFamily::REGULAR) const;
|
||||||
|
void drawTextRotated90CCW(int fontId, int x, int y, const char* text, bool black = true,
|
||||||
|
EpdFontFamily::Style style = EpdFontFamily::REGULAR) const;
|
||||||
int getTextHeight(int fontId) const;
|
int getTextHeight(int fontId) const;
|
||||||
|
|
||||||
// Grayscale functions
|
// Grayscale functions
|
||||||
|
|||||||
@@ -1,11 +1,9 @@
|
|||||||
#include <HalGPIO.h>
|
#include <HalGPIO.h>
|
||||||
#include <SPI.h>
|
#include <SPI.h>
|
||||||
#include <esp_sleep.h>
|
|
||||||
|
|
||||||
void HalGPIO::begin() {
|
void HalGPIO::begin() {
|
||||||
inputMgr.begin();
|
inputMgr.begin();
|
||||||
SPI.begin(EPD_SCLK, SPI_MISO, EPD_MOSI, EPD_CS);
|
SPI.begin(EPD_SCLK, SPI_MISO, EPD_MOSI, EPD_CS);
|
||||||
pinMode(BAT_GPIO0, INPUT);
|
|
||||||
pinMode(UART0_RXD, INPUT);
|
pinMode(UART0_RXD, INPUT);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -23,23 +21,6 @@ bool HalGPIO::wasAnyReleased() const { return inputMgr.wasAnyReleased(); }
|
|||||||
|
|
||||||
unsigned long HalGPIO::getHeldTime() const { return inputMgr.getHeldTime(); }
|
unsigned long HalGPIO::getHeldTime() const { return inputMgr.getHeldTime(); }
|
||||||
|
|
||||||
void HalGPIO::startDeepSleep() {
|
|
||||||
// Ensure that the power button has been released to avoid immediately turning back on if you're holding it
|
|
||||||
while (inputMgr.isPressed(BTN_POWER)) {
|
|
||||||
delay(50);
|
|
||||||
inputMgr.update();
|
|
||||||
}
|
|
||||||
// Arm the wakeup trigger *after* the button is released
|
|
||||||
esp_deep_sleep_enable_gpio_wakeup(1ULL << InputManager::POWER_BUTTON_PIN, ESP_GPIO_WAKEUP_GPIO_LOW);
|
|
||||||
// Enter Deep Sleep
|
|
||||||
esp_deep_sleep_start();
|
|
||||||
}
|
|
||||||
|
|
||||||
int HalGPIO::getBatteryPercentage() const {
|
|
||||||
static const BatteryMonitor battery = BatteryMonitor(BAT_GPIO0);
|
|
||||||
return battery.readPercentage();
|
|
||||||
}
|
|
||||||
|
|
||||||
bool HalGPIO::isUsbConnected() const {
|
bool HalGPIO::isUsbConnected() const {
|
||||||
// U0RXD/GPIO20 reads HIGH when USB is connected
|
// U0RXD/GPIO20 reads HIGH when USB is connected
|
||||||
return digitalRead(UART0_RXD) == HIGH;
|
return digitalRead(UART0_RXD) == HIGH;
|
||||||
|
|||||||
@@ -38,12 +38,6 @@ class HalGPIO {
|
|||||||
bool wasAnyReleased() const;
|
bool wasAnyReleased() const;
|
||||||
unsigned long getHeldTime() const;
|
unsigned long getHeldTime() const;
|
||||||
|
|
||||||
// Setup wake up GPIO and enter deep sleep
|
|
||||||
void startDeepSleep();
|
|
||||||
|
|
||||||
// Get battery percentage (range 0-100)
|
|
||||||
int getBatteryPercentage() const;
|
|
||||||
|
|
||||||
// Check if USB is connected
|
// Check if USB is connected
|
||||||
bool isUsbConnected() const;
|
bool isUsbConnected() const;
|
||||||
|
|
||||||
|
|||||||
49
lib/hal/HalPowerManager.cpp
Normal file
49
lib/hal/HalPowerManager.cpp
Normal file
@@ -0,0 +1,49 @@
|
|||||||
|
#include "HalPowerManager.h"
|
||||||
|
|
||||||
|
#include <Logging.h>
|
||||||
|
#include <esp_sleep.h>
|
||||||
|
|
||||||
|
#include "HalGPIO.h"
|
||||||
|
|
||||||
|
void HalPowerManager::begin() {
|
||||||
|
pinMode(BAT_GPIO0, INPUT);
|
||||||
|
normalFreq = getCpuFrequencyMhz();
|
||||||
|
}
|
||||||
|
|
||||||
|
void HalPowerManager::setPowerSaving(bool enabled) {
|
||||||
|
if (normalFreq <= 0) {
|
||||||
|
return; // invalid state
|
||||||
|
}
|
||||||
|
if (enabled && !isLowPower) {
|
||||||
|
LOG_DBG("PWR", "Going to low-power mode");
|
||||||
|
if (!setCpuFrequencyMhz(LOW_POWER_FREQ)) {
|
||||||
|
LOG_ERR("PWR", "Failed to set low-power CPU frequency");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (!enabled && isLowPower) {
|
||||||
|
LOG_DBG("PWR", "Restoring normal CPU frequency");
|
||||||
|
if (!setCpuFrequencyMhz(normalFreq)) {
|
||||||
|
LOG_ERR("PWR", "Failed to restore normal CPU frequency");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
isLowPower = enabled;
|
||||||
|
}
|
||||||
|
|
||||||
|
void HalPowerManager::startDeepSleep(HalGPIO& gpio) const {
|
||||||
|
// Ensure that the power button has been released to avoid immediately turning back on if you're holding it
|
||||||
|
while (gpio.isPressed(HalGPIO::BTN_POWER)) {
|
||||||
|
delay(50);
|
||||||
|
gpio.update();
|
||||||
|
}
|
||||||
|
// Arm the wakeup trigger *after* the button is released
|
||||||
|
esp_deep_sleep_enable_gpio_wakeup(1ULL << InputManager::POWER_BUTTON_PIN, ESP_GPIO_WAKEUP_GPIO_LOW);
|
||||||
|
// Enter Deep Sleep
|
||||||
|
esp_deep_sleep_start();
|
||||||
|
}
|
||||||
|
|
||||||
|
int HalPowerManager::getBatteryPercentage() const {
|
||||||
|
static const BatteryMonitor battery = BatteryMonitor(BAT_GPIO0);
|
||||||
|
return battery.readPercentage();
|
||||||
|
}
|
||||||
27
lib/hal/HalPowerManager.h
Normal file
27
lib/hal/HalPowerManager.h
Normal file
@@ -0,0 +1,27 @@
|
|||||||
|
#pragma once
|
||||||
|
|
||||||
|
#include <Arduino.h>
|
||||||
|
#include <BatteryMonitor.h>
|
||||||
|
#include <InputManager.h>
|
||||||
|
|
||||||
|
#include "HalGPIO.h"
|
||||||
|
|
||||||
|
class HalPowerManager {
|
||||||
|
int normalFreq = 0; // MHz
|
||||||
|
bool isLowPower = false;
|
||||||
|
|
||||||
|
public:
|
||||||
|
static constexpr int LOW_POWER_FREQ = 10; // MHz
|
||||||
|
static constexpr unsigned long IDLE_POWER_SAVING_MS = 3000; // ms
|
||||||
|
|
||||||
|
void begin();
|
||||||
|
|
||||||
|
// Control CPU frequency for power saving
|
||||||
|
void setPowerSaving(bool enabled);
|
||||||
|
|
||||||
|
// Setup wake up GPIO and enter deep sleep
|
||||||
|
void startDeepSleep(HalGPIO& gpio) const;
|
||||||
|
|
||||||
|
// Get battery percentage (range 0-100)
|
||||||
|
int getBatteryPercentage() const;
|
||||||
|
};
|
||||||
@@ -58,6 +58,16 @@ build_flags =
|
|||||||
-DLOG_LEVEL=2 ; Set log level to debug for development builds
|
-DLOG_LEVEL=2 ; Set log level to debug for development builds
|
||||||
|
|
||||||
|
|
||||||
|
[env:mod]
|
||||||
|
extends = base
|
||||||
|
extra_scripts =
|
||||||
|
${base.extra_scripts}
|
||||||
|
pre:scripts/inject_mod_version.py
|
||||||
|
build_flags =
|
||||||
|
${base.build_flags}
|
||||||
|
-DENABLE_SERIAL_LOG
|
||||||
|
-DLOG_LEVEL=2 ; Set log level to debug for mod builds
|
||||||
|
|
||||||
[env:gh_release]
|
[env:gh_release]
|
||||||
extends = base
|
extends = base
|
||||||
build_flags =
|
build_flags =
|
||||||
|
|||||||
15
scripts/inject_mod_version.py
Normal file
15
scripts/inject_mod_version.py
Normal file
@@ -0,0 +1,15 @@
|
|||||||
|
Import("env")
|
||||||
|
import subprocess
|
||||||
|
|
||||||
|
config = env.GetProjectConfig()
|
||||||
|
version = config.get("crosspoint", "version")
|
||||||
|
|
||||||
|
result = subprocess.run(
|
||||||
|
["git", "rev-parse", "--short", "HEAD"],
|
||||||
|
capture_output=True, text=True
|
||||||
|
)
|
||||||
|
git_hash = result.stdout.strip()
|
||||||
|
|
||||||
|
env.Append(
|
||||||
|
BUILD_FLAGS=[f'-DCROSSPOINT_VERSION=\\"{version}-mod+{git_hash}\\"']
|
||||||
|
)
|
||||||
@@ -22,7 +22,7 @@ void readAndValidate(FsFile& file, uint8_t& member, const uint8_t maxValue) {
|
|||||||
namespace {
|
namespace {
|
||||||
constexpr uint8_t SETTINGS_FILE_VERSION = 1;
|
constexpr uint8_t SETTINGS_FILE_VERSION = 1;
|
||||||
// Increment this when adding new persisted settings fields
|
// Increment this when adding new persisted settings fields
|
||||||
constexpr uint8_t SETTINGS_COUNT = 30;
|
constexpr uint8_t SETTINGS_COUNT = 31;
|
||||||
constexpr char SETTINGS_FILE[] = "/.crosspoint/settings.bin";
|
constexpr char SETTINGS_FILE[] = "/.crosspoint/settings.bin";
|
||||||
|
|
||||||
// Validate front button mapping to ensure each hardware button is unique.
|
// Validate front button mapping to ensure each hardware button is unique.
|
||||||
@@ -118,6 +118,7 @@ bool CrossPointSettings::saveToFile() const {
|
|||||||
serialization::writePod(outputFile, frontButtonRight);
|
serialization::writePod(outputFile, frontButtonRight);
|
||||||
serialization::writePod(outputFile, fadingFix);
|
serialization::writePod(outputFile, fadingFix);
|
||||||
serialization::writePod(outputFile, embeddedStyle);
|
serialization::writePod(outputFile, embeddedStyle);
|
||||||
|
serialization::writePod(outputFile, sleepScreenLetterboxFill);
|
||||||
// New fields added at end for backward compatibility
|
// New fields added at end for backward compatibility
|
||||||
outputFile.close();
|
outputFile.close();
|
||||||
|
|
||||||
@@ -223,6 +224,10 @@ bool CrossPointSettings::loadFromFile() {
|
|||||||
if (++settingsRead >= fileSettingsCount) break;
|
if (++settingsRead >= fileSettingsCount) break;
|
||||||
serialization::readPod(inputFile, embeddedStyle);
|
serialization::readPod(inputFile, embeddedStyle);
|
||||||
if (++settingsRead >= fileSettingsCount) break;
|
if (++settingsRead >= fileSettingsCount) break;
|
||||||
|
readAndValidate(inputFile, sleepScreenLetterboxFill, SLEEP_SCREEN_LETTERBOX_FILL_COUNT);
|
||||||
|
if (++settingsRead >= fileSettingsCount) break;
|
||||||
|
{ uint8_t _ignore; serialization::readPod(inputFile, _ignore); } // legacy: sleepScreenGradientDir
|
||||||
|
if (++settingsRead >= fileSettingsCount) break;
|
||||||
// New fields added at end for backward compatibility
|
// New fields added at end for backward compatibility
|
||||||
} while (false);
|
} while (false);
|
||||||
|
|
||||||
|
|||||||
@@ -31,6 +31,12 @@ class CrossPointSettings {
|
|||||||
INVERTED_BLACK_AND_WHITE = 2,
|
INVERTED_BLACK_AND_WHITE = 2,
|
||||||
SLEEP_SCREEN_COVER_FILTER_COUNT
|
SLEEP_SCREEN_COVER_FILTER_COUNT
|
||||||
};
|
};
|
||||||
|
enum SLEEP_SCREEN_LETTERBOX_FILL {
|
||||||
|
LETTERBOX_DITHERED = 0,
|
||||||
|
LETTERBOX_SOLID = 1,
|
||||||
|
LETTERBOX_NONE = 2,
|
||||||
|
SLEEP_SCREEN_LETTERBOX_FILL_COUNT
|
||||||
|
};
|
||||||
|
|
||||||
// Status bar display type enum
|
// Status bar display type enum
|
||||||
enum STATUS_BAR_MODE {
|
enum STATUS_BAR_MODE {
|
||||||
@@ -125,6 +131,8 @@ class CrossPointSettings {
|
|||||||
uint8_t sleepScreenCoverMode = FIT;
|
uint8_t sleepScreenCoverMode = FIT;
|
||||||
// Sleep screen cover filter
|
// Sleep screen cover filter
|
||||||
uint8_t sleepScreenCoverFilter = NO_FILTER;
|
uint8_t sleepScreenCoverFilter = NO_FILTER;
|
||||||
|
// Sleep screen letterbox fill mode (Dithered / Solid / None)
|
||||||
|
uint8_t sleepScreenLetterboxFill = LETTERBOX_DITHERED;
|
||||||
// Status bar settings
|
// Status bar settings
|
||||||
uint8_t statusBar = FULL;
|
uint8_t statusBar = FULL;
|
||||||
// Text rendering settings
|
// Text rendering settings
|
||||||
|
|||||||
@@ -18,6 +18,8 @@ inline std::vector<SettingInfo> getSettingsList() {
|
|||||||
"sleepScreenCoverMode", "Display"),
|
"sleepScreenCoverMode", "Display"),
|
||||||
SettingInfo::Enum("Sleep Screen Cover Filter", &CrossPointSettings::sleepScreenCoverFilter,
|
SettingInfo::Enum("Sleep Screen Cover Filter", &CrossPointSettings::sleepScreenCoverFilter,
|
||||||
{"None", "Contrast", "Inverted"}, "sleepScreenCoverFilter", "Display"),
|
{"None", "Contrast", "Inverted"}, "sleepScreenCoverFilter", "Display"),
|
||||||
|
SettingInfo::Enum("Letterbox Fill", &CrossPointSettings::sleepScreenLetterboxFill,
|
||||||
|
{"Dithered", "Solid", "None"}, "sleepScreenLetterboxFill", "Display"),
|
||||||
SettingInfo::Enum(
|
SettingInfo::Enum(
|
||||||
"Status Bar", &CrossPointSettings::statusBar,
|
"Status Bar", &CrossPointSettings::statusBar,
|
||||||
{"None", "No Progress", "Full w/ Percentage", "Full w/ Book Bar", "Book Bar Only", "Full w/ Chapter Bar"},
|
{"None", "No Progress", "Full w/ Percentage", "Full w/ Book Bar", "Book Bar Only", "Full w/ Chapter Bar"},
|
||||||
|
|||||||
@@ -3,16 +3,346 @@
|
|||||||
#include <Epub.h>
|
#include <Epub.h>
|
||||||
#include <GfxRenderer.h>
|
#include <GfxRenderer.h>
|
||||||
#include <HalStorage.h>
|
#include <HalStorage.h>
|
||||||
|
#include <Logging.h>
|
||||||
|
#include <Serialization.h>
|
||||||
#include <Txt.h>
|
#include <Txt.h>
|
||||||
#include <Xtc.h>
|
#include <Xtc.h>
|
||||||
|
|
||||||
|
#include <algorithm>
|
||||||
|
#include <cmath>
|
||||||
|
|
||||||
#include "CrossPointSettings.h"
|
#include "CrossPointSettings.h"
|
||||||
#include "CrossPointState.h"
|
#include "CrossPointState.h"
|
||||||
|
#include "util/BookSettings.h"
|
||||||
#include "components/UITheme.h"
|
#include "components/UITheme.h"
|
||||||
#include "fontIds.h"
|
#include "fontIds.h"
|
||||||
#include "images/Logo120.h"
|
#include "images/Logo120.h"
|
||||||
#include "util/StringUtils.h"
|
#include "util/StringUtils.h"
|
||||||
|
|
||||||
|
namespace {
|
||||||
|
|
||||||
|
// Number of source pixels along the image edge to average for the dominant color
|
||||||
|
constexpr int EDGE_SAMPLE_DEPTH = 20;
|
||||||
|
|
||||||
|
// Map a 2-bit quantized pixel value to an 8-bit grayscale value
|
||||||
|
constexpr uint8_t val2bitToGray(uint8_t val2bit) { return val2bit * 85; }
|
||||||
|
|
||||||
|
// Letterbox fill data: one average gray value per edge (top/bottom or left/right).
|
||||||
|
struct LetterboxFillData {
|
||||||
|
uint8_t avgA = 128; // average gray of edge A (top or left)
|
||||||
|
uint8_t avgB = 128; // average gray of edge B (bottom or right)
|
||||||
|
int letterboxA = 0; // pixel size of the first letterbox area (top or left)
|
||||||
|
int letterboxB = 0; // pixel size of the second letterbox area (bottom or right)
|
||||||
|
bool horizontal = false; // true = top/bottom letterbox, false = left/right
|
||||||
|
bool valid = false;
|
||||||
|
};
|
||||||
|
|
||||||
|
// Snap an 8-bit gray value to the nearest of the 4 e-ink levels: 0, 85, 170, 255.
|
||||||
|
uint8_t snapToEinkLevel(uint8_t gray) {
|
||||||
|
// Thresholds at midpoints: 42, 127, 212
|
||||||
|
if (gray < 43) return 0;
|
||||||
|
if (gray < 128) return 85;
|
||||||
|
if (gray < 213) return 170;
|
||||||
|
return 255;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 4x4 Bayer ordered dithering matrix, values 0-255.
|
||||||
|
// Produces a structured halftone pattern for 4-level quantization.
|
||||||
|
// clang-format off
|
||||||
|
constexpr uint8_t BAYER_4X4[4][4] = {
|
||||||
|
{ 0, 128, 32, 160},
|
||||||
|
{192, 64, 224, 96},
|
||||||
|
{ 48, 176, 16, 144},
|
||||||
|
{240, 112, 208, 80}
|
||||||
|
};
|
||||||
|
// clang-format on
|
||||||
|
|
||||||
|
// Ordered (Bayer) dithering for 4-level e-ink display.
|
||||||
|
// Maps an 8-bit gray value to a 2-bit level (0-3) using the Bayer matrix
|
||||||
|
// to produce a structured, repeating halftone pattern.
|
||||||
|
uint8_t quantizeBayerDither(int gray, int x, int y) {
|
||||||
|
const int threshold = BAYER_4X4[y & 3][x & 3];
|
||||||
|
const int scaled = gray * 3;
|
||||||
|
if (scaled < 255) {
|
||||||
|
return (scaled + threshold >= 255) ? 1 : 0;
|
||||||
|
} else if (scaled < 510) {
|
||||||
|
return ((scaled - 255) + threshold >= 255) ? 2 : 1;
|
||||||
|
} else {
|
||||||
|
return ((scaled - 510) + threshold >= 255) ? 3 : 2;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check whether a gray value would produce a dithered mix that crosses the
|
||||||
|
// level-2 / level-3 boundary. This is the ONLY boundary where some dithered
|
||||||
|
// pixels are BLACK (level ≤ 2) and others are WHITE (level 3) in the BW pass,
|
||||||
|
// creating a high-frequency checkerboard that causes e-ink display crosstalk
|
||||||
|
// and washes out adjacent content during HALF_REFRESH.
|
||||||
|
// Gray values 171-254 produce a level-2/level-3 mix via Bayer dithering.
|
||||||
|
bool bayerCrossesBwBoundary(uint8_t gray) {
|
||||||
|
return gray > 170 && gray < 255;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Hash-based block dithering for BW-boundary gray values (171-254).
|
||||||
|
// Each blockSize×blockSize pixel block gets a single uniform level (2 or 3),
|
||||||
|
// determined by a deterministic spatial hash. The proportion of level-3 blocks
|
||||||
|
// approximates the target gray. Unlike Bayer, the pattern is irregular
|
||||||
|
// (noise-like), making it much less visually obvious at the same block size.
|
||||||
|
// The hash is purely spatial (depends only on x, y, blockSize) so it produces
|
||||||
|
// identical levels across BW, LSB, and MSB render passes.
|
||||||
|
static constexpr int BW_DITHER_BLOCK = 2;
|
||||||
|
|
||||||
|
uint8_t hashBlockDither(uint8_t avg, int x, int y) {
|
||||||
|
const int bx = x / BW_DITHER_BLOCK;
|
||||||
|
const int by = y / BW_DITHER_BLOCK;
|
||||||
|
// Fast mixing hash (splitmix32-inspired)
|
||||||
|
uint32_t h = (uint32_t)bx * 2654435761u ^ (uint32_t)by * 2246822519u;
|
||||||
|
h ^= h >> 16;
|
||||||
|
h *= 0x45d9f3bu;
|
||||||
|
h ^= h >> 16;
|
||||||
|
// Proportion of level-3 blocks needed to approximate the target gray
|
||||||
|
const float ratio = (avg - 170.0f) / 85.0f;
|
||||||
|
const uint32_t threshold = (uint32_t)(ratio * 4294967295.0f);
|
||||||
|
return (h < threshold) ? 3 : 2;
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- Edge average cache ---
|
||||||
|
// Caches the computed edge averages alongside the cover BMP so we don't rescan on every sleep.
|
||||||
|
constexpr uint8_t EDGE_CACHE_VERSION = 2;
|
||||||
|
|
||||||
|
bool loadEdgeCache(const std::string& path, int screenWidth, int screenHeight, LetterboxFillData& data) {
|
||||||
|
FsFile file;
|
||||||
|
if (!Storage.openFileForRead("SLP", path, file)) return false;
|
||||||
|
|
||||||
|
uint8_t version;
|
||||||
|
serialization::readPod(file, version);
|
||||||
|
if (version != EDGE_CACHE_VERSION) {
|
||||||
|
file.close();
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
uint16_t cachedW, cachedH;
|
||||||
|
serialization::readPod(file, cachedW);
|
||||||
|
serialization::readPod(file, cachedH);
|
||||||
|
if (cachedW != static_cast<uint16_t>(screenWidth) || cachedH != static_cast<uint16_t>(screenHeight)) {
|
||||||
|
file.close();
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
uint8_t horizontal;
|
||||||
|
serialization::readPod(file, horizontal);
|
||||||
|
data.horizontal = (horizontal != 0);
|
||||||
|
|
||||||
|
serialization::readPod(file, data.avgA);
|
||||||
|
serialization::readPod(file, data.avgB);
|
||||||
|
|
||||||
|
int16_t lbA, lbB;
|
||||||
|
serialization::readPod(file, lbA);
|
||||||
|
serialization::readPod(file, lbB);
|
||||||
|
data.letterboxA = lbA;
|
||||||
|
data.letterboxB = lbB;
|
||||||
|
|
||||||
|
file.close();
|
||||||
|
data.valid = true;
|
||||||
|
LOG_DBG("SLP", "Loaded edge cache from %s (avgA=%d, avgB=%d)", path.c_str(), data.avgA, data.avgB);
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
bool saveEdgeCache(const std::string& path, int screenWidth, int screenHeight, const LetterboxFillData& data) {
|
||||||
|
if (!data.valid) return false;
|
||||||
|
|
||||||
|
FsFile file;
|
||||||
|
if (!Storage.openFileForWrite("SLP", path, file)) return false;
|
||||||
|
|
||||||
|
serialization::writePod(file, EDGE_CACHE_VERSION);
|
||||||
|
serialization::writePod(file, static_cast<uint16_t>(screenWidth));
|
||||||
|
serialization::writePod(file, static_cast<uint16_t>(screenHeight));
|
||||||
|
serialization::writePod(file, static_cast<uint8_t>(data.horizontal ? 1 : 0));
|
||||||
|
serialization::writePod(file, data.avgA);
|
||||||
|
serialization::writePod(file, data.avgB);
|
||||||
|
serialization::writePod(file, static_cast<int16_t>(data.letterboxA));
|
||||||
|
serialization::writePod(file, static_cast<int16_t>(data.letterboxB));
|
||||||
|
file.close();
|
||||||
|
|
||||||
|
LOG_DBG("SLP", "Saved edge cache to %s", path.c_str());
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Read the bitmap once to compute a single average gray value for the top/bottom or left/right edges.
|
||||||
|
// Only computes running sums -- no per-pixel arrays, no malloc beyond row buffers.
|
||||||
|
// After sampling the bitmap is rewound via rewindToData().
|
||||||
|
LetterboxFillData computeEdgeAverages(const Bitmap& bitmap, int imgX, int imgY, int pageWidth, int pageHeight,
|
||||||
|
float scale, float cropX, float cropY) {
|
||||||
|
LetterboxFillData data;
|
||||||
|
|
||||||
|
const int cropPixX = static_cast<int>(std::floor(bitmap.getWidth() * cropX / 2.0f));
|
||||||
|
const int cropPixY = static_cast<int>(std::floor(bitmap.getHeight() * cropY / 2.0f));
|
||||||
|
const int visibleWidth = bitmap.getWidth() - 2 * cropPixX;
|
||||||
|
const int visibleHeight = bitmap.getHeight() - 2 * cropPixY;
|
||||||
|
|
||||||
|
if (visibleWidth <= 0 || visibleHeight <= 0) return data;
|
||||||
|
|
||||||
|
const int outputRowSize = (bitmap.getWidth() + 3) / 4;
|
||||||
|
auto* outputRow = static_cast<uint8_t*>(malloc(outputRowSize));
|
||||||
|
auto* rowBytes = static_cast<uint8_t*>(malloc(bitmap.getRowBytes()));
|
||||||
|
if (!outputRow || !rowBytes) {
|
||||||
|
free(outputRow);
|
||||||
|
free(rowBytes);
|
||||||
|
return data;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (imgY > 0) {
|
||||||
|
// Top/bottom letterboxing -- compute overall average of first/last EDGE_SAMPLE_DEPTH rows
|
||||||
|
data.horizontal = true;
|
||||||
|
const int scaledHeight = static_cast<int>(std::round(static_cast<float>(visibleHeight) * scale));
|
||||||
|
data.letterboxA = imgY;
|
||||||
|
data.letterboxB = pageHeight - imgY - scaledHeight;
|
||||||
|
if (data.letterboxB < 0) data.letterboxB = 0;
|
||||||
|
|
||||||
|
const int sampleRows = std::min(EDGE_SAMPLE_DEPTH, visibleHeight);
|
||||||
|
uint64_t sumTop = 0, sumBot = 0;
|
||||||
|
int countTop = 0, countBot = 0;
|
||||||
|
|
||||||
|
for (int bmpY = 0; bmpY < bitmap.getHeight(); bmpY++) {
|
||||||
|
if (bitmap.readNextRow(outputRow, rowBytes) != BmpReaderError::Ok) break;
|
||||||
|
const int logicalY = bitmap.isTopDown() ? bmpY : bitmap.getHeight() - 1 - bmpY;
|
||||||
|
if (logicalY < cropPixY || logicalY >= bitmap.getHeight() - cropPixY) continue;
|
||||||
|
const int outY = logicalY - cropPixY;
|
||||||
|
|
||||||
|
const bool inTop = (outY < sampleRows);
|
||||||
|
const bool inBot = (outY >= visibleHeight - sampleRows);
|
||||||
|
if (!inTop && !inBot) continue;
|
||||||
|
|
||||||
|
for (int bmpX = cropPixX; bmpX < bitmap.getWidth() - cropPixX; bmpX++) {
|
||||||
|
const uint8_t val = (outputRow[bmpX / 4] >> (6 - ((bmpX * 2) % 8))) & 0x3;
|
||||||
|
const uint8_t gray = val2bitToGray(val);
|
||||||
|
if (inTop) {
|
||||||
|
sumTop += gray;
|
||||||
|
countTop++;
|
||||||
|
}
|
||||||
|
if (inBot) {
|
||||||
|
sumBot += gray;
|
||||||
|
countBot++;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
data.avgA = countTop > 0 ? static_cast<uint8_t>(sumTop / countTop) : 128;
|
||||||
|
data.avgB = countBot > 0 ? static_cast<uint8_t>(sumBot / countBot) : 128;
|
||||||
|
data.valid = true;
|
||||||
|
} else if (imgX > 0) {
|
||||||
|
// Left/right letterboxing -- compute overall average of first/last EDGE_SAMPLE_DEPTH columns
|
||||||
|
data.horizontal = false;
|
||||||
|
const int scaledWidth = static_cast<int>(std::round(static_cast<float>(visibleWidth) * scale));
|
||||||
|
data.letterboxA = imgX;
|
||||||
|
data.letterboxB = pageWidth - imgX - scaledWidth;
|
||||||
|
if (data.letterboxB < 0) data.letterboxB = 0;
|
||||||
|
|
||||||
|
const int sampleCols = std::min(EDGE_SAMPLE_DEPTH, visibleWidth);
|
||||||
|
uint64_t sumLeft = 0, sumRight = 0;
|
||||||
|
int countLeft = 0, countRight = 0;
|
||||||
|
|
||||||
|
for (int bmpY = 0; bmpY < bitmap.getHeight(); bmpY++) {
|
||||||
|
if (bitmap.readNextRow(outputRow, rowBytes) != BmpReaderError::Ok) break;
|
||||||
|
const int logicalY = bitmap.isTopDown() ? bmpY : bitmap.getHeight() - 1 - bmpY;
|
||||||
|
if (logicalY < cropPixY || logicalY >= bitmap.getHeight() - cropPixY) continue;
|
||||||
|
|
||||||
|
for (int bmpX = cropPixX; bmpX < cropPixX + sampleCols; bmpX++) {
|
||||||
|
const uint8_t val = (outputRow[bmpX / 4] >> (6 - ((bmpX * 2) % 8))) & 0x3;
|
||||||
|
sumLeft += val2bitToGray(val);
|
||||||
|
countLeft++;
|
||||||
|
}
|
||||||
|
for (int bmpX = bitmap.getWidth() - cropPixX - sampleCols; bmpX < bitmap.getWidth() - cropPixX; bmpX++) {
|
||||||
|
const uint8_t val = (outputRow[bmpX / 4] >> (6 - ((bmpX * 2) % 8))) & 0x3;
|
||||||
|
sumRight += val2bitToGray(val);
|
||||||
|
countRight++;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
data.avgA = countLeft > 0 ? static_cast<uint8_t>(sumLeft / countLeft) : 128;
|
||||||
|
data.avgB = countRight > 0 ? static_cast<uint8_t>(sumRight / countRight) : 128;
|
||||||
|
data.valid = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
bitmap.rewindToData();
|
||||||
|
free(outputRow);
|
||||||
|
free(rowBytes);
|
||||||
|
return data;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Draw letterbox fill in the areas around the cover image.
|
||||||
|
// DITHERED: fills with the edge average using Bayer ordered dithering to approximate the color.
|
||||||
|
// SOLID: snaps edge average to nearest e-ink level (0/85/170/255) for a clean uniform fill.
|
||||||
|
// Must be called once per render pass (BW, GRAYSCALE_LSB, GRAYSCALE_MSB).
|
||||||
|
void drawLetterboxFill(GfxRenderer& renderer, const LetterboxFillData& data, uint8_t fillMode) {
|
||||||
|
if (!data.valid) return;
|
||||||
|
|
||||||
|
const bool isSolid = (fillMode == CrossPointSettings::SLEEP_SCREEN_LETTERBOX_FILL::LETTERBOX_SOLID);
|
||||||
|
|
||||||
|
// For DITHERED mode with gray values in 171-254 (the level-2/level-3 BW boundary):
|
||||||
|
// Pixel-level Bayer dithering creates a regular high-frequency checkerboard in
|
||||||
|
// the BW pass that causes e-ink display crosstalk during HALF_REFRESH.
|
||||||
|
//
|
||||||
|
// Solution: HASH-BASED BLOCK DITHERING. Each 2x2 pixel block gets a single
|
||||||
|
// level (2 or 3) determined by a spatial hash, with the proportion of level-3
|
||||||
|
// blocks tuned to approximate the target gray. The 2px minimum run avoids BW
|
||||||
|
// crosstalk, and the irregular hash pattern is much less visible than a regular
|
||||||
|
// Bayer grid at the same block size.
|
||||||
|
const bool hashA = !isSolid && bayerCrossesBwBoundary(data.avgA);
|
||||||
|
const bool hashB = !isSolid && bayerCrossesBwBoundary(data.avgB);
|
||||||
|
|
||||||
|
// For solid mode: snap to nearest e-ink level
|
||||||
|
const uint8_t levelA = isSolid ? snapToEinkLevel(data.avgA) / 85 : 0;
|
||||||
|
const uint8_t levelB = isSolid ? snapToEinkLevel(data.avgB) / 85 : 0;
|
||||||
|
|
||||||
|
if (data.horizontal) {
|
||||||
|
if (data.letterboxA > 0) {
|
||||||
|
for (int y = 0; y < data.letterboxA; y++)
|
||||||
|
for (int x = 0; x < renderer.getScreenWidth(); x++) {
|
||||||
|
uint8_t lv;
|
||||||
|
if (isSolid) lv = levelA;
|
||||||
|
else if (hashA) lv = hashBlockDither(data.avgA, x, y);
|
||||||
|
else lv = quantizeBayerDither(data.avgA, x, y);
|
||||||
|
renderer.drawPixelGray(x, y, lv);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (data.letterboxB > 0) {
|
||||||
|
const int start = renderer.getScreenHeight() - data.letterboxB;
|
||||||
|
for (int y = start; y < renderer.getScreenHeight(); y++)
|
||||||
|
for (int x = 0; x < renderer.getScreenWidth(); x++) {
|
||||||
|
uint8_t lv;
|
||||||
|
if (isSolid) lv = levelB;
|
||||||
|
else if (hashB) lv = hashBlockDither(data.avgB, x, y);
|
||||||
|
else lv = quantizeBayerDither(data.avgB, x, y);
|
||||||
|
renderer.drawPixelGray(x, y, lv);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
if (data.letterboxA > 0) {
|
||||||
|
for (int x = 0; x < data.letterboxA; x++)
|
||||||
|
for (int y = 0; y < renderer.getScreenHeight(); y++) {
|
||||||
|
uint8_t lv;
|
||||||
|
if (isSolid) lv = levelA;
|
||||||
|
else if (hashA) lv = hashBlockDither(data.avgA, x, y);
|
||||||
|
else lv = quantizeBayerDither(data.avgA, x, y);
|
||||||
|
renderer.drawPixelGray(x, y, lv);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (data.letterboxB > 0) {
|
||||||
|
const int start = renderer.getScreenWidth() - data.letterboxB;
|
||||||
|
for (int x = start; x < renderer.getScreenWidth(); x++)
|
||||||
|
for (int y = 0; y < renderer.getScreenHeight(); y++) {
|
||||||
|
uint8_t lv;
|
||||||
|
if (isSolid) lv = levelB;
|
||||||
|
else if (hashB) lv = hashBlockDither(data.avgB, x, y);
|
||||||
|
else lv = quantizeBayerDither(data.avgB, x, y);
|
||||||
|
renderer.drawPixelGray(x, y, lv);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
} // namespace
|
||||||
|
|
||||||
void SleepActivity::onEnter() {
|
void SleepActivity::onEnter() {
|
||||||
Activity::onEnter();
|
Activity::onEnter();
|
||||||
GUI.drawPopup(renderer, "Entering Sleep...");
|
GUI.drawPopup(renderer, "Entering Sleep...");
|
||||||
@@ -121,52 +451,92 @@ void SleepActivity::renderDefaultSleepScreen() const {
|
|||||||
renderer.displayBuffer(HalDisplay::HALF_REFRESH);
|
renderer.displayBuffer(HalDisplay::HALF_REFRESH);
|
||||||
}
|
}
|
||||||
|
|
||||||
void SleepActivity::renderBitmapSleepScreen(const Bitmap& bitmap) const {
|
void SleepActivity::renderBitmapSleepScreen(const Bitmap& bitmap, const std::string& edgeCachePath,
|
||||||
|
uint8_t fillModeOverride) const {
|
||||||
int x, y;
|
int x, y;
|
||||||
const auto pageWidth = renderer.getScreenWidth();
|
const auto pageWidth = renderer.getScreenWidth();
|
||||||
const auto pageHeight = renderer.getScreenHeight();
|
const auto pageHeight = renderer.getScreenHeight();
|
||||||
float cropX = 0, cropY = 0;
|
float cropX = 0, cropY = 0;
|
||||||
|
|
||||||
LOG_DBG("SLP", "bitmap %d x %d, screen %d x %d", bitmap.getWidth(), bitmap.getHeight(), pageWidth, pageHeight);
|
LOG_DBG("SLP", "bitmap %d x %d, screen %d x %d", bitmap.getWidth(), bitmap.getHeight(), pageWidth, pageHeight);
|
||||||
if (bitmap.getWidth() > pageWidth || bitmap.getHeight() > pageHeight) {
|
|
||||||
// image will scale, make sure placement is right
|
|
||||||
float ratio = static_cast<float>(bitmap.getWidth()) / static_cast<float>(bitmap.getHeight());
|
|
||||||
const float screenRatio = static_cast<float>(pageWidth) / static_cast<float>(pageHeight);
|
|
||||||
|
|
||||||
LOG_DBG("SLP", "bitmap ratio: %f, screen ratio: %f", ratio, screenRatio);
|
// Always compute aspect-ratio-preserving scale and position (supports both larger and smaller images)
|
||||||
if (ratio > screenRatio) {
|
float ratio = static_cast<float>(bitmap.getWidth()) / static_cast<float>(bitmap.getHeight());
|
||||||
// image wider than viewport ratio, scaled down image needs to be centered vertically
|
const float screenRatio = static_cast<float>(pageWidth) / static_cast<float>(pageHeight);
|
||||||
if (SETTINGS.sleepScreenCoverMode == CrossPointSettings::SLEEP_SCREEN_COVER_MODE::CROP) {
|
|
||||||
cropX = 1.0f - (screenRatio / ratio);
|
LOG_DBG("SLP", "bitmap ratio: %f, screen ratio: %f", ratio, screenRatio);
|
||||||
LOG_DBG("SLP", "Cropping bitmap x: %f", cropX);
|
if (ratio > screenRatio) {
|
||||||
ratio = (1.0f - cropX) * static_cast<float>(bitmap.getWidth()) / static_cast<float>(bitmap.getHeight());
|
// image wider than viewport ratio, needs to be centered vertically
|
||||||
}
|
if (SETTINGS.sleepScreenCoverMode == CrossPointSettings::SLEEP_SCREEN_COVER_MODE::CROP) {
|
||||||
x = 0;
|
cropX = 1.0f - (screenRatio / ratio);
|
||||||
y = std::round((static_cast<float>(pageHeight) - static_cast<float>(pageWidth) / ratio) / 2);
|
LOG_DBG("SLP", "Cropping bitmap x: %f", cropX);
|
||||||
LOG_DBG("SLP", "Centering with ratio %f to y=%d", ratio, y);
|
ratio = (1.0f - cropX) * static_cast<float>(bitmap.getWidth()) / static_cast<float>(bitmap.getHeight());
|
||||||
} else {
|
|
||||||
// image taller than viewport ratio, scaled down image needs to be centered horizontally
|
|
||||||
if (SETTINGS.sleepScreenCoverMode == CrossPointSettings::SLEEP_SCREEN_COVER_MODE::CROP) {
|
|
||||||
cropY = 1.0f - (ratio / screenRatio);
|
|
||||||
LOG_DBG("SLP", "Cropping bitmap y: %f", cropY);
|
|
||||||
ratio = static_cast<float>(bitmap.getWidth()) / ((1.0f - cropY) * static_cast<float>(bitmap.getHeight()));
|
|
||||||
}
|
|
||||||
x = std::round((static_cast<float>(pageWidth) - static_cast<float>(pageHeight) * ratio) / 2);
|
|
||||||
y = 0;
|
|
||||||
LOG_DBG("SLP", "Centering with ratio %f to x=%d", ratio, x);
|
|
||||||
}
|
}
|
||||||
|
x = 0;
|
||||||
|
y = std::round((static_cast<float>(pageHeight) - static_cast<float>(pageWidth) / ratio) / 2);
|
||||||
|
LOG_DBG("SLP", "Centering with ratio %f to y=%d", ratio, y);
|
||||||
} else {
|
} else {
|
||||||
// center the image
|
// image taller than or equal to viewport ratio, needs to be centered horizontally
|
||||||
x = (pageWidth - bitmap.getWidth()) / 2;
|
if (SETTINGS.sleepScreenCoverMode == CrossPointSettings::SLEEP_SCREEN_COVER_MODE::CROP) {
|
||||||
y = (pageHeight - bitmap.getHeight()) / 2;
|
cropY = 1.0f - (ratio / screenRatio);
|
||||||
|
LOG_DBG("SLP", "Cropping bitmap y: %f", cropY);
|
||||||
|
ratio = static_cast<float>(bitmap.getWidth()) / ((1.0f - cropY) * static_cast<float>(bitmap.getHeight()));
|
||||||
|
}
|
||||||
|
x = std::round((static_cast<float>(pageWidth) - static_cast<float>(pageHeight) * ratio) / 2);
|
||||||
|
y = 0;
|
||||||
|
LOG_DBG("SLP", "Centering with ratio %f to x=%d", ratio, x);
|
||||||
}
|
}
|
||||||
|
|
||||||
LOG_DBG("SLP", "drawing to %d x %d", x, y);
|
LOG_DBG("SLP", "drawing to %d x %d", x, y);
|
||||||
|
|
||||||
|
// Compute the scale factor (same formula as drawBitmap) so we can map screen coords to source coords
|
||||||
|
const float effectiveWidth = (1.0f - cropX) * bitmap.getWidth();
|
||||||
|
const float effectiveHeight = (1.0f - cropY) * bitmap.getHeight();
|
||||||
|
const float scale =
|
||||||
|
std::min(static_cast<float>(pageWidth) / effectiveWidth, static_cast<float>(pageHeight) / effectiveHeight);
|
||||||
|
|
||||||
|
// Determine letterbox fill settings (per-book override takes precedence)
|
||||||
|
const uint8_t fillMode = (fillModeOverride != BookSettings::USE_GLOBAL &&
|
||||||
|
fillModeOverride < CrossPointSettings::SLEEP_SCREEN_LETTERBOX_FILL_COUNT)
|
||||||
|
? fillModeOverride
|
||||||
|
: SETTINGS.sleepScreenLetterboxFill;
|
||||||
|
const bool wantFill = (fillMode != CrossPointSettings::SLEEP_SCREEN_LETTERBOX_FILL::LETTERBOX_NONE);
|
||||||
|
|
||||||
|
static const char* fillModeNames[] = {"dithered", "solid", "none"};
|
||||||
|
const char* fillModeName = (fillMode < 3) ? fillModeNames[fillMode] : "unknown";
|
||||||
|
|
||||||
|
// Compute edge averages if letterbox fill is requested (try cache first)
|
||||||
|
LetterboxFillData fillData;
|
||||||
|
const bool hasLetterbox = (x > 0 || y > 0);
|
||||||
|
if (hasLetterbox && wantFill) {
|
||||||
|
bool cacheLoaded = false;
|
||||||
|
if (!edgeCachePath.empty()) {
|
||||||
|
cacheLoaded = loadEdgeCache(edgeCachePath, pageWidth, pageHeight, fillData);
|
||||||
|
}
|
||||||
|
if (!cacheLoaded) {
|
||||||
|
LOG_DBG("SLP", "Letterbox detected (x=%d, y=%d), computing edge averages for %s fill", x, y, fillModeName);
|
||||||
|
fillData = computeEdgeAverages(bitmap, x, y, pageWidth, pageHeight, scale, cropX, cropY);
|
||||||
|
if (fillData.valid && !edgeCachePath.empty()) {
|
||||||
|
saveEdgeCache(edgeCachePath, pageWidth, pageHeight, fillData);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (fillData.valid) {
|
||||||
|
LOG_DBG("SLP", "Letterbox fill: %s, horizontal=%d, avgA=%d, avgB=%d, letterboxA=%d, letterboxB=%d",
|
||||||
|
fillModeName, fillData.horizontal, fillData.avgA, fillData.avgB, fillData.letterboxA,
|
||||||
|
fillData.letterboxB);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
renderer.clearScreen();
|
renderer.clearScreen();
|
||||||
|
|
||||||
const bool hasGreyscale = bitmap.hasGreyscale() &&
|
const bool hasGreyscale = bitmap.hasGreyscale() &&
|
||||||
SETTINGS.sleepScreenCoverFilter == CrossPointSettings::SLEEP_SCREEN_COVER_FILTER::NO_FILTER;
|
SETTINGS.sleepScreenCoverFilter == CrossPointSettings::SLEEP_SCREEN_COVER_FILTER::NO_FILTER;
|
||||||
|
|
||||||
|
// Draw letterbox fill (BW pass)
|
||||||
|
if (fillData.valid) {
|
||||||
|
drawLetterboxFill(renderer, fillData, fillMode);
|
||||||
|
}
|
||||||
|
|
||||||
renderer.drawBitmap(bitmap, x, y, pageWidth, pageHeight, cropX, cropY);
|
renderer.drawBitmap(bitmap, x, y, pageWidth, pageHeight, cropX, cropY);
|
||||||
|
|
||||||
if (SETTINGS.sleepScreenCoverFilter == CrossPointSettings::SLEEP_SCREEN_COVER_FILTER::INVERTED_BLACK_AND_WHITE) {
|
if (SETTINGS.sleepScreenCoverFilter == CrossPointSettings::SLEEP_SCREEN_COVER_FILTER::INVERTED_BLACK_AND_WHITE) {
|
||||||
@@ -179,12 +549,18 @@ void SleepActivity::renderBitmapSleepScreen(const Bitmap& bitmap) const {
|
|||||||
bitmap.rewindToData();
|
bitmap.rewindToData();
|
||||||
renderer.clearScreen(0x00);
|
renderer.clearScreen(0x00);
|
||||||
renderer.setRenderMode(GfxRenderer::GRAYSCALE_LSB);
|
renderer.setRenderMode(GfxRenderer::GRAYSCALE_LSB);
|
||||||
|
if (fillData.valid) {
|
||||||
|
drawLetterboxFill(renderer, fillData, fillMode);
|
||||||
|
}
|
||||||
renderer.drawBitmap(bitmap, x, y, pageWidth, pageHeight, cropX, cropY);
|
renderer.drawBitmap(bitmap, x, y, pageWidth, pageHeight, cropX, cropY);
|
||||||
renderer.copyGrayscaleLsbBuffers();
|
renderer.copyGrayscaleLsbBuffers();
|
||||||
|
|
||||||
bitmap.rewindToData();
|
bitmap.rewindToData();
|
||||||
renderer.clearScreen(0x00);
|
renderer.clearScreen(0x00);
|
||||||
renderer.setRenderMode(GfxRenderer::GRAYSCALE_MSB);
|
renderer.setRenderMode(GfxRenderer::GRAYSCALE_MSB);
|
||||||
|
if (fillData.valid) {
|
||||||
|
drawLetterboxFill(renderer, fillData, fillMode);
|
||||||
|
}
|
||||||
renderer.drawBitmap(bitmap, x, y, pageWidth, pageHeight, cropX, cropY);
|
renderer.drawBitmap(bitmap, x, y, pageWidth, pageHeight, cropX, cropY);
|
||||||
renderer.copyGrayscaleMsbBuffers();
|
renderer.copyGrayscaleMsbBuffers();
|
||||||
|
|
||||||
@@ -209,6 +585,7 @@ void SleepActivity::renderCoverSleepScreen() const {
|
|||||||
}
|
}
|
||||||
|
|
||||||
std::string coverBmpPath;
|
std::string coverBmpPath;
|
||||||
|
std::string bookCachePath;
|
||||||
bool cropped = SETTINGS.sleepScreenCoverMode == CrossPointSettings::SLEEP_SCREEN_COVER_MODE::CROP;
|
bool cropped = SETTINGS.sleepScreenCoverMode == CrossPointSettings::SLEEP_SCREEN_COVER_MODE::CROP;
|
||||||
|
|
||||||
// Check if the current book is XTC, TXT, or EPUB
|
// Check if the current book is XTC, TXT, or EPUB
|
||||||
@@ -227,6 +604,7 @@ void SleepActivity::renderCoverSleepScreen() const {
|
|||||||
}
|
}
|
||||||
|
|
||||||
coverBmpPath = lastXtc.getCoverBmpPath();
|
coverBmpPath = lastXtc.getCoverBmpPath();
|
||||||
|
bookCachePath = lastXtc.getCachePath();
|
||||||
} else if (StringUtils::checkFileExtension(APP_STATE.openEpubPath, ".txt")) {
|
} else if (StringUtils::checkFileExtension(APP_STATE.openEpubPath, ".txt")) {
|
||||||
// Handle TXT file - looks for cover image in the same folder
|
// Handle TXT file - looks for cover image in the same folder
|
||||||
Txt lastTxt(APP_STATE.openEpubPath, "/.crosspoint");
|
Txt lastTxt(APP_STATE.openEpubPath, "/.crosspoint");
|
||||||
@@ -241,6 +619,7 @@ void SleepActivity::renderCoverSleepScreen() const {
|
|||||||
}
|
}
|
||||||
|
|
||||||
coverBmpPath = lastTxt.getCoverBmpPath();
|
coverBmpPath = lastTxt.getCoverBmpPath();
|
||||||
|
bookCachePath = lastTxt.getCachePath();
|
||||||
} else if (StringUtils::checkFileExtension(APP_STATE.openEpubPath, ".epub")) {
|
} else if (StringUtils::checkFileExtension(APP_STATE.openEpubPath, ".epub")) {
|
||||||
// Handle EPUB file
|
// Handle EPUB file
|
||||||
Epub lastEpub(APP_STATE.openEpubPath, "/.crosspoint");
|
Epub lastEpub(APP_STATE.openEpubPath, "/.crosspoint");
|
||||||
@@ -256,16 +635,30 @@ void SleepActivity::renderCoverSleepScreen() const {
|
|||||||
}
|
}
|
||||||
|
|
||||||
coverBmpPath = lastEpub.getCoverBmpPath(cropped);
|
coverBmpPath = lastEpub.getCoverBmpPath(cropped);
|
||||||
|
bookCachePath = lastEpub.getCachePath();
|
||||||
} else {
|
} else {
|
||||||
return (this->*renderNoCoverSleepScreen)();
|
return (this->*renderNoCoverSleepScreen)();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Load per-book letterbox fill override (falls back to global if not set)
|
||||||
|
uint8_t fillModeOverride = BookSettings::USE_GLOBAL;
|
||||||
|
if (!bookCachePath.empty()) {
|
||||||
|
auto bookSettings = BookSettings::load(bookCachePath);
|
||||||
|
fillModeOverride = bookSettings.letterboxFillOverride;
|
||||||
|
}
|
||||||
|
|
||||||
FsFile file;
|
FsFile file;
|
||||||
if (Storage.openFileForRead("SLP", coverBmpPath, file)) {
|
if (Storage.openFileForRead("SLP", coverBmpPath, file)) {
|
||||||
Bitmap bitmap(file);
|
Bitmap bitmap(file);
|
||||||
if (bitmap.parseHeaders() == BmpReaderError::Ok) {
|
if (bitmap.parseHeaders() == BmpReaderError::Ok) {
|
||||||
LOG_DBG("SLP", "Rendering sleep cover: %s", coverBmpPath.c_str());
|
LOG_DBG("SLP", "Rendering sleep cover: %s", coverBmpPath.c_str());
|
||||||
renderBitmapSleepScreen(bitmap);
|
// Derive edge cache path from cover BMP path (e.g. cover.bmp -> cover_edges.bin)
|
||||||
|
std::string edgeCachePath;
|
||||||
|
const auto dotPos = coverBmpPath.rfind(".bmp");
|
||||||
|
if (dotPos != std::string::npos) {
|
||||||
|
edgeCachePath = coverBmpPath.substr(0, dotPos) + "_edges.bin";
|
||||||
|
}
|
||||||
|
renderBitmapSleepScreen(bitmap, edgeCachePath, fillModeOverride);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,4 +1,7 @@
|
|||||||
#pragma once
|
#pragma once
|
||||||
|
|
||||||
|
#include <string>
|
||||||
|
|
||||||
#include "../Activity.h"
|
#include "../Activity.h"
|
||||||
|
|
||||||
class Bitmap;
|
class Bitmap;
|
||||||
@@ -13,6 +16,8 @@ class SleepActivity final : public Activity {
|
|||||||
void renderDefaultSleepScreen() const;
|
void renderDefaultSleepScreen() const;
|
||||||
void renderCustomSleepScreen() const;
|
void renderCustomSleepScreen() const;
|
||||||
void renderCoverSleepScreen() const;
|
void renderCoverSleepScreen() const;
|
||||||
void renderBitmapSleepScreen(const Bitmap& bitmap) const;
|
// fillModeOverride: 0xFF = use global setting, otherwise a SLEEP_SCREEN_LETTERBOX_FILL value.
|
||||||
|
void renderBitmapSleepScreen(const Bitmap& bitmap, const std::string& edgeCachePath = "",
|
||||||
|
uint8_t fillModeOverride = 0xFF) const;
|
||||||
void renderBlankSleepScreen() const;
|
void renderBlankSleepScreen() const;
|
||||||
};
|
};
|
||||||
|
|||||||
537
src/activities/reader/DictionaryDefinitionActivity.cpp
Normal file
537
src/activities/reader/DictionaryDefinitionActivity.cpp
Normal file
@@ -0,0 +1,537 @@
|
|||||||
|
#include "DictionaryDefinitionActivity.h"
|
||||||
|
|
||||||
|
#include <GfxRenderer.h>
|
||||||
|
|
||||||
|
#include <algorithm>
|
||||||
|
#include <cctype>
|
||||||
|
#include <cstdlib>
|
||||||
|
|
||||||
|
#include "CrossPointSettings.h"
|
||||||
|
#include "MappedInputManager.h"
|
||||||
|
#include "components/UITheme.h"
|
||||||
|
#include "fontIds.h"
|
||||||
|
|
||||||
|
void DictionaryDefinitionActivity::taskTrampoline(void* param) {
|
||||||
|
auto* self = static_cast<DictionaryDefinitionActivity*>(param);
|
||||||
|
self->displayTaskLoop();
|
||||||
|
}
|
||||||
|
|
||||||
|
void DictionaryDefinitionActivity::displayTaskLoop() {
|
||||||
|
while (true) {
|
||||||
|
if (updateRequired) {
|
||||||
|
updateRequired = false;
|
||||||
|
xSemaphoreTake(renderingMutex, portMAX_DELAY);
|
||||||
|
renderScreen();
|
||||||
|
xSemaphoreGive(renderingMutex);
|
||||||
|
}
|
||||||
|
vTaskDelay(10 / portTICK_PERIOD_MS);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
void DictionaryDefinitionActivity::onEnter() {
|
||||||
|
Activity::onEnter();
|
||||||
|
renderingMutex = xSemaphoreCreateMutex();
|
||||||
|
wrapText();
|
||||||
|
updateRequired = true;
|
||||||
|
xTaskCreate(&DictionaryDefinitionActivity::taskTrampoline, "DictDefTask", 4096, this, 1, &displayTaskHandle);
|
||||||
|
}
|
||||||
|
|
||||||
|
void DictionaryDefinitionActivity::onExit() {
|
||||||
|
Activity::onExit();
|
||||||
|
xSemaphoreTake(renderingMutex, portMAX_DELAY);
|
||||||
|
if (displayTaskHandle) {
|
||||||
|
vTaskDelete(displayTaskHandle);
|
||||||
|
displayTaskHandle = nullptr;
|
||||||
|
}
|
||||||
|
vSemaphoreDelete(renderingMutex);
|
||||||
|
renderingMutex = nullptr;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Check if a Unicode codepoint is likely renderable by the e-ink bitmap font.
|
||||||
|
// Keeps Latin text, combining marks, common punctuation, currency, and letterlike symbols.
|
||||||
|
// Skips IPA extensions, Greek, Cyrillic, Arabic, CJK, and other non-Latin scripts.
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
bool DictionaryDefinitionActivity::isRenderableCodepoint(uint32_t cp) {
|
||||||
|
if (cp <= 0x024F) return true; // Basic Latin + Latin Extended-A/B
|
||||||
|
if (cp >= 0x0300 && cp <= 0x036F) return true; // Combining Diacritical Marks
|
||||||
|
if (cp >= 0x2000 && cp <= 0x206F) return true; // General Punctuation
|
||||||
|
if (cp >= 0x20A0 && cp <= 0x20CF) return true; // Currency Symbols
|
||||||
|
if (cp >= 0x2100 && cp <= 0x214F) return true; // Letterlike Symbols
|
||||||
|
if (cp >= 0x2190 && cp <= 0x21FF) return true; // Arrows
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// HTML entity decoder
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
std::string DictionaryDefinitionActivity::decodeEntity(const std::string& entity) {
|
||||||
|
// Named entities
|
||||||
|
if (entity == "amp") return "&";
|
||||||
|
if (entity == "lt") return "<";
|
||||||
|
if (entity == "gt") return ">";
|
||||||
|
if (entity == "quot") return "\"";
|
||||||
|
if (entity == "apos") return "'";
|
||||||
|
if (entity == "nbsp" || entity == "thinsp" || entity == "ensp" || entity == "emsp") return " ";
|
||||||
|
if (entity == "ndash") return "\xE2\x80\x93"; // U+2013
|
||||||
|
if (entity == "mdash") return "\xE2\x80\x94"; // U+2014
|
||||||
|
if (entity == "lsquo") return "\xE2\x80\x98";
|
||||||
|
if (entity == "rsquo") return "\xE2\x80\x99";
|
||||||
|
if (entity == "ldquo") return "\xE2\x80\x9C";
|
||||||
|
if (entity == "rdquo") return "\xE2\x80\x9D";
|
||||||
|
if (entity == "hellip") return "\xE2\x80\xA6";
|
||||||
|
if (entity == "lrm" || entity == "rlm" || entity == "zwj" || entity == "zwnj") return "";
|
||||||
|
|
||||||
|
// Numeric entities: { or 
|
||||||
|
if (!entity.empty() && entity[0] == '#') {
|
||||||
|
unsigned long cp = 0;
|
||||||
|
if (entity.size() > 1 && (entity[1] == 'x' || entity[1] == 'X')) {
|
||||||
|
cp = std::strtoul(entity.c_str() + 2, nullptr, 16);
|
||||||
|
} else {
|
||||||
|
cp = std::strtoul(entity.c_str() + 1, nullptr, 10);
|
||||||
|
}
|
||||||
|
if (cp > 0 && cp < 0x80) {
|
||||||
|
return std::string(1, static_cast<char>(cp));
|
||||||
|
}
|
||||||
|
if (cp >= 0x80 && cp < 0x800) {
|
||||||
|
char buf[3] = {static_cast<char>(0xC0 | (cp >> 6)), static_cast<char>(0x80 | (cp & 0x3F)), '\0'};
|
||||||
|
return std::string(buf, 2);
|
||||||
|
}
|
||||||
|
if (cp >= 0x800 && cp < 0x10000) {
|
||||||
|
char buf[4] = {static_cast<char>(0xE0 | (cp >> 12)), static_cast<char>(0x80 | ((cp >> 6) & 0x3F)),
|
||||||
|
static_cast<char>(0x80 | (cp & 0x3F)), '\0'};
|
||||||
|
return std::string(buf, 3);
|
||||||
|
}
|
||||||
|
if (cp >= 0x10000 && cp < 0x110000) {
|
||||||
|
char buf[5] = {static_cast<char>(0xF0 | (cp >> 18)), static_cast<char>(0x80 | ((cp >> 12) & 0x3F)),
|
||||||
|
static_cast<char>(0x80 | ((cp >> 6) & 0x3F)), static_cast<char>(0x80 | (cp & 0x3F)), '\0'};
|
||||||
|
return std::string(buf, 4);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return ""; // unknown entity — drop it
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// HTML → TextAtom list
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
std::vector<DictionaryDefinitionActivity::TextAtom> DictionaryDefinitionActivity::parseHtml(const std::string& html) {
|
||||||
|
std::vector<TextAtom> atoms;
|
||||||
|
|
||||||
|
bool isBold = false;
|
||||||
|
bool isItalic = false;
|
||||||
|
bool inSvg = false;
|
||||||
|
int svgDepth = 0;
|
||||||
|
std::vector<ListState> listStack;
|
||||||
|
std::string currentWord;
|
||||||
|
|
||||||
|
auto currentStyle = [&]() -> EpdFontFamily::Style {
|
||||||
|
if (isBold && isItalic) return EpdFontFamily::BOLD_ITALIC;
|
||||||
|
if (isBold) return EpdFontFamily::BOLD;
|
||||||
|
if (isItalic) return EpdFontFamily::ITALIC;
|
||||||
|
return EpdFontFamily::REGULAR;
|
||||||
|
};
|
||||||
|
|
||||||
|
auto flushWord = [&]() {
|
||||||
|
if (!currentWord.empty() && !inSvg) {
|
||||||
|
atoms.push_back({currentWord, currentStyle(), false, 0});
|
||||||
|
currentWord.clear();
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
auto indentPx = [&]() -> int {
|
||||||
|
// 15 pixels per nesting level (the first level has no extra indent)
|
||||||
|
int depth = static_cast<int>(listStack.size());
|
||||||
|
return (depth > 1) ? (depth - 1) * 15 : 0;
|
||||||
|
};
|
||||||
|
|
||||||
|
// Skip any leading non-HTML text (e.g. pronunciation guides like "/ˈsɪm.pəl/, /ˈsɪmpəl/")
|
||||||
|
// that appears before the first tag in sametypesequence=h entries.
|
||||||
|
size_t i = 0;
|
||||||
|
{
|
||||||
|
size_t firstTag = html.find('<');
|
||||||
|
if (firstTag != std::string::npos) i = firstTag;
|
||||||
|
}
|
||||||
|
|
||||||
|
while (i < html.size()) {
|
||||||
|
// ------- HTML tag -------
|
||||||
|
if (html[i] == '<') {
|
||||||
|
flushWord();
|
||||||
|
|
||||||
|
size_t tagEnd = html.find('>', i);
|
||||||
|
if (tagEnd == std::string::npos) break;
|
||||||
|
|
||||||
|
std::string tagContent = html.substr(i + 1, tagEnd - i - 1);
|
||||||
|
|
||||||
|
// Extract tag name: first token, lowercased, trailing '/' stripped.
|
||||||
|
size_t space = tagContent.find(' ');
|
||||||
|
std::string tagName = (space != std::string::npos) ? tagContent.substr(0, space) : tagContent;
|
||||||
|
for (auto& c : tagName) c = static_cast<char>(std::tolower(static_cast<unsigned char>(c)));
|
||||||
|
if (!tagName.empty() && tagName.back() == '/') tagName.pop_back();
|
||||||
|
|
||||||
|
// --- SVG handling (skip all content inside <svg>…</svg>) ---
|
||||||
|
if (tagName == "svg") {
|
||||||
|
inSvg = true;
|
||||||
|
svgDepth = 1;
|
||||||
|
} else if (inSvg) {
|
||||||
|
if (tagName == "svg") {
|
||||||
|
svgDepth++;
|
||||||
|
} else if (tagName == "/svg") {
|
||||||
|
svgDepth--;
|
||||||
|
if (svgDepth <= 0) inSvg = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!inSvg) {
|
||||||
|
// --- Inline style tags ---
|
||||||
|
if (tagName == "b" || tagName == "strong") {
|
||||||
|
isBold = true;
|
||||||
|
} else if (tagName == "/b" || tagName == "/strong") {
|
||||||
|
isBold = false;
|
||||||
|
} else if (tagName == "i" || tagName == "em") {
|
||||||
|
isItalic = true;
|
||||||
|
} else if (tagName == "/i" || tagName == "/em") {
|
||||||
|
isItalic = false;
|
||||||
|
|
||||||
|
// --- Block-level tags → newlines ---
|
||||||
|
} else if (tagName == "p" || tagName == "h1" || tagName == "h2" || tagName == "h3" || tagName == "h4") {
|
||||||
|
atoms.push_back({"", EpdFontFamily::REGULAR, true, indentPx()});
|
||||||
|
// Headings get bold style applied to following text
|
||||||
|
if (tagName != "p") isBold = true;
|
||||||
|
} else if (tagName == "/p" || tagName == "/h1" || tagName == "/h2" || tagName == "/h3" || tagName == "/h4") {
|
||||||
|
atoms.push_back({"", EpdFontFamily::REGULAR, true, indentPx()});
|
||||||
|
isBold = false;
|
||||||
|
} else if (tagName == "br") {
|
||||||
|
atoms.push_back({"", EpdFontFamily::REGULAR, true, indentPx()});
|
||||||
|
|
||||||
|
// --- Separator between definition entries ---
|
||||||
|
} else if (tagName == "/html") {
|
||||||
|
atoms.push_back({"", EpdFontFamily::REGULAR, true, 0});
|
||||||
|
atoms.push_back({"", EpdFontFamily::REGULAR, true, 0}); // extra blank line
|
||||||
|
isBold = false;
|
||||||
|
isItalic = false;
|
||||||
|
// Skip any raw text between </html> and the next tag — this is where
|
||||||
|
// pronunciation guides (e.g. /ˈsɪmpəl/, /ksɛpt/) live in this dictionary.
|
||||||
|
size_t nextTag = html.find('<', tagEnd + 1);
|
||||||
|
i = (nextTag != std::string::npos) ? nextTag : html.size();
|
||||||
|
continue;
|
||||||
|
|
||||||
|
// --- Lists ---
|
||||||
|
} else if (tagName == "ol") {
|
||||||
|
bool alpha = tagContent.find("lower-alpha") != std::string::npos;
|
||||||
|
listStack.push_back({0, alpha});
|
||||||
|
} else if (tagName == "ul") {
|
||||||
|
listStack.push_back({0, false});
|
||||||
|
} else if (tagName == "/ol" || tagName == "/ul") {
|
||||||
|
if (!listStack.empty()) listStack.pop_back();
|
||||||
|
} else if (tagName == "li") {
|
||||||
|
atoms.push_back({"", EpdFontFamily::REGULAR, true, indentPx()});
|
||||||
|
if (!listStack.empty()) {
|
||||||
|
auto& ls = listStack.back();
|
||||||
|
ls.counter++;
|
||||||
|
std::string marker;
|
||||||
|
if (ls.isAlpha && ls.counter >= 1 && ls.counter <= 26) {
|
||||||
|
marker = std::string(1, static_cast<char>('a' + ls.counter - 1)) + ". ";
|
||||||
|
} else if (ls.isAlpha) {
|
||||||
|
marker = std::to_string(ls.counter) + ". ";
|
||||||
|
} else {
|
||||||
|
marker = std::to_string(ls.counter) + ". ";
|
||||||
|
}
|
||||||
|
atoms.push_back({marker, EpdFontFamily::REGULAR, false, 0});
|
||||||
|
} else {
|
||||||
|
// Unordered list or bare <li>
|
||||||
|
atoms.push_back({"\xE2\x80\xA2 ", EpdFontFamily::REGULAR, false, 0});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// All other tags (span, div, code, sup, sub, table, etc.) are silently ignored;
|
||||||
|
// their text content will still be emitted.
|
||||||
|
}
|
||||||
|
|
||||||
|
i = tagEnd + 1;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Skip content inside SVG
|
||||||
|
if (inSvg) {
|
||||||
|
i++;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ------- HTML entity -------
|
||||||
|
if (html[i] == '&') {
|
||||||
|
size_t semicolon = html.find(';', i);
|
||||||
|
if (semicolon != std::string::npos && semicolon - i < 16) {
|
||||||
|
std::string entity = html.substr(i + 1, semicolon - i - 1);
|
||||||
|
std::string decoded = decodeEntity(entity);
|
||||||
|
if (!decoded.empty()) {
|
||||||
|
// Treat decoded chars like normal text (could be space etc.)
|
||||||
|
for (char dc : decoded) {
|
||||||
|
if (dc == ' ') {
|
||||||
|
flushWord();
|
||||||
|
} else {
|
||||||
|
currentWord += dc;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
i = semicolon + 1;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
// Not a valid entity — emit '&' literally
|
||||||
|
currentWord += '&';
|
||||||
|
i++;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ------- IPA pronunciation (skip /…/ and […] containing non-ASCII) -------
|
||||||
|
if (html[i] == '/' || html[i] == '[') {
|
||||||
|
char closeDelim = (html[i] == '/') ? '/' : ']';
|
||||||
|
size_t end = html.find(closeDelim, i + 1);
|
||||||
|
if (end != std::string::npos && end - i < 80) {
|
||||||
|
bool hasNonAscii = false;
|
||||||
|
for (size_t j = i + 1; j < end; j++) {
|
||||||
|
if (static_cast<unsigned char>(html[j]) > 127) {
|
||||||
|
hasNonAscii = true;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (hasNonAscii) {
|
||||||
|
flushWord();
|
||||||
|
i = end + 1; // skip entire IPA section including delimiters
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// Not IPA — fall through to treat as regular character
|
||||||
|
}
|
||||||
|
|
||||||
|
// ------- Whitespace -------
|
||||||
|
if (html[i] == ' ' || html[i] == '\t' || html[i] == '\n' || html[i] == '\r') {
|
||||||
|
flushWord();
|
||||||
|
i++;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ------- Regular character (with non-renderable character filter) -------
|
||||||
|
{
|
||||||
|
unsigned char byte = static_cast<unsigned char>(html[i]);
|
||||||
|
if (byte < 0x80) {
|
||||||
|
// ASCII — always renderable
|
||||||
|
currentWord += html[i];
|
||||||
|
i++;
|
||||||
|
} else {
|
||||||
|
// Multi-byte UTF-8: decode codepoint and check if renderable
|
||||||
|
int seqLen = 1;
|
||||||
|
uint32_t cp = 0;
|
||||||
|
if ((byte & 0xE0) == 0xC0) {
|
||||||
|
seqLen = 2;
|
||||||
|
cp = byte & 0x1F;
|
||||||
|
} else if ((byte & 0xF0) == 0xE0) {
|
||||||
|
seqLen = 3;
|
||||||
|
cp = byte & 0x0F;
|
||||||
|
} else if ((byte & 0xF8) == 0xF0) {
|
||||||
|
seqLen = 4;
|
||||||
|
cp = byte & 0x07;
|
||||||
|
} else {
|
||||||
|
i++;
|
||||||
|
continue;
|
||||||
|
} // invalid start byte
|
||||||
|
|
||||||
|
if (i + static_cast<size_t>(seqLen) > html.size()) {
|
||||||
|
i++;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
bool valid = true;
|
||||||
|
for (int j = 1; j < seqLen; j++) {
|
||||||
|
unsigned char cb = static_cast<unsigned char>(html[i + j]);
|
||||||
|
if ((cb & 0xC0) != 0x80) {
|
||||||
|
valid = false;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
cp = (cp << 6) | (cb & 0x3F);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (valid && isRenderableCodepoint(cp)) {
|
||||||
|
for (int j = 0; j < seqLen; j++) {
|
||||||
|
currentWord += html[i + j];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// else: silently skip non-renderable character
|
||||||
|
|
||||||
|
i += valid ? seqLen : 1;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
flushWord();
|
||||||
|
return atoms;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Word-wrap the parsed HTML atoms into positioned line segments
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
void DictionaryDefinitionActivity::wrapText() {
|
||||||
|
wrappedLines.clear();
|
||||||
|
|
||||||
|
const bool landscape = orientation == CrossPointSettings::ORIENTATION::LANDSCAPE_CW ||
|
||||||
|
orientation == CrossPointSettings::ORIENTATION::LANDSCAPE_CCW;
|
||||||
|
const int screenWidth = renderer.getScreenWidth();
|
||||||
|
const int lineHeight = renderer.getLineHeight(readerFontId);
|
||||||
|
const int sidePadding = landscape ? 50 : 20;
|
||||||
|
constexpr int topArea = 50;
|
||||||
|
constexpr int bottomArea = 50;
|
||||||
|
const int maxWidth = screenWidth - 2 * sidePadding;
|
||||||
|
const int spaceWidth = renderer.getSpaceWidth(readerFontId);
|
||||||
|
|
||||||
|
linesPerPage = (renderer.getScreenHeight() - topArea - bottomArea) / lineHeight;
|
||||||
|
if (linesPerPage < 1) linesPerPage = 1;
|
||||||
|
|
||||||
|
auto atoms = parseHtml(definition);
|
||||||
|
|
||||||
|
std::vector<Segment> currentLine;
|
||||||
|
int currentX = 0;
|
||||||
|
int baseIndent = 0; // indent for continuation lines within the same block
|
||||||
|
|
||||||
|
for (const auto& atom : atoms) {
|
||||||
|
// ---- Newline directive ----
|
||||||
|
if (atom.isNewline) {
|
||||||
|
// Collapse multiple consecutive blank lines
|
||||||
|
if (currentLine.empty() && !wrappedLines.empty() && wrappedLines.back().empty()) {
|
||||||
|
// Already have a blank line; update indent but don't push another
|
||||||
|
baseIndent = atom.indent;
|
||||||
|
currentX = baseIndent;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
wrappedLines.push_back(std::move(currentLine));
|
||||||
|
currentLine.clear();
|
||||||
|
baseIndent = atom.indent;
|
||||||
|
currentX = baseIndent;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---- Text word ----
|
||||||
|
int wordWidth = renderer.getTextWidth(readerFontId, atom.text.c_str(), atom.style);
|
||||||
|
int gap = (currentX > baseIndent) ? spaceWidth : 0;
|
||||||
|
|
||||||
|
// Wrap if this word won't fit
|
||||||
|
if (currentX + gap + wordWidth > maxWidth && currentX > baseIndent) {
|
||||||
|
wrappedLines.push_back(std::move(currentLine));
|
||||||
|
currentLine.clear();
|
||||||
|
currentX = baseIndent;
|
||||||
|
gap = 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
int16_t x = static_cast<int16_t>(currentX + gap);
|
||||||
|
currentLine.push_back({atom.text, x, atom.style});
|
||||||
|
currentX = x + wordWidth;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Flush last line
|
||||||
|
if (!currentLine.empty()) {
|
||||||
|
wrappedLines.push_back(std::move(currentLine));
|
||||||
|
}
|
||||||
|
|
||||||
|
totalPages = (static_cast<int>(wrappedLines.size()) + linesPerPage - 1) / linesPerPage;
|
||||||
|
if (totalPages < 1) totalPages = 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
void DictionaryDefinitionActivity::loop() {
|
||||||
|
const bool prevPage = mappedInput.wasReleased(MappedInputManager::Button::PageBack) ||
|
||||||
|
mappedInput.wasReleased(MappedInputManager::Button::Left);
|
||||||
|
const bool nextPage = mappedInput.wasReleased(MappedInputManager::Button::PageForward) ||
|
||||||
|
mappedInput.wasReleased(MappedInputManager::Button::Right);
|
||||||
|
|
||||||
|
if (prevPage && currentPage > 0) {
|
||||||
|
currentPage--;
|
||||||
|
updateRequired = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (nextPage && currentPage < totalPages - 1) {
|
||||||
|
currentPage++;
|
||||||
|
updateRequired = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (mappedInput.wasReleased(MappedInputManager::Button::Back) ||
|
||||||
|
mappedInput.wasReleased(MappedInputManager::Button::Confirm)) {
|
||||||
|
onBack();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
void DictionaryDefinitionActivity::renderScreen() {
|
||||||
|
renderer.clearScreen();
|
||||||
|
|
||||||
|
const bool landscape = orientation == CrossPointSettings::ORIENTATION::LANDSCAPE_CW ||
|
||||||
|
orientation == CrossPointSettings::ORIENTATION::LANDSCAPE_CCW;
|
||||||
|
const int sidePadding = landscape ? 50 : 20;
|
||||||
|
constexpr int titleY = 10;
|
||||||
|
const int lineHeight = renderer.getLineHeight(readerFontId);
|
||||||
|
constexpr int bodyStartY = 50;
|
||||||
|
|
||||||
|
// Title: the word in bold (UI font)
|
||||||
|
renderer.drawText(UI_12_FONT_ID, sidePadding, titleY, headword.c_str(), true, EpdFontFamily::BOLD);
|
||||||
|
|
||||||
|
// Separator line
|
||||||
|
renderer.drawLine(sidePadding, 40, renderer.getScreenWidth() - sidePadding, 40);
|
||||||
|
|
||||||
|
// Body: styled definition lines
|
||||||
|
int startLine = currentPage * linesPerPage;
|
||||||
|
for (int i = 0; i < linesPerPage && (startLine + i) < static_cast<int>(wrappedLines.size()); i++) {
|
||||||
|
int y = bodyStartY + i * lineHeight;
|
||||||
|
const auto& line = wrappedLines[startLine + i];
|
||||||
|
for (const auto& seg : line) {
|
||||||
|
renderer.drawText(readerFontId, sidePadding + seg.x, y, seg.text.c_str(), true, seg.style);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Pagination indicator (bottom right)
|
||||||
|
if (totalPages > 1) {
|
||||||
|
std::string pageInfo = std::to_string(currentPage + 1) + "/" + std::to_string(totalPages);
|
||||||
|
int textWidth = renderer.getTextWidth(SMALL_FONT_ID, pageInfo.c_str());
|
||||||
|
renderer.drawText(SMALL_FONT_ID, renderer.getScreenWidth() - sidePadding - textWidth,
|
||||||
|
renderer.getScreenHeight() - 50, pageInfo.c_str());
|
||||||
|
}
|
||||||
|
|
||||||
|
// Button hints (bottom face buttons — hide Confirm stub like Home Screen)
|
||||||
|
const auto labels = mappedInput.mapLabels("\xC2\xAB Back", "", "\xC2\xAB Page", "Page \xC2\xBB");
|
||||||
|
GUI.drawButtonHints(renderer, labels.btn1, labels.btn2, labels.btn3, labels.btn4);
|
||||||
|
|
||||||
|
// Side button hints (drawn in portrait coordinates for correct placement)
|
||||||
|
{
|
||||||
|
const auto origOrientation = renderer.getOrientation();
|
||||||
|
renderer.setOrientation(GfxRenderer::Orientation::Portrait);
|
||||||
|
const int portW = renderer.getScreenWidth();
|
||||||
|
|
||||||
|
constexpr int sideButtonWidth = 30;
|
||||||
|
constexpr int sideButtonHeight = 78;
|
||||||
|
constexpr int sideButtonGap = 5;
|
||||||
|
constexpr int sideTopY = 345;
|
||||||
|
constexpr int cornerRadius = 6;
|
||||||
|
const int sideX = portW - sideButtonWidth;
|
||||||
|
const int sideButtonY[2] = {sideTopY, sideTopY + sideButtonHeight + sideButtonGap};
|
||||||
|
const char* sideLabels[2] = {"\xC2\xAB Page", "Page \xC2\xBB"};
|
||||||
|
const bool useCCW = (orientation == CrossPointSettings::ORIENTATION::LANDSCAPE_CCW);
|
||||||
|
|
||||||
|
for (int i = 0; i < 2; i++) {
|
||||||
|
renderer.fillRect(sideX, sideButtonY[i], sideButtonWidth, sideButtonHeight, false);
|
||||||
|
renderer.drawRoundedRect(sideX, sideButtonY[i], sideButtonWidth, sideButtonHeight, 1, cornerRadius, true, false,
|
||||||
|
true, false, true);
|
||||||
|
|
||||||
|
const std::string truncated = renderer.truncatedText(SMALL_FONT_ID, sideLabels[i], sideButtonHeight);
|
||||||
|
const int tw = renderer.getTextWidth(SMALL_FONT_ID, truncated.c_str());
|
||||||
|
|
||||||
|
if (useCCW) {
|
||||||
|
renderer.drawTextRotated90CCW(SMALL_FONT_ID, sideX,
|
||||||
|
sideButtonY[i] + (sideButtonHeight - tw) / 2, truncated.c_str());
|
||||||
|
} else {
|
||||||
|
renderer.drawTextRotated90CW(SMALL_FONT_ID, sideX,
|
||||||
|
sideButtonY[i] + (sideButtonHeight + tw) / 2, truncated.c_str());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
renderer.setOrientation(origOrientation);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Use half refresh when entering the screen for cleaner transition; fast refresh for page turns.
|
||||||
|
renderer.displayBuffer(firstRender ? HalDisplay::HALF_REFRESH : HalDisplay::FAST_REFRESH);
|
||||||
|
firstRender = false;
|
||||||
|
}
|
||||||
74
src/activities/reader/DictionaryDefinitionActivity.h
Normal file
74
src/activities/reader/DictionaryDefinitionActivity.h
Normal file
@@ -0,0 +1,74 @@
|
|||||||
|
#pragma once
|
||||||
|
#include <EpdFontFamily.h>
|
||||||
|
#include <freertos/FreeRTOS.h>
|
||||||
|
#include <freertos/semphr.h>
|
||||||
|
#include <freertos/task.h>
|
||||||
|
|
||||||
|
#include <functional>
|
||||||
|
#include <string>
|
||||||
|
#include <vector>
|
||||||
|
|
||||||
|
#include "../Activity.h"
|
||||||
|
|
||||||
|
class DictionaryDefinitionActivity final : public Activity {
|
||||||
|
public:
|
||||||
|
explicit DictionaryDefinitionActivity(GfxRenderer& renderer, MappedInputManager& mappedInput,
|
||||||
|
const std::string& headword, const std::string& definition, int readerFontId,
|
||||||
|
uint8_t orientation, const std::function<void()>& onBack)
|
||||||
|
: Activity("DictionaryDefinition", renderer, mappedInput),
|
||||||
|
headword(headword),
|
||||||
|
definition(definition),
|
||||||
|
readerFontId(readerFontId),
|
||||||
|
orientation(orientation),
|
||||||
|
onBack(onBack) {}
|
||||||
|
|
||||||
|
void onEnter() override;
|
||||||
|
void onExit() override;
|
||||||
|
void loop() override;
|
||||||
|
|
||||||
|
private:
|
||||||
|
// A positioned text segment within a wrapped line (pre-calculated x offset and style).
|
||||||
|
struct Segment {
|
||||||
|
std::string text;
|
||||||
|
int16_t x;
|
||||||
|
EpdFontFamily::Style style;
|
||||||
|
};
|
||||||
|
|
||||||
|
// An intermediate token produced by the HTML parser before word-wrapping.
|
||||||
|
struct TextAtom {
|
||||||
|
std::string text;
|
||||||
|
EpdFontFamily::Style style;
|
||||||
|
bool isNewline;
|
||||||
|
int indent; // pixels to indent the new line (for nested lists)
|
||||||
|
};
|
||||||
|
|
||||||
|
// Tracks ordered/unordered list nesting during HTML parsing.
|
||||||
|
struct ListState {
|
||||||
|
int counter; // incremented per <li>, 0 = not yet used
|
||||||
|
bool isAlpha; // true for list-style-type: lower-alpha
|
||||||
|
};
|
||||||
|
|
||||||
|
std::string headword;
|
||||||
|
std::string definition;
|
||||||
|
int readerFontId;
|
||||||
|
uint8_t orientation;
|
||||||
|
const std::function<void()> onBack;
|
||||||
|
|
||||||
|
std::vector<std::vector<Segment>> wrappedLines;
|
||||||
|
int currentPage = 0;
|
||||||
|
int linesPerPage = 0;
|
||||||
|
int totalPages = 0;
|
||||||
|
bool updateRequired = false;
|
||||||
|
bool firstRender = true;
|
||||||
|
|
||||||
|
TaskHandle_t displayTaskHandle = nullptr;
|
||||||
|
SemaphoreHandle_t renderingMutex = nullptr;
|
||||||
|
|
||||||
|
std::vector<TextAtom> parseHtml(const std::string& html);
|
||||||
|
static std::string decodeEntity(const std::string& entity);
|
||||||
|
static bool isRenderableCodepoint(uint32_t cp);
|
||||||
|
void wrapText();
|
||||||
|
void renderScreen();
|
||||||
|
static void taskTrampoline(void* param);
|
||||||
|
[[noreturn]] void displayTaskLoop();
|
||||||
|
};
|
||||||
541
src/activities/reader/DictionaryWordSelectActivity.cpp
Normal file
541
src/activities/reader/DictionaryWordSelectActivity.cpp
Normal file
@@ -0,0 +1,541 @@
|
|||||||
|
#include "DictionaryWordSelectActivity.h"
|
||||||
|
|
||||||
|
#include <GfxRenderer.h>
|
||||||
|
|
||||||
|
#include <algorithm>
|
||||||
|
#include <climits>
|
||||||
|
|
||||||
|
#include "CrossPointSettings.h"
|
||||||
|
#include "MappedInputManager.h"
|
||||||
|
#include "components/UITheme.h"
|
||||||
|
#include "fontIds.h"
|
||||||
|
#include "util/Dictionary.h"
|
||||||
|
#include "util/LookupHistory.h"
|
||||||
|
|
||||||
|
void DictionaryWordSelectActivity::taskTrampoline(void* param) {
|
||||||
|
auto* self = static_cast<DictionaryWordSelectActivity*>(param);
|
||||||
|
self->displayTaskLoop();
|
||||||
|
}
|
||||||
|
|
||||||
|
void DictionaryWordSelectActivity::displayTaskLoop() {
|
||||||
|
while (true) {
|
||||||
|
if (updateRequired) {
|
||||||
|
updateRequired = false;
|
||||||
|
xSemaphoreTake(renderingMutex, portMAX_DELAY);
|
||||||
|
renderScreen();
|
||||||
|
xSemaphoreGive(renderingMutex);
|
||||||
|
}
|
||||||
|
vTaskDelay(10 / portTICK_PERIOD_MS);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
void DictionaryWordSelectActivity::onEnter() {
|
||||||
|
Activity::onEnter();
|
||||||
|
renderingMutex = xSemaphoreCreateMutex();
|
||||||
|
extractWords();
|
||||||
|
mergeHyphenatedWords();
|
||||||
|
if (!rows.empty()) {
|
||||||
|
currentRow = static_cast<int>(rows.size()) / 3;
|
||||||
|
currentWordInRow = 0;
|
||||||
|
}
|
||||||
|
updateRequired = true;
|
||||||
|
xTaskCreate(&DictionaryWordSelectActivity::taskTrampoline, "DictWordSelTask", 4096, this, 1, &displayTaskHandle);
|
||||||
|
}
|
||||||
|
|
||||||
|
void DictionaryWordSelectActivity::onExit() {
|
||||||
|
Activity::onExit();
|
||||||
|
xSemaphoreTake(renderingMutex, portMAX_DELAY);
|
||||||
|
if (displayTaskHandle) {
|
||||||
|
vTaskDelete(displayTaskHandle);
|
||||||
|
displayTaskHandle = nullptr;
|
||||||
|
}
|
||||||
|
vSemaphoreDelete(renderingMutex);
|
||||||
|
renderingMutex = nullptr;
|
||||||
|
}
|
||||||
|
|
||||||
|
bool DictionaryWordSelectActivity::isLandscape() const {
|
||||||
|
return orientation == CrossPointSettings::ORIENTATION::LANDSCAPE_CW ||
|
||||||
|
orientation == CrossPointSettings::ORIENTATION::LANDSCAPE_CCW;
|
||||||
|
}
|
||||||
|
|
||||||
|
bool DictionaryWordSelectActivity::isInverted() const {
|
||||||
|
return orientation == CrossPointSettings::ORIENTATION::INVERTED;
|
||||||
|
}
|
||||||
|
|
||||||
|
void DictionaryWordSelectActivity::extractWords() {
|
||||||
|
words.clear();
|
||||||
|
rows.clear();
|
||||||
|
|
||||||
|
for (const auto& element : page->elements) {
|
||||||
|
// PageLine is the only concrete PageElement type, identified by tag
|
||||||
|
const auto* line = static_cast<const PageLine*>(element.get());
|
||||||
|
|
||||||
|
const auto& block = line->getBlock();
|
||||||
|
if (!block) continue;
|
||||||
|
|
||||||
|
const auto& wordList = block->getWords();
|
||||||
|
const auto& xPosList = block->getWordXpos();
|
||||||
|
|
||||||
|
auto wordIt = wordList.begin();
|
||||||
|
auto xIt = xPosList.begin();
|
||||||
|
|
||||||
|
while (wordIt != wordList.end() && xIt != xPosList.end()) {
|
||||||
|
int16_t screenX = line->xPos + static_cast<int16_t>(*xIt) + marginLeft;
|
||||||
|
int16_t screenY = line->yPos + marginTop;
|
||||||
|
int16_t wordWidth = renderer.getTextWidth(fontId, wordIt->c_str());
|
||||||
|
|
||||||
|
words.push_back({*wordIt, screenX, screenY, wordWidth, 0});
|
||||||
|
++wordIt;
|
||||||
|
++xIt;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Group words into rows by Y position
|
||||||
|
if (words.empty()) return;
|
||||||
|
|
||||||
|
int16_t currentY = words[0].screenY;
|
||||||
|
rows.push_back({currentY, {}});
|
||||||
|
|
||||||
|
for (size_t i = 0; i < words.size(); i++) {
|
||||||
|
// Allow small Y tolerance (words on same line may differ by a pixel)
|
||||||
|
if (std::abs(words[i].screenY - currentY) > 2) {
|
||||||
|
currentY = words[i].screenY;
|
||||||
|
rows.push_back({currentY, {}});
|
||||||
|
}
|
||||||
|
words[i].row = static_cast<int16_t>(rows.size() - 1);
|
||||||
|
rows.back().wordIndices.push_back(static_cast<int>(i));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
void DictionaryWordSelectActivity::mergeHyphenatedWords() {
|
||||||
|
for (size_t r = 0; r + 1 < rows.size(); r++) {
|
||||||
|
if (rows[r].wordIndices.empty() || rows[r + 1].wordIndices.empty()) continue;
|
||||||
|
|
||||||
|
int lastWordIdx = rows[r].wordIndices.back();
|
||||||
|
const std::string& lastWord = words[lastWordIdx].text;
|
||||||
|
if (lastWord.empty()) continue;
|
||||||
|
|
||||||
|
// Check if word ends with hyphen (regular '-' or soft hyphen U+00AD: 0xC2 0xAD)
|
||||||
|
bool endsWithHyphen = false;
|
||||||
|
if (lastWord.back() == '-') {
|
||||||
|
endsWithHyphen = true;
|
||||||
|
} else if (lastWord.size() >= 2 && static_cast<uint8_t>(lastWord[lastWord.size() - 2]) == 0xC2 &&
|
||||||
|
static_cast<uint8_t>(lastWord[lastWord.size() - 1]) == 0xAD) {
|
||||||
|
endsWithHyphen = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!endsWithHyphen) continue;
|
||||||
|
|
||||||
|
int nextWordIdx = rows[r + 1].wordIndices.front();
|
||||||
|
|
||||||
|
// Set bidirectional continuation links for highlighting both parts
|
||||||
|
words[lastWordIdx].continuationIndex = nextWordIdx;
|
||||||
|
words[nextWordIdx].continuationOf = lastWordIdx;
|
||||||
|
|
||||||
|
// Build merged lookup text: remove trailing hyphen and combine
|
||||||
|
std::string firstPart = lastWord;
|
||||||
|
if (firstPart.back() == '-') {
|
||||||
|
firstPart.pop_back();
|
||||||
|
} else if (firstPart.size() >= 2 && static_cast<uint8_t>(firstPart[firstPart.size() - 2]) == 0xC2 &&
|
||||||
|
static_cast<uint8_t>(firstPart[firstPart.size() - 1]) == 0xAD) {
|
||||||
|
firstPart.erase(firstPart.size() - 2);
|
||||||
|
}
|
||||||
|
std::string merged = firstPart + words[nextWordIdx].text;
|
||||||
|
words[lastWordIdx].lookupText = merged;
|
||||||
|
words[nextWordIdx].lookupText = merged;
|
||||||
|
words[nextWordIdx].continuationIndex = nextWordIdx; // self-ref so highlight logic finds the second part
|
||||||
|
}
|
||||||
|
|
||||||
|
// Remove empty rows that may result from merging (e.g., a row whose only word was a continuation)
|
||||||
|
rows.erase(std::remove_if(rows.begin(), rows.end(), [](const Row& r) { return r.wordIndices.empty(); }), rows.end());
|
||||||
|
}
|
||||||
|
|
||||||
|
void DictionaryWordSelectActivity::loop() {
|
||||||
|
if (words.empty()) {
|
||||||
|
if (mappedInput.wasReleased(MappedInputManager::Button::Back)) {
|
||||||
|
onBack();
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
bool changed = false;
|
||||||
|
const bool landscape = isLandscape();
|
||||||
|
const bool inverted = isInverted();
|
||||||
|
|
||||||
|
// Button mapping depends on physical orientation:
|
||||||
|
// - Portrait: side Up/Down = row nav, face Left/Right = word nav
|
||||||
|
// - Inverted: same axes but reversed directions (device is flipped 180)
|
||||||
|
// - Landscape: face Left/Right = row nav (swapped), side Up/Down = word nav
|
||||||
|
bool rowPrevPressed, rowNextPressed, wordPrevPressed, wordNextPressed;
|
||||||
|
|
||||||
|
if (landscape && orientation == CrossPointSettings::ORIENTATION::LANDSCAPE_CW) {
|
||||||
|
rowPrevPressed = mappedInput.wasReleased(MappedInputManager::Button::Left);
|
||||||
|
rowNextPressed = mappedInput.wasReleased(MappedInputManager::Button::Right);
|
||||||
|
wordPrevPressed = mappedInput.wasReleased(MappedInputManager::Button::PageForward) ||
|
||||||
|
mappedInput.wasReleased(MappedInputManager::Button::Down);
|
||||||
|
wordNextPressed = mappedInput.wasReleased(MappedInputManager::Button::PageBack) ||
|
||||||
|
mappedInput.wasReleased(MappedInputManager::Button::Up);
|
||||||
|
} else if (landscape) {
|
||||||
|
rowPrevPressed = mappedInput.wasReleased(MappedInputManager::Button::Right);
|
||||||
|
rowNextPressed = mappedInput.wasReleased(MappedInputManager::Button::Left);
|
||||||
|
wordPrevPressed = mappedInput.wasReleased(MappedInputManager::Button::PageBack) ||
|
||||||
|
mappedInput.wasReleased(MappedInputManager::Button::Up);
|
||||||
|
wordNextPressed = mappedInput.wasReleased(MappedInputManager::Button::PageForward) ||
|
||||||
|
mappedInput.wasReleased(MappedInputManager::Button::Down);
|
||||||
|
} else if (inverted) {
|
||||||
|
rowPrevPressed = mappedInput.wasReleased(MappedInputManager::Button::PageForward) ||
|
||||||
|
mappedInput.wasReleased(MappedInputManager::Button::Down);
|
||||||
|
rowNextPressed = mappedInput.wasReleased(MappedInputManager::Button::PageBack) ||
|
||||||
|
mappedInput.wasReleased(MappedInputManager::Button::Up);
|
||||||
|
wordPrevPressed = mappedInput.wasReleased(MappedInputManager::Button::Right);
|
||||||
|
wordNextPressed = mappedInput.wasReleased(MappedInputManager::Button::Left);
|
||||||
|
} else {
|
||||||
|
// Portrait (default)
|
||||||
|
rowPrevPressed = mappedInput.wasReleased(MappedInputManager::Button::PageBack) ||
|
||||||
|
mappedInput.wasReleased(MappedInputManager::Button::Up);
|
||||||
|
rowNextPressed = mappedInput.wasReleased(MappedInputManager::Button::PageForward) ||
|
||||||
|
mappedInput.wasReleased(MappedInputManager::Button::Down);
|
||||||
|
wordPrevPressed = mappedInput.wasReleased(MappedInputManager::Button::Left);
|
||||||
|
wordNextPressed = mappedInput.wasReleased(MappedInputManager::Button::Right);
|
||||||
|
}
|
||||||
|
|
||||||
|
const int rowCount = static_cast<int>(rows.size());
|
||||||
|
|
||||||
|
// Helper: find closest word by X position in a target row
|
||||||
|
auto findClosestWord = [&](int targetRow) {
|
||||||
|
int wordIdx = rows[currentRow].wordIndices[currentWordInRow];
|
||||||
|
int currentCenterX = words[wordIdx].screenX + words[wordIdx].width / 2;
|
||||||
|
int bestMatch = 0;
|
||||||
|
int bestDist = INT_MAX;
|
||||||
|
for (int i = 0; i < static_cast<int>(rows[targetRow].wordIndices.size()); i++) {
|
||||||
|
int idx = rows[targetRow].wordIndices[i];
|
||||||
|
int centerX = words[idx].screenX + words[idx].width / 2;
|
||||||
|
int dist = std::abs(centerX - currentCenterX);
|
||||||
|
if (dist < bestDist) {
|
||||||
|
bestDist = dist;
|
||||||
|
bestMatch = i;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return bestMatch;
|
||||||
|
};
|
||||||
|
|
||||||
|
// Move to previous row (wrap to bottom)
|
||||||
|
if (rowPrevPressed) {
|
||||||
|
int targetRow = (currentRow > 0) ? currentRow - 1 : rowCount - 1;
|
||||||
|
currentWordInRow = findClosestWord(targetRow);
|
||||||
|
currentRow = targetRow;
|
||||||
|
changed = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Move to next row (wrap to top)
|
||||||
|
if (rowNextPressed) {
|
||||||
|
int targetRow = (currentRow < rowCount - 1) ? currentRow + 1 : 0;
|
||||||
|
currentWordInRow = findClosestWord(targetRow);
|
||||||
|
currentRow = targetRow;
|
||||||
|
changed = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Move to previous word (wrap to end of previous row)
|
||||||
|
if (wordPrevPressed) {
|
||||||
|
if (currentWordInRow > 0) {
|
||||||
|
currentWordInRow--;
|
||||||
|
} else if (rowCount > 1) {
|
||||||
|
currentRow = (currentRow > 0) ? currentRow - 1 : rowCount - 1;
|
||||||
|
currentWordInRow = static_cast<int>(rows[currentRow].wordIndices.size()) - 1;
|
||||||
|
}
|
||||||
|
changed = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Move to next word (wrap to start of next row)
|
||||||
|
if (wordNextPressed) {
|
||||||
|
if (currentWordInRow < static_cast<int>(rows[currentRow].wordIndices.size()) - 1) {
|
||||||
|
currentWordInRow++;
|
||||||
|
} else if (rowCount > 1) {
|
||||||
|
currentRow = (currentRow < rowCount - 1) ? currentRow + 1 : 0;
|
||||||
|
currentWordInRow = 0;
|
||||||
|
}
|
||||||
|
changed = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (mappedInput.wasReleased(MappedInputManager::Button::Confirm)) {
|
||||||
|
int wordIdx = rows[currentRow].wordIndices[currentWordInRow];
|
||||||
|
const std::string& rawWord = words[wordIdx].lookupText;
|
||||||
|
std::string cleaned = Dictionary::cleanWord(rawWord);
|
||||||
|
|
||||||
|
if (cleaned.empty()) {
|
||||||
|
GUI.drawPopup(renderer, "No word");
|
||||||
|
renderer.displayBuffer(HalDisplay::FAST_REFRESH);
|
||||||
|
vTaskDelay(1000 / portTICK_PERIOD_MS);
|
||||||
|
updateRequired = true;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Show looking up popup, then release mutex so display task can run
|
||||||
|
xSemaphoreTake(renderingMutex, portMAX_DELAY);
|
||||||
|
Rect popupLayout = GUI.drawPopup(renderer, "Looking up...");
|
||||||
|
xSemaphoreGive(renderingMutex);
|
||||||
|
|
||||||
|
bool cancelled = false;
|
||||||
|
std::string definition = Dictionary::lookup(
|
||||||
|
cleaned,
|
||||||
|
[this, &popupLayout](int percent) {
|
||||||
|
xSemaphoreTake(renderingMutex, portMAX_DELAY);
|
||||||
|
GUI.fillPopupProgress(renderer, popupLayout, percent);
|
||||||
|
xSemaphoreGive(renderingMutex);
|
||||||
|
},
|
||||||
|
[this, &cancelled]() -> bool {
|
||||||
|
mappedInput.update();
|
||||||
|
if (mappedInput.wasReleased(MappedInputManager::Button::Back)) {
|
||||||
|
cancelled = true;
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
return false;
|
||||||
|
});
|
||||||
|
|
||||||
|
if (cancelled) {
|
||||||
|
updateRequired = true;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (definition.empty()) {
|
||||||
|
GUI.drawPopup(renderer, "Not found");
|
||||||
|
renderer.displayBuffer(HalDisplay::FAST_REFRESH);
|
||||||
|
vTaskDelay(1500 / portTICK_PERIOD_MS);
|
||||||
|
updateRequired = true;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
LookupHistory::addWord(cachePath, cleaned);
|
||||||
|
onLookup(cleaned, definition);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (mappedInput.wasReleased(MappedInputManager::Button::Back)) {
|
||||||
|
onBack();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (changed) {
|
||||||
|
updateRequired = true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
void DictionaryWordSelectActivity::renderScreen() {
|
||||||
|
renderer.clearScreen();
|
||||||
|
|
||||||
|
// Render the page content
|
||||||
|
page->render(renderer, fontId, marginLeft, marginTop);
|
||||||
|
|
||||||
|
if (!words.empty() && currentRow < static_cast<int>(rows.size())) {
|
||||||
|
int wordIdx = rows[currentRow].wordIndices[currentWordInRow];
|
||||||
|
const auto& w = words[wordIdx];
|
||||||
|
|
||||||
|
// Draw inverted highlight behind selected word
|
||||||
|
const int lineHeight = renderer.getLineHeight(fontId);
|
||||||
|
renderer.fillRect(w.screenX - 1, w.screenY - 1, w.width + 2, lineHeight + 2, true);
|
||||||
|
renderer.drawText(fontId, w.screenX, w.screenY, w.text.c_str(), false);
|
||||||
|
|
||||||
|
// Highlight the other half of a hyphenated word (whether selecting first or second part)
|
||||||
|
int otherIdx = (w.continuationOf >= 0) ? w.continuationOf : -1;
|
||||||
|
if (otherIdx < 0 && w.continuationIndex >= 0 && w.continuationIndex != wordIdx) {
|
||||||
|
otherIdx = w.continuationIndex;
|
||||||
|
}
|
||||||
|
if (otherIdx >= 0) {
|
||||||
|
const auto& other = words[otherIdx];
|
||||||
|
renderer.fillRect(other.screenX - 1, other.screenY - 1, other.width + 2, lineHeight + 2, true);
|
||||||
|
renderer.drawText(fontId, other.screenX, other.screenY, other.text.c_str(), false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
drawHints();
|
||||||
|
|
||||||
|
renderer.displayBuffer(HalDisplay::FAST_REFRESH);
|
||||||
|
}
|
||||||
|
|
||||||
|
void DictionaryWordSelectActivity::drawHints() {
|
||||||
|
// Draw button hints in portrait orientation (matching physical buttons and theme).
|
||||||
|
// Any hint whose area would overlap the selected word highlight is completely skipped,
|
||||||
|
// leaving the page content underneath visible.
|
||||||
|
const auto origOrientation = renderer.getOrientation();
|
||||||
|
|
||||||
|
// Get portrait dimensions for overlap math
|
||||||
|
renderer.setOrientation(GfxRenderer::Orientation::Portrait);
|
||||||
|
const int portW = renderer.getScreenWidth(); // 480 in portrait
|
||||||
|
const int portH = renderer.getScreenHeight(); // 800 in portrait
|
||||||
|
renderer.setOrientation(origOrientation);
|
||||||
|
|
||||||
|
// Bottom button constants (match LyraTheme::drawButtonHints)
|
||||||
|
constexpr int buttonHeight = 40; // LyraMetrics::values.buttonHintsHeight
|
||||||
|
constexpr int buttonWidth = 80;
|
||||||
|
constexpr int cornerRadius = 6;
|
||||||
|
constexpr int textYOffset = 7;
|
||||||
|
constexpr int smallButtonHeight = 15;
|
||||||
|
constexpr int buttonPositions[] = {58, 146, 254, 342};
|
||||||
|
|
||||||
|
// Side button constants (match LyraTheme::drawSideButtonHints)
|
||||||
|
constexpr int sideButtonWidth = 30; // LyraMetrics::values.sideButtonHintsWidth
|
||||||
|
constexpr int sideButtonHeight = 78;
|
||||||
|
constexpr int sideButtonGap = 5;
|
||||||
|
constexpr int sideTopY = 345; // topHintButtonY
|
||||||
|
const int sideX = portW - sideButtonWidth;
|
||||||
|
const int sideButtonY[2] = {sideTopY, sideTopY + sideButtonHeight + sideButtonGap};
|
||||||
|
|
||||||
|
// Labels for face and side buttons depend on orientation,
|
||||||
|
// because the physical-to-logical mapping rotates with the screen.
|
||||||
|
const char* facePrev; // label for physical Left face button
|
||||||
|
const char* faceNext; // label for physical Right face button
|
||||||
|
const char* sideTop; // label for physical top side button (PageBack)
|
||||||
|
const char* sideBottom; // label for physical bottom side button (PageForward)
|
||||||
|
|
||||||
|
const bool landscape = isLandscape();
|
||||||
|
const bool inverted = isInverted();
|
||||||
|
|
||||||
|
if (landscape && orientation == CrossPointSettings::ORIENTATION::LANDSCAPE_CW) {
|
||||||
|
facePrev = "Line Up"; faceNext = "Line Dn";
|
||||||
|
sideTop = "Word \xC2\xBB"; sideBottom = "\xC2\xAB Word";
|
||||||
|
} else if (landscape) { // LANDSCAPE_CCW
|
||||||
|
facePrev = "Line Dn"; faceNext = "Line Up";
|
||||||
|
sideTop = "\xC2\xAB Word"; sideBottom = "Word \xC2\xBB";
|
||||||
|
} else if (inverted) {
|
||||||
|
facePrev = "Word \xC2\xBB"; faceNext = "\xC2\xAB Word";
|
||||||
|
sideTop = "Line Dn"; sideBottom = "Line Up";
|
||||||
|
} else { // Portrait (default)
|
||||||
|
facePrev = "\xC2\xAB Word"; faceNext = "Word \xC2\xBB";
|
||||||
|
sideTop = "Line Up"; sideBottom = "Line Dn";
|
||||||
|
}
|
||||||
|
|
||||||
|
const auto labels = mappedInput.mapLabels("\xC2\xAB Back", "Select", facePrev, faceNext);
|
||||||
|
const char* btnLabels[] = {labels.btn1, labels.btn2, labels.btn3, labels.btn4};
|
||||||
|
const char* sideLabels[] = {sideTop, sideBottom};
|
||||||
|
|
||||||
|
// ---- Determine which hints overlap the selected word ----
|
||||||
|
bool hideHint[4] = {false, false, false, false};
|
||||||
|
bool hideSide[2] = {false, false};
|
||||||
|
|
||||||
|
if (!words.empty() && currentRow < static_cast<int>(rows.size())) {
|
||||||
|
const int lineHeight = renderer.getLineHeight(fontId);
|
||||||
|
|
||||||
|
// Collect bounding boxes of the selected word (and its continuation) in current-orientation coords.
|
||||||
|
struct Box {
|
||||||
|
int x, y, w, h;
|
||||||
|
};
|
||||||
|
Box boxes[2];
|
||||||
|
int boxCount = 0;
|
||||||
|
|
||||||
|
int wordIdx = rows[currentRow].wordIndices[currentWordInRow];
|
||||||
|
const auto& sel = words[wordIdx];
|
||||||
|
boxes[0] = {sel.screenX - 1, sel.screenY - 1, sel.width + 2, lineHeight + 2};
|
||||||
|
boxCount = 1;
|
||||||
|
|
||||||
|
int otherIdx = (sel.continuationOf >= 0) ? sel.continuationOf : -1;
|
||||||
|
if (otherIdx < 0 && sel.continuationIndex >= 0 && sel.continuationIndex != wordIdx) {
|
||||||
|
otherIdx = sel.continuationIndex;
|
||||||
|
}
|
||||||
|
if (otherIdx >= 0) {
|
||||||
|
const auto& other = words[otherIdx];
|
||||||
|
boxes[1] = {other.screenX - 1, other.screenY - 1, other.width + 2, lineHeight + 2};
|
||||||
|
boxCount = 2;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Convert each box from the current orientation to portrait coordinates,
|
||||||
|
// then check overlap against both bottom and side button hints.
|
||||||
|
for (int b = 0; b < boxCount; b++) {
|
||||||
|
int px, py, pw, ph;
|
||||||
|
|
||||||
|
if (origOrientation == GfxRenderer::Orientation::Portrait) {
|
||||||
|
px = boxes[b].x;
|
||||||
|
py = boxes[b].y;
|
||||||
|
pw = boxes[b].w;
|
||||||
|
ph = boxes[b].h;
|
||||||
|
} else if (origOrientation == GfxRenderer::Orientation::PortraitInverted) {
|
||||||
|
px = portW - boxes[b].x - boxes[b].w;
|
||||||
|
py = portH - boxes[b].y - boxes[b].h;
|
||||||
|
pw = boxes[b].w;
|
||||||
|
ph = boxes[b].h;
|
||||||
|
} else if (origOrientation == GfxRenderer::Orientation::LandscapeClockwise) {
|
||||||
|
px = boxes[b].y;
|
||||||
|
py = portH - boxes[b].x - boxes[b].w;
|
||||||
|
pw = boxes[b].h;
|
||||||
|
ph = boxes[b].w;
|
||||||
|
} else {
|
||||||
|
px = portW - boxes[b].y - boxes[b].h;
|
||||||
|
py = boxes[b].x;
|
||||||
|
pw = boxes[b].h;
|
||||||
|
ph = boxes[b].w;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Bottom button overlap
|
||||||
|
int hintTop = portH - buttonHeight;
|
||||||
|
if (py + ph > hintTop) {
|
||||||
|
for (int i = 0; i < 4; i++) {
|
||||||
|
if (px + pw > buttonPositions[i] && px < buttonPositions[i] + buttonWidth) {
|
||||||
|
hideHint[i] = true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Side button overlap
|
||||||
|
if (px + pw > sideX) {
|
||||||
|
for (int s = 0; s < 2; s++) {
|
||||||
|
if (py + ph > sideButtonY[s] && py < sideButtonY[s] + sideButtonHeight) {
|
||||||
|
hideSide[s] = true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---- Draw all hints in portrait mode ----
|
||||||
|
// Hidden buttons are skipped entirely so the page content underneath stays visible.
|
||||||
|
renderer.setOrientation(GfxRenderer::Orientation::Portrait);
|
||||||
|
|
||||||
|
// Bottom face buttons
|
||||||
|
for (int i = 0; i < 4; i++) {
|
||||||
|
if (hideHint[i]) continue;
|
||||||
|
|
||||||
|
const int x = buttonPositions[i];
|
||||||
|
renderer.fillRect(x, portH - buttonHeight, buttonWidth, buttonHeight, false);
|
||||||
|
|
||||||
|
if (btnLabels[i] != nullptr && btnLabels[i][0] != '\0') {
|
||||||
|
renderer.drawRoundedRect(x, portH - buttonHeight, buttonWidth, buttonHeight, 1, cornerRadius, true, true, false,
|
||||||
|
false, true);
|
||||||
|
const int tw = renderer.getTextWidth(SMALL_FONT_ID, btnLabels[i]);
|
||||||
|
const int tx = x + (buttonWidth - 1 - tw) / 2;
|
||||||
|
renderer.drawText(SMALL_FONT_ID, tx, portH - buttonHeight + textYOffset, btnLabels[i]);
|
||||||
|
} else {
|
||||||
|
renderer.drawRoundedRect(x, portH - smallButtonHeight, buttonWidth, smallButtonHeight, 1, cornerRadius, true,
|
||||||
|
true, false, false, true);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Side buttons (custom-drawn with background, overlap hiding, truncation, and rotation)
|
||||||
|
const bool useCCW = (orientation == CrossPointSettings::ORIENTATION::LANDSCAPE_CCW);
|
||||||
|
|
||||||
|
for (int i = 0; i < 2; i++) {
|
||||||
|
if (hideSide[i]) continue;
|
||||||
|
if (sideLabels[i] == nullptr || sideLabels[i][0] == '\0') continue;
|
||||||
|
|
||||||
|
// Solid background
|
||||||
|
renderer.fillRect(sideX, sideButtonY[i], sideButtonWidth, sideButtonHeight, false);
|
||||||
|
|
||||||
|
// Outline (rounded on inner side, square on screen edge — matches theme)
|
||||||
|
renderer.drawRoundedRect(sideX, sideButtonY[i], sideButtonWidth, sideButtonHeight, 1, cornerRadius, true, false,
|
||||||
|
true, false, true);
|
||||||
|
|
||||||
|
// Truncate text if it would overflow the button height
|
||||||
|
const std::string truncated = renderer.truncatedText(SMALL_FONT_ID, sideLabels[i], sideButtonHeight);
|
||||||
|
const int tw = renderer.getTextWidth(SMALL_FONT_ID, truncated.c_str());
|
||||||
|
|
||||||
|
if (useCCW) {
|
||||||
|
// Text reads top-to-bottom (90° CCW rotation): y starts near top of button
|
||||||
|
renderer.drawTextRotated90CCW(SMALL_FONT_ID, sideX,
|
||||||
|
sideButtonY[i] + (sideButtonHeight - tw) / 2, truncated.c_str());
|
||||||
|
} else {
|
||||||
|
// Text reads bottom-to-top (90° CW rotation): y starts near bottom of button
|
||||||
|
renderer.drawTextRotated90CW(SMALL_FONT_ID, sideX,
|
||||||
|
sideButtonY[i] + (sideButtonHeight + tw) / 2, truncated.c_str());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
renderer.setOrientation(origOrientation);
|
||||||
|
}
|
||||||
80
src/activities/reader/DictionaryWordSelectActivity.h
Normal file
80
src/activities/reader/DictionaryWordSelectActivity.h
Normal file
@@ -0,0 +1,80 @@
|
|||||||
|
#pragma once
|
||||||
|
#include <Epub/Page.h>
|
||||||
|
#include <freertos/FreeRTOS.h>
|
||||||
|
#include <freertos/semphr.h>
|
||||||
|
#include <freertos/task.h>
|
||||||
|
|
||||||
|
#include <functional>
|
||||||
|
#include <memory>
|
||||||
|
#include <string>
|
||||||
|
#include <vector>
|
||||||
|
|
||||||
|
#include "../Activity.h"
|
||||||
|
|
||||||
|
class DictionaryWordSelectActivity final : public Activity {
|
||||||
|
public:
|
||||||
|
explicit DictionaryWordSelectActivity(GfxRenderer& renderer, MappedInputManager& mappedInput,
|
||||||
|
std::unique_ptr<Page> page, int fontId, int marginLeft, int marginTop,
|
||||||
|
const std::string& cachePath, uint8_t orientation,
|
||||||
|
const std::function<void()>& onBack,
|
||||||
|
const std::function<void(const std::string&, const std::string&)>& onLookup)
|
||||||
|
: Activity("DictionaryWordSelect", renderer, mappedInput),
|
||||||
|
page(std::move(page)),
|
||||||
|
fontId(fontId),
|
||||||
|
marginLeft(marginLeft),
|
||||||
|
marginTop(marginTop),
|
||||||
|
cachePath(cachePath),
|
||||||
|
orientation(orientation),
|
||||||
|
onBack(onBack),
|
||||||
|
onLookup(onLookup) {}
|
||||||
|
|
||||||
|
void onEnter() override;
|
||||||
|
void onExit() override;
|
||||||
|
void loop() override;
|
||||||
|
|
||||||
|
private:
|
||||||
|
struct WordInfo {
|
||||||
|
std::string text;
|
||||||
|
std::string lookupText;
|
||||||
|
int16_t screenX;
|
||||||
|
int16_t screenY;
|
||||||
|
int16_t width;
|
||||||
|
int16_t row;
|
||||||
|
int continuationIndex;
|
||||||
|
int continuationOf;
|
||||||
|
WordInfo(const std::string& t, int16_t x, int16_t y, int16_t w, int16_t r)
|
||||||
|
: text(t), lookupText(t), screenX(x), screenY(y), width(w), row(r), continuationIndex(-1), continuationOf(-1) {}
|
||||||
|
};
|
||||||
|
|
||||||
|
struct Row {
|
||||||
|
int16_t yPos;
|
||||||
|
std::vector<int> wordIndices;
|
||||||
|
};
|
||||||
|
|
||||||
|
std::unique_ptr<Page> page;
|
||||||
|
int fontId;
|
||||||
|
int marginLeft;
|
||||||
|
int marginTop;
|
||||||
|
std::string cachePath;
|
||||||
|
uint8_t orientation;
|
||||||
|
const std::function<void()> onBack;
|
||||||
|
const std::function<void(const std::string&, const std::string&)> onLookup;
|
||||||
|
|
||||||
|
std::vector<WordInfo> words;
|
||||||
|
std::vector<Row> rows;
|
||||||
|
int currentRow = 0;
|
||||||
|
int currentWordInRow = 0;
|
||||||
|
bool updateRequired = false;
|
||||||
|
|
||||||
|
TaskHandle_t displayTaskHandle = nullptr;
|
||||||
|
SemaphoreHandle_t renderingMutex = nullptr;
|
||||||
|
|
||||||
|
bool isLandscape() const;
|
||||||
|
bool isInverted() const;
|
||||||
|
void extractWords();
|
||||||
|
void mergeHyphenatedWords();
|
||||||
|
void renderScreen();
|
||||||
|
void drawHints();
|
||||||
|
static void taskTrampoline(void* param);
|
||||||
|
[[noreturn]] void displayTaskLoop();
|
||||||
|
};
|
||||||
@@ -8,6 +8,7 @@
|
|||||||
|
|
||||||
#include "CrossPointSettings.h"
|
#include "CrossPointSettings.h"
|
||||||
#include "CrossPointState.h"
|
#include "CrossPointState.h"
|
||||||
|
#include "EpubReaderBookmarkSelectionActivity.h"
|
||||||
#include "EpubReaderChapterSelectionActivity.h"
|
#include "EpubReaderChapterSelectionActivity.h"
|
||||||
#include "EpubReaderPercentSelectionActivity.h"
|
#include "EpubReaderPercentSelectionActivity.h"
|
||||||
#include "KOReaderCredentialStore.h"
|
#include "KOReaderCredentialStore.h"
|
||||||
@@ -16,6 +17,9 @@
|
|||||||
#include "RecentBooksStore.h"
|
#include "RecentBooksStore.h"
|
||||||
#include "components/UITheme.h"
|
#include "components/UITheme.h"
|
||||||
#include "fontIds.h"
|
#include "fontIds.h"
|
||||||
|
#include "util/BookmarkStore.h"
|
||||||
|
#include "util/Dictionary.h"
|
||||||
|
#include "util/LookupHistory.h"
|
||||||
|
|
||||||
namespace {
|
namespace {
|
||||||
// pagesPerRefresh now comes from SETTINGS.getRefreshFrequency()
|
// pagesPerRefresh now comes from SETTINGS.getRefreshFrequency()
|
||||||
@@ -102,6 +106,42 @@ void EpubReaderActivity::onEnter() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Prerender covers and thumbnails on first open so Home and Sleep screens are instant.
|
||||||
|
// Each generate* call is a no-op if the file already exists, so this only does work once.
|
||||||
|
{
|
||||||
|
int totalSteps = 0;
|
||||||
|
if (!Storage.exists(epub->getCoverBmpPath(false).c_str())) totalSteps++;
|
||||||
|
if (!Storage.exists(epub->getCoverBmpPath(true).c_str())) totalSteps++;
|
||||||
|
for (int i = 0; i < PRERENDER_THUMB_HEIGHTS_COUNT; i++) {
|
||||||
|
if (!Storage.exists(epub->getThumbBmpPath(PRERENDER_THUMB_HEIGHTS[i]).c_str())) totalSteps++;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (totalSteps > 0) {
|
||||||
|
Rect popupRect = GUI.drawPopup(renderer, "Preparing book...");
|
||||||
|
int completedSteps = 0;
|
||||||
|
|
||||||
|
auto updateProgress = [&]() {
|
||||||
|
completedSteps++;
|
||||||
|
GUI.fillPopupProgress(renderer, popupRect, completedSteps * 100 / totalSteps);
|
||||||
|
};
|
||||||
|
|
||||||
|
if (!Storage.exists(epub->getCoverBmpPath(false).c_str())) {
|
||||||
|
epub->generateCoverBmp(false);
|
||||||
|
updateProgress();
|
||||||
|
}
|
||||||
|
if (!Storage.exists(epub->getCoverBmpPath(true).c_str())) {
|
||||||
|
epub->generateCoverBmp(true);
|
||||||
|
updateProgress();
|
||||||
|
}
|
||||||
|
for (int i = 0; i < PRERENDER_THUMB_HEIGHTS_COUNT; i++) {
|
||||||
|
if (!Storage.exists(epub->getThumbBmpPath(PRERENDER_THUMB_HEIGHTS[i]).c_str())) {
|
||||||
|
epub->generateThumbBmp(PRERENDER_THUMB_HEIGHTS[i]);
|
||||||
|
updateProgress();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// Save current epub as last opened epub and add to recent books
|
// Save current epub as last opened epub and add to recent books
|
||||||
APP_STATE.openEpubPath = epub->getPath();
|
APP_STATE.openEpubPath = epub->getPath();
|
||||||
APP_STATE.saveToFile();
|
APP_STATE.saveToFile();
|
||||||
@@ -196,10 +236,14 @@ void EpubReaderActivity::loop() {
|
|||||||
bookProgress = epub->calculateProgress(currentSpineIndex, chapterProgress) * 100.0f;
|
bookProgress = epub->calculateProgress(currentSpineIndex, chapterProgress) * 100.0f;
|
||||||
}
|
}
|
||||||
const int bookProgressPercent = clampPercent(static_cast<int>(bookProgress + 0.5f));
|
const int bookProgressPercent = clampPercent(static_cast<int>(bookProgress + 0.5f));
|
||||||
|
const bool hasDictionary = Dictionary::exists();
|
||||||
|
const bool isBookmarked = BookmarkStore::hasBookmark(
|
||||||
|
epub->getCachePath(), currentSpineIndex, section ? section->currentPage : 0);
|
||||||
exitActivity();
|
exitActivity();
|
||||||
enterNewActivity(new EpubReaderMenuActivity(
|
enterNewActivity(new EpubReaderMenuActivity(
|
||||||
this->renderer, this->mappedInput, epub->getTitle(), currentPage, totalPages, bookProgressPercent,
|
this->renderer, this->mappedInput, epub->getTitle(), currentPage, totalPages, bookProgressPercent,
|
||||||
SETTINGS.orientation, [this](const uint8_t orientation) { onReaderMenuBack(orientation); },
|
SETTINGS.orientation, hasDictionary, isBookmarked, epub->getCachePath(),
|
||||||
|
[this](const uint8_t orientation) { onReaderMenuBack(orientation); },
|
||||||
[this](EpubReaderMenuActivity::MenuAction action) { onReaderMenuConfirm(action); }));
|
[this](EpubReaderMenuActivity::MenuAction action) { onReaderMenuConfirm(action); }));
|
||||||
xSemaphoreGive(renderingMutex);
|
xSemaphoreGive(renderingMutex);
|
||||||
}
|
}
|
||||||
@@ -293,6 +337,8 @@ void EpubReaderActivity::onReaderMenuBack(const uint8_t orientation) {
|
|||||||
// Apply the user-selected orientation when the menu is dismissed.
|
// Apply the user-selected orientation when the menu is dismissed.
|
||||||
// This ensures the menu can be navigated without immediately rotating the screen.
|
// This ensures the menu can be navigated without immediately rotating the screen.
|
||||||
applyOrientation(orientation);
|
applyOrientation(orientation);
|
||||||
|
// Force a half refresh on the next render to clear menu/popup artifacts
|
||||||
|
pagesUntilFullRefresh = 1;
|
||||||
updateRequired = true;
|
updateRequired = true;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -360,6 +406,170 @@ void EpubReaderActivity::jumpToPercent(int percent) {
|
|||||||
|
|
||||||
void EpubReaderActivity::onReaderMenuConfirm(EpubReaderMenuActivity::MenuAction action) {
|
void EpubReaderActivity::onReaderMenuConfirm(EpubReaderMenuActivity::MenuAction action) {
|
||||||
switch (action) {
|
switch (action) {
|
||||||
|
case EpubReaderMenuActivity::MenuAction::ADD_BOOKMARK: {
|
||||||
|
const int page = section ? section->currentPage : 0;
|
||||||
|
|
||||||
|
// Extract first full sentence from the current page for the bookmark snippet.
|
||||||
|
// If the first word is lowercase, the page starts mid-sentence — skip to the
|
||||||
|
// next sentence boundary and start collecting from there.
|
||||||
|
std::string snippet;
|
||||||
|
if (section) {
|
||||||
|
auto p = section->loadPageFromSectionFile();
|
||||||
|
if (p) {
|
||||||
|
// Gather all words on the page into a flat list for easier traversal
|
||||||
|
std::vector<std::string> allWords;
|
||||||
|
for (const auto& element : p->elements) {
|
||||||
|
const auto* line = static_cast<const PageLine*>(element.get());
|
||||||
|
if (!line) continue;
|
||||||
|
const auto& block = line->getBlock();
|
||||||
|
if (!block) continue;
|
||||||
|
for (const auto& word : block->getWords()) {
|
||||||
|
allWords.push_back(word);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!allWords.empty()) {
|
||||||
|
size_t startIdx = 0;
|
||||||
|
|
||||||
|
// Check if the first word starts with a lowercase letter (mid-sentence)
|
||||||
|
const char firstChar = allWords[0].empty() ? '\0' : allWords[0][0];
|
||||||
|
if (firstChar >= 'a' && firstChar <= 'z') {
|
||||||
|
// Skip past the end of this partial sentence
|
||||||
|
for (size_t i = 0; i < allWords.size(); i++) {
|
||||||
|
if (!allWords[i].empty()) {
|
||||||
|
char last = allWords[i].back();
|
||||||
|
if (last == '.' || last == '!' || last == '?' || last == ':') {
|
||||||
|
startIdx = i + 1;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// If no sentence boundary found, fall back to using everything from the start
|
||||||
|
if (startIdx >= allWords.size()) {
|
||||||
|
startIdx = 0;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Collect words from startIdx until the next sentence boundary
|
||||||
|
for (size_t i = startIdx; i < allWords.size(); i++) {
|
||||||
|
if (!snippet.empty()) snippet += " ";
|
||||||
|
snippet += allWords[i];
|
||||||
|
if (!allWords[i].empty()) {
|
||||||
|
char last = allWords[i].back();
|
||||||
|
if (last == '.' || last == '!' || last == '?' || last == ':') {
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
BookmarkStore::addBookmark(epub->getCachePath(), currentSpineIndex, page, snippet);
|
||||||
|
xSemaphoreTake(renderingMutex, portMAX_DELAY);
|
||||||
|
GUI.drawPopup(renderer, "Bookmark added");
|
||||||
|
renderer.displayBuffer(HalDisplay::FAST_REFRESH);
|
||||||
|
xSemaphoreGive(renderingMutex);
|
||||||
|
vTaskDelay(750 / portTICK_PERIOD_MS);
|
||||||
|
// Exit the menu and return to reading — the bookmark indicator will show on re-render,
|
||||||
|
// and next menu open will reflect the updated state.
|
||||||
|
exitActivity();
|
||||||
|
pagesUntilFullRefresh = 1;
|
||||||
|
updateRequired = true;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
case EpubReaderMenuActivity::MenuAction::REMOVE_BOOKMARK: {
|
||||||
|
const int page = section ? section->currentPage : 0;
|
||||||
|
BookmarkStore::removeBookmark(epub->getCachePath(), currentSpineIndex, page);
|
||||||
|
xSemaphoreTake(renderingMutex, portMAX_DELAY);
|
||||||
|
GUI.drawPopup(renderer, "Bookmark removed");
|
||||||
|
renderer.displayBuffer(HalDisplay::FAST_REFRESH);
|
||||||
|
xSemaphoreGive(renderingMutex);
|
||||||
|
vTaskDelay(750 / portTICK_PERIOD_MS);
|
||||||
|
exitActivity();
|
||||||
|
pagesUntilFullRefresh = 1;
|
||||||
|
updateRequired = true;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
case EpubReaderMenuActivity::MenuAction::GO_TO_BOOKMARK: {
|
||||||
|
auto bookmarks = BookmarkStore::load(epub->getCachePath());
|
||||||
|
|
||||||
|
if (bookmarks.empty()) {
|
||||||
|
// No bookmarks: fall back to Table of Contents if available, otherwise go back
|
||||||
|
if (epub->getTocItemsCount() > 0) {
|
||||||
|
const int currentP = section ? section->currentPage : 0;
|
||||||
|
const int totalP = section ? section->pageCount : 0;
|
||||||
|
const int spineIdx = currentSpineIndex;
|
||||||
|
const std::string path = epub->getPath();
|
||||||
|
|
||||||
|
xSemaphoreTake(renderingMutex, portMAX_DELAY);
|
||||||
|
exitActivity();
|
||||||
|
enterNewActivity(new EpubReaderChapterSelectionActivity(
|
||||||
|
this->renderer, this->mappedInput, epub, path, spineIdx, currentP, totalP,
|
||||||
|
[this] {
|
||||||
|
exitActivity();
|
||||||
|
updateRequired = true;
|
||||||
|
},
|
||||||
|
[this](const int newSpineIndex) {
|
||||||
|
if (currentSpineIndex != newSpineIndex) {
|
||||||
|
currentSpineIndex = newSpineIndex;
|
||||||
|
nextPageNumber = 0;
|
||||||
|
section.reset();
|
||||||
|
}
|
||||||
|
exitActivity();
|
||||||
|
updateRequired = true;
|
||||||
|
},
|
||||||
|
[this](const int newSpineIndex, const int newPage) {
|
||||||
|
if (currentSpineIndex != newSpineIndex || (section && section->currentPage != newPage)) {
|
||||||
|
currentSpineIndex = newSpineIndex;
|
||||||
|
nextPageNumber = newPage;
|
||||||
|
section.reset();
|
||||||
|
}
|
||||||
|
exitActivity();
|
||||||
|
updateRequired = true;
|
||||||
|
}));
|
||||||
|
xSemaphoreGive(renderingMutex);
|
||||||
|
}
|
||||||
|
// If no TOC either, just return to reader (menu already closed by callback)
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
|
||||||
|
xSemaphoreTake(renderingMutex, portMAX_DELAY);
|
||||||
|
exitActivity();
|
||||||
|
enterNewActivity(new EpubReaderBookmarkSelectionActivity(
|
||||||
|
this->renderer, this->mappedInput, epub, std::move(bookmarks), epub->getCachePath(),
|
||||||
|
[this] {
|
||||||
|
exitActivity();
|
||||||
|
updateRequired = true;
|
||||||
|
},
|
||||||
|
[this](const int newSpineIndex, const int newPage) {
|
||||||
|
if (currentSpineIndex != newSpineIndex || (section && section->currentPage != newPage)) {
|
||||||
|
currentSpineIndex = newSpineIndex;
|
||||||
|
nextPageNumber = newPage;
|
||||||
|
section.reset();
|
||||||
|
}
|
||||||
|
exitActivity();
|
||||||
|
updateRequired = true;
|
||||||
|
}));
|
||||||
|
xSemaphoreGive(renderingMutex);
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
case EpubReaderMenuActivity::MenuAction::DELETE_DICT_CACHE: {
|
||||||
|
if (Dictionary::cacheExists()) {
|
||||||
|
Dictionary::deleteCache();
|
||||||
|
xSemaphoreTake(renderingMutex, portMAX_DELAY);
|
||||||
|
GUI.drawPopup(renderer, "Dictionary cache deleted");
|
||||||
|
renderer.displayBuffer(HalDisplay::FAST_REFRESH);
|
||||||
|
xSemaphoreGive(renderingMutex);
|
||||||
|
} else {
|
||||||
|
xSemaphoreTake(renderingMutex, portMAX_DELAY);
|
||||||
|
GUI.drawPopup(renderer, "No cache to delete");
|
||||||
|
renderer.displayBuffer(HalDisplay::FAST_REFRESH);
|
||||||
|
xSemaphoreGive(renderingMutex);
|
||||||
|
}
|
||||||
|
vTaskDelay(1500 / portTICK_PERIOD_MS);
|
||||||
|
break;
|
||||||
|
}
|
||||||
case EpubReaderMenuActivity::MenuAction::SELECT_CHAPTER: {
|
case EpubReaderMenuActivity::MenuAction::SELECT_CHAPTER: {
|
||||||
// Calculate values BEFORE we start destroying things
|
// Calculate values BEFORE we start destroying things
|
||||||
const int currentP = section ? section->currentPage : 0;
|
const int currentP = section ? section->currentPage : 0;
|
||||||
@@ -427,6 +637,92 @@ void EpubReaderActivity::onReaderMenuConfirm(EpubReaderMenuActivity::MenuAction
|
|||||||
xSemaphoreGive(renderingMutex);
|
xSemaphoreGive(renderingMutex);
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
|
case EpubReaderMenuActivity::MenuAction::LOOKUP: {
|
||||||
|
xSemaphoreTake(renderingMutex, portMAX_DELAY);
|
||||||
|
|
||||||
|
// Compute margins (same logic as renderScreen)
|
||||||
|
int orientedMarginTop, orientedMarginRight, orientedMarginBottom, orientedMarginLeft;
|
||||||
|
renderer.getOrientedViewableTRBL(&orientedMarginTop, &orientedMarginRight, &orientedMarginBottom,
|
||||||
|
&orientedMarginLeft);
|
||||||
|
orientedMarginTop += SETTINGS.screenMargin;
|
||||||
|
orientedMarginLeft += SETTINGS.screenMargin;
|
||||||
|
orientedMarginRight += SETTINGS.screenMargin;
|
||||||
|
orientedMarginBottom += SETTINGS.screenMargin;
|
||||||
|
|
||||||
|
if (SETTINGS.statusBar != CrossPointSettings::STATUS_BAR_MODE::NONE) {
|
||||||
|
auto metrics = UITheme::getInstance().getMetrics();
|
||||||
|
const bool showProgressBar =
|
||||||
|
SETTINGS.statusBar == CrossPointSettings::STATUS_BAR_MODE::BOOK_PROGRESS_BAR ||
|
||||||
|
SETTINGS.statusBar == CrossPointSettings::STATUS_BAR_MODE::ONLY_BOOK_PROGRESS_BAR ||
|
||||||
|
SETTINGS.statusBar == CrossPointSettings::STATUS_BAR_MODE::CHAPTER_PROGRESS_BAR;
|
||||||
|
orientedMarginBottom += statusBarMargin - SETTINGS.screenMargin +
|
||||||
|
(showProgressBar ? (metrics.bookProgressBarHeight + progressBarMarginTop) : 0);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Load the current page
|
||||||
|
auto pageForLookup = section ? section->loadPageFromSectionFile() : nullptr;
|
||||||
|
const int readerFontId = SETTINGS.getReaderFontId();
|
||||||
|
const std::string bookCachePath = epub->getCachePath();
|
||||||
|
const uint8_t currentOrientation = SETTINGS.orientation;
|
||||||
|
|
||||||
|
exitActivity();
|
||||||
|
|
||||||
|
if (pageForLookup) {
|
||||||
|
enterNewActivity(new DictionaryWordSelectActivity(
|
||||||
|
renderer, mappedInput, std::move(pageForLookup), readerFontId, orientedMarginLeft, orientedMarginTop,
|
||||||
|
bookCachePath, currentOrientation,
|
||||||
|
[this]() {
|
||||||
|
// On back from word select
|
||||||
|
pendingSubactivityExit = true;
|
||||||
|
},
|
||||||
|
[this, bookCachePath, readerFontId, currentOrientation](const std::string& headword,
|
||||||
|
const std::string& definition) {
|
||||||
|
// On successful lookup - show definition
|
||||||
|
exitActivity();
|
||||||
|
enterNewActivity(new DictionaryDefinitionActivity(renderer, mappedInput, headword, definition,
|
||||||
|
readerFontId, currentOrientation,
|
||||||
|
[this]() { pendingSubactivityExit = true; }));
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
|
||||||
|
xSemaphoreGive(renderingMutex);
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
case EpubReaderMenuActivity::MenuAction::LOOKED_UP_WORDS: {
|
||||||
|
xSemaphoreTake(renderingMutex, portMAX_DELAY);
|
||||||
|
const std::string bookCachePath = epub->getCachePath();
|
||||||
|
const int readerFontId = SETTINGS.getReaderFontId();
|
||||||
|
const uint8_t currentOrientation = SETTINGS.orientation;
|
||||||
|
|
||||||
|
exitActivity();
|
||||||
|
enterNewActivity(new LookedUpWordsActivity(
|
||||||
|
renderer, mappedInput, bookCachePath,
|
||||||
|
[this]() {
|
||||||
|
// On back from looked up words
|
||||||
|
pendingSubactivityExit = true;
|
||||||
|
},
|
||||||
|
[this, bookCachePath, readerFontId, currentOrientation](const std::string& headword) {
|
||||||
|
// Look up the word and show definition with progress bar
|
||||||
|
Rect popupLayout = GUI.drawPopup(renderer, "Looking up...");
|
||||||
|
|
||||||
|
std::string definition = Dictionary::lookup(
|
||||||
|
headword, [this, &popupLayout](int percent) { GUI.fillPopupProgress(renderer, popupLayout, percent); });
|
||||||
|
|
||||||
|
if (definition.empty()) {
|
||||||
|
GUI.drawPopup(renderer, "Not found");
|
||||||
|
renderer.displayBuffer(HalDisplay::FAST_REFRESH);
|
||||||
|
vTaskDelay(1500 / portTICK_PERIOD_MS);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
exitActivity();
|
||||||
|
enterNewActivity(new DictionaryDefinitionActivity(renderer, mappedInput, headword, definition, readerFontId,
|
||||||
|
currentOrientation,
|
||||||
|
[this]() { pendingSubactivityExit = true; }));
|
||||||
|
}));
|
||||||
|
xSemaphoreGive(renderingMutex);
|
||||||
|
break;
|
||||||
|
}
|
||||||
case EpubReaderMenuActivity::MenuAction::GO_HOME: {
|
case EpubReaderMenuActivity::MenuAction::GO_HOME: {
|
||||||
// Defer go home to avoid race condition with display task
|
// Defer go home to avoid race condition with display task
|
||||||
pendingGoHome = true;
|
pendingGoHome = true;
|
||||||
@@ -480,6 +776,10 @@ void EpubReaderActivity::onReaderMenuConfirm(EpubReaderMenuActivity::MenuAction
|
|||||||
}
|
}
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
|
// Handled locally in the menu activity (cycle on Confirm, never dispatched here)
|
||||||
|
case EpubReaderMenuActivity::MenuAction::ROTATE_SCREEN:
|
||||||
|
case EpubReaderMenuActivity::MenuAction::LETTERBOX_FILL:
|
||||||
|
break;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -673,6 +973,22 @@ void EpubReaderActivity::renderContents(std::unique_ptr<Page> page, const int or
|
|||||||
const int orientedMarginRight, const int orientedMarginBottom,
|
const int orientedMarginRight, const int orientedMarginBottom,
|
||||||
const int orientedMarginLeft) {
|
const int orientedMarginLeft) {
|
||||||
page->render(renderer, SETTINGS.getReaderFontId(), orientedMarginLeft, orientedMarginTop);
|
page->render(renderer, SETTINGS.getReaderFontId(), orientedMarginLeft, orientedMarginTop);
|
||||||
|
|
||||||
|
// Draw bookmark ribbon indicator in top-right corner if current page is bookmarked
|
||||||
|
if (section && BookmarkStore::hasBookmark(epub->getCachePath(), currentSpineIndex, section->currentPage)) {
|
||||||
|
const int screenWidth = renderer.getScreenWidth();
|
||||||
|
const int bkWidth = 12;
|
||||||
|
const int bkHeight = 22;
|
||||||
|
const int bkX = screenWidth - orientedMarginRight - bkWidth + 2;
|
||||||
|
const int bkY = 0;
|
||||||
|
const int notchDepth = bkHeight / 3;
|
||||||
|
const int centerX = bkX + bkWidth / 2;
|
||||||
|
|
||||||
|
const int xPoints[5] = {bkX, bkX + bkWidth, bkX + bkWidth, centerX, bkX};
|
||||||
|
const int yPoints[5] = {bkY, bkY, bkY + bkHeight, bkY + bkHeight - notchDepth, bkY + bkHeight};
|
||||||
|
renderer.fillPolygon(xPoints, yPoints, 5, true);
|
||||||
|
}
|
||||||
|
|
||||||
renderStatusBar(orientedMarginRight, orientedMarginBottom, orientedMarginLeft);
|
renderStatusBar(orientedMarginRight, orientedMarginBottom, orientedMarginLeft);
|
||||||
if (pagesUntilFullRefresh <= 1) {
|
if (pagesUntilFullRefresh <= 1) {
|
||||||
renderer.displayBuffer(HalDisplay::HALF_REFRESH);
|
renderer.displayBuffer(HalDisplay::HALF_REFRESH);
|
||||||
|
|||||||
@@ -5,7 +5,10 @@
|
|||||||
#include <freertos/semphr.h>
|
#include <freertos/semphr.h>
|
||||||
#include <freertos/task.h>
|
#include <freertos/task.h>
|
||||||
|
|
||||||
|
#include "DictionaryDefinitionActivity.h"
|
||||||
|
#include "DictionaryWordSelectActivity.h"
|
||||||
#include "EpubReaderMenuActivity.h"
|
#include "EpubReaderMenuActivity.h"
|
||||||
|
#include "LookedUpWordsActivity.h"
|
||||||
#include "activities/ActivityWithSubactivity.h"
|
#include "activities/ActivityWithSubactivity.h"
|
||||||
|
|
||||||
class EpubReaderActivity final : public ActivityWithSubactivity {
|
class EpubReaderActivity final : public ActivityWithSubactivity {
|
||||||
|
|||||||
262
src/activities/reader/EpubReaderBookmarkSelectionActivity.cpp
Normal file
262
src/activities/reader/EpubReaderBookmarkSelectionActivity.cpp
Normal file
@@ -0,0 +1,262 @@
|
|||||||
|
#include "EpubReaderBookmarkSelectionActivity.h"
|
||||||
|
|
||||||
|
#include <GfxRenderer.h>
|
||||||
|
|
||||||
|
#include "MappedInputManager.h"
|
||||||
|
#include "components/UITheme.h"
|
||||||
|
#include "fontIds.h"
|
||||||
|
|
||||||
|
int EpubReaderBookmarkSelectionActivity::getTotalItems() const { return static_cast<int>(bookmarks.size()); }
|
||||||
|
|
||||||
|
int EpubReaderBookmarkSelectionActivity::getPageItems() const {
|
||||||
|
constexpr int lineHeight = 30;
|
||||||
|
|
||||||
|
const int screenHeight = renderer.getScreenHeight();
|
||||||
|
const auto orientation = renderer.getOrientation();
|
||||||
|
const bool isPortraitInverted = orientation == GfxRenderer::Orientation::PortraitInverted;
|
||||||
|
const int hintGutterHeight = isPortraitInverted ? 50 : 0;
|
||||||
|
const int startY = 60 + hintGutterHeight;
|
||||||
|
const int availableHeight = screenHeight - startY - lineHeight;
|
||||||
|
return std::max(1, availableHeight / lineHeight);
|
||||||
|
}
|
||||||
|
|
||||||
|
std::string EpubReaderBookmarkSelectionActivity::getBookmarkPrefix(const Bookmark& bookmark) const {
|
||||||
|
std::string label;
|
||||||
|
if (epub) {
|
||||||
|
const int tocIndex = epub->getTocIndexForSpineIndex(bookmark.spineIndex);
|
||||||
|
if (tocIndex >= 0 && tocIndex < epub->getTocItemsCount()) {
|
||||||
|
label = epub->getTocItem(tocIndex).title;
|
||||||
|
} else {
|
||||||
|
label = "Chapter " + std::to_string(bookmark.spineIndex + 1);
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
label = "Chapter " + std::to_string(bookmark.spineIndex + 1);
|
||||||
|
}
|
||||||
|
if (!bookmark.snippet.empty()) {
|
||||||
|
label += " - " + bookmark.snippet;
|
||||||
|
}
|
||||||
|
return label;
|
||||||
|
}
|
||||||
|
|
||||||
|
std::string EpubReaderBookmarkSelectionActivity::getPageSuffix(const Bookmark& bookmark) {
|
||||||
|
return " - Page " + std::to_string(bookmark.pageNumber + 1);
|
||||||
|
}
|
||||||
|
|
||||||
|
void EpubReaderBookmarkSelectionActivity::taskTrampoline(void* param) {
|
||||||
|
auto* self = static_cast<EpubReaderBookmarkSelectionActivity*>(param);
|
||||||
|
self->displayTaskLoop();
|
||||||
|
}
|
||||||
|
|
||||||
|
void EpubReaderBookmarkSelectionActivity::onEnter() {
|
||||||
|
ActivityWithSubactivity::onEnter();
|
||||||
|
|
||||||
|
renderingMutex = xSemaphoreCreateMutex();
|
||||||
|
|
||||||
|
// Trigger first update
|
||||||
|
updateRequired = true;
|
||||||
|
xTaskCreate(&EpubReaderBookmarkSelectionActivity::taskTrampoline, "BookmarkSelTask",
|
||||||
|
4096, // Stack size
|
||||||
|
this, // Parameters
|
||||||
|
1, // Priority
|
||||||
|
&displayTaskHandle // Task handle
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
void EpubReaderBookmarkSelectionActivity::onExit() {
|
||||||
|
ActivityWithSubactivity::onExit();
|
||||||
|
|
||||||
|
xSemaphoreTake(renderingMutex, portMAX_DELAY);
|
||||||
|
if (displayTaskHandle) {
|
||||||
|
vTaskDelete(displayTaskHandle);
|
||||||
|
displayTaskHandle = nullptr;
|
||||||
|
}
|
||||||
|
vSemaphoreDelete(renderingMutex);
|
||||||
|
renderingMutex = nullptr;
|
||||||
|
}
|
||||||
|
|
||||||
|
void EpubReaderBookmarkSelectionActivity::loop() {
|
||||||
|
if (subActivity) {
|
||||||
|
subActivity->loop();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const int totalItems = getTotalItems();
|
||||||
|
|
||||||
|
if (totalItems == 0) {
|
||||||
|
// All bookmarks deleted, go back
|
||||||
|
if (mappedInput.wasReleased(MappedInputManager::Button::Back) ||
|
||||||
|
mappedInput.wasReleased(MappedInputManager::Button::Confirm)) {
|
||||||
|
onGoBack();
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Delete confirmation mode: wait for confirm (delete) or back (cancel)
|
||||||
|
if (deleteConfirmMode) {
|
||||||
|
if (mappedInput.wasReleased(MappedInputManager::Button::Confirm)) {
|
||||||
|
if (ignoreNextConfirmRelease) {
|
||||||
|
// Ignore the release from the initial long press
|
||||||
|
ignoreNextConfirmRelease = false;
|
||||||
|
} else {
|
||||||
|
// Confirm delete
|
||||||
|
BookmarkStore::removeBookmark(cachePath, bookmarks[pendingDeleteIndex].spineIndex,
|
||||||
|
bookmarks[pendingDeleteIndex].pageNumber);
|
||||||
|
bookmarks.erase(bookmarks.begin() + pendingDeleteIndex);
|
||||||
|
if (selectorIndex >= static_cast<int>(bookmarks.size())) {
|
||||||
|
selectorIndex = std::max(0, static_cast<int>(bookmarks.size()) - 1);
|
||||||
|
}
|
||||||
|
deleteConfirmMode = false;
|
||||||
|
updateRequired = true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (mappedInput.wasReleased(MappedInputManager::Button::Back)) {
|
||||||
|
deleteConfirmMode = false;
|
||||||
|
ignoreNextConfirmRelease = false;
|
||||||
|
updateRequired = true;
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Detect long press on Confirm to trigger delete
|
||||||
|
constexpr unsigned long DELETE_HOLD_MS = 700;
|
||||||
|
if (mappedInput.isPressed(MappedInputManager::Button::Confirm) && mappedInput.getHeldTime() >= DELETE_HOLD_MS) {
|
||||||
|
if (totalItems > 0 && selectorIndex >= 0 && selectorIndex < totalItems) {
|
||||||
|
deleteConfirmMode = true;
|
||||||
|
ignoreNextConfirmRelease = true;
|
||||||
|
pendingDeleteIndex = selectorIndex;
|
||||||
|
updateRequired = true;
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const int pageItems = getPageItems();
|
||||||
|
|
||||||
|
if (mappedInput.wasReleased(MappedInputManager::Button::Confirm)) {
|
||||||
|
if (selectorIndex >= 0 && selectorIndex < totalItems) {
|
||||||
|
const auto& b = bookmarks[selectorIndex];
|
||||||
|
onSelectBookmark(b.spineIndex, b.pageNumber);
|
||||||
|
} else {
|
||||||
|
onGoBack();
|
||||||
|
}
|
||||||
|
} else if (mappedInput.wasReleased(MappedInputManager::Button::Back)) {
|
||||||
|
onGoBack();
|
||||||
|
}
|
||||||
|
|
||||||
|
buttonNavigator.onNextRelease([this, totalItems] {
|
||||||
|
selectorIndex = ButtonNavigator::nextIndex(selectorIndex, totalItems);
|
||||||
|
updateRequired = true;
|
||||||
|
});
|
||||||
|
|
||||||
|
buttonNavigator.onPreviousRelease([this, totalItems] {
|
||||||
|
selectorIndex = ButtonNavigator::previousIndex(selectorIndex, totalItems);
|
||||||
|
updateRequired = true;
|
||||||
|
});
|
||||||
|
|
||||||
|
buttonNavigator.onNextContinuous([this, totalItems, pageItems] {
|
||||||
|
selectorIndex = ButtonNavigator::nextPageIndex(selectorIndex, totalItems, pageItems);
|
||||||
|
updateRequired = true;
|
||||||
|
});
|
||||||
|
|
||||||
|
buttonNavigator.onPreviousContinuous([this, totalItems, pageItems] {
|
||||||
|
selectorIndex = ButtonNavigator::previousPageIndex(selectorIndex, totalItems, pageItems);
|
||||||
|
updateRequired = true;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
void EpubReaderBookmarkSelectionActivity::displayTaskLoop() {
|
||||||
|
while (true) {
|
||||||
|
if (updateRequired && !subActivity) {
|
||||||
|
updateRequired = false;
|
||||||
|
xSemaphoreTake(renderingMutex, portMAX_DELAY);
|
||||||
|
renderScreen();
|
||||||
|
xSemaphoreGive(renderingMutex);
|
||||||
|
}
|
||||||
|
vTaskDelay(10 / portTICK_PERIOD_MS);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
void EpubReaderBookmarkSelectionActivity::renderScreen() {
|
||||||
|
renderer.clearScreen();
|
||||||
|
|
||||||
|
const auto pageWidth = renderer.getScreenWidth();
|
||||||
|
const auto orientation = renderer.getOrientation();
|
||||||
|
const bool isLandscapeCw = orientation == GfxRenderer::Orientation::LandscapeClockwise;
|
||||||
|
const bool isLandscapeCcw = orientation == GfxRenderer::Orientation::LandscapeCounterClockwise;
|
||||||
|
const bool isPortraitInverted = orientation == GfxRenderer::Orientation::PortraitInverted;
|
||||||
|
const int hintGutterWidth = (isLandscapeCw || isLandscapeCcw) ? 30 : 0;
|
||||||
|
const int contentX = isLandscapeCw ? hintGutterWidth : 0;
|
||||||
|
const int contentWidth = pageWidth - hintGutterWidth;
|
||||||
|
const int hintGutterHeight = isPortraitInverted ? 50 : 0;
|
||||||
|
const int contentY = hintGutterHeight;
|
||||||
|
const int pageItems = getPageItems();
|
||||||
|
const int totalItems = getTotalItems();
|
||||||
|
|
||||||
|
// Title
|
||||||
|
const int titleX =
|
||||||
|
contentX + (contentWidth - renderer.getTextWidth(UI_12_FONT_ID, "Go to Bookmark", EpdFontFamily::BOLD)) / 2;
|
||||||
|
renderer.drawText(UI_12_FONT_ID, titleX, 15 + contentY, "Go to Bookmark", true, EpdFontFamily::BOLD);
|
||||||
|
|
||||||
|
if (totalItems == 0) {
|
||||||
|
renderer.drawCenteredText(UI_10_FONT_ID, 100 + contentY, "No bookmarks", true);
|
||||||
|
} else {
|
||||||
|
const auto pageStartIndex = selectorIndex / pageItems * pageItems;
|
||||||
|
renderer.fillRect(contentX, 60 + contentY + (selectorIndex % pageItems) * 30 - 2, contentWidth - 1, 30);
|
||||||
|
|
||||||
|
const int maxLabelWidth = contentWidth - 40 - contentX - 20;
|
||||||
|
|
||||||
|
for (int i = 0; i < pageItems; i++) {
|
||||||
|
int itemIndex = pageStartIndex + i;
|
||||||
|
if (itemIndex >= totalItems) break;
|
||||||
|
const int displayY = 60 + contentY + i * 30;
|
||||||
|
const bool isSelected = (itemIndex == selectorIndex);
|
||||||
|
|
||||||
|
const std::string suffix = getPageSuffix(bookmarks[itemIndex]);
|
||||||
|
const int suffixWidth = renderer.getTextWidth(UI_10_FONT_ID, suffix.c_str());
|
||||||
|
|
||||||
|
// Truncate the prefix (chapter + snippet) to leave room for the page suffix
|
||||||
|
const std::string prefix = getBookmarkPrefix(bookmarks[itemIndex]);
|
||||||
|
const std::string truncatedPrefix =
|
||||||
|
renderer.truncatedText(UI_10_FONT_ID, prefix.c_str(), maxLabelWidth - suffixWidth);
|
||||||
|
|
||||||
|
const std::string label = truncatedPrefix + suffix;
|
||||||
|
|
||||||
|
renderer.drawText(UI_10_FONT_ID, contentX + 20, displayY, label.c_str(), !isSelected);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (deleteConfirmMode && pendingDeleteIndex < static_cast<int>(bookmarks.size())) {
|
||||||
|
// Draw delete confirmation overlay
|
||||||
|
const std::string suffix = getPageSuffix(bookmarks[pendingDeleteIndex]);
|
||||||
|
std::string msg = "Delete bookmark" + suffix + "?";
|
||||||
|
|
||||||
|
constexpr int margin = 15;
|
||||||
|
constexpr int popupY = 200;
|
||||||
|
const int textWidth = renderer.getTextWidth(UI_12_FONT_ID, msg.c_str(), EpdFontFamily::BOLD);
|
||||||
|
const int textHeight = renderer.getLineHeight(UI_12_FONT_ID);
|
||||||
|
const int w = textWidth + margin * 2;
|
||||||
|
const int h = textHeight + margin * 2;
|
||||||
|
const int x = (renderer.getScreenWidth() - w) / 2;
|
||||||
|
|
||||||
|
renderer.fillRect(x - 2, popupY - 2, w + 4, h + 4, true);
|
||||||
|
renderer.fillRect(x, popupY, w, h, false);
|
||||||
|
|
||||||
|
const int textX = x + (w - textWidth) / 2;
|
||||||
|
const int textY = popupY + margin - 2;
|
||||||
|
renderer.drawText(UI_12_FONT_ID, textX, textY, msg.c_str(), true, EpdFontFamily::BOLD);
|
||||||
|
|
||||||
|
const auto labels = mappedInput.mapLabels("Cancel", "Delete", "", "");
|
||||||
|
GUI.drawButtonHints(renderer, labels.btn1, labels.btn2, labels.btn3, labels.btn4);
|
||||||
|
} else {
|
||||||
|
if (!bookmarks.empty()) {
|
||||||
|
const char* deleteHint = "Hold select to delete";
|
||||||
|
const int hintWidth = renderer.getTextWidth(SMALL_FONT_ID, deleteHint);
|
||||||
|
renderer.drawText(SMALL_FONT_ID, (renderer.getScreenWidth() - hintWidth) / 2,
|
||||||
|
renderer.getScreenHeight() - 70, deleteHint);
|
||||||
|
}
|
||||||
|
|
||||||
|
const auto labels = mappedInput.mapLabels("\xC2\xAB Back", "Select", "Up", "Down");
|
||||||
|
GUI.drawButtonHints(renderer, labels.btn1, labels.btn2, labels.btn3, labels.btn4);
|
||||||
|
}
|
||||||
|
|
||||||
|
renderer.displayBuffer();
|
||||||
|
}
|
||||||
60
src/activities/reader/EpubReaderBookmarkSelectionActivity.h
Normal file
60
src/activities/reader/EpubReaderBookmarkSelectionActivity.h
Normal file
@@ -0,0 +1,60 @@
|
|||||||
|
#pragma once
|
||||||
|
#include <Epub.h>
|
||||||
|
#include <freertos/FreeRTOS.h>
|
||||||
|
#include <freertos/semphr.h>
|
||||||
|
#include <freertos/task.h>
|
||||||
|
|
||||||
|
#include <memory>
|
||||||
|
#include <vector>
|
||||||
|
|
||||||
|
#include "../ActivityWithSubactivity.h"
|
||||||
|
#include "util/BookmarkStore.h"
|
||||||
|
#include "util/ButtonNavigator.h"
|
||||||
|
|
||||||
|
class EpubReaderBookmarkSelectionActivity final : public ActivityWithSubactivity {
|
||||||
|
std::shared_ptr<Epub> epub;
|
||||||
|
std::vector<Bookmark> bookmarks;
|
||||||
|
std::string cachePath;
|
||||||
|
TaskHandle_t displayTaskHandle = nullptr;
|
||||||
|
SemaphoreHandle_t renderingMutex = nullptr;
|
||||||
|
ButtonNavigator buttonNavigator;
|
||||||
|
int selectorIndex = 0;
|
||||||
|
bool updateRequired = false;
|
||||||
|
bool deleteConfirmMode = false;
|
||||||
|
bool ignoreNextConfirmRelease = false;
|
||||||
|
int pendingDeleteIndex = 0;
|
||||||
|
const std::function<void()> onGoBack;
|
||||||
|
const std::function<void(int newSpineIndex, int newPage)> onSelectBookmark;
|
||||||
|
|
||||||
|
// Number of items that fit on a page, derived from logical screen height.
|
||||||
|
int getPageItems() const;
|
||||||
|
|
||||||
|
int getTotalItems() const;
|
||||||
|
|
||||||
|
// Build the prefix portion of a bookmark label (chapter + snippet, without page suffix)
|
||||||
|
std::string getBookmarkPrefix(const Bookmark& bookmark) const;
|
||||||
|
|
||||||
|
// Build the page suffix (e.g. " - Page 5")
|
||||||
|
static std::string getPageSuffix(const Bookmark& bookmark);
|
||||||
|
|
||||||
|
static void taskTrampoline(void* param);
|
||||||
|
[[noreturn]] void displayTaskLoop();
|
||||||
|
void renderScreen();
|
||||||
|
|
||||||
|
public:
|
||||||
|
explicit EpubReaderBookmarkSelectionActivity(GfxRenderer& renderer, MappedInputManager& mappedInput,
|
||||||
|
const std::shared_ptr<Epub>& epub,
|
||||||
|
std::vector<Bookmark> bookmarks,
|
||||||
|
const std::string& cachePath,
|
||||||
|
const std::function<void()>& onGoBack,
|
||||||
|
const std::function<void(int newSpineIndex, int newPage)>& onSelectBookmark)
|
||||||
|
: ActivityWithSubactivity("EpubReaderBookmarkSelection", renderer, mappedInput),
|
||||||
|
epub(epub),
|
||||||
|
bookmarks(std::move(bookmarks)),
|
||||||
|
cachePath(cachePath),
|
||||||
|
onGoBack(onGoBack),
|
||||||
|
onSelectBookmark(onSelectBookmark) {}
|
||||||
|
void onEnter() override;
|
||||||
|
void onExit() override;
|
||||||
|
void loop() override;
|
||||||
|
};
|
||||||
@@ -68,6 +68,14 @@ void EpubReaderMenuActivity::loop() {
|
|||||||
updateRequired = true;
|
updateRequired = true;
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
if (selectedAction == MenuAction::LETTERBOX_FILL) {
|
||||||
|
// Cycle through: Default -> Dithered -> Solid -> None -> Default ...
|
||||||
|
int idx = (letterboxFillToIndex() + 1) % LETTERBOX_FILL_OPTION_COUNT;
|
||||||
|
pendingLetterboxFill = indexToLetterboxFill(idx);
|
||||||
|
saveLetterboxFill();
|
||||||
|
updateRequired = true;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
// 1. Capture the callback and action locally
|
// 1. Capture the callback and action locally
|
||||||
auto actionCallback = onAction;
|
auto actionCallback = onAction;
|
||||||
@@ -139,6 +147,12 @@ void EpubReaderMenuActivity::renderScreen() {
|
|||||||
const auto width = renderer.getTextWidth(UI_10_FONT_ID, value);
|
const auto width = renderer.getTextWidth(UI_10_FONT_ID, value);
|
||||||
renderer.drawText(UI_10_FONT_ID, contentX + contentWidth - 20 - width, displayY, value, !isSelected);
|
renderer.drawText(UI_10_FONT_ID, contentX + contentWidth - 20 - width, displayY, value, !isSelected);
|
||||||
}
|
}
|
||||||
|
if (menuItems[i].action == MenuAction::LETTERBOX_FILL) {
|
||||||
|
// Render current letterbox fill value on the right edge of the content area.
|
||||||
|
const auto value = letterboxFillLabels[letterboxFillToIndex()];
|
||||||
|
const auto width = renderer.getTextWidth(UI_10_FONT_ID, value);
|
||||||
|
renderer.drawText(UI_10_FONT_ID, contentX + contentWidth - 20 - width, displayY, value, !isSelected);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Footer / Hints
|
// Footer / Hints
|
||||||
|
|||||||
@@ -9,25 +9,48 @@
|
|||||||
#include <vector>
|
#include <vector>
|
||||||
|
|
||||||
#include "../ActivityWithSubactivity.h"
|
#include "../ActivityWithSubactivity.h"
|
||||||
|
#include "util/BookSettings.h"
|
||||||
#include "util/ButtonNavigator.h"
|
#include "util/ButtonNavigator.h"
|
||||||
|
|
||||||
class EpubReaderMenuActivity final : public ActivityWithSubactivity {
|
class EpubReaderMenuActivity final : public ActivityWithSubactivity {
|
||||||
public:
|
public:
|
||||||
// Menu actions available from the reader menu.
|
// Menu actions available from the reader menu.
|
||||||
enum class MenuAction { SELECT_CHAPTER, GO_TO_PERCENT, ROTATE_SCREEN, GO_HOME, SYNC, DELETE_CACHE };
|
enum class MenuAction {
|
||||||
|
ADD_BOOKMARK,
|
||||||
|
REMOVE_BOOKMARK,
|
||||||
|
LOOKUP,
|
||||||
|
LOOKED_UP_WORDS,
|
||||||
|
ROTATE_SCREEN,
|
||||||
|
LETTERBOX_FILL,
|
||||||
|
SELECT_CHAPTER,
|
||||||
|
GO_TO_BOOKMARK,
|
||||||
|
GO_TO_PERCENT,
|
||||||
|
GO_HOME,
|
||||||
|
SYNC,
|
||||||
|
DELETE_CACHE,
|
||||||
|
DELETE_DICT_CACHE
|
||||||
|
};
|
||||||
|
|
||||||
explicit EpubReaderMenuActivity(GfxRenderer& renderer, MappedInputManager& mappedInput, const std::string& title,
|
explicit EpubReaderMenuActivity(GfxRenderer& renderer, MappedInputManager& mappedInput, const std::string& title,
|
||||||
const int currentPage, const int totalPages, const int bookProgressPercent,
|
const int currentPage, const int totalPages, const int bookProgressPercent,
|
||||||
const uint8_t currentOrientation, const std::function<void(uint8_t)>& onBack,
|
const uint8_t currentOrientation, const bool hasDictionary,
|
||||||
|
const bool isBookmarked, const std::string& bookCachePath,
|
||||||
|
const std::function<void(uint8_t)>& onBack,
|
||||||
const std::function<void(MenuAction)>& onAction)
|
const std::function<void(MenuAction)>& onAction)
|
||||||
: ActivityWithSubactivity("EpubReaderMenu", renderer, mappedInput),
|
: ActivityWithSubactivity("EpubReaderMenu", renderer, mappedInput),
|
||||||
|
menuItems(buildMenuItems(hasDictionary, isBookmarked)),
|
||||||
title(title),
|
title(title),
|
||||||
pendingOrientation(currentOrientation),
|
pendingOrientation(currentOrientation),
|
||||||
|
bookCachePath(bookCachePath),
|
||||||
currentPage(currentPage),
|
currentPage(currentPage),
|
||||||
totalPages(totalPages),
|
totalPages(totalPages),
|
||||||
bookProgressPercent(bookProgressPercent),
|
bookProgressPercent(bookProgressPercent),
|
||||||
onBack(onBack),
|
onBack(onBack),
|
||||||
onAction(onAction) {}
|
onAction(onAction) {
|
||||||
|
// Load per-book settings to initialize the letterbox fill override
|
||||||
|
auto bookSettings = BookSettings::load(bookCachePath);
|
||||||
|
pendingLetterboxFill = bookSettings.letterboxFillOverride;
|
||||||
|
}
|
||||||
|
|
||||||
void onEnter() override;
|
void onEnter() override;
|
||||||
void onExit() override;
|
void onExit() override;
|
||||||
@@ -39,11 +62,7 @@ class EpubReaderMenuActivity final : public ActivityWithSubactivity {
|
|||||||
std::string label;
|
std::string label;
|
||||||
};
|
};
|
||||||
|
|
||||||
// Fixed menu layout (order matters for up/down navigation).
|
std::vector<MenuItem> menuItems;
|
||||||
const std::vector<MenuItem> menuItems = {
|
|
||||||
{MenuAction::SELECT_CHAPTER, "Go to Chapter"}, {MenuAction::ROTATE_SCREEN, "Reading Orientation"},
|
|
||||||
{MenuAction::GO_TO_PERCENT, "Go to %"}, {MenuAction::GO_HOME, "Go Home"},
|
|
||||||
{MenuAction::SYNC, "Sync Progress"}, {MenuAction::DELETE_CACHE, "Delete Book Cache"}};
|
|
||||||
|
|
||||||
int selectedIndex = 0;
|
int selectedIndex = 0;
|
||||||
bool updateRequired = false;
|
bool updateRequired = false;
|
||||||
@@ -53,6 +72,11 @@ class EpubReaderMenuActivity final : public ActivityWithSubactivity {
|
|||||||
std::string title = "Reader Menu";
|
std::string title = "Reader Menu";
|
||||||
uint8_t pendingOrientation = 0;
|
uint8_t pendingOrientation = 0;
|
||||||
const std::vector<const char*> orientationLabels = {"Portrait", "Landscape CW", "Inverted", "Landscape CCW"};
|
const std::vector<const char*> orientationLabels = {"Portrait", "Landscape CW", "Inverted", "Landscape CCW"};
|
||||||
|
std::string bookCachePath;
|
||||||
|
// Letterbox fill override: 0xFF = Default (use global), 0 = Dithered, 1 = Solid, 2 = None
|
||||||
|
uint8_t pendingLetterboxFill = BookSettings::USE_GLOBAL;
|
||||||
|
static constexpr int LETTERBOX_FILL_OPTION_COUNT = 4; // Default + 3 modes
|
||||||
|
const std::vector<const char*> letterboxFillLabels = {"Default", "Dithered", "Solid", "None"};
|
||||||
int currentPage = 0;
|
int currentPage = 0;
|
||||||
int totalPages = 0;
|
int totalPages = 0;
|
||||||
int bookProgressPercent = 0;
|
int bookProgressPercent = 0;
|
||||||
@@ -60,6 +84,50 @@ class EpubReaderMenuActivity final : public ActivityWithSubactivity {
|
|||||||
const std::function<void(uint8_t)> onBack;
|
const std::function<void(uint8_t)> onBack;
|
||||||
const std::function<void(MenuAction)> onAction;
|
const std::function<void(MenuAction)> onAction;
|
||||||
|
|
||||||
|
// Map the internal override value to an index into letterboxFillLabels.
|
||||||
|
int letterboxFillToIndex() const {
|
||||||
|
if (pendingLetterboxFill == BookSettings::USE_GLOBAL) return 0; // "Default"
|
||||||
|
return pendingLetterboxFill + 1; // 0->1 (Dithered), 1->2 (Solid), 2->3 (None)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Map an index from letterboxFillLabels back to an override value.
|
||||||
|
static uint8_t indexToLetterboxFill(int index) {
|
||||||
|
if (index == 0) return BookSettings::USE_GLOBAL;
|
||||||
|
return static_cast<uint8_t>(index - 1);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Save the current letterbox fill override to the book's settings file.
|
||||||
|
void saveLetterboxFill() const {
|
||||||
|
auto bookSettings = BookSettings::load(bookCachePath);
|
||||||
|
bookSettings.letterboxFillOverride = pendingLetterboxFill;
|
||||||
|
BookSettings::save(bookCachePath, bookSettings);
|
||||||
|
}
|
||||||
|
|
||||||
|
static std::vector<MenuItem> buildMenuItems(bool hasDictionary, bool isBookmarked) {
|
||||||
|
std::vector<MenuItem> items;
|
||||||
|
if (isBookmarked) {
|
||||||
|
items.push_back({MenuAction::REMOVE_BOOKMARK, "Remove Bookmark"});
|
||||||
|
} else {
|
||||||
|
items.push_back({MenuAction::ADD_BOOKMARK, "Add Bookmark"});
|
||||||
|
}
|
||||||
|
if (hasDictionary) {
|
||||||
|
items.push_back({MenuAction::LOOKUP, "Lookup Word"});
|
||||||
|
items.push_back({MenuAction::LOOKED_UP_WORDS, "Lookup Word History"});
|
||||||
|
}
|
||||||
|
items.push_back({MenuAction::ROTATE_SCREEN, "Reading Orientation"});
|
||||||
|
items.push_back({MenuAction::LETTERBOX_FILL, "Letterbox Fill"});
|
||||||
|
items.push_back({MenuAction::SELECT_CHAPTER, "Table of Contents"});
|
||||||
|
items.push_back({MenuAction::GO_TO_BOOKMARK, "Go to Bookmark"});
|
||||||
|
items.push_back({MenuAction::GO_TO_PERCENT, "Go to %"});
|
||||||
|
items.push_back({MenuAction::GO_HOME, "Close Book"});
|
||||||
|
items.push_back({MenuAction::SYNC, "Sync Progress"});
|
||||||
|
items.push_back({MenuAction::DELETE_CACHE, "Delete Book Cache"});
|
||||||
|
if (hasDictionary) {
|
||||||
|
items.push_back({MenuAction::DELETE_DICT_CACHE, "Delete Dictionary Cache"});
|
||||||
|
}
|
||||||
|
return items;
|
||||||
|
}
|
||||||
|
|
||||||
static void taskTrampoline(void* param);
|
static void taskTrampoline(void* param);
|
||||||
[[noreturn]] void displayTaskLoop();
|
[[noreturn]] void displayTaskLoop();
|
||||||
void renderScreen();
|
void renderScreen();
|
||||||
|
|||||||
196
src/activities/reader/LookedUpWordsActivity.cpp
Normal file
196
src/activities/reader/LookedUpWordsActivity.cpp
Normal file
@@ -0,0 +1,196 @@
|
|||||||
|
#include "LookedUpWordsActivity.h"
|
||||||
|
|
||||||
|
#include <GfxRenderer.h>
|
||||||
|
|
||||||
|
#include <algorithm>
|
||||||
|
|
||||||
|
#include "MappedInputManager.h"
|
||||||
|
#include "components/UITheme.h"
|
||||||
|
#include "fontIds.h"
|
||||||
|
#include "util/LookupHistory.h"
|
||||||
|
|
||||||
|
void LookedUpWordsActivity::taskTrampoline(void* param) {
|
||||||
|
auto* self = static_cast<LookedUpWordsActivity*>(param);
|
||||||
|
self->displayTaskLoop();
|
||||||
|
}
|
||||||
|
|
||||||
|
void LookedUpWordsActivity::displayTaskLoop() {
|
||||||
|
while (true) {
|
||||||
|
if (updateRequired && !subActivity) {
|
||||||
|
updateRequired = false;
|
||||||
|
xSemaphoreTake(renderingMutex, portMAX_DELAY);
|
||||||
|
renderScreen();
|
||||||
|
xSemaphoreGive(renderingMutex);
|
||||||
|
}
|
||||||
|
vTaskDelay(10 / portTICK_PERIOD_MS);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
void LookedUpWordsActivity::onEnter() {
|
||||||
|
ActivityWithSubactivity::onEnter();
|
||||||
|
renderingMutex = xSemaphoreCreateMutex();
|
||||||
|
words = LookupHistory::load(cachePath);
|
||||||
|
updateRequired = true;
|
||||||
|
xTaskCreate(&LookedUpWordsActivity::taskTrampoline, "LookedUpTask", 4096, this, 1, &displayTaskHandle);
|
||||||
|
}
|
||||||
|
|
||||||
|
void LookedUpWordsActivity::onExit() {
|
||||||
|
ActivityWithSubactivity::onExit();
|
||||||
|
xSemaphoreTake(renderingMutex, portMAX_DELAY);
|
||||||
|
if (displayTaskHandle) {
|
||||||
|
vTaskDelete(displayTaskHandle);
|
||||||
|
displayTaskHandle = nullptr;
|
||||||
|
}
|
||||||
|
vSemaphoreDelete(renderingMutex);
|
||||||
|
renderingMutex = nullptr;
|
||||||
|
}
|
||||||
|
|
||||||
|
void LookedUpWordsActivity::loop() {
|
||||||
|
if (subActivity) {
|
||||||
|
subActivity->loop();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (words.empty()) {
|
||||||
|
if (mappedInput.wasReleased(MappedInputManager::Button::Back) ||
|
||||||
|
mappedInput.wasReleased(MappedInputManager::Button::Confirm)) {
|
||||||
|
onBack();
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Delete confirmation mode: wait for confirm (delete) or back (cancel)
|
||||||
|
if (deleteConfirmMode) {
|
||||||
|
if (mappedInput.wasReleased(MappedInputManager::Button::Confirm)) {
|
||||||
|
if (ignoreNextConfirmRelease) {
|
||||||
|
// Ignore the release from the initial long press
|
||||||
|
ignoreNextConfirmRelease = false;
|
||||||
|
} else {
|
||||||
|
// Confirm delete
|
||||||
|
LookupHistory::removeWord(cachePath, words[pendingDeleteIndex]);
|
||||||
|
words.erase(words.begin() + pendingDeleteIndex);
|
||||||
|
if (selectedIndex >= static_cast<int>(words.size())) {
|
||||||
|
selectedIndex = std::max(0, static_cast<int>(words.size()) - 1);
|
||||||
|
}
|
||||||
|
deleteConfirmMode = false;
|
||||||
|
updateRequired = true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (mappedInput.wasReleased(MappedInputManager::Button::Back)) {
|
||||||
|
deleteConfirmMode = false;
|
||||||
|
ignoreNextConfirmRelease = false;
|
||||||
|
updateRequired = true;
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Detect long press on Confirm to trigger delete
|
||||||
|
constexpr unsigned long DELETE_HOLD_MS = 700;
|
||||||
|
if (mappedInput.isPressed(MappedInputManager::Button::Confirm) && mappedInput.getHeldTime() >= DELETE_HOLD_MS) {
|
||||||
|
deleteConfirmMode = true;
|
||||||
|
ignoreNextConfirmRelease = true;
|
||||||
|
pendingDeleteIndex = selectedIndex;
|
||||||
|
updateRequired = true;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
buttonNavigator.onNext([this] {
|
||||||
|
selectedIndex = ButtonNavigator::nextIndex(selectedIndex, static_cast<int>(words.size()));
|
||||||
|
updateRequired = true;
|
||||||
|
});
|
||||||
|
|
||||||
|
buttonNavigator.onPrevious([this] {
|
||||||
|
selectedIndex = ButtonNavigator::previousIndex(selectedIndex, static_cast<int>(words.size()));
|
||||||
|
updateRequired = true;
|
||||||
|
});
|
||||||
|
|
||||||
|
if (mappedInput.wasReleased(MappedInputManager::Button::Confirm)) {
|
||||||
|
onSelectWord(words[selectedIndex]);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (mappedInput.wasReleased(MappedInputManager::Button::Back)) {
|
||||||
|
onBack();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
void LookedUpWordsActivity::renderScreen() {
|
||||||
|
renderer.clearScreen();
|
||||||
|
|
||||||
|
constexpr int sidePadding = 20;
|
||||||
|
constexpr int titleY = 15;
|
||||||
|
constexpr int startY = 60;
|
||||||
|
constexpr int lineHeight = 30;
|
||||||
|
|
||||||
|
// Title
|
||||||
|
const int titleX =
|
||||||
|
(renderer.getScreenWidth() - renderer.getTextWidth(UI_12_FONT_ID, "Lookup History", EpdFontFamily::BOLD)) / 2;
|
||||||
|
renderer.drawText(UI_12_FONT_ID, titleX, titleY, "Lookup History", true, EpdFontFamily::BOLD);
|
||||||
|
|
||||||
|
if (words.empty()) {
|
||||||
|
renderer.drawCenteredText(UI_10_FONT_ID, 300, "No words looked up yet");
|
||||||
|
} else {
|
||||||
|
const int screenHeight = renderer.getScreenHeight();
|
||||||
|
const int pageItems = std::max(1, (screenHeight - startY - 40) / lineHeight);
|
||||||
|
const int pageStart = selectedIndex / pageItems * pageItems;
|
||||||
|
|
||||||
|
for (int i = 0; i < pageItems; i++) {
|
||||||
|
int idx = pageStart + i;
|
||||||
|
if (idx >= static_cast<int>(words.size())) break;
|
||||||
|
|
||||||
|
const int displayY = startY + i * lineHeight;
|
||||||
|
const bool isSelected = (idx == selectedIndex);
|
||||||
|
|
||||||
|
if (isSelected) {
|
||||||
|
renderer.fillRect(0, displayY - 2, renderer.getScreenWidth() - 1, lineHeight);
|
||||||
|
}
|
||||||
|
|
||||||
|
renderer.drawText(UI_10_FONT_ID, sidePadding, displayY, words[idx].c_str(), !isSelected);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (deleteConfirmMode && pendingDeleteIndex < static_cast<int>(words.size())) {
|
||||||
|
// Draw delete confirmation overlay
|
||||||
|
const std::string& word = words[pendingDeleteIndex];
|
||||||
|
std::string displayWord = word;
|
||||||
|
if (displayWord.size() > 20) {
|
||||||
|
displayWord.erase(17);
|
||||||
|
displayWord += "...";
|
||||||
|
}
|
||||||
|
std::string msg = "Delete '" + displayWord + "'?";
|
||||||
|
|
||||||
|
constexpr int margin = 15;
|
||||||
|
constexpr int popupY = 200;
|
||||||
|
const int textWidth = renderer.getTextWidth(UI_12_FONT_ID, msg.c_str(), EpdFontFamily::BOLD);
|
||||||
|
const int textHeight = renderer.getLineHeight(UI_12_FONT_ID);
|
||||||
|
const int w = textWidth + margin * 2;
|
||||||
|
const int h = textHeight + margin * 2;
|
||||||
|
const int x = (renderer.getScreenWidth() - w) / 2;
|
||||||
|
|
||||||
|
renderer.fillRect(x - 2, popupY - 2, w + 4, h + 4, true);
|
||||||
|
renderer.fillRect(x, popupY, w, h, false);
|
||||||
|
|
||||||
|
const int textX = x + (w - textWidth) / 2;
|
||||||
|
const int textY = popupY + margin - 2;
|
||||||
|
renderer.drawText(UI_12_FONT_ID, textX, textY, msg.c_str(), true, EpdFontFamily::BOLD);
|
||||||
|
|
||||||
|
// Button hints for delete mode
|
||||||
|
const auto labels = mappedInput.mapLabels("Cancel", "Delete", "", "");
|
||||||
|
GUI.drawButtonHints(renderer, labels.btn1, labels.btn2, labels.btn3, labels.btn4);
|
||||||
|
} else {
|
||||||
|
// "Hold select to delete" hint above button hints
|
||||||
|
if (!words.empty()) {
|
||||||
|
const char* deleteHint = "Hold select to delete";
|
||||||
|
const int hintWidth = renderer.getTextWidth(SMALL_FONT_ID, deleteHint);
|
||||||
|
renderer.drawText(SMALL_FONT_ID, (renderer.getScreenWidth() - hintWidth) / 2, renderer.getScreenHeight() - 70,
|
||||||
|
deleteHint);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Normal button hints
|
||||||
|
const auto labels = mappedInput.mapLabels("\xC2\xAB Back", "Select", "^", "v");
|
||||||
|
GUI.drawButtonHints(renderer, labels.btn1, labels.btn2, labels.btn3, labels.btn4);
|
||||||
|
}
|
||||||
|
|
||||||
|
renderer.displayBuffer();
|
||||||
|
}
|
||||||
48
src/activities/reader/LookedUpWordsActivity.h
Normal file
48
src/activities/reader/LookedUpWordsActivity.h
Normal file
@@ -0,0 +1,48 @@
|
|||||||
|
#pragma once
|
||||||
|
#include <freertos/FreeRTOS.h>
|
||||||
|
#include <freertos/semphr.h>
|
||||||
|
#include <freertos/task.h>
|
||||||
|
|
||||||
|
#include <functional>
|
||||||
|
#include <string>
|
||||||
|
#include <vector>
|
||||||
|
|
||||||
|
#include "../ActivityWithSubactivity.h"
|
||||||
|
#include "util/ButtonNavigator.h"
|
||||||
|
|
||||||
|
class LookedUpWordsActivity final : public ActivityWithSubactivity {
|
||||||
|
public:
|
||||||
|
explicit LookedUpWordsActivity(GfxRenderer& renderer, MappedInputManager& mappedInput, const std::string& cachePath,
|
||||||
|
const std::function<void()>& onBack,
|
||||||
|
const std::function<void(const std::string&)>& onSelectWord)
|
||||||
|
: ActivityWithSubactivity("LookedUpWords", renderer, mappedInput),
|
||||||
|
cachePath(cachePath),
|
||||||
|
onBack(onBack),
|
||||||
|
onSelectWord(onSelectWord) {}
|
||||||
|
|
||||||
|
void onEnter() override;
|
||||||
|
void onExit() override;
|
||||||
|
void loop() override;
|
||||||
|
|
||||||
|
private:
|
||||||
|
std::string cachePath;
|
||||||
|
const std::function<void()> onBack;
|
||||||
|
const std::function<void(const std::string&)> onSelectWord;
|
||||||
|
|
||||||
|
std::vector<std::string> words;
|
||||||
|
int selectedIndex = 0;
|
||||||
|
bool updateRequired = false;
|
||||||
|
ButtonNavigator buttonNavigator;
|
||||||
|
|
||||||
|
// Delete confirmation state
|
||||||
|
bool deleteConfirmMode = false;
|
||||||
|
bool ignoreNextConfirmRelease = false;
|
||||||
|
int pendingDeleteIndex = 0;
|
||||||
|
|
||||||
|
TaskHandle_t displayTaskHandle = nullptr;
|
||||||
|
SemaphoreHandle_t renderingMutex = nullptr;
|
||||||
|
|
||||||
|
void renderScreen();
|
||||||
|
static void taskTrampoline(void* param);
|
||||||
|
[[noreturn]] void displayTaskLoop();
|
||||||
|
};
|
||||||
@@ -57,6 +57,15 @@ void TxtReaderActivity::onEnter() {
|
|||||||
|
|
||||||
txt->setupCacheDir();
|
txt->setupCacheDir();
|
||||||
|
|
||||||
|
// Prerender cover on first open so the Sleep screen is instant.
|
||||||
|
// generateCoverBmp() is a no-op if the file already exists, so this only does work once.
|
||||||
|
// TXT has no thumbnail support, so only the sleep screen cover is generated.
|
||||||
|
if (!Storage.exists(txt->getCoverBmpPath().c_str())) {
|
||||||
|
Rect popupRect = GUI.drawPopup(renderer, "Preparing book...");
|
||||||
|
txt->generateCoverBmp();
|
||||||
|
GUI.fillPopupProgress(renderer, popupRect, 100);
|
||||||
|
}
|
||||||
|
|
||||||
// Save current txt as last opened file and add to recent books
|
// Save current txt as last opened file and add to recent books
|
||||||
auto filePath = txt->getPath();
|
auto filePath = txt->getPath();
|
||||||
auto fileName = filePath.substr(filePath.rfind('/') + 1);
|
auto fileName = filePath.substr(filePath.rfind('/') + 1);
|
||||||
|
|||||||
@@ -43,6 +43,37 @@ void XtcReaderActivity::onEnter() {
|
|||||||
// Load saved progress
|
// Load saved progress
|
||||||
loadProgress();
|
loadProgress();
|
||||||
|
|
||||||
|
// Prerender covers and thumbnails on first open so Home and Sleep screens are instant.
|
||||||
|
// Each generate* call is a no-op if the file already exists, so this only does work once.
|
||||||
|
{
|
||||||
|
int totalSteps = 0;
|
||||||
|
if (!Storage.exists(xtc->getCoverBmpPath().c_str())) totalSteps++;
|
||||||
|
for (int i = 0; i < PRERENDER_THUMB_HEIGHTS_COUNT; i++) {
|
||||||
|
if (!Storage.exists(xtc->getThumbBmpPath(PRERENDER_THUMB_HEIGHTS[i]).c_str())) totalSteps++;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (totalSteps > 0) {
|
||||||
|
Rect popupRect = GUI.drawPopup(renderer, "Preparing book...");
|
||||||
|
int completedSteps = 0;
|
||||||
|
|
||||||
|
auto updateProgress = [&]() {
|
||||||
|
completedSteps++;
|
||||||
|
GUI.fillPopupProgress(renderer, popupRect, completedSteps * 100 / totalSteps);
|
||||||
|
};
|
||||||
|
|
||||||
|
if (!Storage.exists(xtc->getCoverBmpPath().c_str())) {
|
||||||
|
xtc->generateCoverBmp();
|
||||||
|
updateProgress();
|
||||||
|
}
|
||||||
|
for (int i = 0; i < PRERENDER_THUMB_HEIGHTS_COUNT; i++) {
|
||||||
|
if (!Storage.exists(xtc->getThumbBmpPath(PRERENDER_THUMB_HEIGHTS[i]).c_str())) {
|
||||||
|
xtc->generateThumbBmp(PRERENDER_THUMB_HEIGHTS[i]);
|
||||||
|
updateProgress();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// Save current XTC as last opened book and add to recent books
|
// Save current XTC as last opened book and add to recent books
|
||||||
APP_STATE.openEpubPath = xtc->getPath();
|
APP_STATE.openEpubPath = xtc->getPath();
|
||||||
APP_STATE.saveToFile();
|
APP_STATE.saveToFile();
|
||||||
|
|||||||
@@ -27,5 +27,10 @@ class UITheme {
|
|||||||
const BaseTheme* currentTheme;
|
const BaseTheme* currentTheme;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// Known theme thumbnail heights to prerender when opening a book for the first time.
|
||||||
|
// These correspond to homeCoverHeight values across all themes (Lyra=226, Base=400).
|
||||||
|
static constexpr int PRERENDER_THUMB_HEIGHTS[] = {226, 400};
|
||||||
|
static constexpr int PRERENDER_THUMB_HEIGHTS_COUNT = 2;
|
||||||
|
|
||||||
// Helper macro to access current theme
|
// Helper macro to access current theme
|
||||||
#define GUI UITheme::getInstance().getTheme()
|
#define GUI UITheme::getInstance().getTheme()
|
||||||
|
|||||||
16
src/main.cpp
16
src/main.cpp
@@ -3,6 +3,7 @@
|
|||||||
#include <GfxRenderer.h>
|
#include <GfxRenderer.h>
|
||||||
#include <HalDisplay.h>
|
#include <HalDisplay.h>
|
||||||
#include <HalGPIO.h>
|
#include <HalGPIO.h>
|
||||||
|
#include <HalPowerManager.h>
|
||||||
#include <HalStorage.h>
|
#include <HalStorage.h>
|
||||||
#include <Logging.h>
|
#include <Logging.h>
|
||||||
#include <SPI.h>
|
#include <SPI.h>
|
||||||
@@ -32,6 +33,7 @@
|
|||||||
|
|
||||||
HalDisplay display;
|
HalDisplay display;
|
||||||
HalGPIO gpio;
|
HalGPIO gpio;
|
||||||
|
HalPowerManager powerManager;
|
||||||
MappedInputManager mappedInputManager(gpio);
|
MappedInputManager mappedInputManager(gpio);
|
||||||
GfxRenderer renderer(display);
|
GfxRenderer renderer(display);
|
||||||
Activity* currentActivity;
|
Activity* currentActivity;
|
||||||
@@ -182,7 +184,7 @@ void verifyPowerButtonDuration() {
|
|||||||
if (abort) {
|
if (abort) {
|
||||||
// Button released too early. Returning to sleep.
|
// Button released too early. Returning to sleep.
|
||||||
// IMPORTANT: Re-arm the wakeup trigger before sleeping again
|
// IMPORTANT: Re-arm the wakeup trigger before sleeping again
|
||||||
gpio.startDeepSleep();
|
powerManager.startDeepSleep(gpio);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -205,7 +207,7 @@ void enterDeepSleep() {
|
|||||||
LOG_DBG("MAIN", "Power button press calibration value: %lu ms", t2 - t1);
|
LOG_DBG("MAIN", "Power button press calibration value: %lu ms", t2 - t1);
|
||||||
LOG_DBG("MAIN", "Entering deep sleep");
|
LOG_DBG("MAIN", "Entering deep sleep");
|
||||||
|
|
||||||
gpio.startDeepSleep();
|
powerManager.startDeepSleep(gpio);
|
||||||
}
|
}
|
||||||
|
|
||||||
void onGoHome();
|
void onGoHome();
|
||||||
@@ -282,6 +284,7 @@ void setup() {
|
|||||||
t1 = millis();
|
t1 = millis();
|
||||||
|
|
||||||
gpio.begin();
|
gpio.begin();
|
||||||
|
powerManager.begin();
|
||||||
|
|
||||||
// Only start serial if USB connected
|
// Only start serial if USB connected
|
||||||
if (gpio.isUsbConnected()) {
|
if (gpio.isUsbConnected()) {
|
||||||
@@ -317,7 +320,7 @@ void setup() {
|
|||||||
case HalGPIO::WakeupReason::AfterUSBPower:
|
case HalGPIO::WakeupReason::AfterUSBPower:
|
||||||
// If USB power caused a cold boot, go back to sleep
|
// If USB power caused a cold boot, go back to sleep
|
||||||
LOG_DBG("MAIN", "Wakeup reason: After USB Power");
|
LOG_DBG("MAIN", "Wakeup reason: After USB Power");
|
||||||
gpio.startDeepSleep();
|
powerManager.startDeepSleep(gpio);
|
||||||
break;
|
break;
|
||||||
case HalGPIO::WakeupReason::AfterFlash:
|
case HalGPIO::WakeupReason::AfterFlash:
|
||||||
// After flashing, just proceed to boot
|
// After flashing, just proceed to boot
|
||||||
@@ -389,7 +392,8 @@ void loop() {
|
|||||||
// Check for any user activity (button press or release) or active background work
|
// Check for any user activity (button press or release) or active background work
|
||||||
static unsigned long lastActivityTime = millis();
|
static unsigned long lastActivityTime = millis();
|
||||||
if (gpio.wasAnyPressed() || gpio.wasAnyReleased() || (currentActivity && currentActivity->preventAutoSleep())) {
|
if (gpio.wasAnyPressed() || gpio.wasAnyReleased() || (currentActivity && currentActivity->preventAutoSleep())) {
|
||||||
lastActivityTime = millis(); // Reset inactivity timer
|
lastActivityTime = millis(); // Reset inactivity timer
|
||||||
|
powerManager.setPowerSaving(false); // Restore normal CPU frequency on user activity
|
||||||
}
|
}
|
||||||
|
|
||||||
const unsigned long sleepTimeoutMs = SETTINGS.getSleepTimeoutMs();
|
const unsigned long sleepTimeoutMs = SETTINGS.getSleepTimeoutMs();
|
||||||
@@ -426,9 +430,9 @@ void loop() {
|
|||||||
if (currentActivity && currentActivity->skipLoopDelay()) {
|
if (currentActivity && currentActivity->skipLoopDelay()) {
|
||||||
yield(); // Give FreeRTOS a chance to run tasks, but return immediately
|
yield(); // Give FreeRTOS a chance to run tasks, but return immediately
|
||||||
} else {
|
} else {
|
||||||
static constexpr unsigned long IDLE_POWER_SAVING_MS = 3000; // 3 seconds
|
if (millis() - lastActivityTime >= HalPowerManager::IDLE_POWER_SAVING_MS) {
|
||||||
if (millis() - lastActivityTime >= IDLE_POWER_SAVING_MS) {
|
|
||||||
// If we've been inactive for a while, increase the delay to save power
|
// If we've been inactive for a while, increase the delay to save power
|
||||||
|
powerManager.setPowerSaving(true); // Lower CPU frequency after extended inactivity
|
||||||
delay(50);
|
delay(50);
|
||||||
} else {
|
} else {
|
||||||
// Short delay to prevent tight loop while still being responsive
|
// Short delay to prevent tight loop while still being responsive
|
||||||
|
|||||||
60
src/util/BookSettings.cpp
Normal file
60
src/util/BookSettings.cpp
Normal file
@@ -0,0 +1,60 @@
|
|||||||
|
#include "BookSettings.h"
|
||||||
|
|
||||||
|
#include <HalStorage.h>
|
||||||
|
#include <Logging.h>
|
||||||
|
#include <Serialization.h>
|
||||||
|
|
||||||
|
namespace {
|
||||||
|
constexpr uint8_t BOOK_SETTINGS_VERSION = 1;
|
||||||
|
constexpr uint8_t BOOK_SETTINGS_COUNT = 1; // Number of persisted fields
|
||||||
|
} // namespace
|
||||||
|
|
||||||
|
std::string BookSettings::filePath(const std::string& cachePath) { return cachePath + "/book_settings.bin"; }
|
||||||
|
|
||||||
|
BookSettings BookSettings::load(const std::string& cachePath) {
|
||||||
|
BookSettings settings;
|
||||||
|
FsFile f;
|
||||||
|
if (!Storage.openFileForRead("BST", filePath(cachePath), f)) {
|
||||||
|
return settings;
|
||||||
|
}
|
||||||
|
|
||||||
|
uint8_t version;
|
||||||
|
serialization::readPod(f, version);
|
||||||
|
if (version != BOOK_SETTINGS_VERSION) {
|
||||||
|
f.close();
|
||||||
|
return settings;
|
||||||
|
}
|
||||||
|
|
||||||
|
uint8_t fieldCount;
|
||||||
|
serialization::readPod(f, fieldCount);
|
||||||
|
|
||||||
|
// Read fields that exist (supports older files with fewer fields)
|
||||||
|
uint8_t fieldsRead = 0;
|
||||||
|
do {
|
||||||
|
serialization::readPod(f, settings.letterboxFillOverride);
|
||||||
|
if (++fieldsRead >= fieldCount) break;
|
||||||
|
// New fields added here for forward compatibility
|
||||||
|
} while (false);
|
||||||
|
|
||||||
|
f.close();
|
||||||
|
LOG_DBG("BST", "Loaded book settings from %s (letterboxFill=%d)", filePath(cachePath).c_str(),
|
||||||
|
settings.letterboxFillOverride);
|
||||||
|
return settings;
|
||||||
|
}
|
||||||
|
|
||||||
|
bool BookSettings::save(const std::string& cachePath, const BookSettings& settings) {
|
||||||
|
FsFile f;
|
||||||
|
if (!Storage.openFileForWrite("BST", filePath(cachePath), f)) {
|
||||||
|
LOG_ERR("BST", "Could not save book settings!");
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
serialization::writePod(f, BOOK_SETTINGS_VERSION);
|
||||||
|
serialization::writePod(f, BOOK_SETTINGS_COUNT);
|
||||||
|
serialization::writePod(f, settings.letterboxFillOverride);
|
||||||
|
// New fields added here
|
||||||
|
f.close();
|
||||||
|
|
||||||
|
LOG_DBG("BST", "Saved book settings to %s", filePath(cachePath).c_str());
|
||||||
|
return true;
|
||||||
|
}
|
||||||
31
src/util/BookSettings.h
Normal file
31
src/util/BookSettings.h
Normal file
@@ -0,0 +1,31 @@
|
|||||||
|
#pragma once
|
||||||
|
#include <cstdint>
|
||||||
|
#include <string>
|
||||||
|
|
||||||
|
#include "CrossPointSettings.h"
|
||||||
|
|
||||||
|
// Per-book settings stored in the book's cache directory.
|
||||||
|
// Fields default to sentinel values (0xFF) meaning "use global setting".
|
||||||
|
class BookSettings {
|
||||||
|
public:
|
||||||
|
// 0xFF = use global default; otherwise one of SLEEP_SCREEN_LETTERBOX_FILL values (0-2).
|
||||||
|
uint8_t letterboxFillOverride = USE_GLOBAL;
|
||||||
|
|
||||||
|
static constexpr uint8_t USE_GLOBAL = 0xFF;
|
||||||
|
|
||||||
|
// Returns the effective letterbox fill mode: the per-book override if set,
|
||||||
|
// otherwise the global setting from CrossPointSettings.
|
||||||
|
uint8_t getEffectiveLetterboxFill() const {
|
||||||
|
if (letterboxFillOverride != USE_GLOBAL &&
|
||||||
|
letterboxFillOverride < CrossPointSettings::SLEEP_SCREEN_LETTERBOX_FILL_COUNT) {
|
||||||
|
return letterboxFillOverride;
|
||||||
|
}
|
||||||
|
return SETTINGS.sleepScreenLetterboxFill;
|
||||||
|
}
|
||||||
|
|
||||||
|
static BookSettings load(const std::string& cachePath);
|
||||||
|
static bool save(const std::string& cachePath, const BookSettings& settings);
|
||||||
|
|
||||||
|
private:
|
||||||
|
static std::string filePath(const std::string& cachePath);
|
||||||
|
};
|
||||||
159
src/util/BookmarkStore.cpp
Normal file
159
src/util/BookmarkStore.cpp
Normal file
@@ -0,0 +1,159 @@
|
|||||||
|
#include "BookmarkStore.h"
|
||||||
|
|
||||||
|
#include <HalStorage.h>
|
||||||
|
#include <Logging.h>
|
||||||
|
|
||||||
|
#include <algorithm>
|
||||||
|
|
||||||
|
std::string BookmarkStore::filePath(const std::string& cachePath) { return cachePath + "/bookmarks.bin"; }
|
||||||
|
|
||||||
|
std::vector<Bookmark> BookmarkStore::load(const std::string& cachePath) {
|
||||||
|
std::vector<Bookmark> bookmarks;
|
||||||
|
FsFile f;
|
||||||
|
if (!Storage.openFileForRead("BKM", filePath(cachePath), f)) {
|
||||||
|
return bookmarks;
|
||||||
|
}
|
||||||
|
|
||||||
|
// File format v2: [version(1)] [count(2)] [entries...]
|
||||||
|
// Each entry: [spine(2)] [page(2)] [snippetLen(1)] [snippet(snippetLen)]
|
||||||
|
// v1 (no version byte): [count(2)] [entries of 4 bytes each]
|
||||||
|
// We detect v1 by checking if the first byte could be a version marker (0xFF).
|
||||||
|
|
||||||
|
uint8_t firstByte;
|
||||||
|
if (f.read(&firstByte, 1) != 1) {
|
||||||
|
f.close();
|
||||||
|
return bookmarks;
|
||||||
|
}
|
||||||
|
|
||||||
|
uint16_t count;
|
||||||
|
bool hasSnippets;
|
||||||
|
|
||||||
|
if (firstByte == 0xFF) {
|
||||||
|
// v2 format: version marker was 0xFF
|
||||||
|
hasSnippets = true;
|
||||||
|
uint8_t countBytes[2];
|
||||||
|
if (f.read(countBytes, 2) != 2) {
|
||||||
|
f.close();
|
||||||
|
return bookmarks;
|
||||||
|
}
|
||||||
|
count = static_cast<uint16_t>(countBytes[0]) | (static_cast<uint16_t>(countBytes[1]) << 8);
|
||||||
|
} else {
|
||||||
|
// v1 format: first byte was part of the count
|
||||||
|
hasSnippets = false;
|
||||||
|
uint8_t secondByte;
|
||||||
|
if (f.read(&secondByte, 1) != 1) {
|
||||||
|
f.close();
|
||||||
|
return bookmarks;
|
||||||
|
}
|
||||||
|
count = static_cast<uint16_t>(firstByte) | (static_cast<uint16_t>(secondByte) << 8);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (count > MAX_BOOKMARKS) {
|
||||||
|
count = MAX_BOOKMARKS;
|
||||||
|
}
|
||||||
|
|
||||||
|
for (uint16_t i = 0; i < count; i++) {
|
||||||
|
uint8_t entry[4];
|
||||||
|
if (f.read(entry, 4) != 4) break;
|
||||||
|
Bookmark b;
|
||||||
|
b.spineIndex = static_cast<int16_t>(static_cast<uint16_t>(entry[0]) | (static_cast<uint16_t>(entry[1]) << 8));
|
||||||
|
b.pageNumber = static_cast<int16_t>(static_cast<uint16_t>(entry[2]) | (static_cast<uint16_t>(entry[3]) << 8));
|
||||||
|
|
||||||
|
if (hasSnippets) {
|
||||||
|
uint8_t snippetLen;
|
||||||
|
if (f.read(&snippetLen, 1) != 1) break;
|
||||||
|
if (snippetLen > 0) {
|
||||||
|
std::vector<uint8_t> buf(snippetLen);
|
||||||
|
if (f.read(buf.data(), snippetLen) != snippetLen) break;
|
||||||
|
b.snippet = std::string(buf.begin(), buf.end());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
bookmarks.push_back(b);
|
||||||
|
}
|
||||||
|
|
||||||
|
f.close();
|
||||||
|
return bookmarks;
|
||||||
|
}
|
||||||
|
|
||||||
|
bool BookmarkStore::save(const std::string& cachePath, const std::vector<Bookmark>& bookmarks) {
|
||||||
|
FsFile f;
|
||||||
|
if (!Storage.openFileForWrite("BKM", filePath(cachePath), f)) {
|
||||||
|
LOG_ERR("BKM", "Could not save bookmarks!");
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Write v2 format: version marker + count + entries with snippets
|
||||||
|
uint8_t version = 0xFF;
|
||||||
|
f.write(&version, 1);
|
||||||
|
|
||||||
|
uint16_t count = static_cast<uint16_t>(bookmarks.size());
|
||||||
|
uint8_t header[2] = {static_cast<uint8_t>(count & 0xFF), static_cast<uint8_t>((count >> 8) & 0xFF)};
|
||||||
|
f.write(header, 2);
|
||||||
|
|
||||||
|
for (const auto& b : bookmarks) {
|
||||||
|
uint8_t entry[4];
|
||||||
|
entry[0] = static_cast<uint8_t>(b.spineIndex & 0xFF);
|
||||||
|
entry[1] = static_cast<uint8_t>((b.spineIndex >> 8) & 0xFF);
|
||||||
|
entry[2] = static_cast<uint8_t>(b.pageNumber & 0xFF);
|
||||||
|
entry[3] = static_cast<uint8_t>((b.pageNumber >> 8) & 0xFF);
|
||||||
|
f.write(entry, 4);
|
||||||
|
|
||||||
|
// Write snippet: length byte + string data
|
||||||
|
uint8_t snippetLen = static_cast<uint8_t>(std::min(static_cast<int>(b.snippet.size()), MAX_SNIPPET_LENGTH));
|
||||||
|
f.write(&snippetLen, 1);
|
||||||
|
if (snippetLen > 0) {
|
||||||
|
f.write(reinterpret_cast<const uint8_t*>(b.snippet.c_str()), snippetLen);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
f.close();
|
||||||
|
LOG_DBG("BKM", "Saved %d bookmarks", count);
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
bool BookmarkStore::addBookmark(const std::string& cachePath, int spineIndex, int page, const std::string& snippet) {
|
||||||
|
auto bookmarks = load(cachePath);
|
||||||
|
|
||||||
|
// Check for duplicate
|
||||||
|
for (const auto& b : bookmarks) {
|
||||||
|
if (b.spineIndex == spineIndex && b.pageNumber == page) {
|
||||||
|
return true; // Already bookmarked
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (static_cast<int>(bookmarks.size()) >= MAX_BOOKMARKS) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
Bookmark b;
|
||||||
|
b.spineIndex = static_cast<int16_t>(spineIndex);
|
||||||
|
b.pageNumber = static_cast<int16_t>(page);
|
||||||
|
b.snippet = snippet.substr(0, MAX_SNIPPET_LENGTH);
|
||||||
|
bookmarks.push_back(b);
|
||||||
|
|
||||||
|
return save(cachePath, bookmarks);
|
||||||
|
}
|
||||||
|
|
||||||
|
bool BookmarkStore::removeBookmark(const std::string& cachePath, int spineIndex, int page) {
|
||||||
|
auto bookmarks = load(cachePath);
|
||||||
|
|
||||||
|
auto it = std::remove_if(bookmarks.begin(), bookmarks.end(),
|
||||||
|
[spineIndex, page](const Bookmark& b) {
|
||||||
|
return b.spineIndex == spineIndex && b.pageNumber == page;
|
||||||
|
});
|
||||||
|
|
||||||
|
if (it == bookmarks.end()) {
|
||||||
|
return false; // Not found
|
||||||
|
}
|
||||||
|
|
||||||
|
bookmarks.erase(it, bookmarks.end());
|
||||||
|
return save(cachePath, bookmarks);
|
||||||
|
}
|
||||||
|
|
||||||
|
bool BookmarkStore::hasBookmark(const std::string& cachePath, int spineIndex, int page) {
|
||||||
|
auto bookmarks = load(cachePath);
|
||||||
|
return std::any_of(bookmarks.begin(), bookmarks.end(), [spineIndex, page](const Bookmark& b) {
|
||||||
|
return b.spineIndex == spineIndex && b.pageNumber == page;
|
||||||
|
});
|
||||||
|
}
|
||||||
24
src/util/BookmarkStore.h
Normal file
24
src/util/BookmarkStore.h
Normal file
@@ -0,0 +1,24 @@
|
|||||||
|
#pragma once
|
||||||
|
#include <cstdint>
|
||||||
|
#include <string>
|
||||||
|
#include <vector>
|
||||||
|
|
||||||
|
struct Bookmark {
|
||||||
|
int16_t spineIndex;
|
||||||
|
int16_t pageNumber;
|
||||||
|
std::string snippet; // First sentence or text excerpt from the page
|
||||||
|
};
|
||||||
|
|
||||||
|
class BookmarkStore {
|
||||||
|
public:
|
||||||
|
static std::vector<Bookmark> load(const std::string& cachePath);
|
||||||
|
static bool save(const std::string& cachePath, const std::vector<Bookmark>& bookmarks);
|
||||||
|
static bool addBookmark(const std::string& cachePath, int spineIndex, int page, const std::string& snippet = "");
|
||||||
|
static bool removeBookmark(const std::string& cachePath, int spineIndex, int page);
|
||||||
|
static bool hasBookmark(const std::string& cachePath, int spineIndex, int page);
|
||||||
|
|
||||||
|
private:
|
||||||
|
static std::string filePath(const std::string& cachePath);
|
||||||
|
static constexpr int MAX_BOOKMARKS = 200;
|
||||||
|
static constexpr int MAX_SNIPPET_LENGTH = 120;
|
||||||
|
};
|
||||||
328
src/util/Dictionary.cpp
Normal file
328
src/util/Dictionary.cpp
Normal file
@@ -0,0 +1,328 @@
|
|||||||
|
#include "Dictionary.h"
|
||||||
|
|
||||||
|
#include <HalStorage.h>
|
||||||
|
|
||||||
|
#include <algorithm>
|
||||||
|
#include <cctype>
|
||||||
|
#include <cstring>
|
||||||
|
|
||||||
|
namespace {
|
||||||
|
constexpr const char* IDX_PATH = "/.dictionary/dictionary.idx";
|
||||||
|
constexpr const char* DICT_PATH = "/.dictionary/dictionary.dict";
|
||||||
|
constexpr const char* CACHE_PATH = "/.dictionary/dictionary.cache";
|
||||||
|
constexpr uint32_t CACHE_MAGIC = 0x44494358; // "DICX"
|
||||||
|
|
||||||
|
// g_ascii_strcasecmp equivalent: compare lowercasing only ASCII A-Z.
|
||||||
|
int asciiCaseCmp(const char* s1, const char* s2) {
|
||||||
|
const auto* p1 = reinterpret_cast<const unsigned char*>(s1);
|
||||||
|
const auto* p2 = reinterpret_cast<const unsigned char*>(s2);
|
||||||
|
while (*p1 && *p2) {
|
||||||
|
unsigned char c1 = *p1, c2 = *p2;
|
||||||
|
if (c1 >= 'A' && c1 <= 'Z') c1 += 32;
|
||||||
|
if (c2 >= 'A' && c2 <= 'Z') c2 += 32;
|
||||||
|
if (c1 != c2) return static_cast<int>(c1) - static_cast<int>(c2);
|
||||||
|
++p1;
|
||||||
|
++p2;
|
||||||
|
}
|
||||||
|
return static_cast<int>(*p1) - static_cast<int>(*p2);
|
||||||
|
}
|
||||||
|
|
||||||
|
// StarDict index comparison: case-insensitive first, then case-sensitive tiebreaker.
|
||||||
|
// This matches the stardict_strcmp used by StarDict to sort .idx entries.
|
||||||
|
int stardictCmp(const char* s1, const char* s2) {
|
||||||
|
int ci = asciiCaseCmp(s1, s2);
|
||||||
|
if (ci != 0) return ci;
|
||||||
|
return std::strcmp(s1, s2);
|
||||||
|
}
|
||||||
|
} // namespace
|
||||||
|
|
||||||
|
std::vector<uint32_t> Dictionary::sparseOffsets;
|
||||||
|
uint32_t Dictionary::totalWords = 0;
|
||||||
|
bool Dictionary::indexLoaded = false;
|
||||||
|
|
||||||
|
bool Dictionary::exists() { return Storage.exists(IDX_PATH); }
|
||||||
|
|
||||||
|
bool Dictionary::cacheExists() { return Storage.exists(CACHE_PATH); }
|
||||||
|
|
||||||
|
void Dictionary::deleteCache() {
|
||||||
|
Storage.remove(CACHE_PATH);
|
||||||
|
// Reset in-memory state so next lookup rebuilds from the .idx file.
|
||||||
|
sparseOffsets.clear();
|
||||||
|
totalWords = 0;
|
||||||
|
indexLoaded = false;
|
||||||
|
}
|
||||||
|
|
||||||
|
std::string Dictionary::cleanWord(const std::string& word) {
|
||||||
|
if (word.empty()) return "";
|
||||||
|
|
||||||
|
// Find first alphanumeric character
|
||||||
|
size_t start = 0;
|
||||||
|
while (start < word.size() && !std::isalnum(static_cast<unsigned char>(word[start]))) {
|
||||||
|
start++;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Find last alphanumeric character
|
||||||
|
size_t end = word.size();
|
||||||
|
while (end > start && !std::isalnum(static_cast<unsigned char>(word[end - 1]))) {
|
||||||
|
end--;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (start >= end) return "";
|
||||||
|
|
||||||
|
std::string result = word.substr(start, end - start);
|
||||||
|
// Lowercase
|
||||||
|
std::transform(result.begin(), result.end(), result.begin(), [](unsigned char c) { return std::tolower(c); });
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Cache: persists the sparse offset table to SD card so subsequent boots skip
|
||||||
|
// the full .idx scan. The cache is invalidated when the .idx file size changes.
|
||||||
|
//
|
||||||
|
// Format: [magic 4B][idxFileSize 4B][totalWords 4B][count 4B][offsets N×4B]
|
||||||
|
// All values are stored in native byte order (little-endian on ESP32).
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
bool Dictionary::loadCachedIndex() {
|
||||||
|
FsFile idx;
|
||||||
|
if (!Storage.openFileForRead("DICT", IDX_PATH, idx)) return false;
|
||||||
|
const uint32_t idxFileSize = static_cast<uint32_t>(idx.fileSize());
|
||||||
|
idx.close();
|
||||||
|
|
||||||
|
FsFile cache;
|
||||||
|
if (!Storage.openFileForRead("DICT", CACHE_PATH, cache)) return false;
|
||||||
|
|
||||||
|
// Read and validate header
|
||||||
|
uint32_t header[4]; // magic, idxFileSize, totalWords, count
|
||||||
|
if (cache.read(reinterpret_cast<uint8_t*>(header), 16) != 16) {
|
||||||
|
cache.close();
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (header[0] != CACHE_MAGIC || header[1] != idxFileSize) {
|
||||||
|
cache.close();
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
totalWords = header[2];
|
||||||
|
const uint32_t count = header[3];
|
||||||
|
|
||||||
|
sparseOffsets.resize(count);
|
||||||
|
const int bytesToRead = static_cast<int>(count * sizeof(uint32_t));
|
||||||
|
if (cache.read(reinterpret_cast<uint8_t*>(sparseOffsets.data()), bytesToRead) != bytesToRead) {
|
||||||
|
cache.close();
|
||||||
|
sparseOffsets.clear();
|
||||||
|
totalWords = 0;
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
cache.close();
|
||||||
|
indexLoaded = true;
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
void Dictionary::saveCachedIndex(uint32_t idxFileSize) {
|
||||||
|
FsFile cache;
|
||||||
|
if (!Storage.openFileForWrite("DICT", CACHE_PATH, cache)) return;
|
||||||
|
|
||||||
|
const uint32_t count = static_cast<uint32_t>(sparseOffsets.size());
|
||||||
|
uint32_t header[4] = {CACHE_MAGIC, idxFileSize, totalWords, count};
|
||||||
|
|
||||||
|
cache.write(reinterpret_cast<const uint8_t*>(header), 16);
|
||||||
|
cache.write(reinterpret_cast<const uint8_t*>(sparseOffsets.data()), count * sizeof(uint32_t));
|
||||||
|
cache.close();
|
||||||
|
}
|
||||||
|
|
||||||
|
// Scan the .idx file to build a sparse offset table for fast lookups.
|
||||||
|
// Records the file offset of every SPARSE_INTERVAL-th entry.
|
||||||
|
bool Dictionary::loadIndex(const std::function<void(int percent)>& onProgress,
|
||||||
|
const std::function<bool()>& shouldCancel) {
|
||||||
|
// Try loading from cache first (nearly instant)
|
||||||
|
if (loadCachedIndex()) return true;
|
||||||
|
|
||||||
|
FsFile idx;
|
||||||
|
if (!Storage.openFileForRead("DICT", IDX_PATH, idx)) return false;
|
||||||
|
|
||||||
|
const uint32_t fileSize = static_cast<uint32_t>(idx.fileSize());
|
||||||
|
|
||||||
|
sparseOffsets.clear();
|
||||||
|
totalWords = 0;
|
||||||
|
|
||||||
|
uint32_t pos = 0;
|
||||||
|
int lastReportedPercent = -1;
|
||||||
|
|
||||||
|
while (pos < fileSize) {
|
||||||
|
if (shouldCancel && (totalWords % 100 == 0) && shouldCancel()) {
|
||||||
|
idx.close();
|
||||||
|
sparseOffsets.clear();
|
||||||
|
totalWords = 0;
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (totalWords % SPARSE_INTERVAL == 0) {
|
||||||
|
sparseOffsets.push_back(pos);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Skip word (read until null terminator)
|
||||||
|
int ch;
|
||||||
|
do {
|
||||||
|
ch = idx.read();
|
||||||
|
if (ch < 0) {
|
||||||
|
pos = fileSize;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
pos++;
|
||||||
|
} while (ch != 0);
|
||||||
|
|
||||||
|
if (pos >= fileSize) break;
|
||||||
|
|
||||||
|
// Skip 8 bytes (4-byte offset + 4-byte size)
|
||||||
|
uint8_t skip[8];
|
||||||
|
if (idx.read(skip, 8) != 8) break;
|
||||||
|
pos += 8;
|
||||||
|
|
||||||
|
totalWords++;
|
||||||
|
|
||||||
|
if (onProgress && fileSize > 0) {
|
||||||
|
int percent = static_cast<int>(static_cast<uint64_t>(pos) * 90 / fileSize);
|
||||||
|
if (percent > lastReportedPercent + 4) {
|
||||||
|
lastReportedPercent = percent;
|
||||||
|
onProgress(percent);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
idx.close();
|
||||||
|
indexLoaded = true;
|
||||||
|
|
||||||
|
// Persist to cache so next boot is instant
|
||||||
|
if (totalWords > 0) saveCachedIndex(fileSize);
|
||||||
|
|
||||||
|
return totalWords > 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Read a null-terminated word string from the current file position.
|
||||||
|
std::string Dictionary::readWord(FsFile& file) {
|
||||||
|
std::string word;
|
||||||
|
while (true) {
|
||||||
|
int ch = file.read();
|
||||||
|
if (ch <= 0) break; // null terminator (0) or error (-1)
|
||||||
|
word += static_cast<char>(ch);
|
||||||
|
}
|
||||||
|
return word;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Read a definition from the .dict file at the given offset and size.
|
||||||
|
std::string Dictionary::readDefinition(uint32_t offset, uint32_t size) {
|
||||||
|
FsFile dict;
|
||||||
|
if (!Storage.openFileForRead("DICT", DICT_PATH, dict)) return "";
|
||||||
|
|
||||||
|
dict.seekSet(offset);
|
||||||
|
|
||||||
|
std::string def(size, '\0');
|
||||||
|
int bytesRead = dict.read(reinterpret_cast<uint8_t*>(&def[0]), size);
|
||||||
|
dict.close();
|
||||||
|
|
||||||
|
if (bytesRead < 0) return "";
|
||||||
|
if (static_cast<uint32_t>(bytesRead) < size) def.resize(bytesRead);
|
||||||
|
return def;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Binary search the sparse offset table, then linear scan within the matching segment.
|
||||||
|
// Uses StarDict's sort order: case-insensitive first, then case-sensitive tiebreaker.
|
||||||
|
// The exact match is case-insensitive so e.g. "simple" matches "Simple".
|
||||||
|
std::string Dictionary::searchIndex(const std::string& word, const std::function<bool()>& shouldCancel) {
|
||||||
|
if (sparseOffsets.empty()) return "";
|
||||||
|
|
||||||
|
FsFile idx;
|
||||||
|
if (!Storage.openFileForRead("DICT", IDX_PATH, idx)) return "";
|
||||||
|
|
||||||
|
// Binary search the sparse offset table to find the right segment.
|
||||||
|
int lo = 0, hi = static_cast<int>(sparseOffsets.size()) - 1;
|
||||||
|
|
||||||
|
while (lo < hi) {
|
||||||
|
if (shouldCancel && shouldCancel()) {
|
||||||
|
idx.close();
|
||||||
|
return "";
|
||||||
|
}
|
||||||
|
|
||||||
|
int mid = lo + (hi - lo + 1) / 2;
|
||||||
|
idx.seekSet(sparseOffsets[mid]);
|
||||||
|
std::string key = readWord(idx);
|
||||||
|
|
||||||
|
if (stardictCmp(key.c_str(), word.c_str()) <= 0) {
|
||||||
|
lo = mid;
|
||||||
|
} else {
|
||||||
|
hi = mid - 1;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Linear scan within the segment starting at sparseOffsets[lo].
|
||||||
|
idx.seekSet(sparseOffsets[lo]);
|
||||||
|
|
||||||
|
int maxEntries = SPARSE_INTERVAL;
|
||||||
|
if (lo == static_cast<int>(sparseOffsets.size()) - 1) {
|
||||||
|
maxEntries = static_cast<int>(totalWords - static_cast<uint32_t>(lo) * SPARSE_INTERVAL);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Scan entries, preferring an exact case-sensitive match over a case-insensitive one.
|
||||||
|
// In stardict order, all case variants of a word are adjacent (e.g. "Professor" then "professor"),
|
||||||
|
// and they may have different definitions. We want the lowercase entry when the user searched
|
||||||
|
// for a lowercase word, falling back to any case variant.
|
||||||
|
uint32_t bestOffset = 0, bestSize = 0;
|
||||||
|
bool found = false;
|
||||||
|
|
||||||
|
for (int i = 0; i < maxEntries; i++) {
|
||||||
|
if (shouldCancel && shouldCancel()) {
|
||||||
|
idx.close();
|
||||||
|
return "";
|
||||||
|
}
|
||||||
|
|
||||||
|
std::string key = readWord(idx);
|
||||||
|
if (key.empty()) break;
|
||||||
|
|
||||||
|
// Read offset and size (4 bytes each, big-endian)
|
||||||
|
uint8_t buf[8];
|
||||||
|
if (idx.read(buf, 8) != 8) break;
|
||||||
|
|
||||||
|
uint32_t dictOffset = (static_cast<uint32_t>(buf[0]) << 24) | (static_cast<uint32_t>(buf[1]) << 16) |
|
||||||
|
(static_cast<uint32_t>(buf[2]) << 8) | static_cast<uint32_t>(buf[3]);
|
||||||
|
uint32_t dictSize = (static_cast<uint32_t>(buf[4]) << 24) | (static_cast<uint32_t>(buf[5]) << 16) |
|
||||||
|
(static_cast<uint32_t>(buf[6]) << 8) | static_cast<uint32_t>(buf[7]);
|
||||||
|
|
||||||
|
if (asciiCaseCmp(key.c_str(), word.c_str()) == 0) {
|
||||||
|
// Case-insensitive match — remember the first one as fallback
|
||||||
|
if (!found) {
|
||||||
|
bestOffset = dictOffset;
|
||||||
|
bestSize = dictSize;
|
||||||
|
found = true;
|
||||||
|
}
|
||||||
|
// Exact case-sensitive match — use immediately
|
||||||
|
if (key == word) {
|
||||||
|
idx.close();
|
||||||
|
return readDefinition(dictOffset, dictSize);
|
||||||
|
}
|
||||||
|
} else if (found) {
|
||||||
|
// We've moved past all case variants of this word — stop
|
||||||
|
break;
|
||||||
|
} else if (stardictCmp(key.c_str(), word.c_str()) > 0) {
|
||||||
|
// Past the target in StarDict sort order — stop scanning
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
idx.close();
|
||||||
|
return found ? readDefinition(bestOffset, bestSize) : "";
|
||||||
|
}
|
||||||
|
|
||||||
|
std::string Dictionary::lookup(const std::string& word, const std::function<void(int percent)>& onProgress,
|
||||||
|
const std::function<bool()>& shouldCancel) {
|
||||||
|
if (!indexLoaded) {
|
||||||
|
if (!loadIndex(onProgress, shouldCancel)) return "";
|
||||||
|
}
|
||||||
|
|
||||||
|
// searchIndex uses StarDict sort order + case-insensitive match,
|
||||||
|
// so a single pass handles all casing variants.
|
||||||
|
std::string result = searchIndex(word, shouldCancel);
|
||||||
|
if (onProgress) onProgress(100);
|
||||||
|
return result;
|
||||||
|
}
|
||||||
31
src/util/Dictionary.h
Normal file
31
src/util/Dictionary.h
Normal file
@@ -0,0 +1,31 @@
|
|||||||
|
#pragma once
|
||||||
|
#include <cstdint>
|
||||||
|
#include <functional>
|
||||||
|
#include <string>
|
||||||
|
#include <vector>
|
||||||
|
|
||||||
|
class FsFile;
|
||||||
|
|
||||||
|
class Dictionary {
|
||||||
|
public:
|
||||||
|
static bool exists();
|
||||||
|
static bool cacheExists();
|
||||||
|
static void deleteCache();
|
||||||
|
static std::string lookup(const std::string& word, const std::function<void(int percent)>& onProgress = nullptr,
|
||||||
|
const std::function<bool()>& shouldCancel = nullptr);
|
||||||
|
static std::string cleanWord(const std::string& word);
|
||||||
|
|
||||||
|
private:
|
||||||
|
static constexpr int SPARSE_INTERVAL = 512;
|
||||||
|
|
||||||
|
static std::vector<uint32_t> sparseOffsets;
|
||||||
|
static uint32_t totalWords;
|
||||||
|
static bool indexLoaded;
|
||||||
|
|
||||||
|
static bool loadIndex(const std::function<void(int percent)>& onProgress, const std::function<bool()>& shouldCancel);
|
||||||
|
static bool loadCachedIndex();
|
||||||
|
static void saveCachedIndex(uint32_t idxFileSize);
|
||||||
|
static std::string searchIndex(const std::string& word, const std::function<bool()>& shouldCancel);
|
||||||
|
static std::string readWord(FsFile& file);
|
||||||
|
static std::string readDefinition(uint32_t offset, uint32_t size);
|
||||||
|
};
|
||||||
88
src/util/LookupHistory.cpp
Normal file
88
src/util/LookupHistory.cpp
Normal file
@@ -0,0 +1,88 @@
|
|||||||
|
#include "LookupHistory.h"
|
||||||
|
|
||||||
|
#include <HalStorage.h>
|
||||||
|
|
||||||
|
#include <algorithm>
|
||||||
|
|
||||||
|
std::string LookupHistory::filePath(const std::string& cachePath) { return cachePath + "/lookups.txt"; }
|
||||||
|
|
||||||
|
bool LookupHistory::hasHistory(const std::string& cachePath) {
|
||||||
|
FsFile f;
|
||||||
|
if (!Storage.openFileForRead("LKH", filePath(cachePath), f)) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
bool nonEmpty = f.available() > 0;
|
||||||
|
f.close();
|
||||||
|
return nonEmpty;
|
||||||
|
}
|
||||||
|
|
||||||
|
std::vector<std::string> LookupHistory::load(const std::string& cachePath) {
|
||||||
|
std::vector<std::string> words;
|
||||||
|
FsFile f;
|
||||||
|
if (!Storage.openFileForRead("LKH", filePath(cachePath), f)) {
|
||||||
|
return words;
|
||||||
|
}
|
||||||
|
|
||||||
|
std::string line;
|
||||||
|
while (f.available() && static_cast<int>(words.size()) < MAX_ENTRIES) {
|
||||||
|
char c;
|
||||||
|
if (f.read(reinterpret_cast<uint8_t*>(&c), 1) != 1) break;
|
||||||
|
if (c == '\n') {
|
||||||
|
if (!line.empty()) {
|
||||||
|
words.push_back(line);
|
||||||
|
line.clear();
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
line += c;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (!line.empty() && static_cast<int>(words.size()) < MAX_ENTRIES) {
|
||||||
|
words.push_back(line);
|
||||||
|
}
|
||||||
|
f.close();
|
||||||
|
return words;
|
||||||
|
}
|
||||||
|
|
||||||
|
void LookupHistory::removeWord(const std::string& cachePath, const std::string& word) {
|
||||||
|
if (word.empty()) return;
|
||||||
|
|
||||||
|
auto existing = load(cachePath);
|
||||||
|
|
||||||
|
FsFile f;
|
||||||
|
if (!Storage.openFileForWrite("LKH", filePath(cachePath), f)) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const auto& w : existing) {
|
||||||
|
if (w != word) {
|
||||||
|
f.write(reinterpret_cast<const uint8_t*>(w.c_str()), w.size());
|
||||||
|
f.write(reinterpret_cast<const uint8_t*>("\n"), 1);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
f.close();
|
||||||
|
}
|
||||||
|
|
||||||
|
void LookupHistory::addWord(const std::string& cachePath, const std::string& word) {
|
||||||
|
if (word.empty()) return;
|
||||||
|
|
||||||
|
// Check if already present
|
||||||
|
auto existing = load(cachePath);
|
||||||
|
if (std::any_of(existing.begin(), existing.end(), [&word](const std::string& w) { return w == word; })) return;
|
||||||
|
|
||||||
|
// Cap at max entries
|
||||||
|
if (static_cast<int>(existing.size()) >= MAX_ENTRIES) return;
|
||||||
|
|
||||||
|
FsFile f;
|
||||||
|
if (!Storage.openFileForWrite("LKH", filePath(cachePath), f)) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Rewrite existing entries plus new one
|
||||||
|
for (const auto& w : existing) {
|
||||||
|
f.write(reinterpret_cast<const uint8_t*>(w.c_str()), w.size());
|
||||||
|
f.write(reinterpret_cast<const uint8_t*>("\n"), 1);
|
||||||
|
}
|
||||||
|
f.write(reinterpret_cast<const uint8_t*>(word.c_str()), word.size());
|
||||||
|
f.write(reinterpret_cast<const uint8_t*>("\n"), 1);
|
||||||
|
f.close();
|
||||||
|
}
|
||||||
15
src/util/LookupHistory.h
Normal file
15
src/util/LookupHistory.h
Normal file
@@ -0,0 +1,15 @@
|
|||||||
|
#pragma once
|
||||||
|
#include <string>
|
||||||
|
#include <vector>
|
||||||
|
|
||||||
|
class LookupHistory {
|
||||||
|
public:
|
||||||
|
static std::vector<std::string> load(const std::string& cachePath);
|
||||||
|
static void addWord(const std::string& cachePath, const std::string& word);
|
||||||
|
static void removeWord(const std::string& cachePath, const std::string& word);
|
||||||
|
static bool hasHistory(const std::string& cachePath);
|
||||||
|
|
||||||
|
private:
|
||||||
|
static std::string filePath(const std::string& cachePath);
|
||||||
|
static constexpr int MAX_ENTRIES = 500;
|
||||||
|
};
|
||||||
Reference in New Issue
Block a user