crosspoint-reader/src/activities/dictionary/DictionarySearchActivity.cpp
cottongin a4adbb9dfe
fix(dictionary): comprehensive dictionary fixes for stability and UX
This commit completes a series of fixes addressing dictionary crashes,
memory issues, and UI/UX improvements.

Memory & Stability (from previous checkpoints):
- Add uncompressed dictionary (.dict) support to avoid decompression
  memory issues with large dictzip chunks (58KB -> direct read)
- Implement chunked on-demand HTML parsing for large definitions,
  parsing pages as user navigates rather than all at once
- Refactor TextBlock/ParsedText from std::list to std::vector,
  reducing heap allocations by ~12x per TextBlock and eliminating
  crashes from repeated page navigation due to heap fragmentation
- Limit cached pages to MAX_CACHED_PAGES (4) with re-parse capability
  for backward navigation beyond the cache window

UI/Layout Fixes (this commit):
- Restore DictionaryMargins.h for proper orientation-aware button
  hint space (front buttons: 45px, side buttons: 50px)
- Add side button hints to definition screen with proper "<" / ">"
  labels for page navigation
- Add side button hints to word selection screen ("UP"/"DOWN" labels,
  borderless, small font, 2px edge margin)
- Add side button hints to dictionary menu ("< Prev", "Next >")
- Fix double-button press bug when loading new chunks by checking
  forward navigation availability after parsing instead of page count
- Add drawSideButtonHints() drawBorder parameter for minimal hints
- Add drawTextRotated90CCW() for LandscapeCCW text orientation
- Move page indicator up to avoid bezel cutoff
2026-01-29 11:39:49 -05:00

263 lines
7.3 KiB
C++

