feat: Integrate PR #857 dictionary intelligence and sub-activity refactor

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 <cursoragent@cursor.com>
This commit is contained in:
cottongin
2026-02-14 20:50:03 -05:00
parent c1dfe92ea3
commit 5dc9d21bdb
12 changed files with 746 additions and 105 deletions

View File

@@ -450,8 +450,16 @@ void DictionaryDefinitionActivity::loop() {
updateRequired = true; updateRequired = true;
} }
if (mappedInput.wasReleased(MappedInputManager::Button::Back) || if (mappedInput.wasReleased(MappedInputManager::Button::Confirm)) {
mappedInput.wasReleased(MappedInputManager::Button::Confirm)) { if (onDone) {
onDone();
} else {
onBack();
}
return;
}
if (mappedInput.wasReleased(MappedInputManager::Button::Back)) {
onBack(); onBack();
return; return;
} }
@@ -491,8 +499,8 @@ void DictionaryDefinitionActivity::renderScreen() {
renderer.getScreenHeight() - 50, pageInfo.c_str()); renderer.getScreenHeight() - 50, pageInfo.c_str());
} }
// Button hints (bottom face buttons — hide Confirm stub like Home Screen) // Button hints (bottom face buttons)
const auto labels = mappedInput.mapLabels("\xC2\xAB Back", "", "\xC2\xAB Page", "Page \xC2\xBB"); 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); GUI.drawButtonHints(renderer, labels.btn1, labels.btn2, labels.btn3, labels.btn4);
// Side button hints (drawn in portrait coordinates for correct placement) // Side button hints (drawn in portrait coordinates for correct placement)

View File

