Compare commits

..

No commits in common. "bf031fd999c1fc3bd62c3761d27f8ea750dabce4" and "838246d1479f5aaed50f3eb4d43fd692bd77660f" have entirely different histories.

11 changed files with 251 additions and 352 deletions

View File

@ -106,18 +106,6 @@ std::vector<size_t> ParsedText::computeLineBreaks(const int pageWidth, const int
ans[i] = j; // j is the index of the last word in this optimal line ans[i] = j; // j is the index of the last word in this optimal line
} }
} }
// Handle oversized word: if no valid configuration found, force single-word line
// This prevents cascade failure where one oversized word breaks all preceding words
if (dp[i] == MAX_COST) {
ans[i] = i; // Just this word on its own line
// Inherit cost from next word to allow subsequent words to find valid configurations
if (i + 1 < static_cast<int>(totalWordCount)) {
dp[i] = dp[i + 1];
} else {
dp[i] = 0;
}
}
} }
// Stores the index of the word that starts the next line (last_word_index + 1) // Stores the index of the word that starts the next line (last_word_index + 1)

View File

@ -115,56 +115,26 @@ bool Section::clearCache() const {
bool Section::persistPageDataToSD(const int fontId, const float lineCompression, const int marginTop, bool Section::persistPageDataToSD(const int fontId, const float lineCompression, const int marginTop,
const int marginRight, const int marginBottom, const int marginLeft, const int marginRight, const int marginBottom, const int marginLeft,
const bool extraParagraphSpacing, const std::function<void()>& progressSetupFn, const bool extraParagraphSpacing) {
const std::function<void(int)>& progressFn) {
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";
File tmpHtml;
// Retry logic for SD card timing issues if (!FsHelpers::openFileForWrite("SCT", tmpHtmlPath, tmpHtml)) {
bool success = false; return false;
size_t fileSize = 0;
for (int attempt = 0; attempt < 3 && !success; attempt++) {
if (attempt > 0) {
Serial.printf("[%lu] [SCT] Retrying stream (attempt %d)...\n", millis(), attempt + 1);
delay(50); // Brief delay before retry
}
// Remove any incomplete file from previous attempt before retrying
if (SD.exists(tmpHtmlPath.c_str())) {
SD.remove(tmpHtmlPath.c_str());
}
File tmpHtml;
if (!FsHelpers::openFileForWrite("SCT", tmpHtmlPath, tmpHtml)) {
continue;
}
success = epub->readItemContentsToStream(localPath, tmpHtml, 1024);
fileSize = tmpHtml.size();
tmpHtml.close();
// If streaming failed, remove the incomplete file immediately
if (!success && SD.exists(tmpHtmlPath.c_str())) {
SD.remove(tmpHtmlPath.c_str());
Serial.printf("[%lu] [SCT] Removed incomplete temp file after failed attempt\n", millis());
}
} }
bool success = epub->readItemContentsToStream(localPath, tmpHtml, 1024);
tmpHtml.close();
if (!success) { if (!success) {
Serial.printf("[%lu] [SCT] Failed to stream item contents to temp file after retries\n", millis()); Serial.printf("[%lu] [SCT] Failed to stream item contents to temp file\n", millis());
return false; return false;
} }
Serial.printf("[%lu] [SCT] Streamed temp HTML to %s (%d bytes)\n", millis(), tmpHtmlPath.c_str(), fileSize); Serial.printf("[%lu] [SCT] Streamed temp HTML to %s\n", millis(), tmpHtmlPath.c_str());
// Only show progress bar for larger chapters where rendering overhead is worth it ChapterHtmlSlimParser visitor(tmpHtmlPath, renderer, fontId, lineCompression, marginTop, marginRight, marginBottom,
if (progressSetupFn && fileSize >= MIN_SIZE_FOR_PROGRESS) { marginLeft, extraParagraphSpacing,
progressSetupFn(); [this](std::unique_ptr<Page> page) { this->onPageComplete(std::move(page)); });
}
ChapterHtmlSlimParser visitor(
tmpHtmlPath, renderer, fontId, lineCompression, marginTop, marginRight, marginBottom, marginLeft,
extraParagraphSpacing, [this](std::unique_ptr<Page> page) { this->onPageComplete(std::move(page)); }, progressFn);
success = visitor.parseAndBuildPages(); success = visitor.parseAndBuildPages();
SD.remove(tmpHtmlPath.c_str()); SD.remove(tmpHtmlPath.c_str());

View File

@ -1,5 +1,4 @@
#pragma once #pragma once
#include <functional>
#include <memory> #include <memory>
#include "Epub.h" #include "Epub.h"
@ -32,8 +31,6 @@ class Section {
void setupCacheDir() const; void setupCacheDir() const;
bool clearCache() const; bool clearCache() const;
bool persistPageDataToSD(int fontId, float lineCompression, int marginTop, int marginRight, int marginBottom, bool persistPageDataToSD(int fontId, float lineCompression, int marginTop, int marginRight, int marginBottom,
int marginLeft, bool extraParagraphSpacing, int marginLeft, bool extraParagraphSpacing);
const std::function<void()>& progressSetupFn = nullptr,
const std::function<void(int)>& progressFn = nullptr);
std::unique_ptr<Page> loadPageFromSD() const; std::unique_ptr<Page> loadPageFromSD() const;
}; };

View File

