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:
@@ -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)
|
||||
|
||||
@@ -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<void()>& onBack)
|
||||
uint8_t orientation, const std::function<void()>& onBack,
|
||||
const std::function<void()>& 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<void()> onBack;
|
||||
const std::function<void()> onDone;
|
||||
|
||||
std::vector<std::vector<Segment>> wrappedLines;
|
||||
int currentPage = 0;
|
||||
|
||||
141
src/activities/reader/DictionarySuggestionsActivity.cpp
Normal file
141
src/activities/reader/DictionarySuggestionsActivity.cpp
Normal 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);
|
||||
}
|
||||
53
src/activities/reader/DictionarySuggestionsActivity.h
Normal file
53
src/activities/reader/DictionarySuggestionsActivity.h
Normal 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();
|
||||
};
|
||||
@@ -6,6 +6,8 @@
|
||||
#include <climits>
|
||||
|
||||
#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<int16_t>(*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<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;
|
||||
++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<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)
|
||||
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,7 +387,36 @@ void DictionaryWordSelectActivity::loop() {
|
||||
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");
|
||||
renderer.displayBuffer(HalDisplay::FAST_REFRESH);
|
||||
vTaskDelay(1500 / portTICK_PERIOD_MS);
|
||||
@@ -305,11 +424,6 @@ void DictionaryWordSelectActivity::loop() {
|
||||
return;
|
||||
}
|
||||
|
||||
LookupHistory::addWord(cachePath, cleaned);
|
||||
onLookup(cleaned, definition);
|
||||
return;
|
||||
}
|
||||
|
||||
if (mappedInput.wasReleased(MappedInputManager::Button::Back)) {
|
||||
onBack();
|
||||
return;
|
||||
|
||||
@@ -9,16 +9,16 @@
|
||||
#include <string>
|
||||
#include <vector>
|
||||
|
||||
#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> page, int fontId, int marginLeft, int marginTop,
|
||||
const std::string& cachePath, uint8_t orientation,
|
||||
const std::function<void()>& onBack,
|
||||
const std::function<void(const std::string&, const std::string&)>& 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<void()> onBack;
|
||||
const std::function<void(const std::string&, const std::string&)> onLookup;
|
||||
std::string nextPageFirstWord;
|
||||
|
||||
std::vector<WordInfo> words;
|
||||
std::vector<Row> rows;
|
||||
int currentRow = 0;
|
||||
int currentWordInRow = 0;
|
||||
bool updateRequired = false;
|
||||
bool pendingBackFromDef = false;
|
||||
bool pendingExitToReader = false;
|
||||
|
||||
TaskHandle_t displayTaskHandle = nullptr;
|
||||
SemaphoreHandle_t renderingMutex = nullptr;
|
||||
|
||||
@@ -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<const PageLine*>(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;
|
||||
}
|
||||
|
||||
@@ -5,7 +5,6 @@
|
||||
#include <freertos/semphr.h>
|
||||
#include <freertos/task.h>
|
||||
|
||||
#include "DictionaryDefinitionActivity.h"
|
||||
#include "DictionaryWordSelectActivity.h"
|
||||
#include "EpubReaderMenuActivity.h"
|
||||
#include "LookedUpWordsActivity.h"
|
||||
|
||||
@@ -4,9 +4,12 @@
|
||||
|
||||
#include <algorithm>
|
||||
|
||||
#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<int>(words.size()));
|
||||
const int totalItems = static_cast<int>(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<int>(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<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);
|
||||
}
|
||||
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<int>(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);
|
||||
}
|
||||
|
||||
|
||||
@@ -13,12 +13,14 @@
|
||||
class LookedUpWordsActivity final : public ActivityWithSubactivity {
|
||||
public:
|
||||
explicit LookedUpWordsActivity(GfxRenderer& renderer, MappedInputManager& mappedInput, const std::string& cachePath,
|
||||
const std::function<void()>& onBack,
|
||||
const std::function<void(const std::string&)>& onSelectWord)
|
||||
int readerFontId, uint8_t orientation, const std::function<void()>& onBack,
|
||||
const std::function<void()>& 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<void()> onBack;
|
||||
const std::function<void(const std::string&)> onSelectWord;
|
||||
const std::function<void()> onDone;
|
||||
|
||||
std::vector<std::string> 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();
|
||||
|
||||
@@ -326,3 +326,264 @@ std::string Dictionary::lookup(const std::string& word, const std::function<void
|
||||
if (onProgress) onProgress(100);
|
||||
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;
|
||||
}
|
||||
|
||||
@@ -14,6 +14,8 @@ class Dictionary {
|
||||
static std::string lookup(const std::string& word, const std::function<void(int percent)>& onProgress = nullptr,
|
||||
const std::function<bool()>& shouldCancel = nullptr);
|
||||
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:
|
||||
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 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);
|
||||
};
|
||||
|
||||
Reference in New Issue
Block a user