Compare commits

..

2 Commits

Author SHA1 Message Date
dangson
140d8749a6
Support swapping the functionality of the front buttons (#133)
Some checks are pending
CI / build (push) Waiting to run
## Summary

**What is the goal of this PR?** 

Adds a setting to swap the front buttons. The default functionality are:
Back/Confirm/Left/Right. When this setting is enabled they become:
Left/Right/Back/Confirm. This makes it more comfortable to use when
holding in your right hand since your thumb can more easily rest on the
next button. The original firmware has a similar setting.

**What changes are included?**

- Add the new setting.
- Create a mapper to dynamically switch the buttons based on the
setting.
- Use mapper on the various activity screens.
- Update the button hints to reflect the swapped buttons.

## Additional Context

Full disclosure: I used Codex CLI to put this PR together, but did
review it to make sure it makes sense.

Also tested on my device:
https://share.cleanshot.com/k76891NY
2025-12-29 14:59:14 +11:00
Dave Allie
534504cf7a
Consolidate chapter page data into single file (#144)
## Summary

* Consolidate chapter page data into single file
* Header structure of the file stays the same, following the page count,
we now put a LUT offset
   * The page data is all then appended to this file
* Finally the LUT is appended to the end of the file, and the page count
is updated
* This will also significantly improve the duration of cache cleanup
which takes a while to scan the directory and cleanup content
* Remove page file version as it's all tied up into the section file now
* Bumped section file version to 7
* Moved section content into sub directory
* Updated docs

## Additional Context

* Benchmarks:
  * Generating 74 pages of content from a chapter in Jade Legacy took:
    * master: 6,229ms
    * this PR: 1,305ms
    * Speedup of 79%
  * Generating 207 pages of content from Livesuit book:
    * With progress bar UI updates:
      * master: 24,250ms
      * this PR: 8,063ms
      * Speedup of 67%
    * Without progress bar UI updates:
      * master: 13,055ms
      * this PR: 3,600ms
      * Speedup of 72%
2025-12-29 13:19:54 +11:00
45 changed files with 447 additions and 283 deletions

View File

@ -98,9 +98,9 @@ CrossPoint Reader is pretty aggressive about caching data down to the SD card to
has ~380KB of usable RAM, so we have to be careful. A lot of the decisions made in the design of the firmware were based has ~380KB of usable RAM, so we have to be careful. A lot of the decisions made in the design of the firmware were based
on this constraint. on this constraint.
### EPUB caching ### Data caching
The first time chapters of an EPUB are loaded, they are cached to the SD card. Subsequent loads are served from the The first time chapters of a book are loaded, they are cached to the SD card. Subsequent loads are served from the
cache. This cache directory exists at `.crosspoint` on the SD card. The structure is as follows: cache. This cache directory exists at `.crosspoint` on the SD card. The structure is as follows:
@ -108,25 +108,22 @@ cache. This cache directory exists at `.crosspoint` on the SD card. The structur
.crosspoint/ .crosspoint/
├── epub_12471232/ # Each EPUB is cached to a subdirectory named `epub_<hash>` ├── epub_12471232/ # Each EPUB is cached to a subdirectory named `epub_<hash>`
│ ├── progress.bin # Stores reading progress (chapter, page, etc.) │ ├── progress.bin # Stores reading progress (chapter, page, etc.)
│ ├── 0/ # Each chapter is stored in a subdirectory named by its index (based on the spine order) │ ├── cover.bmp # Book cover image (once generated)
│ │ ├── section.bin # Section metadata (page count) │ ├── book.bin # Book metadata (title, author, spine, table of contents, etc.)
│ │ ├── page_0.bin # Each page is stored in a separate file, it │ └── sections/ # All chapter data is stored in the sections subdirectory
│ │ ├── page_1.bin # contains the position (x, y) and text for each word │ ├── 0.bin # Chapter data (screen count, all text layout info, etc.)
│ │ └── ... │ ├── 1.bin # files are named by their index in the spine
│ ├── 1/ │ └── ...
│ │ ├── section.bin
│ │ ├── page_0.bin
│ │ ├── page_1.bin
│ │ └── ...
│ └── ...
└── epub_189013891/ └── epub_189013891/
``` ```
Deleting the `.crosspoint` directory will clear the cache. Deleting the `.crosspoint` directory will clear the entire cache.
Due the way it's currently implemented, the cache is not automatically cleared when the EPUB is deleted and moving an Due the way it's currently implemented, the cache is not automatically cleared when a book is deleted and moving a book
EPUB file will reset the reading progress. file will use a new cache directory, resetting the reading progress.
For more details on the internal file structures, see the [file formats document](./docs/file-formats.md).
## Contributing ## Contributing

View File

@ -67,6 +67,7 @@ The Settings screen allows you to configure the device's behavior. There are a f
- **Extra Paragraph Spacing**: If enabled, vertical space will be added between paragraphs in the book, if disabled, - **Extra Paragraph Spacing**: If enabled, vertical space will be added between paragraphs in the book, if disabled,
paragraphs will not have vertical space between them, but will have first word indentation. paragraphs will not have vertical space between them, but will have first word indentation.
- **Short Power Button Click**: Whether to trigger the power button on a short press or a long press. - **Short Power Button Click**: Whether to trigger the power button on a short press or a long press.
- **Front Button Layout**: Swap the order of the bottom edge buttons from Back/Confirm/Left/Right to Left/Right/Back/Confirm.
### 3.6 Sleep Screen ### 3.6 Sleep Screen

9
docs/file-formats.md Normal file
View File

@ -0,0 +1,9 @@
# File Formats
## `book.bin`
![](./images/file-formats/book-bin.png)
## `section.bin`
![](./images/file-formats/section-bin.png)

Binary file not shown.

After

Width:  |  Height:  |  Size: 539 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 296 KiB

View File

@ -3,20 +3,16 @@
#include <HardwareSerial.h> #include <HardwareSerial.h>
#include <Serialization.h> #include <Serialization.h>
namespace {
constexpr uint8_t PAGE_FILE_VERSION = 3;
}
void PageLine::render(GfxRenderer& renderer, const int fontId, const int xOffset, const int yOffset) { void PageLine::render(GfxRenderer& renderer, const int fontId, const int xOffset, const int yOffset) {
block->render(renderer, fontId, xPos + xOffset, yPos + yOffset); block->render(renderer, fontId, xPos + xOffset, yPos + yOffset);
} }
void PageLine::serialize(File& file) { bool PageLine::serialize(File& file) {
serialization::writePod(file, xPos); serialization::writePod(file, xPos);
serialization::writePod(file, yPos); serialization::writePod(file, yPos);
// serialize TextBlock pointed to by PageLine // serialize TextBlock pointed to by PageLine
block->serialize(file); return block->serialize(file);
} }
std::unique_ptr<PageLine> PageLine::deserialize(File& file) { std::unique_ptr<PageLine> PageLine::deserialize(File& file) {
@ -35,27 +31,22 @@ void Page::render(GfxRenderer& renderer, const int fontId, const int xOffset, co
} }
} }
void Page::serialize(File& file) const { bool Page::serialize(File& file) const {
serialization::writePod(file, PAGE_FILE_VERSION);
const uint32_t count = elements.size(); const uint32_t count = elements.size();
serialization::writePod(file, count); serialization::writePod(file, count);
for (const auto& el : elements) { for (const auto& el : elements) {
// Only PageLine exists currently // Only PageLine exists currently
serialization::writePod(file, static_cast<uint8_t>(TAG_PageLine)); serialization::writePod(file, static_cast<uint8_t>(TAG_PageLine));
el->serialize(file); if (!el->serialize(file)) {
return false;
}
} }
return true;
} }
std::unique_ptr<Page> Page::deserialize(File& file) { std::unique_ptr<Page> Page::deserialize(File& file) {
uint8_t version;
serialization::readPod(file, version);
if (version != PAGE_FILE_VERSION) {
Serial.printf("[%lu] [PGE] Deserialization failed: Unknown version %u\n", millis(), version);
return nullptr;
}
auto page = std::unique_ptr<Page>(new Page()); auto page = std::unique_ptr<Page>(new Page());
uint32_t count; uint32_t count;

View File

@ -18,7 +18,7 @@ class PageElement {
explicit PageElement(const int16_t xPos, const int16_t yPos) : xPos(xPos), yPos(yPos) {} explicit PageElement(const int16_t xPos, const int16_t yPos) : xPos(xPos), yPos(yPos) {}
virtual ~PageElement() = default; virtual ~PageElement() = default;
virtual void render(GfxRenderer& renderer, int fontId, int xOffset, int yOffset) = 0; virtual void render(GfxRenderer& renderer, int fontId, int xOffset, int yOffset) = 0;
virtual void serialize(File& file) = 0; virtual bool serialize(File& file) = 0;
}; };
// a line from a block element // a line from a block element
@ -29,7 +29,7 @@ class PageLine final : public PageElement {
PageLine(std::shared_ptr<TextBlock> block, const int16_t xPos, const int16_t yPos) PageLine(std::shared_ptr<TextBlock> block, const int16_t xPos, const int16_t yPos)
: PageElement(xPos, yPos), block(std::move(block)) {} : PageElement(xPos, yPos), block(std::move(block)) {}
void render(GfxRenderer& renderer, int fontId, int xOffset, int yOffset) override; void render(GfxRenderer& renderer, int fontId, int xOffset, int yOffset) override;
void serialize(File& file) override; bool serialize(File& file) override;
static std::unique_ptr<PageLine> deserialize(File& file); static std::unique_ptr<PageLine> deserialize(File& file);
}; };
@ -38,6 +38,6 @@ class Page {
// the list of block index and line numbers on this page // the list of block index and line numbers on this page
std::vector<std::shared_ptr<PageElement>> elements; std::vector<std::shared_ptr<PageElement>> elements;
void render(GfxRenderer& renderer, int fontId, int xOffset, int yOffset) const; void render(GfxRenderer& renderer, int fontId, int xOffset, int yOffset) const;
void serialize(File& file) const; bool serialize(File& file) const;
static std::unique_ptr<Page> deserialize(File& file); static std::unique_ptr<Page> deserialize(File& file);
}; };

View File