@@ -14,13 +14,15 @@ class DictionaryDefinitionActivity final : public Activity {
public: public:
explicit DictionaryDefinitionActivity(GfxRenderer& renderer, MappedInputManager& mappedInput, explicit DictionaryDefinitionActivity(GfxRenderer& renderer, MappedInputManager& mappedInput,
const std::string& headword, const std::string& definition, int readerFontId, const std::string& headword, const std::string& definition, int readerFontId,
uint8_t orientation, const std::function<void()>& onBack) uint8_t orientation, const std::function<void()>& onBack,
const std::function<void()>& onDone = nullptr)
: Activity("DictionaryDefinition", renderer, mappedInput), : Activity("DictionaryDefinition", renderer, mappedInput),
headword(headword), headword(headword),
definition(definition), definition(definition),
readerFontId(readerFontId), readerFontId(readerFontId),
orientation(orientation), orientation(orientation),
onBack(onBack) {} onBack(onBack),
onDone(onDone) {}
void onEnter() override; void onEnter() override;
void onExit() override; void onExit() override;
@@ -53,6 +55,7 @@ class DictionaryDefinitionActivity final : public Activity {
int readerFontId; int readerFontId;
uint8_t orientation; uint8_t orientation;
const std::function<void()> onBack; const std::function<void()> onBack;
const std::function<void()> onDone;
std::vector<std::vector<Segment>> wrappedLines; std::vector<std::vector<Segment>> wrappedLines;
int currentPage = 0; int currentPage = 0;

View File

@@ -0,0 +1,141 @@
#include "DictionarySuggestionsActivity.h"
#include <GfxRenderer.h>
#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<DictionarySuggestionsActivity*>(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<int>(suggestions.size()));
updateRequired = true;
});
buttonNavigator.onPrevious([this] {
selectedIndex = ButtonNavigator::previousIndex(selectedIndex, static_cast<int>(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);
}

View File

@@ -0,0 +1,53 @@
#pragma once
#include <freertos/FreeRTOS.h>
#include <freertos/semphr.h>
#include <freertos/task.h>
#include <functional>
#include <string>
#include <vector>
#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<std::string>& suggestions,
int readerFontId, uint8_t orientation, const std::string& cachePath,
const std::function<void()>& onBack, const std::function<void()>& 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<std::string> suggestions;
int readerFontId;
uint8_t orientation;
std::string cachePath;
const std::function<void()> onBack;
const std::function<void()> 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();
};

View File

@@ -6,6 +6,8 @@
#include <climits> #include <climits>
#include "CrossPointSettings.h" #include "CrossPointSettings.h"
#include "DictionaryDefinitionActivity.h"
#include "DictionarySuggestionsActivity.h"
#include "MappedInputManager.h" #include "MappedInputManager.h"
#include "components/UITheme.h" #include "components/UITheme.h"
#include "fontIds.h" #include "fontIds.h"
@@ -19,7 +21,7 @@ void DictionaryWordSelectActivity::taskTrampoline(void* param) {
void DictionaryWordSelectActivity::displayTaskLoop() { void DictionaryWordSelectActivity::displayTaskLoop() {
while (true) { while (true) {
if (updateRequired) { if (updateRequired && !subActivity) {
updateRequired = false; updateRequired = false;
xSemaphoreTake(renderingMutex, portMAX_DELAY); xSemaphoreTake(renderingMutex, portMAX_DELAY);
renderScreen(); renderScreen();
@@ -30,7 +32,7 @@ void DictionaryWordSelectActivity::displayTaskLoop() {
} }
void DictionaryWordSelectActivity::onEnter() { void DictionaryWordSelectActivity::onEnter() {
Activity::onEnter(); ActivityWithSubactivity::onEnter();
renderingMutex = xSemaphoreCreateMutex(); renderingMutex = xSemaphoreCreateMutex();
extractWords(); extractWords();
mergeHyphenatedWords(); mergeHyphenatedWords();
@@ -43,7 +45,7 @@ void DictionaryWordSelectActivity::onEnter() {
} }
void DictionaryWordSelectActivity::onExit() { void DictionaryWordSelectActivity::onExit() {
Activity::onExit(); ActivityWithSubactivity::onExit();
xSemaphoreTake(renderingMutex, portMAX_DELAY); xSemaphoreTake(renderingMutex, portMAX_DELAY);
if (displayTaskHandle) { if (displayTaskHandle) {
vTaskDelete(displayTaskHandle); vTaskDelete(displayTaskHandle);
@@ -82,9 +84,55 @@ void DictionaryWordSelectActivity::extractWords() {
while (wordIt != wordList.end() && xIt != xPosList.end()) { while (wordIt != wordList.end() && xIt != xPosList.end()) {
int16_t screenX = line->xPos + static_cast<int16_t>(*xIt) + marginLeft; int16_t screenX = line->xPos + static_cast<int16_t>(*xIt) + marginLeft;
int16_t screenY = line->yPos + marginTop; 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<size_t> splitStarts;
size_t partStart = 0;
for (size_t i = 0; i < wordText.size();) {
if (i + 2 < wordText.size() && static_cast<uint8_t>(wordText[i]) == 0xE2 &&
static_cast<uint8_t>(wordText[i + 1]) == 0x80 &&
(static_cast<uint8_t>(wordText[i + 2]) == 0x93 || static_cast<uint8_t>(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<uint8_t>(wordText[textEnd - 3]) == 0xE2 &&
static_cast<uint8_t>(wordText[textEnd - 2]) == 0x80 &&
(static_cast<uint8_t>(wordText[textEnd - 1]) == 0x93 ||
static_cast<uint8_t>(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<int16_t>(screenX + offsetX), screenY, partWidth, 0});
}
}
words.push_back({*wordIt, screenX, screenY, wordWidth, 0});
++wordIt; ++wordIt;
++xIt; ++xIt;
} }
@@ -146,11 +194,53 @@ void DictionaryWordSelectActivity::mergeHyphenatedWords() {
words[nextWordIdx].continuationIndex = nextWordIdx; // self-ref so highlight logic finds the second part 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<uint8_t>(lastWord[lastWord.size() - 2]) == 0xC2 &&
static_cast<uint8_t>(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<uint8_t>(firstPart[firstPart.size() - 2]) == 0xC2 &&
static_cast<uint8_t>(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) // 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()); rows.erase(std::remove_if(rows.begin(), rows.end(), [](const Row& r) { return r.wordIndices.empty(); }), rows.end());
} }
void DictionaryWordSelectActivity::loop() { 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 (words.empty()) {
if (mappedInput.wasReleased(MappedInputManager::Button::Back)) { if (mappedInput.wasReleased(MappedInputManager::Button::Back)) {
onBack(); onBack();
@@ -297,7 +387,36 @@ void DictionaryWordSelectActivity::loop() {
return; return;
} }
if (definition.empty()) { LookupHistory::addWord(cachePath, cleaned);
if (!definition.empty()) {
enterNewActivity(new DictionaryDefinitionActivity(
renderer, mappedInput, cleaned, definition, fontId, orientation,
[this]() { pendingBackFromDef = true; }, [this]() { pendingExitToReader = true; }));
return;
}
// 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"); GUI.drawPopup(renderer, "Not found");
renderer.displayBuffer(HalDisplay::FAST_REFRESH); renderer.displayBuffer(HalDisplay::FAST_REFRESH);
vTaskDelay(1500 / portTICK_PERIOD_MS); vTaskDelay(1500 / portTICK_PERIOD_MS);
@@ -305,11 +424,6 @@ void DictionaryWordSelectActivity::loop() {
return; return;
} }
LookupHistory::addWord(cachePath, cleaned);
onLookup(cleaned, definition);
return;
}
if (mappedInput.wasReleased(MappedInputManager::Button::Back)) { if (mappedInput.wasReleased(MappedInputManager::Button::Back)) {
onBack(); onBack();
return; return;

View File

@@ -9,16 +9,16 @@
#include <string> #include <string>
#include <vector> #include <vector>
#include "../Activity.h" #include "../ActivityWithSubactivity.h"
class DictionaryWordSelectActivity final : public Activity { class DictionaryWordSelectActivity final : public ActivityWithSubactivity {
public: public:
explicit DictionaryWordSelectActivity(GfxRenderer& renderer, MappedInputManager& mappedInput, explicit DictionaryWordSelectActivity(GfxRenderer& renderer, MappedInputManager& mappedInput,
std::unique_ptr<Page> page, int fontId, int marginLeft, int marginTop, std::unique_ptr<Page> page, int fontId, int marginLeft, int marginTop,
const std::string& cachePath, uint8_t orientation, const std::string& cachePath, uint8_t orientation,
const std::function<void()>& onBack, const std::function<void()>& onBack,
const std::function<void(const std::string&, const std::string&)>& onLookup) const std::string& nextPageFirstWord = "")
: Activity("DictionaryWordSelect", renderer, mappedInput), : ActivityWithSubactivity("DictionaryWordSelect", renderer, mappedInput),
page(std::move(page)), page(std::move(page)),
fontId(fontId), fontId(fontId),
marginLeft(marginLeft), marginLeft(marginLeft),
@@ -26,7 +26,7 @@ class DictionaryWordSelectActivity final : public Activity {
cachePath(cachePath), cachePath(cachePath),
orientation(orientation), orientation(orientation),
onBack(onBack), onBack(onBack),
onLookup(onLookup) {} nextPageFirstWord(nextPageFirstWord) {}
void onEnter() override; void onEnter() override;
void onExit() override; void onExit() override;
@@ -58,13 +58,15 @@ class DictionaryWordSelectActivity final : public Activity {
std::string cachePath; std::string cachePath;
uint8_t orientation; uint8_t orientation;
const std::function<void()> onBack; const std::function<void()> onBack;
const std::function<void(const std::string&, const std::string&)> onLookup; std::string nextPageFirstWord;
std::vector<WordInfo> words; std::vector<WordInfo> words;
std::vector<Row> rows; std::vector<Row> rows;
int currentRow = 0; int currentRow = 0;
int currentWordInRow = 0; int currentWordInRow = 0;
bool updateRequired = false; bool updateRequired = false;
bool pendingBackFromDef = false;
bool pendingExitToReader = false;
TaskHandle_t displayTaskHandle = nullptr; TaskHandle_t displayTaskHandle = nullptr;
SemaphoreHandle_t renderingMutex = nullptr; SemaphoreHandle_t renderingMutex = nullptr;

View File

@@ -19,7 +19,6 @@
#include "fontIds.h" #include "fontIds.h"
#include "util/BookmarkStore.h" #include "util/BookmarkStore.h"
#include "util/Dictionary.h" #include "util/Dictionary.h"
#include "util/LookupHistory.h"
namespace { namespace {
// pagesPerRefresh now comes from SETTINGS.getRefreshFrequency() // pagesPerRefresh now comes from SETTINGS.getRefreshFrequency()
@@ -665,24 +664,27 @@ void EpubReaderActivity::onReaderMenuConfirm(EpubReaderMenuActivity::MenuAction
const std::string bookCachePath = epub->getCachePath(); const std::string bookCachePath = epub->getCachePath();
const uint8_t currentOrientation = SETTINGS.orientation; 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<const PageLine*>(nextPage->elements[0].get());
if (firstLine->getBlock() && !firstLine->getBlock()->getWords().empty()) {
nextPageFirstWord = firstLine->getBlock()->getWords().front();
}
}
}
exitActivity(); exitActivity();
if (pageForLookup) { if (pageForLookup) {
enterNewActivity(new DictionaryWordSelectActivity( enterNewActivity(new DictionaryWordSelectActivity(
renderer, mappedInput, std::move(pageForLookup), readerFontId, orientedMarginLeft, orientedMarginTop, renderer, mappedInput, std::move(pageForLookup), readerFontId, orientedMarginLeft, orientedMarginTop,
bookCachePath, currentOrientation, bookCachePath, currentOrientation, [this]() { pendingSubactivityExit = true; }, nextPageFirstWord));
[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; }));
}));
} }
xSemaphoreGive(renderingMutex); xSemaphoreGive(renderingMutex);
@@ -690,36 +692,11 @@ void EpubReaderActivity::onReaderMenuConfirm(EpubReaderMenuActivity::MenuAction
} }
case EpubReaderMenuActivity::MenuAction::LOOKED_UP_WORDS: { case EpubReaderMenuActivity::MenuAction::LOOKED_UP_WORDS: {
xSemaphoreTake(renderingMutex, portMAX_DELAY); xSemaphoreTake(renderingMutex, portMAX_DELAY);
const std::string bookCachePath = epub->getCachePath();
const int readerFontId = SETTINGS.getReaderFontId();
const uint8_t currentOrientation = SETTINGS.orientation;
exitActivity(); exitActivity();
enterNewActivity(new LookedUpWordsActivity( enterNewActivity(new LookedUpWordsActivity(
renderer, mappedInput, bookCachePath, renderer, mappedInput, epub->getCachePath(), SETTINGS.getReaderFontId(), SETTINGS.orientation,
[this]() { [this]() { pendingSubactivityExit = true; }, [this]() { pendingSubactivityExit = true; }));
// 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; }));
}));
xSemaphoreGive(renderingMutex); xSemaphoreGive(renderingMutex);
break; break;
} }

View File

@@ -5,7 +5,6 @@
#include <freertos/semphr.h> #include <freertos/semphr.h>
#include <freertos/task.h> #include <freertos/task.h>
#include "DictionaryDefinitionActivity.h"
#include "DictionaryWordSelectActivity.h" #include "DictionaryWordSelectActivity.h"
#include "EpubReaderMenuActivity.h" #include "EpubReaderMenuActivity.h"
#include "LookedUpWordsActivity.h" #include "LookedUpWordsActivity.h"

View File

@@ -4,9 +4,12 @@
#include <algorithm> #include <algorithm>
#include "DictionaryDefinitionActivity.h"
#include "DictionarySuggestionsActivity.h"
#include "MappedInputManager.h" #include "MappedInputManager.h"
#include "components/UITheme.h" #include "components/UITheme.h"
#include "fontIds.h" #include "fontIds.h"
#include "util/Dictionary.h"
#include "util/LookupHistory.h" #include "util/LookupHistory.h"
void LookedUpWordsActivity::taskTrampoline(void* param) { void LookedUpWordsActivity::taskTrampoline(void* param) {
@@ -30,6 +33,7 @@ void LookedUpWordsActivity::onEnter() {
ActivityWithSubactivity::onEnter(); ActivityWithSubactivity::onEnter();
renderingMutex = xSemaphoreCreateMutex(); renderingMutex = xSemaphoreCreateMutex();
words = LookupHistory::load(cachePath); words = LookupHistory::load(cachePath);
std::reverse(words.begin(), words.end());
updateRequired = true; updateRequired = true;
xTaskCreate(&LookedUpWordsActivity::taskTrampoline, "LookedUpTask", 4096, this, 1, &displayTaskHandle); xTaskCreate(&LookedUpWordsActivity::taskTrampoline, "LookedUpTask", 4096, this, 1, &displayTaskHandle);
} }
@@ -48,6 +52,16 @@ void LookedUpWordsActivity::onExit() {
void LookedUpWordsActivity::loop() { void LookedUpWordsActivity::loop() {
if (subActivity) { if (subActivity) {
subActivity->loop(); subActivity->loop();
if (pendingBackFromDef) {
pendingBackFromDef = false;
exitActivity();
updateRequired = true;
}
if (pendingExitToReader) {
pendingExitToReader = false;
exitActivity();
onDone();
}
return; return;
} }
@@ -94,18 +108,68 @@ void LookedUpWordsActivity::loop() {
return; return;
} }
buttonNavigator.onNext([this] { const int totalItems = static_cast<int>(words.size());
selectedIndex = ButtonNavigator::nextIndex(selectedIndex, static_cast<int>(words.size())); const int pageItems = getPageItems();
buttonNavigator.onNextRelease([this, totalItems] {
selectedIndex = ButtonNavigator::nextIndex(selectedIndex, totalItems);
updateRequired = true; updateRequired = true;
}); });
buttonNavigator.onPrevious([this] { buttonNavigator.onPreviousRelease([this, totalItems] {
selectedIndex = ButtonNavigator::previousIndex(selectedIndex, static_cast<int>(words.size())); 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; updateRequired = true;
}); });
if (mappedInput.wasReleased(MappedInputManager::Button::Confirm)) { 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; 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() { void LookedUpWordsActivity::renderScreen() {
renderer.clearScreen(); renderer.clearScreen();
constexpr int sidePadding = 20; const auto orient = renderer.getOrientation();
constexpr int titleY = 15; const auto metrics = UITheme::getInstance().getMetrics();
constexpr int startY = 60; const bool isLandscapeCw = orient == GfxRenderer::Orientation::LandscapeClockwise;
constexpr int lineHeight = 30; 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 // Header
const int titleX = GUI.drawHeader(
(renderer.getScreenWidth() - renderer.getTextWidth(UI_12_FONT_ID, "Lookup History", EpdFontFamily::BOLD)) / 2; renderer,
renderer.drawText(UI_12_FONT_ID, titleX, titleY, "Lookup History", true, EpdFontFamily::BOLD); 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()) { 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 { } else {
const int screenHeight = renderer.getScreenHeight(); GUI.drawList(
const int pageItems = std::max(1, (screenHeight - startY - 40) / lineHeight); renderer, Rect{contentX, contentTop, pageWidth - hintGutterWidth, contentHeight}, words.size(), selectedIndex,
const int pageStart = selectedIndex / pageItems * pageItems; [this](int index) { return words[index]; }, nullptr, nullptr, nullptr);
for (int i = 0; i < pageItems; i++) {
int idx = pageStart + i;
if (idx >= static_cast<int>(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);
}
} }
if (deleteConfirmMode && pendingDeleteIndex < static_cast<int>(words.size())) { if (deleteConfirmMode && pendingDeleteIndex < static_cast<int>(words.size())) {
@@ -161,12 +232,12 @@ void LookedUpWordsActivity::renderScreen() {
std::string msg = "Delete '" + displayWord + "'?"; std::string msg = "Delete '" + displayWord + "'?";
constexpr int margin = 15; 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 textWidth = renderer.getTextWidth(UI_12_FONT_ID, msg.c_str(), EpdFontFamily::BOLD);
const int textHeight = renderer.getLineHeight(UI_12_FONT_ID); const int textHeight = renderer.getLineHeight(UI_12_FONT_ID);
const int w = textWidth + margin * 2; const int w = textWidth + margin * 2;
const int h = textHeight + 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 - 2, popupY - 2, w + 4, h + 4, true);
renderer.fillRect(x, popupY, w, h, false); renderer.fillRect(x, popupY, w, h, false);
@@ -183,12 +254,14 @@ void LookedUpWordsActivity::renderScreen() {
if (!words.empty()) { if (!words.empty()) {
const char* deleteHint = "Hold select to delete"; const char* deleteHint = "Hold select to delete";
const int hintWidth = renderer.getTextWidth(SMALL_FONT_ID, deleteHint); 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); deleteHint);
} }
// Normal button hints // 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); GUI.drawButtonHints(renderer, labels.btn1, labels.btn2, labels.btn3, labels.btn4);
} }

View File

@@ -13,12 +13,14 @@
class LookedUpWordsActivity final : public ActivityWithSubactivity { class LookedUpWordsActivity final : public ActivityWithSubactivity {
public: public:
explicit LookedUpWordsActivity(GfxRenderer& renderer, MappedInputManager& mappedInput, const std::string& cachePath, explicit LookedUpWordsActivity(GfxRenderer& renderer, MappedInputManager& mappedInput, const std::string& cachePath,
const std::function<void()>& onBack, int readerFontId, uint8_t orientation, const std::function<void()>& onBack,
const std::function<void(const std::string&)>& onSelectWord) const std::function<void()>& onDone)
: ActivityWithSubactivity("LookedUpWords", renderer, mappedInput), : ActivityWithSubactivity("LookedUpWords", renderer, mappedInput),
cachePath(cachePath), cachePath(cachePath),
readerFontId(readerFontId),
orientation(orientation),
onBack(onBack), onBack(onBack),
onSelectWord(onSelectWord) {} onDone(onDone) {}
void onEnter() override; void onEnter() override;
void onExit() override; void onExit() override;
@@ -26,12 +28,16 @@ class LookedUpWordsActivity final : public ActivityWithSubactivity {
private: private:
std::string cachePath; std::string cachePath;
int readerFontId;
uint8_t orientation;
const std::function<void()> onBack; const std::function<void()> onBack;
const std::function<void(const std::string&)> onSelectWord; const std::function<void()> onDone;
std::vector<std::string> words; std::vector<std::string> words;
int selectedIndex = 0; int selectedIndex = 0;
bool updateRequired = false; bool updateRequired = false;
bool pendingBackFromDef = false;
bool pendingExitToReader = false;
ButtonNavigator buttonNavigator; ButtonNavigator buttonNavigator;
// Delete confirmation state // Delete confirmation state
@@ -42,6 +48,7 @@ class LookedUpWordsActivity final : public ActivityWithSubactivity {
TaskHandle_t displayTaskHandle = nullptr; TaskHandle_t displayTaskHandle = nullptr;
SemaphoreHandle_t renderingMutex = nullptr; SemaphoreHandle_t renderingMutex = nullptr;
int getPageItems() const;
void renderScreen(); void renderScreen();
static void taskTrampoline(void* param); static void taskTrampoline(void* param);
[[noreturn]] void displayTaskLoop(); [[noreturn]] void displayTaskLoop();

View File

@@ -326,3 +326,264 @@ std::string Dictionary::lookup(const std::string& word, const std::function<void
if (onProgress) onProgress(100); if (onProgress) onProgress(100);
return result; return result;
} }
std::vector<std::string> Dictionary::getStemVariants(const std::string& word) {
std::vector<std::string> 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<std::string> 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<int>(a.size());
int n = static_cast<int>(b.size());
if (std::abs(m - n) > maxDist) return maxDist + 1;
std::vector<int> 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<std::string> 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<int>(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<int>(sparseOffsets.size()) - 1, lo + 1);
idx.seekSet(sparseOffsets[startSeg]);
int totalToScan = (endSeg - startSeg + 1) * SPARSE_INTERVAL;
int remaining = static_cast<int>(totalWords) - startSeg * SPARSE_INTERVAL;
if (totalToScan > remaining) totalToScan = remaining;
int maxDist = std::max(2, static_cast<int>(word.size()) / 3 + 1);
struct Candidate {
std::string text;
int distance;
};
std::vector<Candidate> 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<std::string> results;
for (size_t i = 0; i < candidates.size() && static_cast<int>(results.size()) < maxResults; i++) {
results.push_back(candidates[i].text);
}
return results;
}

View File

@@ -14,6 +14,8 @@ class Dictionary {
static std::string lookup(const std::string& word, const std::function<void(int percent)>& onProgress = nullptr, static std::string lookup(const std::string& word, const std::function<void(int percent)>& onProgress = nullptr,
const std::function<bool()>& shouldCancel = nullptr); const std::function<bool()>& shouldCancel = nullptr);
static std::string cleanWord(const std::string& word); static std::string cleanWord(const std::string& word);
static std::vector<std::string> getStemVariants(const std::string& word);
static std::vector<std::string> findSimilar(const std::string& word, int maxResults = 6);
private: private:
static constexpr int SPARSE_INTERVAL = 512; static constexpr int SPARSE_INTERVAL = 512;
@@ -28,4 +30,5 @@ class Dictionary {
static std::string searchIndex(const std::string& word, const std::function<bool()>& shouldCancel); static std::string searchIndex(const std::string& word, const std::function<bool()>& shouldCancel);
static std::string readWord(FsFile& file); static std::string readWord(FsFile& file);
static std::string readDefinition(uint32_t offset, uint32_t size); static std::string readDefinition(uint32_t offset, uint32_t size);
static int editDistance(const std::string& a, const std::string& b, int maxDist);
}; };