Compare commits
10 Commits
1.0.3
...
crosspoint
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
3853bfe113 | ||
|
|
fbe7d2feb4 | ||
|
|
520a0cb124 | ||
|
|
be8b02efd6 | ||
|
|
448ce55bb4 | ||
|
|
5464d9de3a | ||
|
|
48267ad848 | ||
|
|
dd630dcf72 | ||
|
|
ef705d3ac6 | ||
|
|
bab374a675 |
@ -6,6 +6,86 @@ Base: CrossPoint Reader 0.15.0
|
|||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
## ef-1.0.5
|
||||||
|
|
||||||
|
**Stability & Memory Improvements**
|
||||||
|
|
||||||
|
### Bug Fixes - Webserver
|
||||||
|
|
||||||
|
- **File Transfer Stability**: Removed blocking MD5 hash computation from file listings that caused EAGAIN errors and connection stalls
|
||||||
|
- **JSON Batching**: Implemented 2KB batch streaming for file listings with pacing to prevent TCP buffer overflow
|
||||||
|
- **Simplified Flow Control**: Removed unnecessary yield/delay logic from content streaming
|
||||||
|
|
||||||
|
### Bug Fixes - Memory
|
||||||
|
|
||||||
|
- **QR Code Caching**: Generate QR codes once on server start instead of regenerating on each screen render
|
||||||
|
- **WiFi Scan Optimization**: Replaced memory-heavy `std::map` deduplication with in-place vector search, limited results to 20 networks, earlier `WiFi.scanDelete()` for faster memory recovery
|
||||||
|
- **Cover Buffer Leak**: Fixed 48KB memory leak when navigating from Home to File Transfer (cover buffer now explicitly freed)
|
||||||
|
|
||||||
|
### Bug Fixes - EPUB Reader
|
||||||
|
|
||||||
|
- **Errant Underlining**: Fixed words before styled inline elements (like `<a>` tags with CSS underline) incorrectly receiving the element's style by flushing the text buffer before style changes
|
||||||
|
|
||||||
|
### Bug Fixes - Flashing Screen
|
||||||
|
|
||||||
|
- **Version String Overflow**: Fixed flash notification parsing failing on longer version strings (buffer limit increased from 30 to 50 characters)
|
||||||
|
- **Display Quality**: Changed flashing screen to half refresh for cleaner appearance
|
||||||
|
- **Timing**: Adjusted pre-flash script timing for half refresh completion
|
||||||
|
|
||||||
|
### Upstream Merges
|
||||||
|
|
||||||
|
- **PR #522 - HAL Abstraction Layer**: Merged hardware abstraction layer refactor introducing `HalDisplay` and `HalGPIO` classes, decoupling application code from direct hardware access
|
||||||
|
- **PR #603 - Sunlight Fading Fix**: Added user-toggleable setting to turn off display between refreshes, mitigating the sunlight fading issue on e-ink displays
|
||||||
|
- New "Sunlight Fading Fix" toggle in Display settings (OFF/ON)
|
||||||
|
- Passes `turnOffScreen` parameter through display stack when enabled
|
||||||
|
|
||||||
|
### Files Changed
|
||||||
|
|
||||||
|
- `src/main.cpp` - flash screen fixes, cover buffer free on File Transfer entry, fading fix integration
|
||||||
|
- `scripts/pre_flash.py` - timing adjustments for full refresh
|
||||||
|
- `src/network/CrossPointWebServer.cpp` - JSON batching, removed MD5 from listings
|
||||||
|
- `src/network/CrossPointWebServer.h` - removed md5 from FileInfo, simplified sendContentSafe
|
||||||
|
- `src/activities/network/CrossPointWebServerActivity.cpp` - QR code caching
|
||||||
|
- `src/activities/network/CrossPointWebServerActivity.h` - QR code cache members
|
||||||
|
- `src/activities/network/WifiSelectionActivity.cpp` - WiFi scan memory optimization
|
||||||
|
- `lib/Epub/Epub/parsers/ChapterHtmlSlimParser.cpp` - flush buffer before style changes
|
||||||
|
- `lib/hal/HalDisplay.h` - new HAL abstraction for display (PR #522), turnOffScreen parameter (PR #603)
|
||||||
|
- `lib/hal/HalDisplay.cpp` - HAL display implementation with fading fix passthrough
|
||||||
|
- `lib/hal/HalGPIO.h` - new HAL abstraction for GPIO (PR #522)
|
||||||
|
- `lib/hal/HalGPIO.cpp` - HAL GPIO implementation
|
||||||
|
- `lib/GfxRenderer/GfxRenderer.h` - updated for HAL layer, added fadingFix member
|
||||||
|
- `lib/GfxRenderer/GfxRenderer.cpp` - updated for HAL layer, passes fadingFix to display
|
||||||
|
- `src/CrossPointSettings.h` - added fadingFix setting
|
||||||
|
- `src/CrossPointSettings.cpp` - fadingFix persistence
|
||||||
|
- `src/activities/settings/SettingsActivity.cpp` - added Sunlight Fading Fix toggle
|
||||||
|
- `open-x4-sdk` - updated submodule with turnOffScreen support in EInkDisplay
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## ef-1.0.4
|
||||||
|
|
||||||
|
**EPUB Rendering & Stability**
|
||||||
|
|
||||||
|
### New Features
|
||||||
|
|
||||||
|
- **End-of-Book "Start Over"**: Press next at end of book to wrap to first page
|
||||||
|
|
||||||
|
### EPUB Rendering Improvements
|
||||||
|
|
||||||
|
- CSS `margin-left`/`padding-left` parsing for block indentation
|
||||||
|
- Vertical bar and italic styling for blockquotes
|
||||||
|
- Left margin indentation for list items (`<ol>`/`<ul>`)
|
||||||
|
- Fixed ordered lists showing bullets instead of numbers
|
||||||
|
- Fixed nested `<p>` inside `<li>` causing marker on separate line
|
||||||
|
|
||||||
|
### Bug Fixes
|
||||||
|
|
||||||
|
- **Webserver**: Fixed file listing disconnection issues with flow control
|
||||||
|
- **Webserver**: Memory optimization for File Transfer mode (frees heap before starting)
|
||||||
|
- **Dictionary**: Fixed zip dictionary allocation order for better memory allocation success
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
## ef-1.0.3
|
## ef-1.0.3
|
||||||
|
|
||||||
**Maintenance Release**
|
**Maintenance Release**
|
||||||
|
|||||||
@ -68,7 +68,9 @@ void ParsedText::layoutAndExtractLines(const GfxRenderer& renderer, const int fo
|
|||||||
// Apply fixed transforms before any per-line layout work.
|
// Apply fixed transforms before any per-line layout work.
|
||||||
applyParagraphIndent();
|
applyParagraphIndent();
|
||||||
|
|
||||||
const int pageWidth = viewportWidth;
|
// Apply horizontal margin (for blockquotes, nested content, etc.)
|
||||||
|
const int leftMargin = blockStyle.marginLeft;
|
||||||
|
const int pageWidth = viewportWidth - leftMargin;
|
||||||
const int spaceWidth = renderer.getSpaceWidth(fontId);
|
const int spaceWidth = renderer.getSpaceWidth(fontId);
|
||||||
auto wordWidths = calculateWordWidths(renderer, fontId);
|
auto wordWidths = calculateWordWidths(renderer, fontId);
|
||||||
std::vector<size_t> lineBreakIndices;
|
std::vector<size_t> lineBreakIndices;
|
||||||
@ -81,7 +83,7 @@ void ParsedText::layoutAndExtractLines(const GfxRenderer& renderer, const int fo
|
|||||||
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, lineBreakIndices, processLine);
|
extractLine(i, pageWidth, spaceWidth, leftMargin, wordWidths, lineBreakIndices, processLine);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -336,7 +338,7 @@ bool ParsedText::hyphenateWordAtIndex(const size_t wordIndex, const int availabl
|
|||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
|
|
||||||
void ParsedText::extractLine(const size_t breakIndex, const int pageWidth, const int spaceWidth,
|
void ParsedText::extractLine(const size_t breakIndex, const int pageWidth, const int spaceWidth, const int leftMargin,
|
||||||
const std::vector<uint16_t>& wordWidths, const std::vector<size_t>& lineBreakIndices,
|
const std::vector<uint16_t>& wordWidths, const std::vector<size_t>& lineBreakIndices,
|
||||||
const std::function<void(std::shared_ptr<TextBlock>)>& processLine) {
|
const std::function<void(std::shared_ptr<TextBlock>)>& processLine) {
|
||||||
const size_t lineBreak = lineBreakIndices[breakIndex];
|
const size_t lineBreak = lineBreakIndices[breakIndex];
|
||||||
@ -359,12 +361,12 @@ void ParsedText::extractLine(const size_t breakIndex, const int pageWidth, const
|
|||||||
spacing = spareSpace / (lineWordCount - 1);
|
spacing = spareSpace / (lineWordCount - 1);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Calculate initial x position
|
// Calculate initial x position (offset by left margin for blockquotes, etc.)
|
||||||
uint16_t xpos = 0;
|
uint16_t xpos = static_cast<uint16_t>(leftMargin);
|
||||||
if (style == TextBlock::RIGHT_ALIGN) {
|
if (style == TextBlock::RIGHT_ALIGN) {
|
||||||
xpos = spareSpace - (lineWordCount - 1) * spaceWidth;
|
xpos += spareSpace - (lineWordCount - 1) * spaceWidth;
|
||||||
} else if (style == TextBlock::CENTER_ALIGN) {
|
} else if (style == TextBlock::CENTER_ALIGN) {
|
||||||
xpos = (spareSpace - (lineWordCount - 1) * spaceWidth) / 2;
|
xpos += (spareSpace - (lineWordCount - 1) * spaceWidth) / 2;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Pre-calculate X positions for words
|
// Pre-calculate X positions for words
|
||||||
@ -378,19 +380,15 @@ void ParsedText::extractLine(const size_t breakIndex, const int pageWidth, const
|
|||||||
|
|
||||||
// *** CRITICAL STEP: CONSUME DATA USING MOVE + ERASE ***
|
// *** CRITICAL STEP: CONSUME DATA USING MOVE + ERASE ***
|
||||||
// Move first lineWordCount elements from words into lineWords
|
// Move first lineWordCount elements from words into lineWords
|
||||||
std::vector<std::string> lineWords(
|
std::vector<std::string> lineWords(std::make_move_iterator(words.begin()),
|
||||||
std::make_move_iterator(words.begin()),
|
std::make_move_iterator(words.begin() + lineWordCount));
|
||||||
std::make_move_iterator(words.begin() + lineWordCount));
|
|
||||||
words.erase(words.begin(), words.begin() + lineWordCount);
|
words.erase(words.begin(), words.begin() + lineWordCount);
|
||||||
|
|
||||||
std::vector<EpdFontFamily::Style> lineWordStyles(
|
std::vector<EpdFontFamily::Style> lineWordStyles(std::make_move_iterator(wordStyles.begin()),
|
||||||
std::make_move_iterator(wordStyles.begin()),
|
std::make_move_iterator(wordStyles.begin() + lineWordCount));
|
||||||
std::make_move_iterator(wordStyles.begin() + lineWordCount));
|
|
||||||
wordStyles.erase(wordStyles.begin(), wordStyles.begin() + lineWordCount);
|
wordStyles.erase(wordStyles.begin(), wordStyles.begin() + lineWordCount);
|
||||||
|
|
||||||
std::vector<bool> lineWordUnderlines(
|
std::vector<bool> lineWordUnderlines(wordUnderlines.begin(), wordUnderlines.begin() + lineWordCount);
|
||||||
wordUnderlines.begin(),
|
|
||||||
wordUnderlines.begin() + lineWordCount);
|
|
||||||
wordUnderlines.erase(wordUnderlines.begin(), wordUnderlines.begin() + lineWordCount);
|
wordUnderlines.erase(wordUnderlines.begin(), wordUnderlines.begin() + lineWordCount);
|
||||||
|
|
||||||
for (auto& word : lineWords) {
|
for (auto& word : lineWords) {
|
||||||
|
|||||||
@ -28,8 +28,8 @@ class ParsedText {
|
|||||||
int spaceWidth, std::vector<uint16_t>& wordWidths);
|
int spaceWidth, std::vector<uint16_t>& wordWidths);
|
||||||
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);
|
||||||
void extractLine(size_t breakIndex, int pageWidth, int spaceWidth, const std::vector<uint16_t>& wordWidths,
|
void extractLine(size_t breakIndex, int pageWidth, int spaceWidth, int leftMargin,
|
||||||
const std::vector<size_t>& lineBreakIndices,
|
const std::vector<uint16_t>& wordWidths, const std::vector<size_t>& lineBreakIndices,
|
||||||
const std::function<void(std::shared_ptr<TextBlock>)>& processLine);
|
const std::function<void(std::shared_ptr<TextBlock>)>& processLine);
|
||||||
std::vector<uint16_t> calculateWordWidths(const GfxRenderer& renderer, int fontId);
|
std::vector<uint16_t> calculateWordWidths(const GfxRenderer& renderer, int fontId);
|
||||||
|
|
||||||
|
|||||||
@ -8,8 +8,8 @@
|
|||||||
#include "parsers/ChapterHtmlSlimParser.h"
|
#include "parsers/ChapterHtmlSlimParser.h"
|
||||||
|
|
||||||
namespace {
|
namespace {
|
||||||
// Version 12: Added content offsets to LUT for position restoration after re-indexing
|
// Version 13: Added marginLeft and hasLeftBorder to BlockStyle serialization
|
||||||
constexpr uint8_t SECTION_FILE_VERSION = 12;
|
constexpr uint8_t SECTION_FILE_VERSION = 13;
|
||||||
constexpr uint32_t HEADER_SIZE = sizeof(uint8_t) + sizeof(int) + sizeof(float) + sizeof(bool) + sizeof(uint8_t) +
|
constexpr uint32_t HEADER_SIZE = sizeof(uint8_t) + sizeof(int) + sizeof(float) + sizeof(bool) + sizeof(uint8_t) +
|
||||||
sizeof(uint16_t) + sizeof(uint16_t) + sizeof(uint16_t) + sizeof(bool) +
|
sizeof(uint16_t) + sizeof(uint16_t) + sizeof(uint16_t) + sizeof(bool) +
|
||||||
sizeof(uint32_t);
|
sizeof(uint32_t);
|
||||||
|
|||||||
@ -9,9 +9,11 @@
|
|||||||
* Padding is treated similarly to margins for rendering purposes.
|
* Padding is treated similarly to margins for rendering purposes.
|
||||||
*/
|
*/
|
||||||
struct BlockStyle {
|
struct BlockStyle {
|
||||||
int8_t marginTop = 0; // 0-2 lines
|
int8_t marginTop = 0; // 0-2 lines
|
||||||
int8_t marginBottom = 0; // 0-2 lines
|
int8_t marginBottom = 0; // 0-2 lines
|
||||||
int8_t paddingTop = 0; // 0-2 lines (treated same as margin)
|
int8_t paddingTop = 0; // 0-2 lines (treated same as margin)
|
||||||
int8_t paddingBottom = 0; // 0-2 lines (treated same as margin)
|
int8_t paddingBottom = 0; // 0-2 lines (treated same as margin)
|
||||||
int16_t textIndent = 0; // pixels
|
int16_t textIndent = 0; // pixels (first line indent)
|
||||||
|
int16_t marginLeft = 0; // pixels (horizontal indent for entire block)
|
||||||
|
bool hasLeftBorder = false; // draw vertical bar in left margin (for blockquotes)
|
||||||
};
|
};
|
||||||
|
|||||||
@ -11,6 +11,17 @@ void TextBlock::render(const GfxRenderer& renderer, const int fontId, const int
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Draw left border (vertical bar) for blockquotes
|
||||||
|
if (blockStyle.hasLeftBorder && blockStyle.marginLeft > 0) {
|
||||||
|
const int lineHeight = renderer.getLineHeight(fontId);
|
||||||
|
const int barX = x + 4; // Small offset from left edge
|
||||||
|
const int barTop = y;
|
||||||
|
const int barBottom = y + lineHeight;
|
||||||
|
// Draw a 2-pixel wide vertical bar
|
||||||
|
renderer.drawLine(barX, barTop, barX, barBottom, true);
|
||||||
|
renderer.drawLine(barX + 1, barTop, barX + 1, barBottom, true);
|
||||||
|
}
|
||||||
|
|
||||||
auto wordIt = words.begin();
|
auto wordIt = words.begin();
|
||||||
auto wordStylesIt = wordStyles.begin();
|
auto wordStylesIt = wordStyles.begin();
|
||||||
auto wordXposIt = wordXpos.begin();
|
auto wordXposIt = wordXpos.begin();
|
||||||
@ -92,6 +103,8 @@ bool TextBlock::serialize(FsFile& file) const {
|
|||||||
serialization::writePod(file, blockStyle.paddingTop);
|
serialization::writePod(file, blockStyle.paddingTop);
|
||||||
serialization::writePod(file, blockStyle.paddingBottom);
|
serialization::writePod(file, blockStyle.paddingBottom);
|
||||||
serialization::writePod(file, blockStyle.textIndent);
|
serialization::writePod(file, blockStyle.textIndent);
|
||||||
|
serialization::writePod(file, blockStyle.marginLeft);
|
||||||
|
serialization::writePod(file, blockStyle.hasLeftBorder);
|
||||||
|
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
@ -144,6 +157,8 @@ std::unique_ptr<TextBlock> TextBlock::deserialize(FsFile& file) {
|
|||||||
serialization::readPod(file, blockStyle.paddingTop);
|
serialization::readPod(file, blockStyle.paddingTop);
|
||||||
serialization::readPod(file, blockStyle.paddingBottom);
|
serialization::readPod(file, blockStyle.paddingBottom);
|
||||||
serialization::readPod(file, blockStyle.textIndent);
|
serialization::readPod(file, blockStyle.textIndent);
|
||||||
|
serialization::readPod(file, blockStyle.marginLeft);
|
||||||
|
serialization::readPod(file, blockStyle.hasLeftBorder);
|
||||||
|
|
||||||
return std::unique_ptr<TextBlock>(new TextBlock(std::move(words), std::move(wordXpos), std::move(wordStyles), style,
|
return std::unique_ptr<TextBlock>(new TextBlock(std::move(words), std::move(wordXpos), std::move(wordStyles), style,
|
||||||
blockStyle, std::move(wordUnderlines)));
|
blockStyle, std::move(wordUnderlines)));
|
||||||
|
|||||||
@ -2,9 +2,9 @@
|
|||||||
#include <EpdFontFamily.h>
|
#include <EpdFontFamily.h>
|
||||||
#include <SdFat.h>
|
#include <SdFat.h>
|
||||||
|
|
||||||
#include <vector>
|
|
||||||
#include <memory>
|
#include <memory>
|
||||||
#include <string>
|
#include <string>
|
||||||
|
#include <vector>
|
||||||
|
|
||||||
#include "Block.h"
|
#include "Block.h"
|
||||||
#include "BlockStyle.h"
|
#include "BlockStyle.h"
|
||||||
@ -30,7 +30,8 @@ class TextBlock final : public Block {
|
|||||||
public:
|
public:
|
||||||
explicit TextBlock(std::vector<std::string> words, std::vector<uint16_t> word_xpos,
|
explicit TextBlock(std::vector<std::string> words, std::vector<uint16_t> word_xpos,
|
||||||
std::vector<EpdFontFamily::Style> word_styles, const Style style,
|
std::vector<EpdFontFamily::Style> word_styles, const Style style,
|
||||||
const BlockStyle& blockStyle = BlockStyle(), std::vector<bool> word_underlines = std::vector<bool>())
|
const BlockStyle& blockStyle = BlockStyle(),
|
||||||
|
std::vector<bool> word_underlines = std::vector<bool>())
|
||||||
: 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)),
|
||||||
|
|||||||
@ -393,6 +393,32 @@ CssStyle CssParser::parseDeclarations(const std::string& declBlock) {
|
|||||||
style.paddingBottom = spacing;
|
style.paddingBottom = spacing;
|
||||||
style.defined.paddingBottom = 1;
|
style.defined.paddingBottom = 1;
|
||||||
}
|
}
|
||||||
|
} else if (propName == "margin-left" || propName == "padding-left") {
|
||||||
|
// Horizontal indentation for blockquotes and nested content
|
||||||
|
const float pixels = interpretLength(propValue);
|
||||||
|
if (pixels > 0) {
|
||||||
|
style.marginLeft += pixels; // Accumulate margin-left and padding-left
|
||||||
|
style.defined.marginLeft = 1;
|
||||||
|
}
|
||||||
|
} else if (propName == "margin") {
|
||||||
|
// Shorthand: margin: top right bottom left OR margin: vertical horizontal
|
||||||
|
const auto values = splitWhitespace(propValue);
|
||||||
|
if (values.size() >= 2) {
|
||||||
|
// At least 2 values: first is vertical (top/bottom), second is horizontal (left/right)
|
||||||
|
const float horizontal = interpretLength(values[1]);
|
||||||
|
if (horizontal > 0) {
|
||||||
|
style.marginLeft = horizontal;
|
||||||
|
style.defined.marginLeft = 1;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (values.size() == 4) {
|
||||||
|
// 4 values: top right bottom left - use the 4th value for left
|
||||||
|
const float left = interpretLength(values[3]);
|
||||||
|
if (left > 0) {
|
||||||
|
style.marginLeft = left;
|
||||||
|
style.defined.marginLeft = 1;
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@ -25,7 +25,8 @@ struct CssPropertyFlags {
|
|||||||
uint16_t marginBottom : 1;
|
uint16_t marginBottom : 1;
|
||||||
uint16_t paddingTop : 1;
|
uint16_t paddingTop : 1;
|
||||||
uint16_t paddingBottom : 1;
|
uint16_t paddingBottom : 1;
|
||||||
uint16_t reserved : 7;
|
uint16_t marginLeft : 1;
|
||||||
|
uint16_t reserved : 6;
|
||||||
|
|
||||||
CssPropertyFlags()
|
CssPropertyFlags()
|
||||||
: alignment(0),
|
: alignment(0),
|
||||||
@ -37,16 +38,17 @@ struct CssPropertyFlags {
|
|||||||
marginBottom(0),
|
marginBottom(0),
|
||||||
paddingTop(0),
|
paddingTop(0),
|
||||||
paddingBottom(0),
|
paddingBottom(0),
|
||||||
|
marginLeft(0),
|
||||||
reserved(0) {}
|
reserved(0) {}
|
||||||
|
|
||||||
[[nodiscard]] bool anySet() const {
|
[[nodiscard]] bool anySet() const {
|
||||||
return alignment || fontStyle || fontWeight || decoration || indent || marginTop || marginBottom || paddingTop ||
|
return alignment || fontStyle || fontWeight || decoration || indent || marginTop || marginBottom || paddingTop ||
|
||||||
paddingBottom;
|
paddingBottom || marginLeft;
|
||||||
}
|
}
|
||||||
|
|
||||||
void clearAll() {
|
void clearAll() {
|
||||||
alignment = fontStyle = fontWeight = decoration = indent = 0;
|
alignment = fontStyle = fontWeight = decoration = indent = 0;
|
||||||
marginTop = marginBottom = paddingTop = paddingBottom = 0;
|
marginTop = marginBottom = paddingTop = paddingBottom = marginLeft = 0;
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
@ -63,6 +65,7 @@ struct CssStyle {
|
|||||||
int8_t marginBottom = 0; // Vertical spacing after block (in lines, 0-2)
|
int8_t marginBottom = 0; // Vertical spacing after block (in lines, 0-2)
|
||||||
int8_t paddingTop = 0; // Padding before (in lines, 0-2)
|
int8_t paddingTop = 0; // Padding before (in lines, 0-2)
|
||||||
int8_t paddingBottom = 0; // Padding after (in lines, 0-2)
|
int8_t paddingBottom = 0; // Padding after (in lines, 0-2)
|
||||||
|
float marginLeft = 0.0f; // Horizontal indent in pixels (for blockquotes, etc.)
|
||||||
|
|
||||||
CssPropertyFlags defined; // Tracks which properties were explicitly set
|
CssPropertyFlags defined; // Tracks which properties were explicitly set
|
||||||
|
|
||||||
@ -105,6 +108,10 @@ struct CssStyle {
|
|||||||
paddingBottom = base.paddingBottom;
|
paddingBottom = base.paddingBottom;
|
||||||
defined.paddingBottom = 1;
|
defined.paddingBottom = 1;
|
||||||
}
|
}
|
||||||
|
if (base.defined.marginLeft) {
|
||||||
|
marginLeft = base.marginLeft;
|
||||||
|
defined.marginLeft = 1;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Compatibility accessors for existing code that uses hasX pattern
|
// Compatibility accessors for existing code that uses hasX pattern
|
||||||
@ -117,6 +124,7 @@ struct CssStyle {
|
|||||||
[[nodiscard]] bool hasMarginBottom() const { return defined.marginBottom; }
|
[[nodiscard]] bool hasMarginBottom() const { return defined.marginBottom; }
|
||||||
[[nodiscard]] bool hasPaddingTop() const { return defined.paddingTop; }
|
[[nodiscard]] bool hasPaddingTop() const { return defined.paddingTop; }
|
||||||
[[nodiscard]] bool hasPaddingBottom() const { return defined.paddingBottom; }
|
[[nodiscard]] bool hasPaddingBottom() const { return defined.paddingBottom; }
|
||||||
|
[[nodiscard]] bool hasMarginLeft() const { return defined.marginLeft; }
|
||||||
|
|
||||||
// Merge another style (alias for applyOver for compatibility)
|
// Merge another style (alias for applyOver for compatibility)
|
||||||
void merge(const CssStyle& other) { applyOver(other); }
|
void merge(const CssStyle& other) { applyOver(other); }
|
||||||
@ -128,6 +136,7 @@ struct CssStyle {
|
|||||||
decoration = CssTextDecoration::None;
|
decoration = CssTextDecoration::None;
|
||||||
indentPixels = 0.0f;
|
indentPixels = 0.0f;
|
||||||
marginTop = marginBottom = paddingTop = paddingBottom = 0;
|
marginTop = marginBottom = paddingTop = paddingBottom = 0;
|
||||||
|
marginLeft = 0.0f;
|
||||||
defined.clearAll();
|
defined.clearAll();
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|||||||
@ -19,6 +19,9 @@ constexpr size_t MIN_SIZE_FOR_PROGRESS = 50 * 1024; // 50KB
|
|||||||
const char* BLOCK_TAGS[] = {"p", "li", "div", "br", "blockquote"};
|
const char* BLOCK_TAGS[] = {"p", "li", "div", "br", "blockquote"};
|
||||||
constexpr int NUM_BLOCK_TAGS = sizeof(BLOCK_TAGS) / sizeof(BLOCK_TAGS[0]);
|
constexpr int NUM_BLOCK_TAGS = sizeof(BLOCK_TAGS) / sizeof(BLOCK_TAGS[0]);
|
||||||
|
|
||||||
|
const char* LIST_TAGS[] = {"ol", "ul"};
|
||||||
|
constexpr int NUM_LIST_TAGS = sizeof(LIST_TAGS) / sizeof(LIST_TAGS[0]);
|
||||||
|
|
||||||
const char* BOLD_TAGS[] = {"b", "strong"};
|
const char* BOLD_TAGS[] = {"b", "strong"};
|
||||||
constexpr int NUM_BOLD_TAGS = sizeof(BOLD_TAGS) / sizeof(BOLD_TAGS[0]);
|
constexpr int NUM_BOLD_TAGS = sizeof(BOLD_TAGS) / sizeof(BOLD_TAGS[0]);
|
||||||
|
|
||||||
@ -55,6 +58,7 @@ BlockStyle createBlockStyleFromCss(const CssStyle& cssStyle) {
|
|||||||
blockStyle.paddingTop = cssStyle.paddingTop;
|
blockStyle.paddingTop = cssStyle.paddingTop;
|
||||||
blockStyle.paddingBottom = cssStyle.paddingBottom;
|
blockStyle.paddingBottom = cssStyle.paddingBottom;
|
||||||
blockStyle.textIndent = static_cast<int16_t>(cssStyle.indentPixels);
|
blockStyle.textIndent = static_cast<int16_t>(cssStyle.indentPixels);
|
||||||
|
blockStyle.marginLeft = static_cast<int16_t>(cssStyle.marginLeft);
|
||||||
return blockStyle;
|
return blockStyle;
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -320,6 +324,18 @@ void XMLCALL ChapterHtmlSlimParser::startElement(void* userData, const XML_Char*
|
|||||||
|
|
||||||
// Determine if this is a block element
|
// Determine if this is a block element
|
||||||
bool isBlockElement = matches(name, HEADER_TAGS, NUM_HEADER_TAGS) || matches(name, BLOCK_TAGS, NUM_BLOCK_TAGS);
|
bool isBlockElement = matches(name, HEADER_TAGS, NUM_HEADER_TAGS) || matches(name, BLOCK_TAGS, NUM_BLOCK_TAGS);
|
||||||
|
bool isListTag = matches(name, LIST_TAGS, NUM_LIST_TAGS);
|
||||||
|
|
||||||
|
// Handle list container tags (ol, ul)
|
||||||
|
if (isListTag) {
|
||||||
|
ListContext ctx;
|
||||||
|
ctx.isOrdered = strcmp(name, "ol") == 0;
|
||||||
|
ctx.counter = 0;
|
||||||
|
ctx.depth = self->depth;
|
||||||
|
self->listStack.push_back(ctx);
|
||||||
|
self->depth += 1;
|
||||||
|
return; // Lists themselves don't create text blocks
|
||||||
|
}
|
||||||
|
|
||||||
// Compute CSS style for this element
|
// Compute CSS style for this element
|
||||||
CssStyle cssStyle;
|
CssStyle cssStyle;
|
||||||
@ -365,6 +381,20 @@ void XMLCALL ChapterHtmlSlimParser::startElement(void* userData, const XML_Char*
|
|||||||
// This fixes issue where <br/> incorrectly wrapped the preceding word to a new line
|
// This fixes issue where <br/> incorrectly wrapped the preceding word to a new line
|
||||||
self->flushPartWordBuffer();
|
self->flushPartWordBuffer();
|
||||||
self->startNewTextBlock(self->currentTextBlock->getStyle());
|
self->startNewTextBlock(self->currentTextBlock->getStyle());
|
||||||
|
} else if (strcmp(name, "li") == 0) {
|
||||||
|
// For list items, DON'T create a text block yet - wait for the first content element
|
||||||
|
// This prevents the marker from being on its own line when <li><p>content</p></li>
|
||||||
|
self->insideListItem = true;
|
||||||
|
self->listItemDepth = self->depth;
|
||||||
|
self->listItemHasContent = false;
|
||||||
|
|
||||||
|
// Increment counter now (so nested lists work correctly)
|
||||||
|
if (!self->listStack.empty()) {
|
||||||
|
self->listStack.back().counter++;
|
||||||
|
}
|
||||||
|
// Don't create text block or add marker yet - will be done when first content arrives
|
||||||
|
self->depth += 1;
|
||||||
|
return;
|
||||||
} else {
|
} else {
|
||||||
// Determine alignment from CSS or default
|
// Determine alignment from CSS or default
|
||||||
auto alignment = static_cast<TextBlock::Style>(self->paragraphAlignment);
|
auto alignment = static_cast<TextBlock::Style>(self->paragraphAlignment);
|
||||||
@ -387,15 +417,77 @@ void XMLCALL ChapterHtmlSlimParser::startElement(void* userData, const XML_Char*
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Apply default styling for blockquote if no CSS margin is specified
|
||||||
|
const bool isBlockquote = strcmp(name, "blockquote") == 0;
|
||||||
|
if (isBlockquote) {
|
||||||
|
if (!cssStyle.hasMarginLeft()) {
|
||||||
|
// Default left indent for blockquotes (~1.5em at 16px base = 24px)
|
||||||
|
cssStyle.marginLeft = 24.0f;
|
||||||
|
cssStyle.defined.marginLeft = 1;
|
||||||
|
}
|
||||||
|
// Also make blockquotes italic by default if not specified
|
||||||
|
if (!cssStyle.hasFontStyle()) {
|
||||||
|
cssStyle.fontStyle = CssFontStyle::Italic;
|
||||||
|
cssStyle.defined.fontStyle = 1;
|
||||||
|
}
|
||||||
|
// Track blockquote context for child elements
|
||||||
|
self->insideBlockquote = true;
|
||||||
|
self->blockquoteDepth = self->depth;
|
||||||
|
self->blockquoteMarginLeft = cssStyle.marginLeft;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Apply blockquote styling to child block elements
|
||||||
|
if (self->insideBlockquote && !isBlockquote) {
|
||||||
|
// Inherit margin and border from parent blockquote
|
||||||
|
if (!cssStyle.hasMarginLeft()) {
|
||||||
|
cssStyle.marginLeft = self->blockquoteMarginLeft;
|
||||||
|
cssStyle.defined.marginLeft = 1;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Apply left margin to list items (indent the whole block)
|
||||||
|
if (self->insideListItem && !cssStyle.hasMarginLeft()) {
|
||||||
|
// Default left indent for list items (~1.5em at 16px base = 24px)
|
||||||
|
cssStyle.marginLeft = 24.0f;
|
||||||
|
cssStyle.defined.marginLeft = 1;
|
||||||
|
}
|
||||||
|
|
||||||
self->currentBlockStyle = cssStyle;
|
self->currentBlockStyle = cssStyle;
|
||||||
self->startNewTextBlock(alignment, createBlockStyleFromCss(cssStyle));
|
BlockStyle blockStyleForElement = createBlockStyleFromCss(cssStyle);
|
||||||
|
if (isBlockquote || self->insideBlockquote) {
|
||||||
|
blockStyleForElement.hasLeftBorder = true; // Draw vertical bar for blockquotes
|
||||||
|
}
|
||||||
|
self->startNewTextBlock(alignment, blockStyleForElement);
|
||||||
self->updateEffectiveInlineStyle();
|
self->updateEffectiveInlineStyle();
|
||||||
|
|
||||||
if (strcmp(name, "li") == 0) {
|
// If this is a blockquote, apply italic styling
|
||||||
self->currentTextBlock->addWord("\xe2\x80\xa2", EpdFontFamily::REGULAR);
|
if (isBlockquote && cssStyle.hasFontStyle() && cssStyle.fontStyle == CssFontStyle::Italic) {
|
||||||
|
self->italicUntilDepth = std::min(self->italicUntilDepth, self->depth);
|
||||||
|
}
|
||||||
|
|
||||||
|
// If this is the first block element inside a list item, add the marker
|
||||||
|
if (self->insideListItem && !self->listItemHasContent) {
|
||||||
|
if (!self->listStack.empty()) {
|
||||||
|
const ListContext& ctx = self->listStack.back();
|
||||||
|
if (ctx.isOrdered) {
|
||||||
|
// Ordered list: use number (counter was already incremented)
|
||||||
|
std::string marker = std::to_string(ctx.counter) + ". ";
|
||||||
|
self->currentTextBlock->addWord(marker, EpdFontFamily::REGULAR);
|
||||||
|
} else {
|
||||||
|
// Unordered list: use bullet
|
||||||
|
self->currentTextBlock->addWord("\xe2\x80\xa2", EpdFontFamily::REGULAR);
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
// No list context (orphan li), use bullet as fallback
|
||||||
|
self->currentTextBlock->addWord("\xe2\x80\xa2", EpdFontFamily::REGULAR);
|
||||||
|
}
|
||||||
|
self->listItemHasContent = true;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
} else if (matches(name, UNDERLINE_TAGS, NUM_UNDERLINE_TAGS)) {
|
} else if (matches(name, UNDERLINE_TAGS, NUM_UNDERLINE_TAGS)) {
|
||||||
|
// Flush buffer with CURRENT style before changing effective style
|
||||||
|
self->flushPartWordBuffer();
|
||||||
|
|
||||||
self->underlineUntilDepth = std::min(self->underlineUntilDepth, self->depth);
|
self->underlineUntilDepth = std::min(self->underlineUntilDepth, self->depth);
|
||||||
// Push inline style entry for underline tag
|
// Push inline style entry for underline tag
|
||||||
StyleStackEntry entry;
|
StyleStackEntry entry;
|
||||||
@ -413,6 +505,9 @@ void XMLCALL ChapterHtmlSlimParser::startElement(void* userData, const XML_Char*
|
|||||||
self->inlineStyleStack.push_back(entry);
|
self->inlineStyleStack.push_back(entry);
|
||||||
self->updateEffectiveInlineStyle();
|
self->updateEffectiveInlineStyle();
|
||||||
} else if (matches(name, BOLD_TAGS, NUM_BOLD_TAGS)) {
|
} else if (matches(name, BOLD_TAGS, NUM_BOLD_TAGS)) {
|
||||||
|
// Flush buffer with CURRENT style before changing effective style
|
||||||
|
self->flushPartWordBuffer();
|
||||||
|
|
||||||
self->boldUntilDepth = std::min(self->boldUntilDepth, self->depth);
|
self->boldUntilDepth = std::min(self->boldUntilDepth, self->depth);
|
||||||
// Push inline style entry for bold tag
|
// Push inline style entry for bold tag
|
||||||
StyleStackEntry entry;
|
StyleStackEntry entry;
|
||||||
@ -430,6 +525,9 @@ void XMLCALL ChapterHtmlSlimParser::startElement(void* userData, const XML_Char*
|
|||||||
self->inlineStyleStack.push_back(entry);
|
self->inlineStyleStack.push_back(entry);
|
||||||
self->updateEffectiveInlineStyle();
|
self->updateEffectiveInlineStyle();
|
||||||
} else if (matches(name, ITALIC_TAGS, NUM_ITALIC_TAGS)) {
|
} else if (matches(name, ITALIC_TAGS, NUM_ITALIC_TAGS)) {
|
||||||
|
// Flush buffer with CURRENT style before changing effective style
|
||||||
|
self->flushPartWordBuffer();
|
||||||
|
|
||||||
self->italicUntilDepth = std::min(self->italicUntilDepth, self->depth);
|
self->italicUntilDepth = std::min(self->italicUntilDepth, self->depth);
|
||||||
// Push inline style entry for italic tag
|
// Push inline style entry for italic tag
|
||||||
StyleStackEntry entry;
|
StyleStackEntry entry;
|
||||||
@ -449,6 +547,10 @@ void XMLCALL ChapterHtmlSlimParser::startElement(void* userData, const XML_Char*
|
|||||||
} else if (strcmp(name, "span") == 0 || !isBlockElement) {
|
} else if (strcmp(name, "span") == 0 || !isBlockElement) {
|
||||||
// Handle span and other inline elements for CSS styling
|
// Handle span and other inline elements for CSS styling
|
||||||
if (cssStyle.hasFontWeight() || cssStyle.hasFontStyle() || cssStyle.hasTextDecoration()) {
|
if (cssStyle.hasFontWeight() || cssStyle.hasFontStyle() || cssStyle.hasTextDecoration()) {
|
||||||
|
// Flush buffer with CURRENT style before changing effective style
|
||||||
|
// This prevents text accumulated before this element from getting the new style
|
||||||
|
self->flushPartWordBuffer();
|
||||||
|
|
||||||
StyleStackEntry entry;
|
StyleStackEntry entry;
|
||||||
entry.depth = self->depth; // Track depth for matching pop
|
entry.depth = self->depth; // Track depth for matching pop
|
||||||
if (cssStyle.hasFontWeight()) {
|
if (cssStyle.hasFontWeight()) {
|
||||||
@ -484,6 +586,33 @@ void XMLCALL ChapterHtmlSlimParser::characterData(void* userData, const XML_Char
|
|||||||
self->lastCharDataOffset = XML_GetCurrentByteIndex(self->xmlParser);
|
self->lastCharDataOffset = XML_GetCurrentByteIndex(self->xmlParser);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// If we're inside an <li> but no text block was created yet (direct text without inner <p>),
|
||||||
|
// create a text block and add the list marker now
|
||||||
|
if (self->insideListItem && !self->listItemHasContent) {
|
||||||
|
// Apply left margin for list items
|
||||||
|
CssStyle cssStyle;
|
||||||
|
cssStyle.marginLeft = 24.0f; // Default indent (~1.5em at 16px base)
|
||||||
|
cssStyle.defined.marginLeft = 1;
|
||||||
|
|
||||||
|
BlockStyle blockStyle = createBlockStyleFromCss(cssStyle);
|
||||||
|
self->startNewTextBlock(static_cast<TextBlock::Style>(self->paragraphAlignment), blockStyle);
|
||||||
|
|
||||||
|
// Add the list marker
|
||||||
|
if (!self->listStack.empty()) {
|
||||||
|
const ListContext& ctx = self->listStack.back();
|
||||||
|
if (ctx.isOrdered) {
|
||||||
|
std::string marker = std::to_string(ctx.counter) + ". ";
|
||||||
|
self->currentTextBlock->addWord(marker, EpdFontFamily::REGULAR);
|
||||||
|
} else {
|
||||||
|
self->currentTextBlock->addWord("\xe2\x80\xa2", EpdFontFamily::REGULAR);
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
// No list context (orphan li), use bullet as fallback
|
||||||
|
self->currentTextBlock->addWord("\xe2\x80\xa2", EpdFontFamily::REGULAR);
|
||||||
|
}
|
||||||
|
self->listItemHasContent = true;
|
||||||
|
}
|
||||||
|
|
||||||
// Determine font style from depth-based tracking and CSS effective style
|
// Determine font style from depth-based tracking and CSS effective style
|
||||||
const bool isBold = self->boldUntilDepth < self->depth || self->effectiveBold;
|
const bool isBold = self->boldUntilDepth < self->depth || self->effectiveBold;
|
||||||
const bool isItalic = self->italicUntilDepth < self->depth || self->effectiveItalic;
|
const bool isItalic = self->italicUntilDepth < self->depth || self->effectiveItalic;
|
||||||
@ -566,7 +695,8 @@ void XMLCALL ChapterHtmlSlimParser::endElement(void* userData, const XML_Char* n
|
|||||||
const bool shouldFlush = styleWillChange || matches(name, BLOCK_TAGS, NUM_BLOCK_TAGS) ||
|
const bool shouldFlush = styleWillChange || matches(name, BLOCK_TAGS, NUM_BLOCK_TAGS) ||
|
||||||
matches(name, HEADER_TAGS, NUM_HEADER_TAGS) || matches(name, BOLD_TAGS, NUM_BOLD_TAGS) ||
|
matches(name, HEADER_TAGS, NUM_HEADER_TAGS) || matches(name, BOLD_TAGS, NUM_BOLD_TAGS) ||
|
||||||
matches(name, ITALIC_TAGS, NUM_ITALIC_TAGS) ||
|
matches(name, ITALIC_TAGS, NUM_ITALIC_TAGS) ||
|
||||||
matches(name, UNDERLINE_TAGS, NUM_UNDERLINE_TAGS) || self->depth == 1;
|
matches(name, UNDERLINE_TAGS, NUM_UNDERLINE_TAGS) ||
|
||||||
|
matches(name, LIST_TAGS, NUM_LIST_TAGS) || self->depth == 1;
|
||||||
|
|
||||||
if (shouldFlush) {
|
if (shouldFlush) {
|
||||||
// Use combined depth-based and CSS-based style
|
// Use combined depth-based and CSS-based style
|
||||||
@ -596,6 +726,27 @@ void XMLCALL ChapterHtmlSlimParser::endElement(void* userData, const XML_Char* n
|
|||||||
self->skipUntilDepth = INT_MAX;
|
self->skipUntilDepth = INT_MAX;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Leaving list container (ol, ul)
|
||||||
|
if (matches(name, LIST_TAGS, NUM_LIST_TAGS)) {
|
||||||
|
if (!self->listStack.empty() && self->listStack.back().depth == self->depth) {
|
||||||
|
self->listStack.pop_back();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Leaving list item (li)
|
||||||
|
if (strcmp(name, "li") == 0 && self->listItemDepth == self->depth) {
|
||||||
|
self->insideListItem = false;
|
||||||
|
self->listItemDepth = INT_MAX;
|
||||||
|
self->listItemHasContent = false;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Leaving blockquote
|
||||||
|
if (strcmp(name, "blockquote") == 0 && self->blockquoteDepth == self->depth) {
|
||||||
|
self->insideBlockquote = false;
|
||||||
|
self->blockquoteDepth = INT_MAX;
|
||||||
|
self->blockquoteMarginLeft = 0.0f;
|
||||||
|
}
|
||||||
|
|
||||||
// Leaving bold tag
|
// Leaving bold tag
|
||||||
if (self->boldUntilDepth == self->depth) {
|
if (self->boldUntilDepth == self->depth) {
|
||||||
self->boldUntilDepth = INT_MAX;
|
self->boldUntilDepth = INT_MAX;
|
||||||
|
|||||||
@ -59,6 +59,22 @@ class ChapterHtmlSlimParser {
|
|||||||
bool effectiveItalic = false;
|
bool effectiveItalic = false;
|
||||||
bool effectiveUnderline = false;
|
bool effectiveUnderline = false;
|
||||||
|
|
||||||
|
// List context tracking for ordered/unordered lists
|
||||||
|
struct ListContext {
|
||||||
|
bool isOrdered = false; // true for <ol>, false for <ul>
|
||||||
|
int counter = 0; // Current item number (for ordered lists)
|
||||||
|
int depth = 0; // Depth at which list was opened
|
||||||
|
};
|
||||||
|
std::vector<ListContext> listStack;
|
||||||
|
bool insideListItem = false; // True when we're inside an <li> element
|
||||||
|
int listItemDepth = INT_MAX; // Depth at which <li> was opened
|
||||||
|
bool listItemHasContent = false; // True if we've added content to the current list item
|
||||||
|
|
||||||
|
// Blockquote context tracking (for left border on child elements)
|
||||||
|
bool insideBlockquote = false;
|
||||||
|
int blockquoteDepth = INT_MAX;
|
||||||
|
float blockquoteMarginLeft = 0.0f; // Inherit margin from blockquote to child elements
|
||||||
|
|
||||||
// Byte offset tracking for position restoration after re-indexing
|
// Byte offset tracking for position restoration after re-indexing
|
||||||
XML_Parser xmlParser = nullptr; // Store parser for getting current byte index
|
XML_Parser xmlParser = nullptr; // Store parser for getting current byte index
|
||||||
size_t currentPageStartOffset = 0; // Byte offset when current page was started
|
size_t currentPageStartOffset = 0; // Byte offset when current page was started
|
||||||
|
|||||||
@ -10,19 +10,19 @@ void GfxRenderer::rotateCoordinates(const int x, const int y, int* rotatedX, int
|
|||||||
// Logical portrait (480x800) → panel (800x480)
|
// Logical portrait (480x800) → panel (800x480)
|
||||||
// Rotation: 90 degrees clockwise
|
// Rotation: 90 degrees clockwise
|
||||||
*rotatedX = y;
|
*rotatedX = y;
|
||||||
*rotatedY = EInkDisplay::DISPLAY_HEIGHT - 1 - x;
|
*rotatedY = HalDisplay::DISPLAY_HEIGHT - 1 - x;
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
case LandscapeClockwise: {
|
case LandscapeClockwise: {
|
||||||
// Logical landscape (800x480) rotated 180 degrees (swap top/bottom and left/right)
|
// Logical landscape (800x480) rotated 180 degrees (swap top/bottom and left/right)
|
||||||
*rotatedX = EInkDisplay::DISPLAY_WIDTH - 1 - x;
|
*rotatedX = HalDisplay::DISPLAY_WIDTH - 1 - x;
|
||||||
*rotatedY = EInkDisplay::DISPLAY_HEIGHT - 1 - y;
|
*rotatedY = HalDisplay::DISPLAY_HEIGHT - 1 - y;
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
case PortraitInverted: {
|
case PortraitInverted: {
|
||||||
// Logical portrait (480x800) → panel (800x480)
|
// Logical portrait (480x800) → panel (800x480)
|
||||||
// Rotation: 90 degrees counter-clockwise
|
// Rotation: 90 degrees counter-clockwise
|
||||||
*rotatedX = EInkDisplay::DISPLAY_WIDTH - 1 - y;
|
*rotatedX = HalDisplay::DISPLAY_WIDTH - 1 - y;
|
||||||
*rotatedY = x;
|
*rotatedY = x;
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
@ -36,7 +36,7 @@ void GfxRenderer::rotateCoordinates(const int x, const int y, int* rotatedX, int
|
|||||||
}
|
}
|
||||||
|
|
||||||
void GfxRenderer::drawPixel(const int x, const int y, const bool state) const {
|
void GfxRenderer::drawPixel(const int x, const int y, const bool state) const {
|
||||||
uint8_t* frameBuffer = einkDisplay.getFrameBuffer();
|
uint8_t* frameBuffer = display.getFrameBuffer();
|
||||||
|
|
||||||
// Early return if no framebuffer is set
|
// Early return if no framebuffer is set
|
||||||
if (!frameBuffer) {
|
if (!frameBuffer) {
|
||||||
@ -49,14 +49,13 @@ void GfxRenderer::drawPixel(const int x, const int y, const bool state) const {
|
|||||||
rotateCoordinates(x, y, &rotatedX, &rotatedY);
|
rotateCoordinates(x, y, &rotatedX, &rotatedY);
|
||||||
|
|
||||||
// Bounds checking against physical panel dimensions
|
// Bounds checking against physical panel dimensions
|
||||||
if (rotatedX < 0 || rotatedX >= EInkDisplay::DISPLAY_WIDTH || rotatedY < 0 ||
|
if (rotatedX < 0 || rotatedX >= HalDisplay::DISPLAY_WIDTH || rotatedY < 0 || rotatedY >= HalDisplay::DISPLAY_HEIGHT) {
|
||||||
rotatedY >= EInkDisplay::DISPLAY_HEIGHT) {
|
|
||||||
Serial.printf("[%lu] [GFX] !! Outside range (%d, %d) -> (%d, %d)\n", millis(), x, y, rotatedX, rotatedY);
|
Serial.printf("[%lu] [GFX] !! Outside range (%d, %d) -> (%d, %d)\n", millis(), x, y, rotatedX, rotatedY);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Calculate byte position and bit position
|
// Calculate byte position and bit position
|
||||||
const uint16_t byteIndex = rotatedY * EInkDisplay::DISPLAY_WIDTH_BYTES + (rotatedX / 8);
|
const uint16_t byteIndex = rotatedY * HalDisplay::DISPLAY_WIDTH_BYTES + (rotatedX / 8);
|
||||||
const uint8_t bitPosition = 7 - (rotatedX % 8); // MSB first
|
const uint8_t bitPosition = 7 - (rotatedX % 8); // MSB first
|
||||||
|
|
||||||
if (state) {
|
if (state) {
|
||||||
@ -202,7 +201,7 @@ void GfxRenderer::drawImage(const uint8_t bitmap[], const int x, const int y, co
|
|||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
// TODO: Rotate bits
|
// TODO: Rotate bits
|
||||||
einkDisplay.drawImage(bitmap, rotatedX, rotatedY, width, height);
|
display.drawImage(bitmap, rotatedX, rotatedY, width, height);
|
||||||
}
|
}
|
||||||
|
|
||||||
void GfxRenderer::drawImageRotated(const uint8_t bitmap[], const int x, const int y, const int width, const int height,
|
void GfxRenderer::drawImageRotated(const uint8_t bitmap[], const int x, const int y, const int width, const int height,
|
||||||
@ -519,21 +518,21 @@ void GfxRenderer::fillPolygon(const int* xPoints, const int* yPoints, int numPoi
|
|||||||
free(nodeX);
|
free(nodeX);
|
||||||
}
|
}
|
||||||
|
|
||||||
void GfxRenderer::clearScreen(const uint8_t color) const { einkDisplay.clearScreen(color); }
|
void GfxRenderer::clearScreen(const uint8_t color) const { display.clearScreen(color); }
|
||||||
|
|
||||||
void GfxRenderer::invertScreen() const {
|
void GfxRenderer::invertScreen() const {
|
||||||
uint8_t* buffer = einkDisplay.getFrameBuffer();
|
uint8_t* buffer = display.getFrameBuffer();
|
||||||
if (!buffer) {
|
if (!buffer) {
|
||||||
Serial.printf("[%lu] [GFX] !! No framebuffer in invertScreen\n", millis());
|
Serial.printf("[%lu] [GFX] !! No framebuffer in invertScreen\n", millis());
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
for (int i = 0; i < EInkDisplay::BUFFER_SIZE; i++) {
|
for (int i = 0; i < HalDisplay::BUFFER_SIZE; i++) {
|
||||||
buffer[i] = ~buffer[i];
|
buffer[i] = ~buffer[i];
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
void GfxRenderer::displayBuffer(const EInkDisplay::RefreshMode refreshMode) const {
|
void GfxRenderer::displayBuffer(const HalDisplay::RefreshMode refreshMode) const {
|
||||||
einkDisplay.displayBuffer(refreshMode);
|
display.displayBuffer(refreshMode, fadingFix);
|
||||||
}
|
}
|
||||||
|
|
||||||
std::string GfxRenderer::truncatedText(const int fontId, const char* text, const int maxWidth,
|
std::string GfxRenderer::truncatedText(const int fontId, const char* text, const int maxWidth,
|
||||||
@ -553,13 +552,13 @@ int GfxRenderer::getScreenWidth() const {
|
|||||||
case Portrait:
|
case Portrait:
|
||||||
case PortraitInverted:
|
case PortraitInverted:
|
||||||
// 480px wide in portrait logical coordinates
|
// 480px wide in portrait logical coordinates
|
||||||
return EInkDisplay::DISPLAY_HEIGHT;
|
return HalDisplay::DISPLAY_HEIGHT;
|
||||||
case LandscapeClockwise:
|
case LandscapeClockwise:
|
||||||
case LandscapeCounterClockwise:
|
case LandscapeCounterClockwise:
|
||||||
// 800px wide in landscape logical coordinates
|
// 800px wide in landscape logical coordinates
|
||||||
return EInkDisplay::DISPLAY_WIDTH;
|
return HalDisplay::DISPLAY_WIDTH;
|
||||||
}
|
}
|
||||||
return EInkDisplay::DISPLAY_HEIGHT;
|
return HalDisplay::DISPLAY_HEIGHT;
|
||||||
}
|
}
|
||||||
|
|
||||||
int GfxRenderer::getScreenHeight() const {
|
int GfxRenderer::getScreenHeight() const {
|
||||||
@ -567,13 +566,13 @@ int GfxRenderer::getScreenHeight() const {
|
|||||||
case Portrait:
|
case Portrait:
|
||||||
case PortraitInverted:
|
case PortraitInverted:
|
||||||
// 800px tall in portrait logical coordinates
|
// 800px tall in portrait logical coordinates
|
||||||
return EInkDisplay::DISPLAY_WIDTH;
|
return HalDisplay::DISPLAY_WIDTH;
|
||||||
case LandscapeClockwise:
|
case LandscapeClockwise:
|
||||||
case LandscapeCounterClockwise:
|
case LandscapeCounterClockwise:
|
||||||
// 480px tall in landscape logical coordinates
|
// 480px tall in landscape logical coordinates
|
||||||
return EInkDisplay::DISPLAY_HEIGHT;
|
return HalDisplay::DISPLAY_HEIGHT;
|
||||||
}
|
}
|
||||||
return EInkDisplay::DISPLAY_WIDTH;
|
return HalDisplay::DISPLAY_WIDTH;
|
||||||
}
|
}
|
||||||
|
|
||||||
int GfxRenderer::getSpaceWidth(const int fontId) const {
|
int GfxRenderer::getSpaceWidth(const int fontId) const {
|
||||||
@ -902,17 +901,18 @@ void GfxRenderer::drawTextRotated90CCW(const int fontId, const int x, const int
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
uint8_t* GfxRenderer::getFrameBuffer() const { return einkDisplay.getFrameBuffer(); }
|
uint8_t* GfxRenderer::getFrameBuffer() const { return display.getFrameBuffer(); }
|
||||||
|
|
||||||
size_t GfxRenderer::getBufferSize() { return EInkDisplay::BUFFER_SIZE; }
|
size_t GfxRenderer::getBufferSize() { return HalDisplay::BUFFER_SIZE; }
|
||||||
|
|
||||||
void GfxRenderer::grayscaleRevert() const { einkDisplay.grayscaleRevert(); }
|
// unused
|
||||||
|
// void GfxRenderer::grayscaleRevert() const { display.grayscaleRevert(); }
|
||||||
|
|
||||||
void GfxRenderer::copyGrayscaleLsbBuffers() const { einkDisplay.copyGrayscaleLsbBuffers(einkDisplay.getFrameBuffer()); }
|
void GfxRenderer::copyGrayscaleLsbBuffers() const { display.copyGrayscaleLsbBuffers(display.getFrameBuffer()); }
|
||||||
|
|
||||||
void GfxRenderer::copyGrayscaleMsbBuffers() const { einkDisplay.copyGrayscaleMsbBuffers(einkDisplay.getFrameBuffer()); }
|
void GfxRenderer::copyGrayscaleMsbBuffers() const { display.copyGrayscaleMsbBuffers(display.getFrameBuffer()); }
|
||||||
|
|
||||||
void GfxRenderer::displayGrayBuffer() const { einkDisplay.displayGrayBuffer(); }
|
void GfxRenderer::displayGrayBuffer() const { display.displayGrayBuffer(fadingFix); }
|
||||||
|
|
||||||
void GfxRenderer::freeBwBufferChunks() {
|
void GfxRenderer::freeBwBufferChunks() {
|
||||||
for (auto& bwBufferChunk : bwBufferChunks) {
|
for (auto& bwBufferChunk : bwBufferChunks) {
|
||||||
@ -930,7 +930,7 @@ void GfxRenderer::freeBwBufferChunks() {
|
|||||||
* Returns true if buffer was stored successfully, false if allocation failed.
|
* Returns true if buffer was stored successfully, false if allocation failed.
|
||||||
*/
|
*/
|
||||||
bool GfxRenderer::storeBwBuffer() {
|
bool GfxRenderer::storeBwBuffer() {
|
||||||
const uint8_t* frameBuffer = einkDisplay.getFrameBuffer();
|
const uint8_t* frameBuffer = display.getFrameBuffer();
|
||||||
if (!frameBuffer) {
|
if (!frameBuffer) {
|
||||||
Serial.printf("[%lu] [GFX] !! No framebuffer in storeBwBuffer\n", millis());
|
Serial.printf("[%lu] [GFX] !! No framebuffer in storeBwBuffer\n", millis());
|
||||||
return false;
|
return false;
|
||||||
@ -985,14 +985,14 @@ void GfxRenderer::restoreBwBuffer() {
|
|||||||
// CRITICAL: Even if restore fails, we must clean up the grayscale state
|
// CRITICAL: Even if restore fails, we must clean up the grayscale state
|
||||||
// to prevent grayscaleRevert() from being called with corrupted RAM state
|
// to prevent grayscaleRevert() from being called with corrupted RAM state
|
||||||
// Use the current framebuffer content (which may not be ideal but prevents worse issues)
|
// Use the current framebuffer content (which may not be ideal but prevents worse issues)
|
||||||
uint8_t* frameBuffer = einkDisplay.getFrameBuffer();
|
uint8_t* frameBuffer = display.getFrameBuffer();
|
||||||
if (frameBuffer) {
|
if (frameBuffer) {
|
||||||
einkDisplay.cleanupGrayscaleBuffers(frameBuffer);
|
display.cleanupGrayscaleBuffers(frameBuffer);
|
||||||
}
|
}
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
uint8_t* frameBuffer = einkDisplay.getFrameBuffer();
|
uint8_t* frameBuffer = display.getFrameBuffer();
|
||||||
if (!frameBuffer) {
|
if (!frameBuffer) {
|
||||||
Serial.printf("[%lu] [GFX] !! No framebuffer in restoreBwBuffer\n", millis());
|
Serial.printf("[%lu] [GFX] !! No framebuffer in restoreBwBuffer\n", millis());
|
||||||
freeBwBufferChunks();
|
freeBwBufferChunks();
|
||||||
@ -1005,7 +1005,7 @@ void GfxRenderer::restoreBwBuffer() {
|
|||||||
Serial.printf("[%lu] [GFX] !! BW buffer chunks not stored - this is likely a bug\n", millis());
|
Serial.printf("[%lu] [GFX] !! BW buffer chunks not stored - this is likely a bug\n", millis());
|
||||||
freeBwBufferChunks();
|
freeBwBufferChunks();
|
||||||
// CRITICAL: Clean up grayscale state even on mid-restore failure
|
// CRITICAL: Clean up grayscale state even on mid-restore failure
|
||||||
einkDisplay.cleanupGrayscaleBuffers(frameBuffer);
|
display.cleanupGrayscaleBuffers(frameBuffer);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -1013,7 +1013,7 @@ void GfxRenderer::restoreBwBuffer() {
|
|||||||
memcpy(frameBuffer + offset, bwBufferChunks[i], BW_BUFFER_CHUNK_SIZE);
|
memcpy(frameBuffer + offset, bwBufferChunks[i], BW_BUFFER_CHUNK_SIZE);
|
||||||
}
|
}
|
||||||
|
|
||||||
einkDisplay.cleanupGrayscaleBuffers(frameBuffer);
|
display.cleanupGrayscaleBuffers(frameBuffer);
|
||||||
|
|
||||||
freeBwBufferChunks();
|
freeBwBufferChunks();
|
||||||
Serial.printf("[%lu] [GFX] Restored and freed BW buffer chunks\n", millis());
|
Serial.printf("[%lu] [GFX] Restored and freed BW buffer chunks\n", millis());
|
||||||
@ -1024,9 +1024,9 @@ void GfxRenderer::restoreBwBuffer() {
|
|||||||
* Use this when BW buffer was re-rendered instead of stored/restored.
|
* Use this when BW buffer was re-rendered instead of stored/restored.
|
||||||
*/
|
*/
|
||||||
void GfxRenderer::cleanupGrayscaleWithFrameBuffer() const {
|
void GfxRenderer::cleanupGrayscaleWithFrameBuffer() const {
|
||||||
uint8_t* frameBuffer = einkDisplay.getFrameBuffer();
|
uint8_t* frameBuffer = display.getFrameBuffer();
|
||||||
if (frameBuffer) {
|
if (frameBuffer) {
|
||||||
einkDisplay.cleanupGrayscaleBuffers(frameBuffer);
|
display.cleanupGrayscaleBuffers(frameBuffer);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@ -1,7 +1,7 @@
|
|||||||
#pragma once
|
#pragma once
|
||||||
|
|
||||||
#include <EInkDisplay.h>
|
|
||||||
#include <EpdFontFamily.h>
|
#include <EpdFontFamily.h>
|
||||||
|
#include <HalDisplay.h>
|
||||||
|
|
||||||
#include <map>
|
#include <map>
|
||||||
|
|
||||||
@ -24,8 +24,8 @@ class GfxRenderer {
|
|||||||
|
|
||||||
private:
|
private:
|
||||||
static constexpr size_t BW_BUFFER_CHUNK_SIZE = 8000; // 8KB chunks to allow for non-contiguous memory
|
static constexpr size_t BW_BUFFER_CHUNK_SIZE = 8000; // 8KB chunks to allow for non-contiguous memory
|
||||||
static constexpr size_t BW_BUFFER_NUM_CHUNKS = EInkDisplay::BUFFER_SIZE / BW_BUFFER_CHUNK_SIZE;
|
static constexpr size_t BW_BUFFER_NUM_CHUNKS = HalDisplay::BUFFER_SIZE / BW_BUFFER_CHUNK_SIZE;
|
||||||
static_assert(BW_BUFFER_CHUNK_SIZE * BW_BUFFER_NUM_CHUNKS == EInkDisplay::BUFFER_SIZE,
|
static_assert(BW_BUFFER_CHUNK_SIZE * BW_BUFFER_NUM_CHUNKS == HalDisplay::BUFFER_SIZE,
|
||||||
"BW buffer chunking does not line up with display buffer size");
|
"BW buffer chunking does not line up with display buffer size");
|
||||||
|
|
||||||
// Base viewable margins (hardware-specific, before bezel compensation)
|
// Base viewable margins (hardware-specific, before bezel compensation)
|
||||||
@ -34,9 +34,10 @@ class GfxRenderer {
|
|||||||
static constexpr int BASE_VIEWABLE_MARGIN_BOTTOM = 3;
|
static constexpr int BASE_VIEWABLE_MARGIN_BOTTOM = 3;
|
||||||
static constexpr int BASE_VIEWABLE_MARGIN_LEFT = 3;
|
static constexpr int BASE_VIEWABLE_MARGIN_LEFT = 3;
|
||||||
|
|
||||||
EInkDisplay& einkDisplay;
|
HalDisplay& display;
|
||||||
RenderMode renderMode;
|
RenderMode renderMode;
|
||||||
Orientation orientation;
|
Orientation orientation;
|
||||||
|
bool fadingFix = false; // Sunlight fading fix - turn off screen after refresh
|
||||||
int bezelCompensation = 0; // Pixels to add for bezel defect compensation
|
int bezelCompensation = 0; // Pixels to add for bezel defect compensation
|
||||||
int bezelEdge = 0; // Which physical edge (0=bottom, 1=top, 2=left, 3=right in portrait)
|
int bezelEdge = 0; // Which physical edge (0=bottom, 1=top, 2=left, 3=right in portrait)
|
||||||
uint8_t* bwBufferChunks[BW_BUFFER_NUM_CHUNKS] = {nullptr};
|
uint8_t* bwBufferChunks[BW_BUFFER_NUM_CHUNKS] = {nullptr};
|
||||||
@ -47,7 +48,7 @@ class GfxRenderer {
|
|||||||
void rotateCoordinates(int x, int y, int* rotatedX, int* rotatedY) const;
|
void rotateCoordinates(int x, int y, int* rotatedX, int* rotatedY) const;
|
||||||
|
|
||||||
public:
|
public:
|
||||||
explicit GfxRenderer(EInkDisplay& einkDisplay) : einkDisplay(einkDisplay), renderMode(BW), orientation(Portrait) {}
|
explicit GfxRenderer(HalDisplay& halDisplay) : display(halDisplay), renderMode(BW), orientation(Portrait) {}
|
||||||
~GfxRenderer() { freeBwBufferChunks(); }
|
~GfxRenderer() { freeBwBufferChunks(); }
|
||||||
|
|
||||||
// Viewable margins (includes bezel compensation applied to the configured edge)
|
// Viewable margins (includes bezel compensation applied to the configured edge)
|
||||||
@ -76,10 +77,13 @@ class GfxRenderer {
|
|||||||
void setOrientation(const Orientation o) { orientation = o; }
|
void setOrientation(const Orientation o) { orientation = o; }
|
||||||
Orientation getOrientation() const { return orientation; }
|
Orientation getOrientation() const { return orientation; }
|
||||||
|
|
||||||
|
// Fading fix control
|
||||||
|
void setFadingFix(const bool enabled) { fadingFix = enabled; }
|
||||||
|
|
||||||
// Screen ops
|
// Screen ops
|
||||||
int getScreenWidth() const;
|
int getScreenWidth() const;
|
||||||
int getScreenHeight() const;
|
int getScreenHeight() const;
|
||||||
void displayBuffer(EInkDisplay::RefreshMode refreshMode = EInkDisplay::FAST_REFRESH) const;
|
void displayBuffer(HalDisplay::RefreshMode refreshMode = HalDisplay::FAST_REFRESH) const;
|
||||||
// EXPERIMENTAL: Windowed update - display only a rectangular region
|
// EXPERIMENTAL: Windowed update - display only a rectangular region
|
||||||
void displayWindow(int x, int y, int width, int height) const;
|
void displayWindow(int x, int y, int width, int height) const;
|
||||||
void invertScreen() const;
|
void invertScreen() const;
|
||||||
|
|||||||
@ -299,8 +299,7 @@ bool StarDict::decompressDefinition(uint32_t offset, uint32_t size, std::string&
|
|||||||
const uint32_t endChunk = (offset + size - 1) / dzInfo.chunkLength;
|
const uint32_t endChunk = (offset + size - 1) / dzInfo.chunkLength;
|
||||||
const uint32_t startOffsetInChunk = offset % dzInfo.chunkLength;
|
const uint32_t startOffsetInChunk = offset % dzInfo.chunkLength;
|
||||||
|
|
||||||
Serial.printf("[DICT-DBG] Chunks: start=%lu, end=%lu, total=%u\n",
|
Serial.printf("[DICT-DBG] Chunks: start=%lu, end=%lu, total=%u\n", startChunk, endChunk, dzInfo.chunkCount);
|
||||||
startChunk, endChunk, dzInfo.chunkCount);
|
|
||||||
|
|
||||||
if (endChunk >= dzInfo.chunkCount) {
|
if (endChunk >= dzInfo.chunkCount) {
|
||||||
Serial.printf("[DICT-DBG] endChunk %lu >= chunkCount %u\n", endChunk, dzInfo.chunkCount);
|
Serial.printf("[DICT-DBG] endChunk %lu >= chunkCount %u\n", endChunk, dzInfo.chunkCount);
|
||||||
@ -324,16 +323,16 @@ bool StarDict::decompressDefinition(uint32_t offset, uint32_t size, std::string&
|
|||||||
|
|
||||||
// Allocate buffers - allocate inflator FIRST (smallest) to reduce fragmentation impact
|
// Allocate buffers - allocate inflator FIRST (smallest) to reduce fragmentation impact
|
||||||
// tinfl_decompressor is ~11KB, so total allocations are ~85KB
|
// tinfl_decompressor is ~11KB, so total allocations are ~85KB
|
||||||
Serial.printf("[DICT-DBG] Allocating inflator=%u, comp=%lu, decomp=%u bytes\n",
|
Serial.printf("[DICT-DBG] Allocating inflator=%u, comp=%lu, decomp=%u bytes\n", sizeof(tinfl_decompressor),
|
||||||
sizeof(tinfl_decompressor), maxCompressedSize, dzInfo.chunkLength);
|
maxCompressedSize, dzInfo.chunkLength);
|
||||||
|
|
||||||
auto* inflator = static_cast<tinfl_decompressor*>(malloc(sizeof(tinfl_decompressor)));
|
auto* inflator = static_cast<tinfl_decompressor*>(malloc(sizeof(tinfl_decompressor)));
|
||||||
if (!inflator) {
|
if (!inflator) {
|
||||||
Serial.printf("[DICT-DBG] inflator alloc failed! (need %u bytes)\n", sizeof(tinfl_decompressor));
|
Serial.printf("[DICT-DBG] inflator alloc failed! (need %u bytes)\n", sizeof(tinfl_decompressor));
|
||||||
file.close();
|
file.close();
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
auto* compressedBuf = static_cast<uint8_t*>(malloc(maxCompressedSize));
|
auto* compressedBuf = static_cast<uint8_t*>(malloc(maxCompressedSize));
|
||||||
if (!compressedBuf) {
|
if (!compressedBuf) {
|
||||||
Serial.printf("[DICT-DBG] compressedBuf alloc failed!\n");
|
Serial.printf("[DICT-DBG] compressedBuf alloc failed!\n");
|
||||||
@ -469,8 +468,7 @@ StarDict::LookupResult StarDict::lookup(const std::string& word) {
|
|||||||
return result;
|
return result;
|
||||||
}
|
}
|
||||||
|
|
||||||
Serial.printf("[DICT-DBG] Searching for: '%s' (normalized: '%s')\n",
|
Serial.printf("[DICT-DBG] Searching for: '%s' (normalized: '%s')\n", word.c_str(), normalizedSearch.c_str());
|
||||||
word.c_str(), normalizedSearch.c_str());
|
|
||||||
|
|
||||||
// First try .idx (main entries) - use prefix jump table for fast lookup
|
// First try .idx (main entries) - use prefix jump table for fast lookup
|
||||||
const std::string idxPath = basePath + ".idx";
|
const std::string idxPath = basePath + ".idx";
|
||||||
@ -487,8 +485,8 @@ StarDict::LookupResult StarDict::lookup(const std::string& word) {
|
|||||||
const uint16_t prefixIdx = DictPrefixIndex::prefixToIndex(normalizedSearch[0], normalizedSearch[1]);
|
const uint16_t prefixIdx = DictPrefixIndex::prefixToIndex(normalizedSearch[0], normalizedSearch[1]);
|
||||||
position = DictPrefixIndex::dictPrefixOffsets[prefixIdx];
|
position = DictPrefixIndex::dictPrefixOffsets[prefixIdx];
|
||||||
}
|
}
|
||||||
Serial.printf("[DICT-DBG] Starting at position %lu (prefix: %c%c)\n",
|
Serial.printf("[DICT-DBG] Starting at position %lu (prefix: %c%c)\n", position, normalizedSearch[0],
|
||||||
position, normalizedSearch[0], normalizedSearch[1]);
|
normalizedSearch[1]);
|
||||||
bool found = false;
|
bool found = false;
|
||||||
uint32_t wordCount = 0;
|
uint32_t wordCount = 0;
|
||||||
|
|
||||||
@ -501,20 +499,19 @@ StarDict::LookupResult StarDict::lookup(const std::string& word) {
|
|||||||
}
|
}
|
||||||
wordCount++;
|
wordCount++;
|
||||||
if (wordCount % 50000 == 0) {
|
if (wordCount % 50000 == 0) {
|
||||||
Serial.printf("[DICT-DBG] Progress: %lu words scanned, pos=%lu, current='%s'\n",
|
Serial.printf("[DICT-DBG] Progress: %lu words scanned, pos=%lu, current='%s'\n", wordCount, position,
|
||||||
wordCount, position, currentWord.c_str());
|
currentWord.c_str());
|
||||||
}
|
}
|
||||||
|
|
||||||
// Use stardictStrcmp for case-insensitive matching
|
// Use stardictStrcmp for case-insensitive matching
|
||||||
const int cmp = stardictStrcmp(normalizedSearch, currentWord);
|
const int cmp = stardictStrcmp(normalizedSearch, currentWord);
|
||||||
|
|
||||||
if (cmp == 0) {
|
if (cmp == 0) {
|
||||||
Serial.printf("[DICT-DBG] MATCH: '%s' == '%s' (offset=%lu, size=%lu)\n",
|
Serial.printf("[DICT-DBG] MATCH: '%s' == '%s' (offset=%lu, size=%lu)\n", normalizedSearch.c_str(),
|
||||||
normalizedSearch.c_str(), currentWord.c_str(), dictOffset, dictSize);
|
currentWord.c_str(), dictOffset, dictSize);
|
||||||
std::string definition;
|
std::string definition;
|
||||||
const bool loaded = useUncompressed
|
const bool loaded = useUncompressed ? readDefinitionDirect(dictOffset, dictSize, definition)
|
||||||
? readDefinitionDirect(dictOffset, dictSize, definition)
|
: decompressDefinition(dictOffset, dictSize, definition);
|
||||||
: decompressDefinition(dictOffset, dictSize, definition);
|
|
||||||
if (loaded) {
|
if (loaded) {
|
||||||
Serial.printf("[DICT-DBG] Definition loaded, %u bytes\n", definition.length());
|
Serial.printf("[DICT-DBG] Definition loaded, %u bytes\n", definition.length());
|
||||||
if (!found) {
|
if (!found) {
|
||||||
@ -537,8 +534,7 @@ StarDict::LookupResult StarDict::lookup(const std::string& word) {
|
|||||||
// may not land exactly at target position
|
// may not land exactly at target position
|
||||||
}
|
}
|
||||||
|
|
||||||
Serial.printf("[DICT-DBG] Search complete: %lu words scanned, found=%s\n",
|
Serial.printf("[DICT-DBG] Search complete: %lu words scanned, found=%s\n", wordCount, found ? "YES" : "NO");
|
||||||
wordCount, found ? "YES" : "NO");
|
|
||||||
idxFile.close();
|
idxFile.close();
|
||||||
|
|
||||||
// If not found in main index, try synonym file with prefix jump
|
// If not found in main index, try synonym file with prefix jump
|
||||||
@ -591,9 +587,8 @@ StarDict::LookupResult StarDict::lookup(const std::string& word) {
|
|||||||
uint32_t dictOffset, dictSize;
|
uint32_t dictOffset, dictSize;
|
||||||
if (readWordAtPosition(idxFile2, pos, mainWord, dictOffset, dictSize)) {
|
if (readWordAtPosition(idxFile2, pos, mainWord, dictOffset, dictSize)) {
|
||||||
std::string definition;
|
std::string definition;
|
||||||
const bool loaded = useUncompressed
|
const bool loaded = useUncompressed ? readDefinitionDirect(dictOffset, dictSize, definition)
|
||||||
? readDefinitionDirect(dictOffset, dictSize, definition)
|
: decompressDefinition(dictOffset, dictSize, definition);
|
||||||
: decompressDefinition(dictOffset, dictSize, definition);
|
|
||||||
if (loaded) {
|
if (loaded) {
|
||||||
result.word = synWord;
|
result.word = synWord;
|
||||||
result.definition = definition;
|
result.definition = definition;
|
||||||
|
|||||||
@ -529,10 +529,24 @@ bool ZipFile::readFileToStream(const char* filename, Print& out, const size_t ch
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (fileStat.method == MZ_DEFLATED) {
|
if (fileStat.method == MZ_DEFLATED) {
|
||||||
// Setup inflator
|
// Allocate largest buffer first to maximize chance of finding contiguous block
|
||||||
|
// Dictionary buffer (32KB) - needed for DEFLATE sliding window
|
||||||
|
const auto outputBuffer = static_cast<uint8_t*>(malloc(TINFL_LZ_DICT_SIZE));
|
||||||
|
if (!outputBuffer) {
|
||||||
|
Serial.printf("[%lu] [ZIP] Failed to allocate memory for dictionary (need %d bytes)\n", millis(),
|
||||||
|
TINFL_LZ_DICT_SIZE);
|
||||||
|
if (!wasOpen) {
|
||||||
|
close();
|
||||||
|
}
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
memset(outputBuffer, 0, TINFL_LZ_DICT_SIZE);
|
||||||
|
|
||||||
|
// Setup inflator (~11KB)
|
||||||
const auto inflator = static_cast<tinfl_decompressor*>(malloc(sizeof(tinfl_decompressor)));
|
const auto inflator = static_cast<tinfl_decompressor*>(malloc(sizeof(tinfl_decompressor)));
|
||||||
if (!inflator) {
|
if (!inflator) {
|
||||||
Serial.printf("[%lu] [ZIP] Failed to allocate memory for inflator\n", millis());
|
Serial.printf("[%lu] [ZIP] Failed to allocate memory for inflator\n", millis());
|
||||||
|
free(outputBuffer);
|
||||||
if (!wasOpen) {
|
if (!wasOpen) {
|
||||||
close();
|
close();
|
||||||
}
|
}
|
||||||
@ -541,29 +555,18 @@ bool ZipFile::readFileToStream(const char* filename, Print& out, const size_t ch
|
|||||||
memset(inflator, 0, sizeof(tinfl_decompressor));
|
memset(inflator, 0, sizeof(tinfl_decompressor));
|
||||||
tinfl_init(inflator);
|
tinfl_init(inflator);
|
||||||
|
|
||||||
// Setup file read buffer
|
// Setup file read buffer (smallest allocation last)
|
||||||
const auto fileReadBuffer = static_cast<uint8_t*>(malloc(chunkSize));
|
const auto fileReadBuffer = static_cast<uint8_t*>(malloc(chunkSize));
|
||||||
if (!fileReadBuffer) {
|
if (!fileReadBuffer) {
|
||||||
Serial.printf("[%lu] [ZIP] Failed to allocate memory for zip file read buffer\n", millis());
|
Serial.printf("[%lu] [ZIP] Failed to allocate memory for zip file read buffer\n", millis());
|
||||||
free(inflator);
|
free(inflator);
|
||||||
|
free(outputBuffer);
|
||||||
if (!wasOpen) {
|
if (!wasOpen) {
|
||||||
close();
|
close();
|
||||||
}
|
}
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
const auto outputBuffer = static_cast<uint8_t*>(malloc(TINFL_LZ_DICT_SIZE));
|
|
||||||
if (!outputBuffer) {
|
|
||||||
Serial.printf("[%lu] [ZIP] Failed to allocate memory for dictionary\n", millis());
|
|
||||||
free(inflator);
|
|
||||||
free(fileReadBuffer);
|
|
||||||
if (!wasOpen) {
|
|
||||||
close();
|
|
||||||
}
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
memset(outputBuffer, 0, TINFL_LZ_DICT_SIZE);
|
|
||||||
|
|
||||||
size_t fileRemainingBytes = deflatedDataSize;
|
size_t fileRemainingBytes = deflatedDataSize;
|
||||||
size_t processedOutputBytes = 0;
|
size_t processedOutputBytes = 0;
|
||||||
size_t fileReadBufferFilledBytes = 0;
|
size_t fileReadBufferFilledBytes = 0;
|
||||||
|
|||||||
53
lib/hal/HalDisplay.cpp
Normal file
53
lib/hal/HalDisplay.cpp
Normal file
@ -0,0 +1,53 @@
|
|||||||
|
#include <HalDisplay.h>
|
||||||
|
#include <HalGPIO.h>
|
||||||
|
|
||||||
|
#define SD_SPI_MISO 7
|
||||||
|
|
||||||
|
HalDisplay::HalDisplay() : einkDisplay(EPD_SCLK, EPD_MOSI, EPD_CS, EPD_DC, EPD_RST, EPD_BUSY) {}
|
||||||
|
|
||||||
|
HalDisplay::~HalDisplay() {}
|
||||||
|
|
||||||
|
void HalDisplay::begin() { einkDisplay.begin(); }
|
||||||
|
|
||||||
|
void HalDisplay::clearScreen(uint8_t color) const { einkDisplay.clearScreen(color); }
|
||||||
|
|
||||||
|
void HalDisplay::drawImage(const uint8_t* imageData, uint16_t x, uint16_t y, uint16_t w, uint16_t h,
|
||||||
|
bool fromProgmem) const {
|
||||||
|
einkDisplay.drawImage(imageData, x, y, w, h, fromProgmem);
|
||||||
|
}
|
||||||
|
|
||||||
|
EInkDisplay::RefreshMode convertRefreshMode(HalDisplay::RefreshMode mode) {
|
||||||
|
switch (mode) {
|
||||||
|
case HalDisplay::FULL_REFRESH:
|
||||||
|
return EInkDisplay::FULL_REFRESH;
|
||||||
|
case HalDisplay::HALF_REFRESH:
|
||||||
|
return EInkDisplay::HALF_REFRESH;
|
||||||
|
case HalDisplay::FAST_REFRESH:
|
||||||
|
default:
|
||||||
|
return EInkDisplay::FAST_REFRESH;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
void HalDisplay::displayBuffer(HalDisplay::RefreshMode mode, bool turnOffScreen) {
|
||||||
|
einkDisplay.displayBuffer(convertRefreshMode(mode), turnOffScreen);
|
||||||
|
}
|
||||||
|
|
||||||
|
void HalDisplay::refreshDisplay(HalDisplay::RefreshMode mode, bool turnOffScreen) {
|
||||||
|
einkDisplay.refreshDisplay(convertRefreshMode(mode), turnOffScreen);
|
||||||
|
}
|
||||||
|
|
||||||
|
void HalDisplay::deepSleep() { einkDisplay.deepSleep(); }
|
||||||
|
|
||||||
|
uint8_t* HalDisplay::getFrameBuffer() const { return einkDisplay.getFrameBuffer(); }
|
||||||
|
|
||||||
|
void HalDisplay::copyGrayscaleBuffers(const uint8_t* lsbBuffer, const uint8_t* msbBuffer) {
|
||||||
|
einkDisplay.copyGrayscaleBuffers(lsbBuffer, msbBuffer);
|
||||||
|
}
|
||||||
|
|
||||||
|
void HalDisplay::copyGrayscaleLsbBuffers(const uint8_t* lsbBuffer) { einkDisplay.copyGrayscaleLsbBuffers(lsbBuffer); }
|
||||||
|
|
||||||
|
void HalDisplay::copyGrayscaleMsbBuffers(const uint8_t* msbBuffer) { einkDisplay.copyGrayscaleMsbBuffers(msbBuffer); }
|
||||||
|
|
||||||
|
void HalDisplay::cleanupGrayscaleBuffers(const uint8_t* bwBuffer) { einkDisplay.cleanupGrayscaleBuffers(bwBuffer); }
|
||||||
|
|
||||||
|
void HalDisplay::displayGrayBuffer(bool turnOffScreen) { einkDisplay.displayGrayBuffer(turnOffScreen); }
|
||||||
52
lib/hal/HalDisplay.h
Normal file
52
lib/hal/HalDisplay.h
Normal file
@ -0,0 +1,52 @@
|
|||||||
|
#pragma once
|
||||||
|
#include <Arduino.h>
|
||||||
|
#include <EInkDisplay.h>
|
||||||
|
|
||||||
|
class HalDisplay {
|
||||||
|
public:
|
||||||
|
// Constructor with pin configuration
|
||||||
|
HalDisplay();
|
||||||
|
|
||||||
|
// Destructor
|
||||||
|
~HalDisplay();
|
||||||
|
|
||||||
|
// Refresh modes
|
||||||
|
enum RefreshMode {
|
||||||
|
FULL_REFRESH, // Full refresh with complete waveform
|
||||||
|
HALF_REFRESH, // Half refresh (1720ms) - balanced quality and speed
|
||||||
|
FAST_REFRESH // Fast refresh using custom LUT
|
||||||
|
};
|
||||||
|
|
||||||
|
// Initialize the display hardware and driver
|
||||||
|
void begin();
|
||||||
|
|
||||||
|
// Display dimensions
|
||||||
|
static constexpr uint16_t DISPLAY_WIDTH = EInkDisplay::DISPLAY_WIDTH;
|
||||||
|
static constexpr uint16_t DISPLAY_HEIGHT = EInkDisplay::DISPLAY_HEIGHT;
|
||||||
|
static constexpr uint16_t DISPLAY_WIDTH_BYTES = DISPLAY_WIDTH / 8;
|
||||||
|
static constexpr uint32_t BUFFER_SIZE = DISPLAY_WIDTH_BYTES * DISPLAY_HEIGHT;
|
||||||
|
|
||||||
|
// Frame buffer operations
|
||||||
|
void clearScreen(uint8_t color = 0xFF) const;
|
||||||
|
void drawImage(const uint8_t* imageData, uint16_t x, uint16_t y, uint16_t w, uint16_t h,
|
||||||
|
bool fromProgmem = false) const;
|
||||||
|
|
||||||
|
void displayBuffer(RefreshMode mode = RefreshMode::FAST_REFRESH, bool turnOffScreen = false);
|
||||||
|
void refreshDisplay(RefreshMode mode = RefreshMode::FAST_REFRESH, bool turnOffScreen = false);
|
||||||
|
|
||||||
|
// Power management
|
||||||
|
void deepSleep();
|
||||||
|
|
||||||
|
// Access to frame buffer
|
||||||
|
uint8_t* getFrameBuffer() const;
|
||||||
|
|
||||||
|
void copyGrayscaleBuffers(const uint8_t* lsbBuffer, const uint8_t* msbBuffer);
|
||||||
|
void copyGrayscaleLsbBuffers(const uint8_t* lsbBuffer);
|
||||||
|
void copyGrayscaleMsbBuffers(const uint8_t* msbBuffer);
|
||||||
|
void cleanupGrayscaleBuffers(const uint8_t* bwBuffer);
|
||||||
|
|
||||||
|
void displayGrayBuffer(bool turnOffScreen = false);
|
||||||
|
|
||||||
|
private:
|
||||||
|
EInkDisplay einkDisplay;
|
||||||
|
};
|
||||||
55
lib/hal/HalGPIO.cpp
Normal file
55
lib/hal/HalGPIO.cpp
Normal file
@ -0,0 +1,55 @@
|
|||||||
|
#include <HalGPIO.h>
|
||||||
|
#include <SPI.h>
|
||||||
|
#include <esp_sleep.h>
|
||||||
|
|
||||||
|
void HalGPIO::begin() {
|
||||||
|
inputMgr.begin();
|
||||||
|
SPI.begin(EPD_SCLK, SPI_MISO, EPD_MOSI, EPD_CS);
|
||||||
|
pinMode(BAT_GPIO0, INPUT);
|
||||||
|
pinMode(UART0_RXD, INPUT);
|
||||||
|
}
|
||||||
|
|
||||||
|
void HalGPIO::update() { inputMgr.update(); }
|
||||||
|
|
||||||
|
bool HalGPIO::isPressed(uint8_t buttonIndex) const { return inputMgr.isPressed(buttonIndex); }
|
||||||
|
|
||||||
|
bool HalGPIO::wasPressed(uint8_t buttonIndex) const { return inputMgr.wasPressed(buttonIndex); }
|
||||||
|
|
||||||
|
bool HalGPIO::wasAnyPressed() const { return inputMgr.wasAnyPressed(); }
|
||||||
|
|
||||||
|
bool HalGPIO::wasReleased(uint8_t buttonIndex) const { return inputMgr.wasReleased(buttonIndex); }
|
||||||
|
|
||||||
|
bool HalGPIO::wasAnyReleased() const { return inputMgr.wasAnyReleased(); }
|
||||||
|
|
||||||
|
unsigned long HalGPIO::getHeldTime() const { return inputMgr.getHeldTime(); }
|
||||||
|
|
||||||
|
void HalGPIO::startDeepSleep() {
|
||||||
|
esp_deep_sleep_enable_gpio_wakeup(1ULL << InputManager::POWER_BUTTON_PIN, ESP_GPIO_WAKEUP_GPIO_LOW);
|
||||||
|
// 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();
|
||||||
|
}
|
||||||
|
// 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 {
|
||||||
|
// U0RXD/GPIO20 reads HIGH when USB is connected
|
||||||
|
return digitalRead(UART0_RXD) == HIGH;
|
||||||
|
}
|
||||||
|
|
||||||
|
bool HalGPIO::isWakeupByPowerButton() const {
|
||||||
|
const auto wakeupCause = esp_sleep_get_wakeup_cause();
|
||||||
|
const auto resetReason = esp_reset_reason();
|
||||||
|
if (isUsbConnected()) {
|
||||||
|
return wakeupCause == ESP_SLEEP_WAKEUP_GPIO;
|
||||||
|
} else {
|
||||||
|
return (wakeupCause == ESP_SLEEP_WAKEUP_UNDEFINED) && (resetReason == ESP_RST_POWERON);
|
||||||
|
}
|
||||||
|
}
|
||||||
61
lib/hal/HalGPIO.h
Normal file
61
lib/hal/HalGPIO.h
Normal file
@ -0,0 +1,61 @@
|
|||||||
|
#pragma once
|
||||||
|
|
||||||
|
#include <Arduino.h>
|
||||||
|
#include <BatteryMonitor.h>
|
||||||
|
#include <InputManager.h>
|
||||||
|
|
||||||
|
// Display SPI pins (custom pins for XteinkX4, not hardware SPI defaults)
|
||||||
|
#define EPD_SCLK 8 // SPI Clock
|
||||||
|
#define EPD_MOSI 10 // SPI MOSI (Master Out Slave In)
|
||||||
|
#define EPD_CS 21 // Chip Select
|
||||||
|
#define EPD_DC 4 // Data/Command
|
||||||
|
#define EPD_RST 5 // Reset
|
||||||
|
#define EPD_BUSY 6 // Busy
|
||||||
|
|
||||||
|
#define SPI_MISO 7 // SPI MISO, shared between SD card and display (Master In Slave Out)
|
||||||
|
|
||||||
|
#define BAT_GPIO0 0 // Battery voltage
|
||||||
|
|
||||||
|
#define UART0_RXD 20 // Used for USB connection detection
|
||||||
|
|
||||||
|
class HalGPIO {
|
||||||
|
#if CROSSPOINT_EMULATED == 0
|
||||||
|
InputManager inputMgr;
|
||||||
|
#endif
|
||||||
|
|
||||||
|
public:
|
||||||
|
HalGPIO() = default;
|
||||||
|
|
||||||
|
// Start button GPIO and setup SPI for screen and SD card
|
||||||
|
void begin();
|
||||||
|
|
||||||
|
// Button input methods
|
||||||
|
void update();
|
||||||
|
bool isPressed(uint8_t buttonIndex) const;
|
||||||
|
bool wasPressed(uint8_t buttonIndex) const;
|
||||||
|
bool wasAnyPressed() const;
|
||||||
|
bool wasReleased(uint8_t buttonIndex) const;
|
||||||
|
bool wasAnyReleased() 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
|
||||||
|
bool isUsbConnected() const;
|
||||||
|
|
||||||
|
// Check if wakeup was caused by power button press
|
||||||
|
bool isWakeupByPowerButton() const;
|
||||||
|
|
||||||
|
// Button indices
|
||||||
|
static constexpr uint8_t BTN_BACK = 0;
|
||||||
|
static constexpr uint8_t BTN_CONFIRM = 1;
|
||||||
|
static constexpr uint8_t BTN_LEFT = 2;
|
||||||
|
static constexpr uint8_t BTN_RIGHT = 3;
|
||||||
|
static constexpr uint8_t BTN_UP = 4;
|
||||||
|
static constexpr uint8_t BTN_DOWN = 5;
|
||||||
|
static constexpr uint8_t BTN_POWER = 6;
|
||||||
|
};
|
||||||
@ -1 +1 @@
|
|||||||
Subproject commit dede09001c4c7bc96bd3616716cdf80913f57658
|
Subproject commit be6ba1b62b1262929cded6ccdae774a098d33010
|
||||||
@ -3,7 +3,7 @@ default_envs = default
|
|||||||
|
|
||||||
[crosspoint]
|
[crosspoint]
|
||||||
# 0.15.0 CrossPoint base, ef-1.0.0 is the first release of the ef branch
|
# 0.15.0 CrossPoint base, ef-1.0.0 is the first release of the ef branch
|
||||||
version = 0.15.ef-1.0.3
|
version = 0.15.ef-1.0.5
|
||||||
|
|
||||||
[base]
|
[base]
|
||||||
platform = espressif32 @ 6.12.0
|
platform = espressif32 @ 6.12.0
|
||||||
|
|||||||
@ -5,7 +5,11 @@ This allows the firmware to display "Flashing firmware..." on the e-ink display
|
|||||||
before the actual flash begins. The e-ink retains this message throughout the
|
before the actual flash begins. The e-ink retains this message throughout the
|
||||||
flash process since it doesn't require power to maintain the display.
|
flash process since it doesn't require power to maintain the display.
|
||||||
|
|
||||||
Protocol: Sends "FLASH:version\n" where version is read from platformio.ini
|
Protocol (Plan A - Simple timing):
|
||||||
|
1. Host opens serial port and sends "FLASH:version"
|
||||||
|
2. Host keeps port open briefly for device to receive and process
|
||||||
|
3. Device displays flash screen when it receives the command
|
||||||
|
4. Host proceeds with flash
|
||||||
"""
|
"""
|
||||||
|
|
||||||
Import("env")
|
Import("env")
|
||||||
@ -15,7 +19,7 @@ from version_utils import get_version
|
|||||||
|
|
||||||
|
|
||||||
def before_upload(source, target, env):
|
def before_upload(source, target, env):
|
||||||
"""Send FLASH command with version to device before upload begins."""
|
"""Send FLASH command to device before uploading firmware."""
|
||||||
port = env.GetProjectOption("upload_port", None)
|
port = env.GetProjectOption("upload_port", None)
|
||||||
|
|
||||||
if not port:
|
if not port:
|
||||||
@ -29,19 +33,20 @@ def before_upload(source, target, env):
|
|||||||
]
|
]
|
||||||
port = ports[0] if ports else None
|
port = ports[0] if ports else None
|
||||||
|
|
||||||
if port:
|
if not port:
|
||||||
try:
|
|
||||||
version = get_version(env)
|
|
||||||
ser = serial.Serial(port, 115200, timeout=1)
|
|
||||||
ser.write(f"FLASH:{version}\n".encode())
|
|
||||||
ser.flush()
|
|
||||||
ser.close()
|
|
||||||
time.sleep(0.8) # Wait for e-ink fast refresh (~500ms) plus margin
|
|
||||||
print(f"[pre_flash] Flash notification sent to {port} (version {version})")
|
|
||||||
except Exception as e:
|
|
||||||
print(f"[pre_flash] Notification skipped: {e}")
|
|
||||||
else:
|
|
||||||
print("[pre_flash] No serial port found, skipping notification")
|
print("[pre_flash] No serial port found, skipping notification")
|
||||||
|
return
|
||||||
|
|
||||||
|
try:
|
||||||
|
version = get_version(env)
|
||||||
|
ser = serial.Serial(port, 115200, timeout=1)
|
||||||
|
ser.write(f"FLASH:{version}\n".encode())
|
||||||
|
ser.flush()
|
||||||
|
time.sleep(4.0) # Keep port open for device to receive and complete full refresh (~2-3s)
|
||||||
|
ser.close()
|
||||||
|
print(f"[pre_flash] Flash notification sent to {port} (version {version})")
|
||||||
|
except Exception as e:
|
||||||
|
print(f"[pre_flash] Notification skipped: {e}")
|
||||||
|
|
||||||
|
|
||||||
env.AddPreAction("upload", before_upload)
|
env.AddPreAction("upload", before_upload)
|
||||||
|
|||||||
@ -303,6 +303,86 @@ bool BookManager::deleteBook(const std::string& bookPath, bool isArchived) {
|
|||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
bool BookManager::clearBookCache(const std::string& bookPath, bool preserveProgress) {
|
||||||
|
Serial.printf("[%lu] [%s] Clearing cache for: %s (preserveProgress=%d)\n", millis(), LOG_TAG, bookPath.c_str(),
|
||||||
|
preserveProgress);
|
||||||
|
|
||||||
|
const std::string cacheDir = getCacheDir(bookPath);
|
||||||
|
if (cacheDir.empty()) {
|
||||||
|
Serial.printf("[%lu] [%s] No cache directory for unsupported format\n", millis(), LOG_TAG);
|
||||||
|
return true; // Nothing to clear, not an error
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!SdMan.exists(cacheDir.c_str())) {
|
||||||
|
Serial.printf("[%lu] [%s] Cache directory doesn't exist: %s\n", millis(), LOG_TAG, cacheDir.c_str());
|
||||||
|
return true; // Nothing to clear, not an error
|
||||||
|
}
|
||||||
|
|
||||||
|
FsFile dir = SdMan.open(cacheDir.c_str());
|
||||||
|
if (!dir || !dir.isDirectory()) {
|
||||||
|
Serial.printf("[%lu] [%s] Failed to open cache directory\n", millis(), LOG_TAG);
|
||||||
|
if (dir) dir.close();
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Files to preserve (always keep bookmarks, optionally keep progress)
|
||||||
|
const auto shouldPreserve = [preserveProgress](const char* name) {
|
||||||
|
// Always preserve bookmarks
|
||||||
|
if (strcmp(name, "bookmarks.bin") == 0) return true;
|
||||||
|
// Optionally preserve progress
|
||||||
|
if (preserveProgress && strcmp(name, "progress.bin") == 0) return true;
|
||||||
|
return false;
|
||||||
|
};
|
||||||
|
|
||||||
|
int deletedCount = 0;
|
||||||
|
int failedCount = 0;
|
||||||
|
char name[128];
|
||||||
|
|
||||||
|
// First pass: delete files (not directories)
|
||||||
|
for (FsFile entry = dir.openNextFile(); entry; entry = dir.openNextFile()) {
|
||||||
|
entry.getName(name, sizeof(name));
|
||||||
|
const bool isDir = entry.isDirectory();
|
||||||
|
entry.close();
|
||||||
|
|
||||||
|
if (!isDir && !shouldPreserve(name)) {
|
||||||
|
std::string fullPath = cacheDir + "/" + name;
|
||||||
|
if (SdMan.remove(fullPath.c_str())) {
|
||||||
|
deletedCount++;
|
||||||
|
} else {
|
||||||
|
Serial.printf("[%lu] [%s] Failed to delete: %s\n", millis(), LOG_TAG, fullPath.c_str());
|
||||||
|
failedCount++;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
dir.close();
|
||||||
|
|
||||||
|
// Second pass: delete subdirectories (like "sections/")
|
||||||
|
dir = SdMan.open(cacheDir.c_str());
|
||||||
|
if (dir && dir.isDirectory()) {
|
||||||
|
for (FsFile entry = dir.openNextFile(); entry; entry = dir.openNextFile()) {
|
||||||
|
entry.getName(name, sizeof(name));
|
||||||
|
const bool isDir = entry.isDirectory();
|
||||||
|
entry.close();
|
||||||
|
|
||||||
|
if (isDir) {
|
||||||
|
std::string fullPath = cacheDir + "/" + name;
|
||||||
|
if (SdMan.removeDir(fullPath.c_str())) {
|
||||||
|
deletedCount++;
|
||||||
|
Serial.printf("[%lu] [%s] Deleted subdirectory: %s\n", millis(), LOG_TAG, fullPath.c_str());
|
||||||
|
} else {
|
||||||
|
Serial.printf("[%lu] [%s] Failed to delete subdirectory: %s\n", millis(), LOG_TAG, fullPath.c_str());
|
||||||
|
failedCount++;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
dir.close();
|
||||||
|
}
|
||||||
|
|
||||||
|
Serial.printf("[%lu] [%s] Cache cleared: %d items deleted, %d failed\n", millis(), LOG_TAG, deletedCount,
|
||||||
|
failedCount);
|
||||||
|
return failedCount == 0;
|
||||||
|
}
|
||||||
|
|
||||||
std::vector<std::string> BookManager::listArchivedBooks() {
|
std::vector<std::string> BookManager::listArchivedBooks() {
|
||||||
std::vector<std::string> archivedBooks;
|
std::vector<std::string> archivedBooks;
|
||||||
|
|
||||||
|
|||||||
@ -57,6 +57,14 @@ class BookManager {
|
|||||||
*/
|
*/
|
||||||
static std::string getCacheDir(const std::string& bookPath);
|
static std::string getCacheDir(const std::string& bookPath);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Clear cache for a single book, optionally preserving reading progress
|
||||||
|
* @param bookPath Full path to the book file
|
||||||
|
* @param preserveProgress If true, keeps progress.bin and bookmarks.bin
|
||||||
|
* @return true if successful (or if no cache exists)
|
||||||
|
*/
|
||||||
|
static bool clearBookCache(const std::string& bookPath, bool preserveProgress);
|
||||||
|
|
||||||
private:
|
private:
|
||||||
// Extract filename from a full path
|
// Extract filename from a full path
|
||||||
static std::string getFilename(const std::string& path);
|
static std::string getFilename(const std::string& path);
|
||||||
|
|||||||
@ -23,7 +23,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 = 29; // 28 + bezelCompensationEdge
|
constexpr uint8_t SETTINGS_COUNT = 30; // 29 + fadingFix
|
||||||
constexpr char SETTINGS_FILE[] = "/.crosspoint/settings.bin";
|
constexpr char SETTINGS_FILE[] = "/.crosspoint/settings.bin";
|
||||||
} // namespace
|
} // namespace
|
||||||
|
|
||||||
@ -72,6 +72,8 @@ bool CrossPointSettings::saveToFile() const {
|
|||||||
serialization::writePod(outputFile, bezelCompensation);
|
serialization::writePod(outputFile, bezelCompensation);
|
||||||
// Which physical edge needs bezel compensation
|
// Which physical edge needs bezel compensation
|
||||||
serialization::writePod(outputFile, bezelCompensationEdge);
|
serialization::writePod(outputFile, bezelCompensationEdge);
|
||||||
|
// Sunlight fading fix
|
||||||
|
serialization::writePod(outputFile, fadingFix);
|
||||||
// New fields added at end for backward compatibility
|
// New fields added at end for backward compatibility
|
||||||
outputFile.close();
|
outputFile.close();
|
||||||
|
|
||||||
@ -182,6 +184,9 @@ bool CrossPointSettings::loadFromFile() {
|
|||||||
// Which physical edge needs bezel compensation
|
// Which physical edge needs bezel compensation
|
||||||
readAndValidate(inputFile, bezelCompensationEdge, BEZEL_EDGE_COUNT);
|
readAndValidate(inputFile, bezelCompensationEdge, BEZEL_EDGE_COUNT);
|
||||||
if (++settingsRead >= fileSettingsCount) break;
|
if (++settingsRead >= fileSettingsCount) break;
|
||||||
|
// Sunlight fading fix
|
||||||
|
serialization::readPod(inputFile, fadingFix);
|
||||||
|
if (++settingsRead >= fileSettingsCount) break;
|
||||||
// New fields added at end for backward compatibility
|
// New fields added at end for backward compatibility
|
||||||
} while (false);
|
} while (false);
|
||||||
|
|
||||||
|
|||||||
@ -144,6 +144,8 @@ class CrossPointSettings {
|
|||||||
uint8_t hideBatteryPercentage = HIDE_NEVER;
|
uint8_t hideBatteryPercentage = HIDE_NEVER;
|
||||||
// Long-press chapter skip on side buttons
|
// Long-press chapter skip on side buttons
|
||||||
uint8_t longPressChapterSkip = 1;
|
uint8_t longPressChapterSkip = 1;
|
||||||
|
// Sunlight fading compensation (0 = off, 1 = on)
|
||||||
|
uint8_t fadingFix = 0;
|
||||||
// System-wide display contrast (0 = normal, 1 = high)
|
// System-wide display contrast (0 = normal, 1 = high)
|
||||||
uint8_t displayContrast = 0;
|
uint8_t displayContrast = 0;
|
||||||
// Bezel compensation - extra margin for physical screen edge defects (0-10px)
|
// Bezel compensation - extra margin for physical screen edge defects (0-10px)
|
||||||
|
|||||||
@ -2,103 +2,79 @@
|
|||||||
|
|
||||||
#include "CrossPointSettings.h"
|
#include "CrossPointSettings.h"
|
||||||
|
|
||||||
decltype(InputManager::BTN_BACK) MappedInputManager::mapButton(const Button button) const {
|
namespace {
|
||||||
|
using ButtonIndex = uint8_t;
|
||||||
|
|
||||||
|
struct FrontLayoutMap {
|
||||||
|
ButtonIndex back;
|
||||||
|
ButtonIndex confirm;
|
||||||
|
ButtonIndex left;
|
||||||
|
ButtonIndex right;
|
||||||
|
};
|
||||||
|
|
||||||
|
struct SideLayoutMap {
|
||||||
|
ButtonIndex pageBack;
|
||||||
|
ButtonIndex pageForward;
|
||||||
|
};
|
||||||
|
|
||||||
|
// Order matches CrossPointSettings::FRONT_BUTTON_LAYOUT.
|
||||||
|
constexpr FrontLayoutMap kFrontLayouts[] = {
|
||||||
|
{HalGPIO::BTN_BACK, HalGPIO::BTN_CONFIRM, HalGPIO::BTN_LEFT, HalGPIO::BTN_RIGHT},
|
||||||
|
{HalGPIO::BTN_LEFT, HalGPIO::BTN_RIGHT, HalGPIO::BTN_BACK, HalGPIO::BTN_CONFIRM},
|
||||||
|
{HalGPIO::BTN_CONFIRM, HalGPIO::BTN_LEFT, HalGPIO::BTN_BACK, HalGPIO::BTN_RIGHT},
|
||||||
|
{HalGPIO::BTN_BACK, HalGPIO::BTN_CONFIRM, HalGPIO::BTN_RIGHT, HalGPIO::BTN_LEFT},
|
||||||
|
};
|
||||||
|
|
||||||
|
// Order matches CrossPointSettings::SIDE_BUTTON_LAYOUT.
|
||||||
|
constexpr SideLayoutMap kSideLayouts[] = {
|
||||||
|
{HalGPIO::BTN_UP, HalGPIO::BTN_DOWN},
|
||||||
|
{HalGPIO::BTN_DOWN, HalGPIO::BTN_UP},
|
||||||
|
};
|
||||||
|
} // namespace
|
||||||
|
|
||||||
|
bool MappedInputManager::mapButton(const Button button, bool (HalGPIO::*fn)(uint8_t) const) const {
|
||||||
const auto frontLayout = static_cast<CrossPointSettings::FRONT_BUTTON_LAYOUT>(SETTINGS.frontButtonLayout);
|
const auto frontLayout = static_cast<CrossPointSettings::FRONT_BUTTON_LAYOUT>(SETTINGS.frontButtonLayout);
|
||||||
const auto sideLayout = static_cast<CrossPointSettings::SIDE_BUTTON_LAYOUT>(SETTINGS.sideButtonLayout);
|
const auto sideLayout = static_cast<CrossPointSettings::SIDE_BUTTON_LAYOUT>(SETTINGS.sideButtonLayout);
|
||||||
|
const auto& front = kFrontLayouts[frontLayout];
|
||||||
|
const auto& side = kSideLayouts[sideLayout];
|
||||||
|
|
||||||
switch (button) {
|
switch (button) {
|
||||||
case Button::Back:
|
case Button::Back:
|
||||||
switch (frontLayout) {
|
return (gpio.*fn)(front.back);
|
||||||
case CrossPointSettings::LEFT_RIGHT_BACK_CONFIRM:
|
|
||||||
return InputManager::BTN_LEFT;
|
|
||||||
case CrossPointSettings::LEFT_BACK_CONFIRM_RIGHT:
|
|
||||||
return InputManager::BTN_CONFIRM;
|
|
||||||
case CrossPointSettings::BACK_CONFIRM_LEFT_RIGHT:
|
|
||||||
/* fall through */
|
|
||||||
case CrossPointSettings::BACK_CONFIRM_RIGHT_LEFT:
|
|
||||||
/* fall through */
|
|
||||||
default:
|
|
||||||
return InputManager::BTN_BACK;
|
|
||||||
}
|
|
||||||
case Button::Confirm:
|
case Button::Confirm:
|
||||||
switch (frontLayout) {
|
return (gpio.*fn)(front.confirm);
|
||||||
case CrossPointSettings::LEFT_RIGHT_BACK_CONFIRM:
|
|
||||||
return InputManager::BTN_RIGHT;
|
|
||||||
case CrossPointSettings::LEFT_BACK_CONFIRM_RIGHT:
|
|
||||||
return InputManager::BTN_LEFT;
|
|
||||||
case CrossPointSettings::BACK_CONFIRM_LEFT_RIGHT:
|
|
||||||
/* fall through */
|
|
||||||
case CrossPointSettings::BACK_CONFIRM_RIGHT_LEFT:
|
|
||||||
/* fall through */
|
|
||||||
default:
|
|
||||||
return InputManager::BTN_CONFIRM;
|
|
||||||
}
|
|
||||||
case Button::Left:
|
case Button::Left:
|
||||||
switch (frontLayout) {
|
return (gpio.*fn)(front.left);
|
||||||
case CrossPointSettings::LEFT_RIGHT_BACK_CONFIRM:
|
|
||||||
/* fall through */
|
|
||||||
case CrossPointSettings::LEFT_BACK_CONFIRM_RIGHT:
|
|
||||||
return InputManager::BTN_BACK;
|
|
||||||
case CrossPointSettings::BACK_CONFIRM_RIGHT_LEFT:
|
|
||||||
return InputManager::BTN_RIGHT;
|
|
||||||
case CrossPointSettings::BACK_CONFIRM_LEFT_RIGHT:
|
|
||||||
/* fall through */
|
|
||||||
default:
|
|
||||||
return InputManager::BTN_LEFT;
|
|
||||||
}
|
|
||||||
case Button::Right:
|
case Button::Right:
|
||||||
switch (frontLayout) {
|
return (gpio.*fn)(front.right);
|
||||||
case CrossPointSettings::LEFT_RIGHT_BACK_CONFIRM:
|
|
||||||
return InputManager::BTN_CONFIRM;
|
|
||||||
case CrossPointSettings::BACK_CONFIRM_RIGHT_LEFT:
|
|
||||||
return InputManager::BTN_LEFT;
|
|
||||||
case CrossPointSettings::BACK_CONFIRM_LEFT_RIGHT:
|
|
||||||
/* fall through */
|
|
||||||
case CrossPointSettings::LEFT_BACK_CONFIRM_RIGHT:
|
|
||||||
/* fall through */
|
|
||||||
default:
|
|
||||||
return InputManager::BTN_RIGHT;
|
|
||||||
}
|
|
||||||
case Button::Up:
|
case Button::Up:
|
||||||
return InputManager::BTN_UP;
|
return (gpio.*fn)(HalGPIO::BTN_UP);
|
||||||
case Button::Down:
|
case Button::Down:
|
||||||
return InputManager::BTN_DOWN;
|
return (gpio.*fn)(HalGPIO::BTN_DOWN);
|
||||||
case Button::Power:
|
case Button::Power:
|
||||||
return InputManager::BTN_POWER;
|
return (gpio.*fn)(HalGPIO::BTN_POWER);
|
||||||
case Button::PageBack:
|
case Button::PageBack:
|
||||||
switch (sideLayout) {
|
return (gpio.*fn)(side.pageBack);
|
||||||
case CrossPointSettings::NEXT_PREV:
|
|
||||||
return InputManager::BTN_DOWN;
|
|
||||||
case CrossPointSettings::PREV_NEXT:
|
|
||||||
/* fall through */
|
|
||||||
default:
|
|
||||||
return InputManager::BTN_UP;
|
|
||||||
}
|
|
||||||
case Button::PageForward:
|
case Button::PageForward:
|
||||||
switch (sideLayout) {
|
return (gpio.*fn)(side.pageForward);
|
||||||
case CrossPointSettings::NEXT_PREV:
|
|
||||||
return InputManager::BTN_UP;
|
|
||||||
case CrossPointSettings::PREV_NEXT:
|
|
||||||
/* fall through */
|
|
||||||
default:
|
|
||||||
return InputManager::BTN_DOWN;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
return InputManager::BTN_BACK;
|
return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
bool MappedInputManager::wasPressed(const Button button) const { return inputManager.wasPressed(mapButton(button)); }
|
bool MappedInputManager::wasPressed(const Button button) const { return mapButton(button, &HalGPIO::wasPressed); }
|
||||||
|
|
||||||
bool MappedInputManager::wasReleased(const Button button) const { return inputManager.wasReleased(mapButton(button)); }
|
bool MappedInputManager::wasReleased(const Button button) const { return mapButton(button, &HalGPIO::wasReleased); }
|
||||||
|
|
||||||
bool MappedInputManager::isPressed(const Button button) const { return inputManager.isPressed(mapButton(button)); }
|
bool MappedInputManager::isPressed(const Button button) const { return mapButton(button, &HalGPIO::isPressed); }
|
||||||
|
|
||||||
bool MappedInputManager::wasAnyPressed() const { return inputManager.wasAnyPressed(); }
|
bool MappedInputManager::wasAnyPressed() const { return gpio.wasAnyPressed(); }
|
||||||
|
|
||||||
bool MappedInputManager::wasAnyReleased() const { return inputManager.wasAnyReleased(); }
|
bool MappedInputManager::wasAnyReleased() const { return gpio.wasAnyReleased(); }
|
||||||
|
|
||||||
unsigned long MappedInputManager::getHeldTime() const { return inputManager.getHeldTime(); }
|
unsigned long MappedInputManager::getHeldTime() const { return gpio.getHeldTime(); }
|
||||||
|
|
||||||
|
bool MappedInputManager::isUsbConnected() const { return gpio.isUsbConnected(); }
|
||||||
|
|
||||||
MappedInputManager::Labels MappedInputManager::mapLabels(const char* back, const char* confirm, const char* previous,
|
MappedInputManager::Labels MappedInputManager::mapLabels(const char* back, const char* confirm, const char* previous,
|
||||||
const char* next) const {
|
const char* next) const {
|
||||||
|
|||||||
@ -1,6 +1,6 @@
|
|||||||
#pragma once
|
#pragma once
|
||||||
|
|
||||||
#include <InputManager.h>
|
#include <HalGPIO.h>
|
||||||
|
|
||||||
class MappedInputManager {
|
class MappedInputManager {
|
||||||
public:
|
public:
|
||||||
@ -13,7 +13,7 @@ class MappedInputManager {
|
|||||||
const char* btn4;
|
const char* btn4;
|
||||||
};
|
};
|
||||||
|
|
||||||
explicit MappedInputManager(InputManager& inputManager) : inputManager(inputManager) {}
|
explicit MappedInputManager(HalGPIO& gpio) : gpio(gpio) {}
|
||||||
|
|
||||||
bool wasPressed(Button button) const;
|
bool wasPressed(Button button) const;
|
||||||
bool wasReleased(Button button) const;
|
bool wasReleased(Button button) const;
|
||||||
@ -21,9 +21,11 @@ class MappedInputManager {
|
|||||||
bool wasAnyPressed() const;
|
bool wasAnyPressed() const;
|
||||||
bool wasAnyReleased() const;
|
bool wasAnyReleased() const;
|
||||||
unsigned long getHeldTime() const;
|
unsigned long getHeldTime() const;
|
||||||
|
bool isUsbConnected() const;
|
||||||
Labels mapLabels(const char* back, const char* confirm, const char* previous, const char* next) const;
|
Labels mapLabels(const char* back, const char* confirm, const char* previous, const char* next) const;
|
||||||
|
|
||||||
private:
|
private:
|
||||||
InputManager& inputManager;
|
HalGPIO& gpio;
|
||||||
decltype(InputManager::BTN_BACK) mapButton(Button button) const;
|
|
||||||
|
bool mapButton(Button button, bool (HalGPIO::*fn)(uint8_t) const) const;
|
||||||
};
|
};
|
||||||
|
|||||||
@ -52,6 +52,13 @@ void RecentBooksStore::clearAll() {
|
|||||||
Serial.printf("[%lu] [RBS] Cleared all recent books\n", millis());
|
Serial.printf("[%lu] [RBS] Cleared all recent books\n", millis());
|
||||||
}
|
}
|
||||||
|
|
||||||
|
void RecentBooksStore::clearFromMemory() {
|
||||||
|
const size_t count = recentBooks.size();
|
||||||
|
recentBooks.clear();
|
||||||
|
recentBooks.shrink_to_fit(); // Actually free the vector capacity
|
||||||
|
Serial.printf("[%lu] [RBS] Cleared %d recent books from memory (not saved)\n", millis(), count);
|
||||||
|
}
|
||||||
|
|
||||||
bool RecentBooksStore::saveToFile() const {
|
bool RecentBooksStore::saveToFile() const {
|
||||||
// Make sure the directory exists
|
// Make sure the directory exists
|
||||||
SdMan.mkdir("/.crosspoint");
|
SdMan.mkdir("/.crosspoint");
|
||||||
|
|||||||
@ -29,9 +29,14 @@ class RecentBooksStore {
|
|||||||
// Returns true if the book was found and removed
|
// Returns true if the book was found and removed
|
||||||
bool removeBook(const std::string& path);
|
bool removeBook(const std::string& path);
|
||||||
|
|
||||||
// Clear all recent books from the list
|
// Clear all recent books from the list (and save to file)
|
||||||
void clearAll();
|
void clearAll();
|
||||||
|
|
||||||
|
// Clear recent books from memory without saving to file
|
||||||
|
// Used to free memory when entering modes that don't need this data (e.g., File Transfer)
|
||||||
|
// Call loadFromFile() to restore the data when needed again
|
||||||
|
void clearFromMemory();
|
||||||
|
|
||||||
// Get the list of recent books (most recent first)
|
// Get the list of recent books (most recent first)
|
||||||
const std::vector<RecentBook>& getBooks() const { return recentBooks; }
|
const std::vector<RecentBook>& getBooks() const { return recentBooks; }
|
||||||
|
|
||||||
|
|||||||
@ -11,14 +11,14 @@
|
|||||||
#include "fontIds.h"
|
#include "fontIds.h"
|
||||||
|
|
||||||
void ScreenComponents::drawBattery(const GfxRenderer& renderer, const int left, const int top,
|
void ScreenComponents::drawBattery(const GfxRenderer& renderer, const int left, const int top,
|
||||||
const bool showPercentage) {
|
const bool showPercentage, const bool isCharging) {
|
||||||
// Left aligned battery icon and percentage
|
// Left aligned battery icon and percentage
|
||||||
const uint16_t percentage = battery.readPercentage();
|
const uint16_t percentage = battery.readPercentage();
|
||||||
const auto percentageText = showPercentage ? std::to_string(percentage) + "%" : "";
|
const auto percentageText = showPercentage ? std::to_string(percentage) + "%" : "";
|
||||||
renderer.drawText(SMALL_FONT_ID, left + 20, top, percentageText.c_str());
|
renderer.drawText(SMALL_FONT_ID, left + 28, top, percentageText.c_str());
|
||||||
|
|
||||||
// 1 column on left, 2 columns on right, 5 columns of battery body
|
// 1.5x original width: 23px wide, 12px tall
|
||||||
constexpr int batteryWidth = 15;
|
constexpr int batteryWidth = 23;
|
||||||
constexpr int batteryHeight = 12;
|
constexpr int batteryHeight = 12;
|
||||||
const int x = left;
|
const int x = left;
|
||||||
const int y = top + 6;
|
const int y = top + 6;
|
||||||
@ -29,30 +29,69 @@ void ScreenComponents::drawBattery(const GfxRenderer& renderer, const int left,
|
|||||||
renderer.drawLine(x + 1, y + batteryHeight - 1, x + batteryWidth - 3, y + batteryHeight - 1);
|
renderer.drawLine(x + 1, y + batteryHeight - 1, x + batteryWidth - 3, y + batteryHeight - 1);
|
||||||
// Left line
|
// Left line
|
||||||
renderer.drawLine(x, y + 1, x, y + batteryHeight - 2);
|
renderer.drawLine(x, y + 1, x, y + batteryHeight - 2);
|
||||||
// Battery end
|
// Battery end (right side with nub)
|
||||||
renderer.drawLine(x + batteryWidth - 2, y + 1, x + batteryWidth - 2, y + batteryHeight - 2);
|
renderer.drawLine(x + batteryWidth - 2, y + 1, x + batteryWidth - 2, y + batteryHeight - 2);
|
||||||
renderer.drawPixel(x + batteryWidth - 1, y + 3);
|
renderer.drawPixel(x + batteryWidth - 1, y + 3);
|
||||||
renderer.drawPixel(x + batteryWidth - 1, y + batteryHeight - 4);
|
renderer.drawPixel(x + batteryWidth - 1, y + batteryHeight - 4);
|
||||||
renderer.drawLine(x + batteryWidth - 0, y + 4, x + batteryWidth - 0, y + batteryHeight - 5);
|
renderer.drawLine(x + batteryWidth - 0, y + 4, x + batteryWidth - 0, y + batteryHeight - 5);
|
||||||
|
|
||||||
// The +1 is to round up, so that we always fill at least one pixel
|
// The +1 is to round up, so that we always fill at least one pixel
|
||||||
|
// Fill area is batteryWidth - 5 = 18px
|
||||||
int filledWidth = percentage * (batteryWidth - 5) / 100 + 1;
|
int filledWidth = percentage * (batteryWidth - 5) / 100 + 1;
|
||||||
if (filledWidth > batteryWidth - 5) {
|
if (filledWidth > batteryWidth - 5) {
|
||||||
filledWidth = batteryWidth - 5; // Ensure we don't overflow
|
filledWidth = batteryWidth - 5; // Ensure we don't overflow
|
||||||
}
|
}
|
||||||
|
|
||||||
renderer.fillRect(x + 2, y + 2, filledWidth, batteryHeight - 4);
|
renderer.fillRect(x + 2, y + 2, filledWidth, batteryHeight - 4);
|
||||||
|
|
||||||
|
// Draw 8x8 lightning bolt overlay when charging, centered in fill area
|
||||||
|
if (isCharging) {
|
||||||
|
// Center bolt in the full fill area (as if 100% charged)
|
||||||
|
const int fillAreaWidth = batteryWidth - 5; // 18px
|
||||||
|
const int fillAreaHeight = batteryHeight - 4; // 8px
|
||||||
|
const int boltX = x + 2 + (fillAreaWidth - 8) / 2; // Center 8px bolt in 18px fill area
|
||||||
|
const int boltY = y + 2 + (fillAreaHeight - 8) / 2; // Center 8px bolt in 8px fill area
|
||||||
|
// 8x8 lightning bolt from SVG: m8 22l1-7H4l9-13h2l-1 8h6L10 22z
|
||||||
|
// Row 0
|
||||||
|
renderer.drawPixel(boltX + 4, boltY + 0, false);
|
||||||
|
renderer.drawPixel(boltX + 5, boltY + 0, false);
|
||||||
|
// Row 1
|
||||||
|
renderer.drawPixel(boltX + 3, boltY + 1, false);
|
||||||
|
renderer.drawPixel(boltX + 4, boltY + 1, false);
|
||||||
|
// Row 2
|
||||||
|
renderer.drawPixel(boltX + 2, boltY + 2, false);
|
||||||
|
renderer.drawPixel(boltX + 3, boltY + 2, false);
|
||||||
|
renderer.drawPixel(boltX + 4, boltY + 2, false);
|
||||||
|
// Row 3
|
||||||
|
renderer.drawPixel(boltX + 1, boltY + 3, false);
|
||||||
|
renderer.drawPixel(boltX + 2, boltY + 3, false);
|
||||||
|
renderer.drawPixel(boltX + 3, boltY + 3, false);
|
||||||
|
renderer.drawPixel(boltX + 4, boltY + 3, false);
|
||||||
|
renderer.drawPixel(boltX + 5, boltY + 3, false);
|
||||||
|
// Row 4
|
||||||
|
renderer.drawPixel(boltX + 3, boltY + 4, false);
|
||||||
|
renderer.drawPixel(boltX + 4, boltY + 4, false);
|
||||||
|
renderer.drawPixel(boltX + 5, boltY + 4, false);
|
||||||
|
// Row 5
|
||||||
|
renderer.drawPixel(boltX + 3, boltY + 5, false);
|
||||||
|
renderer.drawPixel(boltX + 4, boltY + 5, false);
|
||||||
|
// Row 6
|
||||||
|
renderer.drawPixel(boltX + 2, boltY + 6, false);
|
||||||
|
renderer.drawPixel(boltX + 3, boltY + 6, false);
|
||||||
|
// Row 7
|
||||||
|
renderer.drawPixel(boltX + 2, boltY + 7, false);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
void ScreenComponents::drawBatteryLarge(const GfxRenderer& renderer, const int left, const int top,
|
void ScreenComponents::drawBatteryLarge(const GfxRenderer& renderer, const int left, const int top,
|
||||||
const bool showPercentage) {
|
const bool showPercentage, const bool isCharging) {
|
||||||
// Larger battery icon with UI_10 font for bottom button hint area
|
// Larger battery icon with UI_10 font for bottom button hint area
|
||||||
const uint16_t percentage = battery.readPercentage();
|
const uint16_t percentage = battery.readPercentage();
|
||||||
const auto percentageText = showPercentage ? std::to_string(percentage) + "%" : "";
|
const auto percentageText = showPercentage ? std::to_string(percentage) + "%" : "";
|
||||||
renderer.drawText(UI_10_FONT_ID, left + 28, top, percentageText.c_str());
|
renderer.drawText(UI_10_FONT_ID, left + 38, top, percentageText.c_str());
|
||||||
|
|
||||||
// Scaled up battery dimensions (~33% larger)
|
// 1.5x original width: 30px wide, 16px tall
|
||||||
constexpr int batteryWidth = 20;
|
constexpr int batteryWidth = 30;
|
||||||
constexpr int batteryHeight = 16;
|
constexpr int batteryHeight = 16;
|
||||||
const int x = left;
|
const int x = left;
|
||||||
const int y = top + 6;
|
const int y = top + 6;
|
||||||
@ -71,12 +110,70 @@ void ScreenComponents::drawBatteryLarge(const GfxRenderer& renderer, const int l
|
|||||||
renderer.drawLine(x + batteryWidth - 1, y + 5, x + batteryWidth - 1, y + batteryHeight - 6);
|
renderer.drawLine(x + batteryWidth - 1, y + 5, x + batteryWidth - 1, y + batteryHeight - 6);
|
||||||
|
|
||||||
// The +1 is to round up, so that we always fill at least one pixel
|
// The +1 is to round up, so that we always fill at least one pixel
|
||||||
|
// Fill area is batteryWidth - 6 = 24px
|
||||||
int filledWidth = percentage * (batteryWidth - 6) / 100 + 1;
|
int filledWidth = percentage * (batteryWidth - 6) / 100 + 1;
|
||||||
if (filledWidth > batteryWidth - 6) {
|
if (filledWidth > batteryWidth - 6) {
|
||||||
filledWidth = batteryWidth - 6; // Ensure we don't overflow
|
filledWidth = batteryWidth - 6; // Ensure we don't overflow
|
||||||
}
|
}
|
||||||
|
|
||||||
renderer.fillRect(x + 2, y + 2, filledWidth, batteryHeight - 4);
|
renderer.fillRect(x + 2, y + 2, filledWidth, batteryHeight - 4);
|
||||||
|
|
||||||
|
// Draw 12x12 lightning bolt overlay when charging, centered in fill area
|
||||||
|
if (isCharging) {
|
||||||
|
// Center bolt in the full fill area (as if 100% charged)
|
||||||
|
const int fillAreaWidth = batteryWidth - 6; // 24px
|
||||||
|
const int fillAreaHeight = batteryHeight - 4; // 12px
|
||||||
|
const int boltX = x + 2 + (fillAreaWidth - 12) / 2; // Center 12px bolt in 24px fill area
|
||||||
|
const int boltY = y + 2 + (fillAreaHeight - 12) / 2; // Center 12px bolt in 12px fill area
|
||||||
|
// 12x12 lightning bolt from SVG: m8 22l1-7H4l9-13h2l-1 8h6L10 22z
|
||||||
|
// Row 0
|
||||||
|
renderer.drawPixel(boltX + 6, boltY + 0, false);
|
||||||
|
renderer.drawPixel(boltX + 7, boltY + 0, false);
|
||||||
|
// Row 1
|
||||||
|
renderer.drawPixel(boltX + 5, boltY + 1, false);
|
||||||
|
renderer.drawPixel(boltX + 6, boltY + 1, false);
|
||||||
|
renderer.drawPixel(boltX + 7, boltY + 1, false);
|
||||||
|
// Row 2
|
||||||
|
renderer.drawPixel(boltX + 4, boltY + 2, false);
|
||||||
|
renderer.drawPixel(boltX + 5, boltY + 2, false);
|
||||||
|
renderer.drawPixel(boltX + 6, boltY + 2, false);
|
||||||
|
// Row 3
|
||||||
|
renderer.drawPixel(boltX + 3, boltY + 3, false);
|
||||||
|
renderer.drawPixel(boltX + 4, boltY + 3, false);
|
||||||
|
renderer.drawPixel(boltX + 5, boltY + 3, false);
|
||||||
|
// Row 4
|
||||||
|
renderer.drawPixel(boltX + 2, boltY + 4, false);
|
||||||
|
renderer.drawPixel(boltX + 3, boltY + 4, false);
|
||||||
|
renderer.drawPixel(boltX + 4, boltY + 4, false);
|
||||||
|
renderer.drawPixel(boltX + 5, boltY + 4, false);
|
||||||
|
renderer.drawPixel(boltX + 6, boltY + 4, false);
|
||||||
|
renderer.drawPixel(boltX + 7, boltY + 4, false);
|
||||||
|
renderer.drawPixel(boltX + 8, boltY + 4, false);
|
||||||
|
// Row 5
|
||||||
|
renderer.drawPixel(boltX + 4, boltY + 5, false);
|
||||||
|
renderer.drawPixel(boltX + 5, boltY + 5, false);
|
||||||
|
renderer.drawPixel(boltX + 6, boltY + 5, false);
|
||||||
|
renderer.drawPixel(boltX + 7, boltY + 5, false);
|
||||||
|
renderer.drawPixel(boltX + 8, boltY + 5, false);
|
||||||
|
// Row 6
|
||||||
|
renderer.drawPixel(boltX + 5, boltY + 6, false);
|
||||||
|
renderer.drawPixel(boltX + 6, boltY + 6, false);
|
||||||
|
renderer.drawPixel(boltX + 7, boltY + 6, false);
|
||||||
|
// Row 7
|
||||||
|
renderer.drawPixel(boltX + 5, boltY + 7, false);
|
||||||
|
renderer.drawPixel(boltX + 6, boltY + 7, false);
|
||||||
|
// Row 8
|
||||||
|
renderer.drawPixel(boltX + 4, boltY + 8, false);
|
||||||
|
renderer.drawPixel(boltX + 5, boltY + 8, false);
|
||||||
|
// Row 9
|
||||||
|
renderer.drawPixel(boltX + 3, boltY + 9, false);
|
||||||
|
renderer.drawPixel(boltX + 4, boltY + 9, false);
|
||||||
|
// Row 10
|
||||||
|
renderer.drawPixel(boltX + 3, boltY + 10, false);
|
||||||
|
renderer.drawPixel(boltX + 4, boltY + 10, false);
|
||||||
|
// Row 11
|
||||||
|
renderer.drawPixel(boltX + 3, boltY + 11, false);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
void ScreenComponents::drawBookProgressBar(const GfxRenderer& renderer, const size_t bookProgress) {
|
void ScreenComponents::drawBookProgressBar(const GfxRenderer& renderer, const size_t bookProgress) {
|
||||||
|
|||||||
@ -15,11 +15,13 @@ class ScreenComponents {
|
|||||||
public:
|
public:
|
||||||
static const int BOOK_PROGRESS_BAR_HEIGHT = 4;
|
static const int BOOK_PROGRESS_BAR_HEIGHT = 4;
|
||||||
|
|
||||||
static void drawBattery(const GfxRenderer& renderer, int left, int top, bool showPercentage = true);
|
static void drawBattery(const GfxRenderer& renderer, int left, int top, bool showPercentage = true,
|
||||||
|
bool isCharging = false);
|
||||||
static void drawBookProgressBar(const GfxRenderer& renderer, size_t bookProgress);
|
static void drawBookProgressBar(const GfxRenderer& renderer, size_t bookProgress);
|
||||||
|
|
||||||
// Draw a larger battery icon suitable for bottom button hint area
|
// Draw a larger battery icon suitable for bottom button hint area
|
||||||
static void drawBatteryLarge(const GfxRenderer& renderer, int left, int top, bool showPercentage = true);
|
static void drawBatteryLarge(const GfxRenderer& renderer, int left, int top, bool showPercentage = true,
|
||||||
|
bool isCharging = false);
|
||||||
|
|
||||||
// Draw a horizontal tab bar with underline indicator for selected tab
|
// Draw a horizontal tab bar with underline indicator for selected tab
|
||||||
// Returns the height of the tab bar (for positioning content below)
|
// Returns the height of the tab bar (for positioning content below)
|
||||||
|
|||||||
@ -154,7 +154,7 @@ void SleepActivity::renderDefaultSleepScreen() const {
|
|||||||
renderer.invertScreen();
|
renderer.invertScreen();
|
||||||
}
|
}
|
||||||
|
|
||||||
renderer.displayBuffer(EInkDisplay::HALF_REFRESH);
|
renderer.displayBuffer(HalDisplay::HALF_REFRESH);
|
||||||
}
|
}
|
||||||
|
|
||||||
void SleepActivity::renderBitmapSleepScreen(const Bitmap& bitmap, const std::string& bmpPath) const {
|
void SleepActivity::renderBitmapSleepScreen(const Bitmap& bitmap, const std::string& bmpPath) const {
|
||||||
@ -269,7 +269,7 @@ void SleepActivity::renderBitmapSleepScreen(const Bitmap& bitmap, const std::str
|
|||||||
renderer.invertScreen();
|
renderer.invertScreen();
|
||||||
}
|
}
|
||||||
|
|
||||||
renderer.displayBuffer(EInkDisplay::HALF_REFRESH);
|
renderer.displayBuffer(HalDisplay::HALF_REFRESH);
|
||||||
|
|
||||||
if (hasGreyscale) {
|
if (hasGreyscale) {
|
||||||
// Grayscale LSB pass
|
// Grayscale LSB pass
|
||||||
@ -400,7 +400,7 @@ void SleepActivity::renderCoverSleepScreen() const {
|
|||||||
|
|
||||||
void SleepActivity::renderBlankSleepScreen() const {
|
void SleepActivity::renderBlankSleepScreen() const {
|
||||||
renderer.clearScreen();
|
renderer.clearScreen();
|
||||||
renderer.displayBuffer(EInkDisplay::HALF_REFRESH);
|
renderer.displayBuffer(HalDisplay::HALF_REFRESH);
|
||||||
}
|
}
|
||||||
|
|
||||||
std::string SleepActivity::getEdgeCachePath(const std::string& bmpPath) {
|
std::string SleepActivity::getEdgeCachePath(const std::string& bmpPath) {
|
||||||
|
|||||||
@ -98,7 +98,7 @@ void DictionaryResultActivity::loop() {
|
|||||||
// At end of cached pages but more content available - parse next chunk
|
// At end of cached pages but more content available - parse next chunk
|
||||||
Serial.printf("[DICT-DBG] Parsing next chunk on navigation (page %d)\n", currentPage);
|
Serial.printf("[DICT-DBG] Parsing next chunk on navigation (page %d)\n", currentPage);
|
||||||
parseNextChunk();
|
parseNextChunk();
|
||||||
|
|
||||||
// After parsing (and possible page trimming), check if we can advance
|
// After parsing (and possible page trimming), check if we can advance
|
||||||
// Note: Don't compare page counts - trimming may keep size the same while adding new content
|
// Note: Don't compare page counts - trimming may keep size the same while adding new content
|
||||||
if (currentPage < static_cast<int>(pages.size()) - 1) {
|
if (currentPage < static_cast<int>(pages.size()) - 1) {
|
||||||
@ -143,9 +143,9 @@ void DictionaryResultActivity::paginateDefinition() {
|
|||||||
// With HTML overhead, multiply by ~2, plus buffer for finding break points
|
// With HTML overhead, multiply by ~2, plus buffer for finding break points
|
||||||
constexpr size_t CHUNK_SIZE_BASE = 1500; // Base chunk size
|
constexpr size_t CHUNK_SIZE_BASE = 1500; // Base chunk size
|
||||||
const size_t chunkSize = std::max(CHUNK_SIZE_BASE, static_cast<size_t>(linesPerPage * 120));
|
const size_t chunkSize = std::max(CHUNK_SIZE_BASE, static_cast<size_t>(linesPerPage * 120));
|
||||||
|
|
||||||
Serial.printf("[DICT-DBG] Chunked parsing: defLen=%u, chunkSize=%u, linesPerPage=%d\n",
|
Serial.printf("[DICT-DBG] Chunked parsing: defLen=%u, chunkSize=%u, linesPerPage=%d\n", rawDefinition.length(),
|
||||||
rawDefinition.length(), chunkSize, linesPerPage);
|
chunkSize, linesPerPage);
|
||||||
|
|
||||||
// Determine how much to parse for first page
|
// Determine how much to parse for first page
|
||||||
size_t parseEnd;
|
size_t parseEnd;
|
||||||
@ -158,20 +158,18 @@ void DictionaryResultActivity::paginateDefinition() {
|
|||||||
parseEnd = findHtmlBreakPoint(rawDefinition, chunkSize / 2, chunkSize);
|
parseEnd = findHtmlBreakPoint(rawDefinition, chunkSize / 2, chunkSize);
|
||||||
hasMoreContent = (parseEnd < rawDefinition.length());
|
hasMoreContent = (parseEnd < rawDefinition.length());
|
||||||
}
|
}
|
||||||
|
|
||||||
// Extract the chunk to parse
|
// Extract the chunk to parse
|
||||||
std::string chunk = rawDefinition.substr(0, parseEnd);
|
std::string chunk = rawDefinition.substr(0, parseEnd);
|
||||||
parsePosition = parseEnd;
|
parsePosition = parseEnd;
|
||||||
|
|
||||||
Serial.printf("[DICT-DBG] Parsing first chunk: 0-%u of %u, hasMore=%d\n",
|
Serial.printf("[DICT-DBG] Parsing first chunk: 0-%u of %u, hasMore=%d\n", parseEnd, rawDefinition.length(),
|
||||||
parseEnd, rawDefinition.length(), hasMoreContent);
|
hasMoreContent);
|
||||||
|
|
||||||
// Parse this chunk into TextBlocks
|
// Parse this chunk into TextBlocks
|
||||||
std::vector<std::shared_ptr<TextBlock>> allBlocks;
|
std::vector<std::shared_ptr<TextBlock>> allBlocks;
|
||||||
DictHtmlParser::parse(chunk, UI_10_FONT_ID, renderer, textWidth,
|
DictHtmlParser::parse(chunk, UI_10_FONT_ID, renderer, textWidth,
|
||||||
[&allBlocks](std::shared_ptr<TextBlock> block) {
|
[&allBlocks](std::shared_ptr<TextBlock> block) { allBlocks.push_back(block); });
|
||||||
allBlocks.push_back(block);
|
|
||||||
});
|
|
||||||
Serial.printf("[DICT-DBG] First chunk parsed, %u TextBlocks\n", allBlocks.size());
|
Serial.printf("[DICT-DBG] First chunk parsed, %u TextBlocks\n", allBlocks.size());
|
||||||
|
|
||||||
if (allBlocks.empty()) {
|
if (allBlocks.empty()) {
|
||||||
@ -209,27 +207,27 @@ void DictionaryResultActivity::paginateDefinition() {
|
|||||||
if (!currentPageBlocks.empty()) {
|
if (!currentPageBlocks.empty()) {
|
||||||
pages.push_back(currentPageBlocks);
|
pages.push_back(currentPageBlocks);
|
||||||
}
|
}
|
||||||
|
|
||||||
Serial.printf("[DICT-DBG] Initial pagination: %u pages\n", pages.size());
|
Serial.printf("[DICT-DBG] Initial pagination: %u pages\n", pages.size());
|
||||||
}
|
}
|
||||||
|
|
||||||
size_t DictionaryResultActivity::findHtmlBreakPoint(const std::string& html, size_t searchStart, size_t maxPos) {
|
size_t DictionaryResultActivity::findHtmlBreakPoint(const std::string& html, size_t searchStart, size_t maxPos) {
|
||||||
// Search backwards from maxPos for good HTML break points
|
// Search backwards from maxPos for good HTML break points
|
||||||
// Priority: </li>, </p>, </ol>, </ul>, </div> then any '>' then whitespace
|
// Priority: </li>, </p>, </ol>, </ul>, </div> then any '>' then whitespace
|
||||||
|
|
||||||
if (maxPos >= html.length()) {
|
if (maxPos >= html.length()) {
|
||||||
return html.length();
|
return html.length();
|
||||||
}
|
}
|
||||||
|
|
||||||
// Clamp searchStart to not exceed maxPos
|
// Clamp searchStart to not exceed maxPos
|
||||||
if (searchStart > maxPos) {
|
if (searchStart > maxPos) {
|
||||||
searchStart = maxPos;
|
searchStart = maxPos;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Search for closing block tags (best break points)
|
// Search for closing block tags (best break points)
|
||||||
const char* closingTags[] = {"</li>", "</p>", "</ol>", "</ul>", "</div>", "</dd>", "</dt>"};
|
const char* closingTags[] = {"</li>", "</p>", "</ol>", "</ul>", "</div>", "</dd>", "</dt>"};
|
||||||
size_t bestBreak = std::string::npos;
|
size_t bestBreak = std::string::npos;
|
||||||
|
|
||||||
for (const char* tag : closingTags) {
|
for (const char* tag : closingTags) {
|
||||||
size_t pos = html.rfind(tag, maxPos);
|
size_t pos = html.rfind(tag, maxPos);
|
||||||
if (pos != std::string::npos && pos >= searchStart) {
|
if (pos != std::string::npos && pos >= searchStart) {
|
||||||
@ -240,17 +238,17 @@ size_t DictionaryResultActivity::findHtmlBreakPoint(const std::string& html, siz
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if (bestBreak != std::string::npos) {
|
if (bestBreak != std::string::npos) {
|
||||||
return bestBreak;
|
return bestBreak;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Fallback: search for any '>' (end of tag)
|
// Fallback: search for any '>' (end of tag)
|
||||||
size_t tagEnd = html.rfind('>', maxPos);
|
size_t tagEnd = html.rfind('>', maxPos);
|
||||||
if (tagEnd != std::string::npos && tagEnd >= searchStart) {
|
if (tagEnd != std::string::npos && tagEnd >= searchStart) {
|
||||||
return tagEnd + 1;
|
return tagEnd + 1;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Last resort: search for whitespace
|
// Last resort: search for whitespace
|
||||||
for (size_t i = maxPos; i >= searchStart && i != std::string::npos; i--) {
|
for (size_t i = maxPos; i >= searchStart && i != std::string::npos; i--) {
|
||||||
if (std::isspace(static_cast<unsigned char>(html[i]))) {
|
if (std::isspace(static_cast<unsigned char>(html[i]))) {
|
||||||
@ -258,7 +256,7 @@ size_t DictionaryResultActivity::findHtmlBreakPoint(const std::string& html, siz
|
|||||||
}
|
}
|
||||||
if (i == 0) break;
|
if (i == 0) break;
|
||||||
}
|
}
|
||||||
|
|
||||||
// No good break point found - use maxPos
|
// No good break point found - use maxPos
|
||||||
return maxPos;
|
return maxPos;
|
||||||
}
|
}
|
||||||
@ -269,8 +267,7 @@ void DictionaryResultActivity::parseNextChunk() {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
Serial.printf("[DICT-DBG] parseNextChunk starting at position %u of %u\n",
|
Serial.printf("[DICT-DBG] parseNextChunk starting at position %u of %u\n", parsePosition, rawDefinition.length());
|
||||||
parsePosition, rawDefinition.length());
|
|
||||||
|
|
||||||
// Get margins with button hint space for all orientations
|
// Get margins with button hint space for all orientations
|
||||||
int marginTop, marginRight, marginBottom, marginLeft;
|
int marginTop, marginRight, marginBottom, marginLeft;
|
||||||
@ -295,7 +292,7 @@ void DictionaryResultActivity::parseNextChunk() {
|
|||||||
// Determine parse range for this chunk
|
// Determine parse range for this chunk
|
||||||
size_t parseStart = parsePosition;
|
size_t parseStart = parsePosition;
|
||||||
size_t parseEnd;
|
size_t parseEnd;
|
||||||
|
|
||||||
if (parsePosition + chunkSize >= rawDefinition.length()) {
|
if (parsePosition + chunkSize >= rawDefinition.length()) {
|
||||||
// This will be the last chunk
|
// This will be the last chunk
|
||||||
parseEnd = rawDefinition.length();
|
parseEnd = rawDefinition.length();
|
||||||
@ -315,9 +312,7 @@ void DictionaryResultActivity::parseNextChunk() {
|
|||||||
// Parse this chunk into TextBlocks
|
// Parse this chunk into TextBlocks
|
||||||
std::vector<std::shared_ptr<TextBlock>> allBlocks;
|
std::vector<std::shared_ptr<TextBlock>> allBlocks;
|
||||||
DictHtmlParser::parse(chunk, UI_10_FONT_ID, renderer, textWidth,
|
DictHtmlParser::parse(chunk, UI_10_FONT_ID, renderer, textWidth,
|
||||||
[&allBlocks](std::shared_ptr<TextBlock> block) {
|
[&allBlocks](std::shared_ptr<TextBlock> block) { allBlocks.push_back(block); });
|
||||||
allBlocks.push_back(block);
|
|
||||||
});
|
|
||||||
|
|
||||||
Serial.printf("[DICT-DBG] Chunk parsed, %u TextBlocks\n", allBlocks.size());
|
Serial.printf("[DICT-DBG] Chunk parsed, %u TextBlocks\n", allBlocks.size());
|
||||||
|
|
||||||
@ -359,39 +354,38 @@ void DictionaryResultActivity::parseNextChunk() {
|
|||||||
Serial.printf("[DICT-DBG] Trimmed old page, firstPageNumber now %d\n", firstPageNumber);
|
Serial.printf("[DICT-DBG] Trimmed old page, firstPageNumber now %d\n", firstPageNumber);
|
||||||
}
|
}
|
||||||
|
|
||||||
Serial.printf("[DICT-DBG] After chunk: %u cached pages (pages %d-%d)\n",
|
Serial.printf("[DICT-DBG] After chunk: %u cached pages (pages %d-%d)\n", pages.size(), firstPageNumber,
|
||||||
pages.size(), firstPageNumber, firstPageNumber + static_cast<int>(pages.size()) - 1);
|
firstPageNumber + static_cast<int>(pages.size()) - 1);
|
||||||
}
|
}
|
||||||
|
|
||||||
void DictionaryResultActivity::reparseToPage(int targetPageNumber) {
|
void DictionaryResultActivity::reparseToPage(int targetPageNumber) {
|
||||||
// Re-parse from the beginning to reach an earlier page that was trimmed
|
// Re-parse from the beginning to reach an earlier page that was trimmed
|
||||||
// This allows backward navigation through the entire definition
|
// This allows backward navigation through the entire definition
|
||||||
|
|
||||||
Serial.printf("[DICT-DBG] reparseToPage: target=%d, clearing and re-parsing\n", targetPageNumber);
|
Serial.printf("[DICT-DBG] reparseToPage: target=%d, clearing and re-parsing\n", targetPageNumber);
|
||||||
|
|
||||||
// Clear current state and start fresh
|
// Clear current state and start fresh
|
||||||
pages.clear();
|
pages.clear();
|
||||||
parsePosition = 0;
|
parsePosition = 0;
|
||||||
firstPageNumber = 1;
|
firstPageNumber = 1;
|
||||||
hasMoreContent = !rawDefinition.empty();
|
hasMoreContent = !rawDefinition.empty();
|
||||||
|
|
||||||
// Parse chunks until we have the target page
|
// Parse chunks until we have the target page
|
||||||
while (hasMoreContent && firstPageNumber + static_cast<int>(pages.size()) - 1 < targetPageNumber) {
|
while (hasMoreContent && firstPageNumber + static_cast<int>(pages.size()) - 1 < targetPageNumber) {
|
||||||
parseNextChunk();
|
parseNextChunk();
|
||||||
}
|
}
|
||||||
|
|
||||||
// Now position currentPage to show the target page
|
// Now position currentPage to show the target page
|
||||||
if (targetPageNumber >= firstPageNumber &&
|
if (targetPageNumber >= firstPageNumber && targetPageNumber < firstPageNumber + static_cast<int>(pages.size())) {
|
||||||
targetPageNumber < firstPageNumber + static_cast<int>(pages.size())) {
|
|
||||||
currentPage = targetPageNumber - firstPageNumber;
|
currentPage = targetPageNumber - firstPageNumber;
|
||||||
} else {
|
} else {
|
||||||
// Target page doesn't exist (definition is shorter than expected)
|
// Target page doesn't exist (definition is shorter than expected)
|
||||||
currentPage = static_cast<int>(pages.size()) - 1;
|
currentPage = static_cast<int>(pages.size()) - 1;
|
||||||
if (currentPage < 0) currentPage = 0;
|
if (currentPage < 0) currentPage = 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
Serial.printf("[DICT-DBG] reparseToPage done: currentPage=%d, firstPageNumber=%d, pages=%u\n",
|
Serial.printf("[DICT-DBG] reparseToPage done: currentPage=%d, firstPageNumber=%d, pages=%u\n", currentPage,
|
||||||
currentPage, firstPageNumber, pages.size());
|
firstPageNumber, pages.size());
|
||||||
}
|
}
|
||||||
|
|
||||||
void DictionaryResultActivity::displayTaskLoop() {
|
void DictionaryResultActivity::displayTaskLoop() {
|
||||||
@ -425,8 +419,8 @@ void DictionaryResultActivity::render() const {
|
|||||||
renderer.drawCenteredText(UI_10_FONT_ID, centerY, "Word not found");
|
renderer.drawCenteredText(UI_10_FONT_ID, centerY, "Word not found");
|
||||||
} else if (!pages.empty()) {
|
} else if (!pages.empty()) {
|
||||||
// Draw definition text using TextBlocks with rich formatting
|
// Draw definition text using TextBlocks with rich formatting
|
||||||
constexpr int headerHeight = 55; // Space for "Dictionary" + lookup word
|
constexpr int headerHeight = 55; // Space for "Dictionary" + lookup word
|
||||||
constexpr int footerHeight = 20; // Space for page indicator
|
constexpr int footerHeight = 20; // Space for page indicator
|
||||||
const int textStartY = marginTop + headerHeight;
|
const int textStartY = marginTop + headerHeight;
|
||||||
const int textMargin = marginLeft + 10;
|
const int textMargin = marginLeft + 10;
|
||||||
const int lineHeight = renderer.getLineHeight(UI_10_FONT_ID);
|
const int lineHeight = renderer.getLineHeight(UI_10_FONT_ID);
|
||||||
|
|||||||
@ -29,8 +29,8 @@ class DictionaryResultActivity final : public Activity {
|
|||||||
// We limit cached pages to prevent memory exhaustion on long definitions
|
// We limit cached pages to prevent memory exhaustion on long definitions
|
||||||
static constexpr int MAX_CACHED_PAGES = 4;
|
static constexpr int MAX_CACHED_PAGES = 4;
|
||||||
std::vector<std::vector<std::shared_ptr<TextBlock>>> pages;
|
std::vector<std::vector<std::shared_ptr<TextBlock>>> pages;
|
||||||
int currentPage = 0; // Index into pages vector
|
int currentPage = 0; // Index into pages vector
|
||||||
int firstPageNumber = 1; // The page number of pages[0] (1-based for display)
|
int firstPageNumber = 1; // The page number of pages[0] (1-based for display)
|
||||||
bool notFound = false;
|
bool notFound = false;
|
||||||
|
|
||||||
// Chunked parsing state - parse definition on-demand as user navigates
|
// Chunked parsing state - parse definition on-demand as user navigates
|
||||||
|
|||||||
@ -1,7 +1,7 @@
|
|||||||
#include "EpubWordSelectionActivity.h"
|
#include "EpubWordSelectionActivity.h"
|
||||||
|
|
||||||
#include <EInkDisplay.h>
|
|
||||||
#include <GfxRenderer.h>
|
#include <GfxRenderer.h>
|
||||||
|
#include <HalDisplay.h>
|
||||||
|
|
||||||
#include <algorithm>
|
#include <algorithm>
|
||||||
#include <cctype>
|
#include <cctype>
|
||||||
@ -263,5 +263,5 @@ void EpubWordSelectionActivity::render() const {
|
|||||||
const char* sideBottomHint = (currentLineIndex < lastLine) ? "DOWN" : "";
|
const char* sideBottomHint = (currentLineIndex < lastLine) ? "DOWN" : "";
|
||||||
renderer.drawSideButtonHints(SMALL_FONT_ID, sideTopHint, sideBottomHint, false); // No border
|
renderer.drawSideButtonHints(SMALL_FONT_ID, sideTopHint, sideBottomHint, false); // No border
|
||||||
|
|
||||||
renderer.displayBuffer(EInkDisplay::FAST_REFRESH);
|
renderer.displayBuffer(HalDisplay::FAST_REFRESH);
|
||||||
}
|
}
|
||||||
|
|||||||
@ -751,7 +751,7 @@ void HomeActivity::render() {
|
|||||||
SETTINGS.hideBatteryPercentage != CrossPointSettings::HIDE_BATTERY_PERCENTAGE::HIDE_ALWAYS;
|
SETTINGS.hideBatteryPercentage != CrossPointSettings::HIDE_BATTERY_PERCENTAGE::HIDE_ALWAYS;
|
||||||
constexpr int batteryX = 25; // Align with first button hint position
|
constexpr int batteryX = 25; // Align with first button hint position
|
||||||
const int batteryY = pageHeight - 34; // Vertically centered in button hint area
|
const int batteryY = pageHeight - 34; // Vertically centered in button hint area
|
||||||
ScreenComponents::drawBatteryLarge(renderer, batteryX, batteryY, showBatteryPercentage);
|
ScreenComponents::drawBatteryLarge(renderer, batteryX, batteryY, showBatteryPercentage, mappedInput.isUsbConnected());
|
||||||
|
|
||||||
renderer.displayBuffer();
|
renderer.displayBuffer();
|
||||||
}
|
}
|
||||||
|
|||||||
@ -503,22 +503,29 @@ void MyLibraryActivity::executeAction() {
|
|||||||
} else if (selectedAction == ActionType::RemoveFromRecents) {
|
} else if (selectedAction == ActionType::RemoveFromRecents) {
|
||||||
// Just remove from recents list, don't touch the file
|
// Just remove from recents list, don't touch the file
|
||||||
success = RECENT_BOOKS.removeBook(actionTargetPath);
|
success = RECENT_BOOKS.removeBook(actionTargetPath);
|
||||||
|
} else if (selectedAction == ActionType::ClearCache) {
|
||||||
|
// Clear cache for this book, optionally preserving progress
|
||||||
|
success = BookManager::clearBookCache(actionTargetPath, clearCachePreserveProgress);
|
||||||
|
// Also clear thumbnail existence cache since thumbnails may have been deleted
|
||||||
|
clearThumbExistsCache();
|
||||||
}
|
}
|
||||||
// Note: ClearAllRecents is handled directly in loop() via ClearAllRecentsConfirming state
|
// Note: ClearAllRecents is handled directly in loop() via ClearAllRecentsConfirming state
|
||||||
|
|
||||||
if (success) {
|
if (success) {
|
||||||
// Reload data
|
// Reload data
|
||||||
loadRecentBooks();
|
loadRecentBooks();
|
||||||
if (selectedAction != ActionType::RemoveFromRecents) {
|
if (selectedAction != ActionType::RemoveFromRecents && selectedAction != ActionType::ClearCache) {
|
||||||
loadFiles(); // Only reload files for Archive/Delete
|
loadFiles(); // Only reload files for Archive/Delete (not needed for cache clear)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Adjust selector if needed
|
// Adjust selector if needed (not needed for ClearCache since item count doesn't change)
|
||||||
const int itemCount = getCurrentItemCount();
|
if (selectedAction != ActionType::ClearCache) {
|
||||||
if (selectorIndex >= itemCount && itemCount > 0) {
|
const int itemCount = getCurrentItemCount();
|
||||||
selectorIndex = itemCount - 1;
|
if (selectorIndex >= itemCount && itemCount > 0) {
|
||||||
} else if (itemCount == 0) {
|
selectorIndex = itemCount - 1;
|
||||||
selectorIndex = 0;
|
} else if (itemCount == 0) {
|
||||||
|
selectorIndex = 0;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -577,8 +584,8 @@ void MyLibraryActivity::executeListAction() {
|
|||||||
void MyLibraryActivity::loop() {
|
void MyLibraryActivity::loop() {
|
||||||
// Handle action menu state
|
// Handle action menu state
|
||||||
if (uiState == UIState::ActionMenu) {
|
if (uiState == UIState::ActionMenu) {
|
||||||
// Menu has 4 options in Recent tab, 2 options in Files tab
|
// Menu has 5 options in Recent tab, 3 options in Files tab
|
||||||
const int maxMenuSelection = (currentTab == Tab::Recent) ? 3 : 1;
|
const int maxMenuSelection = (currentTab == Tab::Recent) ? 4 : 2;
|
||||||
|
|
||||||
if (mappedInput.wasReleased(MappedInputManager::Button::Back)) {
|
if (mappedInput.wasReleased(MappedInputManager::Button::Back)) {
|
||||||
uiState = UIState::Normal;
|
uiState = UIState::Normal;
|
||||||
@ -608,7 +615,7 @@ void MyLibraryActivity::loop() {
|
|||||||
|
|
||||||
// Map menu selection to action type
|
// Map menu selection to action type
|
||||||
if (currentTab == Tab::Recent) {
|
if (currentTab == Tab::Recent) {
|
||||||
// Recent tab: Archive(0), Delete(1), Remove from Recents(2), Clear All Recents(3)
|
// Recent tab: Archive(0), Delete(1), Clear Cache(2), Remove from Recents(3), Clear All Recents(4)
|
||||||
switch (menuSelection) {
|
switch (menuSelection) {
|
||||||
case 0:
|
case 0:
|
||||||
selectedAction = ActionType::Archive;
|
selectedAction = ActionType::Archive;
|
||||||
@ -617,20 +624,37 @@ void MyLibraryActivity::loop() {
|
|||||||
selectedAction = ActionType::Delete;
|
selectedAction = ActionType::Delete;
|
||||||
break;
|
break;
|
||||||
case 2:
|
case 2:
|
||||||
selectedAction = ActionType::RemoveFromRecents;
|
selectedAction = ActionType::ClearCache;
|
||||||
break;
|
break;
|
||||||
case 3:
|
case 3:
|
||||||
|
selectedAction = ActionType::RemoveFromRecents;
|
||||||
|
break;
|
||||||
|
case 4:
|
||||||
selectedAction = ActionType::ClearAllRecents;
|
selectedAction = ActionType::ClearAllRecents;
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
// Files tab: Archive(0), Delete(1)
|
// Files tab: Archive(0), Delete(1), Clear Cache(2)
|
||||||
selectedAction = (menuSelection == 0) ? ActionType::Archive : ActionType::Delete;
|
switch (menuSelection) {
|
||||||
|
case 0:
|
||||||
|
selectedAction = ActionType::Archive;
|
||||||
|
break;
|
||||||
|
case 1:
|
||||||
|
selectedAction = ActionType::Delete;
|
||||||
|
break;
|
||||||
|
case 2:
|
||||||
|
selectedAction = ActionType::ClearCache;
|
||||||
|
break;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Clear All Recents needs its own confirmation dialog
|
// Clear All Recents needs its own confirmation dialog
|
||||||
if (selectedAction == ActionType::ClearAllRecents) {
|
if (selectedAction == ActionType::ClearAllRecents) {
|
||||||
uiState = UIState::ClearAllRecentsConfirming;
|
uiState = UIState::ClearAllRecentsConfirming;
|
||||||
|
} else if (selectedAction == ActionType::ClearCache) {
|
||||||
|
// Clear Cache shows options dialog first
|
||||||
|
clearCachePreserveProgress = true; // Default to preserving progress
|
||||||
|
uiState = UIState::ClearCacheOptionsConfirming;
|
||||||
} else {
|
} else {
|
||||||
uiState = UIState::Confirming;
|
uiState = UIState::Confirming;
|
||||||
}
|
}
|
||||||
@ -735,6 +759,30 @@ void MyLibraryActivity::loop() {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Handle clear cache options confirmation state
|
||||||
|
if (uiState == UIState::ClearCacheOptionsConfirming) {
|
||||||
|
if (mappedInput.wasReleased(MappedInputManager::Button::Back)) {
|
||||||
|
uiState = UIState::ActionMenu;
|
||||||
|
updateRequired = true;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Up/Down toggle between Yes/No for preserve progress
|
||||||
|
if (mappedInput.wasReleased(MappedInputManager::Button::Up) ||
|
||||||
|
mappedInput.wasReleased(MappedInputManager::Button::Down)) {
|
||||||
|
clearCachePreserveProgress = !clearCachePreserveProgress;
|
||||||
|
updateRequired = true;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (mappedInput.wasReleased(MappedInputManager::Button::Confirm)) {
|
||||||
|
executeAction();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
// Normal state handling
|
// Normal state handling
|
||||||
const int itemCount = getCurrentItemCount();
|
const int itemCount = getCurrentItemCount();
|
||||||
const int pageItems = getPageItems();
|
const int pageItems = getPageItems();
|
||||||
@ -1303,6 +1351,12 @@ void MyLibraryActivity::render() const {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (uiState == UIState::ClearCacheOptionsConfirming) {
|
||||||
|
renderClearCacheOptionsConfirmation();
|
||||||
|
renderer.displayBuffer();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
// Calculate bezel-adjusted margins
|
// Calculate bezel-adjusted margins
|
||||||
const int bezelTop = renderer.getBezelOffsetTop();
|
const int bezelTop = renderer.getBezelOffsetTop();
|
||||||
const int bezelBottom = renderer.getBezelOffsetBottom();
|
const int bezelBottom = renderer.getBezelOffsetBottom();
|
||||||
@ -1661,40 +1715,46 @@ void MyLibraryActivity::renderActionMenu() const {
|
|||||||
renderer.truncatedText(UI_10_FONT_ID, actionTargetName.c_str(), pageWidth - 40 - bezelLeft - bezelRight);
|
renderer.truncatedText(UI_10_FONT_ID, actionTargetName.c_str(), pageWidth - 40 - bezelLeft - bezelRight);
|
||||||
renderer.drawCenteredText(UI_10_FONT_ID, filenameY, truncatedName.c_str());
|
renderer.drawCenteredText(UI_10_FONT_ID, filenameY, truncatedName.c_str());
|
||||||
|
|
||||||
// Menu options - 4 for Recent tab, 2 for Files tab
|
// Menu options - 5 for Recent tab, 3 for Files tab
|
||||||
const bool isRecentTab = (currentTab == Tab::Recent);
|
const bool isRecentTab = (currentTab == Tab::Recent);
|
||||||
const int menuItemCount = isRecentTab ? 4 : 2;
|
const int menuItemCount = isRecentTab ? 5 : 3;
|
||||||
constexpr int menuLineHeight = 35;
|
constexpr int menuLineHeight = 35;
|
||||||
constexpr int menuItemWidth = 160;
|
constexpr int menuItemWidth = 160;
|
||||||
const int menuX = (pageWidth - menuItemWidth) / 2;
|
const int menuX = (pageWidth - menuItemWidth) / 2;
|
||||||
const int menuStartY = pageHeight / 2 - (menuItemCount * menuLineHeight) / 2;
|
const int menuStartY = pageHeight / 2 - (menuItemCount * menuLineHeight) / 2;
|
||||||
|
|
||||||
// Archive option
|
// Archive option (index 0)
|
||||||
if (menuSelection == 0) {
|
if (menuSelection == 0) {
|
||||||
renderer.fillRect(menuX - 10, menuStartY - 5, menuItemWidth + 20, menuLineHeight);
|
renderer.fillRect(menuX - 10, menuStartY - 5, menuItemWidth + 20, menuLineHeight);
|
||||||
}
|
}
|
||||||
renderer.drawCenteredText(UI_10_FONT_ID, menuStartY, "Archive", menuSelection != 0);
|
renderer.drawCenteredText(UI_10_FONT_ID, menuStartY, "Archive", menuSelection != 0);
|
||||||
|
|
||||||
// Delete option
|
// Delete option (index 1)
|
||||||
if (menuSelection == 1) {
|
if (menuSelection == 1) {
|
||||||
renderer.fillRect(menuX - 10, menuStartY + menuLineHeight - 5, menuItemWidth + 20, menuLineHeight);
|
renderer.fillRect(menuX - 10, menuStartY + menuLineHeight - 5, menuItemWidth + 20, menuLineHeight);
|
||||||
}
|
}
|
||||||
renderer.drawCenteredText(UI_10_FONT_ID, menuStartY + menuLineHeight, "Delete", menuSelection != 1);
|
renderer.drawCenteredText(UI_10_FONT_ID, menuStartY + menuLineHeight, "Delete", menuSelection != 1);
|
||||||
|
|
||||||
|
// Clear Cache option (index 2) - available in both tabs
|
||||||
|
if (menuSelection == 2) {
|
||||||
|
renderer.fillRect(menuX - 10, menuStartY + menuLineHeight * 2 - 5, menuItemWidth + 20, menuLineHeight);
|
||||||
|
}
|
||||||
|
renderer.drawCenteredText(UI_10_FONT_ID, menuStartY + menuLineHeight * 2, "Clear Cache", menuSelection != 2);
|
||||||
|
|
||||||
// Recent tab only: Remove from Recents and Clear All Recents
|
// Recent tab only: Remove from Recents and Clear All Recents
|
||||||
if (isRecentTab) {
|
if (isRecentTab) {
|
||||||
// Remove from Recents option
|
// Remove from Recents option (index 3)
|
||||||
if (menuSelection == 2) {
|
|
||||||
renderer.fillRect(menuX - 10, menuStartY + menuLineHeight * 2 - 5, menuItemWidth + 20, menuLineHeight);
|
|
||||||
}
|
|
||||||
renderer.drawCenteredText(UI_10_FONT_ID, menuStartY + menuLineHeight * 2, "Remove from Recents",
|
|
||||||
menuSelection != 2);
|
|
||||||
|
|
||||||
// Clear All Recents option
|
|
||||||
if (menuSelection == 3) {
|
if (menuSelection == 3) {
|
||||||
renderer.fillRect(menuX - 10, menuStartY + menuLineHeight * 3 - 5, menuItemWidth + 20, menuLineHeight);
|
renderer.fillRect(menuX - 10, menuStartY + menuLineHeight * 3 - 5, menuItemWidth + 20, menuLineHeight);
|
||||||
}
|
}
|
||||||
renderer.drawCenteredText(UI_10_FONT_ID, menuStartY + menuLineHeight * 3, "Clear All Recents", menuSelection != 3);
|
renderer.drawCenteredText(UI_10_FONT_ID, menuStartY + menuLineHeight * 3, "Remove from Recents",
|
||||||
|
menuSelection != 3);
|
||||||
|
|
||||||
|
// Clear All Recents option (index 4)
|
||||||
|
if (menuSelection == 4) {
|
||||||
|
renderer.fillRect(menuX - 10, menuStartY + menuLineHeight * 4 - 5, menuItemWidth + 20, menuLineHeight);
|
||||||
|
}
|
||||||
|
renderer.drawCenteredText(UI_10_FONT_ID, menuStartY + menuLineHeight * 4, "Clear All Recents", menuSelection != 4);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Draw side button hints (up/down navigation)
|
// Draw side button hints (up/down navigation)
|
||||||
@ -1828,6 +1888,54 @@ void MyLibraryActivity::renderClearAllRecentsConfirmation() const {
|
|||||||
renderer.drawButtonHints(UI_10_FONT_ID, labels.btn1, labels.btn2, labels.btn3, labels.btn4);
|
renderer.drawButtonHints(UI_10_FONT_ID, labels.btn1, labels.btn2, labels.btn3, labels.btn4);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
void MyLibraryActivity::renderClearCacheOptionsConfirmation() const {
|
||||||
|
const auto pageWidth = renderer.getScreenWidth();
|
||||||
|
const auto pageHeight = renderer.getScreenHeight();
|
||||||
|
|
||||||
|
// Bezel compensation
|
||||||
|
const int bezelTop = renderer.getBezelOffsetTop();
|
||||||
|
|
||||||
|
// Title
|
||||||
|
renderer.drawCenteredText(UI_12_FONT_ID, 20 + bezelTop, "Clear Book Cache", true, EpdFontFamily::BOLD);
|
||||||
|
|
||||||
|
// Show filename
|
||||||
|
const int filenameY = 60 + bezelTop;
|
||||||
|
const int bezelLeft = renderer.getBezelOffsetLeft();
|
||||||
|
const int bezelRight = renderer.getBezelOffsetRight();
|
||||||
|
auto truncatedName =
|
||||||
|
renderer.truncatedText(UI_10_FONT_ID, actionTargetName.c_str(), pageWidth - 40 - bezelLeft - bezelRight);
|
||||||
|
renderer.drawCenteredText(UI_10_FONT_ID, filenameY, truncatedName.c_str());
|
||||||
|
|
||||||
|
// Question text
|
||||||
|
const int questionY = pageHeight / 2 - 50;
|
||||||
|
renderer.drawCenteredText(UI_10_FONT_ID, questionY, "Preserve reading progress?");
|
||||||
|
|
||||||
|
// Yes/No options
|
||||||
|
constexpr int optionLineHeight = 35;
|
||||||
|
constexpr int optionWidth = 100;
|
||||||
|
const int optionX = (pageWidth - optionWidth) / 2;
|
||||||
|
const int optionStartY = questionY + 40;
|
||||||
|
|
||||||
|
// Yes option
|
||||||
|
if (clearCachePreserveProgress) {
|
||||||
|
renderer.fillRect(optionX - 10, optionStartY - 5, optionWidth + 20, optionLineHeight);
|
||||||
|
}
|
||||||
|
renderer.drawCenteredText(UI_10_FONT_ID, optionStartY, "Yes", !clearCachePreserveProgress);
|
||||||
|
|
||||||
|
// No option
|
||||||
|
if (!clearCachePreserveProgress) {
|
||||||
|
renderer.fillRect(optionX - 10, optionStartY + optionLineHeight - 5, optionWidth + 20, optionLineHeight);
|
||||||
|
}
|
||||||
|
renderer.drawCenteredText(UI_10_FONT_ID, optionStartY + optionLineHeight, "No", clearCachePreserveProgress);
|
||||||
|
|
||||||
|
// Draw side button hints (up/down navigation)
|
||||||
|
renderer.drawSideButtonHints(UI_10_FONT_ID, ">", "<");
|
||||||
|
|
||||||
|
// Draw bottom button hints
|
||||||
|
const auto labels = mappedInput.mapLabels("« Cancel", "Clear", "", "");
|
||||||
|
renderer.drawButtonHints(UI_10_FONT_ID, labels.btn1, labels.btn2, labels.btn3, labels.btn4);
|
||||||
|
}
|
||||||
|
|
||||||
void MyLibraryActivity::renderBookmarksTab() const {
|
void MyLibraryActivity::renderBookmarksTab() const {
|
||||||
const auto pageWidth = renderer.getScreenWidth();
|
const auto pageWidth = renderer.getScreenWidth();
|
||||||
const int pageItems = getPageItems();
|
const int pageItems = getPageItems();
|
||||||
|
|||||||
@ -44,9 +44,10 @@ class MyLibraryActivity final : public Activity {
|
|||||||
Confirming,
|
Confirming,
|
||||||
ListActionMenu,
|
ListActionMenu,
|
||||||
ListConfirmingDelete,
|
ListConfirmingDelete,
|
||||||
ClearAllRecentsConfirming
|
ClearAllRecentsConfirming,
|
||||||
|
ClearCacheOptionsConfirming
|
||||||
};
|
};
|
||||||
enum class ActionType { Archive, Delete, RemoveFromRecents, ClearAllRecents };
|
enum class ActionType { Archive, Delete, RemoveFromRecents, ClearCache, ClearAllRecents };
|
||||||
|
|
||||||
private:
|
private:
|
||||||
TaskHandle_t displayTaskHandle = nullptr;
|
TaskHandle_t displayTaskHandle = nullptr;
|
||||||
@ -62,8 +63,9 @@ class MyLibraryActivity final : public Activity {
|
|||||||
ActionType selectedAction = ActionType::Archive;
|
ActionType selectedAction = ActionType::Archive;
|
||||||
std::string actionTargetPath;
|
std::string actionTargetPath;
|
||||||
std::string actionTargetName;
|
std::string actionTargetName;
|
||||||
int menuSelection = 0; // 0 = Archive, 1 = Delete
|
int menuSelection = 0; // 0 = Archive, 1 = Delete
|
||||||
bool ignoreNextConfirmRelease = false; // Prevents immediate selection after long-press opens menu
|
bool ignoreNextConfirmRelease = false; // Prevents immediate selection after long-press opens menu
|
||||||
|
bool clearCachePreserveProgress = true; // For Clear Cache: whether to preserve reading progress
|
||||||
|
|
||||||
// Recent tab state
|
// Recent tab state
|
||||||
std::vector<RecentBook> recentBooks;
|
std::vector<RecentBook> recentBooks;
|
||||||
@ -153,6 +155,9 @@ class MyLibraryActivity final : public Activity {
|
|||||||
// Clear all recents confirmation
|
// Clear all recents confirmation
|
||||||
void renderClearAllRecentsConfirmation() const;
|
void renderClearAllRecentsConfirmation() const;
|
||||||
|
|
||||||
|
// Clear cache options confirmation
|
||||||
|
void renderClearCacheOptionsConfirmation() const;
|
||||||
|
|
||||||
public:
|
public:
|
||||||
explicit MyLibraryActivity(
|
explicit MyLibraryActivity(
|
||||||
GfxRenderer& renderer, MappedInputManager& mappedInput, const std::function<void()>& onGoHome,
|
GfxRenderer& renderer, MappedInputManager& mappedInput, const std::function<void()>& onGoHome,
|
||||||
|
|||||||
@ -300,6 +300,36 @@ void CrossPointWebServerActivity::startAccessPoint() {
|
|||||||
startWebServer();
|
startWebServer();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
void CrossPointWebServerActivity::generateQRCodes() {
|
||||||
|
Serial.printf("[%lu] [WEBACT] Generating QR codes (cached)...\n", millis());
|
||||||
|
const unsigned long startTime = millis();
|
||||||
|
|
||||||
|
// Web browser URL QR code
|
||||||
|
std::string webUrl = "http://" + connectedIP + "/";
|
||||||
|
qrcode_initText(&qrWebBrowser, qrWebBrowserBuffer, 4, ECC_LOW, webUrl.c_str());
|
||||||
|
Serial.printf("[%lu] [WEBACT] QR cached: %s\n", millis(), webUrl.c_str());
|
||||||
|
|
||||||
|
// Companion App (Files) deep link QR code
|
||||||
|
std::string filesUrl = getCompanionAppUrl();
|
||||||
|
qrcode_initText(&qrCompanionApp, qrCompanionAppBuffer, 4, ECC_LOW, filesUrl.c_str());
|
||||||
|
Serial.printf("[%lu] [WEBACT] QR cached: %s\n", millis(), filesUrl.c_str());
|
||||||
|
|
||||||
|
// Companion App (Library) deep link QR code
|
||||||
|
std::string libraryUrl = getCompanionAppLibraryUrl();
|
||||||
|
qrcode_initText(&qrCompanionAppLibrary, qrCompanionAppLibraryBuffer, 4, ECC_LOW, libraryUrl.c_str());
|
||||||
|
Serial.printf("[%lu] [WEBACT] QR cached: %s\n", millis(), libraryUrl.c_str());
|
||||||
|
|
||||||
|
// WiFi config QR code (for AP mode)
|
||||||
|
if (isApMode) {
|
||||||
|
std::string wifiConfig = std::string("WIFI:S:") + connectedSSID + ";;";
|
||||||
|
qrcode_initText(&qrWifiConfig, qrWifiConfigBuffer, 4, ECC_LOW, wifiConfig.c_str());
|
||||||
|
Serial.printf("[%lu] [WEBACT] QR cached: %s\n", millis(), wifiConfig.c_str());
|
||||||
|
}
|
||||||
|
|
||||||
|
qrCacheValid = true;
|
||||||
|
Serial.printf("[%lu] [WEBACT] QR codes cached in %lu ms\n", millis(), millis() - startTime);
|
||||||
|
}
|
||||||
|
|
||||||
void CrossPointWebServerActivity::startWebServer() {
|
void CrossPointWebServerActivity::startWebServer() {
|
||||||
Serial.printf("[%lu] [WEBACT] Starting web server...\n", millis());
|
Serial.printf("[%lu] [WEBACT] Starting web server...\n", millis());
|
||||||
|
|
||||||
@ -311,6 +341,9 @@ void CrossPointWebServerActivity::startWebServer() {
|
|||||||
state = WebServerActivityState::SERVER_RUNNING;
|
state = WebServerActivityState::SERVER_RUNNING;
|
||||||
Serial.printf("[%lu] [WEBACT] Web server started successfully\n", millis());
|
Serial.printf("[%lu] [WEBACT] Web server started successfully\n", millis());
|
||||||
|
|
||||||
|
// Generate and cache QR codes now that we have IP and server ports
|
||||||
|
generateQRCodes();
|
||||||
|
|
||||||
// Force an immediate render since we're transitioning from a subactivity
|
// Force an immediate render since we're transitioning from a subactivity
|
||||||
// that had its own rendering task. We need to make sure our display is shown.
|
// that had its own rendering task. We need to make sure our display is shown.
|
||||||
xSemaphoreTake(renderingMutex, portMAX_DELAY);
|
xSemaphoreTake(renderingMutex, portMAX_DELAY);
|
||||||
@ -468,23 +501,18 @@ void CrossPointWebServerActivity::render() const {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Draw QR code at specified position with configurable pixel size per module
|
// Draw QR code from pre-computed QRCode data at specified position
|
||||||
// Returns the size of the QR code in pixels (width = height = size * pixelsPerModule)
|
// Returns the size of the QR code in pixels (width = height = size * pixelsPerModule)
|
||||||
int drawQRCode(const GfxRenderer& renderer, const int x, const int y, const std::string& data,
|
int drawQRCodeCached(const GfxRenderer& renderer, const int x, const int y, QRCode* qrcode,
|
||||||
const uint8_t pixelsPerModule = 7) {
|
const uint8_t pixelsPerModule = 7) {
|
||||||
QRCode qrcode;
|
for (uint8_t cy = 0; cy < qrcode->size; cy++) {
|
||||||
uint8_t qrcodeBytes[qrcode_getBufferSize(4)];
|
for (uint8_t cx = 0; cx < qrcode->size; cx++) {
|
||||||
Serial.printf("[%lu] [WEBACT] QR Code (%lu): %s\n", millis(), data.length(), data.c_str());
|
if (qrcode_getModule(qrcode, cx, cy)) {
|
||||||
|
|
||||||
qrcode_initText(&qrcode, qrcodeBytes, 4, ECC_LOW, data.c_str());
|
|
||||||
for (uint8_t cy = 0; cy < qrcode.size; cy++) {
|
|
||||||
for (uint8_t cx = 0; cx < qrcode.size; cx++) {
|
|
||||||
if (qrcode_getModule(&qrcode, cx, cy)) {
|
|
||||||
renderer.fillRect(x + pixelsPerModule * cx, y + pixelsPerModule * cy, pixelsPerModule, pixelsPerModule, true);
|
renderer.fillRect(x + pixelsPerModule * cx, y + pixelsPerModule * cy, pixelsPerModule, pixelsPerModule, true);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
return qrcode.size * pixelsPerModule;
|
return qrcode->size * pixelsPerModule;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Helper to format bytes into human-readable sizes
|
// Helper to format bytes into human-readable sizes
|
||||||
@ -612,8 +640,7 @@ void CrossPointWebServerActivity::renderWebBrowserScreen() const {
|
|||||||
|
|
||||||
if (isApMode) {
|
if (isApMode) {
|
||||||
// AP mode: Show WiFi QR code on left, connection info on right
|
// AP mode: Show WiFi QR code on left, connection info on right
|
||||||
const std::string wifiConfig = std::string("WIFI:S:") + connectedSSID + ";;";
|
drawQRCodeCached(renderer, QR_X, QR_Y, &qrWifiConfig, QR_PX);
|
||||||
drawQRCode(renderer, QR_X, QR_Y, wifiConfig, QR_PX);
|
|
||||||
|
|
||||||
std::string ssidInfo = "Network: " + connectedSSID;
|
std::string ssidInfo = "Network: " + connectedSSID;
|
||||||
renderer.drawText(NOTOSANS_12_FONT_ID, TEXT_X, textY, ssidInfo.c_str());
|
renderer.drawText(NOTOSANS_12_FONT_ID, TEXT_X, textY, ssidInfo.c_str());
|
||||||
@ -635,8 +662,7 @@ void CrossPointWebServerActivity::renderWebBrowserScreen() const {
|
|||||||
renderer.drawText(UI_12_FONT_ID, TEXT_X, textY, hostnameUrl.c_str());
|
renderer.drawText(UI_12_FONT_ID, TEXT_X, textY, hostnameUrl.c_str());
|
||||||
} else {
|
} else {
|
||||||
// STA mode: Show URL QR code on left, connection info on right
|
// STA mode: Show URL QR code on left, connection info on right
|
||||||
std::string webUrl = "http://" + connectedIP + "/";
|
drawQRCodeCached(renderer, QR_X, QR_Y, &qrWebBrowser, QR_PX);
|
||||||
drawQRCode(renderer, QR_X, QR_Y, webUrl, QR_PX);
|
|
||||||
|
|
||||||
std::string ssidInfo = "Network: " + connectedSSID;
|
std::string ssidInfo = "Network: " + connectedSSID;
|
||||||
if (ssidInfo.length() > 35) {
|
if (ssidInfo.length() > 35) {
|
||||||
@ -650,6 +676,7 @@ void CrossPointWebServerActivity::renderWebBrowserScreen() const {
|
|||||||
renderer.drawText(NOTOSANS_12_FONT_ID, TEXT_X, textY, ipInfo.c_str());
|
renderer.drawText(NOTOSANS_12_FONT_ID, TEXT_X, textY, ipInfo.c_str());
|
||||||
textY += LINE_SPACING + 8;
|
textY += LINE_SPACING + 8;
|
||||||
|
|
||||||
|
std::string webUrl = "http://" + connectedIP + "/";
|
||||||
renderer.drawText(UI_12_FONT_ID, TEXT_X, textY, webUrl.c_str(), true, EpdFontFamily::BOLD);
|
renderer.drawText(UI_12_FONT_ID, TEXT_X, textY, webUrl.c_str(), true, EpdFontFamily::BOLD);
|
||||||
textY += LINE_SPACING - 4;
|
textY += LINE_SPACING - 4;
|
||||||
|
|
||||||
@ -704,12 +731,12 @@ void CrossPointWebServerActivity::renderCompanionAppScreen() const {
|
|||||||
std::string webUrl = "http://" + connectedIP + "/files";
|
std::string webUrl = "http://" + connectedIP + "/files";
|
||||||
renderer.drawText(UI_12_FONT_ID, TEXT_X, textY, webUrl.c_str());
|
renderer.drawText(UI_12_FONT_ID, TEXT_X, textY, webUrl.c_str());
|
||||||
|
|
||||||
// Draw QR code on left
|
// Draw cached QR code on left
|
||||||
const std::string appUrl = getCompanionAppUrl();
|
drawQRCodeCached(renderer, QR_X, QR_Y, &qrCompanionApp, QR_PX);
|
||||||
drawQRCode(renderer, QR_X, QR_Y, appUrl, QR_PX);
|
|
||||||
|
|
||||||
// Show deep link URL below QR code
|
// Show deep link URL below QR code
|
||||||
const int urlY = QR_Y + QR_SIZE + 10;
|
const int urlY = QR_Y + QR_SIZE + 10;
|
||||||
|
const std::string appUrl = getCompanionAppUrl();
|
||||||
renderer.drawText(UI_12_FONT_ID, QR_X, urlY, appUrl.c_str(), true, EpdFontFamily::BOLD);
|
renderer.drawText(UI_12_FONT_ID, QR_X, urlY, appUrl.c_str(), true, EpdFontFamily::BOLD);
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -754,11 +781,11 @@ void CrossPointWebServerActivity::renderCompanionAppLibraryScreen() const {
|
|||||||
std::string webUrl = "http://" + connectedIP + "/";
|
std::string webUrl = "http://" + connectedIP + "/";
|
||||||
renderer.drawText(UI_12_FONT_ID, TEXT_X, textY, webUrl.c_str());
|
renderer.drawText(UI_12_FONT_ID, TEXT_X, textY, webUrl.c_str());
|
||||||
|
|
||||||
// Draw QR code on left
|
// Draw cached QR code on left
|
||||||
const std::string appUrl = getCompanionAppLibraryUrl();
|
drawQRCodeCached(renderer, QR_X, QR_Y, &qrCompanionAppLibrary, QR_PX);
|
||||||
drawQRCode(renderer, QR_X, QR_Y, appUrl, QR_PX);
|
|
||||||
|
|
||||||
// Show deep link URL below QR code
|
// Show deep link URL below QR code
|
||||||
const int urlY = QR_Y + QR_SIZE + 10;
|
const int urlY = QR_Y + QR_SIZE + 10;
|
||||||
|
const std::string appUrl = getCompanionAppLibraryUrl();
|
||||||
renderer.drawText(UI_12_FONT_ID, QR_X, urlY, appUrl.c_str(), true, EpdFontFamily::BOLD);
|
renderer.drawText(UI_12_FONT_ID, QR_X, urlY, appUrl.c_str(), true, EpdFontFamily::BOLD);
|
||||||
}
|
}
|
||||||
|
|||||||
@ -2,6 +2,7 @@
|
|||||||
#include <freertos/FreeRTOS.h>
|
#include <freertos/FreeRTOS.h>
|
||||||
#include <freertos/semphr.h>
|
#include <freertos/semphr.h>
|
||||||
#include <freertos/task.h>
|
#include <freertos/task.h>
|
||||||
|
#include <qrcode.h>
|
||||||
|
|
||||||
#include <functional>
|
#include <functional>
|
||||||
#include <memory>
|
#include <memory>
|
||||||
@ -11,6 +12,10 @@
|
|||||||
#include "activities/ActivityWithSubactivity.h"
|
#include "activities/ActivityWithSubactivity.h"
|
||||||
#include "network/CrossPointWebServer.h"
|
#include "network/CrossPointWebServer.h"
|
||||||
|
|
||||||
|
// QR code cache - version 4 QR codes (33x33 modules)
|
||||||
|
// Buffer size for version 4: qrcode_getBufferSize(4) ≈ 185 bytes
|
||||||
|
constexpr size_t QR_BUFFER_SIZE = 185;
|
||||||
|
|
||||||
// Web server activity states
|
// Web server activity states
|
||||||
enum class WebServerActivityState {
|
enum class WebServerActivityState {
|
||||||
MODE_SELECTION, // Choosing between Join Network and Create Hotspot
|
MODE_SELECTION, // Choosing between Join Network and Create Hotspot
|
||||||
@ -62,6 +67,19 @@ class CrossPointWebServerActivity final : public ActivityWithSubactivity {
|
|||||||
FileTransferScreen currentScreen = FileTransferScreen::COMPANION_APP_LIBRARY;
|
FileTransferScreen currentScreen = FileTransferScreen::COMPANION_APP_LIBRARY;
|
||||||
unsigned long lastStatsRefresh = 0;
|
unsigned long lastStatsRefresh = 0;
|
||||||
|
|
||||||
|
// Cached QR codes - generated once when server starts
|
||||||
|
// Avoids recomputing QR data on every render (every 30s stats refresh)
|
||||||
|
// Marked mutable since QR drawing doesn't modify logical state but qrcode_getModule takes non-const
|
||||||
|
bool qrCacheValid = false;
|
||||||
|
mutable QRCode qrWebBrowser = {};
|
||||||
|
mutable QRCode qrCompanionApp = {};
|
||||||
|
mutable QRCode qrCompanionAppLibrary = {};
|
||||||
|
mutable QRCode qrWifiConfig = {}; // For AP mode WiFi connection QR
|
||||||
|
uint8_t qrWebBrowserBuffer[QR_BUFFER_SIZE] = {};
|
||||||
|
uint8_t qrCompanionAppBuffer[QR_BUFFER_SIZE] = {};
|
||||||
|
uint8_t qrCompanionAppLibraryBuffer[QR_BUFFER_SIZE] = {};
|
||||||
|
uint8_t qrWifiConfigBuffer[QR_BUFFER_SIZE] = {};
|
||||||
|
|
||||||
static void taskTrampoline(void* param);
|
static void taskTrampoline(void* param);
|
||||||
[[noreturn]] void displayTaskLoop();
|
[[noreturn]] void displayTaskLoop();
|
||||||
void render() const;
|
void render() const;
|
||||||
@ -78,6 +96,7 @@ class CrossPointWebServerActivity final : public ActivityWithSubactivity {
|
|||||||
void startAccessPoint();
|
void startAccessPoint();
|
||||||
void startWebServer();
|
void startWebServer();
|
||||||
void stopWebServer();
|
void stopWebServer();
|
||||||
|
void generateQRCodes();
|
||||||
|
|
||||||
public:
|
public:
|
||||||
explicit CrossPointWebServerActivity(GfxRenderer& renderer, MappedInputManager& mappedInput,
|
explicit CrossPointWebServerActivity(GfxRenderer& renderer, MappedInputManager& mappedInput,
|
||||||
|
|||||||
@ -3,7 +3,7 @@
|
|||||||
#include <GfxRenderer.h>
|
#include <GfxRenderer.h>
|
||||||
#include <WiFi.h>
|
#include <WiFi.h>
|
||||||
|
|
||||||
#include <map>
|
#include <algorithm>
|
||||||
|
|
||||||
#include "MappedInputManager.h"
|
#include "MappedInputManager.h"
|
||||||
#include "WifiCredentialStore.h"
|
#include "WifiCredentialStore.h"
|
||||||
@ -124,48 +124,55 @@ void WifiSelectionActivity::processWifiScanResults() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Scan complete, process results
|
// Scan complete, process results
|
||||||
// Use a map to deduplicate networks by SSID, keeping the strongest signal
|
// Deduplicate directly into the networks vector (avoids std::map overhead)
|
||||||
std::map<std::string, WifiNetworkInfo> uniqueNetworks;
|
networks.clear();
|
||||||
|
networks.reserve(std::min(scanResult, static_cast<int16_t>(20))); // Limit to 20 networks max
|
||||||
|
|
||||||
for (int i = 0; i < scanResult; i++) {
|
for (int i = 0; i < scanResult; i++) {
|
||||||
std::string ssid = WiFi.SSID(i).c_str();
|
String ssidStr = WiFi.SSID(i);
|
||||||
const int32_t rssi = WiFi.RSSI(i);
|
const int32_t rssi = WiFi.RSSI(i);
|
||||||
|
|
||||||
// Skip hidden networks (empty SSID)
|
// Skip hidden networks (empty SSID)
|
||||||
if (ssid.empty()) {
|
if (ssidStr.isEmpty()) {
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Check if we've already seen this SSID
|
std::string ssid = ssidStr.c_str();
|
||||||
auto it = uniqueNetworks.find(ssid);
|
|
||||||
if (it == uniqueNetworks.end() || rssi > it->second.rssi) {
|
// Check if we've already seen this SSID (linear search is fine for small lists)
|
||||||
// New network or stronger signal than existing entry
|
auto existing = std::find_if(networks.begin(), networks.end(),
|
||||||
|
[&ssid](const WifiNetworkInfo& net) { return net.ssid == ssid; });
|
||||||
|
|
||||||
|
if (existing != networks.end()) {
|
||||||
|
// Update if stronger signal
|
||||||
|
if (rssi > existing->rssi) {
|
||||||
|
existing->rssi = rssi;
|
||||||
|
existing->isEncrypted = (WiFi.encryptionType(i) != WIFI_AUTH_OPEN);
|
||||||
|
}
|
||||||
|
} else if (networks.size() < 20) {
|
||||||
|
// New network - only add if under limit
|
||||||
WifiNetworkInfo network;
|
WifiNetworkInfo network;
|
||||||
network.ssid = ssid;
|
network.ssid = std::move(ssid);
|
||||||
network.rssi = rssi;
|
network.rssi = rssi;
|
||||||
network.isEncrypted = (WiFi.encryptionType(i) != WIFI_AUTH_OPEN);
|
network.isEncrypted = (WiFi.encryptionType(i) != WIFI_AUTH_OPEN);
|
||||||
network.hasSavedPassword = WIFI_STORE.hasSavedCredential(network.ssid);
|
network.hasSavedPassword = WIFI_STORE.hasSavedCredential(network.ssid);
|
||||||
uniqueNetworks[ssid] = network;
|
networks.push_back(std::move(network));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Convert map to vector
|
// Free WiFi scan memory immediately (before sorting)
|
||||||
networks.clear();
|
WiFi.scanDelete();
|
||||||
for (const auto& pair : uniqueNetworks) {
|
|
||||||
// cppcheck-suppress useStlAlgorithm
|
|
||||||
networks.push_back(pair.second);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Sort by signal strength (strongest first)
|
// Sort by signal strength (strongest first), then by saved password
|
||||||
std::sort(networks.begin(), networks.end(),
|
|
||||||
[](const WifiNetworkInfo& a, const WifiNetworkInfo& b) { return a.rssi > b.rssi; });
|
|
||||||
|
|
||||||
// Show networks with PW first
|
|
||||||
std::sort(networks.begin(), networks.end(), [](const WifiNetworkInfo& a, const WifiNetworkInfo& b) {
|
std::sort(networks.begin(), networks.end(), [](const WifiNetworkInfo& a, const WifiNetworkInfo& b) {
|
||||||
return a.hasSavedPassword && !b.hasSavedPassword;
|
// Primary: saved passwords first
|
||||||
|
if (a.hasSavedPassword != b.hasSavedPassword) {
|
||||||
|
return a.hasSavedPassword;
|
||||||
|
}
|
||||||
|
// Secondary: strongest signal first
|
||||||
|
return a.rssi > b.rssi;
|
||||||
});
|
});
|
||||||
|
|
||||||
WiFi.scanDelete();
|
|
||||||
state = WifiSelectionState::NETWORK_LIST;
|
state = WifiSelectionState::NETWORK_LIST;
|
||||||
selectedNetworkIndex = 0;
|
selectedNetworkIndex = 0;
|
||||||
updateRequired = true;
|
updateRequired = true;
|
||||||
|
|||||||
@ -89,7 +89,7 @@ void EpubReaderActivity::onEnter() {
|
|||||||
renderer.fillRect(boxX, boxY, boxWidth, boxHeight, false);
|
renderer.fillRect(boxX, boxY, boxWidth, boxHeight, false);
|
||||||
renderer.drawRect(boxX + 5, boxY + 5, boxWidth - 10, boxHeight - 10);
|
renderer.drawRect(boxX + 5, boxY + 5, boxWidth - 10, boxHeight - 10);
|
||||||
renderer.drawText(UI_12_FONT_ID, boxX + boxMargin, boxY + boxMargin, "Preparing book... [0%]");
|
renderer.drawText(UI_12_FONT_ID, boxX + boxMargin, boxY + boxMargin, "Preparing book... [0%]");
|
||||||
renderer.displayBuffer(EInkDisplay::FAST_REFRESH);
|
renderer.displayBuffer(HalDisplay::FAST_REFRESH);
|
||||||
|
|
||||||
// Generate covers with progress callback
|
// Generate covers with progress callback
|
||||||
epub->generateAllCovers([&](int percent) {
|
epub->generateAllCovers([&](int percent) {
|
||||||
@ -103,7 +103,7 @@ void EpubReaderActivity::onEnter() {
|
|||||||
char progressStr[32];
|
char progressStr[32];
|
||||||
snprintf(progressStr, sizeof(progressStr), "Preparing book... [%d%%]", percent);
|
snprintf(progressStr, sizeof(progressStr), "Preparing book... [%d%%]", percent);
|
||||||
renderer.drawText(UI_12_FONT_ID, boxX + boxMargin, boxY + boxMargin, progressStr);
|
renderer.drawText(UI_12_FONT_ID, boxX + boxMargin, boxY + boxMargin, progressStr);
|
||||||
renderer.displayBuffer(EInkDisplay::FAST_REFRESH);
|
renderer.displayBuffer(HalDisplay::FAST_REFRESH);
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
@ -138,7 +138,7 @@ void EpubReaderActivity::onEnter() {
|
|||||||
nextPageNumber = data[2] + (data[3] << 8);
|
nextPageNumber = data[2] + (data[3] << 8);
|
||||||
hasContentOffset = false;
|
hasContentOffset = false;
|
||||||
Serial.printf("[%lu] [ERS] Loaded legacy progress (unknown version %d): spine %d, page %d\n", millis(),
|
Serial.printf("[%lu] [ERS] Loaded legacy progress (unknown version %d): spine %d, page %d\n", millis(),
|
||||||
version, currentSpineIndex, nextPageNumber);
|
version, currentSpineIndex, nextPageNumber);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
} else if (fileSize >= 4) {
|
} else if (fileSize >= 4) {
|
||||||
@ -236,6 +236,15 @@ void EpubReaderActivity::loop() {
|
|||||||
updateRequired = true;
|
updateRequired = true;
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
if (mappedInput.wasReleased(MappedInputManager::Button::PageForward) ||
|
||||||
|
mappedInput.wasReleased(MappedInputManager::Button::Right)) {
|
||||||
|
// Start over from beginning
|
||||||
|
currentSpineIndex = 0;
|
||||||
|
nextPageNumber = 0;
|
||||||
|
showingEndOfBookPrompt = false;
|
||||||
|
updateRequired = true;
|
||||||
|
return;
|
||||||
|
}
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -708,7 +717,7 @@ void EpubReaderActivity::renderScreen() {
|
|||||||
auto progressCallback = [this, barX, barY, barWidth, barHeight](int progress) {
|
auto progressCallback = [this, barX, barY, barWidth, barHeight](int progress) {
|
||||||
const int fillWidth = (barWidth - 2) * progress / 100;
|
const int fillWidth = (barWidth - 2) * progress / 100;
|
||||||
renderer.fillRect(barX + 1, barY + 1, fillWidth, barHeight - 2, true);
|
renderer.fillRect(barX + 1, barY + 1, fillWidth, barHeight - 2, true);
|
||||||
renderer.displayBuffer(EInkDisplay::FAST_REFRESH);
|
renderer.displayBuffer(HalDisplay::FAST_REFRESH);
|
||||||
};
|
};
|
||||||
|
|
||||||
if (!section->createSectionFile(SETTINGS.getReaderFontId(), SETTINGS.getReaderLineCompression(),
|
if (!section->createSectionFile(SETTINGS.getReaderFontId(), SETTINGS.getReaderLineCompression(),
|
||||||
@ -752,8 +761,7 @@ void EpubReaderActivity::renderScreen() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (section->currentPage < 0 || section->currentPage >= section->pageCount) {
|
if (section->currentPage < 0 || section->currentPage >= section->pageCount) {
|
||||||
Serial.printf("[%lu] [ERS] Page out of bounds: %d (max %d)\n", millis(), section->currentPage,
|
Serial.printf("[%lu] [ERS] Page out of bounds: %d (max %d)\n", millis(), section->currentPage, section->pageCount);
|
||||||
section->pageCount);
|
|
||||||
renderer.drawCenteredText(UI_12_FONT_ID, 300, "Out of bounds", true, EpdFontFamily::BOLD);
|
renderer.drawCenteredText(UI_12_FONT_ID, 300, "Out of bounds", true, EpdFontFamily::BOLD);
|
||||||
renderStatusBar(orientedMarginRight, orientedMarginBottom, orientedMarginLeft);
|
renderStatusBar(orientedMarginRight, orientedMarginBottom, orientedMarginLeft);
|
||||||
renderer.displayBuffer();
|
renderer.displayBuffer();
|
||||||
@ -826,7 +834,7 @@ void EpubReaderActivity::renderContents(std::unique_ptr<Page> page, const int or
|
|||||||
|
|
||||||
renderStatusBar(orientedMarginRight, orientedMarginBottom, orientedMarginLeft);
|
renderStatusBar(orientedMarginRight, orientedMarginBottom, orientedMarginLeft);
|
||||||
if (pagesUntilFullRefresh <= 1) {
|
if (pagesUntilFullRefresh <= 1) {
|
||||||
renderer.displayBuffer(EInkDisplay::HALF_REFRESH);
|
renderer.displayBuffer(HalDisplay::HALF_REFRESH);
|
||||||
pagesUntilFullRefresh = SETTINGS.getRefreshFrequency();
|
pagesUntilFullRefresh = SETTINGS.getRefreshFrequency();
|
||||||
} else {
|
} else {
|
||||||
renderer.displayBuffer();
|
renderer.displayBuffer();
|
||||||
@ -911,7 +919,8 @@ void EpubReaderActivity::renderStatusBar(const int orientedMarginRight, const in
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (showBattery) {
|
if (showBattery) {
|
||||||
ScreenComponents::drawBattery(renderer, orientedMarginLeft + 1, textY, showBatteryPercentage);
|
ScreenComponents::drawBattery(renderer, orientedMarginLeft + 1, textY, showBatteryPercentage,
|
||||||
|
mappedInput.isUsbConnected());
|
||||||
}
|
}
|
||||||
|
|
||||||
if (showChapterTitle) {
|
if (showChapterTitle) {
|
||||||
@ -919,7 +928,7 @@ void EpubReaderActivity::renderStatusBar(const int orientedMarginRight, const in
|
|||||||
// Page width minus existing content with 30px padding on each side
|
// Page width minus existing content with 30px padding on each side
|
||||||
const int rendererableScreenWidth = renderer.getScreenWidth() - orientedMarginLeft - orientedMarginRight;
|
const int rendererableScreenWidth = renderer.getScreenWidth() - orientedMarginLeft - orientedMarginRight;
|
||||||
|
|
||||||
const int batterySize = showBattery ? (showBatteryPercentage ? 50 : 20) : 0;
|
const int batterySize = showBattery ? (showBatteryPercentage ? 65 : 28) : 0;
|
||||||
const int titleMarginLeft = batterySize + 30;
|
const int titleMarginLeft = batterySize + 30;
|
||||||
const int titleMarginRight = progressTextWidth + 30;
|
const int titleMarginRight = progressTextWidth + 30;
|
||||||
|
|
||||||
@ -988,7 +997,7 @@ void EpubReaderActivity::renderEndOfBookPrompt() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Button hints
|
// Button hints
|
||||||
const auto labels = mappedInput.mapLabels("« Back", "Select", "", "");
|
const auto labels = mappedInput.mapLabels("« Back", "Select", "", "Start Over");
|
||||||
renderer.drawButtonHints(UI_10_FONT_ID, labels.btn1, labels.btn2, labels.btn3, labels.btn4);
|
renderer.drawButtonHints(UI_10_FONT_ID, labels.btn1, labels.btn2, labels.btn3, labels.btn4);
|
||||||
|
|
||||||
renderer.displayBuffer();
|
renderer.displayBuffer();
|
||||||
|
|||||||
@ -85,7 +85,7 @@ void TxtReaderActivity::onEnter() {
|
|||||||
renderer.fillRect(boxX, boxY, boxWidth, boxHeight, false);
|
renderer.fillRect(boxX, boxY, boxWidth, boxHeight, false);
|
||||||
renderer.drawRect(boxX + 5, boxY + 5, boxWidth - 10, boxHeight - 10);
|
renderer.drawRect(boxX + 5, boxY + 5, boxWidth - 10, boxHeight - 10);
|
||||||
renderer.drawText(UI_12_FONT_ID, boxX + boxMargin, boxY + boxMargin, "Preparing book... [0%]");
|
renderer.drawText(UI_12_FONT_ID, boxX + boxMargin, boxY + boxMargin, "Preparing book... [0%]");
|
||||||
renderer.displayBuffer(EInkDisplay::FAST_REFRESH);
|
renderer.displayBuffer(HalDisplay::FAST_REFRESH);
|
||||||
|
|
||||||
// Generate covers with progress callback
|
// Generate covers with progress callback
|
||||||
(void)txt->generateAllCovers([&](int percent) {
|
(void)txt->generateAllCovers([&](int percent) {
|
||||||
@ -99,7 +99,7 @@ void TxtReaderActivity::onEnter() {
|
|||||||
char progressStr[32];
|
char progressStr[32];
|
||||||
snprintf(progressStr, sizeof(progressStr), "Preparing book... [%d%%]", percent);
|
snprintf(progressStr, sizeof(progressStr), "Preparing book... [%d%%]", percent);
|
||||||
renderer.drawText(UI_12_FONT_ID, boxX + boxMargin, boxY + boxMargin, progressStr);
|
renderer.drawText(UI_12_FONT_ID, boxX + boxMargin, boxY + boxMargin, progressStr);
|
||||||
renderer.displayBuffer(EInkDisplay::FAST_REFRESH);
|
renderer.displayBuffer(HalDisplay::FAST_REFRESH);
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
@ -171,6 +171,14 @@ void TxtReaderActivity::loop() {
|
|||||||
updateRequired = true;
|
updateRequired = true;
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
if (mappedInput.wasReleased(MappedInputManager::Button::PageForward) ||
|
||||||
|
mappedInput.wasReleased(MappedInputManager::Button::Right)) {
|
||||||
|
// Start over from beginning
|
||||||
|
currentPage = 0;
|
||||||
|
showingEndOfBookPrompt = false;
|
||||||
|
updateRequired = true;
|
||||||
|
return;
|
||||||
|
}
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -331,7 +339,7 @@ void TxtReaderActivity::buildPageIndex() {
|
|||||||
// Fill progress bar
|
// Fill progress bar
|
||||||
const int fillWidth = (barWidth - 2) * progressPercent / 100;
|
const int fillWidth = (barWidth - 2) * progressPercent / 100;
|
||||||
renderer.fillRect(barX + 1, barY + 1, fillWidth, barHeight - 2, true);
|
renderer.fillRect(barX + 1, barY + 1, fillWidth, barHeight - 2, true);
|
||||||
renderer.displayBuffer(EInkDisplay::FAST_REFRESH);
|
renderer.displayBuffer(HalDisplay::FAST_REFRESH);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Yield to other tasks periodically
|
// Yield to other tasks periodically
|
||||||
@ -563,7 +571,7 @@ void TxtReaderActivity::renderPage() {
|
|||||||
renderStatusBar(orientedMarginRight, orientedMarginBottom, orientedMarginLeft);
|
renderStatusBar(orientedMarginRight, orientedMarginBottom, orientedMarginLeft);
|
||||||
|
|
||||||
if (pagesUntilFullRefresh <= 1) {
|
if (pagesUntilFullRefresh <= 1) {
|
||||||
renderer.displayBuffer(EInkDisplay::HALF_REFRESH);
|
renderer.displayBuffer(HalDisplay::HALF_REFRESH);
|
||||||
pagesUntilFullRefresh = SETTINGS.getRefreshFrequency();
|
pagesUntilFullRefresh = SETTINGS.getRefreshFrequency();
|
||||||
} else {
|
} else {
|
||||||
renderer.displayBuffer();
|
renderer.displayBuffer();
|
||||||
@ -634,11 +642,13 @@ void TxtReaderActivity::renderStatusBar(const int orientedMarginRight, const int
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (showBattery) {
|
if (showBattery) {
|
||||||
ScreenComponents::drawBattery(renderer, orientedMarginLeft, textY, showBatteryPercentage);
|
ScreenComponents::drawBattery(renderer, orientedMarginLeft, textY, showBatteryPercentage,
|
||||||
|
mappedInput.isUsbConnected());
|
||||||
}
|
}
|
||||||
|
|
||||||
if (showTitle) {
|
if (showTitle) {
|
||||||
const int titleMarginLeft = 50 + 30 + orientedMarginLeft;
|
const int batterySize = showBattery ? (showBatteryPercentage ? 65 : 28) : 0;
|
||||||
|
const int titleMarginLeft = batterySize + 30 + orientedMarginLeft;
|
||||||
const int titleMarginRight = progressTextWidth + 30 + orientedMarginRight;
|
const int titleMarginRight = progressTextWidth + 30 + orientedMarginRight;
|
||||||
const int availableTextWidth = renderer.getScreenWidth() - titleMarginLeft - titleMarginRight;
|
const int availableTextWidth = renderer.getScreenWidth() - titleMarginLeft - titleMarginRight;
|
||||||
|
|
||||||
@ -909,7 +919,7 @@ void TxtReaderActivity::renderEndOfBookPrompt() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Button hints
|
// Button hints
|
||||||
const auto labels = mappedInput.mapLabels("« Back", "Select", "", "");
|
const auto labels = mappedInput.mapLabels("« Back", "Select", "", "Start Over");
|
||||||
renderer.drawButtonHints(UI_10_FONT_ID, labels.btn1, labels.btn2, labels.btn3, labels.btn4);
|
renderer.drawButtonHints(UI_10_FONT_ID, labels.btn1, labels.btn2, labels.btn3, labels.btn4);
|
||||||
|
|
||||||
renderer.displayBuffer();
|
renderer.displayBuffer();
|
||||||
|
|||||||
@ -4,6 +4,9 @@
|
|||||||
#include <HardwareSerial.h>
|
#include <HardwareSerial.h>
|
||||||
#include <SDCardManager.h>
|
#include <SDCardManager.h>
|
||||||
|
|
||||||
|
#include <string>
|
||||||
|
#include <vector>
|
||||||
|
|
||||||
#include "MappedInputManager.h"
|
#include "MappedInputManager.h"
|
||||||
#include "activities/home/HomeActivity.h"
|
#include "activities/home/HomeActivity.h"
|
||||||
#include "activities/home/MyLibraryActivity.h"
|
#include "activities/home/MyLibraryActivity.h"
|
||||||
@ -19,6 +22,7 @@ void ClearCacheActivity::onEnter() {
|
|||||||
|
|
||||||
renderingMutex = xSemaphoreCreateMutex();
|
renderingMutex = xSemaphoreCreateMutex();
|
||||||
state = WARNING;
|
state = WARNING;
|
||||||
|
preserveProgress = true; // Default to preserving progress
|
||||||
updateRequired = true;
|
updateRequired = true;
|
||||||
|
|
||||||
xTaskCreate(&ClearCacheActivity::taskTrampoline, "ClearCacheActivityTask",
|
xTaskCreate(&ClearCacheActivity::taskTrampoline, "ClearCacheActivityTask",
|
||||||
@ -56,6 +60,7 @@ void ClearCacheActivity::displayTaskLoop() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
void ClearCacheActivity::render() {
|
void ClearCacheActivity::render() {
|
||||||
|
const auto pageWidth = renderer.getScreenWidth();
|
||||||
const auto pageHeight = renderer.getScreenHeight();
|
const auto pageHeight = renderer.getScreenHeight();
|
||||||
|
|
||||||
// Bezel compensation
|
// Bezel compensation
|
||||||
@ -67,11 +72,32 @@ void ClearCacheActivity::render() {
|
|||||||
renderer.drawCenteredText(UI_12_FONT_ID, 15 + bezelTop, "Clear Cache", true, EpdFontFamily::BOLD);
|
renderer.drawCenteredText(UI_12_FONT_ID, 15 + bezelTop, "Clear Cache", true, EpdFontFamily::BOLD);
|
||||||
|
|
||||||
if (state == WARNING) {
|
if (state == WARNING) {
|
||||||
renderer.drawCenteredText(UI_10_FONT_ID, centerY - 60, "This will clear all cached book data.", true);
|
renderer.drawCenteredText(UI_10_FONT_ID, centerY - 70, "This will clear all cached book data.", true);
|
||||||
renderer.drawCenteredText(UI_10_FONT_ID, centerY - 30, "All reading progress will be lost!", true,
|
renderer.drawCenteredText(UI_10_FONT_ID, centerY - 45, "Books will need to be re-indexed.", true);
|
||||||
EpdFontFamily::BOLD);
|
|
||||||
renderer.drawCenteredText(UI_10_FONT_ID, centerY + 10, "Books will need to be re-indexed", true);
|
// Preserve progress option
|
||||||
renderer.drawCenteredText(UI_10_FONT_ID, centerY + 30, "when opened again.", true);
|
renderer.drawCenteredText(UI_10_FONT_ID, centerY - 5, "Preserve reading progress?");
|
||||||
|
|
||||||
|
// Yes/No options
|
||||||
|
constexpr int optionLineHeight = 30;
|
||||||
|
constexpr int optionWidth = 80;
|
||||||
|
const int optionX = (pageWidth - optionWidth) / 2;
|
||||||
|
const int optionStartY = centerY + 25;
|
||||||
|
|
||||||
|
// Yes option
|
||||||
|
if (preserveProgress) {
|
||||||
|
renderer.fillRect(optionX - 10, optionStartY - 5, optionWidth + 20, optionLineHeight);
|
||||||
|
}
|
||||||
|
renderer.drawCenteredText(UI_10_FONT_ID, optionStartY, "Yes", !preserveProgress);
|
||||||
|
|
||||||
|
// No option
|
||||||
|
if (!preserveProgress) {
|
||||||
|
renderer.fillRect(optionX - 10, optionStartY + optionLineHeight - 5, optionWidth + 20, optionLineHeight);
|
||||||
|
}
|
||||||
|
renderer.drawCenteredText(UI_10_FONT_ID, optionStartY + optionLineHeight, "No", preserveProgress);
|
||||||
|
|
||||||
|
// Draw side button hints (up/down navigation)
|
||||||
|
renderer.drawSideButtonHints(UI_10_FONT_ID, ">", "<");
|
||||||
|
|
||||||
const auto labels = mappedInput.mapLabels("« Cancel", "Clear", "", "");
|
const auto labels = mappedInput.mapLabels("« Cancel", "Clear", "", "");
|
||||||
renderer.drawButtonHints(UI_10_FONT_ID, labels.btn1, labels.btn2, labels.btn3, labels.btn4);
|
renderer.drawButtonHints(UI_10_FONT_ID, labels.btn1, labels.btn2, labels.btn3, labels.btn4);
|
||||||
@ -110,8 +136,68 @@ void ClearCacheActivity::render() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
void ClearCacheActivity::clearCacheDirectory(const char* dirPath) {
|
||||||
|
// Helper to check if a file should be preserved
|
||||||
|
const auto shouldPreserve = [this](const char* name) {
|
||||||
|
if (!preserveProgress) return false;
|
||||||
|
// Preserve progress and bookmarks when preserveProgress is enabled
|
||||||
|
return (strcmp(name, "progress.bin") == 0 || strcmp(name, "bookmarks.bin") == 0);
|
||||||
|
};
|
||||||
|
|
||||||
|
FsFile dir = SdMan.open(dirPath);
|
||||||
|
if (!dir || !dir.isDirectory()) {
|
||||||
|
if (dir) dir.close();
|
||||||
|
failedCount++;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
char name[128];
|
||||||
|
std::vector<std::string> filesToDelete;
|
||||||
|
std::vector<std::string> dirsToDelete;
|
||||||
|
|
||||||
|
// First pass: collect files and directories to delete
|
||||||
|
for (FsFile entry = dir.openNextFile(); entry; entry = dir.openNextFile()) {
|
||||||
|
entry.getName(name, sizeof(name));
|
||||||
|
const bool isDir = entry.isDirectory();
|
||||||
|
entry.close();
|
||||||
|
|
||||||
|
std::string fullPath = std::string(dirPath) + "/" + name;
|
||||||
|
if (isDir) {
|
||||||
|
dirsToDelete.push_back(fullPath);
|
||||||
|
} else if (!shouldPreserve(name)) {
|
||||||
|
filesToDelete.push_back(fullPath);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
dir.close();
|
||||||
|
|
||||||
|
// Delete files
|
||||||
|
for (const auto& path : filesToDelete) {
|
||||||
|
if (SdMan.remove(path.c_str())) {
|
||||||
|
clearedCount++;
|
||||||
|
} else {
|
||||||
|
Serial.printf("[%lu] [CLEAR_CACHE] Failed to delete file: %s\n", millis(), path.c_str());
|
||||||
|
failedCount++;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Delete subdirectories (like "sections/")
|
||||||
|
for (const auto& path : dirsToDelete) {
|
||||||
|
if (SdMan.removeDir(path.c_str())) {
|
||||||
|
clearedCount++;
|
||||||
|
} else {
|
||||||
|
Serial.printf("[%lu] [CLEAR_CACHE] Failed to delete dir: %s\n", millis(), path.c_str());
|
||||||
|
failedCount++;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// If not preserving progress, try to remove the now-empty directory
|
||||||
|
if (!preserveProgress) {
|
||||||
|
SdMan.rmdir(dirPath); // This will fail if directory is not empty, which is fine
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
void ClearCacheActivity::clearCache() {
|
void ClearCacheActivity::clearCache() {
|
||||||
Serial.printf("[%lu] [CLEAR_CACHE] Clearing cache...\n", millis());
|
Serial.printf("[%lu] [CLEAR_CACHE] Clearing cache (preserveProgress=%d)...\n", millis(), preserveProgress);
|
||||||
|
|
||||||
// Open .crosspoint directory
|
// Open .crosspoint directory
|
||||||
auto root = SdMan.open("/.crosspoint");
|
auto root = SdMan.open("/.crosspoint");
|
||||||
@ -127,35 +213,32 @@ void ClearCacheActivity::clearCache() {
|
|||||||
failedCount = 0;
|
failedCount = 0;
|
||||||
char name[128];
|
char name[128];
|
||||||
|
|
||||||
// Iterate through all entries in the directory
|
// Collect all book cache directories first
|
||||||
|
std::vector<std::string> cacheDirs;
|
||||||
for (auto file = root.openNextFile(); file; file = root.openNextFile()) {
|
for (auto file = root.openNextFile(); file; file = root.openNextFile()) {
|
||||||
file.getName(name, sizeof(name));
|
file.getName(name, sizeof(name));
|
||||||
String itemName(name);
|
String itemName(name);
|
||||||
|
|
||||||
// Only delete directories starting with epub_ or txt_
|
// Only process directories starting with epub_ or txt_
|
||||||
if (file.isDirectory() && (itemName.startsWith("epub_") || itemName.startsWith("txt_"))) {
|
if (file.isDirectory() && (itemName.startsWith("epub_") || itemName.startsWith("txt_"))) {
|
||||||
String fullPath = "/.crosspoint/" + itemName;
|
cacheDirs.push_back("/.crosspoint/" + std::string(name));
|
||||||
Serial.printf("[%lu] [CLEAR_CACHE] Removing cache: %s\n", millis(), fullPath.c_str());
|
|
||||||
|
|
||||||
file.close(); // Close before attempting to delete
|
|
||||||
|
|
||||||
if (SdMan.removeDir(fullPath.c_str())) {
|
|
||||||
clearedCount++;
|
|
||||||
} else {
|
|
||||||
Serial.printf("[%lu] [CLEAR_CACHE] Failed to remove: %s\n", millis(), fullPath.c_str());
|
|
||||||
failedCount++;
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
file.close();
|
|
||||||
}
|
}
|
||||||
|
file.close();
|
||||||
}
|
}
|
||||||
root.close();
|
root.close();
|
||||||
|
|
||||||
|
// Now clear each cache directory
|
||||||
|
for (const auto& cacheDir : cacheDirs) {
|
||||||
|
Serial.printf("[%lu] [CLEAR_CACHE] Clearing: %s\n", millis(), cacheDir.c_str());
|
||||||
|
clearCacheDirectory(cacheDir.c_str());
|
||||||
|
}
|
||||||
|
|
||||||
// Also clear in-memory caches since disk cache is gone
|
// Also clear in-memory caches since disk cache is gone
|
||||||
HomeActivity::freeCoverBufferIfAllocated();
|
HomeActivity::freeCoverBufferIfAllocated();
|
||||||
MyLibraryActivity::clearThumbExistsCache();
|
MyLibraryActivity::clearThumbExistsCache();
|
||||||
|
|
||||||
Serial.printf("[%lu] [CLEAR_CACHE] Cache cleared: %d removed, %d failed\n", millis(), clearedCount, failedCount);
|
Serial.printf("[%lu] [CLEAR_CACHE] Cache cleared: %d items removed, %d failed\n", millis(), clearedCount,
|
||||||
|
failedCount);
|
||||||
|
|
||||||
state = SUCCESS;
|
state = SUCCESS;
|
||||||
updateRequired = true;
|
updateRequired = true;
|
||||||
@ -163,8 +246,17 @@ void ClearCacheActivity::clearCache() {
|
|||||||
|
|
||||||
void ClearCacheActivity::loop() {
|
void ClearCacheActivity::loop() {
|
||||||
if (state == WARNING) {
|
if (state == WARNING) {
|
||||||
|
// Up/Down toggle preserve progress option
|
||||||
|
if (mappedInput.wasPressed(MappedInputManager::Button::Up) ||
|
||||||
|
mappedInput.wasPressed(MappedInputManager::Button::Down)) {
|
||||||
|
preserveProgress = !preserveProgress;
|
||||||
|
updateRequired = true;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
if (mappedInput.wasPressed(MappedInputManager::Button::Confirm)) {
|
if (mappedInput.wasPressed(MappedInputManager::Button::Confirm)) {
|
||||||
Serial.printf("[%lu] [CLEAR_CACHE] User confirmed, starting cache clear\n", millis());
|
Serial.printf("[%lu] [CLEAR_CACHE] User confirmed (preserveProgress=%d), starting cache clear\n", millis(),
|
||||||
|
preserveProgress);
|
||||||
xSemaphoreTake(renderingMutex, portMAX_DELAY);
|
xSemaphoreTake(renderingMutex, portMAX_DELAY);
|
||||||
state = CLEARING;
|
state = CLEARING;
|
||||||
xSemaphoreGive(renderingMutex);
|
xSemaphoreGive(renderingMutex);
|
||||||
|
|||||||
@ -29,9 +29,11 @@ class ClearCacheActivity final : public ActivityWithSubactivity {
|
|||||||
|
|
||||||
int clearedCount = 0;
|
int clearedCount = 0;
|
||||||
int failedCount = 0;
|
int failedCount = 0;
|
||||||
|
bool preserveProgress = true; // Whether to keep progress.bin and bookmarks.bin
|
||||||
|
|
||||||
static void taskTrampoline(void* param);
|
static void taskTrampoline(void* param);
|
||||||
[[noreturn]] void displayTaskLoop();
|
[[noreturn]] void displayTaskLoop();
|
||||||
void render();
|
void render();
|
||||||
void clearCache();
|
void clearCache();
|
||||||
|
void clearCacheDirectory(const char* dirPath); // Helper to clear a single book's cache
|
||||||
};
|
};
|
||||||
|
|||||||
@ -15,7 +15,7 @@ namespace {
|
|||||||
// Visibility condition for bezel edge setting (only show when compensation > 0)
|
// Visibility condition for bezel edge setting (only show when compensation > 0)
|
||||||
bool isBezelCompensationEnabled() { return SETTINGS.bezelCompensation > 0; }
|
bool isBezelCompensationEnabled() { return SETTINGS.bezelCompensation > 0; }
|
||||||
|
|
||||||
constexpr int displaySettingsCount = 9;
|
constexpr int displaySettingsCount = 10;
|
||||||
const SettingInfo displaySettings[displaySettingsCount] = {
|
const SettingInfo displaySettings[displaySettingsCount] = {
|
||||||
// Should match with SLEEP_SCREEN_MODE
|
// Should match with SLEEP_SCREEN_MODE
|
||||||
SettingInfo::Enum("Sleep Screen", &CrossPointSettings::sleepScreen, {"Dark", "Light", "Custom", "Cover", "None"}),
|
SettingInfo::Enum("Sleep Screen", &CrossPointSettings::sleepScreen, {"Dark", "Light", "Custom", "Cover", "None"}),
|
||||||
@ -28,6 +28,7 @@ const SettingInfo displaySettings[displaySettingsCount] = {
|
|||||||
SettingInfo::Enum("Hide Battery %", &CrossPointSettings::hideBatteryPercentage, {"Never", "In Reader", "Always"}),
|
SettingInfo::Enum("Hide Battery %", &CrossPointSettings::hideBatteryPercentage, {"Never", "In Reader", "Always"}),
|
||||||
SettingInfo::Enum("Refresh Frequency", &CrossPointSettings::refreshFrequency,
|
SettingInfo::Enum("Refresh Frequency", &CrossPointSettings::refreshFrequency,
|
||||||
{"1 page", "5 pages", "10 pages", "15 pages", "30 pages"}),
|
{"1 page", "5 pages", "10 pages", "15 pages", "30 pages"}),
|
||||||
|
SettingInfo::Enum("Sunlight Fading Fix", &CrossPointSettings::fadingFix, {"OFF", "ON"}),
|
||||||
SettingInfo::Value("Bezel Compensation", &CrossPointSettings::bezelCompensation, {0, 10, 1}),
|
SettingInfo::Value("Bezel Compensation", &CrossPointSettings::bezelCompensation, {0, 10, 1}),
|
||||||
SettingInfo::Enum("Bezel Edge", &CrossPointSettings::bezelCompensationEdge, {"Bottom", "Top", "Left", "Right"},
|
SettingInfo::Enum("Bezel Edge", &CrossPointSettings::bezelCompensationEdge, {"Bottom", "Top", "Left", "Right"},
|
||||||
isBezelCompensationEnabled)};
|
isBezelCompensationEnabled)};
|
||||||
|
|||||||
@ -1,6 +1,6 @@
|
|||||||
#pragma once
|
#pragma once
|
||||||
#include <EInkDisplay.h>
|
|
||||||
#include <EpdFontFamily.h>
|
#include <EpdFontFamily.h>
|
||||||
|
#include <HalDisplay.h>
|
||||||
|
|
||||||
#include <string>
|
#include <string>
|
||||||
#include <utility>
|
#include <utility>
|
||||||
@ -10,12 +10,12 @@
|
|||||||
class FullScreenMessageActivity final : public Activity {
|
class FullScreenMessageActivity final : public Activity {
|
||||||
std::string text;
|
std::string text;
|
||||||
EpdFontFamily::Style style;
|
EpdFontFamily::Style style;
|
||||||
EInkDisplay::RefreshMode refreshMode;
|
HalDisplay::RefreshMode refreshMode;
|
||||||
|
|
||||||
public:
|
public:
|
||||||
explicit FullScreenMessageActivity(GfxRenderer& renderer, MappedInputManager& mappedInput, std::string text,
|
explicit FullScreenMessageActivity(GfxRenderer& renderer, MappedInputManager& mappedInput, std::string text,
|
||||||
const EpdFontFamily::Style style = EpdFontFamily::REGULAR,
|
const EpdFontFamily::Style style = EpdFontFamily::REGULAR,
|
||||||
const EInkDisplay::RefreshMode refreshMode = EInkDisplay::FAST_REFRESH)
|
const HalDisplay::RefreshMode refreshMode = HalDisplay::FAST_REFRESH)
|
||||||
: Activity("FullScreenMessage", renderer, mappedInput),
|
: Activity("FullScreenMessage", renderer, mappedInput),
|
||||||
text(std::move(text)),
|
text(std::move(text)),
|
||||||
style(style),
|
style(style),
|
||||||
|
|||||||
@ -1,7 +1,7 @@
|
|||||||
#include "KeyboardEntryActivity.h"
|
#include "KeyboardEntryActivity.h"
|
||||||
|
|
||||||
#include "activities/dictionary/DictionaryMargins.h"
|
|
||||||
#include "MappedInputManager.h"
|
#include "MappedInputManager.h"
|
||||||
|
#include "activities/dictionary/DictionaryMargins.h"
|
||||||
#include "fontIds.h"
|
#include "fontIds.h"
|
||||||
|
|
||||||
// Keyboard layouts - lowercase
|
// Keyboard layouts - lowercase
|
||||||
|
|||||||
@ -29,9 +29,9 @@ class QuickMenuActivity final : public Activity {
|
|||||||
const bool isPageBookmarked; // True if current page already has a bookmark
|
const bool isPageBookmarked; // True if current page already has a bookmark
|
||||||
|
|
||||||
// Edit mode state
|
// Edit mode state
|
||||||
bool editMode = false; // True when in edit mode
|
bool editMode = false; // True when in edit mode
|
||||||
int movingIndex = -1; // Index of item being moved (-1 if none)
|
int movingIndex = -1; // Index of item being moved (-1 if none)
|
||||||
uint8_t localOrder[5] = {0}; // Local copy of order for editing
|
uint8_t localOrder[5] = {0}; // Local copy of order for editing
|
||||||
|
|
||||||
static void taskTrampoline(void* param);
|
static void taskTrampoline(void* param);
|
||||||
[[noreturn]] void displayTaskLoop();
|
[[noreturn]] void displayTaskLoop();
|
||||||
|
|||||||
176
src/main.cpp
176
src/main.cpp
@ -1,9 +1,9 @@
|
|||||||
#include <Arduino.h>
|
#include <Arduino.h>
|
||||||
#include <BitmapHelpers.h>
|
#include <BitmapHelpers.h>
|
||||||
#include <EInkDisplay.h>
|
|
||||||
#include <Epub.h>
|
#include <Epub.h>
|
||||||
#include <GfxRenderer.h>
|
#include <GfxRenderer.h>
|
||||||
#include <InputManager.h>
|
#include <HalDisplay.h>
|
||||||
|
#include <HalGPIO.h>
|
||||||
#include <SDCardManager.h>
|
#include <SDCardManager.h>
|
||||||
#include <SPI.h>
|
#include <SPI.h>
|
||||||
#include <builtinFonts/all.h>
|
#include <builtinFonts/all.h>
|
||||||
@ -32,23 +32,10 @@
|
|||||||
#include "fontIds.h"
|
#include "fontIds.h"
|
||||||
#include "images/LockIcon.h"
|
#include "images/LockIcon.h"
|
||||||
|
|
||||||
#define SPI_FQ 40000000
|
HalDisplay display;
|
||||||
// Display SPI pins (custom pins for XteinkX4, not hardware SPI defaults)
|
HalGPIO gpio;
|
||||||
#define EPD_SCLK 8 // SPI Clock
|
MappedInputManager mappedInputManager(gpio);
|
||||||
#define EPD_MOSI 10 // SPI MOSI (Master Out Slave In)
|
GfxRenderer renderer(display);
|
||||||
#define EPD_CS 21 // Chip Select
|
|
||||||
#define EPD_DC 4 // Data/Command
|
|
||||||
#define EPD_RST 5 // Reset
|
|
||||||
#define EPD_BUSY 6 // Busy
|
|
||||||
|
|
||||||
#define UART0_RXD 20 // Used for USB connection detection
|
|
||||||
|
|
||||||
#define SD_SPI_MISO 7
|
|
||||||
|
|
||||||
EInkDisplay einkDisplay(EPD_SCLK, EPD_MOSI, EPD_CS, EPD_DC, EPD_RST, EPD_BUSY);
|
|
||||||
InputManager inputManager;
|
|
||||||
MappedInputManager mappedInputManager(inputManager);
|
|
||||||
GfxRenderer renderer(einkDisplay);
|
|
||||||
Activity* currentActivity;
|
Activity* currentActivity;
|
||||||
|
|
||||||
// Fonts
|
// Fonts
|
||||||
@ -130,11 +117,14 @@ void logMemoryState(const char* tag, const char* context) {
|
|||||||
#define logMemoryState(tag, context) ((void)0)
|
#define logMemoryState(tag, context) ((void)0)
|
||||||
#endif
|
#endif
|
||||||
|
|
||||||
// Flash command detection - receives "FLASH\n" from pre_flash.py script
|
// Flash command detection - receives "FLASH:version\n" from pre_flash.py script
|
||||||
|
// Plan A: Simple polling - host sends command, device checks when Serial is connected
|
||||||
static String flashCmdBuffer;
|
static String flashCmdBuffer;
|
||||||
|
|
||||||
void checkForFlashCommand() {
|
void checkForFlashCommand() {
|
||||||
if (!Serial) return; // Early exit if Serial not initialized
|
// Only check when Serial is connected (host has port open)
|
||||||
|
if (!Serial) return;
|
||||||
|
|
||||||
while (Serial.available()) {
|
while (Serial.available()) {
|
||||||
char c = Serial.read();
|
char c = Serial.read();
|
||||||
if (c == '\n') {
|
if (c == '\n') {
|
||||||
@ -165,56 +155,51 @@ void checkForFlashCommand() {
|
|||||||
const int screenH = renderer.getScreenHeight();
|
const int screenH = renderer.getScreenHeight();
|
||||||
|
|
||||||
// Show current version in bottom-left corner (orientation-aware)
|
// Show current version in bottom-left corner (orientation-aware)
|
||||||
// "Bottom-left" is relative to the current orientation
|
|
||||||
constexpr int versionMargin = 10;
|
constexpr int versionMargin = 10;
|
||||||
const int textWidth = renderer.getTextWidth(SMALL_FONT_ID, CROSSPOINT_VERSION);
|
const int textWidth = renderer.getTextWidth(SMALL_FONT_ID, CROSSPOINT_VERSION);
|
||||||
int versionX, versionY;
|
int versionX, versionY;
|
||||||
switch (renderer.getOrientation()) {
|
switch (renderer.getOrientation()) {
|
||||||
case GfxRenderer::Portrait: // Bottom-left is actual bottom-left
|
case GfxRenderer::Portrait:
|
||||||
versionX = versionMargin;
|
versionX = versionMargin;
|
||||||
versionY = screenH - 30;
|
versionY = screenH - 30;
|
||||||
break;
|
break;
|
||||||
case GfxRenderer::PortraitInverted: // Bottom-left is actual top-right
|
case GfxRenderer::PortraitInverted:
|
||||||
versionX = screenW - textWidth - versionMargin;
|
versionX = screenW - textWidth - versionMargin;
|
||||||
versionY = 20;
|
versionY = 20;
|
||||||
break;
|
break;
|
||||||
case GfxRenderer::LandscapeClockwise: // Bottom-left is actual bottom-right
|
case GfxRenderer::LandscapeClockwise:
|
||||||
versionX = screenW - textWidth - versionMargin;
|
versionX = screenW - textWidth - versionMargin;
|
||||||
versionY = screenH - 30;
|
versionY = screenH - 30;
|
||||||
break;
|
break;
|
||||||
case GfxRenderer::LandscapeCounterClockwise: // Bottom-left is actual bottom-left
|
case GfxRenderer::LandscapeCounterClockwise:
|
||||||
versionX = versionMargin;
|
versionX = versionMargin;
|
||||||
versionY = screenH - 30;
|
versionY = screenH - 30;
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
renderer.drawText(SMALL_FONT_ID, versionX, versionY, CROSSPOINT_VERSION, false);
|
renderer.drawText(SMALL_FONT_ID, versionX, versionY, CROSSPOINT_VERSION, false);
|
||||||
|
|
||||||
// Position and rotate lock icon based on current orientation (USB port location)
|
// Position and rotate lock icon based on current orientation
|
||||||
// USB port locations: Portrait=bottom-left, PortraitInverted=top-right,
|
constexpr int edgeMargin = 28;
|
||||||
// LandscapeCW=top-left, LandscapeCCW=bottom-right
|
constexpr int halfWidth = LOCK_ICON_WIDTH / 2;
|
||||||
// Position offsets: edge margin + half-width offset to center on USB port
|
|
||||||
constexpr int edgeMargin = 28; // Distance from screen edge
|
|
||||||
constexpr int halfWidth = LOCK_ICON_WIDTH / 2; // 16px offset for centering
|
|
||||||
int iconX, iconY;
|
int iconX, iconY;
|
||||||
GfxRenderer::ImageRotation rotation;
|
GfxRenderer::ImageRotation rotation;
|
||||||
// Note: 90/270 rotation swaps output dimensions (W<->H)
|
|
||||||
switch (renderer.getOrientation()) {
|
switch (renderer.getOrientation()) {
|
||||||
case GfxRenderer::Portrait: // USB at bottom-left, shackle points right
|
case GfxRenderer::Portrait:
|
||||||
rotation = GfxRenderer::ROTATE_90;
|
rotation = GfxRenderer::ROTATE_90;
|
||||||
iconX = edgeMargin;
|
iconX = edgeMargin;
|
||||||
iconY = screenH - LOCK_ICON_WIDTH - edgeMargin - halfWidth;
|
iconY = screenH - LOCK_ICON_WIDTH - edgeMargin - halfWidth;
|
||||||
break;
|
break;
|
||||||
case GfxRenderer::PortraitInverted: // USB at top-right, shackle points left
|
case GfxRenderer::PortraitInverted:
|
||||||
rotation = GfxRenderer::ROTATE_270;
|
rotation = GfxRenderer::ROTATE_270;
|
||||||
iconX = screenW - LOCK_ICON_HEIGHT - edgeMargin;
|
iconX = screenW - LOCK_ICON_HEIGHT - edgeMargin;
|
||||||
iconY = edgeMargin + halfWidth;
|
iconY = edgeMargin + halfWidth;
|
||||||
break;
|
break;
|
||||||
case GfxRenderer::LandscapeClockwise: // USB at top-left, shackle points down
|
case GfxRenderer::LandscapeClockwise:
|
||||||
rotation = GfxRenderer::ROTATE_180;
|
rotation = GfxRenderer::ROTATE_180;
|
||||||
iconX = edgeMargin + halfWidth;
|
iconX = edgeMargin + halfWidth;
|
||||||
iconY = edgeMargin;
|
iconY = edgeMargin;
|
||||||
break;
|
break;
|
||||||
case GfxRenderer::LandscapeCounterClockwise: // USB at bottom-right, shackle points up
|
case GfxRenderer::LandscapeCounterClockwise:
|
||||||
rotation = GfxRenderer::ROTATE_0;
|
rotation = GfxRenderer::ROTATE_0;
|
||||||
iconX = screenW - LOCK_ICON_WIDTH - edgeMargin - halfWidth;
|
iconX = screenW - LOCK_ICON_WIDTH - edgeMargin - halfWidth;
|
||||||
iconY = screenH - LOCK_ICON_HEIGHT - edgeMargin;
|
iconY = screenH - LOCK_ICON_HEIGHT - edgeMargin;
|
||||||
@ -222,13 +207,13 @@ void checkForFlashCommand() {
|
|||||||
}
|
}
|
||||||
renderer.drawImageRotated(LockIcon, iconX, iconY, LOCK_ICON_WIDTH, LOCK_ICON_HEIGHT, rotation);
|
renderer.drawImageRotated(LockIcon, iconX, iconY, LOCK_ICON_WIDTH, LOCK_ICON_HEIGHT, rotation);
|
||||||
|
|
||||||
renderer.displayBuffer(EInkDisplay::FAST_REFRESH);
|
// Use full refresh for clean display before flash overwrites firmware
|
||||||
|
renderer.displayBuffer(HalDisplay::HALF_REFRESH);
|
||||||
}
|
}
|
||||||
flashCmdBuffer = "";
|
flashCmdBuffer = "";
|
||||||
} else if (c != '\r') {
|
} else if (c != '\r') {
|
||||||
flashCmdBuffer += c;
|
flashCmdBuffer += c;
|
||||||
// Prevent buffer overflow from random serial data (increased for version info)
|
if (flashCmdBuffer.length() > 50) {
|
||||||
if (flashCmdBuffer.length() > 30) {
|
|
||||||
flashCmdBuffer = "";
|
flashCmdBuffer = "";
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@ -274,21 +259,20 @@ void verifyPowerButtonDuration() {
|
|||||||
const uint16_t calibratedPressDuration =
|
const uint16_t calibratedPressDuration =
|
||||||
(calibration < SETTINGS.getPowerButtonDuration()) ? SETTINGS.getPowerButtonDuration() - calibration : 1;
|
(calibration < SETTINGS.getPowerButtonDuration()) ? SETTINGS.getPowerButtonDuration() - calibration : 1;
|
||||||
|
|
||||||
inputManager.update();
|
gpio.update();
|
||||||
// Verify the user has actually pressed
|
|
||||||
// Needed because inputManager.isPressed() may take up to ~500ms to return the correct state
|
// Needed because inputManager.isPressed() may take up to ~500ms to return the correct state
|
||||||
while (!inputManager.isPressed(InputManager::BTN_POWER) && millis() - start < 1000) {
|
while (!gpio.isPressed(HalGPIO::BTN_POWER) && millis() - start < 1000) {
|
||||||
delay(10); // only wait 10ms each iteration to not delay too much in case of short configured duration.
|
delay(10); // only wait 10ms each iteration to not delay too much in case of short configured duration.
|
||||||
inputManager.update();
|
gpio.update();
|
||||||
}
|
}
|
||||||
|
|
||||||
t2 = millis();
|
t2 = millis();
|
||||||
if (inputManager.isPressed(InputManager::BTN_POWER)) {
|
if (gpio.isPressed(HalGPIO::BTN_POWER)) {
|
||||||
do {
|
do {
|
||||||
delay(10);
|
delay(10);
|
||||||
inputManager.update();
|
gpio.update();
|
||||||
} while (inputManager.isPressed(InputManager::BTN_POWER) && inputManager.getHeldTime() < calibratedPressDuration);
|
} while (gpio.isPressed(HalGPIO::BTN_POWER) && gpio.getHeldTime() < calibratedPressDuration);
|
||||||
abort = inputManager.getHeldTime() < calibratedPressDuration;
|
abort = gpio.getHeldTime() < calibratedPressDuration;
|
||||||
} else {
|
} else {
|
||||||
abort = true;
|
abort = true;
|
||||||
}
|
}
|
||||||
@ -296,16 +280,15 @@ 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
|
||||||
esp_deep_sleep_enable_gpio_wakeup(1ULL << InputManager::POWER_BUTTON_PIN, ESP_GPIO_WAKEUP_GPIO_LOW);
|
gpio.startDeepSleep();
|
||||||
esp_deep_sleep_start();
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
void waitForPowerRelease() {
|
void waitForPowerRelease() {
|
||||||
inputManager.update();
|
gpio.update();
|
||||||
while (inputManager.isPressed(InputManager::BTN_POWER)) {
|
while (gpio.isPressed(HalGPIO::BTN_POWER)) {
|
||||||
delay(50);
|
delay(50);
|
||||||
inputManager.update();
|
gpio.update();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -314,14 +297,11 @@ void enterDeepSleep() {
|
|||||||
exitActivity();
|
exitActivity();
|
||||||
enterNewActivity(new SleepActivity(renderer, mappedInputManager));
|
enterNewActivity(new SleepActivity(renderer, mappedInputManager));
|
||||||
|
|
||||||
einkDisplay.deepSleep();
|
display.deepSleep();
|
||||||
Serial.printf("[%lu] [ ] Power button press calibration value: %lu ms\n", millis(), t2 - t1);
|
Serial.printf("[%lu] [ ] Power button press calibration value: %lu ms\n", millis(), t2 - t1);
|
||||||
Serial.printf("[%lu] [ ] Entering deep sleep.\n", millis());
|
Serial.printf("[%lu] [ ] Entering deep sleep.\n", millis());
|
||||||
esp_deep_sleep_enable_gpio_wakeup(1ULL << InputManager::POWER_BUTTON_PIN, ESP_GPIO_WAKEUP_GPIO_LOW);
|
|
||||||
// Ensure that the power button has been released to avoid immediately turning back on if you're holding it
|
gpio.startDeepSleep();
|
||||||
waitForPowerRelease();
|
|
||||||
// Enter Deep Sleep
|
|
||||||
esp_deep_sleep_start();
|
|
||||||
}
|
}
|
||||||
|
|
||||||
void onGoHome();
|
void onGoHome();
|
||||||
@ -381,6 +361,16 @@ void onGoToListsOrPinned() {
|
|||||||
|
|
||||||
void onGoToFileTransfer() {
|
void onGoToFileTransfer() {
|
||||||
exitActivity();
|
exitActivity();
|
||||||
|
|
||||||
|
// Free memory not needed during file transfer to maximize heap for webserver
|
||||||
|
RECENT_BOOKS.clearFromMemory();
|
||||||
|
APP_STATE.openBookTitle.clear();
|
||||||
|
APP_STATE.openBookTitle.shrink_to_fit();
|
||||||
|
APP_STATE.openBookAuthor.clear();
|
||||||
|
APP_STATE.openBookAuthor.shrink_to_fit();
|
||||||
|
HomeActivity::freeCoverBufferIfAllocated(); // Free 48KB cover buffer
|
||||||
|
Serial.printf("[%lu] [FT] Cleared non-essential memory before File Transfer\n", millis());
|
||||||
|
|
||||||
enterNewActivity(new CrossPointWebServerActivity(renderer, mappedInputManager, onGoHome));
|
enterNewActivity(new CrossPointWebServerActivity(renderer, mappedInputManager, onGoHome));
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -396,12 +386,24 @@ void onGoToClearCache() {
|
|||||||
|
|
||||||
void onGoToMyLibrary() {
|
void onGoToMyLibrary() {
|
||||||
exitActivity();
|
exitActivity();
|
||||||
|
|
||||||
|
// Reload recent books if they were cleared (e.g., when exiting File Transfer mode)
|
||||||
|
if (RECENT_BOOKS.getCount() == 0) {
|
||||||
|
RECENT_BOOKS.loadFromFile();
|
||||||
|
}
|
||||||
|
|
||||||
enterNewActivity(
|
enterNewActivity(
|
||||||
new MyLibraryActivity(renderer, mappedInputManager, onGoHome, onGoToReader, onGoToListView, onGoToBookmarkList));
|
new MyLibraryActivity(renderer, mappedInputManager, onGoHome, onGoToReader, onGoToListView, onGoToBookmarkList));
|
||||||
}
|
}
|
||||||
|
|
||||||
void onGoToMyLibraryWithTab(const std::string& path, MyLibraryActivity::Tab tab) {
|
void onGoToMyLibraryWithTab(const std::string& path, MyLibraryActivity::Tab tab) {
|
||||||
exitActivity();
|
exitActivity();
|
||||||
|
|
||||||
|
// Reload recent books if they were cleared (e.g., when exiting File Transfer mode)
|
||||||
|
if (RECENT_BOOKS.getCount() == 0) {
|
||||||
|
RECENT_BOOKS.loadFromFile();
|
||||||
|
}
|
||||||
|
|
||||||
enterNewActivity(new MyLibraryActivity(renderer, mappedInputManager, onGoHome, onGoToReader, onGoToListView,
|
enterNewActivity(new MyLibraryActivity(renderer, mappedInputManager, onGoHome, onGoToReader, onGoToListView,
|
||||||
onGoToBookmarkList, tab, path));
|
onGoToBookmarkList, tab, path));
|
||||||
}
|
}
|
||||||
@ -413,12 +415,18 @@ void onGoToBrowser() {
|
|||||||
|
|
||||||
void onGoHome() {
|
void onGoHome() {
|
||||||
exitActivity();
|
exitActivity();
|
||||||
|
|
||||||
|
// Reload recent books if they were cleared (e.g., when exiting File Transfer mode)
|
||||||
|
if (RECENT_BOOKS.getCount() == 0) {
|
||||||
|
RECENT_BOOKS.loadFromFile();
|
||||||
|
}
|
||||||
|
|
||||||
enterNewActivity(new HomeActivity(renderer, mappedInputManager, onContinueReading, onGoToListsOrPinned,
|
enterNewActivity(new HomeActivity(renderer, mappedInputManager, onContinueReading, onGoToListsOrPinned,
|
||||||
onGoToMyLibrary, onGoToSettings, onGoToFileTransfer, onGoToBrowser));
|
onGoToMyLibrary, onGoToSettings, onGoToFileTransfer, onGoToBrowser));
|
||||||
}
|
}
|
||||||
|
|
||||||
void setupDisplayAndFonts() {
|
void setupDisplayAndFonts() {
|
||||||
einkDisplay.begin();
|
display.begin();
|
||||||
Serial.printf("[%lu] [ ] Display initialized\n", millis());
|
Serial.printf("[%lu] [ ] Display initialized\n", millis());
|
||||||
renderer.insertFont(BOOKERLY_14_FONT_ID, bookerly14FontFamily);
|
renderer.insertFont(BOOKERLY_14_FONT_ID, bookerly14FontFamily);
|
||||||
#ifndef OMIT_FONTS
|
#ifndef OMIT_FONTS
|
||||||
@ -440,42 +448,24 @@ void setupDisplayAndFonts() {
|
|||||||
Serial.printf("[%lu] [ ] Fonts setup\n", millis());
|
Serial.printf("[%lu] [ ] Fonts setup\n", millis());
|
||||||
}
|
}
|
||||||
|
|
||||||
bool isUsbConnected() {
|
|
||||||
// U0RXD/GPIO20 reads HIGH when USB is connected
|
|
||||||
return digitalRead(UART0_RXD) == HIGH;
|
|
||||||
}
|
|
||||||
|
|
||||||
bool isWakeupByPowerButton() {
|
|
||||||
const auto wakeupCause = esp_sleep_get_wakeup_cause();
|
|
||||||
const auto resetReason = esp_reset_reason();
|
|
||||||
if (isUsbConnected()) {
|
|
||||||
return wakeupCause == ESP_SLEEP_WAKEUP_GPIO;
|
|
||||||
} else {
|
|
||||||
return (wakeupCause == ESP_SLEEP_WAKEUP_UNDEFINED) && (resetReason == ESP_RST_POWERON);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
void setup() {
|
void setup() {
|
||||||
t1 = millis();
|
t1 = millis();
|
||||||
|
|
||||||
// Only start serial if USB connected
|
gpio.begin();
|
||||||
pinMode(UART0_RXD, INPUT);
|
|
||||||
if (isUsbConnected()) {
|
// Always initialize Serial - safe on ESP32-C3 USB CDC even without USB connected
|
||||||
Serial.begin(115200);
|
// (the peripheral just remains idle).
|
||||||
// Wait up to 3 seconds for Serial to be ready to catch early logs
|
Serial.begin(115200);
|
||||||
|
|
||||||
|
// Only wait for terminal connection if USB is physically connected
|
||||||
|
// This allows catching early debug logs when a serial monitor is attached
|
||||||
|
if (gpio.isUsbConnected()) {
|
||||||
unsigned long start = millis();
|
unsigned long start = millis();
|
||||||
while (!Serial && (millis() - start) < 3000) {
|
while (!Serial && (millis() - start) < 3000) {
|
||||||
delay(10);
|
delay(10);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
inputManager.begin();
|
|
||||||
// Initialize pins
|
|
||||||
pinMode(BAT_GPIO0, INPUT);
|
|
||||||
|
|
||||||
// Initialize SPI with custom pins
|
|
||||||
SPI.begin(EPD_SCLK, SD_SPI_MISO, EPD_MOSI, EPD_CS);
|
|
||||||
|
|
||||||
// SD Card Initialization
|
// SD Card Initialization
|
||||||
// We need 6 open files concurrently when parsing a new chapter
|
// We need 6 open files concurrently when parsing a new chapter
|
||||||
if (!SdMan.begin()) {
|
if (!SdMan.begin()) {
|
||||||
@ -492,7 +482,7 @@ void setup() {
|
|||||||
// Apply bezel compensation from settings
|
// Apply bezel compensation from settings
|
||||||
renderer.setBezelCompensation(SETTINGS.bezelCompensation, SETTINGS.bezelCompensationEdge);
|
renderer.setBezelCompensation(SETTINGS.bezelCompensation, SETTINGS.bezelCompensationEdge);
|
||||||
|
|
||||||
if (isWakeupByPowerButton()) {
|
if (gpio.isWakeupByPowerButton()) {
|
||||||
// For normal wakeups, verify power button press duration
|
// For normal wakeups, verify power button press duration
|
||||||
Serial.printf("[%lu] [ ] Verifying power button press duration\n", millis());
|
Serial.printf("[%lu] [ ] Verifying power button press duration\n", millis());
|
||||||
verifyPowerButtonDuration();
|
verifyPowerButtonDuration();
|
||||||
@ -532,7 +522,9 @@ void loop() {
|
|||||||
const unsigned long loopStartTime = millis();
|
const unsigned long loopStartTime = millis();
|
||||||
static unsigned long lastMemPrint = 0;
|
static unsigned long lastMemPrint = 0;
|
||||||
|
|
||||||
inputManager.update();
|
gpio.update();
|
||||||
|
|
||||||
|
renderer.setFadingFix(SETTINGS.fadingFix);
|
||||||
|
|
||||||
if (Serial && millis() - lastMemPrint >= 10000) {
|
if (Serial && millis() - lastMemPrint >= 10000) {
|
||||||
// Basic heap info
|
// Basic heap info
|
||||||
@ -550,8 +542,7 @@ 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 (inputManager.wasAnyPressed() || inputManager.wasAnyReleased() ||
|
if (gpio.wasAnyPressed() || gpio.wasAnyReleased() || (currentActivity && currentActivity->preventAutoSleep())) {
|
||||||
(currentActivity && currentActivity->preventAutoSleep())) {
|
|
||||||
lastActivityTime = millis(); // Reset inactivity timer
|
lastActivityTime = millis(); // Reset inactivity timer
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -563,8 +554,7 @@ void loop() {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (inputManager.isPressed(InputManager::BTN_POWER) &&
|
if (gpio.isPressed(HalGPIO::BTN_POWER) && gpio.getHeldTime() > SETTINGS.getPowerButtonDuration()) {
|
||||||
inputManager.getHeldTime() > SETTINGS.getPowerButtonDuration()) {
|
|
||||||
enterDeepSleep();
|
enterDeepSleep();
|
||||||
// This should never be hit as `enterDeepSleep` calls esp_deep_sleep_start
|
// This should never be hit as `enterDeepSleep` calls esp_deep_sleep_start
|
||||||
return;
|
return;
|
||||||
|
|||||||
@ -371,27 +371,10 @@ void CrossPointWebServer::scanFiles(const char* path, const std::function<void(F
|
|||||||
if (info.isDirectory) {
|
if (info.isDirectory) {
|
||||||
info.size = 0;
|
info.size = 0;
|
||||||
info.isEpub = false;
|
info.isEpub = false;
|
||||||
// md5 remains empty for directories
|
|
||||||
} else {
|
} else {
|
||||||
info.size = file.size();
|
info.size = file.size();
|
||||||
info.isEpub = isEpubFile(info.name);
|
info.isEpub = isEpubFile(info.name);
|
||||||
|
// MD5 not included in listing - clients can request via /api/hash endpoint
|
||||||
// For EPUBs, try to get cached MD5 hash
|
|
||||||
if (info.isEpub) {
|
|
||||||
// Build full file path
|
|
||||||
String fullPath = String(path);
|
|
||||||
if (!fullPath.endsWith("/")) {
|
|
||||||
fullPath += "/";
|
|
||||||
}
|
|
||||||
fullPath += fileName;
|
|
||||||
|
|
||||||
const std::string cachedMd5 =
|
|
||||||
Md5Utils::getCachedMd5(fullPath.c_str(), BookManager::CROSSPOINT_DIR, info.size);
|
|
||||||
if (!cachedMd5.empty()) {
|
|
||||||
info.md5 = String(cachedMd5.c_str());
|
|
||||||
}
|
|
||||||
// If not cached, md5 remains empty (companion app can request via /api/hash)
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
callback(info);
|
callback(info);
|
||||||
@ -435,55 +418,69 @@ void CrossPointWebServer::handleFileListData() const {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Check if we should show hidden files
|
// Check if we should show hidden files (fork addition)
|
||||||
bool showHidden = false;
|
bool showHidden = server->hasArg("showHidden") && server->arg("showHidden") == "true";
|
||||||
if (server->hasArg("showHidden")) {
|
|
||||||
showHidden = server->arg("showHidden") == "true";
|
|
||||||
}
|
|
||||||
|
|
||||||
server->setContentLength(CONTENT_LENGTH_UNKNOWN);
|
server->setContentLength(CONTENT_LENGTH_UNKNOWN);
|
||||||
server->send(200, "application/json", "");
|
server->send(200, "application/json", "");
|
||||||
server->sendContent("[");
|
|
||||||
char output[512];
|
// Batch JSON entries to reduce number of sendContent calls
|
||||||
|
// This helps prevent TCP buffer overflow on memory-constrained systems
|
||||||
|
constexpr size_t BATCH_SIZE = 2048;
|
||||||
|
char batch[BATCH_SIZE];
|
||||||
|
size_t batchPos = 0;
|
||||||
|
batch[batchPos++] = '[';
|
||||||
|
|
||||||
|
char output[256]; // Single entry buffer (reduced from 512)
|
||||||
constexpr size_t outputSize = sizeof(output);
|
constexpr size_t outputSize = sizeof(output);
|
||||||
bool seenFirst = false;
|
bool seenFirst = false;
|
||||||
JsonDocument doc;
|
JsonDocument doc;
|
||||||
|
|
||||||
scanFiles(
|
scanFiles(
|
||||||
currentPath.c_str(),
|
currentPath.c_str(),
|
||||||
[this, &output, &doc, seenFirst](const FileInfo& info) mutable {
|
[this, &batch, &batchPos, &output, &doc, &seenFirst](const FileInfo& info) mutable {
|
||||||
doc.clear();
|
doc.clear();
|
||||||
doc["name"] = info.name;
|
doc["name"] = info.name;
|
||||||
doc["size"] = info.size;
|
doc["size"] = info.size;
|
||||||
doc["isDirectory"] = info.isDirectory;
|
doc["isDirectory"] = info.isDirectory;
|
||||||
doc["isEpub"] = info.isEpub;
|
doc["isEpub"] = info.isEpub;
|
||||||
|
|
||||||
// Include md5 field for EPUBs (null if not cached, hash string if available)
|
|
||||||
if (info.isEpub) {
|
|
||||||
if (info.md5.isEmpty()) {
|
|
||||||
doc["md5"] = nullptr; // JSON null
|
|
||||||
} else {
|
|
||||||
doc["md5"] = info.md5;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const size_t written = serializeJson(doc, output, outputSize);
|
const size_t written = serializeJson(doc, output, outputSize);
|
||||||
if (written >= outputSize) {
|
if (written >= outputSize) {
|
||||||
// JSON output truncated; skip this entry to avoid sending malformed JSON
|
|
||||||
Serial.printf("[%lu] [WEB] Skipping file entry with oversized JSON for name: %s\n", millis(),
|
Serial.printf("[%lu] [WEB] Skipping file entry with oversized JSON for name: %s\n", millis(),
|
||||||
info.name.c_str());
|
info.name.c_str());
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Calculate space needed: comma (if not first) + entry
|
||||||
|
const size_t needed = (seenFirst ? 1 : 0) + written;
|
||||||
|
|
||||||
|
// If batch would overflow, send it first
|
||||||
|
if (batchPos + needed >= BATCH_SIZE - 1) {
|
||||||
|
batch[batchPos] = '\0';
|
||||||
|
server->sendContent(batch);
|
||||||
|
delay(5); // Brief delay between batch sends
|
||||||
|
batchPos = 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Add comma separator if not first entry
|
||||||
if (seenFirst) {
|
if (seenFirst) {
|
||||||
server->sendContent(",");
|
batch[batchPos++] = ',';
|
||||||
} else {
|
} else {
|
||||||
seenFirst = true;
|
seenFirst = true;
|
||||||
}
|
}
|
||||||
server->sendContent(output);
|
|
||||||
|
// Copy entry to batch
|
||||||
|
memcpy(batch + batchPos, output, written);
|
||||||
|
batchPos += written;
|
||||||
},
|
},
|
||||||
showHidden);
|
showHidden);
|
||||||
server->sendContent("]");
|
|
||||||
|
// Send remaining batch with closing bracket
|
||||||
|
batch[batchPos++] = ']';
|
||||||
|
batch[batchPos] = '\0';
|
||||||
|
server->sendContent(batch);
|
||||||
|
|
||||||
// End of streamed response, empty chunk to signal client
|
// End of streamed response, empty chunk to signal client
|
||||||
server->sendContent("");
|
server->sendContent("");
|
||||||
Serial.printf("[%lu] [WEB] Served file listing page for path: %s\n", millis(), currentPath.c_str());
|
Serial.printf("[%lu] [WEB] Served file listing page for path: %s\n", millis(), currentPath.c_str());
|
||||||
@ -1264,6 +1261,16 @@ void CrossPointWebServer::handleRename() const {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
bool CrossPointWebServer::sendContentSafe(const char* content) const {
|
||||||
|
if (!server || !server->client().connected()) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
server->sendContent(content);
|
||||||
|
return server->client().connected();
|
||||||
|
}
|
||||||
|
|
||||||
|
bool CrossPointWebServer::sendContentSafe(const String& content) const { return sendContentSafe(content.c_str()); }
|
||||||
|
|
||||||
bool CrossPointWebServer::copyFile(const String& srcPath, const String& destPath) const {
|
bool CrossPointWebServer::copyFile(const String& srcPath, const String& destPath) const {
|
||||||
FsFile srcFile;
|
FsFile srcFile;
|
||||||
FsFile destFile;
|
FsFile destFile;
|
||||||
|
|||||||
@ -14,7 +14,6 @@ struct FileInfo {
|
|||||||
size_t size;
|
size_t size;
|
||||||
bool isEpub;
|
bool isEpub;
|
||||||
bool isDirectory;
|
bool isDirectory;
|
||||||
String md5; // MD5 hash for EPUBs (empty if not cached/available)
|
|
||||||
};
|
};
|
||||||
|
|
||||||
class CrossPointWebServer {
|
class CrossPointWebServer {
|
||||||
@ -108,6 +107,11 @@ class CrossPointWebServer {
|
|||||||
bool copyFile(const String& srcPath, const String& destPath) const;
|
bool copyFile(const String& srcPath, const String& destPath) const;
|
||||||
bool copyFolder(const String& srcPath, const String& destPath) const;
|
bool copyFolder(const String& srcPath, const String& destPath) const;
|
||||||
|
|
||||||
|
// Helper for safe content sending with connection check
|
||||||
|
// Returns false if client disconnected, true otherwise
|
||||||
|
bool sendContentSafe(const char* content) const;
|
||||||
|
bool sendContentSafe(const String& content) const;
|
||||||
|
|
||||||
// List management handlers
|
// List management handlers
|
||||||
void handleListGet() const;
|
void handleListGet() const;
|
||||||
void handleListPost() const;
|
void handleListPost() const;
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user