@ -8,54 +8,60 @@
#include "parsers/ChapterHtmlSlimParser.h" #include "parsers/ChapterHtmlSlimParser.h"
namespace { namespace {
constexpr uint8_t SECTION_FILE_VERSION = 6; constexpr uint8_t SECTION_FILE_VERSION = 7;
constexpr size_t HEADER_SIZE = sizeof(uint8_t) + sizeof(int) + sizeof(float) + sizeof(bool) + sizeof(int) +
sizeof(int) + sizeof(int) + sizeof(size_t);
} // namespace } // namespace
void Section::onPageComplete(std::unique_ptr<Page> page) { size_t Section::onPageComplete(std::unique_ptr<Page> page) {
const auto filePath = cachePath + "/page_" + std::to_string(pageCount) + ".bin"; if (!file) {
Serial.printf("[%lu] [SCT] File not open for writing page %d\n", millis(), pageCount);
File outputFile; return 0;
if (!FsHelpers::openFileForWrite("SCT", filePath, outputFile)) {
return;
} }
page->serialize(outputFile);
outputFile.close();
const auto position = file.position();
if (!page->serialize(file)) {
Serial.printf("[%lu] [SCT] Failed to serialize page %d\n", millis(), pageCount);
return 0;
}
Serial.printf("[%lu] [SCT] Page %d processed\n", millis(), pageCount); Serial.printf("[%lu] [SCT] Page %d processed\n", millis(), pageCount);
pageCount++; pageCount++;
return position;
} }
void Section::writeCacheMetadata(const int fontId, const float lineCompression, const bool extraParagraphSpacing, void Section::writeSectionFileHeader(const int fontId, const float lineCompression, const bool extraParagraphSpacing,
const int viewportWidth, const int viewportHeight) const { const int viewportWidth, const int viewportHeight) {
File outputFile; if (!file) {
if (!FsHelpers::openFileForWrite("SCT", cachePath + "/section.bin", outputFile)) { Serial.printf("[%lu] [SCT] File not open for writing header\n", millis());
return; return;
} }
serialization::writePod(outputFile, SECTION_FILE_VERSION); static_assert(HEADER_SIZE == sizeof(SECTION_FILE_VERSION) + sizeof(fontId) + sizeof(lineCompression) +
serialization::writePod(outputFile, fontId); sizeof(extraParagraphSpacing) + sizeof(viewportWidth) + sizeof(viewportHeight) +
serialization::writePod(outputFile, lineCompression); sizeof(pageCount) + sizeof(size_t),
serialization::writePod(outputFile, extraParagraphSpacing); "Header size mismatch");
serialization::writePod(outputFile, viewportWidth); serialization::writePod(file, SECTION_FILE_VERSION);
serialization::writePod(outputFile, viewportHeight); serialization::writePod(file, fontId);
serialization::writePod(outputFile, pageCount); serialization::writePod(file, lineCompression);
outputFile.close(); serialization::writePod(file, extraParagraphSpacing);
serialization::writePod(file, viewportWidth);
serialization::writePod(file, viewportHeight);
serialization::writePod(file, pageCount); // Placeholder for page count (will be initially 0 when written)
serialization::writePod(file, static_cast<size_t>(0)); // Placeholder for LUT offset
} }
bool Section::loadCacheMetadata(const int fontId, const float lineCompression, const bool extraParagraphSpacing, bool Section::loadSectionFile(const int fontId, const float lineCompression, const bool extraParagraphSpacing,
const int viewportWidth, const int viewportHeight) { const int viewportWidth, const int viewportHeight) {
const auto sectionFilePath = cachePath + "/section.bin"; if (!FsHelpers::openFileForRead("SCT", filePath, file)) {
File inputFile;
if (!FsHelpers::openFileForRead("SCT", sectionFilePath, inputFile)) {
return false; return false;
} }
// Match parameters // Match parameters
{ {
uint8_t version; uint8_t version;
serialization::readPod(inputFile, version); serialization::readPod(file, version);
if (version != SECTION_FILE_VERSION) { if (version != SECTION_FILE_VERSION) {
inputFile.close(); file.close();
Serial.printf("[%lu] [SCT] Deserialization failed: Unknown version %u\n", millis(), version); Serial.printf("[%lu] [SCT] Deserialization failed: Unknown version %u\n", millis(), version);
clearCache(); clearCache();
return false; return false;
@ -64,41 +70,36 @@ bool Section::loadCacheMetadata(const int fontId, const float lineCompression, c
int fileFontId, fileViewportWidth, fileViewportHeight; int fileFontId, fileViewportWidth, fileViewportHeight;
float fileLineCompression; float fileLineCompression;
bool fileExtraParagraphSpacing; bool fileExtraParagraphSpacing;
serialization::readPod(inputFile, fileFontId); serialization::readPod(file, fileFontId);
serialization::readPod(inputFile, fileLineCompression); serialization::readPod(file, fileLineCompression);
serialization::readPod(inputFile, fileExtraParagraphSpacing); serialization::readPod(file, fileExtraParagraphSpacing);
serialization::readPod(inputFile, fileViewportWidth); serialization::readPod(file, fileViewportWidth);
serialization::readPod(inputFile, fileViewportHeight); serialization::readPod(file, fileViewportHeight);
if (fontId != fileFontId || lineCompression != fileLineCompression || if (fontId != fileFontId || lineCompression != fileLineCompression ||
extraParagraphSpacing != fileExtraParagraphSpacing || viewportWidth != fileViewportWidth || extraParagraphSpacing != fileExtraParagraphSpacing || viewportWidth != fileViewportWidth ||
viewportHeight != fileViewportHeight) { viewportHeight != fileViewportHeight) {
inputFile.close(); file.close();
Serial.printf("[%lu] [SCT] Deserialization failed: Parameters do not match\n", millis()); Serial.printf("[%lu] [SCT] Deserialization failed: Parameters do not match\n", millis());
clearCache(); clearCache();
return false; return false;
} }
} }
serialization::readPod(inputFile, pageCount); serialization::readPod(file, pageCount);
inputFile.close(); file.close();
Serial.printf("[%lu] [SCT] Deserialization succeeded: %d pages\n", millis(), pageCount); Serial.printf("[%lu] [SCT] Deserialization succeeded: %d pages\n", millis(), pageCount);
return true; return true;
} }
void Section::setupCacheDir() const {
epub->setupCacheDir();
SD.mkdir(cachePath.c_str());
}
// Your updated class method (assuming you are using the 'SD' object, which is a wrapper for a specific filesystem) // Your updated class method (assuming you are using the 'SD' object, which is a wrapper for a specific filesystem)
bool Section::clearCache() const { bool Section::clearCache() const {
if (!SD.exists(cachePath.c_str())) { if (!SD.exists(filePath.c_str())) {
Serial.printf("[%lu] [SCT] Cache does not exist, no action needed\n", millis()); Serial.printf("[%lu] [SCT] Cache does not exist, no action needed\n", millis());
return true; return true;
} }
if (!FsHelpers::removeDir(cachePath.c_str())) { if (!SD.remove(filePath.c_str())) {
Serial.printf("[%lu] [SCT] Failed to clear cache\n", millis()); Serial.printf("[%lu] [SCT] Failed to clear cache\n", millis());
return false; return false;
} }
@ -107,10 +108,10 @@ bool Section::clearCache() const {
return true; return true;
} }
bool Section::persistPageDataToSD(const int fontId, const float lineCompression, const bool extraParagraphSpacing, bool Section::createSectionFile(const int fontId, const float lineCompression, const bool extraParagraphSpacing,
const int viewportWidth, const int viewportHeight, const int viewportWidth, const int viewportHeight,
const std::function<void()>& progressSetupFn, const std::function<void()>& progressSetupFn,
const std::function<void(int)>& progressFn) { const std::function<void(int)>& progressFn) {
constexpr size_t MIN_SIZE_FOR_PROGRESS = 50 * 1024; // 50KB constexpr size_t MIN_SIZE_FOR_PROGRESS = 50 * 1024; // 50KB
const auto localPath = epub->getSpineItem(spineIndex).href; const auto localPath = epub->getSpineItem(spineIndex).href;
const auto tmpHtmlPath = epub->getCachePath() + "/.tmp_" + std::to_string(spineIndex) + ".html"; const auto tmpHtmlPath = epub->getCachePath() + "/.tmp_" + std::to_string(spineIndex) + ".html";
@ -156,30 +157,66 @@ bool Section::persistPageDataToSD(const int fontId, const float lineCompression,
progressSetupFn(); progressSetupFn();
} }
if (!FsHelpers::openFileForWrite("SCT", filePath, file)) {
return false;
}
writeSectionFileHeader(fontId, lineCompression, extraParagraphSpacing, viewportWidth, viewportHeight);
std::vector<size_t> lut = {};
ChapterHtmlSlimParser visitor( ChapterHtmlSlimParser visitor(
tmpHtmlPath, renderer, fontId, lineCompression, extraParagraphSpacing, viewportWidth, viewportHeight, tmpHtmlPath, renderer, fontId, lineCompression, extraParagraphSpacing, viewportWidth, viewportHeight,
[this](std::unique_ptr<Page> page) { this->onPageComplete(std::move(page)); }, progressFn); [this, &lut](std::unique_ptr<Page> page) { lut.emplace_back(this->onPageComplete(std::move(page))); },
progressFn);
success = visitor.parseAndBuildPages(); success = visitor.parseAndBuildPages();
SD.remove(tmpHtmlPath.c_str()); SD.remove(tmpHtmlPath.c_str());
if (!success) { if (!success) {
Serial.printf("[%lu] [SCT] Failed to parse XML and build pages\n", millis()); Serial.printf("[%lu] [SCT] Failed to parse XML and build pages\n", millis());
file.close();
SD.remove(filePath.c_str());
return false; return false;
} }
writeCacheMetadata(fontId, lineCompression, extraParagraphSpacing, viewportWidth, viewportHeight); const auto lutOffset = file.position();
bool hasFailedLutRecords = false;
// Write LUT
for (const auto& pos : lut) {
if (pos == 0) {
hasFailedLutRecords = true;
break;
}
serialization::writePod(file, pos);
}
if (hasFailedLutRecords) {
Serial.printf("[%lu] [SCT] Failed to write LUT due to invalid page positions\n", millis());
file.close();
SD.remove(filePath.c_str());
return false;
}
// Go back and write LUT offset
file.seek(HEADER_SIZE - sizeof(size_t) - sizeof(pageCount));
serialization::writePod(file, pageCount);
serialization::writePod(file, lutOffset);
file.close();
return true; return true;
} }
std::unique_ptr<Page> Section::loadPageFromSD() const { std::unique_ptr<Page> Section::loadPageFromSectionFile() {
const auto filePath = cachePath + "/page_" + std::to_string(currentPage) + ".bin"; if (!FsHelpers::openFileForRead("SCT", filePath, file)) {
File inputFile;
if (!FsHelpers::openFileForRead("SCT", filePath, inputFile)) {
return nullptr; return nullptr;
} }
auto page = Page::deserialize(inputFile);
inputFile.close(); file.seek(HEADER_SIZE - sizeof(size_t));
size_t lutOffset;
serialization::readPod(file, lutOffset);
file.seek(lutOffset + sizeof(size_t) * currentPage);
size_t pagePos;
serialization::readPod(file, pagePos);
file.seek(pagePos);
auto page = Page::deserialize(file);
file.close();
return page; return page;
} }

View File

@ -11,11 +11,12 @@ class Section {
std::shared_ptr<Epub> epub; std::shared_ptr<Epub> epub;
const int spineIndex; const int spineIndex;
GfxRenderer& renderer; GfxRenderer& renderer;
std::string cachePath; std::string filePath;
File file;
void writeCacheMetadata(int fontId, float lineCompression, bool extraParagraphSpacing, int viewportWidth, void writeSectionFileHeader(int fontId, float lineCompression, bool extraParagraphSpacing, int viewportWidth,
int viewportHeight) const; int viewportHeight);
void onPageComplete(std::unique_ptr<Page> page); size_t onPageComplete(std::unique_ptr<Page> page);
public: public:
int pageCount = 0; int pageCount = 0;
@ -25,14 +26,13 @@ class Section {
: epub(epub), : epub(epub),
spineIndex(spineIndex), spineIndex(spineIndex),
renderer(renderer), renderer(renderer),
cachePath(epub->getCachePath() + "/" + std::to_string(spineIndex)) {} filePath(epub->getCachePath() + "/sections/" + std::to_string(spineIndex) + ".bin") {}
~Section() = default; ~Section() = default;
bool loadCacheMetadata(int fontId, float lineCompression, bool extraParagraphSpacing, int viewportWidth, bool loadSectionFile(int fontId, float lineCompression, bool extraParagraphSpacing, int viewportWidth,
int viewportHeight); int viewportHeight);
void setupCacheDir() const;
bool clearCache() const; bool clearCache() const;
bool persistPageDataToSD(int fontId, float lineCompression, bool extraParagraphSpacing, int viewportWidth, bool createSectionFile(int fontId, float lineCompression, bool extraParagraphSpacing, int viewportWidth,
int viewportHeight, const std::function<void()>& progressSetupFn = nullptr, int viewportHeight, const std::function<void()>& progressSetupFn = nullptr,
const std::function<void(int)>& progressFn = nullptr); const std::function<void(int)>& progressFn = nullptr);
std::unique_ptr<Page> loadPageFromSD() const; std::unique_ptr<Page> loadPageFromSectionFile();
}; };

View File

@ -24,34 +24,33 @@ void TextBlock::render(const GfxRenderer& renderer, const int fontId, const int
} }
} }
void TextBlock::serialize(File& file) const { bool TextBlock::serialize(File& file) const {
// words if (words.size() != wordXpos.size() || words.size() != wordStyles.size()) {
const uint32_t wc = words.size(); Serial.printf("[%lu] [TXB] Serialization failed: size mismatch (words=%u, xpos=%u, styles=%u)\n", millis(),
serialization::writePod(file, wc); words.size(), wordXpos.size(), wordStyles.size());
return false;
}
// Word data
serialization::writePod(file, static_cast<uint32_t>(words.size()));
for (const auto& w : words) serialization::writeString(file, w); for (const auto& w : words) serialization::writeString(file, w);
// wordXpos
const uint32_t xc = wordXpos.size();
serialization::writePod(file, xc);
for (auto x : wordXpos) serialization::writePod(file, x); for (auto x : wordXpos) serialization::writePod(file, x);
// wordStyles
const uint32_t sc = wordStyles.size();
serialization::writePod(file, sc);
for (auto s : wordStyles) serialization::writePod(file, s); for (auto s : wordStyles) serialization::writePod(file, s);
// style // Block style
serialization::writePod(file, style); serialization::writePod(file, style);
return true;
} }
std::unique_ptr<TextBlock> TextBlock::deserialize(File& file) { std::unique_ptr<TextBlock> TextBlock::deserialize(File& file) {
uint32_t wc, xc, sc; uint32_t wc;
std::list<std::string> words; std::list<std::string> words;
std::list<uint16_t> wordXpos; std::list<uint16_t> wordXpos;
std::list<EpdFontStyle> wordStyles; std::list<EpdFontStyle> wordStyles;
BLOCK_STYLE style; BLOCK_STYLE style;
// words // Word count
serialization::readPod(file, wc); serialization::readPod(file, wc);
// Sanity check: prevent allocation of unreasonably large lists (max 10000 words per block) // Sanity check: prevent allocation of unreasonably large lists (max 10000 words per block)
@ -60,27 +59,15 @@ std::unique_ptr<TextBlock> TextBlock::deserialize(File& file) {
return nullptr; return nullptr;
} }
// Word data
words.resize(wc); words.resize(wc);
wordXpos.resize(wc);
wordStyles.resize(wc);
for (auto& w : words) serialization::readString(file, w); for (auto& w : words) serialization::readString(file, w);
// wordXpos
serialization::readPod(file, xc);
wordXpos.resize(xc);
for (auto& x : wordXpos) serialization::readPod(file, x); for (auto& x : wordXpos) serialization::readPod(file, x);
// wordStyles
serialization::readPod(file, sc);
wordStyles.resize(sc);
for (auto& s : wordStyles) serialization::readPod(file, s); for (auto& s : wordStyles) serialization::readPod(file, s);
// Validate data consistency: all three lists must have the same size // Block style
if (wc != xc || wc != sc) {
Serial.printf("[%lu] [TXB] Deserialization failed: size mismatch (words=%u, xpos=%u, styles=%u)\n", millis(), wc,
xc, sc);
return nullptr;
}
// style
serialization::readPod(file, style); serialization::readPod(file, style);
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));

