From 5dc9d21bdb6eb7fe6fdb7df50a5dc6aec4556589 Mon Sep 17 00:00:00 2001 From: cottongin Date: Sat, 14 Feb 2026 20:50:03 -0500 Subject: [PATCH] feat: Integrate PR #857 dictionary intelligence and sub-activity refactor MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Pull in the full feature update from PR #857 while preserving fork advantages (HTML parsing, custom drawHints, PageForward/PageBack, cache management, stardictCmp, /.dictionary/ paths). - Add morphological stemming (getStemVariants), Levenshtein edit distance, and fuzzy matching (findSimilar) to Dictionary - Create DictionarySuggestionsActivity for "Did you mean?" flow - Add onDone callback to DictionaryDefinitionActivity for direct exit-to-reader via "Done" button - Refactor DictionaryWordSelectActivity to ActivityWithSubactivity with cascading lookup (exact → stems → suggestions → not found), en-dash/em-dash splitting, and cross-page hyphenation - Refactor LookedUpWordsActivity with reverse-chronological order, inline cascading lookup, UITheme-aware rendering, and sub-activities - Simplify EpubReaderActivity LOOKUP/LOOKED_UP_WORDS handlers Co-authored-by: Cursor --- .../reader/DictionaryDefinitionActivity.cpp | 16 +- .../reader/DictionaryDefinitionActivity.h | 7 +- .../reader/DictionarySuggestionsActivity.cpp | 141 ++++++++++ .../reader/DictionarySuggestionsActivity.h | 53 ++++ .../reader/DictionaryWordSelectActivity.cpp | 138 ++++++++- .../reader/DictionaryWordSelectActivity.h | 14 +- src/activities/reader/EpubReaderActivity.cpp | 59 ++-- src/activities/reader/EpubReaderActivity.h | 1 - .../reader/LookedUpWordsActivity.cpp | 143 +++++++--- src/activities/reader/LookedUpWordsActivity.h | 15 +- src/util/Dictionary.cpp | 261 ++++++++++++++++++ src/util/Dictionary.h | 3 + 12 files changed, 746 insertions(+), 105 deletions(-) create mode 100644 src/activities/reader/DictionarySuggestionsActivity.cpp create mode 100644 src/activities/reader/DictionarySuggestionsActivity.h diff --git a/src/activities/reader/DictionaryDefinitionActivity.cpp b/src/activities/reader/DictionaryDefinitionActivity.cpp index e6d503b9..fb3a55d5 100644 --- a/src/activities/reader/DictionaryDefinitionActivity.cpp +++ b/src/activities/reader/DictionaryDefinitionActivity.cpp @@ -450,8 +450,16 @@ void DictionaryDefinitionActivity::loop() { updateRequired = true; } - if (mappedInput.wasReleased(MappedInputManager::Button::Back) || - mappedInput.wasReleased(MappedInputManager::Button::Confirm)) { + if (mappedInput.wasReleased(MappedInputManager::Button::Confirm)) { + if (onDone) { + onDone(); + } else { + onBack(); + } + return; + } + + if (mappedInput.wasReleased(MappedInputManager::Button::Back)) { onBack(); return; } @@ -491,8 +499,8 @@ void DictionaryDefinitionActivity::renderScreen() { renderer.getScreenHeight() - 50, pageInfo.c_str()); } - // Button hints (bottom face buttons — hide Confirm stub like Home Screen) - const auto labels = mappedInput.mapLabels("\xC2\xAB Back", "", "\xC2\xAB Page", "Page \xC2\xBB"); + // Button hints (bottom face buttons) + const auto labels = mappedInput.mapLabels("\xC2\xAB Back", onDone ? "Done" : "", "\xC2\xAB Page", "Page \xC2\xBB"); GUI.drawButtonHints(renderer, labels.btn1, labels.btn2, labels.btn3, labels.btn4); // Side button hints (drawn in portrait coordinates for correct placement) diff --git a/src/activities/reader/DictionaryDefinitionActivity.h b/src/activities/reader/DictionaryDefinitionActivity.h index 648967db..c9ecbeec 100644 --- a/src/activities/reader/DictionaryDefinitionActivity.h +++ b/src/activities/reader/DictionaryDefinitionActivity.h @@ -14,13 +14,15 @@ class DictionaryDefinitionActivity final : public Activity { public: explicit DictionaryDefinitionActivity(GfxRenderer& renderer, MappedInputManager& mappedInput, const std::string& headword, const std::string& definition, int readerFontId, - uint8_t orientation, const std::function& onBack) + uint8_t orientation, const std::function& onBack, + const std::function& onDone = nullptr) : Activity("DictionaryDefinition", renderer, mappedInput), headword(headword), definition(definition), readerFontId(readerFontId), orientation(orientation), - onBack(onBack) {} + onBack(onBack), + onDone(onDone) {} void onEnter() override; void onExit() override; @@ -53,6 +55,7 @@ class DictionaryDefinitionActivity final : public Activity { int readerFontId; uint8_t orientation; const std::function onBack; + const std::function onDone; std::vector> wrappedLines; int currentPage = 0; diff --git a/src/activities/reader/DictionarySuggestionsActivity.cpp b/src/activities/reader/DictionarySuggestionsActivity.cpp new file mode 100644 index 00000000..25d1dcd2 --- /dev/null +++ b/src/activities/reader/DictionarySuggestionsActivity.cpp @@ -0,0 +1,141 @@ +#include "DictionarySuggestionsActivity.h" + +#include + +#include "DictionaryDefinitionActivity.h" +#include "MappedInputManager.h" +#include "components/UITheme.h" +#include "fontIds.h" +#include "util/Dictionary.h" + +void DictionarySuggestionsActivity::taskTrampoline(void* param) { + auto* self = static_cast(param); + self->displayTaskLoop(); +} + +void DictionarySuggestionsActivity::displayTaskLoop() { + while (true) { + if (updateRequired && !subActivity) { + updateRequired = false; + xSemaphoreTake(renderingMutex, portMAX_DELAY); + renderScreen(); + xSemaphoreGive(renderingMutex); + } + vTaskDelay(10 / portTICK_PERIOD_MS); + } +} + +void DictionarySuggestionsActivity::onEnter() { + ActivityWithSubactivity::onEnter(); + renderingMutex = xSemaphoreCreateMutex(); + updateRequired = true; + xTaskCreate(&DictionarySuggestionsActivity::taskTrampoline, "DictSugTask", 4096, this, 1, &displayTaskHandle); +} + +void DictionarySuggestionsActivity::onExit() { + ActivityWithSubactivity::onExit(); + xSemaphoreTake(renderingMutex, portMAX_DELAY); + if (displayTaskHandle) { + vTaskDelete(displayTaskHandle); + displayTaskHandle = nullptr; + } + vSemaphoreDelete(renderingMutex); + renderingMutex = nullptr; +} + +void DictionarySuggestionsActivity::loop() { + if (subActivity) { + subActivity->loop(); + if (pendingBackFromDef) { + pendingBackFromDef = false; + exitActivity(); + updateRequired = true; + } + if (pendingExitToReader) { + pendingExitToReader = false; + exitActivity(); + onDone(); + } + return; + } + + if (suggestions.empty()) { + if (mappedInput.wasReleased(MappedInputManager::Button::Back)) { + onBack(); + } + return; + } + + buttonNavigator.onNext([this] { + selectedIndex = ButtonNavigator::nextIndex(selectedIndex, static_cast(suggestions.size())); + updateRequired = true; + }); + + buttonNavigator.onPrevious([this] { + selectedIndex = ButtonNavigator::previousIndex(selectedIndex, static_cast(suggestions.size())); + updateRequired = true; + }); + + if (mappedInput.wasReleased(MappedInputManager::Button::Confirm)) { + const std::string& selected = suggestions[selectedIndex]; + std::string definition = Dictionary::lookup(selected); + + if (definition.empty()) { + GUI.drawPopup(renderer, "Not found"); + renderer.displayBuffer(HalDisplay::FAST_REFRESH); + vTaskDelay(1000 / portTICK_PERIOD_MS); + updateRequired = true; + return; + } + + enterNewActivity(new DictionaryDefinitionActivity( + renderer, mappedInput, selected, definition, readerFontId, orientation, + [this]() { pendingBackFromDef = true; }, [this]() { pendingExitToReader = true; })); + return; + } + + if (mappedInput.wasReleased(MappedInputManager::Button::Back)) { + onBack(); + return; + } +} + +void DictionarySuggestionsActivity::renderScreen() { + renderer.clearScreen(); + + const auto orient = renderer.getOrientation(); + const auto metrics = UITheme::getInstance().getMetrics(); + const bool isLandscapeCw = orient == GfxRenderer::Orientation::LandscapeClockwise; + const bool isLandscapeCcw = orient == GfxRenderer::Orientation::LandscapeCounterClockwise; + const bool isInverted = orient == GfxRenderer::Orientation::PortraitInverted; + const int hintGutterWidth = (isLandscapeCw || isLandscapeCcw) ? metrics.sideButtonHintsWidth : 0; + const int hintGutterHeight = isInverted ? (metrics.buttonHintsHeight + metrics.verticalSpacing) : 0; + const int contentX = isLandscapeCw ? hintGutterWidth : 0; + const int leftPadding = contentX + metrics.contentSidePadding; + const int pageWidth = renderer.getScreenWidth(); + const int pageHeight = renderer.getScreenHeight(); + + // Header + GUI.drawHeader( + renderer, + Rect{contentX, hintGutterHeight + metrics.topPadding, pageWidth - hintGutterWidth, metrics.headerHeight}, + "Did you mean?"); + + // Subtitle: the original word (manual, below header) + const int subtitleY = hintGutterHeight + metrics.topPadding + metrics.headerHeight + 5; + std::string subtitle = "\"" + originalWord + "\" not found"; + renderer.drawText(SMALL_FONT_ID, leftPadding, subtitleY, subtitle.c_str()); + + // Suggestion list + const int listTop = subtitleY + 25; + const int listHeight = pageHeight - listTop - metrics.buttonHintsHeight - metrics.verticalSpacing; + GUI.drawList( + renderer, Rect{contentX, listTop, pageWidth - hintGutterWidth, listHeight}, suggestions.size(), selectedIndex, + [this](int index) { return suggestions[index]; }, nullptr, nullptr, nullptr); + + // Button hints + const auto labels = mappedInput.mapLabels("\xC2\xAB Back", "Select", "Up", "Down"); + GUI.drawButtonHints(renderer, labels.btn1, labels.btn2, labels.btn3, labels.btn4); + + renderer.displayBuffer(HalDisplay::FAST_REFRESH); +} diff --git a/src/activities/reader/DictionarySuggestionsActivity.h b/src/activities/reader/DictionarySuggestionsActivity.h new file mode 100644 index 00000000..46390b56 --- /dev/null +++ b/src/activities/reader/DictionarySuggestionsActivity.h @@ -0,0 +1,53 @@ +#pragma once +#include +#include +#include + +#include +#include +#include + +#include "../ActivityWithSubactivity.h" +#include "util/ButtonNavigator.h" + +class DictionarySuggestionsActivity final : public ActivityWithSubactivity { + public: + explicit DictionarySuggestionsActivity(GfxRenderer& renderer, MappedInputManager& mappedInput, + const std::string& originalWord, const std::vector& suggestions, + int readerFontId, uint8_t orientation, const std::string& cachePath, + const std::function& onBack, const std::function& onDone) + : ActivityWithSubactivity("DictionarySuggestions", renderer, mappedInput), + originalWord(originalWord), + suggestions(suggestions), + readerFontId(readerFontId), + orientation(orientation), + cachePath(cachePath), + onBack(onBack), + onDone(onDone) {} + + void onEnter() override; + void onExit() override; + void loop() override; + + private: + std::string originalWord; + std::vector suggestions; + int readerFontId; + uint8_t orientation; + std::string cachePath; + const std::function onBack; + const std::function onDone; + + int selectedIndex = 0; + bool updateRequired = false; + bool pendingBackFromDef = false; + bool pendingExitToReader = false; + ButtonNavigator buttonNavigator; + + TaskHandle_t displayTaskHandle = nullptr; + SemaphoreHandle_t renderingMutex = nullptr; + + void renderScreen(); + static void taskTrampoline(void* param); + [[noreturn]] void displayTaskLoop(); +}; diff --git a/src/activities/reader/DictionaryWordSelectActivity.cpp b/src/activities/reader/DictionaryWordSelectActivity.cpp index 262535b1..bb9e507a 100644 --- a/src/activities/reader/DictionaryWordSelectActivity.cpp +++ b/src/activities/reader/DictionaryWordSelectActivity.cpp @@ -6,6 +6,8 @@ #include #include "CrossPointSettings.h" +#include "DictionaryDefinitionActivity.h" +#include "DictionarySuggestionsActivity.h" #include "MappedInputManager.h" #include "components/UITheme.h" #include "fontIds.h" @@ -19,7 +21,7 @@ void DictionaryWordSelectActivity::taskTrampoline(void* param) { void DictionaryWordSelectActivity::displayTaskLoop() { while (true) { - if (updateRequired) { + if (updateRequired && !subActivity) { updateRequired = false; xSemaphoreTake(renderingMutex, portMAX_DELAY); renderScreen(); @@ -30,7 +32,7 @@ void DictionaryWordSelectActivity::displayTaskLoop() { } void DictionaryWordSelectActivity::onEnter() { - Activity::onEnter(); + ActivityWithSubactivity::onEnter(); renderingMutex = xSemaphoreCreateMutex(); extractWords(); mergeHyphenatedWords(); @@ -43,7 +45,7 @@ void DictionaryWordSelectActivity::onEnter() { } void DictionaryWordSelectActivity::onExit() { - Activity::onExit(); + ActivityWithSubactivity::onExit(); xSemaphoreTake(renderingMutex, portMAX_DELAY); if (displayTaskHandle) { vTaskDelete(displayTaskHandle); @@ -82,9 +84,55 @@ void DictionaryWordSelectActivity::extractWords() { while (wordIt != wordList.end() && xIt != xPosList.end()) { int16_t screenX = line->xPos + static_cast(*xIt) + marginLeft; int16_t screenY = line->yPos + marginTop; - int16_t wordWidth = renderer.getTextWidth(fontId, wordIt->c_str()); + const std::string& wordText = *wordIt; + + // Split on en-dash (U+2013: E2 80 93) and em-dash (U+2014: E2 80 94) + std::vector splitStarts; + size_t partStart = 0; + for (size_t i = 0; i < wordText.size();) { + if (i + 2 < wordText.size() && static_cast(wordText[i]) == 0xE2 && + static_cast(wordText[i + 1]) == 0x80 && + (static_cast(wordText[i + 2]) == 0x93 || static_cast(wordText[i + 2]) == 0x94)) { + if (i > partStart) splitStarts.push_back(partStart); + i += 3; + partStart = i; + } else { + i++; + } + } + if (partStart < wordText.size()) splitStarts.push_back(partStart); + + if (splitStarts.size() <= 1 && partStart == 0) { + // No dashes found -- add as a single word + int16_t wordWidth = renderer.getTextWidth(fontId, wordText.c_str()); + words.push_back({wordText, screenX, screenY, wordWidth, 0}); + } else { + // Add each part as a separate selectable word + for (size_t si = 0; si < splitStarts.size(); si++) { + size_t start = splitStarts[si]; + size_t end = (si + 1 < splitStarts.size()) ? splitStarts[si + 1] : wordText.size(); + // Find actual end by trimming any trailing dash bytes + size_t textEnd = end; + while (textEnd > start && textEnd <= wordText.size()) { + if (textEnd >= 3 && static_cast(wordText[textEnd - 3]) == 0xE2 && + static_cast(wordText[textEnd - 2]) == 0x80 && + (static_cast(wordText[textEnd - 1]) == 0x93 || + static_cast(wordText[textEnd - 1]) == 0x94)) { + textEnd -= 3; + } else { + break; + } + } + std::string part = wordText.substr(start, textEnd - start); + if (part.empty()) continue; + + std::string prefix = wordText.substr(0, start); + int16_t offsetX = prefix.empty() ? 0 : renderer.getTextWidth(fontId, prefix.c_str()); + int16_t partWidth = renderer.getTextWidth(fontId, part.c_str()); + words.push_back({part, static_cast(screenX + offsetX), screenY, partWidth, 0}); + } + } - words.push_back({*wordIt, screenX, screenY, wordWidth, 0}); ++wordIt; ++xIt; } @@ -146,11 +194,53 @@ void DictionaryWordSelectActivity::mergeHyphenatedWords() { words[nextWordIdx].continuationIndex = nextWordIdx; // self-ref so highlight logic finds the second part } + // Cross-page hyphenation: last word on page + first word of next page + if (!nextPageFirstWord.empty() && !rows.empty()) { + int lastWordIdx = rows.back().wordIndices.back(); + const std::string& lastWord = words[lastWordIdx].text; + if (!lastWord.empty()) { + bool endsWithHyphen = false; + if (lastWord.back() == '-') { + endsWithHyphen = true; + } else if (lastWord.size() >= 2 && static_cast(lastWord[lastWord.size() - 2]) == 0xC2 && + static_cast(lastWord[lastWord.size() - 1]) == 0xAD) { + endsWithHyphen = true; + } + if (endsWithHyphen) { + std::string firstPart = lastWord; + if (firstPart.back() == '-') { + firstPart.pop_back(); + } else if (firstPart.size() >= 2 && static_cast(firstPart[firstPart.size() - 2]) == 0xC2 && + static_cast(firstPart[firstPart.size() - 1]) == 0xAD) { + firstPart.erase(firstPart.size() - 2); + } + std::string merged = firstPart + nextPageFirstWord; + words[lastWordIdx].lookupText = merged; + } + } + } + // Remove empty rows that may result from merging (e.g., a row whose only word was a continuation) rows.erase(std::remove_if(rows.begin(), rows.end(), [](const Row& r) { return r.wordIndices.empty(); }), rows.end()); } void DictionaryWordSelectActivity::loop() { + // Delegate to subactivity (definition/suggestions screen) if active + if (subActivity) { + subActivity->loop(); + if (pendingBackFromDef) { + pendingBackFromDef = false; + exitActivity(); + updateRequired = true; + } + if (pendingExitToReader) { + pendingExitToReader = false; + exitActivity(); + onBack(); + } + return; + } + if (words.empty()) { if (mappedInput.wasReleased(MappedInputManager::Button::Back)) { onBack(); @@ -297,16 +387,40 @@ void DictionaryWordSelectActivity::loop() { return; } - if (definition.empty()) { - GUI.drawPopup(renderer, "Not found"); - renderer.displayBuffer(HalDisplay::FAST_REFRESH); - vTaskDelay(1500 / portTICK_PERIOD_MS); - updateRequired = true; + LookupHistory::addWord(cachePath, cleaned); + + if (!definition.empty()) { + enterNewActivity(new DictionaryDefinitionActivity( + renderer, mappedInput, cleaned, definition, fontId, orientation, + [this]() { pendingBackFromDef = true; }, [this]() { pendingExitToReader = true; })); return; } - LookupHistory::addWord(cachePath, cleaned); - onLookup(cleaned, definition); + // Try stem variants (e.g., "jumped" -> "jump") + auto stems = Dictionary::getStemVariants(cleaned); + for (const auto& stem : stems) { + std::string stemDef = Dictionary::lookup(stem); + if (!stemDef.empty()) { + enterNewActivity(new DictionaryDefinitionActivity( + renderer, mappedInput, stem, stemDef, fontId, orientation, + [this]() { pendingBackFromDef = true; }, [this]() { pendingExitToReader = true; })); + return; + } + } + + // Find similar words for suggestions + auto similar = Dictionary::findSimilar(cleaned, 6); + if (!similar.empty()) { + enterNewActivity(new DictionarySuggestionsActivity( + renderer, mappedInput, cleaned, similar, fontId, orientation, cachePath, + [this]() { pendingBackFromDef = true; }, [this]() { pendingExitToReader = true; })); + return; + } + + GUI.drawPopup(renderer, "Not found"); + renderer.displayBuffer(HalDisplay::FAST_REFRESH); + vTaskDelay(1500 / portTICK_PERIOD_MS); + updateRequired = true; return; } diff --git a/src/activities/reader/DictionaryWordSelectActivity.h b/src/activities/reader/DictionaryWordSelectActivity.h index 2d513552..71b677e6 100644 --- a/src/activities/reader/DictionaryWordSelectActivity.h +++ b/src/activities/reader/DictionaryWordSelectActivity.h @@ -9,16 +9,16 @@ #include #include -#include "../Activity.h" +#include "../ActivityWithSubactivity.h" -class DictionaryWordSelectActivity final : public Activity { +class DictionaryWordSelectActivity final : public ActivityWithSubactivity { public: explicit DictionaryWordSelectActivity(GfxRenderer& renderer, MappedInputManager& mappedInput, std::unique_ptr page, int fontId, int marginLeft, int marginTop, const std::string& cachePath, uint8_t orientation, const std::function& onBack, - const std::function& onLookup) - : Activity("DictionaryWordSelect", renderer, mappedInput), + const std::string& nextPageFirstWord = "") + : ActivityWithSubactivity("DictionaryWordSelect", renderer, mappedInput), page(std::move(page)), fontId(fontId), marginLeft(marginLeft), @@ -26,7 +26,7 @@ class DictionaryWordSelectActivity final : public Activity { cachePath(cachePath), orientation(orientation), onBack(onBack), - onLookup(onLookup) {} + nextPageFirstWord(nextPageFirstWord) {} void onEnter() override; void onExit() override; @@ -58,13 +58,15 @@ class DictionaryWordSelectActivity final : public Activity { std::string cachePath; uint8_t orientation; const std::function onBack; - const std::function onLookup; + std::string nextPageFirstWord; std::vector words; std::vector rows; int currentRow = 0; int currentWordInRow = 0; bool updateRequired = false; + bool pendingBackFromDef = false; + bool pendingExitToReader = false; TaskHandle_t displayTaskHandle = nullptr; SemaphoreHandle_t renderingMutex = nullptr; diff --git a/src/activities/reader/EpubReaderActivity.cpp b/src/activities/reader/EpubReaderActivity.cpp index 8669f009..98b317a4 100644 --- a/src/activities/reader/EpubReaderActivity.cpp +++ b/src/activities/reader/EpubReaderActivity.cpp @@ -19,7 +19,6 @@ #include "fontIds.h" #include "util/BookmarkStore.h" #include "util/Dictionary.h" -#include "util/LookupHistory.h" namespace { // pagesPerRefresh now comes from SETTINGS.getRefreshFrequency() @@ -665,24 +664,27 @@ void EpubReaderActivity::onReaderMenuConfirm(EpubReaderMenuActivity::MenuAction const std::string bookCachePath = epub->getCachePath(); const uint8_t currentOrientation = SETTINGS.orientation; + // Get first word of next page for cross-page hyphenation + std::string nextPageFirstWord; + if (section && section->currentPage < section->pageCount - 1) { + int savedPage = section->currentPage; + section->currentPage = savedPage + 1; + auto nextPage = section->loadPageFromSectionFile(); + section->currentPage = savedPage; + if (nextPage && !nextPage->elements.empty()) { + const auto* firstLine = static_cast(nextPage->elements[0].get()); + if (firstLine->getBlock() && !firstLine->getBlock()->getWords().empty()) { + nextPageFirstWord = firstLine->getBlock()->getWords().front(); + } + } + } + exitActivity(); if (pageForLookup) { enterNewActivity(new DictionaryWordSelectActivity( renderer, mappedInput, std::move(pageForLookup), readerFontId, orientedMarginLeft, orientedMarginTop, - bookCachePath, currentOrientation, - [this]() { - // On back from word select - pendingSubactivityExit = true; - }, - [this, bookCachePath, readerFontId, currentOrientation](const std::string& headword, - const std::string& definition) { - // On successful lookup - show definition - exitActivity(); - enterNewActivity(new DictionaryDefinitionActivity(renderer, mappedInput, headword, definition, - readerFontId, currentOrientation, - [this]() { pendingSubactivityExit = true; })); - })); + bookCachePath, currentOrientation, [this]() { pendingSubactivityExit = true; }, nextPageFirstWord)); } xSemaphoreGive(renderingMutex); @@ -690,36 +692,11 @@ void EpubReaderActivity::onReaderMenuConfirm(EpubReaderMenuActivity::MenuAction } case EpubReaderMenuActivity::MenuAction::LOOKED_UP_WORDS: { xSemaphoreTake(renderingMutex, portMAX_DELAY); - const std::string bookCachePath = epub->getCachePath(); - const int readerFontId = SETTINGS.getReaderFontId(); - const uint8_t currentOrientation = SETTINGS.orientation; exitActivity(); enterNewActivity(new LookedUpWordsActivity( - renderer, mappedInput, bookCachePath, - [this]() { - // On back from looked up words - pendingSubactivityExit = true; - }, - [this, bookCachePath, readerFontId, currentOrientation](const std::string& headword) { - // Look up the word and show definition with progress bar - Rect popupLayout = GUI.drawPopup(renderer, "Looking up..."); - - std::string definition = Dictionary::lookup( - headword, [this, &popupLayout](int percent) { GUI.fillPopupProgress(renderer, popupLayout, percent); }); - - if (definition.empty()) { - GUI.drawPopup(renderer, "Not found"); - renderer.displayBuffer(HalDisplay::FAST_REFRESH); - vTaskDelay(1500 / portTICK_PERIOD_MS); - return; - } - - exitActivity(); - enterNewActivity(new DictionaryDefinitionActivity(renderer, mappedInput, headword, definition, readerFontId, - currentOrientation, - [this]() { pendingSubactivityExit = true; })); - })); + renderer, mappedInput, epub->getCachePath(), SETTINGS.getReaderFontId(), SETTINGS.orientation, + [this]() { pendingSubactivityExit = true; }, [this]() { pendingSubactivityExit = true; })); xSemaphoreGive(renderingMutex); break; } diff --git a/src/activities/reader/EpubReaderActivity.h b/src/activities/reader/EpubReaderActivity.h index aed79335..937c86af 100644 --- a/src/activities/reader/EpubReaderActivity.h +++ b/src/activities/reader/EpubReaderActivity.h @@ -5,7 +5,6 @@ #include #include -#include "DictionaryDefinitionActivity.h" #include "DictionaryWordSelectActivity.h" #include "EpubReaderMenuActivity.h" #include "LookedUpWordsActivity.h" diff --git a/src/activities/reader/LookedUpWordsActivity.cpp b/src/activities/reader/LookedUpWordsActivity.cpp index 4cf47a59..3b605767 100644 --- a/src/activities/reader/LookedUpWordsActivity.cpp +++ b/src/activities/reader/LookedUpWordsActivity.cpp @@ -4,9 +4,12 @@ #include +#include "DictionaryDefinitionActivity.h" +#include "DictionarySuggestionsActivity.h" #include "MappedInputManager.h" #include "components/UITheme.h" #include "fontIds.h" +#include "util/Dictionary.h" #include "util/LookupHistory.h" void LookedUpWordsActivity::taskTrampoline(void* param) { @@ -30,6 +33,7 @@ void LookedUpWordsActivity::onEnter() { ActivityWithSubactivity::onEnter(); renderingMutex = xSemaphoreCreateMutex(); words = LookupHistory::load(cachePath); + std::reverse(words.begin(), words.end()); updateRequired = true; xTaskCreate(&LookedUpWordsActivity::taskTrampoline, "LookedUpTask", 4096, this, 1, &displayTaskHandle); } @@ -48,6 +52,16 @@ void LookedUpWordsActivity::onExit() { void LookedUpWordsActivity::loop() { if (subActivity) { subActivity->loop(); + if (pendingBackFromDef) { + pendingBackFromDef = false; + exitActivity(); + updateRequired = true; + } + if (pendingExitToReader) { + pendingExitToReader = false; + exitActivity(); + onDone(); + } return; } @@ -94,18 +108,68 @@ void LookedUpWordsActivity::loop() { return; } - buttonNavigator.onNext([this] { - selectedIndex = ButtonNavigator::nextIndex(selectedIndex, static_cast(words.size())); + const int totalItems = static_cast(words.size()); + const int pageItems = getPageItems(); + + buttonNavigator.onNextRelease([this, totalItems] { + selectedIndex = ButtonNavigator::nextIndex(selectedIndex, totalItems); updateRequired = true; }); - buttonNavigator.onPrevious([this] { - selectedIndex = ButtonNavigator::previousIndex(selectedIndex, static_cast(words.size())); + buttonNavigator.onPreviousRelease([this, totalItems] { + selectedIndex = ButtonNavigator::previousIndex(selectedIndex, totalItems); + updateRequired = true; + }); + + buttonNavigator.onNextContinuous([this, totalItems, pageItems] { + selectedIndex = ButtonNavigator::nextPageIndex(selectedIndex, totalItems, pageItems); + updateRequired = true; + }); + + buttonNavigator.onPreviousContinuous([this, totalItems, pageItems] { + selectedIndex = ButtonNavigator::previousPageIndex(selectedIndex, totalItems, pageItems); updateRequired = true; }); if (mappedInput.wasReleased(MappedInputManager::Button::Confirm)) { - onSelectWord(words[selectedIndex]); + const std::string& headword = words[selectedIndex]; + + Rect popupLayout = GUI.drawPopup(renderer, "Looking up..."); + std::string definition = Dictionary::lookup( + headword, [this, &popupLayout](int percent) { GUI.fillPopupProgress(renderer, popupLayout, percent); }); + + if (!definition.empty()) { + enterNewActivity(new DictionaryDefinitionActivity( + renderer, mappedInput, headword, definition, readerFontId, orientation, + [this]() { pendingBackFromDef = true; }, [this]() { pendingExitToReader = true; })); + return; + } + + // Try stem variants + auto stems = Dictionary::getStemVariants(headword); + for (const auto& stem : stems) { + std::string stemDef = Dictionary::lookup(stem); + if (!stemDef.empty()) { + enterNewActivity(new DictionaryDefinitionActivity( + renderer, mappedInput, stem, stemDef, readerFontId, orientation, + [this]() { pendingBackFromDef = true; }, [this]() { pendingExitToReader = true; })); + return; + } + } + + // Show similar word suggestions + auto similar = Dictionary::findSimilar(headword, 6); + if (!similar.empty()) { + enterNewActivity(new DictionarySuggestionsActivity( + renderer, mappedInput, headword, similar, readerFontId, orientation, cachePath, + [this]() { pendingBackFromDef = true; }, [this]() { pendingExitToReader = true; })); + return; + } + + GUI.drawPopup(renderer, "Not found"); + renderer.displayBuffer(HalDisplay::FAST_REFRESH); + vTaskDelay(1500 / portTICK_PERIOD_MS); + updateRequired = true; return; } @@ -115,39 +179,46 @@ void LookedUpWordsActivity::loop() { } } +int LookedUpWordsActivity::getPageItems() const { + const auto orient = renderer.getOrientation(); + const auto metrics = UITheme::getInstance().getMetrics(); + const bool isInverted = orient == GfxRenderer::Orientation::PortraitInverted; + const int hintGutterHeight = isInverted ? (metrics.buttonHintsHeight + metrics.verticalSpacing) : 0; + const int contentTop = hintGutterHeight + metrics.topPadding + metrics.headerHeight + metrics.verticalSpacing; + const int contentHeight = + renderer.getScreenHeight() - contentTop - metrics.buttonHintsHeight - metrics.verticalSpacing; + return std::max(1, contentHeight / metrics.listRowHeight); +} + void LookedUpWordsActivity::renderScreen() { renderer.clearScreen(); - constexpr int sidePadding = 20; - constexpr int titleY = 15; - constexpr int startY = 60; - constexpr int lineHeight = 30; + const auto orient = renderer.getOrientation(); + const auto metrics = UITheme::getInstance().getMetrics(); + const bool isLandscapeCw = orient == GfxRenderer::Orientation::LandscapeClockwise; + const bool isLandscapeCcw = orient == GfxRenderer::Orientation::LandscapeCounterClockwise; + const bool isInverted = orient == GfxRenderer::Orientation::PortraitInverted; + const int hintGutterWidth = (isLandscapeCw || isLandscapeCcw) ? metrics.sideButtonHintsWidth : 0; + const int hintGutterHeight = isInverted ? (metrics.buttonHintsHeight + metrics.verticalSpacing) : 0; + const int contentX = isLandscapeCw ? hintGutterWidth : 0; + const int pageWidth = renderer.getScreenWidth(); + const int pageHeight = renderer.getScreenHeight(); - // Title - const int titleX = - (renderer.getScreenWidth() - renderer.getTextWidth(UI_12_FONT_ID, "Lookup History", EpdFontFamily::BOLD)) / 2; - renderer.drawText(UI_12_FONT_ID, titleX, titleY, "Lookup History", true, EpdFontFamily::BOLD); + // Header + GUI.drawHeader( + renderer, + Rect{contentX, hintGutterHeight + metrics.topPadding, pageWidth - hintGutterWidth, metrics.headerHeight}, + "Lookup History"); + + const int contentTop = hintGutterHeight + metrics.topPadding + metrics.headerHeight + metrics.verticalSpacing; + const int contentHeight = pageHeight - contentTop - metrics.buttonHintsHeight - metrics.verticalSpacing; if (words.empty()) { - renderer.drawCenteredText(UI_10_FONT_ID, 300, "No words looked up yet"); + renderer.drawCenteredText(UI_10_FONT_ID, contentTop + 20, "No words looked up yet"); } else { - const int screenHeight = renderer.getScreenHeight(); - const int pageItems = std::max(1, (screenHeight - startY - 40) / lineHeight); - const int pageStart = selectedIndex / pageItems * pageItems; - - for (int i = 0; i < pageItems; i++) { - int idx = pageStart + i; - if (idx >= static_cast(words.size())) break; - - const int displayY = startY + i * lineHeight; - const bool isSelected = (idx == selectedIndex); - - if (isSelected) { - renderer.fillRect(0, displayY - 2, renderer.getScreenWidth() - 1, lineHeight); - } - - renderer.drawText(UI_10_FONT_ID, sidePadding, displayY, words[idx].c_str(), !isSelected); - } + GUI.drawList( + renderer, Rect{contentX, contentTop, pageWidth - hintGutterWidth, contentHeight}, words.size(), selectedIndex, + [this](int index) { return words[index]; }, nullptr, nullptr, nullptr); } if (deleteConfirmMode && pendingDeleteIndex < static_cast(words.size())) { @@ -161,12 +232,12 @@ void LookedUpWordsActivity::renderScreen() { std::string msg = "Delete '" + displayWord + "'?"; constexpr int margin = 15; - constexpr int popupY = 200; + const int popupY = 200 + hintGutterHeight; const int textWidth = renderer.getTextWidth(UI_12_FONT_ID, msg.c_str(), EpdFontFamily::BOLD); const int textHeight = renderer.getLineHeight(UI_12_FONT_ID); const int w = textWidth + margin * 2; const int h = textHeight + margin * 2; - const int x = (renderer.getScreenWidth() - w) / 2; + const int x = contentX + (renderer.getScreenWidth() - hintGutterWidth - w) / 2; renderer.fillRect(x - 2, popupY - 2, w + 4, h + 4, true); renderer.fillRect(x, popupY, w, h, false); @@ -183,12 +254,14 @@ void LookedUpWordsActivity::renderScreen() { if (!words.empty()) { const char* deleteHint = "Hold select to delete"; const int hintWidth = renderer.getTextWidth(SMALL_FONT_ID, deleteHint); - renderer.drawText(SMALL_FONT_ID, (renderer.getScreenWidth() - hintWidth) / 2, renderer.getScreenHeight() - 70, + const int hintX = contentX + (renderer.getScreenWidth() - hintGutterWidth - hintWidth) / 2; + renderer.drawText(SMALL_FONT_ID, hintX, + renderer.getScreenHeight() - metrics.buttonHintsHeight - metrics.verticalSpacing * 2, deleteHint); } // Normal button hints - const auto labels = mappedInput.mapLabels("\xC2\xAB Back", "Select", "^", "v"); + const auto labels = mappedInput.mapLabels("\xC2\xAB Back", "Select", "Up", "Down"); GUI.drawButtonHints(renderer, labels.btn1, labels.btn2, labels.btn3, labels.btn4); } diff --git a/src/activities/reader/LookedUpWordsActivity.h b/src/activities/reader/LookedUpWordsActivity.h index eeeae67c..6ad9e367 100644 --- a/src/activities/reader/LookedUpWordsActivity.h +++ b/src/activities/reader/LookedUpWordsActivity.h @@ -13,12 +13,14 @@ class LookedUpWordsActivity final : public ActivityWithSubactivity { public: explicit LookedUpWordsActivity(GfxRenderer& renderer, MappedInputManager& mappedInput, const std::string& cachePath, - const std::function& onBack, - const std::function& onSelectWord) + int readerFontId, uint8_t orientation, const std::function& onBack, + const std::function& onDone) : ActivityWithSubactivity("LookedUpWords", renderer, mappedInput), cachePath(cachePath), + readerFontId(readerFontId), + orientation(orientation), onBack(onBack), - onSelectWord(onSelectWord) {} + onDone(onDone) {} void onEnter() override; void onExit() override; @@ -26,12 +28,16 @@ class LookedUpWordsActivity final : public ActivityWithSubactivity { private: std::string cachePath; + int readerFontId; + uint8_t orientation; const std::function onBack; - const std::function onSelectWord; + const std::function onDone; std::vector words; int selectedIndex = 0; bool updateRequired = false; + bool pendingBackFromDef = false; + bool pendingExitToReader = false; ButtonNavigator buttonNavigator; // Delete confirmation state @@ -42,6 +48,7 @@ class LookedUpWordsActivity final : public ActivityWithSubactivity { TaskHandle_t displayTaskHandle = nullptr; SemaphoreHandle_t renderingMutex = nullptr; + int getPageItems() const; void renderScreen(); static void taskTrampoline(void* param); [[noreturn]] void displayTaskLoop(); diff --git a/src/util/Dictionary.cpp b/src/util/Dictionary.cpp index 6e9952a9..8e75b202 100644 --- a/src/util/Dictionary.cpp +++ b/src/util/Dictionary.cpp @@ -326,3 +326,264 @@ std::string Dictionary::lookup(const std::string& word, const std::function Dictionary::getStemVariants(const std::string& word) { + std::vector variants; + size_t len = word.size(); + if (len < 3) return variants; + + auto endsWith = [&word, len](const char* suffix) { + size_t slen = strlen(suffix); + return len >= slen && word.compare(len - slen, slen, suffix) == 0; + }; + + auto add = [&variants](const std::string& s) { + if (s.size() >= 2) variants.push_back(s); + }; + + // Plurals (longer suffixes first to avoid partial matches) + if (endsWith("sses")) add(word.substr(0, len - 2)); + if (endsWith("ses")) add(word.substr(0, len - 2) + "is"); // analyses -> analysis + if (endsWith("ies")) { + add(word.substr(0, len - 3) + "y"); + add(word.substr(0, len - 2)); // dies -> die, ties -> tie + } + if (endsWith("ves")) { + add(word.substr(0, len - 3) + "f"); // wolves -> wolf + add(word.substr(0, len - 3) + "fe"); // knives -> knife + add(word.substr(0, len - 1)); // misgives -> misgive + } + if (endsWith("men")) add(word.substr(0, len - 3) + "man"); // firemen -> fireman + if (endsWith("es") && !endsWith("sses") && !endsWith("ies") && !endsWith("ves")) { + add(word.substr(0, len - 2)); + add(word.substr(0, len - 1)); + } + if (endsWith("s") && !endsWith("ss") && !endsWith("us") && !endsWith("es")) { + add(word.substr(0, len - 1)); + } + + // Past tense + if (endsWith("ied")) { + add(word.substr(0, len - 3) + "y"); + add(word.substr(0, len - 1)); + } + if (endsWith("ed") && !endsWith("ied")) { + add(word.substr(0, len - 2)); + add(word.substr(0, len - 1)); + if (len > 4 && word[len - 3] == word[len - 4]) { + add(word.substr(0, len - 3)); + } + } + + // Progressive + if (endsWith("ying")) { + add(word.substr(0, len - 4) + "ie"); + } + if (endsWith("ing") && !endsWith("ying")) { + add(word.substr(0, len - 3)); + add(word.substr(0, len - 3) + "e"); + if (len > 5 && word[len - 4] == word[len - 5]) { + add(word.substr(0, len - 4)); + } + } + + // Adverb + if (endsWith("ically")) { + add(word.substr(0, len - 6) + "ic"); // historically -> historic + add(word.substr(0, len - 4)); // basically -> basic + } + if (endsWith("ally") && !endsWith("ically")) { + add(word.substr(0, len - 4) + "al"); // accidentally -> accidental + add(word.substr(0, len - 2)); // naturally -> natur... (fallback to -ly strip) + } + if (endsWith("ily") && !endsWith("ally")) { + add(word.substr(0, len - 3) + "y"); + } + if (endsWith("ly") && !endsWith("ily") && !endsWith("ally")) { + add(word.substr(0, len - 2)); + } + + // Comparative / superlative + if (endsWith("ier")) { + add(word.substr(0, len - 3) + "y"); + } + if (endsWith("er") && !endsWith("ier")) { + add(word.substr(0, len - 2)); + add(word.substr(0, len - 1)); + if (len > 4 && word[len - 3] == word[len - 4]) { + add(word.substr(0, len - 3)); + } + } + if (endsWith("iest")) { + add(word.substr(0, len - 4) + "y"); + } + if (endsWith("est") && !endsWith("iest")) { + add(word.substr(0, len - 3)); + add(word.substr(0, len - 2)); + if (len > 5 && word[len - 4] == word[len - 5]) { + add(word.substr(0, len - 4)); + } + } + + // Derivational suffixes + if (endsWith("ness")) add(word.substr(0, len - 4)); + if (endsWith("ment")) add(word.substr(0, len - 4)); + if (endsWith("ful")) add(word.substr(0, len - 3)); + if (endsWith("less")) add(word.substr(0, len - 4)); + if (endsWith("able")) { + add(word.substr(0, len - 4)); + add(word.substr(0, len - 4) + "e"); + } + if (endsWith("ible")) { + add(word.substr(0, len - 4)); + add(word.substr(0, len - 4) + "e"); + } + if (endsWith("ation")) { + add(word.substr(0, len - 5)); // information -> inform + add(word.substr(0, len - 5) + "e"); // exploration -> explore + add(word.substr(0, len - 5) + "ate"); // donation -> donate + } + if (endsWith("tion") && !endsWith("ation")) { + add(word.substr(0, len - 4) + "te"); // completion -> complete + add(word.substr(0, len - 3)); // action -> act + add(word.substr(0, len - 3) + "e"); // reduction -> reduce + } + if (endsWith("ion") && !endsWith("tion")) { + add(word.substr(0, len - 3)); // revision -> revis (-> revise via +e) + add(word.substr(0, len - 3) + "e"); // revision -> revise + } + if (endsWith("al") && !endsWith("ial")) { + add(word.substr(0, len - 2)); + add(word.substr(0, len - 2) + "e"); + } + if (endsWith("ial")) { + add(word.substr(0, len - 3)); + add(word.substr(0, len - 3) + "e"); + } + if (endsWith("ous")) { + add(word.substr(0, len - 3)); // dangerous -> danger + add(word.substr(0, len - 3) + "e"); // famous -> fame + } + if (endsWith("ive")) { + add(word.substr(0, len - 3)); // active -> act + add(word.substr(0, len - 3) + "e"); // creative -> create + } + if (endsWith("ize")) { + add(word.substr(0, len - 3)); // modernize -> modern + add(word.substr(0, len - 3) + "e"); + } + if (endsWith("ise")) { + add(word.substr(0, len - 3)); // advertise -> advert + add(word.substr(0, len - 3) + "e"); + } + if (endsWith("en")) { + add(word.substr(0, len - 2)); // darken -> dark + add(word.substr(0, len - 2) + "e"); // widen -> wide + } + + // Prefix removal + if (len > 5 && word.compare(0, 2, "un") == 0) add(word.substr(2)); + if (len > 6 && word.compare(0, 3, "dis") == 0) add(word.substr(3)); + if (len > 6 && word.compare(0, 3, "mis") == 0) add(word.substr(3)); + if (len > 6 && word.compare(0, 3, "pre") == 0) add(word.substr(3)); + if (len > 7 && word.compare(0, 4, "over") == 0) add(word.substr(4)); + if (len > 5 && word.compare(0, 2, "re") == 0) add(word.substr(2)); + + // Deduplicate while preserving insertion order (inflectional stems first, prefixes last) + std::vector deduped; + for (const auto& v : variants) { + if (std::find(deduped.begin(), deduped.end(), v) != deduped.end()) continue; + // cppcheck-suppress useStlAlgorithm + deduped.push_back(v); + } + return deduped; +} + +int Dictionary::editDistance(const std::string& a, const std::string& b, int maxDist) { + int m = static_cast(a.size()); + int n = static_cast(b.size()); + if (std::abs(m - n) > maxDist) return maxDist + 1; + + std::vector dp(n + 1); + for (int j = 0; j <= n; j++) dp[j] = j; + + for (int i = 1; i <= m; i++) { + int prev = dp[0]; + dp[0] = i; + int rowMin = dp[0]; + for (int j = 1; j <= n; j++) { + int temp = dp[j]; + if (a[i - 1] == b[j - 1]) { + dp[j] = prev; + } else { + dp[j] = 1 + std::min({prev, dp[j], dp[j - 1]}); + } + prev = temp; + if (dp[j] < rowMin) rowMin = dp[j]; + } + if (rowMin > maxDist) return maxDist + 1; + } + return dp[n]; +} + +std::vector Dictionary::findSimilar(const std::string& word, int maxResults) { + if (!indexLoaded || sparseOffsets.empty()) return {}; + + FsFile idx; + if (!Storage.openFileForRead("DICT", IDX_PATH, idx)) return {}; + + // Binary search to find the segment containing or nearest to the word + int lo = 0, hi = static_cast(sparseOffsets.size()) - 1; + while (lo < hi) { + int mid = lo + (hi - lo + 1) / 2; + idx.seekSet(sparseOffsets[mid]); + std::string key = readWord(idx); + if (stardictCmp(key.c_str(), word.c_str()) <= 0) { + lo = mid; + } else { + hi = mid - 1; + } + } + + // Scan entries from the segment before through the segment after the target + int startSeg = std::max(0, lo - 1); + int endSeg = std::min(static_cast(sparseOffsets.size()) - 1, lo + 1); + idx.seekSet(sparseOffsets[startSeg]); + + int totalToScan = (endSeg - startSeg + 1) * SPARSE_INTERVAL; + int remaining = static_cast(totalWords) - startSeg * SPARSE_INTERVAL; + if (totalToScan > remaining) totalToScan = remaining; + + int maxDist = std::max(2, static_cast(word.size()) / 3 + 1); + + struct Candidate { + std::string text; + int distance; + }; + std::vector candidates; + + for (int i = 0; i < totalToScan; i++) { + std::string key = readWord(idx); + if (key.empty()) break; + + uint8_t skip[8]; + if (idx.read(skip, 8) != 8) break; + + if (key == word) continue; + int dist = editDistance(key, word, maxDist); + if (dist <= maxDist) { + candidates.push_back({key, dist}); + } + } + + idx.close(); + + std::sort(candidates.begin(), candidates.end(), + [](const Candidate& a, const Candidate& b) { return a.distance < b.distance; }); + + std::vector results; + for (size_t i = 0; i < candidates.size() && static_cast(results.size()) < maxResults; i++) { + results.push_back(candidates[i].text); + } + return results; +} diff --git a/src/util/Dictionary.h b/src/util/Dictionary.h index 7a7b1f63..b7bf89d8 100644 --- a/src/util/Dictionary.h +++ b/src/util/Dictionary.h @@ -14,6 +14,8 @@ class Dictionary { static std::string lookup(const std::string& word, const std::function& onProgress = nullptr, const std::function& shouldCancel = nullptr); static std::string cleanWord(const std::string& word); + static std::vector getStemVariants(const std::string& word); + static std::vector findSimilar(const std::string& word, int maxResults = 6); private: static constexpr int SPARSE_INTERVAL = 512; @@ -28,4 +30,5 @@ class Dictionary { static std::string searchIndex(const std::string& word, const std::function& shouldCancel); static std::string readWord(FsFile& file); static std::string readDefinition(uint32_t offset, uint32_t size); + static int editDistance(const std::string& a, const std::string& b, int maxDist); };