#include "DictionarySearchActivity.h"
#include <GfxRenderer.h>
#include <HardwareSerial.h>
#include <StarDict.h>
#include "DictionaryMargins.h"
#include "DictionaryResultActivity.h"
#include "MappedInputManager.h"
#include "activities/util/KeyboardEntryActivity.h"
#include "fontIds.h"
namespace {
// Dictionary path on SD card
constexpr const char* DICT_BASE_PATH = "/dictionaries/dict-data";
// Global dictionary instance (lazy initialized)
StarDict* g_dictionary = nullptr;
StarDict& getDictionary() {
if (!g_dictionary) {
g_dictionary = new StarDict(DICT_BASE_PATH);
if (!g_dictionary->begin()) {
Serial.printf("[%lu] [DICT] Failed to initialize dictionary\n", millis());
}
}
return *g_dictionary;
}
} // namespace
void DictionarySearchActivity::taskTrampoline(void* param) {
auto* self = static_cast<DictionarySearchActivity*>(param);
self->displayTaskLoop();
}
void DictionarySearchActivity::onEnter() {
ActivityWithSubactivity::onEnter();
renderingMutex = xSemaphoreCreateMutex();
isSearching = false;
keyboardShown = false;
searchStatus = "";
updateRequired = true;
xTaskCreate(&DictionarySearchActivity::taskTrampoline, "DictSearchTask",
4096, // Stack size (needs more for dictionary operations)
this, // Parameters
1, // Priority
&displayTaskHandle // Task handle
);
// If no initial word provided, show keyboard
if (searchWord.empty()) {
xSemaphoreTake(renderingMutex, portMAX_DELAY);
keyboardShown = true;
enterNewActivity(new KeyboardEntryActivity(
renderer, mappedInput, "Enter Word", "", 10,
64, // maxLength
false, // not password
[this](const std::string& word) {
// User entered a word
exitActivity();
searchWord = word;
keyboardShown = false;
if (!word.empty()) {
performSearch(word);
} else {
onBack();
}
},
[this]() {
// User cancelled keyboard
exitActivity();
keyboardShown = false;
onBack();
}));
xSemaphoreGive(renderingMutex);
} else {
// Perform search with provided word
performSearch(searchWord);
}
}
void DictionarySearchActivity::onExit() {
ActivityWithSubactivity::onExit();
xSemaphoreTake(renderingMutex, portMAX_DELAY);
if (displayTaskHandle) {
vTaskDelete(displayTaskHandle);
displayTaskHandle = nullptr;
vTaskDelay(10 / portTICK_PERIOD_MS); // Let idle task free stack
}
vSemaphoreDelete(renderingMutex);
renderingMutex = nullptr;
}
void DictionarySearchActivity::loop() {
if (subActivity) {
subActivity->loop();
return;
}
// Handle back button - use wasReleased to consume the full button event
if (mappedInput.wasReleased(MappedInputManager::Button::Back)) {
onBack();
return;
}
}
void DictionarySearchActivity::performSearch(const std::string& word) {
isSearching = true;
searchStatus = "Searching...";
updateRequired = true;
// Small delay to allow render
vTaskDelay(50 / portTICK_PERIOD_MS);
// Initialize dictionary if needed
StarDict& dict = getDictionary();
if (!dict.isReady()) {
searchStatus = "Dictionary not found";
isSearching = false;
updateRequired = true;
return;
}
// Perform lookup
const auto result = dict.lookup(word);
if (result.found) {
showResult(result.word, result.definition);
} else {
showNotFound(word);
}
}
void DictionarySearchActivity::showResult(const std::string& word, const std::string& definition) {
xSemaphoreTake(renderingMutex, portMAX_DELAY);
isSearching = false;
exitActivity();
enterNewActivity(new DictionaryResultActivity(
renderer, mappedInput, word, definition,
[this]() {
// Back from result
exitActivity();
onBack();
},
[this]() {
// Search another word
exitActivity();
searchWord = "";
keyboardShown = true;
enterNewActivity(new KeyboardEntryActivity(
renderer, mappedInput, "Enter Word", "", 10, 64, false,
[this](const std::string& newWord) {
exitActivity();
keyboardShown = false;
if (!newWord.empty()) {
performSearch(newWord);
} else {
onBack();
}
},
[this]() {
exitActivity();
keyboardShown = false;
onBack();
}));
}));
xSemaphoreGive(renderingMutex);
}
void DictionarySearchActivity::showNotFound(const std::string& word) {
xSemaphoreTake(renderingMutex, portMAX_DELAY);
isSearching = false;
exitActivity();
enterNewActivity(new DictionaryResultActivity(
renderer, mappedInput, word, "", // Empty definition = not found
[this]() {
// Back from result
exitActivity();
onBack();
},
[this]() {
// Search another word
exitActivity();
searchWord = "";
keyboardShown = true;
enterNewActivity(new KeyboardEntryActivity(
renderer, mappedInput, "Enter Word", "", 10, 64, false,
[this](const std::string& newWord) {
exitActivity();
keyboardShown = false;
if (!newWord.empty()) {
performSearch(newWord);
} else {
onBack();
}
},
[this]() {
exitActivity();
keyboardShown = false;
onBack();
}));
}));
xSemaphoreGive(renderingMutex);
}
void DictionarySearchActivity::displayTaskLoop() {
int animationCounter = 0;
constexpr int ANIMATION_INTERVAL = 30; // ~300ms at 10ms per tick
while (true) {
// Handle animation updates when searching
if (isSearching && !subActivity) {
animationCounter++;
if (animationCounter >= ANIMATION_INTERVAL) {
animationCounter = 0;
animationFrame = (animationFrame + 1) % 3;
updateRequired = true;
}
}
if (updateRequired && !subActivity) {
updateRequired = false;
xSemaphoreTake(renderingMutex, portMAX_DELAY);
render();
xSemaphoreGive(renderingMutex);
}
vTaskDelay(10 / portTICK_PERIOD_MS);
}
}
void DictionarySearchActivity::render() const {
renderer.clearScreen();
// Get margins with button hint space for all orientations
int marginTop, marginRight, marginBottom, marginLeft;
getDictionaryContentMargins(renderer, &marginTop, &marginRight, &marginBottom, &marginLeft);
const auto pageHeight = renderer.getScreenHeight();
// Draw header
renderer.drawCenteredText(UI_12_FONT_ID, marginTop + 5, "Dictionary", true, EpdFontFamily::BOLD);
if (isSearching) {
// Show searching status with word and animated ellipsis
// Center in content area (accounting for margins)
const int centerY = marginTop + (pageHeight - marginTop - marginBottom) / 2;
// Build animated ellipsis
const char* dots = (animationFrame == 0) ? "." : (animationFrame == 1) ? ".." : "...";
// Show "Searching for 'word'..."
char statusText[128];
snprintf(statusText, sizeof(statusText), "Searching for '%s'%s", searchWord.c_str(), dots);
renderer.drawCenteredText(UI_10_FONT_ID, centerY, statusText);
}
renderer.displayBuffer();
}