View File

@ -36,6 +36,6 @@ class TextBlock final : public Block {
// given a renderer works out where to break the words into lines // given a renderer works out where to break the words into lines
void render(const GfxRenderer& renderer, int fontId, int x, int y) const; void render(const GfxRenderer& renderer, int fontId, int x, int y) const;
BlockType getType() override { return TEXT_BLOCK; } BlockType getType() override { return TEXT_BLOCK; }
void serialize(File& file) const; bool serialize(File& file) const;
static std::unique_ptr<TextBlock> deserialize(File& file); static std::unique_ptr<TextBlock> deserialize(File& file);
}; };

View File

@ -11,7 +11,7 @@ CrossPointSettings CrossPointSettings::instance;
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 = 5; constexpr uint8_t SETTINGS_COUNT = 6;
constexpr char SETTINGS_FILE[] = "/.crosspoint/settings.bin"; constexpr char SETTINGS_FILE[] = "/.crosspoint/settings.bin";
} // namespace } // namespace
@ -31,6 +31,7 @@ bool CrossPointSettings::saveToFile() const {
serialization::writePod(outputFile, shortPwrBtn); serialization::writePod(outputFile, shortPwrBtn);
serialization::writePod(outputFile, statusBar); serialization::writePod(outputFile, statusBar);
serialization::writePod(outputFile, orientation); serialization::writePod(outputFile, orientation);
serialization::writePod(outputFile, frontButtonLayout);
outputFile.close(); outputFile.close();
Serial.printf("[%lu] [CPS] Settings saved to file\n", millis()); Serial.printf("[%lu] [CPS] Settings saved to file\n", millis());
@ -67,6 +68,8 @@ bool CrossPointSettings::loadFromFile() {
if (++settingsRead >= fileSettingsCount) break; if (++settingsRead >= fileSettingsCount) break;
serialization::readPod(inputFile, orientation); serialization::readPod(inputFile, orientation);
if (++settingsRead >= fileSettingsCount) break; if (++settingsRead >= fileSettingsCount) break;
serialization::readPod(inputFile, frontButtonLayout);
if (++settingsRead >= fileSettingsCount) break;
} while (false); } while (false);
inputFile.close(); inputFile.close();

View File

@ -28,6 +28,11 @@ class CrossPointSettings {
LANDSCAPE_CCW = 3 // 800x480 logical coordinates, native panel orientation LANDSCAPE_CCW = 3 // 800x480 logical coordinates, native panel orientation
}; };
// Front button layout options
// Default: Back, Confirm, Left, Right
// Swapped: Left, Right, Back, Confirm
enum FRONT_BUTTON_LAYOUT { BACK_CONFIRM_LEFT_RIGHT = 0, LEFT_RIGHT_BACK_CONFIRM = 1 };
// Sleep screen settings // Sleep screen settings
uint8_t sleepScreen = DARK; uint8_t sleepScreen = DARK;
// Status bar settings // Status bar settings
@ -39,6 +44,8 @@ class CrossPointSettings {
// EPUB reading orientation settings // EPUB reading orientation settings
// 0 = portrait (default), 1 = landscape clockwise, 2 = inverted, 3 = landscape counter-clockwise // 0 = portrait (default), 1 = landscape clockwise, 2 = inverted, 3 = landscape counter-clockwise
uint8_t orientation = PORTRAIT; uint8_t orientation = PORTRAIT;
// Front button layout
uint8_t frontButtonLayout = BACK_CONFIRM_LEFT_RIGHT;
~CrossPointSettings() = default; ~CrossPointSettings() = default;

View File

@ -0,0 +1,77 @@
#include "MappedInputManager.h"
decltype(InputManager::BTN_BACK) MappedInputManager::mapButton(const Button button) const {
const auto layout = static_cast<CrossPointSettings::FRONT_BUTTON_LAYOUT>(SETTINGS.frontButtonLayout);
switch (button) {
case Button::Back:
switch (layout) {
case CrossPointSettings::LEFT_RIGHT_BACK_CONFIRM:
return InputManager::BTN_LEFT;
case CrossPointSettings::BACK_CONFIRM_LEFT_RIGHT:
default:
return InputManager::BTN_BACK;
}
case Button::Confirm:
switch (layout) {
case CrossPointSettings::LEFT_RIGHT_BACK_CONFIRM:
return InputManager::BTN_RIGHT;
case CrossPointSettings::BACK_CONFIRM_LEFT_RIGHT:
default:
return InputManager::BTN_CONFIRM;
}
case Button::Left:
switch (layout) {
case CrossPointSettings::LEFT_RIGHT_BACK_CONFIRM:
return InputManager::BTN_BACK;
case CrossPointSettings::BACK_CONFIRM_LEFT_RIGHT:
default:
return InputManager::BTN_LEFT;
}
case Button::Right:
switch (layout) {
case CrossPointSettings::LEFT_RIGHT_BACK_CONFIRM:
return InputManager::BTN_CONFIRM;
case CrossPointSettings::BACK_CONFIRM_LEFT_RIGHT:
default:
return InputManager::BTN_RIGHT;
}
case Button::Up:
return InputManager::BTN_UP;
case Button::Down:
return InputManager::BTN_DOWN;
case Button::Power:
return InputManager::BTN_POWER;
case Button::PageBack:
return InputManager::BTN_UP;
case Button::PageForward:
return InputManager::BTN_DOWN;
}
return InputManager::BTN_BACK;
}
bool MappedInputManager::wasPressed(const Button button) const { return inputManager.wasPressed(mapButton(button)); }
bool MappedInputManager::wasReleased(const Button button) const { return inputManager.wasReleased(mapButton(button)); }
bool MappedInputManager::isPressed(const Button button) const { return inputManager.isPressed(mapButton(button)); }
bool MappedInputManager::wasAnyPressed() const { return inputManager.wasAnyPressed(); }
bool MappedInputManager::wasAnyReleased() const { return inputManager.wasAnyReleased(); }
unsigned long MappedInputManager::getHeldTime() const { return inputManager.getHeldTime(); }
MappedInputManager::Labels MappedInputManager::mapLabels(const char* back, const char* confirm, const char* previous,
const char* next) const {
const auto layout = static_cast<CrossPointSettings::FRONT_BUTTON_LAYOUT>(SETTINGS.frontButtonLayout);
switch (layout) {
case CrossPointSettings::LEFT_RIGHT_BACK_CONFIRM:
return {previous, next, back, confirm};
case CrossPointSettings::BACK_CONFIRM_LEFT_RIGHT:
default:
return {back, confirm, previous, next};
}
}

31
src/MappedInputManager.h Normal file
View File

@ -0,0 +1,31 @@
#pragma once
#include <InputManager.h>
#include "CrossPointSettings.h"
class MappedInputManager {
public:
enum class Button { Back, Confirm, Left, Right, Up, Down, Power, PageBack, PageForward };
struct Labels {
const char* btn1;
const char* btn2;
const char* btn3;
const char* btn4;
};
explicit MappedInputManager(InputManager& inputManager) : inputManager(inputManager) {}
bool wasPressed(Button button) const;
bool wasReleased(Button button) const;
bool isPressed(Button button) const;
bool wasAnyPressed() const;
bool wasAnyReleased() const;
unsigned long getHeldTime() const;
Labels mapLabels(const char* back, const char* confirm, const char* previous, const char* next) const;
private:
InputManager& inputManager;
decltype(InputManager::BTN_BACK) mapButton(Button button) const;
};

View File

@ -5,18 +5,19 @@
#include <string> #include <string>
#include <utility> #include <utility>
class InputManager; #include "../MappedInputManager.h"
class GfxRenderer; class GfxRenderer;
class Activity { class Activity {
protected: protected:
std::string name; std::string name;
GfxRenderer& renderer; GfxRenderer& renderer;
InputManager& inputManager; MappedInputManager& mappedInput;
public: public:
explicit Activity(std::string name, GfxRenderer& renderer, InputManager& inputManager) explicit Activity(std::string name, GfxRenderer& renderer, MappedInputManager& mappedInput)
: name(std::move(name)), renderer(renderer), inputManager(inputManager) {} : name(std::move(name)), renderer(renderer), mappedInput(mappedInput) {}
virtual ~Activity() = default; virtual ~Activity() = default;
virtual void onEnter() { Serial.printf("[%lu] [ACT] Entering activity: %s\n", millis(), name.c_str()); } virtual void onEnter() { Serial.printf("[%lu] [ACT] Entering activity: %s\n", millis(), name.c_str()); }
virtual void onExit() { Serial.printf("[%lu] [ACT] Exiting activity: %s\n", millis(), name.c_str()); } virtual void onExit() { Serial.printf("[%lu] [ACT] Exiting activity: %s\n", millis(), name.c_str()); }

View File

@ -10,8 +10,8 @@ class ActivityWithSubactivity : public Activity {
void enterNewActivity(Activity* activity); void enterNewActivity(Activity* activity);
public: public:
explicit ActivityWithSubactivity(std::string name, GfxRenderer& renderer, InputManager& inputManager) explicit ActivityWithSubactivity(std::string name, GfxRenderer& renderer, MappedInputManager& mappedInput)
: Activity(std::move(name), renderer, inputManager) {} : Activity(std::move(name), renderer, mappedInput) {}
void loop() override; void loop() override;
void onExit() override; void onExit() override;
}; };

View File

@ -3,6 +3,7 @@
class BootActivity final : public Activity { class BootActivity final : public Activity {
public: public:
explicit BootActivity(GfxRenderer& renderer, InputManager& inputManager) : Activity("Boot", renderer, inputManager) {} explicit BootActivity(GfxRenderer& renderer, MappedInputManager& mappedInput)
: Activity("Boot", renderer, mappedInput) {}
void onEnter() override; void onEnter() override;
}; };

View File

@ -5,8 +5,8 @@ class Bitmap;
class SleepActivity final : public Activity { class SleepActivity final : public Activity {
public: public:
explicit SleepActivity(GfxRenderer& renderer, InputManager& inputManager) explicit SleepActivity(GfxRenderer& renderer, MappedInputManager& mappedInput)
: Activity("Sleep", renderer, inputManager) {} : Activity("Sleep", renderer, mappedInput) {}
void onEnter() override; void onEnter() override;
private: private:

View File

@ -49,14 +49,14 @@ void HomeActivity::onExit() {
} }
void HomeActivity::loop() { void HomeActivity::loop() {
const bool prevPressed = const bool prevPressed = mappedInput.wasPressed(MappedInputManager::Button::Up) ||
inputManager.wasPressed(InputManager::BTN_UP) || inputManager.wasPressed(InputManager::BTN_LEFT); mappedInput.wasPressed(MappedInputManager::Button::Left);
const bool nextPressed = const bool nextPressed = mappedInput.wasPressed(MappedInputManager::Button::Down) ||
inputManager.wasPressed(InputManager::BTN_DOWN) || inputManager.wasPressed(InputManager::BTN_RIGHT); mappedInput.wasPressed(MappedInputManager::Button::Right);
const int menuCount = getMenuItemCount(); const int menuCount = getMenuItemCount();
if (inputManager.wasReleased(InputManager::BTN_CONFIRM)) { if (mappedInput.wasReleased(MappedInputManager::Button::Confirm)) {
if (hasContinueReading) { if (hasContinueReading) {
// Menu: Continue Reading, Browse, File transfer, Settings // Menu: Continue Reading, Browse, File transfer, Settings
if (selectorIndex == 0) { if (selectorIndex == 0) {
@ -143,7 +143,8 @@ void HomeActivity::render() const {
renderer.drawText(UI_FONT_ID, 20, menuY, "Settings", selectorIndex != menuIndex); renderer.drawText(UI_FONT_ID, 20, menuY, "Settings", selectorIndex != menuIndex);
renderer.drawButtonHints(UI_FONT_ID, "Back", "Confirm", "Left", "Right"); const auto labels = mappedInput.mapLabels("Back", "Confirm", "Left", "Right");
renderer.drawButtonHints(UI_FONT_ID, labels.btn1, labels.btn2, labels.btn3, labels.btn4);
renderer.displayBuffer(); renderer.displayBuffer();
} }

View File

@ -24,10 +24,10 @@ class HomeActivity final : public Activity {
int getMenuItemCount() const; int getMenuItemCount() const;
public: public:
explicit HomeActivity(GfxRenderer& renderer, InputManager& inputManager, explicit HomeActivity(GfxRenderer& renderer, MappedInputManager& mappedInput,
const std::function<void()>& onContinueReading, const std::function<void()>& onReaderOpen, const std::function<void()>& onContinueReading, const std::function<void()>& onReaderOpen,
const std::function<void()>& onSettingsOpen, const std::function<void()>& onFileTransferOpen) const std::function<void()>& onSettingsOpen, const std::function<void()>& onFileTransferOpen)
: Activity("Home", renderer, inputManager), : Activity("Home", renderer, mappedInput),
onContinueReading(onContinueReading), onContinueReading(onContinueReading),
onReaderOpen(onReaderOpen), onReaderOpen(onReaderOpen),
onSettingsOpen(onSettingsOpen), onSettingsOpen(onSettingsOpen),

View File

@ -57,7 +57,7 @@ void CrossPointWebServerActivity::onEnter() {
// Launch network mode selection subactivity // Launch network mode selection subactivity
Serial.printf("[%lu] [WEBACT] Launching NetworkModeSelectionActivity...\n", millis()); Serial.printf("[%lu] [WEBACT] Launching NetworkModeSelectionActivity...\n", millis());
enterNewActivity(new NetworkModeSelectionActivity( enterNewActivity(new NetworkModeSelectionActivity(
renderer, inputManager, [this](const NetworkMode mode) { onNetworkModeSelected(mode); }, renderer, mappedInput, [this](const NetworkMode mode) { onNetworkModeSelected(mode); },
[this]() { onGoBack(); } // Cancel goes back to home [this]() { onGoBack(); } // Cancel goes back to home
)); ));
} }
@ -141,7 +141,7 @@ void CrossPointWebServerActivity::onNetworkModeSelected(const NetworkMode mode)
state = WebServerActivityState::WIFI_SELECTION; state = WebServerActivityState::WIFI_SELECTION;
Serial.printf("[%lu] [WEBACT] Launching WifiSelectionActivity...\n", millis()); Serial.printf("[%lu] [WEBACT] Launching WifiSelectionActivity...\n", millis());
enterNewActivity(new WifiSelectionActivity(renderer, inputManager, enterNewActivity(new WifiSelectionActivity(renderer, mappedInput,
[this](const bool connected) { onWifiSelectionComplete(connected); })); [this](const bool connected) { onWifiSelectionComplete(connected); }));
} else { } else {
// AP mode - start access point // AP mode - start access point
@ -174,7 +174,7 @@ void CrossPointWebServerActivity::onWifiSelectionComplete(const bool connected)
exitActivity(); exitActivity();
state = WebServerActivityState::MODE_SELECTION; state = WebServerActivityState::MODE_SELECTION;
enterNewActivity(new NetworkModeSelectionActivity( enterNewActivity(new NetworkModeSelectionActivity(
renderer, inputManager, [this](const NetworkMode mode) { onNetworkModeSelected(mode); }, renderer, mappedInput, [this](const NetworkMode mode) { onNetworkModeSelected(mode); },
[this]() { onGoBack(); })); [this]() { onGoBack(); }));
} }
} }
@ -305,7 +305,7 @@ void CrossPointWebServerActivity::loop() {
} }
// Handle exit on Back button // Handle exit on Back button
if (inputManager.wasPressed(InputManager::BTN_BACK)) { if (mappedInput.wasPressed(MappedInputManager::Button::Back)) {
onGoBack(); onGoBack();
return; return;
} }
@ -428,5 +428,6 @@ void CrossPointWebServerActivity::renderServerRunning() const {
REGULAR); REGULAR);
} }
renderer.drawButtonHints(UI_FONT_ID, "« Exit", "", "", ""); const auto labels = mappedInput.mapLabels("« Exit", "", "", "");
renderer.drawButtonHints(UI_FONT_ID, labels.btn1, labels.btn2, labels.btn3, labels.btn4);
} }