@ -4,18 +4,11 @@
#include <Serialization.h> #include <Serialization.h>
void TextBlock::render(const GfxRenderer& renderer, const int fontId, const int x, const int y) const { void TextBlock::render(const GfxRenderer& renderer, const int fontId, const int x, const int y) const {
// Validate iterator bounds before rendering
if (words.size() != wordXpos.size() || words.size() != wordStyles.size()) {
Serial.printf("[%lu] [TXB] Render skipped: size mismatch (words=%u, xpos=%u, styles=%u)\n", millis(),
(uint32_t)words.size(), (uint32_t)wordXpos.size(), (uint32_t)wordStyles.size());
return;
}
auto wordIt = words.begin(); auto wordIt = words.begin();
auto wordStylesIt = wordStyles.begin(); auto wordStylesIt = wordStyles.begin();
auto wordXposIt = wordXpos.begin(); auto wordXposIt = wordXpos.begin();
for (size_t i = 0; i < words.size(); i++) { for (int i = 0; i < words.size(); i++) {
renderer.drawText(fontId, *wordXposIt + x, y, wordIt->c_str(), true, *wordStylesIt); renderer.drawText(fontId, *wordXposIt + x, y, wordIt->c_str(), true, *wordStylesIt);
std::advance(wordIt, 1); std::advance(wordIt, 1);
@ -53,13 +46,6 @@ std::unique_ptr<TextBlock> TextBlock::deserialize(File& file) {
// words // words
serialization::readPod(file, wc); serialization::readPod(file, wc);
// Sanity check: prevent allocation of unreasonably large lists (max 10000 words per block)
if (wc > 10000) {
Serial.printf("[%lu] [TXB] Deserialization failed: word count %u exceeds maximum\n", millis(), wc);
return nullptr;
}
words.resize(wc); words.resize(wc);
for (auto& w : words) serialization::readString(file, w); for (auto& w : words) serialization::readString(file, w);
@ -73,13 +59,6 @@ std::unique_ptr<TextBlock> TextBlock::deserialize(File& file) {
wordStyles.resize(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
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 // style
serialization::readPod(file, style); serialization::readPod(file, style);

View File

@ -11,9 +11,6 @@
const char* HEADER_TAGS[] = {"h1", "h2", "h3", "h4", "h5", "h6"}; const char* HEADER_TAGS[] = {"h1", "h2", "h3", "h4", "h5", "h6"};
constexpr int NUM_HEADER_TAGS = sizeof(HEADER_TAGS) / sizeof(HEADER_TAGS[0]); constexpr int NUM_HEADER_TAGS = sizeof(HEADER_TAGS) / sizeof(HEADER_TAGS[0]);
// Minimum file size (in bytes) to show progress bar - smaller chapters don't benefit from it
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]);
@ -224,11 +221,6 @@ bool ChapterHtmlSlimParser::parseAndBuildPages() {
return false; return false;
} }
// Get file size for progress calculation
const size_t totalSize = file.size();
size_t bytesRead = 0;
int lastProgress = -1;
XML_SetUserData(parser, this); XML_SetUserData(parser, this);
XML_SetElementHandler(parser, startElement, endElement); XML_SetElementHandler(parser, startElement, endElement);
XML_SetCharacterDataHandler(parser, characterData); XML_SetCharacterDataHandler(parser, characterData);
@ -257,17 +249,6 @@ bool ChapterHtmlSlimParser::parseAndBuildPages() {
return false; return false;
} }
// Update progress (call every 10% change to avoid too frequent updates)
// Only show progress for larger chapters where rendering overhead is worth it
bytesRead += len;
if (progressFn && totalSize >= MIN_SIZE_FOR_PROGRESS) {
const int progress = static_cast<int>((bytesRead * 100) / totalSize);
if (lastProgress / 10 != progress / 10) {
lastProgress = progress;
progressFn(progress);
}
}
done = file.available() == 0; done = file.available() == 0;
if (XML_ParseBuffer(parser, static_cast<int>(len), done) == XML_STATUS_ERROR) { if (XML_ParseBuffer(parser, static_cast<int>(len), done) == XML_STATUS_ERROR) {

View File

@ -18,7 +18,6 @@ class ChapterHtmlSlimParser {
const std::string& filepath; const std::string& filepath;
GfxRenderer& renderer; GfxRenderer& renderer;
std::function<void(std::unique_ptr<Page>)> completePageFn; std::function<void(std::unique_ptr<Page>)> completePageFn;
std::function<void(int)> progressFn; // Progress callback (0-100)
int depth = 0; int depth = 0;
int skipUntilDepth = INT_MAX; int skipUntilDepth = INT_MAX;
int boldUntilDepth = INT_MAX; int boldUntilDepth = INT_MAX;
@ -49,8 +48,7 @@ class ChapterHtmlSlimParser {
explicit ChapterHtmlSlimParser(const std::string& filepath, GfxRenderer& renderer, const int fontId, explicit ChapterHtmlSlimParser(const std::string& filepath, GfxRenderer& renderer, const int fontId,
const float lineCompression, const int marginTop, const int marginRight, const float lineCompression, const int marginTop, const int marginRight,
const int marginBottom, const int marginLeft, const bool extraParagraphSpacing, const int marginBottom, const int marginLeft, const bool extraParagraphSpacing,
const std::function<void(std::unique_ptr<Page>)>& completePageFn, const std::function<void(std::unique_ptr<Page>)>& completePageFn)
const std::function<void(int)>& progressFn = nullptr)
: filepath(filepath), : filepath(filepath),
renderer(renderer), renderer(renderer),
fontId(fontId), fontId(fontId),
@ -60,8 +58,7 @@ class ChapterHtmlSlimParser {
marginBottom(marginBottom), marginBottom(marginBottom),
marginLeft(marginLeft), marginLeft(marginLeft),
extraParagraphSpacing(extraParagraphSpacing), extraParagraphSpacing(extraParagraphSpacing),
completePageFn(completePageFn), completePageFn(completePageFn) {}
progressFn(progressFn) {}
~ChapterHtmlSlimParser() = default; ~ChapterHtmlSlimParser() = default;
bool parseAndBuildPages(); bool parseAndBuildPages();
void addLineToPage(std::shared_ptr<TextBlock> line); void addLineToPage(std::shared_ptr<TextBlock> line);

View File

@ -187,21 +187,11 @@ void WifiSelectionActivity::selectNetwork(const int index) {
if (selectedRequiresPassword) { if (selectedRequiresPassword) {
// Show password entry // Show password entry
state = WifiSelectionState::PASSWORD_ENTRY; state = WifiSelectionState::PASSWORD_ENTRY;
enterNewActivity(new KeyboardEntryActivity( enterNewActivity(new KeyboardEntryActivity(renderer, inputManager, "Enter WiFi Password",
renderer, inputManager, "Enter WiFi Password", "", // No initial text
"", // No initial text 64, // Max password length
50, // Y position false // Show password by default (hard keyboard to use)
64, // Max password length ));
false, // Show password by default (hard keyboard to use)
[this](const std::string& text) {
enteredPassword = text;
exitActivity();
},
[this] {
state = WifiSelectionState::NETWORK_LIST;
updateRequired = true;
exitActivity();
}));
updateRequired = true; updateRequired = true;
} else { } else {
// Connect directly for open networks // Connect directly for open networks
@ -218,6 +208,11 @@ void WifiSelectionActivity::attemptConnection() {
WiFi.mode(WIFI_STA); WiFi.mode(WIFI_STA);
// Get password from keyboard if we just entered it
if (subActivity && !usedSavedPassword) {
enteredPassword = static_cast<KeyboardEntryActivity*>(subActivity.get())->getText();
}
if (selectedRequiresPassword && !enteredPassword.empty()) { if (selectedRequiresPassword && !enteredPassword.empty()) {
WiFi.begin(selectedSSID.c_str(), enteredPassword.c_str()); WiFi.begin(selectedSSID.c_str(), enteredPassword.c_str());
} else { } else {
@ -274,11 +269,6 @@ void WifiSelectionActivity::checkConnectionStatus() {
} }
void WifiSelectionActivity::loop() { void WifiSelectionActivity::loop() {
if (subActivity) {
subActivity->loop();
return;
}
// Check scan progress // Check scan progress
if (state == WifiSelectionState::SCANNING) { if (state == WifiSelectionState::SCANNING) {
processWifiScanResults(); processWifiScanResults();
@ -291,9 +281,24 @@ void WifiSelectionActivity::loop() {
return; return;
} }
if (state == WifiSelectionState::PASSWORD_ENTRY) { // Handle password entry state
// Reach here once password entry finished in subactivity if (state == WifiSelectionState::PASSWORD_ENTRY && subActivity) {
attemptConnection(); const auto keyboard = static_cast<KeyboardEntryActivity*>(subActivity.get());
keyboard->handleInput();
if (keyboard->isComplete()) {
attemptConnection();
return;
}
if (keyboard->isCancelled()) {
state = WifiSelectionState::NETWORK_LIST;
exitActivity();
updateRequired = true;
return;
}
updateRequired = true;
return; return;
} }
@ -436,10 +441,6 @@ std::string WifiSelectionActivity::getSignalStrengthIndicator(const int32_t rssi
void WifiSelectionActivity::displayTaskLoop() { void WifiSelectionActivity::displayTaskLoop() {
while (true) { while (true) {
if (subActivity) {
continue;
}
if (updateRequired) { if (updateRequired) {
updateRequired = false; updateRequired = false;
xSemaphoreTake(renderingMutex, portMAX_DELAY); xSemaphoreTake(renderingMutex, portMAX_DELAY);
@ -460,6 +461,9 @@ void WifiSelectionActivity::render() const {
case WifiSelectionState::NETWORK_LIST: case WifiSelectionState::NETWORK_LIST:
renderNetworkList(); renderNetworkList();
break; break;
case WifiSelectionState::PASSWORD_ENTRY:
renderPasswordEntry();
break;
case WifiSelectionState::CONNECTING: case WifiSelectionState::CONNECTING:
renderConnecting(); renderConnecting();
break; break;
@ -557,6 +561,23 @@ void WifiSelectionActivity::renderNetworkList() const {
renderer.drawButtonHints(UI_FONT_ID, "« Back", "Connect", "", ""); renderer.drawButtonHints(UI_FONT_ID, "« Back", "Connect", "", "");
} }
void WifiSelectionActivity::renderPasswordEntry() const {
// Draw header
renderer.drawCenteredText(READER_FONT_ID, 5, "WiFi Password", true, BOLD);
// Draw network name with good spacing from header
std::string networkInfo = "Network: " + selectedSSID;
if (networkInfo.length() > 30) {
networkInfo.replace(27, networkInfo.length() - 27, "...");
}
renderer.drawCenteredText(UI_FONT_ID, 38, networkInfo.c_str(), true, REGULAR);
// Draw keyboard
if (subActivity) {
static_cast<KeyboardEntryActivity*>(subActivity.get())->render(58);
}
}
void WifiSelectionActivity::renderConnecting() const { void WifiSelectionActivity::renderConnecting() const {
const auto pageHeight = renderer.getScreenHeight(); const auto pageHeight = renderer.getScreenHeight();
const auto height = renderer.getLineHeight(UI_FONT_ID); const auto height = renderer.getLineHeight(UI_FONT_ID);

View File

@ -227,51 +227,23 @@ void EpubReaderActivity::renderScreen() {
SETTINGS.extraParagraphSpacing)) { SETTINGS.extraParagraphSpacing)) {
Serial.printf("[%lu] [ERS] Cache not found, building...\n", millis()); Serial.printf("[%lu] [ERS] Cache not found, building...\n", millis());
// Progress bar dimensions
constexpr int barWidth = 200;
constexpr int barHeight = 10;
constexpr int boxMargin = 20;
const int textWidth = renderer.getTextWidth(READER_FONT_ID, "Indexing...");
const int boxWidthWithBar = (barWidth > textWidth ? barWidth : textWidth) + boxMargin * 2;
const int boxWidthNoBar = textWidth + boxMargin * 2;
const int boxHeightWithBar = renderer.getLineHeight(READER_FONT_ID) + barHeight + boxMargin * 3;
const int boxHeightNoBar = renderer.getLineHeight(READER_FONT_ID) + boxMargin * 2;
const int boxXWithBar = (GfxRenderer::getScreenWidth() - boxWidthWithBar) / 2;
const int boxXNoBar = (GfxRenderer::getScreenWidth() - boxWidthNoBar) / 2;
constexpr int boxY = 50;
const int barX = boxXWithBar + (boxWidthWithBar - barWidth) / 2;
const int barY = boxY + renderer.getLineHeight(READER_FONT_ID) + boxMargin * 2;
// Always show "Indexing..." text first
{ {
renderer.fillRect(boxXNoBar, boxY, boxWidthNoBar, boxHeightNoBar, false); const int textWidth = renderer.getTextWidth(READER_FONT_ID, "Indexing...");
renderer.drawText(READER_FONT_ID, boxXNoBar + boxMargin, boxY + boxMargin, "Indexing..."); constexpr int margin = 20;
renderer.drawRect(boxXNoBar + 5, boxY + 5, boxWidthNoBar - 10, boxHeightNoBar - 10); const int x = (GfxRenderer::getScreenWidth() - textWidth - margin * 2) / 2;
constexpr int y = 50;
const int w = textWidth + margin * 2;
const int h = renderer.getLineHeight(READER_FONT_ID) + margin * 2;
renderer.fillRect(x, y, w, h, false);
renderer.drawText(READER_FONT_ID, x + margin, y + margin, "Indexing...");
renderer.drawRect(x + 5, y + 5, w - 10, h - 10);
renderer.displayBuffer(); renderer.displayBuffer();
pagesUntilFullRefresh = 0; pagesUntilFullRefresh = 0;
} }
section->setupCacheDir(); section->setupCacheDir();
// Setup callback - only called for chapters >= 50KB, redraws with progress bar
auto progressSetup = [this, boxXWithBar, boxWidthWithBar, boxHeightWithBar, boxMargin, barX, barY, barWidth,
barHeight]() {
renderer.fillRect(boxXWithBar, boxY, boxWidthWithBar, boxHeightWithBar, false);
renderer.drawText(READER_FONT_ID, boxXWithBar + boxMargin, boxY + boxMargin, "Indexing...");
renderer.drawRect(boxXWithBar + 5, boxY + 5, boxWidthWithBar - 10, boxHeightWithBar - 10);
renderer.drawRect(barX, barY, barWidth, barHeight);
renderer.displayBuffer();
};
// Progress callback to update progress bar
auto progressCallback = [this, barX, barY, barWidth, barHeight](int progress) {
const int fillWidth = (barWidth - 2) * progress / 100;
renderer.fillRect(barX + 1, barY + 1, fillWidth, barHeight - 2, true);
renderer.displayBuffer(EInkDisplay::FAST_REFRESH);
};
if (!section->persistPageDataToSD(READER_FONT_ID, lineCompression, marginTop, marginRight, marginBottom, if (!section->persistPageDataToSD(READER_FONT_ID, lineCompression, marginTop, marginRight, marginBottom,
marginLeft, SETTINGS.extraParagraphSpacing, progressSetup, progressCallback)) { marginLeft, SETTINGS.extraParagraphSpacing)) {
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;

View File

@ -57,9 +57,9 @@ void EpubReaderChapterSelectionActivity::loop() {
const bool skipPage = inputManager.getHeldTime() > SKIP_PAGE_MS; const bool skipPage = inputManager.getHeldTime() > SKIP_PAGE_MS;
if (inputManager.wasReleased(InputManager::BTN_CONFIRM)) { if (inputManager.wasPressed(InputManager::BTN_CONFIRM)) {
onSelectSpineIndex(selectorIndex); onSelectSpineIndex(selectorIndex);
} else if (inputManager.wasReleased(InputManager::BTN_BACK)) { } else if (inputManager.wasPressed(InputManager::BTN_BACK)) {
onGoBack(); onGoBack();
} else if (prevReleased) { } else if (prevReleased) {
if (skipPage) { if (skipPage) {

View File

@ -10,55 +10,41 @@ const char* const KeyboardEntryActivity::keyboard[NUM_ROWS] = {
// Keyboard layouts - uppercase/symbols // Keyboard layouts - uppercase/symbols
const char* const KeyboardEntryActivity::keyboardShift[NUM_ROWS] = {"~!@#$%^&*()_+", "QWERTYUIOP{}|", "ASDFGHJKL:\"", const char* const KeyboardEntryActivity::keyboardShift[NUM_ROWS] = {"~!@#$%^&*()_+", "QWERTYUIOP{}|", "ASDFGHJKL:\"",
"ZXCVBNM<>?", "SPECIAL ROW"}; "ZXCVBNM<>?", "^ _____<OK"};
void KeyboardEntryActivity::taskTrampoline(void* param) { void KeyboardEntryActivity::setText(const std::string& newText) {
auto* self = static_cast<KeyboardEntryActivity*>(param); text = newText;
self->displayTaskLoop(); if (maxLength > 0 && text.length() > maxLength) {
text.resize(maxLength);
}
} }
void KeyboardEntryActivity::displayTaskLoop() { void KeyboardEntryActivity::reset(const std::string& newTitle, const std::string& newInitialText) {
while (true) { if (!newTitle.empty()) {
if (updateRequired) { title = newTitle;
updateRequired = false;
xSemaphoreTake(renderingMutex, portMAX_DELAY);
render();
xSemaphoreGive(renderingMutex);
}
vTaskDelay(10 / portTICK_PERIOD_MS);
} }
text = newInitialText;
selectedRow = 0;
selectedCol = 0;
shiftActive = false;
complete = false;
cancelled = false;
} }
void KeyboardEntryActivity::onEnter() { void KeyboardEntryActivity::onEnter() {
Activity::onEnter(); Activity::onEnter();
renderingMutex = xSemaphoreCreateMutex(); // Reset state when entering the activity
complete = false;
// Trigger first update cancelled = false;
updateRequired = true;
xTaskCreate(&KeyboardEntryActivity::taskTrampoline, "KeyboardEntryActivity",
2048, // Stack size
this, // Parameters
1, // Priority
&displayTaskHandle // Task handle
);
} }
void KeyboardEntryActivity::onExit() { void KeyboardEntryActivity::loop() {
Activity::onExit(); handleInput();
render(10);
// Wait until not rendering to delete task to avoid killing mid-instruction to EPD
xSemaphoreTake(renderingMutex, portMAX_DELAY);
if (displayTaskHandle) {
vTaskDelete(displayTaskHandle);
displayTaskHandle = nullptr;
}
vSemaphoreDelete(renderingMutex);
renderingMutex = nullptr;
} }
int KeyboardEntryActivity::getRowLength(const int row) const { int KeyboardEntryActivity::getRowLength(int row) const {
if (row < 0 || row >= NUM_ROWS) return 0; if (row < 0 || row >= NUM_ROWS) return 0;
// Return actual length of each row based on keyboard layout // Return actual length of each row based on keyboard layout
@ -72,7 +58,7 @@ int KeyboardEntryActivity::getRowLength(const int row) const {
case 3: case 3:
return 10; // zxcvbnm,./ return 10; // zxcvbnm,./
case 4: case 4:
return 10; // caps (2 wide), space (5 wide), backspace (2 wide), OK return 10; // ^, space (5 wide), backspace, OK (2 wide)
default: default:
return 0; return 0;
} }
@ -89,8 +75,8 @@ char KeyboardEntryActivity::getSelectedChar() const {
void KeyboardEntryActivity::handleKeyPress() { void KeyboardEntryActivity::handleKeyPress() {
// Handle special row (bottom row with shift, space, backspace, done) // Handle special row (bottom row with shift, space, backspace, done)
if (selectedRow == SPECIAL_ROW) { if (selectedRow == SHIFT_ROW) {
if (selectedCol >= SHIFT_COL && selectedCol < SPACE_COL) { if (selectedCol == SHIFT_COL) {
// Shift toggle // Shift toggle
shiftActive = !shiftActive; shiftActive = !shiftActive;
return; return;
@ -104,7 +90,7 @@ void KeyboardEntryActivity::handleKeyPress() {
return; return;
} }
if (selectedCol >= BACKSPACE_COL && selectedCol < DONE_COL) { if (selectedCol == BACKSPACE_COL) {
// Backspace // Backspace
if (!text.empty()) { if (!text.empty()) {
text.pop_back(); text.pop_back();
@ -114,6 +100,7 @@ void KeyboardEntryActivity::handleKeyPress() {
if (selectedCol >= DONE_COL) { if (selectedCol >= DONE_COL) {
// Done button // Done button
complete = true;
if (onComplete) { if (onComplete) {
onComplete(text); onComplete(text);
} }
@ -122,61 +109,42 @@ void KeyboardEntryActivity::handleKeyPress() {
} }
// Regular character // Regular character
const char c = getSelectedChar(); char c = getSelectedChar();
if (c == '\0') { if (c != '\0' && c != '^' && c != '_' && c != '<') {
return; if (maxLength == 0 || text.length() < maxLength) {
} text += c;
// Auto-disable shift after typing a letter
if (maxLength == 0 || text.length() < maxLength) { if (shiftActive && ((c >= 'A' && c <= 'Z') || (c >= 'a' && c <= 'z'))) {
text += c; shiftActive = false;
// Auto-disable shift after typing a letter }
if (shiftActive && ((c >= 'A' && c <= 'Z') || (c >= 'a' && c <= 'z'))) {
shiftActive = false;
} }
} }
} }
void KeyboardEntryActivity::loop() { bool KeyboardEntryActivity::handleInput() {
if (complete || cancelled) {
return false;
}
bool handled = false;
// Navigation // Navigation
if (inputManager.wasPressed(InputManager::BTN_UP)) { if (inputManager.wasPressed(InputManager::BTN_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
const int maxCol = getRowLength(selectedRow) - 1; int maxCol = getRowLength(selectedRow) - 1;
if (selectedCol > maxCol) selectedCol = maxCol; if (selectedCol > maxCol) selectedCol = maxCol;
} }
updateRequired = true; handled = true;
} } else if (inputManager.wasPressed(InputManager::BTN_DOWN)) {
if (inputManager.wasPressed(InputManager::BTN_DOWN)) {
if (selectedRow < NUM_ROWS - 1) { if (selectedRow < NUM_ROWS - 1) {
selectedRow++; selectedRow++;
const int maxCol = getRowLength(selectedRow) - 1; int maxCol = getRowLength(selectedRow) - 1;
if (selectedCol > maxCol) selectedCol = maxCol; if (selectedCol > maxCol) selectedCol = maxCol;
} }
updateRequired = true; handled = true;
} } else if (inputManager.wasPressed(InputManager::BTN_LEFT)) {
if (inputManager.wasPressed(InputManager::BTN_LEFT)) {
// Special bottom row case
if (selectedRow == SPECIAL_ROW) {
// Bottom row has special key widths
if (selectedCol >= SHIFT_COL && selectedCol < SPACE_COL) {
// In shift key, do nothing
} else if (selectedCol >= SPACE_COL && selectedCol < BACKSPACE_COL) {
// In space bar, move to shift
selectedCol = SHIFT_COL;
} else if (selectedCol >= BACKSPACE_COL && selectedCol < DONE_COL) {
// In backspace, move to space
selectedCol = SPACE_COL;
} else if (selectedCol >= DONE_COL) {
// At done button, move to backspace
selectedCol = BACKSPACE_COL;
}
updateRequired = true;
return;
}
if (selectedCol > 0) { if (selectedCol > 0) {
selectedCol--; selectedCol--;
} else if (selectedRow > 0) { } else if (selectedRow > 0) {
@ -184,31 +152,9 @@ void KeyboardEntryActivity::loop() {
selectedRow--; selectedRow--;
selectedCol = getRowLength(selectedRow) - 1; selectedCol = getRowLength(selectedRow) - 1;
} }
updateRequired = true; handled = true;
} } else if (inputManager.wasPressed(InputManager::BTN_RIGHT)) {
int maxCol = getRowLength(selectedRow) - 1;
if (inputManager.wasPressed(InputManager::BTN_RIGHT)) {
const int maxCol = getRowLength(selectedRow) - 1;
// Special bottom row case
if (selectedRow == SPECIAL_ROW) {
// Bottom row has special key widths
if (selectedCol >= SHIFT_COL && selectedCol < SPACE_COL) {
// In shift key, move to space
selectedCol = SPACE_COL;
} else if (selectedCol >= SPACE_COL && selectedCol < BACKSPACE_COL) {
// In space bar, move to backspace
selectedCol = BACKSPACE_COL;
} else if (selectedCol >= BACKSPACE_COL && selectedCol < DONE_COL) {
// In backspace, move to done
selectedCol = DONE_COL;
} else if (selectedCol >= DONE_COL) {
// At done button, do nothing
}
updateRequired = true;
return;
}
if (selectedCol < maxCol) { if (selectedCol < maxCol) {
selectedCol++; selectedCol++;
} else if (selectedRow < NUM_ROWS - 1) { } else if (selectedRow < NUM_ROWS - 1) {
@ -216,34 +162,35 @@ void KeyboardEntryActivity::loop() {
selectedRow++; selectedRow++;
selectedCol = 0; selectedCol = 0;
} }
updateRequired = true; handled = true;
} }
// Selection // Selection
if (inputManager.wasPressed(InputManager::BTN_CONFIRM)) { if (inputManager.wasPressed(InputManager::BTN_CONFIRM)) {
handleKeyPress(); handleKeyPress();
updateRequired = true; handled = true;
} }
// Cancel // Cancel
if (inputManager.wasPressed(InputManager::BTN_BACK)) { if (inputManager.wasPressed(InputManager::BTN_BACK)) {
cancelled = true;
if (onCancel) { if (onCancel) {
onCancel(); onCancel();
} }
updateRequired = true; handled = true;
} }
return handled;
} }
void KeyboardEntryActivity::render() const { void KeyboardEntryActivity::render(int startY) const {
const auto pageWidth = GfxRenderer::getScreenWidth(); const auto pageWidth = GfxRenderer::getScreenWidth();
renderer.clearScreen();
// Draw title // Draw title
renderer.drawCenteredText(UI_FONT_ID, startY, title.c_str(), true, REGULAR); renderer.drawCenteredText(UI_FONT_ID, startY, title.c_str(), true, REGULAR);
// Draw input field // Draw input field
const int inputY = startY + 22; int inputY = startY + 22;
renderer.drawText(UI_FONT_ID, 10, inputY, "["); renderer.drawText(UI_FONT_ID, 10, inputY, "[");
std::string displayText; std::string displayText;
@ -257,9 +204,9 @@ void KeyboardEntryActivity::render() const {
displayText += "_"; displayText += "_";
// Truncate if too long for display - use actual character width from font // Truncate if too long for display - use actual character width from font
int approxCharWidth = renderer.getSpaceWidth(UI_FONT_ID); int charWidth = renderer.getSpaceWidth(UI_FONT_ID);
if (approxCharWidth < 1) approxCharWidth = 8; // Fallback to approximate width if (charWidth < 1) charWidth = 8; // Fallback to approximate width
const int maxDisplayLen = (pageWidth - 40) / approxCharWidth; int maxDisplayLen = (pageWidth - 40) / charWidth;
if (displayText.length() > static_cast<size_t>(maxDisplayLen)) { if (displayText.length() > static_cast<size_t>(maxDisplayLen)) {
displayText = "..." + displayText.substr(displayText.length() - maxDisplayLen + 3); displayText = "..." + displayText.substr(displayText.length() - maxDisplayLen + 3);
} }
@ -268,22 +215,22 @@ void KeyboardEntryActivity::render() const {
renderer.drawText(UI_FONT_ID, pageWidth - 15, inputY, "]"); renderer.drawText(UI_FONT_ID, pageWidth - 15, inputY, "]");
// Draw keyboard - use compact spacing to fit 5 rows on screen // Draw keyboard - use compact spacing to fit 5 rows on screen
const int keyboardStartY = inputY + 25; int keyboardStartY = inputY + 25;
constexpr int keyWidth = 18; const int keyWidth = 18;
constexpr int keyHeight = 18; const int keyHeight = 18;
constexpr int keySpacing = 3; const int keySpacing = 3;
const char* const* layout = shiftActive ? keyboardShift : keyboard; const char* const* layout = shiftActive ? keyboardShift : keyboard;
// Calculate left margin to center the longest row (13 keys) // Calculate left margin to center the longest row (13 keys)
constexpr int maxRowWidth = KEYS_PER_ROW * (keyWidth + keySpacing); int maxRowWidth = KEYS_PER_ROW * (keyWidth + keySpacing);
const int leftMargin = (pageWidth - maxRowWidth) / 2; int leftMargin = (pageWidth - maxRowWidth) / 2;
for (int row = 0; row < NUM_ROWS; row++) { for (int row = 0; row < NUM_ROWS; row++) {
const int rowY = keyboardStartY + row * (keyHeight + keySpacing); int rowY = keyboardStartY + row * (keyHeight + keySpacing);
// Left-align all rows for consistent navigation // Left-align all rows for consistent navigation
const int startX = leftMargin; int startX = leftMargin;
// Handle bottom row (row 4) specially with proper multi-column keys // Handle bottom row (row 4) specially with proper multi-column keys
if (row == 4) { if (row == 4) {
@ -293,37 +240,64 @@ void KeyboardEntryActivity::render() const {
int currentX = startX; int currentX = startX;
// CAPS key (logical col 0, spans 2 key widths) // CAPS key (logical col 0, spans 2 key widths)
const bool capsSelected = (selectedRow == 4 && selectedCol >= SHIFT_COL && selectedCol < SPACE_COL); int capsWidth = 2 * keyWidth + keySpacing;
renderItemWithSelector(currentX + 2, rowY, shiftActive ? "CAPS" : "caps", capsSelected); bool capsSelected = (selectedRow == 4 && selectedCol == SHIFT_COL);
currentX += 2 * (keyWidth + keySpacing); if (capsSelected) {
renderer.drawText(UI_FONT_ID, currentX - 2, rowY, "[");
renderer.drawText(UI_FONT_ID, currentX + capsWidth - 4, rowY, "]");
}
renderer.drawText(UI_FONT_ID, currentX + 2, rowY, shiftActive ? "CAPS" : "caps");
currentX += capsWidth + keySpacing;
// Space bar (logical cols 2-6, spans 5 key widths) // Space bar (logical cols 2-6, spans 5 key widths)
const bool spaceSelected = (selectedRow == 4 && selectedCol >= SPACE_COL && selectedCol < BACKSPACE_COL); int spaceWidth = 5 * keyWidth + 4 * keySpacing;
const int spaceTextWidth = renderer.getTextWidth(UI_FONT_ID, "_____"); bool spaceSelected = (selectedRow == 4 && selectedCol >= SPACE_COL && selectedCol < BACKSPACE_COL);
const int spaceXWidth = 5 * (keyWidth + keySpacing); if (spaceSelected) {
const int spaceXPos = currentX + (spaceXWidth - spaceTextWidth) / 2; renderer.drawText(UI_FONT_ID, currentX - 2, rowY, "[");
renderItemWithSelector(spaceXPos, rowY, "_____", spaceSelected); renderer.drawText(UI_FONT_ID, currentX + spaceWidth - 4, rowY, "]");
currentX += spaceXWidth; }
// Draw centered underscores for space bar
int spaceTextX = currentX + (spaceWidth / 2) - 12;
renderer.drawText(UI_FONT_ID, spaceTextX, rowY, "_____");
currentX += spaceWidth + keySpacing;
// Backspace key (logical col 7, spans 2 key widths) // Backspace key (logical col 7, spans 2 key widths)
const bool bsSelected = (selectedRow == 4 && selectedCol >= BACKSPACE_COL && selectedCol < DONE_COL); int bsWidth = 2 * keyWidth + keySpacing;
renderItemWithSelector(currentX + 2, rowY, "<-", bsSelected); bool bsSelected = (selectedRow == 4 && selectedCol == BACKSPACE_COL);
currentX += 2 * (keyWidth + keySpacing); if (bsSelected) {
renderer.drawText(UI_FONT_ID, currentX - 2, rowY, "[");
renderer.drawText(UI_FONT_ID, currentX + bsWidth - 4, rowY, "]");
}
renderer.drawText(UI_FONT_ID, currentX + 6, rowY, "<-");
currentX += bsWidth + keySpacing;
// OK button (logical col 9, spans 2 key widths) // OK button (logical col 9, spans 2 key widths)
const bool okSelected = (selectedRow == 4 && selectedCol >= DONE_COL); int okWidth = 2 * keyWidth + keySpacing;
renderItemWithSelector(currentX + 2, rowY, "OK", okSelected); bool okSelected = (selectedRow == 4 && selectedCol >= DONE_COL);
if (okSelected) {
renderer.drawText(UI_FONT_ID, currentX - 2, rowY, "[");
renderer.drawText(UI_FONT_ID, currentX + okWidth - 4, rowY, "]");
}
renderer.drawText(UI_FONT_ID, currentX + 8, rowY, "OK");
} else { } else {
// Regular rows: render each key individually // Regular rows: render each key individually
for (int col = 0; col < getRowLength(row); col++) { for (int col = 0; col < getRowLength(row); col++) {
// Get the character to display int keyX = startX + col * (keyWidth + keySpacing);
const char c = layout[row][col];
std::string keyLabel(1, c);
const int charWidth = renderer.getTextWidth(UI_FONT_ID, keyLabel.c_str());
const int keyX = startX + col * (keyWidth + keySpacing) + (keyWidth - charWidth) / 2; // Get the character to display
const bool isSelected = row == selectedRow && col == selectedCol; char c = layout[row][col];
renderItemWithSelector(keyX, rowY, keyLabel.c_str(), isSelected); std::string keyLabel(1, c);
// Draw selection highlight
bool isSelected = (row == selectedRow && col == selectedCol);
if (isSelected) {
renderer.drawText(UI_FONT_ID, keyX - 2, rowY, "[");
renderer.drawText(UI_FONT_ID, keyX + keyWidth - 4, rowY, "]");
}
renderer.drawText(UI_FONT_ID, keyX + 2, rowY, keyLabel.c_str());
} }
} }
} }
@ -331,15 +305,4 @@ void KeyboardEntryActivity::render() const {
// Draw help text at absolute bottom of screen (consistent with other screens) // Draw help text at absolute bottom of screen (consistent with other screens)
const auto pageHeight = GfxRenderer::getScreenHeight(); const auto pageHeight = GfxRenderer::getScreenHeight();
renderer.drawText(SMALL_FONT_ID, 10, pageHeight - 30, "Navigate: D-pad | Select: OK | Cancel: BACK"); renderer.drawText(SMALL_FONT_ID, 10, pageHeight - 30, "Navigate: D-pad | Select: OK | Cancel: BACK");
renderer.displayBuffer();
}
void KeyboardEntryActivity::renderItemWithSelector(const int x, const int y, const char* item,
const bool isSelected) const {
if (isSelected) {
const int itemWidth = renderer.getTextWidth(UI_FONT_ID, item);
renderer.drawText(UI_FONT_ID, x - 6, y, "[");
renderer.drawText(UI_FONT_ID, x + itemWidth, y, "]");
}
renderer.drawText(UI_FONT_ID, x, y, item);
} }

View File

@ -1,13 +1,9 @@
#pragma once #pragma once
#include <GfxRenderer.h> #include <GfxRenderer.h>
#include <InputManager.h> #include <InputManager.h>
#include <freertos/FreeRTOS.h>
#include <freertos/semphr.h>
#include <freertos/task.h>
#include <functional> #include <functional>
#include <string> #include <string>
#include <utility>
#include "../Activity.h" #include "../Activity.h"
@ -34,44 +30,80 @@ class KeyboardEntryActivity : public Activity {
* @param inputManager Reference to InputManager for handling input * @param inputManager Reference to InputManager 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 maxLength Maximum length of input text (0 for unlimited) * @param maxLength Maximum length of input text (0 for unlimited)
* @param isPassword If true, display asterisks instead of actual characters * @param isPassword If true, display asterisks instead of actual characters
* @param onComplete Callback invoked when input is complete
* @param onCancel Callback invoked when input is cancelled
*/ */
explicit KeyboardEntryActivity(GfxRenderer& renderer, InputManager& inputManager, std::string title = "Enter Text", KeyboardEntryActivity(GfxRenderer& renderer, InputManager& inputManager, const std::string& title = "Enter Text",
std::string initialText = "", const int startY = 10, const size_t maxLength = 0, const std::string& initialText = "", const size_t maxLength = 0, const bool isPassword = false)
const bool isPassword = false, OnCompleteCallback onComplete = nullptr,
OnCancelCallback onCancel = nullptr)
: Activity("KeyboardEntry", renderer, inputManager), : Activity("KeyboardEntry", renderer, inputManager),
title(std::move(title)), title(title),
text(std::move(initialText)), text(initialText),
startY(startY),
maxLength(maxLength), maxLength(maxLength),
isPassword(isPassword), isPassword(isPassword) {}
onComplete(std::move(onComplete)),
onCancel(std::move(onCancel)) {} /**
* Handle button input. Call this in your main loop.
* @return true if input was handled, false otherwise
*/
bool handleInput();
/**
* Render the keyboard at the specified Y position.
* @param startY Y-coordinate where keyboard rendering starts (default 10)
*/
void render(int startY = 10) const;
/**
* Get the current text entered by the user.
*/
const std::string& getText() const { return text; }
/**
* Set the current text.
*/
void setText(const std::string& newText);
/**
* Check if the user has completed text entry (pressed OK on Done).
*/
bool isComplete() const { return complete; }
/**
* Check if the user has cancelled text entry.
*/
bool isCancelled() const { return cancelled; }
/**
* Reset the keyboard state for reuse.
*/
void reset(const std::string& newTitle = "", const std::string& newInitialText = "");
/**
* Set callback for when input is complete.
*/
void setOnComplete(OnCompleteCallback callback) { onComplete = callback; }
/**
* Set callback for when input is cancelled.
*/
void setOnCancel(OnCancelCallback callback) { onCancel = callback; }
// Activity overrides // Activity overrides
void onEnter() override; void onEnter() override;
void onExit() override;
void loop() override; void loop() override;
private: private:
std::string title; std::string title;
int startY;
std::string text; std::string text;
size_t maxLength; size_t maxLength;
bool isPassword; bool isPassword;
TaskHandle_t displayTaskHandle = nullptr;
SemaphoreHandle_t renderingMutex = nullptr;
bool updateRequired = false;
// Keyboard state // Keyboard state
int selectedRow = 0; int selectedRow = 0;
int selectedCol = 0; int selectedCol = 0;
bool shiftActive = false; bool shiftActive = false;
bool complete = false;
bool cancelled = false;
// Callbacks // Callbacks
OnCompleteCallback onComplete; OnCompleteCallback onComplete;
@ -84,17 +116,16 @@ class KeyboardEntryActivity : public Activity {
static const char* const keyboardShift[NUM_ROWS]; static const char* const keyboardShift[NUM_ROWS];
// Special key positions (bottom row) // Special key positions (bottom row)
static constexpr int SPECIAL_ROW = 4; static constexpr int SHIFT_ROW = 4;
static constexpr int SHIFT_COL = 0; static constexpr int SHIFT_COL = 0;
static constexpr int SPACE_ROW = 4;
static constexpr int SPACE_COL = 2; static constexpr int SPACE_COL = 2;
static constexpr int BACKSPACE_ROW = 4;
static constexpr int BACKSPACE_COL = 7; static constexpr int BACKSPACE_COL = 7;
static constexpr int DONE_ROW = 4;
static constexpr int DONE_COL = 9; static constexpr int DONE_COL = 9;
static void taskTrampoline(void* param);
[[noreturn]] void displayTaskLoop();
char getSelectedChar() const; char getSelectedChar() const;
void handleKeyPress(); void handleKeyPress();
int getRowLength(int row) const; int getRowLength(int row) const;
void render() const;
void renderItemWithSelector(int x, int y, const char* item, bool isSelected) const;
}; };