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,16 +387,40 @@ void DictionaryWordSelectActivity::loop() {
|
||||
return;
|
||||
}
|
||||
|
||||
if (definition.empty()) {
|
||||
GUI.drawPopup(renderer, "Not found");
|
||||
renderer.displayBuffer(HalDisplay::FAST_REFRESH);
|
||||
vTaskDelay(1500 / portTICK_PERIOD_MS);
|
||||
updateRequired = true;
|
||||
LookupHistory::addWord(cachePath, cleaned);
|
||||
|
||||
if (!definition.empty()) {
|
||||
enterNewActivity(new DictionaryDefinitionActivity(
|
||||
renderer, mappedInput, cleaned, definition, fontId, orientation,
|
||||
[this]() { pendingBackFromDef = true; }, [this]() { pendingExitToReader = true; }));
|
||||
return;
|
||||
}
|
||||
|
||||
LookupHistory::addWord(cachePath, cleaned);
|
||||
onLookup(cleaned, definition);
|
||||
// Try stem variants (e.g., "jumped" -> "jump")
|
||||
auto stems = Dictionary::getStemVariants(cleaned);
|
||||
for (const auto& stem : stems) {
|
||||
std::string stemDef = Dictionary::lookup(stem);
|
||||
if (!stemDef.empty()) {
|
||||
enterNewActivity(new DictionaryDefinitionActivity(
|
||||
renderer, mappedInput, stem, stemDef, fontId, orientation,
|
||||
[this]() { pendingBackFromDef = true; }, [this]() { pendingExitToReader = true; }));
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
// Find similar words for suggestions
|
||||
auto similar = Dictionary::findSimilar(cleaned, 6);
|
||||
if (!similar.empty()) {
|
||||
enterNewActivity(new DictionarySuggestionsActivity(
|
||||
renderer, mappedInput, cleaned, similar, fontId, orientation, cachePath,
|
||||
[this]() { pendingBackFromDef = true; }, [this]() { pendingExitToReader = true; }));
|
||||
return;
|
||||
}
|
||||
|
||||
GUI.drawPopup(renderer, "Not found");
|
||||
renderer.displayBuffer(HalDisplay::FAST_REFRESH);
|
||||
vTaskDelay(1500 / portTICK_PERIOD_MS);
|
||||
updateRequired = true;
|
||||
return;
|
||||
}
|
||||
|
||||
|
||||
@@ -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();
|
||||
|
||||
Reference in New Issue
Block a user