View File

@ -63,9 +63,9 @@ class CrossPointWebServerActivity final : public ActivityWithSubactivity {
void stopWebServer(); void stopWebServer();
public: public:
explicit CrossPointWebServerActivity(GfxRenderer& renderer, InputManager& inputManager, explicit CrossPointWebServerActivity(GfxRenderer& renderer, MappedInputManager& mappedInput,
const std::function<void()>& onGoBack) const std::function<void()>& onGoBack)
: ActivityWithSubactivity("CrossPointWebServer", renderer, inputManager), onGoBack(onGoBack) {} : ActivityWithSubactivity("CrossPointWebServer", renderer, mappedInput), onGoBack(onGoBack) {}
void onEnter() override; void onEnter() override;
void onExit() override; void onExit() override;
void loop() override; void loop() override;

View File

@ -51,23 +51,23 @@ void NetworkModeSelectionActivity::onExit() {
void NetworkModeSelectionActivity::loop() { void NetworkModeSelectionActivity::loop() {
// Handle back button - cancel // Handle back button - cancel
if (inputManager.wasPressed(InputManager::BTN_BACK)) { if (mappedInput.wasPressed(MappedInputManager::Button::Back)) {
onCancel(); onCancel();
return; return;
} }
// Handle confirm button - select current option // Handle confirm button - select current option
if (inputManager.wasPressed(InputManager::BTN_CONFIRM)) { if (mappedInput.wasPressed(MappedInputManager::Button::Confirm)) {
const NetworkMode mode = (selectedIndex == 0) ? NetworkMode::JOIN_NETWORK : NetworkMode::CREATE_HOTSPOT; const NetworkMode mode = (selectedIndex == 0) ? NetworkMode::JOIN_NETWORK : NetworkMode::CREATE_HOTSPOT;
onModeSelected(mode); onModeSelected(mode);
return; return;
} }
// Handle navigation // Handle navigation
const bool prevPressed = const bool prevPressed = mappedInput.wasPressed(MappedInputManager::Button::Up) ||
inputManager.wasPressed(InputManager::BTN_UP) || inputManager.wasPressed(InputManager::BTN_LEFT); mappedInput.wasPressed(MappedInputManager::Button::Left);
const bool nextPressed = const bool nextPressed = mappedInput.wasPressed(MappedInputManager::Button::Down) ||
inputManager.wasPressed(InputManager::BTN_DOWN) || inputManager.wasPressed(InputManager::BTN_RIGHT); mappedInput.wasPressed(MappedInputManager::Button::Right);
if (prevPressed) { if (prevPressed) {
selectedIndex = (selectedIndex + MENU_ITEM_COUNT - 1) % MENU_ITEM_COUNT; selectedIndex = (selectedIndex + MENU_ITEM_COUNT - 1) % MENU_ITEM_COUNT;
@ -122,7 +122,8 @@ void NetworkModeSelectionActivity::render() const {
} }
// Draw help text at bottom // Draw help text at bottom
renderer.drawButtonHints(UI_FONT_ID, "« Back", "Select", "", ""); const auto labels = mappedInput.mapLabels("« Back", "Select", "", "");
renderer.drawButtonHints(UI_FONT_ID, labels.btn1, labels.btn2, labels.btn3, labels.btn4);
renderer.displayBuffer(); renderer.displayBuffer();
} }

View File

@ -31,10 +31,10 @@ class NetworkModeSelectionActivity final : public Activity {
void render() const; void render() const;
public: public:
explicit NetworkModeSelectionActivity(GfxRenderer& renderer, InputManager& inputManager, explicit NetworkModeSelectionActivity(GfxRenderer& renderer, MappedInputManager& mappedInput,
const std::function<void(NetworkMode)>& onModeSelected, const std::function<void(NetworkMode)>& onModeSelected,
const std::function<void()>& onCancel) const std::function<void()>& onCancel)
: Activity("NetworkModeSelection", renderer, inputManager), onModeSelected(onModeSelected), onCancel(onCancel) {} : Activity("NetworkModeSelection", renderer, mappedInput), onModeSelected(onModeSelected), onCancel(onCancel) {}
void onEnter() override; void onEnter() override;
void onExit() override; void onExit() override;
void loop() override; void loop() override;

View File

@ -190,7 +190,7 @@ void WifiSelectionActivity::selectNetwork(const int index) {
// Don't allow screen updates while changing activity // Don't allow screen updates while changing activity
xSemaphoreTake(renderingMutex, portMAX_DELAY); xSemaphoreTake(renderingMutex, portMAX_DELAY);
enterNewActivity(new KeyboardEntryActivity( enterNewActivity(new KeyboardEntryActivity(
renderer, inputManager, "Enter WiFi Password", renderer, mappedInput, "Enter WiFi Password",
"", // No initial text "", // No initial text
50, // Y position 50, // Y position
64, // Max password length 64, // Max password length
@ -302,17 +302,19 @@ void WifiSelectionActivity::loop() {
// Handle save prompt state // Handle save prompt state
if (state == WifiSelectionState::SAVE_PROMPT) { if (state == WifiSelectionState::SAVE_PROMPT) {
if (inputManager.wasPressed(InputManager::BTN_LEFT) || inputManager.wasPressed(InputManager::BTN_UP)) { if (mappedInput.wasPressed(MappedInputManager::Button::Up) ||
mappedInput.wasPressed(MappedInputManager::Button::Left)) {
if (savePromptSelection > 0) { if (savePromptSelection > 0) {
savePromptSelection--; savePromptSelection--;
updateRequired = true; updateRequired = true;
} }
} else if (inputManager.wasPressed(InputManager::BTN_RIGHT) || inputManager.wasPressed(InputManager::BTN_DOWN)) { } else if (mappedInput.wasPressed(MappedInputManager::Button::Down) ||
mappedInput.wasPressed(MappedInputManager::Button::Right)) {
if (savePromptSelection < 1) { if (savePromptSelection < 1) {
savePromptSelection++; savePromptSelection++;
updateRequired = true; updateRequired = true;
} }
} else if (inputManager.wasPressed(InputManager::BTN_CONFIRM)) { } else if (mappedInput.wasPressed(MappedInputManager::Button::Confirm)) {
if (savePromptSelection == 0) { if (savePromptSelection == 0) {
// User chose "Yes" - save the password // User chose "Yes" - save the password
xSemaphoreTake(renderingMutex, portMAX_DELAY); xSemaphoreTake(renderingMutex, portMAX_DELAY);
@ -321,7 +323,7 @@ void WifiSelectionActivity::loop() {
} }
// Complete - parent will start web server // Complete - parent will start web server
onComplete(true); onComplete(true);
} else if (inputManager.wasPressed(InputManager::BTN_BACK)) { } else if (mappedInput.wasPressed(MappedInputManager::Button::Back)) {
// Skip saving, complete anyway // Skip saving, complete anyway
onComplete(true); onComplete(true);
} }
@ -330,17 +332,19 @@ void WifiSelectionActivity::loop() {
// Handle forget prompt state (connection failed with saved credentials) // Handle forget prompt state (connection failed with saved credentials)
if (state == WifiSelectionState::FORGET_PROMPT) { if (state == WifiSelectionState::FORGET_PROMPT) {
if (inputManager.wasPressed(InputManager::BTN_LEFT) || inputManager.wasPressed(InputManager::BTN_UP)) { if (mappedInput.wasPressed(MappedInputManager::Button::Up) ||
mappedInput.wasPressed(MappedInputManager::Button::Left)) {
if (forgetPromptSelection > 0) { if (forgetPromptSelection > 0) {
forgetPromptSelection--; forgetPromptSelection--;
updateRequired = true; updateRequired = true;
} }
} else if (inputManager.wasPressed(InputManager::BTN_RIGHT) || inputManager.wasPressed(InputManager::BTN_DOWN)) { } else if (mappedInput.wasPressed(MappedInputManager::Button::Down) ||
mappedInput.wasPressed(MappedInputManager::Button::Right)) {
if (forgetPromptSelection < 1) { if (forgetPromptSelection < 1) {
forgetPromptSelection++; forgetPromptSelection++;
updateRequired = true; updateRequired = true;
} }
} else if (inputManager.wasPressed(InputManager::BTN_CONFIRM)) { } else if (mappedInput.wasPressed(MappedInputManager::Button::Confirm)) {
if (forgetPromptSelection == 0) { if (forgetPromptSelection == 0) {
// User chose "Yes" - forget the network // User chose "Yes" - forget the network
xSemaphoreTake(renderingMutex, portMAX_DELAY); xSemaphoreTake(renderingMutex, portMAX_DELAY);
@ -356,7 +360,7 @@ void WifiSelectionActivity::loop() {
// Go back to network list // Go back to network list
state = WifiSelectionState::NETWORK_LIST; state = WifiSelectionState::NETWORK_LIST;
updateRequired = true; updateRequired = true;
} else if (inputManager.wasPressed(InputManager::BTN_BACK)) { } else if (mappedInput.wasPressed(MappedInputManager::Button::Back)) {
// Skip forgetting, go back to network list // Skip forgetting, go back to network list
state = WifiSelectionState::NETWORK_LIST; state = WifiSelectionState::NETWORK_LIST;
updateRequired = true; updateRequired = true;
@ -373,7 +377,8 @@ void WifiSelectionActivity::loop() {
// Handle connection failed state // Handle connection failed state
if (state == WifiSelectionState::CONNECTION_FAILED) { if (state == WifiSelectionState::CONNECTION_FAILED) {
if (inputManager.wasPressed(InputManager::BTN_BACK) || inputManager.wasPressed(InputManager::BTN_CONFIRM)) { if (mappedInput.wasPressed(MappedInputManager::Button::Back) ||
mappedInput.wasPressed(MappedInputManager::Button::Confirm)) {
// If we used saved credentials, offer to forget the network // If we used saved credentials, offer to forget the network
if (usedSavedPassword) { if (usedSavedPassword) {
state = WifiSelectionState::FORGET_PROMPT; state = WifiSelectionState::FORGET_PROMPT;
@ -390,13 +395,13 @@ void WifiSelectionActivity::loop() {
// Handle network list state // Handle network list state
if (state == WifiSelectionState::NETWORK_LIST) { if (state == WifiSelectionState::NETWORK_LIST) {
// Check for Back button to exit (cancel) // Check for Back button to exit (cancel)
if (inputManager.wasPressed(InputManager::BTN_BACK)) { if (mappedInput.wasPressed(MappedInputManager::Button::Back)) {
onComplete(false); onComplete(false);
return; return;
} }
// Check for Confirm button to select network or rescan // Check for Confirm button to select network or rescan
if (inputManager.wasPressed(InputManager::BTN_CONFIRM)) { if (mappedInput.wasPressed(MappedInputManager::Button::Confirm)) {
if (!networks.empty()) { if (!networks.empty()) {
selectNetwork(selectedNetworkIndex); selectNetwork(selectedNetworkIndex);
} else { } else {
@ -406,12 +411,14 @@ void WifiSelectionActivity::loop() {
} }
// Handle UP/DOWN navigation // Handle UP/DOWN navigation
if (inputManager.wasPressed(InputManager::BTN_UP) || inputManager.wasPressed(InputManager::BTN_LEFT)) { if (mappedInput.wasPressed(MappedInputManager::Button::Up) ||
mappedInput.wasPressed(MappedInputManager::Button::Left)) {
if (selectedNetworkIndex > 0) { if (selectedNetworkIndex > 0) {
selectedNetworkIndex--; selectedNetworkIndex--;
updateRequired = true; updateRequired = true;
} }
} else if (inputManager.wasPressed(InputManager::BTN_DOWN) || inputManager.wasPressed(InputManager::BTN_RIGHT)) { } else if (mappedInput.wasPressed(MappedInputManager::Button::Down) ||
mappedInput.wasPressed(MappedInputManager::Button::Right)) {
if (!networks.empty() && selectedNetworkIndex < static_cast<int>(networks.size()) - 1) { if (!networks.empty() && selectedNetworkIndex < static_cast<int>(networks.size()) - 1) {
selectedNetworkIndex++; selectedNetworkIndex++;
updateRequired = true; updateRequired = true;
@ -557,7 +564,8 @@ void WifiSelectionActivity::renderNetworkList() const {
// Draw help text // Draw help text
renderer.drawText(SMALL_FONT_ID, 20, pageHeight - 75, "* = Encrypted | + = Saved"); renderer.drawText(SMALL_FONT_ID, 20, pageHeight - 75, "* = Encrypted | + = Saved");
renderer.drawButtonHints(UI_FONT_ID, "« Back", "Connect", "", ""); const auto labels = mappedInput.mapLabels("« Back", "Connect", "", "");
renderer.drawButtonHints(UI_FONT_ID, labels.btn1, labels.btn2, labels.btn3, labels.btn4);
} }
void WifiSelectionActivity::renderConnecting() const { void WifiSelectionActivity::renderConnecting() const {

View File

@ -92,9 +92,9 @@ class WifiSelectionActivity final : public ActivityWithSubactivity {
std::string getSignalStrengthIndicator(int32_t rssi) const; std::string getSignalStrengthIndicator(int32_t rssi) const;
public: public:
explicit WifiSelectionActivity(GfxRenderer& renderer, InputManager& inputManager, explicit WifiSelectionActivity(GfxRenderer& renderer, MappedInputManager& mappedInput,
const std::function<void(bool connected)>& onComplete) const std::function<void(bool connected)>& onComplete)
: ActivityWithSubactivity("WifiSelection", renderer, inputManager), onComplete(onComplete) {} : ActivityWithSubactivity("WifiSelection", renderer, mappedInput), onComplete(onComplete) {}
void onEnter() override; void onEnter() override;
void onExit() override; void onExit() override;
void loop() override; void loop() override;

View File

@ -106,12 +106,12 @@ void EpubReaderActivity::loop() {
} }
// Enter chapter selection activity // Enter chapter selection activity
if (inputManager.wasReleased(InputManager::BTN_CONFIRM)) { if (mappedInput.wasReleased(MappedInputManager::Button::Confirm)) {
// Don't start activity transition while rendering // Don't start activity transition while rendering
xSemaphoreTake(renderingMutex, portMAX_DELAY); xSemaphoreTake(renderingMutex, portMAX_DELAY);
exitActivity(); exitActivity();
enterNewActivity(new EpubReaderChapterSelectionActivity( enterNewActivity(new EpubReaderChapterSelectionActivity(
this->renderer, this->inputManager, epub, currentSpineIndex, this->renderer, this->mappedInput, epub, currentSpineIndex,
[this] { [this] {
exitActivity(); exitActivity();
updateRequired = true; updateRequired = true;
@ -129,21 +129,21 @@ void EpubReaderActivity::loop() {
} }
// Long press BACK (1s+) goes directly to home // Long press BACK (1s+) goes directly to home
if (inputManager.isPressed(InputManager::BTN_BACK) && inputManager.getHeldTime() >= goHomeMs) { if (mappedInput.isPressed(MappedInputManager::Button::Back) && mappedInput.getHeldTime() >= goHomeMs) {
onGoHome(); onGoHome();
return; return;
} }
// Short press BACK goes to file selection // Short press BACK goes to file selection
if (inputManager.wasReleased(InputManager::BTN_BACK) && inputManager.getHeldTime() < goHomeMs) { if (mappedInput.wasReleased(MappedInputManager::Button::Back) && mappedInput.getHeldTime() < goHomeMs) {
onGoBack(); onGoBack();
return; return;
} }
const bool prevReleased = const bool prevReleased = mappedInput.wasReleased(MappedInputManager::Button::PageBack) ||
inputManager.wasReleased(InputManager::BTN_UP) || inputManager.wasReleased(InputManager::BTN_LEFT); mappedInput.wasReleased(MappedInputManager::Button::Left);
const bool nextReleased = const bool nextReleased = mappedInput.wasReleased(MappedInputManager::Button::PageForward) ||
inputManager.wasReleased(InputManager::BTN_DOWN) || inputManager.wasReleased(InputManager::BTN_RIGHT); mappedInput.wasReleased(MappedInputManager::Button::Right);
if (!prevReleased && !nextReleased) { if (!prevReleased && !nextReleased) {
return; return;
@ -157,7 +157,7 @@ void EpubReaderActivity::loop() {
return; return;
} }
const bool skipChapter = inputManager.getHeldTime() > skipChapterMs; const bool skipChapter = mappedInput.getHeldTime() > skipChapterMs;
if (skipChapter) { if (skipChapter) {
// We don't want to delete the section mid-render, so grab the semaphore // We don't want to delete the section mid-render, so grab the semaphore
@ -254,8 +254,8 @@ void EpubReaderActivity::renderScreen() {
const auto viewportWidth = renderer.getScreenWidth() - orientedMarginLeft - orientedMarginRight; const auto viewportWidth = renderer.getScreenWidth() - orientedMarginLeft - orientedMarginRight;
const auto viewportHeight = renderer.getScreenHeight() - orientedMarginTop - orientedMarginBottom; const auto viewportHeight = renderer.getScreenHeight() - orientedMarginTop - orientedMarginBottom;
if (!section->loadCacheMetadata(READER_FONT_ID, lineCompression, SETTINGS.extraParagraphSpacing, viewportWidth, if (!section->loadSectionFile(READER_FONT_ID, lineCompression, SETTINGS.extraParagraphSpacing, viewportWidth,
viewportHeight)) { viewportHeight)) {
Serial.printf("[%lu] [ERS] Cache not found, building...\n", millis()); Serial.printf("[%lu] [ERS] Cache not found, building...\n", millis());
// Progress bar dimensions // Progress bar dimensions
@ -282,8 +282,6 @@ void EpubReaderActivity::renderScreen() {
pagesUntilFullRefresh = 0; pagesUntilFullRefresh = 0;
} }
section->setupCacheDir();
// Setup callback - only called for chapters >= 50KB, redraws with progress bar // Setup callback - only called for chapters >= 50KB, redraws with progress bar
auto progressSetup = [this, boxXWithBar, boxWidthWithBar, boxHeightWithBar, barX, barY] { auto progressSetup = [this, boxXWithBar, boxWidthWithBar, boxHeightWithBar, barX, barY] {
renderer.fillRect(boxXWithBar, boxY, boxWidthWithBar, boxHeightWithBar, false); renderer.fillRect(boxXWithBar, boxY, boxWidthWithBar, boxHeightWithBar, false);
@ -300,8 +298,8 @@ void EpubReaderActivity::renderScreen() {
renderer.displayBuffer(EInkDisplay::FAST_REFRESH); renderer.displayBuffer(EInkDisplay::FAST_REFRESH);
}; };
if (!section->persistPageDataToSD(READER_FONT_ID, lineCompression, SETTINGS.extraParagraphSpacing, viewportWidth, if (!section->createSectionFile(READER_FONT_ID, lineCompression, SETTINGS.extraParagraphSpacing, viewportWidth,
viewportHeight, progressSetup, progressCallback)) { viewportHeight, progressSetup, progressCallback)) {
Serial.printf("[%lu] [ERS] Failed to persist page data to SD\n", millis()); Serial.printf("[%lu] [ERS] Failed to persist page data to SD\n", millis());
section.reset(); section.reset();
return; return;
@ -336,7 +334,7 @@ void EpubReaderActivity::renderScreen() {
} }
{ {
auto p = section->loadPageFromSD(); auto p = section->loadPageFromSectionFile();
if (!p) { if (!p) {
Serial.printf("[%lu] [ERS] Failed to load page from SD - clearing section cache\n", millis()); Serial.printf("[%lu] [ERS] Failed to load page from SD - clearing section cache\n", millis());
section->clearCache(); section->clearCache();

View File

@ -27,9 +27,9 @@ class EpubReaderActivity final : public ActivityWithSubactivity {
void renderStatusBar(int orientedMarginRight, int orientedMarginBottom, int orientedMarginLeft) const; void renderStatusBar(int orientedMarginRight, int orientedMarginBottom, int orientedMarginLeft) const;
public: public:
explicit EpubReaderActivity(GfxRenderer& renderer, InputManager& inputManager, std::unique_ptr<Epub> epub, explicit EpubReaderActivity(GfxRenderer& renderer, MappedInputManager& mappedInput, std::unique_ptr<Epub> epub,
const std::function<void()>& onGoBack, const std::function<void()>& onGoHome) const std::function<void()>& onGoBack, const std::function<void()>& onGoHome)
: ActivityWithSubactivity("EpubReader", renderer, inputManager), : ActivityWithSubactivity("EpubReader", renderer, mappedInput),
epub(std::move(epub)), epub(std::move(epub)),
onGoBack(onGoBack), onGoBack(onGoBack),
onGoHome(onGoHome) {} onGoHome(onGoHome) {}

View File

@ -66,17 +66,17 @@ void EpubReaderChapterSelectionActivity::onExit() {
} }
void EpubReaderChapterSelectionActivity::loop() { void EpubReaderChapterSelectionActivity::loop() {
const bool prevReleased = const bool prevReleased = mappedInput.wasReleased(MappedInputManager::Button::Up) ||
inputManager.wasReleased(InputManager::BTN_UP) || inputManager.wasReleased(InputManager::BTN_LEFT); mappedInput.wasReleased(MappedInputManager::Button::Left);
const bool nextReleased = const bool nextReleased = mappedInput.wasReleased(MappedInputManager::Button::Down) ||
inputManager.wasReleased(InputManager::BTN_DOWN) || inputManager.wasReleased(InputManager::BTN_RIGHT); mappedInput.wasReleased(MappedInputManager::Button::Right);
const bool skipPage = inputManager.getHeldTime() > SKIP_PAGE_MS; const bool skipPage = mappedInput.getHeldTime() > SKIP_PAGE_MS;
const int pageItems = getPageItems(); const int pageItems = getPageItems();
if (inputManager.wasReleased(InputManager::BTN_CONFIRM)) { if (mappedInput.wasReleased(MappedInputManager::Button::Confirm)) {
onSelectSpineIndex(selectorIndex); onSelectSpineIndex(selectorIndex);
} else if (inputManager.wasReleased(InputManager::BTN_BACK)) { } else if (mappedInput.wasReleased(MappedInputManager::Button::Back)) {
onGoBack(); onGoBack();
} else if (prevReleased) { } else if (prevReleased) {
if (skipPage) { if (skipPage) {

View File

@ -27,11 +27,11 @@ class EpubReaderChapterSelectionActivity final : public Activity {
void renderScreen(); void renderScreen();
public: public:
explicit EpubReaderChapterSelectionActivity(GfxRenderer& renderer, InputManager& inputManager, explicit EpubReaderChapterSelectionActivity(GfxRenderer& renderer, MappedInputManager& mappedInput,
const std::shared_ptr<Epub>& epub, const int currentSpineIndex, const std::shared_ptr<Epub>& epub, const int currentSpineIndex,
const std::function<void()>& onGoBack, const std::function<void()>& onGoBack,
const std::function<void(int newSpineIndex)>& onSelectSpineIndex) const std::function<void(int newSpineIndex)>& onSelectSpineIndex)
: Activity("EpubReaderChapterSelection", renderer, inputManager), : Activity("EpubReaderChapterSelection", renderer, mappedInput),
epub(epub), epub(epub),
currentSpineIndex(currentSpineIndex), currentSpineIndex(currentSpineIndex),
onGoBack(onGoBack), onGoBack(onGoBack),

View File

@ -89,7 +89,7 @@ void FileSelectionActivity::onExit() {
void FileSelectionActivity::loop() { void FileSelectionActivity::loop() {
// Long press BACK (1s+) goes to root folder // Long press BACK (1s+) goes to root folder
if (inputManager.isPressed(InputManager::BTN_BACK) && inputManager.getHeldTime() >= GO_HOME_MS) { if (mappedInput.isPressed(MappedInputManager::Button::Back) && mappedInput.getHeldTime() >= GO_HOME_MS) {
if (basepath != "/") { if (basepath != "/") {
basepath = "/"; basepath = "/";
loadFiles(); loadFiles();
@ -98,14 +98,14 @@ void FileSelectionActivity::loop() {
return; return;
} }
const bool prevReleased = const bool prevReleased = mappedInput.wasReleased(MappedInputManager::Button::Up) ||
inputManager.wasReleased(InputManager::BTN_UP) || inputManager.wasReleased(InputManager::BTN_LEFT); mappedInput.wasReleased(MappedInputManager::Button::Left);
const bool nextReleased = const bool nextReleased = mappedInput.wasReleased(MappedInputManager::Button::Down) ||
inputManager.wasReleased(InputManager::BTN_DOWN) || inputManager.wasReleased(InputManager::BTN_RIGHT); mappedInput.wasReleased(MappedInputManager::Button::Right);
const bool skipPage = inputManager.getHeldTime() > SKIP_PAGE_MS; const bool skipPage = mappedInput.getHeldTime() > SKIP_PAGE_MS;
if (inputManager.wasReleased(InputManager::BTN_CONFIRM)) { if (mappedInput.wasReleased(MappedInputManager::Button::Confirm)) {
if (files.empty()) { if (files.empty()) {
return; return;
} }
@ -118,9 +118,9 @@ void FileSelectionActivity::loop() {
} else { } else {
onSelect(basepath + files[selectorIndex]); onSelect(basepath + files[selectorIndex]);
} }
} else if (inputManager.wasReleased(InputManager::BTN_BACK)) { } else if (mappedInput.wasReleased(MappedInputManager::Button::Back)) {
// Short press: go up one directory, or go home if at root // Short press: go up one directory, or go home if at root
if (inputManager.getHeldTime() < GO_HOME_MS) { if (mappedInput.getHeldTime() < GO_HOME_MS) {
if (basepath != "/") { if (basepath != "/") {
basepath.replace(basepath.find_last_of('/'), std::string::npos, ""); basepath.replace(basepath.find_last_of('/'), std::string::npos, "");
if (basepath.empty()) basepath = "/"; if (basepath.empty()) basepath = "/";
@ -166,7 +166,8 @@ void FileSelectionActivity::render() const {
renderer.drawCenteredText(READER_FONT_ID, 10, "Books", true, BOLD); renderer.drawCenteredText(READER_FONT_ID, 10, "Books", true, BOLD);
// Help text // Help text
renderer.drawButtonHints(UI_FONT_ID, "« Home", "Open", "", ""); const auto labels = mappedInput.mapLabels("« Home", "Open", "", "");
renderer.drawButtonHints(UI_FONT_ID, labels.btn1, labels.btn2, labels.btn3, labels.btn4);
if (files.empty()) { if (files.empty()) {
renderer.drawText(UI_FONT_ID, 20, 60, "No books found"); renderer.drawText(UI_FONT_ID, 20, 60, "No books found");

View File

@ -25,10 +25,10 @@ class FileSelectionActivity final : public Activity {
void loadFiles(); void loadFiles();
public: public:
explicit FileSelectionActivity(GfxRenderer& renderer, InputManager& inputManager, explicit FileSelectionActivity(GfxRenderer& renderer, MappedInputManager& mappedInput,
const std::function<void(const std::string&)>& onSelect, const std::function<void(const std::string&)>& onSelect,
const std::function<void()>& onGoHome, std::string initialPath = "/") const std::function<void()>& onGoHome, std::string initialPath = "/")
: Activity("FileSelection", renderer, inputManager), : Activity("FileSelection", renderer, mappedInput),
basepath(initialPath.empty() ? "/" : std::move(initialPath)), basepath(initialPath.empty() ? "/" : std::move(initialPath)),
onSelect(onSelect), onSelect(onSelect),
onGoHome(onGoHome) {} onGoHome(onGoHome) {}

View File

@ -61,7 +61,7 @@ std::unique_ptr<Xtc> ReaderActivity::loadXtc(const std::string& path) {
void ReaderActivity::onSelectBookFile(const std::string& path) { void ReaderActivity::onSelectBookFile(const std::string& path) {
currentBookPath = path; // Track current book path currentBookPath = path; // Track current book path
exitActivity(); exitActivity();
enterNewActivity(new FullScreenMessageActivity(renderer, inputManager, "Loading...")); enterNewActivity(new FullScreenMessageActivity(renderer, mappedInput, "Loading..."));
if (isXtcFile(path)) { if (isXtcFile(path)) {
// Load XTC file // Load XTC file
@ -70,7 +70,7 @@ void ReaderActivity::onSelectBookFile(const std::string& path) {
onGoToXtcReader(std::move(xtc)); onGoToXtcReader(std::move(xtc));
} else { } else {
exitActivity(); exitActivity();
enterNewActivity(new FullScreenMessageActivity(renderer, inputManager, "Failed to load XTC", REGULAR, enterNewActivity(new FullScreenMessageActivity(renderer, mappedInput, "Failed to load XTC", REGULAR,
EInkDisplay::HALF_REFRESH)); EInkDisplay::HALF_REFRESH));
delay(2000); delay(2000);
onGoToFileSelection(); onGoToFileSelection();
@ -82,7 +82,7 @@ void ReaderActivity::onSelectBookFile(const std::string& path) {
onGoToEpubReader(std::move(epub)); onGoToEpubReader(std::move(epub));
} else { } else {
exitActivity(); exitActivity();
enterNewActivity(new FullScreenMessageActivity(renderer, inputManager, "Failed to load epub", REGULAR, enterNewActivity(new FullScreenMessageActivity(renderer, mappedInput, "Failed to load epub", REGULAR,
EInkDisplay::HALF_REFRESH)); EInkDisplay::HALF_REFRESH));
delay(2000); delay(2000);
onGoToFileSelection(); onGoToFileSelection();
@ -95,7 +95,7 @@ void ReaderActivity::onGoToFileSelection(const std::string& fromBookPath) {
// If coming from a book, start in that book's folder; otherwise start from root // If coming from a book, start in that book's folder; otherwise start from root
const auto initialPath = fromBookPath.empty() ? "/" : extractFolderPath(fromBookPath); const auto initialPath = fromBookPath.empty() ? "/" : extractFolderPath(fromBookPath);
enterNewActivity(new FileSelectionActivity( enterNewActivity(new FileSelectionActivity(
renderer, inputManager, [this](const std::string& path) { onSelectBookFile(path); }, onGoBack, initialPath)); renderer, mappedInput, [this](const std::string& path) { onSelectBookFile(path); }, onGoBack, initialPath));
} }
void ReaderActivity::onGoToEpubReader(std::unique_ptr<Epub> epub) { void ReaderActivity::onGoToEpubReader(std::unique_ptr<Epub> epub) {
@ -103,7 +103,7 @@ void ReaderActivity::onGoToEpubReader(std::unique_ptr<Epub> epub) {
currentBookPath = epubPath; currentBookPath = epubPath;
exitActivity(); exitActivity();
enterNewActivity(new EpubReaderActivity( enterNewActivity(new EpubReaderActivity(
renderer, inputManager, std::move(epub), [this, epubPath] { onGoToFileSelection(epubPath); }, renderer, mappedInput, std::move(epub), [this, epubPath] { onGoToFileSelection(epubPath); },
[this] { onGoBack(); })); [this] { onGoBack(); }));
} }
@ -112,7 +112,7 @@ void ReaderActivity::onGoToXtcReader(std::unique_ptr<Xtc> xtc) {
currentBookPath = xtcPath; currentBookPath = xtcPath;
exitActivity(); exitActivity();
enterNewActivity(new XtcReaderActivity( enterNewActivity(new XtcReaderActivity(
renderer, inputManager, std::move(xtc), [this, xtcPath] { onGoToFileSelection(xtcPath); }, renderer, mappedInput, std::move(xtc), [this, xtcPath] { onGoToFileSelection(xtcPath); },
[this] { onGoBack(); })); [this] { onGoBack(); }));
} }

View File

@ -21,9 +21,9 @@ class ReaderActivity final : public ActivityWithSubactivity {
void onGoToXtcReader(std::unique_ptr<Xtc> xtc); void onGoToXtcReader(std::unique_ptr<Xtc> xtc);
public: public:
explicit ReaderActivity(GfxRenderer& renderer, InputManager& inputManager, std::string initialBookPath, explicit ReaderActivity(GfxRenderer& renderer, MappedInputManager& mappedInput, std::string initialBookPath,
const std::function<void()>& onGoBack) const std::function<void()>& onGoBack)
: ActivityWithSubactivity("Reader", renderer, inputManager), : ActivityWithSubactivity("Reader", renderer, mappedInput),
initialBookPath(std::move(initialBookPath)), initialBookPath(std::move(initialBookPath)),
onGoBack(onGoBack) {} onGoBack(onGoBack) {}
void onEnter() override; void onEnter() override;

View File

@ -71,21 +71,21 @@ void XtcReaderActivity::onExit() {
void XtcReaderActivity::loop() { void XtcReaderActivity::loop() {
// Long press BACK (1s+) goes directly to home // Long press BACK (1s+) goes directly to home
if (inputManager.isPressed(InputManager::BTN_BACK) && inputManager.getHeldTime() >= goHomeMs) { if (mappedInput.isPressed(MappedInputManager::Button::Back) && mappedInput.getHeldTime() >= goHomeMs) {
onGoHome(); onGoHome();
return; return;
} }
// Short press BACK goes to file selection // Short press BACK goes to file selection
if (inputManager.wasReleased(InputManager::BTN_BACK) && inputManager.getHeldTime() < goHomeMs) { if (mappedInput.wasReleased(MappedInputManager::Button::Back) && mappedInput.getHeldTime() < goHomeMs) {
onGoBack(); onGoBack();
return; return;
} }
const bool prevReleased = const bool prevReleased = mappedInput.wasReleased(MappedInputManager::Button::PageBack) ||
inputManager.wasReleased(InputManager::BTN_UP) || inputManager.wasReleased(InputManager::BTN_LEFT); mappedInput.wasReleased(MappedInputManager::Button::Left);
const bool nextReleased = const bool nextReleased = mappedInput.wasReleased(MappedInputManager::Button::PageForward) ||
inputManager.wasReleased(InputManager::BTN_DOWN) || inputManager.wasReleased(InputManager::BTN_RIGHT); mappedInput.wasReleased(MappedInputManager::Button::Right);
if (!prevReleased && !nextReleased) { if (!prevReleased && !nextReleased) {
return; return;
@ -98,7 +98,7 @@ void XtcReaderActivity::loop() {
return; return;
} }
const bool skipPages = inputManager.getHeldTime() > skipPageMs; const bool skipPages = mappedInput.getHeldTime() > skipPageMs;
const int skipAmount = skipPages ? 10 : 1; const int skipAmount = skipPages ? 10 : 1;
if (prevReleased) { if (prevReleased) {

View File

@ -32,9 +32,9 @@ class XtcReaderActivity final : public Activity {
void loadProgress(); void loadProgress();
public: public:
explicit XtcReaderActivity(GfxRenderer& renderer, InputManager& inputManager, std::unique_ptr<Xtc> xtc, explicit XtcReaderActivity(GfxRenderer& renderer, MappedInputManager& mappedInput, std::unique_ptr<Xtc> xtc,
const std::function<void()>& onGoBack, const std::function<void()>& onGoHome) const std::function<void()>& onGoBack, const std::function<void()>& onGoHome)
: Activity("XtcReader", renderer, inputManager), xtc(std::move(xtc)), onGoBack(onGoBack), onGoHome(onGoHome) {} : Activity("XtcReader", renderer, mappedInput), xtc(std::move(xtc)), onGoBack(onGoBack), onGoHome(onGoHome) {}
void onEnter() override; void onEnter() override;
void onExit() override; void onExit() override;
void loop() override; void loop() override;

View File

@ -72,7 +72,7 @@ void OtaUpdateActivity::onEnter() {
// Launch WiFi selection subactivity // Launch WiFi selection subactivity
Serial.printf("[%lu] [OTA] Launching WifiSelectionActivity...\n", millis()); Serial.printf("[%lu] [OTA] Launching WifiSelectionActivity...\n", millis());
enterNewActivity(new WifiSelectionActivity(renderer, inputManager, enterNewActivity(new WifiSelectionActivity(renderer, mappedInput,
[this](const bool connected) { onWifiSelectionComplete(connected); })); [this](const bool connected) { onWifiSelectionComplete(connected); }));
} }
@ -191,7 +191,7 @@ void OtaUpdateActivity::loop() {
} }
if (state == WAITING_CONFIRMATION) { if (state == WAITING_CONFIRMATION) {
if (inputManager.wasPressed(InputManager::BTN_CONFIRM)) { if (mappedInput.wasPressed(MappedInputManager::Button::Confirm)) {
Serial.printf("[%lu] [OTA] New update available, starting download...\n", millis()); Serial.printf("[%lu] [OTA] New update available, starting download...\n", millis());
xSemaphoreTake(renderingMutex, portMAX_DELAY); xSemaphoreTake(renderingMutex, portMAX_DELAY);
state = UPDATE_IN_PROGRESS; state = UPDATE_IN_PROGRESS;
@ -215,7 +215,7 @@ void OtaUpdateActivity::loop() {
updateRequired = true; updateRequired = true;
} }
if (inputManager.wasPressed(InputManager::BTN_BACK)) { if (mappedInput.wasPressed(MappedInputManager::Button::Back)) {
goBack(); goBack();
} }
@ -223,14 +223,14 @@ void OtaUpdateActivity::loop() {
} }
if (state == FAILED) { if (state == FAILED) {
if (inputManager.wasPressed(InputManager::BTN_BACK)) { if (mappedInput.wasPressed(MappedInputManager::Button::Back)) {
goBack(); goBack();
} }
return; return;
} }
if (state == NO_UPDATE) { if (state == NO_UPDATE) {
if (inputManager.wasPressed(InputManager::BTN_BACK)) { if (mappedInput.wasPressed(MappedInputManager::Button::Back)) {
goBack(); goBack();
} }
return; return;

View File

@ -35,8 +35,9 @@ class OtaUpdateActivity : public ActivityWithSubactivity {
void render(); void render();
public: public:
explicit OtaUpdateActivity(GfxRenderer& renderer, InputManager& inputManager, const std::function<void()>& goBack) explicit OtaUpdateActivity(GfxRenderer& renderer, MappedInputManager& mappedInput,
: ActivityWithSubactivity("OtaUpdate", renderer, inputManager), goBack(goBack), updater() {} const std::function<void()>& goBack)
: ActivityWithSubactivity("OtaUpdate", renderer, mappedInput), goBack(goBack), updater() {}
void onEnter() override; void onEnter() override;
void onExit() override; void onExit() override;
void loop() override; void loop() override;

View File

@ -9,7 +9,7 @@
// Define the static settings list // Define the static settings list
namespace { namespace {
constexpr int settingsCount = 6; constexpr int settingsCount = 7;
const SettingInfo settingsList[settingsCount] = { const SettingInfo settingsList[settingsCount] = {
// Should match with SLEEP_SCREEN_MODE // Should match with SLEEP_SCREEN_MODE
{"Sleep Screen", SettingType::ENUM, &CrossPointSettings::sleepScreen, {"Dark", "Light", "Custom", "Cover"}}, {"Sleep Screen", SettingType::ENUM, &CrossPointSettings::sleepScreen, {"Dark", "Light", "Custom", "Cover"}},
@ -20,6 +20,10 @@ const SettingInfo settingsList[settingsCount] = {
SettingType::ENUM, SettingType::ENUM,
&CrossPointSettings::orientation, &CrossPointSettings::orientation,
{"Portrait", "Landscape CW", "Inverted", "Landscape CCW"}}, {"Portrait", "Landscape CW", "Inverted", "Landscape CCW"}},
{"Front Button Layout",
SettingType::ENUM,
&CrossPointSettings::frontButtonLayout,
{"Bck, Cnfrm, Lft, Rght", "Lft, Rght, Bck, Cnfrm"}},
{"Check for updates", SettingType::ACTION, nullptr, {}}, {"Check for updates", SettingType::ACTION, nullptr, {}},
}; };
} // namespace } // namespace
@ -68,24 +72,26 @@ void SettingsActivity::loop() {
} }
// Handle actions with early return // Handle actions with early return
if (inputManager.wasPressed(InputManager::BTN_CONFIRM)) { if (mappedInput.wasPressed(MappedInputManager::Button::Confirm)) {
toggleCurrentSetting(); toggleCurrentSetting();
updateRequired = true; updateRequired = true;
return; return;
} }
if (inputManager.wasPressed(InputManager::BTN_BACK)) { if (mappedInput.wasPressed(MappedInputManager::Button::Back)) {
SETTINGS.saveToFile(); SETTINGS.saveToFile();
onGoHome(); onGoHome();
return; return;
} }
// Handle navigation // Handle navigation
if (inputManager.wasPressed(InputManager::BTN_UP) || inputManager.wasPressed(InputManager::BTN_LEFT)) { if (mappedInput.wasPressed(MappedInputManager::Button::Up) ||
mappedInput.wasPressed(MappedInputManager::Button::Left)) {
// Move selection up (with wrap-around) // Move selection up (with wrap-around)
selectedSettingIndex = (selectedSettingIndex > 0) ? (selectedSettingIndex - 1) : (settingsCount - 1); selectedSettingIndex = (selectedSettingIndex > 0) ? (selectedSettingIndex - 1) : (settingsCount - 1);
updateRequired = true; updateRequired = true;
} else if (inputManager.wasPressed(InputManager::BTN_DOWN) || inputManager.wasPressed(InputManager::BTN_RIGHT)) { } else if (mappedInput.wasPressed(MappedInputManager::Button::Down) ||
mappedInput.wasPressed(MappedInputManager::Button::Right)) {
// Move selection down // Move selection down
if (selectedSettingIndex < settingsCount - 1) { if (selectedSettingIndex < settingsCount - 1) {
selectedSettingIndex++; selectedSettingIndex++;
@ -113,7 +119,7 @@ void SettingsActivity::toggleCurrentSetting() {
if (std::string(setting.name) == "Check for updates") { if (std::string(setting.name) == "Check for updates") {
xSemaphoreTake(renderingMutex, portMAX_DELAY); xSemaphoreTake(renderingMutex, portMAX_DELAY);
exitActivity(); exitActivity();
enterNewActivity(new OtaUpdateActivity(renderer, inputManager, [this] { enterNewActivity(new OtaUpdateActivity(renderer, mappedInput, [this] {
exitActivity(); exitActivity();
updateRequired = true; updateRequired = true;
})); }));
@ -173,10 +179,13 @@ void SettingsActivity::render() const {
} }
} }
// Draw help text // Draw version text above button hints
renderer.drawButtonHints(UI_FONT_ID, "« Save", "Toggle", "", "");
renderer.drawText(SMALL_FONT_ID, pageWidth - 20 - renderer.getTextWidth(SMALL_FONT_ID, CROSSPOINT_VERSION), renderer.drawText(SMALL_FONT_ID, pageWidth - 20 - renderer.getTextWidth(SMALL_FONT_ID, CROSSPOINT_VERSION),
pageHeight - 30, CROSSPOINT_VERSION); pageHeight - 60, CROSSPOINT_VERSION);
// Draw help text
const auto labels = mappedInput.mapLabels("« Save", "Toggle", "", "");
renderer.drawButtonHints(UI_FONT_ID, labels.btn1, labels.btn2, labels.btn3, labels.btn4);
// Always use standard refresh for settings screen // Always use standard refresh for settings screen
renderer.displayBuffer(); renderer.displayBuffer();

View File

@ -34,8 +34,9 @@ class SettingsActivity final : public ActivityWithSubactivity {
void toggleCurrentSetting(); void toggleCurrentSetting();
public: public:
explicit SettingsActivity(GfxRenderer& renderer, InputManager& inputManager, const std::function<void()>& onGoHome) explicit SettingsActivity(GfxRenderer& renderer, MappedInputManager& mappedInput,
: ActivityWithSubactivity("Settings", renderer, inputManager), onGoHome(onGoHome) {} const std::function<void()>& onGoHome)
: ActivityWithSubactivity("Settings", renderer, mappedInput), onGoHome(onGoHome) {}
void onEnter() override; void onEnter() override;
void onExit() override; void onExit() override;
void loop() override; void loop() override;

View File

@ -13,10 +13,10 @@ class FullScreenMessageActivity final : public Activity {
EInkDisplay::RefreshMode refreshMode; EInkDisplay::RefreshMode refreshMode;
public: public:
explicit FullScreenMessageActivity(GfxRenderer& renderer, InputManager& inputManager, std::string text, explicit FullScreenMessageActivity(GfxRenderer& renderer, MappedInputManager& mappedInput, std::string text,
const EpdFontStyle style = REGULAR, const EpdFontStyle style = REGULAR,
const EInkDisplay::RefreshMode refreshMode = EInkDisplay::FAST_REFRESH) const EInkDisplay::RefreshMode refreshMode = EInkDisplay::FAST_REFRESH)
: Activity("FullScreenMessage", renderer, inputManager), : Activity("FullScreenMessage", renderer, mappedInput),
text(std::move(text)), text(std::move(text)),
style(style), style(style),
refreshMode(refreshMode) {} refreshMode(refreshMode) {}

View File

@ -138,7 +138,7 @@ void KeyboardEntryActivity::handleKeyPress() {
void KeyboardEntryActivity::loop() { void KeyboardEntryActivity::loop() {
// Navigation // Navigation
if (inputManager.wasPressed(InputManager::BTN_UP)) { if (mappedInput.wasPressed(MappedInputManager::Button::Up)) {
if (selectedRow > 0) { if (selectedRow > 0) {
selectedRow--; selectedRow--;
// Clamp column to valid range for new row // Clamp column to valid range for new row
@ -148,7 +148,7 @@ void KeyboardEntryActivity::loop() {
updateRequired = true; updateRequired = true;
} }
if (inputManager.wasPressed(InputManager::BTN_DOWN)) { if (mappedInput.wasPressed(MappedInputManager::Button::Down)) {
if (selectedRow < NUM_ROWS - 1) { if (selectedRow < NUM_ROWS - 1) {
selectedRow++; selectedRow++;
const int maxCol = getRowLength(selectedRow) - 1; const int maxCol = getRowLength(selectedRow) - 1;
@ -157,7 +157,7 @@ void KeyboardEntryActivity::loop() {
updateRequired = true; updateRequired = true;
} }
if (inputManager.wasPressed(InputManager::BTN_LEFT)) { if (mappedInput.wasPressed(MappedInputManager::Button::Left)) {
// Special bottom row case // Special bottom row case
if (selectedRow == SPECIAL_ROW) { if (selectedRow == SPECIAL_ROW) {
// Bottom row has special key widths // Bottom row has special key widths
@ -187,7 +187,7 @@ void KeyboardEntryActivity::loop() {
updateRequired = true; updateRequired = true;
} }
if (inputManager.wasPressed(InputManager::BTN_RIGHT)) { if (mappedInput.wasPressed(MappedInputManager::Button::Right)) {
const int maxCol = getRowLength(selectedRow) - 1; const int maxCol = getRowLength(selectedRow) - 1;
// Special bottom row case // Special bottom row case
@ -220,13 +220,13 @@ void KeyboardEntryActivity::loop() {
} }
// Selection // Selection
if (inputManager.wasPressed(InputManager::BTN_CONFIRM)) { if (mappedInput.wasPressed(MappedInputManager::Button::Confirm)) {
handleKeyPress(); handleKeyPress();
updateRequired = true; updateRequired = true;
} }
// Cancel // Cancel
if (inputManager.wasPressed(InputManager::BTN_BACK)) { if (mappedInput.wasPressed(MappedInputManager::Button::Back)) {
if (onCancel) { if (onCancel) {
onCancel(); onCancel();
} }

View File

@ -1,6 +1,5 @@
#pragma once #pragma once
#include <GfxRenderer.h> #include <GfxRenderer.h>
#include <InputManager.h>
#include <freertos/FreeRTOS.h> #include <freertos/FreeRTOS.h>
#include <freertos/semphr.h> #include <freertos/semphr.h>
#include <freertos/task.h> #include <freertos/task.h>
@ -31,7 +30,7 @@ class KeyboardEntryActivity : public Activity {
/** /**
* Constructor * Constructor
* @param renderer Reference to the GfxRenderer for drawing * @param renderer Reference to the GfxRenderer for drawing
* @param inputManager Reference to InputManager for handling input * @param mappedInput Reference to MappedInputManager for handling input
* @param title Title to display above the keyboard * @param title Title to display above the keyboard
* @param initialText Initial text to show in the input field * @param initialText Initial text to show in the input field
* @param startY Y position to start rendering the keyboard * @param startY Y position to start rendering the keyboard
@ -40,11 +39,11 @@ class KeyboardEntryActivity : public Activity {
* @param onComplete Callback invoked when input is complete * @param onComplete Callback invoked when input is complete
* @param onCancel Callback invoked when input is cancelled * @param onCancel Callback invoked when input is cancelled
*/ */
explicit KeyboardEntryActivity(GfxRenderer& renderer, InputManager& inputManager, std::string title = "Enter Text", explicit KeyboardEntryActivity(GfxRenderer& renderer, MappedInputManager& mappedInput,
std::string initialText = "", const int startY = 10, const size_t maxLength = 0, std::string title = "Enter Text", std::string initialText = "", const int startY = 10,
const bool isPassword = false, OnCompleteCallback onComplete = nullptr, const size_t maxLength = 0, const bool isPassword = false,
OnCancelCallback onCancel = nullptr) OnCompleteCallback onComplete = nullptr, OnCancelCallback onCancel = nullptr)
: Activity("KeyboardEntry", renderer, inputManager), : Activity("KeyboardEntry", renderer, mappedInput),
title(std::move(title)), title(std::move(title)),
text(std::move(initialText)), text(std::move(initialText)),
startY(startY), startY(startY),

View File

@ -16,6 +16,7 @@
#include "Battery.h" #include "Battery.h"
#include "CrossPointSettings.h" #include "CrossPointSettings.h"
#include "CrossPointState.h" #include "CrossPointState.h"
#include "MappedInputManager.h"
#include "activities/boot_sleep/BootActivity.h" #include "activities/boot_sleep/BootActivity.h"
#include "activities/boot_sleep/SleepActivity.h" #include "activities/boot_sleep/SleepActivity.h"
#include "activities/home/HomeActivity.h" #include "activities/home/HomeActivity.h"
@ -41,6 +42,7 @@
EInkDisplay einkDisplay(EPD_SCLK, EPD_MOSI, EPD_CS, EPD_DC, EPD_RST, EPD_BUSY); EInkDisplay einkDisplay(EPD_SCLK, EPD_MOSI, EPD_CS, EPD_DC, EPD_RST, EPD_BUSY);
InputManager inputManager; InputManager inputManager;
MappedInputManager mappedInputManager(inputManager);
GfxRenderer renderer(einkDisplay); GfxRenderer renderer(einkDisplay);
Activity* currentActivity; Activity* currentActivity;
@ -124,7 +126,7 @@ void waitForPowerRelease() {
// Enter deep sleep mode // Enter deep sleep mode
void enterDeepSleep() { void enterDeepSleep() {
exitActivity(); exitActivity();
enterNewActivity(new SleepActivity(renderer, inputManager)); enterNewActivity(new SleepActivity(renderer, mappedInputManager));
einkDisplay.deepSleep(); einkDisplay.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);
@ -139,24 +141,24 @@ void enterDeepSleep() {
void onGoHome(); void onGoHome();
void onGoToReader(const std::string& initialEpubPath) { void onGoToReader(const std::string& initialEpubPath) {
exitActivity(); exitActivity();
enterNewActivity(new ReaderActivity(renderer, inputManager, initialEpubPath, onGoHome)); enterNewActivity(new ReaderActivity(renderer, mappedInputManager, initialEpubPath, onGoHome));
} }
void onGoToReaderHome() { onGoToReader(std::string()); } void onGoToReaderHome() { onGoToReader(std::string()); }
void onContinueReading() { onGoToReader(APP_STATE.openEpubPath); } void onContinueReading() { onGoToReader(APP_STATE.openEpubPath); }
void onGoToFileTransfer() { void onGoToFileTransfer() {
exitActivity(); exitActivity();
enterNewActivity(new CrossPointWebServerActivity(renderer, inputManager, onGoHome)); enterNewActivity(new CrossPointWebServerActivity(renderer, mappedInputManager, onGoHome));
} }
void onGoToSettings() { void onGoToSettings() {
exitActivity(); exitActivity();
enterNewActivity(new SettingsActivity(renderer, inputManager, onGoHome)); enterNewActivity(new SettingsActivity(renderer, mappedInputManager, onGoHome));
} }
void onGoHome() { void onGoHome() {
exitActivity(); exitActivity();
enterNewActivity(new HomeActivity(renderer, inputManager, onContinueReading, onGoToReaderHome, onGoToSettings, enterNewActivity(new HomeActivity(renderer, mappedInputManager, onContinueReading, onGoToReaderHome, onGoToSettings,
onGoToFileTransfer)); onGoToFileTransfer));
} }
@ -193,7 +195,7 @@ void setup() {
Serial.printf("[%lu] [ ] SD card initialization failed\n", millis()); Serial.printf("[%lu] [ ] SD card initialization failed\n", millis());
setupDisplayAndFonts(); setupDisplayAndFonts();
exitActivity(); exitActivity();
enterNewActivity(new FullScreenMessageActivity(renderer, inputManager, "SD card error", BOLD)); enterNewActivity(new FullScreenMessageActivity(renderer, mappedInputManager, "SD card error", BOLD));
return; return;
} }
@ -205,7 +207,7 @@ void setup() {
setupDisplayAndFonts(); setupDisplayAndFonts();
exitActivity(); exitActivity();
enterNewActivity(new BootActivity(renderer, inputManager)); enterNewActivity(new BootActivity(renderer, mappedInputManager));
APP_STATE.loadFromFile(); APP_STATE.loadFromFile();
if (APP_STATE.openEpubPath.empty()) { if (APP_STATE.openEpubPath.empty()) {